Compare commits

...
1 Commits
Author SHA1 Message Date
bruno f4c8504c8d feat: chat — les posts affichent le markdown rendu comme un document, code coloré #193
CI / lint (push) Successful in 2m50s
CI / security (push) Successful in 1m37s
CI / test (push) Successful in 4m34s
CI / build (push) Successful in 1m31s
CI / e2e (push) Successful in 17m36s
2026-10-09 15:48:04 -04:00
17 changed files with 308 additions and 19 deletions
+27 -1
View File
@@ -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.62.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.63.0**.
---
@@ -14,6 +14,32 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.63.0] — 2026-10-09
### Ajouté
- **#193 — Chat : un post s'affiche comme un document markdown**
- Le rendu vient du **serveur** : `backend/file_chat.py` ajoute un champ
`html` à chaque message en réutilisant le pipeline des documents
(`backend/render.py::_render_markdown` → mistune + wikilinks + masquage des
secrets + **sanitizer BUG-021**). Pas de second moteur markdown côté client,
donc pas de nouvelle surface XSS.
- Titres, listes, tableaux, citations, notes, listes de tâches et **blocs de
code colorés** : le front injecte le HTML dans un `.md-content` puis passe
`safeHighlight()` (highlight.js + alias de langages) sur chaque `pre code`,
exactement comme la visionneuse de documents.
- Le `html` est calculé **à la lecture** (et à l'ajout, pour l'écho SSE) :
rien n'est persisté dans `data/chats/*.json`, un message reste du texte.
- Repli conservé : sans `html`, le texte brut + liens cliquables s'affichent
comme avant ; un échec de rendu n'emporte jamais le message.
- CSS : `.file-chat-md` annule le `pre-wrap` de la bulle (sinon les retours à
la ligne du HTML source devenaient des lignes vides) et compacte
`.md-content` (tailles, `pre`/`table` défilants) pour une bulle de chat.
- Tests : pytest `TestMarkdownRendering` +7 (54 → **61** dans
`test_file_chat.py`), JSDOM `filechat.test.mjs` +2 (25 → **27**).
---
## [2.62.0] — 2026-10-09
### Modifié
+3 -3
View File
@@ -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.
[![Version](https://img.shields.io/badge/Version-2.62.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.63.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -976,8 +976,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.62.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.63.0).
---
*Projet : ObsiGate | Version : 2.62.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.63.0 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -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.
[![Version](https://img.shields.io/badge/Version-2.62.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.63.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1151,8 +1151,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.62.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.63.0).
---
*Project: ObsiGate | Version: 2.62.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.63.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.62.0
2.63.0
+25 -3
View File
@@ -29,6 +29,8 @@ from urllib.parse import urljoin, urlparse
import httpx
from backend.render import _render_markdown
logger = logging.getLogger("obsigate.file_chat")
CHAT_DIR = Path("data/chats")
@@ -96,8 +98,28 @@ def _write(file: Path, doc: dict[str, Any]) -> None:
def get_messages(vault: str, path: str) -> list[dict[str, Any]]:
"""Return the chat history for *(vault, path)* (chronological)."""
return list(_read(vault, path).get("messages", []))
"""Return the chat history for *(vault, path)* (chronological).
Every message carries its rendered ``html`` (#193): same markdown pipeline
as a document (mistune + sanitizer), computed on read so a template change
applies to the whole history without rewriting the JSON store.
"""
return [_decorate(m, vault) for m in _read(vault, path).get("messages", [])]
def _decorate(msg: dict[str, Any], vault: str) -> dict[str, Any]:
"""Return a copy of *msg* with its sanitized markdown ``html`` (#193).
The stored message is left untouched (``html`` is never persisted). A
rendering failure must never break the chat: the message goes out with an
empty ``html`` and the client falls back to plain text.
"""
try:
html = _render_markdown(msg.get("text", ""), vault)
except Exception as e: # pragma: no cover - defensive
logger.warning("chat markdown rendering failed: %s", e)
html = ""
return {**msg, "html": html}
def add_message(
@@ -147,7 +169,7 @@ def _append(
messages = messages[-MAX_MESSAGES:]
doc["messages"] = messages
_write(_chat_file(vault, path), doc)
return msg
return _decorate(msg, vault) # #193 — le html part avec l'écho SSE
# --- #192 : accusé de réception ---------------------------------------------
+7
View File
@@ -166,6 +166,13 @@ class ChatMessageItem(BaseModel):
default=None,
description="Link preview card {url, title, description, image, site} (#191)",
)
html: str = Field(
default="",
description=(
"Rendered (mistune + sanitized) HTML of ``text``, like a document "
"preview — tables, lists, fenced code blocks (#193)"
),
)
class ChatHistoryResponse(BaseModel):
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.62.0"
version = "2.63.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.62.0"
version = "2.63.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.62.0",
"version": "2.63.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+2
View File
@@ -337,6 +337,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-10-08 | #192 + BUG-109 | Fonctionnalité + correction | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/js/sync.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#192 — chat : saisie auto-agrandissante + accusé de réception.** La boîte d'envoi (panneau document **et** sidebar) devient un `textarea` dont la hauteur suit le contenu (plafond 160 px puis défilement, repasse à 1 ligne après l'envoi), `Entrée` envoie / `Maj+Entrée` saute une ligne ; placeholder sidebar désormais traduit à l'init. **Accusé de réception** : chaque document de conversation porte `read = {utilisateur: ts}` (verrou global sur le read-modify-write — une lecture ne peut plus faire disparaître un message), `GET` d'historique renvoie la carte, nouveau `POST /api/chat/read` (400 sans vault/path, 403 DM hors pair, ACL vault pour un chat de fichier) marque la lecture et diffuse `chat_read` en SSE ; côté client `✓` envoyé / `✓✓` lu sur **mes** messages (exclut ma propre carte de lecture), bascule en direct via `onChatRead()` + toast « {user} a lu votre message », accusé émis à l'ouverture d'une conversation et à la réception d'un message déjà affiché. **BUG-109** : la vignette des tuiles de lien ne s'affichait jamais (CSP `img-src 'self'` + `og:image` relative résolue contre ObsiGate) → `_proxy_image()` téléverse l'image dans `chat_uploads` (same-origin, `urljoin` contre la page, garde SSRF, 2 Mo max, allow-list) et la carte reste affichée si le téléchargement échoue. Tests : pytest `test_file_chat.py` 45→**54** (`TestReadReceipts` 7, proxy image 2, 2 assertions de réponse adaptées), JSDOM `filechat.test.mjs` 16→**19** ; ruff 0, mypy 0 (113 fichiers), validate-imports 42/371, unit 13/13 | ✅ livré (en attente vérif utilisateur) |
| 2026-10-09 | BUG-110 | Correction + refonte UI chat | `frontend/js/filechat.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/filechat.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-110 — refonte visuelle du chat** : (1) les boutons de pièce jointe deviennent des **icônes seules** (24 px, `aria-label`) rangées **sous l'image, alignées à droite** — plus de gros boutons à libellé ; (2) le clic sur une image ouvre le **même visualiseur que pour une image de vault** (zoom/pan/plein écran) mais dans un **onglet de l'app** (`TabManager.openChatImage()`, branche `chatImage` de `activate()`, `renderImageViewer()` accepte désormais une `url` directe : une pièce jointe n'est pas dans un vault) au lieu d'un onglet du navigateur ; (3) **point rouge pulsant sur l'icône chat** de la sidebar tant qu'il reste des non-lus (classe `chat-has-unread`, en plus du badge chiffré) ; (4) **notification OS** (`Notification`) quand l'app n'est pas au premier plan (`document.hidden`/hors focus), permission demandée au premier clic d'ouverture du chat ; (5) la suppression n'est plus mensongère : le `DELETE` est **différé de 10 s**, la ligne passe à « Ce message a été supprimé » avec « Annuler » — annuler réarme le message (l'ancien code restaurait une copie DOM d'un message déjà détruit côté serveur en affichant « Message restauré ») ; échec réseau → le message revient. Vérifié : `filechat.test.mjs` 25/25 (dont 6 tests #110 : icônes seules, clic image → `openChatImage`, aucun `DELETE` pendant la fenêtre d'annulation, notification OS), `validate-imports` 42 modules / 372 exports 0 erreur, `unit.test.mjs` 13/13 | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-09 | #193 | Fonctionnalité | `backend/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/style.css`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#193 — un post du chat s'affiche comme un document markdown** : le serveur rend le texte de chaque message avec le **pipeline des documents** (`backend/render.py::_render_markdown` : mistune — tableaux, listes de tâches, notes —, wikilinks, masquage des secrets #188, **sanitizer BUG-021**) et le renvoie dans un champ `html` (ajouté à `ChatMessageItem`, sinon `response_model` le filtrait). Le rendu est calculé **à la lecture** (`get_messages`) et à l'ajout (`_append`) — il n'est donc **jamais persisté** dans `data/chats/*.json` — et l'écho SSE part avec : les autres clients voient le rendu sans refresh. Côté front, `_fillBody()` injecte le HTML dans un `<div class="md-content">` puis appelle `safeHighlight()` sur chaque `pre code` (highlight.js + alias de langages, exactement comme le viewer ; mermaid non déclenché). Repli intact si `html` manque (écho optimiste → texte brut + URL cliquable) et un échec de rendu n'emporte jamais le message. CSS : `.file-chat-md` annule le `pre-wrap` hérité de la bulle (les retours à la ligne du HTML source produisaient des lignes vides) et compacte `.md-content` (`pre`/`table` en `overflow-x: auto`). Vérifié : `test_file_chat.py` 54→**61** (`TestMarkdownRendering` ×7 : html à l'ajout et à la lecture, **absent du JSON**, classe `language-xxx`, listes, `<script>` neutralisé, routes POST/GET, broadcast SSE), `filechat.test.mjs` 25→**27**, suite 1689 passed, ruff 0, mypy 0 (113 fichiers), validate-imports 42 modules / 372 exports | ✅ livré (en attente vérif utilisateur) |
---
## 📜 Historique des bugs résolus
+1 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.62.0 | **Dernière mise à jour :** 2026-10-09
> **Version :** 2.63.0 | **Dernière mise à jour :** 2026-10-09
> **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)**
+48
View File
@@ -211,6 +211,54 @@ carte reste (jamais de message perdu).
direct + notification + pas d'écho, textarea (tagName/rows, hauteur
pilotée, `Entrée` intercepté / `Maj+Entrée` non).
## #193 — Rendu markdown des posts (2026-10-09)
Un post du chat s'affiche désormais **comme un document** : titres, listes,
tableaux, citations, blocs de code colorés — au lieu du texte brut avec liens
cliquables.
### A. Le rendu vient du serveur (pas de second moteur markdown)
- `backend/file_chat.py::_decorate()` ajoute un champ `html` à chaque message
en réutilisant `backend/render.py::_render_markdown()` — **le pipeline exact
des documents** : mistune (tableaux, strikethrough, notes, listes de tâches),
résolution des wikilinks, normalisation des sauts de ligne, masquage des
secrets (#188) et **sanitizer BUG-021** en sortie. Aucun markdown côté client,
donc aucun XSS nouveau à traiter : la sanitisation est déjà éprouvée.
- `ChatMessageItem.html` (schéma de réponse) : sans ce champ, `response_model`
filtrait la clé.
- Le `html` est calculé **à la lecture** (`get_messages()`) et à l'ajout
(`_append()`), donc il n'est **jamais persisté** dans `data/chats/*.json` :
un message reste du texte, le rendu suit les évolutions du pipeline.
- L'écho SSE part avec le `html` : les autres clients voient le rendu sans
attendre un refresh.
- Un échec de rendu n'emporte pas le message : repli silencieux (`html = ""`),
le client affiche le texte brut.
### B. Frontend : `.md-content` + highlight.js
- `_fillBody()` injecte le `html` dans un `<div class="md-content">` (la
typographie des documents) puis appelle `safeHighlight()` sur chaque
`pre code` — le même helper (et les mêmes alias de langages) que le viewer.
Mermaid n'est pas déclenché dans le chat.
- Repli intact : un message sans `html` (écho optimiste d'un autre client)
repasse par l'ancien chemin texte + URL cliquable.
- CSS : `.file-chat-body.file-chat-md` annule le `white-space: pre-wrap` hérité
de la bulle (sinon les retours à la ligne du HTML source créaient des lignes
vides) et compacte `.md-content` (tailles, marges, `pre`/`table` en
`overflow-x: auto`) pour tenir dans une bulle de 92 % de large.
### Tests #193
- pytest `TestMarkdownRendering` (7) : `html` présent à l'ajout **et** à la
lecture, `html` **absent** du JSON stocké, classe `language-xxx` conservée
(ce sur quoi hljs se branche), tableaux/listes, `<script>` neutralisé,
routes `POST/GET` (chat de fichier + chat général) et écho SSE porteur du
`html`. Suite : 1689 passed.
- JSDOM `filechat.test.mjs` +2 (27) : post avec `html` → `.md-content`,
`<strong>`, `pre code.language-python` ; post sans `html` → texte brut et URL
cliquable conservés.
## Décisions
- **SSE plutôt qu'un second WebSocket** : le transport de #62 (EventSource
+28 -3
View File
@@ -12,7 +12,7 @@
import { api, AuthManager } from "./auth.js";
import { t } from "./i18n.js";
import { showToast, TabManager } from "./ui.js";
import { escapeHtml, safeCreateIcons } from "./utils.js";
import { escapeHtml, safeCreateIcons, safeHighlight } from "./utils.js";
import { icon } from "./viewer.js";
const PANEL_ID = "file-chat-panel";
@@ -147,7 +147,32 @@ async function _markRead(vault, path) {
}
// URL dans le texte → lien cliquable (partage d'URL #190), texte sinon.
function _fillBody(body, text) {
/**
* Remplit le corps d'un message (#193).
*
* Quand le serveur a fourni le rendu markdown (`html`, même pipeline que les
* documents : mistune + sanitizer BUG-021), on l'injecte dans un `.md-content`
* — titres, listes, tableaux, blocs de code — et on colore les blocs avec
* highlight.js (`safeHighlight`), exactement comme dans le viewer.
*
* Sans `html` (message optimiste d'un autre client, cache ancien), on retombe
* sur le texte brut avec liens cliquables, comme avant.
*/
function _fillBody(body, m) {
const text = typeof m === "string" ? m : (m && m.text) || "";
const html = m && typeof m === "object" ? m.html || "" : "";
if (html) {
body.classList.add("file-chat-md");
const div = document.createElement("div");
div.className = "md-content";
div.innerHTML = html; // rendu + assaini côté serveur
body.appendChild(div);
div.querySelectorAll("pre code").forEach((block) => {
if (block.querySelector(".secret-mask")) return;
safeHighlight(block);
});
return;
}
const parts = String(text || "").split(/(https?:\/\/[^\s<>"']+)/g);
parts.forEach((part) => {
if (!part) return;
@@ -341,7 +366,7 @@ export function _messageEl(m, currentUser, delUrl, readMap) {
}
const body = document.createElement("div");
body.className = "file-chat-body";
_fillBody(body, m.text);
_fillBody(body, m);
wrap.appendChild(meta);
if (m.text) wrap.appendChild(body);
const att = _attachEl(m.attachment);
+64
View File
@@ -13852,6 +13852,70 @@ body.reading-mode .file-actions {
white-space: pre-wrap;
word-break: break-word;
}
/* #193 — un post peut être rendu comme un document markdown (mistune côté
serveur + hljs côté client) : on annule le `pre-wrap` hérité (sinon les
retours à la ligne du HTML source deviennent des lignes vides) et on
compacte la typographie `.md-content` pour une bulle de chat. */
.file-chat-body.file-chat-md {
white-space: normal;
word-break: normal;
}
.file-chat-body .md-content > :first-child {
margin-top: 0;
}
.file-chat-body .md-content > :last-child {
margin-bottom: 0;
}
.file-chat-body .md-content h1,
.file-chat-body .md-content h2,
.file-chat-body .md-content h3,
.file-chat-body .md-content h4,
.file-chat-body .md-content h5,
.file-chat-body .md-content h6 {
font-size: 1rem;
margin: 0.7em 0 0.35em;
border-bottom: none;
padding-bottom: 0;
}
.file-chat-body .md-content p {
margin: 0.4em 0;
}
.file-chat-body .md-content ul,
.file-chat-body .md-content ol {
padding-left: 1.3em;
margin: 0.4em 0;
}
.file-chat-body .md-content pre {
max-width: 100%;
overflow-x: auto;
margin: 0.5em 0;
}
.file-chat-body .md-content pre code {
font-size: 0.78rem;
line-height: 1.45;
}
.file-chat-body .md-content :not(pre) > code {
font-size: 0.82rem;
padding: 1px 4px;
border-radius: 4px;
background: var(--bg-hover, rgba(128, 128, 128, 0.15));
}
.file-chat-body .md-content table {
display: block;
max-width: 100%;
overflow-x: auto;
border-collapse: collapse;
font-size: 0.82rem;
}
.file-chat-body .md-content img {
max-width: 100%;
border-radius: 6px;
}
.file-chat-body .md-content .wikilink,
.file-chat-body .md-content .wikilink-anchor {
color: var(--accent, #58a6ff);
cursor: pointer;
}
.file-chat-form {
display: flex;
gap: 8px;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.62.0",
"version": "2.63.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": {
+24
View File
@@ -433,6 +433,30 @@ test("#110 — notification OS quand l'app est en arrière-plan", () => {
}
});
// --- #193 : un post est rendu comme un document (markdown + code coloré) -----
test("#193 — post avec html rendu : markdown + bloc de code", () => {
const list = document.createElement("div");
renderChatMessages(list, [{
id: "md1", user: "alice", text: "**gras**\n\n```python\nprint(1)\n```", ts: 400,
html: '<p><strong>gras</strong></p><pre><code class="language-python">print(1)</code></pre>',
}], "bruno");
const body = list.querySelector("[data-id='md1'] .file-chat-body");
assert.ok(body.classList.contains("file-chat-md"), "classe md posée sur le corps");
assert.ok(body.querySelector(".md-content"), "conteneur md-content");
assert.ok(body.querySelector(".md-content strong"), "markdown rendu (strong)");
assert.ok(body.querySelector("pre code.language-python"), "bloc de code conservé");
});
test("#193 — sans html, repli texte brut + URL cliquable", () => {
const list = document.createElement("div");
renderChatMessages(list, [{ id: "md2", user: "alice", text: "voir https://ex.com/a", ts: 401 }], "bruno");
const body = list.querySelector("[data-id='md2'] .file-chat-body");
assert.ok(!body.classList.contains("file-chat-md"), "pas de rendu markdown");
assert.ok(body.querySelector("a.file-chat-link"), "URL toujours cliquable");
assert.strictEqual(body.textContent, "voir https://ex.com/a", "texte intact");
});
// Sequential execution: each case depends on panel state left by the previous.
for (const [name, fn] of cases) {
try {
+71
View File
@@ -1,6 +1,7 @@
# tests/test_file_chat.py — Chat par fichier (#169)
"""Store + REST routes for the per-file chat history."""
import json
import time
import pytest
@@ -479,3 +480,73 @@ class TestReadReceipts:
assert [e for e, _ in events] == ["chat_read"]
assert events[0][1]["user"] == "anonymous"
assert events[0][1]["vault"] == "__global__"
# ═══════════════════════════════════════════════════════════════════
# #193 — un post est rendu comme un document markdown
# ═══════════════════════════════════════════════════════════════════
class TestMarkdownRendering:
"""Le post porte son HTML (mistune + sanitizer), comme une prévisualisation
de document : le client n'a plus à réimplémenter le markdown."""
def test_message_carries_rendered_html(self, tmp_path, monkeypatch):
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
msg = _store.add_message("V", "a.md", "u", "**gras** et `code`")
assert "<strong>gras</strong>" in msg["html"]
assert "<code>code</code>" in msg["html"]
got = _store.get_messages("V", "a.md")
assert "<strong>gras</strong>" in got[0]["html"]
def test_html_is_not_persisted(self, tmp_path, monkeypatch):
"""`html` est recalculé à chaque lecture, jamais écrit dans le store."""
chat_dir = tmp_path / "chats"
monkeypatch.setattr(_store, "CHAT_DIR", chat_dir)
_store.add_message("V", "a.md", "u", "**gras**")
raw = json.loads(next(chat_dir.glob("*.json")).read_text(encoding="utf-8"))
assert "html" not in raw["messages"][0]
def test_fenced_code_block_keeps_its_language(self, tmp_path, monkeypatch):
"""La classe `language-xxx` est ce sur quoi hljs se branche (comme dans
le viewer)."""
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
msg = _store.add_message("V", "a.md", "u", "```python\nprint(1)\n```")
assert 'class="language-python"' in msg["html"]
def test_tables_and_lists_are_rendered(self, tmp_path, monkeypatch):
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
msg = _store.add_message("V", "a.md", "u", "- un\n- deux")
assert "<ul>" in msg["html"] and "<li>un</li>" in msg["html"]
def test_raw_html_is_sanitized(self, tmp_path, monkeypatch):
"""Pas de XSS par le chat : le sanitizer des documents s'applique."""
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
msg = _store.add_message("V", "a.md", "u", "<script>alert(1)</script>ok")
assert "<script" not in msg["html"]
def test_routes_return_html(self, client, test_vault_dir):
r = client.post(
"/api/file/TestVault/chat",
json={"path": "note1.md", "text": "## Titre\n\n- item"},
)
assert r.status_code == 200
html = r.json()["message"]["html"]
assert "<h2" in html and "<li>item</li>" in html
r2 = client.get("/api/file/TestVault/chat", params={"path": "note1.md"})
assert "<h2" in r2.json()["messages"][0]["html"]
def test_sse_broadcast_carries_html(self, client, test_vault_dir, monkeypatch):
"""L'écho SSE part avec le html : les autres clients n'attendent pas un
refresh pour voir le rendu."""
from backend.routers import file_chat as _router
events = []
class _Spy:
async def broadcast(self, event, data):
events.append((event, data))
monkeypatch.setattr(_router, "sse_manager", _Spy())
client.post("/api/chat", json={"text": "**fort**"})
assert events and events[0][0] == "chat_message"
assert "<strong>fort</strong>" in events[0][1]["message"]["html"]