feat: chat — suppression de post, messages privés, boîte compacte, link preview #191
CI / lint (push) Successful in 2m50s
CI / security (push) Successful in 1m35s
CI / test (push) Successful in 4m28s
CI / build (push) Successful in 1m28s
CI / e2e (push) Successful in 17m11s

This commit is contained in:
2026-10-08 22:33:29 -04:00
parent 55a9fcc0f4
commit f00a8bea8f
23 changed files with 946 additions and 66 deletions
+30 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.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é
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.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*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.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*
+1 -1
View File
@@ -1 +1 @@
2.58.0
2.60.0
+130 -2
View File
@@ -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/``<title>`` 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[^>]*>([^<]*)</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)
+107 -2
View File
@@ -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"}
+4
View File
@@ -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):
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.58.0"
version = "2.60.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.58.0",
"version": "2.60.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+8
View File
@@ -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).
---
+1
View File
@@ -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) |
+3 -2
View File
@@ -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) |
+61
View File
@@ -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 `<title>`,
`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
+20 -2
View File
@@ -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>
+216 -45
View File
@@ -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());
}
+11 -1
View File
@@ -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();
+5
View File
@@ -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",
+5
View File
@@ -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",
+110
View File
@@ -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
--------------------------------------------------------------------------- */
+1 -1
View File
@@ -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": {
+67
View File
@@ -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 {
+157
View File
@@ -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</title>"
'<meta property="og:title" content="Titre OG">'
'<meta property="og:description" content="Desc">'
'<meta property="og:site_name" content="Site">'
"</head></html>"
)
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",
}