Compare commits

...
13 Commits
Author SHA1 Message Date
bruno b83d8dacdf refactor: #85 T6a extrait lecture fichiers vers backend/routers (comportement inchange) 2026-09-26 13:43:53 -04:00
bruno dadc055429 refactor: #85 T5 extrait le domaine search vers backend/routers + executor partage (comportement inchange) 2026-09-26 13:14:31 -04:00
bruno 750114a923 refactor: #85 T4 extrait le domaine backups vers backend/routers + sse partage (comportement inchange) 2026-09-26 13:06:45 -04:00
bruno 83a81da319 refactor: #85 T3 extrait le domaine sharing vers backend/routers (comportement inchange) 2026-09-26 12:51:06 -04:00
bruno 3eb0256127 refactor: #85 T2 extrait le CRUD webhooks vers backend/routers (comportement inchange) 2026-09-26 12:43:57 -04:00
bruno 9d8b3cc854 refactor: #85 T1 extrait le domaine health vers backend/routers (comportement inchange) 2026-09-26 12:36:49 -04:00
bruno 0ab402aa73 docs: roadmap priorise dette et securite #85 #87 (#77 non signe, #73 reporte)
CI / lint (push) Successful in 2m13s
CI / security (push) Successful in 1m34s
CI / test (push) Successful in 4m9s
CI / build (push) Successful in 1m31s
CI / e2e (push) Successful in 14m17s
2026-09-26 11:55:32 -04:00
bruno c36c299466 docs: #152 — aligne changelog et roadmap sur la version livrée 2.27.0
Fusionne la section 2.26.0 (jamais publiée) dans 2.27.0 et corrige la ligne
index de la roadmap ; suppression du tag local v2.26.0.
2026-09-25 20:32:14 -04:00
bruno 8611416670 feat: #152 viewer XLSX — affichage multi-feuilles, édition des cellules et téléchargement des .xlsx
- backend/xlsx_reader.py : rend openpyxl en tableaux HTML (plafond 500x40 par feuille)
- PUT /api/file/{vault}/xlsx/save : service edit_xlsx_cells (backup avant écriture, refs A1 validées)
- frontend : renderXlsxViewer (onglets, contenteditable, sauvegarde par feuille)
- docs : CHANGELOG [Unreleased], Roadmap index #152, archive, README FR/EN, exemple OpenAPI
2026-09-25 20:30:45 -04:00
bruno e20fd6bf97 fix: /api/diagnostics 500 « dictionary changed size during iteration » — snapshot des dicts avant itération BUG-079
CI / lint (push) Successful in 2m3s
CI / security (push) Successful in 1m27s
CI / test (push) Successful in 4m22s
CI / build (push) Successful in 1m22s
CI / e2e (push) Successful in 13m36s
2026-09-24 17:13:40 -04:00
bruno 943005328c feat: barre d'outils de lecture epinglee, coloration syntaxique des fichiers de code et avatars predefinis (#115, #117, BUG-078)
CI / lint (push) Successful in 2m5s
CI / security (push) Successful in 1m27s
CI / test (push) Successful in 4m10s
CI / build (push) Successful in 1m23s
CI / e2e (push) Successful in 13m55s
2026-09-24 16:21:45 -04:00
bruno e1842043d8 feat: assistant IA — approbation groupee des actions, bloc d'etapes, refresh UI et bouton Stop (BUG-074, BUG-075, BUG-076, BUG-077)
CI / lint (push) Successful in 2m1s
CI / security (push) Successful in 1m25s
CI / test (push) Successful in 4m19s
CI / build (push) Successful in 1m26s
CI / e2e (push) Successful in 13m38s
2026-09-24 10:05:32 -04:00
bruno 9fb094f505 feat: refonte mobile de la section Configurations #114
CI / lint (push) Successful in 2m1s
CI / security (push) Successful in 1m26s
CI / test (push) Successful in 4m50s
CI / build (push) Successful in 1m22s
CI / e2e (push) Successful in 15m34s
2026-09-24 08:43:18 -04:00
66 changed files with 4905 additions and 2172 deletions
+206 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.22.1**.
> [Unreleased](#unreleased). La dernière version livrée est **2.27.7**.
---
@@ -14,6 +14,211 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.27.7] — 2026-09-26
---
## [2.27.6] — 2026-09-26
---
## [2.27.5] — 2026-09-26
---
## [2.27.4] — 2026-09-26
---
## [2.27.3] — 2026-09-26
---
## [2.27.2] — 2026-09-26
### Modifié
- **#85 (T6a) — extraction lecture fichiers hors du monolithe `backend/main.py`.**
`/api/browse/{vault}`, `/api/file/{vault}/raw|download|backlinks` et
`GET /api/file/{vault}` (vue rendue tous formats) sont servis par le
nouveau `backend/routers/files_read.py` ; modèles dans `schemas.py`,
`_content_disposition`/`_media_max_inline_bytes` dans
`backend/routers/helpers.py` (partagés avec les tranches suivantes).
Correctif au passage : décorateur orphelin `/s/{token}` resté en T3 et
double-enregistrement de `/api/conflicts` supprimés.
- **#85 (T5) — extraction du domaine `search` hors du monolithe `backend/main.py`.**
Les 11 routes (`/api/search`, `/advanced`, `/replace`, `/tags`,
`/tree-search`, `/vault/{vault}/paths`, `/suggest`, `/tags/suggest`,
`/graph/{vault}`, `/index/reload`, `/index/reload/{vault}`) sont servies
par le nouveau `backend/routers/search.py` ; les modèles search dans
`schemas.py` et le pool de threads dans `backend/search_executor.py`
(même dimensionnement, même cycle de vie) — comportement inchangé.
- **#85 (T4) — extraction du domaine `backups` hors du monolithe `backend/main.py`.**
Les 9 routes (`/api/file/{vault}/backups|diff|restore`, `/api/backups`,
`/delete`, `/purge`, `/content`, `/compress`, `/auto`) sont servies par le
nouveau `backend/routers/backups.py` ; `Diff/Restore*` déménagent dans
`schemas.py` et le singleton SSE dans `backend/sse.py` (partagé avec
`main`) — comportement inchangé, aucun impact utilisateur.
- **#85 (T3) — extraction du domaine `sharing` hors du monolithe `backend/main.py`.**
`POST /api/share/{vault}`, `GET /api/shares`, `DELETE /api/share/{share_id}`
et les pages publiques `/s/{token}`, `/s/{token}/raw`, `/s/{token}/pdf`
sont servis par le nouveau `backend/routers/sharing.py` — chemins,
réponses, tags OpenAPI et authentification inchangés (aucun impact
utilisateur).
- **#85 (T2) — extraction du domaine `webhooks` hors du monolithe `backend/main.py`.**
Le CRUD `GET/POST/PATCH/DELETE /api/webhooks` (admin) est servi par le
nouveau `backend/routers/webhooks.py` — chemins, réponses, tags OpenAPI et
authentification inchangés (aucun impact utilisateur).
- **#85 (T1) — extraction du domaine `health` hors du monolithe `backend/main.py`.**
`GET /api/health` et `GET /api/health/detailed` (admin) sont servis par le
nouveau `backend/routers/health.py` (monté dans `main.py`) et le modèle
`HealthResponse` déménage dans `backend/schemas.py` — chemins, réponses,
tags OpenAPI et authentification inchangés (aucun impact utilisateur).
---
## [2.27.1] — 2026-09-26
### Modifié
- **Roadmap — priorisation dette & sécurité (décisions 2026-09-26).**
Items #85 (refonte architecturale) et #87 (CI/CD) détaillés et marqués
prioritaires : `backend/main.py` mesuré à ~4 827 lignes, `tools/registry.py`
à créer, persistance SQLite/Redis, verrous asyncio, audit des `except`
larges, CI sécurité bloquante (bandit/semgrep/trivy, audits pip/npm),
finition CSP nonce (BUG-034), cookies `Secure` par défaut, rotation clé
DeepSeek à confirmer (BUG-006). #73 Sync reporté (P4, hors chemin
critique) ; desktop #77 confirmé non signé + doc SmartScreen, reste les
6 tests E2E manuels. Corrections : sections livrées #83/#84 retirées du
backlog (détail dans l'archive, index inchangé), total restant recalculé
(~12-18 jours chemin critique : #77 fin + #85 + #87).
---
## [2.27.0] — 2026-09-25
### Ajouté
- **#152 — Viewer XLSX** : affichage des fichiers `.xlsx` en tableaux multi-feuilles (onglets,
en-têtes A1), édition inline des cellules avec `PUT /api/file/{vault}/xlsx/save` (backup avant
écriture, coercion numérique) et téléchargement du fichier d'origine.
---
## [2.25.1] — 2026-09-24
### Corrigé
- **`/api/diagnostics` — erreur 500 « dictionary changed size during iteration ».**
Le calcul des statistiques d'index itérait `inv.word_index` et `index` en
direct, pendant que l'indexeur les modifiait depuis un autre thread (build au
démarrage, hooks incrémentaux) : l'itérateur de dict levait `RuntimeError` et
l'endpoint renvoyait 500. Les deux dicts sont désormais **copiés avant
itération** (copie atomique sous le GIL), ce qui supprime la course.
---
## [2.25.0] — 2026-09-24
### Ajouté
- **#115 — barre d'outils de lecture toujours visible.** La barre d'actions d'un
document (pop-out, bookmark, Editer, Source, Copier, Partager…) est désormais
**épinglée en haut** de la zone de lecture : elle reste accessible en permanence,
même au bas d'un document long, au lieu de disparaître au défilement. Elle est
rendue comme un enfant direct du conteneur de défilement (`.file-toolbar`), masquée
en mode lecture, et alignée dans la fenêtre pop-out.
- **#117 — avatars prédéfinis dans le profil.** La section **Profil** propose une
galerie de **12 avatars** fournis avec l'application (`frontend/icons/avatar/`) :
un clic charge l'image, la recadre au format carré 256 px (même pipeline que
l'import) et l'enregistre sur le compte. L'avatar actif est surligné ; l'import
d'une photo personnalisée et la suppression restent disponibles.
### Corrigé
- **BUG-078 — coloration syntaxique des fichiers de code perdue.** Les feuilles de
style highlight.js étaient basculées à partir de la **clé de thème**
(`defaut-obsigate`, …) au lieu du **mode** (`dark`/`light`) : les deux feuilles se
retrouvaient désactivées et les blocs de code (`.py`, `.sh`, `.ps1`, `.yml`, JSON,
blocs Markdown…) s'affichaient en texte brut. Le basculement est désormais piloté
par le mode, de façon déterministe, dans le moteur de thème (`themes.js`) comme au
premier rendu (`ui.js`) ; sépia et contraste élevé réutilisent la palette claire.
---
## [2.24.0] — 2026-09-24
### Ajouté
- **BUG-077 — assistant IA : bouton « Stop ».** Pendant qu'une réponse se diffuse, le
bouton d'envoi du composeur devient un bouton **Stop** (icône carrée, couleur
d'alerte) : un clic interrompt immédiatement l'exécution de l'agent (abort de la
requête SSE, annulation de la tâche côté serveur à la déconnexion) et la réponse
partielle est conservée avec un marqueur « ⏹ Exécution arrêtée. ». Le bouton reste
actif (il n'est plus désactivé) tant que l'agent travaille, y compris pendant la
reprise d'une confirmation.
### Modifié
- **BUG-075 — assistant IA : approbation globale des actions.** Une même réponse du
modèle peut demander **plusieurs** mutations (créer un dossier et les fichiers
qu'il contient…). Elles sont désormais **regroupées en une seule confirmation** (au
lieu d'une action après l'autre) : la carte liste chaque action avec son libellé et
son aperçu de diff, et un unique bouton « **Tout approuver (N)** » envoie
`confirm_all` — les actions en attente sont appliquées d'un bloc, puis la suite de
l'exécution est autorisée sans nouvelle carte. Les appels en lecture du même lot
s'exécutent immédiatement (conversation valide). Côté backend, `pending.actions`
remplace le report `deferred` des mutations d'un lot (`backend/agent/loop.py`) et
`confirm_all` arme `ToolContext.confirmed` pour le reste du run
(`backend/bookslm_routes.py`).
### Corrigé
- **BUG-074 — assistant IA : bloc d'étapes sans titre et compteur figé à 1.** Le
résumé replié affiche désormais un **titre** (le libellé de la première action,
« N étapes — Fichier créé : notes/a.md ▶ »), le compteur ne compte plus les
**réflexions** (seules les actions) et la reprise d'une confirmation continue de
diffuser dans **le même message** au lieu de créer un nouveau message « 1 étape »
par approbation : le bloc d'étapes s'accumule sur tout l'échange.
- **BUG-076 — assistant IA : arborescence et document ouvert non rafraîchis.** Après
une action mutatrice de l'agent (création/suppression/renommage de fichier ou
dossier, écriture), l'arborescence est rafraîchie immédiatement (rafraîchissement
débouncé sur les événements `tool` mutateurs, sans attendre le watcher) et le
document affiché est rechargé depuis le disque ; les outils de création de documents
(xlsx/docx/csv/pdf) notifient désormais aussi la visionneuse.
---
## [2.23.0] — 2026-09-24
### Ajouté
- **#114 — Configuration, refonte mobile-responsive.** La page « Configurations »
est désormais pensée pour le tactile (≤ 768 px) : modale **plein écran**
(`100dvh`, safe-area), sommaire en **drawer coulissant** gauche
(`position: fixed`, largeur `min(320px, 88vw)`) avec fond assombri
(`#config-modal.config-toc-open::before`) et bouton de fermeture
`#config-toc-close` (tap sur le backdrop ou Échap ferme le drawer d'abord,
puis la modale) ; formulaires sur **une colonne** ; tous les boutons
(save/secondary/danger/add/sm/hamburger/fermer) et liens du sommaire en cibles
**≥ 44 px** ; champs et selects en **16 px + min-height 44 px** (anti-zoom
iOS) ; rangée « Sauvegarder / Réindexer / Réinitialiser » **sticky** en bas de
sa section avec safe-area ; MFA (codes de secours en 1 colonne, champ de code
full-width et wrap des rangées d'action) ; débordements webhook/token/share
corrigés. Dettes HTML/i18n de l'audit incluses : `.config-actions-row` replacée
dans `#cfg-backend-settings` (+ `</section>` orphelin supprimé), id dupliqué
`cfg-partages-publics` retiré du `<h2>`, conteneur mort
`#plugins-settings-container` supprimé, libellé `#mt-explorer` i18n
(`settings.explorer`), doublons `.config-btn-add` et règle morte
`.mfa-recovery-input` purgés ; i18n : clés mortes `settings.backend`,
`settings.backend_hint`, `settings.restart_badge`, `settings.save`,
`settings.plugins` supprimées, `config.toc_close` ajoutée, `settings.tabs` FR
corrigé (« Onglets »). Tests : `config-mobile.test.mjs` étendu (27, au CI) +
E2E `config-mobile.spec.js` (5, projet `chromium-mobile`). Fiche :
[docs/features/settings-mobile-114.md](./docs/features/settings-mobile-114.md).
---
## [2.22.1] — 2026-09-23
### Corrigé
+4 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.22.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.27.7-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -85,6 +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` 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), plus le téléchargement du fichier d'origine
- **🎨 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
@@ -975,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.22.1).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.27.7).
---
*Projet : ObsiGate | Version : 2.22.1 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.27.7 | Dernière mise à jour : Septembre 2026*
+4 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.22.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.27.7-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -84,6 +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` 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), plus download of the original file
- **🎨 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
@@ -1150,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.22.1).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.27.7).
---
*Project: ObsiGate | Version: 2.22.1 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.27.7 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.22.1
2.27.7
+121 -71
View File
@@ -28,6 +28,7 @@ from backend.tools.api import (
ToolError,
ToolScope,
call_tool,
get_tool,
get_tool_schemas,
)
from backend.tools.labels import thought_step_label, tool_step_label
@@ -121,12 +122,15 @@ def _assistant_tool_message(content: str | None, tool_calls: list[Any]) -> dict[
def _deferred_tool_message(call: Any, reason: str | None = None) -> dict[str, Any]:
"""Answer a tool call that was not reached because the run stopped early.
A single LLM response may carry several tool calls. When one of them is
mutating and pauses the run for confirmation, the assistant message already
lists *all* of them, so every ``tool_call_id`` must get a tool result before
the next LLM call (the OpenAI tool protocol rejects dangling ids). The calls
that were not reached get a synthetic ``deferred`` result; the model
re-issues them once the confirmed call has been applied (BUG-050).
A single LLM response may carry several tool calls; when the run stops
before reaching some of them (tool-call quota), the assistant message still
lists *all* of them, so every ``tool_call_id`` must get a tool result
before the next LLM call (the OpenAI tool protocol rejects dangling ids).
The calls that were not reached get a synthetic ``deferred`` result.
Note: mutating calls that pause the run for confirmation are no longer
deferred — they are batched and applied together on resume (BUG-075); this
helper remains for budget stops (BUG-050/BUG-052).
"""
return {
"role": "tool",
@@ -135,13 +139,29 @@ def _deferred_tool_message(call: Any, reason: str | None = None) -> dict[str, An
"content": json.dumps({
"status": "deferred",
"reason": reason or (
"Not executed: the run paused to confirm an earlier tool call. "
"Not executed: the run stopped before reaching this tool call. "
"Re-issue this call if it is still needed."
),
}, ensure_ascii=False),
}
def _action_descriptor(call: Any) -> dict[str, Any]:
"""Describe one paused mutating tool call for the confirmation payload.
A single LLM response may request several mutations (create a folder and
the files inside it…). They are batched into one confirmation so the user
approves the whole plan in one click (BUG-075). ``step`` reuses the
Notion-style label, so the confirmation card reads like the steps block.
"""
return {
"id": call.id,
"tool": call.name,
"arguments": call.arguments,
"step": tool_step_label(call.name, call.arguments),
}
def _fallback_summary(executed: list[ToolCallRecord]) -> str:
"""Deterministic non-empty answer built from the gathered tool results.
@@ -212,53 +232,66 @@ def _execute_confirmed(
executed: list[ToolCallRecord],
on_tool_call: Callable[[ToolCallRecord], None] | None,
) -> None:
"""Apply a previously-paused mutating tool call and feed its result back.
"""Apply previously-paused mutating tool calls and feed their results back.
The pending payload is the ``error`` object emitted by a ``confirmation``
event. The assistant tool-call message is expected to already be in
event, optionally carrying an ``actions`` list with every mutating call of
the LLM turn (BUG-075). Each action is applied with a one-shot confirmation
and its ``tool_call_id`` answered, keeping the conversation valid for the
resumed turn. The assistant tool-call message is expected to already be in
``convo`` (it is part of the snapshot returned with the confirmation).
"""
from backend.ai_chat import ToolCall
error = confirm_pending.get("error", confirm_pending)
name = error.get("tool")
arguments = error.get("arguments") or {}
call_id = error.get("id") or "call_pending"
error = confirm_pending.get("error", confirm_pending) or {}
actions = confirm_pending.get("actions")
if not isinstance(actions, list) or not actions:
# Legacy single-action payload (no ``actions`` list).
actions = [{
"id": error.get("id") or "call_pending",
"tool": error.get("tool"),
"arguments": error.get("arguments") or {},
}]
if not name:
raise ToolError("Malformed confirmation payload", code="invalid_confirmation")
for action in actions:
name = action.get("tool")
arguments = action.get("arguments") or {}
call_id = action.get("id") or "call_pending"
# Make sure the assistant tool-call message is present in the snapshot.
if not any(
m.get("role") == "assistant" and any(
tc.get("id") == call_id for tc in (m.get("tool_calls") or [])
if not name:
raise ToolError("Malformed confirmation payload", code="invalid_confirmation")
# Make sure the assistant tool-call message is present in the snapshot.
if not any(
m.get("role") == "assistant" and any(
tc.get("id") == call_id for tc in (m.get("tool_calls") or [])
)
for m in convo
):
convo.append(_assistant_tool_message(None, [ToolCall(id=call_id, name=name, arguments=arguments)]))
try:
result = call_tool(name, ctx, arguments, confirm=True)
payload = result.data
ok = True
except ToolError as e:
payload = e.to_dict()
ok = False
record = ToolCallRecord(
name=name, arguments=arguments, ok=ok, result=payload,
step=tool_step_label(name, arguments),
)
for m in convo
):
convo.append(_assistant_tool_message(None, [ToolCall(id=call_id, name=name, arguments=arguments)]))
executed.append(record)
if on_tool_call is not None:
on_tool_call(record)
try:
result = call_tool(name, ctx, arguments, confirm=True)
payload = result.data
ok = True
except ToolError as e:
payload = e.to_dict()
ok = False
record = ToolCallRecord(
name=name, arguments=arguments, ok=ok, result=payload,
step=tool_step_label(name, arguments),
)
executed.append(record)
if on_tool_call is not None:
on_tool_call(record)
convo.append({
"role": "tool",
"tool_call_id": call_id,
"name": name,
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
})
convo.append({
"role": "tool",
"tool_call_id": call_id,
"name": name,
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
})
async def run_agent(
@@ -315,6 +348,36 @@ async def run_agent(
convo = [dict(m) for m in (resume_messages if resume_messages is not None else messages)]
executed: list[ToolCallRecord] = []
def _run_call(call: Any) -> None:
"""Execute one tool call, record it and answer its ``tool_call_id``.
``ToolConfirmationRequired`` propagates to the caller so the loop can
pause and batch the mutating calls of the turn (BUG-075).
"""
try:
result = call_tool(call.name, ctx, call.arguments)
payload: Any = result.data
ok = True
except ToolConfirmationRequired:
raise
except ToolError as e:
payload = e.to_dict()
ok = False
record = ToolCallRecord(
name=call.name, arguments=call.arguments, ok=ok, result=payload,
step=tool_step_label(call.name, call.arguments),
)
executed.append(record)
steps.append(record.step)
if on_tool_call is not None:
on_tool_call(record)
convo.append({
"role": "tool",
"tool_call_id": call.id,
"name": call.name,
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
})
if confirm_pending:
if quota is not None and len(executed) >= quota:
return AgentResult(
@@ -357,19 +420,25 @@ async def run_agent(
llm, convo, executed, steps, iteration, STOP_QUOTA_EXCEEDED
)
try:
result = call_tool(call.name, ctx, call.arguments)
payload = result.data
ok = True
_run_call(call)
except ToolConfirmationRequired as e:
logger.info(f"Agent paused: confirmation required for '{call.name}'")
pending = e.to_dict()
# Include the tool-call id so the client can echo it back.
pending["error"]["id"] = call.id
# BUG-050: the assistant message lists every tool call of this
# batch, so answer the ones we did not reach to keep the
# conversation valid for the resumed turn.
for skipped in response.tool_calls[index + 1:]:
convo.append(_deferred_tool_message(skipped))
# BUG-075: batch every mutating call of this LLM turn so the
# user approves the whole plan at once (one resume applies them
# all) instead of approving one action after another. Read-only
# calls of the batch run immediately and answer their
# ``tool_call_id`` so the resumed turn stays valid.
actions = [_action_descriptor(call)]
for after in response.tool_calls[index + 1:]:
spec = get_tool(after.name)
if spec is not None and spec.requires_confirmation:
actions.append(_action_descriptor(after))
else:
_run_call(after)
pending["actions"] = actions
return AgentResult(
content=response.content or "",
messages=convo,
@@ -379,25 +448,6 @@ async def run_agent(
stopped=STOP_CONFIRMATION_REQUIRED,
pending=pending,
)
except ToolError as e:
payload = e.to_dict()
ok = False
record = ToolCallRecord(
name=call.name, arguments=call.arguments, ok=ok, result=payload,
step=tool_step_label(call.name, call.arguments),
)
executed.append(record)
steps.append(record.step)
if on_tool_call is not None:
on_tool_call(record)
convo.append({
"role": "tool",
"tool_call_id": call.id,
"name": call.name,
"content": json.dumps(_truncate(payload), ensure_ascii=False, default=str),
})
logger.warning(f"Agent reached max iterations ({max_iterations})")
return await _finalize_answer(
+15 -3
View File
@@ -110,6 +110,12 @@ class BooksLMChatRequest(BaseModel):
description="Conversation snapshot returned alongside a ``confirmation`` event, "
"echoed back to resume the agent run.",
)
confirm_all: bool = Field(
default=False,
description="Global approval (BUG-075): apply every pending action of the batch "
"and auto-approve the remaining mutating calls of the same run, "
"so the run does not pause on each action.",
)
app_context: dict[str, Any] | None = Field(
default=None,
description="Live client UI state for the General assistant: open_documents, "
@@ -506,9 +512,11 @@ async def api_bookslm_agent(
Same context as ``/chat`` but the model may call tools (read/search the
vault) through the shared tool layer. Emits one ``tool`` event per executed
tool call, then a final ``message`` event. Mutating tools pause the run with
a ``confirmation`` event (two-step propose/apply) carrying the pending call
and the conversation snapshot; the client resumes by echoing them back in
``confirm`` / ``confirm_messages``.
a ``confirmation`` event (two-step propose/apply) carrying the pending
``actions`` (every mutating call of the turn) and the conversation snapshot;
the client resumes by echoing them back in ``confirm`` / ``confirm_messages``,
optionally with ``confirm_all`` to apply the whole batch and auto-approve the
rest of the run (BUG-075).
"""
_validate_vision_support(req)
system_prompt = _resolve_system_prompt(req, current_user, agent=True)
@@ -523,6 +531,10 @@ async def api_bookslm_agent(
messages.append({"role": "user", "content": _build_user_content(req, vault_path)})
ctx = ToolContext(user=current_user, mode=ToolMode.IN_APP)
if req.confirm_all:
# BUG-075: a single global approval authorizes the whole plan, so the
# run no longer pauses on every subsequent mutating call.
ctx.confirmed = True
async def _llm(msgs, tool_schemas):
return await chat_completion(
+11
View File
@@ -65,6 +65,7 @@ SUPPORTED_EXTENSIONS = {
".sh", ".bash", ".zsh", ".fish", ".bat", ".cmd", ".ps1",
".json", ".yaml", ".yml", ".toml", ".xml", ".csv",
".cfg", ".ini", ".conf", ".env", ".pdf",
".xlsx",
".html", ".css", ".scss", ".less",
".java", ".c", ".cpp", ".h", ".hpp", ".cs", ".go", ".rs", ".rb",
".php", ".sql", ".r", ".m", ".swift", ".kt",
@@ -559,6 +560,12 @@ def _scan_vault(
raw = ""
title = fpath.stem.replace("-", " ").replace("_", " ")
content_preview = ""
elif ext == ".xlsx":
# #152 — binary workbook: metadata only, the viewer renders
# it (parity with _index_single_file_sync).
raw = ""
title = fpath.stem.replace("-", " ").replace("_", " ")
content_preview = ""
else:
raw = fpath.read_text(encoding="utf-8", errors="replace")
title = fpath.stem.replace("-", " ").replace("_", " ")
@@ -947,6 +954,10 @@ def _index_single_file_sync(vault_name: str, vault_path: str, file_path: str, va
# #108 — binary media: metadata only, never read the bytes.
raw = ""
content_preview = ""
elif ext == ".xlsx":
# #152 — binary workbook: metadata only (parity with _scan_vault).
raw = ""
content_preview = ""
else:
raw = fpath.read_text(encoding="utf-8", errors="replace")
content_preview = raw[:200].strip()
+97 -1874
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -181,6 +181,10 @@ _ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = {
"request": {"path": "notes/Accueil.md", "content": "# Accueil\n\nMis à jour."},
"response": {"status": "ok", "vault": "TestVault", "path": "notes/Accueil.md", "size": 26},
},
("put", "/api/file/{vault_name}/xlsx/save"): {
"request": {"sheet": "Budget", "cells": {"B1": "250"}},
"response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 1},
},
("post", "/api/search/replace"): {
"request": {"query": "Python", "replacement": "Python 3", "vault": "all", "dry_run": True},
"response": {"matches": [{"vault": "TestVault", "path": "note1.md", "title": "Python", "match_count": 3}], "total_matches": 3, "dry_run": True},
+7
View File
@@ -0,0 +1,7 @@
"""ObsiGate — routers FastAPI par domaine (ROADMAP #85).
Découpage progressif du monolithe ``backend/main.py`` : chaque module de ce
paquet expose un ``APIRouter`` monté par ``main.py``. Les handlers sont
déplacés sans changement de comportement (mêmes chemins, mêmes modèles de
réponse, mêmes dépendances d'authentification).
"""
+412
View File
@@ -0,0 +1,412 @@
"""Backup endpoints (ROADMAP #85, tranche 4).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins (``/api/file/{vault}/backups|diff|restore``,
``/api/backups*``), mêmes modèles de réponse, mêmes dépendances
d'authentification. La logique métier vit déjà dans
:mod:`backend.services.backups`.
Adaptations strictement équivalentes :
- ``_resolve_safe_path`` / ``_backup_file`` / ``_list_backup_files`` de
``main`` n'étaient que des wrappers directs : appelés ici via
:mod:`backend.services.paths` et :mod:`backend.services.backups`.
- ``RestoreRequest`` / ``RestoreResponse`` / ``DiffResponse`` ont déménagé
dans :mod:`backend.schemas`.
- Le singleton SSE vit désormais dans :mod:`backend.sse` (partagé avec
``main`` : les clients ``/api/events`` reçoivent les mêmes broadcasts).
"""
import logging
import os
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from backend.auth.middleware import check_vault_access, require_auth
from backend.indexer import get_vault_data, index, update_single_file
from backend.schemas import (
BackupContentResponse,
BackupsAutoResponse,
BackupsCompressResponse,
BackupsDeletedResponse,
BackupsListResponse,
BackupsResponse,
DiffResponse,
RestoreRequest,
RestoreResponse,
)
from backend.services.backups import (
create_backup,
)
from backend.services.backups import (
diff_backup as service_diff_backup,
)
from backend.services.backups import (
list_backup_files as service_list_backup_files,
)
from backend.services.mutations import (
restore_backup as service_restore_backup,
)
from backend.services.paths import resolve_safe_path
from backend.sse import sse_manager
from backend.webhooks import dispatch_webhooks
logger = logging.getLogger("obsigate")
router = APIRouter(tags=["backups"])
@router.get("/api/file/{vault_name}/backups", response_model=BackupsResponse)
async def api_file_backups(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
current_user=Depends(require_auth),
):
"""List all available backups for a file.
Args:
vault_name: Name of the vault.
path: Relative path of the file within the vault.
Returns:
BackupListResponse with backups sorted newest first.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
try:
backups = service_list_backup_files(vault_name, path)
except Exception as e:
logger.error(f"Error listing backups for {vault_name}/{path}: {type(e).__name__}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Erreur lors de la lecture des backups: {e!s}")
return {"vault": vault_name, "path": path, "backups": backups}
@router.get("/api/file/{vault_name}/diff", response_model=DiffResponse)
async def api_file_diff(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
version: int = Query(..., description="Timestamp of the backup version (left/old side)"),
compare_with: int | None = Query(default=None, description="Timestamp of another backup (right/new side). If omitted, compares with the current file."),
current_user=Depends(require_auth),
):
"""Generate a unified diff between a backup version and another version or the current file.
Args:
vault_name: Name of the vault.
path: Relative path of the file within the vault.
version: Timestamp of the backup to use as the old/left side.
compare_with: Optional timestamp of another backup as the new/right side.
If omitted, the current file on disk is used.
Returns:
DiffResponse containing the unified diff string.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
return service_diff_backup(vault_name, path, version, compare_with)
@router.post("/api/file/{vault_name}/restore", response_model=RestoreResponse)
async def api_file_restore(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
body: RestoreRequest = ..., # type: ignore
current_user=Depends(require_auth),
):
"""Restore a file from a backup version.
The current file is backed up before being overwritten (so the operation is reversible).
Args:
vault_name: Name of the vault.
path: Relative path of the file within the vault.
body: RestoreRequest with the backup version timestamp.
Returns:
RestoreResponse confirming the restore.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
result = service_restore_backup(vault_name, path, body.version)
current_backed_up = result["current_backed_up"]
# Update index
await update_single_file(vault_name, path)
# Broadcast SSE event
await sse_manager.broadcast("file_restored", {
"vault": vault_name,
"path": path,
"restored_from": body.version,
"current_backed_up": current_backed_up,
})
await dispatch_webhooks("file_restored", {"vault": vault_name, "path": path, "restored_from": body.version})
return {
"success": True,
"vault": vault_name,
"path": path,
"restored_from": body.version,
"current_backed_up": current_backed_up,
}
@router.get("/api/backups", response_model=BackupsListResponse)
async def api_backups_list(
vault: str | None = Query(None, description="Filter by vault name"),
current_user=Depends(require_auth),
):
"""List all backups across vaults, grouped by file."""
result: list[dict[str, Any]] = []
try:
for vault_name in index:
if vault and vault_name != vault:
continue
if not check_vault_access(vault_name, current_user):
continue
vd = get_vault_data(vault_name)
if not vd:
continue
vault_root = Path(vd["path"])
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
if not backup_root.is_absolute():
backup_root = vault_root / backup_root
vault_backup_dir = backup_root / vault_name
if not vault_backup_dir.exists():
continue
for fpath in vault_backup_dir.rglob("*.bak"):
if not fpath.is_file():
continue
st = fpath.stat()
fsize = st.st_size
ts_part = fpath.name.rsplit(".", 2)
if len(ts_part) < 3 or not ts_part[-2].isdigit():
continue
ts = int(ts_part[-2])
rel_dir = str(fpath.parent.relative_to(vault_backup_dir)).replace("\\", "/")
rel_file = rel_dir + "/" + ts_part[0] if rel_dir != "." else ts_part[0]
result.append({
"vault": vault_name,
"file": rel_file,
"backup_file": fpath.name,
"timestamp": ts,
"datetime": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat(),
"size": fsize,
"full_path": str(fpath),
})
result.sort(key=lambda x: x["timestamp"], reverse=True)
total_size = sum(r["size"] for r in result)
return {"backups": result, "total": len(result), "total_size_bytes": total_size}
except Exception as e:
logger.error(f"Error listing backups: {type(e).__name__}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Erreur listing backups: {e!s}")
@router.post("/api/backups/delete", response_model=BackupsDeletedResponse)
async def api_backups_delete(
body: dict = Body(...),
current_user=Depends(require_auth),
):
"""Delete one or more backup files."""
paths = body.get("paths", [])
if not paths:
raise HTTPException(status_code=400, detail="No backup paths provided")
deleted = 0
for p in paths:
try:
fpath = Path(p)
# Security: ensure path is within a backup directory
if ".obsigate-backup" not in str(fpath):
continue
if fpath.exists() and fpath.is_file():
fpath.unlink()
deleted += 1
except Exception as e:
logger.warning(f"Failed to delete backup {p}: {e}")
return {"deleted": deleted}
@router.post("/api/backups/purge", response_model=BackupsDeletedResponse)
async def api_backups_purge(
body: dict = Body(...),
current_user=Depends(require_auth),
):
"""Purge all backups for a specific file or entire vault."""
vault_name = body.get("vault")
file_path = body.get("file") # optional
if not vault_name:
raise HTTPException(status_code=400, detail="Vault name required")
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail="Access denied")
vd = get_vault_data(vault_name)
if not vd:
raise HTTPException(status_code=404, detail="Vault not found")
vault_root = Path(vd["path"])
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
if not backup_root.is_absolute():
backup_root = vault_root / backup_root
if file_path:
# Delete backups for specific file
backup_dir = backup_root / vault_name / Path(file_path).parent
if backup_dir.exists():
fname = Path(file_path).name
deleted = 0
for f in backup_dir.iterdir():
if f.is_file() and f.name.startswith(fname + ".") and f.name.endswith(".bak"):
f.unlink()
deleted += 1
return {"deleted": deleted}
return {"deleted": 0}
else:
# Delete all backups for vault
vault_backup_dir = backup_root / vault_name
if vault_backup_dir.exists():
deleted = 0
for f in vault_backup_dir.rglob("*.bak"):
if f.is_file():
f.unlink()
deleted += 1
return {"deleted": deleted}
return {"deleted": 0}
@router.get("/api/backups/content", response_model=BackupContentResponse)
async def api_backups_content(
path: str = Query(..., description="Full path to backup file"),
current_user=Depends(require_auth),
):
"""Return the content of a specific backup file."""
try:
fpath = Path(path)
if ".obsigate-backup" not in str(fpath):
raise HTTPException(status_code=403, detail="Access denied")
if not fpath.exists() or not fpath.is_file():
raise HTTPException(status_code=404, detail="Backup not found")
content = fpath.read_text(encoding="utf-8", errors="replace")
# Truncate large files to 100KB
if len(content) > 102400:
content = content[:102400] + "\n\n... (tronque a 100 Ko)"
return {"content": content, "name": fpath.name, "size": len(content)}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/backups/compress", response_model=BackupsCompressResponse)
async def api_backups_compress(
body: dict = Body(...),
current_user=Depends(require_auth),
):
"""Compress backups older than N days. Body: {older_than_days: 30, dry_run: false}"""
import gzip as gz_mod
older_than = body.get("older_than_days", 30)
dry_run = body.get("dry_run", False)
cutoff = time.time() - (older_than * 86400)
compressed = 0
saved_bytes = 0
for vault_name in index:
if not check_vault_access(vault_name, current_user):
continue
vd = get_vault_data(vault_name)
if not vd:
continue
vault_root = Path(vd["path"])
backup_root = Path(os.environ.get("OBSIGATE_BACKUP_DIR", ".obsigate-backup"))
if not backup_root.is_absolute():
backup_root = vault_root / backup_root
vault_dir = backup_root / vault_name
if not vault_dir.exists():
continue
for fpath in vault_dir.rglob("*.bak"):
if not fpath.is_file():
continue
if fpath.name.endswith(".bak.gz"):
continue
mtime = fpath.stat().st_mtime
if mtime > cutoff:
continue
if not dry_run:
try:
gz_path = fpath.with_suffix(fpath.suffix + ".gz")
data = fpath.read_bytes()
with gz_mod.open(str(gz_path), "wb", compresslevel=6) as gzf:
gzf.write(data)
orig_size = len(data)
gz_size = gz_path.stat().st_size
if gz_size < orig_size:
fpath.unlink()
saved_bytes += (orig_size - gz_size)
else:
gz_path.unlink() # compression didn't help
compressed += 1
except Exception as e:
logger.warning(f"Failed to compress {fpath}: {e}")
else:
compressed += 1
return {"compressed": compressed, "saved_bytes": saved_bytes, "dry_run": dry_run}
@router.post("/api/backups/auto", response_model=BackupsAutoResponse)
async def api_backups_auto(
body: dict = Body(...),
current_user=Depends(require_auth),
):
"""Create backups for files modified since a given time. Body: {since_hours: 24}"""
since_hours = body.get("since_hours", 24)
cutoff = time.time() - (since_hours * 3600)
backed_up = 0
for vault_name in index:
if not check_vault_access(vault_name, current_user):
continue
vd = get_vault_data(vault_name)
if not vd:
continue
vault_root = Path(vd["path"])
for fpath in vault_root.rglob("*"):
if not fpath.is_file():
continue
if fpath.name.startswith('.'):
continue
if any(p.startswith('.') or p in {'.obsidian', '.trash', '.git', '.obsigate-backup', '__pycache__', 'node_modules'} for p in fpath.relative_to(vault_root).parts):
continue
mtime = fpath.stat().st_mtime
if mtime < cutoff:
continue
try:
rel = str(fpath.relative_to(vault_root)).replace("\\", "/")
create_backup(fpath, vault_name, rel)
backed_up += 1
except Exception as e:
logger.warning(f"Auto-backup failed for {rel}: {e}")
return {"backed_up": backed_up, "since_hours": since_hours}
+525
View File
@@ -0,0 +1,525 @@
"""File browsing & reading endpoints (ROADMAP #85, tranche 6a).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins (``/api/browse/*``, ``/api/file/*`` en
lecture), mêmes modèles de réponse (déménagés dans
:mod:`backend.schemas`), mêmes dépendances d'authentification.
Adaptations strictement équivalentes :
- ``_resolve_safe_path`` → :mod:`backend.services.paths` (pass-through).
- ``_render_markdown`` reste dans ``main`` (import différé, extraction
prévue dans une tranche ultérieure).
- ``_content_disposition`` / ``_media_max_inline_bytes`` / ``EXT_TO_LANG``
ont déménagé : helpers partagés dans :mod:`backend.routers.helpers`
(``EXT_TO_LANG`` n'était utilisé que par la vue fichier).
"""
import html as html_mod
import logging
from pathlib import Path
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import FileResponse
from backend.auth.middleware import check_vault_access, require_auth
from backend.history import record_open
from backend.indexer import (
_extract_tags,
get_backlinks,
get_vault_data,
parse_markdown_file,
)
from backend.media_types import is_audio, is_image, is_video, media_mime_type
from backend.routers.helpers import media_max_inline_bytes
from backend.schemas import (
BacklinksResponse,
BrowseResponse,
FileContentResponse,
FileRawResponse,
)
from backend.services.files import read_raw_file
from backend.services.paths import resolve_safe_path
from backend.services.vaults import browse_directory
logger = logging.getLogger("obsigate")
# Map file extensions to highlight.js language hints
EXT_TO_LANG = {
".py": "python", ".js": "javascript", ".ts": "typescript",
".jsx": "jsx", ".tsx": "tsx", ".sh": "bash", ".bash": "bash",
".zsh": "bash", ".fish": "fish", ".bat": "batch", ".cmd": "batch",
".ps1": "powershell", ".json": "json", ".yaml": "yaml", ".yml": "yaml",
".toml": "toml", ".xml": "xml", ".csv": "plaintext",
".cfg": "ini", ".ini": "ini", ".conf": "ini", ".env": "bash",
".html": "html", ".css": "css", ".scss": "scss", ".less": "less",
".java": "java", ".c": "c", ".cpp": "cpp", ".h": "c", ".hpp": "cpp",
".cs": "csharp", ".go": "go", ".rs": "rust", ".rb": "ruby",
".php": "php", ".sql": "sql", ".r": "r", ".swift": "swift",
".kt": "kotlin", ".txt": "plaintext", ".log": "plaintext",
".lua": "lua", ".pl": "perl", ".pm": "perl", ".ex": "elixir", ".exs": "elixir",
".dart": "dart", ".tf": "haskell", ".gradle": "groovy", ".groovy": "groovy",
".graphql": "graphql", ".gql": "graphql", ".prisma": "sql", ".proto": "c",
".vb": "basic", ".asm": "x86asm", ".s": "armasm",
".vue": "xml", ".svelte": "xml", ".astro": "xml",
".properties": "ini", ".service": "ini", ".hosts": "ini",
".ksh": "bash", ".dockerfile": "dockerfile",
".makefile": "makefile", ".cmake": "cmake",
}
router = APIRouter(tags=["files"])
@router.get("/api/browse/{vault_name}", response_model=BrowseResponse)
async def api_browse(vault_name: str, path: str = "", current_user=Depends(require_auth)):
"""Browse directories and files in a vault at a given path level.
Returns sorted entries (directories first, then files) with metadata.
Hidden files/directories (starting with ``"."`` ) are excluded.
Args:
vault_name: Name of the vault to browse.
path: Relative directory path within the vault (empty = root).
Returns:
``BrowseResponse`` with vault name, path, and item list.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
return browse_directory(vault_name, path)
@router.get("/api/file/{vault_name}/raw", response_model=FileRawResponse)
async def api_file_raw(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Return raw file content as plain text.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
Returns:
``FileRawResponse`` with vault, path, and raw text content.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
return read_raw_file(vault_name, path)
@router.get("/api/file/{vault_name}/download", response_class=FileResponse)
async def api_file_download(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Download a file as an attachment.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
Returns:
``FileResponse`` with ``application/octet-stream`` content-type.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
# Record history
record_open(current_user.get("username"), vault_name, path)
return FileResponse(
path=str(file_path),
filename=file_path.name,
media_type="application/octet-stream",
)
@router.get("/api/file/{vault_name}/backlinks", response_model=BacklinksResponse)
async def api_file_backlinks(
vault_name: str,
path: str = Query(..., description="Relative path to file"),
current_user=Depends(require_auth),
):
"""Get backlinks (files linking to this file via wikilinks).
Returns a list of files that contain `[[wikilinks]]` pointing
to the requested file, across all accessible vaults.
Args:
vault_name: Name of the vault containing the target file.
path: Relative path of the target file within the vault.
Returns:
``{"vault": str, "path": str, "backlinks": [...]}``
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
backlinks = get_backlinks(vault_name, path)
# Filter by user-accessible vaults
if "*" not in user_vaults:
backlinks = [b for b in backlinks if b["vault"] in user_vaults]
return {
"vault": vault_name,
"path": path,
"backlinks": backlinks,
"total": len(backlinks),
}
@router.get("/api/file/{vault_name}", response_model=FileContentResponse)
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Return rendered HTML and metadata for a file.
Markdown files are parsed for frontmatter, rendered with wikilink
support, and returned with extracted tags. Other supported file
types are syntax-highlighted as code blocks.
Args:
vault_name: Name of the vault.
path: Relative file path within the vault.
Returns:
``FileContentResponse`` with HTML, metadata, and tags.
"""
from backend.main import _render_markdown # différé : évite l'import circulaire (#85)
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
vault_root = Path(vault_data["path"])
file_path = resolve_safe_path(vault_root, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
# Record history
record_open(current_user.get("username"), vault_name, path, title=file_path.name)
ext = file_path.suffix.lower()
# === PDF: special handling before read_text (binary file) ===
if ext == ".pdf":
try:
from backend.pdf_reader import extract_pdf_metadata, extract_pdf_text, extract_pdf_toc
pdf_text = extract_pdf_text(file_path, max_chars=100000)
pdf_meta = extract_pdf_metadata(file_path)
pdf_toc = extract_pdf_toc(file_path)
size = file_path.stat().st_size
return {
"vault": vault_name,
"path": path,
"title": pdf_meta.get("title") or file_path.name,
"tags": [],
"frontmatter": {},
"html": f"<div class='pdf-viewer'><p>PDF — {pdf_meta.get('pages', '?')} pages</p><pre>{pdf_text[:5000]}</pre></div>",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"is_pdf": True,
"unsupported": False,
"pdf_metadata": pdf_meta,
"pdf_toc": pdf_toc,
"size_bytes": size,
}
except Exception as e:
logger.error(f"PDF read error for {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading PDF: {e!s}")
# === Excel .xlsx: render sheets as HTML tables (binary, before read_text) ===
if ext == ".xlsx":
try:
from backend.xlsx_reader import render_sheets
sheets = render_sheets(file_path)
size = file_path.stat().st_size
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": sheets[0]["html"] if sheets else "",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"is_xlsx": True,
"xlsx_sheets": sheets,
"unsupported": False,
"size_bytes": size,
}
except Exception as e:
logger.error(f"XLSX read error for {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
# === Images: return as viewable image ===
if is_image(ext):
size = file_path.stat().st_size
mime = media_mime_type(str(file_path))
# #108-B1 — the raw endpoint returns JSON (FileRawResponse), so the
# standalone <img> must point to /api/image, which serves the bytes
# with the right MIME type. Paths are URL-encoded (accents, spaces).
img_url = f"/api/image/{quote(vault_name, safe='')}?path={quote(path, safe='')}"
html = (
f'<div class="image-viewer">'
f'<img src="{img_url}" '
f'alt="{html_mod.escape(file_path.name, quote=True)}" '
f'style="max-width:100%;max-height:80vh;object-fit:contain" />'
f'</div>'
)
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": html,
"raw_length": size,
"extension": ext,
"is_markdown": False,
"is_image": True,
"image_mime": mime,
"size_bytes": size,
}
# === Audio / Video: HTML5 players streamed from /api/media (roadmap #109) ===
if is_audio(ext) or is_video(ext):
size = file_path.stat().st_size
mime = media_mime_type(str(file_path))
media_kind = "audio" if is_audio(ext) else "video"
# #109-A3 — beyond the inline limit the viewer falls back to download
# (a single uvicorn worker must not be pinned by multi-GB media).
if size > media_max_inline_bytes():
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": "",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"unsupported": True,
"media_too_large": True,
"size_bytes": size,
}
# #109-A2 — byte-range endpoint: enables scrub and is required by Safari.
stream_url = f"/api/media/{quote(vault_name, safe='')}?path={quote(path, safe='')}"
if media_kind == "audio":
html = (
f'<div class="audio-viewer">'
f'<audio controls preload="metadata" src="{stream_url}"></audio>'
f'</div>'
)
else:
html = (
f'<div class="video-viewer">'
f'<video controls playsinline preload="metadata" src="{stream_url}"></video>'
f'</div>'
)
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": html,
"raw_length": size,
"extension": ext,
"is_markdown": False,
"is_audio": media_kind == "audio",
"is_video": media_kind == "video",
"media_mime": mime,
"stream_url": stream_url,
"size_bytes": size,
}
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
except PermissionError as e:
logger.error(f"Permission denied reading file {path}: {e}")
raise HTTPException(status_code=403, detail=f"Permission denied: cannot read file {path}")
except UnicodeDecodeError:
# Binary / unsupported file — return structured info with download option
size = file_path.stat().st_size
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": "",
"raw_length": size,
"extension": ext,
"is_markdown": False,
"unsupported": True,
"size_bytes": size,
}
except Exception as e:
logger.error(f"Unexpected error reading file {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}")
# === CSV: render as HTML table ===
if ext == ".csv":
import csv
import io as csv_io
reader = csv.reader(csv_io.StringIO(raw))
rows = list(reader)
if not rows:
html = "<p><em>Fichier CSV vide</em></p>"
else:
headers = rows[0]
data_rows = rows[1:]
html = '<div class="csv-table-wrapper"><table class="csv-table"><thead><tr>'
for h in headers:
html += f"<th>{h}</th>"
html += "</tr></thead><tbody>"
for row in data_rows:
html += "<tr>"
for cell in row:
html += f"<td>{cell}</td>"
html += "</tr>"
html += "</tbody></table></div>"
return {
"vault": vault_name, "path": path,
"title": file_path.name, "tags": [], "frontmatter": {},
"html": html, "raw_length": len(raw), "extension": ext,
"is_markdown": False, "is_csv": True,
}
# === JSON: syntax-highlighted display ===
if ext == ".json":
import json as json_mod
try:
parsed = json_mod.loads(raw)
formatted = json_mod.dumps(parsed, indent=2, ensure_ascii=False)
except json_mod.JSONDecodeError:
formatted = raw
html = f"<pre class='json-viewer'><code>{html_mod.escape(formatted)}</code></pre>"
return {
"vault": vault_name, "path": path,
"title": file_path.name, "tags": [], "frontmatter": {},
"html": html, "raw_length": len(raw), "extension": ext,
"is_markdown": False, "is_json": True,
}
# === Excalidraw .excalidraw.md (Obsidian plugin format) ===
if path.lower().endswith(".excalidraw.md"):
import re as re_mod
raw_lower = file_path.read_text(encoding="utf-8", errors="replace")
# Check for excalidraw-plugin in frontmatter or body
if "excalidraw-plugin:" in raw_lower:
# Extract compressed JSON block
match = re_mod.search(r'```compressed-json\n(.*?)\n```', raw_lower, re_mod.DOTALL)
if match:
compressed = match.group(1).strip()
return {
"vault": vault_name, "path": path,
"title": file_path.name.replace(".excalidraw.md", ""),
"tags": [], "frontmatter": {},
"html": "", "raw_length": len(raw_lower),
"extension": ".excalidraw.md",
"is_markdown": False,
"is_excalidraw": True,
"excalidraw_data_compressed": compressed,
}
# Fallback: treat as regular markdown
raw = raw_lower
if ext == ".excalidraw":
import json as json_mod
try:
parsed = json_mod.loads(raw)
except json_mod.JSONDecodeError:
parsed = None
if parsed and parsed.get("type") == "excalidraw":
return {
"vault": vault_name,
"path": path,
"title": parsed.get("appState", {}).get("name") or file_path.name,
"tags": [],
"frontmatter": {},
"html": "",
"raw_length": len(raw),
"extension": ext,
"is_markdown": False,
"is_excalidraw": True,
"excalidraw_data": {
"elements": parsed.get("elements", []),
"appState": parsed.get("appState", {}),
"files": parsed.get("files", {}),
},
}
else:
# Not a valid Excalidraw file — fall through to text viewer
pass
# === Plain text / other readable files ===
TEXT_EXTENSIONS = {".txt", ".log", ".yml", ".yaml", ".toml", ".ini", ".cfg",
".sh", ".bash", ".py", ".js", ".ts", ".html", ".css",
".xml", ".rst", ".tex", ".sql", ".conf", ".env"}
if ext in TEXT_EXTENSIONS or ext == ".md":
pass # handled below or by markdown section
if ext == ".md":
post = parse_markdown_file(raw)
# Extract metadata using shared indexer logic
tags = _extract_tags(post)
title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " "))
html_content = _render_markdown(post.content, vault_name, file_path)
return {
"vault": vault_name,
"path": path,
"title": str(title),
"tags": tags,
"frontmatter": dict(post.metadata) if post.metadata else {},
"html": html_content,
"raw_length": len(raw),
"extension": ext,
"is_markdown": True,
}
else:
# Non-markdown: wrap in syntax-highlighted code block
lang = EXT_TO_LANG.get(ext, "")
if not lang:
# Fichiers sans extension usuels (Dockerfile, Makefile, etc.)
NAME_TO_LANG = {
"dockerfile": "dockerfile", "makefile": "makefile",
"cmakelists.txt": "cmake", "jenkinsfile": "groovy",
"vagrantfile": "ruby", "rakefile": "ruby", "gemfile": "ruby",
"procfile": "plaintext", "bashrc": "bash", "bash_profile": "bash",
"zshrc": "bash", "profile": "bash", "gitignore": "plaintext",
}
lang = NAME_TO_LANG.get(file_path.name.lower(), "plaintext")
escaped = html_mod.escape(raw)
html_content = f'<pre><code class="language-{lang}">{escaped}</code></pre>'
return {
"vault": vault_name,
"path": path,
"title": file_path.name,
"tags": [],
"frontmatter": {},
"html": html_content,
"raw_length": len(raw),
"extension": ext,
"is_markdown": False,
}
+143
View File
@@ -0,0 +1,143 @@
"""System health endpoints (ROADMAP #85, tranche 1).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins (``/api/health``, ``/api/health/detailed``),
même ``response_model`` (:class:`backend.schemas.HealthResponse`), même
dépendance admin. Seule différence : la version est lue via
:func:`backend.version.get_version` au lieu de ``app.version`` (valeur
identique, figée au démarrage depuis le fichier ``VERSION``).
Note : ``uptime_seconds`` reprend l'expression d'origine
(``'_SERVER_START_TIME' in globals()``), qui vaut toujours 0 — le global
n'est défini nulle part dans ``backend.main`` (voir ``backend.admin`` qui
possède son propre compteur). Ce comportement est préservé tel quel ; le
corriger fera l'objet d'une tranche ultérieure avec test dédié.
"""
from fastapi import APIRouter, Depends
from backend.auth.middleware import require_admin
from backend.indexer import index
from backend.schemas import HealthResponse
from backend.version import get_git_commit, get_git_describe, get_version
router = APIRouter(tags=["System"])
@router.get("/api/health", response_model=HealthResponse)
async def api_health():
"""Health check endpoint for Docker and monitoring.
Returns:
Application status, version, vault count and total file count.
"""
total_files = sum(len(v["files"]) for v in index.values())
total_tokens = sum(len(v.get("files", [])) * 1000 for v in index.values()) # rough approx
import time
from backend.indexer import _last_full_index_ts
# `_SERVER_START_TIME` n'existe dans aucun module (comportement d'origine
# préservé : uptime toujours 0 — voir docstring du module).
uptime = int(time.time() - _SERVER_START_TIME) if '_SERVER_START_TIME' in globals() else 0 # noqa: F821
return {
"status": "ok",
"version": get_version(),
"vaults": len(index),
"total_files": total_files,
"total_tokens": total_tokens,
"last_full_index_ts": _last_full_index_ts,
"uptime_seconds": uptime,
"git_describe": get_git_describe(),
"git_commit": get_git_commit(),
}
@router.get("/api/health/detailed", response_model=HealthResponse)
async def api_health_detailed(current_user=Depends(require_admin)):
"""Detailed health check — admin only.
Returns enriched metrics including memory, disk, SSE connections, and backup stats.
"""
import psutil
from backend.admin import _count_active_sessions, _get_disk_stats
from backend.indexer import _last_full_index_ts, index
total_files = sum(len(v["files"]) for v in index.values())
total_tokens = sum(len(v.get("files", [])) * 1000 for v in index.values())
import time
uptime = int(time.time() - _SERVER_START_TIME) if '_SERVER_START_TIME' in globals() else 0 # noqa: F821 — voir ci-dessus
# Memory
vm = psutil.virtual_memory()
mem_used_mb = round(vm.used / (1024 ** 2), 1)
mem_total_mb = round(vm.total / (1024 ** 2), 1)
mem_pct = round(vm.percent, 1)
# CPU
cpu_pct = psutil.cpu_percent(interval=None)
# Disk
disk_used_gb, disk_total_gb = _get_disk_stats()
disk_free_gb = round(disk_total_gb - disk_used_gb, 2)
disk_pct = round((disk_used_gb / disk_total_gb * 100) if disk_total_gb > 0 else 0, 1)
# SSE connections (approximation)
active_sessions = _count_active_sessions()
# Backups
from backend.admin import _scan_backups
backup_rows = _scan_backups()
total_backups = len(backup_rows)
total_backup_size_mb = round(sum(r["size"] for r in backup_rows) / (1024 ** 2), 2)
oldest_backup_age_days = 0.0
if backup_rows:
now_ts = int(time.time())
oldest_ts = min(r["timestamp"] for r in backup_rows)
oldest_backup_age_days = round((now_ts - oldest_ts) / 86400, 2)
# Index details
index_detail = {}
for name, data in index.items():
index_detail[name] = {
"file_count": len(data["files"]),
"tag_count": len(data.get("tags", [])),
"token_count_approx": len(data.get("files", [])) * 1000,
}
return {
"status": "ok",
"version": get_version(),
"vaults": len(index),
"total_files": total_files,
"total_tokens": total_tokens,
"last_full_index_ts": _last_full_index_ts,
"uptime_seconds": uptime,
"git_describe": get_git_describe(),
"git_commit": get_git_commit(),
# Enriched fields
"memory": {
"used_mb": mem_used_mb,
"total_mb": mem_total_mb,
"percent": mem_pct,
},
"cpu": {
"percent": cpu_pct,
},
"disk": {
"used_gb": disk_used_gb,
"total_gb": disk_total_gb,
"free_gb": disk_free_gb,
"percent": disk_pct,
},
"connections": {
"active_sse": active_sessions,
},
"backups": {
"total_count": total_backups,
"total_size_mb": total_backup_size_mb,
"oldest_age_days": oldest_backup_age_days,
},
"index": index_detail,
}
+51
View File
@@ -0,0 +1,51 @@
"""Shared helpers for the file routers (ROADMAP #85, tranche 6a).
Petites fonctions pures extraites de :mod:`backend.main` sans changement
de comportement. Regroupées ici car utilisées par plusieurs routers
(``files_read`` aujourd'hui, ``files_media`` / mutations ensuite) :
- :func:`content_disposition` — aussi utilisée par ``_stream_file_with_range``
(resté dans ``main`` jusqu'à la tranche media).
- :func:`media_max_inline_bytes` — aussi utilisée par ``/api/media``.
"""
from __future__ import annotations
import os
from pathlib import Path
def content_disposition(disposition: str, filename: str) -> str:
"""Build a header-safe Content-Disposition value.
HTTP header values must be ASCII. Unicode filenames are sent per
RFC 5987 via ``filename*`` (percent-encoded UTF-8) with a pure-ASCII
``filename`` fallback. This avoids a UnicodeDecodeError / HTTP 500 when
the filename contains accented characters (e.g. 'Bière blonde…pdf').
"""
from urllib.parse import quote
ascii_name = "".join(c for c in filename if c.isascii() and (c.isalnum() or c in " _-.")).strip() or "file"
ext = Path(filename).suffix
if ext and not Path(ascii_name).suffix:
ascii_name = ascii_name + ext
return f"{disposition}; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(filename)}"
def media_max_inline_bytes() -> int:
"""Maximum size (bytes) for inline audio/video playback (roadmap #109-A3).
Configurable via ``OBSIGATE_MEDIA_MAX_INLINE_MB`` (default 500 MB). Files
above the limit are not streamed in the viewer (the UI falls back to the
download button), which keeps a single uvicorn worker from being pinned by
multi-gigabyte media. Invalid or non-positive values fall back to default.
"""
default_mb = 500
raw = os.environ.get("OBSIGATE_MEDIA_MAX_INLINE_MB", "").strip()
if not raw:
return default_mb * 1024 * 1024
try:
mb = float(raw)
except ValueError:
return default_mb * 1024 * 1024
if mb <= 0:
return default_mb * 1024 * 1024
return int(mb * 1024 * 1024)
+353
View File
@@ -0,0 +1,353 @@
"""Search, suggest, graph & index-reload endpoints (ROADMAP #85, tranche 5).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins, mêmes modèles de réponse (déménagés dans
:mod:`backend.schemas`), mêmes dépendances d'authentification. La logique
métier vit déjà dans :mod:`backend.services.search`,
:mod:`backend.search`, :mod:`backend.services.graph` et
:mod:`backend.services.mutations`.
Adaptations strictement équivalentes :
- Le pool ``_search_executor`` de ``main`` vit désormais dans
:mod:`backend.search_executor` (même dimensionnement, même cycle de vie
géré par le lifespan de ``main``) : accès via
:func:`get_search_executor`.
"""
import asyncio
import logging
from functools import partial
from pathlib import Path
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from backend.audit import log_file_save
from backend.auth.middleware import check_vault_access, require_admin, require_auth
from backend.indexer import get_vault_data, reload_index, update_single_file
from backend.schemas import (
AdvancedSearchResponse,
GraphResponse,
ReloadResponse,
ReplaceResponse,
SearchResponse,
SuggestResponse,
TagsResponse,
TagSuggestResponse,
TreeSearchResponse,
VaultPathsResponse,
VaultStatsResponse,
)
from backend.search import suggest_tags, suggest_titles
from backend.search_executor import get_search_executor
from backend.services.graph import get_graph as service_get_graph
from backend.services.mutations import (
replace_in_files as service_replace_in_files,
)
from backend.services.search import advanced_search_vaults, list_paths, search_paths, search_vaults
from backend.services.search import list_tags as service_list_tags
from backend.sse import sse_manager
logger = logging.getLogger("obsigate")
router = APIRouter(tags=["search"])
@router.get("/api/search", response_model=SearchResponse)
async def api_search(
q: str = Query("", description="Search query"),
vault: str = Query("all", description="Vault filter"),
tag: str | None = Query(None, description="Tag filter"),
limit: int = Query(50, ge=1, le=200, description="Results per page"),
offset: int = Query(0, ge=0, description="Pagination offset"),
current_user=Depends(require_auth),
):
"""Full-text search across vaults with relevance scoring.
Supports combining free-text queries with tag filters.
Results are ranked by a multi-factor scoring algorithm.
Pagination via ``limit`` and ``offset`` (defaults preserve backward compat).
Args:
q: Free-text search string.
vault: Vault name or ``"all"`` to search everywhere.
tag: Comma-separated tag names to require.
limit: Max results per page (1–200).
offset: Pagination offset.
Returns:
``SearchResponse`` with ranked results and snippets.
"""
loop = asyncio.get_event_loop()
# Fetch the full result set (capped at DEFAULT_SEARCH_LIMIT internally) and
# paginate in the shared service so routes and tools share the same logic.
return await loop.run_in_executor(
get_search_executor(),
partial(search_vaults, q, vault, tag, limit, offset),
)
@router.get("/api/tags", response_model=TagsResponse)
async def api_tags(vault: str | None = Query(None, description="Vault filter"), current_user=Depends(require_auth)):
"""Return all unique tags with occurrence counts.
Args:
vault: Optional vault name to restrict tag aggregation.
Returns:
``TagsResponse`` with tags sorted by descending count.
"""
return {"vault_filter": vault, "tags": service_list_tags(vault)}
@router.get("/api/tree-search", response_model=TreeSearchResponse)
async def api_tree_search(
q: str = Query("", description="Search query"),
vault: str = Query("all", description="Vault filter"),
current_user=Depends(require_auth),
):
"""Search for files and directories in the tree structure using pre-built index.
Uses the in-memory path index for instant filtering without filesystem access.
Args:
q: Search string to match against file/directory paths.
vault: Vault name or "all" to search everywhere.
Returns:
``TreeSearchResponse`` with matching paths.
"""
return search_paths(q, vault)
@router.get("/api/vault/{vault_name}/paths", response_model=VaultPathsResponse)
async def api_vault_paths(
vault_name: str,
limit: int = Query(5000, ge=1, le=20000, description="Maximum number of indexed paths to return"),
current_user=Depends(require_auth),
):
"""Return a flat list of every indexed file and directory in a vault.
Used by the AI assistant ``@`` mention menu to filter paths instantly on
the client (one request instead of one per keystroke).
Args:
vault_name: Name of the vault.
limit: Maximum number of entries returned.
Returns:
``VaultPathsResponse`` with the vault's indexed paths.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
return list_paths(vault_name, limit=limit)
@router.get("/api/search/advanced", response_model=AdvancedSearchResponse)
async def api_advanced_search(
q: str = Query("", description="Advanced search query (supports tag:, vault:, title:, path:, ext: operators)"),
vault: str = Query("all", description="Vault filter"),
tag: str | None = Query(None, description="Comma-separated tag filter"),
limit: int = Query(50, ge=1, le=200, description="Results per page"),
offset: int = Query(0, ge=0, description="Pagination offset"),
sort: str = Query("relevance", description="Sort by 'relevance' or 'modified'"),
case_sensitive: bool = Query(False, description="Match case"),
whole_word: bool = Query(False, description="Match whole words only"),
regex: bool = Query(False, description="Treat query as regex"),
include_paths: str | None = Query(None, description="Comma-separated glob patterns to include"),
exclude_paths: str | None = Query(None, description="Comma-separated glob patterns to exclude"),
created: str | None = Query(None, description="Created date filter (>date, <date, date..date)"),
modified: str | None = Query(None, description="Modified date filter (>date, <date, date..date, <Nd)"),
size: str | None = Query(None, description="Size filter (>size, <size, size..size, e.g. >1MB, <10KB)"),
semantic: bool = Query(False, description="Fuse TF-IDF with semantic embeddings (RRF)"),
current_user=Depends(require_auth),
):
"""Advanced full-text search with TF-IDF scoring, facets, and pagination.
Supports advanced query operators:
- ``tag:<name>`` or ``#<name>`` — filter by tag
- ``vault:<name>`` — filter by vault
- ``title:<text>`` — filter by title substring
- ``path:<text>`` — filter by path substring
- ``ext:<type>`` — filter by file extension
- ``created:>2024-01-01`` — filter by creation date
- ``modified:<7d`` or ``modified:2024-01-01..2024-06-01`` — filter by modification date
- ``size:>1MB`` or ``size:100KB..1MB`` — filter by file size
- Remaining text is scored using TF-IDF with accent normalization.
- Toggles: case_sensitive, whole_word, regex
- Path filters: include_paths, exclude_paths (glob patterns)
- ``semantic=true`` — fuse the TF-IDF ranking with the semantic (embedding)
ranking via Reciprocal Rank Fusion and expose ``semantic_score`` per result.
Results include ``<mark>``-highlighted snippets and faceted tag/vault counts.
"""
loop = asyncio.get_event_loop()
search_fn = partial(advanced_search_vaults, q, vault=vault, tag=tag,
limit=limit, offset=offset, sort=sort,
case_sensitive=case_sensitive, whole_word=whole_word, regex=regex,
include_paths=include_paths, exclude_paths=exclude_paths,
created=created, modified=modified, size=size, semantic=semantic)
try:
return await loop.run_in_executor(get_search_executor(), search_fn)
except ValueError as e:
raise HTTPException(400, str(e)) from e
@router.post("/api/search/replace", response_model=ReplaceResponse)
async def api_search_replace(
body: dict = Body(...),
current_user=Depends(require_auth),
):
"""Find and replace across vault files."""
query = body.get("query", "")
replacement = body.get("replacement", "")
vault_filter = body.get("vault", "all")
case_sensitive = body.get("case_sensitive", False)
whole_word = body.get("whole_word", False)
regex_mode = body.get("regex", False)
include_paths = body.get("include_paths")
exclude_paths = body.get("exclude_paths")
replace_all = body.get("replace_all", False)
dry_run = body.get("dry_run", not replace_all)
if not query:
raise HTTPException(400, "Query is required")
result = service_replace_in_files(
query,
replacement,
vault=vault_filter,
case_sensitive=case_sensitive,
whole_word=whole_word,
regex=regex_mode,
include_paths=include_paths,
exclude_paths=exclude_paths,
replace_all=replace_all,
dry_run=dry_run,
is_vault_allowed=lambda v: check_vault_access(v, current_user),
)
if dry_run:
return result
# Side effects for applied replacements (audit + incremental index).
for match in result.get("replaced", []):
log_file_save(current_user["username"], match["vault"], match["path"], match.get("size", 0))
vault_data = get_vault_data(match["vault"])
if vault_data:
abs_path = str(Path(vault_data["path"]) / match["path"])
await update_single_file(match["vault"], abs_path)
return result
@router.get("/api/suggest", response_model=SuggestResponse)
async def api_suggest(
q: str = Query("", description="Prefix to search for in file titles"),
vault: str = Query("all", description="Vault filter"),
limit: int = Query(10, ge=1, le=50, description="Max suggestions"),
current_user=Depends(require_auth),
):
"""Suggest file titles matching a prefix (accent-insensitive).
Used for autocomplete in the search input.
Args:
q: User-typed prefix (minimum 2 characters).
vault: Vault name or ``"all"``.
limit: Max number of suggestions.
Returns:
``SuggestResponse`` with matching file title suggestions.
"""
suggestions = suggest_titles(q, vault_filter=vault, limit=limit)
return {"query": q, "suggestions": suggestions}
@router.get("/api/tags/suggest", response_model=TagSuggestResponse)
async def api_tags_suggest(
q: str = Query("", description="Prefix to search for in tags"),
vault: str = Query("all", description="Vault filter"),
limit: int = Query(10, ge=1, le=50, description="Max suggestions"),
current_user=Depends(require_auth),
):
"""Suggest tags matching a prefix (accent-insensitive).
Used for autocomplete when typing ``tag:`` or ``#`` in the search input.
Args:
q: User-typed prefix (with or without ``#``, minimum 2 characters).
vault: Vault name or ``"all"``.
limit: Max number of suggestions.
Returns:
``TagSuggestResponse`` with matching tag suggestions and counts.
"""
suggestions = suggest_tags(q, vault_filter=vault, limit=limit)
return {"query": q, "suggestions": suggestions}
@router.get("/api/index/reload", response_model=ReloadResponse)
async def api_reload(current_user=Depends(require_admin)):
"""Force a full re-index of all configured vaults.
Returns:
``ReloadResponse`` with per-vault file and tag counts.
"""
stats = await reload_index()
await sse_manager.broadcast("index_reloaded", {
"vaults": list(stats.keys()),
"stats": stats,
})
return {"status": "ok", "vaults": stats}
@router.get("/api/graph/{vault_name}", response_model=GraphResponse)
async def api_graph(
vault_name: str,
path: str = Query("", description="Relative path to focus on"),
depth: int = Query(1, ge=0, le=3, description="How many levels deep to expand"),
scope: str = Query("directory", description="'directory' (default) or 'full' for entire vault"),
tag: str = Query("", description="Filter: only show files with this tag"),
current_user=Depends(require_auth),
):
"""Return graph data (nodes and edges) for a vault or directory.
Nodes represent files and directories. Edges represent parent-child
relationships and wikilinks between markdown files.
Args:
vault_name: Name of the vault.
path: Relative directory path to focus on (empty = root).
depth: Expansion depth (0 = only direct children, 1-3 = deeper).
scope: 'directory' for subtree, 'full' for entire vault.
tag: Optional tag filter (only files with this tag appear).
Returns:
``GraphResponse`` with nodes and edges.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
return service_get_graph(vault_name, path=path, depth=depth, scope=scope, tag=tag)
@router.get("/api/index/reload/{vault_name}", response_model=VaultStatsResponse)
async def api_reload_vault(vault_name: str, current_user=Depends(require_admin)):
"""Force a re-index of a single vault.
Args:
vault_name: Name of the vault to reindex.
Returns:
Dict with vault statistics.
"""
try:
from backend.indexer import reload_single_vault
stats = await reload_single_vault(vault_name)
await sse_manager.broadcast("vault_reloaded", {
"vault": vault_name,
"stats": stats,
})
return {"status": "ok", "vault": vault_name, "stats": stats}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
+300
View File
@@ -0,0 +1,300 @@
"""Public share endpoints (ROADMAP #85, tranche 3).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins (``/api/share/*``, ``/api/shares``,
``/s/{token}*``), mêmes modèles de réponse, mêmes dépendances
d'authentification (les pages ``/s/*`` restent publiques). La logique
métier vit déjà dans :mod:`backend.share`.
Adaptations strictement équivalentes (pas de changement de comportement) :
- ``_resolve_safe_path`` / ``_backup_file`` de ``main`` n'étaient que des
wrappers directs : appelés ici via :mod:`backend.services.paths` et
:mod:`backend.services.backups` (mêmes signatures, mêmes exceptions
``ServiceError`` toujours mappées par le handler global de ``main``).
- ``_render_markdown`` reste défini dans ``main`` (extraction prévue dans
une tranche ultérieure) : import différé à l'intérieur des handlers, donc
sans import circulaire au chargement.
"""
import html as html_mod
import json as _json
import logging
from pathlib import Path
import frontmatter
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from fastapi.responses import FileResponse, HTMLResponse, Response
from backend.auth.middleware import check_vault_access, require_auth
from backend.indexer import get_vault_data, parse_markdown_file, update_single_file
from backend.schemas import ShareModel, StatusResponse
from backend.secret_redactor import redact_file_content
from backend.services.backups import create_backup
from backend.services.paths import resolve_safe_path
from backend.share import (
create_share,
get_share_by_token,
list_shares,
record_access,
revoke_share,
)
logger = logging.getLogger("obsigate")
# Lazy import: WeasyPrint PDF export (requires GTK, may not be available everywhere)
try:
from backend.pdf_export import build_pdf_html, generate_pdf
except Exception: # pragma: no cover - WeasyPrint/GTK missing
generate_pdf = None # type: ignore[assignment]
build_pdf_html = None # type: ignore[assignment]
logging.getLogger("obsigate").warning("PDF export unavailable (WeasyPrint/GTK not found)")
router = APIRouter(tags=["sharing"])
@router.post("/api/share/{vault_name}", response_model=ShareModel)
async def api_share_create(
vault_name: str,
body: dict = Body(...),
current_user=Depends(require_auth),
):
"""Create a public share link for a document.
Also sets ``publish: true`` in the file's YAML frontmatter so the
frontend can visually indicate the file is publicly shared.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
path = body.get("path", "")
expires = body.get("expires_in_hours")
share = create_share(vault_name, path, current_user["username"], expires)
share["url"] = f"/s/{share['token']}"
# Set publish: true in the file's frontmatter
vault_data = get_vault_data(vault_name)
if vault_data:
file_path = resolve_safe_path(Path(vault_data["path"]), path)
if file_path.exists() and file_path.suffix == ".md":
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
post = frontmatter.loads(raw)
if not post.metadata.get("publish"):
post.metadata["publish"] = True
new_raw = frontmatter.dumps(post)
create_backup(file_path, vault_name, path)
file_path.write_text(new_raw, encoding="utf-8")
await update_single_file(vault_name, str(file_path))
logger.info(f"Set publish:true on {vault_name}/{path}")
except Exception as e:
logger.warning(f"Failed to set publish metadata on {vault_name}/{path}: {e}")
return share
@router.get("/api/shares", response_model=list[ShareModel])
async def api_shares_list(vault: str | None = Query(None), current_user=Depends(require_auth)):
"""List all shares (optionally filtered by vault)."""
shares = list_shares(vault)
for s in shares:
s["url"] = f"/s/{s['token']}"
return shares
@router.delete("/api/share/{share_id}", response_model=StatusResponse)
async def api_share_revoke(share_id: str, current_user=Depends(require_auth)):
if not revoke_share(share_id):
raise HTTPException(404, "Share not found")
return {"status": "revoked"}
@router.get(
"/s/{token}/pdf",
response_class=Response,
responses={200: {"content": {"application/pdf": {}}, "description": "Shared document as PDF"}},
)
async def public_share_pdf_download(token: str):
"""Download shared document as real PDF via WeasyPrint."""
from backend.main import _render_markdown # différé : évite l'import circulaire (#85)
if generate_pdf is None:
raise HTTPException(501, "PDF export unavailable (WeasyPrint/GTK not available)")
share = get_share_by_token(token)
if not share:
raise HTTPException(404, "Share not found or expired")
vault_data = get_vault_data(share["vault"])
if not vault_data:
raise HTTPException(404, "Vault not found")
vault_root = Path(vault_data["path"])
file_path = resolve_safe_path(vault_root, share["path"])
if not file_path.exists():
raise HTTPException(404, "File not found")
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
except Exception:
raise HTTPException(500, "Cannot read file")
record_access(token)
raw = redact_file_content(raw, str(file_path))
post = parse_markdown_file(raw)
ext = file_path.suffix.lower()
if ext == ".md":
html = _render_markdown(post.content, share["vault"], file_path)
else:
html = f'<pre style="font-family:monospace;font-size:12px;line-height:1.6;white-space:pre-wrap">{html_mod.escape(raw)}</pre>'
title = post.metadata.get("title", file_path.stem)
pdf_html = build_pdf_html(html, str(title))
pdf_bytes = generate_pdf(pdf_html, str(title))
safe_name = "".join(c for c in str(title) if c.isascii() and (c.isalnum() or c in " _-.")).strip() or "document"
return Response(content=pdf_bytes, media_type="application/pdf", headers={"Content-Disposition": f'attachment; filename="{safe_name}.pdf"'})
@router.get("/s/{token}/raw", response_class=FileResponse)
async def public_share_raw(token: str):
"""Download the raw (original) shared document."""
share = get_share_by_token(token)
if not share:
raise HTTPException(404, "Share not found or expired")
vault_data = get_vault_data(share["vault"])
if not vault_data:
raise HTTPException(404, "Vault not found")
vault_root = Path(vault_data["path"])
file_path = resolve_safe_path(vault_root, share["path"])
if not file_path.exists():
raise HTTPException(404, "File not found")
record_access(token)
return FileResponse(path=str(file_path), filename=file_path.name, media_type="application/octet-stream")
@router.get("/s/{token}", response_class=HTMLResponse)
async def public_share_view(token: str):
"""Public share view — no authentication required."""
from backend.main import _render_markdown # différé : évite l'import circulaire (#85)
share = get_share_by_token(token)
if not share:
raise HTTPException(404, "Share not found or expired")
vault_data = get_vault_data(share["vault"])
if not vault_data:
raise HTTPException(404, "Vault not found")
vault_root = Path(vault_data["path"])
file_path = resolve_safe_path(vault_root, share["path"])
if not file_path.exists():
raise HTTPException(404, "File not found")
try:
raw = file_path.read_text(encoding="utf-8", errors="replace")
except Exception:
raise HTTPException(500, "Cannot read file")
record_access(token)
raw = redact_file_content(raw, str(file_path))
post = parse_markdown_file(raw)
ext = file_path.suffix.lower()
if ext == ".md":
html = _render_markdown(post.content, share["vault"], file_path)
else:
escaped = html_mod.escape(raw)
html = f'<pre style="background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:16px;overflow-x:auto;font-size:0.85rem;line-height:1.6"><code>{escaped}</code></pre>'
title = post.metadata.get("title", file_path.stem)
# Escape everything user-controlled before embedding in HTML/JS (BUG-022).
title_esc = html_mod.escape(str(title))
# Neutralise ``</script>`` in the JS string literal too.
title_download_js = (
_json.dumps(f"{title}.md")
.replace("<", "\\u003c")
.replace(">", "\\u003e")
.replace("&", "\\u0026")
)
# JSON-escape raw content for embedding in HTML, and neutralise ``</script>``.
raw_json = (
_json.dumps(raw)
.replace("<", "\\u003c")
.replace(">", "\\u003e")
.replace("&", "\\u0026")
)
fm_html = ""
if post.metadata:
fm_items = []
skip_keys = {"title", "titre"}
for k, v in post.metadata.items():
if k in skip_keys:
continue
if isinstance(v, list):
v = ", ".join(str(x) for x in v)
elif isinstance(v, bool):
v = "✓" if v else "✗"
elif v is None:
v = "—"
fm_items.append(
f'<div class="fm-row"><span class="fm-key">{html_mod.escape(str(k))}</span>'
f'<span class="fm-val">{html_mod.escape(str(v))}</span></div>'
)
if fm_items:
fm_html = f'<div class="fm-section"><div class="fm-header">Frontmatter</div><div class="fm-body">{"".join(fm_items)}</div></div>'
return HTMLResponse(f"""<!DOCTYPE html><html lang="fr" data-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>{title_esc} — ObsiGate Share</title>
<style>
:root {{ --bg:#1a1a2e; --bg-card:#16213e; --text:#e0e0e0; --text-muted:#888; --accent:#6366f1; --border:#2a2a4a; --banner-bg:var(--accent); --banner-text:#fff; }}
[data-theme="light"] {{ --bg:#f8f9fa; --bg-card:#fff; --text:#1a1a2e; --text-muted:#666; --accent:#4f46e5; --border:#ddd; --banner-bg:#eef2ff; --banner-text:#4338ca; }}
*{{box-sizing:border-box;margin:0;padding:0}}
body{{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);line-height:1.7;min-height:100vh}}
.toolbar{{position:sticky;top:0;z-index:10;background:var(--bg-card);border-bottom:1px solid var(--border);padding:8px 16px;display:flex;align-items:center;gap:8px;flex-wrap:wrap}}
.toolbar-title{{font-weight:600;font-size:0.9rem;margin-right:auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
.toolbar-btn{{padding:6px 12px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);cursor:pointer;font-size:0.8rem;display:flex;align-items:center;gap:5px;transition:all .15s}}
.toolbar-btn:hover{{background:var(--accent);color:#fff;border-color:var(--accent)}}
.toolbar-btn svg{{width:15px;height:15px;flex-shrink:0}}
.toolbar-btn:hover svg{{stroke:#fff}}
.share-banner{{background:var(--banner-bg);color:var(--banner-text);padding:6px 16px;font-size:0.8rem;text-align:center;display:flex;align-items:center;justify-content:center;gap:6px}}
.share-banner svg{{width:14px;height:14px;flex-shrink:0}}
.content{{max-width:820px;margin:0 auto;padding:24px 20px 60px}}
.content h1{{font-size:1.8rem;margin-bottom:16px;border-bottom:2px solid var(--border);padding-bottom:8px}}
.content h2{{font-size:1.4rem;margin:24px 0 12px}}
.content h3{{font-size:1.15rem;margin:20px 0 8px}}
.content p{{margin:8px 0}}
.content pre{{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:12px 16px;overflow-x:auto;font-size:0.85rem}}
.content code{{font-size:0.9em;background:var(--bg-card);padding:1px 4px;border-radius:3px}}
.content pre code{{background:none;padding:0}}
.content a{{color:var(--accent)}}.content img{{max-width:100%;border-radius:6px}}
.fm-section{{background:var(--bg-card);border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:20px}}
.fm-header{{font-weight:600;font-size:0.8rem;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:8px}}
.fm-body{{display:grid;grid-template-columns:1fr 2fr;gap:4px 12px;font-size:0.85rem}}
.fm-row{{display:contents}}
.fm-key{{color:var(--accent);font-weight:500}}
.fm-val{{color:var(--text);word-break:break-word}}
.content blockquote{{border-left:3px solid var(--accent);padding-left:16px;color:var(--text-muted);margin:12px 0}}
.content table{{border-collapse:collapse;width:100%;margin:12px 0}}
.content th,.content td{{border:1px solid var(--border);padding:8px 12px;text-align:left}}
.content th{{background:var(--bg-card)}}
@media print{{.toolbar,.share-banner{{display:none}}body{{background:#fff;color:#000}}}}
@media(max-width:600px){{.content{{padding:16px 12px 40px}}.toolbar{{gap:4px}}.toolbar-btn{{padding:4px 8px;font-size:0.7rem}}}}
</style></head>
<body>
<div class="share-banner">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>
Document partagé via ObsiGate
</div>
<div class="toolbar">
<span class="toolbar-title">{title_esc}</span>
<button class="toolbar-btn" onclick="toggleTheme()" title="Thème clair/sombre">
<svg id="theme-icon-dark" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
<svg id="theme-icon-light" xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
</button>
<button class="toolbar-btn" onclick="exportMD()" title="Télécharger en Markdown">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
.md
</button>
<button class="toolbar-btn" onclick="location.href=location.pathname+'/pdf'" title="Télécharger en PDF">
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
PDF
</button>
</div>
<div class="content" id="content">{fm_html}{html}</div>
<script id="raw-content" type="text/plain" style="display:none">{raw_json}</script>
<script>
function toggleTheme(){{var t=document.documentElement;var isDark=t.dataset.theme==="dark";t.dataset.theme=isDark?"light":"dark";document.getElementById("theme-icon-dark").style.display=isDark?"none":"";document.getElementById("theme-icon-light").style.display=isDark?"":"none";localStorage.setItem("obsigate-share-theme",t.dataset.theme)}}
(function(){{var s=localStorage.getItem("obsigate-share-theme");if(!s)s="dark";document.documentElement.dataset.theme=s;var isDark=s==="dark";document.getElementById("theme-icon-dark").style.display=isDark?"":"none";document.getElementById("theme-icon-light").style.display=isDark?"none":""}})();
function exportMD(){{var raw=JSON.parse(document.getElementById("raw-content").textContent);var b=new Blob([raw],{{type:"text/markdown"}});var a=document.createElement("a");a.href=URL.createObjectURL(b);a.download={title_download_js};a.click()}}
</script></body></html>""")
+54
View File
@@ -0,0 +1,54 @@
"""Webhook CRUD endpoints (ROADMAP #85, tranche 2).
Handlers déplacés depuis :mod:`backend.main` sans changement de
comportement : mêmes chemins (``/api/webhooks``), même modèle de réponse
(:class:`backend.schemas.WebhookModel`), même dépendance admin. La logique
métier vit déjà dans :mod:`backend.webhooks` (validation d'URL anti-SSRF,
store ``webhook_secrets.json`` — BUG-026).
"""
from fastapi import APIRouter, Body, Depends, HTTPException
from backend.auth.middleware import require_admin
from backend.schemas import StatusResponse, WebhookModel
from backend.webhooks import (
create_webhook,
delete_webhook,
get_webhooks,
update_webhook,
)
router = APIRouter(prefix="/api/webhooks", tags=["webhooks"])
@router.get("", response_model=list[WebhookModel])
async def api_webhooks_list(current_user=Depends(require_admin)):
return get_webhooks()
@router.post("", response_model=WebhookModel)
async def api_webhooks_create(body: dict = Body(...), current_user=Depends(require_admin)):
name = body.get("name", "Unnamed")
url = body.get("url", "")
events = body.get("events", [])
secret = body.get("secret")
if not url:
raise HTTPException(400, "URL is required")
return create_webhook(name, url, events, secret)
@router.patch("/{webhook_id}", response_model=WebhookModel)
async def api_webhooks_update(
webhook_id: str, body: dict = Body(...), current_user=Depends(require_admin)
):
result = update_webhook(webhook_id, body)
if not result:
raise HTTPException(404, "Webhook not found")
return result
@router.delete("/{webhook_id}", response_model=StatusResponse)
async def api_webhooks_delete(webhook_id: str, current_user=Depends(require_admin)):
if not delete_webhook(webhook_id):
raise HTTPException(404, "Webhook not found")
return {"status": "deleted"}
+290
View File
@@ -188,6 +188,271 @@ class BackupsAutoResponse(BaseModel):
since_hours: int | float = Field(description="Look-back window in hours")
class DiffResponse(BaseModel):
"""Response containing a unified diff between two file versions (#85 — extrait de backend.main, inchangé)."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
version: int = Field(description="Backup version timestamp (left/old side)")
compare_with: int | None = Field(default=None, description="Other backup version or null for current file (right/new side)")
diff: str = Field(description="Unified diff (empty if no changes)")
class RestoreRequest(BaseModel):
"""Request to restore a file from a backup (#85 — extrait de backend.main, inchangé)."""
version: int = Field(description="Timestamp of the backup version to restore")
class RestoreResponse(BaseModel):
"""Response after restoring a file from backup (#85 — extrait de backend.main, inchangé)."""
success: bool = Field(description="Whether restore succeeded")
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
restored_from: int = Field(description="Timestamp of the backup used")
current_backed_up: int | None = Field(default=None, description="Timestamp of the backup created from the current version before restore, if any")
# ---------------------------------------------------------------------------
# Files — browse / read (#85 — extrait de backend.main, inchangé)
# ---------------------------------------------------------------------------
class BrowseItem(BaseModel):
"""A single entry (file or directory) returned by the browse endpoint."""
name: str = Field(description="File or directory name")
path: str = Field(description="Relative path within vault")
type: str = Field(description="'file' or 'directory'")
children_count: int | None = Field(default=None, description="Number of children (directories only)")
size: int | None = Field(default=None, description="File size in bytes")
extension: str | None = Field(default=None, description="File extension")
class BrowseResponse(BaseModel):
"""Paginated directory listing for a vault."""
vault: str
path: str
items: list[BrowseItem]
class FileContentResponse(BaseModel):
"""Rendered file content with metadata."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path within the vault")
title: str = Field(description="File title (from frontmatter or filename)")
tags: list[str] = Field(description="Extracted tags from frontmatter and inline #tags")
frontmatter: dict[str, Any] = Field(description="YAML frontmatter as key-value dict")
html: str = Field(description="Rendered HTML content")
raw_length: int = Field(description="Length of raw file content in characters")
extension: str = Field(description="File extension (e.g. .md, .txt)")
is_markdown: bool = Field(description="Whether the file is markdown")
unsupported: bool | None = Field(default=False, description="True for binary/unsupported files")
size_bytes: int | None = Field(default=None, description="File size in bytes (for unsupported files)")
is_pdf: bool | None = Field(default=None, description="True for PDF files")
is_image: bool | None = Field(default=None, description="True for image files")
is_audio: bool | None = Field(default=None, description="True for audio files (HTML5 <audio>, roadmap #109)")
is_video: bool | None = Field(default=None, description="True for video files (HTML5 <video>, roadmap #109)")
media_too_large: bool | None = Field(default=None, description="True when audio/video exceeds the inline streaming limit")
stream_url: str | None = Field(default=None, description="Byte-range streaming URL under /api/media (audio/video)")
media_mime: str | None = Field(default=None, description="MIME type for audio/video files")
is_csv: bool | None = Field(default=None, description="True for CSV files")
is_xlsx: bool | None = Field(default=None, description="True for Excel .xlsx files")
xlsx_sheets: list[dict[str, Any]] | None = Field(
default=None, description="Rendered xlsx sheets [{name, html}]"
)
is_json: bool | None = Field(default=None, description="True for JSON files")
is_excalidraw: bool | None = Field(default=None, description="True for Excalidraw diagram files")
excalidraw_data: dict[str, Any] | None = Field(default=None, description="Excalidraw diagram data (elements, appState, files)")
excalidraw_data_compressed: str | None = Field(default=None, description="Compressed Excalidraw data for .excalidraw.md files")
pdf_metadata: dict[str, Any] | None = Field(default=None, description="PDF metadata")
pdf_toc: list[dict[str, Any]] | None = Field(default=None, description="PDF table of contents")
image_mime: str | None = Field(default=None, description="MIME type for image files")
class FileRawResponse(BaseModel):
"""Raw text content of a file."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path within the vault")
raw: str = Field(description="Raw file content as text")
# ---------------------------------------------------------------------------
# Search / suggest / graph (#85 — extrait de backend.main, inchangé)
# ---------------------------------------------------------------------------
class SearchResultItem(BaseModel):
"""A single search result."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
title: str = Field(description="File title")
tags: list[str] = Field(description="File tags")
score: int = Field(description="Relevance score")
snippet: str = Field(description="Content excerpt with highlights")
modified: str = Field(description="ISO 8601 modification timestamp")
class SearchResponse(BaseModel):
"""Full-text search response with optional pagination."""
query: str = Field(description="Original search query")
vault_filter: str = Field(description="Vault filter applied ('all' or vault name)")
tag_filter: str | None = Field(default=None, description="Tag filter applied")
count: int = Field(description="Number of results in this response")
total: int = Field(default=0, description="Total results before pagination")
offset: int = Field(default=0, description="Current pagination offset")
limit: int = Field(default=200, description="Page size")
results: list[SearchResultItem] = Field(description="Search result items")
class TagsResponse(BaseModel):
"""Tag aggregation response."""
vault_filter: str | None = Field(default=None, description="Vault filter applied")
tags: dict[str, int] = Field(description="Tag name → count mapping")
class TreeSearchResult(BaseModel):
"""A single tree search result item."""
vault: str = Field(description="Vault name")
path: str = Field(description="Full relative path")
name: str = Field(description="File or directory name")
type: str = Field(description="'file' or 'directory'")
matched_path: str = Field(description="Path segment that matched the query")
class TreeSearchResponse(BaseModel):
"""Tree search response with matching paths."""
query: str = Field(description="Search query")
vault_filter: str = Field(description="Vault filter applied")
results: list[TreeSearchResult] = Field(description="Matching files and directories")
class VaultPathEntry(BaseModel):
"""A single indexed path (file or directory) in a vault."""
vault: str = Field(description="Vault name")
path: str = Field(description="Full relative path")
name: str = Field(description="File or directory name")
type: str = Field(description="'file' or 'directory'")
class VaultPathsResponse(BaseModel):
"""Flat list of every indexed path in a vault (capped)."""
vault: str = Field(description="Vault name")
count: int = Field(description="Number of returned entries")
results: list[VaultPathEntry] = Field(description="Indexed files and directories")
class AdvancedSearchResultItem(BaseModel):
"""A single advanced search result with highlighted snippet."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
title: str = Field(description="File title")
tags: list[str] = Field(description="File tags")
score: float = Field(description="TF-IDF relevance score (or fused RRF score in semantic mode)")
semantic_score: float = Field(default=0.0, description="Cosine similarity from the semantic index (0 when unavailable)")
snippet: str = Field(description="Content excerpt with <mark> highlights")
modified: str = Field(description="ISO 8601 modification timestamp")
extension: str = Field(default="", description="File extension")
class SearchFacets(BaseModel):
"""Faceted counts for search results."""
tags: dict[str, int] = Field(default_factory=dict)
vaults: dict[str, int] = Field(default_factory=dict)
class AdvancedSearchResponse(BaseModel):
"""Advanced search response with TF-IDF scoring, facets, and pagination."""
results: list[AdvancedSearchResultItem] = Field(description="Search results")
total: int = Field(description="Total number of matching results")
offset: int = Field(description="Current pagination offset")
limit: int = Field(description="Page size")
facets: SearchFacets = Field(description="Faceted counts by tag and vault")
query_time_ms: float = Field(default=0, description="Server-side query time in milliseconds")
semantic_available: bool = Field(default=False, description="True when the semantic (embedding) index is ready")
class TitleSuggestion(BaseModel):
"""A file title suggestion for autocomplete."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path")
title: str = Field(description="File title")
class SuggestResponse(BaseModel):
"""Autocomplete suggestions for file titles."""
query: str = Field(description="Original query string")
suggestions: list[TitleSuggestion] = Field(description="Matching file suggestions")
class TagSuggestion(BaseModel):
"""A tag suggestion for autocomplete."""
tag: str = Field(description="Tag name")
count: int = Field(description="Number of files with this tag")
class TagSuggestResponse(BaseModel):
"""Autocomplete suggestions for tags."""
query: str = Field(description="Original query string")
suggestions: list[TagSuggestion] = Field(description="Matching tag suggestions")
class GraphNode(BaseModel):
"""A single node in the graph view."""
id: str = Field(description="Unique node identifier")
name: str = Field(description="Display name")
type: str = Field(description="'vault', 'directory', or 'file'")
path: str = Field(description="Relative path within vault")
size: int = Field(default=0, description="File size in bytes")
tags: list[str] = Field(default_factory=list, description="Tags from frontmatter")
incoming_count: int = Field(default=0, description="Number of incoming wikilinks")
outgoing_count: int = Field(default=0, description="Number of outgoing wikilinks")
class GraphEdge(BaseModel):
"""An edge between two nodes in the graph view."""
source: str = Field(description="Source node ID")
target: str = Field(description="Target node ID")
relation: str = Field(description="'parent', 'wikilink', or 'backlink'")
class GraphResponse(BaseModel):
"""Graph data for a vault or directory."""
vault: str = Field(description="Vault name")
path: str = Field(description="Root path for the graph")
scope: str = Field(default="directory", description="'directory' or 'full'")
nodes: list[GraphNode] = Field(description="Graph nodes (files and directories)")
edges: list[GraphEdge] = Field(description="Graph edges (parent and wikilink relations)")
class ReloadResponse(BaseModel):
"""Index reload confirmation with per-vault stats."""
status: str = Field(description="Reload status ('ok' or 'error')")
vaults: dict[str, Any] = Field(description="Per-vault file counts after reload")
# ---------------------------------------------------------------------------
# PDF
# ---------------------------------------------------------------------------
@@ -408,6 +673,31 @@ class DashboardResponse(BaseModel):
total_images: int = 0
# ---------------------------------------------------------------------------
# System / health (#85 — extrait de backend.main, comportement inchangé)
# ---------------------------------------------------------------------------
class HealthResponse(BaseModel):
"""Application health status.
Déplacé depuis :mod:`backend.main` sans modification : pas de
``extra="allow"`` ici, pour préserver la validation actuelle des
réponses (les champs enrichis de ``/api/health/detailed`` restent
filtrés comme avant).
"""
status: str = Field(description="Health status ('ok' or 'error')")
version: str = Field(description="Application version (x.y.z — latest release tag)")
vaults: int = Field(description="Number of configured vaults")
total_files: int = Field(description="Total indexed files across all vaults")
total_tokens: int = Field(description="Total indexed tokens (approx.) across all vaults", default=0)
last_full_index_ts: str = Field(description="ISO timestamp of last full index rebuild", default="")
uptime_seconds: int = Field(description="Server uptime in seconds", default=0)
git_describe: str = Field(default="", description="Full git describe string (commits beyond tag), empty if no git")
git_commit: str = Field(default="", description="Short HEAD commit hash, empty if no git")
# ---------------------------------------------------------------------------
# Webhooks, sharing & conflicts
# ---------------------------------------------------------------------------
+35
View File
@@ -0,0 +1,35 @@
"""Shared thread pool for CPU-bound search (ROADMAP #85, tranche 5).
Holder extrait de :mod:`backend.main` sans changement de comportement :
un seul pool (2 workers, préfixe ``"search"``) créé au démarrage et arrêté
à l'extinction par le lifespan de ``main``. Les routers et les endpoints
restants y accèdent via :func:`get_search_executor` au lieu du global de
``main`` (plus d'import circulaire potentiel).
"""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
_executor: ThreadPoolExecutor | None = None
def init_search_executor(max_workers: int = 2) -> ThreadPoolExecutor:
"""Create (or reuse) the shared search thread pool."""
global _executor
if _executor is None:
_executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="search")
return _executor
def shutdown_search_executor() -> None:
"""Stop the shared search thread pool (best-effort, non-blocking)."""
global _executor
if _executor is not None:
_executor.shutdown(wait=False)
_executor = None
def get_search_executor() -> ThreadPoolExecutor | None:
"""Return the shared search thread pool (``None`` before startup)."""
return _executor
+97
View File
@@ -14,6 +14,7 @@ from __future__ import annotations
import logging
import os
import re
import shutil
from collections.abc import Callable
from pathlib import Path
@@ -223,6 +224,102 @@ def edit_file(
return {"success": True, "vault": vault_name, "path": rel_path, "size": len(content)}
# Cell reference like "A1" / "AB42" (Excel A1 notation, up to 3 letters / 8 digits).
_XLSX_CELL_RE = re.compile(r"^[A-Z]{1,3}[1-9][0-9]{0,7}$")
# ponytail: bare int/float coercion mirrors what Excel does when you type a
# number; dates/booleans stay text (upgrade path: parse locale dates too).
_XLSX_INT_RE = re.compile(r"^[+-]?\d+$")
_XLSX_FLOAT_RE = re.compile(r"^[+-]?(?:\d+\.\d*|\.\d+)$")
def _coerce_xlsx_value(value: Any) -> Any:
"""Turn the string sent by the cell editor back into a scalar."""
if not isinstance(value, str):
return value
text = value.strip()
if text == "":
return None
if _XLSX_INT_RE.match(text):
return int(text)
if _XLSX_FLOAT_RE.match(text):
return float(text)
return value
def edit_xlsx_cells(
vault_name: str,
path: str,
sheet: str,
cells: dict[str, Any],
*,
backup: bool = True,
) -> dict[str, Any]:
"""Apply a batch of cell edits to an ``.xlsx`` workbook.
Raises:
ServiceError: ``not_found`` (404), ``read_only`` (403) or
``invalid`` (400) for a bad sheet, cell reference or value.
ponytail: openpyxl round-trips values/formulas/styles but drops charts,
images and pivot tables; use the SheetJS path if a workbook needs those.
"""
root = get_vault_root(vault_name)
_ensure_writable(root)
file_path = resolve_safe_path(root, path)
if not file_path.exists() or not file_path.is_file():
raise ServiceError(
f"File not found: {path}",
code="not_found",
status=404,
details={"vault": vault_name, "path": path},
)
if file_path.suffix.lower() != ".xlsx":
raise ServiceError(
f"Not an .xlsx file: {path}", code="invalid", status=400
)
if not cells:
raise ServiceError("No cells to update", code="invalid", status=400)
for ref in cells:
if not isinstance(ref, str) or not _XLSX_CELL_RE.match(ref):
raise ServiceError(
f"Invalid cell reference: {ref!r}", code="invalid", status=400
)
from openpyxl import load_workbook
try:
wb = load_workbook(file_path)
except Exception as exc:
raise ServiceError(
f"Cannot open workbook: {exc}", code="invalid", status=400
) from exc
if sheet not in wb.sheetnames:
raise ServiceError(
f"Unknown sheet: {sheet}",
code="invalid",
status=400,
details={"sheets": wb.sheetnames},
)
rel_path = _rel(root, file_path)
if backup:
create_backup(file_path, vault_name, rel_path)
ws = wb[sheet]
for ref, value in cells.items():
ws[ref].value = _coerce_xlsx_value(value)
wb.save(file_path)
logger.info(f"XLSX cells saved: {vault_name}/{rel_path} [{sheet}] +{len(cells)}")
return {
"success": True,
"vault": vault_name,
"path": rel_path,
"size": len(cells),
}
def append_to_file(
vault_name: str,
path: str,
+54
View File
@@ -0,0 +1,54 @@
"""Server-Sent Events manager (ROADMAP #85, tranche 4).
Singleton extrait de :mod:`backend.main` sans changement de comportement :
les routers montés par ``main`` partagent la même instance (les clients SSE
connectés sur ``/api/events`` reçoivent les broadcasts émis depuis
n'importe quel router).
"""
from __future__ import annotations
import asyncio
import json as _json
import logging
logger = logging.getLogger("obsigate")
class SSEManager:
"""Manages SSE client connections and broadcasts events."""
def __init__(self):
self._clients: list[asyncio.Queue] = []
async def connect(self) -> asyncio.Queue:
"""Register a new SSE client and return its message queue."""
queue: asyncio.Queue = asyncio.Queue()
self._clients.append(queue)
logger.debug(f"SSE client connected (total: {len(self._clients)})")
return queue
def disconnect(self, queue: asyncio.Queue):
"""Remove a disconnected SSE client."""
if queue in self._clients:
self._clients.remove(queue)
logger.debug(f"SSE client disconnected (total: {len(self._clients)})")
async def broadcast(self, event_type: str, data: dict):
"""Send an event to all connected SSE clients."""
message = _json.dumps(data, ensure_ascii=False)
dead: list[asyncio.Queue] = []
for q in self._clients:
try:
q.put_nowait({"event": event_type, "data": message})
except asyncio.QueueFull:
dead.append(q)
for q in dead:
self.disconnect(q)
@property
def client_count(self) -> int:
return len(self._clients)
sse_manager = SSEManager()
+86
View File
@@ -0,0 +1,86 @@
"""Render ``.xlsx`` workbooks as HTML tables for the viewer (#xlsx).
Read-only: formulas are shown as their text (``data_only=False``) so a
round-trip through the viewer never depends on Excel's cached values.
Write-side lives in ``backend.services.mutations.edit_xlsx_cells``.
"""
from __future__ import annotations
import html
from datetime import date, datetime
from pathlib import Path
from typing import Any
from openpyxl import load_workbook
from openpyxl.utils import get_column_letter
# ponytail: hard caps bound the rendered grid (500 rows x 40 cols per sheet).
# Raise them, or paginate per sheet, if a real workbook needs more.
MAX_ROWS = 500
MAX_COLS = 40
def _fmt(value: Any) -> str:
if value is None:
return ""
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M")
if isinstance(value, date):
return value.isoformat()
return str(value)
def _trim(grid: list[list[str]]) -> list[list[str]]:
"""Drop trailing empty rows and columns (openpyxl pads to max_col)."""
while grid and not any(grid[-1]):
grid.pop()
if not grid:
return grid
width = 0
for row in grid:
for i in range(len(row) - 1, -1, -1):
if row[i]:
width = max(width, i + 1)
break
return [row[:width] for row in grid]
def _table(grid: list[list[str]]) -> str:
if not grid:
return "<p><em>Feuille vide</em></p>"
n_cols = max(len(row) for row in grid)
out = [
(
'<div class="csv-table-wrapper"><table class="csv-table xlsx-table">'
'<thead><tr><th class="xlsx-corner"></th>'
)
]
out += [f"<th>{get_column_letter(c)}</th>" for c in range(1, n_cols + 1)]
out.append("</tr></thead><tbody>")
for r, row in enumerate(grid, start=1):
out.append(f'<tr><th class="xlsx-rownum">{r}</th>')
for c, val in enumerate(row, start=1):
ref = f"{get_column_letter(c)}{r}"
out.append(f'<td data-cell="{ref}">{html.escape(val)}</td>')
out.append("</tr>")
out.append("</tbody></table></div>")
return "".join(out)
def render_sheets(file_path: Path) -> list[dict[str, str]]:
"""Return ``[{"name": sheet_title, "html": table_html}, ...]``."""
wb = load_workbook(str(file_path), read_only=True, data_only=False)
try:
sheets = []
for ws in wb.worksheets:
grid = [
[_fmt(v) for v in row]
for row in ws.iter_rows(
min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True
)
]
sheets.append({"name": ws.title, "html": _table(_trim(grid))})
return sheets
finally:
wb.close()
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.22.1"
version = "2.27.7"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.22.1"
version = "2.27.7"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.22.1",
"version": "2.27.7",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+12 -1
View File
@@ -14,7 +14,7 @@
- **Projet** : ObsiGate — Porte d'entrée web pour vaults Obsidian
- **Stack** : Python 3.11+ (backend FastAPI) · JavaScript/Vanilla (frontend) · Tauri/Rust (desktop)
- **Dernière mise à jour** : 2026-09-17
- **Dernière mise à jour** : 2026-09-24
---
@@ -183,6 +183,12 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-072* | Visionneuse d'images : le plein écran et le panneau « Métadonnées » ne sont pas conservés lors de la navigation ←/→, et le panneau s'affiche sous la pellicule au lieu d'une barre latérale | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/viewer.js`, `frontend/style.css` | Ouvrir une image, activer le plein écran (ou Métadonnées), puis naviguer avec les flèches précédent/suivant | État persistant `_imageViewerState { lightbox, meta }` + drapeau `_imageViewerNavPending` posé par `go()`/pellicule : `renderFile` ne réinitialise que hors navigation image→image. Panneau reconstruit dans `.image-viewer-body` (sidebar droite, `border-left`, `width:280px; max-width:40%`) ; la règle lightbox ne masque plus que la pellicule. Boutons `image-btn-lightbox`/`image-btn-metadata` (+ `aria-pressed`), `Escape` resynchronisé. Tests : `tests/frontend/image-viewer.test.mjs` (+2), E2E `tests/e2e/image-viewer.spec.js` (+1). | Navigation → `openFile` → `renderImageViewer` recréait le conteneur : les états `lightbox`/`metaPanel` étaient perdus. Le panneau était rendu en bas (colonne) au lieu d'une sidebar droite |
| *BUG-073* | Mobile : la barre de navigation fixe du bas masque le bas de tous les documents et pages affichés (les dernières lignes restent définitivement sous la barre) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/style.css` | Mobile (≤768px) : ouvrir un document long, défiler jusqu'au bas → la fin du contenu passe sous la barre `#mobile-toolbar` et n'est jamais atteignable | Clearance mobile retargetée de `.main-layout` (sélecteur mort, absent de `index.html`) vers `.main-body` (`calc(64px + env(safe-area-inset-bottom, 0))`) ; règle sœur morte `.editor-modal.active ~ .main-layout` supprimée ; reset `body.reading-mode .main-body { padding-bottom: 0 }` (barre masquée en mode lecture) ; `body.np-active .content-area` ramené à `76px` (dégagement dock seul, géométrie totale inchangée). Tests : `tests/frontend/mobile-toolbar.test.mjs` (7, au CI), E2E `tests/e2e/mobile-toolbar.spec.js` (2, `chromium-mobile`, skip desktop) | La règle de clearance du bloc mobile cible `.main-layout`, classe absente de `index.html` (vrai conteneur : `.main-body`) → sélecteur mort, aucun dégagement réservé |
| | | | | | | | | | | |
| *BUG-074* | [🟡 IMPORTANT] Assistant IA : le bloc d'étapes affiche « 1 step » sans titre alors que l'agent réalise plusieurs actions (compteur toujours à 1) | 🟢 corrigé | P1 | 📱 frontend + ⚙️ backend | IA | `frontend/js/bookslm.js`, `backend/agent/loop.py` | Mode agent : demander une création multi-fichiers/dossiers → chaque message ne montre qu'« 1 étape ▶ » sans détail | `backend/agent/loop.py` + `frontend/js/bookslm.js` : compteur = actions (hors réflexions) + titre = 1re action dans le `<summary>` ; reprise de confirmation diffusée dans le **même** message (fusion des étapes). Tests : `tests/frontend/ai.test.mjs` (+3) | Le résumé `<summary>` ne porte aucun titre ; chaque reprise de confirmation crée un **nouveau** message assistant qui ne contient qu'une action ; les réflexions gonflent le compteur |
| *BUG-075* | [🟡 IMPORTANT] Assistant IA : chaque action mutatrice demande son propre « Appliquer » — aucun résumé des actions en attente ni approbation globale | 🟢 corrigé | P1 | ⚙️ backend + 📱 frontend | IA | `backend/agent/loop.py`, `backend/bookslm_routes.py`, `frontend/js/bookslm.js` | Mode agent : demander une structure de répertoires multi-fichiers → valider une action après l'autre | `backend/agent/loop.py` (`pending.actions`, lot exécuté au resume) ; `backend/bookslm_routes.py` (`confirm_all` → `ctx.confirmed`) ; `frontend/js/bookslm.js` (carte multi-actions + « Tout approuver (N) »). Tests : `tests/test_agent_loop.py`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs` | La pause de confirmation ne capture que le **premier** appel mutateur du lot (les suivants sont `deferred`) ; carte unique sans liste ; nouveau `confirm_all` à ajouter pour autoriser la suite de l'exécution en une approbation |
| *BUG-076* | [🟡 IMPORTANT] Assistant IA : après une action de l'agent, l'arborescence et le document ouvert ne sont pas rafraîchis dynamiquement | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : créer/supprimer un fichier ou dossier, modifier le document ouvert → l'UI ne bouge pas | `frontend/js/bookslm.js` : `MUTATING_TOOLS`/`FILE_WRITE_TOOLS`, refresh d'arborescence débouncé sur event `tool`, `_notifyFileWritten` étendu (xlsx/docx/csv/pdf). Tests : `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs` | Aucun refresh explicite sur les événements `tool` mutateurs (repose uniquement sur le watcher SSE) ; `_notifyFileWritten` ignore les créations de documents (xlsx/docx/csv/pdf) |
| *BUG-077* | [🟡 IMPORTANT] Assistant IA : aucun bouton « Stop » pour arrêter l'exécution de l'agent à tout moment | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/bookslm.js` | Mode agent : lancer une longue tâche → le bouton Envoyer est désactivé, impossible d'arrêter (seule la fermeture du panneau abort) | `frontend/js/bookslm.js` + `frontend/style.css` : bouton d'envoi → Stop (`_syncSendButton`/`_stopGeneration`/`_markStopped`), i18n `ai.stop`/`ai.stopped`. Tests : `tests/frontend/ai.test.mjs` (+2) | `_abortCtrl` n'est déclenché que par `close()` ; aucun signal d'arrêt côté client pendant le stream |
| *BUG-078* | [🟡 IMPORTANT] Fichiers de code : la coloration syntaxique (highlight.js) disparaît — les feuilles de thème sont basculées à partir de la **clé** de thème au lieu du **mode** | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/themes.js`, `frontend/js/ui.js`, `tests/frontend/unit.test.mjs` | Ouvrir un fichier `.py`/`.sh`/`.ps1`/`.yml` : le code s'affiche en texte brut, sans couleurs | `frontend/js/themes.js` : `applyTheme` bascule `hljs-theme-dark`/`hljs-theme-light` selon le **mode** (`isDark`). `frontend/js/ui.js` : `initTheme`/`applyTheme` résolvent le mode persisté (`obsigate-theme-mode`) au lieu de traiter la clé (`defaut-obsigate`) comme un mode. Test : `unit.test.mjs` (+1). | Les deux feuilles étaient désactivées car `defaut-obsigate !== "dark"` et `!== "light"` ; résultat **non déterministe** selon l'ordre `UI.initTheme()` (clé) / `Sync.init()` → `themes.initThemes()` (mode). Vérifié Playwright : 5/5 chargements colorés (`.py`), sépia/contraste élevé sur la palette claire |
| *BUG-079* | `GET /api/diagnostics` → 500 « dictionary changed size during iteration » (stats d'index) | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` | Charger la page de diagnostic pendant une indexation : `GET /api/diagnostics` → 500 | `backend/main.py` (`api_diagnostics`) : snapshot avant itération — `list(index.items())` et `inv.word_index.copy()` (copie C atomique sous le GIL) ; test de non-régression `tests/test_api_main.py::TestConfig::test_diagnostics_concurrent_index_writes` | Le handler itérait les dicts en direct alors que l'indexeur les modifiait depuis un autre thread (rebuild initial dans `_search_executor`, hooks incrémentaux `add_document`/`remove_document`) → `RuntimeError` dans le générateur → 500. Test déterministe (`RaceDict` fait grossir le dict en cours d'itération) : échoue sans le correctif, passe avec. Vérifié : pytest 1305 passed / 6 skipped, ruff 0, mypy 0 |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -262,6 +268,11 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-09-23 | BUG-071 (complément E2E) | Test | `tests/e2e/config-mobile.spec.js` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-071 (complément E2E)** : spec Playwright mobile (convention `mobile-editor.spec.js` : `test.skip` hors viewport ≤768px, donc inactive sur le projet `chromium-desktop` du CI). Vérifié en local sur l'instance de test (port 2029, auth désactivée) : hamburger → sommaire, sélection → scroll + actif + repli, 0 débordement horizontal à 393px (3/3 `chromium-mobile`, 3 ignorés en desktop) ; suite `mobile-editor.spec.js` intacte (3/3). | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-072 | Correction | `frontend/js/viewer.js`, `frontend/style.css`, `tests/frontend/image-viewer.test.mjs`, `tests/e2e/image-viewer.spec.js`, `scripts/run-e2e-local.ps1` (nouveau), `package.json`, `AGENTS.md`, `README.md`, `README.fr.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-072** : dans la visionneuse d'images (#108-D), le plein écran (lightbox) et le panneau « Métadonnées » étaient perdus dès qu'on changeait d'image avec ←/→ (ou la pellicule), car `openFile` → `renderFile` recrée entièrement `renderImageViewer`. (1) **Persistance** : état module `_imageViewerState { lightbox, meta }` restauré à chaque rendu ; un drapeau `_imageViewerNavPending` posé par `go()` et le clic de vignette indique à `renderFile` que le rendu suivant est une navigation image→image (pas de réinitialisation) — toute autre ouverture repart à zéro. (2) **Panneau latéral** : `.image-meta-panel` déplacé dans un nouveau `.image-viewer-body` en flex row, à droite de `.image-stage` (`border-left`, `width:280px; max-width:40%`, défilement vertical) au lieu d'une bande sous la pellicule ; la règle lightbox ne masque plus que la pellicule. Boutons stables `image-btn-lightbox`/`image-btn-metadata` + `aria-pressed`, `Escape` resynchronise l'état. Tests statiques `image-viewer.test.mjs` (+2) et E2E Playwright (+1). **Diagnostic E2E** : `npm run test:e2e` bloquait car `bash` résout vers WSL (HS, Ubuntu `Stopped`, `HCS_E_CONNECTION_TIMEOUT`) et git-bash est bloqué par App Control → lanceur PowerShell ajouté. Vérifié : `image-viewer.spec.js` 4/4, **suite `chromium-desktop` complète 103 passed / 6 skipped (10,3 min)** via `scripts/run-e2e-local.ps1`, `image-viewer.test.mjs` 12/12, unit 10/10, validate-imports 40 modules. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-23 | BUG-073 | Correction | `frontend/style.css`, `tests/frontend/mobile-toolbar.test.mjs` (nouveau), `tests/e2e/mobile-toolbar.spec.js` (nouveau), `.gitea/workflows/ci.yml`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-073** : en mobile (≤768px), la barre fixe `#mobile-toolbar` (64px + safe-area) recouvrait le bas de tous les documents/pages — fin de contenu inaccessible. (1) **Cause** : la règle de clearance du bloc `@media (max-width: 768px)` ciblait `.main-layout`, classe absente de `index.html` (vrai conteneur : `.main-body`) → sélecteur mort, zéro dégagement ; règle sœur morte `.editor-modal.active ~ .main-layout { padding-bottom: 0 }` supprimée (overlay plein écran / nécessaire en édition inline). (2) **Correctif** : `.main-body { padding-bottom: calc(64px + env(safe-area-inset-bottom, 0)) }`, reset `body.reading-mode .main-body { padding-bottom: 0 }` (barre masquée en mode lecture), `body.np-active .content-area` ramené de `calc(64px+safe+76px)` à `76px` (dégagement dock seul — géométrie totale identique, pas de double comptage avec `.main-body`). Tests : `mobile-toolbar.test.mjs` (7 statiques, ajouté au CI), E2E `mobile-toolbar.spec.js` (géométrie + scroll fin de `ANALYSE_REVIEW.md`, skip hors viewport ≤768). Vérifié : `mobile-toolbar` 7/7, JSDOM 14 suites 0 échec, **E2E `chromium-mobile` BUG-073 2/2 + régressions mobile-editor/config-mobile 6/6**, **suite `chromium-desktop` complète 106 passed / 9 skipped (11,4 min)**, pytest 1302 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules, unit 11/11. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-24 | #114 | Feature | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs`, `tests/e2e/config-mobile.spec.js`, `docs/features/settings-mobile-114.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **#114 — Configuration, refonte mobile-responsive (≤768px)**. (1) **Drawer sommaire** : `#config-nav` en panneau `position: fixed` (`min(320px, 88vw)`, z-index 40) sous backdrop `#config-modal.config-toc-open::before` (z-index 35) ; bouton `#config-toc-close` ; backdrop/Échap ferment le drawer d'abord puis la modale ; `_setConfigNav` bascule la classe + conserve le `display` inline de reset. (2) **Modale plein écran** : `100dvh` + `padding: 0`, `border-radius: 0`. (3) **Tactile** : boutons/liens ≥44px, inputs/selects `16px` + `min-height: 44px` (anti-zoom iOS, scopé `#config-modal`), rangée `.config-actions-row` sticky column + safe-area, formulaires 1 colonne, MFA 1 colonne + code full-width, wrap webhook/token/share/diag/avatar/webauthn. (4) **Dettes HTML/i18n** : `.config-actions-row` replacée dans `#cfg-backend-settings` (`</section>` orphelin supprimé), id dupliqué `cfg-partages-publics` retiré du `<h2>`, `#plugins-settings-container` supprimé, doublons `.config-btn-add` + règle morte `.mfa-recovery-input` purgés, `#mt-explorer` → `data-i18n="settings.explorer"` ; i18n : clés mortes `settings.{backend,backend_hint,restart_badge,save,plugins}` supprimées, `settings.explorer` + `config.toc_close` ajoutées, `settings.tabs` FR = « Onglets ». Vérifié : `config-mobile.test.mjs` 27/27 (au CI), unit 11/11, validate-imports 40 modules, pytest 1302 passed / 6 skipped, ruff/mypy 0, E2E `chromium-mobile` 5/5. | ✅ livré (en attente vérif utilisateur) |
| 2026-09-24 | BUG-074 → BUG-077 | Correction | `backend/agent/loop.py`, `backend/bookslm_routes.py`, `frontend/js/bookslm.js`, `frontend/style.css`, `frontend/index.html`, `frontend/locales/{fr,en}.json`, `frontend/sw.js`, `tests/test_agent_loop.py`, `tests/test_bookslm.py`, `tests/frontend/ai.test.mjs`, `tests/frontend/editor-inline.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **Lot assistant IA (mode agent)** : (BUG-075) confirmation par lot — `pending.actions` regroupe toutes les mutations d'un tour LLM, un unique bouton « Tout approuver (N) » envoie `confirm_all` (`ToolContext.confirmed`) et n'interrompt plus à chaque action ; les lectures du lot s'exécutent aussitôt. (BUG-074) résumé du bloc d'étapes avec titre de la 1re action + compteur limité aux actions, reprise diffusée dans le même message (fini le « 1 étape » fragmenté). (BUG-076) refresh de l'arborescence débouncé sur les events `tool` mutateurs + `_notifyFileWritten` étendu aux documents (xlsx/docx/csv/pdf). (BUG-077) le bouton d'envoi devient « Stop » pendant le stream (abort SSE, tâche serveur annulée à la déconnexion, marqueur « Exécution arrêtée. »). Guide/i18n FR/EN + `ai.stop`/`ai.stopped`/`ai.confirm_actions`/`ai.action_apply_all` ; `SW_VERSION` v25. Vérifié : pytest 1304 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules, unit 11/11, ai 100/100, editor-inline 44/44, mobile-editor 35/35, ai-sidebar 6/6, forge 32/32, pane-manager 9/9, sw 8/8. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-24 | #115, #117, BUG-078 | Feature + correction | `frontend/js/themes.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/index.html`, `frontend/style.css`, `frontend/popout.html`, `frontend/locales/{fr,en}.json`, `frontend/icons/avatar/*` (nouveau), `tests/frontend/unit.test.mjs`, `tests/frontend/toolbar-order.test.mjs`, `tests/frontend/settings-order-avatar.test.mjs`, `docs/features/viewer-toolbar-highlight-avatars.md` (nouvelle), `docs/ROADMAP.md`, `CHANGELOG.md` | **#115** barre d'outils de lecture épinglée : `viewer.js`/`popout.html` sortent `.file-actions` de `.file-header` dans un `.file-toolbar` enfant direct de `.content-area` (`position: sticky; top: 0`), masqué en mode lecture. **BUG-078** coloration syntaxique : le basculement des feuilles highlight.js suit le **mode** (`themes.applyTheme` + `ui.initTheme/applyTheme` lisent `obsigate-theme-mode`) au lieu de la clé de thème qui désactivait les deux feuilles. **#117** avatars prédéfinis : galerie de 12 images (`frontend/icons/avatar/`) dans `#cfg-profile`, clic → recadrage 256 px (pipeline import) + `PATCH /api/auth/me`, avatars actifs surlignés (`obsigate-avatar-preset`), import personnalisé et suppression conservés. Vérifié : Playwright (coloration 5/5 déterministe, toolbar épinglée à `barTop` constant au défilement), `unit.test.mjs` 12/12, `toolbar-order` 13/13, `settings-order-avatar` 12/12, JSDOM editor-inline/pane-manager/mobile-editor/image-viewer/pdf-viewer/config-mobile/media-viewer/excalidraw verts, pytest 1304 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-09-24 | BUG-079 | Correction | `backend/main.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-079** : `GET /api/diagnostics` renvoyait 500 « dictionary changed size during iteration ». Le handler itérait `inv.word_index.values()` et `index.items()` en direct alors que l'indexeur les modifiait depuis un autre thread (rebuild initial dans `_search_executor`, hooks incrémentaux `add_document`/`remove_document`) → `RuntimeError` dans le générateur. Correctif : **snapshot avant itération** (`list(index.items())`, `inv.word_index.copy()`) — copie C atomique sous le GIL, pas de verrou ajouté. Test de non-régression déterministe (`RaceDict` fait grossir le dict pendant l'itération ; échoue sans le correctif, passe avec). Vérifié : pytest 1305 passed / 6 skipped, ruff 0, mypy 0 (80 fichiers), validate-imports 40 modules, unit 12/12. | 🟢 corrigé (en attente vérif utilisateur) |
---
+29 -50
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.22.1 | **Dernière mise à jour :** 2026-09-23
> **Version :** 2.27.7 | **Dernière mise à jour :** 2026-09-26
> **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)**
@@ -37,14 +37,17 @@
- **Reste à faire :**
- [x] **Signature de l'updater Tauri** (gratuit) : paire de clés générée, `pubkey` renseignée, `createUpdaterArtifacts` activé, secrets CI câblés
- [x] **Manifeste `latest.json`** généré par `scripts/updater_manifest.py` (intégré à `publish_release.py`), endpoint updater pointé sur `main`
- [ ] **Signature de code Windows** : non retenue (pas de certificat) — alternatives : livrer non signé, 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)
---
## ⚪ Backlog — Priorité 4 (P4)
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
- **Effort :** 6-8 jours | **Impact :** 🟢
- **Décision 2026-09-26 : reporté (P4)** — axe prioritaire = dette & sécurité (#85/#87) ; #73 hors chemin critique. Si réactivé : partir d'un MVP export/hash/LWW adossé à #59 (PWA offline) + #62 (collab Yjs/CRDT) plutôt qu'un protocole parallèle.
- **Description :** Synchronisation des vaults entre plusieurs instances d'ObsiGate via un protocole de synchronisation décentralisé ou compatible Obsidian Sync. Alternative self-hosted à Obsidian Sync.
- **Sous-tâches :**
- [ ] Protocole : évaluation CRDT vs OT vs diff/patch pour fichiers markdown
@@ -58,60 +61,30 @@
---
## ⚪ Backlog — Priorité 2 (P2)
### 83. Barre d'outils d'édition mobile — style Obsidian Android
- **Effort :** 3-5 jours | **Impact :** 🟡 | **Zone :** frontend (mobile)
- **Statut :** ✅ livré — ruban horizontal défilable ancré au-dessus du clavier, commandes étendues et personnalisation persistée. Détail : [archive/COMPLETED_v1-v2.md](./archive/COMPLETED_v1-v2.md) (section #83).
- **Description :** remplacer la barre de mise en forme Markdown actuelle par un **ruban horizontal
défilable** ancré juste au-dessus du clavier virtuel, reprenant l'ergonomie de l'app Android
Obsidian : fond anthracite aux coins arrondis, insertion/enrobage de la syntaxe au curseur ou sur
la sélection, et personnalisation des commandes via une icône clé à molette.
- **Sous-tâches :**
- [x] Ruban horizontal défilable (glissement tactile gauche/droite) ancré au-dessus du clavier
- [x] Actions rapides : annuler, refaire, `[[ ]]` (lien interne), modèle/fichiers, tag `#`, pièce jointe
- [x] Formatage : H1–H6, gras, italique, barré (`~~`), surligné (`==`), code en ligne/bloc, citation (`>`)
- [x] Liens externes, listes à puces/numérotées, case à cocher (`- [ ]`), indenter / désindenter
- [x] Personnalisation (clé à molette) : ajouter / supprimer / réordonner les commandes
- [x] i18n FR/EN + tests frontend (helpers purs) + E2E mobile
---
## ⚪ Backlog — Sécurité, architecture & performance (P0/P1)
### 84. Consolidation & sécurité — revue statique 2026-09-13 (phase 1)
- **Effort :** 6-9 jours | **Impact :** 🔴 | **Zone :** backend + frontend | **Référence :** [ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md) BUG-021 → BUG-034
- **Statut :** 🟢 livré (phase 1) — sanitizer XSS, rate-limit/lockout MFA, isolation vaults, ReDoS, SSRF webhooks, cycle de vie des sessions, politique de mot de passe, verrous `users.json`, audits IP, rate-limit par compte, symlinks, recherche via inverted index, token en cookie HttpOnly. Détail : [archive/COMPLETED_v1-v2.md](./archive/COMPLETED_v1-v2.md) (section #84).
- **Description :** traiter toutes les vulnérabilités critiques et importantes issues de la revue statique : XSS markdown (`escape=False`) et page publique de partage, brute-force MFA, isolation des vaults (`resolve_safe_path`), ReDoS, SSRF webhooks, cycle de vie des sessions, politique de mot de passe, races `users.json`, audits IP, rate-limit partagé, indexation symlinks.
- **Sous-tâches :**
- [x] Assainir le rendu markdown (sanitizer serveur en whitelist) et la page de partage (échappement `title`/frontmatter) — *DOMPurify client non ajouté (défense en profondeur serveur suffisante)*
- [x] Rate-limit + lockout sur les endpoints MFA (`totp/verify`, `recovery`, `webauthn/verify`)
- [x] Corriger `resolve_safe_path` (comparaison de chemin stricte par segment) + test de régression
- [x] Rotation du refresh token, révocation de l'access token au logout, persistance des JTI révoqués
- [x] Valider la politique de mot de passe à la création ; bloquer le SSRF des webhooks et externaliser les secrets
- [x] Verrous sur les mutations `users.json` ; consigner l'adresse IP réelle dans les audits
- [x] Ignorer les symlinks de l'index ; caps CPU/timeout regex (ReDoS)
- [~] Durcir la CSP — *partiel* : directives `object-src`/`base-uri`/`form-action`/`frame-ancestors` ajoutées et token retiré de `sessionStorage` ; migration **nonce** restante (nécessite la conversion des gestionnaires d'événements inline)
### 85. Refonte architecturale — découpage du monolithe & persistance d'état (phase 2)
- **Effort :** 8-12 jours | **Impact :** 🟡 | **Zone :** backend
- **Description :** extraire le monolithe `backend/main.py` (~4 260 lignes) en routers FastAPI par domaine et rendre persistant l'état qui ne l'est pas (index de recherche, JTI révoqués, compteurs de rate-limit) pour préparer le multi-nœuds.
- **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).**
- **Statut :** 🔵 en cours depuis 2026-09-26 — découpe par tranches à impact minimal (comportement inchangé, un domaine par commit). **T1 livrée (v2.27.2) :** `health` (`/api/health`, `/api/health/detailed` → `backend/routers/health.py`, `HealthResponse` → `schemas.py`). **T2 livrée (v2.27.3) :** `webhooks` (CRUD `/api/webhooks` → `backend/routers/webhooks.py`, logique déjà dans `backend/webhooks.py`). **T3 livrée (v2.27.4) :** `sharing` (`/api/share/*`, `/api/shares`, `/s/{token}*` → `backend/routers/sharing.py`, logique déjà dans `backend/share.py`). **T4 livrée (v2.27.5) :** `backups` (9 routes `/api/file/{vault}/backups|diff|restore` + `/api/backups*` → `backend/routers/backups.py`, `Diff/Restore*` → `schemas.py`, singleton SSE → `backend/sse.py`). **T5 livrée (v2.27.6) :** `search` (11 routes search/tags/suggest/graph/reload → `backend/routers/search.py`, modèles search → `schemas.py`, pool threads → `backend/search_executor.py`). **T6a livrée (v2.27.7) :** lecture fichiers (`/api/browse`, `/raw`, `/download`, `/backlinks`, `GET /api/file` → `backend/routers/files_read.py`, modèles + `EXT_TO_LANG` déplacés, helpers `_content_disposition`/`_media_max_inline_bytes` → `backend/routers/helpers.py`).
- **Description :** extraire le monolithe `backend/main.py` (~4 827 lignes au 2026-09-26, ~17 % du backend) en routers FastAPI par domaine et rendre persistant l'état qui ne l'est pas (index de recherche, JTI révoqués, compteurs de rate-limit) pour préparer le multi-nœuds. L'état mémoire actuel (index, inverted index, vecteurs sémantiques, `SSEManager`, collab) rend le multi-workers unsafe.
- **Sous-tâches :**
- [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai
- [ ] Centraliser le contrat d'outils IA sur `tools/registry.py` (permissions, quotas, redaction)
- [ ] Persister index, JTI révoqués et compteurs de rate-limit (SQLite/Redis)
- [ ] Verrous asyncio autour de l'index global et des stores JSON ; service de partage public (expiration, révocation, quotas)
- [ ] Routers par domaine : files, search, share, webhooks, plugins, collab, admin, ai — `main.py` conservé comme assemblage (< 500 lignes) ; dédupliquer les modèles Pydantic vers `schemas.py`. **Avancement :** `health` ✅ (T1, `backend/routers/health.py`), `webhooks` ✅ (T2, `backend/routers/webhooks.py`), `sharing` ✅ (T3, `backend/routers/sharing.py`), `backups` ✅ (T4, `backend/routers/backups.py` + `backend/sse.py`), `search` ✅ (T5, `backend/routers/search.py` + `backend/search_executor.py`), `files-read` ✅ (T6a, `backend/routers/files_read.py` + `helpers.py`) ; `tools/registry.py` existe déjà (permissions/quotas/redaction — à compléter, pas à créer)
- [ ] Compléter `tools/registry.py` (existant : permissions/quotas/redaction) comme contrat central des outils IA si des manques sont constatés
- [ ] Persister index, JTI révoqués et compteurs de rate-limit (SQLite par défaut, Redis en option multi-nœuds ; le rate-limit actuel est in-memory mono-process)
- [ ] Verrous asyncio autour de l'index global et des stores JSON ; auditer les `except Exception` larges (> 100 occurrences) : best-effort (backup/audit) vs masquage d'erreur (erreurs typées 4xx/5xx + test)
- [ ] Extraire le service de partage public (expiration, révocation, quotas)
### 87. Amélioration continue — tests, CI/CD, revues de sécurité (phase 4)
- **Effort :** 3-5 jours | **Impact :** 🟡 | **Zone :** `.gitea/workflows/`, `tests/`
- **Description :** renforcer le pipeline (`.gitea/workflows/ci.yml`, `desktop-build.yml`) pour le rendre bloquant par défaut et accompagner les phases 1 → 3.
- **Décision 2026-09-26 : prioritaire (axe Dette & sécurité).**
- **Description :** renforcer le pipeline (`.gitea/workflows/ci.yml`, `desktop-build.yml`) pour le rendre bloquant par défaut et accompagner les phases 1 → 3. Constat 2026-09-26 : job `security` non bloquant (`bandit`/`pip-audit` en `|| echo`, ni semgrep ni trivy), E2E limité à `chromium-desktop`, 5 suites frontend hors CI.
- **Sous-tâches :**
- [ ] Jobs CI sécurité (bandit/semgrep/trivy, audits pip/npm) + tests E2E XSS (page de partage + lecteur markdown)
- [ ] Tests de concurrence (`users.json`), fuzzing de timing regex, couverture des composants critiques
- [ ] Jobs CI sécurité **bloquants** (bandit/semgrep/trivy, audits pip/npm) + tests E2E XSS (page de partage + lecteur markdown)
- [ ] Tests de concurrence (`users.json`), fuzzing de timing regex, couverture des composants critiques ; intégrer au CI les 5 suites frontend hors CI (`upload`, `pretty`, `media-viewer`, `mfa-settings`, `config-ai-keys`)
- [ ] Finir BUG-034 (migration CSP **nonce**, conversion des handlers inline), `Secure` cookies à `true` par défaut, politique CORS same-origin explicite ; confirmer la rotation de la clé DeepSeek (BUG-006, clé dans l'historique Git)
- [ ] Revue périodique des dépendances ; documentation utilisateur FR/EN synchronisée ; contrôle automatisé de la conformité au DoD
---
@@ -124,6 +97,7 @@
| # | Domaine / fonctionnalité | Version | Détails |
|---|---|---|---|
| 152 | Viewer XLSX — affichage multi-feuilles, édition des cellules, téléchargement | 2.27.0 | [archive](./archive/COMPLETED_v1-v2.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) |
| 90 | Barre d'actions du document — regroupement fonctionnel + spacers | 2.3.0 | [archive](./archive/COMPLETED_v1-v2.md) |
| 89 | Drag & drop complet de fichiers/dossiers & intégration Assistant IA | 2.3.0 | [features/drag-and-drop-ai.md](./features/drag-and-drop-ai.md) |
@@ -186,6 +160,10 @@
| 111 | Visionneuse d'images — navigation fluide : image ajustée au cadre, navigation en place (cache annuaire + préchargement), pellicule persistante, flèches latérales au survol | 2.20.0 | [features/image-navigation-111.md](./features/image-navigation-111.md) |
| 112 | En-tête allégé & compte en sidebar — version dans le menu Options, retrait utilisateur/déconnexion du header, section compte en bas de la sidebar, pellicule d'images défilable (molette + flèches) | 2.21.0 | [features/header-user-sidebar-112.md](./features/header-user-sidebar-112.md) |
| 113 | Configuration — ordre naturel des sections (Profil 1er, À propos dernier, TOC = page) & avatar utilisateur (import PNG/JPG/WEBP, persistance serveur, cercle sidebar) | 2.22.0 | [features/settings-order-avatar-113.md](./features/settings-order-avatar-113.md) |
| 114 | Configuration — refonte mobile-responsive (modale plein écran 100dvh, sommaire en drawer coulissant, cibles tactiles ≥ 44 px, inputs 16 px anti-zoom, sauvegarde sticky, MFA 1 colonne, purge i18n/HTML) | 2.23.0 | [features/settings-mobile-114.md](./features/settings-mobile-114.md) |
| BUG-078 | Fichiers de code — coloration syntaxique restaurée (feuilles highlight.js basculées sur le mode de thème et non la clé) | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
| 115 | Viewer — barre d'outils de lecture épinglée au défilement | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
| 117 | Configuration — avatars prédéfinis dans le profil utilisateur (12 images) | 2.25.0 | [features/viewer-toolbar-highlight-avatars.md](./features/viewer-toolbar-highlight-avatars.md) |
---
@@ -193,16 +171,17 @@
| Priorité | Items | Effort total estimé |
|---|---|---|
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–113, #92 | ~131 jours réalisés |
| 🔵 P2 restant | #77 Desktop : signature de code (non retenue), 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) | ~0,5-1 jour |
| ⚪ P4 restant | #73 Sync (6-8j) | 6-8 jours |
| ⚪ P0/P1 restant | #85, #87 Refonte architecturale, CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~11-17 jours |
| **Total restant** | **6 items + finitions** | **~23-38 jours** |
| ✅ Complété | #1 → #59, #61–72, #74–76, #78–84, #86, #88–93, #94–100, #102–115, #117, #92 | ~133 jours réalisés |
| 🔵 Finitions | #77 Desktop : 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) — signature Windows non retenue (décision 2026-09-26) | ~0,5-1 jour |
| ⚪ P4 reporté | #73 Sync — **reporté (décision 2026-09-26)**, hors chemin critique | 6-8 jours si réactivé |
| ⚪ P0/P1 prioritaire | #85, #87 Refonte architecturale, CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~11-17 jours |
| **Total chemin critique** | **#77 fin + #85 + #87** | **~12-18 jours** |
---
## Notes
- **Décisions 2026-09-26 :** axe prioritaire = dette & sécurité (#85/#87) ; #73 Sync reporté (P4, hors chemin critique) ; desktop livré non signé + doc SmartScreen.
- Les items P3/P4 ne sont pas ordonnés par priorité interne — à raffiner selon les retours utilisateurs.
- L'effort inclut le développement + tests unitaires + intégration CI, mais pas la documentation utilisateur.
- Les items marqués 🟢 (nice-to-have) sont de bons candidats pour des contributions externes.
+15
View File
@@ -404,6 +404,21 @@ Deux compléments au bouton « Ajouter » de l'assistant IA.
---
## #152 — Viewer XLSX : affichage, édition, téléchargement ✅ TERMINÉ
Les fichiers `.xlsx` s'ouvrent dans un dédié : un tableau HTML par feuille (onglets en cas de
multi-feuilles, en-têtes A1, cellules `contenteditable`), bouton **Enregistrer** actif dès la
première modification et téléchargement du fichier d'origine.
| Aspect | Détail |
|---|---|
| Lecture | `backend/xlsx_reader.py` — openpyxl `read_only`, formules affichées comme texte, plafond 500×40 cellules par feuille |
| Écriture | `PUT /api/file/{vault}/xlsx/save` → `services/mutations.edit_xlsx_cells` (backup avant écriture, refs A1 validées, `str`→`int`/`float`, 500 cellules max par requête) |
| Frontend | `renderXlsxViewer` dans `frontend/js/viewer.js` (onglets, cellules sales, Entrée/Échap, collage monoligne) |
| Limite connue | Le round-trip openpyxl conserve valeurs/formules/styles mais perd graphiques, images et tableaux croisés |
---
## Grosses fonctionnalités — fiches dédiées
| # | Feature | Version | Fiche |
+1
View File
@@ -20,6 +20,7 @@
- [x] **B3.** Fallback : retry sans `tools` si le provider rejette les tools (400/404/422) → chat simple ; protocole texte `obsigate-action` conservé côté frontend **pour le chat classique uniquement** (BUG-053 : en mode agent, le prompt impose les outils natifs et interdit les blocs `obsigate-action`)
- [x] **B4.** SSE réellement streaming — `ai_chat.stream_completion` (`_openai_stream` + `_gemini_stream`) alimente `/api/ai/bookslm/chat` token par token ; le middleware GZip laisse passer les endpoints SSE BooksLM.
- [x] **B5.** Confirmations UI : toggle « mode agent » (front → `/agent`), événements `tool`/`confirmation`, carte Apply + aperçu diff (LCS) pour les mutations, reprise `confirm`/`confirm_messages` côté backend. *S'active dès que la phase D enregistre des outils `write`.*
- **Complément (BUG-074 → BUG-077)** : la pause de confirmation **regroupe toutes les mutations** d'un même tour LLM (`pending.actions`, chacune avec son libellé `step` et son diff) et la carte n'offre plus qu'un seul bouton « **Tout approuver (N)** » ; la reprise envoie `confirm_all` et le backend arme `ToolContext.confirmed` pour le reste du run (plus d'approbation action par action). Le bloc d'étapes affiche un **titre** (1re action) et ne compte que les **actions** (hors réflexions) ; la reprise diffuse dans le **même message** (« N étapes » cumulées). L'arborescence et le document affiché sont **rafraîchis** dès une action mutatrice (refresh débouncé + `obsigate:file-written`, documents xlsx/docx/csv/pdf inclus). Le bouton d'envoi devient « **Stop** » pendant le stream (abort SSE, tâche serveur annulée à la déconnexion, marqueur « Exécution arrêtée. »).
- [x] **B6.** Outils de navigation in-app : `open_file`, `reveal_in_tree` (événement `obsigate:open-file`) — livré via les liens cliquables de l'assistant (#80, [ai-assistant-ux.md](./ai-assistant-ux.md))
- [x] **B7.** Tests : agent loop LLM mocké (`tests/test_agent_loop.py`), providers (`tests/test_ai_chat.py`), endpoint (`tests/test_bookslm.py`)
+172
View File
@@ -0,0 +1,172 @@
# #114 — Configuration — refonte mobile-responsive de la section Settings
> **Statut :** 🟢 · **Impact :** 🟡 · **Zone :** frontend (mobile, ≤ 768 px)
> **Fichiers :** `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`,
> `frontend/locales/{fr,en}.json`, `tests/frontend/config-mobile.test.mjs`,
> `tests/e2e/config-mobile.spec.js`.
## Contexte
La page **Configurations** (`#config-modal`) était utilisable en mobile seulement
partiellement (correctifs BUG-071) : le sommaire s'ouvrait en bloc haut, la modale
n'était pas plein écran, les cibles tactiles étaient sous 44 px, le clavier virtuel
iOS zoomait les champs, la rangée « Sauvegarder » disparaissait au scroll et plusieurs
dettes HTML/i18n étaient restées en place.
## Ce qui a été livré
### A. Sommaire en drawer coulissant
- `#config-nav` devient un **panneau coulissant gauche** (`position: fixed`,
`width: min(320px, 88vw)`, `z-index: 40`) sous un fond assombri
(`#config-modal.config-toc-open::before`, `z-index: 35`).
- Ouverture/fermeture pilotée par la classe **`.config-toc-open`** sur `#config-modal`
(JS `_setConfigNav`) + un `display` inline conservé pour le contrat de reset.
- Bouton **`#config-toc-close`** (classe `.help-toc-close`, `aria-label`
`config.toc_close`) dans l'en-tête du drawer ; visible uniquement dans le drawer
de la config (masqué sur desktop où la nav est toujours visible).
- **Backdrop** : un tap hors du drawer ferme d'abord le drawer, pas la modale
(`e.target === modal` → `config-toc-open` présent → `_setConfigNav(false)`).
- **Échap** : ferme le drawer d'abord, puis la modale.
- Fermeture de la modale (`closeConfigModal`) nettoie toujours la classe
`config-toc-open` et le `display` inline (aucun fond ne subsiste).
- Animation `config-toc-slide-in` (translateX) à l'ouverture.
### B. Modale plein écran
- `#config-modal` : `padding: 0`, `.editor-container` en `100vw × 100dvh`
(`100dvh` = hauteur du viewport dynamique, tient compte de la barre du navigateur
mobile), `border-radius: 0`, `border: none`.
- Le contenu (`#config-scroll`) garde son scroll propre ; le sous-bloc
`.config-content` reçoit un `padding-bottom: 80 px` pour ne jamais passer sous la
rangée sticky.
### C. Cibles tactiles ≥ 44 px & anti-zoom iOS
- **Champs** : `.config-input`, `.config-select`, `.help-nav-search`,
`.profile-field .config-input/.config-select` → `min-height: 44px` +
`font-size: 16px` (**anti-zoom iOS** : un `font-size < 16px` déclenche le zoom
automatique au focus). La règle est **scoppée `#config-modal`** pour ne pas écraser
`.mfa-code-input` (qui a sa propre typographie).
- **Boutons** : `.config-btn-save`, `.config-btn-secondary`, `.config-btn-primary`,
`.config-btn-danger`, `.config-btn-add`, `.config-btn-sm`, `.mfa-link-btn`,
`.theme-action-btn`, `.profile-avatar-actions .config-btn-secondary`,
`.editor-btn` (fermer), `#config-hamburger`, `#config-toc-close`,
`.help-search-clear` → `min-height/min-width: 44px`.
- **Liens du sommaire** : `.help-nav-link` → `min-height: 44px` (ligne tactile
confortable).
- `.help-hamburger` passe de 36 px à **44 px** en mobile (règle partagée avec le
modal d'aide).
### D. Rangée « Sauvegarder » sticky
- `.config-actions-row` (dans `#cfg-backend-settings`) → `position: sticky;
bottom: 0`, empilée verticalement (`flex-direction: column`), boutons pleine
largeur 44 px, fond opaque + bordure, `padding-bottom` avec
`env(safe-area-inset-bottom)` (barre home iOS).
- La rangée reste visible pendant le scroll de la section backend ; le
`padding-bottom: 80px` de `.config-content` garantit qu'elle ne masque jamais les
derniers contrôles.
### E. Formulaires 1 colonne & grilles
- `.config-row` → 1 colonne (`grid-template-columns: 1fr`), `.config-input--num`
pleine largeur, `text-align: left`.
- `.ai-default-grid` / `.ai-provider-fields` → 1 colonne.
- Add-rows (`.config-add-row`, `.config-add-pattern`) → wrap + largeurs inline
(`180/140/100px`) neutralisées (`width: auto !important`).
- Items webhook/token/share → wrap ; URLs/méta sur leur propre ligne
(`overflow-wrap: anywhere`) ; boutons de suppression 44 px.
- `.hidden-files-add-row` → wrap, input pleine largeur.
- `.config-diag-row` → wrap.
- `.profile-avatar-row` → wrap ; `.profile-form` → `max-width: 100%`.
- `.webauthn-key-item` → wrap ; `.webauthn-key-label` → pleine largeur.
- `.theme-grid` reste en `auto-fill minmax(160px, 1fr)` (déjà responsive).
### F. MFA & sécurité
- `.mfa-recovery-list` → **1 colonne** en mobile (2 colonnes illisibles à 360 px).
- `.mfa-verify-section`, `.mfa-recovery-actions`, `.mfa-disable-actions` → wrap ;
champs/boutons enfants en pleine largeur.
- `.mfa-code-input` → `width: 100%`, `max-width: 320px`, `letter-spacing: 6px`
(au lieu de 12 px qui débordait), `min-height: 52px`.
- `.mfa-secret-code` → `word-break: break-all` (secret TOTP long).
- `.mfa-code-input-group` → wrap.
- Règle morte **`.mfa-recovery-input`** supprimée (auth.js utilise
`mfa-code-input recovery-input`).
### G. Dettes HTML corrigées
- `.config-actions-row` (Sauvegarder / Réindexer / Réinitialiser) déplacée
**dans** `#cfg-backend-settings` (elle était hors de toute section → le sticky
n'avait pas de conteneur de scroll fiable) ; `</section>` orphelin supprimé.
- Id dupliqué **`cfg-partages-publics`** retiré du `<h2>` (l'id reste sur la
`<section>`, cf. #113).
- Conteneur mort **`#plugins-settings-container`** supprimé (le rendu réel est
`#cfg-plugins` via `plugins.js`).
- Sections plugins/about ré-indentées.
- **`#mt-explorer`** : libellé brut `settings.search` remplacé par
`<span data-i18n="settings.explorer">` (i18n correct, FR « Explorateur » / EN
« Files »).
- Doublons CSS **`.config-btn-add`** (3 définitions) réduits à la définition de
référence.
### H. i18n FR/EN
- **Purge des clés mortes** (aucune référence HTML/JS/tests/backend) :
`settings.backend`, `settings.backend_hint`, `settings.restart_badge`,
`settings.save`, `settings.plugins`.
- **Ajouts** : `settings.explorer` (FR « Explorateur » / EN « Files »),
`config.toc_close` (FR « Fermer le sommaire » / EN « Close contents »).
- **Correctif** : `settings.tabs` en FR était le mot anglais « Tabs » →
**« Onglets »** (EN reste « Tabs »).
- Clés vivantes conservées : `settings.reindex`, `settings.no_restart_badge`,
`settings.backend_section`, `settings.security`, `settings.search`,
`settings.tabs`, `settings.search_placeholder`.
## Tests
### Statics — `tests/frontend/config-mobile.test.mjs` (27, au CI)
- BUG-071a–e (hamburger, toggle JS, grilles/wrap, ancres mortes,
`data-i18n-attr` multi-paires) — conservés et adaptés au drawer.
- **#114a** : `#config-toc-close` présent + i18n ; `_setConfigNav` bascule
`.config-toc-open` ; backdrop `::before` (z-index 35) ; `.help-toc-close`
masqué sur desktop / visible dans le drawer ; Échap et backdrop ferment le
drawer d'abord ; `closeConfigModal` nettoie la classe.
- **#114b** : modale `100dvh` + `padding: 0` ; inputs/selects `16px` +
`44px` ; boutons `44px` ; rangée sticky (`position: sticky` +
`flex-direction: column` + safe-area) ; MFA 1 colonne + wrap + code
full-width ; `.config-actions-row` bien dans `#cfg-backend-settings`.
- **#114i18n** : clés mortes purgées ; `settings.explorer` FR/EN ;
`settings.tabs` FR = « Onglets » ; `#mt-explorer` porte
`data-i18n="settings.explorer"` ; `#plugins-settings-container` absent.
### E2E — `tests/e2e/config-mobile.spec.js` (5, projet `chromium-mobile`)
1. Hamburger → drawer `position: fixed` + classe `config-toc-open` sur la modale.
2. Bouton `#config-toc-close` et tap backdrop ferment le drawer **sans** fermer la
modale.
3. Sélection d'une section → scroll doux + lien actif + repli du drawer.
4. Modale plein écran (largeur/hauteur ≈ viewport) + `#config-hamburger`,
`#config-close` et `#cfg-save-backend` ≥ 44 px.
5. Aucun débordement horizontal à 393 px (sections IA / tokens / webhooks /
partages).
## Détails d'implémentation notables
- **Spécificité CSS** : les règles `#config-modal #config-nav` (2 ids) priment sur
les règles génériques `.help-nav` / `body .help-nav` qui masquent la nav en
mobile — pas besoin de `!important`.
- **Backdrop = pseudo-élément** : les clics sur `::before` sont attribués à
l'élément or (`#config-modal`), donc le handler `e.target === modal` existant
fonctionne sans node supplémentaire.
- **Contrat de reset conservé** : `configNavOnOpen.style.display = ''` à
l'ouverture (test statique BUG-071b) — le CSS reprend la main (drawer masqué par
défaut sur mobile).
- **`100dvh` avec repli `100vh`** : les navigateurs sans support `dvh` gardent le
comportement précédent.
- La règle `.help-hamburger { display: inline-flex }` du bloc mobile du modal
d'aide est **partagée** (44 px) ; le drawer de la config double avec
`#config-modal .help-hamburger` pour rester robuste à un réordonnancement des
règles.
@@ -0,0 +1,122 @@
# #115 / BUG-078 / #117 — Barre d'outils épinglée, coloration syntaxique des fichiers de code & avatars prédéfinis
> **Statut :** 🟢 livré (en attente vérification utilisateur)
> **Impact :** 🟡 (#115, BUG-078) · 🟢 (#117)
> **Zone :** frontend (`frontend/js/viewer.js`, `frontend/js/themes.js`,
> `frontend/js/ui.js`, `frontend/js/config.js`, `frontend/index.html`,
> `frontend/style.css`, `frontend/popout.html`, `frontend/locales/fr.json`,
> `frontend/locales/en.json`, `frontend/icons/avatar/*`)
Trois demandes traitées dans la même livraison, toutes côté frontend.
## #115 — Barre d'outils de lecture toujours visible
### Problème
La barre d'actions d'un document (pop-out, bookmark, Editer, Source, Copier, PDF, Export,
Partager…) était rendue **dans** `.file-header`, un conteneur court placé en haut de la
zone de lecture. Or un élément `position: sticky` reste borné par son parent : dès que
`.file-header` sortait de l'écran au défilement d'un document long, la barre disparaissait.
### Correctif
- `frontend/js/viewer.js` et `frontend/popout.html` sortent la barre d'actions de
`.file-header` et la placent dans un nouveau conteneur `.file-toolbar`, **enfant direct
de `.content-area`** (le conteneur de défilement). La barre peut donc se coller au haut
de la zone de lecture pour toute la hauteur du document.
- `frontend/style.css` :
```css
.file-toolbar {
position: sticky;
top: 0;
z-index: 30;
margin: 0 0 16px;
padding: 8px 0;
background: var(--bg-primary);
border-bottom: 1px solid var(--border);
}
```
Le fond est opaque pour qu'aucun texte ne transparaisse dessous.
- `body.reading-mode .file-toolbar { display: none }` : la barre reste masquée en mode
lecture, comme les autres actions.
## BUG-078 — Coloration syntaxique des fichiers de code
### Problème
Les fichiers `.py`, `.sh`, `.ps1`, `.yml`, `.json`… (et les blocs de code Markdown)
s'affichaient en **texte brut**, sans couleurs. Le code était pourtant correctement
généré côté backend (`<pre><code class="language-python">…`) et `safeHighlight()` appelait
bien `hljs.highlightElement()`.
La cause était ailleurs : les deux feuilles de style de highlight.js (`#hljs-theme-dark`
et `#hljs-theme-light`, chargées depuis le CDN dans `index.html`) étaient basculées à
partir de la **clé de thème** persistée (`obsigate-theme` = `defaut-obsigate`, …) :
```js
darkSheet.disabled = theme !== "dark"; // "defaut-obsigate" !== "dark" → true
lightSheet.disabled = theme !== "light"; // "defaut-obsigate" !== "light" → true
```
Les deux feuilles finissaient donc désactivées, privant tous les tokens de leurs couleurs.
Le résultat dépendait de l'ordre de deux initialisations concurrentes — `UI.initTheme()`
(clé de thème) au démarrage et `themes.initThemes()` (mode) via `Sync.init()` — d'où un
comportement **non déterministe** (parfois coloré, le plus souvent non).
### Correctif
- `frontend/js/themes.js` — `applyTheme(themeKey, mode)` bascule désormais les feuilles
highlight.js selon le **mode** :
```js
var isDark = mode === 'dark';
darkSheet.disabled = !isDark;
lightSheet.disabled = isDark;
```
(`sepia` et `high-contrast` réutilisent la palette claire.)
- `frontend/js/ui.js` — `initTheme()` lit le **mode** persisté (`obsigate-theme-mode`) et
`applyTheme()` résout un mode avant de fixer `data-theme` et de basculer les feuilles,
au lieu de comparer la clé de thème à `"dark"`/`"light"`.
Le basculement est ainsi **déterministe** dès le premier rendu et à chaque changement de
mode.
## #117 — Avatars prédéfinis dans le profil
### Ce qui a été livré
- **Galerie de 12 avatars** dans la section `Profil` (`#cfg-profile`), servis depuis
`frontend/icons/avatar/` (`/static/icons/avatar/<fichier>.jpg`) : Chat, chien, elephan,
hibou, koala, lapin, lion, ours, penda, pingouin, raton, tigre.
- Un clic charge l'image, la fait passer par le **même pipeline que l'import** (recadrage
carré central, redimensionnement 256 px, export JPEG 0,85) et l'enregistre via
`PATCH /api/auth/me` — aucune modification backend n'a été nécessaire, la validation
data-URL PNG/JPEG/WebP existante (BUG-113) s'applique telle quelle.
- L'avatar actif est **surligné** ; le choix est mémorisé dans `localStorage`
(`obsigate-avatar-preset`) et purgé dès qu'on importe une photo personnalisée ou qu'on
supprime l'avatar.
- L'import d'une photo et la suppression restent disponibles.
- i18n : nouvelle clé `config.avatar_presets_label` (FR/EN).
## Tests
- `tests/frontend/unit.test.mjs` — `syntax highlight theme` (BUG-078) : `themes.applyTheme`
bascule les feuilles selon le mode et `ui.js` ne compare plus la clé au mode.
- `tests/frontend/toolbar-order.test.mjs` — #115 : `.file-toolbar` dans `viewer.js` et
`popout.html`, règle CSS `position: sticky; top: 0`, masquage en mode lecture.
- `tests/frontend/settings-order-avatar.test.mjs` — #117 : 12 avatars présents dans
`#cfg-profile`, fichiers d'images existants, pipeline `config.js`, règles CSS, clé i18n
FR/EN.
- Vérification Playwright (instance locale, auth désactivée) : coloration déterministe sur
5 chargements successifs d'un `.py` ; `.file-toolbar` dont le `top` ne bouge plus après
défilement (épinglage effectif).
## Limitations
- L'avatar reste stocké en data-URL 256 px dans `data/users.json` (comportement #113
conservé).
- Le mode `high-contrast`/`sepia` utilise le thème highlight.js **clair** (pas de palette
dédiée).
Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

+56 -19
View File
@@ -1551,6 +1551,17 @@
<nav class="help-nav" id="config-nav">
<div class="help-nav-header">
<div class="help-nav-title" data-i18n="config.title">Configuration</div>
<button
class="help-toc-close"
id="config-toc-close"
data-i18n-attr="aria-label:config.toc_close"
aria-label="Fermer le sommaire"
>
<i
data-lucide="x"
style="width: 16px; height: 16px"
></i>
</button>
</div>
<div class="help-nav-search-wrap">
<input
@@ -1649,6 +1660,27 @@
<span class="profile-hint" data-i18n="config.avatar_hint">PNG, JPG ou WEBP — image carrée recadrée et réduite à 256 px.</span>
</div>
</div>
<span class="profile-avatar-label" data-i18n="config.avatar_presets_label">Avatars prédéfinis</span>
<div
class="profile-avatar-presets"
id="profile-avatar-presets"
role="radiogroup"
data-i18n-attr="aria-label:config.avatar_presets_label"
aria-label="Avatars prédéfinis"
>
<button type="button" class="profile-avatar-preset" data-avatar="Chat.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/Chat.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="chien.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/chien.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="elephan.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/elephan.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="hibou.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/hibou.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="koala.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/koala.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="lapin.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/lapin.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="lion.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/lion.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="ours.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/ours.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="penda.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/penda.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="pingouin.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/pingouin.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="raton.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/raton.jpg" alt="" loading="lazy" /></button>
<button type="button" class="profile-avatar-preset" data-avatar="tigre.jpg" role="radio" aria-checked="false"><img src="/static/icons/avatar/tigre.jpg" alt="" loading="lazy" /></button>
</div>
<p class="profile-avatar-error hidden" id="profile-avatar-error" role="alert"></p>
</div>
<div class="profile-field">
@@ -2146,7 +2178,6 @@
>Nombre max de tokens élargis par préfixe (10-200)</span
>
</div>
</section>
<div class="config-actions-row">
<button class="config-btn-save"
id="cfg-save-backend" data-i18n="help.shortcut_save">
@@ -2509,7 +2540,7 @@
<!-- Partages publics -->
<section class="config-section help-section" id="cfg-partages-publics">
<h2 id="cfg-partages-publics">📤 Partages publics</h2>
<h2>📤 Partages publics</h2>
<p class="config-description">
Liens de partage publics pour des documents
(lecture seule, sans authentification).
@@ -2517,23 +2548,22 @@
<div id="shares-list"></div>
</section>
<!-- Plugins -->
<section
class="config-section help-section"
id="cfg-plugins"
>
<h2 data-i18n="config.section_plugins">🧩 Plugins</h2>
<p class="config-description" data-i18n="plugins.description">
Extend ObsiGate with custom renderers, search filters, and editor actions.
</p>
<div id="plugins-settings-container"></div>
</section>
<!-- Plugins -->
<section
class="config-section help-section"
id="cfg-plugins"
>
<h2 data-i18n="config.section_plugins">🧩 Plugins</h2>
<p class="config-description" data-i18n="plugins.description">
Extend ObsiGate with custom renderers, search filters, and editor actions.
</p>
</section>
<!-- À propos -->
<section
class="config-section help-section"
id="cfg-about"
>
<!-- À propos -->
<section
class="config-section help-section"
id="cfg-about"
>
<h2 data-i18n="auto.a3319169">📦 À propos</h2>
<div
id="config-about"
@@ -4635,6 +4665,13 @@
chercher) ; les actions de modification demandent une
confirmation avec aperçu des changements.
</li>
<li data-i18n="help.assistant_agent_run">
Les actions s'affichent dans le fil : l'assistant regroupe les
modifications en une seule approbation (« Tout approuver ») et
met à jour l'arborescence et le document ouvert dès qu'elles
sont appliquées. Le bouton d'envoi devient « Stop » pour
interrompre l'exécution à tout moment.
</li>
<li data-i18n="help.assistant_resize">
Le bord gauche du panneau est redimensionnable ; la largeur est
mémorisée.
@@ -5614,7 +5651,7 @@ curl -X POST https://votre-serveur.com/webhook \
data-lucide="folder-open"
style="width: 20px; height: 20px"
></i>
<span class="mt-label">settings.search</span>
<span class="mt-label" data-i18n="settings.explorer">Explorateur</span>
</button>
<button
class="mt-btn"
+195 -41
View File
@@ -52,6 +52,22 @@ const PANEL_MIN_WIDTH = 320;
const PANEL_MAX_WIDTH = 1000;
const PANEL_WIDTH_KEY = 'obsigate-bookslm-width';
// BUG-076 — Tools that mutate the vault: their execution must refresh the
// sidebar tree immediately (the watcher SSE is delayed and directory-only
// changes may not produce an index event).
const MUTATING_TOOLS = new Set([
'create_file', 'create_directory', 'edit_file', 'append_to_file',
'rename_file', 'rename_directory', 'move_path', 'replace_in_files',
'delete_file', 'delete_directory', 'restore_backup',
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
]);
// Subset carrying a concrete `vault` + `path`: the displayed document is
// reloaded from disk so an open viewer/editor reflects the agent's write.
const FILE_WRITE_TOOLS = new Set([
'edit_file', 'append_to_file', 'create_file', 'restore_backup',
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
]);
/**
* BUG-059 — Is this pointer press a scrollbar drag (and only that)?
*
@@ -895,15 +911,14 @@ class BooksLM {
}
/**
* #93 — A write tool just modified a vault file. Notify the UI so the
* displayed document is reloaded from disk: otherwise the read view keeps the
* stale content, and an open editor buffer autosaves the old text back over
* the assistant's change (see utils.reloadExternalWrite).
* #93 / BUG-076 — A write tool just modified a vault file. Notify the UI so
* the displayed document is reloaded from disk: otherwise the read view keeps
* the stale content, and an open editor buffer autosaves the old text back
* over the assistant's change (see utils.reloadExternalWrite).
*/
_notifyFileWritten(data) {
if (!data || data.ok === false) return;
const WRITE_TOOLS = ['edit_file', 'append_to_file', 'create_file', 'restore_backup'];
if (WRITE_TOOLS.indexOf(data.name) === -1) return;
if (!FILE_WRITE_TOOLS.has(data.name)) return;
const args = data.arguments || {};
if (!args.vault || !args.path) return;
window.dispatchEvent(
@@ -913,6 +928,24 @@ class BooksLM {
);
}
/**
* BUG-076 — Refresh the sidebar tree right after an agent mutation, without
* waiting for the (debounced) watcher SSE. Debounced so a batch of tool
* events (e.g. a folder plus its files) triggers a single refresh.
*/
_scheduleTreeRefresh() {
if (this._treeRefreshTimer) clearTimeout(this._treeRefreshTimer);
this._treeRefreshTimer = setTimeout(async () => {
this._treeRefreshTimer = null;
try {
const m = await import('./sidebar.js');
if (m && typeof m.refreshSidebarTreePreservingState === 'function') {
await m.refreshSidebarTreePreservingState();
}
} catch { /* tree refresh is best-effort */ }
}, 250);
}
// ── Rendering ───────────────────────────────────────────────────────
_render() {
@@ -1041,7 +1074,11 @@ class BooksLM {
}
});
panel.querySelector('.bookslm-btn-send').addEventListener('click', () => this._sendMessage());
panel.querySelector('.bookslm-btn-send').addEventListener('click', () => {
// BUG-077: the same button stops the run while a response streams.
if (this._isLoading) this._stopGeneration();
else this._sendMessage();
});
// Resizable panel (drag the left edge).
const resizeHandle = panel.querySelector('.bookslm-resize-handle');
@@ -2665,6 +2702,17 @@ class BooksLM {
return t('ai.tool_call', { name: call.name });
}
/**
* BUG-074 — number of *action* steps of a message (reasoning notes are not
* actions). Falls back to the total when the block holds only thoughts so a
* “0 étape” label can never appear.
*/
_actionStepCount(toolCalls) {
const list = toolCalls || [];
const actions = list.filter((c) => !(c.step && c.step.key === 'thought'));
return actions.length || list.length;
}
/** Chevron used by every collapsible block (▶ closed / ▼ open, via CSS). */
_chevron() {
const c = document.createElement('span');
@@ -2698,13 +2746,32 @@ class BooksLM {
dots.classList.add('bookslm-steps-dots');
summary.appendChild(dots);
}
const countKey = toolCalls.length > 1 ? 'ai.steps_count_plural' : 'ai.steps_count';
// BUG-074: the counter reflects *actions*, not reasoning notes, and the
// collapsed header also previews the first action so an “N étapes ▶” line
// is no longer an opaque title.
const actionCalls = toolCalls.filter((c) => !(c.step && c.step.key === 'thought'));
const count = this._actionStepCount(toolCalls);
const countKey = count > 1 ? 'ai.steps_count_plural' : 'ai.steps_count';
const label = document.createElement('span');
label.className = 'bookslm-steps-label';
label.textContent = t(countKey, { count: toolCalls.length });
label.textContent = t(countKey, { count });
summary.appendChild(label);
const first = actionCalls[0];
let preview = '';
if (first) {
preview = this._stepText(first);
const title = document.createElement('span');
title.className = 'bookslm-steps-title';
title.textContent = `— ${preview}`;
summary.appendChild(title);
}
summary.appendChild(this._chevron());
if (running) summary.setAttribute('aria-label', `${label.textContent} — ${t('ai.steps_running')}`);
if (running) {
summary.setAttribute(
'aria-label',
`${label.textContent}${preview ? ` — ${preview}` : ''} — ${t('ai.steps_running')}`,
);
}
wrap.appendChild(summary);
const body = document.createElement('div');
@@ -2804,8 +2871,11 @@ class BooksLM {
const conf = msg.confirmation;
const pending = conf.pending || {};
const error = pending.error || pending;
const tool = error.tool || 'action';
const args = error.arguments || {};
// BUG-075: the run batches every mutating call of the LLM turn into
// `pending.actions`; fall back to the single legacy `error` shape.
const actions = (Array.isArray(pending.actions) && pending.actions.length)
? pending.actions
: [{ id: error.id, tool: error.tool, arguments: error.arguments || {}, step: null }];
const card = document.createElement('div');
card.className = 'bookslm-action bookslm-confirm';
@@ -2814,23 +2884,48 @@ class BooksLM {
meta.className = 'bookslm-action-meta';
meta.innerHTML = '<span class="bookslm-action-icon">🔒</span>';
const textEl = document.createElement('span');
textEl.textContent = t('ai.tool_call', { name: tool }) + (args.path ? ` — ${args.path}` : '');
if (actions.length > 1) {
textEl.textContent = t('ai.confirm_actions', { count: actions.length });
} else {
const tool = actions[0].tool || 'action';
const args = actions[0].arguments || {};
textEl.textContent = t('ai.tool_call', { name: tool }) + (args.path ? ` — ${args.path}` : '');
}
meta.appendChild(textEl);
card.appendChild(meta);
const diffHost = document.createElement('div');
diffHost.className = 'bookslm-confirm-diff';
if (conf._diffHtml) {
diffHost.innerHTML = conf._diffHtml;
} else if (!conf._diffLoading) {
conf._diffLoading = true;
this._fillConfirmationDiff(args, conf).then(() => this._renderMessages());
// One row per action with a diff preview when it writes file content.
const hasContent = (a) => {
const args = a.arguments || {};
return typeof args.content === 'string' && args.vault && args.path;
};
if (actions.length > 1) {
const list = document.createElement('div');
list.className = 'bookslm-confirm-actions';
for (const action of actions) {
const args = action.arguments || {};
const row = document.createElement('details');
row.className = 'bookslm-confirm-action';
const summary = document.createElement('summary');
const text = document.createElement('span');
text.textContent = this._stepText({ step: action.step, name: action.tool })
+ (args.path ? ` — ${args.path}` : '');
summary.appendChild(text);
if (hasContent(action)) summary.appendChild(this._chevron());
row.appendChild(summary);
if (hasContent(action)) row.appendChild(this._diffHost(action, args));
list.appendChild(row);
}
card.appendChild(list);
} else if (hasContent(actions[0])) {
card.appendChild(this._diffHost(conf, actions[0].arguments || {}));
}
card.appendChild(diffHost);
const apply = document.createElement('button');
apply.className = 'bookslm-action-apply';
apply.textContent = t('ai.action_apply');
apply.textContent = actions.length > 1
? t('ai.action_apply_all', { count: actions.length })
: t('ai.action_apply');
apply.addEventListener('click', async () => {
apply.disabled = true;
apply.textContent = t('ai.action_applying');
@@ -2839,7 +2934,9 @@ class BooksLM {
apply.textContent = t('ai.action_applied');
} catch (e) {
apply.disabled = false;
apply.textContent = t('ai.action_apply');
apply.textContent = actions.length > 1
? t('ai.action_apply_all', { count: actions.length })
: t('ai.action_apply');
showToast(t('ai.action_failed', { error: e.message }), 'error');
}
});
@@ -2847,6 +2944,19 @@ class BooksLM {
return card;
}
/** Diff host for one confirmation action (lazy LCS diff, cached on the host). */
_diffHost(host, args) {
const diffHost = document.createElement('div');
diffHost.className = 'bookslm-confirm-diff';
if (host._diffHtml) {
diffHost.innerHTML = host._diffHtml;
} else if (!host._diffLoading) {
host._diffLoading = true;
this._fillConfirmationDiff(args, host).then(() => this._renderMessages());
}
return diffHost;
}
async _fillConfirmationDiff(args, conf) {
const proposed = args.content;
if (typeof proposed !== 'string' || !args.vault || !args.path) return;
@@ -2935,22 +3045,20 @@ class BooksLM {
if (!conf || conf._applying) return;
conf._applying = true;
// BUG-075: one click applies the whole pending batch AND authorizes the
// remaining actions of the same run (no second confirmation card).
const payload = {
...(msg.payload || {}),
confirm: conf.pending,
confirm_messages: conf.messages,
confirm_all: true,
};
this._isLoading = true;
this._abortCtrl = new AbortController();
const sendBtn = this._panel && this._panel.querySelector('.bookslm-btn-send');
if (sendBtn) sendBtn.disabled = true;
this._syncSendButton();
this._setActivity('working', t('ai.activity_streaming'));
// BUG-046: carry the original request payload over to the continuation.
// When an applied tool failed and the model re-proposed a confirmation,
// the continuation used to have `payload: null`: the second "Appliquer"
// then resumed without `message` → 422 → "[object Object]" toast.
const continuation = { role: 'assistant', content: '', sources: [], toolCalls: [], confirmation: null, payload: msg.payload ? { ...msg.payload } : null };
try {
let resp = await this._postChat(payload);
if (resp.status === 401 && AuthManager._authEnabled) {
@@ -2960,21 +3068,65 @@ class BooksLM {
if (!resp.ok) {
throw await this._responseError(resp);
}
// The confirmation is resolved: drop the card and show the continuation.
// The confirmation is resolved: drop the card and keep streaming into the
// SAME message, so the steps block accumulates the whole exchange
// instead of fragmenting into a new “1 étape” message per approval
// (BUG-074). BUG-046 payload carry-over is inherent: `msg.payload` stays.
msg.confirmation = null;
this._messages.push(continuation);
this._renderMessages({ anchor: true });
await this._streamResponse(resp, continuation, payload);
await this._streamResponse(resp, msg, payload);
} catch (e) {
if (e.name === 'AbortError') {
this._markStopped(msg);
} else {
throw e;
}
} finally {
conf._applying = false;
this._isLoading = false;
this._abortCtrl = null;
if (sendBtn) sendBtn.disabled = false;
this._syncSendButton();
this._renderMessages();
this._saveHistory();
}
}
/** BUG-077 — abort the in-flight agent/chat request. */
_stopGeneration() {
if (this._abortCtrl) {
try {
this._abortCtrl.abort();
} catch { /* already aborted */ }
}
}
/** Append a visible « stopped by the user » marker to an assistant message. */
_markStopped(msg) {
const marker = `⏹ ${t('ai.stopped')}`;
const content = String(msg.content || '');
if (!content.includes(marker)) {
msg.content = content ? `${content}\n\n${marker}` : marker;
}
this._setActivity('idle');
}
/**
* BUG-077 — the composer's round button doubles as a Stop control while a
* response streams: a new send is already blocked by `_isLoading`, so the
* button switches to a stop icon instead of being disabled.
*/
_syncSendButton() {
const btn = this._panel && this._panel.querySelector('.bookslm-btn-send');
if (!btn) return;
const loading = !!this._isLoading;
btn.classList.toggle('is-stopping', loading);
btn.title = loading ? t('ai.stop') : t('bookslm.send');
btn.setAttribute('aria-label', btn.title);
const icon = btn.querySelector('i');
if (icon) icon.setAttribute('data-lucide', loading ? 'square' : 'arrow-up');
if (typeof safeCreateIcons === 'function') safeCreateIcons();
}
// ── Messaging ───────────────────────────────────────────────────────
async _sendMessage() {
@@ -3053,10 +3205,8 @@ class BooksLM {
this._isLoading = true;
this._renderMessages({ anchor: true, instant: true });
const sendBtn = this._panel.querySelector('.bookslm-btn-send');
if (sendBtn) sendBtn.disabled = true;
this._abortCtrl = new AbortController();
this._syncSendButton();
this._setActivity('working', t('ai.activity_sending'));
try {
@@ -3085,17 +3235,17 @@ class BooksLM {
}
this._setActivity('done', t('ai.activity_done'));
} catch (e) {
if (e.name !== 'AbortError') {
if (e.name === 'AbortError') {
this._markStopped(assistantMsg);
} else {
assistantMsg.content = `⚠ Error: ${e.message}`;
console.warn('AI assistant chat error:', e);
this._setActivity('error', t('ai.activity_error'));
} else {
this._setActivity('idle');
}
}
this._isLoading = false;
if (sendBtn) sendBtn.disabled = false;
this._syncSendButton();
this._abortCtrl = null;
this._renderMessages();
this._saveHistory();
@@ -3155,6 +3305,10 @@ class BooksLM {
});
// #93 — a vault write must be reflected in the displayed document.
this._notifyFileWritten(data);
// BUG-076 — reflect tree changes (create/delete/rename…) right away.
if (data.ok !== false && MUTATING_TOOLS.has(data.name)) {
this._scheduleTreeRefresh();
}
this._setActivity('working', t('ai.activity_tool', { name: data.name }));
return;
}
+94 -6
View File
@@ -767,10 +767,15 @@ function initConfigModal() {
openBtn.addEventListener("click", async () => {
modal.classList.add("active");
closeHeaderMenu();
// BUG-071: reset the TOC to the CSS default (mobile: hidden, desktop:
// visible) like the help modal does on open.
// BUG-071/#114: reset the TOC to the CSS default (mobile: hidden drawer,
// desktop: visible sidebar) like the help modal does on open. Clear the
// stale inline display and the drawer-open class so a previous mobile
// session cannot leave the nav stuck open.
var configNavOnOpen = document.getElementById("config-nav");
if (configNavOnOpen) configNavOnOpen.style.display = '';
modal.classList.remove("config-toc-open");
var configHamburgerOnOpen = document.getElementById("config-hamburger");
if (configHamburgerOnOpen) configHamburgerOnOpen.classList.remove("active");
renderConfigFilters();
loadConfigFields();
loadDiagnostics();
@@ -786,6 +791,12 @@ function initConfigModal() {
closeBtn.addEventListener("click", closeConfigModal);
modal.addEventListener("click", (e) => {
if (e.target === modal) {
// #114: a tap on the backdrop (or outside the drawer) first closes the
// TOC drawer; only a second tap closes the whole modal.
if (modal.classList.contains("config-toc-open")) {
_setConfigNav(false);
return;
}
closeConfigModal();
}
});
@@ -890,17 +901,19 @@ function initConfigModal() {
});
}
// BUG-071: mobile table of contents. #config-nav shares the .help-nav
// BUG-071/#114: mobile table of contents. #config-nav shares the .help-nav
// rule that hides it below 768px, but — unlike the help modal — the config
// modal had no toggle to reveal it, leaving mobile users with no way to
// reach a section. The header hamburger opens it as a top block; picking
// a section smooth-scrolls inside the modal and collapses it on mobile.
// reach a section. The header hamburger opens it as a left slide-over
// drawer (backdrop via .config-toc-open on the modal); picking a section
// smooth-scrolls inside the modal and collapses it on mobile.
var configNav = document.getElementById("config-nav");
var configHamburger = document.getElementById("config-hamburger");
function _isConfigMobile() { return window.innerWidth <= 768; }
function _setConfigNav(open) {
if (!configNav) return;
configNav.style.display = open ? "flex" : "none";
modal.classList.toggle("config-toc-open", !!open && _isConfigMobile());
if (configHamburger) configHamburger.classList.toggle("active", !!open);
}
if (configHamburger) {
@@ -910,6 +923,14 @@ function initConfigModal() {
_setConfigNav(hidden);
});
}
// #114: close button inside the drawer header.
var configTocClose = document.getElementById("config-toc-close");
if (configTocClose) {
configTocClose.addEventListener("click", function(e) {
e.stopPropagation();
_setConfigNav(false);
});
}
if (configNav) {
configNav.querySelectorAll(".help-nav-link").forEach(function(a) {
a.addEventListener("click", function(e) {
@@ -930,6 +951,11 @@ function initConfigModal() {
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.classList.contains("active")) {
// #114: Escape closes the TOC drawer first, then the modal.
if (modal.classList.contains("config-toc-open")) {
_setConfigNav(false);
return;
}
closeConfigModal();
}
});
@@ -949,7 +975,13 @@ function initConfigModal() {
function closeConfigModal() {
const modal = document.getElementById("config-modal");
if (modal) modal.classList.remove("active");
if (modal) {
modal.classList.remove("active");
// #114: never leave the drawer-open class (backdrop) behind.
modal.classList.remove("config-toc-open");
const nav = document.getElementById("config-nav");
if (nav) nav.style.display = '';
}
}
// --- Config field helpers ---
@@ -2502,6 +2534,7 @@ function initProfile() {
var overlayBtn = document.getElementById('profile-avatar-overlay');
var previewBox = document.getElementById('profile-avatar-preview');
var removeBtn = document.getElementById('profile-avatar-remove');
var presetsBox = document.getElementById('profile-avatar-presets');
if (!avatarField || !avatarInput || !chooseBtn) return;
// The avatar only exists on a real account — hide it when auth is off.
@@ -2522,6 +2555,59 @@ function initProfile() {
})
.catch(function () { /* non-bloquant */ });
// ── Preset avatars (#117) ────────────────────────────────────────
var PRESET_STORAGE_KEY = 'obsigate-avatar-preset';
function markActivePreset(name) {
if (!presetsBox) return;
presetsBox.querySelectorAll('.profile-avatar-preset').forEach(function (btn) {
var on = !!name && btn.getAttribute('data-avatar') === name;
btn.classList.toggle('active', on);
btn.setAttribute('aria-checked', on ? 'true' : 'false');
});
}
function clearActivePreset() {
try { localStorage.removeItem(PRESET_STORAGE_KEY); } catch (e) { /* ignore */ }
markActivePreset(null);
}
function rememberActivePreset(name) {
try { localStorage.setItem(PRESET_STORAGE_KEY, name); } catch (e) { /* ignore */ }
markActivePreset(name);
}
var savedPreset = null;
try { savedPreset = localStorage.getItem(PRESET_STORAGE_KEY); } catch (e) { /* ignore */ }
markActivePreset(savedPreset);
if (presetsBox) {
presetsBox.querySelectorAll('.profile-avatar-preset').forEach(function (btn) {
btn.addEventListener('click', async function () {
var preset = btn.getAttribute('data-avatar');
if (!preset) return;
_profileAvatarError(null);
var all = presetsBox.querySelectorAll('.profile-avatar-preset');
all.forEach(function (b) { b.disabled = true; });
try {
// Load the bundled image, then reuse the upload pipeline (center-crop
// + 256 px JPEG) so it stores exactly like an imported picture.
var res = await fetch('/static/icons/avatar/' + encodeURIComponent(preset), { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status);
var blob = await res.blob();
var dataUrl = await _resizeAvatarFile(blob);
var user = await _patchProfileAvatar(dataUrl);
AuthManager.updateCachedUser(user);
_renderProfileAvatar(user.avatar || dataUrl);
AuthManager.renderUserSection();
rememberActivePreset(preset);
showToast(t('config.avatar_updated'), 'success');
} catch (err) {
_profileAvatarError('config.avatar_upload_failed');
console.error('Avatar preset failed:', err);
} finally {
all.forEach(function (b) { b.disabled = false; });
}
});
});
}
function openPicker() {
_profileAvatarError(null);
avatarInput.click();
@@ -2555,6 +2641,7 @@ function initProfile() {
AuthManager.updateCachedUser(user);
_renderProfileAvatar(user.avatar || dataUrl);
AuthManager.renderUserSection();
clearActivePreset(); // an imported photo is not a preset
showToast(t('config.avatar_updated'), 'success');
} catch (err) {
_profileAvatarError('config.avatar_upload_failed');
@@ -2573,6 +2660,7 @@ function initProfile() {
AuthManager.updateCachedUser(user);
_renderProfileAvatar(null);
AuthManager.renderUserSection();
clearActivePreset();
showToast(t('config.avatar_removed'), 'success');
} catch (err) {
_profileAvatarError('config.avatar_upload_failed');
+11
View File
@@ -598,6 +598,17 @@ function applyTheme(themeKey, mode) {
// Notify other components of theme change
try {
root.setAttribute('data-theme', mode);
// Syntax highlighting follows the light/dark mode (BUG-078): the theme
// *key* is not a mode, so toggling the sheets by key used to disable both
// and strip every code block of its colours. Sepia/high-contrast reuse the
// light palette.
var darkSheet = document.getElementById('hljs-theme-dark');
var lightSheet = document.getElementById('hljs-theme-light');
if (darkSheet && lightSheet) {
var isDark = mode === 'dark';
darkSheet.disabled = !isDark;
lightSheet.disabled = isDark;
}
document.dispatchEvent(new CustomEvent('themechange', { detail: { theme: themeKey, mode: mode } }));
} catch(e) {}
}
+22 -10
View File
@@ -137,14 +137,26 @@ export const RightSidebarManager = {
// ---------------------------------------------------------------------------
// Theme
// ---------------------------------------------------------------------------
const _THEME_MODES = ["dark", "light", "high-contrast", "sepia"];
export function initTheme() {
const saved = localStorage.getItem("obsigate-theme") || "dark";
applyTheme(saved);
// The theme engine (themes.js) owns the CSS variables and the theme *key*;
// the <html data-theme> attribute must carry the *mode* (dark/light/…). Read
// the persisted mode here so the first paint and the highlight.js stylesheet
// are right before the async theme init runs (BUG-078).
let mode = "dark";
try { mode = localStorage.getItem("obsigate-theme-mode") || "dark"; } catch (e) { /* ignore */ }
applyTheme(mode);
}
export function applyTheme(theme) {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("obsigate-theme", theme);
let mode = theme;
if (_THEME_MODES.indexOf(mode) === -1) {
// Callers may pass a theme key (legacy): fall back to the persisted mode.
try { mode = localStorage.getItem("obsigate-theme-mode") || "dark"; } catch (e) { mode = "dark"; }
}
const isDark = mode === "dark";
document.documentElement.setAttribute("data-theme", mode);
// Update theme button icon and label
const themeBtn = document.getElementById("theme-toggle");
@@ -152,23 +164,23 @@ export function applyTheme(theme) {
if (themeBtn && themeLabel) {
const icon = themeBtn.querySelector("i");
if (icon) {
icon.setAttribute("data-lucide", theme === "dark" ? "moon" : "sun");
icon.setAttribute("data-lucide", isDark ? "moon" : "sun");
}
themeLabel.textContent = theme === "dark" ? t('theme.dark') : t('theme.light');
themeLabel.textContent = isDark ? t('theme.dark') : t('theme.light');
safeCreateIcons();
}
// Swap highlight.js theme
// Swap highlight.js theme — keyed on the mode, not the theme key (BUG-078).
const darkSheet = document.getElementById("hljs-theme-dark");
const lightSheet = document.getElementById("hljs-theme-light");
if (darkSheet && lightSheet) {
darkSheet.disabled = theme !== "dark";
lightSheet.disabled = theme !== "light";
darkSheet.disabled = !isDark;
lightSheet.disabled = isDark;
}
// Update Mermaid theme for newly rendered diagrams
import('./mermaid-viewer.js').then(function(m) {
m.updateMermaidTheme(theme === 'dark');
m.updateMermaidTheme(isDark);
}).catch(function() {});
}
+116 -1
View File
@@ -995,6 +995,110 @@ export function renderVideoViewer(area, data) {
}
// ── Excel .xlsx — sheet tabs + editable cells ─────────────────────────────
// Cells are contenteditable; edits are collected per sheet and sent to
// PUT /api/file/{vault}/xlsx/save. Formula cells show their text and are
// saved back as formulas (no client-side recalculation — ceiling accepted).
function renderXlsxViewer(area, data) {
const sheets = data.xlsx_sheets || [];
const tabs = sheets.length > 1
? `<div class="xlsx-tabs">${sheets.map((s, i) =>
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}">${escapeHtml(s.name)}</button>`
).join("")}</div>`
: "";
const panels = sheets.map((s, i) =>
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${s.html}</div>`
).join("");
area.innerHTML = `
<div class="xlsx-viewer">
<div class="xlsx-toolbar">
${tabs}
<span class="xlsx-toolbar-actions">
<button class="btn-action" id="xlsx-save-btn" disabled>${t("common.save")}</button>
<button class="btn-action" id="xlsx-download-btn">
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
</button>
</span>
</div>
<div class="xlsx-panels">${panels}</div>
</div>`;
const saveBtn = area.querySelector("#xlsx-save-btn");
const panelEls = [...area.querySelectorAll(".xlsx-panel")];
const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length;
const refreshSaveState = () => { saveBtn.disabled = dirtyCount() === 0; };
// Editable cells: Enter blurs, Escape reverts, paste stays single-line.
area.querySelectorAll(".xlsx-table td").forEach((td) => {
td.contentEditable = "true";
td.spellcheck = false;
td.dataset.orig = td.textContent;
td.addEventListener("input", () => {
td.classList.add("xlsx-dirty");
refreshSaveState();
});
td.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); td.blur(); }
if (e.key === "Escape") {
td.textContent = td.dataset.orig;
td.classList.remove("xlsx-dirty");
refreshSaveState();
}
});
td.addEventListener("paste", (e) => {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData("text").replace(/\r?\n/g, " ");
document.execCommand("insertText", false, text);
});
});
area.querySelectorAll(".xlsx-tab").forEach((tab) => {
tab.addEventListener("click", () => {
const idx = tab.dataset.sheet;
area.querySelectorAll(".xlsx-tab").forEach((x) => x.classList.toggle("active", x === tab));
panelEls.forEach((p) => { p.style.display = p.dataset.sheet === idx ? "" : "none"; });
});
});
saveBtn.addEventListener("click", async () => {
// One PUT per sheet (dirty cells can span tabs before a save).
const jobs = panelEls
.map((panel) => {
const cells = {};
panel.querySelectorAll("td.xlsx-dirty").forEach((td) => { cells[td.dataset.cell] = td.textContent; });
return { sheet: sheets[Number(panel.dataset.sheet)].name, cells };
})
.filter((job) => Object.keys(job.cells).length);
if (!jobs.length) return;
saveBtn.disabled = true;
try {
for (const job of jobs) {
await api(`/api/file/${encodeURIComponent(data.vault)}/xlsx/save?path=${encodeURIComponent(data.path)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(job),
});
}
area.querySelectorAll("td.xlsx-dirty").forEach((td) => {
td.classList.remove("xlsx-dirty");
td.dataset.orig = td.textContent;
});
refreshSaveState();
showToast(t("editor.saved"), "success");
} catch (err) {
refreshSaveState();
showToast(`${t("editor.save_error")}: ${err.message || err}`, "error");
}
});
area.querySelector("#xlsx-download-btn").addEventListener("click", () => {
window.open(`/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`, "_blank");
});
safeCreateIcons();
}
export function renderFile(data) {
// #93 — An inline edition session (#editor-container mounted in the content
// area) is destroyed by this very re-render: release it first so the editor
@@ -1058,6 +1162,12 @@ export function renderFile(data) {
return;
}
// Handle Excel .xlsx — editable table view (display / edit / download)
if (data.is_xlsx) {
renderXlsxViewer(area, data);
return;
}
// Handle Excalidraw — render in iframe editor
if (data.is_excalidraw) {
renderExcalidraw(area, data, data.vault, data.path);
@@ -1418,7 +1528,12 @@ export function renderFile(data) {
// Assemble
area.innerHTML = "";
area.appendChild(breadcrumb);
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv, el("div", { class: "file-actions" }, fileActions)]));
area.appendChild(el("div", { class: "file-header" }, [el("div", { class: "file-title" }, [document.createTextNode(data.title)]), tagsDiv]));
// #115 — the action bar is a direct child of the scroll container (wrapped in
// `.file-toolbar`) so it can stay pinned while long documents scroll; nested
// inside `.file-header` it would stop sticking as soon as the header left the
// viewport.
area.appendChild(el("div", { class: "file-toolbar" }, [el("div", { class: "file-actions" }, fileActions)]));
if (fmSection) area.appendChild(fmSection);
area.appendChild(mdDiv);
area.appendChild(rawDiv);
+9 -6
View File
@@ -80,12 +80,16 @@
"admin.users_title": "User management",
"ai.action_applied": "Applied ✓",
"ai.action_apply": "Apply",
"ai.action_apply_all": "Approve all ({count})",
"ai.action_applying": "Applying…",
"ai.action_create_dir": "Create folder {path}",
"ai.action_create_file": "Create file {path}",
"ai.action_created_dir": "Folder created: {path}",
"ai.action_created_file": "File created: {path}",
"ai.action_failed": "Action failed: {error}",
"ai.confirm_actions": "{count} actions to approve",
"ai.stop": "Stop the assistant",
"ai.stopped": "Run stopped.",
"ai.agent_mode_off": "Agent mode off (read/search + actions)",
"ai.agent_mode_on": "Agent mode on (read/search tools + actions)",
"ai.casual": "Casual tone",
@@ -382,6 +386,7 @@
"config.key_deleted": "Key deleted:",
"config.avatar_label": "Profile picture",
"config.avatar_hint": "PNG, JPG or WEBP — square image cropped and resized to 256 px. Shown in the sidebar.",
"config.avatar_presets_label": "Or pick a preset avatar",
"config.avatar_choose": "Choose an image",
"config.avatar_remove": "Remove picture",
"config.avatar_updated": "Profile picture updated",
@@ -578,6 +583,7 @@
"config.test": "Test",
"config.timeout_label": "Search timeout (ms)",
"config.title": "Settings",
"config.toc_close": "Close contents",
"config.toc_toggle": "Show contents",
"config.title_boost": "Title boost",
"config.title_boost_hint": "Relevance multiplier for title matches",
@@ -1351,6 +1357,7 @@
"help.assistant_links": "Cited files and paths are links: a bare filename copies the name to the clipboard, a folder is revealed in the tree, and a file path opens it in the viewer.",
"help.assistant_sessions": "The header history icon lists past sessions (reopen or delete); “+” starts a new conversation.",
"help.assistant_agent": "The \"agent mode\" button enables tools (read, list, search); modifying actions require confirmation with a change preview.",
"help.assistant_agent_run": "Actions appear in the thread: the assistant groups modifications into a single approval (\"Approve all\") and refreshes the file tree and the open document as soon as they are applied. The send button becomes \"Stop\" to interrupt the run at any time.",
"help.assistant_resize": "The left edge of the panel is resizable; the width is remembered.",
"help.assistant_at": "Type @ to attach a file or directory to the context, or to attach an image from a directory.",
"help.assistant_slash": "Type / to run a skill (research, summary, correction, plan…) or an admin command (/help, /providers, /model, /keys).",
@@ -1631,25 +1638,21 @@
"search.whole_word": "Whole word",
"settings.about": "📦 About",
"settings.ai": "🤖 AI",
"settings.backend": "⚙️ Backend",
"settings.backend_hint": "These settings are saved on the server. Some require a restart or reindexing.",
"settings.backend_section": "Backend Settings",
"settings.client_label": "These settings apply immediately on the client side.",
"settings.diagnostics": "🩺 Diagnostics",
"settings.explorer": "Files",
"settings.history_count_desc": "Between 5 and 100 files (saved on server)",
"settings.history_count_label": "Files in history",
"settings.history_section": "Recent History",
"settings.no_restart_badge": "No restart needed",
"settings.profile": "👤 Profile",
"settings.reindex": "Force reindex",
"settings.restart_badge": "Restart required",
"settings.save": "Save",
"settings.search": "Search",
"settings.tabs": "Tabs",
"settings.search_placeholder": "Search...",
"settings.sync": "🔄 Sync",
"settings.tabs": "Tabs",
"settings.themes": "🎨 Themes",
"settings.plugins": "🧩 Plugins",
"share.copied": "Link copied!",
"share.copy_link": "Copy link",
"share.create": "Create share link",
+9 -6
View File
@@ -80,12 +80,16 @@
"admin.users_title": "Gestion utilisateurs",
"ai.action_applied": "Appliqué ✓",
"ai.action_apply": "Appliquer",
"ai.action_apply_all": "Tout approuver ({count})",
"ai.action_applying": "Application…",
"ai.action_create_dir": "Créer le dossier {path}",
"ai.action_create_file": "Créer le fichier {path}",
"ai.action_created_dir": "Dossier créé : {path}",
"ai.action_created_file": "Fichier créé : {path}",
"ai.action_failed": "Échec de l'action : {error}",
"ai.confirm_actions": "{count} actions à approuver",
"ai.stop": "Arrêter l'assistant",
"ai.stopped": "Exécution arrêtée.",
"ai.agent_mode_off": "Mode agent désactivé (lecture/recherche + actions)",
"ai.agent_mode_on": "Mode agent activé (outils de lecture/recherche + actions)",
"ai.casual": "Ton décontracté",
@@ -382,6 +386,7 @@
"config.key_deleted": "Clé supprimée :",
"config.avatar_label": "Photo de profil",
"config.avatar_hint": "PNG, JPG ou WEBP — image carrée recadrée et réduite à 256 px. Apparaît dans la barre latérale.",
"config.avatar_presets_label": "Ou choisissez un avatar prédéfini",
"config.avatar_choose": "Choisir une image",
"config.avatar_remove": "Supprimer la photo",
"config.avatar_updated": "Photo de profil mise à jour",
@@ -578,6 +583,7 @@
"config.test": "Tester",
"config.timeout_label": "Timeout recherche (ms)",
"config.title": "Configuration",
"config.toc_close": "Fermer le sommaire",
"config.toc_toggle": "Afficher le sommaire",
"config.title_boost": "Boost titre",
"config.title_boost_hint": "Multiplicateur de pertinence pour les correspondances dans le titre",
@@ -1351,6 +1357,7 @@
"help.assistant_links": "Les fichiers et chemins cités sont des liens : un simple nom de fichier copie le nom dans le presse-papiers, un dossier est révélé dans l'arborescence, et un chemin de fichier l'ouvre dans le viewer.",
"help.assistant_sessions": "L'icône historique de l'en-tête liste les sessions passées (recharger ou supprimer) ; « + » démarre une nouvelle conversation.",
"help.assistant_agent": "Le bouton « mode agent » active les outils (lire, lister, chercher) ; les actions de modification demandent une confirmation avec aperçu des changements.",
"help.assistant_agent_run": "Les actions s'affichent dans le fil : l'assistant regroupe les modifications en une seule approbation (« Tout approuver ») et met à jour l'arborescence et le document ouvert dès qu'elles sont appliquées. Le bouton d'envoi devient « Stop » pour interrompre l'exécution à tout moment.",
"help.assistant_resize": "Le bord gauche du panneau est redimensionnable ; la largeur est mémorisée.",
"help.assistant_at": "Tapez @ pour joindre un fichier ou un répertoire au contexte, ou pour attacher une image d'un répertoire.",
"help.assistant_slash": "Tapez / pour lancer un skill (recherche, résumé, correction, plan…) ou une commande admin (/help, /providers, /model, /keys).",
@@ -1631,25 +1638,21 @@
"search.whole_word": "Mot entier",
"settings.about": "📦 À propos",
"settings.ai": "🤖 IA",
"settings.backend": "⚙️ Backend",
"settings.backend_hint": "Ces paramètres sont sauvegardés sur le serveur. Certains nécessitent un redémarrage ou une réindexation.",
"settings.backend_section": "Paramètres backend",
"settings.client_label": "Ces paramètres s'appliquent immédiatement côté client.",
"settings.diagnostics": "🩺 Diagnostics",
"settings.explorer": "Explorateur",
"settings.history_count_desc": "Entre 5 et 100 fichiers (sauvegarde sur le serveur)",
"settings.history_count_label": "Nombre de fichiers dans l'historique",
"settings.history_section": "Historique récent",
"settings.no_restart_badge": "Redémarrage non requis",
"settings.profile": "👤 Profil",
"settings.reindex": "Forcer réindexation",
"settings.restart_badge": "Redémarrage requis",
"settings.save": "Sauvegarder",
"settings.search": "Recherche",
"settings.tabs": "Tabs",
"settings.search_placeholder": "Rechercher...",
"settings.sync": "🔄 Synchronisation",
"settings.tabs": "Onglets",
"settings.themes": "🎨 Thèmes",
"settings.plugins": "🧩 Plugins",
"share.copied": "Lien copié !",
"share.copy_link": "Copier le lien",
"share.create": "Créer un lien de partage",
+7 -2
View File
@@ -38,7 +38,7 @@
}
})();
</script>
<link rel="stylesheet" href="/static/style.css?v=2">
<link rel="stylesheet" href="/static/style.css?v=3">
<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css" id="hljs-theme-dark">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css" id="hljs-theme-light" disabled>
@@ -871,8 +871,13 @@ const RightSidebarManager = {
group.forEach(function (b) { actionsDiv.appendChild(b); });
});
header.appendChild(actionsDiv);
area.appendChild(header);
// #115 — pinned action bar (direct child of the scroll container) so it
// stays visible while long documents scroll, as in the main viewer.
const toolbar = document.createElement("div");
toolbar.className = "file-toolbar";
toolbar.appendChild(actionsDiv);
area.appendChild(toolbar);
// Frontmatter — Accent Card
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
+398 -39
View File
@@ -1767,7 +1767,22 @@ select {
/* File header */
.file-header {
margin-bottom: 20px;
margin-bottom: 8px;
}
/* #115 — sticky document action bar: stays visible while long content scrolls.
A direct child of `.content-area` (the scroll container), opaque so the text
scrolling underneath never bleeds through. */
.file-toolbar {
position: sticky;
top: 0;
z-index: 30;
margin: 0 0 16px;
padding: 8px 0;
background: var(--bg-primary);
border-bottom: 1px solid var(--border);
}
.file-toolbar .file-actions {
margin-top: 0;
}
.file-title {
font-family: "JetBrains Mono", monospace;
@@ -2670,6 +2685,32 @@ select {
.profile-avatar-remove-btn { background: var(--danger-bg) !important; color: var(--danger) !important; border-color: var(--danger) !important; }
.profile-avatar-error { font-size: 0.7rem; color: var(--danger); margin-top: 8px; }
.profile-avatar-error.hidden { display: none; }
/* #117 — preset avatar gallery */
.profile-avatar-presets {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
margin-top: 10px;
max-width: 420px;
}
.profile-avatar-preset {
padding: 0;
margin: 0;
border: 2px solid var(--border);
border-radius: 50%;
overflow: hidden;
cursor: pointer;
background: var(--bg-secondary);
aspect-ratio: 1;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
}
.profile-avatar-preset img { width: 100%; height: 100%; object-fit: cover; display: block; }
.profile-avatar-preset:hover { border-color: var(--accent); transform: translateY(-1px); }
.profile-avatar-preset.active {
border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
}
.profile-avatar-preset:disabled { opacity: 0.6; cursor: progress; }
.config-save-btn {
padding: 6px 18px; font-size: 0.75rem; font-weight: 600;
background: var(--accent); color: #fff;
@@ -3164,6 +3205,23 @@ select {
margin-right: 8px;
}
.help-hamburger:hover { color: var(--accent); border-color: var(--accent); }
/* #114: close button for the mobile TOC drawer (hidden on desktop where the
nav is always visible). Scoped to #config-modal so the help modal — which
has no such button — is unaffected. */
.help-toc-close {
display: none;
width: 44px;
height: 44px;
padding: 0;
border: 1px solid var(--border);
border-radius: 6px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.help-nav {
width: 260px;
min-width: 260px;
@@ -4777,22 +4835,209 @@ body.resizing-v {
}
}
/* BUG-071: Configurations modal — mobile usability (viewport ≤ 768px).
#config-nav shares the .help-nav rule that hides it, but the config modal
had no toggle (unlike the help modal): the header hamburger
(#config-hamburger, same .help-hamburger treatment) reveals it as a
collapsible top block. Two-column grids and fixed-width add-rows are
stacked/wrapped so nothing overflows a 360px viewport. */
/* --- #114: Configurations modal — mobile UX refonte (viewport ≤ 768px).
Builds on BUG-071 (which stacked grids/wrapped rows): the TOC becomes a
left slide-over drawer with a backdrop, the modal goes full-screen with
100dvh, every interactive control gets a ≥44px touch target, inputs are
≥16px to stop iOS auto-zoom, and the backend Save row sticks to the
bottom of its scroll container. The drawer is opened by JS toggling
.config-toc-open on #config-modal (backdrop + nav visibility) plus an
inline display on #config-nav (kept for the display-reset contract). */
@media (max-width: 768px) {
/* TOC as a collapsible top block (JS toggles inline display flex/none,
which wins over the hiding rule); the list scrolls within a capped nav. */
#config-modal #config-nav {
/* Full-screen modal with dynamic viewport height (mobile browser chrome). */
#config-modal {
padding: 0;
align-items: stretch;
}
#config-modal .editor-container {
max-width: 100%;
width: 100%;
height: 100vh;
height: 100dvh;
max-height: 100vh;
max-height: 100dvh;
border-radius: 0;
border: none;
overflow: hidden;
}
/* Hamburger: 44px touch target (shared .help-hamburger rule in the help
modal media query also sets 44px; this scopes it to the config modal
in case rule order changes). */
#config-modal .help-hamburger {
display: inline-flex;
width: 44px;
height: 44px;
min-width: 44px;
}
/* Close button in the TOC drawer header. */
#config-modal .help-toc-close {
display: inline-flex;
align-items: center;
justify-content: center;
}
#config-modal .help-nav-header {
padding: 12px 12px 8px;
gap: 8px;
}
/* Close (X) in the modal header. */
#config-modal .editor-btn {
width: 44px;
height: 44px;
min-width: 44px;
}
/* Anti-zoom: ≥16px font on text fields inside the config modal. Never
target input[type=text] globally — it would clobber .mfa-code-input. */
#config-modal .config-input,
#config-modal .config-select,
#config-modal .help-nav-search,
#config-modal .profile-field .config-input,
#config-modal .profile-field .config-select {
min-height: 44px;
font-size: 16px;
}
#config-modal .config-input--num {
width: 100%;
text-align: left;
}
#config-modal .help-nav-search-wrap {
padding: 0 12px 10px;
}
#config-modal .help-search-clear {
min-width: 44px;
min-height: 44px;
}
#config-modal .help-nav-link {
min-height: 44px;
display: flex;
align-items: center;
box-sizing: border-box;
}
/* Sticky Save row inside #cfg-backend-settings (its scroll container is
#config-scroll.help-content, which is the overflow ancestor). */
#config-modal .config-actions-row {
position: sticky;
bottom: 0;
z-index: 5;
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 16px;
margin-bottom: 0;
padding: 10px 0 calc(10px + env(safe-area-inset-bottom, 0));
background: var(--bg-primary);
border-top: 1px solid var(--border);
}
#config-modal .config-actions-row .config-btn-save,
#config-modal .config-actions-row .config-btn-secondary,
#config-modal .config-actions-row .config-btn-primary,
#config-modal .config-actions-row .config-btn-danger {
flex: 1 1 auto;
width: 100%;
min-height: 44px;
box-sizing: border-box;
}
#config-modal .config-btn-save,
#config-modal .config-btn-secondary,
#config-modal .config-btn-primary,
#config-modal .config-btn-danger,
#config-modal .config-btn-add,
#config-modal .config-btn-sm,
#config-modal .mfa-link-btn,
#config-modal .theme-action-btn,
#config-modal .profile-avatar-actions .config-btn-secondary,
#config-modal .editor-btn {
min-height: 44px;
box-sizing: border-box;
}
#config-modal .config-btn-sm,
#config-modal .mfa-link-btn,
#config-modal .theme-action-btn {
min-width: 44px;
padding-left: 12px;
padding-right: 12px;
}
/* Profile avatar row: wrap instead of overflowing a 360px viewport. */
#config-modal .profile-avatar-row {
flex-wrap: wrap;
gap: 12px;
}
/* Preset avatars: 4 per row on narrow screens (44px touch targets). */
#config-modal .profile-avatar-presets {
grid-template-columns: repeat(4, minmax(0, 1fr));
max-width: 100%;
}
#config-modal .profile-form {
max-width: 100%;
}
/* MFA: one-column recovery list, wrapping action rows, full-width code
input with a readable letter-spacing (12px overflows 360px). */
#config-modal .mfa-recovery-list {
grid-template-columns: 1fr;
gap: 6px;
}
#config-modal .mfa-verify-section,
#config-modal .mfa-recovery-actions,
#config-modal .mfa-disable-actions {
flex-wrap: wrap;
}
#config-modal .mfa-verify-section .config-input,
#config-modal .mfa-verify-section .config-btn-primary {
flex: 1 1 100%;
width: 100%;
min-width: 0;
max-width: 100%;
border-right: none;
border-bottom: 1px solid var(--border);
max-height: 46vh;
box-sizing: border-box;
}
#config-modal .mfa-recovery-actions .config-btn-secondary,
#config-modal .mfa-disable-actions .config-btn-danger {
flex: 1 1 auto;
min-height: 44px;
box-sizing: border-box;
}
#config-modal .mfa-code-input {
width: 100%;
max-width: 320px;
min-height: 52px;
font-size: 24px;
letter-spacing: 6px;
}
#config-modal .mfa-secret-code {
display: block;
word-break: break-all;
overflow-wrap: anywhere;
}
#config-modal .mfa-code-input-group {
flex-wrap: wrap;
justify-content: flex-start;
}
/* WebAuthn key rows: wrap instead of clipping the remove button. */
#config-modal .webauthn-key-item {
flex-wrap: wrap;
gap: 8px;
}
#config-modal .webauthn-key-label {
flex: 1 1 100%;
min-width: 0;
}
#config-modal .webauthn-key-item .config-btn-sm {
min-height: 44px;
min-width: 44px;
}
#config-modal .hidden-files-add-row {
flex-wrap: wrap;
}
#config-modal .hidden-files-add-row .config-input {
flex: 1 1 100%;
max-width: none;
min-width: 0;
}
#config-modal .config-diag-row {
flex-wrap: wrap;
gap: 4px 8px;
}
/* AI keys sticky footer: clear the mobile toolbar + home indicator. */
#config-modal .ai-keys-footer {
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0));
bottom: 0;
}
/* Two-column grids → single column. */
#config-modal .ai-default-grid,
@@ -4851,6 +5096,61 @@ body.resizing-v {
min-width: 0;
overflow-wrap: anywhere;
}
/* Drawer: left slide-over above a dimming backdrop. Higher specificity
than the generic .help-nav rules (body .help-nav / .help-nav) that hide
the nav on mobile. JS toggles .config-toc-open on #config-modal. */
#config-modal #config-nav {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(320px, 88vw);
min-width: 0;
max-width: 88vw;
max-height: 100dvh;
height: 100%;
box-sizing: border-box;
z-index: 40;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
border-bottom: none;
box-shadow: none;
backdrop-filter: none;
display: none;
overflow: hidden;
animation: config-toc-slide-in 200ms ease-out;
}
#config-modal.config-toc-open #config-nav {
display: flex;
max-height: 100dvh;
}
#config-modal .help-nav-list {
padding-bottom: calc(20px + env(safe-area-inset-bottom, 0));
}
/* Backdrop: covers the full modal, sits above the content (z-index auto)
and below the drawer (z-index 40). Clicks on the pseudo-element are
attributed to #config-modal, so the existing e.target === modal handler
closes the drawer first; it also blocks taps reaching the content. */
#config-modal.config-toc-open::before {
content: "";
position: absolute;
inset: 0;
z-index: 35;
background: rgba(0, 0, 0, 0.45);
}
@keyframes config-toc-slide-in {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
/* Content area gets a bottom pad so the sticky Save row never covers the
last controls while the virtual keyboard is open. */
#config-modal #config-scroll .config-content {
padding-bottom: 80px;
}
}
/* --- Toast notifications --- */
@@ -5819,28 +6119,6 @@ body.resizing-v {
font-size: 0.9rem;
}
.config-btn-add {
padding: 6px 12px;
background: var(--accent);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: all 150ms ease;
white-space: nowrap;
}
.config-btn-add:hover {
background: color-mix(in srgb, var(--accent) 85%, black);
transform: translateY(-1px);
}
.config-btn-add:active {
transform: translateY(0);
}
/* ---------------------------------------------------------------------------
Utility — hidden class
--------------------------------------------------------------------------- */
@@ -7907,7 +8185,6 @@ body.popup-mode .content-area {
.webhook-events { color: var(--text-muted); font-size: 0.7rem; }
.webhook-delete { background: none; border: none; color: var(--text-error); cursor: pointer; font-size: 1rem; padding: 2px 6px; }
.config-add-row { display: flex; gap: 8px; margin-top: 8px; }
.config-btn-add { padding: 6px 14px; background: var(--accent); color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 0.8rem; }
/* ── API/MCP tokens UI (#107) ── */
.token-item {
@@ -8506,9 +8783,13 @@ body.desktop-mode .editor-container {
.help-hamburger {
display: inline-flex;
width: 44px;
height: 44px;
min-width: 44px;
}
/* TOC: collapsible block at top (not slide-over) */
/* TOC drawer: collapsible block for the help modal; the config modal
(#114) overrides this with a fixed left drawer + backdrop. */
.help-nav {
position: static;
width: 100%;
@@ -10643,6 +10924,69 @@ body.desktop-mode .editor-container {
background: var(--surface);
}
/* ── XLSX Viewer ── */
.xlsx-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.xlsx-toolbar-actions {
margin-left: auto;
display: flex;
gap: 8px;
}
.xlsx-tabs {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.xlsx-tab {
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-secondary);
border-radius: 4px;
padding: 4px 10px;
font-size: 0.8rem;
cursor: pointer;
}
.xlsx-tab.active {
background: var(--accent, #4a90d9);
border-color: var(--accent, #4a90d9);
color: #fff;
}
.xlsx-table th.xlsx-corner,
.xlsx-table th.xlsx-rownum {
background: var(--surface);
color: var(--text-secondary);
font-weight: 400;
text-align: right;
padding: 6px 8px;
border-bottom: 2px solid var(--border);
border-right: 1px solid var(--border-light, var(--border));
position: sticky;
left: 0;
z-index: 1;
}
.xlsx-table th.xlsx-corner {
left: 0;
top: 0;
z-index: 2;
}
.xlsx-table td[contenteditable] {
cursor: text;
min-width: 40px;
white-space: pre-wrap;
}
.xlsx-table td[contenteditable]:focus {
outline: 2px solid var(--accent, #4a90d9);
outline-offset: -2px;
}
.xlsx-table td.xlsx-dirty {
background: rgba(255, 196, 0, 0.18);
}
/* ── JSON Viewer ── */
.json-viewer {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
@@ -10763,7 +11107,6 @@ body.desktop-mode .editor-container {
}
.mfa-link-btn:hover { opacity: 0.8; }
.mfa-error { color: #e74c3c; font-size: 13px; margin-top: 8px; }
.mfa-recovery-input { width: 200px; font-size: 20px; letter-spacing: 4px; }
/* MFA Settings (Security tab) */
.mfa-status-section { padding: 16px 0; }
@@ -10962,6 +11305,9 @@ body.desktop-mode .editor-container {
justify-content: center; flex-shrink: 0; }
.bookslm-input-area button:hover { opacity: 0.9; }
.bookslm-input-area button:disabled { opacity: 0.5; cursor: not-allowed; }
/* BUG-077 — the send button doubles as a Stop control while streaming. */
.bookslm-input-area button.bookslm-btn-send.is-stopping { background: var(--danger); }
.bookslm-input-area button.bookslm-btn-send.is-stopping:hover { opacity: 1; filter: brightness(1.08); }
.bookslm-input-hint { padding: 0 16px 10px; font-size: 10px; color: var(--text-secondary);
text-align: right; border-top: none; }
/* Action cards proposed by the General assistant (file/dir creation). */
@@ -10990,6 +11336,9 @@ body.desktop-mode .editor-container {
.bookslm-tool-trace > summary:hover { color: var(--text-primary); }
.bookslm-tool-trace[open] > summary { margin-bottom: 4px; }
.bookslm-steps-dots { color: var(--accent); }
/* BUG-074 — preview of the first action next to the step counter. */
.bookslm-steps-title { color: var(--text-secondary); max-width: 45vw; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
.bookslm-steps-body { display: flex; flex-direction: column; gap: 3px; }
.bookslm-chevron { font-size: 8px; line-height: 1; opacity: 0.7; }
.bookslm-chevron::before { content: '▶'; }
@@ -11025,6 +11374,15 @@ details[open] > summary .bookslm-chevron::before { content: '▼'; }
/* Mutation confirmation card (two-step propose/apply). */
.bookslm-confirm { flex-wrap: wrap; }
.bookslm-confirm-diff { flex-basis: 100%; width: 100%; margin-top: 4px; }
/* BUG-075 — one row per action when the run batches several mutations. */
.bookslm-confirm-actions { flex-basis: 100%; width: 100%; display: flex;
flex-direction: column; gap: 4px; margin-top: 4px; }
.bookslm-confirm-action > summary { display: inline-flex; align-items: center; gap: 5px;
font-size: 12px; color: var(--text-secondary); cursor: pointer; list-style: none;
word-break: break-word; }
.bookslm-confirm-action > summary::-webkit-details-marker { display: none; }
.bookslm-confirm-action > summary:hover { color: var(--text-primary); }
.bookslm-confirm-action[open] > summary { color: var(--text-primary); }
.bookslm-diff-title { font-size: 11px; color: var(--text-secondary); margin-bottom: 4px; }
.bookslm-diff { background: rgba(0,0,0,0.25); border-radius: 6px; padding: 8px;
overflow-x: auto; font-size: 12px; line-height: 1.4; max-height: 240px; margin: 0; }
@@ -11604,6 +11962,7 @@ body.reading-mode .main-body {
padding-bottom: 0;
}
body.reading-mode .breadcrumb,
body.reading-mode .file-toolbar,
body.reading-mode .file-actions {
display: none;
}
+1 -1
View File
@@ -11,7 +11,7 @@
* cache or Cloudflare does NOT clear the Service Worker Cache Storage, which is
* a separate store. Bumping SW_VERSION invalidates it on every release.
*/
const SW_VERSION = 'v24';
const SW_VERSION = 'v26';
const CODE_CACHE = `obsigate-code-${SW_VERSION}`;
const RUNTIME_CACHE = `obsigate-runtime-${SW_VERSION}`;
const API_CACHE = `obsigate-api-${SW_VERSION}`;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.22.1",
"version": "2.27.7",
"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": {
+73 -8
View File
@@ -1,15 +1,18 @@
/**
* E2E tests for the Configurations modal on mobile (BUG-071).
* E2E tests for the Configurations modal on mobile (BUG-071 + #114).
*
* Runs only under the `chromium-mobile` Playwright project (viewport ≤ 768px);
* skipped on the desktop project that the CI job executes — same convention
* as mobile-editor.spec.js.
*
* Covered:
* - the TOC hamburger (#config-hamburger) is visible and reveals #config-nav,
* which is hidden by default on mobile;
* - the TOC hamburger (#config-hamburger) is visible and reveals #config-nav
* as a left slide-over drawer (fixed positioning), hidden by default;
* - the drawer has a close button (#config-toc-close) and a dimming backdrop
* (tap outside closes the drawer, not the whole modal);
* - picking a TOC entry scrolls to the section, marks the link active and
* collapses the nav;
* collapses the drawer;
* - the modal is full-screen (100dvh) and interactive controls are ≥44px;
* - the modal content does not overflow horizontally at 393px.
*
* Run:
@@ -20,6 +23,7 @@
import { test, expect } from '@playwright/test';
const MOBILE_MAX_WIDTH = 768;
const MIN_TOUCH_TARGET = 44;
async function boot(page) {
await page.goto('/');
@@ -33,8 +37,8 @@ async function openConfigModal(page) {
await expect(page.locator('#config-modal.active')).toBeVisible();
}
test.describe('Configurations modal on mobile (BUG-071)', () => {
test('hamburger reveals the table of contents', async ({ page, viewport }) => {
test.describe('Configurations modal on mobile (BUG-071 + #114)', () => {
test('hamburger reveals the TOC as a fixed drawer', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openConfigModal(page);
@@ -45,9 +49,39 @@ test.describe('Configurations modal on mobile (BUG-071)', () => {
await page.locator('#config-hamburger').click();
await expect(page.locator('#config-nav')).toBeVisible();
// #114: the drawer is a fixed slide-over, not an inline top block.
const position = await page.locator('#config-nav').evaluate(
(el) => window.getComputedStyle(el).position,
);
expect(position).toBe('fixed');
// The modal carries the drawer-open class (backdrop contract).
await expect(page.locator('#config-modal')).toHaveClass(/config-toc-open/);
});
test('picking a section scrolls to it and collapses the nav', async ({ page, viewport }) => {
test('close button and backdrop dismiss the drawer, not the modal', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openConfigModal(page);
// Close button inside the drawer header.
await page.locator('#config-hamburger').click();
await expect(page.locator('#config-toc-close')).toBeVisible();
await page.locator('#config-toc-close').click();
await expect(page.locator('#config-nav')).toBeHidden();
await expect(page.locator('#config-modal.active')).toBeVisible();
// Backdrop tap closes the drawer first; the modal stays open.
// Click near the right edge of the modal (outside the left drawer).
await page.locator('#config-hamburger').click();
await expect(page.locator('#config-nav')).toBeVisible();
await page.locator('#config-modal').click({ position: { x: (viewport?.width ?? 393) - 8, y: 200 } });
await expect(page.locator('#config-nav')).toBeHidden();
await expect(page.locator('#config-modal.active')).toBeVisible();
});
test('picking a section scrolls to it and collapses the drawer', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openConfigModal(page);
@@ -57,7 +91,7 @@ test.describe('Configurations modal on mobile (BUG-071)', () => {
await expect(link).toBeVisible();
await link.click();
// Nav collapses on mobile after selection…
// Drawer collapses on mobile after selection…
await expect(page.locator('#config-nav')).toBeHidden();
// …the link is marked active…
await expect(link).toHaveClass(/active/);
@@ -76,6 +110,37 @@ test.describe('Configurations modal on mobile (BUG-071)', () => {
.toBeLessThanOrEqual(0);
});
test('modal is full-screen and key controls meet the 44px touch target', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
await openConfigModal(page);
// Full-screen: the modal container fills the viewport.
const container = page.locator('#config-modal .editor-container');
const box = await container.boundingBox();
expect(box).not.toBeNull();
expect(box.width).toBeGreaterThanOrEqual((viewport?.width ?? 0) - 2);
expect(box.height).toBeGreaterThanOrEqual((viewport?.height ?? 0) - 2);
// Header controls (hamburger + close) are ≥44px.
for (const sel of ['#config-hamburger', '#config-close']) {
const b = await page.locator(sel).boundingBox();
expect(b, `${sel} has no bounding box`).not.toBeNull();
expect(b.height, `${sel} height`).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET);
expect(b.width, `${sel} width`).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET);
}
// Backend Save row: buttons are ≥44px tall.
await page.locator('#config-hamburger').click();
await page.locator('#config-nav a[href="#cfg-backend-settings"]').click();
await expect(page.locator('#config-nav')).toBeHidden();
const saveBtn = page.locator('#cfg-save-backend');
await expect(saveBtn).toBeVisible();
const saveBox = await saveBtn.boundingBox();
expect(saveBox).not.toBeNull();
expect(saveBox.height).toBeGreaterThanOrEqual(MIN_TOUCH_TARGET);
});
test('no horizontal overflow at 393px', async ({ page, viewport }) => {
test.skip((viewport?.width ?? 0) > MOBILE_MAX_WIDTH, 'Mobile viewport required');
await boot(page);
+158 -2
View File
@@ -104,6 +104,7 @@ async function main() {
const bookslmMod = await import(pathToFileURL(path.join(JS_DIR, "bookslm.js")).href);
const { BooksLM, MODE } = bookslmMod;
const { collectOpenDocuments, documentsSignature } = bookslmMod;
const { t } = await import(pathToFileURL(path.join(JS_DIR, "i18n.js")).href);
const { state } = await import(pathToFileURL(path.join(JS_DIR, "state.js")).href);
// ── 1. Rewrite modal resolves with the typed instruction ──
@@ -213,8 +214,14 @@ async function main() {
const { readFileSync } = await import("node:fs");
const src = readFileSync(path.join(JS_DIR, "bookslm.js"), "utf-8");
const apply = src.slice(src.indexOf("async _applyConfirmation"));
assert.match(apply, /payload:\s*msg\.payload\s*\?\s*\{[^\n]*\.\.\.msg\.payload[^\n]*\}\s*:\s*null/,
"continuation must inherit the original request payload (no null payload)");
// BUG-075: the whole batch is approved at once and the continuation keeps
// streaming into the SAME message (its `payload` stays available, so the
// BUG-046 null-payload regression cannot reappear).
assert.match(apply, /confirm_all:\s*true/, "global approval flag sent");
assert.match(apply, /await this\._streamResponse\(resp, msg, payload\)/,
"resume streams into the original message (payload preserved)");
assert.doesNotMatch(apply, /this\._messages\.push\(continuation\)/,
"no fragmented continuation message per approval");
assert.match(apply, /await this\._responseError\(resp\)/,
"resume path must format non-OK responses via _responseError");
});
@@ -550,6 +557,50 @@ async function main() {
assert.ok(summary.getAttribute("aria-label"), "the state is announced to screen readers");
});
await test("the steps header previews the first action and counts actions only (BUG-074)", () => {
const b = new BooksLM();
// Two reasoning notes + one action → the header counts 1 action and
// previews it; the notes must not inflate the counter.
const calls = [
{ name: "", ok: true, step: { key: "thought", params: { value: "je réfléchis" } } },
{ name: "create_file", ok: true, step: { key: "file_create", params: { value: "a.md" } } },
{ name: "", ok: true, step: { key: "thought", params: { value: "encore" } } },
];
const trace = b._renderToolActivity(calls, {}, false);
const summary = trace.querySelector("summary");
assert.equal(
summary.querySelector(".bookslm-steps-label").textContent,
t("ai.steps_count", { count: 1 }),
"only actions are counted",
);
const title = summary.querySelector(".bookslm-steps-title");
assert.ok(title, "the first action is previewed in the collapsed header");
assert.ok(title.textContent.startsWith("— "), "preview is prefixed with a dash");
assert.ok(title.textContent.includes(b._stepText(calls[1])),
"preview shows the first action's label");
// Several actions → plural label.
const two = b._renderToolActivity([
{ name: "create_file", ok: true, step: { key: "file_create", params: { value: "a.md" } } },
{ name: "create_directory", ok: true, step: { key: "dir_create", params: { value: "d" } } },
], {}, false);
assert.equal(
two.querySelector(".bookslm-steps-label").textContent,
t("ai.steps_count_plural", { count: 2 }),
"plural for several actions",
);
});
await test("_actionStepCount ignores reasoning notes (BUG-074)", () => {
const b = new BooksLM();
assert.equal(b._actionStepCount([
{ step: { key: "thought" } },
{ step: { key: "file_create" } },
]), 1, "only the action is counted");
assert.equal(b._actionStepCount([{ step: { key: "thought" } }]), 1,
"a thoughts-only block still shows 1, never 0");
assert.equal(b._actionStepCount([]), 0);
});
await test("reasoning notes become « Réflexion » sub-sections, open state kept", () => {
const b = new BooksLM();
const msg = {};
@@ -1206,14 +1257,119 @@ async function main() {
assert.equal(msg.confirmation, null, "confirmation resolved");
assert.ok(posted, "resumed via /agent");
assert.equal(posted.confirm.error.tool, "edit_file");
assert.equal(posted.confirm_all, true, "BUG-075: global approval flag sent");
assert.deepEqual(posted.confirm_messages, [{ role: "system", content: "s" }]);
// BUG-074: the continuation stays in the same message (cumulative steps).
assert.equal(b._messages.length, 1, "no fragmented continuation message");
const cont = b._messages[b._messages.length - 1];
assert.equal(cont, msg, "streams into the original message");
assert.equal(cont.content, "Fait.");
assert.equal(cont.toolCalls.length, 1, "tool trace captured");
b._panel.remove();
localStorage.clear();
});
await test("a batch confirmation lists every action and offers one approval (BUG-075)", () => {
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
b._fillConfirmationDiff = async () => {};
const msg = {
role: "assistant",
content: "",
confirmation: {
pending: {
error: { tool: "create_file", arguments: { vault: "V", path: "a.md" }, id: "1" },
actions: [
{ id: "1", tool: "create_directory", step: { key: "dir_create", params: { value: "proj" } }, arguments: { vault: "V", path: "proj" } },
{ id: "2", tool: "create_file", step: { key: "file_create", params: { value: "proj/a.md" } }, arguments: { vault: "V", path: "proj/a.md", content: "x" } },
],
},
messages: [],
},
};
const card = b._renderConfirmationCard(msg);
const rows = card.querySelectorAll(".bookslm-confirm-action");
assert.equal(rows.length, 2, "one row per pending action");
assert.ok(
rows[0].querySelector("summary").textContent.includes(
b._stepText({ step: { key: "dir_create", params: { value: "proj" } }, name: "create_directory" }),
),
"each row shows the action's human label",
);
const apply = card.querySelector(".bookslm-action-apply");
assert.equal(apply.textContent, t("ai.action_apply_all", { count: 2 }),
"a single global approval button");
b._panel.remove();
});
await test("single-action confirmation keeps the legacy Apply label (BUG-075)", () => {
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
b._fillConfirmationDiff = async () => {};
const msg = {
role: "assistant",
content: "",
confirmation: {
pending: { error: { tool: "edit_file", arguments: { vault: "V", path: "a.md", content: "x" }, id: "1" } },
messages: [],
},
};
const card = b._renderConfirmationCard(msg);
assert.equal(card.querySelectorAll(".bookslm-confirm-action").length, 0,
"no batch list for a single action");
assert.equal(card.querySelector(".bookslm-action-apply").textContent, t("ai.action_apply"));
b._panel.remove();
});
await test("the send button doubles as a Stop control while running (BUG-077)", () => {
const b = new BooksLM();
b._panel = b._render();
document.body.appendChild(b._panel);
const btn = b._panel.querySelector(".bookslm-btn-send");
b._syncSendButton();
assert.equal(btn.querySelector("i").getAttribute("data-lucide"), "arrow-up");
assert.ok(!btn.classList.contains("is-stopping"), "idle: normal send button");
let aborted = false;
b._isLoading = true;
b._abortCtrl = { abort: () => { aborted = true; } };
b._syncSendButton();
assert.ok(btn.classList.contains("is-stopping"), "stop state applied while running");
assert.equal(btn.querySelector("i").getAttribute("data-lucide"), "square");
btn.click();
assert.ok(aborted, "clicking while running aborts the request");
b._isLoading = false;
b._panel.remove();
});
await test("_markStopped appends a visible stop marker (BUG-077)", () => {
const b = new BooksLM();
const msg = { content: "Début de réponse" };
b._markStopped(msg);
assert.ok(msg.content.includes("⏹"), "marker present");
assert.ok(msg.content.startsWith("Début de réponse"), "partial answer kept");
assert.ok(!msg.content.includes("⏹\n"), "marker separated from the answer");
const empty = { content: "" };
b._markStopped(empty);
assert.ok(empty.content.includes("⏹"));
assert.ok(!empty.content.startsWith("\n"), "no leading blank line for an empty answer");
});
await test("document writes notify the viewer; path-less tools do not (BUG-076)", () => {
const b = new BooksLM();
const seen = [];
const handler = (e) => seen.push(e.detail);
window.addEventListener("obsigate:file-written", handler);
b._notifyFileWritten({ name: "create_xlsx", ok: true, arguments: { vault: "V", path: "a.xlsx" } });
b._notifyFileWritten({ name: "edit_file", ok: false, arguments: { vault: "V", path: "b.md" } });
b._notifyFileWritten({ name: "replace_in_files", ok: true, arguments: { vault: "V" } });
window.removeEventListener("obsigate:file-written", handler);
assert.equal(seen.length, 1, "only the successful path-bearing write notifies");
assert.equal(seen[0].path, "a.xlsx");
});
// ── 29. Resizable panel bounds and persistence (#81) ──
await test("panel width is clamped and persisted", async () => {
localStorage.clear();
+147 -4
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* ObsiGate — Configurations modal mobile usability non-regression tests (BUG-071).
* ObsiGate — Configurations modal mobile usability tests (BUG-071 + #114).
*
* Static checks (no jsdom needed — runs in the "Frontend unit tests" CI step):
* - BUG-071a: #config-nav shared the .help-nav rule that hides it below
@@ -18,8 +18,16 @@
* style.css must stack/wrap them below 768px with 44px touch targets.
* - BUG-071d: every #config-nav link target must exist (dead-anchor guard,
* same class of bug as BUG-067 for the help modal).
* - BUG-071e: data-i18n-attr supports several "attr:key" pairs (";"-
* - BUG-071e: data-i18n-attr supports several "attr:key" pairs (";"
* separated) so the toggle carries translated title AND aria-label.
* - #114a: the TOC is a left slide-over drawer (position: fixed) opened by
* .config-toc-open on #config-modal, with a backdrop (::before) and a
* close button (#config-toc-close).
* - #114b: the modal is full-screen (100dvh), inputs are ≥16px anti-zoom
* with 44px min-height, buttons are 44px touch targets, the backend Save
* row is sticky, and the MFA recovery list is single-column on mobile.
* - #114i18n: dead settings.* keys purged, settings.explorer and
* config.toc_close present in FR and EN, settings.tabs translated in FR.
*
* Usage: node tests/frontend/config-mobile.test.mjs
*/
@@ -82,12 +90,17 @@ test("config.js — TOC links smooth-scroll, mark active, collapse on mobile", (
test("config.js — TOC display reset when the modal opens", () => {
assert.match(configJs, /configNavOnOpen[\s\S]{0,120}?style\.display = ''/, "stale inline display would stick across sessions");
assert.match(configJs, /modal\.classList\.remove\("config-toc-open"\)/,
"the drawer-open class (backdrop) must be cleared on open");
});
// ── BUG-071c: mobile CSS ────────────────────────────────────────────────────
test("style.css — config TOC becomes a capped top block on mobile", () => {
test("style.css — config TOC becomes a fixed drawer on mobile", () => {
assert.match(css, /#config-modal #config-nav/, "no mobile rule scoped to #config-modal #config-nav");
assert.match(css, /#config-modal #config-nav[\s\S]{0,400}?max-height/, "the opened TOC must be height-capped so content stays reachable");
assert.match(css, /#config-modal #config-nav[\s\S]{0,400}?position: fixed/,
"the TOC must be a fixed slide-over drawer on mobile");
assert.match(css, /#config-modal #config-nav[\s\S]{0,400}?max-height/,
"the drawer must be height-capped so content stays reachable");
});
test("style.css — two-column config grids stack on mobile", () => {
@@ -124,6 +137,136 @@ test("i18n.js — data-i18n-attr supports several attr:key pairs", () => {
assert.match(i18nJs, /el\.setAttribute\(attr, t\(key\)\)/, "each pair must set its attribute");
});
// ── #114a: drawer, backdrop, close button ───────────────────────────────────
test("index.html — #config-toc-close exists inside the TOC header", () => {
const nav = indexHtml.match(/<nav class="help-nav" id="config-nav">([\s\S]*?)<\/nav>/);
assert.ok(nav, "#config-nav not found");
assert.match(nav[1], /id="config-toc-close"/, "no #config-toc-close in the TOC — no way to dismiss the drawer");
assert.match(nav[1], /config\.toc_close/, "the close button label must use the i18n key config.toc_close");
});
test("i18n — config.toc_close exists in FR and EN", () => {
assert.ok(fr["config.toc_close"], "fr.json missing config.toc_close");
assert.ok(en["config.toc_close"], "en.json missing config.toc_close");
assert.notEqual(fr["config.toc_close"], "config.toc_close", "FR value must be translated");
assert.notEqual(en["config.toc_close"], "config.toc_close", "EN value must be translated");
});
test("config.js — drawer open class + close button + backdrop + Escape", () => {
assert.match(configJs, /classList\.toggle\("config-toc-open"/,
"_setConfigNav must toggle .config-toc-open on the modal (backdrop + drawer CSS)");
assert.match(configJs, /getElementById\("config-toc-close"\)/,
"no binding on #config-toc-close");
assert.match(configJs, /config-toc-open[\s\S]{0,200}?_setConfigNav\(false\)/,
"a backdrop tap must close the drawer first, not the whole modal");
assert.match(configJs, /Escape[\s\S]{0,200}?config-toc-open[\s\S]{0,120}?_setConfigNav\(false\)/,
"Escape must close the drawer first, then the modal");
assert.match(configJs, /closeConfigModal[\s\S]{0,200}?classList\.remove\("config-toc-open"\)/,
"closeConfigModal must never leave the backdrop class behind");
});
test("style.css — drawer backdrop via .config-toc-open::before", () => {
assert.match(css, /#config-modal\.config-toc-open::before/,
"no backdrop pseudo-element for the open drawer");
assert.match(css, /#config-modal\.config-toc-open::before[\s\S]{0,200}?z-index: 35/,
"the backdrop must sit above the content and below the drawer (z-index 40)");
assert.match(css, /#config-modal\.config-toc-open #config-nav/,
"the open drawer must be forced visible via the modal class");
});
test("style.css — .help-toc-close hidden on desktop, visible in the config drawer", () => {
assert.match(css, /\.help-toc-close\s*\{[^}]*display:\s*none/,
".help-toc-close must be hidden by default (desktop nav is always visible)");
assert.match(css, /#config-modal \.help-toc-close\s*\{[^}]*display:\s*inline-flex/,
"#config-modal .help-toc-close must show on mobile");
});
// ── #114b: full-screen, anti-zoom, touch targets, sticky save ───────────────
test("style.css — config modal goes full-screen with 100dvh on mobile", () => {
assert.match(css, /#config-modal \.editor-container[\s\S]{0,300}?100dvh/,
"the modal must fill the dynamic viewport (mobile browser chrome)");
assert.match(css, /#config-modal[\s\S]{0,200}?padding:\s*0/,
"the modal must drop its outer padding on mobile");
});
test("style.css — inputs are ≥16px with 44px min-height (anti-zoom iOS)", () => {
assert.match(css, /#config-modal \.config-input[\s\S]{0,200}?font-size:\s*16px/,
"config inputs must be ≥16px to stop iOS auto-zoom");
assert.match(css, /#config-modal \.config-input[\s\S]{0,200}?min-height:\s*44px/,
"config inputs need a 44px touch target");
assert.match(css, /#config-modal \.config-select[\s\S]{0,200}?font-size:\s*16px/,
"config selects must be ≥16px to stop iOS auto-zoom");
});
test("style.css — primary buttons are 44px touch targets", () => {
// The shared button block lists many selectors; match from the block start
// (first #config-modal .config-btn-save) to the min-height declaration.
const buttonBlock = css.match(/#config-modal \.config-btn-save,[\s\S]{0,600}?\{[^}]*min-height:\s*44px/);
assert.ok(buttonBlock, ".config-btn-save needs a 44px touch target");
for (const cls of ["config-btn-secondary", "config-btn-danger", "config-btn-sm"]) {
assert.match(css, new RegExp(`#config-modal \\.${cls}`), `#${cls} has no #114 mobile rule`);
}
assert.match(css, /#config-modal \.config-actions-row[\s\S]{0,400}?min-height:\s*44px/,
"buttons inside the sticky Save row need a 44px touch target");
});
test("style.css — backend Save row is sticky at the bottom of its section", () => {
assert.match(css, /#config-modal \.config-actions-row\s*\{[^}]*position:\s*sticky/,
"the Save row must stick to the bottom while scrolling the backend section");
assert.match(css, /#config-modal \.config-actions-row[\s\S]{0,300}?flex-direction:\s*column/,
"the Save row must stack vertically on mobile");
assert.match(css, /#config-modal \.config-actions-row[\s\S]{0,300}?env\(safe-area-inset-bottom/,
"the sticky Save row must clear the home indicator");
});
test("style.css — MFA recovery list single-column + verify section wraps", () => {
assert.match(css, /#config-modal \.mfa-recovery-list\s*\{[^}]*grid-template-columns:\s*1fr/,
"recovery codes must be a single column on mobile");
assert.match(css, /#config-modal \.mfa-verify-section[\s\S]{0,200}?flex-wrap:\s*wrap/,
"the MFA verify row must wrap instead of overflowing");
assert.match(css, /#config-modal \.mfa-code-input[\s\S]{0,300}?width:\s*100%/,
"the MFA code input must be full-width on mobile");
});
test("index.html — .config-actions-row lives inside #cfg-backend-settings", () => {
const section = indexHtml.match(/<section[^>]*id="cfg-backend-settings"[\s\S]*?<\/section>/);
assert.ok(section, "#cfg-backend-settings section not found");
assert.match(section[0], /class="config-actions-row"/,
".config-actions-row must be inside the backend section (sticky footer contract)");
});
// ── #114i18n: dead keys purged, new keys added ──────────────────────────────
test("i18n — dead settings.* keys are purged from FR and EN", () => {
const dead = ["settings.backend", "settings.backend_hint", "settings.restart_badge",
"settings.save", "settings.plugins"];
for (const key of dead) {
assert.equal(fr[key], undefined, `fr.json still has dead key ${key}`);
assert.equal(en[key], undefined, `en.json still has dead key ${key}`);
}
});
test("i18n — settings.explorer exists in FR and EN", () => {
assert.ok(fr["settings.explorer"], "fr.json missing settings.explorer");
assert.ok(en["settings.explorer"], "en.json missing settings.explorer");
assert.notEqual(fr["settings.explorer"], "settings.explorer", "FR value must be translated");
assert.notEqual(en["settings.explorer"], "settings.explorer", "EN value must be translated");
});
test("i18n — settings.tabs is translated in FR (was the English word \"Tabs\")", () => {
assert.equal(fr["settings.tabs"], "Onglets", "fr.json settings.tabs must be French");
assert.equal(en["settings.tabs"], "Tabs", "en.json settings.tabs must stay English");
});
test("index.html — #mt-explorer label carries data-i18n=settings.explorer", () => {
assert.match(indexHtml, /id="mt-explorer"[\s\S]{0,400}?data-i18n="settings\.explorer"/,
"the explorer toolbar button must be translated via settings.explorer");
});
test("index.html — no dead container #plugins-settings-container", () => {
assert.ok(!indexHtml.includes("plugins-settings-container"),
"#plugins-settings-container was a dead div — plugins render into #cfg-plugins");
});
if (process.exitCode) {
console.error("\nConfig mobile tests FAILED");
} else {
+7 -2
View File
@@ -333,8 +333,13 @@ test("bookslm.js reports the edited document and the assistant writes", () => {
assert.match(bookslmSrc, /this\._notifyFileWritten\(data\);/);
const notify = bookslmSrc.match(/_notifyFileWritten\(data\) \{([\s\S]*?)\n \}/);
assert.ok(notify, "_notifyFileWritten not found");
for (const tool of ["edit_file", "append_to_file", "create_file", "restore_backup"]) {
assert.ok(notify[1].includes(`'${tool}'`), `${tool} missing from the write tools`);
// BUG-076: the write-tool list is a module-level set (documents included).
assert.match(notify[1], /FILE_WRITE_TOOLS\.has\(data\.name\)/);
const writeSet = bookslmSrc.match(/const FILE_WRITE_TOOLS = new Set\(\[([\s\S]*?)\]\);/);
assert.ok(writeSet, "FILE_WRITE_TOOLS not found");
for (const tool of ["edit_file", "append_to_file", "create_file", "restore_backup",
"create_xlsx", "create_docx", "create_csv", "create_pdf"]) {
assert.ok(writeSet[1].includes(`'${tool}'`), `${tool} missing from the write tools`);
}
assert.match(notify[1], /new CustomEvent\('obsigate:file-written'/);
});
+36 -1
View File
@@ -16,7 +16,7 @@
*/
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -136,11 +136,46 @@ test("style.css — preview circle and sidebar image rules exist", () => {
assert.match(css, /\.sidebar-user-avatar-img\s*\{/, "no .sidebar-user-avatar-img rule");
});
// ── #117: preset avatar gallery ─────────────────────────────────────────────
test("index.html — #cfg-profile exposes 12 bundled preset avatars", () => {
const section = indexHtml.match(/<section[^>]*id="cfg-profile"[\s\S]*?<\/section>/);
assert.ok(section, "#cfg-profile section not found");
const html = section[0];
assert.match(html, /id="profile-avatar-presets"/, "preset gallery container missing");
const buttons = [...html.matchAll(/class="profile-avatar-preset"[^>]*data-avatar="([^"]+)"/g)];
assert.equal(buttons.length, 12, `expected 12 preset avatars, found ${buttons.length}`);
const names = buttons.map((m) => m[1]);
assert.equal(new Set(names).size, 12, "preset avatar names must be unique");
for (const name of names) {
const rel = path.join(ROOT, "frontend", "icons", "avatar", name);
assert.ok(existsSync(rel), `missing avatar file: ${rel}`);
assert.ok(html.includes(`/static/icons/avatar/${name}`),
`#${name} must be served from /static/icons/avatar/`);
}
});
test("config.js — preset avatars reuse the upload pipeline", () => {
assert.match(configJs, /profile-avatar-presets/, "preset container lookup missing");
assert.match(configJs, /\.profile-avatar-preset/, "preset button selector missing");
assert.match(configJs, /\/static\/icons\/avatar\//, "preset image base URL missing");
assert.match(configJs, /_resizeAvatarFile\(blob\)/, "preset must go through the resize pipeline");
assert.match(configJs, /_patchProfileAvatar\(dataUrl\)/, "preset must be persisted via PATCH");
assert.match(configJs, /obsigate-avatar-preset/, "active preset marker missing");
assert.match(configJs, /markActivePreset/, "active preset highlight missing");
});
test("style.css — preset avatar rules exist", () => {
assert.match(css, /\.profile-avatar-presets\s*\{/, "no .profile-avatar-presets rule");
assert.match(css, /\.profile-avatar-preset\s*\{/, "no .profile-avatar-preset rule");
assert.match(css, /\.profile-avatar-preset\.active\s*\{/, "no active preset rule");
});
// ── #113c: i18n + backend ───────────────────────────────────────────────────
test("i18n — avatar keys exist in FR and EN", () => {
const keys = [
"config.avatar_label",
"config.avatar_hint",
"config.avatar_presets_label",
"config.avatar_choose",
"config.avatar_remove",
"config.avatar_updated",
+24
View File
@@ -111,6 +111,30 @@ test("style.css — .action-sep rule exists", () => {
assert.match(css, /\.file-actions \.action-sep\s*\{[^}]*background:[^;]*;/);
});
// ── #115 : barre d'outils collante au défilement ─────────────────────────
test("viewer.js — action bar wrapped in a sticky .file-toolbar", () => {
assert.match(viewer, /class:\s*"file-toolbar"[\s\S]{0,120}file-actions/,
"the document action bar must live in a .file-toolbar wrapper");
});
test("popout.html — action bar wrapped in a .file-toolbar", () => {
assert.match(popout, /className\s*=\s*"file-toolbar"/,
"popout action bar must live in a .file-toolbar wrapper");
});
test("style.css — .file-toolbar is pinned to the top while scrolling", () => {
const m = css.match(/\.file-toolbar\s*\{([\s\S]*?)\}/);
assert.ok(m, ".file-toolbar rule missing");
assert.match(m[1], /position:\s*sticky/, ".file-toolbar must be sticky");
assert.match(m[1], /top:\s*0/, ".file-toolbar must pin to the top");
assert.match(m[1], /background:/, ".file-toolbar needs an opaque background");
});
test("style.css — reading mode hides the sticky toolbar", () => {
assert.match(css, /body\.reading-mode\s+\.file-toolbar\s*[,{]/,
"reading mode must hide .file-toolbar");
});
if (process.exitCode) {
console.error("\nToolbar order tests FAILED");
} else {
+23
View File
@@ -284,6 +284,28 @@ function testSidebarUserSection() {
console.log(' ✓ header trimmed + sidebar account section (#112)');
}
// ── Test syntax highlighting follows the theme mode (BUG-078) ──────────────
function testSyntaxHighlightTheme() {
const themes = readFileSync(join(JS_DIR, 'themes.js'), 'utf-8');
const ui = readFileSync(join(JS_DIR, 'ui.js'), 'utf-8');
// themes.js must swap the highlight.js stylesheets based on the *mode*.
const applyTheme = themes.match(/function applyTheme\(themeKey, mode\)\s*\{([\s\S]*?)\n\}/);
assert.ok(applyTheme, 'themes.js applyTheme not found');
assert.match(applyTheme[1], /hljs-theme-dark/, 'applyTheme must toggle the dark hljs sheet');
assert.match(applyTheme[1], /hljs-theme-light/, 'applyTheme must toggle the light hljs sheet');
assert.match(applyTheme[1], /mode === 'dark'|mode === "dark"/, 'sheet choice must key on the mode');
// ui.js must resolve the persisted *mode*, never treat the theme key as one.
assert.match(ui, /obsigate-theme-mode/, 'ui.js must read the persisted theme mode');
assert.doesNotMatch(
ui,
/darkSheet\.disabled\s*=\s*theme !== "dark"[\s\S]{0,120}lightSheet\.disabled\s*=\s*theme !== "light"/,
'ui.js must not compare the theme key to "dark"/"light" (BUG-078 regression)',
);
console.log(' ✓ syntax highlighting follows the theme mode (BUG-078)');
}
// ── Run all tests ──────────────────────────────────────────────────────────
async function main() {
let passed = 0, failed = 0;
@@ -300,6 +322,7 @@ async function main() {
['ai-fab module exports', testAIFabModuleExports],
['config TOC icons', testConfigTocIcons],
['sidebar user section', testSidebarUserSection],
['syntax highlight theme', testSyntaxHighlightTheme],
];
for (const [name, fn] of tests) {
+34 -13
View File
@@ -281,12 +281,13 @@ class TestConfirmationResume:
assert assistant_tool_msgs[0]["tool_calls"][0]["id"] == "call_9"
@pytest.mark.asyncio
async def test_confirmation_with_parallel_calls_keeps_conversation_valid(self, monkeypatch):
"""BUG-050: pausing on one tool call of a batch must answer the others.
async def test_confirmation_batches_all_writes_and_keeps_conversation_valid(self, monkeypatch):
"""BUG-075 (was BUG-050): one pause batches every mutating call.
The assistant message lists every tool call of the response, so the
provider rejects the resumed turn when a ``tool_call_id`` has no tool
result (the "create a folder and a file inside" scenario).
result. Mutating calls of the batch are now all pending (one approval
applies them together); no dangling id remains.
"""
_register(monkeypatch, "_write", lambda ctx, params: {"done": params}, risk=ToolRisk.WRITE)
@@ -297,14 +298,15 @@ class TestConfirmationResume:
paused = await run_agent([{"role": "user", "content": "write both"}], ctx=_ctx(), llm=llm1)
assert paused.stopped == STOP_CONFIRMATION_REQUIRED
assert paused.pending["error"]["id"] == "1"
# The call that was not reached is answered right away; the pending one
# gets its result on resume, when the user applies it.
# Both mutating calls are batched into the single confirmation.
actions = paused.pending["actions"]
assert [a["id"] for a in actions] == ["1", "2"]
assert actions[0]["step"]["key"] == "generic"
# Nothing is executed nor deferred while waiting for the approval.
answered = {m["tool_call_id"] for m in paused.messages if m.get("role") == "tool"}
assert "2" in answered
assert "1" not in answered
assert answered == set()
# Resume: the pending call is applied, the next turn stays valid.
# Resume: both pending calls are applied, the next turn stays valid.
llm2 = ScriptedLLM([LLMResponse(content="ok")])
resumed = await run_agent(
[{"role": "user", "content": "write both"}],
@@ -315,8 +317,8 @@ class TestConfirmationResume:
)
assert resumed.stopped == STOP_DONE
assert resumed.content == "ok"
assert len(resumed.tool_calls) == 1
assert resumed.tool_calls[0].ok is True
assert [r.name for r in resumed.tool_calls] == ["_write", "_write"]
assert all(r.ok for r in resumed.tool_calls)
# Before the resumed LLM call, every announced tool_call_id is answered.
resumed_messages = llm2.calls[0]["messages"]
assistant = next(
@@ -326,12 +328,31 @@ class TestConfirmationResume:
announced = {tc["id"] for tc in assistant["tool_calls"]}
answered = {m["tool_call_id"] for m in resumed_messages if m.get("role") == "tool"}
assert announced <= answered
# The skipped call is flagged "deferred" so the model can re-issue it.
# No deferred result: the batched calls were approved, not skipped.
deferred = [
m for m in resumed_messages
if m.get("role") == "tool" and json.loads(m["content"]).get("status") == "deferred"
]
assert [m["tool_call_id"] for m in deferred] == ["2"]
assert deferred == []
@pytest.mark.asyncio
async def test_confirmation_runs_read_calls_of_the_batch_immediately(self, monkeypatch):
"""Only mutating calls are batched; read-only calls of the turn run now."""
_register(monkeypatch, "_write", lambda ctx, params: {"done": params}, risk=ToolRisk.WRITE)
_register(monkeypatch, "_read", lambda ctx, params: {"value": 1})
llm1 = ScriptedLLM([LLMResponse(tool_calls=[
ToolCall(id="1", name="_write", arguments={"x": 1}),
ToolCall(id="2", name="_read", arguments={}),
ToolCall(id="3", name="_write", arguments={"x": 3}),
])])
paused = await run_agent([{"role": "user", "content": "write and read"}], ctx=_ctx(), llm=llm1)
assert paused.stopped == STOP_CONFIRMATION_REQUIRED
# The read ran while the two writes are pending.
assert [r.name for r in paused.tool_calls] == ["_read"]
assert [a["id"] for a in paused.pending["actions"]] == ["1", "3"]
answered = {m["tool_call_id"] for m in paused.messages if m.get("role") == "tool"}
assert answered == {"2"}
class TestAgentPermissions:
+33
View File
@@ -460,6 +460,39 @@ class TestConfig:
# Should have some diagnostic info
assert len(data) > 0
def test_diagnostics_concurrent_index_writes(self, client):
"""Regression: word_index mutated while the handler iterates it raised
"dictionary changed size during iteration" -> 500."""
from backend.search import get_inverted_index
inv = get_inverted_index()
original = inv.word_index
class RaceDict(dict):
"""dict whose .values() grows the dict mid-iteration, exactly like
the indexer writing from another thread."""
def values(self):
it = iter(dict.values(self))
first = next(it, _SENTINEL)
if first is _SENTINEL:
return iter(())
self["__injected__"] = {"__injected_doc__": 1}
def gen():
yield first
yield from it # raises RuntimeError: dict changed size
return gen()
_SENTINEL = object()
inv.word_index = RaceDict(original)
try:
resp = client.get("/api/diagnostics")
assert resp.status_code == 200, resp.text
assert "memory_estimate_mb" in resp.json()["inverted_index"]
finally:
inv.word_index = original
original.pop("__injected__", None)
# ═══════════════════════════════════════════════════════════════════
# Dashboard
+75
View File
@@ -1065,6 +1065,81 @@ class TestBooksLMAgentEndpoint:
assert "C'est fait." in resp2.text
assert "event: message" in resp2.text
def test_agent_confirmation_batches_actions_and_confirm_all(self, bookslm_client, monkeypatch):
"""BUG-075: one confirmation lists every mutating call of the turn and
``confirm_all`` authorizes the rest of the run without pausing again."""
import re
import backend.bookslm_routes as routes
from backend.ai_chat import LLMResponse, ToolCall
from backend.tools import registry
from backend.tools.api import ToolRisk
from backend.tools.registry import ToolSpec
from backend.tools.schemas import ListVaultsInput
calls = []
def handler(ctx, params):
calls.append(params)
return {"ok": True}
spec = ToolSpec(
name="_agent_write_batch",
description="write for tests",
input_model=ListVaultsInput,
handler=handler,
risk=ToolRisk.WRITE,
)
monkeypatch.setitem(registry._REGISTRY, "_agent_write_batch", spec)
responses = [LLMResponse(tool_calls=[
ToolCall(id="c1", name="_agent_write_batch", arguments={}),
ToolCall(id="c2", name="_agent_write_batch", arguments={}),
])]
async def fake_chat_completion(messages, **kwargs):
return responses.pop(0)
monkeypatch.setattr(routes, "chat_completion", fake_chat_completion)
monkeypatch.setattr(routes, "_resolve_provider_name", lambda requested: "deepseek")
token, _ = _login_bookslm(bookslm_client)
payload = {"vault": "TestVault", "directory": "", "message": "crée tout", "mode": "directory"}
resp = bookslm_client.post(
"/api/ai/bookslm/agent",
json=payload,
headers={"Authorization": f"Bearer {token}"},
)
assert "event: confirmation" in resp.text
match = re.search(r"event: confirmation\ndata: (.*)", resp.text)
assert match, resp.text
confirmation = json.loads(match.group(1))
# Both mutating calls are pending, not one.
assert [a["tool"] for a in confirmation["pending"]["actions"]] == [
"_agent_write_batch", "_agent_write_batch",
]
assert calls == [], "nothing runs before the approval"
# Resume with a global approval: the two pending calls run, and a third
# mutating call of the same run runs without a second confirmation.
responses.append(LLMResponse(tool_calls=[ToolCall(id="c3", name="_agent_write_batch", arguments={})]))
responses.append(LLMResponse(content="Terminé."))
resume_payload = dict(
payload,
confirm=confirmation["pending"],
confirm_messages=confirmation["messages"],
confirm_all=True,
)
resp2 = bookslm_client.post(
"/api/ai/bookslm/agent",
json=resume_payload,
headers={"Authorization": f"Bearer {token}"},
)
assert resp2.status_code == 200
assert "event: confirmation" not in resp2.text, "confirm_all must not pause again"
assert len(calls) == 3, "the whole plan ran in one approval"
assert "Terminé." in resp2.text
def test_agent_message_reports_effective_model(self, bookslm_client, monkeypatch):
"""The SSE payload carries the model really used, not the raw request.
+152
View File
@@ -0,0 +1,152 @@
"""Display / edit / download for .xlsx files (viewer + PUT xlsx/save)."""
from __future__ import annotations
from pathlib import Path
import pytest
openpyxl = pytest.importorskip("openpyxl")
VAULT = "TestVault"
@pytest.fixture
def xlsx_file(test_vault_dir: str) -> str:
from openpyxl import Workbook
path = Path(test_vault_dir) / "budget.xlsx"
wb = Workbook()
ws = wb.active
ws.title = "Budget"
ws["A1"] = "Poste"
ws["B1"] = 100
ws["A2"] = "Total"
ws["B2"] = "=B1*2"
notes = wb.create_sheet("Notes")
notes["A1"] = "hello"
wb.save(path)
return str(path)
# ── Display ───────────────────────────────────────────────────────────────
class TestXlsxDisplay:
def test_renders_all_sheets(self, client, xlsx_file):
resp = client.get(f"/api/file/{VAULT}", params={"path": "budget.xlsx"})
assert resp.status_code == 200
data = resp.json()
assert data["is_xlsx"] is True
assert data["unsupported"] is False
assert [s["name"] for s in data["xlsx_sheets"]] == ["Budget", "Notes"]
first = data["xlsx_sheets"][0]["html"]
assert "Poste" in first
assert 'data-cell="B1"' in first
assert "=B1*2" in first # formula kept as text (data_only=False)
assert 'data-cell="A1"' in data["xlsx_sheets"][1]["html"]
def test_corrupt_xlsx_returns_500(self, client, test_vault_dir):
bad = Path(test_vault_dir) / "corrupt.xlsx"
bad.write_bytes(b"this is not a zip archive")
resp = client.get(f"/api/file/{VAULT}", params={"path": "corrupt.xlsx"})
assert resp.status_code == 500
# ── Index parity (tree visibility) ────────────────────────────────────────
class TestXlsxIndexing:
def test_xlsx_in_supported_extensions(self):
from backend.indexer import SUPPORTED_EXTENSIONS
assert ".xlsx" in SUPPORTED_EXTENSIONS
def test_xlsx_indexed_metadata_only(self, test_vault_dir, xlsx_file):
from backend.indexer import _index_single_file_sync
info = _index_single_file_sync(VAULT, test_vault_dir, xlsx_file)
assert info is not None
assert info["extension"] == ".xlsx"
assert info["content"] == "" # binary: never read into TF-IDF
assert info["title"] # filename-derived title
# ── Edit ──────────────────────────────────────────────────────────────────
class TestXlsxSave:
def _save(self, client, body, path="budget.xlsx"):
return client.put(
f"/api/file/{VAULT}/xlsx/save",
params={"path": path},
json=body,
)
def test_save_updates_cell_with_number_coercion(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Budget", "cells": {"B1": "250"}})
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
from openpyxl import load_workbook
wb = load_workbook(xlsx_file)
assert wb["Budget"]["B1"].value == 250 # int, not "250"
def test_save_leaves_other_sheets_and_formulas(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Budget", "cells": {"A1": "Titre", "B1": 42}})
assert resp.status_code == 200
from openpyxl import load_workbook
wb = load_workbook(xlsx_file)
assert wb["Budget"]["B2"].value == "=B1*2"
assert wb["Notes"]["A1"].value == "hello"
def test_save_empty_string_clears_cell(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Budget", "cells": {"A1": ""}})
assert resp.status_code == 200
from openpyxl import load_workbook
assert load_workbook(xlsx_file)["Budget"]["A1"].value is None
def test_save_creates_backup(self, client, xlsx_file):
from backend.services.backups import get_backup_dir
resp = self._save(client, {"sheet": "Budget", "cells": {"A1": "backup-me"}})
assert resp.status_code == 200
backup_dir = Path(get_backup_dir(VAULT, "budget.xlsx"))
assert backup_dir.is_dir()
assert list(backup_dir.glob("*.bak"))
def test_unknown_sheet_400(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Nope", "cells": {"A1": "x"}})
assert resp.status_code == 400
def test_invalid_cell_ref_400(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Budget", "cells": {"A-1": "x"}})
assert resp.status_code == 400
def test_wrong_extension_400(self, client, test_vault_dir):
(Path(test_vault_dir) / "note.md").write_text("# hi\n", encoding="utf-8")
resp = self._save(client, {"sheet": "Sheet", "cells": {"A1": "x"}}, path="note.md")
assert resp.status_code == 400
def test_missing_file_404(self, client):
resp = self._save(client, {"sheet": "Budget", "cells": {"A1": "x"}}, path="absent.xlsx")
assert resp.status_code == 404
def test_too_many_cells_400(self, client, xlsx_file):
cells = {f"A{i}": i for i in range(1, 502)}
resp = self._save(client, {"sheet": "Budget", "cells": cells})
assert resp.status_code == 400
def test_nested_value_400(self, client, xlsx_file):
resp = self._save(client, {"sheet": "Budget", "cells": {"A1": {"nested": 1}}})
assert resp.status_code == 400
def test_missing_sheet_field_400(self, client, xlsx_file):
resp = self._save(client, {"cells": {"A1": "x"}})
assert resp.status_code == 400