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). 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 > **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 ## [2.58.0] — 2026-10-08
### Ajouté ### 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. **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) [![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/) [![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/) [![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 ## 📝 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. **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) [![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/) [![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/) [![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 ## 📝 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 from __future__ import annotations
import hashlib import hashlib
import html
import json import json
import logging import logging
import re
import shutil import shutil
import time import time
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import httpx
logger = logging.getLogger("obsigate.file_chat") logger = logging.getLogger("obsigate.file_chat")
CHAT_DIR = Path("data/chats") CHAT_DIR = Path("data/chats")
@@ -33,6 +37,11 @@ MAX_TEXT = 4000 # characters per message
GLOBAL_VAULT = "__global__" GLOBAL_VAULT = "__global__"
GLOBAL_PATH = "general" 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. # #190 — attachments (image/video) live outside the vaults.
UPLOAD_DIR = Path("data/chat_uploads") UPLOAD_DIR = Path("data/chat_uploads")
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB per attachment MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB per attachment
@@ -41,6 +50,10 @@ ALLOWED_ATTACH_EXT = {
".mp4", ".webm", ".ogg", ".mov", ".m4v", ".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: def _chat_file(vault: str, path: str) -> Path:
"""Return the chat file for *(vault, path)* (hashed, traversal-proof).""" """Return the chat file for *(vault, path)* (hashed, traversal-proof)."""
@@ -85,11 +98,13 @@ def add_message(
user: str, user: str,
text: str, text: str,
attachment: dict[str, Any] | None = None, attachment: dict[str, Any] | None = None,
preview: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Append a message and persist it. Returns the stored message. """Append a message and persist it. Returns the stored message.
The list is capped at :data:`MAX_MESSAGES` (oldest dropped first). 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] text = (text or "").strip()[:MAX_TEXT]
msg: dict[str, Any] = { msg: dict[str, Any] = {
@@ -100,6 +115,8 @@ def add_message(
} }
if attachment: if attachment:
msg["attachment"] = attachment msg["attachment"] = attachment
if preview:
msg["preview"] = preview
doc = _read(vault, path) doc = _read(vault, path)
return _append(vault, path, doc, msg) return _append(vault, path, doc, msg)
@@ -120,6 +137,47 @@ def _append(
return msg 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) ------------- # --- #190 : chat général (conversation centrale, hors fichier) -------------
def get_global_messages() -> list[dict[str, Any]]: def get_global_messages() -> list[dict[str, Any]]:
@@ -131,9 +189,10 @@ def add_global_message(
user: str, user: str,
text: str, text: str,
attachment: dict[str, Any] | None = None, attachment: dict[str, Any] | None = None,
preview: dict[str, Any] | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Append a message to the general chat. Returns the stored message.""" """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]: 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: def attachment_path(name: str) -> Path | None:
"""Resolve an attachment by its stored name (UUID+ext only, no traversal).""" """Resolve an attachment by its stored name (UUID+ext only, no traversal)."""
p = Path(name) 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 import file_chat as _store
from backend.auth.middleware import check_vault_access, require_auth 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.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.services.paths import resolve_safe_path
from backend.sse import sse_manager from backend.sse import sse_manager
@@ -104,8 +105,10 @@ async def api_chat_post(
text = str(body.get("text") or "") text = str(body.get("text") or "")
if not text.strip(): if not text.strip():
raise HTTPException(400, "text is required") raise HTTPException(400, "text is required")
# #191 — best-effort link preview: a dead/slow URL never blocks the post.
msg = _store.add_global_message( 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( await sse_manager.broadcast(
"chat_message", "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") raise HTTPException(404, "Attachment not found")
info = _store._MIME_BY_EXT.get(path.suffix.lower(), "application/octet-stream") info = _store._MIME_BY_EXT.get(path.suffix.lower(), "application/octet-stream")
return FileResponse(str(path), media_type=info) 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, default=None,
description="Optional image/video/url attachment {name, url, mime, kind}", 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): class ChatHistoryResponse(BaseModel):
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]] [[package]]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.58.0" version = "2.60.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"env_logger", "env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "obsigate-desktop" name = "obsigate-desktop"
version = "2.58.0" version = "2.60.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"] authors = ["Bruno Charest"]
edition = "2021" edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate", "productName": "ObsiGate",
"version": "2.58.0", "version": "2.60.0",
"identifier": "com.obsigate.desktop", "identifier": "com.obsigate.desktop",
"build": { "build": {
"frontendDist": "../frontend", "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. - **📎** 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 - La **barre de filtre** sert à chercher dans le chat (texte ou auteur) quand
cet onglet est actif. 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-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-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 | #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 | #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) | | 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 # 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 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées. > vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** > - **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) | | 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) | | 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) | | 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) | | 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) | | 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) | | 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 - `initSidebarFilter` (`sidebar.js`) : `routeFilter`/`routeClear` routent
vers `filterChatMessages()` (texte **et** auteur, insensible à la casse). 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 ## Décisions
- **SSE plutôt qu'un second WebSocket** : le transport de #62 (EventSource - **SSE plutôt qu'un second WebSocket** : le transport de #62 (EventSource
+20 -2
View File
@@ -1072,6 +1072,13 @@
role="tabpanel" role="tabpanel"
aria-labelledby="sidebar-tab-chat" 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 <div
id="sidebar-panel-chat-list" id="sidebar-panel-chat-list"
class="file-chat-list sidebar-chat-list" class="file-chat-list sidebar-chat-list"
@@ -1090,7 +1097,7 @@
> >
<i <i
data-lucide="paperclip" data-lucide="paperclip"
style="width: 16px; height: 16px" style="width: 15px; height: 15px"
></i> ></i>
</button> </button>
<input <input
@@ -1102,7 +1109,18 @@
autocomplete="off" autocomplete="off"
aria-label="Écrire un message" 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> </form>
</div> </div>
+216 -45
View File
@@ -12,20 +12,23 @@
import { api, AuthManager } from "./auth.js"; import { api, AuthManager } from "./auth.js";
import { t } from "./i18n.js"; import { t } from "./i18n.js";
import { showToast } from "./ui.js"; import { showToast } from "./ui.js";
import { escapeHtml } from "./utils.js"; import { escapeHtml, safeCreateIcons } from "./utils.js";
const PANEL_ID = "file-chat-panel"; const PANEL_ID = "file-chat-panel";
const GLOBAL_VAULT = "__global__"; 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 _vault = null;
let _path = null; let _path = null;
let _title = ""; let _title = "";
let _sending = false; let _sending = false;
// État du chat général (onglet sidebar) // État du chat sidebar (#190 B + #191 : canaux général / privé)
let _generalMessages = []; let _channel = { type: "general" }; // ou {type: "dm", user: "bob"}
let _generalLoaded = false; let _channelMessages = [];
let _channelLoaded = false;
let _usersCache = null; // liste des destinataires DM (une seule requête)
let _pendingAttach = null; let _pendingAttach = null;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -90,7 +93,65 @@ function _attachEl(att) {
return null; 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 mine = currentUser && m.user === currentUser;
const wrap = document.createElement("div"); const wrap = document.createElement("div");
wrap.className = "file-chat-msg" + (mine ? " mine" : ""); wrap.className = "file-chat-msg" + (mine ? " mine" : "");
@@ -100,6 +161,16 @@ export function _messageEl(m, currentUser) {
meta.innerHTML = meta.innerHTML =
`<span class="file-chat-user">${_esc(m.user)}</span>` + `<span class="file-chat-user">${_esc(m.user)}</span>` +
`<span class="file-chat-ts">${_esc(_formatTime(m.ts))}</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"); const body = document.createElement("div");
body.className = "file-chat-body"; body.className = "file-chat-body";
_fillBody(body, m.text); _fillBody(body, m.text);
@@ -107,10 +178,11 @@ export function _messageEl(m, currentUser) {
if (m.text) wrap.appendChild(body); if (m.text) wrap.appendChild(body);
const att = _attachEl(m.attachment); const att = _attachEl(m.attachment);
if (att) wrap.appendChild(att); if (att) wrap.appendChild(att);
if (m.preview) wrap.appendChild(_previewEl(m.preview));
return wrap; return wrap;
} }
export function renderChatMessages(container, messages, currentUser) { export function renderChatMessages(container, messages, currentUser, delUrl) {
container.innerHTML = ""; container.innerHTML = "";
if (!messages || messages.length === 0) { if (!messages || messages.length === 0) {
const empty = document.createElement("div"); const empty = document.createElement("div");
@@ -119,16 +191,16 @@ export function renderChatMessages(container, messages, currentUser) {
container.appendChild(empty); container.appendChild(empty);
return; return;
} }
messages.forEach((m) => container.appendChild(_messageEl(m, currentUser))); messages.forEach((m) => container.appendChild(_messageEl(m, currentUser, delUrl)));
container.scrollTop = container.scrollHeight; container.scrollTop = container.scrollHeight;
} }
function _appendMessage(list, m) { function _appendMessage(list, m, delUrl) {
const empty = list.querySelector(".file-chat-empty"); const empty = list.querySelector(".file-chat-empty");
if (empty) empty.remove(); if (empty) empty.remove();
const id = m.id; const id = m.id;
if (id && list.querySelector(`[data-id="${CSS.escape(id)}"]`)) return false; 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; list.scrollTop = list.scrollHeight;
return true; return true;
} }
@@ -203,8 +275,11 @@ function _renderShell(panel) {
input.setAttribute("aria-label", t("chat.placeholder")); input.setAttribute("aria-label", t("chat.placeholder"));
const send = document.createElement("button"); const send = document.createElement("button");
send.type = "submit"; send.type = "submit";
send.className = "file-chat-send"; send.className = "file-chat-send file-chat-send-icon";
send.textContent = t("chat.send"); 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(input);
form.appendChild(send); form.appendChild(send);
form.addEventListener("submit", (e) => { form.addEventListener("submit", (e) => {
@@ -215,6 +290,7 @@ function _renderShell(panel) {
panel.appendChild(header); panel.appendChild(header);
panel.appendChild(list); panel.appendChild(list);
panel.appendChild(form); panel.appendChild(form);
safeCreateIcons(); // #191 — hydrate l'icône d'envoi
} }
export async function openFileChat(vault, path, title) { 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() { function _sidebarList() {
return document.getElementById("sidebar-panel-chat-list"); 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() { export async function openSidebarChat() {
const list = _sidebarList(); const list = _sidebarList();
if (!list) return; if (!list) return;
const users = await _loadUsers();
_renderChannels(users);
try { try {
const data = await api("/api/chat"); const data = await api(_channelUrl());
_generalMessages = (data.messages || []).slice(); _channelMessages = (data.messages || []).slice();
_generalLoaded = true; _channelLoaded = true;
renderChatMessages(list, _generalMessages, _currentUser()); renderChatMessages(list, _channelMessages, _currentUser(), _channelDelUrl());
_clearUnread(); _clearUnread();
} catch (_) { } catch (_) {
list.innerHTML = ""; 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) { export function filterChatMessages(query) {
const list = _sidebarList(); const list = _sidebarList();
if (!list || !_generalLoaded) return; if (!list || !_channelLoaded) return;
const q = String(query || "").toLowerCase().trim(); const q = String(query || "").toLowerCase().trim();
const shown = q const shown = q
? _generalMessages.filter((m) => ? _channelMessages.filter((m) =>
((m.text || "") + " " + (m.user || "")).toLowerCase().includes(q), ((m.text || "") + " " + (m.user || "")).toLowerCase().includes(q),
) )
: _generalMessages; : _channelMessages;
renderChatMessages(list, shown, _currentUser()); renderChatMessages(list, shown, _currentUser(), _channelDelUrl());
} }
function _sendInput() { function _sendInput() {
@@ -344,14 +485,14 @@ export async function sendGeneralMessage() {
const body = { text }; const body = { text };
if (_pendingAttach) body.attachment = _pendingAttach; if (_pendingAttach) body.attachment = _pendingAttach;
try { 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 = ""; input.value = "";
_pendingAttach = null; _pendingAttach = null;
_renderPending(); _renderPending();
const list = _sidebarList(); const list = _sidebarList();
if (list) { if (list) {
_generalMessages.push(data.message); _channelMessages.push(data.message);
_appendMessage(list, data.message); _appendMessage(list, data.message, _channelDelUrl());
} }
input.focus(); input.focus();
} catch (_) { } 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() { function _readUnread() {
try { try {
return JSON.parse(localStorage.getItem(UNREAD_KEY) || "{}") || {}; return JSON.parse(localStorage.getItem(UNREAD_KEY) || "{}") || {};
@@ -396,13 +539,23 @@ function _readUnread() {
} }
} }
function _clearUnread() { function _writeUnread(u) {
const u = _readUnread();
u.general = Math.floor(Date.now() / 1000);
try { try {
localStorage.setItem(UNREAD_KEY, JSON.stringify(u)); localStorage.setItem(UNREAD_KEY, JSON.stringify(u));
} catch (_) { /* quota */ } } 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) { function _renderBadge(count) {
@@ -421,13 +574,9 @@ function _renderBadge(count) {
badge.textContent = count > 99 ? "99+" : String(count); badge.textContent = count > 99 ? "99+" : String(count);
} }
/** Compte les messages reçus depuis la dernière visite. */ /** Affiche le total des messages non lus (tous canaux confondus). */
export function refreshUnreadBadge(messages) { export function refreshUnreadBadge() {
const list = messages || _generalMessages; _renderBadge(_totalUnread());
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);
} }
/** Initialisation : formulaire + raccourcis clavier. */ /** Initialisation : formulaire + raccourcis clavier. */
@@ -456,18 +605,20 @@ export function initSidebarChat() {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function onChatMessage(data) { export function onChatMessage(data) {
if (!data || !data.message) return; if (!data || !data.message) return;
const isGeneral = data.vault === GLOBAL_VAULT;
if (isGeneral) { // --- Canaux sidebar : général (#190) ou privé (#191) ---
_generalMessages.push(data.message); if (data.vault === GLOBAL_VAULT || data.vault === DM_VAULT) {
const list = _sidebarList(); const key = data.vault === DM_VAULT ? `dm:${_pairOf(data.path)}` : "general";
const isActive = key === _channelKey();
const panel = document.getElementById("sidebar-panel-chat"); const panel = document.getElementById("sidebar-panel-chat");
const visible = panel && panel.classList.contains("active"); const visible = panel && panel.classList.contains("active");
if (list && visible) { if (isActive) _channelMessages.push(data.message);
_appendMessage(list, data.message); if (visible && isActive) {
_appendMessage(_sidebarList(), data.message, _channelDelUrl());
_clearUnread(); _clearUnread();
} else if (data.message.user !== _currentUser()) { } else if (data.message.user !== _currentUser()) {
refreshUnreadBadge(_generalMessages); _bumpUnread(key);
refreshUnreadBadge();
showToast(t("chat.new_message", { user: data.message.user }), "info"); showToast(t("chat.new_message", { user: data.message.user }), "info");
} }
return; return;
@@ -486,3 +637,23 @@ export function onChatMessage(data) {
if (data.message.user === _currentUser()) return; // son propre message if (data.message.user === _currentUser()) return; // son propre message
showToast(t("chat.new_message", { user: data.message.user }), "info"); 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) => { eventSource.addEventListener("chat_message", (e) => {
try { try {
const data = JSON.parse(e.data); 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 = () => { eventSource.onerror = () => {
connectionState = "disconnected"; connectionState = "disconnected";
_updateBadge(); _updateBadge();
+5
View File
@@ -320,6 +320,10 @@
"backups.title": "Backups", "backups.title": "Backups",
"button.cancel": "Cancel", "button.cancel": "Cancel",
"chat.attach_error": "Attachment upload failed", "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.attach_remove": "Remove attachment",
"chat.button": "Chat", "chat.button": "Chat",
"chat.button_title": "Discuss this file", "chat.button_title": "Discuss this file",
@@ -328,6 +332,7 @@
"chat.load_error": "Could not load the discussion", "chat.load_error": "Could not load the discussion",
"chat.new_message": "New message from {user}", "chat.new_message": "New message from {user}",
"chat.placeholder": "Write a message…", "chat.placeholder": "Write a message…",
"chat.placeholder_dm": "Private message to {user}…",
"chat.send": "Send", "chat.send": "Send",
"chat.send_error": "Failed to send the message", "chat.send_error": "Failed to send the message",
"chat.tab": "General chat", "chat.tab": "General chat",
+5
View File
@@ -320,6 +320,10 @@
"backups.title": "Backups", "backups.title": "Backups",
"button.cancel": "Annuler", "button.cancel": "Annuler",
"chat.attach_error": "Échec de l'envoi de la pièce jointe", "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.attach_remove": "Retirer la pièce jointe",
"chat.button": "Chat", "chat.button": "Chat",
"chat.button_title": "Discuter de ce fichier", "chat.button_title": "Discuter de ce fichier",
@@ -328,6 +332,7 @@
"chat.load_error": "Impossible de charger la discussion", "chat.load_error": "Impossible de charger la discussion",
"chat.new_message": "Nouveau message de {user}", "chat.new_message": "Nouveau message de {user}",
"chat.placeholder": "Écrire un message…", "chat.placeholder": "Écrire un message…",
"chat.placeholder_dm": "Message privé à {user}…",
"chat.send": "Envoyer", "chat.send": "Envoyer",
"chat.send_error": "Échec de l'envoi du message", "chat.send_error": "Échec de l'envoi du message",
"chat.tab": "Chat général", "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 #190 — Chat : en-tête ciblé, onglet sidebar, pièces jointes, badge
--------------------------------------------------------------------------- */ --------------------------------------------------------------------------- */
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "obsigate", "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.", "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", "main": "patch.js",
"directories": { "directories": {
+67
View File
@@ -69,10 +69,15 @@ globalThis.fetch = (url) => {
const { initI18n } = await import(pathToFileURL(path.join(JS_DIR, "i18n.js")).href); const { initI18n } = await import(pathToFileURL(path.join(JS_DIR, "i18n.js")).href);
await initI18n(); 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 mod = await import("../../frontend/js/filechat.js");
const { const {
renderChatMessages, onChatMessage, openFileChat, closeFileChat, toggleFileChat, renderChatMessages, onChatMessage, openFileChat, closeFileChat, toggleFileChat,
followFileChat, openSidebarChat, filterChatMessages, refreshUnreadBadge, followFileChat, openSidebarChat, filterChatMessages, refreshUnreadBadge,
onChatDeleted,
} = mod; } = mod;
let pass = 0; 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")); 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. // Sequential execution: each case depends on panel state left by the previous.
for (const [name, fn] of cases) { for (const [name, fn] of cases) {
try { try {
+157
View File
@@ -8,6 +8,13 @@ import pytest
from backend import file_chat as _store 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) @pytest.fixture(autouse=True)
def _isolated_chat_dir(tmp_path, monkeypatch): def _isolated_chat_dir(tmp_path, monkeypatch):
"""Keep every test (store AND routes) out of the real ``data/chats``/ """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}) r = client.post("/api/chat", json={"text": "voici", "attachment": att})
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["message"]["attachment"]["kind"] == "image" 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",
}