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