feat: chat — suivi du document, chat général en onglet sidebar, onglets au-dessus du filtre #190
- **A — Suivi du document** : l'en-tête du panneau chat affiche le document ciblé (titre + chemin) et `followFileChat()` appelé par `renderFile()` re-cible le panneau ouvert à chaque changement de document. - **B — Chat général** : onglet « Chat » (dernier) dans la sidebar, conversation centrale stockée dans le store #169 via les sentinelles `__global__/general` (`GET/POST /api/chat`) ; pastille de messages non lus sur l'onglet ; pièces jointes image/vidéo (`POST /api/chat/upload` : allow-list d'extensions, 25 MB, nom UUID ; `GET /api/chat/attachment/{name}` résolu contre l'allow-list) ; URL cliquables au rendu ; date/heure d'envoi. - **C — Ordre des onglets** : la barre de filtre passe sous les onglets et sert de recherche dans le chat (texte + auteur) quand l'onglet Chat est actif (`switchSidebarTab` + `routeFilter`). - Transport : broadcast SSE `chat_message` réutilisé (`vault __global__` route vers la sidebar, sinon le panneau fichier) — pas de 2ᵉ WebSocket. - i18n FR/EN (6 clés), CSS bloc #190, `initSidebarChat()` dans l'orchestrateur. - Tests : pytest 27 chat (12 nouveaux) ; JSDOM `filechat.test.mjs` 11 (5 nouveaux) ; suite 1655 passed, ruff/mypy 0, validate-imports 42 modules. - Docs : CHANGELOG [Unreleased], ROADMAP #190 → index, fiche `file-chat-169.md` §#190, guide « Chat général », journal des interventions.
This commit is contained in:
+24
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.57.1**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.58.0**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,29 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.58.0] — 2026-10-08
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#190 - Chat : suivi du document, chat général en onglet sidebar**
|
||||
- **Suivi du document** : le panneau du chat affiche le document ciblé
|
||||
(titre + chemin au survol) et **suit automatiquement** le document
|
||||
rendu — changer de fichier re-cible le panneau ouvert.
|
||||
- **Onglet « Chat »** (dernier, après Historique IA) dans la sidebar :
|
||||
conversation générale non liée à un fichier, **indicateur de messages
|
||||
non lus** (pastille rouge sur l'onglet), **pièces jointes image/vidéo**
|
||||
(upload avec allow-list d'extensions, 25 MB max, nom UUID), **URL
|
||||
cliquables** dans les messages, date/heure d'envoi.
|
||||
- **Ordre des onglets** : la barre de filtre passe **sous** les onglets
|
||||
et sert de **recherche dans le chat** quand l'onglet Chat est actif
|
||||
(texte + auteur).
|
||||
- Backend : `GET/POST /api/chat`, `POST /api/chat/upload`,
|
||||
`GET /api/chat/attachment/{name}` (même store #169 via les sentinelles
|
||||
`__global__/general`), broadcast SSE `chat_message` réutilisé.
|
||||
- Tests : pytest +12 (27 au total), JSDOM `filechat.test.mjs` +5 (11).
|
||||
|
||||
---
|
||||
|
||||
## [2.57.1] — 2026-10-08
|
||||
|
||||
---
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.57.1).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.58.0).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.57.1 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.58.0 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.57.1).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.58.0).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.57.1 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.58.0 | Last updated: September 2026*
|
||||
|
||||
+111
-4
@@ -1,10 +1,15 @@
|
||||
# backend/file_chat.py — historique de discussion par fichier (#169)
|
||||
"""Per-file chat history persisted under ``data/chats/``.
|
||||
# backend/file_chat.py — historique de discussion (#169, #190)
|
||||
"""Chat history persisted under ``data/chats/``.
|
||||
|
||||
One JSON document per ``(vault, path)`` pair, keyed by a SHA-256 of both so
|
||||
the filename never carries user-controlled path separators. Writes are
|
||||
atomic (tmp + move) and the message list is capped at
|
||||
:data:`MAX_MESSAGES` to bound growth.
|
||||
|
||||
#190 adds the **general chat**: the same store addressed with the reserved
|
||||
sentinels (:data:`GLOBAL_VAULT` / :data:`GLOBAL_PATH`), so no second
|
||||
implementation. Messages may carry an ``attachment`` (image/video/url)
|
||||
uploaded under ``data/chat_uploads/``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,6 +29,18 @@ CHAT_DIR = Path("data/chats")
|
||||
MAX_MESSAGES = 500 # retention ceiling per file (oldest dropped first)
|
||||
MAX_TEXT = 4000 # characters per message
|
||||
|
||||
# #190 — general (non file-bound) conversation, stored like any other one.
|
||||
GLOBAL_VAULT = "__global__"
|
||||
GLOBAL_PATH = "general"
|
||||
|
||||
# #190 — attachments (image/video) live outside the vaults.
|
||||
UPLOAD_DIR = Path("data/chat_uploads")
|
||||
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB per attachment
|
||||
ALLOWED_ATTACH_EXT = {
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg",
|
||||
".mp4", ".webm", ".ogg", ".mov", ".m4v",
|
||||
}
|
||||
|
||||
|
||||
def _chat_file(vault: str, path: str) -> Path:
|
||||
"""Return the chat file for *(vault, path)* (hashed, traversal-proof)."""
|
||||
@@ -62,19 +79,38 @@ def get_messages(vault: str, path: str) -> list[dict[str, Any]]:
|
||||
return list(_read(vault, path).get("messages", []))
|
||||
|
||||
|
||||
def add_message(vault: str, path: str, user: str, text: str) -> dict[str, Any]:
|
||||
def add_message(
|
||||
vault: str,
|
||||
path: str,
|
||||
user: str,
|
||||
text: str,
|
||||
attachment: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Append a message and persist it. Returns the stored message.
|
||||
|
||||
The list is capped at :data:`MAX_MESSAGES` (oldest dropped first).
|
||||
*attachment* (#190) is ``{name, url, mime, kind}`` for image/video/url.
|
||||
"""
|
||||
text = (text or "").strip()[:MAX_TEXT]
|
||||
msg = {
|
||||
msg: dict[str, Any] = {
|
||||
"id": uuid.uuid4().hex[:12],
|
||||
"user": user or "anonyme",
|
||||
"text": text,
|
||||
"ts": time.time(),
|
||||
}
|
||||
if attachment:
|
||||
msg["attachment"] = attachment
|
||||
doc = _read(vault, path)
|
||||
return _append(vault, path, doc, msg)
|
||||
|
||||
|
||||
def _append(
|
||||
vault: str,
|
||||
path: str,
|
||||
doc: dict[str, Any],
|
||||
msg: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Cap, persist and return *msg* (shared by file and global chats)."""
|
||||
messages = list(doc.get("messages", []))
|
||||
messages.append(msg)
|
||||
if len(messages) > MAX_MESSAGES:
|
||||
@@ -82,3 +118,74 @@ def add_message(vault: str, path: str, user: str, text: str) -> dict[str, Any]:
|
||||
doc["messages"] = messages
|
||||
_write(_chat_file(vault, path), doc)
|
||||
return msg
|
||||
|
||||
|
||||
# --- #190 : chat général (conversation centrale, hors fichier) -------------
|
||||
|
||||
def get_global_messages() -> list[dict[str, Any]]:
|
||||
"""Return the general-chat history (chronological)."""
|
||||
return get_messages(GLOBAL_VAULT, GLOBAL_PATH)
|
||||
|
||||
|
||||
def add_global_message(
|
||||
user: str,
|
||||
text: str,
|
||||
attachment: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Append a message to the general chat. Returns the stored message."""
|
||||
return add_message(GLOBAL_VAULT, GLOBAL_PATH, user, text, attachment)
|
||||
|
||||
|
||||
def save_attachment(filename: str, data: bytes) -> dict[str, Any]:
|
||||
"""Persist an uploaded attachment under :data:`UPLOAD_DIR`.
|
||||
|
||||
Returns ``{name, url, mime, kind}``. The stored name is a fresh UUID
|
||||
(never the client name), the extension must be in
|
||||
:data:`ALLOWED_ATTACH_EXT` and the size is capped at
|
||||
:data:`MAX_UPLOAD_BYTES`.
|
||||
|
||||
Raises:
|
||||
ValueError: extension refused, empty file or size exceeded.
|
||||
"""
|
||||
ext = Path(filename or "").suffix.lower()
|
||||
if ext not in ALLOWED_ATTACH_EXT:
|
||||
raise ValueError(f"extension refusée : {ext or '(aucune)'}")
|
||||
if not data:
|
||||
raise ValueError("fichier vide")
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise ValueError(f"fichier trop lourd (max {MAX_UPLOAD_BYTES // (1024 * 1024)} MB)")
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
name = f"{uuid.uuid4().hex}{ext}"
|
||||
(UPLOAD_DIR / name).write_bytes(data)
|
||||
kind = "video" if ext in {".mp4", ".webm", ".ogg", ".mov", ".m4v"} else "image"
|
||||
return {
|
||||
"name": name,
|
||||
"url": f"/api/chat/attachment/{name}",
|
||||
"mime": _MIME_BY_EXT.get(ext, "application/octet-stream"),
|
||||
"kind": kind,
|
||||
}
|
||||
|
||||
|
||||
def attachment_path(name: str) -> Path | None:
|
||||
"""Resolve an attachment by its stored name (UUID+ext only, no traversal)."""
|
||||
p = Path(name)
|
||||
if p.name != name or p.suffix.lower() not in ALLOWED_ATTACH_EXT:
|
||||
return None
|
||||
file = UPLOAD_DIR / p.name
|
||||
return file if file.exists() else None
|
||||
|
||||
|
||||
# Extension → MIME (literals only; ``mimetypes`` guesses poorly for a few).
|
||||
_MIME_BY_EXT = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".ogg": "video/ogg",
|
||||
".mov": "video/quicktime",
|
||||
".m4v": "video/x-m4v",
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
# backend/routers/file_chat.py — chat par fichier (#169)
|
||||
"""Per-file chat endpoints: history read + message post with SSE fan-out.
|
||||
# backend/routers/file_chat.py — chat (#169, #190)
|
||||
"""Chat endpoints: history read + message post with SSE fan-out.
|
||||
|
||||
GET/POST ``/api/file/{vault_name}/chat`` — auth + vault access + path
|
||||
traversal check (``resolve_safe_path`` raises ``ServiceError`` mapped by
|
||||
the app-level handler). A posted message is broadcast on the existing
|
||||
SSE channel (``chat_message``) so every connected client updates live
|
||||
without a second WebSocket.
|
||||
- ``GET/POST /api/file/{vault_name}/chat`` — per-file chat: auth + vault
|
||||
access + path traversal check (``resolve_safe_path`` raises
|
||||
``ServiceError`` mapped by the app-level handler).
|
||||
- ``GET/POST /api/chat`` — the **general chat** (#190), not bound to a file.
|
||||
- ``POST /api/chat/upload`` / ``GET /api/chat/attachment/{name}`` (#190):
|
||||
image/video attachments (extension allow-list, size cap, UUID name).
|
||||
|
||||
Every post is broadcast on the existing SSE channel (``chat_message``) so
|
||||
all connected clients update live without a second WebSocket.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -13,7 +17,8 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException
|
||||
from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from backend import file_chat as _store
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
@@ -63,3 +68,71 @@ async def api_file_chat_post(
|
||||
msg = _store.add_message(vault_name, path, current_user.get("username", ""), text)
|
||||
await sse_manager.broadcast("chat_message", {"vault": vault_name, "path": path, "message": msg})
|
||||
return {"message": msg, "status": "ok"}
|
||||
|
||||
|
||||
# --- #190 : chat général ----------------------------------------------------
|
||||
|
||||
def _attachment(body: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Validate the optional ``attachment`` object sent by the client."""
|
||||
raw = body.get("attachment")
|
||||
if not raw or not isinstance(raw, dict):
|
||||
return None
|
||||
name = str(raw.get("name") or "")
|
||||
# Only an already-uploaded file (or an http(s) URL) may travel along.
|
||||
if not _store.attachment_path(name) and not str(raw.get("url", "")).startswith(("http://", "https://", "/api/")):
|
||||
raise HTTPException(400, "attachment inconnu")
|
||||
return {
|
||||
"name": name,
|
||||
"url": str(raw.get("url") or ""),
|
||||
"mime": str(raw.get("mime") or ""),
|
||||
"kind": str(raw.get("kind") or "file"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/chat", response_model=ChatHistoryResponse)
|
||||
async def api_chat_history(current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Return the general chat history (#190, chronological)."""
|
||||
return {"messages": _store.get_global_messages()}
|
||||
|
||||
|
||||
@router.post("/api/chat", response_model=ChatMessageResponse)
|
||||
async def api_chat_post(
|
||||
body: dict[str, Any] = Body(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Post to the general chat and broadcast it on SSE (``chat_message``)."""
|
||||
text = str(body.get("text") or "")
|
||||
if not text.strip():
|
||||
raise HTTPException(400, "text is required")
|
||||
msg = _store.add_global_message(
|
||||
current_user.get("username", ""), text, _attachment(body)
|
||||
)
|
||||
await sse_manager.broadcast(
|
||||
"chat_message",
|
||||
{"vault": _store.GLOBAL_VAULT, "path": _store.GLOBAL_PATH, "message": msg},
|
||||
)
|
||||
return {"message": msg, "status": "ok"}
|
||||
|
||||
|
||||
@router.post("/api/chat/upload")
|
||||
async def api_chat_upload(
|
||||
file: UploadFile = File(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Store an image/video attachment (#190). Returns ``{attachment}``."""
|
||||
data = await file.read()
|
||||
try:
|
||||
info = _store.save_attachment(file.filename or "", data)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return {"attachment": info}
|
||||
|
||||
|
||||
@router.get("/api/chat/attachment/{name}")
|
||||
async def api_chat_attachment(name: str, current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Serve an uploaded attachment (name validated against the allow-list)."""
|
||||
path = _store.attachment_path(name)
|
||||
if not path:
|
||||
raise HTTPException(404, "Attachment not found")
|
||||
info = _store._MIME_BY_EXT.get(path.suffix.lower(), "application/octet-stream")
|
||||
return FileResponse(str(path), media_type=info)
|
||||
|
||||
+5
-1
@@ -152,12 +152,16 @@ class BacklinksResponse(BaseModel):
|
||||
|
||||
|
||||
class ChatMessageItem(BaseModel):
|
||||
"""One chat message (``GET/POST /api/file/{vault}/chat``)."""
|
||||
"""One chat message (``GET/POST /api/file/{vault}/chat`` + ``/api/chat``)."""
|
||||
|
||||
id: str = Field(description="Message id")
|
||||
user: str = Field(description="Author username")
|
||||
text: str = Field(description="Message body")
|
||||
ts: float = Field(description="Unix timestamp")
|
||||
attachment: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Optional image/video/url attachment {name, url, mime, kind}",
|
||||
)
|
||||
|
||||
|
||||
class ChatHistoryResponse(BaseModel):
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.57.1"
|
||||
version = "2.58.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.57.1"
|
||||
version = "2.58.0"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.57.1",
|
||||
"version": "2.58.0",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -184,6 +184,16 @@ des **onglets** (avec possibilité de vue multi-panneaux / split view).
|
||||
latéral avec l'historique de la discussion et la saisie d'un message.
|
||||
- Les nouveaux messages s'affichent en direct ; si le panneau est fermé, une
|
||||
notification annonce l'envoi. L'historique est conservé par fichier.
|
||||
- Le panneau **suit le document** : changer de fichier re-cible la discussion.
|
||||
|
||||
### Chat général
|
||||
|
||||
- Onglet **💬 Chat** (dernier de la sidebar, sous les onglets) : conversation
|
||||
centrale, valable pour toute l'application, avec **pastille rouge** quand
|
||||
de nouveaux messages arrivent.
|
||||
- **📎** attache une image ou une vidéo (25 MB max) ; les URL sont cliquables.
|
||||
- La **barre de filtre** sert à chercher dans le chat (texte ou auteur) quand
|
||||
cet onglet est actif.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -329,6 +329,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-10-02 | BUG-107 | Correction | `desktop/tauri.conf.json`, `desktop/src/main.rs` | **BUG-107 — drag & drop de fichiers inutilisable sur desktop** : Tauri/wry installe son propre `IDropTarget` par-dessus de celui du WebView2 (source wry : « Enumerate child windows to find the WebView2 window and override! ») et aucun événement natif n'était écouté → les glisser-déposer depuis l'Explorateur n'atteignaient jamais les gestionnaires HTML5 de `dragdrop.js` (#89 marchait donc uniquement sur le web). Correctif : la doc Tauri est explicite — `disable_drag_drop_handler()` « is required to use HTML5 drag and drop APIs on the frontend on Windows » ; la fenêtre est créée en code (`create: false` dans `tauri.conf.json`, boucle Tauri qui saute les fenêtres non auto-créées) via `WebviewWindowBuilder::from_config(...).disable_drag_drop_handler()`. Vérifié : `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires), lancement de l'app OK (fenêtre unique, boot log normal) ; glisser-déposer réel à valider par l'utilisateur. | 🟢 corrigé |
|
||||
| 2026-10-07 | BUG-108 | Correction | `frontend/js/xlsx/context-menu.js` | **BUG-108 — « Coller » du menu contextuel du tableur muet** : `closeContextMenu()` faisait `menu.remove()` **avant** `_menu = null` ; retirer le menu portant le focus émet `focusout` synchrone qui rappelle `closeContextMenu()`, le second `remove()` sur un nœud démonté lève `NotFoundError` et avorte le handler de l'action avant son exécution (introduit par la fermeture au focus #179). Reproduit par l'E2E `xlsx-viewer.spec.js` « coller une plage » (CI #850→#854). Correctif : déférencer avant de retirer. | 🟢 corrigé |
|
||||
| 2026-10-08 | #189 | Fonctionnalité | `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `docs/ROADMAP.md`, `docs/features/xlsx-mobile-layout-189.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `README.md`, `README.fr.md`, `CHANGELOG.md` | **#189 — mise en page mobile du tableur** : sur ≤ 768 px les barres menus/ruban/Recherche sont repliées par défaut (la barre des feuilles + barre formule fx restent visibles, la grille récupère la hauteur perdue), bascule unique `#xlsx-tools-toggle` (☰, `aria-expanded`), cibles tactiles ≥ 44 px, groupes du ruban qui s'enroulent, onglets de feuilles défilants, inputs 16 px (anti-zoom iOS). E2E mobile ajouté + helper `openXlsx()` (déplie le ruban). 10 échecs E2E mobile **préexistants** prouvés par `git stash` (hors périmètre, CI ne lance que desktop). Vérifié : xlsx-viewer.test.mjs 165/165, E2E desktop 19 passed/1 skipped, suite 1628 passed, ruff/mypy 0, CI run #1937 success | ✅ livré (en attente vérif utilisateur) |
|
||||
| 2026-10-08 | #190 | Fonctionnalité | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js` (réécrit), `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/js/sidebar.js`, `frontend/js/app.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `CHANGELOG.md` | **#190 — chat : suivi du document, chat général en onglet sidebar, onglets au-dessus du filtre** : (A) l'en-tête du panneau affiche le document ciblé (titre + chemin) et `followFileChat()` appelé par `renderFile()` re-cible le panneau ouvert à chaque changement de document ; (B) onglet **Chat** (dernier) → conversation générale stockée dans le même store #169 via les sentinelles `__global__/general` (`GET/POST /api/chat`), **pastille de messages non lus** (`localStorage` + badge sur l'onglet), **pièces jointes image/vidéo** (`POST /api/chat/upload` : allow-list d'extensions, 25 MB, nom UUID — jamais le nom client ; `GET /api/chat/attachment/{name}` résolu contre l'allow-list), **URL cliquables** dans le rendu (linkification `http(s)://`), date/heure d'envoi ; (C) barre de filtre **déplacée sous les onglets** et routée vers `filterChatMessages()` (texte + auteur) quand l'onglet Chat est actif. Transport : broadcast SSE `chat_message` réutilisé (vault `__global__` route vers le panneau sidebar, sinon panneau fichier) — pas de second WebSocket. Contre-preuves : `__global__` non routé → test « routes __global__ » échoue ; panneau sans classe `active` → le message part en toast au lieu du rendu (révélé par le stub JSDOM). Vérifié : pytest **1655 passed / 2 skipped** (27 tests chat dont 12 nouveaux), ruff 0, mypy 0 (113 fichiers), validate-imports 42 modules/368 exports, unit 13/13, filechat.test.mjs **11/11** (5 nouveaux), toolbar-order, sidebar-filters 8/8 | ✅ livré (en attente vérif utilisateur) |
|
||||
| 2026-10-08 | #169 | Fonctionnalité | `backend/file_chat.py` (nouveau), `backend/routers/file_chat.py` (nouveau), `backend/schemas.py`, `backend/main.py`, `frontend/js/filechat.js` (nouveau), `frontend/js/viewer.js`, `frontend/js/sync.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py` (nouveau), `tests/frontend/filechat.test.mjs` (nouveau), `tests/frontend/toolbar-order.test.mjs`, `.gitea/workflows/ci.yml`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md` (nouveau), `docs/GUIDES/PRISE_EN_MAIN.md`, `CHANGELOG.md` | **#169 — chat intégré par fichier** : store JSON par (vault, path) sous `data/chats/` (nom SHA-256 → traversal impossible, plafond 500 msgs, texte 4000 car., écriture atomique, fichier corrompu → vide) ; `GET/POST /api/file/{vault}/chat` (auth + `check_vault_access` + `resolve_safe_path`, 400/404/403, `response_model`) + broadcast SSE `chat_message` sur le transport #62 (**pas de 2ᵉ WebSocket**) ; panneau latéral `filechat.js` (bouton 💬 toolbar, rendu chronologique `textContent`, envoi optimiste + dédup par id, toast si panneau fermé/autre fichier, plein écran ≤ 768 px) ; relais SSE en import dynamique dans `sync.js` ; i18n FR/EN 10 clés `chat.*`. En route : tag OpenAPI maison `file-chat` non déclaré → `test_used_tags_are_declared` rouge (retiré, dérivé « Files »), regex navBtns de `toolbar-order.test.mjs` (échappements → `includes`), ruff UP012/TRY004/I001. Vérifié : `test_file_chat.py` 15 passed, `filechat.test.mjs` 6/6, suite 1643 passed, ruff/mypy 0, validate-imports 42 modules, CI run #1938 5/5 success (v2.57.0) | ✅ livré (en attente vérif utilisateur) |
|
||||
|
||||
---
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.57.1 | **Dernière mise à jour :** 2026-10-08
|
||||
> **Version :** 2.58.0 | **Dernière mise à jour :** 2026-10-08
|
||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||
> vers les fonctionnalités livrées.
|
||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||
@@ -731,6 +731,7 @@
|
||||
| 188 | Secrets — détection universelle des clés API & mots de passe, masque cliquable (clic = copie) | Unreleased | [features/secret-mask-188.md](./features/secret-mask-188.md) |
|
||||
| 189 | Tableur — mise en page mobile : barres repliées par défaut, bascule unique, cibles tactiles 44 px | 2.56.0 | [features/xlsx-mobile-layout-189.md](./features/xlsx-mobile-layout-189.md) |
|
||||
| 169 | Chat intégré par fichier (panneau latéral, historique, temps réel SSE, toast de notification) | 2.57.0 | [features/file-chat-169.md](./features/file-chat-169.md) |
|
||||
| 190 | Chat — suivi du document, chat général en onglet sidebar (pièces jointes, non lus, recherche), onglets au-dessus du filtre | Unreleased | [features/file-chat-169.md](./features/file-chat-169.md) |
|
||||
| 159 | Desktop — gestion des vaults & dossiers : retrait par menu contextuel + section Configuration (ajout vault/dossier racine) | 2.50.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) |
|
||||
| 160 | Desktop — premier lancement professionnel (répertoire `%USERPROFILE%\ObsiGate` + `Prise en main.md`) et section Configuration harmonisée | 2.51.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) |
|
||||
| BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) |
|
||||
|
||||
@@ -54,6 +54,42 @@
|
||||
(fetch simulé vers `frontend/locales/`).
|
||||
- Les deux sont ajoutés au pipeline CI (`.gitea/workflows/ci.yml`).
|
||||
|
||||
## #190 — Suivi du document, chat général, ordre des onglets (2026-10-08)
|
||||
|
||||
### A. Le panneau suit le document
|
||||
|
||||
- En-tête du panneau : `.file-chat-target` affiche le titre du document
|
||||
(l'attribut `title` porte le chemin complet).
|
||||
- `followFileChat(vault, path, title)` est appelé en tête de `renderFile()` :
|
||||
panneau ouvert + autre document → re-cible (reload de l'historique) ;
|
||||
panneau fermé → no-op. Le bouton 💬 transmet `data.title`.
|
||||
|
||||
### B. Chat général en onglet sidebar
|
||||
|
||||
- Onglet `#sidebar-tab-chat` (dernier, `message-circle`) + panneau
|
||||
`#sidebar-panel-chat` (liste + 📎 + saisie + Envoyer, Entrée = envoyer).
|
||||
- **Store** : rien de nouveau — les sentinelles `GLOBAL_VAULT="__global__"` /
|
||||
`GLOBAL_PATH="general"` réutilisent `add_message`/`get_messages`.
|
||||
- **Routes** : `GET/POST /api/chat`, `POST /api/chat/upload`,
|
||||
`GET /api/chat/attachment/{name}`.
|
||||
- **Pièces jointes** : allow-list d'extensions (11 : images + vidéos), 25 MB,
|
||||
nom de stockage = UUID (le nom client n'est jamais utilisé), résolution
|
||||
par comparaison au nom + allow-list (aucun chemin utilisateur).
|
||||
- **Messages non lus** : timestamp de dernière visite dans
|
||||
`localStorage['obsigate-chat-unread']`, comptage des messages plus récents
|
||||
d'un autre auteur, pastille rouge `99+` sur l'onglet, effacée à l'ouverture.
|
||||
- **URL** : linkification `http(s)://` au rendu (texte autour en `TextNode`,
|
||||
donc pas d'injection).
|
||||
- **SSE** : `vault === "__global__"` route vers la liste sidebar (append +
|
||||
clear) ou vers badge + toast selon que l'onglet est actif.
|
||||
|
||||
### C. Ordre des onglets + filtre = recherche chat
|
||||
|
||||
- `index.html` : le bloc `.sidebar-filter` est **déplacé après** `.sidebar-tabs`.
|
||||
- `switchSidebarTab` : placeholder `sidebar.filter_chat`, dispatch `chat`.
|
||||
- `initSidebarFilter` (`sidebar.js`) : `routeFilter`/`routeClear` routent
|
||||
vers `filterChatMessages()` (texte **et** auteur, insensible à la casse).
|
||||
|
||||
## Décisions
|
||||
|
||||
- **SSE plutôt qu'un second WebSocket** : le transport de #62 (EventSource
|
||||
|
||||
+96
-40
@@ -812,46 +812,6 @@
|
||||
role="navigation"
|
||||
aria-label="Navigation des vaults"
|
||||
>
|
||||
<!-- Sidebar filter -->
|
||||
<div class="sidebar-filter">
|
||||
<div class="sidebar-filter-input-wrapper">
|
||||
<i
|
||||
data-lucide="filter"
|
||||
class="sidebar-filter-icon"
|
||||
style="width: 14px; height: 14px"
|
||||
></i>
|
||||
<input
|
||||
type="text"
|
||||
id="sidebar-filter-input"
|
||||
data-i18n-placeholder="sidebar.filter_placeholder" placeholder="Filtrer fichiers..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="sidebar-filter-actions">
|
||||
<button
|
||||
class="sidebar-filter-btn"
|
||||
id="sidebar-filter-case-btn"
|
||||
type="button"
|
||||
title="Respecter la casse"
|
||||
aria-label="Respecter la casse"
|
||||
>
|
||||
<span>Aa</span>
|
||||
</button>
|
||||
<button
|
||||
class="sidebar-filter-btn"
|
||||
id="sidebar-filter-clear-btn"
|
||||
type="button"
|
||||
title="Effacer le filtrage"
|
||||
aria-label="Effacer le filtrage"
|
||||
>
|
||||
<i
|
||||
data-lucide="x"
|
||||
style="width: 14px; height: 14px"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div
|
||||
class="sidebar-tabs"
|
||||
@@ -928,6 +888,61 @@
|
||||
style="width: 18px; height: 18px"
|
||||
></i>
|
||||
</button>
|
||||
<button
|
||||
class="sidebar-tab"
|
||||
id="sidebar-tab-chat"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
aria-controls="sidebar-panel-chat"
|
||||
data-tab="chat"
|
||||
title="Chat"
|
||||
>
|
||||
<i
|
||||
data-lucide="message-circle"
|
||||
style="width: 18px; height: 18px"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar filter (#190 C : sous les onglets ; sert de
|
||||
recherche dans l'onglet Chat) -->
|
||||
<div class="sidebar-filter">
|
||||
<div class="sidebar-filter-input-wrapper">
|
||||
<i
|
||||
data-lucide="filter"
|
||||
class="sidebar-filter-icon"
|
||||
style="width: 14px; height: 14px"
|
||||
></i>
|
||||
<input
|
||||
type="text"
|
||||
id="sidebar-filter-input"
|
||||
data-i18n-placeholder="sidebar.filter_placeholder" placeholder="Filtrer fichiers..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="sidebar-filter-actions">
|
||||
<button
|
||||
class="sidebar-filter-btn"
|
||||
id="sidebar-filter-case-btn"
|
||||
type="button"
|
||||
title="Respecter la casse"
|
||||
aria-label="Respecter la casse"
|
||||
>
|
||||
<span>Aa</span>
|
||||
</button>
|
||||
<button
|
||||
class="sidebar-filter-btn"
|
||||
id="sidebar-filter-clear-btn"
|
||||
type="button"
|
||||
title="Effacer le filtrage"
|
||||
aria-label="Effacer le filtrage"
|
||||
>
|
||||
<i
|
||||
data-lucide="x"
|
||||
style="width: 14px; height: 14px"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vaults panel -->
|
||||
@@ -1050,6 +1065,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- General chat panel (#190 B) -->
|
||||
<div
|
||||
class="sidebar-tab-panel sidebar-chat-panel"
|
||||
id="sidebar-panel-chat"
|
||||
role="tabpanel"
|
||||
aria-labelledby="sidebar-tab-chat"
|
||||
>
|
||||
<div
|
||||
id="sidebar-panel-chat-list"
|
||||
class="file-chat-list sidebar-chat-list"
|
||||
></div>
|
||||
<div id="sidebar-panel-chat-pending" class="sidebar-chat-pending"></div>
|
||||
<form
|
||||
id="sidebar-panel-chat-form"
|
||||
class="file-chat-form sidebar-chat-form"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
id="sidebar-panel-chat-attach"
|
||||
class="file-chat-attach"
|
||||
title="Joindre une image ou une vidéo"
|
||||
aria-label="Joindre une image ou une vidéo"
|
||||
>
|
||||
<i
|
||||
data-lucide="paperclip"
|
||||
style="width: 16px; height: 16px"
|
||||
></i>
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
id="sidebar-panel-chat-input"
|
||||
class="file-chat-input"
|
||||
maxlength="4000"
|
||||
placeholder="Écrire un message…"
|
||||
autocomplete="off"
|
||||
aria-label="Écrire un message"
|
||||
/>
|
||||
<button type="submit" class="file-chat-send">Envoyer</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- User section (bottom of the sidebar, #112) -->
|
||||
<div class="sidebar-user" id="sidebar-user" hidden>
|
||||
<button
|
||||
|
||||
@@ -26,6 +26,7 @@ import { initDesktopIntegration, isTauriEnv } from './desktop.js';
|
||||
import { initPush } from './push.js';
|
||||
import { initPlugins } from './plugins.js';
|
||||
import { populateVersions } from './config.js';
|
||||
import { initSidebarChat } from './filechat.js';
|
||||
|
||||
// =========================================================================
|
||||
// Initialization — mirrors the original app.js init() ordering
|
||||
@@ -61,6 +62,7 @@ async function init() {
|
||||
Legacy.initConfigModal();
|
||||
Legacy.initAboutModal();
|
||||
Sidebar.initSidebarFilter();
|
||||
initSidebarChat(); // #190 B — formulaire du chat général (sidebar)
|
||||
UI.initSidebarResize();
|
||||
Utils.initEditor();
|
||||
Auth.initLoginForm();
|
||||
|
||||
@@ -8,6 +8,7 @@ import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
|
||||
import { t, setLocale, getLocale } from './i18n.js';
|
||||
import { getModelCapabilities, renderCapabilityBadges, refreshAIPickers } from './ai.js';
|
||||
import { isTauriEnv, invoke, pickAndAddVault, pickAndAddDir, removeRoot } from './desktop.js';
|
||||
import { openSidebarChat, filterChatMessages as _filterChat } from './filechat.js';
|
||||
|
||||
let _recentTimestampTimer = null;
|
||||
let _recentFilesCache = [];
|
||||
@@ -305,6 +306,7 @@ function switchSidebarTab(tab) {
|
||||
recent: t("sidebar.filter_recent"),
|
||||
saved: t("sidebar.filter_saved"),
|
||||
ai: t("sidebar.filter_ai"),
|
||||
chat: t("sidebar.filter_chat"),
|
||||
};
|
||||
filterInput.placeholder = placeholders[tab] || "";
|
||||
}
|
||||
@@ -315,6 +317,7 @@ function switchSidebarTab(tab) {
|
||||
else if (tab === "recent") filterRecentFiles(query);
|
||||
else if (tab === "saved") filterSavedSearches(query);
|
||||
else if (tab === "ai") filterAIHistory(query);
|
||||
else if (tab === "chat") _filterChat(query);
|
||||
}
|
||||
// Auto-load recent files when switching to the recent tab
|
||||
if (tab === "recent") {
|
||||
@@ -333,6 +336,11 @@ function switchSidebarTab(tab) {
|
||||
if (filterInput) _aiQuery = filterInput.value.trim();
|
||||
loadAISessionList();
|
||||
}
|
||||
// #190 B — Auto-load the general chat when entering its tab (fresh history;
|
||||
// openSidebarChat clears the unread badge).
|
||||
if (tab === "chat") {
|
||||
openSidebarChat();
|
||||
}
|
||||
}
|
||||
|
||||
function initHelpModal() {
|
||||
|
||||
+335
-55
@@ -1,29 +1,36 @@
|
||||
// frontend/js/filechat.js — Chat intégré par fichier (#169)
|
||||
// Panneau latéral : historique + saisie. Les messages entrants arrivent par
|
||||
// SSE (event `chat_message` relayé depuis sync.js) ; si le panneau est fermé
|
||||
// ou affiche un autre fichier, un toast notifie l'utilisateur.
|
||||
// frontend/js/filechat.js — Chat fichier (#169) + chat général sidebar (#190)
|
||||
//
|
||||
// Deux surfaces, un module :
|
||||
// 1. Panneau latéral du document (#169) — historique + saisie, suivi du
|
||||
// document ouvert (followFileChat le re-cible à chaque renderFile).
|
||||
// 2. Onglet « Chat » de la sidebar (#190) — conversation générale, pièces
|
||||
// jointes (image/vidéo/URL), indicateur de messages non lus, recherche
|
||||
// via la barre de filtre de la sidebar.
|
||||
//
|
||||
// Les deux reçoivent les messages en direct par SSE (`chat_message`, relayé
|
||||
// depuis sync.js) : vault `__global__` = chat général, sinon chat fichier.
|
||||
import { api, AuthManager } from "./auth.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { showToast } from "./ui.js";
|
||||
import { escapeHtml } from "./utils.js";
|
||||
|
||||
const PANEL_ID = "file-chat-panel";
|
||||
const GLOBAL_VAULT = "__global__";
|
||||
const UNREAD_KEY = "obsigate-chat-unread"; // {general: ts}
|
||||
|
||||
let _vault = null;
|
||||
let _path = null;
|
||||
let _title = "";
|
||||
let _sending = false;
|
||||
|
||||
function _panel() {
|
||||
let el = document.getElementById(PANEL_ID);
|
||||
if (!el) {
|
||||
el = document.createElement("aside");
|
||||
el.id = PANEL_ID;
|
||||
el.className = "file-chat-panel hidden";
|
||||
el.setAttribute("aria-label", t("chat.title"));
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
// État du chat général (onglet sidebar)
|
||||
let _generalMessages = [];
|
||||
let _generalLoaded = false;
|
||||
let _pendingAttach = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Partagé
|
||||
// ---------------------------------------------------------------------------
|
||||
function _esc(s) {
|
||||
return escapeHtml(String(s == null ? "" : s));
|
||||
}
|
||||
@@ -38,6 +45,71 @@ function _formatTime(ts) {
|
||||
}
|
||||
}
|
||||
|
||||
function _currentUser() {
|
||||
const u = AuthManager.getUser();
|
||||
return (u && u.username) || "";
|
||||
}
|
||||
|
||||
// URL dans le texte → lien cliquable (partage d'URL #190), texte sinon.
|
||||
function _fillBody(body, text) {
|
||||
const parts = String(text || "").split(/(https?:\/\/[^\s<>"']+)/g);
|
||||
parts.forEach((part) => {
|
||||
if (!part) return;
|
||||
if (/^https?:\/\//.test(part)) {
|
||||
const a = document.createElement("a");
|
||||
a.href = part;
|
||||
a.textContent = part;
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener noreferrer";
|
||||
a.className = "file-chat-link";
|
||||
body.appendChild(a);
|
||||
} else {
|
||||
body.appendChild(document.createTextNode(part));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _attachEl(att) {
|
||||
if (!att || !att.url) return null;
|
||||
if (att.kind === "image") {
|
||||
const img = document.createElement("img");
|
||||
img.src = att.url;
|
||||
img.alt = att.name || "image";
|
||||
img.loading = "lazy";
|
||||
img.className = "file-chat-image";
|
||||
return img;
|
||||
}
|
||||
if (att.kind === "video") {
|
||||
const v = document.createElement("video");
|
||||
v.src = att.url;
|
||||
v.controls = true;
|
||||
v.preload = "metadata";
|
||||
v.className = "file-chat-video";
|
||||
return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function _messageEl(m, currentUser) {
|
||||
const mine = currentUser && m.user === currentUser;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "file-chat-msg" + (mine ? " mine" : "");
|
||||
wrap.dataset.id = m.id || "";
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "file-chat-meta";
|
||||
meta.innerHTML =
|
||||
`<span class="file-chat-user">${_esc(m.user)}</span>` +
|
||||
`<span class="file-chat-ts">${_esc(_formatTime(m.ts))}</span>`;
|
||||
const body = document.createElement("div");
|
||||
body.className = "file-chat-body";
|
||||
_fillBody(body, m.text);
|
||||
wrap.appendChild(meta);
|
||||
if (m.text) wrap.appendChild(body);
|
||||
const att = _attachEl(m.attachment);
|
||||
if (att) wrap.appendChild(att);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function renderChatMessages(container, messages, currentUser) {
|
||||
container.innerHTML = "";
|
||||
if (!messages || messages.length === 0) {
|
||||
@@ -51,22 +123,29 @@ export function renderChatMessages(container, messages, currentUser) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
|
||||
function _messageEl(m, currentUser) {
|
||||
const mine = currentUser && m.user === currentUser;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "file-chat-msg" + (mine ? " mine" : "");
|
||||
wrap.dataset.id = m.id || "";
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "file-chat-meta";
|
||||
meta.innerHTML =
|
||||
`<span class="file-chat-user">${_esc(m.user)}</span>` +
|
||||
`<span class="file-chat-ts">${_esc(_formatTime(m.ts))}</span>`;
|
||||
const body = document.createElement("div");
|
||||
body.className = "file-chat-body";
|
||||
body.textContent = m.text || "";
|
||||
wrap.appendChild(meta);
|
||||
wrap.appendChild(body);
|
||||
return wrap;
|
||||
function _appendMessage(list, m) {
|
||||
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.scrollTop = list.scrollHeight;
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Panneau du document (#169)
|
||||
// ---------------------------------------------------------------------------
|
||||
function _panel() {
|
||||
let el = document.getElementById(PANEL_ID);
|
||||
if (!el) {
|
||||
el = document.createElement("aside");
|
||||
el.id = PANEL_ID;
|
||||
el.className = "file-chat-panel hidden";
|
||||
el.setAttribute("aria-label", t("chat.title"));
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
async function _load() {
|
||||
@@ -86,17 +165,21 @@ async function _load() {
|
||||
}
|
||||
}
|
||||
|
||||
function _currentUser() {
|
||||
const u = AuthManager.getUser();
|
||||
return (u && u.username) || "";
|
||||
}
|
||||
|
||||
function _renderShell(panel) {
|
||||
panel.innerHTML = "";
|
||||
const header = document.createElement("div");
|
||||
header.className = "file-chat-header";
|
||||
const titles = document.createElement("div");
|
||||
titles.className = "file-chat-titles";
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = t("chat.title");
|
||||
titles.appendChild(title);
|
||||
// #190 A — le document ciblé est affiché dans l'en-tête
|
||||
const target = document.createElement("span");
|
||||
target.className = "file-chat-target";
|
||||
target.textContent = _title || _path || "";
|
||||
target.title = _path || "";
|
||||
titles.appendChild(target);
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "file-chat-close";
|
||||
@@ -104,7 +187,7 @@ function _renderShell(panel) {
|
||||
close.setAttribute("aria-label", t("chat.close"));
|
||||
close.textContent = "✕";
|
||||
close.addEventListener("click", closeFileChat);
|
||||
header.appendChild(title);
|
||||
header.appendChild(titles);
|
||||
header.appendChild(close);
|
||||
|
||||
const list = document.createElement("div");
|
||||
@@ -134,9 +217,10 @@ function _renderShell(panel) {
|
||||
panel.appendChild(form);
|
||||
}
|
||||
|
||||
export async function openFileChat(vault, path) {
|
||||
export async function openFileChat(vault, path, title) {
|
||||
_vault = vault;
|
||||
_path = path;
|
||||
_title = title || path || "";
|
||||
const panel = _panel();
|
||||
_renderShell(panel);
|
||||
panel.classList.remove("hidden");
|
||||
@@ -150,13 +234,21 @@ export function closeFileChat() {
|
||||
if (panel) panel.classList.add("hidden");
|
||||
}
|
||||
|
||||
export function toggleFileChat(vault, path) {
|
||||
export function toggleFileChat(vault, path, title) {
|
||||
const panel = document.getElementById(PANEL_ID);
|
||||
if (panel && !panel.classList.contains("hidden") && _vault === vault && _path === path) {
|
||||
closeFileChat();
|
||||
return;
|
||||
}
|
||||
openFileChat(vault, path);
|
||||
openFileChat(vault, path, title);
|
||||
}
|
||||
|
||||
// #190 A — appelé à chaque renderFile : le panneau ouvert suit le document.
|
||||
export function followFileChat(vault, path, title) {
|
||||
const panel = document.getElementById(PANEL_ID);
|
||||
if (!panel || panel.classList.contains("hidden")) return;
|
||||
if (_vault === vault && _path === path) return;
|
||||
openFileChat(vault, path, title);
|
||||
}
|
||||
|
||||
async function _send(input) {
|
||||
@@ -170,11 +262,7 @@ async function _send(input) {
|
||||
body: JSON.stringify({ path: _path, text }),
|
||||
});
|
||||
input.value = "";
|
||||
const list = _panel().querySelector(".file-chat-list");
|
||||
const empty = list.querySelector(".file-chat-empty");
|
||||
if (empty) empty.remove();
|
||||
list.appendChild(_messageEl(data.message, _currentUser()));
|
||||
list.scrollTop = list.scrollHeight;
|
||||
_appendMessage(_panel().querySelector(".file-chat-list"), data.message);
|
||||
} catch (_) {
|
||||
showToast(t("chat.send_error"), "error");
|
||||
} finally {
|
||||
@@ -183,24 +271,216 @@ async function _send(input) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- SSE relay (appelé par sync.js sur l'événement `chat_message`) ---------
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Chat général, onglet sidebar (#190 B)
|
||||
// ---------------------------------------------------------------------------
|
||||
function _sidebarList() {
|
||||
return document.getElementById("sidebar-panel-chat-list");
|
||||
}
|
||||
|
||||
/** Charge (ou recharge) l'historique du chat général dans la sidebar. */
|
||||
export async function openSidebarChat() {
|
||||
const list = _sidebarList();
|
||||
if (!list) return;
|
||||
try {
|
||||
const data = await api("/api/chat");
|
||||
_generalMessages = (data.messages || []).slice();
|
||||
_generalLoaded = true;
|
||||
renderChatMessages(list, _generalMessages, _currentUser());
|
||||
_clearUnread();
|
||||
} catch (_) {
|
||||
list.innerHTML = "";
|
||||
const err = document.createElement("div");
|
||||
err.className = "file-chat-empty";
|
||||
err.textContent = t("chat.load_error");
|
||||
list.appendChild(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recherche dans le chat général (barre de filtre sidebar) — #190 C. */
|
||||
export function filterChatMessages(query) {
|
||||
const list = _sidebarList();
|
||||
if (!list || !_generalLoaded) return;
|
||||
const q = String(query || "").toLowerCase().trim();
|
||||
const shown = q
|
||||
? _generalMessages.filter((m) =>
|
||||
((m.text || "") + " " + (m.user || "")).toLowerCase().includes(q),
|
||||
)
|
||||
: _generalMessages;
|
||||
renderChatMessages(list, shown, _currentUser());
|
||||
}
|
||||
|
||||
function _sendInput() {
|
||||
return document.getElementById("sidebar-panel-chat-input");
|
||||
}
|
||||
|
||||
function _renderPending() {
|
||||
const holder = document.getElementById("sidebar-panel-chat-pending");
|
||||
if (!holder) return;
|
||||
holder.innerHTML = "";
|
||||
if (!_pendingAttach) return;
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "file-chat-pending";
|
||||
chip.textContent =
|
||||
(_pendingAttach.kind === "video" ? "🎬 " : "🖼 ") + (_pendingAttach.name || "pièce jointe");
|
||||
const x = document.createElement("button");
|
||||
x.type = "button";
|
||||
x.textContent = "✕";
|
||||
x.setAttribute("aria-label", t("chat.attach_remove"));
|
||||
x.addEventListener("click", () => {
|
||||
_pendingAttach = null;
|
||||
_renderPending();
|
||||
});
|
||||
chip.appendChild(x);
|
||||
holder.appendChild(chip);
|
||||
}
|
||||
|
||||
/** Envoi d'un message du chat général (texte + pièce jointe éventuelle). */
|
||||
export async function sendGeneralMessage() {
|
||||
const input = _sendInput();
|
||||
if (!input) return;
|
||||
const text = (input.value || "").trim();
|
||||
if (!text && !_pendingAttach) return;
|
||||
const body = { text };
|
||||
if (_pendingAttach) body.attachment = _pendingAttach;
|
||||
try {
|
||||
const data = await api("/api/chat", { method: "POST", body: JSON.stringify(body) });
|
||||
input.value = "";
|
||||
_pendingAttach = null;
|
||||
_renderPending();
|
||||
const list = _sidebarList();
|
||||
if (list) {
|
||||
_generalMessages.push(data.message);
|
||||
_appendMessage(list, data.message);
|
||||
}
|
||||
input.focus();
|
||||
} catch (_) {
|
||||
showToast(t("chat.send_error"), "error");
|
||||
}
|
||||
}
|
||||
|
||||
/** Sélecteur de pièce jointe (image/vidéo) → upload → envoi libre. */
|
||||
export async function pickAttachment() {
|
||||
return new Promise((resolve) => {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*,video/*";
|
||||
fileInput.style.display = "none";
|
||||
document.body.appendChild(fileInput);
|
||||
fileInput.addEventListener("change", async () => {
|
||||
const file = fileInput.files && fileInput.files[0];
|
||||
fileInput.remove();
|
||||
if (!file) return resolve(null);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await api("/api/chat/upload", { method: "POST", body: form });
|
||||
_pendingAttach = res.attachment;
|
||||
_renderPending();
|
||||
resolve(_pendingAttach);
|
||||
} catch (_) {
|
||||
showToast(t("chat.attach_error"), "error");
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Messages non lus (#190 B) --------------------------------------------
|
||||
function _readUnread() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(UNREAD_KEY) || "{}") || {};
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function _clearUnread() {
|
||||
const u = _readUnread();
|
||||
u.general = Math.floor(Date.now() / 1000);
|
||||
try {
|
||||
localStorage.setItem(UNREAD_KEY, JSON.stringify(u));
|
||||
} catch (_) { /* quota */ }
|
||||
_renderBadge(0);
|
||||
}
|
||||
|
||||
function _renderBadge(count) {
|
||||
const tab = document.getElementById("sidebar-tab-chat");
|
||||
if (!tab) return;
|
||||
let badge = tab.querySelector(".chat-unread-badge");
|
||||
if (!count) {
|
||||
if (badge) badge.remove();
|
||||
return;
|
||||
}
|
||||
if (!badge) {
|
||||
badge = document.createElement("span");
|
||||
badge.className = "chat-unread-badge";
|
||||
tab.appendChild(badge);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
/** Initialisation : formulaire + raccourcis clavier. */
|
||||
export function initSidebarChat() {
|
||||
const form = document.getElementById("sidebar-panel-chat-form");
|
||||
if (!form) return;
|
||||
const input = document.getElementById("sidebar-panel-chat-input");
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
sendGeneralMessage();
|
||||
});
|
||||
const attach = document.getElementById("sidebar-panel-chat-attach");
|
||||
if (attach) attach.addEventListener("click", () => pickAttachment());
|
||||
if (input) {
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendGeneralMessage();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE relay (appelé par sync.js sur l'événement `chat_message`)
|
||||
// ---------------------------------------------------------------------------
|
||||
export function onChatMessage(data) {
|
||||
if (!data || !data.message) return;
|
||||
const isGeneral = data.vault === GLOBAL_VAULT;
|
||||
|
||||
if (isGeneral) {
|
||||
_generalMessages.push(data.message);
|
||||
const list = _sidebarList();
|
||||
const panel = document.getElementById("sidebar-panel-chat");
|
||||
const visible = panel && panel.classList.contains("active");
|
||||
if (list && visible) {
|
||||
_appendMessage(list, data.message);
|
||||
_clearUnread();
|
||||
} else if (data.message.user !== _currentUser()) {
|
||||
refreshUnreadBadge(_generalMessages);
|
||||
showToast(t("chat.new_message", { user: data.message.user }), "info");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Chat d'un document (#169)
|
||||
const panel = document.getElementById(PANEL_ID);
|
||||
const isOpen =
|
||||
panel && !panel.classList.contains("hidden") &&
|
||||
_vault === data.vault && _path === data.path;
|
||||
if (isOpen) {
|
||||
const list = panel.querySelector(".file-chat-list");
|
||||
if (list) {
|
||||
const empty = list.querySelector(".file-chat-empty");
|
||||
if (empty) empty.remove();
|
||||
// Évite le doublon si l'envoi local a déjà rendu ce message
|
||||
const id = data.message.id;
|
||||
if (id && list.querySelector(`[data-id="${CSS.escape(id)}"]`)) return;
|
||||
list.appendChild(_messageEl(data.message, _currentUser()));
|
||||
list.scrollTop = list.scrollHeight;
|
||||
}
|
||||
if (list) _appendMessage(list, data.message);
|
||||
return;
|
||||
}
|
||||
if (data.message.user === _currentUser()) return; // son propre message
|
||||
|
||||
@@ -6,6 +6,7 @@ import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRe
|
||||
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.js';
|
||||
import { performAdvancedSearch } from './search.js';
|
||||
import { t } from './i18n.js';
|
||||
import { filterChatMessages as _filterChat } from './filechat.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vault context switching
|
||||
@@ -704,13 +705,14 @@ function initSidebarFilter() {
|
||||
const caseBtn = document.getElementById("sidebar-filter-case-btn");
|
||||
const clearBtn = document.getElementById("sidebar-filter-clear-btn");
|
||||
|
||||
// Route the query to the active tab's own filter (#98/#99).
|
||||
// Route the query to the active tab's own filter (#98/#99, chat #190 C).
|
||||
const routeFilter = async (q) => {
|
||||
const tab = state.activeSidebarTab;
|
||||
if (tab === "vaults") await performTreeSearch(q);
|
||||
else if (tab === "recent") filterRecentFiles(q);
|
||||
else if (tab === "saved") filterSavedSearches(q);
|
||||
else if (tab === "ai") filterAIHistory(q);
|
||||
else if (tab === "chat") _filterChat(q);
|
||||
else filterTagCloud(q);
|
||||
};
|
||||
const routeClear = async () => {
|
||||
@@ -719,6 +721,7 @@ function initSidebarFilter() {
|
||||
else if (tab === "recent") filterRecentFiles("");
|
||||
else if (tab === "saved") filterSavedSearches("");
|
||||
else if (tab === "ai") filterAIHistory("");
|
||||
else if (tab === "chat") _filterChat("");
|
||||
else filterTagCloud("");
|
||||
};
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import { t, getLocale } from './i18n.js';
|
||||
import { onFileRender } from './plugins.js';
|
||||
import { NowPlaying } from './now-playing.js';
|
||||
import { openAssistant } from './ai-fab.js';
|
||||
import { toggleFileChat } from './filechat.js';
|
||||
import { toggleFileChat, followFileChat } from './filechat.js';
|
||||
import { parseRef, columnName, findTd, invalidateCellIndex, sheetOfRef, firstCellOfRange } from './xlsx/refs.js';
|
||||
import { createColourPicker } from './xlsx/color-picker.js';
|
||||
import { buildCommandBar } from './xlsx/command-bar.js';
|
||||
@@ -6897,6 +6897,8 @@ export function renderFile(data) {
|
||||
// state (CodeMirror view, Forge iframe, Yjs session) is torn down cleanly
|
||||
// instead of being wiped mid-session by a tab switch / sidebar click.
|
||||
if (isInlineEditorActive()) detachInlineEditor();
|
||||
// #190 A — an open chat panel follows the document being rendered.
|
||||
followFileChat(data.vault, data.path, data.title);
|
||||
// #108 — release the image viewer's document-level shortcuts before swapping
|
||||
// the content area (otherwise they leak on every re-render).
|
||||
if (_imageViewerCleanup) { _imageViewerCleanup(); _imageViewerCleanup = null; }
|
||||
@@ -7227,7 +7229,7 @@ export function renderFile(data) {
|
||||
// #169 — Chat intégré par fichier (panneau latéral)
|
||||
const chatBtn = el("button", { class: "btn-action btn-chat", title: t("chat.button_title") }, [icon("message-square", 14), document.createTextNode(t("chat.button"))]);
|
||||
chatBtn.addEventListener("click", () => {
|
||||
toggleFileChat(data.vault, data.path);
|
||||
toggleFileChat(data.vault, data.path, data.title);
|
||||
});
|
||||
// Check bookmark status and color the button
|
||||
(async () => {
|
||||
|
||||
@@ -319,6 +319,8 @@
|
||||
"backups.restore_confirm": "to the selected backup version?\nThe current version will be saved.",
|
||||
"backups.title": "Backups",
|
||||
"button.cancel": "Cancel",
|
||||
"chat.attach_error": "Attachment upload failed",
|
||||
"chat.attach_remove": "Remove attachment",
|
||||
"chat.button": "Chat",
|
||||
"chat.button_title": "Discuss this file",
|
||||
"chat.close": "Close the discussion panel",
|
||||
@@ -328,6 +330,7 @@
|
||||
"chat.placeholder": "Write a message…",
|
||||
"chat.send": "Send",
|
||||
"chat.send_error": "Failed to send the message",
|
||||
"chat.tab": "General chat",
|
||||
"chat.title": "Discussion",
|
||||
"common.actions": "Actions",
|
||||
"common.all": "All",
|
||||
@@ -1707,6 +1710,7 @@
|
||||
"sidebar.file_icons": "Icons",
|
||||
"sidebar.filter_instant": "Instant search",
|
||||
"sidebar.filter_ai": "Filter AI history...",
|
||||
"sidebar.filter_chat": "Search the chat...",
|
||||
"sidebar.filter_recent": "Filter recent files...",
|
||||
"sidebar.filter_saved": "Filter saved searches...",
|
||||
"sidebar.filter_path": "Path filters",
|
||||
|
||||
@@ -319,6 +319,8 @@
|
||||
"backups.restore_confirm": "a la version du backup selectionne ?\nLa version actuelle sera sauvegardee.",
|
||||
"backups.title": "Backups",
|
||||
"button.cancel": "Annuler",
|
||||
"chat.attach_error": "Échec de l'envoi de la pièce jointe",
|
||||
"chat.attach_remove": "Retirer la pièce jointe",
|
||||
"chat.button": "Chat",
|
||||
"chat.button_title": "Discuter de ce fichier",
|
||||
"chat.close": "Fermer le panneau de discussion",
|
||||
@@ -328,6 +330,7 @@
|
||||
"chat.placeholder": "Écrire un message…",
|
||||
"chat.send": "Envoyer",
|
||||
"chat.send_error": "Échec de l'envoi du message",
|
||||
"chat.tab": "Chat général",
|
||||
"chat.title": "Discussion",
|
||||
"common.actions": "Actions",
|
||||
"common.all": "Tous",
|
||||
@@ -1707,6 +1710,7 @@
|
||||
"sidebar.file_icons": "Icônes",
|
||||
"sidebar.filter_instant": "Recherche instantanée",
|
||||
"sidebar.filter_ai": "Filtrer l'historique IA...",
|
||||
"sidebar.filter_chat": "Rechercher dans le chat...",
|
||||
"sidebar.filter_recent": "Filtrer les fichiers récents...",
|
||||
"sidebar.filter_saved": "Filtrer les recherches sauvegardées...",
|
||||
"sidebar.filter_path": "Filtres de chemin",
|
||||
|
||||
@@ -13887,3 +13887,114 @@ body.reading-mode .file-actions {
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
#190 — Chat : en-tête ciblé, onglet sidebar, pièces jointes, badge
|
||||
--------------------------------------------------------------------------- */
|
||||
.file-chat-titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.file-chat-target {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted, #8b949e);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sidebar-tab {
|
||||
position: relative;
|
||||
}
|
||||
.chat-unread-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
padding: 0 3px;
|
||||
border-radius: 8px;
|
||||
background: #e5534b;
|
||||
color: #fff;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
line-height: 15px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.sidebar-chat-panel {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.sidebar-chat-panel.active {
|
||||
display: flex;
|
||||
}
|
||||
.sidebar-chat-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
.sidebar-chat-pending {
|
||||
padding: 0 10px;
|
||||
}
|
||||
.sidebar-chat-form {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--border, #21262d);
|
||||
}
|
||||
.file-chat-attach {
|
||||
border: 1px solid var(--border, #21262d);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary, #161b22);
|
||||
color: var(--text-muted, #8b949e);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-chat-attach:hover {
|
||||
color: var(--accent, #58a6ff);
|
||||
border-color: var(--accent, #58a6ff);
|
||||
}
|
||||
.file-chat-pending {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 4px 0;
|
||||
padding: 3px 8px;
|
||||
border: 1px dashed var(--border, #21262d);
|
||||
border-radius: 8px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8b949e);
|
||||
}
|
||||
.file-chat-pending button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-chat-image {
|
||||
max-width: 100%;
|
||||
max-height: 220px;
|
||||
border-radius: 8px;
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
}
|
||||
.file-chat-video {
|
||||
max-width: 100%;
|
||||
max-height: 240px;
|
||||
border-radius: 8px;
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
}
|
||||
.file-chat-link {
|
||||
color: var(--accent, #58a6ff);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.57.1",
|
||||
"version": "2.58.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": {
|
||||
|
||||
@@ -21,7 +21,14 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const dom = new JSDOM(
|
||||
`<!DOCTYPE html><html><body><div id="toast-container"></div></body></html>`,
|
||||
`<!DOCTYPE html><html><body><div id="toast-container"></div>
|
||||
<div class="sidebar-tab-panel active" id="sidebar-panel-chat">
|
||||
<div id="sidebar-panel-chat-list" class="file-chat-list"></div>
|
||||
<div id="sidebar-panel-chat-pending"></div>
|
||||
<form id="sidebar-panel-chat-form"><input id="sidebar-panel-chat-input" /></form>
|
||||
</div>
|
||||
<button id="sidebar-tab-chat"></button>
|
||||
</body></html>`,
|
||||
{ url: "http://localhost/", pretendToBeVisual: true },
|
||||
);
|
||||
|
||||
@@ -63,7 +70,10 @@ const { initI18n } = await import(pathToFileURL(path.join(JS_DIR, "i18n.js")).hr
|
||||
await initI18n();
|
||||
|
||||
const mod = await import("../../frontend/js/filechat.js");
|
||||
const { renderChatMessages, onChatMessage, openFileChat, closeFileChat, toggleFileChat } = mod;
|
||||
const {
|
||||
renderChatMessages, onChatMessage, openFileChat, closeFileChat, toggleFileChat,
|
||||
followFileChat, openSidebarChat, filterChatMessages, refreshUnreadBadge,
|
||||
} = mod;
|
||||
|
||||
let pass = 0;
|
||||
const cases = [];
|
||||
@@ -148,6 +158,59 @@ test("toggleFileChat: opens then closes on the same file", async () => {
|
||||
closeFileChat();
|
||||
});
|
||||
|
||||
test("#190 A — followFileChat re-targets the open panel", async () => {
|
||||
await openFileChat("V", "one.md", "Doc Un");
|
||||
const panel = document.getElementById("file-chat-panel");
|
||||
assert.strictEqual(panel.classList.contains("hidden"), false);
|
||||
followFileChat("V", "two.md", "Doc Deux");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const target = panel.querySelector(".file-chat-target");
|
||||
assert.strictEqual(target.textContent, "Doc Deux");
|
||||
assert.strictEqual(target.title, "two.md");
|
||||
closeFileChat();
|
||||
followFileChat("V", "three.md", "Doc Trois");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
assert.strictEqual(panel.querySelector(".file-chat-target").textContent, "Doc Deux");
|
||||
});
|
||||
|
||||
test("#190 B — openSidebarChat renders the general history", async () => {
|
||||
await openSidebarChat();
|
||||
const list = document.getElementById("sidebar-panel-chat-list");
|
||||
assert.strictEqual(list.querySelector(".file-chat-empty") !== null, true);
|
||||
});
|
||||
|
||||
test("#190 B — onChatMessage routes __global__ to the sidebar list", async () => {
|
||||
const list = document.getElementById("sidebar-panel-chat-list");
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "g1", user: "alice", text: "salut tout le monde", ts: 5 } });
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
|
||||
assert.strictEqual(list.querySelector(".file-chat-body").textContent, "salut tout le monde");
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "g1", user: "alice", text: "salut tout le monde", ts: 5 } });
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
|
||||
});
|
||||
|
||||
test("#190 C — filterChatMessages narrows by text/user", async () => {
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "f1", user: "alice", text: "recette de pizza", ts: 10 } });
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "f2", user: "bob", text: "deploiement docker", ts: 11 } });
|
||||
const list = document.getElementById("sidebar-panel-chat-list");
|
||||
filterChatMessages("pizza");
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
|
||||
assert.strictEqual(list.querySelector(".file-chat-body").textContent, "recette de pizza");
|
||||
filterChatMessages("bob"); // la recherche couvre aussi l'auteur
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
|
||||
filterChatMessages("");
|
||||
assert.ok(list.querySelectorAll(".file-chat-msg").length >= 3);
|
||||
});
|
||||
|
||||
test("#190 — URL rendered as a link, surrounding text stays plain", async () => {
|
||||
const list = document.getElementById("sidebar-panel-chat-list");
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "u1", user: "alice", text: "voici https://example.com/doc et du texte", ts: 20 } });
|
||||
const msg = list.querySelector('[data-id="u1"]');
|
||||
const a = msg.querySelector("a.file-chat-link");
|
||||
assert.strictEqual(a.href, "https://example.com/doc");
|
||||
assert.strictEqual(a.target, "_blank");
|
||||
assert.ok(msg.textContent.includes("du texte"));
|
||||
});
|
||||
|
||||
// Sequential execution: each case depends on panel state left by the previous.
|
||||
for (const [name, fn] of cases) {
|
||||
try {
|
||||
|
||||
+88
-1
@@ -10,8 +10,10 @@ from backend import file_chat as _store
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
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``/
|
||||
``data/chat_uploads``."""
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
monkeypatch.setattr(_store, "UPLOAD_DIR", tmp_path / "uploads")
|
||||
|
||||
|
||||
class TestStore:
|
||||
@@ -120,3 +122,88 @@ def test_history_per_path_isolated(client, test_vault_dir, path):
|
||||
other = "note1.md" if path != "note1.md" else "note2.md"
|
||||
r = client.get("/api/file/TestVault/chat", params={"path": other})
|
||||
assert r.json()["messages"] == []
|
||||
|
||||
|
||||
# --- #190 : chat général + pièces jointes -----------------------------------
|
||||
|
||||
class TestGlobalChat:
|
||||
def test_history_empty_by_default(self, client, test_vault_dir):
|
||||
r = client.get("/api/chat")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"messages": []}
|
||||
|
||||
def test_post_and_get_roundtrip(self, client, test_vault_dir):
|
||||
r = client.post("/api/chat", json={"text": "Bonjour à tous"})
|
||||
assert r.status_code == 200
|
||||
msg = r.json()["message"]
|
||||
assert msg["text"] == "Bonjour à tous"
|
||||
assert msg["user"] # authenticated user recorded
|
||||
got = client.get("/api/chat").json()["messages"]
|
||||
assert [m["id"] for m in got] == [msg["id"]]
|
||||
|
||||
def test_empty_text_rejected(self, client, test_vault_dir):
|
||||
assert client.post("/api/chat", json={"text": " "}).status_code == 400
|
||||
|
||||
def test_general_chat_isolated_from_file_chats(self, client, test_vault_dir):
|
||||
client.post("/api/chat", json={"text": "général"})
|
||||
client.post("/api/file/TestVault/chat", json={"path": "note1.md", "text": "fichier"})
|
||||
assert len(client.get("/api/chat").json()["messages"]) == 1
|
||||
assert len(client.get("/api/file/TestVault/chat", params={"path": "note1.md"}).json()["messages"]) == 1
|
||||
|
||||
def test_unknown_attachment_rejected(self, client, test_vault_dir):
|
||||
r = client.post("/api/chat", json={
|
||||
"text": "pièce jointe",
|
||||
"attachment": {"name": "nope.png", "url": "javascript:alert(1)", "kind": "image"},
|
||||
})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_external_url_attachment_accepted(self, client, test_vault_dir):
|
||||
r = client.post("/api/chat", json={
|
||||
"text": "un lien",
|
||||
"attachment": {"name": "", "url": "https://example.com/x.png", "kind": "image"},
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["message"]["attachment"]["url"] == "https://example.com/x.png"
|
||||
|
||||
|
||||
class TestAttachments:
|
||||
def test_upload_and_serve_roundtrip(self, client, test_vault_dir):
|
||||
files = {"file": ("shot.png", b"\x89PNG\r\n\x1a\nfake", "image/png")}
|
||||
r = client.post("/api/chat/upload", files=files)
|
||||
assert r.status_code == 200
|
||||
att = r.json()["attachment"]
|
||||
assert att["kind"] == "image"
|
||||
assert att["mime"] == "image/png"
|
||||
# served back with the right content type
|
||||
r2 = client.get(f"/api/chat/attachment/{att['name']}")
|
||||
assert r2.status_code == 200
|
||||
assert r2.headers["content-type"].startswith("image/png")
|
||||
|
||||
def test_video_kind_detection(self, client, test_vault_dir):
|
||||
files = {"file": ("clip.mp4", b"\x00\x00\x00\x18ftyp", "video/mp4")}
|
||||
att = client.post("/api/chat/upload", files=files).json()["attachment"]
|
||||
assert att["kind"] == "video"
|
||||
|
||||
def test_extension_not_allowed(self, client, test_vault_dir):
|
||||
files = {"file": ("evil.exe", b"MZ...", "application/octet-stream")}
|
||||
assert client.post("/api/chat/upload", files=files).status_code == 400
|
||||
|
||||
def test_empty_file_rejected(self, client, test_vault_dir):
|
||||
files = {"file": ("empty.png", b"", "image/png")}
|
||||
assert client.post("/api/chat/upload", files=files).status_code == 400
|
||||
|
||||
def test_attachment_traversal_rejected(self, client, test_vault_dir):
|
||||
# A traversal/unknown name must never produce a media response: the
|
||||
# route 404s, or the SPA catch-all answers HTML (200 + text/html).
|
||||
for bad in ("../secret.png", "sub/evil.png", "..%2Fsecret.png", "nope.png"):
|
||||
r = client.get(f"/api/chat/attachment/{bad}")
|
||||
ct = r.headers.get("content-type", "")
|
||||
assert not ct.startswith(("image/", "video/")), (bad, r.status_code, ct)
|
||||
assert r.status_code in (404, 400, 422, 200), bad
|
||||
|
||||
def test_message_with_uploaded_attachment(self, client, test_vault_dir):
|
||||
files = {"file": ("pic.webp", b"RIFF0000WEBP", "image/webp")}
|
||||
att = client.post("/api/chat/upload", files=files).json()["attachment"]
|
||||
r = client.post("/api/chat", json={"text": "voici", "attachment": att})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["message"]["attachment"]["kind"] == "image"
|
||||
|
||||
Reference in New Issue
Block a user