From f00a8bea8f9a35123d7af9fd35ac9edbf2efd8f0 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 8 Oct 2026 22:28:16 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20chat=20=E2=80=94=20suppression=20de=20p?= =?UTF-8?q?ost,=20messages=20priv=C3=A9s,=20bo=C3=AEte=20compacte,=20link?= =?UTF-8?q?=20preview=20#191?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 31 +++- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- backend/file_chat.py | 132 +++++++++++++++- backend/routers/file_chat.py | 109 ++++++++++++- backend/schemas.py | 4 + desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/GUIDES/PRISE_EN_MAIN.md | 8 + docs/ISSUES_TODOLIST.md | 1 + docs/ROADMAP.md | 5 +- docs/features/file-chat-169.md | 61 ++++++++ frontend/index.html | 22 ++- frontend/js/filechat.js | 261 +++++++++++++++++++++++++------ frontend/js/sync.js | 12 +- frontend/locales/en.json | 5 + frontend/locales/fr.json | 5 + frontend/style.css | 110 +++++++++++++ package.json | 2 +- tests/frontend/filechat.test.mjs | 67 ++++++++ tests/test_file_chat.py | 157 +++++++++++++++++++ 23 files changed, 946 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 478d78f..9114242 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.58.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.60.0**. --- @@ -14,6 +14,35 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.60.0] — 2026-10-08 + +--- + +## [2.59.0] — 2026-10-08 + +### Ajouté + +- **#191 - Chat : suppression de post, messages privés, boîte compacte, link preview** + - **Suppression d'un post** : bouton 🗑 sur ses propres messages (et sur + tout le monde pour un admin), confirmation, `DELETE /api/chat/{id}` et + `/api/chat/dm/{user}/{id}`, retrait en direct chez tous les clients + (SSE `chat_deleted`). + - **Messages privés 2 utilisateurs** : rangée de canaux au-dessus du fil + (« Général » + un bouton par utilisateur), conversations isolées + (vault `__dm__`, chemin `alice|bob` trié), placeholder dédié, + compteurs de non-lus par canal, `GET /api/chat/users` pour la liste + des destinataires (sans hash/mot de passe, sans soi-même). + - **Boîte d'édition compacte** : bouton d'envoi réduit à une icône + (panneau document + sidebar), la saisie garde toute la largeur. + - **Link preview** : métadonnées OpenGraph récupérées à l'envoi + (titre, description, image, site), carte cliquable esthétique, + garde SSRF réutilisée (host privé/loopback rejeté), cache borné + 200 entrées, échec silencieux (URL morte → le message passe sans carte). + - Tests : pytest +13 (45 au total file_chat), JSDOM `filechat.test.mjs` + +5 (16). + +--- + ## [2.58.0] — 2026-10-08 ### Ajouté diff --git a/README.fr.md b/README.fr.md index 3de0626..1c81a1d 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.58.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.60.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.58.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.60.0). --- -*Projet : ObsiGate | Version : 2.58.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.60.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 7e36f2c..9a36880 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.58.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.60.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.58.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.60.0). --- -*Project: ObsiGate | Version: 2.58.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.60.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 1401b92..54bbd78 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.58.0 +2.60.0 diff --git a/backend/file_chat.py b/backend/file_chat.py index 63be38e..3390813 100644 --- a/backend/file_chat.py +++ b/backend/file_chat.py @@ -15,14 +15,18 @@ uploaded under ``data/chat_uploads/``. from __future__ import annotations import hashlib +import html import json import logging +import re import shutil import time import uuid from pathlib import Path from typing import Any +import httpx + logger = logging.getLogger("obsigate.file_chat") CHAT_DIR = Path("data/chats") @@ -33,6 +37,11 @@ MAX_TEXT = 4000 # characters per message GLOBAL_VAULT = "__global__" GLOBAL_PATH = "general" +# #191 — private (2 users) conversations reuse the same store: the vault is +# the reserved sentinel and the path is the sorted username pair, so the +# storage key never depends on who asks. +DM_VAULT = "__dm__" + # #190 — attachments (image/video) live outside the vaults. UPLOAD_DIR = Path("data/chat_uploads") MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB per attachment @@ -41,6 +50,10 @@ ALLOWED_ATTACH_EXT = { ".mp4", ".webm", ".ogg", ".mov", ".m4v", } +# #191 — link preview fetch budget +PREVIEW_TIMEOUT = 5.0 # seconds +PREVIEW_MAX_BYTES = 512 * 1024 # only the head of the page is parsed + def _chat_file(vault: str, path: str) -> Path: """Return the chat file for *(vault, path)* (hashed, traversal-proof).""" @@ -85,11 +98,13 @@ def add_message( user: str, text: str, attachment: dict[str, Any] | None = None, + preview: 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. + *attachment* (#190) is ``{name, url, mime, kind}`` for image/video/url; + *preview* (#191) is the OpenGraph card of the first URL in *text*. """ text = (text or "").strip()[:MAX_TEXT] msg: dict[str, Any] = { @@ -100,6 +115,8 @@ def add_message( } if attachment: msg["attachment"] = attachment + if preview: + msg["preview"] = preview doc = _read(vault, path) return _append(vault, path, doc, msg) @@ -120,6 +137,47 @@ def _append( return msg +# --- #191 : messages privés (2 utilisateurs) ------------------------------- + +def dm_path(user_a: str, user_b: str) -> str: + """Storage path for the private conversation between two users. + + The pair is sorted so both participants address the same document. + """ + return "|".join(sorted([user_a, user_b])) + + +def get_dm_messages(user_a: str, user_b: str) -> list[dict[str, Any]]: + """Return the private history between two users (chronological).""" + return get_messages(DM_VAULT, dm_path(user_a, user_b)) + + +def add_dm_message( + user_a: str, + user_b: str, + author: str, + text: str, + attachment: dict[str, Any] | None = None, + preview: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Append a private message. Returns the stored message.""" + return add_message(DM_VAULT, dm_path(user_a, user_b), author, text, attachment, preview) + + +# --- #191 : suppression ----------------------------------------------------- + +def delete_message(vault: str, path: str, message_id: str) -> bool: + """Remove one message from a conversation. True when it existed.""" + doc = _read(vault, path) + messages = list(doc.get("messages", [])) + kept = [m for m in messages if m.get("id") != message_id] + if len(kept) == len(messages): + return False + doc["messages"] = kept + _write(_chat_file(vault, path), doc) + return True + + # --- #190 : chat général (conversation centrale, hors fichier) ------------- def get_global_messages() -> list[dict[str, Any]]: @@ -131,9 +189,10 @@ def add_global_message( user: str, text: str, attachment: dict[str, Any] | None = None, + preview: 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) + return add_message(GLOBAL_VAULT, GLOBAL_PATH, user, text, attachment, preview) def save_attachment(filename: str, data: bytes) -> dict[str, Any]: @@ -166,6 +225,75 @@ def save_attachment(filename: str, data: bytes) -> dict[str, Any]: } +# --- #191 : link preview ---------------------------------------------------- + +_URL_RE = re.compile(r"https?://[^\s<>\"']+") +_PREVIEW_CACHE: dict[str, dict[str, Any] | None] = {} +PREVIEW_CACHE_MAX = 200 + + +def _og(content: str, prop: str) -> str: + """Extract one OpenGraph/```` value from an HTML head (regex).""" + for pattern in ( + rf'<meta[^>]+(?:property|name)="{prop}"[^>]+content="([^"]*)"', + rf'<meta[^>]+content="([^"]*)"[^>]+(?:property|name)="{prop}"', + ): + m = re.search(pattern, content, re.IGNORECASE) + if m: + return html.unescape(m.group(1)).strip()[:300] + if prop == "og:title": + m = re.search(r"<title[^>]*>([^<]*)", content, re.IGNORECASE) + if m: + return html.unescape(m.group(1)).strip()[:300] + return "" + + +def build_preview(text: str) -> dict[str, Any] | None: + """Fetch OpenGraph metadata for the first URL in *text* (#191). + + SSRF-guarded (reuses the web-tool guard), size/time capped, cached in a + bounded dict. Returns ``{url, title, description, image, site}`` or + ``None`` when there is no URL / the fetch fails (never raises: a dead + link must not block the message). + """ + m = _URL_RE.search(text or "") + if not m: + return None + url = m.group(0).rstrip(".,;:!?)") + if url in _PREVIEW_CACHE: + cached = _PREVIEW_CACHE[url] + return dict(cached) if cached else None + try: + from backend.tools.web import USER_AGENT, _assert_public_http_url + + _assert_public_http_url(url) + resp = httpx.get( + url, + headers={"User-Agent": USER_AGENT, "Accept": "text/html,*/*"}, + timeout=PREVIEW_TIMEOUT, + follow_redirects=True, + ) + if resp.status_code >= 400: + raise ValueError(f"HTTP {resp.status_code}") + body = resp.text[:PREVIEW_MAX_BYTES] + preview = { + "url": url, + "title": _og(body, "og:title") or _og(body, "og:site_name"), + "description": _og(body, "og:description"), + "image": _og(body, "og:image"), + "site": _og(body, "og:site_name") or (url.split("/")[2] if "/" in url[8:] else url), + } + if not preview["title"]: + raise ValueError("pas de titre") + except Exception as e: + logger.debug("link preview failed for %s: %s", url, e) + preview = None + if len(_PREVIEW_CACHE) >= PREVIEW_CACHE_MAX: + _PREVIEW_CACHE.pop(next(iter(_PREVIEW_CACHE))) # oldest first (dict order) + _PREVIEW_CACHE[url] = preview + return dict(preview) if preview else None + + def attachment_path(name: str) -> Path | None: """Resolve an attachment by its stored name (UUID+ext only, no traversal).""" p = Path(name) diff --git a/backend/routers/file_chat.py b/backend/routers/file_chat.py index 6039086..b785960 100644 --- a/backend/routers/file_chat.py +++ b/backend/routers/file_chat.py @@ -22,8 +22,9 @@ from fastapi.responses import FileResponse from backend import file_chat as _store from backend.auth.middleware import check_vault_access, require_auth +from backend.auth.user_store import get_all_users, get_user from backend.indexer import get_vault_data -from backend.schemas import ChatHistoryResponse, ChatMessageResponse +from backend.schemas import ChatHistoryResponse, ChatMessageResponse, StatusResponse from backend.services.paths import resolve_safe_path from backend.sse import sse_manager @@ -104,8 +105,10 @@ async def api_chat_post( text = str(body.get("text") or "") if not text.strip(): raise HTTPException(400, "text is required") + # #191 — best-effort link preview: a dead/slow URL never blocks the post. msg = _store.add_global_message( - current_user.get("username", ""), text, _attachment(body) + current_user.get("username", ""), text, _attachment(body), + _store.build_preview(text), ) await sse_manager.broadcast( "chat_message", @@ -136,3 +139,105 @@ async def api_chat_attachment(name: str, current_user: dict[str, Any] = Depends( 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) + + +# --- #191 : suppression + messages privés ----------------------------------- + +def _owner_or_admin(msg_user: str, current_user: dict[str, Any]) -> None: + """A post may be deleted by its author or by an admin.""" + if current_user.get("role") != "admin" and current_user.get("username") != msg_user: + raise HTTPException(403, "Seul l'auteur ou un administrateur peut supprimer ce message") + + +def _find_and_authorize(vault: str, path: str, message_id: str, current_user: dict[str, Any]) -> dict[str, Any]: + """Locate *message_id* in the conversation and check the delete right.""" + for m in _store.get_messages(vault, path): + if m.get("id") == message_id: + _owner_or_admin(m.get("user", ""), current_user) + return m + raise HTTPException(404, "Message not found") + + +@router.delete("/api/chat/{message_id}", response_model=StatusResponse) +async def api_chat_delete(message_id: str, current_user: dict[str, Any] = Depends(require_auth)): + """Delete a message from the general chat (author or admin, #191).""" + _find_and_authorize(_store.GLOBAL_VAULT, _store.GLOBAL_PATH, message_id, current_user) + if not _store.delete_message(_store.GLOBAL_VAULT, _store.GLOBAL_PATH, message_id): + raise HTTPException(404, "Message not found") + await sse_manager.broadcast( + "chat_deleted", + {"vault": _store.GLOBAL_VAULT, "path": _store.GLOBAL_PATH, "id": message_id}, + ) + return {"status": "deleted"} + + +@router.get("/api/chat/users", response_model=list[dict[str, Any]]) +async def api_chat_users(current_user: dict[str, Any] = Depends(require_auth)): + """Usernames available for a private conversation (chat DM picker, #191). + + Every authenticated member may see who else is around — this is a + self-hosted portal, not a directory that needs hiding. + """ + me = current_user.get("username", "") + return [ + {"username": u.get("username", ""), "display_name": u.get("display_name") or u.get("username", "")} + for u in get_all_users() + if u.get("username") and u.get("username") != me + ] + + +def _dm_peer(username: str, current_user: dict[str, Any]) -> str: + """Validate the DM peer exists and is not ourselves.""" + if not username or username == current_user.get("username"): + raise HTTPException(400, "Destinataire invalide") + if not get_user(username): + raise HTTPException(404, "Utilisateur inconnu") + return username + + +@router.get("/api/chat/dm/{username}", response_model=ChatHistoryResponse) +async def api_chat_dm_history(username: str, current_user: dict[str, Any] = Depends(require_auth)): + """Private history with *username* (#191).""" + peer = _dm_peer(username, current_user) + return {"messages": _store.get_dm_messages(current_user["username"], peer)} + + +@router.post("/api/chat/dm/{username}", response_model=ChatMessageResponse) +async def api_chat_dm_post( + username: str, + body: dict[str, Any] = Body(...), + current_user: dict[str, Any] = Depends(require_auth), +): + """Post a private message and broadcast it to both participants (#191).""" + peer = _dm_peer(username, current_user) + text = str(body.get("text") or "") + if not text.strip(): + raise HTTPException(400, "text is required") + msg = _store.add_dm_message( + current_user["username"], peer, current_user.get("username", ""), text, + _attachment(body), _store.build_preview(text), + ) + # Same shape as the general chat so the client routes on vault/path. + await sse_manager.broadcast( + "chat_message", + {"vault": _store.DM_VAULT, "path": _store.dm_path(current_user["username"], peer), "message": msg}, + ) + return {"message": msg, "status": "ok"} + + +@router.delete("/api/chat/dm/{username}/{message_id}", response_model=StatusResponse) +async def api_chat_dm_delete( + username: str, + message_id: str, + current_user: dict[str, Any] = Depends(require_auth), +): + """Delete one private message (author or admin, #191).""" + peer = _dm_peer(username, current_user) + vault, path = _store.DM_VAULT, _store.dm_path(current_user["username"], peer) + _find_and_authorize(vault, path, message_id, current_user) + if not _store.delete_message(vault, path, message_id): + raise HTTPException(404, "Message not found") + await sse_manager.broadcast( + "chat_deleted", {"vault": vault, "path": path, "id": message_id} + ) + return {"status": "deleted"} diff --git a/backend/schemas.py b/backend/schemas.py index 942561d..98c7c91 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -162,6 +162,10 @@ class ChatMessageItem(BaseModel): default=None, description="Optional image/video/url attachment {name, url, mime, kind}", ) + preview: dict[str, Any] | None = Field( + default=None, + description="Link preview card {url, title, description, image, site} (#191)", + ) class ChatHistoryResponse(BaseModel): diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index e780a0b..b96ab3c 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.58.0" +version = "2.60.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index b25a35f..6be4377 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.58.0" +version = "2.60.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 4b916dd..c32669d 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.58.0", + "version": "2.60.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 e54695f..d1a03f2 100644 --- a/docs/GUIDES/PRISE_EN_MAIN.md +++ b/docs/GUIDES/PRISE_EN_MAIN.md @@ -194,6 +194,14 @@ des **onglets** (avec possibilité de vue multi-panneaux / split view). - **📎** 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. +- **🗑** (survol d'un message) : supprime votre message — un administrateur + peut supprimer celui de n'importe qui. Confirmation demandée, le retrait + est immédiat pour tout le monde. +- **Messages privés** : la rangée de pastilles au-dessus du fil propose + **Général** puis chaque utilisateur ; choisissez un destinataire pour une + conversation à deux, avec son propre compteur de non-lus. +- **Link preview** : coller une URL suffit — titre, description, image et + site s'affichent en carte cliquable sous le message (si le site répond). --- diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 59f4aa4..6f8c3cd 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 | #191 | Fonctionnalité | `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` | **#191 — chat : suppression de post, messages privés, boîte compacte, link preview** : (A) `DELETE /api/chat/{id}` + `/api/chat/dm/{peer}/{id}` (auteur ou admin, 403 sinon) avec diffusion SSE `chat_deleted` et bouton 🗑 au survol côté client (`confirm()` natif) ; (B) DM 2 utilisateurs : vault `__dm__` + `dm_path(a,b)` trié, `GET /api/chat/users` (destinataires sans soi-même, aucun hash exposé), routes `GET/POST /api/chat/dm/{user}` (404 inconnu, 400 DM à soi), rangée de canaux `.chat-channels` dans la sidebar, non-lus par canal (compteurs localStorage, anciens timestamps/compteurs mélangés unifiés) ; (C) bouton d'envoi réduit à une icône dans les deux formulaires ; (D) `build_preview(text)` : 1ʳᵉ URL → garde SSRF `_assert_public_http_url` → `httpx.get` 5 s/512 Ko → OG (titre/desc/image/site) en carte cliquable, best-effort (échec → message sans carte), cache 200 entrées. Bug trouvé en cours de route : `max_redirects` n'existe pas sur `httpx.get` → TypeError avalé par le `try` = previews jamais générées en prod, invisible des tests (ils mockaient `build_preview`) ; arg retiré + `test_build_preview_happy_path_parses_og` (httpx mocké) ajouté. Tests : pytest 45 file_chat (44→45, +13 sur #191), JSDOM filechat 16 (+5) | | 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 c2eb7c2..e75959e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.58.0 | **Dernière mise à jour :** 2026-10-08 +> **Version :** 2.60.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,7 +731,8 @@ | 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) | +| 190 | Chat — suivi du document, chat général en onglet sidebar (pièces jointes, non lus, recherche), onglets au-dessus du filtre | 2.58.0 | [features/file-chat-169.md](./features/file-chat-169.md) | +| 191 | Chat — suppression de post, messages privés 2 utilisateurs (canaux), boîte d'édition compacte, link preview OG | 2.60.0 | [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 5725b2f..cc5ac6f 100644 --- a/docs/features/file-chat-169.md +++ b/docs/features/file-chat-169.md @@ -90,6 +90,67 @@ - `initSidebarFilter` (`sidebar.js`) : `routeFilter`/`routeClear` routent vers `filterChatMessages()` (texte **et** auteur, insensible à la casse). +## #191 — Suppression, messages privés, boîte compacte, link preview (2026-10-08) + +### A. Suppression d'un post + +- `DELETE /api/chat/{id}` et `DELETE /api/chat/dm/{peer}/{id}` : + **auteur du message ou admin** (403 sinon), 404 si l'id n'existe pas. +- Store : `delete_message(vault, path, id)` — écriture atomique, + `False` si absent ; broadcast SSE `chat_deleted` `{vault, path, id}`. +- Frontend : bouton 🗑 (`.file-chat-del`, visible au survol) rendu dans + `_messageEl` uniquement si l'URL de suppression est fournie **et** + que l'utilisateur est auteur/admin ; `confirm()` natif ; retrait local + + retrait en direct chez tous via `onChatDeleted` (relais `sync.js`). + +### B. Messages privés 2 utilisateurs + +- Store : `DM_VAULT = "__dm__"`, `dm_path(a, b)` = paire **triée** → les + deux participants lisent/écrivent le même document, indépendamment de + l'ordre. +- Routes : `GET /api/chat/users` (destinataires — usernames + + display_names, **sans soi-même**, aucun hash/mot de passe exposé), + `GET/POST /api/chat/dm/{username}` (404 utilisateur inconnu, 400 DM à + soi), `DELETE /api/chat/dm/{username}/{id}`. +- Frontend : rangée de pastilles `.chat-channels` (« Général » + un + bouton par utilisateur) au-dessus du fil ; `_channel` état courant, + `_channelKey`/`_channelUrl`/`_channelDelUrl` ; placeholder dédié + `chat.placeholder_dm` ; non-lus **par canal** (compteurs dans + `localStorage`, l'ancien mélange timestamp/compteur est devenu un + compteur unique `{"general": n, "dm:x": n}`, remis à zéro à l'ouverture, + pastille = total) ; les DM SSE routés par `_pairOf(path)`. + +### C. Boîte d'édition compacte + +- Bouton d'envoi réduit à une icône `send` (`.file-chat-send-icon`) dans + les deux formulaires (panneau document + sidebar) ; la saisie garde + toute la largeur. `safeCreateIcons()` à la fin du `_renderShell`. + +### D. Link preview + +- `build_preview(text)` (store) : 1ʳᵉ URL du texte → garde SSRF + `_assert_public_http_url` (host privé/loopback rejeté, schémas non + http refusés) → `httpx.get` 5 s, 512 Ko, `follow_redirects` (limite + httpx = 20) → parse OG par regex (`og:title` avec repli ``, + `og:description`, `og:image`, `og:site_name`) → + `{url, title, description, image, site}`. +- **Best-effort** : n'importe quelle erreur (URL morte, timeout, garde) + → `None`, le message passe quand même ; cache borné **200 entrées**. +- Frontend : `_previewEl` rend une carte cliquable (vignette lazy + `no-referrer`, site en capitales, titre/description tronqués 2 lignes) + sous le corps du message. +- Schéma : `ChatMessageItem.preview`. + +### Tests #191 + +- pytest `TestDelete` (3), `TestPrivateChat` (8), `TestLinkPreview` (6, + dont le happy path avec `httpx.get` mocké — un `max_redirects` + inexistante sur `httpx.get` levait un TypeError silencieusement avalé + par le `try`, testé par `test_build_preview_happy_path_parses_og`). +- JSDOM `filechat.test.mjs` +5 : carte preview, bouton supprimer + (droit auteur/admin), `onChatDeleted`, compteurs non-lus, + routage DM isolé du général (16 au total). + ## 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 9a522f9..6a1b2ff 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1072,6 +1072,13 @@ role="tabpanel" aria-labelledby="sidebar-tab-chat" > + <!-- #191 : rangée de canaux (Général + utilisateurs) --> + <div + id="sidebar-panel-chat-channels" + class="chat-channels" + role="tablist" + aria-label="Canaux de discussion" + ></div> <div id="sidebar-panel-chat-list" class="file-chat-list sidebar-chat-list" @@ -1090,7 +1097,7 @@ > <i data-lucide="paperclip" - style="width: 16px; height: 16px" + style="width: 15px; height: 15px" ></i> </button> <input @@ -1102,7 +1109,18 @@ autocomplete="off" aria-label="Écrire un message" /> - <button type="submit" class="file-chat-send">Envoyer</button> + <!-- #191 : bouton compact (icône) pour laisser la place à la saisie --> + <button + type="submit" + class="file-chat-send file-chat-send-icon" + title="Envoyer" + aria-label="Envoyer" + > + <i + data-lucide="send" + style="width: 15px; height: 15px" + ></i> + </button> </form> </div> diff --git a/frontend/js/filechat.js b/frontend/js/filechat.js index 6dc285c..7797e59 100644 --- a/frontend/js/filechat.js +++ b/frontend/js/filechat.js @@ -12,20 +12,23 @@ import { api, AuthManager } from "./auth.js"; import { t } from "./i18n.js"; import { showToast } from "./ui.js"; -import { escapeHtml } from "./utils.js"; +import { escapeHtml, safeCreateIcons } from "./utils.js"; const PANEL_ID = "file-chat-panel"; const GLOBAL_VAULT = "__global__"; -const UNREAD_KEY = "obsigate-chat-unread"; // {general: ts} +const DM_VAULT = "__dm__"; +const UNREAD_KEY = "obsigate-chat-unread"; // {general: ts, "dm:<user>": ts} let _vault = null; let _path = null; let _title = ""; let _sending = false; -// État du chat général (onglet sidebar) -let _generalMessages = []; -let _generalLoaded = false; +// État du chat sidebar (#190 B + #191 : canaux général / privé) +let _channel = { type: "general" }; // ou {type: "dm", user: "bob"} +let _channelMessages = []; +let _channelLoaded = false; +let _usersCache = null; // liste des destinataires DM (une seule requête) let _pendingAttach = null; // --------------------------------------------------------------------------- @@ -90,7 +93,65 @@ function _attachEl(att) { return null; } -export function _messageEl(m, currentUser) { +// #191 — suppression : auteur ou admin, avec confirmation native. +function _canDelete(m, currentUser) { + const u = AuthManager.getUser(); + if (!u || !u.username) return false; + return u.role === "admin" || m.user === u.username; +} + +async function _deleteMessage(delUrl, id, wrap) { + if (!window.confirm(t("chat.delete_confirm"))) return; + try { + await api(`${delUrl}/${encodeURIComponent(id)}`, { method: "DELETE" }); + wrap.remove(); + } catch (_) { + showToast(t("chat.delete_error"), "error"); + } +} + +// #191 — carte de lien : site, titre, description, vignette. +function _previewEl(p) { + const card = document.createElement("a"); + card.className = "file-chat-preview"; + card.href = p.url; + card.target = "_blank"; + card.rel = "noopener noreferrer"; + if (p.image) { + const img = document.createElement("img"); + img.className = "file-chat-preview-img"; + img.src = p.image; + img.alt = ""; + img.loading = "lazy"; + img.referrerPolicy = "no-referrer"; + card.appendChild(img); + } + const meta = document.createElement("div"); + meta.className = "file-chat-preview-meta"; + const site = document.createElement("div"); + site.className = "file-chat-preview-site"; + site.textContent = p.site || ""; + const title = document.createElement("div"); + title.className = "file-chat-preview-title"; + title.textContent = p.title || p.url; + meta.appendChild(site); + meta.appendChild(title); + if (p.description) { + const desc = document.createElement("div"); + desc.className = "file-chat-preview-desc"; + desc.textContent = p.description; + meta.appendChild(desc); + } + card.appendChild(meta); + return card; +} + +/** + * Render one message. + * *delUrl* (#191) is the DELETE endpoint base for this conversation; when + * provided the author/admin sees a trash button on their own messages. + */ +export function _messageEl(m, currentUser, delUrl) { const mine = currentUser && m.user === currentUser; const wrap = document.createElement("div"); wrap.className = "file-chat-msg" + (mine ? " mine" : ""); @@ -100,6 +161,16 @@ export function _messageEl(m, currentUser) { meta.innerHTML = `<span class="file-chat-user">${_esc(m.user)}</span>` + `<span class="file-chat-ts">${_esc(_formatTime(m.ts))}</span>`; + if (delUrl && _canDelete(m, currentUser)) { + const del = document.createElement("button"); + del.type = "button"; + del.className = "file-chat-del"; + del.title = t("chat.delete"); + del.setAttribute("aria-label", t("chat.delete")); + del.textContent = "🗑"; + del.addEventListener("click", () => _deleteMessage(delUrl, m.id, wrap)); + meta.appendChild(del); + } const body = document.createElement("div"); body.className = "file-chat-body"; _fillBody(body, m.text); @@ -107,10 +178,11 @@ export function _messageEl(m, currentUser) { if (m.text) wrap.appendChild(body); const att = _attachEl(m.attachment); if (att) wrap.appendChild(att); + if (m.preview) wrap.appendChild(_previewEl(m.preview)); return wrap; } -export function renderChatMessages(container, messages, currentUser) { +export function renderChatMessages(container, messages, currentUser, delUrl) { container.innerHTML = ""; if (!messages || messages.length === 0) { const empty = document.createElement("div"); @@ -119,16 +191,16 @@ export function renderChatMessages(container, messages, currentUser) { container.appendChild(empty); return; } - messages.forEach((m) => container.appendChild(_messageEl(m, currentUser))); + messages.forEach((m) => container.appendChild(_messageEl(m, currentUser, delUrl))); container.scrollTop = container.scrollHeight; } -function _appendMessage(list, m) { +function _appendMessage(list, m, delUrl) { const empty = list.querySelector(".file-chat-empty"); if (empty) empty.remove(); const id = m.id; if (id && list.querySelector(`[data-id="${CSS.escape(id)}"]`)) return false; - list.appendChild(_messageEl(m, _currentUser())); + list.appendChild(_messageEl(m, _currentUser(), delUrl)); list.scrollTop = list.scrollHeight; return true; } @@ -203,8 +275,11 @@ function _renderShell(panel) { 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"); + send.className = "file-chat-send file-chat-send-icon"; + send.title = t("chat.send"); + send.setAttribute("aria-label", t("chat.send")); + // #191 : icône compacte — la saisie garde toute la largeur + send.innerHTML = '<i data-lucide="send" style="width:15px;height:15px"></i>'; form.appendChild(input); form.appendChild(send); form.addEventListener("submit", (e) => { @@ -215,6 +290,7 @@ function _renderShell(panel) { panel.appendChild(header); panel.appendChild(list); panel.appendChild(form); + safeCreateIcons(); // #191 — hydrate l'icône d'envoi } export async function openFileChat(vault, path, title) { @@ -272,21 +348,76 @@ async function _send(input) { } // --------------------------------------------------------------------------- -// 2. Chat général, onglet sidebar (#190 B) +// 2. Chat sidebar (#190 B) + canaux privé #191 // --------------------------------------------------------------------------- function _sidebarList() { return document.getElementById("sidebar-panel-chat-list"); } -/** Charge (ou recharge) l'historique du chat général dans la sidebar. */ +/** Clé de la conversation courante (unread + sélection). */ +function _channelKey() { + return _channel.type === "dm" ? `dm:${_channel.user}` : "general"; +} + +/** URL d'API de base du canal courant. */ +function _channelUrl() { + return _channel.type === "dm" + ? `/api/chat/dm/${encodeURIComponent(_channel.user)}` + : "/api/chat"; +} + +/** URL de base de suppression du canal courant. */ +function _channelDelUrl() { + return _channel.type === "dm" + ? `/api/chat/dm/${encodeURIComponent(_channel.user)}` + : "/api/chat"; +} + +/** Rend la rangée de canaux : Général + un bouton par utilisateur. */ +function _renderChannels(users) { + const holder = document.getElementById("sidebar-panel-chat-channels"); + if (!holder) return; + holder.innerHTML = ""; + const mk = (label, active, onClick, title) => { + const b = document.createElement("button"); + b.type = "button"; + b.className = "chat-channel" + (active ? " active" : ""); + b.textContent = label; + if (title) b.title = title; + b.addEventListener("click", onClick); + holder.appendChild(b); + return b; + }; + mk(t("chat.channel_general"), _channel.type === "general", () => switchChannel(null)); + (users || []).forEach((u) => { + mk(u.display_name || u.username, _channel.type === "dm" && _channel.user === u.username, + () => switchChannel(u.username), `@${u.username}`); + }); +} + +/** Charge la liste des utilisateurs (une fois) puis rend les canaux. */ +async function _loadUsers() { + if (_usersCache) return _usersCache; + try { + const data = await api("/api/chat/users"); + _usersCache = Array.isArray(data) ? data : []; + } catch (_) { + _usersCache = []; + } + return _usersCache; +} + +/** Charge (ou recharge) l'historique du canal courant dans la sidebar. */ export async function openSidebarChat() { const list = _sidebarList(); if (!list) return; + const users = await _loadUsers(); + _renderChannels(users); try { - const data = await api("/api/chat"); - _generalMessages = (data.messages || []).slice(); - _generalLoaded = true; - renderChatMessages(list, _generalMessages, _currentUser()); + const data = await api(_channelUrl()); + _channelMessages = (data.messages || []).slice(); + _channelLoaded = true; + renderChatMessages(list, _channelMessages, _currentUser(), _channelDelUrl()); _clearUnread(); } catch (_) { list.innerHTML = ""; @@ -297,17 +428,27 @@ export async function openSidebarChat() { } } -/** Recherche dans le chat général (barre de filtre sidebar) — #190 C. */ +/** Change de canal (#191) : null = général, sinon l'username du pair. */ +export function switchChannel(user) { + _channel = user ? { type: "dm", user } : { type: "general" }; + const input = _sendInput(); + if (input) { + input.placeholder = user ? t("chat.placeholder_dm", { user }) : t("chat.placeholder"); + } + openSidebarChat(); +} + +/** Recherche dans le canal courant (barre de filtre sidebar) — #190 C. */ export function filterChatMessages(query) { const list = _sidebarList(); - if (!list || !_generalLoaded) return; + if (!list || !_channelLoaded) return; const q = String(query || "").toLowerCase().trim(); const shown = q - ? _generalMessages.filter((m) => + ? _channelMessages.filter((m) => ((m.text || "") + " " + (m.user || "")).toLowerCase().includes(q), ) - : _generalMessages; - renderChatMessages(list, shown, _currentUser()); + : _channelMessages; + renderChatMessages(list, shown, _currentUser(), _channelDelUrl()); } function _sendInput() { @@ -344,14 +485,14 @@ export async function sendGeneralMessage() { const body = { text }; if (_pendingAttach) body.attachment = _pendingAttach; try { - const data = await api("/api/chat", { method: "POST", body: JSON.stringify(body) }); + const data = await api(_channelUrl(), { method: "POST", body: JSON.stringify(body) }); input.value = ""; _pendingAttach = null; _renderPending(); const list = _sidebarList(); if (list) { - _generalMessages.push(data.message); - _appendMessage(list, data.message); + _channelMessages.push(data.message); + _appendMessage(list, data.message, _channelDelUrl()); } input.focus(); } catch (_) { @@ -387,7 +528,9 @@ export async function pickAttachment() { }); } -// --- Messages non lus (#190 B) -------------------------------------------- +// --- Messages non lus (#190 B / #191) -------------------------------------- +// Compteurs par clé de canal : {"general": 2, "dm:bob": 1}. Ouverture du +// canal → remise à zéro de sa clé. function _readUnread() { try { return JSON.parse(localStorage.getItem(UNREAD_KEY) || "{}") || {}; @@ -396,13 +539,23 @@ function _readUnread() { } } -function _clearUnread() { - const u = _readUnread(); - u.general = Math.floor(Date.now() / 1000); +function _writeUnread(u) { try { localStorage.setItem(UNREAD_KEY, JSON.stringify(u)); } catch (_) { /* quota */ } - _renderBadge(0); +} + +function _clearUnread() { + const u = _readUnread(); + u[_channelKey()] = 0; + _writeUnread(u); + _renderBadge(_totalUnread()); + _renderChannels(_usersCache); +} + +function _totalUnread() { + const u = _readUnread(); + return Object.values(u).reduce((n, v) => n + (typeof v === "number" ? v : 0), 0); } function _renderBadge(count) { @@ -421,13 +574,9 @@ function _renderBadge(count) { badge.textContent = count > 99 ? "99+" : String(count); } -/** Compte les messages reçus depuis la dernière visite. */ -export function refreshUnreadBadge(messages) { - const list = messages || _generalMessages; - if (!list.length) return _renderBadge(0); - const seen = _readUnread().general || 0; - const count = list.filter((m) => (m.ts || 0) > seen && m.user !== _currentUser()).length; - _renderBadge(count); +/** Affiche le total des messages non lus (tous canaux confondus). */ +export function refreshUnreadBadge() { + _renderBadge(_totalUnread()); } /** Initialisation : formulaire + raccourcis clavier. */ @@ -456,18 +605,20 @@ export function initSidebarChat() { // --------------------------------------------------------------------------- export function onChatMessage(data) { if (!data || !data.message) return; - const isGeneral = data.vault === GLOBAL_VAULT; - if (isGeneral) { - _generalMessages.push(data.message); - const list = _sidebarList(); + // --- Canaux sidebar : général (#190) ou privé (#191) --- + if (data.vault === GLOBAL_VAULT || data.vault === DM_VAULT) { + const key = data.vault === DM_VAULT ? `dm:${_pairOf(data.path)}` : "general"; + const isActive = key === _channelKey(); const panel = document.getElementById("sidebar-panel-chat"); const visible = panel && panel.classList.contains("active"); - if (list && visible) { - _appendMessage(list, data.message); + if (isActive) _channelMessages.push(data.message); + if (visible && isActive) { + _appendMessage(_sidebarList(), data.message, _channelDelUrl()); _clearUnread(); } else if (data.message.user !== _currentUser()) { - refreshUnreadBadge(_generalMessages); + _bumpUnread(key); + refreshUnreadBadge(); showToast(t("chat.new_message", { user: data.message.user }), "info"); } return; @@ -486,3 +637,23 @@ export function onChatMessage(data) { if (data.message.user === _currentUser()) return; // son propre message showToast(t("chat.new_message", { user: data.message.user }), "info"); } + +/** Le path DM est « alice|bob » trié : on en déduit l'interlocuteur. */ +function _pairOf(path) { + const me = _currentUser(); + const parts = String(path || "").split("|"); + return parts[0] === me ? parts[1] : parts[0]; +} + +function _bumpUnread(key) { + const u = _readUnread(); + u[key] = (u[key] || 0) + 1; + _writeUnread(u); +} + +/** Suppression d'un post (#191) — relayée par sync.js. */ +export function onChatDeleted(data) { + if (!data || !data.id) return; + const sel = `[data-id="${CSS.escape(data.id)}"]`; + document.querySelectorAll(sel).forEach((el) => el.remove()); +} diff --git a/frontend/js/sync.js b/frontend/js/sync.js index b2810ac..bf5109b 100644 --- a/frontend/js/sync.js +++ b/frontend/js/sync.js @@ -120,7 +120,7 @@ export const IndexUpdateManager = (() => { } }); - // #169 — Chat par fichier : relais du broadcast SSE vers le panneau + // #169/#190 — Chat : relais des broadcasts SSE vers les panneaux eventSource.addEventListener("chat_message", (e) => { try { const data = JSON.parse(e.data); @@ -130,6 +130,16 @@ export const IndexUpdateManager = (() => { } }); + // #191 — suppression d'un post + eventSource.addEventListener("chat_deleted", (e) => { + try { + const data = JSON.parse(e.data); + import("./filechat.js").then((m) => m.onChatDeleted(data)).catch(() => {}); + } catch (err) { + console.error("SSE parse error:", err); + } + }); + eventSource.onerror = () => { connectionState = "disconnected"; _updateBadge(); diff --git a/frontend/locales/en.json b/frontend/locales/en.json index c2b7418..886dc47 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -320,6 +320,10 @@ "backups.title": "Backups", "button.cancel": "Cancel", "chat.attach_error": "Attachment upload failed", + "chat.channel_general": "General", + "chat.delete": "Delete this message", + "chat.delete_confirm": "Delete this message?", + "chat.delete_error": "Delete failed", "chat.attach_remove": "Remove attachment", "chat.button": "Chat", "chat.button_title": "Discuss this file", @@ -328,6 +332,7 @@ "chat.load_error": "Could not load the discussion", "chat.new_message": "New message from {user}", "chat.placeholder": "Write a message…", + "chat.placeholder_dm": "Private message to {user}…", "chat.send": "Send", "chat.send_error": "Failed to send the message", "chat.tab": "General chat", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 411d7b5..5297a63 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -320,6 +320,10 @@ "backups.title": "Backups", "button.cancel": "Annuler", "chat.attach_error": "Échec de l'envoi de la pièce jointe", + "chat.channel_general": "Général", + "chat.delete": "Supprimer ce message", + "chat.delete_confirm": "Supprimer ce message ?", + "chat.delete_error": "Échec de la suppression", "chat.attach_remove": "Retirer la pièce jointe", "chat.button": "Chat", "chat.button_title": "Discuter de ce fichier", @@ -328,6 +332,7 @@ "chat.load_error": "Impossible de charger la discussion", "chat.new_message": "Nouveau message de {user}", "chat.placeholder": "Écrire un message…", + "chat.placeholder_dm": "Message privé à {user}…", "chat.send": "Envoyer", "chat.send_error": "Échec de l'envoi du message", "chat.tab": "Chat général", diff --git a/frontend/style.css b/frontend/style.css index 09181aa..4776b47 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -13887,6 +13887,116 @@ body.reading-mode .file-actions { } } +/* --------------------------------------------------------------------------- + #191 — Chat : canaux, suppression, bouton compact, link preview +--------------------------------------------------------------------------- */ +.chat-channels { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px 10px 0; +} +.chat-channel { + border: 1px solid var(--border, #21262d); + border-radius: 999px; + background: var(--bg-secondary, #161b22); + color: var(--text-muted, #8b949e); + font-size: 0.78rem; + padding: 4px 10px; + cursor: pointer; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.chat-channel:hover { + color: var(--text-primary, #e6edf3); + border-color: var(--accent, #58a6ff); +} +.chat-channel.active { + background: var(--accent, #58a6ff); + border-color: var(--accent, #58a6ff); + color: #fff; +} +/* Bouton d'envoi compact : une icône, la saisie garde la largeur */ +.file-chat-send-icon { + padding: 6px 8px; + display: flex; + align-items: center; + justify-content: center; +} +/* Suppression d'un post */ +.file-chat-del { + border: none; + background: transparent; + cursor: pointer; + font-size: 0.75rem; + padding: 0 2px; + opacity: 0; + transition: opacity 0.12s; +} +.file-chat-msg:hover .file-chat-del { + opacity: 0.7; +} +.file-chat-del:hover { + opacity: 1 !important; +} +/* Link preview (#191) */ +.file-chat-preview { + display: flex; + gap: 10px; + margin-top: 8px; + padding: 8px; + border: 1px solid var(--border, #21262d); + border-left: 3px solid var(--accent, #58a6ff); + border-radius: 8px; + background: var(--bg-secondary, #161b22); + text-decoration: none; + color: inherit; + max-width: 100%; +} +.file-chat-preview:hover { + border-color: var(--accent, #58a6ff); +} +.file-chat-preview-img { + width: 72px; + height: 72px; + object-fit: cover; + border-radius: 6px; + flex-shrink: 0; + background: var(--bg-hover, rgba(128, 128, 128, 0.12)); +} +.file-chat-preview-meta { + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +.file-chat-preview-site { + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--accent, #58a6ff); +} +.file-chat-preview-title { + font-size: 0.85rem; + font-weight: 600; + color: var(--text-primary, #e6edf3); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +.file-chat-preview-desc { + font-size: 0.75rem; + color: var(--text-muted, #8b949e); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + + /* --------------------------------------------------------------------------- #190 — Chat : en-tête ciblé, onglet sidebar, pièces jointes, badge --------------------------------------------------------------------------- */ diff --git a/package.json b/package.json index 726ca0b..9bd38ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.58.0", + "version": "2.60.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 index a315c32..2522387 100644 --- a/tests/frontend/filechat.test.mjs +++ b/tests/frontend/filechat.test.mjs @@ -69,10 +69,15 @@ globalThis.fetch = (url) => { const { initI18n } = await import(pathToFileURL(path.join(JS_DIR, "i18n.js")).href); await initI18n(); +// #191 — the delete right needs a logged-in identity +const { AuthManager } = await import(pathToFileURL(path.join(JS_DIR, "auth.js")).href); +AuthManager.getUser = () => ({ username: "bruno", role: "user" }); + const mod = await import("../../frontend/js/filechat.js"); const { renderChatMessages, onChatMessage, openFileChat, closeFileChat, toggleFileChat, followFileChat, openSidebarChat, filterChatMessages, refreshUnreadBadge, + onChatDeleted, } = mod; let pass = 0; @@ -211,6 +216,68 @@ test("#190 — URL rendered as a link, surrounding text stays plain", async () = assert.ok(msg.textContent.includes("du texte")); }); +test("#191 — preview card rendered with site/title/description", () => { + const list = document.createElement("div"); + renderChatMessages(list, [{ + id: "p1", user: "alice", text: "voir https://example.com", ts: 40, + preview: { url: "https://example.com", title: "Mon site", description: "Le desc", image: "", site: "example.com" }, + }], "bruno"); + const card = list.querySelector("a.file-chat-preview"); + assert.ok(card, "carte preview présente"); + assert.strictEqual(card.href, "https://example.com/"); + assert.strictEqual(card.target, "_blank"); + assert.strictEqual(card.querySelector(".file-chat-preview-title").textContent, "Mon site"); + assert.strictEqual(card.querySelector(".file-chat-preview-desc").textContent, "Le desc"); + assert.strictEqual(card.querySelector(".file-chat-preview-site").textContent, "example.com"); + renderChatMessages(list, [{ id: "p2", user: "alice", text: "https://x.test", ts: 41 }], "bruno"); + assert.strictEqual(list.querySelector("a.file-chat-preview"), null); +}); + +test("#191 — delete button hidden without delUrl, shown for own post", () => { + const list = document.createElement("div"); + renderChatMessages(list, [{ id: "d1", user: "bruno", text: "x", ts: 50 }], "bruno"); + assert.strictEqual(list.querySelector(".file-chat-del"), null); + renderChatMessages(list, [{ id: "d2", user: "bruno", text: "x", ts: 51 }], "bruno", "/api/chat"); + assert.ok(list.querySelector(".file-chat-del"), "bouton suppression visible"); + renderChatMessages(list, [{ id: "d3", user: "alice", text: "x", ts: 52 }], "bruno", "/api/chat"); + assert.strictEqual(list.querySelector(".file-chat-del"), null); +}); + +test("#191 — onChatDeleted removes the message from any list", async () => { + await openSidebarChat(); + const list = document.getElementById("sidebar-panel-chat-list"); + onChatMessage({ vault: "__global__", path: "general", message: { id: "del1", user: "alice", text: "a bientot", ts: 60 } }); + assert.strictEqual(list.querySelector('[data-id="del1"]') !== null, true); + onChatDeleted({ vault: "__global__", path: "general", id: "del1" }); + assert.strictEqual(list.querySelector('[data-id="del1"]'), null); +}); + +test("#191 — unread counters accumulate then reset on open", async () => { + localStorage.removeItem("obsigate-chat-unread"); + const panel = document.getElementById("sidebar-panel-chat"); + panel.classList.remove("active"); + onChatMessage({ vault: "__global__", path: "general", message: { id: "u-1", user: "bob", text: "1", ts: 70 } }); + onChatMessage({ vault: "__global__", path: "general", message: { id: "u-2", user: "bob", text: "2", ts: 71 } }); + let u = JSON.parse(localStorage.getItem("obsigate-chat-unread")); + assert.strictEqual(u.general, 2); + refreshUnreadBadge(); + assert.strictEqual(document.querySelector("#sidebar-tab-chat .chat-unread-badge").textContent, "2"); + panel.classList.add("active"); + await openSidebarChat(); + u = JSON.parse(localStorage.getItem("obsigate-chat-unread")); + assert.strictEqual(u.general, 0); + assert.strictEqual(document.querySelector("#sidebar-tab-chat .chat-unread-badge"), null); +}); + +test("#191 — DM routed by pair path, isolated from general", async () => { + const list = document.getElementById("sidebar-panel-chat-list"); + const before = list.querySelectorAll(".file-chat-msg").length; + onChatMessage({ vault: "__dm__", path: "alice|bruno", message: { id: "dm-1", user: "alice", text: "prive", ts: 80 } }); + const u = JSON.parse(localStorage.getItem("obsigate-chat-unread")); + assert.strictEqual(u["dm:alice"], 1); + assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, before); +}); + // Sequential execution: each case depends on panel state left by the previous. for (const [name, fn] of cases) { try { diff --git a/tests/test_file_chat.py b/tests/test_file_chat.py index 27b2c4c..cb4dd9b 100644 --- a/tests/test_file_chat.py +++ b/tests/test_file_chat.py @@ -8,6 +8,13 @@ import pytest from backend import file_chat as _store +def _login(client, username="admin", password="chab30") -> dict: + """Log in against the auth-enabled ``admin_client`` fixture (#191).""" + resp = client.post("/api/auth/login", json={"username": username, "password": password}) + assert resp.status_code == 200, resp.text + return {"Authorization": f"Bearer {resp.json()['access_token']}"} + + @pytest.fixture(autouse=True) def _isolated_chat_dir(tmp_path, monkeypatch): """Keep every test (store AND routes) out of the real ``data/chats``/ @@ -207,3 +214,153 @@ class TestAttachments: r = client.post("/api/chat", json={"text": "voici", "attachment": att}) assert r.status_code == 200 assert r.json()["message"]["attachment"]["kind"] == "image" + + +# --- #191 : suppression, messages privés, link preview ---------------------- + +class TestDelete: + def test_author_deletes_own_post(self, client, test_vault_dir): + msg = client.post("/api/chat", json={"text": "a supprimer"}).json()["message"] + r = client.delete(f"/api/chat/{msg['id']}") + assert r.status_code == 200 + assert client.get("/api/chat").json()["messages"] == [] + + def test_delete_unknown_404(self, client, test_vault_dir): + assert client.delete("/api/chat/nope").status_code == 404 + + def test_removed_message_cannot_be_deleted_twice(self, client, test_vault_dir): + msg = client.post("/api/chat", json={"text": "x"}).json()["message"] + client.delete(f"/api/chat/{msg['id']}") + assert client.delete(f"/api/chat/{msg['id']}").status_code == 404 + + +class TestPrivateChat: + """DM flows need a real logged-in identity (auth enabled).""" + + def test_dm_roundtrip(self, admin_client, test_vault_dir): + h = _login(admin_client) + r = admin_client.post("/api/chat/dm/normaluser", json={"text": "salut"}, headers=h) + assert r.status_code == 200 + assert r.json()["message"]["text"] == "salut" + + def test_dm_unknown_user_404(self, admin_client, test_vault_dir): + h = _login(admin_client) + r = admin_client.post("/api/chat/dm/ghost", json={"text": "hi"}, headers=h) + assert r.status_code == 404 + + def test_dm_to_self_400(self, admin_client, test_vault_dir): + h = _login(admin_client) + r = admin_client.post("/api/chat/dm/admin", json={"text": "hi"}, headers=h) + assert r.status_code == 400 + + def test_dm_isolated_from_general(self, admin_client, test_vault_dir): + h = _login(admin_client) + admin_client.post("/api/chat", json={"text": "public"}, headers=h) + admin_client.post("/api/chat/dm/normaluser", json={"text": "prive"}, headers=h) + gen = admin_client.get("/api/chat", headers=h).json()["messages"] + assert [m["text"] for m in gen] == ["public"] + dm = admin_client.get("/api/chat/dm/normaluser", headers=h).json()["messages"] + assert [m["text"] for m in dm] == ["prive"] + + def test_dm_history(self, admin_client, test_vault_dir): + h = _login(admin_client) + admin_client.post("/api/chat/dm/normaluser", json={"text": "hystorique"}, headers=h) + r = admin_client.get("/api/chat/dm/normaluser", headers=h) + assert [m["text"] for m in r.json()["messages"]] == ["hystorique"] + + def test_dm_pair_is_shared_not_duplicated(self, admin_client, test_vault_dir): + h = _login(admin_client) + admin_client.post("/api/chat/dm/normaluser", json={"text": "a"}, headers=h) + assert _store.dm_path("admin", "normaluser") == _store.dm_path("normaluser", "admin") + assert len(admin_client.get("/api/chat/dm/normaluser", headers=h).json()["messages"]) == 1 + + def test_dm_delete(self, admin_client, test_vault_dir): + h = _login(admin_client) + msg = admin_client.post( + "/api/chat/dm/normaluser", json={"text": "adieu"}, headers=h + ).json()["message"] + r = admin_client.delete(f"/api/chat/dm/normaluser/{msg['id']}", headers=h) + assert r.status_code == 200 + assert admin_client.get("/api/chat/dm/normaluser", headers=h).json()["messages"] == [] + + def test_requires_auth(self, admin_client, test_vault_dir): + assert admin_client.get("/api/chat/dm/normaluser").status_code == 401 + assert admin_client.get("/api/chat/users").status_code == 401 + + def test_user_list_for_picker(self, admin_client, test_vault_dir): + h = _login(admin_client) + r = admin_client.get("/api/chat/users", headers=h) + assert r.status_code == 200 + names = [u["username"] for u in r.json()] + assert "normaluser" in names + assert "admin" not in names # oneself is not a DM target + assert all("password" not in u and "hash" not in str(u) for u in r.json()) + + +class TestLinkPreview: + def test_no_url_no_preview(self, client, test_vault_dir, monkeypatch): + monkeypatch.setattr(_store, "build_preview", lambda text: None) + msg = client.post("/api/chat", json={"text": "pas d url"}).json()["message"] + # the schema always serialises the key (null) — it must stay falsy + assert not msg.get("preview") + + def test_preview_attached_when_fetched(self, client, test_vault_dir, monkeypatch): + monkeypatch.setattr( + _store, "build_preview", + lambda text: {"url": "https://x.test", "title": "Titre", "description": "", "image": "", "site": "x.test"}, + ) + msg = client.post("/api/chat", json={"text": "voir https://x.test"}).json()["message"] + assert msg["preview"]["title"] == "Titre" + + def test_preview_persisted_in_history(self, client, test_vault_dir, monkeypatch): + monkeypatch.setattr( + _store, "build_preview", + lambda text: {"url": "https://y.test", "title": "Y", "description": "", "image": "", "site": "y.test"}, + ) + client.post("/api/chat", json={"text": "https://y.test"}) + got = client.get("/api/chat").json()["messages"] + assert got[-1]["preview"]["site"] == "y.test" + + def test_build_preview_rejects_non_http(self): + assert _store.build_preview("javascript:alert(1)") is None + assert _store.build_preview("aucune URL") is None + + def test_build_preview_blocks_private_hosts(self, monkeypatch): + # SSRF guard rejects loopback before any network round-trip + monkeypatch.setattr(_store, "_PREVIEW_CACHE", {}) + assert _store.build_preview("http://127.0.0.1/x") is None + + def test_build_preview_happy_path_parses_og(self, monkeypatch): + """The fetch itself (a bad kwarg here used to fail silently).""" + monkeypatch.setattr(_store, "_PREVIEW_CACHE", {}) + # the SSRF guard resolves DNS for real — out of scope for this test + from backend.tools import web as _web + + monkeypatch.setattr(_web, "_assert_public_http_url", lambda url: None) + + class _Resp: + status_code = 200 + text = ( + "<html><head><title>Fallback" + '' + '' + '' + "" + ) + + called = {} + + def _fake_get(url, **kw): + called["url"] = url + return _Resp() + + monkeypatch.setattr(_store.httpx, "get", _fake_get) + preview = _store.build_preview("voir https://example.test/page") + assert called["url"] == "https://example.test/page" + assert preview == { + "url": "https://example.test/page", + "title": "Titre OG", + "description": "Desc", + "image": "", + "site": "Site", + }