Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab7c227b97 | ||
|
|
b8054665bc | ||
|
|
99a5b735c8 |
@@ -58,6 +58,7 @@ jobs:
|
||||
node desktop.test.mjs
|
||||
node toolbar-order.test.mjs
|
||||
node editor-inline.test.mjs
|
||||
node ai-quick-actions.test.mjs
|
||||
else
|
||||
echo "tests/frontend/node_modules missing - installing jsdom"
|
||||
npm install --no-audit --no-fund --silent
|
||||
@@ -74,6 +75,7 @@ jobs:
|
||||
node desktop.test.mjs
|
||||
node toolbar-order.test.mjs
|
||||
node editor-inline.test.mjs
|
||||
node ai-quick-actions.test.mjs
|
||||
fi
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
+37
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.13.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.14.0**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,30 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.14.0] — 2026-09-19
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#106 - Assistant IA : actions instantanées contextuelles + catalogue de prompts** :
|
||||
la zone d'accueil remplace les 3 suggestions statiques par des **actions suggérées
|
||||
selon le contexte détecté** (sélection dans l'éditeur → concis/corriger/expliquer ;
|
||||
fichier de code → expliquer/bugs/tests ; 2+ docs → fusionner/comparer/frictions ;
|
||||
1 doc → résumé 3 points/checklist/frontmatter ; répertoire ou général sinon), avec
|
||||
badge de contexte dans l'en-tête. Un bouton **« Toutes les actions »** ouvre un
|
||||
tiroir catalogue (recherche instantanée, 6 catégories, 25 actions i18n FR/EN).
|
||||
Nouveau prompt **« Générer le frontmatter YAML »** au format complet du vault
|
||||
(titre, auteur, dates ISO-8601, tags, aliases, booléens, NomDeVoute, Description)
|
||||
et **« Mettre à jour le frontmatter »** (conserve les champs existants, actualise
|
||||
`modification_date`, recalcule tags/aliases/Description, complète les manquants) —
|
||||
tous deux basculent en mode agent et appliquent le résultat via la carte de
|
||||
confirmation. Détail : [features/ai-quick-actions.md](./features/ai-quick-actions.md).
|
||||
|
||||
---
|
||||
|
||||
## [2.13.1] — 2026-09-18
|
||||
|
||||
---
|
||||
|
||||
## [2.13.0] — 2026-09-18
|
||||
|
||||
### Ajouté
|
||||
@@ -41,6 +65,18 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
Source de vérité du nouveau contenu : `scripts/guide_content.py` (locales générées,
|
||||
jamais éditées à la main). Tests : `tests/test_guide.py` (11).
|
||||
|
||||
### Modifié (ajustements retour utilisateur sur #105)
|
||||
|
||||
- Boutons de téléchargement du guide : **icônes seules** (plus de libellé « Markdown »/
|
||||
« PDF »), tooltip i18n explicite sur chaque bouton.
|
||||
- PDF du guide : le bloc Mermaid de la section Architecture est converti en **diagramme
|
||||
rendu** (PNG pré-rendu commité via `scripts/build_guide_diagrams.py` +
|
||||
`scripts/render_guide_diagram.mjs`, inséré par `diagram_png_for()`), au lieu du code
|
||||
brut ; le Markdown garde le fenced ` ```mermaid ` (copiable).
|
||||
- PDF du guide : emoji rendus **en couleur** au lieu de rectangles — `fonts-noto-color-emoji`
|
||||
ajouté à l'image Docker et `"Noto Color Emoji"` en fin de pile de polices du PDF
|
||||
(la TOC était correcte car elle utilise DejaVu, qui n'a pas les emoji pleine chasse).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-067 - Guide : entrée « 📱 Mobile » morte** : le sommaire pointait vers
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ COPY --from=builder /install /usr/local
|
||||
|
||||
# WeasyPrint runtime dependencies
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libpango-1.0-0 libpangocairo-1.0-0 shared-mime-info \
|
||||
&& apt-get install -y --no-install-recommends libpango-1.0-0 libpangocairo-1.0-0 shared-mime-info fonts-noto-color-emoji \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -927,8 +927,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.13.0).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.14.0).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.13.0 | Dernière mise à jour : Juin 2026*
|
||||
*Projet : ObsiGate | Version : 2.14.0 | Dernière mise à jour : Juin 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -1096,8 +1096,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.13.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.14.0).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.13.0 | Last updated: May 2026*
|
||||
*Project: ObsiGate | Version: 2.14.0 | Last updated: May 2026*
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
+21
-2
@@ -13,6 +13,7 @@ quand les bibliothèques natives GTK manquent (Windows).
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
@@ -26,6 +27,17 @@ ROOT = Path(__file__).resolve().parent.parent
|
||||
INDEX_HTML = ROOT / "frontend" / "index.html"
|
||||
LOCALES_DIR = ROOT / "frontend" / "locales"
|
||||
VERSION_FILE = ROOT / "VERSION"
|
||||
DIAGRAMS_DIR = ROOT / "backend" / "assets" / "guide_diagrams"
|
||||
|
||||
|
||||
def diagram_png_for(code: str) -> Path | None:
|
||||
"""Chemin du PNG pré-rendu (scripts/build_guide_diagrams.py) pour un code
|
||||
Mermaid, ou None. Le hash doit rester synchrone avec le script de build :
|
||||
sha1(unescape(code).strip())[:16]."""
|
||||
normalized = html.unescape(code).strip()
|
||||
sha = hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:16]
|
||||
png = DIAGRAMS_DIR / (sha + ".png")
|
||||
return png if png.exists() else None
|
||||
|
||||
# Éléments décoratifs exclus des exports
|
||||
_SKIP_CLASSES = {"help-hero-visual", "editor-modal", "help-nav"}
|
||||
@@ -388,8 +400,15 @@ def _html_block(node: Node | str, out: list[str], loc: dict[str, str]) -> None:
|
||||
tag = node.tag
|
||||
|
||||
if tag == "pre":
|
||||
raw = html.escape(_pre_text(node), quote=False)
|
||||
out.append(f"<pre><code>{raw}</code></pre>")
|
||||
raw = _pre_text(node)
|
||||
classes = _pre_classes(node)
|
||||
if "language-mermaid" in classes:
|
||||
png = diagram_png_for(raw)
|
||||
if png is not None:
|
||||
url = "file:///" + str(png).replace("\\", "/").lstrip("/")
|
||||
out.append(f'<img src="{url}" style="max-width: 100%" />')
|
||||
return
|
||||
out.append(f"<pre><code>{html.escape(raw, quote=False)}</code></pre>")
|
||||
return
|
||||
|
||||
kids = _resolve_i18n(node, loc)
|
||||
|
||||
@@ -43,7 +43,7 @@ def build_pdf_html(body_html: str, title: str, theme: str = "light") -> str:
|
||||
<head><meta charset="utf-8"><title>{title}</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
font-family: Georgia, "Times New Roman", serif, "Noto Color Emoji";
|
||||
max-width: 720px;
|
||||
margin: 40px auto;
|
||||
padding: 0 20px;
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.13.0"
|
||||
version = "2.14.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.13.0"
|
||||
version = "2.14.0"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.13.0",
|
||||
"version": "2.14.0",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -248,6 +248,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-09-17 | #78 (complément) | UI | `frontend/excalidraw-editor.html`, `docs/features/excalidraw.md`, `CHANGELOG.md` | **#78 (complément)** : la barre d'outils de l'éditeur Excalidraw passe en **colonne d'icônes** (34×34 px, SVG seuls), **collée au bord droit** (`right: 0` ; `top: 45%` ; empilement vertical), avec `title`/`aria-label`. L'icône du bouton Save est remplacée par une coche pendant 1,2 s après une sauvegarde réussie. Badge « Modifié » réduit à une pastille. Vérifié Playwright : bord droit au bord de l'iframe, haut 45 %, 4 boutons empilés ; bascule plein écran OK, cycle d'icône Save + `PUT save` observés. | 🟢 livré (en attente vérif utilisateur) |
|
||||
| 2026-09-18 | BUG-066 | Correction | `frontend/locales/fr.json`, `frontend/locales/en.json`, `tests/frontend/unit.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-066** : la table des matières de la page de configuration n'affichait aucune icône pour « Fichiers cachés » et « Partages publics ». Les libellés du sommaire proviennent de clés i18n (`config.section_hidden`, `config.section_shares`) distinctes des titres de section qui, eux, portaient déjà l'icône. Alignement : 🗂️ / 📤 en FR **et** EN. Test de non-régression : `unit.test.mjs` vérifie que **toutes** les entrées `.help-nav-link` du sommaire portent une icône dans les deux langues (17/17). Vérifié : `unit.test.mjs` 10/10, `validate-imports` 38 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-18 | #105, BUG-067 | Documentation + correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/main.py`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **#105** : audit complet de couverture du Guide d'utilisation — 8 nouvelles sections (Architecture + diagramme Mermaid, API & intégrations, Diagrammes Mermaid & Excalidraw, Hors-ligne & synchronisation, Collaboration temps réel, Application desktop, Bibliothèque & signets, Multilingue) et compléments (recherche sémantique, MFA/WebAuthn, notifications push, exports HTML/ePub/ZIP, PDF, vue multi-panneaux, admin). Téléchargement du guide en Markdown et PDF (`GET /api/guide/download?format=md|pdf`, FR/EN, rendu par le moteur d'export existant). Guide plus large en desktop. **BUG-067** : ancre morte `#help-mobile-editor` → section dédiée créée. | 🟢 corrigé (en attente vérif utilisateur)
|
||||
| 2026-09-18 | #105 (ajustements) | Amélioration | `frontend/index.html`, `frontend/js/config.js`, `frontend/sw.js`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/pdf_export.py`, `Dockerfile`, `scripts/build_guide_diagrams.py`, `scripts/render_guide_diagram.mjs`, `scripts/guide_content.py`, `backend/assets/guide_diagrams/df7366a40db6a5a2.png`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md` | **#105 (retour utilisateur)** : 1) boutons de téléchargement du guide passés en icônes seules (tooltips i18n conservés) ; 2) le diagramme Mermaid de la section Architecture est désormais rendu en **vraie image** dans le PDF (pipeline de pré-rendu PNG Chromium+mermaid v11, PNG commité sous `backend/assets/guide_diagrams/<sha1>.png`, résolu par `diagram_png_for()` ; le Markdown garde le fenced mermaid) ; 3) emoji du PDF rendus **en couleur** au lieu de rectangles : `fonts-noto-color-emoji` ajouté au Dockerfile + `"Noto Color Emoji"` en fin de pile de polices PDF. Vérifié : pytest 1218 (test_guide ×13), ruff/mypy 0, validate-imports 38, unit 10/10 ; PDF live conteneur 2020 : 24 pages, 0 glyphes tofu, diagramme 3568x1174 embarqué. | 🟢 livré
|
||||
|
||||
---
|
||||
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.13.0 | **Dernière mise à jour :** 2026-09-18
|
||||
> **Version :** 2.14.0 | **Dernière mise à jour :** 2026-09-19
|
||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||
> vers les fonctionnalités livrées.
|
||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||
@@ -188,6 +188,7 @@
|
||||
| 103 | Configuration — clés utilisateur des sources connectées & recherche à clé (page Configurations, `data/api_keys.json`, priorité sur l'env) | 2.11.0 | [features/ai-tools-roadmap.md](./features/ai-tools-roadmap.md) |
|
||||
| 104 | Configuration — Redesign UI de la section « Clés API IA » : recherche fournisseurs, carte défaut 2 colonnes + badges de capacités, cartes dépliables, footer d'actions sticky | 2.12.0 | [features/ai-keys-ui.md](./features/ai-keys-ui.md) |
|
||||
| 105 | Guide d'utilisation — audit de couverture complet, téléchargement Markdown/PDF, guide desktop élargi, section Architecture (Mermaid) + BUG-067 | 2.13.0 | [features/guide-coverage-105.md](./features/guide-coverage-105.md) |
|
||||
| 106 | Assistant IA — Actions instantanées contextuelles, catalogue « Toutes les actions » & frontmatter complet | 2.14.0 | [features/ai-quick-actions.md](./features/ai-quick-actions.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -195,7 +196,7 @@
|
||||
|
||||
| Priorité | Items | Effort total estimé |
|
||||
|---|---|---|
|
||||
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93, #94–100, #102–104, #92 | ~115 jours réalisés |
|
||||
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #88–93, #94–100, #102–106, #92 | ~115 jours réalisés |
|
||||
| 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour |
|
||||
| ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
|
||||
| ⚪ P0/P1 restant | #85-87 Refonte architecturale, performance, CI/CD (BUG-035 → BUG-040 corrigés) | ~15-23 jours |
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# #106 — Assistant IA : actions instantanées contextuelles & catalogue de prompts
|
||||
|
||||
> **Statut :** livré en 2.14.0 · **Domaine :** Assistant IA (frontend) ·
|
||||
> **Fichiers :** `frontend/js/ai-quick-actions.js`, `frontend/js/bookslm.js`,
|
||||
> `frontend/style.css`, `frontend/locales/{fr,en}.json`,
|
||||
> `tests/frontend/ai-quick-actions.test.mjs`
|
||||
|
||||
## Problème
|
||||
|
||||
La zone d'accueil de l'assistant affichait 3 suggestions **statiques** par mode
|
||||
(résumé / thèmes / contradictions), sans lien avec ce que l'utilisateur regarde
|
||||
réellement (un fichier de code ? plusieurs documents ? une sélection dans
|
||||
l'éditeur ?), et sans point d'accès au reste des prompts utiles.
|
||||
|
||||
## Solution
|
||||
|
||||
### Catalogue (`frontend/js/ai-quick-actions.js`, module pur sans DOM)
|
||||
|
||||
25 actions en 6 catégories, chacune = `{id, cat, icon (lucide), labelKey,
|
||||
promptKey, agent?}` — libellés **et** prompts i18n FR/EN (clés `qa.*`) :
|
||||
|
||||
| Catégorie | Actions |
|
||||
|---|---|
|
||||
| 📝 Synthèse & Analyse | Résumer en 3 points clés · Frictions/contradictions · Vulgariser · FAQ |
|
||||
| ✅ Productivité & Structuration | Checklist d'actions · Plan d'action · Mémo exécutif · **Générer le frontmatter YAML** · **Mettre à jour le frontmatter** · Liens/backlinks · Sections hiérarchiques |
|
||||
| 💻 Code & Scripts | Expliquer · Bugs & failles · Docstrings/types · Tests unitaires |
|
||||
| 🔗 Cross-documents | Comparer · Fusionner en note de synthèse · Chronologie |
|
||||
| ✍️ Édition & Reformulation | Concis · Corriger le style · Reformuler · Traduire · Expliquer la sélection |
|
||||
| ❔ Assistant (général) | Que sais-tu faire · Rechercher efficacement · Créer une note de réunion |
|
||||
|
||||
Les deux actions frontmatter sont marquées `agent: true` : un clic bascule
|
||||
transparentement le panneau en **mode agent** (comme Deep Research) puis envoie
|
||||
le prompt — l'assistant lit le document, propose la mutation via l'outil
|
||||
`edit_file`/`append_to_file`, et la **carte de confirmation** (#79) applique le
|
||||
nouveau bloc en tête du fichier.
|
||||
|
||||
Le prompt « Générer le frontmatter YAML » demande le format complet du vault de
|
||||
Bruno (titre, auteur, creation_date/modification_date ISO-8601 avec fuseau,
|
||||
catégorie, tags en liste inline, aliases, status, publish, favoris, template,
|
||||
task, archive, draft, private, NomDeVoute, Description). « Mettre à jour le
|
||||
frontmatter » **conserve les champs existants** : actualise
|
||||
`modification_date`, recalcule titre/tags/aliases/catégorie/NomDeVoute/Description
|
||||
d'après le contenu, complète les champs manquants.
|
||||
|
||||
### Sélection contextuelle (triage par défaut dans l'UI)
|
||||
|
||||
`detectContext({mode, docCount, currentPath, hasSelection})` — précédence :
|
||||
**sélection > code > multi-docs > doc unique > répertoire > général**.
|
||||
|
||||
| Contexte | Boutons suggérés |
|
||||
|---|---|
|
||||
| 1 doc texte | Résumer 3 points · Checklist · Générer le frontmatter · Mettre à jour le frontmatter |
|
||||
| 2+ docs | Fusionner · Comparer · Frictions/contradictions |
|
||||
| Fichier de code (.py, .ts, .sh…) | Expliquer · Bugs & failles · Tests unitaires |
|
||||
| Sélection dans l'éditeur | Concis · Corriger le style · Expliquer la sélection |
|
||||
| Répertoire | Résumer le répertoire · Thèmes · Checklist |
|
||||
| Général | Que sais-tu faire · Rechercher · Créer une note |
|
||||
|
||||
### Présentation (calquée sur le POC `ai_assistant_quick_actions_poc.html`)
|
||||
|
||||
- Badge de contexte dans l'en-tête (« 1 doc ouvert », « Fichier de code »,
|
||||
« Sélection active », « 3 docs ouverts »…).
|
||||
- Ligne « Actions suggérées » + bouton **« Toutes les actions »** ouvrant un
|
||||
**tiroir bottom-sheet** : recherche instantanée (insensible aux accents/casse
|
||||
via `normalizeSearch`) + catalogue groupé par catégorie, Échap/backdrop
|
||||
pour fermer.
|
||||
- Boutons icône + libellé + flèche au survol ; clic = **envoi immédiat** du
|
||||
prompt dans le composer (chemin `_sendMessage()` : skills, images et mode
|
||||
agent continuent de fonctionner).
|
||||
- La rangée se masque dès le premier message du fil (comportement historique) ;
|
||||
le rafraîchissement du contexte à la sélection éditeur est throttled 250 ms
|
||||
(`selectionchange` + `mouseup`/`keyup`, CodeMirror n'émettant pas
|
||||
`selectionchange` natif).
|
||||
- CSS 100 % variables de thème (`--surface`, `--accent`, `--accent-bg`…) →
|
||||
conforme aux 15 thèmes ; transitions désactivées en `prefers-reduced-motion`.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/frontend/ai-quick-actions.test.mjs` (13 tests, câblé au job lint CI) :
|
||||
précédence des contextes, presets 3-4 actions résolvables, table de conception,
|
||||
agent-flag des actions frontmatter, **complétude FR+EN** de chaque clé
|
||||
catalogue/badge contre les vrais JSON de locales.
|
||||
|
||||
## Vérification live (obsigate-test :2020, Playwright)
|
||||
|
||||
badge « 1 doc ouvert », 4 suggestions dont les 2 frontmatter, tiroir à 6
|
||||
catégories / 25 actions, recherche « frontmatter » → 2 résultats, clic →
|
||||
`aria-pressed=true` sur le bouton agent + message envoyé avec le nouveau
|
||||
prompt + réponse de l'assistant.
|
||||
@@ -47,7 +47,8 @@ le viewer ne rend que les vues document, la modale est donc enrichie ici. Si le
|
||||
CDN Mermaid n'est pas prêt, le bloc reste du code lisible et le rendu est
|
||||
retenté à l'ouverture suivante (`data-mermaid-rendered` ne se pose qu'après
|
||||
succès). Le MD exporté embarque le diagramme en fenced block ` ```mermaid `
|
||||
(copiable, rendu par GitHub/VS Code/Obsidian) ; le PDF contient le même bloc.
|
||||
(copiable, rendu par GitHub/VS Code/Obsidian) ; le PDF affiche le PNG pré-rendu
|
||||
(section 4 — WeasyPrint n'exécute pas Mermaid).
|
||||
|
||||
## 4. Téléchargement (MD + PDF)
|
||||
|
||||
@@ -63,13 +64,26 @@ nu) — même stratégie que le bouton « PDF » des documents (#92).
|
||||
`index.html` et `fr.json`) pour éviter de re-générer à chaque requête.
|
||||
|
||||
Endpoint `GET /api/guide/download?format=md|pdf&lang=fr|en`
|
||||
(`require_auth`, tag OpenAPI **« Guide »**), `Content-Disposition: attachment`.
|
||||
Côté UI : boutons « Markdown » / « PDF » dans l'en-tête de la modale
|
||||
(`#help-download-md`/`#help-download-pdf`), handler `downloadGuide()` dans
|
||||
(`require_auth`, tag OpenAPI « Guide »), `Content-Disposition: attachment`.
|
||||
Côté UI : boutons **icônes seules** 📄/⬇ dans l'en-tête de la modale
|
||||
(`#help-download-md`/`#help-download-pdf`, tooltip i18n), handler `downloadGuide()` dans
|
||||
`frontend/js/config.js` — fetch avec `AuthManager.getAuthHeaders()` +
|
||||
credentials (identique à `viewer.downloadExport()`), blob → lien
|
||||
téléchargeable, toasts i18n réutilisés (`viewer.export_*`).
|
||||
|
||||
Diagramme d'architecture dans le PDF : WeasyPrint n'exécute pas Mermaid → le code
|
||||
Mermaid est **pré-rendu en PNG** (`scripts/build_guide_diagrams.py` +
|
||||
`scripts/render_guide_diagram.mjs`, Chromium + mermaid v11 CDN, scale 2) et le PNG
|
||||
est **commité** dans `backend/assets/guide_diagrams/<sha1>.png` ; `diagram_png_for()`
|
||||
(hash sha1[:16] du code normalisé, même algorithme des deux côtés) remplace le
|
||||
bloc par `<img src="file:///…">` dans le HTML d'export. Après modification du
|
||||
diagramme dans `guide_content.py` + réinsertion : relancer
|
||||
`python scripts/build_guide_diagrams.py` et committer le nouveau PNG.
|
||||
Emoji : l'image Docker installe `fonts-noto-color-emoji` et la pile de polices
|
||||
PDF finit par `"Noto Color Emoji"` — sans cela les emoji pleine chasse des titres
|
||||
de section s'affichent en rectangles (la TOC n'était pas touchée car elle passe
|
||||
par DejaVu/Sans).
|
||||
|
||||
## 5. Guide desktop élargi
|
||||
|
||||
`frontend/js/desktop.js::initDesktopIntegration()` ajoute `body.desktop-mode`
|
||||
@@ -106,14 +120,15 @@ même layout est accordé aux viewports ≥1400 px web via media-query (contenu
|
||||
|
||||
## 7. Tests & vérifications
|
||||
|
||||
- `tests/test_guide.py` (11) : ancre TOC→sections (garde-fou BUG-067),
|
||||
- `tests/test_guide.py` (13) : ancre TOC→sections (garde-fou BUG-067),
|
||||
sections #105 présentes, parité/présence des clés `guide105.*` FR=EN,
|
||||
Markdown FR/EN (titres, mermaid, absence de balises résiduelles), PDF
|
||||
(`%PDF`, taille), metadata `get_guide_document`, endpoint MD/PDF/400 via
|
||||
TestClient, OpenAPI (path + tag « Guide »).
|
||||
- Suite backend pytest : 1216 passed · ruff/mypy 0 erreur ·
|
||||
(`%PDF`, taille), **diagramme Architecture rendu en `<img>` PNG dans le HTML
|
||||
d'export**, MD garde le fenced mermaid, metadata `get_guide_document`,
|
||||
endpoint MD/PDF/400 via TestClient, OpenAPI (path + tag « Guide »).
|
||||
- Suite backend pytest : 1218 passed · ruff/mypy 0 erreur ·
|
||||
`validate-imports` 38 modules · `unit.test.mjs` 10/10 · E2E complet CI.
|
||||
- SW cache busting : `SW_VERSION` v21→v22.
|
||||
- SW cache busting : `SW_VERSION` v21→v23 (guides + boutons icônes).
|
||||
|
||||
## 8. Limites assumées
|
||||
|
||||
|
||||
@@ -2879,7 +2879,6 @@
|
||||
data-lucide="file-code-2"
|
||||
style="width: 16px; height: 16px"
|
||||
></i>
|
||||
<span data-i18n="guide105.dl_md">Markdown</span>
|
||||
</button>
|
||||
<button
|
||||
class="editor-btn"
|
||||
@@ -2891,7 +2890,6 @@
|
||||
data-lucide="file-down"
|
||||
style="width: 16px; height: 16px"
|
||||
></i>
|
||||
<span data-i18n="guide105.dl_pdf">PDF</span>
|
||||
</button>
|
||||
<button
|
||||
class="editor-btn"
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
// ObsiGate — #106 : Actions instantanées de l'assistant IA.
|
||||
//
|
||||
// Two responsibilities, deliberately framework-free and DOM-free so the
|
||||
// context rules stay unit-testable (tests/frontend/ai-quick-actions.test.mjs):
|
||||
//
|
||||
// 1. ACTION_CATALOG — every prompt action grouped by category. Each action
|
||||
// carries an id, a lucide icon name, an i18n label key and an i18n
|
||||
// prompt key; both strings are resolved through t() at render time so
|
||||
// the FR/EN switch is live.
|
||||
// 2. detectContext / suggestionsFor — a pure function of the assistant's
|
||||
// live state (mode, open documents, current file, editor selection)
|
||||
// returning the context key, then the 3 top action ids for that context
|
||||
// (contextual triage table, see docs/features/ai-quick-actions.md).
|
||||
//
|
||||
// Context precedence (first match wins):
|
||||
// selection — the user has a live text selection in the open editor
|
||||
// code — the focused document is a source file (.py, .js, .sh…)
|
||||
// multi_doc — two or more documents are open in tabs/panes
|
||||
// single_doc — exactly one text document (.md, .txt, …) is open
|
||||
// directory — a vault folder was opened from the tree context menu
|
||||
// general — nothing open: app-help assistant
|
||||
import { t } from './i18n.js';
|
||||
|
||||
/** Categories of the action catalogue (order = drawer display order). */
|
||||
export const CATEGORIES = Object.freeze([
|
||||
{ id: 'synthesis', icon: 'sparkles', labelKey: 'qa.cat_synthesis' },
|
||||
{ id: 'structure', icon: 'list-checks', labelKey: 'qa.cat_structure' },
|
||||
{ id: 'code', icon: 'code', labelKey: 'qa.cat_code' },
|
||||
{ id: 'cross', icon: 'git-compare', labelKey: 'qa.cat_cross' },
|
||||
{ id: 'edition', icon: 'pen-tool', labelKey: 'qa.cat_edition' },
|
||||
{ id: 'general', icon: 'help-circle', labelKey: 'qa.cat_general' },
|
||||
]);
|
||||
|
||||
/**
|
||||
* The full catalogue. `labelKey` is the button text, `promptKey` the message
|
||||
* actually sent to the assistant (kept richer than the label on purpose:
|
||||
* labels stay scannable, prompts stay precise).
|
||||
*/
|
||||
export const ACTION_CATALOG = Object.freeze([
|
||||
// ── Synthèse & Analyse ──────────────────────────────────────────────
|
||||
{ id: 'summarize_3', cat: 'synthesis', icon: 'align-left', labelKey: 'qa.summarize_3', promptKey: 'qa.summarize_3.prompt' },
|
||||
{ id: 'frictions', cat: 'synthesis', icon: 'alert-triangle', labelKey: 'qa.frictions', promptKey: 'qa.frictions.prompt' },
|
||||
{ id: 'vulgarize', cat: 'synthesis', icon: 'lightbulb', labelKey: 'qa.vulgarize', promptKey: 'qa.vulgarize.prompt' },
|
||||
{ id: 'faq', cat: 'synthesis', icon: 'help-circle', labelKey: 'qa.faq', promptKey: 'qa.faq.prompt' },
|
||||
// ── Productivité & Structuration ────────────────────────────────────
|
||||
{ id: 'checklist', cat: 'structure', icon: 'list-checks', labelKey: 'qa.checklist', promptKey: 'qa.checklist.prompt' },
|
||||
{ id: 'plan', cat: 'structure', icon: 'list-ordered', labelKey: 'qa.plan', promptKey: 'qa.plan.prompt' },
|
||||
{ id: 'memo', cat: 'structure', icon: 'scroll-text', labelKey: 'qa.memo', promptKey: 'qa.memo.prompt' },
|
||||
{ id: 'frontmatter', cat: 'structure', icon: 'braces', labelKey: 'qa.frontmatter', promptKey: 'qa.frontmatter.prompt', agent: true },
|
||||
{ id: 'frontmatter_update', cat: 'structure', icon: 'refresh-cw', labelKey: 'qa.frontmatter_update', promptKey: 'qa.frontmatter_update.prompt', agent: true },
|
||||
{ id: 'backlinks', cat: 'structure', icon: 'link-2', labelKey: 'qa.backlinks', promptKey: 'qa.backlinks.prompt' },
|
||||
{ id: 'sections', cat: 'structure', icon: 'heading', labelKey: 'qa.sections', promptKey: 'qa.sections.prompt' },
|
||||
// ── Code & Scripts ───────────────────────────────────────────────────
|
||||
{ id: 'explain_code', cat: 'code', icon: 'file-code', labelKey: 'qa.explain_code', promptKey: 'qa.explain_code.prompt' },
|
||||
{ id: 'audit_code', cat: 'code', icon: 'bug', labelKey: 'qa.audit_code', promptKey: 'qa.audit_code.prompt' },
|
||||
{ id: 'doc_code', cat: 'code', icon: 'braces', labelKey: 'qa.doc_code', promptKey: 'qa.doc_code.prompt' },
|
||||
{ id: 'test_code', cat: 'code', icon: 'flask-conical', labelKey: 'qa.test_code', promptKey: 'qa.test_code.prompt' },
|
||||
// ── Cross-documents ──────────────────────────────────────────────────
|
||||
{ id: 'compare', cat: 'cross', icon: 'git-compare', labelKey: 'qa.compare', promptKey: 'qa.compare.prompt' },
|
||||
{ id: 'merge', cat: 'cross', icon: 'layers', labelKey: 'qa.merge', promptKey: 'qa.merge.prompt' },
|
||||
{ id: 'timeline', cat: 'cross', icon: 'history', labelKey: 'qa.timeline', promptKey: 'qa.timeline.prompt' },
|
||||
// ── Édition & Reformulation (sélection active) ───────────────────────
|
||||
{ id: 'concise', cat: 'edition', icon: 'scissors', labelKey: 'qa.concise', promptKey: 'qa.concise.prompt' },
|
||||
{ id: 'fix_style', cat: 'edition', icon: 'spell-check', labelKey: 'qa.fix_style', promptKey: 'qa.fix_style.prompt' },
|
||||
{ id: 'rephrase', cat: 'edition', icon: 'pen-tool', labelKey: 'qa.rephrase', promptKey: 'qa.rephrase.prompt' },
|
||||
{ id: 'translate', cat: 'edition', icon: 'languages', labelKey: 'qa.translate', promptKey: 'qa.translate.prompt' },
|
||||
// ── Général (aucun document ouvert) — reprises des anciennes suggestions
|
||||
{ id: 'capabilities', cat: 'general', icon: 'sparkles', labelKey: 'qa.capabilities', promptKey: 'qa.capabilities.prompt' },
|
||||
{ id: 'search_help', cat: 'general', icon: 'search', labelKey: 'qa.search_help', promptKey: 'qa.search_help.prompt' },
|
||||
{ id: 'create_note', cat: 'general', icon: 'notebook-pen', labelKey: 'qa.create_note', promptKey: 'qa.create_note.prompt' },
|
||||
]);
|
||||
|
||||
/** id → action record, for O(1) lookup by the preset tables. */
|
||||
export const ACTIONS_BY_ID = Object.freeze(
|
||||
ACTION_CATALOG.reduce((acc, a) => { acc[a.id] = a; return acc; }, {}),
|
||||
);
|
||||
|
||||
/** File extensions treated as source code (drive the `code` context). */
|
||||
export const CODE_EXT_RE = /\.(?:py|js|mjs|cjs|ts|tsx|jsx|vue|svelte|go|rs|java|kt|c|h|cpp|hpp|cc|cs|php|rb|swift|sh|bash|zsh|ps1|bat|sql|lua|pl|r|dart|scala)$/i;
|
||||
|
||||
/** Text-ish document extensions (everything else stays "single_doc"). */
|
||||
export const TEXT_DOC_EXT_RE = /\.(?:md|markdown|mdx|txt|rst|org|adoc)$/i;
|
||||
|
||||
/**
|
||||
* Map a set of live facts to one context key.
|
||||
*
|
||||
* @param {object} facts
|
||||
* @param {string} facts.mode assistant mode ('directory'|'documents'|'general')
|
||||
* @param {number} facts.docCount number of open documents (tabs/panes)
|
||||
* @param {string|null} facts.currentPath path of the focused document, if any
|
||||
* @param {boolean} facts.hasSelection true when the open editor holds a text selection
|
||||
* @param {number} [facts.fileCount] indexed files of the current directory context
|
||||
* @returns {'selection'|'code'|'multi_doc'|'single_doc'|'directory'|'general'}
|
||||
*/
|
||||
export function detectContext(facts) {
|
||||
const { mode, docCount = 0, currentPath = null, hasSelection = false } = facts || {};
|
||||
// A live editor selection is the strongest intent: act on the selection.
|
||||
if (hasSelection) return 'selection';
|
||||
if (mode === 'documents' || docCount > 0) {
|
||||
if (currentPath && CODE_EXT_RE.test(currentPath)) return 'code';
|
||||
if (docCount >= 2) return 'multi_doc';
|
||||
return 'single_doc';
|
||||
}
|
||||
if (mode === 'directory') return 'directory';
|
||||
return 'general';
|
||||
}
|
||||
|
||||
/**
|
||||
* Contextual triage: the 3 top action ids per context (the "boutons 1-3" of
|
||||
* the design table). Everything not shown stays reachable via the drawer.
|
||||
*/
|
||||
export const CONTEXT_PRESETS = Object.freeze({
|
||||
single_doc: ['summarize_3', 'checklist', 'frontmatter', 'frontmatter_update'],
|
||||
multi_doc: ['merge', 'compare', 'frictions'],
|
||||
code: ['explain_code', 'audit_code', 'test_code'],
|
||||
selection: ['concise', 'fix_style', 'explain_selection'],
|
||||
directory: ['summarize_dir', 'themes', 'checklist'],
|
||||
general: ['capabilities', 'search_help', 'create_note'],
|
||||
});
|
||||
|
||||
// Preset ids that are context phrasings rather than catalogue entries
|
||||
// (their prompt needs the directory/list framing, so they are synthesized).
|
||||
const EXTRA_ACTIONS = Object.freeze({
|
||||
explain_selection: { id: 'explain_selection', cat: 'edition', icon: 'lightbulb', labelKey: 'qa.explain_selection', promptKey: 'qa.explain_selection.prompt' },
|
||||
summarize_dir: { id: 'summarize_dir', cat: 'synthesis', icon: 'align-left', labelKey: 'bookslm.suggestion_summary', promptKey: 'bookslm.suggestion_summary' },
|
||||
themes: { id: 'themes', cat: 'synthesis', icon: 'library', labelKey: 'bookslm.suggestion_themes', promptKey: 'bookslm.suggestion_themes' },
|
||||
});
|
||||
|
||||
/** Resolve an action id (catalogue or preset extra) to its record. */
|
||||
export function getAction(id) {
|
||||
return ACTIONS_BY_ID[id] || EXTRA_ACTIONS[id] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ordered action records suggested for one context key.
|
||||
* Unknown keys fall back to the general preset (never throws).
|
||||
*/
|
||||
export function suggestionsFor(contextKey) {
|
||||
const ids = CONTEXT_PRESETS[contextKey] || CONTEXT_PRESETS.general;
|
||||
return ids.map((id) => getAction(id)).filter(Boolean);
|
||||
}
|
||||
|
||||
/** Translated label + prompt for an action record (empty string when absent). */
|
||||
export function actionTexts(action) {
|
||||
if (!action) return { label: '', prompt: '' };
|
||||
return { label: t(action.labelKey), prompt: t(action.promptKey) };
|
||||
}
|
||||
|
||||
/** Badge text for the contextual header chip. */
|
||||
export function contextBadgeKey(contextKey, docCount) {
|
||||
switch (contextKey) {
|
||||
case 'selection': return 'qa.badge_selection';
|
||||
case 'code': return 'qa.badge_code';
|
||||
case 'multi_doc': return 'qa.badge_multi';
|
||||
case 'single_doc': return 'qa.badge_single';
|
||||
case 'directory': return 'qa.badge_directory';
|
||||
default: return docCount > 0 ? 'qa.badge_single' : 'qa.badge_general';
|
||||
}
|
||||
}
|
||||
+253
-34
@@ -10,6 +10,15 @@ import { safeCreateIcons } from './utils.js';
|
||||
import { api, AuthManager } from './auth.js';
|
||||
import { showToast } from './ui.js';
|
||||
import { state } from './state.js';
|
||||
// #106 — contextual quick actions (catalogue + triage rules live in ai-quick-actions.js).
|
||||
import {
|
||||
detectContext,
|
||||
suggestionsFor,
|
||||
actionTexts,
|
||||
contextBadgeKey,
|
||||
ACTION_CATALOG,
|
||||
CATEGORIES,
|
||||
} from './ai-quick-actions.js';
|
||||
|
||||
export const MODE = Object.freeze({
|
||||
DIRECTORY: 'directory',
|
||||
@@ -920,6 +929,7 @@ class BooksLM {
|
||||
<span class="bookslm-title"></span>
|
||||
<span class="bookslm-subtitle"></span>
|
||||
</div>
|
||||
<span class="bookslm-qa-badge hidden" data-qa-context=""></span>
|
||||
<div class="bookslm-header-actions">
|
||||
<button class="bookslm-btn-agent" title="${t('ai.agent_mode_off')}" aria-label="${t('ai.agent_mode_off')}" aria-pressed="false"><i data-lucide="bot" style="width:16px;height:16px"></i></button>
|
||||
<button class="bookslm-btn-history" title="${t('bookslm.session_history')}" aria-label="${t('bookslm.session_history')}"><i data-lucide="history" style="width:16px;height:16px"></i></button>
|
||||
@@ -1071,6 +1081,25 @@ class BooksLM {
|
||||
});
|
||||
textarea.addEventListener('paste', (e) => this._onPaste(e));
|
||||
|
||||
// #106 — The suggested row flips to "selection" actions while the user
|
||||
// selects text in the open editor; refresh (throttled) on selectionchange.
|
||||
let qaSelTimer = null;
|
||||
const onSelectionChange = () => {
|
||||
if (qaSelTimer) return;
|
||||
qaSelTimer = setTimeout(() => {
|
||||
qaSelTimer = null;
|
||||
if (!this._panel || this._panel.classList.contains('hidden')) return;
|
||||
if (this._messages.length) return;
|
||||
const ctx = this._quickContext();
|
||||
if (ctx !== this._qaContext) this._showSuggestions();
|
||||
}, 250);
|
||||
};
|
||||
document.addEventListener('selectionchange', onSelectionChange);
|
||||
// CodeMirror owns its selection (no native selectionchange on drag), so
|
||||
// the same throttled refresh is bound to pointer/keyboard release too.
|
||||
document.addEventListener('mouseup', onSelectionChange);
|
||||
document.addEventListener('keyup', onSelectionChange);
|
||||
|
||||
// Menu navigation is handled at the panel level (capture phase) so the
|
||||
// arrow keys keep working even if focus is not exactly on the textarea
|
||||
// (e.g. after interacting with the menu). It runs before the textarea
|
||||
@@ -2015,26 +2044,52 @@ class BooksLM {
|
||||
}
|
||||
}
|
||||
|
||||
_suggestionsForContext() {
|
||||
if (this._mode === MODE.GENERAL) {
|
||||
return [
|
||||
t('ai.suggestion_capabilities'),
|
||||
t('ai.suggestion_search'),
|
||||
t('ai.suggestion_create_file'),
|
||||
];
|
||||
}
|
||||
if (this._mode === MODE.DOCUMENTS) {
|
||||
return [
|
||||
t('ai.suggestion_docs_summary'),
|
||||
t('ai.suggestion_docs_keypoints'),
|
||||
t('ai.suggestion_docs_contradictions'),
|
||||
];
|
||||
}
|
||||
return [
|
||||
t('bookslm.suggestion_summary'),
|
||||
t('bookslm.suggestion_themes'),
|
||||
t('bookslm.suggestion_contradictions'),
|
||||
];
|
||||
// ── #106 — Actions instantanées contextuelles ───────────────────────
|
||||
//
|
||||
// The welcome zone above the composer shows the 3 top actions for the
|
||||
// *detected* context (selection > code > multi-doc > single-doc >
|
||||
// directory > general, rules in ai-quick-actions.js); the full catalogue
|
||||
// stays reachable through the "Toutes les actions" drawer.
|
||||
|
||||
/** True when the open editor (CM6, or the mobile textarea fallback)
|
||||
* holds a non-empty text selection. Best-effort, never throws. */
|
||||
_hasEditorSelection() {
|
||||
try {
|
||||
const view = state.editorView;
|
||||
if (view && view.state && view.state.selection) {
|
||||
const sel = view.state.selection.main;
|
||||
if (sel && sel.from !== sel.to) return true;
|
||||
}
|
||||
const ta = state.fallbackEditorEl;
|
||||
if (ta && document.body.contains(ta)
|
||||
&& ta.selectionStart != null && ta.selectionEnd > ta.selectionStart) {
|
||||
return true;
|
||||
}
|
||||
} catch { /* editor not ready */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The live context key driving the suggested-actions row. */
|
||||
_quickContext() {
|
||||
const docs = this._mode === MODE.DOCUMENTS ? this._documents : [];
|
||||
return detectContext({
|
||||
mode: this._mode,
|
||||
docCount: docs.length,
|
||||
currentPath: docs.length ? (docs[0].path || null) : null,
|
||||
hasSelection: this._hasEditorSelection(),
|
||||
});
|
||||
}
|
||||
|
||||
/** Header chip describing the detected context ("1 doc ouvert", …). */
|
||||
_updateQaBadge() {
|
||||
if (!this._panel) return;
|
||||
const badge = this._panel.querySelector('.bookslm-qa-badge');
|
||||
if (!badge) return;
|
||||
const ctx = this._qaContext || this._quickContext();
|
||||
const docCount = this._mode === MODE.DOCUMENTS ? this._documents.length : 0;
|
||||
badge.textContent = t(contextBadgeKey(ctx, docCount), { count: docCount });
|
||||
badge.dataset.qaContext = ctx;
|
||||
badge.classList.remove('hidden');
|
||||
}
|
||||
|
||||
_showSuggestions() {
|
||||
@@ -2042,21 +2097,185 @@ class BooksLM {
|
||||
const sugEl = this._panel.querySelector('.bookslm-suggestions');
|
||||
if (!sugEl) return;
|
||||
sugEl.innerHTML = '';
|
||||
if (this._messages.length) return;
|
||||
|
||||
for (const s of this._suggestionsForContext()) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'bookslm-suggestion';
|
||||
btn.textContent = s;
|
||||
btn.addEventListener('click', () => {
|
||||
const textarea = this._panel.querySelector('textarea');
|
||||
if (textarea) {
|
||||
textarea.value = s;
|
||||
this._sendMessage();
|
||||
}
|
||||
});
|
||||
sugEl.appendChild(btn);
|
||||
if (this._messages.length) {
|
||||
sugEl.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
sugEl.classList.remove('hidden');
|
||||
const ctx = this._quickContext();
|
||||
this._qaContext = ctx;
|
||||
this._updateQaBadge();
|
||||
|
||||
// Welcome hint (mirrors the POC copy: actions or free question).
|
||||
const hint = document.createElement('p');
|
||||
hint.className = 'bookslm-qa-hint';
|
||||
hint.textContent = t('qa.empty_hint');
|
||||
sugEl.appendChild(hint);
|
||||
|
||||
// Row head: label + access to the full catalogue.
|
||||
const head = document.createElement('div');
|
||||
head.className = 'bookslm-qa-head';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'bookslm-qa-label';
|
||||
label.textContent = t('qa.header_suggested');
|
||||
head.appendChild(label);
|
||||
const more = document.createElement('button');
|
||||
more.type = 'button';
|
||||
more.className = 'bookslm-qa-more';
|
||||
more.innerHTML = `<span>${t('qa.all_actions')}</span>`
|
||||
+ '<i data-lucide="layout-grid" style="width:13px;height:13px"></i>';
|
||||
more.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._toggleActionDrawer();
|
||||
});
|
||||
head.appendChild(more);
|
||||
sugEl.appendChild(head);
|
||||
|
||||
const list = document.createElement('div');
|
||||
list.className = 'bookslm-qa-list';
|
||||
for (const action of suggestionsFor(ctx)) {
|
||||
list.appendChild(this._renderQuickActionBtn(action));
|
||||
}
|
||||
sugEl.appendChild(list);
|
||||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||||
}
|
||||
|
||||
/** One action button (icon + label + hover arrow), used in both the row
|
||||
* and the drawer catalogue. */
|
||||
_renderQuickActionBtn(action, opts = {}) {
|
||||
const { label, prompt } = actionTexts(action);
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'bookslm-qa-btn' + (opts.compact ? ' compact' : '');
|
||||
btn.dataset.qaAction = action.id;
|
||||
btn.innerHTML = `
|
||||
<i data-lucide="${action.icon}" class="bookslm-qa-icon" style="width:15px;height:15px"></i>
|
||||
<span class="bookslm-qa-text">${label}</span>
|
||||
<i data-lucide="arrow-right" class="bookslm-qa-go" style="width:14px;height:14px"></i>
|
||||
`;
|
||||
btn.title = prompt;
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._runQuickAction(action);
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
/** Immediate-send the action prompt through the composer (same path as a
|
||||
* typed message: skills, agent mode and images all keep working). Actions
|
||||
* flagged `agent` (they mutate the document) transparently switch the
|
||||
* assistant to agent mode first — same pattern as Deep Research. */
|
||||
_runQuickAction(action) {
|
||||
if (!this._panel || !action) return;
|
||||
const { prompt } = actionTexts(action);
|
||||
if (!prompt) return;
|
||||
this._closeActionDrawer();
|
||||
if (action.agent && !this._agentMode) this._toggleAgentMode();
|
||||
const textarea = this._panel.querySelector('textarea');
|
||||
if (!textarea) return;
|
||||
textarea.value = prompt;
|
||||
this._sendMessage();
|
||||
}
|
||||
|
||||
// ── Drawer "Toutes les actions" (bottom sheet, searchable) ──────────
|
||||
|
||||
_toggleActionDrawer() {
|
||||
const drawer = this._ensureActionDrawer();
|
||||
if (!drawer) return;
|
||||
if (drawer.classList.contains('open')) this._closeActionDrawer();
|
||||
else this._openActionDrawer();
|
||||
}
|
||||
|
||||
_ensureActionDrawer() {
|
||||
if (!this._panel) return null;
|
||||
let drawer = this._panel.querySelector('.bookslm-qa-drawer');
|
||||
if (drawer) return drawer;
|
||||
drawer = document.createElement('div');
|
||||
drawer.className = 'bookslm-qa-drawer hidden';
|
||||
drawer.innerHTML = `
|
||||
<div class="bookslm-qa-sheet" role="dialog" aria-modal="true" aria-label="${t('qa.drawer_title')}">
|
||||
<div class="bookslm-qa-sheet-head">
|
||||
<span class="bookslm-qa-sheet-title">
|
||||
<i data-lucide="layout-grid" style="width:15px;height:15px"></i>
|
||||
${t('qa.drawer_title')}
|
||||
</span>
|
||||
<button type="button" class="bookslm-qa-sheet-close" aria-label="${t('ai.close')}">
|
||||
<i data-lucide="x" style="width:15px;height:15px"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="bookslm-qa-search">
|
||||
<i data-lucide="search" style="width:14px;height:14px"></i>
|
||||
<input type="text" placeholder="${t('qa.search_placeholder')}" aria-label="${t('qa.search_placeholder')}">
|
||||
</div>
|
||||
<div class="bookslm-qa-sheet-list"></div>
|
||||
</div>
|
||||
`;
|
||||
this._panel.appendChild(drawer);
|
||||
drawer.querySelector('.bookslm-qa-sheet-close').addEventListener('click', () => this._closeActionDrawer());
|
||||
// Clicking the backdrop (outside the sheet) closes the drawer.
|
||||
drawer.addEventListener('click', (e) => {
|
||||
if (e.target === drawer) this._closeActionDrawer();
|
||||
});
|
||||
const input = drawer.querySelector('.bookslm-qa-search input');
|
||||
input.addEventListener('input', () => this._renderActionDrawerList(input.value));
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this._closeActionDrawer();
|
||||
}
|
||||
});
|
||||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||||
return drawer;
|
||||
}
|
||||
|
||||
_openActionDrawer() {
|
||||
const drawer = this._ensureActionDrawer();
|
||||
if (!drawer) return;
|
||||
drawer.classList.remove('hidden');
|
||||
requestAnimationFrame(() => drawer.classList.add('open'));
|
||||
this._renderActionDrawerList('');
|
||||
const input = drawer.querySelector('.bookslm-qa-search input');
|
||||
if (input) { input.value = ''; input.focus(); }
|
||||
}
|
||||
|
||||
_closeActionDrawer() {
|
||||
if (!this._panel) return;
|
||||
const drawer = this._panel.querySelector('.bookslm-qa-drawer');
|
||||
if (!drawer) return;
|
||||
drawer.classList.remove('open');
|
||||
// Let the slide-down transition finish before hiding.
|
||||
setTimeout(() => drawer.classList.add('hidden'), 220);
|
||||
}
|
||||
|
||||
_renderActionDrawerList(filter) {
|
||||
const drawer = this._panel && this._panel.querySelector('.bookslm-qa-drawer');
|
||||
if (!drawer) return;
|
||||
const body = drawer.querySelector('.bookslm-qa-sheet-list');
|
||||
if (!body) return;
|
||||
body.innerHTML = '';
|
||||
const q = normalizeSearch(filter || '');
|
||||
let shown = 0;
|
||||
for (const cat of CATEGORIES) {
|
||||
const actions = ACTION_CATALOG.filter((a) => a.cat === cat.id
|
||||
&& (!q || normalizeSearch(actionTexts(a).label).includes(q)));
|
||||
if (!actions.length) continue;
|
||||
const head = document.createElement('p');
|
||||
head.className = 'bookslm-qa-cat';
|
||||
head.innerHTML = `<i data-lucide="${cat.icon}" style="width:13px;height:13px"></i> ${t(cat.labelKey)}`;
|
||||
body.appendChild(head);
|
||||
for (const action of actions) {
|
||||
body.appendChild(this._renderQuickActionBtn(action, { compact: true }));
|
||||
shown++;
|
||||
}
|
||||
}
|
||||
if (!shown) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'bookslm-qa-empty';
|
||||
empty.textContent = t('qa.no_match');
|
||||
body.appendChild(empty);
|
||||
}
|
||||
if (typeof safeCreateIcons === 'function') safeCreateIcons();
|
||||
}
|
||||
|
||||
_renderMessages(opts = {}) {
|
||||
|
||||
@@ -1479,6 +1479,76 @@
|
||||
"pwa.install_button": "Install",
|
||||
"pwa.install_desc": "Install this app on your device for quick access.",
|
||||
"pwa.install_title": "Install ObsiGate",
|
||||
"qa.all_actions": "All actions",
|
||||
"qa.audit_code": "Detect bugs and potential flaws",
|
||||
"qa.audit_code.prompt": "Identify potential bugs, unhandled edge cases and security flaws in this code, with proposed fixes.",
|
||||
"qa.backlinks": "Suggest vault links and backlinks",
|
||||
"qa.backlinks.prompt": "Suggest relevant [[wikilinks]] to other notes in the vault and backlinks to add to this document.",
|
||||
"qa.badge_code": "Code file",
|
||||
"qa.badge_directory": "Directory",
|
||||
"qa.badge_general": "General mode",
|
||||
"qa.badge_multi": "{count} docs open",
|
||||
"qa.badge_selection": "Active selection",
|
||||
"qa.badge_single": "1 doc open",
|
||||
"qa.capabilities": "What can you do?",
|
||||
"qa.capabilities.prompt": "What can you do? Present your capabilities on this vault.",
|
||||
"qa.cat_code": "Code & Scripts",
|
||||
"qa.cat_cross": "Cross-documents",
|
||||
"qa.cat_edition": "Editing & Rewriting",
|
||||
"qa.cat_general": "Assistant",
|
||||
"qa.cat_structure": "Productivity & Structuring",
|
||||
"qa.cat_synthesis": "Synthesis & Analysis",
|
||||
"qa.checklist": "Extract the action checklist",
|
||||
"qa.checklist.prompt": "Extract every concrete action to take as a Markdown to-do list with [ ] checkboxes.",
|
||||
"qa.compare": "Compare differences and convergences",
|
||||
"qa.compare.prompt": "Compare all open documents and summarise their convergences, divergences and oppositions.",
|
||||
"qa.concise": "Make it more concise and punchy",
|
||||
"qa.concise.prompt": "Rewrite the selection to make it more concise and punchy without losing the essentials.",
|
||||
"qa.create_note": "Create a meeting note",
|
||||
"qa.create_note.prompt": "Create a meeting notes file in the vault.",
|
||||
"qa.doc_code": "Add documentation and types",
|
||||
"qa.doc_code.prompt": "Add the appropriate docstrings, JSDoc and type annotations to every function in this file.",
|
||||
"qa.drawer_title": "Action library",
|
||||
"qa.empty_hint": "Pick a context-aware quick action, or ask a question directly.",
|
||||
"qa.explain_code": "Explain the script logic",
|
||||
"qa.explain_code.prompt": "Analyse and explain step by step the structure and algorithm of this source code.",
|
||||
"qa.explain_selection": "Explain the selection",
|
||||
"qa.explain_selection.prompt": "Explain the selected passage: its role, its context and what it implies.",
|
||||
"qa.faq": "Generate a FAQ / key questions",
|
||||
"qa.faq.prompt": "Generate a list of 5 key questions and answers to check comprehension of this text.",
|
||||
"qa.fix_style": "Fix and improve the style",
|
||||
"qa.fix_style.prompt": "Fix spelling and grammar mistakes and improve the syntactic flow of this passage.",
|
||||
"qa.frictions": "Spot frictions and contradictions",
|
||||
"qa.frictions.prompt": "Analyse this document and point out inconsistencies, blind spots or contradictions.",
|
||||
"qa.frontmatter": "Generate the YAML frontmatter",
|
||||
"qa.frontmatter.prompt": "Generate a complete YAML frontmatter block in the vault's format and apply it to the open document (insert it at the top of the file, or replace the existing block, keeping non-empty values already present): titre, auteur, creation_date and modification_date in ISO-8601 with timezone, catégorie, tags (inline list [a, b]), aliases, status, publish, favoris, template, task, archive, draft, private (booleans), NomDeVoute (current vault name), Description (a one-sentence summary of the content).",
|
||||
"qa.frontmatter_update": "Update the frontmatter",
|
||||
"qa.frontmatter_update.prompt": "Update the YAML frontmatter of the open document without deleting existing fields: refresh modification_date (current ISO-8601 timestamp with timezone), recompute titre, tags, aliases, catégorie, NomDeVoute and Description from the current content, fill in any missing metadata field (auteur, creation_date, status, publish, favoris, template, task, archive, draft, private) and apply the change to the file.",
|
||||
"qa.header_suggested": "Suggested actions",
|
||||
"qa.memo": "Write a shareable executive memo",
|
||||
"qa.memo.prompt": "Write a shareable executive memo based on this document: context, findings, recommendations.",
|
||||
"qa.merge": "Merge into one synthesis note",
|
||||
"qa.merge.prompt": "Merge the essential elements of all open documents into one unified, flowing synthesis note.",
|
||||
"qa.no_match": "No matching action.",
|
||||
"qa.plan": "Create a step-by-step action plan",
|
||||
"qa.plan.prompt": "Turn this content into a step-by-step action plan with priorities and estimates.",
|
||||
"qa.rephrase": "Rephrase this passage",
|
||||
"qa.rephrase.prompt": "Rephrase this passage keeping the meaning but using different wording.",
|
||||
"qa.search_help": "Search my notes effectively",
|
||||
"qa.search_help.prompt": "How do I search my notes effectively?",
|
||||
"qa.search_placeholder": "Search an action...",
|
||||
"qa.sections": "Structure into hierarchical sections",
|
||||
"qa.sections.prompt": "Restructure this document with a logical hierarchy of Markdown headings (H2, H3) and clean bullet lists.",
|
||||
"qa.summarize_3": "Summarize in 3 key points",
|
||||
"qa.summarize_3.prompt": "Provide a concise summary of this document in 3 clear key points.",
|
||||
"qa.test_code": "Generate unit tests",
|
||||
"qa.test_code.prompt": "Write a unit test suite covering nominal and error cases for this code.",
|
||||
"qa.timeline": "Build a cross-document timeline",
|
||||
"qa.timeline.prompt": "Build a cross-document timeline of the dated events mentioned in these documents.",
|
||||
"qa.translate": "Translate the selection",
|
||||
"qa.translate.prompt": "Translate this passage into the appropriate language (English if the text is in French, and vice versa).",
|
||||
"qa.vulgarize": "Plain-language explainer",
|
||||
"qa.vulgarize.prompt": "Explain the content of this document in simple, accessible language without jargon.",
|
||||
"search.advanced_operators": "Advanced operators",
|
||||
"search.aria_label": "Search suggestions",
|
||||
"search.case_sensitive": "Case sensitive",
|
||||
@@ -2051,8 +2121,6 @@
|
||||
"guide105.admin_p": "The admin role unlocks a dedicated <code>/admin.html</code> page (Options menu button): live server status, users, vaults, active sessions and the audit log. User CRUD also lives in Configuration.",
|
||||
"guide105.dl_md_title": "Download this guide as Markdown",
|
||||
"guide105.dl_pdf_title": "Download this guide as PDF",
|
||||
"guide105.dl_md": "Markdown",
|
||||
"guide105.dl_pdf": "PDF",
|
||||
"guide105.export_title": "ObsiGate User Guide",
|
||||
"guide105.export_footer": "Generated from ObsiGate {version} — {date}. This document mirrors the in-app guide; the latest version always lives in the application."
|
||||
}
|
||||
|
||||
@@ -1479,6 +1479,76 @@
|
||||
"pwa.install_button": "Installer",
|
||||
"pwa.install_desc": "Installez cette application sur votre appareil pour un accès rapide.",
|
||||
"pwa.install_title": "Installer ObsiGate",
|
||||
"qa.all_actions": "Toutes les actions",
|
||||
"qa.audit_code": "Détecter les bugs et failles potentielles",
|
||||
"qa.audit_code.prompt": "Identifie les bugs potentiels, cas limites non gérés et failles de sécurité dans ce code, avec les correctifs proposés.",
|
||||
"qa.backlinks": "Suggérer des liens et backlinks du vault",
|
||||
"qa.backlinks.prompt": "Suggère des liens [[wikilinks]] pertinents vers d'autres notes du vault et des backlinks à ajouter à ce document.",
|
||||
"qa.badge_code": "Fichier de code",
|
||||
"qa.badge_directory": "Répertoire",
|
||||
"qa.badge_general": "Mode général",
|
||||
"qa.badge_multi": "{count} docs ouverts",
|
||||
"qa.badge_selection": "Sélection active",
|
||||
"qa.badge_single": "1 doc ouvert",
|
||||
"qa.capabilities": "Que sais-tu faire ?",
|
||||
"qa.capabilities.prompt": "Que sais-tu faire ? Présente tes capacités sur ce vault.",
|
||||
"qa.cat_code": "Code & Scripts",
|
||||
"qa.cat_cross": "Cross-documents",
|
||||
"qa.cat_edition": "Édition & Reformulation",
|
||||
"qa.cat_general": "Assistant",
|
||||
"qa.cat_structure": "Productivité & Structuration",
|
||||
"qa.cat_synthesis": "Synthèse & Analyse",
|
||||
"qa.checklist": "Extraire la checklist d'actions",
|
||||
"qa.checklist.prompt": "Extrais toutes les actions concrètes à mener sous forme de to-do list Markdown avec cases à cocher [ ].",
|
||||
"qa.compare": "Comparer différences et convergences",
|
||||
"qa.compare.prompt": "Compare l'ensemble des documents ouverts et résume leurs convergences, divergences et oppositions.",
|
||||
"qa.concise": "Rendre plus concis et percutant",
|
||||
"qa.concise.prompt": "Reformule la sélection pour la rendre plus concise et percutante, sans perdre l'essentiel.",
|
||||
"qa.create_note": "Créer une note de réunion",
|
||||
"qa.create_note.prompt": "Crée un fichier de notes de réunion dans le vault.",
|
||||
"qa.doc_code": "Ajouter la documentation et les types",
|
||||
"qa.doc_code.prompt": "Ajoute les docstrings, JSDoc et annotations de type appropriés à toutes les fonctions de ce fichier.",
|
||||
"qa.drawer_title": "Bibliothèque d'actions",
|
||||
"qa.empty_hint": "Sélectionnez une action instantanée adaptée à votre contexte, ou posez une question directe.",
|
||||
"qa.explain_code": "Expliquer la logique du script",
|
||||
"qa.explain_code.prompt": "Analyse et explique pas à pas la structure et l'algorithme de ce code source.",
|
||||
"qa.explain_selection": "Expliquer la sélection",
|
||||
"qa.explain_selection.prompt": "Explique le passage sélectionné : son rôle, son contexte et ce qu'il implique.",
|
||||
"qa.faq": "Générer une FAQ / questions-clés",
|
||||
"qa.faq.prompt": "Génère une liste de 5 questions-réponses clés pour évaluer la compréhension de ce texte.",
|
||||
"qa.fix_style": "Corriger et améliorer le style",
|
||||
"qa.fix_style.prompt": "Corrige les fautes d'orthographe, de grammaire et améliore la fluidité syntaxique de ce passage.",
|
||||
"qa.frictions": "Relever les frictions et contradictions",
|
||||
"qa.frictions.prompt": "Analyse ce document et relève les incohérences, zones d'ombre ou contradictions.",
|
||||
"qa.frontmatter": "Générer le frontmatter YAML",
|
||||
"qa.frontmatter.prompt": "Génère un bloc frontmatter YAML complet au format du vault et applique-le au document ouvert (insère-le en tête du fichier ou remplace le bloc existant, en conservant les valeurs non vides déjà présentes) : titre, auteur, creation_date et modification_date en ISO-8601 avec fuseau, catégorie, tags (liste en ligne [a, b]), aliases, status, publish, favoris, template, task, archive, draft, private (booléens), NomDeVoute (nom du vault courant), Description (résumé en une phrase du contenu).",
|
||||
"qa.frontmatter_update": "Mettre à jour le frontmatter",
|
||||
"qa.frontmatter_update.prompt": "Mets à jour le frontmatter YAML du document ouvert sans supprimer les champs existants : actualise modification_date (horodatage courant ISO-8601 avec fuseau), recalcule titre, tags, aliases, catégorie, NomDeVoute et Description d'après le contenu actuel, complète tout champ de la section métadonnée manquant (auteur, creation_date, status, publish, favoris, template, task, archive, draft, private) et applique la modification au fichier.",
|
||||
"qa.header_suggested": "Actions suggérées",
|
||||
"qa.memo": "Rédiger un mémo exécutif partageable",
|
||||
"qa.memo.prompt": "Rédige un mémo exécutif partageable basé sur ce document : contexte, constats, recommandations.",
|
||||
"qa.merge": "Fusionner en une note de synthèse",
|
||||
"qa.merge.prompt": "Fusionne les éléments essentiels de tous les documents ouverts en une note de synthèse unifiée et fluide.",
|
||||
"qa.no_match": "Aucune action ne correspond.",
|
||||
"qa.plan": "Créer un plan d'action par étapes",
|
||||
"qa.plan.prompt": "Transforme ce contenu en un plan d'action structuré par étapes, avec priorités et estimations.",
|
||||
"qa.rephrase": "Reformuler ce passage",
|
||||
"qa.rephrase.prompt": "Reformule ce passage en gardant le sens mais avec une tournure différente.",
|
||||
"qa.search_help": "Rechercher efficacement dans mes notes",
|
||||
"qa.search_help.prompt": "Comment rechercher efficacement dans mes notes ?",
|
||||
"qa.search_placeholder": "Rechercher une action...",
|
||||
"qa.sections": "Structurer en sections hiérarchiques",
|
||||
"qa.sections.prompt": "Restructure ce document avec une hiérarchie logique de titres Markdown (H2, H3) et des listes à puces propres.",
|
||||
"qa.summarize_3": "Résumer en 3 points clés",
|
||||
"qa.summarize_3.prompt": "Fais un résumé synthétique de ce document en 3 points clés, clairs et concis.",
|
||||
"qa.test_code": "Générer les tests unitaires",
|
||||
"qa.test_code.prompt": "Rédige une suite de tests unitaires couvrant les cas nominaux et d'erreur pour ce code.",
|
||||
"qa.timeline": "Construire une chronologie transversale",
|
||||
"qa.timeline.prompt": "Construis une chronologie transversale des événements datés mentionnés dans ces documents.",
|
||||
"qa.translate": "Traduire la sélection",
|
||||
"qa.translate.prompt": "Traduis ce passage dans la langue appropriée (anglais si le texte est en français, et inversement).",
|
||||
"qa.vulgarize": "Vulgariser ce document",
|
||||
"qa.vulgarize.prompt": "Explique le contenu de ce document de manière simple, accessible et sans jargon.",
|
||||
"search.advanced_operators": "Opérateurs avancés",
|
||||
"search.aria_label": "Suggestions de recherche",
|
||||
"search.case_sensitive": "Respecter la casse",
|
||||
@@ -2051,8 +2121,6 @@
|
||||
"guide105.admin_p": "Le rôle admin ouvre une page dédiée <code>/admin.html</code> (bouton du menu Options) : statut du serveur en direct, utilisateurs, vaults, sessions actives et journal d'audit. Le CRUD utilisateurs est aussi disponible dans les Configurations.",
|
||||
"guide105.dl_md_title": "Télécharger ce guide en Markdown",
|
||||
"guide105.dl_pdf_title": "Télécharger ce guide en PDF",
|
||||
"guide105.dl_md": "Markdown",
|
||||
"guide105.dl_pdf": "PDF",
|
||||
"guide105.export_title": "Guide d'utilisation ObsiGate",
|
||||
"guide105.export_footer": "Généré depuis ObsiGate {version} — {date}. Ce document est la copie du guide intégré ; la version la plus récente est toujours dans l'application."
|
||||
}
|
||||
|
||||
+64
-3
@@ -9925,10 +9925,71 @@ details[open] > summary .bookslm-chevron::before { content: '▼'; }
|
||||
.bookslm-diff-add { color: #4ade80; display: block; }
|
||||
.bookslm-diff-del { color: #f87171; display: block; }
|
||||
.bookslm-diff-ctx { color: var(--text-secondary); display: block; }
|
||||
/* #106 — Actions instantanées contextuelles de l'assistant. */
|
||||
.bookslm-suggestions { display: flex; flex-direction: column; gap: 6px; padding: 8px 16px 0; }
|
||||
.bookslm-suggestion { padding: 8px 12px; border-radius: 8px; border: 1px solid var(--border);
|
||||
background: var(--surface); color: var(--text-secondary); cursor: pointer; font-size: 13px; text-align: left; }
|
||||
.bookslm-suggestion:hover { background: var(--surface2); color: var(--text-primary); border-color: var(--accent); }
|
||||
.bookslm-suggestions.hidden { display: none; }
|
||||
.bookslm-qa-badge { flex-shrink: 0; font-size: 10px; text-transform: uppercase;
|
||||
letter-spacing: 0.5px; font-weight: 600; color: var(--accent-text, var(--accent));
|
||||
background: var(--accent-bg);
|
||||
border: 1px solid var(--border); border-radius: 999px; padding: 2px 8px; }
|
||||
.bookslm-qa-badge.hidden { display: none; }
|
||||
.bookslm-qa-hint { margin: 0 0 2px; font-size: 12px; color: var(--text-secondary); line-height: 1.45; }
|
||||
.bookslm-qa-head { display: flex; align-items: center; justify-content: space-between; padding: 0 2px; }
|
||||
.bookslm-qa-label { font-size: 10px; font-weight: 700; letter-spacing: 0.8px;
|
||||
text-transform: uppercase; color: var(--text-muted, var(--text-secondary)); opacity: 0.85; }
|
||||
.bookslm-qa-more { display: inline-flex; align-items: center; gap: 5px; background: none;
|
||||
border: none; color: var(--accent); font-size: 12px; cursor: pointer; padding: 2px 4px;
|
||||
border-radius: 6px; }
|
||||
.bookslm-qa-more:hover { background: var(--bg-hover); }
|
||||
.bookslm-qa-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.bookslm-qa-btn { display: flex; align-items: center; gap: 10px; width: 100%;
|
||||
text-align: left; padding: 9px 12px; border-radius: 10px; cursor: pointer;
|
||||
background: var(--surface); border: 1px solid var(--border); color: var(--text-secondary);
|
||||
font-size: 13px; transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; }
|
||||
.bookslm-qa-btn:hover { background: var(--surface2); border-color: var(--accent); color: var(--text-primary); }
|
||||
.bookslm-qa-btn .bookslm-qa-icon { color: var(--text-muted, var(--text-secondary)); flex-shrink: 0; }
|
||||
.bookslm-qa-btn:hover .bookslm-qa-icon { color: var(--accent); }
|
||||
.bookslm-qa-text { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis;
|
||||
white-space: nowrap; font-weight: 500; }
|
||||
.bookslm-qa-go { opacity: 0; transform: translateX(-3px); transition: opacity 0.15s ease,
|
||||
transform 0.15s ease; color: var(--accent); flex-shrink: 0; }
|
||||
.bookslm-qa-btn:hover .bookslm-qa-go { opacity: 1; transform: translateX(0); }
|
||||
/* Tiroir catalogue ("Toutes les actions") : bottom-sheet dans le panneau. */
|
||||
.bookslm-qa-drawer { position: absolute; inset: 0; z-index: 60; background: rgba(0, 0, 0, 0.45);
|
||||
display: flex; align-items: flex-end; opacity: 0; transition: opacity 0.2s ease; }
|
||||
.bookslm-qa-drawer.hidden { display: none; }
|
||||
.bookslm-qa-drawer.open { opacity: 1; }
|
||||
.bookslm-qa-sheet { width: 100%; max-height: 82%; display: flex; flex-direction: column;
|
||||
background: var(--bg-primary); border-top: 1px solid var(--border);
|
||||
border-radius: 14px 14px 0 0; padding: 12px 14px 10px; box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.35);
|
||||
transform: translateY(100%); transition: transform 0.22s ease; }
|
||||
.bookslm-qa-drawer.open .bookslm-qa-sheet { transform: translateY(0); }
|
||||
.bookslm-qa-sheet-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.bookslm-qa-sheet-title { display: inline-flex; align-items: center; gap: 7px; font-size: 14px;
|
||||
font-weight: 600; color: var(--text-primary); }
|
||||
.bookslm-qa-sheet-title i { color: var(--accent); }
|
||||
.bookslm-qa-sheet-close { display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 26px; height: 26px; border-radius: 50%; border: none; cursor: pointer;
|
||||
background: var(--surface); color: var(--text-secondary); }
|
||||
.bookslm-qa-sheet-close:hover { background: var(--surface2); color: var(--text-primary); }
|
||||
.bookslm-qa-search { position: relative; margin-bottom: 10px; }
|
||||
.bookslm-qa-search i { position: absolute; left: 9px; top: 50%; transform: translateY(-50%);
|
||||
color: var(--text-secondary); pointer-events: none; }
|
||||
.bookslm-qa-search input { width: 100%; box-sizing: border-box; padding: 7px 10px 7px 30px;
|
||||
border-radius: 8px; border: 1px solid var(--border); background: var(--surface);
|
||||
color: var(--text-primary); font-size: 13px; outline: none; }
|
||||
.bookslm-qa-search input:focus { border-color: var(--accent); }
|
||||
.bookslm-qa-sheet-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 6px;
|
||||
padding-bottom: 8px; }
|
||||
.bookslm-qa-cat { margin: 8px 2px 2px; display: flex; align-items: center; gap: 6px;
|
||||
font-size: 10px; font-weight: 700; letter-spacing: 0.8px; text-transform: uppercase;
|
||||
color: var(--text-secondary); position: sticky; top: 0; background: var(--bg-primary);
|
||||
padding: 4px 0; z-index: 1; }
|
||||
.bookslm-qa-btn.compact { padding: 7px 10px; border-radius: 8px; font-size: 12.5px; }
|
||||
.bookslm-qa-empty { padding: 18px 8px; text-align: center; font-size: 12px; color: var(--text-secondary); }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bookslm-qa-drawer, .bookslm-qa-sheet, .bookslm-qa-btn, .bookslm-qa-go { transition: none; }
|
||||
}
|
||||
/* Formatted markdown inside assistant answers. */
|
||||
.bookslm-bubble.assistant > *:first-child { margin-top: 0; }
|
||||
.bookslm-bubble.assistant > *:last-child { margin-bottom: 0; }
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
|
||||
* a separate store. Bumping SW_VERSION invalidates it on every release.
|
||||
*/
|
||||
const SW_VERSION = 'v22';
|
||||
const SW_VERSION = 'v23';
|
||||
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
|
||||
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
|
||||
const API_CACHE = `obsigate-api-${SW_VERSION}`;
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.13.0",
|
||||
"version": "2.14.0",
|
||||
"description": "**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.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Pré-rend les diagrammes Mermaid du guide intégré en PNG (#105).
|
||||
|
||||
Scanne les blocs ``<pre class="mermaid-code">`` de frontend/index.html,
|
||||
extrait leur code, et appelle scripts/render_guide_diagram.mjs (Chromium +
|
||||
mermaid v11) pour produire ``backend/assets/guide_diagrams/<sha1>.png``.
|
||||
Ces PNG sont commités : le PDF du guide les embarque comme vraies images
|
||||
(WeasyPrint ne sait pas exécuter Mermaid).
|
||||
|
||||
Usage : python scripts/build_guide_diagrams.py
|
||||
"""
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ASSETS = ROOT / "backend" / "assets" / "guide_diagrams"
|
||||
|
||||
|
||||
def extract_mermaid() -> dict[str, str]:
|
||||
page_html = (ROOT / "frontend" / "index.html").read_text(encoding="utf-8")
|
||||
out: dict[str, str] = {}
|
||||
for m in re.finditer(
|
||||
r'<pre class="mermaid-code"><code class="language-mermaid">(.*?)</code></pre>',
|
||||
page_html,
|
||||
re.DOTALL,
|
||||
):
|
||||
code = m.group(1)
|
||||
code = html.unescape(code).strip()
|
||||
sha = hashlib.sha1(code.encode("utf-8")).hexdigest()[:16]
|
||||
out[sha] = code
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
jobs = extract_mermaid()
|
||||
if not jobs:
|
||||
print("aucun bloc mermaid dans index.html")
|
||||
return 1
|
||||
todo = {k: v for k, v in jobs.items() if not (ASSETS / f"{k}.png").exists()}
|
||||
print("diagrammes:", len(jobs), "| à rendre:", len(todo))
|
||||
if not todo:
|
||||
return 0
|
||||
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as f:
|
||||
json.dump(todo, f)
|
||||
path = f.name
|
||||
r = subprocess.run(
|
||||
["node", str(ROOT / "scripts" / "render_guide_diagram.mjs"), path],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
print(r.stdout[-2000:])
|
||||
if r.returncode != 0:
|
||||
print(r.stderr[-2000:])
|
||||
return r.returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -427,9 +427,6 @@ CONTENT: dict[str, tuple[str, str]] = {
|
||||
# -- Boutons de téléchargement ---------------------------------------------------------------
|
||||
"dl_md_title": ("Télécharger ce guide en Markdown", "Download this guide as Markdown"),
|
||||
"dl_pdf_title": ("Télécharger ce guide en PDF", "Download this guide as PDF"),
|
||||
"dl_md": ("Markdown", "Markdown"),
|
||||
"dl_pdf": ("PDF", "PDF"),
|
||||
|
||||
# -- Export MD/PDF (côté serveur) -------------------------------------------------------------
|
||||
"export_title": ("Guide d'utilisation ObsiGate", "ObsiGate User Guide"),
|
||||
"export_footer": (
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// Pré-rend les diagrammes Mermaid du guide (#105 PDF) en PNG.
|
||||
// Usage: node scripts/render_guide_diagram.mjs
|
||||
// Lit le(s) code(s) Mermaid via --input JSON {sha: code} et écrit
|
||||
// backend/assets/guide_diagrams/<sha>.png (rendu chromium + mermaid v11 CDN,
|
||||
// scale 2, fond blanc).
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createRequire } from 'module';
|
||||
const require = createRequire(import.meta.url);
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const OUT_DIR = path.join(ROOT, 'backend', 'assets', 'guide_diagrams');
|
||||
|
||||
(async () => {
|
||||
const jobs = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); // {sha: code}
|
||||
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1800, height: 1400 }, deviceScaleFactor: 2 });
|
||||
await page.goto('about:blank');
|
||||
await page.addScriptTag({ url: 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js' });
|
||||
await page.waitForFunction(() => typeof window.mermaid !== 'undefined');
|
||||
for (const [sha, code] of Object.entries(jobs)) {
|
||||
const out = await page.evaluate(async (src) => {
|
||||
window.mermaid.initialize({ startOnLoad: false, theme: 'default', securityLevel: 'loose', fontFamily: 'DejaVu Sans, Arial, sans-serif' });
|
||||
try {
|
||||
const { svg } = await window.mermaid.render('d_' + Math.random().toString(36).slice(2), src);
|
||||
const box = document.createElement('div');
|
||||
box.innerHTML = svg;
|
||||
box.style.background = 'white';
|
||||
document.body.replaceChildren(box);
|
||||
const svgEl = box.querySelector('svg');
|
||||
if (!svgEl) return { err: 'no svg' };
|
||||
svgEl.style.maxWidth = 'none';
|
||||
const r = svgEl.getBoundingClientRect();
|
||||
return { w: Math.min(Math.ceil(r.width), 2000), h: Math.min(Math.ceil(r.height), 2600), ok: true };
|
||||
} catch (e) {
|
||||
return { err: String(e).slice(0, 300) };
|
||||
}
|
||||
}, code);
|
||||
if (out.err) { console.error(sha, 'RENDER ERROR', out.err); process.exitCode = 1; continue; }
|
||||
const file = path.join(OUT_DIR, sha + '.png');
|
||||
await page.screenshot({ path: file, clip: { x: 0, y: 0, width: out.w, height: out.h } });
|
||||
console.log(sha, '->', file, out.w + 'x' + out.h);
|
||||
}
|
||||
await browser.close();
|
||||
})();
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — Frontend unit tests for #106 (assistant contextual quick actions).
|
||||
*
|
||||
* Covers the pure logic of frontend/js/ai-quick-actions.js:
|
||||
* - detectContext precedence (selection > code > multi-doc > single-doc >
|
||||
* directory > general)
|
||||
* - every preset id resolves to a real action (label + prompt keys exist)
|
||||
* - i18n completeness: every qa.* key referenced by the catalogue exists in
|
||||
* BOTH fr.json and en.json (FR/EN contract from AGENTS.md)
|
||||
*
|
||||
* Usage: node tests/frontend/ai-quick-actions.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const ROOT = path.resolve(__dirname, "..", "..");
|
||||
|
||||
// Minimal browser shim: i18n.js exposes `window.t = t` at module top-level.
|
||||
// Strings are not loaded here, so t() falls back to the key — enough for the
|
||||
// pure-logic contract asserted below (keys existence is checked against the
|
||||
// real locale JSON files instead).
|
||||
globalThis.window = globalThis.window || globalThis;
|
||||
|
||||
const {
|
||||
detectContext,
|
||||
suggestionsFor,
|
||||
getAction,
|
||||
contextBadgeKey,
|
||||
ACTION_CATALOG,
|
||||
CATEGORIES,
|
||||
CONTEXT_PRESETS,
|
||||
} = await import(pathToFileURL(path.join(ROOT, "frontend", "js", "ai-quick-actions.js")).href);
|
||||
|
||||
let testCount = 0;
|
||||
let passCount = 0;
|
||||
|
||||
async function test(name, fn) {
|
||||
testCount++;
|
||||
try {
|
||||
await fn();
|
||||
passCount++;
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (err) {
|
||||
console.error(` ✗ ${name}\n ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ── detectContext precedence ───────────────────────────────────────────────
|
||||
|
||||
await test("selection wins over every other context", () => {
|
||||
assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "a.md", hasSelection: true }), "selection");
|
||||
assert.equal(detectContext({ mode: "documents", docCount: 3, currentPath: "x.py", hasSelection: true }), "selection");
|
||||
});
|
||||
|
||||
await test("code file drives the code context", () => {
|
||||
assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "backend/main.py" }), "code");
|
||||
assert.equal(detectContext({ mode: "documents", docCount: 2, currentPath: "script.SH" }), "code");
|
||||
assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "app.tsx" }), "code");
|
||||
});
|
||||
|
||||
await test("2+ documents drive the multi_doc context", () => {
|
||||
assert.equal(detectContext({ mode: "documents", docCount: 2, currentPath: "a.md" }), "multi_doc");
|
||||
});
|
||||
|
||||
await test("one text document drives the single_doc context", () => {
|
||||
assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "note.md" }), "single_doc");
|
||||
assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "plan.txt" }), "single_doc");
|
||||
assert.equal(detectContext({ mode: "documents", docCount: 1, currentPath: "image.png" }), "single_doc");
|
||||
});
|
||||
|
||||
await test("directory and general fall back correctly", () => {
|
||||
assert.equal(detectContext({ mode: "directory", docCount: 0 }), "directory");
|
||||
assert.equal(detectContext({ mode: "general", docCount: 0 }), "general");
|
||||
assert.equal(detectContext(), "general");
|
||||
});
|
||||
|
||||
// ── presets & catalogue integrity ──────────────────────────────────────────
|
||||
|
||||
await test("every context has 3-4 suggested actions, all resolvable", () => {
|
||||
for (const [ctx, ids] of Object.entries(CONTEXT_PRESETS)) {
|
||||
assert.ok(ids.length >= 3 && ids.length <= 4, `preset ${ctx} must hold 3-4 actions`);
|
||||
for (const id of ids) assert.ok(getAction(id), `preset ${ctx}: unknown action id ${id}`);
|
||||
assert.equal(suggestionsFor(ctx).length, ids.length);
|
||||
}
|
||||
});
|
||||
|
||||
await test("unknown context falls back to the general preset", () => {
|
||||
assert.deepEqual(suggestionsFor("nonsense").map((a) => a.id), suggestionsFor("general").map((a) => a.id));
|
||||
});
|
||||
|
||||
await test("catalogue actions have unique ids, a known category, icon and keys", () => {
|
||||
const catIds = new Set(CATEGORIES.map((c) => c.id));
|
||||
const seen = new Set();
|
||||
for (const a of ACTION_CATALOG) {
|
||||
assert.ok(!seen.has(a.id), `duplicate action id ${a.id}`);
|
||||
seen.add(a.id);
|
||||
assert.ok(catIds.has(a.cat), `action ${a.id}: unknown category ${a.cat}`);
|
||||
assert.ok(a.icon, `action ${a.id}: missing icon`);
|
||||
assert.ok(a.labelKey && a.promptKey, `action ${a.id}: missing i18n keys`);
|
||||
}
|
||||
});
|
||||
|
||||
await test("the 4 scenarios of the design table map to the expected actions", () => {
|
||||
assert.deepEqual(suggestionsFor("single_doc").map((a) => a.id),
|
||||
["summarize_3", "checklist", "frontmatter", "frontmatter_update"]);
|
||||
assert.deepEqual(suggestionsFor("multi_doc").map((a) => a.id), ["merge", "compare", "frictions"]);
|
||||
assert.deepEqual(suggestionsFor("code").map((a) => a.id), ["explain_code", "audit_code", "test_code"]);
|
||||
assert.deepEqual(suggestionsFor("selection").map((a) => a.id), ["concise", "fix_style", "explain_selection"]);
|
||||
});
|
||||
|
||||
await test("frontmatter actions are agent-mode (they mutate the document)", () => {
|
||||
assert.equal(getAction("frontmatter").agent, true);
|
||||
assert.equal(getAction("frontmatter_update").agent, true);
|
||||
assert.ok(!getAction("summarize_3").agent);
|
||||
});
|
||||
|
||||
// ── i18n completeness (FR + EN) ────────────────────────────────────────────
|
||||
|
||||
const FR = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "fr.json"), "utf8"));
|
||||
const EN = JSON.parse(readFileSync(path.join(ROOT, "frontend", "locales", "en.json"), "utf8"));
|
||||
|
||||
await test("every catalogue/preset key exists in FR and EN (label + prompt)", () => {
|
||||
const keys = new Set();
|
||||
for (const a of ACTION_CATALOG) { keys.add(a.labelKey); keys.add(a.promptKey); }
|
||||
for (const id of Object.values(CONTEXT_PRESETS).flat()) {
|
||||
const a = getAction(id);
|
||||
keys.add(a.labelKey); keys.add(a.promptKey);
|
||||
}
|
||||
for (const c of CATEGORIES) keys.add(c.labelKey);
|
||||
for (const k of ["qa.header_suggested", "qa.all_actions", "qa.drawer_title",
|
||||
"qa.search_placeholder", "qa.no_match", "qa.empty_hint", "qa.badge_single",
|
||||
"qa.badge_multi", "qa.badge_code", "qa.badge_selection", "qa.badge_directory",
|
||||
"qa.badge_general"]) keys.add(k);
|
||||
for (const k of keys) {
|
||||
assert.ok(typeof FR[k] === "string" && FR[k].length > 0, `fr.json missing ${k}`);
|
||||
assert.ok(typeof EN[k] === "string" && EN[k].length > 0, `en.json missing ${k}`);
|
||||
}
|
||||
});
|
||||
|
||||
await test("context badge keys resolve per context", () => {
|
||||
assert.equal(contextBadgeKey("code"), "qa.badge_code");
|
||||
assert.equal(contextBadgeKey("selection"), "qa.badge_selection");
|
||||
assert.equal(contextBadgeKey("multi_doc"), "qa.badge_multi");
|
||||
assert.equal(contextBadgeKey("single_doc"), "qa.badge_single");
|
||||
assert.equal(contextBadgeKey("directory"), "qa.badge_directory");
|
||||
assert.equal(contextBadgeKey("general"), "qa.badge_general");
|
||||
});
|
||||
|
||||
await test("multi-doc badge carries the {count} param in both locales", () => {
|
||||
assert.ok(FR["qa.badge_multi"].includes("{count}"));
|
||||
assert.ok(EN["qa.badge_multi"].includes("{count}"));
|
||||
});
|
||||
|
||||
console.log(`\n${passCount} passed, ${testCount - passCount} failed`);
|
||||
if (passCount !== testCount) process.exit(1);
|
||||
@@ -116,6 +116,25 @@ def test_build_guide_pdf():
|
||||
assert len(pdf) > 20_000
|
||||
|
||||
|
||||
def test_guide_pdf_embeds_rendered_diagram():
|
||||
# #105 feedback : le bloc Mermaid de la section Architecture doit devenir
|
||||
# une VRAIE image dans le PDF (PNG pré-rendu commité), pas du code brut.
|
||||
import re
|
||||
|
||||
from backend.guide_export import build_guide_html
|
||||
|
||||
html = build_guide_html("fr")
|
||||
imgs = re.findall(r'<img src="file://[^"]+\.png"', html)
|
||||
assert imgs, "aucun diagramme PNG pré-rendu dans le HTML du guide"
|
||||
|
||||
|
||||
def test_guide_download_md_has_mermaid_fenced():
|
||||
# cote Markdown : le diagramme reste du code mermaid copiable
|
||||
from backend.guide_export import build_guide_markdown
|
||||
|
||||
assert "```mermaid" in build_guide_markdown("fr").decode("utf-8")
|
||||
|
||||
|
||||
def test_guide_document_metadata():
|
||||
from backend.guide_export import get_guide_document
|
||||
|
||||
|
||||
Reference in New Issue
Block a user