diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a96661..729fb91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.10.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.10.1**. --- @@ -14,6 +14,26 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.10.1] — 2026-09-17 + +### Corrigé + +- **BUG-059 — Assistant IA : un clic dans la conversation faisait sauter le texte au bas de la + fenêtre** : dans une conversation ouverte (post ancré en haut), tout clic — lien de fichier, + étapes, sélection de texte — dépinait l'ancre via le gestionnaire `mousedown` et retirait le + padding d'ancre, bornant le scroll à la nouvelle hauteur max (saut au bas). Le dépintage est + désormais réservé aux vrais gestes de scroll : molette, tactile et poignée de scroll + uniquement (`isScrollbarPress`). Fichiers : `frontend/js/bookslm.js`, + `tests/frontend/ai.test.mjs`. +- **#92 — `create_pdf` de l'Assistant : tableaux mal formatés** : l'outil `create_pdf` + (génération PDF via l'assistant) utilisait un rendu reportlab simplifié sans support des + tableaux. Il passe désormais par le même pipeline que le bouton « Télécharger PDF » de la + page document (mistune + plugin `table` + WeasyPrint), avec repli automatique sur le rendu + simple si WeasyPrint n'est pas disponible (GTK absent). Fichiers : + `backend/tools/documents.py`, `tests/test_document_tools.py`. + +--- + ## [2.10.0] — 2026-09-17 ### Ajouté diff --git a/README.fr.md b/README.fr.md index 12211f5..8795ade 100644 --- a/README.fr.md +++ b/README.fr.md @@ -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/) @@ -922,8 +922,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.10.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.10.1). --- -*Projet : ObsiGate | Version : 2.10.0 | Dernière mise à jour : Juin 2026* +*Projet : ObsiGate | Version : 2.10.1 | Dernière mise à jour : Juin 2026* diff --git a/README.md b/README.md index 65546a8..19e0cb9 100644 --- a/README.md +++ b/README.md @@ -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/) @@ -1091,8 +1091,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.10.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.10.1). --- -*Project: ObsiGate | Version: 2.10.0 | Last updated: May 2026* +*Project: ObsiGate | Version: 2.10.1 | Last updated: May 2026* diff --git a/VERSION b/VERSION index 10c2c0c..8bbb6e4 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.10.0 +2.10.1 diff --git a/backend/tools/documents.py b/backend/tools/documents.py index ac48d21..8d50f59 100644 --- a/backend/tools/documents.py +++ b/backend/tools/documents.py @@ -153,24 +153,39 @@ def _markdown_to_flowables(content: str) -> list[tuple[str, str]]: return blocks -@tool( - name="create_pdf", - description=( - "Create a .pdf document in a vault from markdown-ish content " - "(# headings + paragraphs). Use for printable deliverables." - ), - input_model=PdfInput, - risk=ToolRisk.WRITE, - requires_vault=True, -) -def create_pdf(ctx: ToolContext, params: PdfInput) -> dict[str, Any]: - """Render the content with reportlab and save the PDF into the vault.""" +def _render_markdown_pdf(content: str, title: str) -> bytes | None: + """Render markdown → HTML → PDF through the document-page pipeline. + + Uses the same stack as the « Download PDF » button of the document viewer + (mistune with the table plugin + WeasyPrint print CSS), so tables, code + blocks and lists are laid out correctly. Returns ``None`` when WeasyPrint + is not importable (missing GTK on some hosts) so the caller can fall back + to the simplified reportlab renderer. + """ + try: + import mistune + + from backend.pdf_export import build_pdf_html, generate_pdf + + renderer = mistune.create_markdown( + escape=False, + plugins=["table", "strikethrough", "footnotes", "task_lists"], + ) + html = renderer(content) + return generate_pdf(build_pdf_html(html, title), title) + except Exception as e: + # WeasyPrint loads GTK lazily: a missing native library can surface at + # import OR render time. Fall back to the simple renderer either way. + logger.warning("WeasyPrint pipeline unavailable for create_pdf: %s", e) + return None + + +def _render_reportlab_pdf(content: str, title: str) -> bytes: + """Fallback renderer (no WeasyPrint): headings + paragraphs, no tables.""" from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import getSampleStyleSheet from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer - path = _check_extension(params.path, ".pdf") - text = params.content[:MAX_PDF_CHARS] styles = getSampleStyleSheet() style_map = { "P": styles["BodyText"], @@ -179,10 +194,36 @@ def create_pdf(ctx: ToolContext, params: PdfInput) -> dict[str, Any]: "H3": styles["Heading3"], } buffer = io.BytesIO() - doc = SimpleDocTemplate(buffer, pagesize=A4, title=params.title[:200]) - story: list[Any] = [Paragraph(saxutils.escape(params.title[:300]), styles["Title"])] - for style, line in _markdown_to_flowables(text): + doc = SimpleDocTemplate(buffer, pagesize=A4, title=title[:200]) + story: list[Any] = [Paragraph(saxutils.escape(title[:300]), styles["Title"])] + for style, line in _markdown_to_flowables(content): story.append(Spacer(1, 4)) story.append(Paragraph(saxutils.escape(line), style_map[style])) doc.build(story) - return _save(params.vault, path, buffer.getvalue(), params.overwrite) + return buffer.getvalue() + + +@tool( + name="create_pdf", + description=( + "Create a .pdf document in a vault from markdown content (headings, " + "paragraphs, tables, code blocks, lists). Use for printable " + "deliverables; tables are laid out like the document-page PDF export." + ), + input_model=PdfInput, + risk=ToolRisk.WRITE, + requires_vault=True, +) +def create_pdf(ctx: ToolContext, params: PdfInput) -> dict[str, Any]: + """Render the content and save the PDF into the vault. + + Primary path: mistune (tables) + WeasyPrint — identical to the viewer's + « Download PDF » export. Fallback (WeasyPrint unavailable): simplified + reportlab layout without tables. + """ + path = _check_extension(params.path, ".pdf") + content = params.content[:MAX_PDF_CHARS] + pdf_bytes = _render_markdown_pdf(content, params.title[:300]) + if pdf_bytes is None: + pdf_bytes = _render_reportlab_pdf(content, params.title[:300]) + return _save(params.vault, path, pdf_bytes, params.overwrite) diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index ffd0a86..4a7d4e3 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.10.0" +version = "2.10.1" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 77ff7a3..ac83c21 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.10.0" +version = "2.10.1" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 8369c90..4225019 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.10.0", + "version": "2.10.1", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index b2a1412..773ad5d 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -229,6 +229,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-09-17 | BUG-056 | Correction | `frontend/editor-poc.html`, `frontend/js/sync.js`, `tests/frontend/forge-completion.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-056** : en plein écran Forge, l'Assistant IA s'ouvrait en arrière-plan. La sortie du plein écran est désormais faite **côté iframe** (`openAssistant` → `document.exitFullscreen()` puis `postMessage` à la résolution) **et côté parent** (`sync.js` sur `forge-open-ai` → `document.exitFullscreen()` puis `openForCurrentContext()`), car le plein écran peut appartenir au document parent (l'iframe voit alors `fullscreenElement` nul et sa sortie échoue — c'était le cas non couvert par le premier correctif). Vérifié : `forge-completion.test.mjs` 32/32 (+1), `editor-inline.test.mjs` 42/42 (+1), 14 suites frontend vertes, validate-imports 38 modules. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-17 | BUG-057, #102 | Correction + feature | `frontend/js/bookslm.js`, `frontend/editor-poc.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `docs/archive/COMPLETED_v1-v2.md`, `docs/ROADMAP.md`, `CHANGELOG.md` | **BUG-057** : le bouton « Ajouter » de l'assistant ne ciblait que `state.editorView` (CodeMirror) ; en Forge il affichait « Aucun document ouvert dans l'éditeur ». `_insertIntoEditor()` gère désormais les trois surfaces : CodeMirror, l'iframe Forge (`postMessage({ type: 'parent-insert', text })` → `insertAtCursor` dans `editor-poc.html`) et le textarea de repli. **#102** : chaque bloc de code d'une réponse reçoit un bouton « Ajouter la section » (`.bookslm-code-insert`, révélé au survol) qui insère le contenu du bloc sans les délimiteurs ` ``` `. Vérifié : `ai.test.mjs` 91/91 (+3), `editor-inline.test.mjs` 43/43 (+1), `forge-completion.test.mjs` 32/32, unit 9/9, validate-imports 38 modules, pytest 1101 passed / 6 skipped, ruff 0, mypy 0. | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-17 | BUG-058 | Correction | `frontend/style.css`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-058** : la barre de numérotation de ligne de l'éditeur « Editer » ne suivait pas le thème — CodeMirror peint `.cm-gutters` avec des valeurs claires codées en dur (`#f5f5f5`, bordure `#ddd`), visibles en thème sombre. Correctif : le gutter dérive des variables CSS ObsiGate (`background: color-mix(in srgb, var(--text-primary) 5%, transparent)`, `color: var(--text-secondary)`, `border-right: 1px solid var(--border)`, ligne active `color-mix(… 10% …)` / `--text-primary`), donc il suit les 15 thèmes et les 4 modes. Vérifié : `editor-inline.test.mjs` 44/44 (+1), unit 9/9, validate-imports 38 modules, pytest 1101 passed / 6 skipped, ruff 0, mypy 0, et Playwright sur l'instance de test (route `style.css` remplacée par le fichier local) — sombre `color(srgb 0.90 0.93 0.95 / 0.05)` + bordure `#21262d`, clair `color(srgb 0.12 0.14 0.16 / 0.05)` + bordure `#d0d7de`, plus de `rgb(245,245,245)`. | 🟢 corrigé (en attente vérif utilisateur) | +| 2026-09-17 | BUG-059 | Correction | `frontend/js/bookslm.js`, `tests/frontend/ai.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-059** : dans une conversation ouverte (post ancré en haut), **tout clic** dans la fenêtre de messages — lien de fichier, étapes, sélection de texte — faisait sauter toute la conversation au bas de la fenêtre. Cause : le gestionnaire `mousedown` de dépintage (prévu pour la molette/tactile/poignée de scroll) se déclenchait aussi sur un simple clic, et le retrait du padding d'ancre (`paddingBottom`) bornait le `scrollTop` à la nouvelle hauteur max → saut au bas. Correctif : helper pur `isScrollbarPress(target, clientX, container)` — un appui ne dépine que s'il vise la **poignée de scroll** (cible = conteneur + zone de gouttière droite) ; molette et tactile conservent leur comportement. Vérifié : `ai.test.mjs` 92/92 (+1), unit 9/9, validate-imports 38 modules, pytest / ruff / mypy inchangés côté backend. | 🟢 corrigé (en attente vérif utilisateur) | --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f562fe5..87b9e65 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.10.0 | **Dernière mise à jour :** 2026-09-17 +> **Version :** 2.10.1 | **Dernière mise à jour :** 2026-09-17 > **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)** diff --git a/frontend/js/bookslm.js b/frontend/js/bookslm.js index 5b3bc3d..cf55e9f 100644 --- a/frontend/js/bookslm.js +++ b/frontend/js/bookslm.js @@ -43,6 +43,25 @@ const PANEL_MIN_WIDTH = 320; const PANEL_MAX_WIDTH = 1000; const PANEL_WIDTH_KEY = 'obsigate-bookslm-width'; +/** + * BUG-059 — Is this pointer press a scrollbar drag (and only that)? + * + * Only genuine scroll gestures may release the top-pinning of the latest + * question (wheel / touchmove / scrollbar drag). A scrollbar drag reports the + * scrollable container itself as the event target and lands inside the + * vertical scrollbar gutter. A plain click anywhere in the content — a link, + * a file path, the steps toggle, text selection — must NOT unpin: clearing + * the anchor padding would clamp the scroll position and jump the whole + * thread to the bottom of the conversation window. + */ +export function isScrollbarPress(target, clientX, container) { + if (!container || target !== container) return false; + const rect = container.getBoundingClientRect(); + if (!rect || !Number.isFinite(rect.right)) return false; + const GUTTER = 24; // conservative vertical-scrollbar width estimate + return clientX >= rect.right - GUTTER; +} + /** * Accent- and case-insensitive normalization used to match `/` commands and * `@` mentions: skill ids/labels and vault paths may contain accented @@ -985,7 +1004,12 @@ class BooksLM { }; messagesEl.addEventListener('wheel', unpin, { passive: true }); messagesEl.addEventListener('touchmove', unpin, { passive: true }); - messagesEl.addEventListener('mousedown', unpin, { passive: true }); + // BUG-059: a scrollbar drag only — a plain click on the content must + // not unpin (it would clear the anchor padding and jump the thread to + // the bottom of the window). + messagesEl.addEventListener('mousedown', (e) => { + if (isScrollbarPress(e.target, e.clientX, messagesEl)) unpin(); + }, { passive: true }); } // Close the session / command menus when clicking elsewhere in the panel. diff --git a/package.json b/package.json index 1487075..478d5a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.10.0", + "version": "2.10.1", "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": { diff --git a/tests/frontend/ai.test.mjs b/tests/frontend/ai.test.mjs index b6ac542..14059e5 100644 --- a/tests/frontend/ai.test.mjs +++ b/tests/frontend/ai.test.mjs @@ -302,6 +302,25 @@ async function main() { } }); + await test("isScrollbarPress: content clicks never unpin, scrollbar drags do (BUG-059)", () => { + const { isScrollbarPress } = bookslmMod; + const container = document.createElement("div"); + container.getBoundingClientRect = () => ({ + left: 0, right: 800, top: 0, bottom: 600, width: 800, height: 600, + }); + const link = document.createElement("a"); + // Click on a file link / steps toggle → must NOT unpin (the thread + // would otherwise jump to the bottom when the padding is cleared). + assert.equal(isScrollbarPress(link, 790, container), false); + // Click on blank content area → must NOT unpin either. + assert.equal(isScrollbarPress(container, 300, container), false); + // Dragging the vertical scrollbar: target is the container itself and + // the press lands in the right-edge gutter → unpin allowed. + assert.equal(isScrollbarPress(container, 792, container), true); + // Defensive: no container / no geometry → never unpin. + assert.equal(isScrollbarPress(link, 790, null), false); + }); + await test("the placeholder render keeps the anchor (no scroll reset before first token)", async () => { // Regression: the assistant placeholder used to be rendered while // `_isLoading` was still false, so it took the "restore previous diff --git a/tests/test_document_tools.py b/tests/test_document_tools.py index 7ae92f6..b8a12e2 100644 --- a/tests/test_document_tools.py +++ b/tests/test_document_tools.py @@ -150,6 +150,43 @@ class TestCreatePdf: }, confirm=True) assert (vault / "big.pdf").exists() + def test_markdown_tables_go_through_the_export_pipeline(self, vault, monkeypatch): + # The document-page pipeline (mistune tables + WeasyPrint) must be + # used when available: capture the HTML handed to the PDF generator. + import sys + import types + + captured = {} + fake = types.ModuleType("backend.pdf_export") + + def fake_build(html, title, **kw): + captured["html"] = html + captured["title"] = title + return "" + html + "" + + fake.build_pdf_html = fake_build + fake.generate_pdf = lambda html, title=None, **kw: b"%PDF-fake" + monkeypatch.setitem(sys.modules, "backend.pdf_export", fake) + out = call_tool("create_pdf", _ctx(), { + "vault": "Vault", "path": "table.pdf", "title": "Rapport", + "content": "# T\n\n| a | b |\n|---|---|\n| 1 | 2 |", + }, confirm=True) + assert out.ok + assert "