diff --git a/CHANGELOG.md b/CHANGELOG.md index 07e2206..478d78f 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.57.1**. +> [Unreleased](#unreleased). La dernière version livrée est **2.58.0**. --- @@ -14,6 +14,29 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.58.0] — 2026-10-08 + +### Ajouté + +- **#190 - Chat : suivi du document, chat général en onglet sidebar** + - **Suivi du document** : le panneau du chat affiche le document ciblé + (titre + chemin au survol) et **suit automatiquement** le document + rendu — changer de fichier re-cible le panneau ouvert. + - **Onglet « Chat »** (dernier, après Historique IA) dans la sidebar : + conversation générale non liée à un fichier, **indicateur de messages + non lus** (pastille rouge sur l'onglet), **pièces jointes image/vidéo** + (upload avec allow-list d'extensions, 25 MB max, nom UUID), **URL + cliquables** dans les messages, date/heure d'envoi. + - **Ordre des onglets** : la barre de filtre passe **sous** les onglets + et sert de **recherche dans le chat** quand l'onglet Chat est actif + (texte + auteur). + - Backend : `GET/POST /api/chat`, `POST /api/chat/upload`, + `GET /api/chat/attachment/{name}` (même store #169 via les sentinelles + `__global__/general`), broadcast SSE `chat_message` réutilisé. + - Tests : pytest +12 (27 au total), JSDOM `filechat.test.mjs` +5 (11). + +--- + ## [2.57.1] — 2026-10-08 --- diff --git a/README.fr.md b/README.fr.md index bfc4984..3de0626 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. -[![Version](https://img.shields.io/badge/Version-2.57.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.58.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.57.1). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.58.0). --- -*Projet : ObsiGate | Version : 2.57.1 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.58.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 92a3803..7e36f2c 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. -[![Version](https://img.shields.io/badge/Version-2.57.1-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.58.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.57.1). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.58.0). --- -*Project: ObsiGate | Version: 2.57.1 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.58.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 6c63e49..1401b92 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.57.1 +2.58.0 diff --git a/backend/file_chat.py b/backend/file_chat.py index 3e81334..63be38e 100644 --- a/backend/file_chat.py +++ b/backend/file_chat.py @@ -1,10 +1,15 @@ -# backend/file_chat.py — historique de discussion par fichier (#169) -"""Per-file chat history persisted under ``data/chats/``. +# backend/file_chat.py — historique de discussion (#169, #190) +"""Chat history persisted under ``data/chats/``. One JSON document per ``(vault, path)`` pair, keyed by a SHA-256 of both so the filename never carries user-controlled path separators. Writes are atomic (tmp + move) and the message list is capped at :data:`MAX_MESSAGES` to bound growth. + +#190 adds the **general chat**: the same store addressed with the reserved +sentinels (:data:`GLOBAL_VAULT` / :data:`GLOBAL_PATH`), so no second +implementation. Messages may carry an ``attachment`` (image/video/url) +uploaded under ``data/chat_uploads/``. """ from __future__ import annotations @@ -24,6 +29,18 @@ CHAT_DIR = Path("data/chats") MAX_MESSAGES = 500 # retention ceiling per file (oldest dropped first) MAX_TEXT = 4000 # characters per message +# #190 — general (non file-bound) conversation, stored like any other one. +GLOBAL_VAULT = "__global__" +GLOBAL_PATH = "general" + +# #190 — attachments (image/video) live outside the vaults. +UPLOAD_DIR = Path("data/chat_uploads") +MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB per attachment +ALLOWED_ATTACH_EXT = { + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", + ".mp4", ".webm", ".ogg", ".mov", ".m4v", +} + def _chat_file(vault: str, path: str) -> Path: """Return the chat file for *(vault, path)* (hashed, traversal-proof).""" @@ -62,19 +79,38 @@ def get_messages(vault: str, path: str) -> list[dict[str, Any]]: return list(_read(vault, path).get("messages", [])) -def add_message(vault: str, path: str, user: str, text: str) -> dict[str, Any]: +def add_message( + vault: str, + path: str, + user: str, + text: str, + attachment: dict[str, Any] | None = None, +) -> dict[str, Any]: """Append a message and persist it. Returns the stored message. The list is capped at :data:`MAX_MESSAGES` (oldest dropped first). + *attachment* (#190) is ``{name, url, mime, kind}`` for image/video/url. """ text = (text or "").strip()[:MAX_TEXT] - msg = { + msg: dict[str, Any] = { "id": uuid.uuid4().hex[:12], "user": user or "anonyme", "text": text, "ts": time.time(), } + if attachment: + msg["attachment"] = attachment doc = _read(vault, path) + return _append(vault, path, doc, msg) + + +def _append( + vault: str, + path: str, + doc: dict[str, Any], + msg: dict[str, Any], +) -> dict[str, Any]: + """Cap, persist and return *msg* (shared by file and global chats).""" messages = list(doc.get("messages", [])) messages.append(msg) if len(messages) > MAX_MESSAGES: @@ -82,3 +118,74 @@ def add_message(vault: str, path: str, user: str, text: str) -> dict[str, Any]: doc["messages"] = messages _write(_chat_file(vault, path), doc) return msg + + +# --- #190 : chat général (conversation centrale, hors fichier) ------------- + +def get_global_messages() -> list[dict[str, Any]]: + """Return the general-chat history (chronological).""" + return get_messages(GLOBAL_VAULT, GLOBAL_PATH) + + +def add_global_message( + user: str, + text: str, + attachment: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Append a message to the general chat. Returns the stored message.""" + return add_message(GLOBAL_VAULT, GLOBAL_PATH, user, text, attachment) + + +def save_attachment(filename: str, data: bytes) -> dict[str, Any]: + """Persist an uploaded attachment under :data:`UPLOAD_DIR`. + + Returns ``{name, url, mime, kind}``. The stored name is a fresh UUID + (never the client name), the extension must be in + :data:`ALLOWED_ATTACH_EXT` and the size is capped at + :data:`MAX_UPLOAD_BYTES`. + + Raises: + ValueError: extension refused, empty file or size exceeded. + """ + ext = Path(filename or "").suffix.lower() + if ext not in ALLOWED_ATTACH_EXT: + raise ValueError(f"extension refusée : {ext or '(aucune)'}") + if not data: + raise ValueError("fichier vide") + if len(data) > MAX_UPLOAD_BYTES: + raise ValueError(f"fichier trop lourd (max {MAX_UPLOAD_BYTES // (1024 * 1024)} MB)") + UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + name = f"{uuid.uuid4().hex}{ext}" + (UPLOAD_DIR / name).write_bytes(data) + kind = "video" if ext in {".mp4", ".webm", ".ogg", ".mov", ".m4v"} else "image" + return { + "name": name, + "url": f"/api/chat/attachment/{name}", + "mime": _MIME_BY_EXT.get(ext, "application/octet-stream"), + "kind": kind, + } + + +def attachment_path(name: str) -> Path | None: + """Resolve an attachment by its stored name (UUID+ext only, no traversal).""" + p = Path(name) + if p.name != name or p.suffix.lower() not in ALLOWED_ATTACH_EXT: + return None + file = UPLOAD_DIR / p.name + return file if file.exists() else None + + +# Extension → MIME (literals only; ``mimetypes`` guesses poorly for a few). +_MIME_BY_EXT = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".svg": "image/svg+xml", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".ogg": "video/ogg", + ".mov": "video/quicktime", + ".m4v": "video/x-m4v", +} diff --git a/backend/routers/file_chat.py b/backend/routers/file_chat.py index 8734d9f..6039086 100644 --- a/backend/routers/file_chat.py +++ b/backend/routers/file_chat.py @@ -1,11 +1,15 @@ -# backend/routers/file_chat.py — chat par fichier (#169) -"""Per-file chat endpoints: history read + message post with SSE fan-out. +# backend/routers/file_chat.py — chat (#169, #190) +"""Chat endpoints: history read + message post with SSE fan-out. -GET/POST ``/api/file/{vault_name}/chat`` — auth + vault access + path -traversal check (``resolve_safe_path`` raises ``ServiceError`` mapped by -the app-level handler). A posted message is broadcast on the existing -SSE channel (``chat_message``) so every connected client updates live -without a second WebSocket. +- ``GET/POST /api/file/{vault_name}/chat`` — per-file chat: auth + vault + access + path traversal check (``resolve_safe_path`` raises + ``ServiceError`` mapped by the app-level handler). +- ``GET/POST /api/chat`` — the **general chat** (#190), not bound to a file. +- ``POST /api/chat/upload`` / ``GET /api/chat/attachment/{name}`` (#190): + image/video attachments (extension allow-list, size cap, UUID name). + +Every post is broadcast on the existing SSE channel (``chat_message``) so +all connected clients update live without a second WebSocket. """ from __future__ import annotations @@ -13,7 +17,8 @@ from __future__ import annotations from pathlib import Path from typing import Any -from fastapi import APIRouter, Body, Depends, HTTPException +from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile +from fastapi.responses import FileResponse from backend import file_chat as _store from backend.auth.middleware import check_vault_access, require_auth @@ -63,3 +68,71 @@ async def api_file_chat_post( msg = _store.add_message(vault_name, path, current_user.get("username", ""), text) await sse_manager.broadcast("chat_message", {"vault": vault_name, "path": path, "message": msg}) return {"message": msg, "status": "ok"} + + +# --- #190 : chat général ---------------------------------------------------- + +def _attachment(body: dict[str, Any]) -> dict[str, Any] | None: + """Validate the optional ``attachment`` object sent by the client.""" + raw = body.get("attachment") + if not raw or not isinstance(raw, dict): + return None + name = str(raw.get("name") or "") + # Only an already-uploaded file (or an http(s) URL) may travel along. + if not _store.attachment_path(name) and not str(raw.get("url", "")).startswith(("http://", "https://", "/api/")): + raise HTTPException(400, "attachment inconnu") + return { + "name": name, + "url": str(raw.get("url") or ""), + "mime": str(raw.get("mime") or ""), + "kind": str(raw.get("kind") or "file"), + } + + +@router.get("/api/chat", response_model=ChatHistoryResponse) +async def api_chat_history(current_user: dict[str, Any] = Depends(require_auth)): + """Return the general chat history (#190, chronological).""" + return {"messages": _store.get_global_messages()} + + +@router.post("/api/chat", response_model=ChatMessageResponse) +async def api_chat_post( + body: dict[str, Any] = Body(...), + current_user: dict[str, Any] = Depends(require_auth), +): + """Post to the general chat and broadcast it on SSE (``chat_message``).""" + text = str(body.get("text") or "") + if not text.strip(): + raise HTTPException(400, "text is required") + msg = _store.add_global_message( + current_user.get("username", ""), text, _attachment(body) + ) + await sse_manager.broadcast( + "chat_message", + {"vault": _store.GLOBAL_VAULT, "path": _store.GLOBAL_PATH, "message": msg}, + ) + return {"message": msg, "status": "ok"} + + +@router.post("/api/chat/upload") +async def api_chat_upload( + file: UploadFile = File(...), + current_user: dict[str, Any] = Depends(require_auth), +): + """Store an image/video attachment (#190). Returns ``{attachment}``.""" + data = await file.read() + try: + info = _store.save_attachment(file.filename or "", data) + except ValueError as e: + raise HTTPException(400, str(e)) from e + return {"attachment": info} + + +@router.get("/api/chat/attachment/{name}") +async def api_chat_attachment(name: str, current_user: dict[str, Any] = Depends(require_auth)): + """Serve an uploaded attachment (name validated against the allow-list).""" + path = _store.attachment_path(name) + if not path: + raise HTTPException(404, "Attachment not found") + info = _store._MIME_BY_EXT.get(path.suffix.lower(), "application/octet-stream") + return FileResponse(str(path), media_type=info) diff --git a/backend/schemas.py b/backend/schemas.py index d24caf2..942561d 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -152,12 +152,16 @@ class BacklinksResponse(BaseModel): class ChatMessageItem(BaseModel): - """One chat message (``GET/POST /api/file/{vault}/chat``).""" + """One chat message (``GET/POST /api/file/{vault}/chat`` + ``/api/chat``).""" id: str = Field(description="Message id") user: str = Field(description="Author username") text: str = Field(description="Message body") ts: float = Field(description="Unix timestamp") + attachment: dict[str, Any] | None = Field( + default=None, + description="Optional image/video/url attachment {name, url, mime, kind}", + ) class ChatHistoryResponse(BaseModel): diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index fb79fb5..e780a0b 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.57.1" +version = "2.58.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 6c5aac5..b25a35f 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.57.1" +version = "2.58.0" 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 0058d59..4b916dd 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.57.1", + "version": "2.58.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/GUIDES/PRISE_EN_MAIN.md b/docs/GUIDES/PRISE_EN_MAIN.md index 00ee2e6..e54695f 100644 --- a/docs/GUIDES/PRISE_EN_MAIN.md +++ b/docs/GUIDES/PRISE_EN_MAIN.md @@ -184,6 +184,16 @@ des **onglets** (avec possibilité de vue multi-panneaux / split view). latéral avec l'historique de la discussion et la saisie d'un message. - Les nouveaux messages s'affichent en direct ; si le panneau est fermé, une notification annonce l'envoi. L'historique est conservé par fichier. +- Le panneau **suit le document** : changer de fichier re-cible la discussion. + +### Chat général + +- Onglet **💬 Chat** (dernier de la sidebar, sous les onglets) : conversation + centrale, valable pour toute l'application, avec **pastille rouge** quand + de nouveaux messages arrivent. +- **📎** attache une image ou une vidéo (25 MB max) ; les URL sont cliquables. +- La **barre de filtre** sert à chercher dans le chat (texte ou auteur) quand + cet onglet est actif. --- diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index acf517f..59f4aa4 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -329,6 +329,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | 2026-10-02 | BUG-107 | Correction | `desktop/tauri.conf.json`, `desktop/src/main.rs` | **BUG-107 — drag & drop de fichiers inutilisable sur desktop** : Tauri/wry installe son propre `IDropTarget` par-dessus de celui du WebView2 (source wry : « Enumerate child windows to find the WebView2 window and override! ») et aucun événement natif n'était écouté → les glisser-déposer depuis l'Explorateur n'atteignaient jamais les gestionnaires HTML5 de `dragdrop.js` (#89 marchait donc uniquement sur le web). Correctif : la doc Tauri est explicite — `disable_drag_drop_handler()` « is required to use HTML5 drag and drop APIs on the frontend on Windows » ; la fenêtre est créée en code (`create: false` dans `tauri.conf.json`, boucle Tauri qui saute les fenêtres non auto-créées) via `WebviewWindowBuilder::from_config(...).disable_drag_drop_handler()`. Vérifié : `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires), lancement de l'app OK (fenêtre unique, boot log normal) ; glisser-déposer réel à valider par l'utilisateur. | 🟢 corrigé | | 2026-10-07 | BUG-108 | Correction | `frontend/js/xlsx/context-menu.js` | **BUG-108 — « Coller » du menu contextuel du tableur muet** : `closeContextMenu()` faisait `menu.remove()` **avant** `_menu = null` ; retirer le menu portant le focus émet `focusout` synchrone qui rappelle `closeContextMenu()`, le second `remove()` sur un nœud démonté lève `NotFoundError` et avorte le handler de l'action avant son exécution (introduit par la fermeture au focus #179). Reproduit par l'E2E `xlsx-viewer.spec.js` « coller une plage » (CI #850→#854). Correctif : déférencer avant de retirer. | 🟢 corrigé | | 2026-10-08 | #189 | Fonctionnalité | `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `docs/ROADMAP.md`, `docs/features/xlsx-mobile-layout-189.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `README.md`, `README.fr.md`, `CHANGELOG.md` | **#189 — mise en page mobile du tableur** : sur ≤ 768 px les barres menus/ruban/Recherche sont repliées par défaut (la barre des feuilles + barre formule fx restent visibles, la grille récupère la hauteur perdue), bascule unique `#xlsx-tools-toggle` (☰, `aria-expanded`), cibles tactiles ≥ 44 px, groupes du ruban qui s'enroulent, onglets de feuilles défilants, inputs 16 px (anti-zoom iOS). E2E mobile ajouté + helper `openXlsx()` (déplie le ruban). 10 échecs E2E mobile **préexistants** prouvés par `git stash` (hors périmètre, CI ne lance que desktop). Vérifié : xlsx-viewer.test.mjs 165/165, E2E desktop 19 passed/1 skipped, suite 1628 passed, ruff/mypy 0, CI run #1937 success | ✅ livré (en attente vérif utilisateur) | +| 2026-10-08 | #190 | Fonctionnalité | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js` (réécrit), `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/js/sidebar.js`, `frontend/js/app.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`, `CHANGELOG.md` | **#190 — chat : suivi du document, chat général en onglet sidebar, onglets au-dessus du filtre** : (A) l'en-tête du panneau affiche le document ciblé (titre + chemin) et `followFileChat()` appelé par `renderFile()` re-cible le panneau ouvert à chaque changement de document ; (B) onglet **Chat** (dernier) → conversation générale stockée dans le même store #169 via les sentinelles `__global__/general` (`GET/POST /api/chat`), **pastille de messages non lus** (`localStorage` + badge sur l'onglet), **pièces jointes image/vidéo** (`POST /api/chat/upload` : allow-list d'extensions, 25 MB, nom UUID — jamais le nom client ; `GET /api/chat/attachment/{name}` résolu contre l'allow-list), **URL cliquables** dans le rendu (linkification `http(s)://`), date/heure d'envoi ; (C) barre de filtre **déplacée sous les onglets** et routée vers `filterChatMessages()` (texte + auteur) quand l'onglet Chat est actif. Transport : broadcast SSE `chat_message` réutilisé (vault `__global__` route vers le panneau sidebar, sinon panneau fichier) — pas de second WebSocket. Contre-preuves : `__global__` non routé → test « routes __global__ » échoue ; panneau sans classe `active` → le message part en toast au lieu du rendu (révélé par le stub JSDOM). Vérifié : pytest **1655 passed / 2 skipped** (27 tests chat dont 12 nouveaux), ruff 0, mypy 0 (113 fichiers), validate-imports 42 modules/368 exports, unit 13/13, filechat.test.mjs **11/11** (5 nouveaux), toolbar-order, sidebar-filters 8/8 | ✅ livré (en attente vérif utilisateur) | | 2026-10-08 | #169 | Fonctionnalité | `backend/file_chat.py` (nouveau), `backend/routers/file_chat.py` (nouveau), `backend/schemas.py`, `backend/main.py`, `frontend/js/filechat.js` (nouveau), `frontend/js/viewer.js`, `frontend/js/sync.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py` (nouveau), `tests/frontend/filechat.test.mjs` (nouveau), `tests/frontend/toolbar-order.test.mjs`, `.gitea/workflows/ci.yml`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md` (nouveau), `docs/GUIDES/PRISE_EN_MAIN.md`, `CHANGELOG.md` | **#169 — chat intégré par fichier** : store JSON par (vault, path) sous `data/chats/` (nom SHA-256 → traversal impossible, plafond 500 msgs, texte 4000 car., écriture atomique, fichier corrompu → vide) ; `GET/POST /api/file/{vault}/chat` (auth + `check_vault_access` + `resolve_safe_path`, 400/404/403, `response_model`) + broadcast SSE `chat_message` sur le transport #62 (**pas de 2ᵉ WebSocket**) ; panneau latéral `filechat.js` (bouton 💬 toolbar, rendu chronologique `textContent`, envoi optimiste + dédup par id, toast si panneau fermé/autre fichier, plein écran ≤ 768 px) ; relais SSE en import dynamique dans `sync.js` ; i18n FR/EN 10 clés `chat.*`. En route : tag OpenAPI maison `file-chat` non déclaré → `test_used_tags_are_declared` rouge (retiré, dérivé « Files »), regex navBtns de `toolbar-order.test.mjs` (échappements → `includes`), ruff UP012/TRY004/I001. Vérifié : `test_file_chat.py` 15 passed, `filechat.test.mjs` 6/6, suite 1643 passed, ruff/mypy 0, validate-imports 42 modules, CI run #1938 5/5 success (v2.57.0) | ✅ livré (en attente vérif utilisateur) | --- diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0c46338..c2eb7c2 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.57.1 | **Dernière mise à jour :** 2026-10-08 +> **Version :** 2.58.0 | **Dernière mise à jour :** 2026-10-08 > **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)** @@ -731,6 +731,7 @@ | 188 | Secrets — détection universelle des clés API & mots de passe, masque cliquable (clic = copie) | Unreleased | [features/secret-mask-188.md](./features/secret-mask-188.md) | | 189 | Tableur — mise en page mobile : barres repliées par défaut, bascule unique, cibles tactiles 44 px | 2.56.0 | [features/xlsx-mobile-layout-189.md](./features/xlsx-mobile-layout-189.md) | | 169 | Chat intégré par fichier (panneau latéral, historique, temps réel SSE, toast de notification) | 2.57.0 | [features/file-chat-169.md](./features/file-chat-169.md) | +| 190 | Chat — suivi du document, chat général en onglet sidebar (pièces jointes, non lus, recherche), onglets au-dessus du filtre | Unreleased | [features/file-chat-169.md](./features/file-chat-169.md) | | 159 | Desktop — gestion des vaults & dossiers : retrait par menu contextuel + section Configuration (ajout vault/dossier racine) | 2.50.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) | | 160 | Desktop — premier lancement professionnel (répertoire `%USERPROFILE%\ObsiGate` + `Prise en main.md`) et section Configuration harmonisée | 2.51.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) | | BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) | diff --git a/docs/features/file-chat-169.md b/docs/features/file-chat-169.md index 228e14b..5725b2f 100644 --- a/docs/features/file-chat-169.md +++ b/docs/features/file-chat-169.md @@ -54,6 +54,42 @@ (fetch simulé vers `frontend/locales/`). - Les deux sont ajoutés au pipeline CI (`.gitea/workflows/ci.yml`). +## #190 — Suivi du document, chat général, ordre des onglets (2026-10-08) + +### A. Le panneau suit le document + +- En-tête du panneau : `.file-chat-target` affiche le titre du document + (l'attribut `title` porte le chemin complet). +- `followFileChat(vault, path, title)` est appelé en tête de `renderFile()` : + panneau ouvert + autre document → re-cible (reload de l'historique) ; + panneau fermé → no-op. Le bouton 💬 transmet `data.title`. + +### B. Chat général en onglet sidebar + +- Onglet `#sidebar-tab-chat` (dernier, `message-circle`) + panneau + `#sidebar-panel-chat` (liste + 📎 + saisie + Envoyer, Entrée = envoyer). +- **Store** : rien de nouveau — les sentinelles `GLOBAL_VAULT="__global__"` / + `GLOBAL_PATH="general"` réutilisent `add_message`/`get_messages`. +- **Routes** : `GET/POST /api/chat`, `POST /api/chat/upload`, + `GET /api/chat/attachment/{name}`. +- **Pièces jointes** : allow-list d'extensions (11 : images + vidéos), 25 MB, + nom de stockage = UUID (le nom client n'est jamais utilisé), résolution + par comparaison au nom + allow-list (aucun chemin utilisateur). +- **Messages non lus** : timestamp de dernière visite dans + `localStorage['obsigate-chat-unread']`, comptage des messages plus récents + d'un autre auteur, pastille rouge `99+` sur l'onglet, effacée à l'ouverture. +- **URL** : linkification `http(s)://` au rendu (texte autour en `TextNode`, + donc pas d'injection). +- **SSE** : `vault === "__global__"` route vers la liste sidebar (append + + clear) ou vers badge + toast selon que l'onglet est actif. + +### C. Ordre des onglets + filtre = recherche chat + +- `index.html` : le bloc `.sidebar-filter` est **déplacé après** `.sidebar-tabs`. +- `switchSidebarTab` : placeholder `sidebar.filter_chat`, dispatch `chat`. +- `initSidebarFilter` (`sidebar.js`) : `routeFilter`/`routeClear` routent + vers `filterChatMessages()` (texte **et** auteur, insensible à la casse). + ## Décisions - **SSE plutôt qu'un second WebSocket** : le transport de #62 (EventSource diff --git a/frontend/index.html b/frontend/index.html index e25a168..9a522f9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -812,46 +812,6 @@ role="navigation" aria-label="Navigation des vaults" > - - - + + + @@ -1050,6 +1065,47 @@ + + +