Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0e10d4cc6 | ||
|
|
c94f065f80 | ||
|
|
36b4962efb |
@@ -72,6 +72,7 @@ jobs:
|
||||
node upload.test.mjs
|
||||
node config-ai-keys.test.mjs
|
||||
node xlsx-viewer.test.mjs
|
||||
node filechat.test.mjs
|
||||
else
|
||||
echo "tests/frontend/node_modules missing - installing jsdom"
|
||||
npm install --no-audit --no-fund --silent
|
||||
@@ -93,6 +94,7 @@ jobs:
|
||||
node upload.test.mjs
|
||||
node config-ai-keys.test.mjs
|
||||
node xlsx-viewer.test.mjs
|
||||
node filechat.test.mjs
|
||||
fi
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
+49
-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.55.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.57.1**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,54 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.57.1] — 2026-10-08
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#189 - Tableur : mise en page mobile (barres repliées, grille prioritaire)**
|
||||
- Sur mobile (≤ 768 px), la **barre de menus** (Fichier, Modifier…), le
|
||||
**ruban** de formatage et le **groupe Recherche** sont **repliés par
|
||||
défaut** : la barre des feuilles et la **barre de formule (fx)** restent
|
||||
toujours visibles, et la grille récupère la hauteur d'écran perdue
|
||||
(l'ensemble dépassait 50 % de l'écran).
|
||||
- Un **bouton unique** (`#xlsx-tools-toggle`, icône ☰) dans la barre des
|
||||
feuilles déplie/replie le tout (`aria-expanded`, état actif) ; sur
|
||||
desktop il reste masqué et rien ne bouge.
|
||||
- **Ergonomie tactile** : cibles ≥ 44 px (boutons, menus, onglets), groupes
|
||||
du ruban qui s'enroulent au lieu de déborder à cette taille, onglets de
|
||||
feuilles sur une seule ligne qui **défile horizontalement**, champs de la
|
||||
barre de formule en 16 px (pas de zoom iOS au focus), barre de formule qui
|
||||
passe en 2 lignes quand les outils sont ouverts.
|
||||
- Tests : JSDOM `xlsx-viewer.test.mjs` (bascule + règles CSS, 165/165) et
|
||||
E2E mobile (repli par défaut, dépliage, ruban sans débordement, cibles
|
||||
44 px).
|
||||
|
||||
---
|
||||
|
||||
## [2.55.0] — 2026-10-08
|
||||
|
||||
### Ajouté
|
||||
|
||||
+4
-4
@@ -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/)
|
||||
@@ -85,7 +85,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
|
||||
- **🖼️ Images Obsidian** : Support complet des syntaxes d'images Obsidian avec résolution intelligente
|
||||
- **🎬 Audio & vidéo** : Lecteurs HTML5 intégrés (`.mp3 .wav .flac .mp4 .webm`…) avec streaming HTTP Range (lecture, déplacement, plein écran) et **lecture persistante** (mini-lecteur flottant / mini-fenêtre vidéo, retour au média ou arrêt à tout moment, contrôles écran verrouillé via Media Session), repli téléchargement si le format n'est pas lisible par le navigateur
|
||||
- **🎨 Diagrammes Excalidraw** : Visualiseur/éditeur natif des fichiers `.excalidraw` et `.excalidraw.md` (iframe sandboxée, auto-save, thème clair/sombre, texte des diagrammes indexé pour la recherche)
|
||||
- **📊 Tableurs Excel** : les fichiers `.xlsx` et `.xlsm` s'ouvrent dans un visualiseur dédié — un tableau par feuille avec onglets, en-têtes A1 et édition directe des cellules (`PUT /api/file/{vault}/xlsx/save`, backup automatique, écriture atomique), plus le téléchargement du fichier d'origine. Le visualiseur rend polices, couleurs, cellules fusionnées et volets figés, et offre navigation et raccourcis clavier (`Ctrl+S`, `Suppr`, `F2`, `Ctrl+Origine/Fin`, `PgPréc/PgSuiv`, `Ctrl+flèches`), barre de formule avec noms de fonctions, zone Nom éditable (« Atteindre » `A1:B3`), presse-papiers de plage (copier/couper/coller un bloc, depuis ou vers Excel), un menu **Mise en forme** (gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur — `PUT /api/file/{vault}/xlsx/style`), tri/filtre/recherche sur toutes les feuilles, export CSV/Markdown/HTML et impression (sélection ou feuille), édition de la structure (feuilles, lignes, colonnes) et un tableau de bord du classeur (plages nommées, détection graphiques/TCD, stats par feuille) ; un `.csv` s'édite dans la même grille (RFC 4180) tandis que `.xls` et `.ods` s'ouvrent en lecture seule. Les classeurs contenant des éléments qu'ObsiGate ne peut pas conserver (valeurs calculées, segments, contrôles de formulaire, signature…) affichent un **avertissement** et demandent confirmation avant l'enregistrement ; une saisie commençant par `=` ou `@` est stockée comme texte sauf activation du bouton `f(x)`, et les écritures concurrentes d'un autre poste sont détectées (`If-Match` → « Réessayer »). L'assistant IA peut lister les feuilles, injecter un tableau borné dans son contexte, rechercher dans le classeur, analyser une plage, modifier des cellules et ajouter des lignes — sur `.xlsx`, `.xlsm` et `.csv`
|
||||
- **📊 Tableurs Excel** : les fichiers `.xlsx` et `.xlsm` s'ouvrent dans un visualiseur dédié — un tableau par feuille avec onglets, en-têtes A1 et édition directe des cellules (`PUT /api/file/{vault}/xlsx/save`, backup automatique, écriture atomique), plus le téléchargement du fichier d'origine. Le visualiseur rend polices, couleurs, cellules fusionnées et volets figés, et offre navigation et raccourcis clavier (`Ctrl+S`, `Suppr`, `F2`, `Ctrl+Origine/Fin`, `PgPréc/PgSuiv`, `Ctrl+flèches`), barre de formule avec noms de fonctions, zone Nom éditable (« Atteindre » `A1:B3`), presse-papiers de plage (copier/couper/coller un bloc, depuis ou vers Excel), un menu **Mise en forme** (gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur — `PUT /api/file/{vault}/xlsx/style`), tri/filtre/recherche sur toutes les feuilles, export CSV/Markdown/HTML et impression (sélection ou feuille), édition de la structure (feuilles, lignes, colonnes) et un tableau de bord du classeur (plages nommées, détection graphiques/TCD, stats par feuille) ; un `.csv` s'édite dans la même grille (RFC 4180) tandis que `.xls` et `.ods` s'ouvrent en lecture seule. Les classeurs contenant des éléments qu'ObsiGate ne peut pas conserver (valeurs calculées, segments, contrôles de formulaire, signature…) affichent un **avertissement** et demandent confirmation avant l'enregistrement ; une saisie commençant par `=` ou `@` est stockée comme texte sauf activation du bouton `f(x)`, et les écritures concurrentes d'un autre poste sont détectées (`If-Match` → « Réessayer »). L'assistant IA peut lister les feuilles, injecter un tableau borné dans son contexte, rechercher dans le classeur, analyser une plage, modifier des cellules et ajouter des lignes — sur `.xlsx`, `.xlsm` et `.csv`. Sur mobile (≤ 768 px), la barre de menus et le ruban sont **repliés par défaut** — un bouton ☰ les déplie — pour que la grille occupe toute la hauteur d'écran
|
||||
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
|
||||
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
|
||||
- **📡 Synchronisation temps réel** : Surveillance automatique des fichiers via watchdog avec mise à jour incrémentale de l'index
|
||||
@@ -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.55.0).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.57.1).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.55.0 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.57.1 | 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/)
|
||||
@@ -84,7 +84,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
|
||||
- **🖼️ Obsidian Images** : Full support for all Obsidian image syntaxes with intelligent resolution
|
||||
- **🎬 Audio & video** : Built-in HTML5 players (`.mp3 .wav .flac .mp4 .webm`…) with HTTP Range streaming (play, seek, fullscreen) and **persistent playback** (floating mini-player / mini video window, return to media or stop anytime, lock-screen controls via Media Session), falling back to download when the format is not playable in the browser
|
||||
- **🎨 Excalidraw Diagrams** : Native viewer/editor for `.excalidraw` and `.excalidraw.md` files (sandboxed iframe, autosave, dark/light theme, diagram text indexed for search)
|
||||
- **📊 Excel Spreadsheets** : `.xlsx` and `.xlsm` files open in a dedicated viewer — one table per sheet with tabs, A1 headers and inline cell editing (`PUT /api/file/{vault}/xlsx/save`, automatic backup, atomic write), plus download of the original file. The viewer renders fonts, colors, merged cells and frozen panes, offers keyboard navigation and shortcuts (`Ctrl+S`, `Delete`, `F2`, `Ctrl+Home/End`, `PgUp/PgDn`, `Ctrl+arrows`), a formula bar with function suggestions, an editable Name Box ("go to" `A1:B3`), a range clipboard (copy/cut/paste a block, from or to Excel), a **Format** menu (bold/italic/underline, alignments, font & fill colours, number formats, merges, frozen panes, column width/row height — `PUT /api/file/{vault}/xlsx/style`), sort/filter/find across every sheet, CSV/Markdown/HTML export and printing (selection or sheet), sheet & row/column structure editing and a workbook dashboard (named ranges, charts/pivot detection, per-sheet stats); `.csv` is edited in the same grid (RFC 4180) while `.xls` and `.ods` open read-only. Workbooks holding elements ObsiGate cannot preserve (cached values, slicers, form controls, signature…) show a **warning** and ask for confirmation before saving; a value starting with `=` or `@` is stored as text unless the `f(x)` toggle is enabled, and concurrent writes from another process are detected (`If-Match` → "Retry"). The AI assistant can list sheets, dump a bounded table to its context, search the workbook, analyze a range, update cells and append rows — on `.xlsx`, `.xlsm` and `.csv`
|
||||
- **📊 Excel Spreadsheets** : `.xlsx` and `.xlsm` files open in a dedicated viewer — one table per sheet with tabs, A1 headers and inline cell editing (`PUT /api/file/{vault}/xlsx/save`, automatic backup, atomic write), plus download of the original file. The viewer renders fonts, colors, merged cells and frozen panes, offers keyboard navigation and shortcuts (`Ctrl+S`, `Delete`, `F2`, `Ctrl+Home/End`, `PgUp/PgDn`, `Ctrl+arrows`), a formula bar with function suggestions, an editable Name Box ("go to" `A1:B3`), a range clipboard (copy/cut/paste a block, from or to Excel), a **Format** menu (bold/italic/underline, alignments, font & fill colours, number formats, merges, frozen panes, column width/row height — `PUT /api/file/{vault}/xlsx/style`), sort/filter/find across every sheet, CSV/Markdown/HTML export and printing (selection or sheet), sheet & row/column structure editing and a workbook dashboard (named ranges, charts/pivot detection, per-sheet stats); `.csv` is edited in the same grid (RFC 4180) while `.xls` and `.ods` open read-only. Workbooks holding elements ObsiGate cannot preserve (cached values, slicers, form controls, signature…) show a **warning** and ask for confirmation before saving; a value starting with `=` or `@` is stored as text unless the `f(x)` toggle is enabled, and concurrent writes from another process are detected (`If-Match` → "Retry"). The AI assistant can list sheets, dump a bounded table to its context, search the workbook, analyze a range, update cells and append rows — on `.xlsx`, `.xlsm` and `.csv`. On mobile (≤ 768 px), the menu bar and ribbon are **collapsed by default** — a single ☰ button expands them — so the grid gets the full screen height
|
||||
- **🎨 Syntax Highlight** : Syntax highlighting for code blocks
|
||||
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
|
||||
- **📡 Real-time Sync** : Automatic file monitoring via watchdog with incremental index updates
|
||||
@@ -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.55.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.57.1).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.55.0 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.57.1 | 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.conflicts import router as conflicts_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_read import router as files_read_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(conflicts_router) # ROADMAP #85 T8 — Conflicts
|
||||
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_media_router) # ROADMAP #85 T6c — Media/export
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
"""Response for ``GET /api/backups``."""
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.55.0"
|
||||
version = "2.57.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.55.0"
|
||||
version = "2.57.1"
|
||||
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.55.0",
|
||||
"version": "2.57.1",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"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`
|
||||
> 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
|
||||
|
||||
@@ -155,6 +155,14 @@ regroupe figeage, tri, filtre, dimensions, lien, commentaire ; les menus
|
||||
haut** de la page et l'éditeur occupe toute la largeur du contenu, pour que
|
||||
les commandes restent atteignables pendant le défilement d'un grand tableau.
|
||||
|
||||
**Mise en page mobile** — sur un écran étroit (≤ 768 px), la barre de menus et
|
||||
le ruban de formatage sont **repliés par défaut** afin que la grille occupe
|
||||
toute la hauteur disponible : seuls les onglets de feuilles, **Enregistrer** et
|
||||
la **barre de formule (fx)** restent affichés. Le bouton **☰** (à gauche des
|
||||
onglets) déplie/replie l'ensemble ; les commandes tactiles font au moins
|
||||
44 px, les onglets défilent horizontalement et les champs de saisie restent en
|
||||
16 px pour éviter le zoom automatique d'iOS.
|
||||
|
||||
Les largeurs de colonnes et hauteurs de lignes s'ajustent en **glissant le
|
||||
bord des en-têtes** (souris et tactile) : en plaçant le pointeur sur le bord
|
||||
d'un en-tête de colonne (`A`, `B`, `C`…) ou de ligne (`1`, `2`, `3`…), le
|
||||
|
||||
@@ -328,6 +328,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-10-02 | BUG-106 | Correction | `backend/main.py`, `tests/test_csp_nonce.py` | **BUG-106 — page blanche sur /docs** : la CSP `#87 T5c` (`script-src` sans `unsafe-inline`, nonce frais par réponse) s'applique aussi aux pages générées par FastAPI (`/docs`, `/redoc`) dont le balisage n'est jamais passé par `inject_csp_nonce` → le navigateur refusait le script inline `SwaggerUIBundle(…)`, la div restait vide. `SecurityHeadersMiddleware` injecte désormais le nonce dans ce HTML avec le helper existant ; le corps est décompressé/recompressé car `SSESafeGZipMiddleware` est plus proche de la route (les réponses arrivent gunzippées). `/redoc` n'a aucun script inline (bundle jsdelivr seul, couvert par `script-src`) — vérifié tel quel. Vérifié : `test_docs_inline_script_nonced`, `test_redoc_served_with_csp`, `test_docs_external_bundle_not_nonced` + 45 passed sur les 3 suites sécurité, ruff/mypy 0. | 🟢 corrigé |
|
||||
| 2026-10-02 | BUG-107 | Correction | `desktop/tauri.conf.json`, `desktop/src/main.rs` | **BUG-107 — drag & drop de fichiers inutilisable sur desktop** : Tauri/wry installe son propre `IDropTarget` par-dessus de celui du WebView2 (source wry : « Enumerate child windows to find the WebView2 window and override! ») et aucun événement natif n'était écouté → les glisser-déposer depuis l'Explorateur n'atteignaient jamais les gestionnaires HTML5 de `dragdrop.js` (#89 marchait donc uniquement sur le web). Correctif : la doc Tauri est explicite — `disable_drag_drop_handler()` « is required to use HTML5 drag and drop APIs on the frontend on Windows » ; la fenêtre est créée en code (`create: false` dans `tauri.conf.json`, boucle Tauri qui saute les fenêtres non auto-créées) via `WebviewWindowBuilder::from_config(...).disable_drag_drop_handler()`. Vérifié : `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires), lancement de l'app OK (fenêtre unique, boot log normal) ; glisser-déposer réel à valider par l'utilisateur. | 🟢 corrigé |
|
||||
| 2026-10-07 | BUG-108 | Correction | `frontend/js/xlsx/context-menu.js` | **BUG-108 — « Coller » du menu contextuel du tableur muet** : `closeContextMenu()` faisait `menu.remove()` **avant** `_menu = null` ; retirer le menu portant le focus émet `focusout` synchrone qui rappelle `closeContextMenu()`, le second `remove()` sur un nœud démonté lève `NotFoundError` et avorte le handler de l'action avant son exécution (introduit par la fermeture au focus #179). Reproduit par l'E2E `xlsx-viewer.spec.js` « coller une plage » (CI #850→#854). Correctif : déférencer avant de retirer. | 🟢 corrigé |
|
||||
| 2026-10-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 | #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) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+33
-23
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.55.0 | **Dernière mise à jour :** 2026-10-08
|
||||
> **Version :** 2.57.1 | **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)**
|
||||
@@ -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
|
||||
- [ ] 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)
|
||||
@@ -528,6 +508,34 @@
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Mise en page mobile du tableur
|
||||
|
||||
### 189. Tableur — mise en page mobile (barres repliées, grille prioritaire)
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Framework :** CSS vanilla + `frontend/js/viewer.js`
|
||||
- **Statut :** ✅ **livré le 2026-10-08** — ouvert le 2026-10-08
|
||||
- **Fiche :** [features/xlsx-mobile-layout-189.md](./features/xlsx-mobile-layout-189.md)
|
||||
- **Description :** sur mobile, la barre de menus + le ruban + la barre de
|
||||
formule s'empilaient et occupaient plus de 50 % de la hauteur d'écran.
|
||||
Sous 768 px, `.xlsx-menubar`, `.xlsx-cmdbar` et le groupe Recherche sont
|
||||
**repliés par défaut** (la barre de formule fx et les onglets restent
|
||||
visibles) et un **bouton unique** `#xlsx-tools-toggle` dans la barre des
|
||||
feuilles déplie/replie l'ensemble (`aria-expanded`, état actif) — desktop
|
||||
inchangé (bouton masqué).
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Repli CSS par défaut ≤ 768 px + révélation via
|
||||
`.xlsx-toolbar.xlsx-tools-open` (menubar, ruban, Recherche)
|
||||
- [x] **A2** Bascule `#xlsx-tools-toggle` dans `.xlsx-sheetbar`
|
||||
(`aria-expanded` + classe `active` + i18n `xlsx.tools_toggle` FR/EN)
|
||||
- [x] **A3** Tactile : cibles ≥ 44 px, onglets en défilement horizontal,
|
||||
champs de la barre de formule en 16 px (anti-zoom iOS), barre de
|
||||
formule en 2 lignes quand les outils sont ouverts
|
||||
- [x] **A4** Tests : JSDOM `xlsx-viewer.test.mjs` (+2, 165/165) et E2E
|
||||
mobile (repli par défaut, dépliage, no-overflow ruban, 44 px) ;
|
||||
`openXlsx()` déplie automatiquement ≤ 768 px pour les tests desktop
|
||||
|
||||
---
|
||||
|
||||
## ⚪ Backlog — Priorité 4 (P4)
|
||||
|
||||
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
|
||||
@@ -610,7 +618,7 @@
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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) | ✅ |
|
||||
@@ -681,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) |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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é) |
|
||||
@@ -721,6 +729,8 @@
|
||||
| 185 | Tableur — barre de menus Google Sheets (10 menus), ruban Sheets, grille unie | 2.54.0 | [features/xlsx-menus-185.md](./features/xlsx-menus-185.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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
@@ -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,81 @@
|
||||
# #189 — Tableur : mise en page mobile (barres repliées, grille prioritaire)
|
||||
|
||||
> **Statut :** ✅ livré (2026-10-08) | **Effort :** 0,5-1 jour | **Impact :** 🟡
|
||||
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
|
||||
> [#180](./xlsx-sheetbar-180.md) · [#185](./xlsx-menus-185.md)
|
||||
|
||||
## 1. Constat
|
||||
|
||||
Sur mobile, l'empilement `.xlsx-menubar` (10 menus) + `.xlsx-cmdbar`
|
||||
(~40 boutons qui s'enroulent sur 4-5 rangées) + `.xlsx-sheetbar` +
|
||||
`.xlsx-formula-bar` occupait **plus de 50 % de la hauteur d'écran** : la
|
||||
grille, objet premier de l'éditeur, passait après la chrome.
|
||||
|
||||
## 2. Livré
|
||||
|
||||
### A1 — Repli par défaut (CSS, ≤ 768 px)
|
||||
|
||||
Bloc `#189` dans `frontend/style.css` (après les règles `pointer: coarse`) :
|
||||
|
||||
- `.xlsx-menubar`, `.xlsx-cmdbar`, `.xlsx-find-group` → `display: none` par
|
||||
défaut sous 768 px ;
|
||||
- `.xlsx-toolbar.xlsx-tools-open …` les réaffiche (état de la classe portée
|
||||
par la **barre entière**, donc menubar + ruban + Recherche restent cohérents) ;
|
||||
- `.xlsx-formula-bar` reste **toujours visible** (zone Nom + `fx` + champ) et
|
||||
passe en `flex-wrap` quand les outils sont ouverts (le groupe Recherche
|
||||
réapparaît sur une 2ᵉ ligne) ;
|
||||
- `.xlsx-tools-toggle { display: none }` sur desktop : **zéro changement
|
||||
visuel hors mobile**.
|
||||
|
||||
### A2 — Bascule unique (JS)
|
||||
|
||||
`frontend/js/viewer.js` (`renderXlsxViewer`) :
|
||||
|
||||
- bouton `#xlsx-tools-toggle` (icône Lucide `menu`, `aria-expanded`,
|
||||
`aria-label` i18n `xlsx.tools_toggle`) inséré **en tête de
|
||||
`.xlsx-sheetbar`** — donc toujours visible, entre les onglets et Enregistrer ;
|
||||
- listener : bascule `.xlsx-tools-open` sur `.xlsx-toolbar`, met à jour
|
||||
`aria-expanded` + classe `active`, `safeCreateIcons()` pour l'icône.
|
||||
|
||||
### A3 — Ergonomie tactile (CSS)
|
||||
|
||||
- cibles ≥ **44 px** : `.xlsx-toolbar .btn-action`, `.xlsx-menubtn`,
|
||||
`.xlsx-tab` / `.xlsx-tab-add` ; à cette taille un groupe du ruban
|
||||
(police, nombre…) déborde d'un téléphone : `.xlsx-cmd-group` passe en
|
||||
`flex: 0 1 auto; min-width: 0; flex-wrap: wrap` pour s'enrouler au lieu
|
||||
de sortir de l'écran (contre-preuve E2E : `scrollWidth - clientWidth ≤ 1`) ;
|
||||
- onglets de feuilles : une seule ligne, **défilement horizontal**
|
||||
(`overflow-x: auto`, scrollbars masquées) au lieu d'enrouler ;
|
||||
- champs de la barre de formule en **16 px** : en dessous, iOS zoome
|
||||
automatiquement la page au focus (la « classique » debout de l'UI) ;
|
||||
- groupe Recherche masqué avec le ruban pour ne pas écraser le champ de saisie.
|
||||
|
||||
### A4 — i18n
|
||||
|
||||
Clé `xlsx.tools_toggle` : FR « Afficher / masquer les barres d'outils » /
|
||||
EN « Show / hide toolbars » (parité vérifiée par `tests/test_i18n_parity.py`).
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- `tests/frontend/xlsx-viewer.test.mjs` (+2, **165/165**) :
|
||||
1. la bascule vit dans la barre des feuilles et flippe correctement
|
||||
classe / `aria-expanded` / `active` (aller-retour) ;
|
||||
2. `style.css` masque menubar/ruban/Recherche par défaut sous 768 px et les
|
||||
réaffiche via `.xlsx-tools-open` (44 px, onglets scrollables, inputs
|
||||
16 px présents dans le bloc).
|
||||
- `tests/e2e/xlsx-viewer.spec.js` — test mobile réécrit : repli par défaut
|
||||
(menubar/ruban cachés, formule + onglets visibles) → dépliage → ruban sans
|
||||
débordement horizontal → cibles ≥ 44 px → repli ; `openXlsx()` déplie
|
||||
automatiquement sous 768 px pour que les tests « desktop » restent valables
|
||||
sur les deux viewports.
|
||||
|
||||
## 4. Limites assumées
|
||||
|
||||
- Le repli est piloté par la **largeur** (`max-width: 768px`), pas par
|
||||
`pointer: coarse` : une tablette large en mode tactile garde la chrome
|
||||
complète (elle a la hauteur pour).
|
||||
- Le groupe Recherche n'est accessible sur mobile qu'avec les outils ouverts
|
||||
; le reset de tri reste atteignable par la mini-barre de sélection et le
|
||||
menu Données (vérifié).
|
||||
- Pas de drawer latéral ni de FAB : un seul bouton rétractable suffit à
|
||||
l'usage — ajouter un panneau glissant si le retour utilisateur le réclame.
|
||||
@@ -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 = () => {
|
||||
connectionState = "disconnected";
|
||||
_updateBadge();
|
||||
|
||||
+27
-1
@@ -16,6 +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 { parseRef, columnName, findTd, invalidateCellIndex, sheetOfRef, firstCellOfRange } from './xlsx/refs.js';
|
||||
import { createColourPicker } from './xlsx/color-picker.js';
|
||||
import { buildCommandBar } from './xlsx/command-bar.js';
|
||||
@@ -1473,6 +1474,11 @@ const GRID_CHUNK_ROWS = 100; // rows materialised per extension step
|
||||
<span class="xlsx-toolbar-actions">${actionsHtml}</span>
|
||||
</div>
|
||||
<div class="xlsx-sheetbar">
|
||||
<button class="btn-action xlsx-tools-toggle" id="xlsx-tools-toggle" type="button"
|
||||
aria-expanded="false" title="${escapeHtml(t("xlsx.tools_toggle"))}"
|
||||
aria-label="${escapeHtml(t("xlsx.tools_toggle"))}">
|
||||
<i data-lucide="menu" style="width:18px;height:18px" aria-hidden="true"></i>
|
||||
</button>
|
||||
${tabs}
|
||||
<span class="xlsx-sheetbar-end">${fileHtml}</span>
|
||||
</div>
|
||||
@@ -1526,6 +1532,20 @@ const GRID_CHUNK_ROWS = 100; // rows materialised per extension step
|
||||
// A re-render invalidates any open context menu (it targets the old DOM).
|
||||
closeContextMenu();
|
||||
|
||||
// #189 — mobile : la barre de menus + le ruban sont repliés par défaut
|
||||
// (cf. style.css ≤768px). Ce bouton, seul dans la barre des feuilles,
|
||||
// déplie/replie le tout sans jamais masquer la barre de formule.
|
||||
const toolsToggle = area.querySelector("#xlsx-tools-toggle");
|
||||
const toolsBar = area.querySelector(".xlsx-toolbar");
|
||||
if (toolsToggle && toolsBar) {
|
||||
toolsToggle.addEventListener("click", () => {
|
||||
const open = toolsBar.classList.toggle("xlsx-tools-open");
|
||||
toolsToggle.setAttribute("aria-expanded", String(open));
|
||||
toolsToggle.classList.toggle("active", open);
|
||||
safeCreateIcons();
|
||||
});
|
||||
}
|
||||
|
||||
// ── #153 A17 — dashboard panel: named ranges, objects, per-sheet KPI ──
|
||||
// Fetched lazily on first open of the dashboard tab; a workbook without
|
||||
// anything notable hides the button entirely (no empty panel).
|
||||
@@ -7203,6 +7223,12 @@ export function renderFile(data) {
|
||||
|
||||
// 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")]);
|
||||
|
||||
// #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
|
||||
(async () => {
|
||||
try {
|
||||
@@ -7295,7 +7321,7 @@ export function renderFile(data) {
|
||||
// spacer de groupe vide retire.
|
||||
const navBtns = [];
|
||||
if (data.is_markdown) navBtns.push(tocBtn);
|
||||
navBtns.push(openNewWindowBtn, bookmarkBtn);
|
||||
navBtns.push(openNewWindowBtn, bookmarkBtn, chatBtn);
|
||||
const editBtns = [];
|
||||
if (!data.unsupported) editBtns.push(editBtn);
|
||||
editBtns.push(sourceBtn);
|
||||
|
||||
@@ -319,6 +319,16 @@
|
||||
"backups.restore_confirm": "to the selected backup version?\nThe current version will be saved.",
|
||||
"backups.title": "Backups",
|
||||
"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.all": "All",
|
||||
"common.back": "Back",
|
||||
@@ -2050,6 +2060,7 @@
|
||||
|
||||
"xlsx.mbar_label": "Spreadsheet menus",
|
||||
"xlsx.cmdbar_label": "Spreadsheet command bar",
|
||||
"xlsx.tools_toggle": "Show / hide toolbars",
|
||||
"xlsx.mbar_file": "File",
|
||||
"xlsx.mbar_edit": "Edit",
|
||||
"xlsx.mbar_view": "View",
|
||||
|
||||
@@ -319,6 +319,16 @@
|
||||
"backups.restore_confirm": "a la version du backup selectionne ?\nLa version actuelle sera sauvegardee.",
|
||||
"backups.title": "Backups",
|
||||
"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.all": "Tous",
|
||||
"common.back": "Retour",
|
||||
@@ -2050,6 +2060,7 @@
|
||||
|
||||
"xlsx.mbar_label": "Menus du tableur",
|
||||
"xlsx.cmdbar_label": "Barre de commandes du tableur",
|
||||
"xlsx.tools_toggle": "Afficher / masquer les barres d'outils",
|
||||
"xlsx.mbar_file": "Fichier",
|
||||
"xlsx.mbar_edit": "Modifier",
|
||||
"xlsx.mbar_view": "Affichage",
|
||||
|
||||
@@ -12172,6 +12172,85 @@ body.desktop-mode .editor-container {
|
||||
max-width: 92vw;
|
||||
}
|
||||
}
|
||||
/* #189 — mise en page mobile : par défaut seuls restent la barre des
|
||||
feuilles (avec le basculeur) et la barre de formule. La barre de menus et
|
||||
le ruban sont repliés derrière #xlsx-tools-toggle ; le groupe Recherche,
|
||||
logé dans la barre de formule, suit le même état pour ne pas écraser le
|
||||
champ de saisie. */
|
||||
.xlsx-tools-toggle {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.xlsx-tools-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.xlsx-menubar,
|
||||
.xlsx-cmdbar,
|
||||
.xlsx-find-group {
|
||||
display: none;
|
||||
}
|
||||
.xlsx-toolbar.xlsx-tools-open .xlsx-menubar {
|
||||
display: flex;
|
||||
}
|
||||
.xlsx-toolbar.xlsx-tools-open .xlsx-cmdbar {
|
||||
display: flex;
|
||||
}
|
||||
.xlsx-toolbar.xlsx-tools-open .xlsx-find-group {
|
||||
display: flex;
|
||||
}
|
||||
/* La barre de formule ne déborde jamais : elle passe en 2 lignes quand le
|
||||
groupe Recherche réapparaît avec les outils ouverts. */
|
||||
.xlsx-toolbar.xlsx-tools-open .xlsx-formula-bar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
/* Une seule ligne de feuilles : les onglets défilent horizontalement au
|
||||
lieu d'enrouler et de grignoter la hauteur de la grille. */
|
||||
.xlsx-sheetbar {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.xlsx-sheetbar .xlsx-tabs {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.xlsx-sheetbar .xlsx-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
/* Cibles tactiles : le ruban replié n'existe que derrière le basculeur,
|
||||
mais une fois ouvert chaque commande reste atteignable au doigt. */
|
||||
.xlsx-toolbar .btn-action {
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
/* À 44px un groupe (police, nombre…) dépasse la largeur d'un téléphone :
|
||||
il doit pouvoir se réduire ET s'enrouler plutôt que déborder. */
|
||||
.xlsx-cmdbar .xlsx-cmd-group {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.xlsx-menubtn {
|
||||
padding: 12px 14px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.xlsx-tab,
|
||||
.xlsx-tab-add {
|
||||
padding: 10px 14px;
|
||||
min-height: 44px;
|
||||
}
|
||||
/* 16px minimum : en dessous iOS zoome automatiquement la page dès que le
|
||||
champ reçoit le focus, ce qui fait « sauter » l'interface. */
|
||||
.xlsx-active-cell,
|
||||
.xlsx-formula-input,
|
||||
.xlsx-find-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
.xlsx-context-item {
|
||||
text-align: left;
|
||||
border: none;
|
||||
@@ -13669,3 +13748,142 @@ body.reading-mode .file-actions {
|
||||
color: var(--text-muted);
|
||||
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",
|
||||
"version": "2.55.0",
|
||||
"version": "2.57.1",
|
||||
"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": {
|
||||
|
||||
@@ -72,6 +72,14 @@ async function openXlsx(page, file) {
|
||||
}
|
||||
await treeItem.dblclick({ timeout: 5000 });
|
||||
await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 });
|
||||
// #189 — sous 768px la barre de menus et le ruban sont repliés par défaut.
|
||||
// Les tests « desktop » qui les manipulent les déplient d'abord ; le test
|
||||
// mobile dédié ouvre lui-même la feuille pour vérifier l'état replié.
|
||||
const toggle = page.locator('#xlsx-tools-toggle');
|
||||
if (await toggle.isVisible().catch(() => false)) {
|
||||
await toggle.click();
|
||||
await expect(page.locator('#content-area .xlsx-menubar')).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#153)', () => {
|
||||
@@ -513,12 +521,27 @@ test.describe('Excel viewer — menus à icônes et fermeture au focus (#179)',
|
||||
await expect(page.locator('#xlsx-minibar')).toBeVisible();
|
||||
});
|
||||
|
||||
test('mobile : ruban sans débordement et cibles tactiles 44px', async ({ page, viewport }) => {
|
||||
test('mobile : barres repliées par défaut, ruban sans débordement et cibles 44px (#189)', async ({ page, viewport }) => {
|
||||
test.skip((viewport?.width ?? 9999) > 768, 'Mobile viewport required');
|
||||
await login(page);
|
||||
await page.evaluate(() => window.TabManager.open('TestVault', FIXTURE));
|
||||
await page.evaluate((f) => window.TabManager.open('TestVault', f), FIXTURE);
|
||||
await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// #189 — par défaut la barre de menus et le ruban sont repliés : la grille
|
||||
// garde la hauteur d'écran. La barre de formule, elle, reste visible.
|
||||
await expect(page.locator('#content-area .xlsx-menubar')).toBeHidden();
|
||||
await expect(page.locator('#content-area .xlsx-cmdbar')).toBeHidden();
|
||||
await expect(page.locator('#content-area .xlsx-formula-bar')).toBeVisible();
|
||||
await expect(page.locator('#content-area .xlsx-sheetbar')).toBeVisible();
|
||||
const toggle = page.locator('#xlsx-tools-toggle');
|
||||
await expect(toggle).toBeVisible();
|
||||
|
||||
// Un clic déplie menus + ruban (état annoncé par aria-expanded).
|
||||
await toggle.click();
|
||||
await expect(page.locator('#content-area .xlsx-menubar')).toBeVisible();
|
||||
await expect(page.locator('#content-area .xlsx-cmdbar')).toBeVisible();
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// Le ruban s'enroule au lieu de déborder horizontalement.
|
||||
const overflow = await page.evaluate(() => {
|
||||
const bar = document.querySelector('#content-area .xlsx-cmdbar');
|
||||
@@ -526,6 +549,17 @@ test.describe('Excel viewer — menus à icônes et fermeture au focus (#179)',
|
||||
});
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
|
||||
// Cibles tactiles : le basculeur et chaque commande du ruban font 44px.
|
||||
const toggleBox = await toggle.boundingBox();
|
||||
expect(toggleBox.height).toBeGreaterThanOrEqual(44);
|
||||
const ribbonBtnBox = await page.locator('#xs-b').boundingBox();
|
||||
expect(ribbonBtnBox.height).toBeGreaterThanOrEqual(44);
|
||||
|
||||
// Repli : la grille retrouve toute sa hauteur.
|
||||
await toggle.click();
|
||||
await expect(page.locator('#content-area .xlsx-menubar')).toBeHidden();
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
// Menu contextuel (clic droit émulé) : tient dans le viewport, entrées ≥ 44px.
|
||||
await page.locator('#content-area td[data-cell="A1"]').click({ button: 'right' });
|
||||
const menu = page.locator('.xlsx-context-menu');
|
||||
|
||||
@@ -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/);
|
||||
assert.ok(m, "navBtns block not found");
|
||||
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", () => {
|
||||
|
||||
@@ -2775,6 +2775,54 @@ await test("a read-only workbook keeps the view menus but drops the editing ones
|
||||
assert.ok(area.querySelector('[data-xlsx-action="toggle-grid"]'), "Gridlines stay");
|
||||
});
|
||||
|
||||
// ── #189 — mise en page mobile : barres repliées derrière un basculeur ──────
|
||||
|
||||
await test("the sheet strip carries the tools toggle that flips the toolbar (#189)", () => {
|
||||
const area = mountGrid();
|
||||
const strip = area.querySelector(".xlsx-sheetbar");
|
||||
const toggle = area.querySelector("#xlsx-tools-toggle");
|
||||
assert.ok(toggle, "toggle rendered");
|
||||
assert.equal(toggle.closest(".xlsx-sheetbar"), strip, "it lives in the sheet strip");
|
||||
assert.equal(toggle.getAttribute("aria-expanded"), "false", "starts collapsed");
|
||||
const toolbar = area.querySelector(".xlsx-toolbar");
|
||||
assert.ok(!toolbar.classList.contains("xlsx-tools-open"), "toolbar starts closed");
|
||||
toggle.click();
|
||||
assert.ok(toolbar.classList.contains("xlsx-tools-open"), "one click opens the tools");
|
||||
assert.equal(toggle.getAttribute("aria-expanded"), "true");
|
||||
assert.ok(toggle.classList.contains("active"), "the toggle shows its own state");
|
||||
toggle.click();
|
||||
assert.ok(!toolbar.classList.contains("xlsx-tools-open"), "a second click closes them");
|
||||
assert.equal(toggle.getAttribute("aria-expanded"), "false");
|
||||
});
|
||||
|
||||
await test("style.css hides menus/ribbon/find by default under 768px and reveals them on .xlsx-tools-open (#189)", () => {
|
||||
const css = readFileSync(path.join(REPO_ROOT, "frontend", "style.css"), "utf8");
|
||||
// The desktop default: the toggle itself is out of the way.
|
||||
assert.match(css, /\.xlsx-tools-toggle\s*\{\s*display:\s*none/, "toggle hidden on desktop");
|
||||
// Mobile block: collapsed by default… (CRLF-safe slice, from the #189
|
||||
// comment up to the next top-level rule).
|
||||
const from = css.indexOf("/* #189");
|
||||
assert.ok(from > 0, "the #189 mobile block exists after the coarse-pointer rules");
|
||||
const to = css.indexOf(".xlsx-context-item", from);
|
||||
assert.ok(to > from, "the #189 block is followed by the next rule");
|
||||
const block = css.slice(from, to);
|
||||
for (const cls of [".xlsx-menubar", ".xlsx-cmdbar", ".xlsx-find-group"]) {
|
||||
const hidden = new RegExp(cls.replace(".", "\\.") + "[^}]*display:\\s*none").test(block)
|
||||
|| new RegExp(cls.replace(".", "\\.") + ",[\\s\\S]{0,120}?display:\\s*none").test(block);
|
||||
assert.ok(hidden, `${cls} hidden by default on mobile`);
|
||||
assert.ok(
|
||||
block.includes(`.xlsx-toolbar.xlsx-tools-open ${cls}`),
|
||||
`${cls} revealed by .xlsx-tools-open`,
|
||||
);
|
||||
}
|
||||
// The formula bar always stays, and the open state can wrap it (find group).
|
||||
assert.ok(block.includes(".xlsx-toolbar.xlsx-tools-open .xlsx-formula-bar"), "formula bar wraps when open");
|
||||
// Touch: 44px targets, tabs on one scrollable row, 16px inputs (no iOS zoom).
|
||||
assert.match(block, /min-width:\s*44px/, "44px touch targets");
|
||||
assert.match(block, /\.xlsx-sheetbar \.xlsx-tabs\s*\{[^}]*overflow-x:\s*auto/, "tabs scroll horizontally");
|
||||
assert.match(block, /font-size:\s*16px/, "inputs at 16px avoid the iOS focus zoom");
|
||||
});
|
||||
|
||||
// ── Report ──────────────────────────────────────────────────────────────────
|
||||
console.log(`\n${passCount}/${testCount} tests passed\n`);
|
||||
process.exit(passCount === testCount ? 0 : 1);
|
||||
|
||||
@@ -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