diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 49d6ddd..52dfeff 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -72,6 +72,7 @@ jobs: node upload.test.mjs node config-ai-keys.test.mjs node xlsx-viewer.test.mjs + node filechat.test.mjs else echo "tests/frontend/node_modules missing - installing jsdom" npm install --no-audit --no-fund --silent @@ -93,6 +94,7 @@ jobs: node upload.test.mjs node config-ai-keys.test.mjs node xlsx-viewer.test.mjs + node filechat.test.mjs fi # ── Tests ───────────────────────────────────────────────────────── diff --git a/CHANGELOG.md b/CHANGELOG.md index a8dac55..0621523 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.56.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.57.0**. --- @@ -14,6 +14,26 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.57.0] — 2026-10-08 + +### Ajouté + +- **#169 - Chat intégré par fichier** + - Bouton **💬 Chat** dans la barre d'actions du document : ouvre un panneau + latéral avec l'historique de la discussion et la saisie d'un message. + - Persistance par fichier (`data/chats/`, plafond 500 messages, texte + tronqué à 4000 caractères) ; routes `GET/POST /api/file/{vault}/chat` + protégées (auth + accès vault + contrôle de traversal). + - Temps réel via le transport SSE existant (événement `chat_message`) : + le panneau ouvert s'incrémente en direct, **toast de notification** + « Nouveau message de … » si le panneau est fermé ou affiche un autre + fichier (pas de doublon côté émetteur). + - Mobile : panneau plein écran ; i18n FR/EN. + - Tests : pytest `test_file_chat.py` (15) + JSDOM `filechat.test.mjs` (6), + ajoutés au pipeline CI. + +--- + ## [2.56.0] — 2026-10-08 ### Ajouté diff --git a/README.fr.md b/README.fr.md index a92751c..04e64ff 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/) @@ -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.56.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.57.0). --- -*Projet : ObsiGate | Version : 2.56.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.57.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 1ef3250..b05418b 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/) @@ -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.56.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.57.0). --- -*Project: ObsiGate | Version: 2.56.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.57.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 5f46e11..4b9cd6e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.56.0 +2.57.0 diff --git a/backend/file_chat.py b/backend/file_chat.py new file mode 100644 index 0000000..3e81334 --- /dev/null +++ b/backend/file_chat.py @@ -0,0 +1,84 @@ +# backend/file_chat.py — historique de discussion par fichier (#169) +"""Per-file 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. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import shutil +import time +import uuid +from pathlib import Path +from typing import Any + +logger = logging.getLogger("obsigate.file_chat") + +CHAT_DIR = Path("data/chats") +MAX_MESSAGES = 500 # retention ceiling per file (oldest dropped first) +MAX_TEXT = 4000 # characters per message + + +def _chat_file(vault: str, path: str) -> Path: + """Return the chat file for *(vault, path)* (hashed, traversal-proof).""" + CHAT_DIR.mkdir(parents=True, exist_ok=True) + key = hashlib.sha256(f"{vault}\0{path}".encode()).hexdigest()[:32] + return CHAT_DIR / f"{key}.json" + + +def _read(vault: str, path: str) -> dict[str, Any]: + """Load the raw chat document (empty structure when missing/corrupt).""" + file = _chat_file(vault, path) + if not file.exists(): + return {"vault": vault, "path": path, "messages": []} + try: + doc = json.loads(file.read_text(encoding="utf-8")) + if not isinstance(doc.get("messages"), list): + raise TypeError("messages is not a list") # caught by the handler below + return doc + except Exception as e: + logger.error("Failed to read chat for %s/%s: %s", vault, path, e) + return {"vault": vault, "path": path, "messages": []} + + +def _write(file: Path, doc: dict[str, Any]) -> None: + """Atomically persist *doc* (tmp file + rename).""" + try: + tmp = file.with_suffix(".tmp") + tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=1), encoding="utf-8") + shutil.move(str(tmp), str(file)) + except Exception as e: + logger.error("Failed to write chat %s: %s", file.name, e) + + +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", [])) + + +def add_message(vault: str, path: str, user: str, text: str) -> dict[str, Any]: + """Append a message and persist it. Returns the stored message. + + The list is capped at :data:`MAX_MESSAGES` (oldest dropped first). + """ + text = (text or "").strip()[:MAX_TEXT] + msg = { + "id": uuid.uuid4().hex[:12], + "user": user or "anonyme", + "text": text, + "ts": time.time(), + } + doc = _read(vault, path) + messages = list(doc.get("messages", [])) + messages.append(msg) + if len(messages) > MAX_MESSAGES: + messages = messages[-MAX_MESSAGES:] + doc["messages"] = messages + _write(_chat_file(vault, path), doc) + return msg diff --git a/backend/main.py b/backend/main.py index 3dcde45..c5f7f6e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -509,6 +509,7 @@ from backend.routers.config import _load_config from backend.routers.config import router as config_router from backend.routers.conflicts import router as conflicts_router from backend.routers.duplicates import router as duplicates_router +from backend.routers.file_chat import router as file_chat_router from backend.routers.files_media import router as files_media_router from backend.routers.files_read import router as files_read_router from backend.routers.files_write import router as files_write_router @@ -534,6 +535,7 @@ app.include_router(search_router) # ROADMAP #85 T5 — Search app.include_router(backups_router) # ROADMAP #85 T4 — Backups app.include_router(conflicts_router) # ROADMAP #85 T8 — Conflicts app.include_router(config_router) # ROADMAP #85 T7 — Config +app.include_router(file_chat_router) # ROADMAP #169 — Chat par fichier app.include_router(files_read_router) # ROADMAP #85 T6a — Files read app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export app.include_router(files_write_router) # ROADMAP #85 T6b — Files write diff --git a/backend/routers/file_chat.py b/backend/routers/file_chat.py new file mode 100644 index 0000000..8734d9f --- /dev/null +++ b/backend/routers/file_chat.py @@ -0,0 +1,65 @@ +# backend/routers/file_chat.py — chat par fichier (#169) +"""Per-file 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. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, Body, Depends, HTTPException + +from backend import file_chat as _store +from backend.auth.middleware import check_vault_access, require_auth +from backend.indexer import get_vault_data +from backend.schemas import ChatHistoryResponse, ChatMessageResponse +from backend.services.paths import resolve_safe_path +from backend.sse import sse_manager + +router = APIRouter() # tags dérivés de `tag_for_path` → « Files » + + +def _check(vault_name: str, path: str, current_user: dict[str, Any]) -> None: + """Authz + traversal guard shared by both verbs.""" + if not check_vault_access(vault_name, current_user): + raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'") + vault_data = get_vault_data(vault_name) + if not vault_data: + raise HTTPException(404, "Vault not found") + resolve_safe_path(Path(vault_data["path"]), path) # ServiceError → 403/500 + + +@router.get("/api/file/{vault_name}/chat", response_model=ChatHistoryResponse) +async def api_file_chat_history( + vault_name: str, + path: str, + current_user: dict[str, Any] = Depends(require_auth), +): + """Return the chat history for a file (chronological).""" + _check(vault_name, path, current_user) + return {"messages": _store.get_messages(vault_name, path)} + + +@router.post("/api/file/{vault_name}/chat", response_model=ChatMessageResponse) +async def api_file_chat_post( + vault_name: str, + body: dict[str, Any] = Body(...), + current_user: dict[str, Any] = Depends(require_auth), +): + """Post a chat message and broadcast it on SSE (``chat_message``).""" + path = str(body.get("path") or "") + text = str(body.get("text") or "") + if not path: + raise HTTPException(400, "path is required") + if not text.strip(): + raise HTTPException(400, "text is required") + _check(vault_name, path, current_user) + 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"} diff --git a/backend/schemas.py b/backend/schemas.py index 70fdd8a..d24caf2 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -151,6 +151,28 @@ class BacklinksResponse(BaseModel): total: int +class ChatMessageItem(BaseModel): + """One chat message (``GET/POST /api/file/{vault}/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") + + +class ChatHistoryResponse(BaseModel): + """Response for ``GET /api/file/{vault}/chat``.""" + + messages: list[ChatMessageItem] = Field(description="Messages, chronological") + + +class ChatMessageResponse(BaseModel): + """Response for ``POST /api/file/{vault}/chat``.""" + + message: ChatMessageItem + status: str = Field(description="'ok'") + + class BackupsListResponse(BaseModel): """Response for ``GET /api/backups``.""" diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 8d76b74..f425dc8 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.56.0" +version = "2.57.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index b4ef971..367d41a 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.56.0" +version = "2.57.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 eebee4b..c6676f2 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.56.0", + "version": "2.57.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 dc531ef..00ee2e6 100644 --- a/docs/GUIDES/PRISE_EN_MAIN.md +++ b/docs/GUIDES/PRISE_EN_MAIN.md @@ -178,6 +178,13 @@ des **onglets** (avec possibilité de vue multi-panneaux / split view). > Selon le mode, la lecture et l'édition se remplacent : `Editer` et `Forge` > prennent la place de la vue lecture ; revenez avec `✓` / `×` ou `Échap`. +### Discuter d'un fichier + +- **Bouton « Chat »** (💬, barre d'actions du document) : ouvre un panneau + 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. + --- ## 7. Rechercher diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fd80b4b..9711fc9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.56.0 | **Dernière mise à jour :** 2026-10-08 +> **Version :** 2.57.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)** @@ -40,26 +40,6 @@ - [ ] **Signature de code Windows** : **non retenue — décision confirmée le 2026-09-26** : livraison non signée + documentation SmartScreen (« Exécuter quand même »). Alternatives écartées sauf retour utilisateur : SignPath.io (OSS gratuit), Certum OSS, Azure Trusted Signing, certificat EV - [ ] Exécuter les 6 tests E2E **manuels** — protocole documenté : [DESKTOP_E2E_CHECKLIST.md](./DESKTOP_E2E_CHECKLIST.md) -### 159. Gestion desktop des vaults & dossiers — retrait par menu contextuel + ajout en configuration - -- **Effort :** 0,5-1 jour | **Impact :** 🟢 | **Framework :** commandes Rust existantes (`add/remove/list_vaults|dirs`) + `ContextMenuManager` -- **Statut :** ✅ livré le 2026-10-02 (v2.50.0) — ouvert le 2026-10-02 (prérequis : BUG-104/BUG-105 livrés) -- **Reste à faire :** - - [x] Retrait d'un vault **ou dossier racine** par menu contextuel (entrée « Retirer de l'application », desktop uniquement, confirmation, déregistration seule) - - [x] Section Configuration : liste des vaults/dossiers desktop + ajout (bouton vault existant `pickAndAddVault` + nouveau sélecteur `pick_folder` sans effet de bord pour les dossiers) - - [x] Rafraîchissement de la jump list après chaque ajout/retrait - - [x] i18n FR/EN, tests (garde-fou ACL + unit), E2E, fiche features, release - -### 160. Premier lancement professionnel + section Configuration harmonisée - -- **Effort :** 0,5-1 jour | **Impact :** 🟢 | **Framework :** config Rust + CSS classes existantes -- **Statut :** ✅ livré le 2026-10-02 (v2.51.0) — ouvert le 2026-10-02 -- **Reste à faire :** - - [x] Premier lancement : monter `%USERPROFILE%\ObsiGate` comme vault par défaut (remplace `voute_obsidian`), `vault_path` dessus, nom de dossier racine home dérivé du chemin (fin du hardcode « bruno ») - - [x] Document `Prise en main.md` (contenu embarqué `include_str!`) écrit dans ce dossier si absent — jamais d'écrasement - - [x] Refonte visuelle de la section « 🖥️ Vaults & dossiers (Desktop) » : classes CSS (motif `webauthn-key-item`), boutons `.config-btn-sm`, zéro style inline, override mobile 44px - - [x] Tests Rust (config par défaut + doc embarqué), suites frontend, E2E, docs, release - --- ## ✅ Terminé — Visionneuse & édition Excel (P0/P1/P2) @@ -638,7 +618,7 @@ | 172 — Chiffrement/déchiffrement de fichiers | Non livré (AES-GCM interne réservé aux tokens) | ⚪ #172 | | 173 — Vues personnalisées (Kanban, Calendrier) | Non livré | ⚪ #173 | | 174 — Optimisation mobile | Base **largement livrée** (#69 éditeur mobile, #83 ruban mobile, #114 config responsive 44 px) ; reste un audit du solde | ⚪ #178 | -| 175 — Chat intégré | Non livré (transport WebSocket/rooms de #62 réutilisable) | ⚪ #169 | +| 175 — Chat intégré | **Livré le 2026-10-08** (SSE, panneau latéral, historique, toast) | ✅ #169 | | 176 — Thèmes dynamiques (règles heure/date) | Base livrée (#65 thèmes + import/export) ; règles automatiques manquantes | ⚪ #174 | | 177 — Export formats propriétaires (OneNote, Evernote) | Base livrée (#66 HTML/MD bundle/ePub + PDF #74) ; formats tiers manquants | ⚪ #175 | | 178 — Webhooks pour événements | **Déjà livrée** (#9 — `backend/webhooks.py`, événements + secrets + validation d'URL SSRF-safe) | ✅ | @@ -709,7 +689,7 @@ | 166 | Détection & fusion de doublons (score de similarité, liste des paires potentielles, outil de fusion) — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md)) | 3-4 j | 🟢 | embeddings/TF-IDF #70 ; **jamais de fusion sans confirmation explicite** (+ backup préalable) | | 167 | Connexions tierces — Google Drive & Notion (import/export, OAuth2, respect des quotas) | 5-7 j | 🟡 | page « sources connectées » #103 ; rate-limit existant | | 168 | Notifications externes — canaux Slack/Discord (webhooks) et Email (SMTP) + choix des déclencheurs — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md) ; périmètre : Discord, Telegram, SMTP, webhook générique — Slack via webhook générique) | 3-4 j | 🟢 | `backend/webhooks.py` #9 ; Push API #67 | -| 169 | Chat intégré par fichier (panneau latéral, historique, notifications de nouveaux messages) | 3-4 j | 🟢 | transport WebSocket/rooms de la collab #62 | +| 169 | Chat intégré par fichier (panneau latéral, historique, notifications de nouveaux messages) — ✅ **livré le 2026-10-08** ([fiche](./features/file-chat-169.md) ; transport SSE #62, pas de 2ᵉ WebSocket) | 3-4 j | 🟢 | transport SSE #62 | | 170 | Planification de tâches automatiques (type cron ; actions = outils existants `create_file`/`append_to_file` ; notifications d'échec) — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md) ; tick asyncio 60 s, API + outils, sans UI dédiée) | 3-4 j | 🟢 | scheduler backend (APScheduler ou asyncio) ; UI de création de tâches | | 171 | Audit des accès — extension : historique lecture/édition par fichier, filtres (utilisateur/fichier/date), export CSV | 2-3 j | 🟡 | `backend/audit.py` + `/api/admin/audit` (#71) | | 172 | Chiffrement/déchiffrement de fichiers sensibles (AES-256-GCM, gestion de clés, confirmation) | 3-4 j | 🟡 | **backup automatique préalable** ; clés hors dépôt (jamais de secret committé) | @@ -750,6 +730,7 @@ | 186 | Création d'un fichier Excel (`.xlsx`) depuis la modale « Créer un fichier » | 2.54.0 | [features/create-file-xlsx-186.md](./features/create-file-xlsx-186.md) | | 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 | Unreleased | [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) | 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 new file mode 100644 index 0000000..228e14b --- /dev/null +++ b/docs/features/file-chat-169.md @@ -0,0 +1,67 @@ +# Chat intégré par fichier — #169 + +- **Statut :** ✅ livré le 2026-10-08 +- **Effort :** 3-4 j (réalisé en 1 session) | **Impact :** 🟢 +- **Transport :** SSE existant (#62) — pas de second WebSocket + +## Ce qui a été livré + +### Backend + +- **Store `backend/file_chat.py`** — historique JSON par paire + `(vault, path)` sous `data/chats/` : + - nom de fichier = SHA-256 de `vault\0path` (32 premiers hex) : aucun + séparateur de chemin utilisateur dans le nom → traversal impossible ; + - plafond **500 messages** par fichier (les plus vieux supprimés) ; + - texte tronqué à **4000 caractères** ; + - écriture atomique (tmp + move) ; fichier corrompu → historique vide + (fallback, pas de crash). +- **Routes `backend/routers/file_chat.py`** (tag OpenAPI « Files » dérivé) : + - `GET /api/file/{vault}/chat?path=…` → historique chronologique ; + - `POST /api/file/{vault}/chat` `{path, text}` → ajout + broadcast SSE ; + - garde commune : `require_auth` + `check_vault_access` + + `resolve_safe_path` (traversal → 403/500 via le handler `ServiceError`) ; + - `text` ou `path` vides → 400 ; vault inconnu → 404. +- **Schémas** `ChatMessageItem` / `ChatHistoryResponse` / + `ChatMessageResponse` dans `backend/schemas.py` (`response_model` sur les + deux routes). +- **Broadcast** : `sse_manager.broadcast("chat_message", {vault, path, message})`. + +### Frontend + +- **`frontend/js/filechat.js`** — panneau latéral : + - bouton 💬 (`.btn-chat`) dans le groupe nav de la toolbar fichier + (viewer.js), toggle ouvre/ferme ; + - historique rendu chronologiquement, bulles « mine » à droite, + `textContent` pour le corps (pas d'injection HTML) ; + - envoi POST optimiste + dédoublonnage par `id` à la réception SSE ; + - panneau fermé / autre fichier → **toast** « Nouveau message de … » + (silencier si c'est son propre message). +- **`frontend/js/sync.js`** — listener SSE `chat_message` avec import + dynamique de `filechat.js` (pas de cycle d'imports). +- **CSS** bloc `#169` : panneau fixe 340 px, **plein écran ≤ 768 px**, + variables CSS avec fallbacks, input 16 px (anti-zoom iOS). +- **i18n** : 10 clés `chat.*` dans `fr.json` et `en.json`. + +## Tests + +- `tests/test_file_chat.py` — 15 tests : roundtrip store, isolation par + fichier, plafond de rétention, troncature, traversal hashée, fichier + corrompu, routes (200/400/404/403), fixture autouse isolant `CHAT_DIR`. +- `tests/frontend/filechat.test.mjs` — 6 tests JSDOM : empty state, rendu + chronologique + classe `mine`, anti-injection HTML, append live + dédup + par id, isolation inter-fichiers, toggle. Locales réellement chargées + (fetch simulé vers `frontend/locales/`). +- Les deux sont ajoutés au pipeline CI (`.gitea/workflows/ci.yml`). + +## Décisions + +- **SSE plutôt qu'un second WebSocket** : le transport de #62 (EventSource + `/api/events`, auth par cookie) existe déjà côté client dans `sync.js` ; + un WS par fichier aurait dupliqué reconnexion/heartbeat/auth pour zéro + bénéfice sur du messages-postés. +- **Pas d'événement dans le panneau de sync** (`_addEvent`) : les messages + de chat gonfleraient l'historique d'événements d'index sans valeur. +- **Tag OpenAPI** : pas de tag maison (`file-chat`) — non déclaré dans + `TAGS_METADATA`, ce qui cassait `test_used_tags_are_declared`. Les routes + `/api/file/*` sont dérivées automatiquement sous « Files ». diff --git a/frontend/js/filechat.js b/frontend/js/filechat.js new file mode 100644 index 0000000..b25571a --- /dev/null +++ b/frontend/js/filechat.js @@ -0,0 +1,208 @@ +// frontend/js/filechat.js — Chat intégré par fichier (#169) +// Panneau latéral : historique + saisie. Les messages entrants arrivent par +// SSE (event `chat_message` relayé depuis sync.js) ; si le panneau est fermé +// ou affiche un autre fichier, un toast notifie l'utilisateur. +import { api, AuthManager } from "./auth.js"; +import { t } from "./i18n.js"; +import { showToast } from "./ui.js"; +import { escapeHtml } from "./utils.js"; + +const PANEL_ID = "file-chat-panel"; +let _vault = null; +let _path = null; +let _sending = false; + +function _panel() { + let el = document.getElementById(PANEL_ID); + if (!el) { + el = document.createElement("aside"); + el.id = PANEL_ID; + el.className = "file-chat-panel hidden"; + el.setAttribute("aria-label", t("chat.title")); + document.body.appendChild(el); + } + return el; +} + +function _esc(s) { + return escapeHtml(String(s == null ? "" : s)); +} + +function _formatTime(ts) { + try { + return new Date(ts * 1000).toLocaleString(undefined, { + day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit", + }); + } catch (_) { + return ""; + } +} + +export function renderChatMessages(container, messages, currentUser) { + container.innerHTML = ""; + if (!messages || messages.length === 0) { + const empty = document.createElement("div"); + empty.className = "file-chat-empty"; + empty.textContent = t("chat.empty"); + container.appendChild(empty); + return; + } + messages.forEach((m) => container.appendChild(_messageEl(m, currentUser))); + container.scrollTop = container.scrollHeight; +} + +function _messageEl(m, currentUser) { + const mine = currentUser && m.user === currentUser; + const wrap = document.createElement("div"); + wrap.className = "file-chat-msg" + (mine ? " mine" : ""); + wrap.dataset.id = m.id || ""; + const meta = document.createElement("div"); + meta.className = "file-chat-meta"; + meta.innerHTML = + `${_esc(m.user)}` + + `${_esc(_formatTime(m.ts))}`; + const body = document.createElement("div"); + body.className = "file-chat-body"; + body.textContent = m.text || ""; + wrap.appendChild(meta); + wrap.appendChild(body); + return wrap; +} + +async function _load() { + const panel = _panel(); + const list = panel.querySelector(".file-chat-list"); + try { + const data = await api( + `/api/file/${encodeURIComponent(_vault)}/chat?path=${encodeURIComponent(_path)}`, + ); + renderChatMessages(list, data.messages || [], _currentUser()); + } catch (_) { + list.innerHTML = ""; + const err = document.createElement("div"); + err.className = "file-chat-empty"; + err.textContent = t("chat.load_error"); + list.appendChild(err); + } +} + +function _currentUser() { + const u = AuthManager.getUser(); + return (u && u.username) || ""; +} + +function _renderShell(panel) { + panel.innerHTML = ""; + const header = document.createElement("div"); + header.className = "file-chat-header"; + const title = document.createElement("h3"); + title.textContent = t("chat.title"); + const close = document.createElement("button"); + close.type = "button"; + close.className = "file-chat-close"; + close.title = t("chat.close"); + close.setAttribute("aria-label", t("chat.close")); + close.textContent = "✕"; + close.addEventListener("click", closeFileChat); + header.appendChild(title); + header.appendChild(close); + + const list = document.createElement("div"); + list.className = "file-chat-list"; + + const form = document.createElement("form"); + form.className = "file-chat-form"; + const input = document.createElement("input"); + input.type = "text"; + input.className = "file-chat-input"; + input.placeholder = t("chat.placeholder"); + input.maxLength = 4000; + input.setAttribute("aria-label", t("chat.placeholder")); + const send = document.createElement("button"); + send.type = "submit"; + send.className = "file-chat-send"; + send.textContent = t("chat.send"); + form.appendChild(input); + form.appendChild(send); + form.addEventListener("submit", (e) => { + e.preventDefault(); + _send(input); + }); + + panel.appendChild(header); + panel.appendChild(list); + panel.appendChild(form); +} + +export async function openFileChat(vault, path) { + _vault = vault; + _path = path; + const panel = _panel(); + _renderShell(panel); + panel.classList.remove("hidden"); + await _load(); + const input = panel.querySelector(".file-chat-input"); + if (input) input.focus(); +} + +export function closeFileChat() { + const panel = document.getElementById(PANEL_ID); + if (panel) panel.classList.add("hidden"); +} + +export function toggleFileChat(vault, path) { + const panel = document.getElementById(PANEL_ID); + if (panel && !panel.classList.contains("hidden") && _vault === vault && _path === path) { + closeFileChat(); + return; + } + openFileChat(vault, path); +} + +async function _send(input) { + if (_sending) return; + const text = (input.value || "").trim(); + if (!text) return; + _sending = true; + try { + const data = await api(`/api/file/${encodeURIComponent(_vault)}/chat`, { + method: "POST", + body: JSON.stringify({ path: _path, text }), + }); + input.value = ""; + const list = _panel().querySelector(".file-chat-list"); + const empty = list.querySelector(".file-chat-empty"); + if (empty) empty.remove(); + list.appendChild(_messageEl(data.message, _currentUser())); + list.scrollTop = list.scrollHeight; + } catch (_) { + showToast(t("chat.send_error"), "error"); + } finally { + _sending = false; + input.focus(); + } +} + +// --- SSE relay (appelé par sync.js sur l'événement `chat_message`) --------- +export function onChatMessage(data) { + if (!data || !data.message) return; + const panel = document.getElementById(PANEL_ID); + const isOpen = + panel && !panel.classList.contains("hidden") && + _vault === data.vault && _path === data.path; + if (isOpen) { + const list = panel.querySelector(".file-chat-list"); + if (list) { + const empty = list.querySelector(".file-chat-empty"); + if (empty) empty.remove(); + // Évite le doublon si l'envoi local a déjà rendu ce message + const id = data.message.id; + if (id && list.querySelector(`[data-id="${CSS.escape(id)}"]`)) return; + list.appendChild(_messageEl(data.message, _currentUser())); + list.scrollTop = list.scrollHeight; + } + return; + } + if (data.message.user === _currentUser()) return; // son propre message + showToast(t("chat.new_message", { user: data.message.user }), "info"); +} diff --git a/frontend/js/sync.js b/frontend/js/sync.js index 2df2a23..b2810ac 100644 --- a/frontend/js/sync.js +++ b/frontend/js/sync.js @@ -120,6 +120,16 @@ export const IndexUpdateManager = (() => { } }); + // #169 — Chat par fichier : relais du broadcast SSE vers le panneau + eventSource.addEventListener("chat_message", (e) => { + try { + const data = JSON.parse(e.data); + import("./filechat.js").then((m) => m.onChatMessage(data)).catch(() => {}); + } catch (err) { + console.error("SSE parse error:", err); + } + }); + eventSource.onerror = () => { connectionState = "disconnected"; _updateBadge(); diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index bc40b83..30f4647 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -16,6 +16,7 @@ import { t, getLocale } from './i18n.js'; import { onFileRender } from './plugins.js'; import { NowPlaying } from './now-playing.js'; import { openAssistant } from './ai-fab.js'; +import { toggleFileChat } from './filechat.js'; import { parseRef, columnName, findTd, invalidateCellIndex, sheetOfRef, firstCellOfRange } from './xlsx/refs.js'; import { createColourPicker } from './xlsx/color-picker.js'; import { buildCommandBar } from './xlsx/command-bar.js'; @@ -7222,6 +7223,12 @@ export function renderFile(data) { // Bookmark button — check if already bookmarked const bookmarkBtn = el("button", { class: "btn-action btn-bookmark", title: t("viewer.bookmark_toggle") }, [icon("bookmark-plus", 14), document.createTextNode("Bookmark")]); + + // #169 — Chat intégré par fichier (panneau latéral) + const chatBtn = el("button", { class: "btn-action btn-chat", title: t("chat.button_title") }, [icon("message-square", 14), document.createTextNode(t("chat.button"))]); + chatBtn.addEventListener("click", () => { + toggleFileChat(data.vault, data.path); + }); // Check bookmark status and color the button (async () => { try { @@ -7314,7 +7321,7 @@ export function renderFile(data) { // spacer de groupe vide retire. const navBtns = []; if (data.is_markdown) navBtns.push(tocBtn); - navBtns.push(openNewWindowBtn, bookmarkBtn); + navBtns.push(openNewWindowBtn, bookmarkBtn, chatBtn); const editBtns = []; if (!data.unsupported) editBtns.push(editBtn); editBtns.push(sourceBtn); diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 68e8329..a22f326 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -319,6 +319,16 @@ "backups.restore_confirm": "to the selected backup version?\nThe current version will be saved.", "backups.title": "Backups", "button.cancel": "Cancel", + "chat.button": "Chat", + "chat.button_title": "Discuss this file", + "chat.close": "Close the discussion panel", + "chat.empty": "No messages yet. Start the conversation!", + "chat.load_error": "Could not load the discussion", + "chat.new_message": "New message from {user}", + "chat.placeholder": "Write a message…", + "chat.send": "Send", + "chat.send_error": "Failed to send the message", + "chat.title": "Discussion", "common.actions": "Actions", "common.all": "All", "common.back": "Back", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 37adf68..ae498ba 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -319,6 +319,16 @@ "backups.restore_confirm": "a la version du backup selectionne ?\nLa version actuelle sera sauvegardee.", "backups.title": "Backups", "button.cancel": "Annuler", + "chat.button": "Chat", + "chat.button_title": "Discuter de ce fichier", + "chat.close": "Fermer le panneau de discussion", + "chat.empty": "Aucun message pour l'instant. Lancez la discussion !", + "chat.load_error": "Impossible de charger la discussion", + "chat.new_message": "Nouveau message de {user}", + "chat.placeholder": "Écrire un message…", + "chat.send": "Envoyer", + "chat.send_error": "Échec de l'envoi du message", + "chat.title": "Discussion", "common.actions": "Actions", "common.all": "Tous", "common.back": "Retour", diff --git a/frontend/style.css b/frontend/style.css index 155703c..bb58b73 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -13748,3 +13748,142 @@ body.reading-mode .file-actions { color: var(--text-muted); font-size: 0.85rem; } + +/* --------------------------------------------------------------------------- + #169 — Chat intégré par fichier (panneau latéral) +--------------------------------------------------------------------------- */ +.file-chat-panel { + position: fixed; + top: 0; + right: 0; + width: 340px; + max-width: 100vw; + height: 100vh; + display: flex; + flex-direction: column; + background: var(--bg-primary, #0d1117); + border-left: 1px solid var(--border, #21262d); + box-shadow: -8px 0 24px rgba(0, 0, 0, 0.35); + z-index: 1500; +} +.file-chat-panel.hidden { + display: none; +} +.file-chat-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 12px 14px; + border-bottom: 1px solid var(--border, #21262d); +} +.file-chat-header h3 { + margin: 0; + font-size: 0.95rem; + font-weight: 600; + color: var(--text-primary, #e6edf3); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.file-chat-close { + border: none; + background: transparent; + color: var(--text-muted, #8b949e); + font-size: 1rem; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; + flex-shrink: 0; +} +.file-chat-close:hover { + background: var(--bg-hover, rgba(128, 128, 128, 0.18)); + color: var(--text-primary, #e6edf3); +} +.file-chat-list { + flex: 1; + overflow-y: auto; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 10px; +} +.file-chat-empty { + color: var(--text-muted, #8b949e); + font-size: 0.85rem; + text-align: center; + padding: 24px 8px; +} +.file-chat-msg { + background: var(--bg-secondary, #161b22); + border: 1px solid var(--border, #21262d); + border-radius: 10px; + padding: 8px 10px; + max-width: 92%; +} +.file-chat-msg.mine { + align-self: flex-end; + background: var(--bg-hover, rgba(88, 166, 255, 0.12)); + border-color: var(--accent, #58a6ff); +} +.file-chat-meta { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 0.72rem; + color: var(--text-muted, #8b949e); + margin-bottom: 4px; +} +.file-chat-user { + font-weight: 600; + color: var(--accent, #58a6ff); +} +.file-chat-body { + font-size: 0.88rem; + color: var(--text-primary, #e6edf3); + white-space: pre-wrap; + word-break: break-word; +} +.file-chat-form { + display: flex; + gap: 8px; + padding: 10px 14px; + border-top: 1px solid var(--border, #21262d); +} +.file-chat-input { + flex: 1; + min-width: 0; + padding: 8px 10px; + font-size: 16px; /* anti-zoom iOS */ + border: 1px solid var(--border, #21262d); + border-radius: 8px; + background: var(--bg-secondary, #161b22); + color: var(--text-primary, #e6edf3); +} +.file-chat-input:focus { + outline: none; + border-color: var(--accent, #58a6ff); +} +.file-chat-send { + border: none; + border-radius: 8px; + padding: 8px 14px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + background: var(--accent, #58a6ff); + color: #fff; + flex-shrink: 0; +} +.file-chat-send:disabled { + opacity: 0.6; + cursor: default; +} + +@media (max-width: 768px) { + .file-chat-panel { + width: 100vw; + border-left: none; + } +} + diff --git a/package.json b/package.json index 131a20f..993e444 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.56.0", + "version": "2.57.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": { diff --git a/tests/frontend/filechat.test.mjs b/tests/frontend/filechat.test.mjs new file mode 100644 index 0000000..d65054d --- /dev/null +++ b/tests/frontend/filechat.test.mjs @@ -0,0 +1,162 @@ +#!/usr/bin/env node +/** + * ObsiGate — JSDOM tests for the per-file chat panel (#169): + * `frontend/js/filechat.js`. + * + * Covers: + * - `renderChatMessages()`: chronological render + empty state + escaping + * - `onChatMessage()`: live append when open on the same file, dedupe by id, + * no duplicate append when the panel shows another file + * + * Usage: node tests/frontend/filechat.test.mjs + */ + +import { strict as assert } from "node:assert"; +import { JSDOM } from "jsdom"; +import { readFileSync } from "node:fs"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import path from "node:path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const dom = new JSDOM( + `
`, + { url: "http://localhost/", pretendToBeVisual: true }, +); + +const w = dom.window; +globalThis.window = w; +globalThis.document = w.document; +globalThis.HTMLElement = w.HTMLElement; +globalThis.CSS = w.CSS || { escape: (s) => s }; +globalThis.localStorage = w.localStorage; +globalThis.requestAnimationFrame = w.requestAnimationFrame || ((cb) => setTimeout(cb, 16)); +globalThis.cancelAnimationFrame = w.cancelAnimationFrame || clearTimeout; +globalThis.sessionStorage = w.sessionStorage; +const JS_DIR = path.resolve(__dirname, "..", "..", "frontend", "js"); +const LOCALES_DIR = path.resolve(__dirname, "..", "..", "frontend", "locales"); + +// Route fetch: locales from disk, auth 401, API chat endpoints from a stub map. +const apiStubs = new Map(); +globalThis.fetch = (url) => { + const u = String(url); + if (u.includes("/static/locales/")) { + const name = u.split("/").pop(); + try { + const body = readFileSync(path.join(LOCALES_DIR, name), "utf-8"); + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(JSON.parse(body)) }); + } catch (_) { + return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) }); + } + } + if (u.includes("/api/auth/me")) { + return Promise.resolve({ ok: false, status: 401, json: () => Promise.resolve({}) }); + } + if (apiStubs.has(u)) { + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(apiStubs.get(u)) }); + } + return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ messages: [] }) }); +}; + +const { initI18n } = await import(pathToFileURL(path.join(JS_DIR, "i18n.js")).href); +await initI18n(); + +const mod = await import("../../frontend/js/filechat.js"); +const { renderChatMessages, onChatMessage, openFileChat, closeFileChat, toggleFileChat } = mod; + +let pass = 0; +const cases = []; +function test(name, fn) { + cases.push([name, fn]); +} + +function makeList() { + const list = document.createElement("div"); + list.className = "file-chat-list"; + return list; +} + +test("renderChatMessages: empty/missing shows the empty state", async () => { + const list = makeList(); + renderChatMessages(list, [], "bruno"); + assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 0); + assert.strictEqual(list.querySelector(".file-chat-empty") !== null, true); + renderChatMessages(list, undefined, "bruno"); + assert.strictEqual(list.querySelector(".file-chat-empty") !== null, true); +}); + +test("renderChatMessages: renders chronologically with mine class", async () => { + const list = makeList(); + renderChatMessages( + list, + [ + { id: "1", user: "alice", text: "bonjour", ts: 1700000000 }, + { id: "2", user: "bruno", text: "salut !", ts: 1700000060 }, + ], + "bruno", + ); + const msgs = list.querySelectorAll(".file-chat-msg"); + assert.strictEqual(msgs.length, 2); + assert.strictEqual(msgs[0].querySelector(".file-chat-user").textContent, "alice"); + assert.strictEqual(msgs[0].classList.contains("mine"), false); + assert.strictEqual(msgs[1].classList.contains("mine"), true); + assert.strictEqual(msgs[1].querySelector(".file-chat-body").textContent, "salut !"); +}); + +test("renderChatMessages: message text is text (no HTML injection)", async () => { + const list = makeList(); + renderChatMessages(list, [{ id: "1", user: "