Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2be0b15bcf | ||
|
|
de6bde1613 | ||
|
|
fdc9f47a0d | ||
|
|
49c715199d | ||
|
|
fd192df036 | ||
|
|
8662d23ec8 | ||
|
|
10453d8dfe | ||
|
|
8235d632b8 | ||
|
|
f4c8504c8d | ||
|
|
4fb7c43e06 | ||
|
|
54caa48d1f | ||
|
|
f00a8bea8f | ||
|
|
55a9fcc0f4 | ||
|
|
4cdea956d7 | ||
|
|
ae06436f91 | ||
|
|
d0e10d4cc6 | ||
|
|
c94f065f80 | ||
|
|
36b4962efb |
@@ -72,6 +72,7 @@ jobs:
|
||||
node upload.test.mjs
|
||||
node config-ai-keys.test.mjs
|
||||
node xlsx-viewer.test.mjs
|
||||
node filechat.test.mjs
|
||||
else
|
||||
echo "tests/frontend/node_modules missing - installing jsdom"
|
||||
npm install --no-audit --no-fund --silent
|
||||
@@ -93,6 +94,7 @@ jobs:
|
||||
node upload.test.mjs
|
||||
node config-ai-keys.test.mjs
|
||||
node xlsx-viewer.test.mjs
|
||||
node filechat.test.mjs
|
||||
fi
|
||||
|
||||
# ── Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -44,6 +44,7 @@ test_dir/video/
|
||||
test_vault/images/
|
||||
test_vault/markdown/
|
||||
test_vault/budget.xlsx
|
||||
test_home/
|
||||
|
||||
# Tauri updater signing keys (private key — never commit)
|
||||
desktop/*.key
|
||||
|
||||
+342
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.55.0**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.67.2**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,347 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.67.2] — 2026-10-10
|
||||
|
||||
---
|
||||
|
||||
## [2.67.1] — 2026-10-10
|
||||
|
||||
---
|
||||
|
||||
## [2.67.0] — 2026-10-10
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#196 — Partage dirigé (itération 2 : visibilité & recherche)**
|
||||
- Icône « partagé » (share-2 bleu) dans l'arborescence sur tout fichier
|
||||
porteur d'un partage actif (créé ou reçu).
|
||||
- Dossier virtuel **Partage** à la racine du dossier personnel du
|
||||
destinataire : documents reçus listés avec l'icône de partage et
|
||||
« Partagé par X » en infobulle ; ouverture en lecture seule via la page
|
||||
de partage.
|
||||
- Les documents reçus sont **cherchables** : la recherche plein texte
|
||||
couvre les partages dirigés reçus (résultats `home-<user>/Partage/…`,
|
||||
clic → page de partage, jamais l'éditeur du vault d'autrui).
|
||||
- Cache d'icônes partagé invalidé à la création/révocation.
|
||||
- **Itération 3 :** correction « Directory not found: Partage » (le dossier
|
||||
virtuel ne déclenche plus la navigation) ; un fichier reçu s'ouvre dans
|
||||
l'application en onglet — arbre, recherche et dashboard — via résolution
|
||||
serveur `home-<user>/Partage/<fichier>` vers le fichier source
|
||||
(`/api/file`, raw, download) ; un `path` vide/null est rejeté (400) à la
|
||||
création d'un partage.
|
||||
- **Itération 4 :** correctifs d'ouverture — « Accès refusé à la vault … »
|
||||
(le partage dirigé est l'autorisation, l'ACL du vault source est
|
||||
court-circuitée après résolution `Partage/…`), « File not found:
|
||||
Partage/… » (les partages non adressés à l'utilisateur ne s'affichent
|
||||
plus comme reçus ; chemin canonique `Partage/<token>/<nom>` pour
|
||||
désambiguïser les fichiers homonymes).
|
||||
|
||||
---
|
||||
|
||||
## [2.66.0] — 2026-10-10
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#196 — Partage dirigé entre utilisateurs**
|
||||
- Un utilisateur peut partager un document à un ou plusieurs comptes
|
||||
précis (champ destinataires dans le dialogue de partage, noms séparés
|
||||
par des virgules) : les pages `/s/{token}` (+ PDF, téléchargement brut)
|
||||
exigent alors une session et n'acceptent que le créateur, les admins et
|
||||
les destinataires — 404 opaque sinon (l'existence d'un token dirigé
|
||||
n'est pas confirmable de l'extérieur).
|
||||
- Dashboard : les partages reçus s'affichent avec « Partagé par X » et
|
||||
s'ouvrent en lecture seule via la page de partage ; pas de bouton
|
||||
révoquer pour un destinataire.
|
||||
- Rétrocompatible : les partages publics existants (sans destinataires)
|
||||
restent accessibles anonymement.
|
||||
|
||||
### Sécurité
|
||||
|
||||
- **#196 — `GET /api/shares` scopé** : un utilisateur non-admin ne voit plus
|
||||
TOUS les partages de l'instance, mais uniquement ceux qu'il a créés ou
|
||||
reçus (fuite préexistante, intenable avec le partage user-to-user). La
|
||||
révocation d'un partage est réservée à son créateur ou à un admin.
|
||||
- i18n FR/EN (7 nouvelles clés), tests `tests/test_directed_shares.py` (9),
|
||||
fiche `docs/features/directed-shares-196.md`.
|
||||
|
||||
---
|
||||
|
||||
## [2.65.2] — 2026-10-10
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-111 — Admin : icône de fermeture + navigation du tableau de bord**
|
||||
- La modale « Administration — Utilisateurs » ouverte depuis la page `/admin` n'affichait pas l'icône du bouton fermer (lucide absent de `admin.html`) — même version du script UMD que `index.html` ajoutée.
|
||||
- Le tableau de bord admin affiche désormais **une seule section** à la fois (état initial : hash de l'URL ou « Statistiques temps réel ») au lieu des 4 sections empilées.
|
||||
|
||||
---
|
||||
|
||||
## [2.65.1] — 2026-10-10
|
||||
|
||||
---
|
||||
|
||||
## [2.65.0] — 2026-10-10
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#194 (UI) — Dossier personnel visible dans l'arborescence**
|
||||
- Les vaults `home-<user>` s'affichent **en premier** dans l'arborescence de
|
||||
la barre latérale (tri stable appliqué à la source, `frontend/js/sidebar.js`,
|
||||
donc aussi les sélecteurs de vault).
|
||||
- Icône **maison** lucide (`data-lucide="home"`, même style/`stroke:
|
||||
currentColor` que les autres icônes) via `getVaultIcon()` —
|
||||
`frontend/js/viewer.js`.
|
||||
- `docker-compose.test-win.yml` : montage `test_home:/vaults/Home` +
|
||||
`OBSIGATE_HOME_ROOT=/vaults/Home` pour tester la feature en local
|
||||
(dossier `test_home/` ignoré par git).
|
||||
- Tests : `sidebar-filters.test.mjs` +2 (icône maison pour `home-*`,
|
||||
gemme violette inchangée ailleurs).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **#194 — Un dossier perso n'est plus visible par les autres comptes**
|
||||
- `check_vault_access` (`backend/auth/middleware.py`) refuse désormais tout
|
||||
vault `home-*` qui n'est pas **explicitement** octroyé au compte : le
|
||||
`vaults: ["*"]` de l'admin ne couvre plus les dossiers persos (avant,
|
||||
l'admin voyait `home-<user>` de chaque utilisateur dans sa barre latérale).
|
||||
- Les filtres `« * »` écrits en dur dans les routes ont été remplacés par
|
||||
cet appel unique — dashboard, conflits, liens retour, favoris,
|
||||
abonnements push : partout la même règle.
|
||||
- `/api/search` filtre les résultats par vault accessible **avant**
|
||||
pagination (`search_vaults(is_allowed=…)`), sinon les notes personnelles
|
||||
d'un autre pouvait apparaître dans les résultats.
|
||||
- `_grant` octroie désormais le home aussi aux comptes `« * »` : l'admin
|
||||
voit **son** `home-admin`, réparé au démarrage pour les comptes existants.
|
||||
- Tests : `test_user_home.py` +2 (`« * »` sans homes, filtrage de la
|
||||
recherche), assertion API inversée dans `test_auth_api.py`.
|
||||
|
||||
---
|
||||
|
||||
## [2.64.0] — 2026-10-10
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#194 — Dossier personnel par utilisateur**
|
||||
- Chaque utilisateur créé reçoit `<OBSIGATE_HOME_ROOT>/<username>` (ex.
|
||||
`/NFS/OBSIDIAN_DOC/Home/<user>` côté hôte) monté comme un vault propre
|
||||
`home-<user>` : l'isolation utilise l'**ACL par vault existante**
|
||||
(`check_vault_access`), sans nouvelle ACL par chemin.
|
||||
- `backend/user_home.py` : `ensure_user_home()` idempotent (mkdir + registre
|
||||
vault + watcher + octroi dans `user.vaults`), appelé à la création d'un
|
||||
compte (`POST /api/auth/admin/users`) **et** au démarrage
|
||||
(`ensure_all_user_homes()` : migration des users existants + réparation
|
||||
d'un dossier ou d'un registre perdu).
|
||||
- **Registre persistant des vaults dynamiques** `data/vaults.json`
|
||||
(`load_vault_config()` le fusionne avec les env `VAULT_N_*`) : auparavant,
|
||||
tout vault ajouté à la volée (`/api/vaults/add`) disparaissait au premier
|
||||
reindex complet et au redémarrage.
|
||||
- Suppression d'un compte → vault refermé (index/watcher/registre) mais
|
||||
**dossier conservé** (aucune perte de données).
|
||||
- Correctif : `reload_single_vault` remet la config du vault après
|
||||
`remove_vault_from_index` (sinon le vault disparaissait de
|
||||
`vault_config` jusqu'au rechargement complet).
|
||||
- Déploiement : montage unique `/NFS/OBSIDIAN_DOC/Home:/vaults/Home` +
|
||||
variable `OBSIGATE_HOME_ROOT` (fonctionnalité inactive sans cette
|
||||
variable — dev, tests, desktop inchangés).
|
||||
- Tests : `tests/test_user_home.py` (10) + 2 tests API dans
|
||||
`test_auth_api.py` (provisionnement, isolation, retrait).
|
||||
|
||||
---
|
||||
|
||||
## [2.63.0] — 2026-10-09
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#193 — Chat : un post s'affiche comme un document markdown**
|
||||
- Le rendu vient du **serveur** : `backend/file_chat.py` ajoute un champ
|
||||
`html` à chaque message en réutilisant le pipeline des documents
|
||||
(`backend/render.py::_render_markdown` → mistune + wikilinks + masquage des
|
||||
secrets + **sanitizer BUG-021**). Pas de second moteur markdown côté client,
|
||||
donc pas de nouvelle surface XSS.
|
||||
- Titres, listes, tableaux, citations, notes, listes de tâches et **blocs de
|
||||
code colorés** : le front injecte le HTML dans un `.md-content` puis passe
|
||||
`safeHighlight()` (highlight.js + alias de langages) sur chaque `pre code`,
|
||||
exactement comme la visionneuse de documents.
|
||||
- Le `html` est calculé **à la lecture** (et à l'ajout, pour l'écho SSE) :
|
||||
rien n'est persisté dans `data/chats/*.json`, un message reste du texte.
|
||||
- Repli conservé : sans `html`, le texte brut + liens cliquables s'affichent
|
||||
comme avant ; un échec de rendu n'emporte jamais le message.
|
||||
- CSS : `.file-chat-md` annule le `pre-wrap` de la bulle (sinon les retours à
|
||||
la ligne du HTML source devenaient des lignes vides) et compacte
|
||||
`.md-content` (tailles, `pre`/`table` défilants) pour une bulle de chat.
|
||||
- Tests : pytest `TestMarkdownRendering` +7 (54 → **61** dans
|
||||
`test_file_chat.py`), JSDOM `filechat.test.mjs` +2 (25 → **27**).
|
||||
|
||||
---
|
||||
|
||||
## [2.62.0] — 2026-10-09
|
||||
|
||||
### Modifié
|
||||
|
||||
- **BUG-110 — Chat : refonte visuelle des posts et des indicateurs**
|
||||
- **Pièces jointes image** : boutons **icônes seules** (24 px, `aria-label`)
|
||||
rangés **sous l'image**, alignés à droite — télécharger, ouvrir.
|
||||
- **Clic sur une image = onglet de l'app** : le clic (ou le bouton « ouvrir »)
|
||||
affiche la pièce jointe dans un **onglet**, avec le même visualiseur que les
|
||||
images de vault (zoom/pan/plein écran/lightbox) — plus d'ouverture dans un
|
||||
onglet du navigateur. `TabManager.openChatImage()` + branche `chatImage` de
|
||||
`activate()` ; `renderImageViewer()` accepte une `url` directe (une pièce
|
||||
jointe de chat ne vit pas dans un vault).
|
||||
- **Indicateur de non-lus** : point de couleur pulsant sur l'icône « Chat » de
|
||||
la sidebar tant qu'il reste des messages non lus, en plus du badge chiffré.
|
||||
- **Notification OS** : quand l'app n'est pas au premier plan (`document.hidden`
|
||||
ou hors focus), un message entrant déclenche une notification navigateur
|
||||
(titre « Nouveau message de {user} », aperçu du texte, clic → retour sur
|
||||
l'app). Permission demandée au premier clic d'ouverture du chat.
|
||||
- **Suppression réellement annulable** : le `DELETE` est **différé de 10 s** ;
|
||||
la ligne passe à « Ce message a été supprimé » avec un bouton « Annuler » qui
|
||||
réarme le message, et un échec réseau le rend. (L'ancien « Annuler »
|
||||
restaurait une copie DOM d'un message déjà détruit côté serveur.)
|
||||
- i18n FR/EN : `chat.deleted_message`, `chat.undo`, `chat.download_image`,
|
||||
`chat.view_image` — clés mortes retirées (`chat.undo_success`,
|
||||
`chat.undo_error`, `chat.new_messages_indicator`).
|
||||
- Tests : `tests/frontend/filechat.test.mjs` 19 → **25** (6 tests #110).
|
||||
|
||||
---
|
||||
|
||||
## [2.61.0] — 2026-10-08
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#192 - Chat : boîte de saisie auto-agrandissante + accusé de réception**
|
||||
- **Saisie auto-agrandissante** : la boîte d'envoi (panneau document +
|
||||
sidebar) est un `textarea` dont la hauteur suit le contenu (plafond
|
||||
160 px puis défilement) — on relit son message entier avant l'envoi.
|
||||
`Entrée` envoie, `Maj+Entrée` saute une ligne, la hauteur repasse à une
|
||||
ligne après l'envoi.
|
||||
- **Accusé de réception** : chaque conversation stocke
|
||||
`{utilisateur: dernier_ts_de_lecture}` ; l'expéditeur voit **✓ envoyé**
|
||||
puis **✓✓ lu** sur ses propres messages (un message est lu dès qu'un
|
||||
*autre* participant ouvre la conversation). Le `GET` d'historique renvoie
|
||||
la carte de lecture, `POST /api/chat/read` la met à jour et la diffuse
|
||||
en SSE (`chat_read`) — les indicateurs basculent en direct.
|
||||
- **Notification** : quand un message passe à ✓✓, l'expéditeur reçoit le
|
||||
toast « {user} a lu votre message » (FR/EN) ; un message reçu pendant
|
||||
l'affichage du canal accuse réception aussitôt.
|
||||
- Verrou global sur le read-modify-write du store de chat (une lecture ne
|
||||
peut plus faire disparaître un message).
|
||||
- Tests : pytest `TestReadReceipts` (7) + JSDOM `filechat.test.mjs` +3
|
||||
(19).
|
||||
|
||||
### Corrigé
|
||||
|
||||
- **BUG-109 - Tuile de lien : l'image de la carte ne s'affichait jamais** —
|
||||
la CSP (`img-src 'self' data: blob:`) bloque toute `og:image` distante et
|
||||
les images relatives étaient résolues contre l'origine d'ObsiGate (404).
|
||||
La vignette est désormais téléversée à l'envoi dans `chat_uploads`
|
||||
(`/api/chat/attachment/<uuid>`, même origine, garde SSRF + plafond 2 Mo +
|
||||
allow-list d'extensions) ; échec du téléchargement → carte conservée sans
|
||||
vignette. Les cartes déjà publiées gardent leur ancien lien (elles
|
||||
étaient déjà muettes).
|
||||
|
||||
---
|
||||
|
||||
## [2.60.0] — 2026-10-08
|
||||
|
||||
---
|
||||
|
||||
## [2.59.0] — 2026-10-08
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#191 - Chat : suppression de post, messages privés, boîte compacte, link preview**
|
||||
- **Suppression d'un post** : bouton 🗑 sur ses propres messages (et sur
|
||||
tout le monde pour un admin), confirmation, `DELETE /api/chat/{id}` et
|
||||
`/api/chat/dm/{user}/{id}`, retrait en direct chez tous les clients
|
||||
(SSE `chat_deleted`).
|
||||
- **Messages privés 2 utilisateurs** : rangée de canaux au-dessus du fil
|
||||
(« Général » + un bouton par utilisateur), conversations isolées
|
||||
(vault `__dm__`, chemin `alice|bob` trié), placeholder dédié,
|
||||
compteurs de non-lus par canal, `GET /api/chat/users` pour la liste
|
||||
des destinataires (sans hash/mot de passe, sans soi-même).
|
||||
- **Boîte d'édition compacte** : bouton d'envoi réduit à une icône
|
||||
(panneau document + sidebar), la saisie garde toute la largeur.
|
||||
- **Link preview** : métadonnées OpenGraph récupérées à l'envoi
|
||||
(titre, description, image, site), carte cliquable esthétique,
|
||||
garde SSRF réutilisée (host privé/loopback rejeté), cache borné
|
||||
200 entrées, échec silencieux (URL morte → le message passe sans carte).
|
||||
- Tests : pytest +13 (45 au total file_chat), JSDOM `filechat.test.mjs`
|
||||
+5 (16).
|
||||
|
||||
---
|
||||
|
||||
## [2.58.0] — 2026-10-08
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#190 - Chat : suivi du document, chat général en onglet sidebar**
|
||||
- **Suivi du document** : le panneau du chat affiche le document ciblé
|
||||
(titre + chemin au survol) et **suit automatiquement** le document
|
||||
rendu — changer de fichier re-cible le panneau ouvert.
|
||||
- **Onglet « Chat »** (dernier, après Historique IA) dans la sidebar :
|
||||
conversation générale non liée à un fichier, **indicateur de messages
|
||||
non lus** (pastille rouge sur l'onglet), **pièces jointes image/vidéo**
|
||||
(upload avec allow-list d'extensions, 25 MB max, nom UUID), **URL
|
||||
cliquables** dans les messages, date/heure d'envoi.
|
||||
- **Ordre des onglets** : la barre de filtre passe **sous** les onglets
|
||||
et sert de **recherche dans le chat** quand l'onglet Chat est actif
|
||||
(texte + auteur).
|
||||
- Backend : `GET/POST /api/chat`, `POST /api/chat/upload`,
|
||||
`GET /api/chat/attachment/{name}` (même store #169 via les sentinelles
|
||||
`__global__/general`), broadcast SSE `chat_message` réutilisé.
|
||||
- Tests : pytest +12 (27 au total), JSDOM `filechat.test.mjs` +5 (11).
|
||||
|
||||
---
|
||||
|
||||
## [2.57.1] — 2026-10-08
|
||||
|
||||
---
|
||||
|
||||
## [2.57.0] — 2026-10-08
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#169 - Chat intégré par fichier**
|
||||
- Bouton **💬 Chat** dans la barre d'actions du document : ouvre un panneau
|
||||
latéral avec l'historique de la discussion et la saisie d'un message.
|
||||
- Persistance par fichier (`data/chats/`, plafond 500 messages, texte
|
||||
tronqué à 4000 caractères) ; routes `GET/POST /api/file/{vault}/chat`
|
||||
protégées (auth + accès vault + contrôle de traversal).
|
||||
- Temps réel via le transport SSE existant (événement `chat_message`) :
|
||||
le panneau ouvert s'incrémente en direct, **toast de notification**
|
||||
« Nouveau message de … » si le panneau est fermé ou affiche un autre
|
||||
fichier (pas de doublon côté émetteur).
|
||||
- Mobile : panneau plein écran ; i18n FR/EN.
|
||||
- Tests : pytest `test_file_chat.py` (15) + JSDOM `filechat.test.mjs` (6),
|
||||
ajoutés au pipeline CI.
|
||||
|
||||
---
|
||||
|
||||
## [2.56.0] — 2026-10-08
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **#189 - Tableur : mise en page mobile (barres repliées, grille prioritaire)**
|
||||
- Sur mobile (≤ 768 px), la **barre de menus** (Fichier, Modifier…), le
|
||||
**ruban** de formatage et le **groupe Recherche** sont **repliés par
|
||||
défaut** : la barre des feuilles et la **barre de formule (fx)** restent
|
||||
toujours visibles, et la grille récupère la hauteur d'écran perdue
|
||||
(l'ensemble dépassait 50 % de l'écran).
|
||||
- Un **bouton unique** (`#xlsx-tools-toggle`, icône ☰) dans la barre des
|
||||
feuilles déplie/replie le tout (`aria-expanded`, état actif) ; sur
|
||||
desktop il reste masqué et rien ne bouge.
|
||||
- **Ergonomie tactile** : cibles ≥ 44 px (boutons, menus, onglets), groupes
|
||||
du ruban qui s'enroulent au lieu de déborder à cette taille, onglets de
|
||||
feuilles sur une seule ligne qui **défile horizontalement**, champs de la
|
||||
barre de formule en 16 px (pas de zoom iOS au focus), barre de formule qui
|
||||
passe en 2 lignes quand les outils sont ouverts.
|
||||
- Tests : JSDOM `xlsx-viewer.test.mjs` (bascule + règles CSS, 165/165) et
|
||||
E2E mobile (repli par défaut, dépliage, ruban sans débordement, cibles
|
||||
44 px).
|
||||
|
||||
---
|
||||
|
||||
## [2.55.0] — 2026-10-08
|
||||
|
||||
### Ajouté
|
||||
|
||||
+5
-4
@@ -4,7 +4,7 @@
|
||||
|
||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -85,7 +85,7 @@ Les **guides d'utilisation** pas à pas se trouvent dans [`docs/GUIDES/`](docs/G
|
||||
- **🖼️ Images Obsidian** : Support complet des syntaxes d'images Obsidian avec résolution intelligente
|
||||
- **🎬 Audio & vidéo** : Lecteurs HTML5 intégrés (`.mp3 .wav .flac .mp4 .webm`…) avec streaming HTTP Range (lecture, déplacement, plein écran) et **lecture persistante** (mini-lecteur flottant / mini-fenêtre vidéo, retour au média ou arrêt à tout moment, contrôles écran verrouillé via Media Session), repli téléchargement si le format n'est pas lisible par le navigateur
|
||||
- **🎨 Diagrammes Excalidraw** : Visualiseur/éditeur natif des fichiers `.excalidraw` et `.excalidraw.md` (iframe sandboxée, auto-save, thème clair/sombre, texte des diagrammes indexé pour la recherche)
|
||||
- **📊 Tableurs Excel** : les fichiers `.xlsx` et `.xlsm` s'ouvrent dans un visualiseur dédié — un tableau par feuille avec onglets, en-têtes A1 et édition directe des cellules (`PUT /api/file/{vault}/xlsx/save`, backup automatique, écriture atomique), plus le téléchargement du fichier d'origine. Le visualiseur rend polices, couleurs, cellules fusionnées et volets figés, et offre navigation et raccourcis clavier (`Ctrl+S`, `Suppr`, `F2`, `Ctrl+Origine/Fin`, `PgPréc/PgSuiv`, `Ctrl+flèches`), barre de formule avec noms de fonctions, zone Nom éditable (« Atteindre » `A1:B3`), presse-papiers de plage (copier/couper/coller un bloc, depuis ou vers Excel), un menu **Mise en forme** (gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur — `PUT /api/file/{vault}/xlsx/style`), tri/filtre/recherche sur toutes les feuilles, export CSV/Markdown/HTML et impression (sélection ou feuille), édition de la structure (feuilles, lignes, colonnes) et un tableau de bord du classeur (plages nommées, détection graphiques/TCD, stats par feuille) ; un `.csv` s'édite dans la même grille (RFC 4180) tandis que `.xls` et `.ods` s'ouvrent en lecture seule. Les classeurs contenant des éléments qu'ObsiGate ne peut pas conserver (valeurs calculées, segments, contrôles de formulaire, signature…) affichent un **avertissement** et demandent confirmation avant l'enregistrement ; une saisie commençant par `=` ou `@` est stockée comme texte sauf activation du bouton `f(x)`, et les écritures concurrentes d'un autre poste sont détectées (`If-Match` → « Réessayer »). L'assistant IA peut lister les feuilles, injecter un tableau borné dans son contexte, rechercher dans le classeur, analyser une plage, modifier des cellules et ajouter des lignes — sur `.xlsx`, `.xlsm` et `.csv`
|
||||
- **📊 Tableurs Excel** : les fichiers `.xlsx` et `.xlsm` s'ouvrent dans un visualiseur dédié — un tableau par feuille avec onglets, en-têtes A1 et édition directe des cellules (`PUT /api/file/{vault}/xlsx/save`, backup automatique, écriture atomique), plus le téléchargement du fichier d'origine. Le visualiseur rend polices, couleurs, cellules fusionnées et volets figés, et offre navigation et raccourcis clavier (`Ctrl+S`, `Suppr`, `F2`, `Ctrl+Origine/Fin`, `PgPréc/PgSuiv`, `Ctrl+flèches`), barre de formule avec noms de fonctions, zone Nom éditable (« Atteindre » `A1:B3`), presse-papiers de plage (copier/couper/coller un bloc, depuis ou vers Excel), un menu **Mise en forme** (gras/italique/souligné, alignements, couleurs, formats de nombre, fusions, volets figés, largeur/hauteur — `PUT /api/file/{vault}/xlsx/style`), tri/filtre/recherche sur toutes les feuilles, export CSV/Markdown/HTML et impression (sélection ou feuille), édition de la structure (feuilles, lignes, colonnes) et un tableau de bord du classeur (plages nommées, détection graphiques/TCD, stats par feuille) ; un `.csv` s'édite dans la même grille (RFC 4180) tandis que `.xls` et `.ods` s'ouvrent en lecture seule. Les classeurs contenant des éléments qu'ObsiGate ne peut pas conserver (valeurs calculées, segments, contrôles de formulaire, signature…) affichent un **avertissement** et demandent confirmation avant l'enregistrement ; une saisie commençant par `=` ou `@` est stockée comme texte sauf activation du bouton `f(x)`, et les écritures concurrentes d'un autre poste sont détectées (`If-Match` → « Réessayer »). L'assistant IA peut lister les feuilles, injecter un tableau borné dans son contexte, rechercher dans le classeur, analyser une plage, modifier des cellules et ajouter des lignes — sur `.xlsx`, `.xlsm` et `.csv`. Sur mobile (≤ 768 px), la barre de menus et le ruban sont **repliés par défaut** — un bouton ☰ les déplie — pour que la grille occupe toute la hauteur d'écran
|
||||
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
|
||||
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
|
||||
- **📡 Synchronisation temps réel** : Surveillance automatique des fichiers via watchdog avec mise à jour incrémentale de l'index
|
||||
@@ -222,6 +222,7 @@ Les vaults sont configurées par paires `VAULT_N_NAME` / `VAULT_N_PATH` (N = 1,
|
||||
| `VAULT_1_PATH` | Chemin dans le conteneur | `/vaults/Obsidian-RECETTES` |
|
||||
| `VAULT_1_ATTACHMENTS_PATH` | Dossier d'attachements (optionnel) | `06_Boite_a_Outils/6.2_Attachments` |
|
||||
| `VAULT_1_SCAN_ATTACHMENTS` | Scan d'images au démarrage (défaut : true) | `true` |
|
||||
| `OBSIGATE_HOME_ROOT` | Racine des dossiers personnels (un vault `home-<user>` par compte). Absente = fonctionnalité désactivée. | `/vaults/Home` |
|
||||
|
||||
**Règles de nommage :** lettres, chiffres et tirets uniquement ; pas d'espaces ; le nom doit correspondre au chemin dans le conteneur.
|
||||
|
||||
@@ -976,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.55.0).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.67.2).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.55.0 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.67.2 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](https://www.python.org/)
|
||||
@@ -84,7 +84,7 @@ Step-by-step **user guides** live in [`docs/GUIDES/`](docs/GUIDES/):
|
||||
- **🖼️ Obsidian Images** : Full support for all Obsidian image syntaxes with intelligent resolution
|
||||
- **🎬 Audio & video** : Built-in HTML5 players (`.mp3 .wav .flac .mp4 .webm`…) with HTTP Range streaming (play, seek, fullscreen) and **persistent playback** (floating mini-player / mini video window, return to media or stop anytime, lock-screen controls via Media Session), falling back to download when the format is not playable in the browser
|
||||
- **🎨 Excalidraw Diagrams** : Native viewer/editor for `.excalidraw` and `.excalidraw.md` files (sandboxed iframe, autosave, dark/light theme, diagram text indexed for search)
|
||||
- **📊 Excel Spreadsheets** : `.xlsx` and `.xlsm` files open in a dedicated viewer — one table per sheet with tabs, A1 headers and inline cell editing (`PUT /api/file/{vault}/xlsx/save`, automatic backup, atomic write), plus download of the original file. The viewer renders fonts, colors, merged cells and frozen panes, offers keyboard navigation and shortcuts (`Ctrl+S`, `Delete`, `F2`, `Ctrl+Home/End`, `PgUp/PgDn`, `Ctrl+arrows`), a formula bar with function suggestions, an editable Name Box ("go to" `A1:B3`), a range clipboard (copy/cut/paste a block, from or to Excel), a **Format** menu (bold/italic/underline, alignments, font & fill colours, number formats, merges, frozen panes, column width/row height — `PUT /api/file/{vault}/xlsx/style`), sort/filter/find across every sheet, CSV/Markdown/HTML export and printing (selection or sheet), sheet & row/column structure editing and a workbook dashboard (named ranges, charts/pivot detection, per-sheet stats); `.csv` is edited in the same grid (RFC 4180) while `.xls` and `.ods` open read-only. Workbooks holding elements ObsiGate cannot preserve (cached values, slicers, form controls, signature…) show a **warning** and ask for confirmation before saving; a value starting with `=` or `@` is stored as text unless the `f(x)` toggle is enabled, and concurrent writes from another process are detected (`If-Match` → "Retry"). The AI assistant can list sheets, dump a bounded table to its context, search the workbook, analyze a range, update cells and append rows — on `.xlsx`, `.xlsm` and `.csv`
|
||||
- **📊 Excel Spreadsheets** : `.xlsx` and `.xlsm` files open in a dedicated viewer — one table per sheet with tabs, A1 headers and inline cell editing (`PUT /api/file/{vault}/xlsx/save`, automatic backup, atomic write), plus download of the original file. The viewer renders fonts, colors, merged cells and frozen panes, offers keyboard navigation and shortcuts (`Ctrl+S`, `Delete`, `F2`, `Ctrl+Home/End`, `PgUp/PgDn`, `Ctrl+arrows`), a formula bar with function suggestions, an editable Name Box ("go to" `A1:B3`), a range clipboard (copy/cut/paste a block, from or to Excel), a **Format** menu (bold/italic/underline, alignments, font & fill colours, number formats, merges, frozen panes, column width/row height — `PUT /api/file/{vault}/xlsx/style`), sort/filter/find across every sheet, CSV/Markdown/HTML export and printing (selection or sheet), sheet & row/column structure editing and a workbook dashboard (named ranges, charts/pivot detection, per-sheet stats); `.csv` is edited in the same grid (RFC 4180) while `.xls` and `.ods` open read-only. Workbooks holding elements ObsiGate cannot preserve (cached values, slicers, form controls, signature…) show a **warning** and ask for confirmation before saving; a value starting with `=` or `@` is stored as text unless the `f(x)` toggle is enabled, and concurrent writes from another process are detected (`If-Match` → "Retry"). The AI assistant can list sheets, dump a bounded table to its context, search the workbook, analyze a range, update cells and append rows — on `.xlsx`, `.xlsm` and `.csv`. On mobile (≤ 768 px), the menu bar and ribbon are **collapsed by default** — a single ☰ button expands them — so the grid gets the full screen height
|
||||
- **🎨 Syntax Highlight** : Syntax highlighting for code blocks
|
||||
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
|
||||
- **📡 Real-time Sync** : Automatic file monitoring via watchdog with incremental index updates
|
||||
@@ -238,6 +238,7 @@ Vaults are configured using pairs of `VAULT_N_NAME` / `VAULT_N_PATH` variables (
|
||||
| `VAULT_1_SCAN_ATTACHMENTS` | Enable image scanning on startup (optional, default: true) | `true` |
|
||||
| `VAULT_2_NAME` | Display name of the vault | `IT` |
|
||||
| `VAULT_2_PATH` | Path inside the container | `/vaults/Obsidian_IT` |
|
||||
| `OBSIGATE_HOME_ROOT` | Root folder for per-user home directories (one `home-<user>` vault per account). Unset = feature disabled. | `/vaults/Home` |
|
||||
|
||||
**Naming rules:**
|
||||
- Use only letters, numbers, and hyphens
|
||||
@@ -1151,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.55.0).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.67.2).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.55.0 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.67.2 | Last updated: September 2026*
|
||||
|
||||
@@ -142,6 +142,11 @@ def require_admin(current_user=Depends(require_auth)):
|
||||
return current_user
|
||||
|
||||
|
||||
def is_home_vault(vault_name: str) -> bool:
|
||||
"""Un dossier personnel (#194) : vault « home-<user> »."""
|
||||
return vault_name.startswith("home-")
|
||||
|
||||
|
||||
def check_vault_access(vault_name: str, user: dict) -> bool:
|
||||
"""Check if a user has access to a specific vault.
|
||||
|
||||
@@ -149,8 +154,13 @@ def check_vault_access(vault_name: str, user: dict) -> bool:
|
||||
- vaults == ["*"] → full access (admin default)
|
||||
- vault_name in vaults → access granted
|
||||
- otherwise → denied
|
||||
|
||||
#194 : un dossier personnel n'est **jamais** couvert par ``*`` — sinon
|
||||
un admin (``vaults: ["*"]``) verrait le dossier de chaque utilisateur.
|
||||
"""
|
||||
vaults = user.get("_token_vaults") or user.get("vaults", [])
|
||||
if is_home_vault(vault_name):
|
||||
return vault_name in vaults
|
||||
if "*" in vaults:
|
||||
return True
|
||||
return vault_name in vaults
|
||||
|
||||
+17
-2
@@ -946,9 +946,20 @@ async def create_user_endpoint(
|
||||
user = create_user(
|
||||
req.username, req.password, req.role, req.vaults, req.display_name
|
||||
)
|
||||
return user
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
# #194 : dossier perso — best effort, réparé au démarrage si le disque
|
||||
# (NFS) est indisponible (ensure_user_home journalise l'erreur).
|
||||
from backend.auth.user_store import get_user
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
await ensure_user_home(req.username)
|
||||
# La réponse doit refléter l'octroi du vault perso (#194) : create_user a
|
||||
# renvoyé un instantané construit avant l'octroi.
|
||||
fresh = get_user(req.username)
|
||||
if fresh:
|
||||
user["vaults"] = fresh.get("vaults", [])
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/admin/users/{username}")
|
||||
@@ -975,9 +986,13 @@ async def delete_user_endpoint(
|
||||
raise HTTPException(400, "Impossible de supprimer son propre compte")
|
||||
try:
|
||||
delete_user(username)
|
||||
return {"message": f"Utilisateur '{username}' supprimé"}
|
||||
except ValueError as e:
|
||||
raise HTTPException(404, str(e))
|
||||
# #194 : fermer le vault du dossier perso (le dossier est conservé).
|
||||
from backend.user_home import release_user_home
|
||||
|
||||
await release_user_home(username)
|
||||
return {"message": f"Utilisateur '{username}' supprimé"}
|
||||
|
||||
|
||||
# ── API / MCP tokens (feature #107) ──────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
# backend/file_chat.py — historique de discussion (#169, #190)
|
||||
"""Chat history persisted under ``data/chats/``.
|
||||
|
||||
One JSON document per ``(vault, path)`` pair, keyed by a SHA-256 of both so
|
||||
the filename never carries user-controlled path separators. Writes are
|
||||
atomic (tmp + move) and the message list is capped at
|
||||
:data:`MAX_MESSAGES` to bound growth.
|
||||
|
||||
#190 adds the **general chat**: the same store addressed with the reserved
|
||||
sentinels (:data:`GLOBAL_VAULT` / :data:`GLOBAL_PATH`), so no second
|
||||
implementation. Messages may carry an ``attachment`` (image/video/url)
|
||||
uploaded under ``data/chat_uploads/``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from backend.render import _render_markdown
|
||||
|
||||
logger = logging.getLogger("obsigate.file_chat")
|
||||
|
||||
CHAT_DIR = Path("data/chats")
|
||||
MAX_MESSAGES = 500 # retention ceiling per file (oldest dropped first)
|
||||
MAX_TEXT = 4000 # characters per message
|
||||
|
||||
# ponytail: global lock over read-modify-write — chat writes are HTTP-only and
|
||||
# serialized anyway, this just makes losing a message to a future thread (or a
|
||||
# watcher hook) impossible; per-file locks if it ever becomes contended.
|
||||
_LOCK = threading.RLock()
|
||||
|
||||
# #190 — general (non file-bound) conversation, stored like any other one.
|
||||
GLOBAL_VAULT = "__global__"
|
||||
GLOBAL_PATH = "general"
|
||||
|
||||
# #191 — private (2 users) conversations reuse the same store: the vault is
|
||||
# the reserved sentinel and the path is the sorted username pair, so the
|
||||
# storage key never depends on who asks.
|
||||
DM_VAULT = "__dm__"
|
||||
|
||||
# #190 — attachments (image/video) live outside the vaults.
|
||||
UPLOAD_DIR = Path("data/chat_uploads")
|
||||
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB per attachment
|
||||
ALLOWED_ATTACH_EXT = {
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg",
|
||||
".mp4", ".webm", ".ogg", ".mov", ".m4v",
|
||||
}
|
||||
|
||||
# #191 — link preview fetch budget
|
||||
PREVIEW_TIMEOUT = 5.0 # seconds
|
||||
PREVIEW_MAX_BYTES = 512 * 1024 # only the head of the page is parsed
|
||||
PREVIEW_IMAGE_MAX = 2 * 1024 * 1024 # ponytail: 2 MB ceiling on a thumbnail
|
||||
|
||||
|
||||
def _chat_file(vault: str, path: str) -> Path:
|
||||
"""Return the chat file for *(vault, path)* (hashed, traversal-proof)."""
|
||||
CHAT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
key = hashlib.sha256(f"{vault}\0{path}".encode()).hexdigest()[:32]
|
||||
return CHAT_DIR / f"{key}.json"
|
||||
|
||||
|
||||
def _read(vault: str, path: str) -> dict[str, Any]:
|
||||
"""Load the raw chat document (empty structure when missing/corrupt)."""
|
||||
file = _chat_file(vault, path)
|
||||
if not file.exists():
|
||||
return {"vault": vault, "path": path, "messages": []}
|
||||
try:
|
||||
doc = json.loads(file.read_text(encoding="utf-8"))
|
||||
if not isinstance(doc.get("messages"), list):
|
||||
raise TypeError("messages is not a list") # caught by the handler below
|
||||
return doc
|
||||
except Exception as e:
|
||||
logger.error("Failed to read chat for %s/%s: %s", vault, path, e)
|
||||
return {"vault": vault, "path": path, "messages": []}
|
||||
|
||||
|
||||
def _write(file: Path, doc: dict[str, Any]) -> None:
|
||||
"""Atomically persist *doc* (tmp file + rename)."""
|
||||
try:
|
||||
tmp = file.with_suffix(".tmp")
|
||||
tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=1), encoding="utf-8")
|
||||
shutil.move(str(tmp), str(file))
|
||||
except Exception as e:
|
||||
logger.error("Failed to write chat %s: %s", file.name, e)
|
||||
|
||||
|
||||
def get_messages(vault: str, path: str) -> list[dict[str, Any]]:
|
||||
"""Return the chat history for *(vault, path)* (chronological).
|
||||
|
||||
Every message carries its rendered ``html`` (#193): same markdown pipeline
|
||||
as a document (mistune + sanitizer), computed on read so a template change
|
||||
applies to the whole history without rewriting the JSON store.
|
||||
"""
|
||||
return [_decorate(m, vault) for m in _read(vault, path).get("messages", [])]
|
||||
|
||||
|
||||
def _decorate(msg: dict[str, Any], vault: str) -> dict[str, Any]:
|
||||
"""Return a copy of *msg* with its sanitized markdown ``html`` (#193).
|
||||
|
||||
The stored message is left untouched (``html`` is never persisted). A
|
||||
rendering failure must never break the chat: the message goes out with an
|
||||
empty ``html`` and the client falls back to plain text.
|
||||
"""
|
||||
try:
|
||||
html = _render_markdown(msg.get("text", ""), vault)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
logger.warning("chat markdown rendering failed: %s", e)
|
||||
html = ""
|
||||
return {**msg, "html": html}
|
||||
|
||||
|
||||
def add_message(
|
||||
vault: str,
|
||||
path: str,
|
||||
user: str,
|
||||
text: str,
|
||||
attachment: dict[str, Any] | None = None,
|
||||
preview: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Append a message and persist it. Returns the stored message.
|
||||
|
||||
The list is capped at :data:`MAX_MESSAGES` (oldest dropped first).
|
||||
*attachment* (#190) is ``{name, url, mime, kind}`` for image/video/url;
|
||||
*preview* (#191) is the OpenGraph card of the first URL in *text*.
|
||||
"""
|
||||
text = (text or "").strip()[:MAX_TEXT]
|
||||
msg: dict[str, Any] = {
|
||||
"id": uuid.uuid4().hex[:12],
|
||||
"user": user or "anonyme",
|
||||
"text": text,
|
||||
"ts": time.time(),
|
||||
}
|
||||
if attachment:
|
||||
msg["attachment"] = attachment
|
||||
if preview:
|
||||
msg["preview"] = preview
|
||||
return _append(vault, path, msg)
|
||||
|
||||
|
||||
def _append(
|
||||
vault: str,
|
||||
path: str,
|
||||
msg: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Cap, persist and return *msg* (shared by file, global and DM chats).
|
||||
|
||||
The read-modify-write of the whole document happens under ``_LOCK`` so a
|
||||
concurrent writer can never drop a message (same class of bug as
|
||||
BUG-029 on ``users.json``).
|
||||
"""
|
||||
with _LOCK:
|
||||
doc = _read(vault, path)
|
||||
messages = list(doc.get("messages", []))
|
||||
messages.append(msg)
|
||||
if len(messages) > MAX_MESSAGES:
|
||||
messages = messages[-MAX_MESSAGES:]
|
||||
doc["messages"] = messages
|
||||
_write(_chat_file(vault, path), doc)
|
||||
return _decorate(msg, vault) # #193 — le html part avec l'écho SSE
|
||||
|
||||
|
||||
# --- #192 : accusé de réception ---------------------------------------------
|
||||
|
||||
def get_read(vault: str, path: str) -> dict[str, float]:
|
||||
"""Return ``{username: last_read_ts}`` for a conversation (#192)."""
|
||||
return {str(u): float(ts) for u, ts in (_read(vault, path).get("read") or {}).items()}
|
||||
|
||||
|
||||
def mark_read(vault: str, path: str, user: str) -> dict[str, float]:
|
||||
"""Record that *user* has seen the conversation (#192).
|
||||
|
||||
Returns the whole read map so the caller can broadcast it on SSE: a
|
||||
sender learns their messages were received as soon as the recipient
|
||||
displays the conversation.
|
||||
"""
|
||||
if not user:
|
||||
return get_read(vault, path)
|
||||
with _LOCK:
|
||||
doc = _read(vault, path)
|
||||
read = {str(u): float(ts) for u, ts in (doc.get("read") or {}).items()}
|
||||
read[user] = time.time()
|
||||
doc["read"] = read
|
||||
_write(_chat_file(vault, path), doc)
|
||||
return read
|
||||
|
||||
|
||||
# --- #191 : messages privés (2 utilisateurs) -------------------------------
|
||||
|
||||
def dm_path(user_a: str, user_b: str) -> str:
|
||||
"""Storage path for the private conversation between two users.
|
||||
|
||||
The pair is sorted so both participants address the same document.
|
||||
"""
|
||||
return "|".join(sorted([user_a, user_b]))
|
||||
|
||||
|
||||
def get_dm_messages(user_a: str, user_b: str) -> list[dict[str, Any]]:
|
||||
"""Return the private history between two users (chronological)."""
|
||||
return get_messages(DM_VAULT, dm_path(user_a, user_b))
|
||||
|
||||
|
||||
def add_dm_message(
|
||||
user_a: str,
|
||||
user_b: str,
|
||||
author: str,
|
||||
text: str,
|
||||
attachment: dict[str, Any] | None = None,
|
||||
preview: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Append a private message. Returns the stored message."""
|
||||
return add_message(DM_VAULT, dm_path(user_a, user_b), author, text, attachment, preview)
|
||||
|
||||
|
||||
# --- #191 : suppression -----------------------------------------------------
|
||||
|
||||
def delete_message(vault: str, path: str, message_id: str) -> bool:
|
||||
"""Remove one message from a conversation. True when it existed."""
|
||||
with _LOCK:
|
||||
doc = _read(vault, path)
|
||||
messages = list(doc.get("messages", []))
|
||||
kept = [m for m in messages if m.get("id") != message_id]
|
||||
if len(kept) == len(messages):
|
||||
return False
|
||||
doc["messages"] = kept
|
||||
_write(_chat_file(vault, path), doc)
|
||||
return True
|
||||
|
||||
|
||||
# --- #190 : chat général (conversation centrale, hors fichier) -------------
|
||||
|
||||
def get_global_messages() -> list[dict[str, Any]]:
|
||||
"""Return the general-chat history (chronological)."""
|
||||
return get_messages(GLOBAL_VAULT, GLOBAL_PATH)
|
||||
|
||||
|
||||
def add_global_message(
|
||||
user: str,
|
||||
text: str,
|
||||
attachment: dict[str, Any] | None = None,
|
||||
preview: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Append a message to the general chat. Returns the stored message."""
|
||||
return add_message(GLOBAL_VAULT, GLOBAL_PATH, user, text, attachment, preview)
|
||||
|
||||
|
||||
def save_attachment(filename: str, data: bytes) -> dict[str, Any]:
|
||||
"""Persist an uploaded attachment under :data:`UPLOAD_DIR`.
|
||||
|
||||
Returns ``{name, url, mime, kind}``. The stored name is a fresh UUID
|
||||
(never the client name), the extension must be in
|
||||
:data:`ALLOWED_ATTACH_EXT` and the size is capped at
|
||||
:data:`MAX_UPLOAD_BYTES`.
|
||||
|
||||
Raises:
|
||||
ValueError: extension refused, empty file or size exceeded.
|
||||
"""
|
||||
ext = Path(filename or "").suffix.lower()
|
||||
if ext not in ALLOWED_ATTACH_EXT:
|
||||
raise ValueError(f"extension refusée : {ext or '(aucune)'}")
|
||||
if not data:
|
||||
raise ValueError("fichier vide")
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise ValueError(f"fichier trop lourd (max {MAX_UPLOAD_BYTES // (1024 * 1024)} MB)")
|
||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||
name = f"{uuid.uuid4().hex}{ext}"
|
||||
(UPLOAD_DIR / name).write_bytes(data)
|
||||
kind = "video" if ext in {".mp4", ".webm", ".ogg", ".mov", ".m4v"} else "image"
|
||||
return {
|
||||
"name": name,
|
||||
"url": f"/api/chat/attachment/{name}",
|
||||
"mime": _MIME_BY_EXT.get(ext, "application/octet-stream"),
|
||||
"kind": kind,
|
||||
}
|
||||
|
||||
|
||||
# --- #191 : link preview ----------------------------------------------------
|
||||
|
||||
_URL_RE = re.compile(r"https?://[^\s<>\"']+")
|
||||
_PREVIEW_CACHE: dict[str, dict[str, Any] | None] = {}
|
||||
PREVIEW_CACHE_MAX = 200
|
||||
|
||||
|
||||
def _og(content: str, prop: str) -> str:
|
||||
"""Extract one OpenGraph/``<title>`` value from an HTML head (regex)."""
|
||||
for pattern in (
|
||||
rf'<meta[^>]+(?:property|name)="{prop}"[^>]+content="([^"]*)"',
|
||||
rf'<meta[^>]+content="([^"]*)"[^>]+(?:property|name)="{prop}"',
|
||||
):
|
||||
m = re.search(pattern, content, re.IGNORECASE)
|
||||
if m:
|
||||
return html.unescape(m.group(1)).strip()[:300]
|
||||
if prop == "og:title":
|
||||
m = re.search(r"<title[^>]*>([^<]*)</title>", content, re.IGNORECASE)
|
||||
if m:
|
||||
return html.unescape(m.group(1)).strip()[:300]
|
||||
return ""
|
||||
|
||||
|
||||
# BUG-109 — content-type → extension (the attachment allow-list decides).
|
||||
_IMG_EXT_BY_MIME = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/gif": ".gif",
|
||||
"image/webp": ".webp",
|
||||
"image/svg+xml": ".svg",
|
||||
}
|
||||
|
||||
|
||||
def _proxy_image(img_url: str, page_url: str) -> str:
|
||||
"""Download *img_url* into ``chat_uploads`` and return a same-origin URL.
|
||||
|
||||
The response CSP is ``img-src 'self' data: blob:``: a remote ``og:image``
|
||||
would be blocked by the browser (BUG-109). Relative and protocol-relative
|
||||
values are resolved against *page_url* first. Raises ``ValueError`` /
|
||||
``SSRFError`` on any failure — the caller keeps the card and drops only
|
||||
the thumbnail.
|
||||
"""
|
||||
from backend.tools.web import USER_AGENT, _assert_public_http_url
|
||||
|
||||
full = urljoin(page_url, img_url)
|
||||
_assert_public_http_url(full)
|
||||
resp = httpx.get(
|
||||
full,
|
||||
headers={"User-Agent": USER_AGENT},
|
||||
timeout=PREVIEW_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise ValueError(f"HTTP {resp.status_code}")
|
||||
data = resp.content
|
||||
if not data or len(data) > PREVIEW_IMAGE_MAX:
|
||||
raise ValueError("image vide ou trop lourde")
|
||||
mime = (resp.headers.get("content-type") or "").split(";")[0].strip().lower()
|
||||
ext = _IMG_EXT_BY_MIME.get(mime) or Path(urlparse(full).path).suffix.lower()
|
||||
# save_attachment(): allow-list d'extensions + nom UUID (jamais le nom distant)
|
||||
return str(save_attachment(f"preview{ext}", data)["url"])
|
||||
|
||||
|
||||
def build_preview(text: str) -> dict[str, Any] | None:
|
||||
"""Fetch OpenGraph metadata for the first URL in *text* (#191).
|
||||
|
||||
SSRF-guarded (reuses the web-tool guard), size/time capped, cached in a
|
||||
bounded dict. Returns ``{url, title, description, image, site}`` or
|
||||
``None`` when there is no URL / the fetch fails (never raises: a dead
|
||||
link must not block the message). ``image`` is a **same-origin**
|
||||
``/api/chat/attachment/...`` URL (BUG-109), empty when the thumbnail
|
||||
could not be fetched.
|
||||
"""
|
||||
m = _URL_RE.search(text or "")
|
||||
if not m:
|
||||
return None
|
||||
url = m.group(0).rstrip(".,;:!?)")
|
||||
if url in _PREVIEW_CACHE:
|
||||
cached = _PREVIEW_CACHE[url]
|
||||
return dict(cached) if cached else None
|
||||
try:
|
||||
from backend.tools.web import USER_AGENT, _assert_public_http_url
|
||||
|
||||
_assert_public_http_url(url)
|
||||
resp = httpx.get(
|
||||
url,
|
||||
headers={"User-Agent": USER_AGENT, "Accept": "text/html,*/*"},
|
||||
timeout=PREVIEW_TIMEOUT,
|
||||
follow_redirects=True,
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise ValueError(f"HTTP {resp.status_code}")
|
||||
body = resp.text[:PREVIEW_MAX_BYTES]
|
||||
# BUG-109 : vignette téléchargée côté serveur — une image distante
|
||||
# échouerait à la CSP. Échec isolé = carte sans vignette.
|
||||
image = _og(body, "og:image")
|
||||
try:
|
||||
image = _proxy_image(image, url) if image else ""
|
||||
except Exception as ie:
|
||||
logger.debug("preview image failed for %s: %s", url, ie)
|
||||
image = ""
|
||||
preview = {
|
||||
"url": url,
|
||||
"title": _og(body, "og:title") or _og(body, "og:site_name"),
|
||||
"description": _og(body, "og:description"),
|
||||
"image": image,
|
||||
"site": _og(body, "og:site_name") or (url.split("/")[2] if "/" in url[8:] else url),
|
||||
}
|
||||
if not preview["title"]:
|
||||
raise ValueError("pas de titre")
|
||||
except Exception as e:
|
||||
logger.debug("link preview failed for %s: %s", url, e)
|
||||
preview = None
|
||||
if len(_PREVIEW_CACHE) >= PREVIEW_CACHE_MAX:
|
||||
_PREVIEW_CACHE.pop(next(iter(_PREVIEW_CACHE))) # oldest first (dict order)
|
||||
_PREVIEW_CACHE[url] = preview
|
||||
return dict(preview) if preview else None
|
||||
|
||||
|
||||
def attachment_path(name: str) -> Path | None:
|
||||
"""Resolve an attachment by its stored name (UUID+ext only, no traversal)."""
|
||||
p = Path(name)
|
||||
if p.name != name or p.suffix.lower() not in ALLOWED_ATTACH_EXT:
|
||||
return None
|
||||
file = UPLOAD_DIR / p.name
|
||||
return file if file.exists() else None
|
||||
|
||||
|
||||
# Extension → MIME (literals only; ``mimetypes`` guesses poorly for a few).
|
||||
_MIME_BY_EXT = {
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".svg": "image/svg+xml",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".ogg": "video/ogg",
|
||||
".mov": "video/quicktime",
|
||||
".m4v": "video/x-m4v",
|
||||
}
|
||||
+68
-1
@@ -37,6 +37,12 @@ _last_full_index_ts: str = ""
|
||||
# Hook for incremental inverted index updates: called as (action, vault, path, file_info)
|
||||
_on_index_change: Callable[..., None] | None = None
|
||||
|
||||
# Registre des vaults ajoutés à la volée (#194) : les env VAULT_N_*/DIR_N_*
|
||||
# ne couvrent que le déploiement, tout ce qui est créé à runtime
|
||||
# (/api/vaults/add, dossiers perso) vivrait uniquement en mémoire sinon et
|
||||
# disparaîtrait au prochain rebuild ou redémarrage.
|
||||
DYNAMIC_VAULTS_FILE = Path("data/vaults.json")
|
||||
|
||||
|
||||
def set_index_change_hook(hook):
|
||||
"""Register a callback for incremental inverted index updates.
|
||||
@@ -134,7 +140,64 @@ def load_vault_config() -> dict[str, dict[str, Any]]:
|
||||
}
|
||||
n += 1
|
||||
|
||||
return vaults
|
||||
# Registre dynamique (#194) : les vaults créés à runtime (dossiers
|
||||
# persos, /api/vaults/add) sont chargés en premier, les env gagnent en
|
||||
# cas de collision de nom (vérité du déploiement).
|
||||
merged = _load_dynamic_vaults()
|
||||
merged.update(vaults)
|
||||
return merged
|
||||
|
||||
|
||||
def _load_dynamic_vaults() -> dict[str, dict[str, Any]]:
|
||||
"""Read vaults registered at runtime from ``data/vaults.json`` (#194)."""
|
||||
if not DYNAMIC_VAULTS_FILE.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(DYNAMIC_VAULTS_FILE.read_text(encoding="utf-8"))
|
||||
vaults = data.get("vaults", {})
|
||||
return vaults if isinstance(vaults, dict) else {}
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.error(f"Failed to read {DYNAMIC_VAULTS_FILE}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def _save_dynamic_vaults(vaults: dict[str, dict[str, Any]]) -> None:
|
||||
"""Atomic write of the dynamic vault registry (tmp + rename, as users.json)."""
|
||||
try:
|
||||
DYNAMIC_VAULTS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = DYNAMIC_VAULTS_FILE.with_suffix(".tmp")
|
||||
tmp.write_text(
|
||||
json.dumps({"version": 1, "vaults": vaults}, indent=2, default=str),
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(tmp, DYNAMIC_VAULTS_FILE)
|
||||
except OSError as e:
|
||||
# Le registre est un filet : vault_config mémoire reste valable
|
||||
# jusqu'au prochain rebuild, qui se reparera du dossier manquant.
|
||||
logger.error(f"Failed to write {DYNAMIC_VAULTS_FILE}: {e}")
|
||||
|
||||
|
||||
def persist_vault(vault_name: str) -> None:
|
||||
"""Persist *vault_name* so it survives rebuild/restart (#194).
|
||||
|
||||
Idempotent. Env-declared vaults are re-added by :func:`load_vault_config`
|
||||
anyway; persisting them too is harmless (single source after merge).
|
||||
"""
|
||||
cfg = vault_config.get(vault_name)
|
||||
if not cfg:
|
||||
return
|
||||
vaults = _load_dynamic_vaults()
|
||||
vaults[vault_name] = cfg
|
||||
_save_dynamic_vaults(vaults)
|
||||
|
||||
|
||||
def unpersist_vault(vault_name: str) -> None:
|
||||
"""Drop *vault_name* from the dynamic registry (#194). No-op if absent."""
|
||||
vaults = _load_dynamic_vaults()
|
||||
if vault_name not in vaults:
|
||||
return
|
||||
vaults.pop(vault_name, None)
|
||||
_save_dynamic_vaults(vaults)
|
||||
|
||||
|
||||
|
||||
@@ -867,6 +930,10 @@ async def reload_single_vault(vault_name: str) -> dict[str, Any]:
|
||||
|
||||
# Remove old vault data from index structures
|
||||
await remove_vault_from_index(vault_name)
|
||||
# remove_vault_from_index a poppé la config : la remettre, sinon le vault
|
||||
# disparaît de vault_config jusqu'au prochain reload complet (#194 — les
|
||||
# vaults dynamiques n'y reviennent que par data/vaults.json).
|
||||
vault_config[vault_name] = config
|
||||
|
||||
# Re-add the vault with updated configuration
|
||||
import functools
|
||||
|
||||
@@ -364,6 +364,12 @@ async def lifespan(app: FastAPI):
|
||||
else:
|
||||
logger.info("File watcher disabled by configuration.")
|
||||
|
||||
# #194 : dossier personnel par utilisateur — réparation/migration
|
||||
# (mkdir manquant, registre perdu, user créé hors API). Après le
|
||||
# watcher pour que les vaults ajoutés soient surveillés.
|
||||
from backend.user_home import ensure_all_user_homes
|
||||
await ensure_all_user_homes()
|
||||
|
||||
logger.info("Background startup complete.")
|
||||
|
||||
asyncio.create_task(_background_startup())
|
||||
@@ -509,6 +515,7 @@ from backend.routers.config import _load_config
|
||||
from backend.routers.config import router as config_router
|
||||
from backend.routers.conflicts import router as conflicts_router
|
||||
from backend.routers.duplicates import router as duplicates_router
|
||||
from backend.routers.file_chat import router as file_chat_router
|
||||
from backend.routers.files_media import router as files_media_router
|
||||
from backend.routers.files_read import router as files_read_router
|
||||
from backend.routers.files_write import router as files_write_router
|
||||
@@ -534,6 +541,7 @@ app.include_router(search_router) # ROADMAP #85 T5 — Search
|
||||
app.include_router(backups_router) # ROADMAP #85 T4 — Backups
|
||||
app.include_router(conflicts_router) # ROADMAP #85 T8 — Conflicts
|
||||
app.include_router(config_router) # ROADMAP #85 T7 — Config
|
||||
app.include_router(file_chat_router) # ROADMAP #169 — Chat par fichier
|
||||
app.include_router(files_read_router) # ROADMAP #85 T6a — Files read
|
||||
app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export
|
||||
app.include_router(files_write_router) # ROADMAP #85 T6b — Files write
|
||||
|
||||
+4
-4
@@ -13,7 +13,7 @@ from typing import Any
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.auth.middleware import require_auth
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
|
||||
logger = logging.getLogger("obsigate.push")
|
||||
|
||||
@@ -167,9 +167,9 @@ async def subscribe_push(
|
||||
"""Subscribe to push notifications for a vault."""
|
||||
username = current_user.get("username", "unknown")
|
||||
|
||||
# Check if user has access to this vault
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
if "*" not in user_vaults and request.vault not in user_vaults:
|
||||
# Check if user has access to this vault (#194 : "*" n'inclut pas les
|
||||
# dossiers persos, check_vault_access est la source unique).
|
||||
if not check_vault_access(request.vault, current_user):
|
||||
raise HTTPException(status_code=403, detail="No access to this vault")
|
||||
|
||||
# Check if subscription already exists
|
||||
|
||||
@@ -24,7 +24,7 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
|
||||
from backend.ai import PROVIDERS, _read_ai_keys, get_ai_key
|
||||
from backend.auth.middleware import require_admin, require_auth
|
||||
from backend.auth.middleware import check_vault_access, require_admin, require_auth
|
||||
from backend.indexer import index
|
||||
from backend.media_types import IMAGE_EXTENSIONS
|
||||
from backend.schemas import (
|
||||
@@ -495,14 +495,13 @@ async def api_diagnostics(current_user=Depends(require_admin)):
|
||||
@router.get("/api/dashboard", response_model=DashboardResponse)
|
||||
async def api_dashboard(current_user=Depends(require_auth)):
|
||||
"""Aggregated dashboard statistics across all accessible vaults."""
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
vault_stats = []
|
||||
total_files = 0
|
||||
total_tags = set()
|
||||
total_size = 0
|
||||
total_images = 0
|
||||
for vname, vdata in index.items():
|
||||
if "*" not in user_vaults and vname not in user_vaults:
|
||||
if not check_vault_access(vname, current_user):
|
||||
continue
|
||||
files = vdata.get("files", [])
|
||||
fc = len(files)
|
||||
|
||||
@@ -31,10 +31,9 @@ router = APIRouter(tags=["conflicts"])
|
||||
@router.get("/api/conflicts", response_model=ConflictsResponse)
|
||||
async def api_conflicts(current_user=Depends(require_auth)):
|
||||
"""List sync-conflict files across accessible vaults."""
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
all_conflicts = get_conflicts()
|
||||
if "*" not in user_vaults:
|
||||
all_conflicts = [c for c in all_conflicts if c["vault"] in user_vaults]
|
||||
# #194 : filtrage via check_vault_access ("*" n'inclut pas les homes).
|
||||
all_conflicts = [c for c in all_conflicts if check_vault_access(c["vault"], current_user)]
|
||||
return {"conflicts": all_conflicts, "total": len(all_conflicts)}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
# backend/routers/file_chat.py — chat (#169, #190)
|
||||
"""Chat endpoints: history read + message post with SSE fan-out.
|
||||
|
||||
- ``GET/POST /api/file/{vault_name}/chat`` — per-file chat: auth + vault
|
||||
access + path traversal check (``resolve_safe_path`` raises
|
||||
``ServiceError`` mapped by the app-level handler).
|
||||
- ``GET/POST /api/chat`` — the **general chat** (#190), not bound to a file.
|
||||
- ``POST /api/chat/upload`` / ``GET /api/chat/attachment/{name}`` (#190):
|
||||
image/video attachments (extension allow-list, size cap, UUID name).
|
||||
|
||||
Every post is broadcast on the existing SSE channel (``chat_message``) so
|
||||
all connected clients update live without a second WebSocket.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from backend import file_chat as _store
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.auth.user_store import get_all_users, get_user
|
||||
from backend.indexer import get_vault_data
|
||||
from backend.schemas import ChatHistoryResponse, ChatMessageResponse, ChatReadResponse, StatusResponse
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.sse import sse_manager
|
||||
|
||||
router = APIRouter() # tags dérivés de `tag_for_path` → « Files »
|
||||
|
||||
|
||||
def _check(vault_name: str, path: str, current_user: dict[str, Any]) -> None:
|
||||
"""Authz + traversal guard shared by both verbs."""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
|
||||
vault_data = get_vault_data(vault_name)
|
||||
if not vault_data:
|
||||
raise HTTPException(404, "Vault not found")
|
||||
resolve_safe_path(Path(vault_data["path"]), path) # ServiceError → 403/500
|
||||
|
||||
|
||||
@router.get("/api/file/{vault_name}/chat", response_model=ChatHistoryResponse)
|
||||
async def api_file_chat_history(
|
||||
vault_name: str,
|
||||
path: str,
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Return the chat history for a file (chronological)."""
|
||||
_check(vault_name, path, current_user)
|
||||
return {"messages": _store.get_messages(vault_name, path), "read": _store.get_read(vault_name, path)}
|
||||
|
||||
|
||||
@router.post("/api/file/{vault_name}/chat", response_model=ChatMessageResponse)
|
||||
async def api_file_chat_post(
|
||||
vault_name: str,
|
||||
body: dict[str, Any] = Body(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Post a chat message and broadcast it on SSE (``chat_message``)."""
|
||||
path = str(body.get("path") or "")
|
||||
text = str(body.get("text") or "")
|
||||
if not path:
|
||||
raise HTTPException(400, "path is required")
|
||||
if not text.strip():
|
||||
raise HTTPException(400, "text is required")
|
||||
_check(vault_name, path, current_user)
|
||||
msg = _store.add_message(vault_name, path, current_user.get("username", ""), text)
|
||||
await sse_manager.broadcast("chat_message", {"vault": vault_name, "path": path, "message": msg})
|
||||
return {"message": msg, "status": "ok"}
|
||||
|
||||
|
||||
# --- #190 : chat général ----------------------------------------------------
|
||||
|
||||
def _attachment(body: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Validate the optional ``attachment`` object sent by the client."""
|
||||
raw = body.get("attachment")
|
||||
if not raw or not isinstance(raw, dict):
|
||||
return None
|
||||
name = str(raw.get("name") or "")
|
||||
# Only an already-uploaded file (or an http(s) URL) may travel along.
|
||||
if not _store.attachment_path(name) and not str(raw.get("url", "")).startswith(("http://", "https://", "/api/")):
|
||||
raise HTTPException(400, "attachment inconnu")
|
||||
return {
|
||||
"name": name,
|
||||
"url": str(raw.get("url") or ""),
|
||||
"mime": str(raw.get("mime") or ""),
|
||||
"kind": str(raw.get("kind") or "file"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/chat", response_model=ChatHistoryResponse)
|
||||
async def api_chat_history(current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Return the general chat history (#190, chronological)."""
|
||||
return {
|
||||
"messages": _store.get_global_messages(),
|
||||
"read": _store.get_read(_store.GLOBAL_VAULT, _store.GLOBAL_PATH),
|
||||
}
|
||||
|
||||
|
||||
# --- #192 : accusé de réception ---------------------------------------------
|
||||
|
||||
def _check_read(vault: str, path: str, current_user: dict[str, Any]) -> None:
|
||||
"""Authorization for marking a conversation read (#192).
|
||||
|
||||
General chat is open to any member, a DM only to the two participants,
|
||||
a file chat follows the vault ACL.
|
||||
"""
|
||||
if vault == _store.GLOBAL_VAULT:
|
||||
return
|
||||
if vault == _store.DM_VAULT:
|
||||
if current_user.get("username") not in str(path).split("|"):
|
||||
raise HTTPException(403, "Accès refusé à cette conversation privée")
|
||||
return
|
||||
_check(vault, path, current_user)
|
||||
|
||||
|
||||
@router.post("/api/chat/read", response_model=ChatReadResponse)
|
||||
async def api_chat_read(
|
||||
body: dict[str, Any] = Body(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Record that the caller has seen a conversation (#192).
|
||||
|
||||
Broadcast on SSE (``chat_read``) so the sender's own messages flip to
|
||||
✓✓ live on every connected client.
|
||||
"""
|
||||
vault = str(body.get("vault") or "")
|
||||
path = str(body.get("path") or "")
|
||||
if not vault or not path:
|
||||
raise HTTPException(400, "vault and path are required")
|
||||
_check_read(vault, path, current_user)
|
||||
username = current_user.get("username", "")
|
||||
read = _store.mark_read(vault, path, username)
|
||||
await sse_manager.broadcast(
|
||||
"chat_read",
|
||||
{"vault": vault, "path": path, "user": username, "ts": read.get(username, 0.0), "read": read},
|
||||
)
|
||||
return {"read": read, "status": "ok"}
|
||||
|
||||
|
||||
@router.post("/api/chat", response_model=ChatMessageResponse)
|
||||
async def api_chat_post(
|
||||
body: dict[str, Any] = Body(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Post to the general chat and broadcast it on SSE (``chat_message``)."""
|
||||
text = str(body.get("text") or "")
|
||||
if not text.strip():
|
||||
raise HTTPException(400, "text is required")
|
||||
# #191 — best-effort link preview: a dead/slow URL never blocks the post.
|
||||
msg = _store.add_global_message(
|
||||
current_user.get("username", ""), text, _attachment(body),
|
||||
_store.build_preview(text),
|
||||
)
|
||||
await sse_manager.broadcast(
|
||||
"chat_message",
|
||||
{"vault": _store.GLOBAL_VAULT, "path": _store.GLOBAL_PATH, "message": msg},
|
||||
)
|
||||
return {"message": msg, "status": "ok"}
|
||||
|
||||
|
||||
@router.post("/api/chat/upload")
|
||||
async def api_chat_upload(
|
||||
file: UploadFile = File(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Store an image/video attachment (#190). Returns ``{attachment}``."""
|
||||
data = await file.read()
|
||||
try:
|
||||
info = _store.save_attachment(file.filename or "", data)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
return {"attachment": info}
|
||||
|
||||
|
||||
@router.get("/api/chat/attachment/{name}")
|
||||
async def api_chat_attachment(name: str, current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Serve an uploaded attachment (name validated against the allow-list)."""
|
||||
path = _store.attachment_path(name)
|
||||
if not path:
|
||||
raise HTTPException(404, "Attachment not found")
|
||||
info = _store._MIME_BY_EXT.get(path.suffix.lower(), "application/octet-stream")
|
||||
return FileResponse(str(path), media_type=info)
|
||||
|
||||
|
||||
# --- #191 : suppression + messages privés -----------------------------------
|
||||
|
||||
def _owner_or_admin(msg_user: str, current_user: dict[str, Any]) -> None:
|
||||
"""A post may be deleted by its author or by an admin."""
|
||||
if current_user.get("role") != "admin" and current_user.get("username") != msg_user:
|
||||
raise HTTPException(403, "Seul l'auteur ou un administrateur peut supprimer ce message")
|
||||
|
||||
|
||||
def _find_and_authorize(vault: str, path: str, message_id: str, current_user: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Locate *message_id* in the conversation and check the delete right."""
|
||||
for m in _store.get_messages(vault, path):
|
||||
if m.get("id") == message_id:
|
||||
_owner_or_admin(m.get("user", ""), current_user)
|
||||
return m
|
||||
raise HTTPException(404, "Message not found")
|
||||
|
||||
|
||||
@router.delete("/api/chat/{message_id}", response_model=StatusResponse)
|
||||
async def api_chat_delete(message_id: str, current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Delete a message from the general chat (author or admin, #191)."""
|
||||
_find_and_authorize(_store.GLOBAL_VAULT, _store.GLOBAL_PATH, message_id, current_user)
|
||||
if not _store.delete_message(_store.GLOBAL_VAULT, _store.GLOBAL_PATH, message_id):
|
||||
raise HTTPException(404, "Message not found")
|
||||
await sse_manager.broadcast(
|
||||
"chat_deleted",
|
||||
{"vault": _store.GLOBAL_VAULT, "path": _store.GLOBAL_PATH, "id": message_id},
|
||||
)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/api/chat/users", response_model=list[dict[str, Any]])
|
||||
async def api_chat_users(current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Usernames available for a private conversation (chat DM picker, #191).
|
||||
|
||||
Every authenticated member may see who else is around — this is a
|
||||
self-hosted portal, not a directory that needs hiding.
|
||||
"""
|
||||
me = current_user.get("username", "")
|
||||
return [
|
||||
{"username": u.get("username", ""), "display_name": u.get("display_name") or u.get("username", "")}
|
||||
for u in get_all_users()
|
||||
if u.get("username") and u.get("username") != me
|
||||
]
|
||||
|
||||
|
||||
def _dm_peer(username: str, current_user: dict[str, Any]) -> str:
|
||||
"""Validate the DM peer exists and is not ourselves."""
|
||||
if not username or username == current_user.get("username"):
|
||||
raise HTTPException(400, "Destinataire invalide")
|
||||
if not get_user(username):
|
||||
raise HTTPException(404, "Utilisateur inconnu")
|
||||
return username
|
||||
|
||||
|
||||
@router.get("/api/chat/dm/{username}", response_model=ChatHistoryResponse)
|
||||
async def api_chat_dm_history(username: str, current_user: dict[str, Any] = Depends(require_auth)):
|
||||
"""Private history with *username* (#191)."""
|
||||
peer = _dm_peer(username, current_user)
|
||||
return {
|
||||
"messages": _store.get_dm_messages(current_user["username"], peer),
|
||||
"read": _store.get_read(_store.DM_VAULT, _store.dm_path(current_user["username"], peer)),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/chat/dm/{username}", response_model=ChatMessageResponse)
|
||||
async def api_chat_dm_post(
|
||||
username: str,
|
||||
body: dict[str, Any] = Body(...),
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Post a private message and broadcast it to both participants (#191)."""
|
||||
peer = _dm_peer(username, current_user)
|
||||
text = str(body.get("text") or "")
|
||||
if not text.strip():
|
||||
raise HTTPException(400, "text is required")
|
||||
msg = _store.add_dm_message(
|
||||
current_user["username"], peer, current_user.get("username", ""), text,
|
||||
_attachment(body), _store.build_preview(text),
|
||||
)
|
||||
# Same shape as the general chat so the client routes on vault/path.
|
||||
await sse_manager.broadcast(
|
||||
"chat_message",
|
||||
{"vault": _store.DM_VAULT, "path": _store.dm_path(current_user["username"], peer), "message": msg},
|
||||
)
|
||||
return {"message": msg, "status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/api/chat/dm/{username}/{message_id}", response_model=StatusResponse)
|
||||
async def api_chat_dm_delete(
|
||||
username: str,
|
||||
message_id: str,
|
||||
current_user: dict[str, Any] = Depends(require_auth),
|
||||
):
|
||||
"""Delete one private message (author or admin, #191)."""
|
||||
peer = _dm_peer(username, current_user)
|
||||
vault, path = _store.DM_VAULT, _store.dm_path(current_user["username"], peer)
|
||||
_find_and_authorize(vault, path, message_id, current_user)
|
||||
if not _store.delete_message(vault, path, message_id):
|
||||
raise HTTPException(404, "Message not found")
|
||||
await sse_manager.broadcast(
|
||||
"chat_deleted", {"vault": vault, "path": path, "id": message_id}
|
||||
)
|
||||
return {"status": "deleted"}
|
||||
@@ -45,9 +45,46 @@ from backend.services.files import read_raw_file
|
||||
from backend.services.mutations import file_revision
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import browse_directory, get_vault_root
|
||||
from backend.share import list_shares
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
|
||||
|
||||
def _resolve_shared_file(vault_name: str, path: str, username: str | None):
|
||||
"""#196 — resolve ``home-<user>/Partage/<file>`` to the real source file.
|
||||
|
||||
Returns ``(real_vault, real_path)`` when *path* is a received share
|
||||
mounted in the user's personal folder, else ``None``. Read-only: only a
|
||||
recipient (or the share creator, whose own file is already accessible)
|
||||
gets a mapping — a share directed to someone else never resolves here.
|
||||
|
||||
Chemin canonique : ``Partage/<token>/<nom>`` (token = lève l'ambiguïté
|
||||
de deux partages au même nom). Fallback par nom seul pour les liens
|
||||
ne transportant pas le token.
|
||||
"""
|
||||
if not username or not vault_name.startswith("home-") or not path.startswith("Partage/"):
|
||||
return None
|
||||
owner = vault_name[len("home-"):]
|
||||
if owner != username:
|
||||
return None
|
||||
# Chemin virtuel canonique : Partage/<token>/<nom> — le token lève
|
||||
# l'ambiguïté (deux partages, même nom de base). Fallback : match par
|
||||
# nom pour les liens ne portant pas le token.
|
||||
rest = path.split("/", 1)[1]
|
||||
parts = rest.split("/", 1)
|
||||
if len(parts) == 2 and len(parts[0]) >= 20: # token (64 hex) vs nom de fichier
|
||||
token, _name = parts
|
||||
for s in list_shares(user=username):
|
||||
if s.get("token") == token and s.get("created_by") != username:
|
||||
return s["vault"], s["path"]
|
||||
return None
|
||||
for s in list_shares(user=username):
|
||||
if s.get("created_by") == username:
|
||||
continue
|
||||
if (s.get("path") or "").split("/")[-1] == rest:
|
||||
return s["vault"], s["path"]
|
||||
return None
|
||||
|
||||
# Map file extensions to highlight.js language hints
|
||||
EXT_TO_LANG = {
|
||||
".py": "python", ".js": "javascript", ".ts": "typescript",
|
||||
@@ -104,7 +141,11 @@ async def api_file_raw(vault_name: str, path: str = Query(..., description="Rela
|
||||
Returns:
|
||||
``FileRawResponse`` with vault, path, and raw text content.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
shared = _resolve_shared_file(vault_name, path, current_user.get("username"))
|
||||
if shared:
|
||||
# Partage dirigé = autorisation (cf. api_file).
|
||||
vault_name, path = shared
|
||||
elif 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)
|
||||
|
||||
@@ -120,7 +161,11 @@ async def api_file_download(vault_name: str, path: str = Query(..., description=
|
||||
Returns:
|
||||
``FileResponse`` with ``application/octet-stream`` content-type.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
shared = _resolve_shared_file(vault_name, path, current_user.get("username"))
|
||||
if shared:
|
||||
# Partage dirigé = autorisation (cf. api_file).
|
||||
vault_name, path = shared
|
||||
elif 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:
|
||||
@@ -166,12 +211,11 @@ async def api_file_backlinks(
|
||||
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]
|
||||
# Filter by user-accessible vaults (#194 : check_vault_access, "*" sans
|
||||
# les dossiers persos).
|
||||
backlinks = [b for b in backlinks if check_vault_access(b["vault"], current_user)]
|
||||
|
||||
return {
|
||||
"vault": vault_name,
|
||||
@@ -306,8 +350,18 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
Returns:
|
||||
``FileContentResponse`` with HTML, metadata, and tags.
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
# #196 — fichier reçu par partage dirigé : home-<user>/Partage/<fichier>
|
||||
# est résolu vers le fichier source (lecture seule, viewer standard).
|
||||
# Résolu AVANT l'ACL vault : le dossier est virtuel, l'autorisation réelle
|
||||
# est l'appartenance au partage (vérifiée dans le resolver).
|
||||
shared = _resolve_shared_file(vault_name, path, current_user.get("username"))
|
||||
if shared:
|
||||
# Le partage dirigé EST l'autorisation : le destinataire n'a par
|
||||
# définition pas accès au vault source — on ne passe PAS par l'ACL.
|
||||
vault_name, path = shared
|
||||
elif 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")
|
||||
|
||||
@@ -59,7 +59,6 @@ async def api_recent(limit: int | None = Query(None), vault: str | None = Query(
|
||||
@router.get("/api/bookmarks", response_model=BookmarksResponse)
|
||||
async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(require_auth)):
|
||||
username = current_user.get("username")
|
||||
user_vaults = current_user.get("_token_vaults") or current_user.get("vaults", [])
|
||||
|
||||
if not username:
|
||||
return {"files": []}
|
||||
@@ -68,7 +67,8 @@ async def api_bookmarks(vault: str | None = Query(None), current_user=Depends(re
|
||||
files_resp = []
|
||||
for item in history:
|
||||
v_name = item["vault"]
|
||||
if "*" not in user_vaults and v_name not in user_vaults:
|
||||
# #194 : check_vault_access — "*" n'inclut pas les dossiers persos.
|
||||
if not check_vault_access(v_name, current_user):
|
||||
continue
|
||||
|
||||
# Find in index to get metadata
|
||||
|
||||
@@ -82,7 +82,15 @@ async def api_search(
|
||||
# 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),
|
||||
# #194 : filtre par vault accessible AVANT pagination ("*" sans les
|
||||
# dossiers persos) — sinon un user voyait les notes des autres.
|
||||
# #196 : les documents reçus par partage dirigé sont ajoutés aux
|
||||
# résultats (vault virtuel "home-<user>/Partage").
|
||||
partial(
|
||||
search_vaults, q, vault, tag, limit, offset,
|
||||
is_allowed=lambda v: check_vault_access(v, current_user),
|
||||
username=current_user["username"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+54
-15
@@ -24,7 +24,8 @@ import frontmatter
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse, Response
|
||||
|
||||
from backend.auth.middleware import check_vault_access, require_auth
|
||||
from backend.auth.middleware import check_vault_access, get_current_user, require_auth
|
||||
from backend.auth.user_store import get_user
|
||||
from backend.indexer import get_vault_data, parse_markdown_file, update_single_file
|
||||
from backend.render import _render_markdown
|
||||
from backend.schemas import ShareModel, StatusResponse
|
||||
@@ -53,6 +54,27 @@ except Exception: # pragma: no cover - WeasyPrint/GTK missing
|
||||
router = APIRouter(tags=["sharing"])
|
||||
|
||||
|
||||
def _gate_share(share: dict | None, user: dict | None) -> dict:
|
||||
"""#196 — directed shares: auth + membership check for ``/s/*`` pages.
|
||||
|
||||
Public share (``shared_with`` empty) → pass-through, no auth required.
|
||||
Directed share → requires an authenticated user who is the creator, an
|
||||
admin, or listed in ``shared_with``. Anything else answers **404** (never
|
||||
403) so an outsider cannot learn that a token exists.
|
||||
"""
|
||||
if share is None:
|
||||
raise HTTPException(404, "Share not found or expired")
|
||||
recipients = share.get("shared_with") or []
|
||||
if not recipients:
|
||||
return share
|
||||
if user is None:
|
||||
raise HTTPException(404, "Share not found or expired")
|
||||
if user["username"] == share.get("created_by") or user.get("role") == "admin" \
|
||||
or user["username"] in recipients:
|
||||
return share
|
||||
raise HTTPException(404, "Share not found or expired")
|
||||
|
||||
|
||||
@router.post("/api/share/{vault_name}", response_model=ShareModel)
|
||||
async def api_share_create(
|
||||
vault_name: str,
|
||||
@@ -66,9 +88,18 @@ async def api_share_create(
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(403, f"Accès refusé à la vault '{vault_name}'")
|
||||
path = body.get("path", "")
|
||||
path = body.get("path") or ""
|
||||
if not path:
|
||||
raise HTTPException(400, "Chemin de fichier requis")
|
||||
expires = body.get("expires_in_hours")
|
||||
share = create_share(vault_name, path, current_user["username"], expires)
|
||||
# #196 — directed share: validate recipients before creating anything.
|
||||
recipients = body.get("shared_with") or []
|
||||
if not isinstance(recipients, list):
|
||||
raise HTTPException(400, "shared_with doit être une liste d'utilisateurs")
|
||||
for name in recipients:
|
||||
if not isinstance(name, str) or not get_user(name):
|
||||
raise HTTPException(400, f"Utilisateur inconnu : {name}")
|
||||
share = create_share(vault_name, path, current_user["username"], expires, recipients)
|
||||
share["url"] = f"/s/{share['token']}"
|
||||
|
||||
# Set publish: true in the file's frontmatter
|
||||
@@ -94,8 +125,13 @@ async def api_share_create(
|
||||
|
||||
@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)
|
||||
"""List shares (optionally filtered by vault).
|
||||
|
||||
#196 : un non-admin ne voit que SES partages (créés ou reçus) ; un admin
|
||||
voit tout (comportement historique).
|
||||
"""
|
||||
username = None if current_user.get("role") == "admin" else current_user["username"]
|
||||
shares = list_shares(vault, username)
|
||||
for s in shares:
|
||||
s["url"] = f"/s/{s['token']}"
|
||||
return shares
|
||||
@@ -103,6 +139,12 @@ async def api_shares_list(vault: str | None = Query(None), current_user=Depends(
|
||||
|
||||
@router.delete("/api/share/{share_id}", response_model=StatusResponse)
|
||||
async def api_share_revoke(share_id: str, current_user=Depends(require_auth)):
|
||||
# #196 — only the creator or an admin may revoke; a recipient cannot.
|
||||
from backend.share import _read
|
||||
share = _read()["shares"].get(share_id)
|
||||
if share and current_user.get("role") != "admin" \
|
||||
and share.get("created_by") != current_user["username"]:
|
||||
raise HTTPException(403, "Seul le créateur du partage peut le révoquer")
|
||||
if not revoke_share(share_id):
|
||||
raise HTTPException(404, "Share not found")
|
||||
return {"status": "revoked"}
|
||||
@@ -113,13 +155,12 @@ async def api_share_revoke(share_id: str, current_user=Depends(require_auth)):
|
||||
response_class=Response,
|
||||
responses={200: {"content": {"application/pdf": {}}, "description": "Shared document as PDF"}},
|
||||
)
|
||||
async def public_share_pdf_download(token: str):
|
||||
async def public_share_pdf_download(token: str, current_user=Depends(get_current_user)):
|
||||
"""Download shared document as real PDF via WeasyPrint."""
|
||||
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")
|
||||
share = _gate_share(share, current_user)
|
||||
vault_data = get_vault_data(share["vault"])
|
||||
if not vault_data:
|
||||
raise HTTPException(404, "Vault not found")
|
||||
@@ -147,11 +188,10 @@ async def public_share_pdf_download(token: str):
|
||||
|
||||
|
||||
@router.get("/s/{token}/raw", response_class=FileResponse)
|
||||
async def public_share_raw(token: str):
|
||||
async def public_share_raw(token: str, current_user=Depends(get_current_user)):
|
||||
"""Download the raw (original) shared document."""
|
||||
share = get_share_by_token(token)
|
||||
if not share:
|
||||
raise HTTPException(404, "Share not found or expired")
|
||||
share = _gate_share(share, current_user)
|
||||
vault_data = get_vault_data(share["vault"])
|
||||
if not vault_data:
|
||||
raise HTTPException(404, "Vault not found")
|
||||
@@ -164,13 +204,12 @@ async def public_share_raw(token: str):
|
||||
|
||||
|
||||
@router.get("/s/{token}", response_class=HTMLResponse)
|
||||
async def public_share_view(request: Request, token: str):
|
||||
"""Public share view — no authentication required."""
|
||||
async def public_share_view(request: Request, token: str, current_user=Depends(get_current_user)):
|
||||
"""Public share view — no authentication required (unless directed #196)."""
|
||||
from backend.csp import inject_csp_nonce
|
||||
|
||||
share = get_share_by_token(token)
|
||||
if not share:
|
||||
raise HTTPException(404, "Share not found or expired")
|
||||
share = _gate_share(share, current_user)
|
||||
vault_data = get_vault_data(share["vault"])
|
||||
if not vault_data:
|
||||
raise HTTPException(404, "Vault not found")
|
||||
|
||||
@@ -55,6 +55,11 @@ async def api_add_vault(body: dict = Body(...), current_user=Depends(require_adm
|
||||
|
||||
stats = await add_vault_to_index(name, vault_path)
|
||||
|
||||
# #194 : persister, sinon ce vault disparaît au prochain rebuild/redémarrage.
|
||||
from backend.indexer import persist_vault
|
||||
|
||||
persist_vault(name)
|
||||
|
||||
# Start watching the new vault
|
||||
watcher = get_watcher()
|
||||
if watcher:
|
||||
@@ -80,6 +85,10 @@ async def api_remove_vault(vault_name: str, current_user=Depends(require_admin))
|
||||
await watcher.remove_vault(vault_name)
|
||||
|
||||
await remove_vault_from_index(vault_name)
|
||||
# #194 : plus de trace au redémarrage (les vaults d'env, eux, reviennent).
|
||||
from backend.indexer import unpersist_vault
|
||||
|
||||
unpersist_vault(vault_name)
|
||||
await sse_manager.broadcast("vault_removed", {"vault": vault_name})
|
||||
return {"status": "ok", "vault": vault_name}
|
||||
|
||||
|
||||
+52
-1
@@ -151,6 +151,54 @@ class BacklinksResponse(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
class ChatMessageItem(BaseModel):
|
||||
"""One chat message (``GET/POST /api/file/{vault}/chat`` + ``/api/chat``)."""
|
||||
|
||||
id: str = Field(description="Message id")
|
||||
user: str = Field(description="Author username")
|
||||
text: str = Field(description="Message body")
|
||||
ts: float = Field(description="Unix timestamp")
|
||||
attachment: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Optional image/video/url attachment {name, url, mime, kind}",
|
||||
)
|
||||
preview: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Link preview card {url, title, description, image, site} (#191)",
|
||||
)
|
||||
html: str = Field(
|
||||
default="",
|
||||
description=(
|
||||
"Rendered (mistune + sanitized) HTML of ``text``, like a document "
|
||||
"preview — tables, lists, fenced code blocks (#193)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ChatHistoryResponse(BaseModel):
|
||||
"""Response for ``GET /api/file/{vault}/chat``."""
|
||||
|
||||
messages: list[ChatMessageItem] = Field(description="Messages, chronological")
|
||||
read: dict[str, float] = Field(
|
||||
default_factory=dict,
|
||||
description="{username: last read unix ts} per participant (#192)",
|
||||
)
|
||||
|
||||
|
||||
class ChatReadResponse(BaseModel):
|
||||
"""Response for ``POST /api/chat/read`` (#192 read receipts)."""
|
||||
|
||||
read: dict[str, float] = Field(description="{username: last read unix ts}")
|
||||
status: str = Field(description="'ok'")
|
||||
|
||||
|
||||
class ChatMessageResponse(BaseModel):
|
||||
"""Response for ``POST /api/file/{vault}/chat``."""
|
||||
|
||||
message: ChatMessageItem
|
||||
status: str = Field(description="'ok'")
|
||||
|
||||
|
||||
class BackupsListResponse(BaseModel):
|
||||
"""Response for ``GET /api/backups``."""
|
||||
|
||||
@@ -563,13 +611,15 @@ class BookmarkToggleRequest(BaseModel):
|
||||
class SearchResultItem(BaseModel):
|
||||
"""A single search result."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
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")
|
||||
modified: str | None = Field(default=None, description="ISO 8601 modification timestamp")
|
||||
share_token: str | None = Field(default=None, description="#196 token de partage dirigé (document reçu)")
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
@@ -1008,6 +1058,7 @@ class ShareModel(BaseModel):
|
||||
expires_at: str | None = None
|
||||
access_count: int = 0
|
||||
last_accessed: str | None = None
|
||||
shared_with: list[str] = Field(default_factory=list, description="#196 destinataires (partage dirigé)")
|
||||
|
||||
|
||||
class ConflictEntry(BaseModel):
|
||||
|
||||
@@ -12,6 +12,7 @@ import time
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from backend.auth.middleware import is_home_vault
|
||||
from backend.history import get_recent_opened, is_bookmarked
|
||||
from backend.indexer import find_file_in_index, index
|
||||
|
||||
@@ -31,6 +32,10 @@ def humanize_mtime(mtime: float) -> str:
|
||||
|
||||
|
||||
def _can_access(vault: str, user_vaults: list[str]) -> bool:
|
||||
# #194 : un dossier perso ne bénéficie jamais de "*" (même règle que
|
||||
# backend.auth.middleware.check_vault_access).
|
||||
if is_home_vault(vault):
|
||||
return vault in user_vaults
|
||||
return "*" in user_vaults or vault in user_vaults
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -11,15 +12,29 @@ def search_vaults(
|
||||
tag: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
is_allowed: Callable[[str], bool] | None = None,
|
||||
username: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Full-text search with pagination, returned as the API response payload.
|
||||
|
||||
No permission filtering is applied here: callers that need it (the tool
|
||||
layer) filter the ``results`` list themselves.
|
||||
``is_allowed`` (#194) filters the raw hits **before** pagination, so a
|
||||
restricted vault set neither distorts ``total`` nor the returned page.
|
||||
The tool layer filters on its own and leaves it as ``None``.
|
||||
|
||||
``username`` (#196) : les documents **reçus** par cet utilisateur via un
|
||||
partage dirigé sont ajoutés aux résultats (lecture seule, sans indexer
|
||||
les fichiers d'autrui — le contenu est lu à la volée et mis en cache
|
||||
TF-IDF côté index, pas sur disque).
|
||||
"""
|
||||
from backend.search import search
|
||||
|
||||
all_results = search(q, vault_filter=vault, tag_filter=tag)
|
||||
if is_allowed is not None:
|
||||
all_results = [r for r in all_results if is_allowed(r.get("vault", ""))]
|
||||
|
||||
if username is not None:
|
||||
all_results = all_results + _shared_results(username, q, vault)
|
||||
|
||||
total = len(all_results)
|
||||
page = all_results[offset: offset + limit]
|
||||
return {
|
||||
@@ -34,6 +49,67 @@ def search_vaults(
|
||||
}
|
||||
|
||||
|
||||
def _shared_results(username: str, q: str, vault: str) -> list[dict[str, Any]]:
|
||||
"""Search results for documents shared TO *username* (#196).
|
||||
|
||||
Reads the shared file content on the fly (read-only, source vault on
|
||||
disk) and returns hits shaped exactly like ordinary search results so
|
||||
the frontend can open them via the normal share page.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from backend.indexer import get_vault_data
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.share import list_shares
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
if not q:
|
||||
return out
|
||||
q_lower = q.lower()
|
||||
|
||||
for s in list_shares(user=username):
|
||||
if s.get("created_by") == username:
|
||||
continue # own share — already indexed in its own vault
|
||||
if vault not in ("all", f"home-{username}"):
|
||||
continue
|
||||
data = get_vault_data(s["vault"])
|
||||
if not data:
|
||||
continue
|
||||
try:
|
||||
fp = resolve_safe_path(Path(data["path"]), s["path"])
|
||||
if not fp.exists() or fp.suffix.lower() != ".md":
|
||||
continue
|
||||
raw = fp.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
continue
|
||||
title = fp.stem
|
||||
occurrences = raw.lower().count(q_lower)
|
||||
if q_lower in title.lower():
|
||||
occurrences += 1
|
||||
if occurrences == 0:
|
||||
continue
|
||||
out.append({
|
||||
"vault": f"home-{username}",
|
||||
"path": f"Partage/{fp.name}",
|
||||
"title": title,
|
||||
"tags": [],
|
||||
"score": min(occurrences, 10),
|
||||
"snippet": _snippet(raw, q_lower),
|
||||
"modified": None,
|
||||
"share_token": s["token"],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _snippet(text: str, q_lower: str, width: int = 160) -> str:
|
||||
"""Small excerpt around the first match (#196, shared-file search)."""
|
||||
idx = text.lower().find(q_lower)
|
||||
if idx < 0:
|
||||
return text[:width]
|
||||
start = max(0, idx - width // 2)
|
||||
return "…" + text[start:start + width].replace("\n", " ") + "…"
|
||||
|
||||
|
||||
def list_tags(vault: str | None = None) -> dict[str, int]:
|
||||
"""Return tag → count, optionally restricted to a single vault."""
|
||||
from backend.search import get_all_tags
|
||||
|
||||
+17
-3
@@ -44,8 +44,14 @@ def create_share(
|
||||
path: str,
|
||||
created_by: str,
|
||||
expires_in_hours: int | None = None,
|
||||
shared_with: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""Create a new share token for a document."""
|
||||
"""Create a new share token for a document.
|
||||
|
||||
``shared_with`` non vide (#196) : partage **dirigé** — la page ``/s/…``
|
||||
exige alors une session et n'accepte que les destinataires listés (plus
|
||||
le créateur et les admins). Vide/absent : comportement public inchangé.
|
||||
"""
|
||||
with _lock:
|
||||
data = _read()
|
||||
token = secrets.token_hex(32) # 64-char hex token
|
||||
@@ -64,6 +70,7 @@ def create_share(
|
||||
"expires_at": expires_at,
|
||||
"access_count": 0,
|
||||
"last_accessed": None,
|
||||
"shared_with": list(shared_with) if shared_with else [],
|
||||
}
|
||||
data["shares"][token] = share
|
||||
_write(data)
|
||||
@@ -107,10 +114,17 @@ def revoke_share(share_id: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def list_shares(vault_filter: str | None = None) -> list:
|
||||
"""List all shares, optionally filtered by vault."""
|
||||
def list_shares(vault_filter: str | None = None, user: str | None = None) -> list:
|
||||
"""List shares, optionally filtered by vault and/or requesting user.
|
||||
|
||||
#196 : un non-admin ne voit que les partages qu'il a créés **ou** qui lui
|
||||
sont dirigés. Un admin voit tout. ``user=None`` (anciens appels, tests
|
||||
unitaires) conserve l'ancien comportement : tout lister.
|
||||
"""
|
||||
data = _read()
|
||||
shares = list(data["shares"].values())
|
||||
if user is not None:
|
||||
shares = [s for s in shares if user in (s.get("created_by"), *(s.get("shared_with") or []))]
|
||||
if vault_filter:
|
||||
shares = [s for s in shares if s["vault"] == vault_filter]
|
||||
# Most recent first
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Dossier personnel par utilisateur (#194).
|
||||
|
||||
Chaque utilisateur reçoit ``<OBSIGATE_HOME_ROOT>/<username>`` monté comme un
|
||||
vault propre ``home-<username>`` : l'isolation profite de l'ACL par vault
|
||||
déjà en place (``check_vault_access``), aucune ACL par chemin à inventer.
|
||||
|
||||
Tout est idempotent (``ensure_user_home``) pour être rappelé à la création
|
||||
d'un compte ET au démarrage : un dossier supprimé, un registre perdu ou un
|
||||
user créé hors API se réparent au boot.
|
||||
|
||||
La fonctionnalité est inactive tant que ``OBSIGATE_HOME_ROOT`` n'est pas
|
||||
défini (dev, tests, desktop) — comportement inchangé.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger("obsigate.user_home")
|
||||
|
||||
# Même grammaire que CreateUserRequest.username_valid (backend/auth/router.py) :
|
||||
# segment de chemin sûr, déjà passé par la validation de l'API. Re-vérifié ici
|
||||
# car le nom sert à construire un chemin (défense en profondeur).
|
||||
_USERNAME_RE = re.compile(r"^[a-zA-Z0-9_-]{2,32}$")
|
||||
|
||||
|
||||
def home_root() -> Path | None:
|
||||
"""Racine des dossiers persos, ou ``None`` si la fonctionnalité est inactive."""
|
||||
root = os.environ.get("OBSIGATE_HOME_ROOT", "").strip()
|
||||
return Path(root) if root else None
|
||||
|
||||
|
||||
def home_vault_name(username: str) -> str:
|
||||
"""Nom de vault du dossier perso — pas de ``/`` ni ``::`` (segment d'URL, clé d'index)."""
|
||||
return f"home-{username}"
|
||||
|
||||
|
||||
async def ensure_user_home(username: str) -> str | None:
|
||||
"""Crée (si besoin) le dossier perso de *username*, son vault et son octroi.
|
||||
|
||||
Retourne le nom de vault, ou ``None`` si désactivé / nom invalide /
|
||||
erreur disque (journalisée, réparée au prochain démarrage).
|
||||
"""
|
||||
root = home_root()
|
||||
if root is None:
|
||||
return None
|
||||
if not _USERNAME_RE.match(username):
|
||||
logger.warning(f"Home folder skipped: invalid username {username!r}")
|
||||
return None
|
||||
|
||||
home = root / username
|
||||
try:
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
except OSError:
|
||||
logger.exception(f"Cannot create home folder {home} for user '{username}'")
|
||||
return None
|
||||
|
||||
name = home_vault_name(username)
|
||||
try:
|
||||
from backend.indexer import add_vault_to_index, index, persist_vault, vault_config
|
||||
from backend.sse import sse_manager
|
||||
|
||||
vault_path = str(home)
|
||||
if name not in index:
|
||||
await add_vault_to_index(name, vault_path)
|
||||
persist_vault(name)
|
||||
from backend.watcher_state import get_watcher
|
||||
|
||||
watcher = get_watcher()
|
||||
if watcher:
|
||||
await watcher.add_vault(name, vault_path)
|
||||
await sse_manager.broadcast("vault_added", {"vault": name})
|
||||
logger.info(f"Home vault '{name}' registered at {vault_path}")
|
||||
elif name not in vault_config:
|
||||
vault_config[name] = {"path": vault_path, "attachmentsPath": None,
|
||||
"scanAttachmentsOnStartup": True}
|
||||
except Exception:
|
||||
logger.exception(f"Cannot register home vault '{name}' for user '{username}'")
|
||||
return None
|
||||
|
||||
_grant(username, name)
|
||||
return name
|
||||
|
||||
|
||||
async def release_user_home(username: str) -> None:
|
||||
"""Retire le vault du dossier perso à la suppression du compte (#194).
|
||||
|
||||
Le dossier sur disque est **conservé** (décision produit : pas de perte
|
||||
de données) ; seul l'index, le watcher et le registre le referment.
|
||||
"""
|
||||
name = home_vault_name(username)
|
||||
try:
|
||||
from backend.indexer import index, remove_vault_from_index, unpersist_vault
|
||||
|
||||
unpersist_vault(name)
|
||||
if name not in index:
|
||||
return
|
||||
await remove_vault_from_index(name)
|
||||
from backend.sse import sse_manager
|
||||
from backend.watcher_state import get_watcher
|
||||
|
||||
watcher = get_watcher()
|
||||
if watcher:
|
||||
await watcher.remove_vault(name)
|
||||
await sse_manager.broadcast("vault_removed", {"vault": name})
|
||||
logger.info(f"Home vault '{name}' released (folder kept)")
|
||||
except Exception:
|
||||
logger.exception(f"Cannot release home vault '{name}'")
|
||||
|
||||
|
||||
def _grant(username: str, vault_name: str) -> None:
|
||||
"""Ajoute le vault à ``user.vaults`` s'il n'y est pas déjà (#194).
|
||||
|
||||
L'octroi est explicite même pour un admin (``vaults: ["*"]``) : ``*`` ne
|
||||
couvre jamais un dossier perso (voir ``check_vault_access``).
|
||||
"""
|
||||
from backend.auth.user_store import get_user, update_user
|
||||
|
||||
user = get_user(username)
|
||||
if not user:
|
||||
return # créé hors API (bootstrap avant users.json) → réparé au boot suivant
|
||||
vaults = user.get("vaults") or []
|
||||
if vault_name in vaults:
|
||||
return
|
||||
update_user(username, {"vaults": [*vaults, vault_name]})
|
||||
|
||||
|
||||
async def ensure_all_user_homes() -> int:
|
||||
"""Passe de réparation/migration au démarrage : un home par user existant.
|
||||
|
||||
Balaye aussi les vaults orphelins (compte supprimé hors route, ex.
|
||||
``create_admin.py delete``) : ils sont refermés, dossier conservé.
|
||||
"""
|
||||
from backend.auth.user_store import get_all_users
|
||||
from backend.indexer import index, vault_config
|
||||
|
||||
root = home_root()
|
||||
if root is None:
|
||||
return 0
|
||||
users = get_all_users()
|
||||
usernames = {u.get("username") for u in users}
|
||||
created = 0
|
||||
for user in users:
|
||||
username = user.get("username")
|
||||
if not username:
|
||||
continue
|
||||
if await ensure_user_home(username):
|
||||
created += 1
|
||||
if created:
|
||||
logger.info(f"User home folders ensured for {created} user(s)")
|
||||
|
||||
# Orphelins : vault home-<x> toujours indexé mais <x> n'existe plus.
|
||||
# Path.parent == root → on ne touche qu'aux dossiers sous la racine Home,
|
||||
# jamais à un vault admin nommé « home-… » par ailleurs.
|
||||
for name in list(index):
|
||||
if not name.startswith("home-"):
|
||||
continue
|
||||
owner = name[len("home-"):]
|
||||
if owner in usernames:
|
||||
continue
|
||||
cfg_path = Path((vault_config.get(name) or {}).get("path", ""))
|
||||
if cfg_path.parent != root:
|
||||
continue
|
||||
logger.warning(f"Orphan home vault '{name}' released (user deleted?)")
|
||||
await release_user_home(owner)
|
||||
return created
|
||||
@@ -223,4 +223,4 @@ $COMPOSE_CMD -f "$COMPOSE_FILE" ps
|
||||
|
||||
echo ""
|
||||
info "Logs récents (Ctrl+C pour quitter) :"
|
||||
$COMPOSE_CMD -f "$COMPOSE_FILE" logs --tail=20 -f
|
||||
$COMPOSE_CMD -f "$COMPOSE_FILE" logs --tail=20
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.55.0"
|
||||
version = "2.67.2"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.55.0"
|
||||
version = "2.67.2"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.55.0",
|
||||
"version": "2.67.2",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -21,12 +21,17 @@ services:
|
||||
volumes:
|
||||
- /c/dev/git/python/ObsiGate/test_vault:/vaults/TestVault
|
||||
- /c/dev/git/python/ObsiGate/test_dir:/vaults/TestDir
|
||||
# #194 : racine des dossiers persos — UN seul montage pour tous les
|
||||
# utilisateurs (dossier hôte : mkdir test_home à côté du dépôt).
|
||||
- /c/dev/git/python/ObsiGate/test_home:/vaults/Home
|
||||
- obsigate_data:/app/data
|
||||
environment:
|
||||
- VAULT_1_NAME=TestVault
|
||||
- VAULT_1_PATH=/vaults/TestVault
|
||||
- DIR_1_NAME=TestDir
|
||||
- DIR_1_PATH=/vaults/TestDir
|
||||
# #194 : dossier personnel par utilisateur = <racine>/home-<user>
|
||||
- OBSIGATE_HOME_ROOT=/vaults/Home
|
||||
- OBSIGATE_AUTH_ENABLED=true
|
||||
- OBSIGATE_ADMIN_USER=admin
|
||||
- OBSIGATE_ADMIN_PASSWORD=test123
|
||||
|
||||
@@ -36,6 +36,9 @@ services:
|
||||
- /NFS/OBSIDIAN_DOC/Obsidian_WORKOUT:/vaults/Obsidian_WORKOUT
|
||||
- /NFS/OBSIDIAN_DOC/SessionsManager:/vaults/SessionsManager
|
||||
- /home/bruno:/vaults/bruno
|
||||
# #194 : racine des dossiers perso — UN seul montage pour tous les
|
||||
# utilisateurs (un montage par user demanderait un redémarrage).
|
||||
- /NFS/OBSIDIAN_DOC/Home:/vaults/Home
|
||||
- /DOCKER_CONFIG/ObsiGate/data:/app/data
|
||||
environment:
|
||||
- VAULT_1_NAME=Recettes
|
||||
@@ -50,6 +53,8 @@ services:
|
||||
- VAULT_5_PATH=/vaults/SessionsManager
|
||||
- DIR_1_NAME=Bruno
|
||||
- DIR_1_PATH=/vaults/bruno
|
||||
# #194 : dossier personnel par utilisateur = <racine>/home-<user>
|
||||
- OBSIGATE_HOME_ROOT=/vaults/Home
|
||||
- OBSIGATE_AUTH_ENABLED=true
|
||||
- OBSIGATE_ADMIN_USER=admin
|
||||
# OBSIGATE_ADMIN_PASSWORD → .env
|
||||
|
||||
@@ -0,0 +1,527 @@
|
||||
# Assistant V2 — ObsiGate comme second cerveau & assistant personnel
|
||||
|
||||
> **Statut :** 📐 Spécification de conception (non implémentée)
|
||||
> **Dernière mise à jour :** 2026-10-08
|
||||
> **IDs proposés :** ObsiGate **#191 → #201** (liste « Assistant V2 »)
|
||||
> **Portée :** évolution de l'assistant IA existant en assistant à mémoire long terme,
|
||||
> outillé (connecteurs externes), planifié (rappels, briefing) et proactif (insights).
|
||||
> **Hors portée :** toute intégration avec l'infrastructure externe du homelab
|
||||
> (Honcho, Hermes, Ollama partagé, ntfy homelab, cron système). ObsiGate est **autonome** :
|
||||
> un seul conteneur, ses propres stores, ses propres clés.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision
|
||||
|
||||
Transformer ObsiGate d'un portail de notes avec assistant vers un **second cerveau
|
||||
conversationnel et autonome** :
|
||||
|
||||
1. **Il se souvient** — mémoire long terme structurée (faits, préférences, décisions,
|
||||
entités), consolidée chaque nuit, **visible et éditable en markdown dans le vault**
|
||||
(pas de boîte noire).
|
||||
2. **Il agit** — lit/écrit le vault, mais aussi les services externes connectés :
|
||||
Gmail, Google Agenda, Teams, Discord, Notion, Drive… (cadre de connecteurs unifié).
|
||||
3. **Il anticipe** — rappels conversationnels, briefing quotidien généré par l'agent,
|
||||
détection de boucles ouvertes et d'engagements non tenus.
|
||||
4. **Il centralise** — ObsiGate devient le point d'entrée unique prise de notes +
|
||||
assistant ; tout le reste (desktop Tauri, MCP externe, PWA mobile) n'est qu'un
|
||||
accès à *ce même* cerveau.
|
||||
|
||||
Le modèle économique de la conception : **Muse/Snow/ChatGPT-Memory rendent l'effet
|
||||
« l'assistant me connaît » ; ici la mémoire est auditable (markdown dans le vault,
|
||||
audit log, dédupli, réversibilité) et les données ne quittent jamais le stack ObsiGate
|
||||
sauf appel explicite aux API des services que Bruno a connectés lui-même.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Principes directeurs (non négociables)
|
||||
|
||||
| # | Principe | Conséquence technique |
|
||||
|---|---|---|
|
||||
| P1 | **Autonomie du stack** | Tout vit dans le conteneur `obsigate` + son volume `data/`. Aucun nouveau conteneur runtime (Ollama est retiré — voir §9). |
|
||||
| P2 | **Zéro framework d'agent externe** | Décision déjà prise en #92 : boucle maison `backend/agent/loop.py`. L'Assistant V2 l'étend, ne la remplace pas. |
|
||||
| P3 | **Un outil = un `@tool`** | Toute nouvelle capacité passe par le registre typé (`backend/tools/registry.py`) : risque, scope, rate-limit, confirmation, audit, labels + i18n. |
|
||||
| P4 | **Mémoire = markdown d'abord** | Les artefacts durables (profil, insights, briefings, rappels) sont des notes du vault ; la base SQLite n'est que l'index reconstructible. |
|
||||
| P5 | **Humain dans la boucle** | `WRITE`/`DANGEROUS` exigent confirmation (#91/#92) ; un fait de confiance faible reste `pending` tant que l'utilisateur ne le valide pas. |
|
||||
| P6 | **Secrets jamais committés** | Pattern existant : `data/api_keys.json` / `data/connector_secrets.json` (0600) ou variables `OBSIGATE_*` dans `.env` (`env_file`, jamais dans le compose). |
|
||||
| P7 | **i18n FR/EN systématique** | Toute clé d'interface (`ai.step.*`, config connecteurs, cartes mémoire) existe dans les deux locales. |
|
||||
| P8 | **Le desktop Tauri reste fonctionnel** | Les connecteurs se dégradent silencieusement hors ligne (capability = absente si pas de token) ; aucune dépendance à un service réseau au démarrage. |
|
||||
| P9 | **Sécurité existante réutilisée** | `_resolve_safe_path()`, `require_auth`/`require_vault_access`, SSRF guards (`validate_webhook_url`, `is_safe_target`), `secret_redactor` avant tout appel LLM. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Inventaire de l'existant (socle sur lequel on bâtit)
|
||||
|
||||
Constat clé : **l'ossature « agent » existe déjà**. Ce qui manque, c'est la mémoire
|
||||
long terme, les connecteurs, la couche proactive et l'ordonnancement de haut niveau.
|
||||
|
||||
| Brique | Fichier(s) | État | Rôle dans la V2 |
|
||||
|---|---|---|---|
|
||||
| Boucle agent multi-étapes + tool calling natif | `backend/agent/loop.py` (`run_agent`), `backend/ai_chat.py` (payload `tools`/`tool_choice`, conversion Gemini, fallback si refus) | ✅ livré (#91/#92) | Moteur de l'assistant V2 — inchangé |
|
||||
| Registre d'outils typé | `backend/tools/registry.py` (`@tool`, `ToolRisk` READ/WRITE/DANGEROUS, `ToolScope` IN_APP/MCP, confirmation auto si non-READ, rate-limit, audit, schéma OpenAI) | ✅ | Cadre d'insertion de **tous** les nouveaux outils |
|
||||
| Outils vault (~26) | `backend/tools/service.py`, `backend/services/*` (files, mutations, search, vaults, duplicates…) | ✅ | Outils natifs de l'assistant |
|
||||
| Recherche TF-IDF + sémantique embeddings (RRF) | `backend/search.py`, `backend/semantic_search.py`, `requirements-semantic.txt` (sentence-transformers **local, optionnel**, FAISS) | ✅ (#70) | Retrieval de la mémoire épisodique ; remplace le besoin Ollama embeddings |
|
||||
| Historique de conversation persistant | `backend/ai_history.py` (`data/sessions`, par utilisateur) | ✅ (#95) | **Mémoire épisodique** brute — source de la consolidation |
|
||||
| Serveur MCP externe | `backend/mcp/server.py` (Streamable HTTP `/mcp`, propose/apply) | ✅ (#79) | Même registre exposé — les outils V2 en profitent gratis |
|
||||
| Scheduler type cron | `backend/scheduler.py` (tick asyncio 60 s, `data/scheduled_tasks.json`, `interval_hours`/`daily_time`/`once_at`, actions `create_file`/`append_to_file`/`notify`) + outils `create/list/delete/run_scheduled_task` (`backend/tools/scheduled.py`) | ✅ (#170) | Socle des rappels & briefing — à étendre (§8) |
|
||||
| Notifications externes | `backend/notify.py` (canaux **Discord webhook, Telegram, SMTP, webhook générique**, triggers, secrets 0600) + outil `notify_external` | ✅ (#168) | Bras de sortie des rappels/briefing |
|
||||
| Web Push PWA | `backend/push.py` (VAPID) + `frontend/sw.js` | ✅ (#67) | Deuxième canal de rappel, sans dépendance externe |
|
||||
| Sources connectées (premier précédent) | `backend/tools/connected.py` (Gitea/GitHub, READ), page Configurations #103, `backend/tools/secrets.py` | ✅ (#92/#103) | Patron à généraliser en cadre connecteurs (§7) |
|
||||
| Web : recherche + fetch + crawl | `backend/tools/web.py` (Tavily/Brave/SerpAPI/Exa/SearXNG, `fetch_url`), `backend/tools/crawler.py` (`crawl_site`) | ✅ | Outils « connaissances hors vault » |
|
||||
| Production de documents | `backend/tools/documents.py` (`create_xlsx/docx/csv/pdf`) | ✅ | Actions de l'assistant |
|
||||
| Doublons | `backend/tools/duplicates.py` (`find_duplicates`, `merge_duplicate_notes` DANGEROUS) | ✅ (#166) | Hygiène de la mémoire |
|
||||
| Skills / slash-commandes | `backend/skills.py` (`/cmd`, `data/skills.json`) | ✅ | Vecteur naturel des prompts de consolidation & briefing |
|
||||
| Cache SQLite | `backend/tools/webcache.py` (précédent `sqlite3` stdlib dans `data/`) | ✅ | Patron du store mémoire (§5) |
|
||||
| Fournisseurs LLM | `backend/ai.py` (`PROVIDERS` : deepseek, openrouter, gemini, nvidia, qwencloud, xiaomi, mistral, ollama), `reload_ai_config()`, clés `data/api_keys.json` + fallback env | ✅ | Garder le multi-provider ; **retirer le conteneur ollama** (§9) |
|
||||
| Audit + redaction | `backend/audit.py`, `backend/tools/audit.py`, `backend/secret_redactor.py` | ✅ | Traçabilité mémoire/connecteurs |
|
||||
|
||||
**Gaps** (→ §5–§8) : mémoire sémantique (faits) · miroir markdown · consolidation ·
|
||||
cadre connecteurs générique + OAuth2 · Gmail/Calendar/Teams/Discord(in)/Notion/Drive ·
|
||||
rappels conversationnels · agent programmé (briefing) · insights proactifs.
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture cible
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────┐
|
||||
│ FRONTEND (SPA + PWA + Tauri) │
|
||||
│ Assistant panel · Cartes mémoire · Config │
|
||||
│ « Sources connectées » · Briefings │
|
||||
└───────────────┬────────────────────────────┘
|
||||
│ REST + SSE (existant)
|
||||
┌──────────────────────────────────────▼─────────────────────────────────────┐
|
||||
│ BACKEND obsigate (1 conteneur) │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────────┐ ┌────────────────────────────┐ │
|
||||
│ │ agent/loop.py│◄──│ tools/registry.py│◄──│ TOUS les outils V2 (@tool) │ │
|
||||
│ │ (run_agent) │ │ risque/scope/ │ │ vault · web · docs (exist.)│ │
|
||||
│ └──────┬───────┘ │ audit/limit │ │ memory.* (nouveau §5) │ │
|
||||
│ │ └──────────────────┘ │ connector.* (§7) │ │
|
||||
│ ┌──────┴────────┐ │ reminder.* (§8) │ │
|
||||
│ │ ai_chat.py │ │ scheduled.* (étendu) │ │
|
||||
│ │ providers API │ └────────────────────────────┘ │
|
||||
│ └──────┬────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────▼───────────┐ ┌────────────────────┐ ┌───────────────────────┐ │
|
||||
│ │ scheduler.py │ │ memory/ │ │ connectors/ │ │
|
||||
│ │ tick 60 s │ │ store SQLite │ │ base OAuth2+PKCE │ │
|
||||
│ │ + agent_prompt │─►│ (faits) │ │ gmail/gcal/teams/ │ │
|
||||
│ │ (nouv. action) │ │ consolidator │ │ discord/notion/drive │ │
|
||||
│ └──────┬───────────┘ └─────────┬──────────┘ └───────────┬───────────┘ │
|
||||
│ │ notify.broadcast │ miroir markdown │ HTTPS API │
|
||||
│ │ + Web Push ▼ ▼ │
|
||||
│ ┌──────▼──────────┐ ┌────────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ Vault Obsidian │ │ Vault Obsidian │ │ Services externes │ │
|
||||
│ │ (5 volumes NFS) │ │ 99_Assistant/*.md │ │ (Google/MS/Discord) │ │
|
||||
│ └─────────────────┘ └────────────────────┘ └─────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Nouveaux modules backend (alignés sur l'organisation existante) :
|
||||
|
||||
```
|
||||
backend/
|
||||
memory/
|
||||
store.py # SQLite data/memory.sqlite3 — CRUD faits + runs
|
||||
recall.py # fusion recall: facts + sémantique (#70) + épisodes (ai_history)
|
||||
consolidator.py # job « dreaming » nocturne (prompt + parsing + dédup)
|
||||
mirror.py # rendu markdown 99_Assistant/ + parse des edits humains
|
||||
connectors/
|
||||
base.py # ConnectorInterface: id, scopes, authorize_url, exchange,
|
||||
# refresh, capabilities(), call() (retry + rate-limit + audit)
|
||||
oauth.py # flux code + PKCE, redirect /api/connectors/{id}/callback
|
||||
registry.py # registre des connecteurs installés (comme tools/registry)
|
||||
gmail.py gcal.py teams.py discord.py notion.py drive.py
|
||||
routers/
|
||||
connectors.py # CRUD admin: installation, statut, test de connexion
|
||||
memory.py # API faits (liste/édit/suppression/validation pending)
|
||||
tools/
|
||||
memory.py # @tool memory.* (§5.4)
|
||||
reminders.py # @tool reminder.* (§8.1)
|
||||
connectors.py # @tool par capability connecteur (§7.4)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Mémoire long terme — #192 (socle), #193 (miroir), #194 (consolidation)
|
||||
|
||||
### 5.1 Modèle de données (SQLite, `data/memory.sqlite3`)
|
||||
|
||||
Précédent : `webcache.py`. Une seule table applicative + une table d'audit des runs.
|
||||
|
||||
```sql
|
||||
CREATE TABLE facts (
|
||||
id TEXT PRIMARY KEY, -- f-<uuid4>
|
||||
user_id TEXT NOT NULL,
|
||||
subject TEXT NOT NULL, -- ex: "santé", "Projet FlowDeck", "bruno"
|
||||
predicate TEXT NOT NULL, -- ex: "rdv", "préférence", "décision"
|
||||
value TEXT NOT NULL, -- contenu FR court (< 500 car.)
|
||||
kind TEXT NOT NULL CHECK (kind IN
|
||||
('preference','decision','entity','event','habit','goal','open_loop')),
|
||||
confidence REAL NOT NULL DEFAULT 0.5, -- 0..1 ; < 0.7 → status pending
|
||||
status TEXT NOT NULL DEFAULT 'confirmed'
|
||||
CHECK (status IN ('pending','confirmed','rejected','expired')),
|
||||
source TEXT NOT NULL, -- 'consolidation' | 'user' | 'chat'
|
||||
source_ref TEXT, -- session id ou chemin de note
|
||||
supersedes TEXT, -- id du fait remplacé (dédup temporel)
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL, -- reinforcement par usage
|
||||
expires_at TEXT -- nullable (ex: rdv passé)
|
||||
);
|
||||
CREATE INDEX idx_facts_subject ON facts(user_id, subject);
|
||||
CREATE INDEX idx_facts_status ON facts(user_id, status);
|
||||
|
||||
CREATE TABLE consolidation_runs (
|
||||
id TEXT PRIMARY KEY, started_at TEXT, finished_at TEXT,
|
||||
sessions_consumed INTEGER, notes_changed INTEGER,
|
||||
facts_added INTEGER, facts_updated INTEGER, error TEXT
|
||||
);
|
||||
```
|
||||
|
||||
Règles :
|
||||
- **Dédup temporel** : un nouveau fait `(subject, predicate)` avec valeur différente
|
||||
crée une ligne et marque l'ancienne `superseded` (via `supersedes`, status `expired`).
|
||||
Historique conservé, recall = dernier `confirmed` non expiré.
|
||||
- **Renforcement** : `last_seen_at` bump quand le fait est réaffirmé en chat ;
|
||||
les facts non revus depuis N jours (180) et non `decision` deviennent `expired`.
|
||||
- **PII santé/famille** : `consolidator` force `status='pending'` (validation humaine
|
||||
via cartes) si la note source appartient aux dossiers sensibles configurables
|
||||
(`OBSIGATE_MEMORY_REVIEW_FOLDERS`, ex. `Santé,Famille`).
|
||||
- Backup : `data/` étant déjà dans le cycle de backups existant, rien de neuf.
|
||||
|
||||
### 5.2 Miroir markdown dans le vault — #193
|
||||
|
||||
Dossier **`99_Assistant/`** (créé à la première activation, dans le vault principal
|
||||
configurable `OBSIGATE_MEMORY_VAULT=Main`, dossier racine configurable) :
|
||||
|
||||
| Fichier | Contenu | Écrit par | Éditable par Bruno |
|
||||
|---|---|---|---|
|
||||
| `Profil.md` | Préférences/habitus/goals confirmés, groupés par sujet (H2) | mirror (consolidation) | ✅ — les edits sont respectés (§5.3 règles de merge) |
|
||||
| `Insights.md` | Détections proactes #201 (boucles ouvertes, engagements, patterns), horodatées | mirror | ✅ (suppression = `rejected` en base) |
|
||||
| `Rappels.md` | Table des rappels actifs (liens vers tâches planifiées) | mirror scheduler | lecture seule (UI dédiée) |
|
||||
| `Briefings/YYYY-MM-DD.md` | Briefing quotidien #200 | agent programmé | ✅ (archivage, jamais régénéré) |
|
||||
| `Journal.md` | Dernières consolidations (nb faits ajoutés/validés) | mirror | lecture seule |
|
||||
|
||||
Le miroir utilise des **balises HTML de section** :
|
||||
|
||||
```markdown
|
||||
<!-- obsigate:managed:preferences -->
|
||||
…bloc régénéré…
|
||||
<!-- /obsigate:managed:preferences -->
|
||||
```
|
||||
|
||||
Règle de merge : à chaque écriture, le mirror compare le contenu précédent généré
|
||||
(hash stocké en base) avec le contenu actuel du fichier. Si l'utilisateur a modifié
|
||||
l'intérieur d'une section gérée → la modification est **re-parse en faits `source='user'`**
|
||||
(confiance 1.0, status confirmed) puis la section est régénérée à partir de la base.
|
||||
Tout ce qui est hors balises est laissé intact. C'est le mécanisme qui rend la mémoire
|
||||
*auditable et contestable* — l'anti-boîte-noire.
|
||||
|
||||
### 5.3 Consolidation nocturne (« dreaming ») — #194
|
||||
|
||||
Job planifié via le scheduler existant (§8.3), action `agent_prompt` dédiée :
|
||||
|
||||
1. **Collecte incrémentale** : sessions `ai_history` créées/modifiées depuis le dernier
|
||||
run + fichiers `.md` modifiés depuis le dernier run (via l'index/watcher mtime) —
|
||||
plafonds : 200 fichiers × 30 Ko (comme bookslm), contenu `redact_file_content()` avant LLM.
|
||||
2. **Extraction** (1 appel LLM, provider par défaut = cheap model) : prompt système
|
||||
d'extraction → JSON `[{subject, predicate, value, kind, confidence}]` validé Pydantic ;
|
||||
rejet des valeurs non sourcées.
|
||||
3. **Réconciliation** (déterministe, stdlib) : dédup/supersede par `(subject,predicate)`,
|
||||
plancher de confiance, dossiers sensibles → pending.
|
||||
4. **Écriture** : `facts` + `mirror.py` → sections gérées de `Profil.md`.
|
||||
5. **Cartes de validation** : les faits `pending` deviennent des cartes dans le panneau
|
||||
Assistant (« Valider / Modifier / Oublier »), persistées jusqu'à action.
|
||||
|
||||
Coût : 1–3 appels API/nuit (deepseek-chat ≈ quelques centimes). Budget max :
|
||||
`OBSIGATE_CONSOLIDATION_MAX_TOKENS` (défaut 30 000/jour) pour bornes dures.
|
||||
|
||||
### 5.4 Outils mémoire (registés `@tool`) — #192
|
||||
|
||||
| Outil | Risque | Description (schéma) |
|
||||
|---|---|---|
|
||||
| `memory_recall` | READ | `{query, subject?, kind?}` → faits confirmed pertinents + fusion RRF avec sémantique #70 sur le vault. Injecté **automatiquement** dans le prompt système de chaque chat (voir §5.5). |
|
||||
| `memory_save` | WRITE | `{subject, predicate, value, kind, confidence=0.9}` — le modèle propose, la carte de confirmation affiche le fait ; Bruno valide → `source='chat'`. |
|
||||
| `memory_update` | WRITE | `{fact_id, value | status}` — correction/revalidation. |
|
||||
| `memory_forget` | DANGEROUS | `{fact_id}` — status `rejected`, supprimé du miroir, tracé à l'audit. |
|
||||
| `profile_get` | READ | `{section?}` → contenu courant de `Profil.md` (plus riche que le recall brut). |
|
||||
|
||||
### 5.5 Injection dans le contexte (le « ça me connaît » quotidien)
|
||||
|
||||
Au démarrage de chaque session agent (`bookslm_routes` → futur endpoint `/agent`),
|
||||
construction d'un bloc mémoire coiffant le prompt système, budget ≤ 2 500 tokens :
|
||||
|
||||
```
|
||||
<memoried-context>
|
||||
Profil (résumé géré) : préférences, habitudes, objectifs actifs…
|
||||
Faits saillants liés au sujet : <memory_recall(query=requête utilisateur)>
|
||||
Rappels actifs : <n lignes de Rappels.md>
|
||||
Dernier briefing : <3 puces>
|
||||
</memoried-context>
|
||||
```
|
||||
|
||||
Ce bloc est **visible** dans la section « étapes » du panneau (label « Mémoire »),
|
||||
comme les autres étapes d'outils — pas de magie cachée.
|
||||
|
||||
---
|
||||
|
||||
## 6. Rattachement MCP / Desktop / Skills
|
||||
|
||||
- Tous les nouveaux outils `@tool` sont automatiquement exposés au **serveur MCP**
|
||||
(`/mcp`, scopes IN_APP+MCP par défaut, propose/apply déjà gérés) → Claude Desktop /
|
||||
Cursor bénéficient gratis de `memory.*`, `reminder.*`, `connector.*`.
|
||||
- **Skills `/`** : nouvelles built-in `/profil` (montre Profil.md), `/rappel <texte>`
|
||||
(crée un rappel), `/brief` (régénère le briefing du jour), `/consolider`
|
||||
(déclenche le run de consolidation à la demande), `/connecteurs` (état des sources).
|
||||
- **Desktop Tauri** : les connecteurs sans token enregistré n'apparaissent pas dans le
|
||||
registre de schémas envoyés au LLM (`list_tools` filtré par capabilities actives) ;
|
||||
zéro appel réseau au démarrage.
|
||||
|
||||
---
|
||||
|
||||
## 7. Connecteurs externes — #195 (cadre), #196–#198 (implémentations)
|
||||
|
||||
### 7.1 Cadre (`backend/connectors/`)
|
||||
|
||||
Décision : **remplacer la logique ad hoc de `connected.py` (Gitea/GitHub par variables
|
||||
d'env) par un cadre général** — tout en gardant ces deux sources fonctionnelles
|
||||
(migration interne, même UX « Sources connectées » #103 étendue).
|
||||
|
||||
Concepts :
|
||||
|
||||
- **Connector** = classe (id, label, docs URL, scopes OAuth, capabilities nommées).
|
||||
- **Installation** = entrée `data/connectors.json` `{id, type, status, config (public),
|
||||
created_at}` ; secrets dans `data/connector_secrets.json` (0600) ou
|
||||
`OBSIGATE_CONNECTOR_SECRET_<ID>` — **même pattern strict que #168/#103**.
|
||||
- **Auth** : soit *token statique* (Discord webhook, Notion key, GitHub), soit
|
||||
**OAuth2 authorization-code + PKCE** (Google, Microsoft) :
|
||||
`GET /api/connectors/{id}/authorize` → redirection → `GET /api/connectors/{id}/callback`
|
||||
(échange + stockage `refresh_token` chiffré avec `data/secret.key` existant) →
|
||||
`POST /api/connectors/{id}/test` (validation par un appel READ bon marché).
|
||||
- **Refresh** transparent dans `call()` (anticipation du expiry, 1 retry sur 401).
|
||||
- **Capabilities → tools** : chaque capability expose un `@tool` (dynamiquement
|
||||
enregistré si le connecteur est installé), préfixé :
|
||||
`gmail.search_messages`, `gcal.list_events`, `teams.list_chats`…
|
||||
Risque par capability (lecture READ / écriture WRITE / envoi DANGEROUS).
|
||||
- **Budget & limites** : rate-limit par connecteur via `tools/ratelimit.py`,
|
||||
timeout httpx 15 s, retry backoff (pattern #92), cache SQLite des réponses GET
|
||||
bornées (pattern `webcache.py`, TTL 5 min pour le contexte de chat).
|
||||
- **Audit** : chaque `call()` journalisé (connecteur, capability, args tronqués,
|
||||
latence, statut) via `tools/audit.py`.
|
||||
|
||||
### 7.2 Matrice des connecteurs cibles
|
||||
|
||||
| Connecteur | Auth | Capabilités (→ outil) | Risque | Phase |
|
||||
|---|---|---|---|---|
|
||||
| **Google — Calendar** | OAuth2 PKCE | `gcal.list_events` (période, requête), `gcal.get_event`, `gcal.create_event`, `gcal.update_event` | READ, READ, WRITE, WRITE | #196 |
|
||||
| **Google — Gmail** | OAuth2 PKCE | `gmail.search_messages` (query `is:unread…`), `gmail.read_message`, `gmail.create_draft`, ~~`gmail.send`~~ | READ, READ, WRITE, 🔴 non exposé v1 | #196 |
|
||||
| **Google — Drive** | OAuth2 PKCE | `drive.search`, `drive.read_file` (import md/docx → vault) | READ | #198 (rattache backlog #167) |
|
||||
| **Microsoft — Teams / Graph** | Entra ID client, OAuth2 | `teams.list_chats`, `teams.read_messages`, `teams.send_message` | READ, READ, WRITE | #197 |
|
||||
| **Microsoft — Outlook** | idem Graph | `outlook.search_messages`, `outlook.list_events` (calendriers persos MS) | READ | #197 (optionnel, même jeton) |
|
||||
| **Discord** | webhook (sortie, ✅ #168) + **bot token lecture** (entrée, scopes `read`) | `discord.send` (existant via notify), `discord.list_messages`, `discord.search` (REST, pas de gateway websocket en v1) | WRITE, READ, READ | #198 |
|
||||
| **Notion** | API key (user token) | `notion.search`, `notion.read_page`, `notion.append_blocks` | READ, READ, WRITE | #198 |
|
||||
| **Slack** | webhook sortie + bot token lecture REST | `slack.send`, `slack.search_messages` | WRITE, READ | option future |
|
||||
| **Gitea / GitHub** | token (existant) | migrés sous le cadre | READ | #195 (migration) |
|
||||
| **Todoist / tasks externes** | token | — | — | hors v1 ; les tâches restent **dans le vault** (checklists) |
|
||||
|
||||
Règle transversale : **la prise de notes reste le vault**. Les connecteurs *nourrissent*
|
||||
l'assistant (contexte) et reçoivent des *actions* (événement, brouillon, message) ; ils
|
||||
ne remplacent jamais la note. C'est l'inverse des apps chat généralistes.
|
||||
|
||||
### 7.3 Utilisation des connecteurs par l'assistant
|
||||
|
||||
- L'agent choisit les outils selon les demandes (tool-calling existant, `tool_choice=auto`).
|
||||
- **Contexte briefing** (§8.2) : agrège `gcal.list_events(jour)` + rappels + mails
|
||||
non lus (si Gmail connecté) + notes modifiées → note de briefing.
|
||||
- **Demande type** : « est-ce que j'ai une réponse du dentiste ? » →
|
||||
`gmail.search_messages(from:...)` → cite le mail → propose la note.
|
||||
- **Confidentialité** : le contenu des connecteurs entre dans la conversation mais
|
||||
**jamais** dans la consolidation mémoire par défaut (seuls les faits *explicitement*
|
||||
confirmés par l'utilisateur via `memory_save` entrent en base).
|
||||
|
||||
### 7.4 UI « Sources connectées » (#103 étendue)
|
||||
|
||||
Page Configurations : carte par connecteur (statut, scopes, bouton Connect/Test/
|
||||
Déconnecter), journal des derniers appels (audit filtré), budget tokens. Textes FR/EN.
|
||||
|
||||
---
|
||||
|
||||
## 8. Rappels, briefing, agent programmé — #199 (rappels), #200 (agent+briefing)
|
||||
|
||||
### 8.1 Rappels conversationnels — #199
|
||||
|
||||
Le scheduler #170 sait déjà planifier `notify`/`create_file`. Les rappels = **action
|
||||
typée de haut niveau**, pour que l'assistant puisse les gérer naturellement :
|
||||
|
||||
- Nouvelle action scheduler `reminder` : `{user_id, text, deliver: [channels…],
|
||||
recurring: none|daily|weekly|cron}` → au due : `notify.broadcast` + Web Push +
|
||||
append à `99_Assistant/Rappels.md` (historique `Rappels.md` → section « tenus »).
|
||||
- Outils : `reminder_create` (WRITE, `{texte, quand (ISO ou relatif « in 3 weeks »
|
||||
résolu côté serveur par la LLM→date), récurrence, canaux}`), `reminder_list` (READ),
|
||||
`reminder_snooze` (WRITE, +`{task_id, delta}` — le snooze bump `last_snoozed_count`,
|
||||
signal pour #201), `reminder_cancel` (WRITE).
|
||||
- UX : panneau Assistant « Cloche » + section latérale dédiée ; les rappels dus sont
|
||||
injectés en contexte mémoire (§5.5) pour que l'assistant puisse en parler de lui-même.
|
||||
|
||||
### 8.2 Agent programmé — nouveau type d'action `agent_prompt` — #200
|
||||
|
||||
Brique générique manquante : le scheduler doit pouvoir **lancer le run_agent** avec un
|
||||
prompt, pas seulement des actions primitives.
|
||||
|
||||
- Sécurité de la tâche non interactive (pas de confirmation humaine possible) :
|
||||
**whitelist de capacités** — READ sans limite ; WRITE restreint aux chemins
|
||||
`99_Assistant/**` (le prompt d'un scheduled-agent ne peut écrire ailleurs) ;
|
||||
DANGEROUS et `connectors.*` en écriture **interdits**. Paramètre
|
||||
`allowed_extra_tools: []` pour élargir explicitement à la création.
|
||||
- Plafonds : max 8 itérations d'outils, budget tokens par tâche, `last_error` +
|
||||
broadcast `schedule_failure` (déjà en place), `last_run_at` persisté.
|
||||
- Le **briefing du matin** = built-in : skill `/brief` + tâche `daily_time 07:00`
|
||||
(fuseau `OBSIGATE_TZ`) → prompt agrégateur (§7.3) → écrit `Briefings/YYYY-MM-DD.md`
|
||||
+ notification (canaux de l'utilisateur + Web Push).
|
||||
- Autres prompts programmatifs naturels : « résumé hebdo des notes modifiées »,
|
||||
« veille » (crawl d'une page + diff dans une note) — mais chacun reste une tâche
|
||||
créée par l'utilisateur ou l'assistant, pas de magie par défaut.
|
||||
|
||||
### 8.3 Le job de consolidation passe par `agent_prompt`
|
||||
|
||||
#194 s'implémente comme tâche built-in `daily_time 23:30` + whitelist
|
||||
`memory.*`/`profile_get` — l'infrastructure d'ordonnancement de #200 sert la mémoire.
|
||||
(Ordre de livraison : #200 avant #194, cf. §10.)
|
||||
|
||||
---
|
||||
|
||||
## 9. Retrait d'Ollama — #191
|
||||
|
||||
Objectif utilisateur : ObsiGate ne doit pas hériter de l'infra (ni Ollama, ni Honcho) ;
|
||||
et le conteneur Ollama (1,4 Go+ de poids, 2 modèles, RAM) ne sert plus.
|
||||
|
||||
1. `docker-compose.yml` : supprimer le service `ollama`, le volume `ollama_data`,
|
||||
les vars `OLLAMA_BASE_URL`/`OLLAMA_MODEL`, et la mention « port 11434 » de l'en-tête.
|
||||
Idem `docker-compose.test-linux.yml` / `docker-compose.test-win.yml`.
|
||||
2. `backend/ai.py` / `model_capabilities.py` : **conserver** le provider `ollama`
|
||||
(option de configuration, utile au desktop hors-ligne qui pointe vers un ollama
|
||||
local de l'utilisateur) mais retirer tout défaut pointant vers le réseau interne.
|
||||
Les tests d'isolation : `OBSIGATE_*` ne référence plus `http://ollama:11434`.
|
||||
3. Embeddings : par **sentence-transformers local optionnel** (déjà #70) — le mode
|
||||
par défaut de l'Assistant V2 fonctionne **sans embeddings** (TF-IDF + RRF + faits
|
||||
SQLite), les embeddings étant un raffinement auto-détecté (`SemanticIndex` existe,
|
||||
avec fallback API provider si configurée).
|
||||
4. Migration : `docker compose down && up -d` (règle .env connue P17/P29) ; supprimer
|
||||
le volume `ollama_data` (procédure documentée dans le CHANGELOG + ROADMAP de la tache #191).
|
||||
|
||||
---
|
||||
|
||||
## 10. Phasage & IDs
|
||||
|
||||
> Chaque tranche est livrable indépendamment, CI verte + DoD `DELIVERY_WORKFLOW.md`,
|
||||
> i18n FR/EN, tests, guide utilisateur `docs/GUIDES/`.
|
||||
|
||||
| ID | Tranche | Contenu | Effort | Dépend | Critères d'acceptation |
|
||||
|---|---|---|---|---|---|
|
||||
| #191 | **Purge Ollama** | compose ×3, docs, notes migration | 0,5 j | — | plus aucun conteneur ollama ; AI editor + agent OK sur deepseek/openrouter/gemini |
|
||||
| #192 | **Mémoire socle** | `memory/store.py`, `recall.py`, 5 outils `@tool`, tests | 3–4 j | #191 | recall/save/update/forget fonctionnent en chat ; audit + confirmation OK |
|
||||
| #193 | **Miroir markdown** | `mirror.py`, `99_Assistant/`, balises gérées, re-parse edits | 2–3 j | #192 | édition manuelle de Profil.md → faits `source='user'` ; réécritures idempotentes |
|
||||
| #200 | **Agent programmé + briefing** | action `agent_prompt` (whitelist), skill `/brief`, tâches built-in | 3–4 j | #192 | briefing 07:00 → note + notif ; WRITE limité `99_Assistant/**` prouvé par tests |
|
||||
| #194 | **Consolidation nocturne** | `consolidator.py`, incrémental, pending/cartes, budget tokens | 3–4 j | #200, #193 | 1 nuit test → ≥1 fait utile extrait ; aucun secret extrait (redaction test) ; budget respecté |
|
||||
| #199 | **Rappels** | action `reminder`, 4 outils, UI cloche, snooze-count | 2–3 j | #200 | « rappelle-moi dans 3 semaines » → rappel réel ; snooze ≥3 → signal visible #201 |
|
||||
| #195 | **Cadre connecteurs** | `connectors/` base+oauth+registry, UI Sources, migration Gitea/GitHub | 4–5 j | — | OAuth2 PKCE de bout en bout avec un provider de test ; secrets 0600 |
|
||||
| #196 | **Google Calendar + Gmail** | capabilities §7.2, context briefing étendu | 3–4 j | #195 | agenda du jour cité dans briefing ; recherche mail via chat avec confirmation WRITE nulle |
|
||||
| #197 | **Microsoft Teams/Outlook** | Graph client min, capabilities READ + send | 3–4 j | #195 | lire un chat Teams et y répondre via carte de confirmation |
|
||||
| #198 | **Discord read + Notion + Drive** | capacités restantes §7.2 | 3–5 j | #195 | import page Notion → note vault ; `drive.read_file` OK |
|
||||
| #201 | **Proactivité / insights** | détecteurs stdlib (open loops, snoozes, pourrissement TODO, récurrences) → `Insights.md` + suggestions en contexte | 3–4 j | #194, #199 | un scenario « note à faire > 21 jours » et « snooze ×3 » génèrent des insights réels et acceptables/refusables |
|
||||
|
||||
Ordre recommandé : `#191 → #192 → #193 → #200 → #194 → #199` (la mémoire et le
|
||||
briefing rendent l'assistant utile **sans** connecteur) puis connecteurs `#195 → #196…`
|
||||
et enfin `#201` (le coaching a besoin de la mémoire garnie).
|
||||
|
||||
---
|
||||
|
||||
## 11. Sécurité & confidentialité (rappel explicite)
|
||||
|
||||
- **Secrets** : uniquement `data/connector_secrets.json` (0600, chiffrés `secret.key`)
|
||||
ou env `OBSIGATE_CONNECTOR_SECRET_*` — jamais le compose, jamais git (#168/#103).
|
||||
- **SSRF** : toutes les URLs configurées par l'utilisateur passent
|
||||
`validate_webhook_url`/`is_safe_target` ; les endpoints des providers OAuth sont
|
||||
une allowlist codée (accounts.google.com, login.microsoftonline.com…).
|
||||
- **Chiffrement au repos** optionnel : même pattern que #172 (backlog existant) —
|
||||
si activé, s'applique à `connector_secrets.json` + `memory.sqlite3`.
|
||||
- **Redaction** : `redact_file_content()` systématique avant LLM pour toute note
|
||||
consolidée ; jamais le contenu brut des connecteurs dans les prompts hors
|
||||
appel d'outil explicite.
|
||||
- **Consentement par tâche planifiée** : les tâches `agent_prompt` listent leurs
|
||||
capacités autorisées dans l'UI, avec journal `last_run` dépliable.
|
||||
- **Effacement** : `memory_forget` + purge des sessions #95 + export de la base
|
||||
(`GET /api/memory/export` JSON) → réversibilité complète RGPD-like.
|
||||
|
||||
---
|
||||
|
||||
## 12. Risques & arbitrages assumés
|
||||
|
||||
| Risque | Mitigation |
|
||||
|---|---|
|
||||
| Qualité variable de l'extraction de faits → mémoire « bruitée » | seuil confiance + pending/cartes ; dossiers sensibles → validation manuelle ; `last_seen` decay ; effacement facile |
|
||||
| Coût LLM (consolidation + briefing quotidiens) | cheap model dédié par var `OBSIGATE_AGENT_BACKGROUND_MODEL` ; budgets durs en tokens ; aggregation déterministe (stdlib) avant LLM |
|
||||
| Dérive du scheduler qui exécute des prompts non surveillés | whitelist capacités + budget itérations + plafond chemins ; journal d'audit par tâche |
|
||||
| Élargissement du scope backend (main.py déjà volumineux) | nouveaux modules dédiés (`memory/`, `connectors/`) ; aucune logique dans `main.py` ; routers séparés |
|
||||
| OAuth app Google/Microsoft à créer manuellement (client id) | guide `docs/GUIDES/CONNECTEURS_GOOGLE_MS.md` pas-à-pas FR/EN ; possible de rester sur tokens statiques Notion/GitHub sans projet cloud |
|
||||
| Éditions humaines du miroir non re-parsables (markdown libre) | balises `managed` strictes ; sections hors-balises intouchables ; re-parse = faits, pas diff sémantique |
|
||||
| Tauri desktop + connecteurs = surface réseau | capabilities absentes du registre tant que non installées ; offline-first global inchangé |
|
||||
|
||||
---
|
||||
|
||||
## 13. Définition de terminé (par tranche, conforme DoD dépôt)
|
||||
|
||||
Pour chaque ID : tests unitaires backend (+tests de non-régression pour tout bug),
|
||||
tests JSDOM si UI, E2E Playwright si flux UI touché, ruff/mypy 0 erreur, i18n FR/EN,
|
||||
labels `ai.step.*` pour tout nouvel outil, mise à jour `CHANGELOG.md` `[Unreleased]`,
|
||||
`docs/ROADMAP.md` (statut + fiche `docs/features/assistant-v2-<slug>.md` par tranche),
|
||||
guide utilisateur `docs/GUIDES/` si exposé à l'utilisateur, CI verte, commit conventionnel
|
||||
référençant l'ID.
|
||||
|
||||
---
|
||||
|
||||
## Annexe A — Variables d'environnement nouvelles
|
||||
|
||||
```
|
||||
# Mémoire
|
||||
OBSIGATE_MEMORY_ENABLED=true
|
||||
OBSIGATE_MEMORY_VAULT=Main # vault où vit 99_Assistant/
|
||||
OBSIGATE_MEMORY_REVIEW_FOLDERS=Santé,Famille # forcent status=pending
|
||||
OBSIGATE_CONSOLIDATION_MODEL= # cheap model, défaut = provider courant
|
||||
OBSIGATE_CONSOLIDATION_MAX_TOKENS=30000
|
||||
# Briefing / agent programmé
|
||||
OBSIGATE_TZ=America/Toronto
|
||||
OBSIGATE_BRIEFING_TIME=07:00
|
||||
# Connecteurs (ou UI Sources connectées — stockage prioritaire)
|
||||
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET # app Google Cloud (consent screen)
|
||||
AZURE_CLIENT_ID / AZURE_CLIENT_SECRET # app Entra ID
|
||||
OBSIGATE_NOTION_API_KEY=
|
||||
OBSIGATE_DISCORD_BOT_TOKEN= # lecture REST uniquement
|
||||
```
|
||||
|
||||
## Annexe B — Prompt de consolidation (extrait)
|
||||
|
||||
```
|
||||
Tu extrais des faits durables à partir de notes et conversations récentes d'un
|
||||
journal personnel. Pour chaque fait : subject, predicate, value (une phrase,
|
||||
français), kind ∈ preference|decision|entity|event|habit|goal|open_loop,
|
||||
confidence 0..1. Règles :
|
||||
- Uniquement ce qui est explicitement affirmé ou fortement implicite ;
|
||||
- Rien de sensible sans appui textuel direct (santé, finances, familles) ;
|
||||
- Jamais de mot de passe, token, numéro personnel (signalés comme refusés) ;
|
||||
- Si un fait contredit un fait existant, produit le nouveau avec note de supersede.
|
||||
Sortie : JSON strict {facts: [...]} — aucun commentaire.
|
||||
```
|
||||
|
||||
## Annexe C — Prompt du briefing (extrait)
|
||||
|
||||
```
|
||||
Contexte fourni : agenda du jour (gcal), rappels dus, mails non lus importants
|
||||
(gmail si connecté), notes modifiées hier, faits pending à valider.
|
||||
Produis une note markdown Briefings/YYYY-MM-DD.md :
|
||||
- Aujourd'hui : événements + rappels (liens vault si applicable)
|
||||
- À trancher : 2–4 questions ouvertes détectées dans les notes récentes
|
||||
- Mémoire : 1–3 faits nouveaux à valider (citez la source note)
|
||||
Pas d'invention. Chaque affirmation cite sa source (chemin ou sujet connecteur).
|
||||
```
|
||||
@@ -178,6 +178,31 @@ des **onglets** (avec possibilité de vue multi-panneaux / split view).
|
||||
> Selon le mode, la lecture et l'édition se remplacent : `Editer` et `Forge`
|
||||
> prennent la place de la vue lecture ; revenez avec `✓` / `×` ou `Échap`.
|
||||
|
||||
### Discuter d'un fichier
|
||||
|
||||
- **Bouton « Chat »** (💬, barre d'actions du document) : ouvre un panneau
|
||||
latéral avec l'historique de la discussion et la saisie d'un message.
|
||||
- Les nouveaux messages s'affichent en direct ; si le panneau est fermé, une
|
||||
notification annonce l'envoi. L'historique est conservé par fichier.
|
||||
- Le panneau **suit le document** : changer de fichier re-cible la discussion.
|
||||
|
||||
### Chat général
|
||||
|
||||
- Onglet **💬 Chat** (dernier de la sidebar, sous les onglets) : conversation
|
||||
centrale, valable pour toute l'application, avec **pastille rouge** quand
|
||||
de nouveaux messages arrivent.
|
||||
- **📎** attache une image ou une vidéo (25 MB max) ; les URL sont cliquables.
|
||||
- La **barre de filtre** sert à chercher dans le chat (texte ou auteur) quand
|
||||
cet onglet est actif.
|
||||
- **🗑** (survol d'un message) : supprime votre message — un administrateur
|
||||
peut supprimer celui de n'importe qui. Confirmation demandée, le retrait
|
||||
est immédiat pour tout le monde.
|
||||
- **Messages privés** : la rangée de pastilles au-dessus du fil propose
|
||||
**Général** puis chaque utilisateur ; choisissez un destinataire pour une
|
||||
conversation à deux, avec son propre compteur de non-lus.
|
||||
- **Link preview** : coller une URL suffit — titre, description, image et
|
||||
site s'affichent en carte cliquable sous le message (si le site répond).
|
||||
|
||||
---
|
||||
|
||||
## 7. Rechercher
|
||||
|
||||
@@ -155,6 +155,14 @@ regroupe figeage, tri, filtre, dimensions, lien, commentaire ; les menus
|
||||
haut** de la page et l'éditeur occupe toute la largeur du contenu, pour que
|
||||
les commandes restent atteignables pendant le défilement d'un grand tableau.
|
||||
|
||||
**Mise en page mobile** — sur un écran étroit (≤ 768 px), la barre de menus et
|
||||
le ruban de formatage sont **repliés par défaut** afin que la grille occupe
|
||||
toute la hauteur disponible : seuls les onglets de feuilles, **Enregistrer** et
|
||||
la **barre de formule (fx)** restent affichés. Le bouton **☰** (à gauche des
|
||||
onglets) déplie/replie l'ensemble ; les commandes tactiles font au moins
|
||||
44 px, les onglets défilent horizontalement et les champs de saisie restent en
|
||||
16 px pour éviter le zoom automatique d'iOS.
|
||||
|
||||
Les largeurs de colonnes et hauteurs de lignes s'ajustent en **glissant le
|
||||
bord des en-têtes** (souris et tactile) : en plaçant le pointeur sur le bord
|
||||
d'un en-tête de colonne (`A`, `B`, `C`…) ou de ligne (`1`, `2`, `3`…), le
|
||||
|
||||
+19
-1
@@ -216,6 +216,9 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *BUG-106* | Page blanche sur `/docs` (Swagger UI) : la CSP `script-src` sans `unsafe-inline` (#87 T5c) refuse le script inline d'init de FastAPI, jamais noncé | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` (`SecurityHeadersMiddleware`) | Ouvrir `https://og.dracodev.net/docs` : HTML servi (200) mais aucune UI, console `Refused to execute inline script … Content Security Policy` | Injection du nonce dans le HTML de `/docs` et `/redoc` depuis le middleware, via le helper existant `inject_csp_nonce` ; corps décompressé/recompressé (GZipMiddleware est plus proche de la route) | `pytest tests/test_csp_nonce.py` (3 tests ajoutés : nonce sur `/docs`, `/redoc` sans injection, regex sur bundle externe) + 45 passed sur les 3 suites sécurité ; ruff/mypy 0 |
|
||||
| *BUG-107* | Drag & drop de fichiers depuis l'Explorateur inutilisable sur desktop : Tauri/wry pose son propre `IDropTarget` par-dessus de celui du WebView2 et aucun événement natif n'est écouté — #89 ne fonctionnait que sur le web | 🟢 corrigé | P1 | 🖥️ desktop | IA | `desktop/tauri.conf.json` (`create: false`), `desktop/src/main.rs` (`WebviewWindowBuilder::…disable_drag_drop_handler`) | App desktop : glisser un fichier de l'Explorateur sur la fenêtre → aucun survol, aucun dépôt — les gestionnaires HTML5 de `dragdrop.js` ne sont jamais déclenchés | Création manuelle de la fenêtre avec `disable_drag_drop_handler()` (doc Tauri : « required to use HTML5 drag and drop APIs on the frontend on Windows ») ; `create: false` évite la double création par la boucle Tauri | `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires) + lancement OK (fenêtre unique, boot normal) ; dépôt réel à valider par l'utilisateur |
|
||||
| *BUG-108* | Tableur : le « Coller » du menu contextuel ne fait rien (copier-coller par menu muet) | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/xlsx/context-menu.js` (`closeContextMenu`) | Ouvrir un `.xlsx`, clic droit « Copier » sur une plage, clic droit cible puis « Coller » → rien n'est collé (le menu se ferme sans action) | `closeContextMenu()` retirait le nœud du DOM **avant** de remettre `_menu` à `null` : retirer le menu qui tient le focus émet `focusout` en synchrone, qui rappelle `closeContextMenu()` → second `remove()` sur un nœud déjà démonté → `NotFoundError` qui avorte le handler de l'action « Coller » du viewer. Introduit par #179 (fermeture au focus). Correctif : `_menu = null` avant `menu.remove()` | Reproduit en local sur HEAD (e2e « coller une plage » : attendu `Date`, obtenu la valeur datée) puis vert après fix : E2E `-g "coller une plage"` 1/1, `xlsx-menus` 11/11, `ai-quick-actions` 13/13 |
|
||||
| *BUG-109* | Chat : dans la tuile d'une URL (link preview) l'image ne s'affiche jamais | 🟢 corrigé | P2 | ⚙️ backend | IA | `backend/file_chat.py` (`build_preview`, `_proxy_image`) | Poster une URL avec `og:image` dans le chat général → la carte s'affiche mais sans vignette (console : `Refused to load the image … Content Security Policy`) | La CSP de réponse (`img-src 'self' data: blob:`) interdit toute image externe, et les `og:image` **relatives** étaient résolues contre l'origine d'ObsiGate (404). La vignette est désormais **téléversée à l'envoi** dans `chat_uploads` et servie same-origin (`/api/chat/attachment/<uuid>`) : résolution `urljoin` contre la page, garde SSRF réutilisée, plafond 2 Mo, allow-list d'extensions ; échec du téléchargement → carte conservée sans vignette (jamais de message perdu) | Tests `test_preview_image_is_proxied_same_origin` + `test_preview_image_failure_keeps_the_card` (contre-preuve : proxy neutralisé → `image == ""`). Cartes antérieures inchangées : leur URL distante était déjà bloquée |
|
||||
| *BUG-111* | Admin : modale « Administration — Utilisateurs » sans icône de fermeture (lucide absent de admin.html) et tableau de bord admin affichant toutes les sections au lieu de la seule section sélectionnée | 2026-10-10 | Utilisateur | `frontend/admin.html` (ajout du script lucide UMD) + `frontend/js/admin.js` (`_wireSectionNav` : show/hide au lieu du scrollspy) ; suites frontend + backend vertes, vérifié sur instance locale 2020 | lucide n'était chargé que dans index.html ; le scrollspy laissait les 4 sections visibles |
|
||||
| *BUG-110* | Chat : refonte visuelle des posts — boutons discrets sous l'image, image ouverte dans un onglet de l'app, point de non-lus sur l'icône chat, notification OS hors focus, annulation **réelle** d'une suppression | 🟢 corrigé | P2 | 📱 frontend | IA | `frontend/js/filechat.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/filechat.test.mjs` | Clic sur une image du chat → ouverture dans un onglet du navigateur au lieu du visualiseur de l'app ; boutons de pièce jointe bavards (icône + libellé) ; aucun repère visuel persistant sur l'icône chat ; aucune alerte OS quand l'app est en arrière-plan ; le bouton « Annuler » d'une suppression ne restaurait rien (copie DOM d'un message déjà supprimé côté serveur) | `TabManager.openChatImage()` (+ branche `chatImage` de `activate()`, `renderImageViewer()` accepte une `url` directe) ouvre la pièce jointe dans un **onglet** avec le visualiseur des images de vault (zoom/pan/plein écran) ; boutons **icônes seules** (24 px, `aria-label`) sous l'image, alignés à droite ; **point rouge pulsant** (`.chat-has-unread`) tant qu'il reste des non-lus, en plus du badge chiffré ; `_notifyNewMessage()` — notification OS si `document.hidden`/hors focus, permission demandée à l'ouverture du chat ; suppression **différée de 10 s** (ligne « Ce message a été supprimé » + « Annuler », DELETE envoyé à l'échéance seulement, échec réseau → le message revient) | Vérifié : `filechat.test.mjs` 25/25 (dont 6 tests #110), `validate-imports` 42 modules / 372 exports 0 erreur, `unit.test.mjs` 13/13 |
|
||||
| | | | | | | | | | |
|
||||
### TODOs techniques (améliorations / nouvelles tâches)
|
||||
|
||||
@@ -293,7 +296,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-09-17 | BUG-065, #78 (complément) | Correction + feature | `frontend/js/excalidraw-viewer.js`, `frontend/js/utils.js`, `frontend/excalidraw-editor.html`, `tests/frontend/excalidraw-viewer.test.mjs`, `docs/features/excalidraw.md`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-065** : l'auto-save Excalidraw (débounce 2 s) déclenchait `PUT save` → SSE `index_updated` → `reloadExternalWrite` → `openFile` → recréation de l'iframe = refresh visible pendant le dessin. Auto-save retirée (`excalidraw-viewer.js` : plus de `requestSave`/`saveTimer`), sauvegarde explicite (bouton 💾 / Ctrl+S) ; `reloadExternalWrite` (utils.js) court-circuite le re-rendu si un iframe Excalidraw est ouvert sur ce fichier (attributs `data-excalidraw-vault`/`data-excalidraw-path`) ; le badge « Modified » suit désormais une signature des éléments (`id:versionNonce`) au lieu de tout `onChange` — resize/zoom/plein écran ne marquent plus le fichier modifié. **#78 (complément)** : bouton **plein écran** `#btn-fullscreen` dans la barre d'outils de l'éditeur (`requestFullscreen` sur le document de l'iframe) + iframe créée avec `allow="fullscreen" allowfullscreen`. Vérifié Playwright : bascule plein écran OK (`document.fullscreenElement` true→false), badge non modifié après bascule ; tests statiques `excalidraw-viewer.test.mjs` 12/12, validate-imports 38 modules, unit 9/9. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-17 | #78 (complément) | UI | `frontend/excalidraw-editor.html`, `docs/features/excalidraw.md`, `CHANGELOG.md` | **#78 (complément)** : la barre d'outils de l'éditeur Excalidraw passe en **colonne d'icônes** (34×34 px, SVG seuls), **collée au bord droit** (`right: 0` ; `top: 45%` ; empilement vertical), avec `title`/`aria-label`. L'icône du bouton Save est remplacée par une coche pendant 1,2 s après une sauvegarde réussie. Badge « Modifié » réduit à une pastille. Vérifié Playwright : bord droit au bord de l'iframe, haut 45 %, 4 boutons empilés ; bascule plein écran OK, cycle d'icône Save + `PUT save` observés. | 🟢 livré (en attente vérif utilisateur) |
|
||||
| 2026-09-18 | BUG-066 | Correction | `frontend/locales/fr.json`, `frontend/locales/en.json`, `tests/frontend/unit.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-066** : la table des matières de la page de configuration n'affichait aucune icône pour « Fichiers cachés » et « Partages publics ». Les libellés du sommaire proviennent de clés i18n (`config.section_hidden`, `config.section_shares`) distinctes des titres de section qui, eux, portaient déjà l'icône. Alignement : 🗂️ / 📤 en FR **et** EN. Test de non-régression : `unit.test.mjs` vérifie que **toutes** les entrées `.help-nav-link` du sommaire portent une icône dans les deux langues (17/17). Vérifié : `unit.test.mjs` 10/10, `validate-imports` 38 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-18 | #105, BUG-067 | Documentation + correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/main.py`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **#105** : audit complet de couverture du Guide d'utilisation — 8 nouvelles sections (Architecture + diagramme Mermaid, API & intégrations, Diagrammes Mermaid & Excalidraw, Hors-ligne & synchronisation, Collaboration temps réel, Application desktop, Bibliothèque & signets, Multilingue) et compléments (recherche sémantique, MFA/WebAuthn, notifications push, exports HTML/ePub/ZIP, PDF, vue multi-panneaux, admin). Téléchargement du guide en Markdown et PDF (`GET /api/guide/download?format=md|pdf`, FR/EN, rendu par le moteur d'export existant). Guide plus large en desktop. **BUG-067** : ancre morte `#help-mobile-editor` → section dédiée créée. | 🟢 corrigé (en attente vérif utilisateur)
|
||||
| 2026-09-18 | #105, BUG-067 | Documentation + correction | `frontend/index.html`, `frontend/js/config.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/main.py`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **#105** : audit complet de couverture du Guide d'utilisation — 8 nouvelles sections (Architecture + diagramme Mermaid, API & intégrations, Diagrammes Mermaid & Excalidraw, Hors-ligne & synchronisation, Collaboration temps réel, Application desktop, Bibliothèque & signets, Multilingue) et compléments (recherche sémantique, MFA/WebAuthn, notifications push, exports HTML/ePub/ZIP, PDF, vue multi-panneaux, admin). Téléchargement du guide en Markdown et PDF (`GET /api/guide/download?format=md|pdf`, FR/EN, rendu par le moteur d'export existant). Guide plus large en desktop. **BUG-067** : ancre morte `#help-mobile-editor` → section dédiée créée. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-18 | #105 (ajustements) | Amélioration | `frontend/index.html`, `frontend/js/config.js`, `frontend/sw.js`, `frontend/locales/{fr,en}.json`, `backend/guide_export.py`, `backend/pdf_export.py`, `Dockerfile`, `scripts/build_guide_diagrams.py`, `scripts/render_guide_diagram.mjs`, `scripts/guide_content.py`, `backend/assets/guide_diagrams/df7366a40db6a5a2.png`, `tests/test_guide.py`, `docs/features/guide-coverage-105.md`, `CHANGELOG.md` | **#105 (retour utilisateur)** : 1) boutons de téléchargement du guide passés en icônes seules (tooltips i18n conservés) ; 2) le diagramme Mermaid de la section Architecture est désormais rendu en **vraie image** dans le PDF (pipeline de pré-rendu PNG Chromium+mermaid v11, PNG commité sous `backend/assets/guide_diagrams/<sha1>.png`, résolu par `diagram_png_for()` ; le Markdown garde le fenced mermaid) ; 3) emoji du PDF rendus **en couleur** au lieu de rectangles : `fonts-noto-color-emoji` ajouté au Dockerfile + `"Noto Color Emoji"` en fin de pile de polices PDF. Vérifié : pytest 1218 (test_guide ×13), ruff/mypy 0, validate-imports 38, unit 10/10 ; PDF live conteneur 2020 : 24 pages, 0 glyphes tofu, diagramme 3568x1174 embarqué. | 🟢 livré
|
||||
| 2026-09-22 | BUG-068 | Correction | `backend/auth/router.py`, `backend/requirements.txt`, `frontend/js/auth.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_mfa.py`, `tests/frontend/mfa-settings.test.mjs` (nouveau), `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-068** : section « 🔒 Sécurité du compte » finalisée. (1) Boutons hors thème : `config-btn-primary`/`config-btn-danger` n'existaient pas en CSS → définis depuis les variables du thème (+ états disabled). (2) QR invisible : l'image tierce était bloquée par la CSP (`img-src 'self' data: blob:`) et exposait le secret TOTP → QR SVG `data:` généré en local par le backend (`qr_data_url`, segno) avec repli saisie manuelle. (3) Codes de récupération perdus à la 1re activation WebAuthn → `_showRecoveryCodes(codes, targetId)` avec repli `webauthn-flow-area`. (4) Carte « Mot de passe » ajoutée (endpoint `change-password` existant, jusque-là sans UI) + échappement des libellés de clés WebAuthn. Vérifié : pytest 1241 passed / 6 skipped, ruff 0, mypy 0 (78 fichiers), `mfa-settings.test.mjs` 9/9, unit 10/10, validate-imports 39 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-09-23 | BUG-069 | Correction | `frontend/js/auth.js`, `frontend/locales/{fr,en}.json`, `tests/frontend/mfa-settings.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-069** : login 2FA bloqué sans erreur — après user+pwd corrects, `showMfaChallenge` cherchait `.login-box` (inexistant dans `index.html`, marquage réel `#login-screen > .login-card`) et faisait un `return` silencieux : page de login figée, aucune erreur. Correctif : montage dans `.login-card` (repli `#login-screen`) + erreur visible `mfa.challenge_unavailable` (FR/EN) si le point de montage manque. **Reproduit au navigateur** (Playwright, instance Docker `obsigate-test`, compte jetable avec TOTP) : avant → challenge jamais affiché ; après → challenge affiché, code erroné → erreur, code valide (verify 200) → app. Tests : `mfa-settings.test.mjs` 11/11 (+2 ancrage DOM), unit 10/10, validate-imports 39 modules. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
@@ -328,6 +331,21 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-10-02 | BUG-106 | Correction | `backend/main.py`, `tests/test_csp_nonce.py` | **BUG-106 — page blanche sur /docs** : la CSP `#87 T5c` (`script-src` sans `unsafe-inline`, nonce frais par réponse) s'applique aussi aux pages générées par FastAPI (`/docs`, `/redoc`) dont le balisage n'est jamais passé par `inject_csp_nonce` → le navigateur refusait le script inline `SwaggerUIBundle(…)`, la div restait vide. `SecurityHeadersMiddleware` injecte désormais le nonce dans ce HTML avec le helper existant ; le corps est décompressé/recompressé car `SSESafeGZipMiddleware` est plus proche de la route (les réponses arrivent gunzippées). `/redoc` n'a aucun script inline (bundle jsdelivr seul, couvert par `script-src`) — vérifié tel quel. Vérifié : `test_docs_inline_script_nonced`, `test_redoc_served_with_csp`, `test_docs_external_bundle_not_nonced` + 45 passed sur les 3 suites sécurité, ruff/mypy 0. | 🟢 corrigé |
|
||||
| 2026-10-02 | BUG-107 | Correction | `desktop/tauri.conf.json`, `desktop/src/main.rs` | **BUG-107 — drag & drop de fichiers inutilisable sur desktop** : Tauri/wry installe son propre `IDropTarget` par-dessus de celui du WebView2 (source wry : « Enumerate child windows to find the WebView2 window and override! ») et aucun événement natif n'était écouté → les glisser-déposer depuis l'Explorateur n'atteignaient jamais les gestionnaires HTML5 de `dragdrop.js` (#89 marchait donc uniquement sur le web). Correctif : la doc Tauri est explicite — `disable_drag_drop_handler()` « is required to use HTML5 drag and drop APIs on the frontend on Windows » ; la fenêtre est créée en code (`create: false` dans `tauri.conf.json`, boucle Tauri qui saute les fenêtres non auto-créées) via `WebviewWindowBuilder::from_config(...).disable_drag_drop_handler()`. Vérifié : `cargo test` 26 passed (nouveau garde-fou `test_window_created_without_tauri_drag_drop_handler` : les deux marqueurs obligatoires), lancement de l'app OK (fenêtre unique, boot log normal) ; glisser-déposer réel à valider par l'utilisateur. | 🟢 corrigé |
|
||||
| 2026-10-07 | BUG-108 | Correction | `frontend/js/xlsx/context-menu.js` | **BUG-108 — « Coller » du menu contextuel du tableur muet** : `closeContextMenu()` faisait `menu.remove()` **avant** `_menu = null` ; retirer le menu portant le focus émet `focusout` synchrone qui rappelle `closeContextMenu()`, le second `remove()` sur un nœud démonté lève `NotFoundError` et avorte le handler de l'action avant son exécution (introduit par la fermeture au focus #179). Reproduit par l'E2E `xlsx-viewer.spec.js` « coller une plage » (CI #850→#854). Correctif : déférencer avant de retirer. | 🟢 corrigé |
|
||||
| 2026-10-08 | #189 | Fonctionnalité | `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `docs/ROADMAP.md`, `docs/features/xlsx-mobile-layout-189.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `README.md`, `README.fr.md`, `CHANGELOG.md` | **#189 — mise en page mobile du tableur** : sur ≤ 768 px les barres menus/ruban/Recherche sont repliées par défaut (la barre des feuilles + barre formule fx restent visibles, la grille récupère la hauteur perdue), bascule unique `#xlsx-tools-toggle` (☰, `aria-expanded`), cibles tactiles ≥ 44 px, groupes du ruban qui s'enroulent, onglets de feuilles défilants, inputs 16 px (anti-zoom iOS). E2E mobile ajouté + helper `openXlsx()` (déplie le ruban). 10 échecs E2E mobile **préexistants** prouvés par `git stash` (hors périmètre, CI ne lance que desktop). Vérifié : xlsx-viewer.test.mjs 165/165, E2E desktop 19 passed/1 skipped, suite 1628 passed, ruff/mypy 0, CI run #1937 success | ✅ livré (en attente vérif utilisateur) |
|
||||
| 2026-10-08 | #191 | Fonctionnalité | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/js/sync.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#191 — chat : suppression de post, messages privés, boîte compacte, link preview** : (A) `DELETE /api/chat/{id}` + `/api/chat/dm/{peer}/{id}` (auteur ou admin, 403 sinon) avec diffusion SSE `chat_deleted` et bouton 🗑 au survol côté client (`confirm()` natif) ; (B) DM 2 utilisateurs : vault `__dm__` + `dm_path(a,b)` trié, `GET /api/chat/users` (destinataires sans soi-même, aucun hash exposé), routes `GET/POST /api/chat/dm/{user}` (404 inconnu, 400 DM à soi), rangée de canaux `.chat-channels` dans la sidebar, non-lus par canal (compteurs localStorage, anciens timestamps/compteurs mélangés unifiés) ; (C) bouton d'envoi réduit à une icône dans les deux formulaires ; (D) `build_preview(text)` : 1ʳᵉ URL → garde SSRF `_assert_public_http_url` → `httpx.get` 5 s/512 Ko → OG (titre/desc/image/site) en carte cliquable, best-effort (échec → message sans carte), cache 200 entrées. Bug trouvé en cours de route : `max_redirects` n'existe pas sur `httpx.get` → TypeError avalé par le `try` = previews jamais générées en prod, invisible des tests (ils mockaient `build_preview`) ; arg retiré + `test_build_preview_happy_path_parses_og` (httpx mocké) ajouté. Tests : pytest 45 file_chat (44→45, +13 sur #191), JSDOM filechat 16 (+5) |
|
||||
| 2026-10-08 | #190 | Fonctionnalité | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js` (réécrit), `frontend/js/viewer.js`, `frontend/js/config.js`, `frontend/js/sidebar.js`, `frontend/js/app.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `CHANGELOG.md` | **#190 — chat : suivi du document, chat général en onglet sidebar, onglets au-dessus du filtre** : (A) l'en-tête du panneau affiche le document ciblé (titre + chemin) et `followFileChat()` appelé par `renderFile()` re-cible le panneau ouvert à chaque changement de document ; (B) onglet **Chat** (dernier) → conversation générale stockée dans le même store #169 via les sentinelles `__global__/general` (`GET/POST /api/chat`), **pastille de messages non lus** (`localStorage` + badge sur l'onglet), **pièces jointes image/vidéo** (`POST /api/chat/upload` : allow-list d'extensions, 25 MB, nom UUID — jamais le nom client ; `GET /api/chat/attachment/{name}` résolu contre l'allow-list), **URL cliquables** dans le rendu (linkification `http(s)://`), date/heure d'envoi ; (C) barre de filtre **déplacée sous les onglets** et routée vers `filterChatMessages()` (texte + auteur) quand l'onglet Chat est actif. Transport : broadcast SSE `chat_message` réutilisé (vault `__global__` route vers le panneau sidebar, sinon panneau fichier) — pas de second WebSocket. Contre-preuves : `__global__` non routé → test « routes __global__ » échoue ; panneau sans classe `active` → le message part en toast au lieu du rendu (révélé par le stub JSDOM). Vérifié : pytest **1655 passed / 2 skipped** (27 tests chat dont 12 nouveaux), ruff 0, mypy 0 (113 fichiers), validate-imports 42 modules/368 exports, unit 13/13, filechat.test.mjs **11/11** (5 nouveaux), toolbar-order, sidebar-filters 8/8 | ✅ livré (en attente vérif utilisateur) |
|
||||
| 2026-10-08 | #169 | Fonctionnalité | `backend/file_chat.py` (nouveau), `backend/routers/file_chat.py` (nouveau), `backend/schemas.py`, `backend/main.py`, `frontend/js/filechat.js` (nouveau), `frontend/js/viewer.js`, `frontend/js/sync.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py` (nouveau), `tests/frontend/filechat.test.mjs` (nouveau), `tests/frontend/toolbar-order.test.mjs`, `.gitea/workflows/ci.yml`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md` (nouveau), `docs/GUIDES/PRISE_EN_MAIN.md`, `CHANGELOG.md` | **#169 — chat intégré par fichier** : store JSON par (vault, path) sous `data/chats/` (nom SHA-256 → traversal impossible, plafond 500 msgs, texte 4000 car., écriture atomique, fichier corrompu → vide) ; `GET/POST /api/file/{vault}/chat` (auth + `check_vault_access` + `resolve_safe_path`, 400/404/403, `response_model`) + broadcast SSE `chat_message` sur le transport #62 (**pas de 2ᵉ WebSocket**) ; panneau latéral `filechat.js` (bouton 💬 toolbar, rendu chronologique `textContent`, envoi optimiste + dédup par id, toast si panneau fermé/autre fichier, plein écran ≤ 768 px) ; relais SSE en import dynamique dans `sync.js` ; i18n FR/EN 10 clés `chat.*`. En route : tag OpenAPI maison `file-chat` non déclaré → `test_used_tags_are_declared` rouge (retiré, dérivé « Files »), regex navBtns de `toolbar-order.test.mjs` (échappements → `includes`), ruff UP012/TRY004/I001. Vérifié : `test_file_chat.py` 15 passed, `filechat.test.mjs` 6/6, suite 1643 passed, ruff/mypy 0, validate-imports 42 modules, CI run #1938 5/5 success (v2.57.0) | ✅ livré (en attente vérif utilisateur) |
|
||||
| 2026-10-08 | #192 + BUG-109 | Fonctionnalité + correction | `backend/file_chat.py`, `backend/routers/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/js/sync.js`, `frontend/index.html`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/ROADMAP.md`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#192 — chat : saisie auto-agrandissante + accusé de réception.** La boîte d'envoi (panneau document **et** sidebar) devient un `textarea` dont la hauteur suit le contenu (plafond 160 px puis défilement, repasse à 1 ligne après l'envoi), `Entrée` envoie / `Maj+Entrée` saute une ligne ; placeholder sidebar désormais traduit à l'init. **Accusé de réception** : chaque document de conversation porte `read = {utilisateur: ts}` (verrou global sur le read-modify-write — une lecture ne peut plus faire disparaître un message), `GET` d'historique renvoie la carte, nouveau `POST /api/chat/read` (400 sans vault/path, 403 DM hors pair, ACL vault pour un chat de fichier) marque la lecture et diffuse `chat_read` en SSE ; côté client `✓` envoyé / `✓✓` lu sur **mes** messages (exclut ma propre carte de lecture), bascule en direct via `onChatRead()` + toast « {user} a lu votre message », accusé émis à l'ouverture d'une conversation et à la réception d'un message déjà affiché. **BUG-109** : la vignette des tuiles de lien ne s'affichait jamais (CSP `img-src 'self'` + `og:image` relative résolue contre ObsiGate) → `_proxy_image()` téléverse l'image dans `chat_uploads` (same-origin, `urljoin` contre la page, garde SSRF, 2 Mo max, allow-list) et la carte reste affichée si le téléchargement échoue. Tests : pytest `test_file_chat.py` 45→**54** (`TestReadReceipts` 7, proxy image 2, 2 assertions de réponse adaptées), JSDOM `filechat.test.mjs` 16→**19** ; ruff 0, mypy 0 (113 fichiers), validate-imports 42/371, unit 13/13 | ✅ livré (en attente vérif utilisateur) |
|
||||
| 2026-10-10 | BUG-111 | Correction UI admin | `frontend/admin.html`, `frontend/js/admin.js`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-111** : (1) la modale « Administration — Utilisateurs » ouverte depuis `/admin` n'affichait pas l'icône du bouton fermer — `safeCreateIcons()` retourne immédiatement car le global `lucide` n'existe pas sur `admin.html` (script UMD absent — seul index.html le chargeait) ; ajout du même `<script defer>` lucide@0.344.0. (2) Le tableau de bord admin affichait les 4 sections d'un coup (scrollspy) — `_wireSectionNav` masque désormais toutes les sections sauf celle cliquée (état initial : hash de l'URL ou section Stats). Vérifié : 1705 passed, suites frontend vertes, instance docker locale 2020. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-09 | BUG-110 | Correction + refonte UI chat | `frontend/js/filechat.js`, `frontend/js/ui.js`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/frontend/filechat.test.mjs`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-110 — refonte visuelle du chat** : (1) les boutons de pièce jointe deviennent des **icônes seules** (24 px, `aria-label`) rangées **sous l'image, alignées à droite** — plus de gros boutons à libellé ; (2) le clic sur une image ouvre le **même visualiseur que pour une image de vault** (zoom/pan/plein écran) mais dans un **onglet de l'app** (`TabManager.openChatImage()`, branche `chatImage` de `activate()`, `renderImageViewer()` accepte désormais une `url` directe : une pièce jointe n'est pas dans un vault) au lieu d'un onglet du navigateur ; (3) **point rouge pulsant sur l'icône chat** de la sidebar tant qu'il reste des non-lus (classe `chat-has-unread`, en plus du badge chiffré) ; (4) **notification OS** (`Notification`) quand l'app n'est pas au premier plan (`document.hidden`/hors focus), permission demandée au premier clic d'ouverture du chat ; (5) la suppression n'est plus mensongère : le `DELETE` est **différé de 10 s**, la ligne passe à « Ce message a été supprimé » avec « Annuler » — annuler réarme le message (l'ancien code restaurait une copie DOM d'un message déjà détruit côté serveur en affichant « Message restauré ») ; échec réseau → le message revient. Vérifié : `filechat.test.mjs` 25/25 (dont 6 tests #110 : icônes seules, clic image → `openChatImage`, aucun `DELETE` pendant la fenêtre d'annulation, notification OS), `validate-imports` 42 modules / 372 exports 0 erreur, `unit.test.mjs` 13/13 | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
|
||||
| 2026-10-09 | #193 | Fonctionnalité | `backend/file_chat.py`, `backend/schemas.py`, `frontend/js/filechat.js`, `frontend/style.css`, `tests/test_file_chat.py`, `tests/frontend/filechat.test.mjs`, `docs/features/file-chat-169.md`, `CHANGELOG.md` | **#193 — un post du chat s'affiche comme un document markdown** : le serveur rend le texte de chaque message avec le **pipeline des documents** (`backend/render.py::_render_markdown` : mistune — tableaux, listes de tâches, notes —, wikilinks, masquage des secrets #188, **sanitizer BUG-021**) et le renvoie dans un champ `html` (ajouté à `ChatMessageItem`, sinon `response_model` le filtrait). Le rendu est calculé **à la lecture** (`get_messages`) et à l'ajout (`_append`) — il n'est donc **jamais persisté** dans `data/chats/*.json` — et l'écho SSE part avec : les autres clients voient le rendu sans refresh. Côté front, `_fillBody()` injecte le HTML dans un `<div class="md-content">` puis appelle `safeHighlight()` sur chaque `pre code` (highlight.js + alias de langages, exactement comme le viewer ; mermaid non déclenché). Repli intact si `html` manque (écho optimiste → texte brut + URL cliquable) et un échec de rendu n'emporte jamais le message. CSS : `.file-chat-md` annule le `pre-wrap` hérité de la bulle (les retours à la ligne du HTML source produisaient des lignes vides) et compacte `.md-content` (`pre`/`table` en `overflow-x: auto`). Vérifié : `test_file_chat.py` 54→**61** (`TestMarkdownRendering` ×7 : html à l'ajout et à la lecture, **absent du JSON**, classe `language-xxx`, listes, `<script>` neutralisé, routes POST/GET, broadcast SSE), `filechat.test.mjs` 25→**27**, suite 1689 passed, ruff 0, mypy 0 (113 fichiers), validate-imports 42 modules / 372 exports | ✅ livré (en attente vérif utilisateur) |
|
||||
|
||||
| 2026-10-09 | #194 | Fonctionnalité | `backend/user_home.py` (nouveau), `backend/indexer.py`, `backend/auth/router.py`, `backend/routers/vaults.py`, `backend/main.py`, `docker-compose.yml`, `tests/test_user_home.py` (nouveau), `tests/test_auth_api.py`, `docs/features/user-home-194.md` (nouveau), `docs/ROADMAP.md`, `CHANGELOG.md`, `README.md`, `README.fr.md` | **#194 — dossier personnel par utilisateur** : chaque compte reçoit `<OBSIGATE_HOME_ROOT>/<user>` monté comme vault propre `home-<user>` (option A validée) — l'isolation s'appuie sur l'ACL par vault **existante** (`check_vault_access`), aucune ACL par chemin inventée. `backend/user_home.py` : `ensure_user_home()` idempotent (mkdir → `add_vault_to_index` → `persist_vault` → watcher → SSE → octroi dans `user.vaults`) appelé à `POST /api/auth/admin/users` **et** au démarrage (`ensure_all_user_homes()` : migration des users existants, réparation des dossiers/registres manquants, sweep des vaults `home-*` orphelins dont l'utilisateur a disparu hors route — garde `Path.parent == racine` pour ne jamais toucher à un vault admin homonyme) ; `release_user_home()` à la suppression (index/watcher/registre refermés, **dossier conservé**). **Blocage levé** : registre persistant `data/vaults.json` fusionné par `load_vault_config()` — avant, tout vault ajouté à la volée (`/api/vaults/add`) disparaissait au premier reindex complet et au redémarrage. Correctif au passage : `reload_single_vault()` remet `vault_config[name]` après `remove_vault_from_index()`. Réponse du POST = vaults réactualisés (l'instantané de `create_user` était antérieur à l'octroi). Déploiement : montage unique `/NFS/OBSIDIAN_DOC/Home:/vaults/Home` + `OBSIGATE_HOME_ROOT` (absente = feature inactive, dev/tests/desktop inchangés). Vérifié : `test_user_home.py` 12/12 (création, idempotence, username invalide, persistance après `load_vault_config`, retrait, isolation `check_vault_access`, orphelins), `test_auth_api.py::TestUserHome` 2/2, suite **1703 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, `validate-imports` 42 modules / 372 exports, `unit.test.mjs` 13/13 | ✅ livré (en attente vérif utilisateur) |
|
||||
|
||||
| 2026-10-09 | #194 | Fonctionnalité (UI) | `frontend/js/sidebar.js`, `frontend/js/viewer.js`, `docker-compose.test-win.yml`, `.gitignore`, `tests/frontend/sidebar-filters.test.mjs`, `docs/ROADMAP.md`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 (incrément UI)** : les vaults `home-<user>` s'affichent **en premier** dans l'arborescence de la sidebar — tri stable appliqué à la source (`_homeFirst` dans `loadVaults()` et dans le soft-refresh, donc `state.allVaults`, l'arborescence et les sélecteurs restent cohérents) — et portent l'icône **maison** lucide (`data-lucide="home"` via `getVaultIcon`, même `safeCreateIcons`/`stroke: currentColor` que les autres icônes, thème-aware). Compose de test : `test_home:/vaults/Home` + `OBSIGATE_HOME_ROOT=/vaults/Home` (dossier `test_home/` ignoré par git). Vérifié : `sidebar-filters.test.mjs` 8→**10/10**, `validate-imports` 42 modules / 372 exports, `unit.test.mjs` 13/13, suites voisines (ai-sidebar, toolbar-order, search-facets, pane-manager, editor-inline) vertes, **live Playwright sur l'instance 2020** : ordre `home-admin, home-bruno, TestDir, TestVault` + `svg.lucide-home` rendu (placeholder `<i>` remplacé) | ✅ livré (en attente vérif utilisateur) |
|
||||
|
||||
| 2026-10-09 | #194 | Correction (isolation) | `backend/auth/middleware.py`, `backend/services/recent.py`, `backend/services/search.py`, `backend/routers/search.py`, `backend/routers/config.py`, `backend/routers/conflicts.py`, `backend/routers/files_read.py`, `backend/routers/history.py`, `backend/push.py`, `backend/user_home.py`, `tests/test_user_home.py`, `tests/test_auth_api.py`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 — l'admin voyait le home de chaque utilisateur** (retour utilisateur) : `vaults: ["*"]` ouvrait **tous** les vaults, donc `home-<user>` compris. Correctif à la racine : `check_vault_access` exige un octroi **explicite** pour tout vault `home-*` (nouveau `is_home_vault()`), et les filtres `« * »` écrits en dur dans les routes ont été remplacés par cet appel unique — dashboard (`routers/config.py`), conflits, liens retour, favoris, abonnements push (`/api/search/replace` utilisait déjà `is_vault_allowed`). `/api/search` filtre désormais les bruts **avant** pagination via `search_vaults(is_allowed=…)` (sinon notes personnelles d'un autre compte dans les résultats). `_grant` n'écarte plus les comptes `« * »` → l'admin reçoit son propre `home-admin` (auto-réparé au démarrage). Vérifié : `test_user_home.py` 12→**14/14** (dont « * » sans homes + filtrage recherche), `test_auth_api.py` 2/2 (admin ne voit **plus** `home-alice`, alice voit le sien), suite **1705 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, **live Playwright instance 2020** : arborescence admin = `home-admin, TestDir, TestVault` (plus de `home-bruno`), `svg.lucide-home` rendu. Reste ouvert (fuite pré-existante, hors périmètre) : `/api/search/advanced`, `/api/tags`, `/api/tags/suggest`, `/api/tree-search`, `/api/suggest` ne filtrent pas par vault accessible | ✅ livré (en attente vérif utilisateur) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+106
-23
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.55.0 | **Dernière mise à jour :** 2026-10-08
|
||||
> **Version :** 2.67.2 | **Dernière mise à jour :** 2026-10-10
|
||||
> **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)**
|
||||
@@ -30,6 +30,76 @@
|
||||
|
||||
## 🔵 En cours
|
||||
|
||||
### 196. Partage dirigé entre utilisateurs (document → utilisateur)
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-10 | **Statut :** ✅ livré le 2026-10-10
|
||||
- **Fiche :** [features/directed-shares-196.md](./features/directed-shares-196.md)
|
||||
- **Description :** un utilisateur peut partager un document à un ou plusieurs
|
||||
autres comptes (pas un lien public) : les pages `/s/{token}` exigent alors
|
||||
une session et n'acceptent que créateur + admin + destinataires (404 sinon,
|
||||
pas de fuite d'existence). Vue « partagé avec moi » dans le dashboard,
|
||||
destinataires listés dans le dialogue de partage, révocabilité réservée au
|
||||
créateur/admin, `GET /api/shares` scopé (un non-admin ne voit que ses
|
||||
partages créés/reçus — corrige une fuite préexistante). Rétrocompatible :
|
||||
les partages publics existants restent anonymes.
|
||||
- **Sous-tâches :**
|
||||
- [x] Store `shared_with` + validation des destinataires à la création
|
||||
- [x] Gate auth+appartenance sur `/s/{token}` (+`/pdf`, `/raw`) — 404 opaque
|
||||
- [x] Scope de `GET /api/shares` ; révocation créateur/admin uniquement
|
||||
- [x] UI : dialogue dirigé/public + destinataires, dashboard « partagé par X »
|
||||
- [x] i18n FR/EN, tests `test_directed_shares.py` (9), fiche feature
|
||||
- [x] **Itération 2 (2026-10-11)** : icône « partagé » (share-2 bleu) sur les
|
||||
fichiers porteurs d'un partage actif dans l'arborescence ; dossier
|
||||
virtuel **Partage** à la racine du home du destinataire (fichiers reçus,
|
||||
ouverture lecture seule `/s/{token}`) ; les documents reçus sont
|
||||
**cherchables** (merge dans `search_vaults`, résultats
|
||||
`home-<user>/Partage/…` avec `share_token`, clic → page de partage —
|
||||
jamais l'éditeur du vault d'autrui) ; cache front invalidé à la
|
||||
création/révocation. +2 tests (recherche trouvée par le destinataire,
|
||||
aucune fuite vers un non-destinataire).
|
||||
- [x] **Itération 3 (2026-10-11)** : le dossier Partage ne déclenche plus la
|
||||
navigation (dossier virtuel — corrige « Directory not found: Partage ») ;
|
||||
un fichier reçu s'ouvre **dans l'application en onglet** (arbre,
|
||||
recherche, dashboard) via la résolution serveur
|
||||
`home-<user>/Partage/<fichier>` → fichier source dans `/api/file`
|
||||
(+ raw, download) ; validation `path` non-nulle à la création d'un
|
||||
partage. +2 tests.
|
||||
- [x] **Itération 4 (2026-10-11)** : le partage dirigé EST l'autorisation —
|
||||
la résolution `Partage/…` court-circuite l'ACL du vault source (fix
|
||||
« Accès refusé à la vault … » : le destinataire n'a par définition
|
||||
pas accès au vault de l'émetteur) ; chemin virtuel canonique
|
||||
`Partage/<token>/<nom>` (lève l'ambiguïté de fichiers homonymes,
|
||||
fallback par nom conservé) ; « reçu » = adressé à moi (un admin ne
|
||||
voit plus les partages d'autres comptes comme reçus — fix 404
|
||||
« File not found: Partage/… »). +3 tests.
|
||||
|
||||
### 194. Dossier personnel par utilisateur (`<racine>/Home/<user>`)
|
||||
|
||||
- **Effort :** 1-2 jours | **Impact :** 🟡 | **Ouvert :** 2026-10-09 | **Statut :** 🔵 en cours
|
||||
- **Cadrage :** chaque utilisateur créé reçoit un dossier personnel monté en vault propre
|
||||
(`home-<user>`), isolé par l'ACL par vault existante — pas d'ACL par chemin à inventer.
|
||||
- **Décisions validées :** option A (un vault par utilisateur), nom `home-<user>`,
|
||||
dossier **conservé** à la suppression du compte, racine `/NFS/OBSIDIAN_DOC/Home`
|
||||
(montée `/vaults/Home`, exposée par `OBSIGATE_HOME_ROOT`).
|
||||
- **Sous-tâches :**
|
||||
- [x] ID stable créé (avant codage, AGENTS.md)
|
||||
- [x] Registre persistant des vaults dynamiques `data/vaults.json` (les vaults ajoutés à la
|
||||
volée sont aujourd'hui perdus au redémarrage / reindex — `load_vault_config` ne lit
|
||||
que les env `VAULT_N_*`)
|
||||
- [x] Service `ensure_user_home()` idempotent : mkdir + vault + octroi, appelé à la création
|
||||
d'un user et au démarrage (réparation/migration)
|
||||
- [x] Retrait au suppression de compte (index/watcher/registre) sans supprimer le dossier
|
||||
- [x] Montage Docker + `OBSIGATE_HOME_ROOT` (prod **et** compose de test
|
||||
`docker-compose.test-win.yml` → `test_home:/vaults/Home`)
|
||||
- [x] UI : vaults `home-*` en **premier** dans l'arborescence + icône maison
|
||||
lucide (`getVaultIcon`), vérifié live sur l'instance 2020
|
||||
- [x] Isolation : « * » (admin) ne couvre **plus** les dossiers persos —
|
||||
`check_vault_access` + filtres des routes + `/api/search` (avant
|
||||
pagination) ; octroi du home aussi aux comptes « * »
|
||||
- [x] Tests (création, persistance après rebuild, isolation 403 entre users) + docs
|
||||
|
||||
---
|
||||
|
||||
### 77. Application Desktop native — Tauri (Windows / Linux / macOS)
|
||||
|
||||
- **Effort :** 8-12 jours | **Impact :** 🟡 | **Framework :** Tauri v2 (Rust + Webview)
|
||||
@@ -40,26 +110,6 @@
|
||||
- [ ] **Signature de code Windows** : **non retenue — décision confirmée le 2026-09-26** : livraison non signée + documentation SmartScreen (« Exécuter quand même »). Alternatives écartées sauf retour utilisateur : SignPath.io (OSS gratuit), Certum OSS, Azure Trusted Signing, certificat EV
|
||||
- [ ] Exécuter les 6 tests E2E **manuels** — protocole documenté : [DESKTOP_E2E_CHECKLIST.md](./DESKTOP_E2E_CHECKLIST.md)
|
||||
|
||||
### 159. Gestion desktop des vaults & dossiers — retrait par menu contextuel + ajout en configuration
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟢 | **Framework :** commandes Rust existantes (`add/remove/list_vaults|dirs`) + `ContextMenuManager`
|
||||
- **Statut :** ✅ livré le 2026-10-02 (v2.50.0) — ouvert le 2026-10-02 (prérequis : BUG-104/BUG-105 livrés)
|
||||
- **Reste à faire :**
|
||||
- [x] Retrait d'un vault **ou dossier racine** par menu contextuel (entrée « Retirer de l'application », desktop uniquement, confirmation, déregistration seule)
|
||||
- [x] Section Configuration : liste des vaults/dossiers desktop + ajout (bouton vault existant `pickAndAddVault` + nouveau sélecteur `pick_folder` sans effet de bord pour les dossiers)
|
||||
- [x] Rafraîchissement de la jump list après chaque ajout/retrait
|
||||
- [x] i18n FR/EN, tests (garde-fou ACL + unit), E2E, fiche features, release
|
||||
|
||||
### 160. Premier lancement professionnel + section Configuration harmonisée
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟢 | **Framework :** config Rust + CSS classes existantes
|
||||
- **Statut :** ✅ livré le 2026-10-02 (v2.51.0) — ouvert le 2026-10-02
|
||||
- **Reste à faire :**
|
||||
- [x] Premier lancement : monter `%USERPROFILE%\ObsiGate` comme vault par défaut (remplace `voute_obsidian`), `vault_path` dessus, nom de dossier racine home dérivé du chemin (fin du hardcode « bruno »)
|
||||
- [x] Document `Prise en main.md` (contenu embarqué `include_str!`) écrit dans ce dossier si absent — jamais d'écrasement
|
||||
- [x] Refonte visuelle de la section « 🖥️ Vaults & dossiers (Desktop) » : classes CSS (motif `webauthn-key-item`), boutons `.config-btn-sm`, zéro style inline, override mobile 44px
|
||||
- [x] Tests Rust (config par défaut + doc embarqué), suites frontend, E2E, docs, release
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Visionneuse & édition Excel (P0/P1/P2)
|
||||
@@ -528,6 +578,34 @@
|
||||
|
||||
---
|
||||
|
||||
## ✅ Terminé — Mise en page mobile du tableur
|
||||
|
||||
### 189. Tableur — mise en page mobile (barres repliées, grille prioritaire)
|
||||
|
||||
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Framework :** CSS vanilla + `frontend/js/viewer.js`
|
||||
- **Statut :** ✅ **livré le 2026-10-08** — ouvert le 2026-10-08
|
||||
- **Fiche :** [features/xlsx-mobile-layout-189.md](./features/xlsx-mobile-layout-189.md)
|
||||
- **Description :** sur mobile, la barre de menus + le ruban + la barre de
|
||||
formule s'empilaient et occupaient plus de 50 % de la hauteur d'écran.
|
||||
Sous 768 px, `.xlsx-menubar`, `.xlsx-cmdbar` et le groupe Recherche sont
|
||||
**repliés par défaut** (la barre de formule fx et les onglets restent
|
||||
visibles) et un **bouton unique** `#xlsx-tools-toggle` dans la barre des
|
||||
feuilles déplie/replie l'ensemble (`aria-expanded`, état actif) — desktop
|
||||
inchangé (bouton masqué).
|
||||
- **Sous-tâches :**
|
||||
- [x] **A1** Repli CSS par défaut ≤ 768 px + révélation via
|
||||
`.xlsx-toolbar.xlsx-tools-open` (menubar, ruban, Recherche)
|
||||
- [x] **A2** Bascule `#xlsx-tools-toggle` dans `.xlsx-sheetbar`
|
||||
(`aria-expanded` + classe `active` + i18n `xlsx.tools_toggle` FR/EN)
|
||||
- [x] **A3** Tactile : cibles ≥ 44 px, onglets en défilement horizontal,
|
||||
champs de la barre de formule en 16 px (anti-zoom iOS), barre de
|
||||
formule en 2 lignes quand les outils sont ouverts
|
||||
- [x] **A4** Tests : JSDOM `xlsx-viewer.test.mjs` (+2, 165/165) et E2E
|
||||
mobile (repli par défaut, dépliage, no-overflow ruban, 44 px) ;
|
||||
`openXlsx()` déplie automatiquement ≤ 768 px pour les tests desktop
|
||||
|
||||
---
|
||||
|
||||
## ⚪ Backlog — Priorité 4 (P4)
|
||||
|
||||
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
|
||||
@@ -610,7 +688,7 @@
|
||||
| 172 — Chiffrement/déchiffrement de fichiers | Non livré (AES-GCM interne réservé aux tokens) | ⚪ #172 |
|
||||
| 173 — Vues personnalisées (Kanban, Calendrier) | Non livré | ⚪ #173 |
|
||||
| 174 — Optimisation mobile | Base **largement livrée** (#69 éditeur mobile, #83 ruban mobile, #114 config responsive 44 px) ; reste un audit du solde | ⚪ #178 |
|
||||
| 175 — Chat intégré | Non livré (transport WebSocket/rooms de #62 réutilisable) | ⚪ #169 |
|
||||
| 175 — Chat intégré | **Livré le 2026-10-08** (SSE, panneau latéral, historique, toast) | ✅ #169 |
|
||||
| 176 — Thèmes dynamiques (règles heure/date) | Base livrée (#65 thèmes + import/export) ; règles automatiques manquantes | ⚪ #174 |
|
||||
| 177 — Export formats propriétaires (OneNote, Evernote) | Base livrée (#66 HTML/MD bundle/ePub + PDF #74) ; formats tiers manquants | ⚪ #175 |
|
||||
| 178 — Webhooks pour événements | **Déjà livrée** (#9 — `backend/webhooks.py`, événements + secrets + validation d'URL SSRF-safe) | ✅ |
|
||||
@@ -681,7 +759,7 @@
|
||||
| 166 | Détection & fusion de doublons (score de similarité, liste des paires potentielles, outil de fusion) — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md)) | 3-4 j | 🟢 | embeddings/TF-IDF #70 ; **jamais de fusion sans confirmation explicite** (+ backup préalable) |
|
||||
| 167 | Connexions tierces — Google Drive & Notion (import/export, OAuth2, respect des quotas) | 5-7 j | 🟡 | page « sources connectées » #103 ; rate-limit existant |
|
||||
| 168 | Notifications externes — canaux Slack/Discord (webhooks) et Email (SMTP) + choix des déclencheurs — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md) ; périmètre : Discord, Telegram, SMTP, webhook générique — Slack via webhook générique) | 3-4 j | 🟢 | `backend/webhooks.py` #9 ; Push API #67 |
|
||||
| 169 | Chat intégré par fichier (panneau latéral, historique, notifications de nouveaux messages) | 3-4 j | 🟢 | transport WebSocket/rooms de la collab #62 |
|
||||
| 169 | Chat intégré par fichier (panneau latéral, historique, notifications de nouveaux messages) — ✅ **livré le 2026-10-08** ([fiche](./features/file-chat-169.md) ; transport SSE #62, pas de 2ᵉ WebSocket) | 3-4 j | 🟢 | transport SSE #62 |
|
||||
| 170 | Planification de tâches automatiques (type cron ; actions = outils existants `create_file`/`append_to_file` ; notifications d'échec) — ✅ **livré le 2026-10-04** ([fiche](./features/agent-phase4-166-168-170.md) ; tick asyncio 60 s, API + outils, sans UI dédiée) | 3-4 j | 🟢 | scheduler backend (APScheduler ou asyncio) ; UI de création de tâches |
|
||||
| 171 | Audit des accès — extension : historique lecture/édition par fichier, filtres (utilisateur/fichier/date), export CSV | 2-3 j | 🟡 | `backend/audit.py` + `/api/admin/audit` (#71) |
|
||||
| 172 | Chiffrement/déchiffrement de fichiers sensibles (AES-256-GCM, gestion de clés, confirmation) | 3-4 j | 🟡 | **backup automatique préalable** ; clés hors dépôt (jamais de secret committé) |
|
||||
@@ -721,6 +799,11 @@
|
||||
| 185 | Tableur — barre de menus Google Sheets (10 menus), ruban Sheets, grille unie | 2.54.0 | [features/xlsx-menus-185.md](./features/xlsx-menus-185.md) |
|
||||
| 186 | Création d'un fichier Excel (`.xlsx`) depuis la modale « Créer un fichier » | 2.54.0 | [features/create-file-xlsx-186.md](./features/create-file-xlsx-186.md) |
|
||||
| 188 | Secrets — détection universelle des clés API & mots de passe, masque cliquable (clic = copie) | Unreleased | [features/secret-mask-188.md](./features/secret-mask-188.md) |
|
||||
| 189 | Tableur — mise en page mobile : barres repliées par défaut, bascule unique, cibles tactiles 44 px | 2.56.0 | [features/xlsx-mobile-layout-189.md](./features/xlsx-mobile-layout-189.md) |
|
||||
| 169 | Chat intégré par fichier (panneau latéral, historique, temps réel SSE, toast de notification) | 2.57.0 | [features/file-chat-169.md](./features/file-chat-169.md) |
|
||||
| 190 | Chat — suivi du document, chat général en onglet sidebar (pièces jointes, non lus, recherche), onglets au-dessus du filtre | 2.58.0 | [features/file-chat-169.md](./features/file-chat-169.md) |
|
||||
| 191 | Chat — suppression de post, messages privés 2 utilisateurs (canaux), boîte d'édition compacte, link preview OG | 2.60.0 | [features/file-chat-169.md](./features/file-chat-169.md) |
|
||||
| 192 | Chat — boîte de saisie auto-agrandissante + accusé de réception ✓✓ (retour live SSE, notification de lecture) · BUG-109 (image de tuile de lien) | 2.61.0 | [features/file-chat-169.md](./features/file-chat-169.md) |
|
||||
| 159 | Desktop — gestion des vaults & dossiers : retrait par menu contextuel + section Configuration (ajout vault/dossier racine) | 2.50.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) |
|
||||
| 160 | Desktop — premier lancement professionnel (répertoire `%USERPROFILE%\ObsiGate` + `Prise en main.md`) et section Configuration harmonisée | 2.51.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) |
|
||||
| BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) |
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# #196 — Partage dirigé entre utilisateurs
|
||||
|
||||
> **Statut :** livré | **Ouvert :** 2026-10-10 | **Type :** feature (sharing + auth)
|
||||
|
||||
## Problème
|
||||
|
||||
Le seul partage existant (#85) était un lien **public** : quiconque possède
|
||||
l'URL lit le document, sans compte ni session. Il n'existait aucun moyen de
|
||||
partager un document *à un utilisateur précis* de la plateforme — un
|
||||
utilisateur ne pouvait ni adresser un document à un autre, ni consulter les
|
||||
documents qu'on lui adresse, ni (côté créateur) retirer ce partage.
|
||||
|
||||
## Décision d'architecture
|
||||
|
||||
Deux formes étaient possibles :
|
||||
|
||||
1. **Réutiliser le partage par token existant, dirigé** (retenu) — le record
|
||||
`data/shares.json` gagne `shared_with: [usernames]`. Si la liste est non
|
||||
vide, les pages `/s/{token}` (et `/pdf`, `/raw`) exigent une session et
|
||||
n'acceptent que créateur + admin + destinataires. Vide/absent : le
|
||||
comportement public historique est inchangé (rétrocompatibilité totale,
|
||||
zéro migration — les shares existants n'ont pas le champ).
|
||||
2. Octroyer le vault entier au destinataire (`update_user {"vaults": …}`)
|
||||
(écartée) — accès lecture-**écriture** au vault complet au lieu d'un
|
||||
document en lecture seule ; c'est le modèle « collaborateur de vault »,
|
||||
pas « partager un document ». Reste la bonne réponse pour ce besoin-là.
|
||||
|
||||
Points de sécurité délibérés :
|
||||
|
||||
- **404, jamais 403/401, sur la gate** — un visiteur sans session (ou un
|
||||
utilisateur non destinataire) apprend le même « Share not found or
|
||||
expired » qu'un token inventé : l'existence d'un token dirigé n'est pas
|
||||
confirmable de l'extérieur.
|
||||
- **`GET /api/shares` scopé** (correctif au passage) — un non-admin ne voit
|
||||
que les partages qu'il a créés **ou reçus`** (avant : tout le monde voyait
|
||||
tous les partages, fuite préexistante devenue intenable avec du user-to-user).
|
||||
Un admin voit tout.
|
||||
- **Révocation réservée au créateur/admin** — un destinataire ne peut pas
|
||||
casser l'accès des autres.
|
||||
- Les **destinataires sont validés** à la création (`get_user`) : pas de lien
|
||||
mort vers un compte inexistant.
|
||||
|
||||
## Mise en place
|
||||
|
||||
| Élément | Où |
|
||||
|---|---|
|
||||
| Store : champ `shared_with`, param de `create_share` | `backend/share.py` |
|
||||
| Gate `_gate_share()` + branchement `/s/{token}`, `/s/{token}/pdf`, `/s/{token}/raw` | `backend/routers/sharing.py` |
|
||||
| Scope de `GET /api/shares` (non-admin = créés + reçus) | `backend/routers/sharing.py`, `backend/share.py::list_shares(user=…)` |
|
||||
| Révocabilité créateur/admin uniquement | `backend/routers/sharing.py::api_share_revoke` |
|
||||
| Validation des destinataires à la création | `backend/routers/sharing.py::api_share_create` |
|
||||
| `ShareModel.shared_with` | `backend/schemas.py` |
|
||||
| Dialogue : radio public/dirigé + champ destinataires (virgules) | `frontend/js/config.js::openShareDialog` |
|
||||
| Destinataires affichés dans la vue « déjà partagé » | `frontend/js/config.js` |
|
||||
| Dashboard : cartes reçues (« Partagé par X », ouverture `/s/`, pas de bouton révoquer) | `frontend/js/dashboard.js::DashboardSharedWidget` |
|
||||
| i18n FR/EN (6 clés config.* + dashboard.shared_by) | `frontend/locales/{fr,en}.json` |
|
||||
|
||||
Ce qui est gratuit par construction : lecture seule (le destinataire ne
|
||||
passe jamais par l'éditeur), expiration, compteur d'accès, suivi des
|
||||
renommages (`update_shares_after_rename`), masquage des secrets
|
||||
(`redact_file_content` déjà dans les 3 routes `/s/*`), isolation des
|
||||
dossiers perso #194 (le destinataire ne voit jamais l'arbre du vault de
|
||||
l'auteur, seulement le document rendu).
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_directed_shares.py` (9) : création dirigée, destinataire inconnu
|
||||
→ 400, anonyme → 404 / destinataire + créateur → 200, non-destinataire →
|
||||
404, lien public toujours anonyme, scope de `/api/shares`, destinataire ne
|
||||
peut pas révoquer (403), créateur révoque, la révocation coupe l'accès.
|
||||
|
||||
## Limites connues
|
||||
|
||||
- `shared_with` est une liste plate d'usernames — pas de groupes. Ajouter
|
||||
des groupes quand le besoin sera réel.
|
||||
- Pas de notification push : le destinataire découvre les partages reçus au
|
||||
rechargement du dashboard (badge/rafraîchissement suffisent ; SSE existe
|
||||
si un vrai push est demandé).
|
||||
- La date d'« expiration » reste globale au partage (pas par destinataire).
|
||||
|
||||
## Itération 2 — visibilité & recherche (2026-10-11)
|
||||
|
||||
| Élément | Où |
|
||||
|---|---|
|
||||
| Icône « partagé » (share-2 bleu) sur les fichiers de l'arborescence | `frontend/js/sidebar.js` (`loadSharedPaths` + `sharedFileIconSync`, appliqué dans `loadDirectory` et `incrementalLoadDirectory`) |
|
||||
| Dossier virtuel **Partage** à la racine du home du destinataire | `frontend/js/sidebar.js` (injection `virtual-share` + rendu, clic fichier → `/s/{token}`) |
|
||||
| Recherche des documents reçus | `backend/services/search.py::_shared_results` (merge dans `search_vaults` avant pagination), `backend/routers/search.py` (passe `username`), `SearchResultItem.share_token` |
|
||||
| Clic sur un résultat reçu → page de partage | `frontend/js/search.js` |
|
||||
| Invalidation du cache front | `invalidateSharedPaths()` exporté, appelé par `openShareDialog` après création/révocation |
|
||||
|
||||
Décisions :
|
||||
|
||||
- **Dossier virtuel, pas physique** : l'indexeur exclut les symlinks
|
||||
(sécurité path traversal) et un dossier réel ferait double indexation.
|
||||
La source de vérité reste `data/shares.json`.
|
||||
- **Recherche à la volée** (`_shared_results`) : le contenu des documents
|
||||
reçus est lu au moment de la requête, uniquement pour les destinataires —
|
||||
jamais indexé dans le TF-IDF global, donc aucune fuite vers un
|
||||
non-destinataire (testé). ponytail: lecture disque par requête, plafond
|
||||
~quelques centaines de partages reçus par user ; indexer dans le TF-IDF
|
||||
avec un champ "destinataires" si le volume devient réel.
|
||||
- Icône déterminée côté client via `/api/shares` (déjà scopé #196) : la
|
||||
liste des partages actifs "créés OU reçus" suffit — le créateur voit
|
||||
l'icône sur son fichier source, le destinataire voit le dossier Partage.
|
||||
|
||||
+2 tests : `test_search_finds_received_share_for_recipient`,
|
||||
`test_search_does_not_leak_share_to_other_user`.
|
||||
|
||||
|
||||
## Itération 3 — ouverture en onglet (2026-10-11)
|
||||
|
||||
- **Résolution serveur** : `_resolve_shared_file()` (`backend/routers/files_read.py`)
|
||||
mappe `home-<user>/Partage/<fichier>` vers le fichier source pour les routes
|
||||
`/api/file`, `/api/file/raw`, `/api/file/download`. Résolu AVANT l'ACL vault
|
||||
(le home virtuel peut ne pas être dans `user.vaults`) — l'autorisation réelle
|
||||
est l'appartenance au partage, vérifiée dans le resolver (destinataire seul).
|
||||
- **Front** : clic dossier Partage → simple dépliage (plus de `openNav`, fix
|
||||
« Directory not found ») ; clic fichier reçu → `TabManager.openPreview/openPersistent`
|
||||
(arbre, recherche, dashboard) — même parcours qu'un fichier ordinaire.
|
||||
- `POST /api/share` : `path` vide/null → 400 (un share `path: None` cassait
|
||||
`/api/shares` en 500).
|
||||
+2 tests : ouverture applicative du fichier reçu (file + raw), résolution
|
||||
scopée par utilisateur (home d'autrui → 403).
|
||||
|
||||
|
||||
## Itération 4 — correctifs d'ouverture (2026-10-11)
|
||||
|
||||
- **Le partage EST l'autorisation** : après résolution `Partage/…`, l'ACL du
|
||||
vault source n'est plus appliquée (`elif` dans `/api/file`, raw, download) —
|
||||
un destinataire sans accès au vault émetteur ouvrait « Accès refusé à la
|
||||
vault '…' », précisément le cas d'usage du partage. L'appartenance au
|
||||
partage (vérifiée dans `_resolve_shared_file` via `list_shares(user=…)`)
|
||||
reste la seule autorisation.
|
||||
- **Chemin canonique `Partage/<token>/<nom>`** : lève l'ambiguïté de deux
|
||||
partages homonymes (le token identifie le partage exact). Fallback par nom
|
||||
conservé pour les liens historiques. Transporté par l'arbre (`data-path`),
|
||||
la recherche et le dashboard.
|
||||
- **« Reçu » = adressé à moi** : sidebar `_loadReceivedShares` et dashboard
|
||||
filtrent désormais `shared_with.includes(me)` — un admin voyant TOUS les
|
||||
partages (`/api/shares` non scopé pour lui) n'a plus les partages d'autres
|
||||
comptes dans son dossier Partage (source des « File not found »).
|
||||
+3 tests : destinataire sans accès au vault source (200), disambiguïsation
|
||||
par token, home d'autrui (403). 14 tests au total.
|
||||
@@ -0,0 +1,272 @@
|
||||
# Chat intégré par fichier — #169
|
||||
|
||||
- **Statut :** ✅ livré le 2026-10-08
|
||||
- **Effort :** 3-4 j (réalisé en 1 session) | **Impact :** 🟢
|
||||
- **Transport :** SSE existant (#62) — pas de second WebSocket
|
||||
|
||||
## Ce qui a été livré
|
||||
|
||||
### Backend
|
||||
|
||||
- **Store `backend/file_chat.py`** — historique JSON par paire
|
||||
`(vault, path)` sous `data/chats/` :
|
||||
- nom de fichier = SHA-256 de `vault\0path` (32 premiers hex) : aucun
|
||||
séparateur de chemin utilisateur dans le nom → traversal impossible ;
|
||||
- plafond **500 messages** par fichier (les plus vieux supprimés) ;
|
||||
- texte tronqué à **4000 caractères** ;
|
||||
- écriture atomique (tmp + move) ; fichier corrompu → historique vide
|
||||
(fallback, pas de crash).
|
||||
- **Routes `backend/routers/file_chat.py`** (tag OpenAPI « Files » dérivé) :
|
||||
- `GET /api/file/{vault}/chat?path=…` → historique chronologique ;
|
||||
- `POST /api/file/{vault}/chat` `{path, text}` → ajout + broadcast SSE ;
|
||||
- garde commune : `require_auth` + `check_vault_access` +
|
||||
`resolve_safe_path` (traversal → 403/500 via le handler `ServiceError`) ;
|
||||
- `text` ou `path` vides → 400 ; vault inconnu → 404.
|
||||
- **Schémas** `ChatMessageItem` / `ChatHistoryResponse` /
|
||||
`ChatMessageResponse` dans `backend/schemas.py` (`response_model` sur les
|
||||
deux routes).
|
||||
- **Broadcast** : `sse_manager.broadcast("chat_message", {vault, path, message})`.
|
||||
|
||||
### Frontend
|
||||
|
||||
- **`frontend/js/filechat.js`** — panneau latéral :
|
||||
- bouton 💬 (`.btn-chat`) dans le groupe nav de la toolbar fichier
|
||||
(viewer.js), toggle ouvre/ferme ;
|
||||
- historique rendu chronologiquement, bulles « mine » à droite,
|
||||
`textContent` pour le corps (pas d'injection HTML) ;
|
||||
- envoi POST optimiste + dédoublonnage par `id` à la réception SSE ;
|
||||
- panneau fermé / autre fichier → **toast** « Nouveau message de … »
|
||||
(silencier si c'est son propre message).
|
||||
- **`frontend/js/sync.js`** — listener SSE `chat_message` avec import
|
||||
dynamique de `filechat.js` (pas de cycle d'imports).
|
||||
- **CSS** bloc `#169` : panneau fixe 340 px, **plein écran ≤ 768 px**,
|
||||
variables CSS avec fallbacks, input 16 px (anti-zoom iOS).
|
||||
- **i18n** : 10 clés `chat.*` dans `fr.json` et `en.json`.
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/test_file_chat.py` — 15 tests : roundtrip store, isolation par
|
||||
fichier, plafond de rétention, troncature, traversal hashée, fichier
|
||||
corrompu, routes (200/400/404/403), fixture autouse isolant `CHAT_DIR`.
|
||||
- `tests/frontend/filechat.test.mjs` — 6 tests JSDOM : empty state, rendu
|
||||
chronologique + classe `mine`, anti-injection HTML, append live + dédup
|
||||
par id, isolation inter-fichiers, toggle. Locales réellement chargées
|
||||
(fetch simulé vers `frontend/locales/`).
|
||||
- Les deux sont ajoutés au pipeline CI (`.gitea/workflows/ci.yml`).
|
||||
|
||||
## #190 — Suivi du document, chat général, ordre des onglets (2026-10-08)
|
||||
|
||||
### A. Le panneau suit le document
|
||||
|
||||
- En-tête du panneau : `.file-chat-target` affiche le titre du document
|
||||
(l'attribut `title` porte le chemin complet).
|
||||
- `followFileChat(vault, path, title)` est appelé en tête de `renderFile()` :
|
||||
panneau ouvert + autre document → re-cible (reload de l'historique) ;
|
||||
panneau fermé → no-op. Le bouton 💬 transmet `data.title`.
|
||||
|
||||
### B. Chat général en onglet sidebar
|
||||
|
||||
- Onglet `#sidebar-tab-chat` (dernier, `message-circle`) + panneau
|
||||
`#sidebar-panel-chat` (liste + 📎 + saisie + Envoyer, Entrée = envoyer).
|
||||
- **Store** : rien de nouveau — les sentinelles `GLOBAL_VAULT="__global__"` /
|
||||
`GLOBAL_PATH="general"` réutilisent `add_message`/`get_messages`.
|
||||
- **Routes** : `GET/POST /api/chat`, `POST /api/chat/upload`,
|
||||
`GET /api/chat/attachment/{name}`.
|
||||
- **Pièces jointes** : allow-list d'extensions (11 : images + vidéos), 25 MB,
|
||||
nom de stockage = UUID (le nom client n'est jamais utilisé), résolution
|
||||
par comparaison au nom + allow-list (aucun chemin utilisateur).
|
||||
- **Messages non lus** : timestamp de dernière visite dans
|
||||
`localStorage['obsigate-chat-unread']`, comptage des messages plus récents
|
||||
d'un autre auteur, pastille rouge `99+` sur l'onglet, effacée à l'ouverture.
|
||||
- **URL** : linkification `http(s)://` au rendu (texte autour en `TextNode`,
|
||||
donc pas d'injection).
|
||||
- **SSE** : `vault === "__global__"` route vers la liste sidebar (append +
|
||||
clear) ou vers badge + toast selon que l'onglet est actif.
|
||||
|
||||
### C. Ordre des onglets + filtre = recherche chat
|
||||
|
||||
- `index.html` : le bloc `.sidebar-filter` est **déplacé après** `.sidebar-tabs`.
|
||||
- `switchSidebarTab` : placeholder `sidebar.filter_chat`, dispatch `chat`.
|
||||
- `initSidebarFilter` (`sidebar.js`) : `routeFilter`/`routeClear` routent
|
||||
vers `filterChatMessages()` (texte **et** auteur, insensible à la casse).
|
||||
|
||||
## #191 — Suppression, messages privés, boîte compacte, link preview (2026-10-08)
|
||||
|
||||
### A. Suppression d'un post
|
||||
|
||||
- `DELETE /api/chat/{id}` et `DELETE /api/chat/dm/{peer}/{id}` :
|
||||
**auteur du message ou admin** (403 sinon), 404 si l'id n'existe pas.
|
||||
- Store : `delete_message(vault, path, id)` — écriture atomique,
|
||||
`False` si absent ; broadcast SSE `chat_deleted` `{vault, path, id}`.
|
||||
- Frontend : bouton 🗑 (`.file-chat-del`, visible au survol) rendu dans
|
||||
`_messageEl` uniquement si l'URL de suppression est fournie **et**
|
||||
que l'utilisateur est auteur/admin ; `confirm()` natif ; retrait local
|
||||
+ retrait en direct chez tous via `onChatDeleted` (relais `sync.js`).
|
||||
|
||||
### B. Messages privés 2 utilisateurs
|
||||
|
||||
- Store : `DM_VAULT = "__dm__"`, `dm_path(a, b)` = paire **triée** → les
|
||||
deux participants lisent/écrivent le même document, indépendamment de
|
||||
l'ordre.
|
||||
- Routes : `GET /api/chat/users` (destinataires — usernames +
|
||||
display_names, **sans soi-même**, aucun hash/mot de passe exposé),
|
||||
`GET/POST /api/chat/dm/{username}` (404 utilisateur inconnu, 400 DM à
|
||||
soi), `DELETE /api/chat/dm/{username}/{id}`.
|
||||
- Frontend : rangée de pastilles `.chat-channels` (« Général » + un
|
||||
bouton par utilisateur) au-dessus du fil ; `_channel` état courant,
|
||||
`_channelKey`/`_channelUrl`/`_channelDelUrl` ; placeholder dédié
|
||||
`chat.placeholder_dm` ; non-lus **par canal** (compteurs dans
|
||||
`localStorage`, l'ancien mélange timestamp/compteur est devenu un
|
||||
compteur unique `{"general": n, "dm:x": n}`, remis à zéro à l'ouverture,
|
||||
pastille = total) ; les DM SSE routés par `_pairOf(path)`.
|
||||
|
||||
### C. Boîte d'édition compacte
|
||||
|
||||
- Bouton d'envoi réduit à une icône `send` (`.file-chat-send-icon`) dans
|
||||
les deux formulaires (panneau document + sidebar) ; la saisie garde
|
||||
toute la largeur. `safeCreateIcons()` à la fin du `_renderShell`.
|
||||
|
||||
### D. Link preview
|
||||
|
||||
- `build_preview(text)` (store) : 1ʳᵉ URL du texte → garde SSRF
|
||||
`_assert_public_http_url` (host privé/loopback rejeté, schémas non
|
||||
http refusés) → `httpx.get` 5 s, 512 Ko, `follow_redirects` (limite
|
||||
httpx = 20) → parse OG par regex (`og:title` avec repli `<title>`,
|
||||
`og:description`, `og:image`, `og:site_name`) →
|
||||
`{url, title, description, image, site}`.
|
||||
- **Best-effort** : n'importe quelle erreur (URL morte, timeout, garde)
|
||||
→ `None`, le message passe quand même ; cache borné **200 entrées**.
|
||||
- Frontend : `_previewEl` rend une carte cliquable (vignette lazy
|
||||
`no-referrer`, site en capitales, titre/description tronqués 2 lignes)
|
||||
sous le corps du message.
|
||||
- Schéma : `ChatMessageItem.preview`.
|
||||
|
||||
### Tests #191
|
||||
|
||||
- pytest `TestDelete` (3), `TestPrivateChat` (8), `TestLinkPreview` (8,
|
||||
dont le happy path avec `httpx.get` mocké — un `max_redirects`
|
||||
inexistante sur `httpx.get` levait un TypeError silencieusement avalé
|
||||
par le `try`, testé par `test_build_preview_happy_path_parses_og`).
|
||||
- JSDOM `filechat.test.mjs` +5 : carte preview, bouton supprimer
|
||||
(droit auteur/admin), `onChatDeleted`, compteurs non-lus,
|
||||
routage DM isolé du général (16 au total).
|
||||
|
||||
## #192 — Saisie auto-agrandissante, accusé de réception, vignette de lien (2026-10-08)
|
||||
|
||||
### A. Boîte de saisie qui grandit avec le texte
|
||||
|
||||
`<input type="text">` → `<textarea rows="1">` (panneau document créé dans
|
||||
`_renderShell()`, sidebar statique dans `index.html`). `_autoGrow(el)`
|
||||
pose `height: auto` puis `min(scrollHeight, 160)px` à chaque `input`, la
|
||||
règle CSS `textarea.file-chat-input` ajoute `resize: none` +
|
||||
`overflow-y: auto` (au-delà du plafond la zone défile) et la hauteur repasse
|
||||
à une ligne après l'envoi. `Entrée` (sans `Maj`) est interceptée dans les
|
||||
deux formulaires → envoi, `Maj+Entrée` garde le retour à la ligne. Le
|
||||
placeholder de la sidebar est aussi appliqué à l'init (`t()`), le HTML ne
|
||||
portant que le FR.
|
||||
|
||||
### B. Accusé de réception (✓ envoyé / ✓✓ lu)
|
||||
|
||||
- **Store** (`backend/file_chat.py`) : le document de conversation gagne
|
||||
`read = {username: dernier_ts}` (`get_read()` / `mark_read()`), sous le
|
||||
`_LOCK` du read-modify-write partagé avec l'ajout et la suppression.
|
||||
- **API** : les trois `GET` d'historique renvoient `read` (schéma
|
||||
`ChatHistoryResponse.read`) ; nouveau `POST /api/chat/read`
|
||||
(`ChatReadResponse`) avec `_check_read()` — général = tout membre, DM =
|
||||
pair uniquement (403 sinon), fichier = ACL vault + `resolve_safe_path` —
|
||||
qui marque la lecture et diffuse `chat_read`
|
||||
`{vault, path, user, ts, read}` en SSE.
|
||||
- **Client** (`filechat.js`) : carte de lecture par surface (`_fileRead`,
|
||||
`_channelRead`). Un message est « lu » dès qu'un **autre** participant a
|
||||
un `ts ≥` celui du message (ma propre lecture ne compte pas).
|
||||
`_messageEl()` affiche `✓`/`✓✓` (`.file-chat-status`, bleu accent quand
|
||||
lu) ; `_applyRead()` fait basculer les indicateurs affichés ;
|
||||
`_syncRead()` fusionne une carte reçue ; `_markRead(vault, path)` (POST
|
||||
fire-and-forget) est émis à l'ouverture d'une conversation **et** à la
|
||||
réception d'un message pendant qu'elle est affichée. `onChatRead()`
|
||||
(relais `chat_read` ajouté dans `sync.js`) actualise en direct et
|
||||
notifie l'expéditeur par toast `chat.read_by` quand un de ses messages
|
||||
passe à ✓✓ — jamais pour son propre écho.
|
||||
|
||||
### C. BUG-109 — vignette des tuiles de lien
|
||||
|
||||
`img-src 'self' data: blob:` interdit toute `og:image` distante, et une
|
||||
`og:image` relative était résolue contre l'origine d'ObsiGate (404).
|
||||
`_proxy_image(img_url, page_url)` téléverse la vignette à l'envoi dans
|
||||
`chat_uploads` via `save_attachment()` (allow-list + nom UUID) et renvoie
|
||||
`/api/chat/attachment/<uuid>` — résolution `urljoin` contre la page,
|
||||
garde SSRF réutilisée, plafond `PREVIEW_IMAGE_MAX` (2 Mo), dérivation de
|
||||
l'extension depuis le `content-type`. Échec isolé → `image = ""`, la
|
||||
carte reste (jamais de message perdu).
|
||||
|
||||
### Tests #192
|
||||
|
||||
- pytest `TestReadReceipts` (7) : roundtrip store, messages préservés,
|
||||
`POST /api/chat/read` (enregistrement + restitution par le `GET`),
|
||||
403 DM hors pair, 400 champs manquants, ACL vault, broadcast `chat_read`
|
||||
(spy sur `sse_manager`) ; `TestLinkPreview` +2 (proxy same-origin avec
|
||||
fichier écrit sur disque, dégradation sans vignette).
|
||||
- JSDOM `filechat.test.mjs` +3 (19) : indicateur ✓/✓✓ (ma propre carte de
|
||||
lecture exclue, aucun indicateur sur les messages des autres), bascule en
|
||||
direct + notification + pas d'écho, textarea (tagName/rows, hauteur
|
||||
pilotée, `Entrée` intercepté / `Maj+Entrée` non).
|
||||
|
||||
## #193 — Rendu markdown des posts (2026-10-09)
|
||||
|
||||
Un post du chat s'affiche désormais **comme un document** : titres, listes,
|
||||
tableaux, citations, blocs de code colorés — au lieu du texte brut avec liens
|
||||
cliquables.
|
||||
|
||||
### A. Le rendu vient du serveur (pas de second moteur markdown)
|
||||
|
||||
- `backend/file_chat.py::_decorate()` ajoute un champ `html` à chaque message
|
||||
en réutilisant `backend/render.py::_render_markdown()` — **le pipeline exact
|
||||
des documents** : mistune (tableaux, strikethrough, notes, listes de tâches),
|
||||
résolution des wikilinks, normalisation des sauts de ligne, masquage des
|
||||
secrets (#188) et **sanitizer BUG-021** en sortie. Aucun markdown côté client,
|
||||
donc aucun XSS nouveau à traiter : la sanitisation est déjà éprouvée.
|
||||
- `ChatMessageItem.html` (schéma de réponse) : sans ce champ, `response_model`
|
||||
filtrait la clé.
|
||||
- Le `html` est calculé **à la lecture** (`get_messages()`) et à l'ajout
|
||||
(`_append()`), donc il n'est **jamais persisté** dans `data/chats/*.json` :
|
||||
un message reste du texte, le rendu suit les évolutions du pipeline.
|
||||
- L'écho SSE part avec le `html` : les autres clients voient le rendu sans
|
||||
attendre un refresh.
|
||||
- Un échec de rendu n'emporte pas le message : repli silencieux (`html = ""`),
|
||||
le client affiche le texte brut.
|
||||
|
||||
### B. Frontend : `.md-content` + highlight.js
|
||||
|
||||
- `_fillBody()` injecte le `html` dans un `<div class="md-content">` (la
|
||||
typographie des documents) puis appelle `safeHighlight()` sur chaque
|
||||
`pre code` — le même helper (et les mêmes alias de langages) que le viewer.
|
||||
Mermaid n'est pas déclenché dans le chat.
|
||||
- Repli intact : un message sans `html` (écho optimiste d'un autre client)
|
||||
repasse par l'ancien chemin texte + URL cliquable.
|
||||
- CSS : `.file-chat-body.file-chat-md` annule le `white-space: pre-wrap` hérité
|
||||
de la bulle (sinon les retours à la ligne du HTML source créaient des lignes
|
||||
vides) et compacte `.md-content` (tailles, marges, `pre`/`table` en
|
||||
`overflow-x: auto`) pour tenir dans une bulle de 92 % de large.
|
||||
|
||||
### Tests #193
|
||||
|
||||
- pytest `TestMarkdownRendering` (7) : `html` présent à l'ajout **et** à la
|
||||
lecture, `html` **absent** du JSON stocké, classe `language-xxx` conservée
|
||||
(ce sur quoi hljs se branche), tableaux/listes, `<script>` neutralisé,
|
||||
routes `POST/GET` (chat de fichier + chat général) et écho SSE porteur du
|
||||
`html`. Suite : 1689 passed.
|
||||
- JSDOM `filechat.test.mjs` +2 (27) : post avec `html` → `.md-content`,
|
||||
`<strong>`, `pre code.language-python` ; post sans `html` → texte brut et URL
|
||||
cliquable conservés.
|
||||
|
||||
## Décisions
|
||||
|
||||
- **SSE plutôt qu'un second WebSocket** : le transport de #62 (EventSource
|
||||
`/api/events`, auth par cookie) existe déjà côté client dans `sync.js` ;
|
||||
un WS par fichier aurait dupliqué reconnexion/heartbeat/auth pour zéro
|
||||
bénéfice sur du messages-postés.
|
||||
- **Pas d'événement dans le panneau de sync** (`_addEvent`) : les messages
|
||||
de chat gonfleraient l'historique d'événements d'index sans valeur.
|
||||
- **Tag OpenAPI** : pas de tag maison (`file-chat`) — non déclaré dans
|
||||
`TAGS_METADATA`, ce qui cassait `test_used_tags_are_declared`. Les routes
|
||||
`/api/file/*` sont dérivées automatiquement sous « Files ».
|
||||
@@ -0,0 +1,107 @@
|
||||
# #194 — Dossier personnel par utilisateur
|
||||
|
||||
> **Statut :** livré | **Ouvert :** 2026-10-09 | **Type :** feature (auth + vaults)
|
||||
|
||||
## Problème
|
||||
|
||||
Un utilisateur créé dans ObsiGate n'avait aucun espace propre : il ne voyait
|
||||
que les vaults qu'un admin lui attribuait à la main, et rien n'était préparé
|
||||
sur le disque à sa création.
|
||||
|
||||
## Décision d'architecture
|
||||
|
||||
Deux formes étaient possibles :
|
||||
|
||||
1. **Un vault par utilisateur** (retenu) — `<racine>/<user>` devient le vault
|
||||
`home-<user>`, octroyé dans `user.vaults`. L'ACL par vault existante
|
||||
(`backend/auth/middleware.py::check_vault_access`, appliquée partout :
|
||||
API, MCP, search, push, tools) fait l'isolation **sans rien inventer**.
|
||||
2. Un vault `Home` unique + ACL par sous-dossier (écartée) — il n'existe
|
||||
aucune ACL par chemin dans le code ; il aurait fallu la brancher dans
|
||||
`files_read`, `files_media`, `history`, `backups`, `mcp/tools`, `push`…
|
||||
Surface de sécurité trop grande pour un gain marginal.
|
||||
|
||||
Décisions produit validées : nom `home-<user>`, dossier **conservé** à la
|
||||
suppression du compte, racine `/NFS/OBSIDIAN_DOC/Home` montée `/vaults/Home`.
|
||||
|
||||
## Mise en place
|
||||
|
||||
| Élément | Où |
|
||||
|---|---|
|
||||
| Service idempotent `ensure_user_home()` / `release_user_home()` / `ensure_all_user_homes()` | `backend/user_home.py` |
|
||||
| Registre persistant des vaults dynamiques `data/vaults.json`, fusionné par `load_vault_config()` | `backend/indexer.py` (`persist_vault` / `unpersist_vault`) |
|
||||
| Appel à la création et à la suppression d'un compte | `backend/auth/router.py` (`POST/DELETE /api/auth/admin/users`) |
|
||||
| Passe de réparation/migration au démarrage | `backend/main.py` (`lifespan` → après le watcher) |
|
||||
| Persistance des vaults ajoutés par `/api/vaults/add` + retrait | `backend/routers/vaults.py` |
|
||||
| Montage + variable d'environnement | `docker-compose.yml` (`/NFS/OBSIDIAN_DOC/Home:/vaults/Home`, `OBSIGATE_HOME_ROOT=/vaults/Home`) |
|
||||
|
||||
## Garde-fous
|
||||
|
||||
- **Fonctionnalité inactive sans `OBSIGATE_HOME_ROOT`** : dev, tests et
|
||||
desktop n'ont aucun comportement modifié.
|
||||
- **Validation du nom** : `^[a-zA-Z0-9_-]{2,32}$` (même grammaire que
|
||||
`CreateUserRequest.username_valid`), re-vérifiée avant de construire un
|
||||
chemin — défense en profondeur contre le path traversal.
|
||||
- **Nom de vault sans `/` ni `::`** : le nom sert de segment d'URL
|
||||
(`/api/file/{vault}/…`) et de clé d'index `vault::path`.
|
||||
- **Auto-réparation** : `ensure_all_user_homes()` au démarrage crée les
|
||||
dossiers manquants, ré-enregistre les vaults perdus et octroie l'accès aux
|
||||
users créés hors API. Toute erreur disque (NFS indisponible) est
|
||||
journalisée et rattrapée au boot.
|
||||
- **Suppression de compte** : index/watcher/registre refermés, dossier sur
|
||||
disque conservé.
|
||||
|
||||
## Bug corrigé au passage
|
||||
|
||||
- `load_vault_config()` ne lisait que les env `VAULT_N_*`/`DIR_N_*` : tout
|
||||
vault ajouté à la volée (y compris via `/api/vaults/add`) disparaissait au
|
||||
premier reindex complet et au redémarrage. → `data/vaults.json`.
|
||||
- `reload_single_vault()` perdait l'entrée `vault_config[vault]` après
|
||||
`remove_vault_from_index()`.
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/test_user_home.py` (10) : création (dossier + vault + octroi +
|
||||
registre), idempotence, username invalide, désactivation sans variable,
|
||||
relecture du registre par `load_vault_config`, `release` (dossier
|
||||
conservé), isolation `check_vault_access` entre users, passe de réparation.
|
||||
- `tests/test_auth_api.py::TestUserHome` (2) : provisionnement via l'API
|
||||
(création → dossier, vault visible, alice ne voit que son home, suppression
|
||||
→ vault refermé / dossier conservé) + absence de dossier sans
|
||||
`OBSIGATE_HOME_ROOT`.
|
||||
|
||||
## UI — arborescence (incrément)
|
||||
|
||||
- **Ordre** : tri stable `home-*` en premier, appliqué à la source
|
||||
(`frontend/js/sidebar.js::_homeFirst`, sur les deux sites qui alimentent
|
||||
`state.allVaults`) → arborescence, sélecteurs et refreshs homogènes.
|
||||
- **Icône** : `getVaultIcon()` (`frontend/js/viewer.js`) renvoie
|
||||
`icon("home", size)` pour `home-*` → même pipeline lucide que le reste de
|
||||
l'app (`safeCreateIcons`, `stroke: currentColor`, suit le thème).
|
||||
- **Test local** : `docker-compose.test-win.yml` monte
|
||||
`test_home:/vaults/Home` + `OBSIGATE_HOME_ROOT=/vaults/Home`
|
||||
(`test_home/` est dans `.gitignore`).
|
||||
- **Vérifié live** (instance 2020, Playwright) : ordre `home-admin, TestDir,
|
||||
TestVault` (l'admin ne voit plus le home des autres), `svg.lucide-home`
|
||||
rendu, placeholder `<i>` remplacé.
|
||||
|
||||
## Isolation — « * » ne couvre pas les dossiers persos
|
||||
|
||||
- **Racine** : `check_vault_access` (`backend/auth/middleware.py`) renvoie
|
||||
``vault_name in vaults`` pour tout vault ``home-*``, quel que soit le ``*``.
|
||||
Toutes les routes (89 usages) passent par cette fonction.
|
||||
- **Filtres « * » en dur remplacés** par cet appel : dashboard
|
||||
(`routers/config.py`), conflits, liens retour, favoris, abonnements push.
|
||||
- **Recherche** : `services/search.search_vaults(is_allowed=…)` filtre les
|
||||
bruts **avant** pagination — ``total`` et page restent justes.
|
||||
- **Octroi** : `_grant` n'écarte plus les comptes ``*`` → l'admin reçoit son
|
||||
propre ``home-admin`` (réparé au démarrage pour les comptes existants).
|
||||
- **Reste ouvert** (fuite pré-existante, hors #194) : `/api/search/advanced`,
|
||||
`/api/tags`, `/api/tags/suggest`, `/api/tree-search` et `/api/suggest` ne
|
||||
filtrent pas par vault accessible — à traiter dans une tâche dédiée.
|
||||
|
||||
## Impact utilisateur
|
||||
|
||||
Aucune nouvelle chaîne i18n : les noms de vault sont des données, la liste
|
||||
provient déjà de `GET /api/vaults`. Le dossier apparaît tout seul dans le
|
||||
sélecteur de vaults de l'utilisateur concerné.
|
||||
@@ -0,0 +1,81 @@
|
||||
# #189 — Tableur : mise en page mobile (barres repliées, grille prioritaire)
|
||||
|
||||
> **Statut :** ✅ livré (2026-10-08) | **Effort :** 0,5-1 jour | **Impact :** 🟡
|
||||
> **Références :** [Roadmap](../ROADMAP.md) · [Changelog](../../CHANGELOG.md) ·
|
||||
> [#180](./xlsx-sheetbar-180.md) · [#185](./xlsx-menus-185.md)
|
||||
|
||||
## 1. Constat
|
||||
|
||||
Sur mobile, l'empilement `.xlsx-menubar` (10 menus) + `.xlsx-cmdbar`
|
||||
(~40 boutons qui s'enroulent sur 4-5 rangées) + `.xlsx-sheetbar` +
|
||||
`.xlsx-formula-bar` occupait **plus de 50 % de la hauteur d'écran** : la
|
||||
grille, objet premier de l'éditeur, passait après la chrome.
|
||||
|
||||
## 2. Livré
|
||||
|
||||
### A1 — Repli par défaut (CSS, ≤ 768 px)
|
||||
|
||||
Bloc `#189` dans `frontend/style.css` (après les règles `pointer: coarse`) :
|
||||
|
||||
- `.xlsx-menubar`, `.xlsx-cmdbar`, `.xlsx-find-group` → `display: none` par
|
||||
défaut sous 768 px ;
|
||||
- `.xlsx-toolbar.xlsx-tools-open …` les réaffiche (état de la classe portée
|
||||
par la **barre entière**, donc menubar + ruban + Recherche restent cohérents) ;
|
||||
- `.xlsx-formula-bar` reste **toujours visible** (zone Nom + `fx` + champ) et
|
||||
passe en `flex-wrap` quand les outils sont ouverts (le groupe Recherche
|
||||
réapparaît sur une 2ᵉ ligne) ;
|
||||
- `.xlsx-tools-toggle { display: none }` sur desktop : **zéro changement
|
||||
visuel hors mobile**.
|
||||
|
||||
### A2 — Bascule unique (JS)
|
||||
|
||||
`frontend/js/viewer.js` (`renderXlsxViewer`) :
|
||||
|
||||
- bouton `#xlsx-tools-toggle` (icône Lucide `menu`, `aria-expanded`,
|
||||
`aria-label` i18n `xlsx.tools_toggle`) inséré **en tête de
|
||||
`.xlsx-sheetbar`** — donc toujours visible, entre les onglets et Enregistrer ;
|
||||
- listener : bascule `.xlsx-tools-open` sur `.xlsx-toolbar`, met à jour
|
||||
`aria-expanded` + classe `active`, `safeCreateIcons()` pour l'icône.
|
||||
|
||||
### A3 — Ergonomie tactile (CSS)
|
||||
|
||||
- cibles ≥ **44 px** : `.xlsx-toolbar .btn-action`, `.xlsx-menubtn`,
|
||||
`.xlsx-tab` / `.xlsx-tab-add` ; à cette taille un groupe du ruban
|
||||
(police, nombre…) déborde d'un téléphone : `.xlsx-cmd-group` passe en
|
||||
`flex: 0 1 auto; min-width: 0; flex-wrap: wrap` pour s'enrouler au lieu
|
||||
de sortir de l'écran (contre-preuve E2E : `scrollWidth - clientWidth ≤ 1`) ;
|
||||
- onglets de feuilles : une seule ligne, **défilement horizontal**
|
||||
(`overflow-x: auto`, scrollbars masquées) au lieu d'enrouler ;
|
||||
- champs de la barre de formule en **16 px** : en dessous, iOS zoome
|
||||
automatiquement la page au focus (la « classique » debout de l'UI) ;
|
||||
- groupe Recherche masqué avec le ruban pour ne pas écraser le champ de saisie.
|
||||
|
||||
### A4 — i18n
|
||||
|
||||
Clé `xlsx.tools_toggle` : FR « Afficher / masquer les barres d'outils » /
|
||||
EN « Show / hide toolbars » (parité vérifiée par `tests/test_i18n_parity.py`).
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- `tests/frontend/xlsx-viewer.test.mjs` (+2, **165/165**) :
|
||||
1. la bascule vit dans la barre des feuilles et flippe correctement
|
||||
classe / `aria-expanded` / `active` (aller-retour) ;
|
||||
2. `style.css` masque menubar/ruban/Recherche par défaut sous 768 px et les
|
||||
réaffiche via `.xlsx-tools-open` (44 px, onglets scrollables, inputs
|
||||
16 px présents dans le bloc).
|
||||
- `tests/e2e/xlsx-viewer.spec.js` — test mobile réécrit : repli par défaut
|
||||
(menubar/ruban cachés, formule + onglets visibles) → dépliage → ruban sans
|
||||
débordement horizontal → cibles ≥ 44 px → repli ; `openXlsx()` déplie
|
||||
automatiquement sous 768 px pour que les tests « desktop » restent valables
|
||||
sur les deux viewports.
|
||||
|
||||
## 4. Limites assumées
|
||||
|
||||
- Le repli est piloté par la **largeur** (`max-width: 768px`), pas par
|
||||
`pointer: coarse` : une tablette large en mode tactile garde la chrome
|
||||
complète (elle a la hauteur pour).
|
||||
- Le groupe Recherche n'est accessible sur mobile qu'avec les outils ouverts
|
||||
; le reset de tri reste atteignable par la mini-barre de sélection et le
|
||||
menu Données (vérifié).
|
||||
- Pas de drawer latéral ni de FAB : un seul bouton rétractable suffit à
|
||||
l'usage — ajouter un panneau glissant si le retour utilisateur le réclame.
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
<link rel="icon" type="image/svg+xml" href="/static/icons/icon-72x72.svg" />
|
||||
|
||||
<!-- Lucide : requis par safeCreateIcons() (icônes des modales, ex. bouton fermer) -->
|
||||
<script defer src="https://unpkg.com/[email protected]/dist/umd/lucide.min.js"></script>
|
||||
|
||||
<!-- Anti-FOUC : applique vite le mode du thème sauvegardé avant le CSS -->
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
+114
-40
@@ -812,46 +812,6 @@
|
||||
role="navigation"
|
||||
aria-label="Navigation des vaults"
|
||||
>
|
||||
<!-- Sidebar filter -->
|
||||
<div class="sidebar-filter">
|
||||
<div class="sidebar-filter-input-wrapper">
|
||||
<i
|
||||
data-lucide="filter"
|
||||
class="sidebar-filter-icon"
|
||||
style="width: 14px; height: 14px"
|
||||
></i>
|
||||
<input
|
||||
type="text"
|
||||
id="sidebar-filter-input"
|
||||
data-i18n-placeholder="sidebar.filter_placeholder" placeholder="Filtrer fichiers..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="sidebar-filter-actions">
|
||||
<button
|
||||
class="sidebar-filter-btn"
|
||||
id="sidebar-filter-case-btn"
|
||||
type="button"
|
||||
title="Respecter la casse"
|
||||
aria-label="Respecter la casse"
|
||||
>
|
||||
<span>Aa</span>
|
||||
</button>
|
||||
<button
|
||||
class="sidebar-filter-btn"
|
||||
id="sidebar-filter-clear-btn"
|
||||
type="button"
|
||||
title="Effacer le filtrage"
|
||||
aria-label="Effacer le filtrage"
|
||||
>
|
||||
<i
|
||||
data-lucide="x"
|
||||
style="width: 14px; height: 14px"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab bar -->
|
||||
<div
|
||||
class="sidebar-tabs"
|
||||
@@ -928,6 +888,61 @@
|
||||
style="width: 18px; height: 18px"
|
||||
></i>
|
||||
</button>
|
||||
<button
|
||||
class="sidebar-tab"
|
||||
id="sidebar-tab-chat"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
aria-controls="sidebar-panel-chat"
|
||||
data-tab="chat"
|
||||
title="Chat"
|
||||
>
|
||||
<i
|
||||
data-lucide="message-circle"
|
||||
style="width: 18px; height: 18px"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar filter (#190 C : sous les onglets ; sert de
|
||||
recherche dans l'onglet Chat) -->
|
||||
<div class="sidebar-filter">
|
||||
<div class="sidebar-filter-input-wrapper">
|
||||
<i
|
||||
data-lucide="filter"
|
||||
class="sidebar-filter-icon"
|
||||
style="width: 14px; height: 14px"
|
||||
></i>
|
||||
<input
|
||||
type="text"
|
||||
id="sidebar-filter-input"
|
||||
data-i18n-placeholder="sidebar.filter_placeholder" placeholder="Filtrer fichiers..."
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="sidebar-filter-actions">
|
||||
<button
|
||||
class="sidebar-filter-btn"
|
||||
id="sidebar-filter-case-btn"
|
||||
type="button"
|
||||
title="Respecter la casse"
|
||||
aria-label="Respecter la casse"
|
||||
>
|
||||
<span>Aa</span>
|
||||
</button>
|
||||
<button
|
||||
class="sidebar-filter-btn"
|
||||
id="sidebar-filter-clear-btn"
|
||||
type="button"
|
||||
title="Effacer le filtrage"
|
||||
aria-label="Effacer le filtrage"
|
||||
>
|
||||
<i
|
||||
data-lucide="x"
|
||||
style="width: 14px; height: 14px"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vaults panel -->
|
||||
@@ -1050,6 +1065,65 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- General chat panel (#190 B) -->
|
||||
<div
|
||||
class="sidebar-tab-panel sidebar-chat-panel"
|
||||
id="sidebar-panel-chat"
|
||||
role="tabpanel"
|
||||
aria-labelledby="sidebar-tab-chat"
|
||||
>
|
||||
<!-- #191 : rangée de canaux (Général + utilisateurs) -->
|
||||
<div
|
||||
id="sidebar-panel-chat-channels"
|
||||
class="chat-channels"
|
||||
role="tablist"
|
||||
aria-label="Canaux de discussion"
|
||||
></div>
|
||||
<div
|
||||
id="sidebar-panel-chat-list"
|
||||
class="file-chat-list sidebar-chat-list"
|
||||
></div>
|
||||
<div id="sidebar-panel-chat-pending" class="sidebar-chat-pending"></div>
|
||||
<form
|
||||
id="sidebar-panel-chat-form"
|
||||
class="file-chat-form sidebar-chat-form"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
id="sidebar-panel-chat-attach"
|
||||
class="file-chat-attach"
|
||||
title="Joindre une image ou une vidéo"
|
||||
aria-label="Joindre une image ou une vidéo"
|
||||
>
|
||||
<i
|
||||
data-lucide="paperclip"
|
||||
style="width: 15px; height: 15px"
|
||||
></i>
|
||||
</button>
|
||||
<textarea
|
||||
id="sidebar-panel-chat-input"
|
||||
class="file-chat-input"
|
||||
rows="1"
|
||||
maxlength="4000"
|
||||
placeholder="Écrire un message…"
|
||||
autocomplete="off"
|
||||
aria-label="Écrire un message"
|
||||
></textarea>
|
||||
<!-- #191 : bouton compact (icône) pour laisser la place à la saisie -->
|
||||
<button
|
||||
type="submit"
|
||||
class="file-chat-send file-chat-send-icon"
|
||||
title="Envoyer"
|
||||
aria-label="Envoyer"
|
||||
>
|
||||
<i
|
||||
data-lucide="send"
|
||||
style="width: 15px; height: 15px"
|
||||
></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- User section (bottom of the sidebar, #112) -->
|
||||
<div class="sidebar-user" id="sidebar-user" hidden>
|
||||
<button
|
||||
|
||||
+19
-38
@@ -394,54 +394,35 @@ function _wireAuditFilters() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Section nav (scrollspy) ───────────────────────────────────────────
|
||||
// ── Section nav (une seule section visible à la fois) ──────────────────
|
||||
|
||||
/**
|
||||
* Wire the sticky section nav: smooth-scroll on click + highlight the section
|
||||
* currently in view (IntersectionObserver). Falls back to default anchor jump
|
||||
* if the observer is unavailable.
|
||||
* Wire the sticky section nav: clicking a link shows ONLY that section
|
||||
* (the others are hidden). The first section (Stats) is shown on load.
|
||||
*/
|
||||
function _wireSectionNav() {
|
||||
const links = Array.from(document.querySelectorAll("#admin-subnav a"));
|
||||
if (!links.length) return;
|
||||
const targets = links
|
||||
.map((a) => document.getElementById((a.getAttribute("href") || "#").slice(1)))
|
||||
.filter(Boolean);
|
||||
const sections = Array.from(document.querySelectorAll("#admin-main .admin-section"));
|
||||
|
||||
// Smooth scroll on click
|
||||
links.forEach((a) => {
|
||||
a.addEventListener("click", (e) => {
|
||||
const href = a.getAttribute("href");
|
||||
if (!href || !href.startsWith("#")) return;
|
||||
const t = document.getElementById(href.slice(1));
|
||||
if (!t) return;
|
||||
e.preventDefault();
|
||||
t.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
});
|
||||
});
|
||||
|
||||
// Scrollspy: mark the section currently at the top of the viewport
|
||||
let active = null;
|
||||
const setActive = (id) => {
|
||||
if (active === id) return;
|
||||
active = id;
|
||||
const show = (id) => {
|
||||
sections.forEach((s) => { s.style.display = s.id === id ? "" : "none"; });
|
||||
links.forEach((l) => l.classList.toggle("active", l.getAttribute("href") === "#" + id));
|
||||
};
|
||||
|
||||
if ("IntersectionObserver" in window) {
|
||||
const io = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((en) => {
|
||||
if (en.isIntersecting) setActive(en.target.id);
|
||||
});
|
||||
},
|
||||
{ rootMargin: "-64px 0px -70% 0px", threshold: 0 }
|
||||
);
|
||||
targets.forEach((t) => io.observe(t));
|
||||
} else {
|
||||
// Fallback: highlight the first section only (no scroll observation)
|
||||
links[0] && links[0].classList.add("active");
|
||||
}
|
||||
links.forEach((a) => {
|
||||
a.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
const href = a.getAttribute("href") || "";
|
||||
if (!href.startsWith("#")) return;
|
||||
show(href.slice(1));
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
});
|
||||
});
|
||||
|
||||
// État initial : seule la section demandée par le hash (ou la première) est visible.
|
||||
const initial = (location.hash && sections.find((s) => "#" + s.id === location.hash)) || sections[0];
|
||||
if (initial) show(initial.id);
|
||||
}
|
||||
|
||||
// ── Auth gate ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -26,6 +26,7 @@ import { initDesktopIntegration, isTauriEnv } from './desktop.js';
|
||||
import { initPush } from './push.js';
|
||||
import { initPlugins } from './plugins.js';
|
||||
import { populateVersions } from './config.js';
|
||||
import { initSidebarChat } from './filechat.js';
|
||||
|
||||
// =========================================================================
|
||||
// Initialization — mirrors the original app.js init() ordering
|
||||
@@ -61,6 +62,7 @@ async function init() {
|
||||
Legacy.initConfigModal();
|
||||
Legacy.initAboutModal();
|
||||
Sidebar.initSidebarFilter();
|
||||
initSidebarChat(); // #190 B — formulaire du chat général (sidebar)
|
||||
UI.initSidebarResize();
|
||||
Utils.initEditor();
|
||||
Auth.initLoginForm();
|
||||
|
||||
+35
-3
@@ -2,12 +2,13 @@
|
||||
import { api, AuthManager, initMfaSettings } from './auth.js';
|
||||
import { state } from './state.js';
|
||||
import { el, icon, openFile, filterSavedSearches } from './viewer.js';
|
||||
import { syncVaultSelectors, setSelectedVaultContext, refreshSidebarForContext, loadVaults, loadVaultSettings, loadTags, TagFilterService, refreshSidebarTreePreservingState } from './sidebar.js';
|
||||
import { syncVaultSelectors, setSelectedVaultContext, refreshSidebarForContext, loadVaults, loadVaultSettings, loadTags, TagFilterService, refreshSidebarTreePreservingState, invalidateSharedPaths } from './sidebar.js';
|
||||
import { escapeHtml, safeCreateIcons } from './utils.js';
|
||||
import { showToast, closeHeaderMenu, closeMobileSidebar } from './ui.js';
|
||||
import { t, setLocale, getLocale } from './i18n.js';
|
||||
import { getModelCapabilities, renderCapabilityBadges, refreshAIPickers } from './ai.js';
|
||||
import { isTauriEnv, invoke, pickAndAddVault, pickAndAddDir, removeRoot } from './desktop.js';
|
||||
import { openSidebarChat, filterChatMessages as _filterChat } from './filechat.js';
|
||||
|
||||
let _recentTimestampTimer = null;
|
||||
let _recentFilesCache = [];
|
||||
@@ -305,6 +306,7 @@ function switchSidebarTab(tab) {
|
||||
recent: t("sidebar.filter_recent"),
|
||||
saved: t("sidebar.filter_saved"),
|
||||
ai: t("sidebar.filter_ai"),
|
||||
chat: t("sidebar.filter_chat"),
|
||||
};
|
||||
filterInput.placeholder = placeholders[tab] || "";
|
||||
}
|
||||
@@ -315,6 +317,7 @@ function switchSidebarTab(tab) {
|
||||
else if (tab === "recent") filterRecentFiles(query);
|
||||
else if (tab === "saved") filterSavedSearches(query);
|
||||
else if (tab === "ai") filterAIHistory(query);
|
||||
else if (tab === "chat") _filterChat(query);
|
||||
}
|
||||
// Auto-load recent files when switching to the recent tab
|
||||
if (tab === "recent") {
|
||||
@@ -333,6 +336,11 @@ function switchSidebarTab(tab) {
|
||||
if (filterInput) _aiQuery = filterInput.value.trim();
|
||||
loadAISessionList();
|
||||
}
|
||||
// #190 B — Auto-load the general chat when entering its tab (fresh history;
|
||||
// openSidebarChat clears the unread badge).
|
||||
if (tab === "chat") {
|
||||
openSidebarChat();
|
||||
}
|
||||
}
|
||||
|
||||
function initHelpModal() {
|
||||
@@ -1648,10 +1656,14 @@ export async function openShareDialog(vault, path) {
|
||||
const expiresInfo = existingShare.expires_at
|
||||
? `<p style="font-size:0.75rem;color:var(--text-muted)">Expire le ${new Date(existingShare.expires_at).toLocaleDateString("fr-FR")}</p>`
|
||||
: '<p style="font-size:0.75rem;color:var(--text-muted)">Sans expiration</p>';
|
||||
const recipientsInfo = (existingShare.shared_with && existingShare.shared_with.length)
|
||||
? `<p style="font-size:0.75rem;color:var(--text-muted);margin-bottom:4px">${t('config.share_shared_with')} : ${escapeHtml(existingShare.shared_with.join(", "))}</p>`
|
||||
: '';
|
||||
div.innerHTML = `
|
||||
<div class="share-dialog">
|
||||
<h3>📤 Document partagé</h3>
|
||||
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:4px">${escapeHtml(vault)}/${escapeHtml(path)}</p>
|
||||
${recipientsInfo}
|
||||
${expiresInfo}
|
||||
<p style="font-size:0.75rem;color:var(--text-muted);margin-bottom:8px">${existingShare.access_count} vue(s)</p>
|
||||
<input type="text" class="share-url-input" value="${url}" readonly>
|
||||
@@ -1679,6 +1691,7 @@ export async function openShareDialog(vault, path) {
|
||||
div.querySelector(".share-revoke-btn").addEventListener("click", async () => {
|
||||
try {
|
||||
await api(`/api/share/${existingShare.id}`, { method: "DELETE" });
|
||||
invalidateSharedPaths();
|
||||
showToast(t("config.share_revoked"), "success");
|
||||
existingShare = null;
|
||||
renderContent();
|
||||
@@ -1689,7 +1702,16 @@ export async function openShareDialog(vault, path) {
|
||||
<div class="share-dialog">
|
||||
<h3>📤 Partager ce document</h3>
|
||||
<p style="font-size:0.85rem;color:var(--text-muted);margin-bottom:12px">${escapeHtml(vault)}/${escapeHtml(path)}</p>
|
||||
<p style="font-size:0.8rem;margin-bottom:8px">Ce lien sera accessible <strong>publiquement, sans authentification</strong>.</p>
|
||||
<label style="font-size:0.8rem;display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
<input type="radio" name="share-visibility" value="directed" checked>
|
||||
${t('config.share_directed')}
|
||||
</label>
|
||||
<input type="text" id="share-recipients" placeholder="${t('config.share_recipients_ph')}"
|
||||
style="width:100%;padding:6px 8px;margin-bottom:8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem">
|
||||
<label style="font-size:0.8rem;display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
<input type="radio" name="share-visibility" value="public">
|
||||
${t('config.share_public')}
|
||||
</label>
|
||||
<label style="font-size:0.8rem;display:flex;align-items:center;gap:8px;margin-bottom:12px">
|
||||
Expiration :
|
||||
<select id="share-expiry" style="padding:4px 8px;border:1px solid var(--border);border-radius:4px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem">
|
||||
@@ -1708,12 +1730,22 @@ export async function openShareDialog(vault, path) {
|
||||
div.querySelector(".share-create-btn").addEventListener("click", async () => {
|
||||
try {
|
||||
const expiry = document.getElementById("share-expiry")?.value;
|
||||
const visibility = div.querySelector('input[name="share-visibility"]:checked')?.value || "directed";
|
||||
const recipientsRaw = document.getElementById("share-recipients")?.value || "";
|
||||
const recipients = visibility === "directed"
|
||||
? recipientsRaw.split(",").map(s => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
if (visibility === "directed" && !recipients.length) {
|
||||
showToast(t('config.share_need_recipient'), "error");
|
||||
return;
|
||||
}
|
||||
const share = await api(`/api/share/${encodeURIComponent(vault)}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ path, expires_in_hours: expiry ? parseInt(expiry) : null }),
|
||||
body: JSON.stringify({ path, expires_in_hours: expiry ? parseInt(expiry) : null, shared_with: recipients }),
|
||||
});
|
||||
existingShare = share;
|
||||
invalidateSharedPaths();
|
||||
renderContent();
|
||||
showToast(t("config.link_created"), "success");
|
||||
} catch (err) { showToast("Erreur: " + err.message, "error"); }
|
||||
|
||||
+23
-11
@@ -1,5 +1,5 @@
|
||||
// dashboard.js — extracted from app.js (3414-3806) + DashboardBookmarkWidget (3810-3870)
|
||||
import { api } from './auth.js';
|
||||
import { api, AuthManager } from './auth.js';
|
||||
import { state } from './state.js';
|
||||
import { escapeHtml, safeCreateIcons, getFileIcon } from './utils.js';
|
||||
import { icon, openFile } from './viewer.js';
|
||||
@@ -60,24 +60,35 @@ const DashboardSharedWidget = {
|
||||
const shares = await api("/api/shares");
|
||||
if (!shares.length) { if (empty) empty.style.display = ""; grid.innerHTML = ""; return; }
|
||||
if (empty) empty.style.display = "none";
|
||||
grid.innerHTML = shares.map(s => `
|
||||
<div class="shared-card" data-vault="${escapeHtml(s.vault)}" data-path="${escapeHtml(s.path)}">
|
||||
const me = (AuthManager.getUser() || {}).username;
|
||||
grid.innerHTML = shares.map(s => {
|
||||
// Reçu = adressé à moi (et pas créé par moi). Un admin voit TOUS les
|
||||
// partages via /api/shares — ceux des autres comptes ne sont pas
|
||||
// « reçus » pour lui : ils ne se résolvent pas dans son Partage/.
|
||||
const received = me && s.created_by && s.created_by !== me
|
||||
&& Array.isArray(s.shared_with) && s.shared_with.includes(me);
|
||||
const actions = received
|
||||
? `<button class="shared-open-btn">${t('dashboard.open_btn')}</button>`
|
||||
: `<button class="shared-copy-btn" data-url="${window.location.origin}/s/${s.token}">${t('dashboard.copy_btn')}</button>
|
||||
<button class="shared-open-btn">${t('dashboard.open_btn')}</button>
|
||||
<button class="shared-revoke-btn" data-id="${s.id}">🗑</button>`;
|
||||
const cardVault = received ? `home-${me}` : s.vault;
|
||||
const cardPath = received ? `Partage/${(s.path || "").split("/").pop()}` : s.path;
|
||||
return `
|
||||
<div class="shared-card" data-vault="${escapeHtml(cardVault)}" data-path="${escapeHtml(cardPath)}" data-token="${s.token}" data-received="${received ? "1" : ""}">
|
||||
<div class="shared-card-header">
|
||||
<i data-lucide="file-text" style="width:14px;height:14px"></i>
|
||||
<span class="shared-card-title">${escapeHtml(s.path.split("/").pop().replace(/\.md$/i, ""))}</span>
|
||||
<span class="shared-card-title">${escapeHtml(s.path.split("/").pop().replace(/\\.md$/i, ""))}</span>
|
||||
<span class="shared-card-vault">${escapeHtml(s.vault)}</span>
|
||||
</div>
|
||||
<div class="shared-card-meta">
|
||||
${received ? `<span>${t('dashboard.shared_by', { user: s.created_by })}</span>` : ""}
|
||||
<span>${s.access_count || 0} ${t('dashboard.views', {count: s.access_count || 0})}</span>
|
||||
${s.expires_at ? `<span>${t('dashboard.expires', {date: new Date(s.expires_at).toLocaleDateString(getLocale() === 'fr' ? 'fr-FR' : 'en-US')})}</span>` : ""}
|
||||
</div>
|
||||
<div class="shared-card-actions">
|
||||
<button class="shared-copy-btn" data-url="${window.location.origin}/s/${s.token}">${t('dashboard.copy_btn')}</button>
|
||||
<button class="shared-open-btn">${t('dashboard.open_btn')}</button>
|
||||
<button class="shared-revoke-btn" data-id="${s.id}">🗑</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
<div class="shared-card-actions">${actions}</div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
lucide.createIcons();
|
||||
grid.querySelectorAll(".shared-copy-btn").forEach(b => b.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
@@ -88,6 +99,7 @@ const DashboardSharedWidget = {
|
||||
grid.querySelectorAll(".shared-open-btn").forEach(b => b.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
const card = b.closest(".shared-card");
|
||||
// #196 : onglet applicatif — /api/file résout Partage/ vers la source.
|
||||
if (card) TabManager.openPreview(card.dataset.vault, card.dataset.path);
|
||||
}));
|
||||
grid.querySelectorAll(".shared-revoke-btn").forEach(b => b.addEventListener("click", async (e) => {
|
||||
|
||||
@@ -0,0 +1,920 @@
|
||||
// frontend/js/filechat.js — Chat fichier (#169) + chat général sidebar (#190)
|
||||
//
|
||||
// Deux surfaces, un module :
|
||||
// 1. Panneau latéral du document (#169) — historique + saisie, suivi du
|
||||
// document ouvert (followFileChat le re-cible à chaque renderFile).
|
||||
// 2. Onglet « Chat » de la sidebar (#190) — conversation générale, pièces
|
||||
// jointes (image/vidéo/URL), indicateur de messages non lus, recherche
|
||||
// via la barre de filtre de la sidebar.
|
||||
//
|
||||
// Les deux reçoivent les messages en direct par SSE (`chat_message`, relayé
|
||||
// depuis sync.js) : vault `__global__` = chat général, sinon chat fichier.
|
||||
import { api, AuthManager } from "./auth.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { showToast, TabManager } from "./ui.js";
|
||||
import { escapeHtml, safeCreateIcons, safeHighlight } from "./utils.js";
|
||||
import { icon } from "./viewer.js";
|
||||
|
||||
const PANEL_ID = "file-chat-panel";
|
||||
const GLOBAL_VAULT = "__global__";
|
||||
const GLOBAL_PATH = "general";
|
||||
const DM_VAULT = "__dm__";
|
||||
const UNREAD_KEY = "obsigate-chat-unread"; // {general: ts, "dm:<user>": ts}
|
||||
const MAX_INPUT_H = 160; // #192 — plafond de la boîte de saisie auto-agrandie
|
||||
|
||||
let _vault = null;
|
||||
let _path = null;
|
||||
let _title = "";
|
||||
let _sending = false;
|
||||
|
||||
// État du chat sidebar (#190 B + #191 : canaux général / privé)
|
||||
let _channel = { type: "general" }; // ou {type: "dm", user: "bob"}
|
||||
let _channelMessages = [];
|
||||
let _channelLoaded = false;
|
||||
let _usersCache = null; // liste des destinataires DM (une seule requête)
|
||||
let _pendingAttach = null;
|
||||
|
||||
// #192 — accusé de réception : {username: dernier_ts_de_lecture} par surface.
|
||||
let _fileRead = {};
|
||||
let _channelRead = {};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Partagé
|
||||
// ---------------------------------------------------------------------------
|
||||
function _esc(s) {
|
||||
return escapeHtml(String(s == null ? "" : s));
|
||||
}
|
||||
|
||||
function _formatTime(ts) {
|
||||
try {
|
||||
return new Date(ts * 1000).toLocaleString(undefined, {
|
||||
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
|
||||
});
|
||||
} catch (_) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function _currentUser() {
|
||||
const u = AuthManager.getUser();
|
||||
return (u && u.username) || "";
|
||||
}
|
||||
|
||||
// #192 — chemin de la conversation du canal courant (général ou privé).
|
||||
function _channelVaultPath() {
|
||||
return _channel.type === "dm"
|
||||
? { vault: DM_VAULT, path: _dmPath(_channel.user) }
|
||||
: { vault: GLOBAL_VAULT, path: GLOBAL_PATH };
|
||||
}
|
||||
|
||||
/** Paire DM triée côté client — même règle que ``dm_path()`` serveur. */
|
||||
function _dmPath(user) {
|
||||
return [_currentUser(), user].sort().join("|");
|
||||
}
|
||||
|
||||
/**
|
||||
* #192 — la boîte de saisie suit le contenu : hauteur auto, plafonné à
|
||||
* ``MAX_INPUT_H`` (au-delà la zone défile).
|
||||
*/
|
||||
function _autoGrow(el) {
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, MAX_INPUT_H)}px`;
|
||||
}
|
||||
|
||||
/** #192 — un message est lu dès qu'un AUTRE participant l'a ouvert. */
|
||||
function _isRead(ts, readMap, me) {
|
||||
return Object.keys(readMap || {}).some((u) => u !== me && Number(readMap[u]) >= Number(ts));
|
||||
}
|
||||
|
||||
/**
|
||||
* #192 — passe les indicateurs de mes messages (liste affichée) à ✓✓.
|
||||
* Retourne le nombre de messages qui venaient de passer à « lu ».
|
||||
*/
|
||||
function _applyRead(list, readMap, me) {
|
||||
if (!list || !readMap) return 0;
|
||||
let flipped = 0;
|
||||
list.querySelectorAll(".file-chat-msg.mine").forEach((el) => {
|
||||
if (el.classList.contains("read")) return;
|
||||
if (!_isRead(el.dataset.ts, readMap, me)) return;
|
||||
el.classList.add("read");
|
||||
const st = el.querySelector(".file-chat-status");
|
||||
if (st) {
|
||||
st.textContent = "✓✓";
|
||||
st.title = t("chat.status_read");
|
||||
}
|
||||
flipped++;
|
||||
});
|
||||
return flipped;
|
||||
}
|
||||
|
||||
/**
|
||||
* #192 — fusionne *readMap* dans la conversation affichée à l'écran et
|
||||
* actualise les indicateurs. Retourne le nombre de mes messages passés à lu.
|
||||
*/
|
||||
function _syncRead(vault, path, readMap) {
|
||||
if (!readMap) return 0;
|
||||
const me = _currentUser();
|
||||
let flipped = 0;
|
||||
const panel = document.getElementById(PANEL_ID);
|
||||
if (panel && !panel.classList.contains("hidden") && _vault === vault && _path === path) {
|
||||
_fileRead = Object.assign({}, _fileRead, readMap);
|
||||
flipped += _applyRead(panel.querySelector(".file-chat-list"), _fileRead, me);
|
||||
}
|
||||
const key =
|
||||
vault === DM_VAULT ? `dm:${_pairOf(path)}` : vault === GLOBAL_VAULT ? "general" : null;
|
||||
const sidebar = document.getElementById("sidebar-panel-chat");
|
||||
if (key && key === _channelKey() && sidebar && sidebar.classList.contains("active")) {
|
||||
_channelRead = Object.assign({}, _channelRead, readMap);
|
||||
flipped += _applyRead(_sidebarList(), _channelRead, me);
|
||||
}
|
||||
return flipped;
|
||||
}
|
||||
|
||||
/**
|
||||
* #192 — signale au serveur que la conversation est affichée (accusé de
|
||||
* réception). Fire-and-forget : un réseau indisponible ne casse rien.
|
||||
*/
|
||||
async function _markRead(vault, path) {
|
||||
try {
|
||||
const data = await api("/api/chat/read", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ vault, path }),
|
||||
});
|
||||
_syncRead(vault, path, data && data.read ? data.read : {});
|
||||
} catch (_) {
|
||||
/* réseau indisponible : pas d'accusé de réception, ni d'erreur */
|
||||
}
|
||||
}
|
||||
|
||||
// URL dans le texte → lien cliquable (partage d'URL #190), texte sinon.
|
||||
/**
|
||||
* Remplit le corps d'un message (#193).
|
||||
*
|
||||
* Quand le serveur a fourni le rendu markdown (`html`, même pipeline que les
|
||||
* documents : mistune + sanitizer BUG-021), on l'injecte dans un `.md-content`
|
||||
* — titres, listes, tableaux, blocs de code — et on colore les blocs avec
|
||||
* highlight.js (`safeHighlight`), exactement comme dans le viewer.
|
||||
*
|
||||
* Sans `html` (message optimiste d'un autre client, cache ancien), on retombe
|
||||
* sur le texte brut avec liens cliquables, comme avant.
|
||||
*/
|
||||
function _fillBody(body, m) {
|
||||
const text = typeof m === "string" ? m : (m && m.text) || "";
|
||||
const html = m && typeof m === "object" ? m.html || "" : "";
|
||||
if (html) {
|
||||
body.classList.add("file-chat-md");
|
||||
const div = document.createElement("div");
|
||||
div.className = "md-content";
|
||||
div.innerHTML = html; // rendu + assaini côté serveur
|
||||
body.appendChild(div);
|
||||
div.querySelectorAll("pre code").forEach((block) => {
|
||||
if (block.querySelector(".secret-mask")) return;
|
||||
safeHighlight(block);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const parts = String(text || "").split(/(https?:\/\/[^\s<>"']+)/g);
|
||||
parts.forEach((part) => {
|
||||
if (!part) return;
|
||||
if (/^https?:\/\//.test(part)) {
|
||||
const a = document.createElement("a");
|
||||
a.href = part;
|
||||
a.textContent = part;
|
||||
a.target = "_blank";
|
||||
a.rel = "noopener noreferrer";
|
||||
a.className = "file-chat-link";
|
||||
body.appendChild(a);
|
||||
} else {
|
||||
body.appendChild(document.createTextNode(part));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// #110 — image jointe : clic = même visualiseur que pour une image de vault
|
||||
// (ouvert dans un onglet de l'app), boutons discrets (icônes seules) sous
|
||||
// l'image, alignés à droite.
|
||||
function _attachEl(att) {
|
||||
if (!att || !att.url) return null;
|
||||
if (att.kind === "image") {
|
||||
const container = document.createElement("div");
|
||||
container.className = "file-chat-image-container";
|
||||
|
||||
const img = document.createElement("img");
|
||||
img.src = att.url;
|
||||
img.alt = att.name || "image";
|
||||
img.loading = "lazy";
|
||||
img.className = "file-chat-image";
|
||||
img.title = t("chat.view_image");
|
||||
img.addEventListener("click", () => TabManager.openChatImage(att));
|
||||
container.appendChild(img);
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "file-chat-image-actions";
|
||||
const mkBtn = (iconName, label, cls, onClick) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = `file-chat-image-btn ${cls}`;
|
||||
b.title = label;
|
||||
b.setAttribute("aria-label", label);
|
||||
b.appendChild(icon(iconName, 14));
|
||||
b.addEventListener("click", (e) => { e.stopPropagation(); onClick(); });
|
||||
return b;
|
||||
};
|
||||
actions.appendChild(mkBtn("download", t("chat.download_image"), "file-chat-image-download", () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = att.url;
|
||||
a.download = att.name || "image";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}));
|
||||
actions.appendChild(mkBtn("maximize", t("chat.view_image"), "file-chat-open", () => TabManager.openChatImage(att)));
|
||||
container.appendChild(actions);
|
||||
|
||||
return container;
|
||||
}
|
||||
if (att.kind === "video") {
|
||||
const v = document.createElement("video");
|
||||
v.src = att.url;
|
||||
v.controls = true;
|
||||
v.preload = "metadata";
|
||||
v.className = "file-chat-video";
|
||||
return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// #191 — suppression : auteur ou admin, avec confirmation native.
|
||||
function _canDelete(m, currentUser) {
|
||||
const u = AuthManager.getUser();
|
||||
if (!u || !u.username) return false;
|
||||
return u.role === "admin" || m.user === u.username;
|
||||
}
|
||||
|
||||
// #110 — suppression différée : la ligne passe tout de suite à « supprimé »
|
||||
// avec un bouton Annuler ; le DELETE n'est envoyé qu'au bout de 10 s.
|
||||
// ponytail: un rechargement complet pendant la fenêtre annule la suppression
|
||||
// (le message reste côté serveur) — connu, acceptable.
|
||||
const _pendingDeletes = new Map(); // id -> {timer}
|
||||
|
||||
export function _deleteMessage(delUrl, id, wrap) {
|
||||
if (_pendingDeletes.has(id)) return;
|
||||
const parent = wrap.parentNode;
|
||||
if (!parent) return;
|
||||
|
||||
const holder = document.createElement("div");
|
||||
holder.className = "file-chat-msg file-chat-deleted";
|
||||
holder.innerHTML =
|
||||
`<div class="file-chat-body file-chat-deleted-text">${t("chat.deleted_message")}` +
|
||||
`<button class="file-chat-undo" type="button">${t("chat.undo")}</button></div>`;
|
||||
parent.replaceChild(holder, wrap);
|
||||
|
||||
const undo = () => {
|
||||
const entry = _pendingDeletes.get(id);
|
||||
if (!entry) return;
|
||||
clearTimeout(entry.timer);
|
||||
_pendingDeletes.delete(id);
|
||||
holder.replaceWith(wrap);
|
||||
};
|
||||
const timer = setTimeout(async () => {
|
||||
_pendingDeletes.delete(id);
|
||||
try {
|
||||
await api(`${delUrl}/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
holder.remove();
|
||||
} catch (_) {
|
||||
showToast(t("chat.delete_error"), "error");
|
||||
holder.replaceWith(wrap); // suppression ratée : le message revient
|
||||
}
|
||||
}, 10000);
|
||||
_pendingDeletes.set(id, { timer });
|
||||
holder.querySelector(".file-chat-undo").addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
undo();
|
||||
});
|
||||
}
|
||||
|
||||
// #191 — carte de lien : site, titre, description, vignette.
|
||||
function _previewEl(p) {
|
||||
const card = document.createElement("a");
|
||||
card.className = "file-chat-preview";
|
||||
card.href = p.url;
|
||||
card.target = "_blank";
|
||||
card.rel = "noopener noreferrer";
|
||||
if (p.image) {
|
||||
const img = document.createElement("img");
|
||||
img.className = "file-chat-preview-img";
|
||||
img.src = p.image;
|
||||
img.alt = "";
|
||||
img.loading = "lazy";
|
||||
img.referrerPolicy = "no-referrer";
|
||||
card.appendChild(img);
|
||||
}
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "file-chat-preview-meta";
|
||||
const site = document.createElement("div");
|
||||
site.className = "file-chat-preview-site";
|
||||
site.textContent = p.site || "";
|
||||
const title = document.createElement("div");
|
||||
title.className = "file-chat-preview-title";
|
||||
title.textContent = p.title || p.url;
|
||||
meta.appendChild(site);
|
||||
meta.appendChild(title);
|
||||
if (p.description) {
|
||||
const desc = document.createElement("div");
|
||||
desc.className = "file-chat-preview-desc";
|
||||
desc.textContent = p.description;
|
||||
meta.appendChild(desc);
|
||||
}
|
||||
card.appendChild(meta);
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one message.
|
||||
* *delUrl* (#191) is the DELETE endpoint base for this conversation; when
|
||||
* provided the author/admin sees a trash button on their own messages.
|
||||
* *readMap* (#192) drives the ✓/✓✓ receipt shown on my own messages.
|
||||
*/
|
||||
export function _messageEl(m, currentUser, delUrl, readMap) {
|
||||
const mine = currentUser && m.user === currentUser;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "file-chat-msg" + (mine ? " mine" : "");
|
||||
wrap.dataset.id = m.id || "";
|
||||
wrap.dataset.ts = m.ts || 0;
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "file-chat-meta";
|
||||
meta.innerHTML =
|
||||
`<span class="file-chat-user">${_esc(m.user)}</span>` +
|
||||
`<span class="file-chat-ts">${_esc(_formatTime(m.ts))}</span>`;
|
||||
if (mine) {
|
||||
const read = _isRead(m.ts, readMap, currentUser);
|
||||
const status = document.createElement("span");
|
||||
status.className = "file-chat-status" + (read ? " read" : "");
|
||||
status.textContent = read ? "✓✓" : "✓";
|
||||
status.title = t(read ? "chat.status_read" : "chat.status_sent");
|
||||
meta.appendChild(status);
|
||||
}
|
||||
if (delUrl && _canDelete(m, currentUser)) {
|
||||
const del = document.createElement("button");
|
||||
del.type = "button";
|
||||
del.className = "file-chat-del";
|
||||
del.title = t("chat.delete");
|
||||
del.setAttribute("aria-label", t("chat.delete"));
|
||||
del.textContent = "🗑";
|
||||
del.addEventListener("click", () => _deleteMessage(delUrl, m.id, wrap));
|
||||
meta.appendChild(del);
|
||||
}
|
||||
const body = document.createElement("div");
|
||||
body.className = "file-chat-body";
|
||||
_fillBody(body, m);
|
||||
wrap.appendChild(meta);
|
||||
if (m.text) wrap.appendChild(body);
|
||||
const att = _attachEl(m.attachment);
|
||||
if (att) wrap.appendChild(att);
|
||||
if (m.preview) wrap.appendChild(_previewEl(m.preview));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
export function renderChatMessages(container, messages, currentUser, delUrl, readMap) {
|
||||
container.innerHTML = "";
|
||||
if (!messages || messages.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "file-chat-empty";
|
||||
empty.textContent = t("chat.empty");
|
||||
container.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
messages.forEach((m) => container.appendChild(_messageEl(m, currentUser, delUrl, readMap)));
|
||||
container.scrollTop = container.scrollHeight;
|
||||
safeCreateIcons(); // #110 — hydrate les icônes des boutons de pièce jointe
|
||||
}
|
||||
|
||||
function _appendMessage(list, m, delUrl) {
|
||||
const empty = list.querySelector(".file-chat-empty");
|
||||
if (empty) empty.remove();
|
||||
const id = m.id;
|
||||
if (id && list.querySelector(`[data-id="${CSS.escape(id)}"]`)) return false;
|
||||
list.appendChild(_messageEl(m, _currentUser(), delUrl));
|
||||
list.scrollTop = list.scrollHeight;
|
||||
safeCreateIcons();
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Panneau du document (#169)
|
||||
// ---------------------------------------------------------------------------
|
||||
function _panel() {
|
||||
let el = document.getElementById(PANEL_ID);
|
||||
if (!el) {
|
||||
el = document.createElement("aside");
|
||||
el.id = PANEL_ID;
|
||||
el.className = "file-chat-panel hidden";
|
||||
el.setAttribute("aria-label", t("chat.title"));
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
async function _load() {
|
||||
const panel = _panel();
|
||||
const list = panel.querySelector(".file-chat-list");
|
||||
try {
|
||||
const data = await api(
|
||||
`/api/file/${encodeURIComponent(_vault)}/chat?path=${encodeURIComponent(_path)}`,
|
||||
);
|
||||
_fileRead = data.read || {};
|
||||
renderChatMessages(list, data.messages || [], _currentUser(), null, _fileRead);
|
||||
_markRead(_vault, _path); // #192 — accusé de réception
|
||||
} catch (_) {
|
||||
list.innerHTML = "";
|
||||
const err = document.createElement("div");
|
||||
err.className = "file-chat-empty";
|
||||
err.textContent = t("chat.load_error");
|
||||
list.appendChild(err);
|
||||
}
|
||||
}
|
||||
|
||||
function _renderShell(panel) {
|
||||
panel.innerHTML = "";
|
||||
const header = document.createElement("div");
|
||||
header.className = "file-chat-header";
|
||||
const titles = document.createElement("div");
|
||||
titles.className = "file-chat-titles";
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = t("chat.title");
|
||||
titles.appendChild(title);
|
||||
// #190 A — le document ciblé est affiché dans l'en-tête
|
||||
const target = document.createElement("span");
|
||||
target.className = "file-chat-target";
|
||||
target.textContent = _title || _path || "";
|
||||
target.title = _path || "";
|
||||
titles.appendChild(target);
|
||||
const close = document.createElement("button");
|
||||
close.type = "button";
|
||||
close.className = "file-chat-close";
|
||||
close.title = t("chat.close");
|
||||
close.setAttribute("aria-label", t("chat.close"));
|
||||
close.textContent = "✕";
|
||||
close.addEventListener("click", closeFileChat);
|
||||
header.appendChild(titles);
|
||||
header.appendChild(close);
|
||||
|
||||
const list = document.createElement("div");
|
||||
list.className = "file-chat-list";
|
||||
|
||||
const form = document.createElement("form");
|
||||
form.className = "file-chat-form";
|
||||
// #192 — textarea auto-agrandie : on lit le message entier avant l'envoi.
|
||||
const input = document.createElement("textarea");
|
||||
input.className = "file-chat-input";
|
||||
input.rows = 1;
|
||||
input.placeholder = t("chat.placeholder");
|
||||
input.maxLength = 4000;
|
||||
input.setAttribute("aria-label", t("chat.placeholder"));
|
||||
input.addEventListener("input", () => _autoGrow(input));
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault(); // Shift+Enter = retour à la ligne
|
||||
_send(input);
|
||||
}
|
||||
});
|
||||
const send = document.createElement("button");
|
||||
send.type = "submit";
|
||||
send.className = "file-chat-send file-chat-send-icon";
|
||||
send.title = t("chat.send");
|
||||
send.setAttribute("aria-label", t("chat.send"));
|
||||
// #191 : icône compacte — la saisie garde toute la largeur
|
||||
send.innerHTML = '<i data-lucide="send" style="width:15px;height:15px"></i>';
|
||||
form.appendChild(input);
|
||||
form.appendChild(send);
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
_send(input);
|
||||
});
|
||||
|
||||
panel.appendChild(header);
|
||||
panel.appendChild(list);
|
||||
panel.appendChild(form);
|
||||
safeCreateIcons(); // #191 — hydrate l'icône d'envoi
|
||||
}
|
||||
|
||||
export async function openFileChat(vault, path, title) {
|
||||
_vault = vault;
|
||||
_path = path;
|
||||
_title = title || path || "";
|
||||
_ensureNotifyPermission(); // #110 — geste utilisateur → permission notifications
|
||||
const panel = _panel();
|
||||
_renderShell(panel);
|
||||
panel.classList.remove("hidden");
|
||||
await _load();
|
||||
const input = panel.querySelector(".file-chat-input");
|
||||
if (input) input.focus();
|
||||
}
|
||||
|
||||
export function closeFileChat() {
|
||||
const panel = document.getElementById(PANEL_ID);
|
||||
if (panel) panel.classList.add("hidden");
|
||||
}
|
||||
|
||||
export function toggleFileChat(vault, path, title) {
|
||||
const panel = document.getElementById(PANEL_ID);
|
||||
if (panel && !panel.classList.contains("hidden") && _vault === vault && _path === path) {
|
||||
closeFileChat();
|
||||
return;
|
||||
}
|
||||
openFileChat(vault, path, title);
|
||||
}
|
||||
|
||||
// #190 A — appelé à chaque renderFile : le panneau ouvert suit le document.
|
||||
export function followFileChat(vault, path, title) {
|
||||
const panel = document.getElementById(PANEL_ID);
|
||||
if (!panel || panel.classList.contains("hidden")) return;
|
||||
if (_vault === vault && _path === path) return;
|
||||
openFileChat(vault, path, title);
|
||||
}
|
||||
|
||||
async function _send(input) {
|
||||
if (_sending) return;
|
||||
const text = (input.value || "").trim();
|
||||
if (!text) return;
|
||||
_sending = true;
|
||||
try {
|
||||
const data = await api(`/api/file/${encodeURIComponent(_vault)}/chat`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path: _path, text }),
|
||||
});
|
||||
input.value = "";
|
||||
_autoGrow(input); // #192 — repasse à la hauteur d'une ligne
|
||||
_appendMessage(_panel().querySelector(".file-chat-list"), data.message);
|
||||
} catch (_) {
|
||||
showToast(t("chat.send_error"), "error");
|
||||
} finally {
|
||||
_sending = false;
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Chat sidebar (#190 B) + canaux privé #191
|
||||
// ---------------------------------------------------------------------------
|
||||
function _sidebarList() {
|
||||
return document.getElementById("sidebar-panel-chat-list");
|
||||
}
|
||||
|
||||
/** Clé de la conversation courante (unread + sélection). */
|
||||
function _channelKey() {
|
||||
return _channel.type === "dm" ? `dm:${_channel.user}` : "general";
|
||||
}
|
||||
|
||||
/** URL d'API de base du canal courant. */
|
||||
function _channelUrl() {
|
||||
return _channel.type === "dm"
|
||||
? `/api/chat/dm/${encodeURIComponent(_channel.user)}`
|
||||
: "/api/chat";
|
||||
}
|
||||
|
||||
/** URL de base de suppression du canal courant. */
|
||||
function _channelDelUrl() {
|
||||
return _channel.type === "dm"
|
||||
? `/api/chat/dm/${encodeURIComponent(_channel.user)}`
|
||||
: "/api/chat";
|
||||
}
|
||||
|
||||
/** Rend la rangée de canaux : Général + un bouton par utilisateur. */
|
||||
function _renderChannels(users) {
|
||||
const holder = document.getElementById("sidebar-panel-chat-channels");
|
||||
if (!holder) return;
|
||||
holder.innerHTML = "";
|
||||
const mk = (label, active, onClick, title) => {
|
||||
const b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "chat-channel" + (active ? " active" : "");
|
||||
b.textContent = label;
|
||||
if (title) b.title = title;
|
||||
b.addEventListener("click", onClick);
|
||||
holder.appendChild(b);
|
||||
return b;
|
||||
};
|
||||
mk(t("chat.channel_general"), _channel.type === "general", () => switchChannel(null));
|
||||
(users || []).forEach((u) => {
|
||||
mk(u.display_name || u.username, _channel.type === "dm" && _channel.user === u.username,
|
||||
() => switchChannel(u.username), `@${u.username}`);
|
||||
});
|
||||
}
|
||||
|
||||
/** Charge la liste des utilisateurs (une fois) puis rend les canaux. */
|
||||
async function _loadUsers() {
|
||||
if (_usersCache) return _usersCache;
|
||||
try {
|
||||
const data = await api("/api/chat/users");
|
||||
_usersCache = Array.isArray(data) ? data : [];
|
||||
} catch (_) {
|
||||
_usersCache = [];
|
||||
}
|
||||
return _usersCache;
|
||||
}
|
||||
|
||||
/** Charge (ou recharge) l'historique du canal courant dans la sidebar. */
|
||||
export async function openSidebarChat() {
|
||||
const list = _sidebarList();
|
||||
if (!list) return;
|
||||
_ensureNotifyPermission(); // #110 — geste utilisateur → permission notifications
|
||||
const users = await _loadUsers();
|
||||
_renderChannels(users);
|
||||
try {
|
||||
const data = await api(_channelUrl());
|
||||
_channelMessages = (data.messages || []).slice();
|
||||
_channelLoaded = true;
|
||||
_channelRead = data.read || {};
|
||||
renderChatMessages(list, _channelMessages, _currentUser(), _channelDelUrl(), _channelRead);
|
||||
_clearUnread();
|
||||
const vp = _channelVaultPath();
|
||||
_markRead(vp.vault, vp.path); // #192 — accusé de réception
|
||||
} catch (_) {
|
||||
list.innerHTML = "";
|
||||
const err = document.createElement("div");
|
||||
err.className = "file-chat-empty";
|
||||
err.textContent = t("chat.load_error");
|
||||
list.appendChild(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Change de canal (#191) : null = général, sinon l'username du pair. */
|
||||
export function switchChannel(user) {
|
||||
_channel = user ? { type: "dm", user } : { type: "general" };
|
||||
const input = _sendInput();
|
||||
if (input) {
|
||||
input.placeholder = user ? t("chat.placeholder_dm", { user }) : t("chat.placeholder");
|
||||
}
|
||||
openSidebarChat();
|
||||
}
|
||||
|
||||
/** Recherche dans le canal courant (barre de filtre sidebar) — #190 C. */
|
||||
export function filterChatMessages(query) {
|
||||
const list = _sidebarList();
|
||||
if (!list || !_channelLoaded) return;
|
||||
const q = String(query || "").toLowerCase().trim();
|
||||
const shown = q
|
||||
? _channelMessages.filter((m) =>
|
||||
((m.text || "") + " " + (m.user || "")).toLowerCase().includes(q),
|
||||
)
|
||||
: _channelMessages;
|
||||
renderChatMessages(list, shown, _currentUser(), _channelDelUrl(), _channelRead);
|
||||
}
|
||||
|
||||
function _sendInput() {
|
||||
return document.getElementById("sidebar-panel-chat-input");
|
||||
}
|
||||
|
||||
function _renderPending() {
|
||||
const holder = document.getElementById("sidebar-panel-chat-pending");
|
||||
if (!holder) return;
|
||||
holder.innerHTML = "";
|
||||
if (!_pendingAttach) return;
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "file-chat-pending";
|
||||
chip.textContent =
|
||||
(_pendingAttach.kind === "video" ? "🎬 " : "🖼 ") + (_pendingAttach.name || "pièce jointe");
|
||||
const x = document.createElement("button");
|
||||
x.type = "button";
|
||||
x.textContent = "✕";
|
||||
x.setAttribute("aria-label", t("chat.attach_remove"));
|
||||
x.addEventListener("click", () => {
|
||||
_pendingAttach = null;
|
||||
_renderPending();
|
||||
});
|
||||
chip.appendChild(x);
|
||||
holder.appendChild(chip);
|
||||
}
|
||||
|
||||
/** Envoi d'un message du chat général (texte + pièce jointe éventuelle). */
|
||||
export async function sendGeneralMessage() {
|
||||
const input = _sendInput();
|
||||
if (!input) return;
|
||||
const text = (input.value || "").trim();
|
||||
if (!text && !_pendingAttach) return;
|
||||
const body = { text };
|
||||
if (_pendingAttach) body.attachment = _pendingAttach;
|
||||
try {
|
||||
const data = await api(_channelUrl(), { method: "POST", body: JSON.stringify(body) });
|
||||
input.value = "";
|
||||
_autoGrow(input); // #192 — repasse à la hauteur d'une ligne
|
||||
_pendingAttach = null;
|
||||
_renderPending();
|
||||
const list = _sidebarList();
|
||||
if (list) {
|
||||
_channelMessages.push(data.message);
|
||||
_appendMessage(list, data.message, _channelDelUrl());
|
||||
}
|
||||
input.focus();
|
||||
} catch (_) {
|
||||
showToast(t("chat.send_error"), "error");
|
||||
}
|
||||
}
|
||||
|
||||
/** Sélecteur de pièce jointe (image/vidéo) → upload → envoi libre. */
|
||||
export async function pickAttachment() {
|
||||
return new Promise((resolve) => {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = "image/*,video/*";
|
||||
fileInput.style.display = "none";
|
||||
document.body.appendChild(fileInput);
|
||||
fileInput.addEventListener("change", async () => {
|
||||
const file = fileInput.files && fileInput.files[0];
|
||||
fileInput.remove();
|
||||
if (!file) return resolve(null);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await api("/api/chat/upload", { method: "POST", body: form });
|
||||
_pendingAttach = res.attachment;
|
||||
_renderPending();
|
||||
resolve(_pendingAttach);
|
||||
} catch (_) {
|
||||
showToast(t("chat.attach_error"), "error");
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Messages non lus (#190 B / #191) --------------------------------------
|
||||
// Compteurs par clé de canal : {"general": 2, "dm:bob": 1}. Ouverture du
|
||||
// canal → remise à zéro de sa clé.
|
||||
function _readUnread() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(UNREAD_KEY) || "{}") || {};
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function _writeUnread(u) {
|
||||
try {
|
||||
localStorage.setItem(UNREAD_KEY, JSON.stringify(u));
|
||||
} catch (_) { /* quota */ }
|
||||
}
|
||||
|
||||
function _clearUnread() {
|
||||
const u = _readUnread();
|
||||
u[_channelKey()] = 0;
|
||||
_writeUnread(u);
|
||||
_renderBadge(_totalUnread());
|
||||
_renderChannels(_usersCache);
|
||||
}
|
||||
|
||||
function _totalUnread() {
|
||||
const u = _readUnread();
|
||||
return Object.values(u).reduce((n, v) => n + (typeof v === "number" ? v : 0), 0);
|
||||
}
|
||||
|
||||
function _renderBadge(count) {
|
||||
const tab = document.getElementById("sidebar-tab-chat");
|
||||
if (!tab) return;
|
||||
// #110 — point de couleur sur l'icône chat tant qu'il reste des non-lus
|
||||
tab.classList.toggle("chat-has-unread", count > 0);
|
||||
let badge = tab.querySelector(".chat-unread-badge");
|
||||
if (!count) {
|
||||
if (badge) badge.remove();
|
||||
return;
|
||||
}
|
||||
if (!badge) {
|
||||
badge = document.createElement("span");
|
||||
badge.className = "chat-unread-badge";
|
||||
tab.appendChild(badge);
|
||||
}
|
||||
badge.textContent = count > 99 ? "99+" : String(count);
|
||||
}
|
||||
|
||||
/** Affiche le total des messages non lus (tous canaux confondus). */
|
||||
export function refreshUnreadBadge() {
|
||||
_renderBadge(_totalUnread());
|
||||
}
|
||||
|
||||
/** #110 — notification OS quand l'app n'est pas au premier plan. */
|
||||
function _notifyNewMessage(m) {
|
||||
const Notif = window.Notification;
|
||||
if (!Notif) return;
|
||||
if (!document.hidden && document.hasFocus()) return; // visible : le toast suffit
|
||||
if (Notif.permission !== "granted") return;
|
||||
try {
|
||||
const n = new Notif(t("chat.new_message", { user: m.user }), {
|
||||
body: (m.text || "").slice(0, 140),
|
||||
icon: "/static/icons/icon-192x192.svg",
|
||||
tag: "obsigate-chat",
|
||||
});
|
||||
n.onclick = () => { try { window.focus(); } catch (_) { /* ignore */ } n.close(); };
|
||||
} catch (_) { /* notifications indisponibles */ }
|
||||
}
|
||||
|
||||
/** Demande la permission au premier clic utilisateur (ouverture du chat). */
|
||||
function _ensureNotifyPermission() {
|
||||
const Notif = window.Notification;
|
||||
if (!Notif || Notif.permission !== "default") return;
|
||||
try { Notif.requestPermission(); } catch (_) { /* ignoré */ }
|
||||
}
|
||||
|
||||
/** Initialisation : formulaire + raccourcis clavier. */
|
||||
export function initSidebarChat() {
|
||||
const form = document.getElementById("sidebar-panel-chat-form");
|
||||
if (!form) return;
|
||||
const input = document.getElementById("sidebar-panel-chat-input");
|
||||
if (input) {
|
||||
input.placeholder = t("chat.placeholder"); // le HTML embarque le FR
|
||||
input.addEventListener("input", () => _autoGrow(input)); // #192
|
||||
}
|
||||
form.addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
sendGeneralMessage();
|
||||
});
|
||||
const attach = document.getElementById("sidebar-panel-chat-attach");
|
||||
if (attach) attach.addEventListener("click", () => pickAttachment());
|
||||
if (input) {
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
sendGeneralMessage();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE relay (appelé par sync.js sur l'événement `chat_message`)
|
||||
// ---------------------------------------------------------------------------
|
||||
export function onChatMessage(data) {
|
||||
if (!data || !data.message) return;
|
||||
|
||||
// --- Canaux sidebar : général (#190) ou privé (#191) ---
|
||||
if (data.vault === GLOBAL_VAULT || data.vault === DM_VAULT) {
|
||||
const key = data.vault === DM_VAULT ? `dm:${_pairOf(data.path)}` : "general";
|
||||
const isActive = key === _channelKey();
|
||||
const panel = document.getElementById("sidebar-panel-chat");
|
||||
const visible = panel && panel.classList.contains("active");
|
||||
if (isActive) _channelMessages.push(data.message);
|
||||
if (visible && isActive) {
|
||||
_appendMessage(_sidebarList(), data.message, _channelDelUrl());
|
||||
_clearUnread();
|
||||
// #192 — le canal est affiché : on accuse réception aussitôt.
|
||||
if (data.message.user !== _currentUser()) _markRead(data.vault, data.path);
|
||||
} else if (data.message.user !== _currentUser()) {
|
||||
_bumpUnread(key);
|
||||
refreshUnreadBadge();
|
||||
showToast(t("chat.new_message", { user: data.message.user }), "info");
|
||||
_notifyNewMessage(data.message); // #110 — notification OS si app en arrière-plan
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Chat d'un document (#169)
|
||||
const panel = document.getElementById(PANEL_ID);
|
||||
const isOpen =
|
||||
panel && !panel.classList.contains("hidden") &&
|
||||
_vault === data.vault && _path === data.path;
|
||||
if (isOpen) {
|
||||
const list = panel.querySelector(".file-chat-list");
|
||||
if (list) _appendMessage(list, data.message);
|
||||
if (data.message.user !== _currentUser()) {
|
||||
// #192 — panneau affiché : accusé de réception immédiat.
|
||||
_markRead(data.vault, data.path);
|
||||
_notifyNewMessage(data.message); // #110 — notification OS si app en arrière-plan
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (data.message.user === _currentUser()) return; // son propre message
|
||||
showToast(t("chat.new_message", { user: data.message.user }), "info");
|
||||
_notifyNewMessage(data.message); // #110
|
||||
}
|
||||
|
||||
/**
|
||||
* #192 — SSE `chat_read` : quelqu'un vient d'ouvrir la conversation. Les
|
||||
* indicateurs de MES messages passent à ✓✓ et, si un message change d'état,
|
||||
* l'expéditeur est notifié.
|
||||
*/
|
||||
export function onChatRead(data) {
|
||||
if (!data || !data.user || data.user === _currentUser()) return; // notre propre écho
|
||||
const flipped = _syncRead(data.vault, data.path, data.read || { [data.user]: data.ts });
|
||||
if (flipped) showToast(t("chat.read_by", { user: data.user }), "info");
|
||||
}
|
||||
|
||||
/** Le path DM est « alice|bob » trié : on en déduit l'interlocuteur. */
|
||||
function _pairOf(path) {
|
||||
const me = _currentUser();
|
||||
const parts = String(path || "").split("|");
|
||||
return parts[0] === me ? parts[1] : parts[0];
|
||||
}
|
||||
|
||||
function _bumpUnread(key) {
|
||||
const u = _readUnread();
|
||||
u[key] = (u[key] || 0) + 1;
|
||||
_writeUnread(u);
|
||||
}
|
||||
|
||||
/** Suppression d'un post (#191) — relayée par sync.js. */
|
||||
export function onChatDeleted(data) {
|
||||
if (!data || !data.id) return;
|
||||
const sel = `[data-id="${CSS.escape(data.id)}"]`;
|
||||
document.querySelectorAll(sel).forEach((el) => el.remove());
|
||||
}
|
||||
@@ -911,8 +911,14 @@ export function renderSearchResults(data, query, tagFilter) {
|
||||
});
|
||||
if (tagsDiv.children.length > 0) item.appendChild(tagsDiv);
|
||||
}
|
||||
item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path));
|
||||
item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); });
|
||||
// #196 : document reçu par partage → onglet applicatif (/api/file résout
|
||||
// Partage/ vers la source, lecture seule). Fallback /s/ si résolution KO.
|
||||
if (r.share_token) {
|
||||
item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path));
|
||||
} else {
|
||||
item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path));
|
||||
item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); });
|
||||
}
|
||||
container.appendChild(item);
|
||||
});
|
||||
area.appendChild(container);
|
||||
|
||||
+158
-9
@@ -1,11 +1,56 @@
|
||||
import { state } from './state.js';
|
||||
import { safeCreateIcons, getFileIcon, flushIcons } from './utils.js';
|
||||
import { api } from './auth.js';
|
||||
import { api, AuthManager } from './auth.js';
|
||||
import { populateCustomDropdown, TabManager, closeMobileSidebar, ContextMenuManager } from './ui.js';
|
||||
import { _populateRecentVaultFilter, switchSidebarTab, filterAIHistory, filterRecentFiles } from './config.js';
|
||||
import { el, icon, getVaultIcon, smallBadge, attachTreeItemActionButton, attachTreeItemLongPress, showWelcome, appendHighlightedText, filterSavedSearches } from './viewer.js';
|
||||
import { performAdvancedSearch } from './search.js';
|
||||
import { t } from './i18n.js';
|
||||
import { filterChatMessages as _filterChat } from './filechat.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared-file decoration (#196) : fichier porteur d'un partage actif →
|
||||
// icône « share » dans l'arborescence ; dossier virtuel « Partage » dans le
|
||||
// home du destinataire.
|
||||
// ---------------------------------------------------------------------------
|
||||
let _sharedPaths = null; // Promise<Set<"vault::path">> — parts actives (créées ou reçues)
|
||||
|
||||
function loadSharedPaths() {
|
||||
if (!_sharedPaths) {
|
||||
_sharedPaths = api("/api/shares")
|
||||
.then(shares => new Set(shares.map(s => `${s.vault}::${s.path}`)))
|
||||
.catch(() => new Set());
|
||||
}
|
||||
return _sharedPaths;
|
||||
}
|
||||
|
||||
function invalidateSharedPaths() { _sharedPaths = null; }
|
||||
|
||||
/** Partages dirigés reçus par l'utilisateur courant (#196). */
|
||||
async function _loadReceivedShares() {
|
||||
const me = (AuthManager.getUser() || {}).username;
|
||||
if (!me) return [];
|
||||
try {
|
||||
const shares = await api("/api/shares");
|
||||
// Adressés à moi uniquement : un admin voit tous les partages via
|
||||
// /api/shares, ceux des autres ne sont pas dans SON Partage/.
|
||||
return shares.filter(s => s.created_by && s.created_by !== me
|
||||
&& Array.isArray(s.shared_with) && s.shared_with.includes(me));
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
/** Icône « partagé » (#196) — share-2 bleu. */
|
||||
function _sharedIcon(size = 16) {
|
||||
const i = icon("share-2", size);
|
||||
i.style.color = "var(--accent-blue, #3b82f6)";
|
||||
return i;
|
||||
}
|
||||
|
||||
/** Fichier partagé (créé ou reçu) → icône share-2 bleue, sinon icône usuelle. */
|
||||
function sharedFileIconSync(sharedPaths, itemName, vaultName, itemPath) {
|
||||
if (sharedPaths && sharedPaths.has(`${vaultName}::${itemPath}`)) return _sharedIcon();
|
||||
return icon(getFileIcon(itemName), 16);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vault context switching
|
||||
@@ -219,8 +264,15 @@ async function loadVaultSettings() {
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar — Vault tree
|
||||
// ---------------------------------------------------------------------------
|
||||
// #194 : le dossier personnel (vault « home-<user> ») s'affiche en premier
|
||||
// dans l'arborescence. Tri stable fait sur place, à la source de toutes les
|
||||
// listes (arborescence, sélecteurs, state.allVaults).
|
||||
const _homeFirst = (a, b) =>
|
||||
(String(b?.name).startsWith("home-") ? 1 : 0) - (String(a?.name).startsWith("home-") ? 1 : 0);
|
||||
|
||||
async function loadVaults() {
|
||||
const vaults = await api("/api/vaults");
|
||||
vaults.sort(_homeFirst);
|
||||
state.allVaults = vaults;
|
||||
const container = document.getElementById("vault-tree");
|
||||
container.innerHTML = "";
|
||||
@@ -298,7 +350,53 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
// #196 : icône « partagé » — résout le cache avant le rendu.
|
||||
const sharedPaths = await loadSharedPaths();
|
||||
|
||||
// #196 : dossier virtuel « Partage » à la racine du home du destinataire.
|
||||
const items = Array.isArray(data.items) ? data.items.slice() : [];
|
||||
const me = (AuthManager.getUser() || {}).username;
|
||||
if (me && vaultName === `home-${me}` && !dirPath) {
|
||||
const myShares = await _loadReceivedShares();
|
||||
if (myShares.length) items.unshift({ type: "virtual-share", shares: myShares });
|
||||
}
|
||||
data.items = items;
|
||||
|
||||
data.items.forEach((item) => {
|
||||
// #196 : dossier virtuel « Partage » (fichiers reçus via partage dirigé).
|
||||
if (item.type === "virtual-share") {
|
||||
const shareDir = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": "Partage", "data-type": "directory" }, [icon("chevron-right", 14), _sharedIcon(), el("span", { class: "tree-item-text" }, [document.createTextNode(t("sidebar.shared_folder"))]), smallBadge(item.shares.length)]);
|
||||
const shareSub = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-Partage` });
|
||||
item.shares.forEach((s) => {
|
||||
const name = (s.path || "").split("/").pop().replace(/\.md$/i, "");
|
||||
const virtualPath = `Partage/${s.token}/${s.path.split("/").pop()}`;
|
||||
const fItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": virtualPath, "data-type": "file", "data-share-token": s.token }, [_sharedIcon(), el("span", { class: "tree-item-text" }, [document.createTextNode(name)])]);
|
||||
fItem.title = t("dashboard.shared_by", { user: s.created_by || "" });
|
||||
fItem.addEventListener("click", () => {
|
||||
scrollTreeItemIntoView(fItem, false);
|
||||
// #196 : ouverture en onglet applicatif — /api/file résout
|
||||
// Partage/<token>/<nom> vers le fichier source (lecture seule).
|
||||
if (window.innerWidth <= 768) {
|
||||
TabManager.openPersistent(vaultName, virtualPath);
|
||||
} else {
|
||||
TabManager.openPreview(vaultName, virtualPath);
|
||||
}
|
||||
closeMobileSidebar();
|
||||
});
|
||||
shareSub.appendChild(fItem);
|
||||
});
|
||||
shareDir.addEventListener("click", () => {
|
||||
// #196 : dossier virtuel — pas de navigation (pas de répertoire physique).
|
||||
const expanded = !shareSub.classList.contains("collapsed");
|
||||
shareSub.classList.toggle("collapsed", expanded);
|
||||
const chev = shareDir.querySelector("[data-lucide]");
|
||||
if (chev) chev.setAttribute("data-lucide", expanded ? "chevron-right" : "chevron-down");
|
||||
safeCreateIcons();
|
||||
});
|
||||
fragment.appendChild(shareDir);
|
||||
fragment.appendChild(shareSub);
|
||||
return;
|
||||
}
|
||||
if (!shouldDisplayPath(item.path, vaultName)) return;
|
||||
|
||||
const existing = existingItems[item.path];
|
||||
@@ -366,11 +464,11 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
|
||||
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "directory", false);
|
||||
});
|
||||
} else {
|
||||
const fileIconName = getFileIcon(item.name);
|
||||
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
|
||||
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]);
|
||||
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
|
||||
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
||||
const fileIconName = getFileIcon(item.name);
|
||||
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
|
||||
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [sharedFileIconSync(sharedPaths, item.name, vaultName, item.path), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]);
|
||||
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
|
||||
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
||||
fileItem.addEventListener("click", () => {
|
||||
scrollTreeItemIntoView(fileItem, false);
|
||||
if (window.innerWidth <= 768) {
|
||||
@@ -425,6 +523,7 @@ export async function refreshSidebarTreePreservingState() {
|
||||
// 2. Soft update: vault names/counts without wiping the tree
|
||||
try {
|
||||
const vaults = await api("/api/vaults");
|
||||
vaults.sort(_homeFirst);
|
||||
state.allVaults = vaults;
|
||||
vaults.forEach((v) => {
|
||||
const vItem = document.querySelector(`.vault-item[data-vault="${CSS.escape(v.name)}"]`);
|
||||
@@ -620,9 +719,55 @@ async function loadDirectory(vaultName, dirPath, container) {
|
||||
}
|
||||
container.innerHTML = "";
|
||||
|
||||
// #196 : icône « partagé » — résout le cache avant le rendu synchrone.
|
||||
const sharedPaths = await loadSharedPaths();
|
||||
|
||||
// #196 : dossier virtuel « Partage » à la racine du home du destinataire.
|
||||
const items = Array.isArray(data.items) ? data.items.slice() : [];
|
||||
const me = (AuthManager.getUser() || {}).username;
|
||||
if (me && vaultName === `home-${me}` && !dirPath) {
|
||||
const myShares = await _loadReceivedShares();
|
||||
if (myShares.length) items.unshift({ type: "virtual-share", shares: myShares });
|
||||
}
|
||||
data.items = items;
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
data.items.forEach((item) => {
|
||||
// #196 : dossier virtuel « Partage » (fichiers reçus via partage dirigé).
|
||||
if (item.type === "virtual-share") {
|
||||
const shareDir = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": "Partage", "data-type": "directory" }, [icon("chevron-right", 14), _sharedIcon(), el("span", { class: "tree-item-text" }, [document.createTextNode(t("sidebar.shared_folder"))]), smallBadge(item.shares.length)]);
|
||||
const shareSub = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-Partage` });
|
||||
item.shares.forEach((s) => {
|
||||
const name = (s.path || "").split("/").pop().replace(/\.md$/i, "");
|
||||
const virtualPath = `Partage/${s.token}/${s.path.split("/").pop()}`;
|
||||
const fItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": virtualPath, "data-type": "file", "data-share-token": s.token }, [_sharedIcon(), el("span", { class: "tree-item-text" }, [document.createTextNode(name)])]);
|
||||
fItem.title = t("dashboard.shared_by", { user: s.created_by || "" });
|
||||
fItem.addEventListener("click", () => {
|
||||
scrollTreeItemIntoView(fItem, false);
|
||||
// #196 : ouverture en onglet applicatif — /api/file résout
|
||||
// Partage/<token>/<nom> vers le fichier source (lecture seule).
|
||||
if (window.innerWidth <= 768) {
|
||||
TabManager.openPersistent(vaultName, virtualPath);
|
||||
} else {
|
||||
TabManager.openPreview(vaultName, virtualPath);
|
||||
}
|
||||
closeMobileSidebar();
|
||||
});
|
||||
shareSub.appendChild(fItem);
|
||||
});
|
||||
shareDir.addEventListener("click", () => {
|
||||
// #196 : dossier virtuel — pas de navigation (pas de répertoire physique).
|
||||
const expanded = !shareSub.classList.contains("collapsed");
|
||||
shareSub.classList.toggle("collapsed", expanded);
|
||||
const chev = shareDir.querySelector("[data-lucide]");
|
||||
if (chev) chev.setAttribute("data-lucide", expanded ? "chevron-right" : "chevron-down");
|
||||
safeCreateIcons();
|
||||
});
|
||||
fragment.appendChild(shareDir);
|
||||
fragment.appendChild(shareSub);
|
||||
return;
|
||||
}
|
||||
// Apply client-side filtering for hidden files
|
||||
if (!shouldDisplayPath(item.path, vaultName)) {
|
||||
return; // Skip this item
|
||||
@@ -664,7 +809,8 @@ async function loadDirectory(vaultName, dirPath, container) {
|
||||
} else {
|
||||
const fileIconName = getFileIcon(item.name);
|
||||
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
|
||||
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]);
|
||||
const isShared = sharedPaths.has(`${vaultName}::${item.path}`);
|
||||
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [isShared ? _sharedIcon() : icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]);
|
||||
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
|
||||
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
||||
fileItem.addEventListener("click", () => {
|
||||
@@ -696,6 +842,7 @@ async function loadDirectory(vaultName, dirPath, container) {
|
||||
safeCreateIcons();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sidebar filter
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -704,13 +851,14 @@ function initSidebarFilter() {
|
||||
const caseBtn = document.getElementById("sidebar-filter-case-btn");
|
||||
const clearBtn = document.getElementById("sidebar-filter-clear-btn");
|
||||
|
||||
// Route the query to the active tab's own filter (#98/#99).
|
||||
// Route the query to the active tab's own filter (#98/#99, chat #190 C).
|
||||
const routeFilter = async (q) => {
|
||||
const tab = state.activeSidebarTab;
|
||||
if (tab === "vaults") await performTreeSearch(q);
|
||||
else if (tab === "recent") filterRecentFiles(q);
|
||||
else if (tab === "saved") filterSavedSearches(q);
|
||||
else if (tab === "ai") filterAIHistory(q);
|
||||
else if (tab === "chat") _filterChat(q);
|
||||
else filterTagCloud(q);
|
||||
};
|
||||
const routeClear = async () => {
|
||||
@@ -719,6 +867,7 @@ function initSidebarFilter() {
|
||||
else if (tab === "recent") filterRecentFiles("");
|
||||
else if (tab === "saved") filterSavedSearches("");
|
||||
else if (tab === "ai") filterAIHistory("");
|
||||
else if (tab === "chat") _filterChat("");
|
||||
else filterTagCloud("");
|
||||
};
|
||||
|
||||
@@ -1122,4 +1271,4 @@ function searchByTag(tag) {
|
||||
}
|
||||
|
||||
|
||||
export { initVaultContext, setSelectedVaultContext, syncVaultSelectors, shouldDisplayPath, loadVaults, loadVaultSettings, initSidebarFilter, TagFilterService, loadTags, scrollTreeItemIntoView, refreshSidebarForContext, focusVaultInSidebar, focusPathInSidebar, refreshTagsForContext, syncActiveFileTreeItem, searchByTag, addTagFilter, buildSearchResultsHeader, removeTagFilter };
|
||||
export { initVaultContext, setSelectedVaultContext, syncVaultSelectors, shouldDisplayPath, loadVaults, loadVaultSettings, initSidebarFilter, TagFilterService, loadTags, scrollTreeItemIntoView, refreshSidebarForContext, focusVaultInSidebar, focusPathInSidebar, refreshTagsForContext, syncActiveFileTreeItem, searchByTag, addTagFilter, buildSearchResultsHeader, removeTagFilter, invalidateSharedPaths };
|
||||
|
||||
@@ -120,6 +120,36 @@ export const IndexUpdateManager = (() => {
|
||||
}
|
||||
});
|
||||
|
||||
// #169/#190 — Chat : relais des broadcasts SSE vers les panneaux
|
||||
eventSource.addEventListener("chat_message", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
import("./filechat.js").then((m) => m.onChatMessage(data)).catch(() => {});
|
||||
} catch (err) {
|
||||
console.error("SSE parse error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// #191 — suppression d'un post
|
||||
eventSource.addEventListener("chat_deleted", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
import("./filechat.js").then((m) => m.onChatDeleted(data)).catch(() => {});
|
||||
} catch (err) {
|
||||
console.error("SSE parse error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// #192 — accusé de réception (un participant vient d'ouvrir la conversation)
|
||||
eventSource.addEventListener("chat_read", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
import("./filechat.js").then((m) => m.onChatRead(data)).catch(() => {});
|
||||
} catch (err) {
|
||||
console.error("SSE parse error:", err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = () => {
|
||||
connectionState = "disconnected";
|
||||
_updateBadge();
|
||||
|
||||
+42
-1
@@ -1,7 +1,7 @@
|
||||
/* ObsiGate — UI module */
|
||||
import { api, AuthManager, initLoginForm } from './auth.js';
|
||||
import { state } from './state.js';
|
||||
import { openFile, showWelcome, renderFile, el } from './viewer.js';
|
||||
import { openFile, showWelcome, renderFile, renderImageViewer, el } from './viewer.js';
|
||||
import { safeCreateIcons, getFileIcon, escapeHtml, detachInlineEditor } from './utils.js';
|
||||
import { syncActiveFileTreeItem, refreshSidebarTreePreservingState } from './sidebar.js';
|
||||
import { GraphViewManager } from './graph.js';
|
||||
@@ -2122,6 +2122,28 @@ export const TabManager = {
|
||||
this.activate(tabId);
|
||||
},
|
||||
|
||||
/** #110 — ouvre une image jointe au chat dans un onglet, avec le même
|
||||
* visualiseur que pour une image de vault (zoom/pan/plein écran). La pièce
|
||||
* jointe est servie par ``/api/chat/attachment/`` : pas de couple
|
||||
* (vault, path) derrière, donc onglet dédié qui rend le viewer directement
|
||||
* depuis l'URL (aucun fetch de l'API fichiers).
|
||||
* ponytail: l'onglet reste sur le TabManager singleton (les PaneTabManager
|
||||
* n'ont pas cette branche) — suffisant pour un cas limite du mode split. */
|
||||
async openChatImage(att) {
|
||||
if (!att || !att.url) return;
|
||||
const tabId = `chatimg::${att.url}`;
|
||||
const name = att.name || "image";
|
||||
const existing = this._tabs.find(t => t.id === tabId);
|
||||
if (existing) {
|
||||
this.activate(tabId);
|
||||
return;
|
||||
}
|
||||
this._tabs.push({ id: tabId, vault: null, path: name, name, icon: "image", chatImage: true });
|
||||
this._tabCache[tabId] = { chatImage: true, url: att.url, mime: att.mime, name, title: name };
|
||||
this._renderTabs();
|
||||
this.activate(tabId);
|
||||
},
|
||||
|
||||
/** Open (or retarget) the navigation tab for a vault directory (#158).
|
||||
* One navigation tab per vault: clicking another directory reuses it and
|
||||
* updates its directory, so open file tabs are never displaced. */
|
||||
@@ -2229,6 +2251,25 @@ export const TabManager = {
|
||||
return;
|
||||
}
|
||||
|
||||
// #110 — onglet image de chat : rendu direct depuis l'URL de la pièce
|
||||
// jointe, sans passer par l'API fichiers (l'image n'est pas dans un vault).
|
||||
if (cache.chatImage) {
|
||||
state.currentVault = null;
|
||||
state.currentPath = null;
|
||||
const chatArea = document.getElementById("content-area");
|
||||
if (!chatArea) return;
|
||||
renderImageViewer(chatArea, {
|
||||
vault: null,
|
||||
path: cache.name,
|
||||
title: cache.name,
|
||||
url: cache.url,
|
||||
image_mime: cache.mime,
|
||||
});
|
||||
const dash = document.getElementById("dashboard-home");
|
||||
if (dash) dash.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
// Update global state
|
||||
state.currentVault = cache.vault;
|
||||
state.currentPath = cache.path;
|
||||
|
||||
+37
-3
@@ -16,6 +16,7 @@ import { t, getLocale } from './i18n.js';
|
||||
import { onFileRender } from './plugins.js';
|
||||
import { NowPlaying } from './now-playing.js';
|
||||
import { openAssistant } from './ai-fab.js';
|
||||
import { toggleFileChat, followFileChat } from './filechat.js';
|
||||
import { parseRef, columnName, findTd, invalidateCellIndex, sheetOfRef, firstCellOfRange } from './xlsx/refs.js';
|
||||
import { createColourPicker } from './xlsx/color-picker.js';
|
||||
import { buildCommandBar } from './xlsx/command-bar.js';
|
||||
@@ -726,7 +727,7 @@ export function renderImageViewer(area, data) {
|
||||
// of re-rendering the whole viewer (which used to refetch the file + listing).
|
||||
let currentPath = data.path;
|
||||
let currentTitle = data.title || (currentPath || "").split("/").pop();
|
||||
let imgUrl = buildImageUrl(vault, currentPath);
|
||||
let imgUrl = data.url || buildImageUrl(vault, currentPath);
|
||||
let currentMeta = {
|
||||
image_mime: data.image_mime || imageMimeFor(currentPath),
|
||||
size_bytes: data.size_bytes,
|
||||
@@ -958,6 +959,7 @@ export function renderImageViewer(area, data) {
|
||||
}, { passive: false });
|
||||
|
||||
(async () => {
|
||||
if (!vault) return; // #110 — pièce jointe de chat : pas de listing voisin
|
||||
try {
|
||||
const dir = currentPath.includes("/") ? currentPath.slice(0, currentPath.lastIndexOf("/")) : "";
|
||||
siblings = await listImageSiblings(vault, dir);
|
||||
@@ -1022,7 +1024,8 @@ export function renderImageViewer(area, data) {
|
||||
nextBtn.addEventListener("click", () => go(1));
|
||||
originalBtn.addEventListener("click", () => window.open(imgUrl, "_blank"));
|
||||
downloadBtn.addEventListener("click", () => {
|
||||
const dlUrl = `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(currentPath)}`;
|
||||
// #110 — une pièce jointe de chat n'est pas dans un vault : URL directe.
|
||||
const dlUrl = data.url || `/api/file/${encodeURIComponent(vault)}/download?path=${encodeURIComponent(currentPath)}`;
|
||||
window.open(dlUrl, "_blank");
|
||||
});
|
||||
metaBtn.setAttribute("aria-pressed", _imageViewerState.meta ? "true" : "false");
|
||||
@@ -1473,6 +1476,11 @@ const GRID_CHUNK_ROWS = 100; // rows materialised per extension step
|
||||
<span class="xlsx-toolbar-actions">${actionsHtml}</span>
|
||||
</div>
|
||||
<div class="xlsx-sheetbar">
|
||||
<button class="btn-action xlsx-tools-toggle" id="xlsx-tools-toggle" type="button"
|
||||
aria-expanded="false" title="${escapeHtml(t("xlsx.tools_toggle"))}"
|
||||
aria-label="${escapeHtml(t("xlsx.tools_toggle"))}">
|
||||
<i data-lucide="menu" style="width:18px;height:18px" aria-hidden="true"></i>
|
||||
</button>
|
||||
${tabs}
|
||||
<span class="xlsx-sheetbar-end">${fileHtml}</span>
|
||||
</div>
|
||||
@@ -1526,6 +1534,20 @@ const GRID_CHUNK_ROWS = 100; // rows materialised per extension step
|
||||
// A re-render invalidates any open context menu (it targets the old DOM).
|
||||
closeContextMenu();
|
||||
|
||||
// #189 — mobile : la barre de menus + le ruban sont repliés par défaut
|
||||
// (cf. style.css ≤768px). Ce bouton, seul dans la barre des feuilles,
|
||||
// déplie/replie le tout sans jamais masquer la barre de formule.
|
||||
const toolsToggle = area.querySelector("#xlsx-tools-toggle");
|
||||
const toolsBar = area.querySelector(".xlsx-toolbar");
|
||||
if (toolsToggle && toolsBar) {
|
||||
toolsToggle.addEventListener("click", () => {
|
||||
const open = toolsBar.classList.toggle("xlsx-tools-open");
|
||||
toolsToggle.setAttribute("aria-expanded", String(open));
|
||||
toolsToggle.classList.toggle("active", open);
|
||||
safeCreateIcons();
|
||||
});
|
||||
}
|
||||
|
||||
// ── #153 A17 — dashboard panel: named ranges, objects, per-sheet KPI ──
|
||||
// Fetched lazily on first open of the dashboard tab; a workbook without
|
||||
// anything notable hides the button entirely (no empty panel).
|
||||
@@ -6877,6 +6899,8 @@ export function renderFile(data) {
|
||||
// state (CodeMirror view, Forge iframe, Yjs session) is torn down cleanly
|
||||
// instead of being wiped mid-session by a tab switch / sidebar click.
|
||||
if (isInlineEditorActive()) detachInlineEditor();
|
||||
// #190 A — an open chat panel follows the document being rendered.
|
||||
followFileChat(data.vault, data.path, data.title);
|
||||
// #108 — release the image viewer's document-level shortcuts before swapping
|
||||
// the content area (otherwise they leak on every re-render).
|
||||
if (_imageViewerCleanup) { _imageViewerCleanup(); _imageViewerCleanup = null; }
|
||||
@@ -7203,6 +7227,12 @@ export function renderFile(data) {
|
||||
|
||||
// Bookmark button — check if already bookmarked
|
||||
const bookmarkBtn = el("button", { class: "btn-action btn-bookmark", title: t("viewer.bookmark_toggle") }, [icon("bookmark-plus", 14), document.createTextNode("Bookmark")]);
|
||||
|
||||
// #169 — Chat intégré par fichier (panneau latéral)
|
||||
const chatBtn = el("button", { class: "btn-action btn-chat", title: t("chat.button_title") }, [icon("message-square", 14), document.createTextNode(t("chat.button"))]);
|
||||
chatBtn.addEventListener("click", () => {
|
||||
toggleFileChat(data.vault, data.path, data.title);
|
||||
});
|
||||
// Check bookmark status and color the button
|
||||
(async () => {
|
||||
try {
|
||||
@@ -7295,7 +7325,7 @@ export function renderFile(data) {
|
||||
// spacer de groupe vide retire.
|
||||
const navBtns = [];
|
||||
if (data.is_markdown) navBtns.push(tocBtn);
|
||||
navBtns.push(openNewWindowBtn, bookmarkBtn);
|
||||
navBtns.push(openNewWindowBtn, bookmarkBtn, chatBtn);
|
||||
const editBtns = [];
|
||||
if (!data.unsupported) editBtns.push(editBtn);
|
||||
editBtns.push(sourceBtn);
|
||||
@@ -7549,6 +7579,10 @@ export function attachTreeItemLongPress(itemEl, getMenuData) {
|
||||
}
|
||||
|
||||
export function getVaultIcon(vaultName, size = 16) {
|
||||
// Dossier personnel (#194) : icône maison, même style lucide que le reste.
|
||||
if (typeof vaultName === "string" && vaultName.startsWith("home-")) {
|
||||
return icon("home", size);
|
||||
}
|
||||
const v = state.allVaults.find((val) => val.name === vaultName);
|
||||
const type = v ? v.type : "VAULT";
|
||||
|
||||
|
||||
@@ -319,6 +319,30 @@
|
||||
"backups.restore_confirm": "to the selected backup version?\nThe current version will be saved.",
|
||||
"backups.title": "Backups",
|
||||
"button.cancel": "Cancel",
|
||||
"chat.attach_error": "Attachment upload failed",
|
||||
"chat.channel_general": "General",
|
||||
"chat.delete": "Delete this message",
|
||||
"chat.delete_error": "Delete failed",
|
||||
"chat.deleted_message": "This message has been deleted",
|
||||
"chat.undo": "Undo",
|
||||
"chat.download_image": "Download image",
|
||||
"chat.view_image": "View full size",
|
||||
"chat.attach_remove": "Remove attachment",
|
||||
"chat.button": "Chat",
|
||||
"chat.button_title": "Discuss this file",
|
||||
"chat.close": "Close the discussion panel",
|
||||
"chat.empty": "No messages yet. Start the conversation!",
|
||||
"chat.load_error": "Could not load the discussion",
|
||||
"chat.new_message": "New message from {user}",
|
||||
"chat.placeholder": "Write a message…",
|
||||
"chat.placeholder_dm": "Private message to {user}…",
|
||||
"chat.read_by": "{user} read your message",
|
||||
"chat.send": "Send",
|
||||
"chat.send_error": "Failed to send the message",
|
||||
"chat.tab": "General chat",
|
||||
"chat.status_read": "Read",
|
||||
"chat.status_sent": "Sent",
|
||||
"chat.title": "Discussion",
|
||||
"common.actions": "Actions",
|
||||
"common.all": "All",
|
||||
"common.back": "Back",
|
||||
@@ -580,6 +604,11 @@
|
||||
"config.server_config": "Server config",
|
||||
"config.settings_saved": "✓ Settings saved",
|
||||
"config.share_revoked": "Share revoked",
|
||||
"config.share_directed": "Share with users (authentication required)",
|
||||
"config.share_public": "Public link (no authentication)",
|
||||
"config.share_recipients_ph": "Recipients (usernames, comma-separated)",
|
||||
"config.share_need_recipient": "Provide at least one recipient",
|
||||
"config.share_shared_with": "Shared with",
|
||||
"config.shares": "Public shares",
|
||||
"config.shares_desc": "Manage public share links for your documents.",
|
||||
"config.shares_empty": "No active shares",
|
||||
@@ -677,6 +706,7 @@
|
||||
"dashboard.recent": "Recent",
|
||||
"dashboard.recent_hint": "Open a file to see it appear here",
|
||||
"dashboard.shared_hint": "Share a document to see it appear here",
|
||||
"dashboard.shared_by": "Shared by {user}",
|
||||
"dashboard.shares": "Shares",
|
||||
"dashboard.show": "Show",
|
||||
"dashboard.size_bytes": " B",
|
||||
@@ -1691,12 +1721,14 @@
|
||||
"shortcut.search_in_doc": "Search in document",
|
||||
"shortcut.tip_preview": "Hover a document with **Ctrl** for a quick preview. Click **Forge** in the toolbar for the advanced editor.",
|
||||
"sidebar.all_vaults": "All vaults",
|
||||
"sidebar.shared_folder": "Shared",
|
||||
"sidebar.case_sensitive": "Case sensitive",
|
||||
"sidebar.clear_filter": "Clear",
|
||||
"sidebar.expand_collapse": "Expand/Collapse",
|
||||
"sidebar.file_icons": "Icons",
|
||||
"sidebar.filter_instant": "Instant search",
|
||||
"sidebar.filter_ai": "Filter AI history...",
|
||||
"sidebar.filter_chat": "Search the chat...",
|
||||
"sidebar.filter_recent": "Filter recent files...",
|
||||
"sidebar.filter_saved": "Filter saved searches...",
|
||||
"sidebar.filter_path": "Path filters",
|
||||
@@ -2050,6 +2082,7 @@
|
||||
|
||||
"xlsx.mbar_label": "Spreadsheet menus",
|
||||
"xlsx.cmdbar_label": "Spreadsheet command bar",
|
||||
"xlsx.tools_toggle": "Show / hide toolbars",
|
||||
"xlsx.mbar_file": "File",
|
||||
"xlsx.mbar_edit": "Edit",
|
||||
"xlsx.mbar_view": "View",
|
||||
|
||||
@@ -319,6 +319,30 @@
|
||||
"backups.restore_confirm": "a la version du backup selectionne ?\nLa version actuelle sera sauvegardee.",
|
||||
"backups.title": "Backups",
|
||||
"button.cancel": "Annuler",
|
||||
"chat.attach_error": "Échec de l'envoi de la pièce jointe",
|
||||
"chat.channel_general": "Général",
|
||||
"chat.delete": "Supprimer ce message",
|
||||
"chat.delete_error": "Échec de la suppression",
|
||||
"chat.deleted_message": "Ce message a été supprimé",
|
||||
"chat.undo": "Annuler",
|
||||
"chat.download_image": "Télécharger l'image",
|
||||
"chat.view_image": "Afficher en grand",
|
||||
"chat.attach_remove": "Retirer la pièce jointe",
|
||||
"chat.button": "Chat",
|
||||
"chat.button_title": "Discuter de ce fichier",
|
||||
"chat.close": "Fermer le panneau de discussion",
|
||||
"chat.empty": "Aucun message pour l'instant. Lancez la discussion !",
|
||||
"chat.load_error": "Impossible de charger la discussion",
|
||||
"chat.new_message": "Nouveau message de {user}",
|
||||
"chat.placeholder": "Écrire un message…",
|
||||
"chat.placeholder_dm": "Message privé à {user}…",
|
||||
"chat.read_by": "{user} a lu votre message",
|
||||
"chat.send": "Envoyer",
|
||||
"chat.send_error": "Échec de l'envoi du message",
|
||||
"chat.tab": "Chat général",
|
||||
"chat.status_read": "Lu",
|
||||
"chat.status_sent": "Envoyé",
|
||||
"chat.title": "Discussion",
|
||||
"common.actions": "Actions",
|
||||
"common.all": "Tous",
|
||||
"common.back": "Retour",
|
||||
@@ -580,6 +604,11 @@
|
||||
"config.server_config": "Configuration serveur",
|
||||
"config.settings_saved": "✓ Paramètres sauvegardés",
|
||||
"config.share_revoked": "Partage révoqué",
|
||||
"config.share_directed": "Partager avec des utilisateurs (authentification requise)",
|
||||
"config.share_public": "Lien public (sans authentification)",
|
||||
"config.share_recipients_ph": "Destinataires (noms d'utilisateur, séparés par des virgules)",
|
||||
"config.share_need_recipient": "Indiquez au moins un destinataire",
|
||||
"config.share_shared_with": "Partagé avec",
|
||||
"config.shares": "Partages publics",
|
||||
"config.shares_desc": "Gérez les liens de partage public de vos documents.",
|
||||
"config.shares_empty": "Aucun partage actif",
|
||||
@@ -677,6 +706,7 @@
|
||||
"dashboard.recent": "Récents",
|
||||
"dashboard.recent_hint": "Ouvrez un fichier pour le voir apparaître ici",
|
||||
"dashboard.shared_hint": "Partagez un document pour le voir apparaître ici",
|
||||
"dashboard.shared_by": "Partagé par {user}",
|
||||
"dashboard.shares": "Partages",
|
||||
"dashboard.show": "Afficher",
|
||||
"dashboard.size_bytes": " o",
|
||||
@@ -1691,12 +1721,14 @@
|
||||
"shortcut.search_in_doc": "Rechercher dans le document",
|
||||
"shortcut.tip_preview": "Survolez un document avec **Ctrl** pour un aperçu rapide. Cliquez sur **Forge** dans la barre d'outils pour l'éditeur avancé.",
|
||||
"sidebar.all_vaults": "Tous les vaults",
|
||||
"sidebar.shared_folder": "Partage",
|
||||
"sidebar.case_sensitive": "Respecter la casse",
|
||||
"sidebar.clear_filter": "Effacer",
|
||||
"sidebar.expand_collapse": "Développer/Réduire",
|
||||
"sidebar.file_icons": "Icônes",
|
||||
"sidebar.filter_instant": "Recherche instantanée",
|
||||
"sidebar.filter_ai": "Filtrer l'historique IA...",
|
||||
"sidebar.filter_chat": "Rechercher dans le chat...",
|
||||
"sidebar.filter_recent": "Filtrer les fichiers récents...",
|
||||
"sidebar.filter_saved": "Filtrer les recherches sauvegardées...",
|
||||
"sidebar.filter_path": "Filtres de chemin",
|
||||
@@ -2050,6 +2082,7 @@
|
||||
|
||||
"xlsx.mbar_label": "Menus du tableur",
|
||||
"xlsx.cmdbar_label": "Barre de commandes du tableur",
|
||||
"xlsx.tools_toggle": "Afficher / masquer les barres d'outils",
|
||||
"xlsx.mbar_file": "Fichier",
|
||||
"xlsx.mbar_edit": "Modifier",
|
||||
"xlsx.mbar_view": "Affichage",
|
||||
|
||||
@@ -12172,6 +12172,85 @@ body.desktop-mode .editor-container {
|
||||
max-width: 92vw;
|
||||
}
|
||||
}
|
||||
/* #189 — mise en page mobile : par défaut seuls restent la barre des
|
||||
feuilles (avec le basculeur) et la barre de formule. La barre de menus et
|
||||
le ruban sont repliés derrière #xlsx-tools-toggle ; le groupe Recherche,
|
||||
logé dans la barre de formule, suit le même état pour ne pas écraser le
|
||||
champ de saisie. */
|
||||
.xlsx-tools-toggle {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.xlsx-tools-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.xlsx-menubar,
|
||||
.xlsx-cmdbar,
|
||||
.xlsx-find-group {
|
||||
display: none;
|
||||
}
|
||||
.xlsx-toolbar.xlsx-tools-open .xlsx-menubar {
|
||||
display: flex;
|
||||
}
|
||||
.xlsx-toolbar.xlsx-tools-open .xlsx-cmdbar {
|
||||
display: flex;
|
||||
}
|
||||
.xlsx-toolbar.xlsx-tools-open .xlsx-find-group {
|
||||
display: flex;
|
||||
}
|
||||
/* La barre de formule ne déborde jamais : elle passe en 2 lignes quand le
|
||||
groupe Recherche réapparaît avec les outils ouverts. */
|
||||
.xlsx-toolbar.xlsx-tools-open .xlsx-formula-bar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
/* Une seule ligne de feuilles : les onglets défilent horizontalement au
|
||||
lieu d'enrouler et de grignoter la hauteur de la grille. */
|
||||
.xlsx-sheetbar {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.xlsx-sheetbar .xlsx-tabs {
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.xlsx-sheetbar .xlsx-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
/* Cibles tactiles : le ruban replié n'existe que derrière le basculeur,
|
||||
mais une fois ouvert chaque commande reste atteignable au doigt. */
|
||||
.xlsx-toolbar .btn-action {
|
||||
min-width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
/* À 44px un groupe (police, nombre…) dépasse la largeur d'un téléphone :
|
||||
il doit pouvoir se réduire ET s'enrouler plutôt que déborder. */
|
||||
.xlsx-cmdbar .xlsx-cmd-group {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.xlsx-menubtn {
|
||||
padding: 12px 14px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.xlsx-tab,
|
||||
.xlsx-tab-add {
|
||||
padding: 10px 14px;
|
||||
min-height: 44px;
|
||||
}
|
||||
/* 16px minimum : en dessous iOS zoome automatiquement la page dès que le
|
||||
champ reçoit le focus, ce qui fait « sauter » l'interface. */
|
||||
.xlsx-active-cell,
|
||||
.xlsx-formula-input,
|
||||
.xlsx-find-input {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
.xlsx-context-item {
|
||||
text-align: left;
|
||||
border: none;
|
||||
@@ -13669,3 +13748,544 @@ body.reading-mode .file-actions {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
#169 — Chat intégré par fichier (panneau latéral)
|
||||
--------------------------------------------------------------------------- */
|
||||
.file-chat-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 340px;
|
||||
max-width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-primary, #0d1117);
|
||||
border-left: 1px solid var(--border, #21262d);
|
||||
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.35);
|
||||
z-index: 1500;
|
||||
}
|
||||
.file-chat-panel.hidden {
|
||||
display: none;
|
||||
}
|
||||
.file-chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border, #21262d);
|
||||
}
|
||||
.file-chat-header h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e6edf3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.file-chat-close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted, #8b949e);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-chat-close:hover {
|
||||
background: var(--bg-hover, rgba(128, 128, 128, 0.18));
|
||||
color: var(--text-primary, #e6edf3);
|
||||
}
|
||||
.file-chat-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.file-chat-empty {
|
||||
color: var(--text-muted, #8b949e);
|
||||
font-size: 0.85rem;
|
||||
text-align: center;
|
||||
padding: 24px 8px;
|
||||
}
|
||||
.file-chat-msg {
|
||||
background: var(--bg-secondary, #161b22);
|
||||
border: 1px solid var(--border, #21262d);
|
||||
border-radius: 10px;
|
||||
padding: 8px 10px;
|
||||
max-width: 92%;
|
||||
}
|
||||
.file-chat-msg.mine {
|
||||
align-self: flex-end;
|
||||
background: var(--bg-hover, rgba(88, 166, 255, 0.12));
|
||||
border-color: var(--accent, #58a6ff);
|
||||
}
|
||||
.file-chat-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted, #8b949e);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.file-chat-user {
|
||||
font-weight: 600;
|
||||
color: var(--accent, #58a6ff);
|
||||
}
|
||||
/* #192 — accusé de réception sur mes messages : ✓ envoyé, ✓✓ lu */
|
||||
.file-chat-status {
|
||||
color: var(--text-muted, #8b949e);
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
.file-chat-status.read {
|
||||
color: var(--accent, #58a6ff);
|
||||
}
|
||||
.file-chat-body {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-primary, #e6edf3);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
/* #193 — un post peut être rendu comme un document markdown (mistune côté
|
||||
serveur + hljs côté client) : on annule le `pre-wrap` hérité (sinon les
|
||||
retours à la ligne du HTML source deviennent des lignes vides) et on
|
||||
compacte la typographie `.md-content` pour une bulle de chat. */
|
||||
.file-chat-body.file-chat-md {
|
||||
white-space: normal;
|
||||
word-break: normal;
|
||||
}
|
||||
.file-chat-body .md-content > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.file-chat-body .md-content > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.file-chat-body .md-content h1,
|
||||
.file-chat-body .md-content h2,
|
||||
.file-chat-body .md-content h3,
|
||||
.file-chat-body .md-content h4,
|
||||
.file-chat-body .md-content h5,
|
||||
.file-chat-body .md-content h6 {
|
||||
font-size: 1rem;
|
||||
margin: 0.7em 0 0.35em;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.file-chat-body .md-content p {
|
||||
margin: 0.4em 0;
|
||||
}
|
||||
.file-chat-body .md-content ul,
|
||||
.file-chat-body .md-content ol {
|
||||
padding-left: 1.3em;
|
||||
margin: 0.4em 0;
|
||||
}
|
||||
.file-chat-body .md-content pre {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.file-chat-body .md-content pre code {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.file-chat-body .md-content :not(pre) > code {
|
||||
font-size: 0.82rem;
|
||||
padding: 1px 4px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-hover, rgba(128, 128, 128, 0.15));
|
||||
}
|
||||
.file-chat-body .md-content table {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.file-chat-body .md-content img {
|
||||
max-width: 100%;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.file-chat-body .md-content .wikilink,
|
||||
.file-chat-body .md-content .wikilink-anchor {
|
||||
color: var(--accent, #58a6ff);
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-chat-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--border, #21262d);
|
||||
}
|
||||
.file-chat-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
font-size: 16px; /* anti-zoom iOS */
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
border: 1px solid var(--border, #21262d);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary, #161b22);
|
||||
color: var(--text-primary, #e6edf3);
|
||||
}
|
||||
/* #192 — textarea auto-agrandie (hauteur pilotée en JS, plafond ci-dessous) */
|
||||
textarea.file-chat-input {
|
||||
display: block;
|
||||
resize: none;
|
||||
overflow-y: auto;
|
||||
max-height: 160px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.file-chat-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent, #58a6ff);
|
||||
}
|
||||
.file-chat-send {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
background: var(--accent, #58a6ff);
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-chat-send:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.file-chat-panel {
|
||||
width: 100vw;
|
||||
border-left: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
#191 — Chat : canaux, suppression, bouton compact, link preview
|
||||
--------------------------------------------------------------------------- */
|
||||
.chat-channels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 8px 10px 0;
|
||||
}
|
||||
.chat-channel {
|
||||
border: 1px solid var(--border, #21262d);
|
||||
border-radius: 999px;
|
||||
background: var(--bg-secondary, #161b22);
|
||||
color: var(--text-muted, #8b949e);
|
||||
font-size: 0.78rem;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chat-channel:hover {
|
||||
color: var(--text-primary, #e6edf3);
|
||||
border-color: var(--accent, #58a6ff);
|
||||
}
|
||||
.chat-channel.active {
|
||||
background: var(--accent, #58a6ff);
|
||||
border-color: var(--accent, #58a6ff);
|
||||
color: #fff;
|
||||
}
|
||||
/* Bouton d'envoi compact : une icône, la saisie garde la largeur */
|
||||
.file-chat-send-icon {
|
||||
padding: 6px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
/* Suppression d'un post */
|
||||
.file-chat-del {
|
||||
border: none;
|
||||
background: var(--bg-hover, #1f2430);
|
||||
cursor: pointer;
|
||||
font-size: 0.72rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.15s, background 0.15s;
|
||||
color: var(--danger, #ff7b72);
|
||||
line-height: 1;
|
||||
margin-left: auto; /* #110 — l'action se détache à droite de la ligne */
|
||||
}
|
||||
.file-chat-msg:hover .file-chat-del {
|
||||
opacity: 1;
|
||||
}
|
||||
.file-chat-del:hover {
|
||||
opacity: 1 !important;
|
||||
background: var(--danger-bg, #3d1a18);
|
||||
}
|
||||
/* Link preview (#191) */
|
||||
.file-chat-preview {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border, #21262d);
|
||||
border-left: 3px solid var(--accent, #58a6ff);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary, #161b22);
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
max-width: 100%;
|
||||
}
|
||||
.file-chat-preview:hover {
|
||||
border-color: var(--accent, #58a6ff);
|
||||
}
|
||||
.file-chat-preview-img {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-hover, rgba(128, 128, 128, 0.12));
|
||||
}
|
||||
.file-chat-preview-meta {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.file-chat-preview-site {
|
||||
font-size: 0.68rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--accent, #58a6ff);
|
||||
}
|
||||
.file-chat-preview-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #e6edf3);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.file-chat-preview-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8b949e);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
#190 — Chat : en-tête ciblé, onglet sidebar, pièces jointes, badge
|
||||
--------------------------------------------------------------------------- */
|
||||
.file-chat-titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.file-chat-target {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted, #8b949e);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sidebar-tab {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* #110 — point de couleur sur l'icône chat tant qu'il reste des non-lus */
|
||||
.sidebar-tab#sidebar-tab-chat.chat-has-unread::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
left: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--danger, #e5534b);
|
||||
animation: chat-dot-pulse 1.8s ease-out infinite;
|
||||
}
|
||||
|
||||
@keyframes chat-dot-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(229, 83, 75, 0.5); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(229, 83, 75, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(229, 83, 75, 0); }
|
||||
}
|
||||
.chat-unread-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
padding: 0 3px;
|
||||
border-radius: 8px;
|
||||
background: #e5534b;
|
||||
color: #fff;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
line-height: 15px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
.sidebar-chat-panel {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.sidebar-chat-panel.active {
|
||||
display: flex;
|
||||
}
|
||||
.sidebar-chat-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
.sidebar-chat-pending {
|
||||
padding: 0 10px;
|
||||
}
|
||||
.sidebar-chat-form {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-top: 1px solid var(--border, #21262d);
|
||||
}
|
||||
.file-chat-attach {
|
||||
border: 1px solid var(--border, #21262d);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-secondary, #161b22);
|
||||
color: var(--text-muted, #8b949e);
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-chat-attach:hover {
|
||||
color: var(--accent, #58a6ff);
|
||||
border-color: var(--accent, #58a6ff);
|
||||
}
|
||||
.file-chat-pending {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: 4px 0;
|
||||
padding: 3px 8px;
|
||||
border: 1px dashed var(--border, #21262d);
|
||||
border-radius: 8px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #8b949e);
|
||||
}
|
||||
.file-chat-pending button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-chat-image-container {
|
||||
max-width: 100%;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.file-chat-image-container .file-chat-image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 220px;
|
||||
border-radius: 8px;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
/* Boutons discrets : icônes seules, sous l'image, alignés à droite */
|
||||
.file-chat-image-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.file-chat-image-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-muted, #8b949e);
|
||||
opacity: 0.55;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
.file-chat-image-btn:hover {
|
||||
opacity: 1;
|
||||
color: var(--accent, #58a6ff);
|
||||
background: var(--bg-hover, rgba(128, 128, 128, 0.12));
|
||||
}
|
||||
.file-chat-image-btn i {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.file-chat-video {
|
||||
max-width: 100%;
|
||||
max-height: 240px;
|
||||
border-radius: 8px;
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
}.file-chat-link {
|
||||
color: var(--accent, #58a6ff);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* #110 — message supprimé avec bouton Undo */
|
||||
.file-chat-deleted {
|
||||
opacity: 0.7;
|
||||
background: var(--bg-hover, #1f2430);
|
||||
border-left: 2px solid var(--text-muted, #484f58);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.file-chat-deleted-text {
|
||||
font-style: italic;
|
||||
color: var(--text-muted, #8b949e);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.file-chat-undo {
|
||||
background: var(--accent, #58a6ff);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
margin-left: 6px;
|
||||
transition: background 0.15s;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.file-chat-undo:hover {
|
||||
background: var(--accent-hover, #4494e6);
|
||||
}
|
||||
|
||||
.file-chat-undo i {
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.55.0",
|
||||
"version": "2.67.2",
|
||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||
"main": "patch.js",
|
||||
"directories": {
|
||||
|
||||
@@ -72,6 +72,14 @@ async function openXlsx(page, file) {
|
||||
}
|
||||
await treeItem.dblclick({ timeout: 5000 });
|
||||
await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 });
|
||||
// #189 — sous 768px la barre de menus et le ruban sont repliés par défaut.
|
||||
// Les tests « desktop » qui les manipulent les déplient d'abord ; le test
|
||||
// mobile dédié ouvre lui-même la feuille pour vérifier l'état replié.
|
||||
const toggle = page.locator('#xlsx-tools-toggle');
|
||||
if (await toggle.isVisible().catch(() => false)) {
|
||||
await toggle.click();
|
||||
await expect(page.locator('#content-area .xlsx-menubar')).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#153)', () => {
|
||||
@@ -513,12 +521,27 @@ test.describe('Excel viewer — menus à icônes et fermeture au focus (#179)',
|
||||
await expect(page.locator('#xlsx-minibar')).toBeVisible();
|
||||
});
|
||||
|
||||
test('mobile : ruban sans débordement et cibles tactiles 44px', async ({ page, viewport }) => {
|
||||
test('mobile : barres repliées par défaut, ruban sans débordement et cibles 44px (#189)', async ({ page, viewport }) => {
|
||||
test.skip((viewport?.width ?? 9999) > 768, 'Mobile viewport required');
|
||||
await login(page);
|
||||
await page.evaluate(() => window.TabManager.open('TestVault', FIXTURE));
|
||||
await page.evaluate((f) => window.TabManager.open('TestVault', f), FIXTURE);
|
||||
await expect(page.locator('#content-area .xlsx-table')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// #189 — par défaut la barre de menus et le ruban sont repliés : la grille
|
||||
// garde la hauteur d'écran. La barre de formule, elle, reste visible.
|
||||
await expect(page.locator('#content-area .xlsx-menubar')).toBeHidden();
|
||||
await expect(page.locator('#content-area .xlsx-cmdbar')).toBeHidden();
|
||||
await expect(page.locator('#content-area .xlsx-formula-bar')).toBeVisible();
|
||||
await expect(page.locator('#content-area .xlsx-sheetbar')).toBeVisible();
|
||||
const toggle = page.locator('#xlsx-tools-toggle');
|
||||
await expect(toggle).toBeVisible();
|
||||
|
||||
// Un clic déplie menus + ruban (état annoncé par aria-expanded).
|
||||
await toggle.click();
|
||||
await expect(page.locator('#content-area .xlsx-menubar')).toBeVisible();
|
||||
await expect(page.locator('#content-area .xlsx-cmdbar')).toBeVisible();
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// Le ruban s'enroule au lieu de déborder horizontalement.
|
||||
const overflow = await page.evaluate(() => {
|
||||
const bar = document.querySelector('#content-area .xlsx-cmdbar');
|
||||
@@ -526,6 +549,17 @@ test.describe('Excel viewer — menus à icônes et fermeture au focus (#179)',
|
||||
});
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
|
||||
// Cibles tactiles : le basculeur et chaque commande du ruban font 44px.
|
||||
const toggleBox = await toggle.boundingBox();
|
||||
expect(toggleBox.height).toBeGreaterThanOrEqual(44);
|
||||
const ribbonBtnBox = await page.locator('#xs-b').boundingBox();
|
||||
expect(ribbonBtnBox.height).toBeGreaterThanOrEqual(44);
|
||||
|
||||
// Repli : la grille retrouve toute sa hauteur.
|
||||
await toggle.click();
|
||||
await expect(page.locator('#content-area .xlsx-menubar')).toBeHidden();
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
// Menu contextuel (clic droit émulé) : tient dans le viewport, entrées ≥ 44px.
|
||||
await page.locator('#content-area td[data-cell="A1"]').click({ button: 'right' });
|
||||
const menu = page.locator('.xlsx-context-menu');
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ObsiGate — JSDOM tests for the per-file chat panel (#169):
|
||||
* `frontend/js/filechat.js`.
|
||||
*
|
||||
* Covers:
|
||||
* - `renderChatMessages()`: chronological render + empty state + escaping
|
||||
* - `onChatMessage()`: live append when open on the same file, dedupe by id,
|
||||
* no duplicate append when the panel shows another file
|
||||
*
|
||||
* Usage: node tests/frontend/filechat.test.mjs
|
||||
*/
|
||||
|
||||
import { strict as assert } from "node:assert";
|
||||
import { JSDOM } from "jsdom";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const dom = new JSDOM(
|
||||
`<!DOCTYPE html><html><body><div id="toast-container"></div>
|
||||
<div class="sidebar-tab-panel active" id="sidebar-panel-chat">
|
||||
<div id="sidebar-panel-chat-list" class="file-chat-list"></div>
|
||||
<div id="sidebar-panel-chat-pending"></div>
|
||||
<form id="sidebar-panel-chat-form"><textarea id="sidebar-panel-chat-input"></textarea></form>
|
||||
</div>
|
||||
<button id="sidebar-tab-chat"></button>
|
||||
</body></html>`,
|
||||
{ url: "http://localhost/", pretendToBeVisual: true },
|
||||
);
|
||||
|
||||
const w = dom.window;
|
||||
globalThis.window = w;
|
||||
globalThis.document = w.document;
|
||||
globalThis.HTMLElement = w.HTMLElement;
|
||||
globalThis.CSS = w.CSS || { escape: (s) => s };
|
||||
globalThis.localStorage = w.localStorage;
|
||||
globalThis.requestAnimationFrame = w.requestAnimationFrame || ((cb) => setTimeout(cb, 16));
|
||||
globalThis.cancelAnimationFrame = w.cancelAnimationFrame || clearTimeout;
|
||||
globalThis.sessionStorage = w.sessionStorage;
|
||||
const JS_DIR = path.resolve(__dirname, "..", "..", "frontend", "js");
|
||||
const LOCALES_DIR = path.resolve(__dirname, "..", "..", "frontend", "locales");
|
||||
|
||||
// Route fetch: locales from disk, auth 401, API chat endpoints from a stub map.
|
||||
const apiStubs = new Map();
|
||||
const fetchCalls = []; // #110 — journal des appels {url, method}
|
||||
globalThis.fetch = (url, opts = {}) => {
|
||||
const u = String(url);
|
||||
fetchCalls.push({ url: u, method: (opts.method || "GET").toUpperCase() });
|
||||
if (u.includes("/static/locales/")) {
|
||||
const name = u.split("/").pop();
|
||||
try {
|
||||
const body = readFileSync(path.join(LOCALES_DIR, name), "utf-8");
|
||||
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(JSON.parse(body)) });
|
||||
} catch (_) {
|
||||
return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) });
|
||||
}
|
||||
}
|
||||
if (u.includes("/api/auth/me")) {
|
||||
return Promise.resolve({ ok: false, status: 401, json: () => Promise.resolve({}) });
|
||||
}
|
||||
if (apiStubs.has(u)) {
|
||||
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(apiStubs.get(u)) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ messages: [] }) });
|
||||
};
|
||||
|
||||
const { initI18n } = await import(pathToFileURL(path.join(JS_DIR, "i18n.js")).href);
|
||||
await initI18n();
|
||||
|
||||
// #191 — the delete right needs a logged-in identity
|
||||
const { AuthManager } = await import(pathToFileURL(path.join(JS_DIR, "auth.js")).href);
|
||||
AuthManager.getUser = () => ({ username: "bruno", role: "user" });
|
||||
|
||||
const mod = await import("../../frontend/js/filechat.js");
|
||||
const {
|
||||
renderChatMessages, onChatMessage, openFileChat, closeFileChat, toggleFileChat,
|
||||
followFileChat, openSidebarChat, filterChatMessages, refreshUnreadBadge,
|
||||
onChatDeleted, onChatRead, _deleteMessage,
|
||||
} = mod;
|
||||
|
||||
let pass = 0;
|
||||
const cases = [];
|
||||
function test(name, fn) {
|
||||
cases.push([name, fn]);
|
||||
}
|
||||
|
||||
function makeList() {
|
||||
const list = document.createElement("div");
|
||||
list.className = "file-chat-list";
|
||||
return list;
|
||||
}
|
||||
|
||||
test("renderChatMessages: empty/missing shows the empty state", async () => {
|
||||
const list = makeList();
|
||||
renderChatMessages(list, [], "bruno");
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 0);
|
||||
assert.strictEqual(list.querySelector(".file-chat-empty") !== null, true);
|
||||
renderChatMessages(list, undefined, "bruno");
|
||||
assert.strictEqual(list.querySelector(".file-chat-empty") !== null, true);
|
||||
});
|
||||
|
||||
test("renderChatMessages: renders chronologically with mine class", async () => {
|
||||
const list = makeList();
|
||||
renderChatMessages(
|
||||
list,
|
||||
[
|
||||
{ id: "1", user: "alice", text: "bonjour", ts: 1700000000 },
|
||||
{ id: "2", user: "bruno", text: "salut !", ts: 1700000060 },
|
||||
],
|
||||
"bruno",
|
||||
);
|
||||
const msgs = list.querySelectorAll(".file-chat-msg");
|
||||
assert.strictEqual(msgs.length, 2);
|
||||
assert.strictEqual(msgs[0].querySelector(".file-chat-user").textContent, "alice");
|
||||
assert.strictEqual(msgs[0].classList.contains("mine"), false);
|
||||
assert.strictEqual(msgs[1].classList.contains("mine"), true);
|
||||
assert.strictEqual(msgs[1].querySelector(".file-chat-body").textContent, "salut !");
|
||||
});
|
||||
|
||||
test("renderChatMessages: message text is text (no HTML injection)", async () => {
|
||||
const list = makeList();
|
||||
renderChatMessages(list, [{ id: "1", user: "<img>", text: "<b>x</b>", ts: 1 }], "u");
|
||||
assert.strictEqual(list.querySelector(".file-chat-body").innerHTML, "<b>x</b>");
|
||||
assert.strictEqual(list.querySelectorAll("b").length, 0);
|
||||
});
|
||||
|
||||
test("onChatMessage: appends live message to the open panel", async () => {
|
||||
await openFileChat("V", "note.md");
|
||||
const panel = document.getElementById("file-chat-panel");
|
||||
assert.strictEqual(panel.classList.contains("hidden"), false);
|
||||
// Seed an empty state so we can assert its removal
|
||||
const list = panel.querySelector(".file-chat-list");
|
||||
renderChatMessages(list, [], "bruno");
|
||||
onChatMessage({ vault: "V", path: "note.md", message: { id: "m1", user: "alice", text: "oui", ts: 2 } });
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
|
||||
assert.strictEqual(list.querySelector(".file-chat-empty"), null);
|
||||
// Same id again → deduped
|
||||
onChatMessage({ vault: "V", path: "note.md", message: { id: "m1", user: "alice", text: "oui", ts: 2 } });
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
|
||||
closeFileChat();
|
||||
assert.strictEqual(document.getElementById("file-chat-panel").classList.contains("hidden"), true);
|
||||
});
|
||||
|
||||
test("onChatMessage: ignores events for another file (no cross-file append)", async () => {
|
||||
await openFileChat("V", "note.md");
|
||||
const panel = document.getElementById("file-chat-panel");
|
||||
const list = panel.querySelector(".file-chat-list");
|
||||
renderChatMessages(list, [], "bruno");
|
||||
onChatMessage({ vault: "V", path: "OTHER.md", message: { id: "x", user: "alice", text: "no", ts: 3 } });
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 0);
|
||||
closeFileChat();
|
||||
});
|
||||
|
||||
test("toggleFileChat: opens then closes on the same file", async () => {
|
||||
await toggleFileChat("V", "t.md");
|
||||
let panel = document.getElementById("file-chat-panel");
|
||||
assert.strictEqual(panel.classList.contains("hidden"), false);
|
||||
await toggleFileChat("V", "t.md");
|
||||
panel = document.getElementById("file-chat-panel");
|
||||
assert.strictEqual(panel.classList.contains("hidden"), true);
|
||||
closeFileChat();
|
||||
});
|
||||
|
||||
test("#190 A — followFileChat re-targets the open panel", async () => {
|
||||
await openFileChat("V", "one.md", "Doc Un");
|
||||
const panel = document.getElementById("file-chat-panel");
|
||||
assert.strictEqual(panel.classList.contains("hidden"), false);
|
||||
followFileChat("V", "two.md", "Doc Deux");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const target = panel.querySelector(".file-chat-target");
|
||||
assert.strictEqual(target.textContent, "Doc Deux");
|
||||
assert.strictEqual(target.title, "two.md");
|
||||
closeFileChat();
|
||||
followFileChat("V", "three.md", "Doc Trois");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
assert.strictEqual(panel.querySelector(".file-chat-target").textContent, "Doc Deux");
|
||||
});
|
||||
|
||||
test("#190 B — openSidebarChat renders the general history", async () => {
|
||||
await openSidebarChat();
|
||||
const list = document.getElementById("sidebar-panel-chat-list");
|
||||
assert.strictEqual(list.querySelector(".file-chat-empty") !== null, true);
|
||||
});
|
||||
|
||||
test("#190 B — onChatMessage routes __global__ to the sidebar list", async () => {
|
||||
const list = document.getElementById("sidebar-panel-chat-list");
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "g1", user: "alice", text: "salut tout le monde", ts: 5 } });
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
|
||||
assert.strictEqual(list.querySelector(".file-chat-body").textContent, "salut tout le monde");
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "g1", user: "alice", text: "salut tout le monde", ts: 5 } });
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
|
||||
});
|
||||
|
||||
test("#190 C — filterChatMessages narrows by text/user", async () => {
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "f1", user: "alice", text: "recette de pizza", ts: 10 } });
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "f2", user: "bob", text: "deploiement docker", ts: 11 } });
|
||||
const list = document.getElementById("sidebar-panel-chat-list");
|
||||
filterChatMessages("pizza");
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
|
||||
assert.strictEqual(list.querySelector(".file-chat-body").textContent, "recette de pizza");
|
||||
filterChatMessages("bob"); // la recherche couvre aussi l'auteur
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, 1);
|
||||
filterChatMessages("");
|
||||
assert.ok(list.querySelectorAll(".file-chat-msg").length >= 3);
|
||||
});
|
||||
|
||||
test("#190 — URL rendered as a link, surrounding text stays plain", async () => {
|
||||
const list = document.getElementById("sidebar-panel-chat-list");
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "u1", user: "alice", text: "voici https://example.com/doc et du texte", ts: 20 } });
|
||||
const msg = list.querySelector('[data-id="u1"]');
|
||||
const a = msg.querySelector("a.file-chat-link");
|
||||
assert.strictEqual(a.href, "https://example.com/doc");
|
||||
assert.strictEqual(a.target, "_blank");
|
||||
assert.ok(msg.textContent.includes("du texte"));
|
||||
});
|
||||
|
||||
test("#191 — preview card rendered with site/title/description", () => {
|
||||
const list = document.createElement("div");
|
||||
renderChatMessages(list, [{
|
||||
id: "p1", user: "alice", text: "voir https://example.com", ts: 40,
|
||||
preview: { url: "https://example.com", title: "Mon site", description: "Le desc", image: "", site: "example.com" },
|
||||
}], "bruno");
|
||||
const card = list.querySelector("a.file-chat-preview");
|
||||
assert.ok(card, "carte preview présente");
|
||||
assert.strictEqual(card.href, "https://example.com/");
|
||||
assert.strictEqual(card.target, "_blank");
|
||||
assert.strictEqual(card.querySelector(".file-chat-preview-title").textContent, "Mon site");
|
||||
assert.strictEqual(card.querySelector(".file-chat-preview-desc").textContent, "Le desc");
|
||||
assert.strictEqual(card.querySelector(".file-chat-preview-site").textContent, "example.com");
|
||||
renderChatMessages(list, [{ id: "p2", user: "alice", text: "https://x.test", ts: 41 }], "bruno");
|
||||
assert.strictEqual(list.querySelector("a.file-chat-preview"), null);
|
||||
});
|
||||
|
||||
test("#191 — delete button hidden without delUrl, shown for own post", () => {
|
||||
const list = document.createElement("div");
|
||||
renderChatMessages(list, [{ id: "d1", user: "bruno", text: "x", ts: 50 }], "bruno");
|
||||
assert.strictEqual(list.querySelector(".file-chat-del"), null);
|
||||
renderChatMessages(list, [{ id: "d2", user: "bruno", text: "x", ts: 51 }], "bruno", "/api/chat");
|
||||
assert.ok(list.querySelector(".file-chat-del"), "bouton suppression visible");
|
||||
renderChatMessages(list, [{ id: "d3", user: "alice", text: "x", ts: 52 }], "bruno", "/api/chat");
|
||||
assert.strictEqual(list.querySelector(".file-chat-del"), null);
|
||||
});
|
||||
|
||||
test("#191 — onChatDeleted removes the message from any list", async () => {
|
||||
await openSidebarChat();
|
||||
const list = document.getElementById("sidebar-panel-chat-list");
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "del1", user: "alice", text: "a bientot", ts: 60 } });
|
||||
assert.strictEqual(list.querySelector('[data-id="del1"]') !== null, true);
|
||||
onChatDeleted({ vault: "__global__", path: "general", id: "del1" });
|
||||
assert.strictEqual(list.querySelector('[data-id="del1"]'), null);
|
||||
});
|
||||
|
||||
test("#191 — unread counters accumulate then reset on open", async () => {
|
||||
localStorage.removeItem("obsigate-chat-unread");
|
||||
const panel = document.getElementById("sidebar-panel-chat");
|
||||
panel.classList.remove("active");
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "u-1", user: "bob", text: "1", ts: 70 } });
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "u-2", user: "bob", text: "2", ts: 71 } });
|
||||
let u = JSON.parse(localStorage.getItem("obsigate-chat-unread"));
|
||||
assert.strictEqual(u.general, 2);
|
||||
refreshUnreadBadge();
|
||||
assert.strictEqual(document.querySelector("#sidebar-tab-chat .chat-unread-badge").textContent, "2");
|
||||
panel.classList.add("active");
|
||||
await openSidebarChat();
|
||||
u = JSON.parse(localStorage.getItem("obsigate-chat-unread"));
|
||||
assert.strictEqual(u.general, 0);
|
||||
assert.strictEqual(document.querySelector("#sidebar-tab-chat .chat-unread-badge"), null);
|
||||
});
|
||||
|
||||
test("#191 — DM routed by pair path, isolated from general", async () => {
|
||||
const list = document.getElementById("sidebar-panel-chat-list");
|
||||
const before = list.querySelectorAll(".file-chat-msg").length;
|
||||
onChatMessage({ vault: "__dm__", path: "alice|bruno", message: { id: "dm-1", user: "alice", text: "prive", ts: 80 } });
|
||||
const u = JSON.parse(localStorage.getItem("obsigate-chat-unread"));
|
||||
assert.strictEqual(u["dm:alice"], 1);
|
||||
assert.strictEqual(list.querySelectorAll(".file-chat-msg").length, before);
|
||||
});
|
||||
|
||||
// --- #192 : accusé de réception + boîte de saisie auto-agrandie --------------
|
||||
|
||||
test("#192 — receipt shows ✓ then ✓✓ once another participant read it", () => {
|
||||
const list = document.createElement("div");
|
||||
renderChatMessages(list, [{ id: "r1", user: "bruno", text: "coucou", ts: 100 }], "bruno", null, {});
|
||||
let st = list.querySelector(".file-chat-status");
|
||||
assert.strictEqual(st.textContent, "✓");
|
||||
assert.strictEqual(st.classList.contains("read"), false);
|
||||
// MY OWN read timestamp must never count as "the peer read it"
|
||||
renderChatMessages(list, [{ id: "r2", user: "bruno", text: "coucou", ts: 100 }], "bruno", null, { bruno: 500 });
|
||||
assert.strictEqual(list.querySelector(".file-chat-status").textContent, "✓");
|
||||
renderChatMessages(list, [{ id: "r3", user: "bruno", text: "coucou", ts: 100 }], "bruno", null, { alice: 150 });
|
||||
st = list.querySelector(".file-chat-status");
|
||||
assert.strictEqual(st.textContent, "✓✓");
|
||||
assert.strictEqual(st.classList.contains("read"), true);
|
||||
// messages of others carry no receipt
|
||||
renderChatMessages(list, [{ id: "r4", user: "alice", text: "ok", ts: 100 }], "bruno", null, {});
|
||||
assert.strictEqual(list.querySelector(".file-chat-status"), null);
|
||||
});
|
||||
|
||||
test("#192 — onChatRead flips ✓ → ✓✓ live and notifies the sender", async () => {
|
||||
await openSidebarChat();
|
||||
const list = document.getElementById("sidebar-panel-chat-list");
|
||||
renderChatMessages(list, [{ id: "s1", user: "bruno", text: "msg", ts: 200 }], "bruno", "/api/chat", {});
|
||||
assert.strictEqual(list.querySelector(".file-chat-status").textContent, "✓");
|
||||
const container = document.getElementById("toast-container");
|
||||
const before = container.children.length;
|
||||
onChatRead({ vault: "__global__", path: "general", user: "alice", ts: 300, read: { bruno: 250, alice: 300 } });
|
||||
assert.strictEqual(list.querySelector(".file-chat-status").textContent, "✓✓");
|
||||
assert.ok(container.children.length > before, "notification de lecture affichée");
|
||||
// notre propre écho ne notifie pas
|
||||
const same = container.children.length;
|
||||
onChatRead({ vault: "__global__", path: "general", user: "bruno", ts: 310, read: { bruno: 310 } });
|
||||
assert.strictEqual(container.children.length, same);
|
||||
});
|
||||
|
||||
test("#192 — saisie en textarea auto-agrandie, Enter envoie", async () => {
|
||||
assert.strictEqual(document.getElementById("sidebar-panel-chat-input").tagName, "TEXTAREA");
|
||||
await openFileChat("V", "grow.md");
|
||||
const panel = document.getElementById("file-chat-panel");
|
||||
const input = panel.querySelector(".file-chat-input");
|
||||
assert.strictEqual(input.tagName, "TEXTAREA");
|
||||
assert.strictEqual(input.rows, 1);
|
||||
input.value = "ligne1\nligne2";
|
||||
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
||||
assert.ok(input.style.height, "hauteur pilotée en JS");
|
||||
let prevented = false;
|
||||
const ev = new w.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true });
|
||||
Object.defineProperty(ev, "preventDefault", { value: () => { prevented = true; } });
|
||||
input.value = ""; // rien à envoyer : on ne teste que le geste
|
||||
input.dispatchEvent(ev);
|
||||
assert.strictEqual(prevented, true, "Enter (sans Shift) est intercepté");
|
||||
const nl = new w.KeyboardEvent("keydown", { key: "Enter", shiftKey: true, bubbles: true, cancelable: true });
|
||||
let nlPrevented = false;
|
||||
Object.defineProperty(nl, "preventDefault", { value: () => { nlPrevented = true; } });
|
||||
input.dispatchEvent(nl);
|
||||
assert.strictEqual(nlPrevented, false, "Shift+Enter garde le retour à la ligne");
|
||||
closeFileChat();
|
||||
});
|
||||
|
||||
// --- #110 : boutons discrets, image en onglet, point non-lu, delete différé -
|
||||
|
||||
test("#110 — bouton supprimer visible (pas opacity:0)", () => {
|
||||
const list = document.createElement("div");
|
||||
renderChatMessages(list, [{ id: "d1", user: "bruno", text: "x", ts: 50 }], "bruno", "/api/chat");
|
||||
const del = list.querySelector(".file-chat-del");
|
||||
assert.ok(del, "bouton suppression présent");
|
||||
assert.strictEqual(del.style.opacity, "", "pas de opacity:0 — bouton visible");
|
||||
});
|
||||
|
||||
test("#110 — image : boutons icônes discrets sous l'image, à droite", () => {
|
||||
const list = document.createElement("div");
|
||||
renderChatMessages(list, [{
|
||||
id: "img1", user: "alice", text: "", ts: 100,
|
||||
attachment: { kind: "image", url: "/api/chat/attachment/photo.png", name: "photo.png", mime: "image/png" },
|
||||
}], "bruno");
|
||||
const container = list.querySelector(".file-chat-image-container");
|
||||
assert.ok(container, "conteneur image présent");
|
||||
const actions = container.querySelector(".file-chat-image-actions");
|
||||
assert.ok(actions, "rangée de boutons sous l'image");
|
||||
assert.ok(actions.querySelector(".file-chat-image-download"), "bouton téléchargement présent");
|
||||
assert.ok(actions.querySelector(".file-chat-open"), "bouton visualisation présent");
|
||||
actions.querySelectorAll("button").forEach((b) => {
|
||||
assert.strictEqual(b.textContent.trim(), "", "bouton icône seule (sans libellé texte)");
|
||||
assert.ok(b.getAttribute("aria-label"), "aria-label présent");
|
||||
});
|
||||
});
|
||||
|
||||
test("#110 — clic sur l'image : ouverture dans un onglet de l'app", async () => {
|
||||
const ui = await import(pathToFileURL(path.join(JS_DIR, "ui.js")).href);
|
||||
const seen = [];
|
||||
const orig = ui.TabManager.openChatImage;
|
||||
ui.TabManager.openChatImage = (att) => seen.push(att);
|
||||
try {
|
||||
const list = document.createElement("div");
|
||||
const att = { kind: "image", url: "/api/chat/attachment/p2.png", name: "p2.png" };
|
||||
renderChatMessages(list, [{ id: "img2", user: "alice", text: "", ts: 101, attachment: att }], "bruno");
|
||||
list.querySelector(".file-chat-image").click();
|
||||
assert.strictEqual(seen.length, 1, "openChatImage appelé au clic sur l'image");
|
||||
assert.strictEqual(seen[0].url, att.url, "même pièce jointe transmise");
|
||||
list.querySelector(".file-chat-open").click();
|
||||
assert.strictEqual(seen.length, 2, "le bouton discret ouvre aussi l'onglet");
|
||||
} finally {
|
||||
ui.TabManager.openChatImage = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test("#110 — point de couleur sur l'icône chat tant qu'il reste des non-lus", () => {
|
||||
const tab = document.getElementById("sidebar-tab-chat");
|
||||
assert.ok(tab, "onglet chat présent");
|
||||
localStorage.setItem("obsigate-chat-unread", JSON.stringify({ general: 3 }));
|
||||
refreshUnreadBadge();
|
||||
assert.ok(tab.classList.contains("chat-has-unread"), "classe chat-has-unread posée");
|
||||
localStorage.setItem("obsigate-chat-unread", JSON.stringify({}));
|
||||
refreshUnreadBadge();
|
||||
assert.ok(!tab.classList.contains("chat-has-unread"), "classe retirée quand tout est lu");
|
||||
});
|
||||
|
||||
test("#110 — suppression différée : Annuler remet le message, aucun DELETE", () => {
|
||||
const list = document.createElement("div");
|
||||
renderChatMessages(list, [{ id: "del9", user: "bruno", text: "mon message", ts: 200 }], "bruno", "/api/chat");
|
||||
const before = fetchCalls.length;
|
||||
list.querySelector(".file-chat-del").click();
|
||||
assert.ok(list.querySelector(".file-chat-deleted"), "ligne remplacée par « supprimé »");
|
||||
assert.ok(list.querySelector(".file-chat-undo"), "bouton Annuler présent");
|
||||
assert.ok(!fetchCalls.slice(before).some((c) => c.method === "DELETE"), "aucun DELETE parti pendant la fenêtre");
|
||||
list.querySelector(".file-chat-undo").click();
|
||||
assert.ok(list.querySelector("[data-id='del9']"), "message d'origine restauré");
|
||||
assert.strictEqual(list.querySelector(".file-chat-deleted"), null, "ligne « supprimé » retirée");
|
||||
assert.ok(!fetchCalls.slice(before).some((c) => c.method === "DELETE"), "toujours aucun DELETE");
|
||||
});
|
||||
|
||||
test("#110 — notification OS quand l'app est en arrière-plan", () => {
|
||||
const fired = [];
|
||||
class FakeNotification {
|
||||
constructor(title, opts) { fired.push({ title, opts }); }
|
||||
close() {}
|
||||
}
|
||||
FakeNotification.permission = "granted";
|
||||
window.Notification = FakeNotification;
|
||||
Object.defineProperty(document, "hidden", { value: true, configurable: true });
|
||||
try {
|
||||
const panel = document.getElementById("sidebar-panel-chat");
|
||||
panel.classList.remove("active");
|
||||
onChatMessage({ vault: "__global__", path: "general", message: { id: "n-1", user: "bob", text: "coucou", ts: 300 } });
|
||||
assert.strictEqual(fired.length, 1, "notification affichée");
|
||||
assert.ok(String(fired[0].opts.body).includes("coucou"), "aperçu du message dans la notification");
|
||||
} finally {
|
||||
Object.defineProperty(document, "hidden", { value: false, configurable: true });
|
||||
window.Notification = { permission: "denied" };
|
||||
}
|
||||
});
|
||||
|
||||
// --- #193 : un post est rendu comme un document (markdown + code coloré) -----
|
||||
|
||||
test("#193 — post avec html rendu : markdown + bloc de code", () => {
|
||||
const list = document.createElement("div");
|
||||
renderChatMessages(list, [{
|
||||
id: "md1", user: "alice", text: "**gras**\n\n```python\nprint(1)\n```", ts: 400,
|
||||
html: '<p><strong>gras</strong></p><pre><code class="language-python">print(1)</code></pre>',
|
||||
}], "bruno");
|
||||
const body = list.querySelector("[data-id='md1'] .file-chat-body");
|
||||
assert.ok(body.classList.contains("file-chat-md"), "classe md posée sur le corps");
|
||||
assert.ok(body.querySelector(".md-content"), "conteneur md-content");
|
||||
assert.ok(body.querySelector(".md-content strong"), "markdown rendu (strong)");
|
||||
assert.ok(body.querySelector("pre code.language-python"), "bloc de code conservé");
|
||||
});
|
||||
|
||||
test("#193 — sans html, repli texte brut + URL cliquable", () => {
|
||||
const list = document.createElement("div");
|
||||
renderChatMessages(list, [{ id: "md2", user: "alice", text: "voir https://ex.com/a", ts: 401 }], "bruno");
|
||||
const body = list.querySelector("[data-id='md2'] .file-chat-body");
|
||||
assert.ok(!body.classList.contains("file-chat-md"), "pas de rendu markdown");
|
||||
assert.ok(body.querySelector("a.file-chat-link"), "URL toujours cliquable");
|
||||
assert.strictEqual(body.textContent, "voir https://ex.com/a", "texte intact");
|
||||
});
|
||||
|
||||
// Sequential execution: each case depends on panel state left by the previous.
|
||||
for (const [name, fn] of cases) {
|
||||
try {
|
||||
await fn();
|
||||
pass++;
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (err) {
|
||||
console.error(` ✗ ${name}\n ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
console.log(`\n${pass} passed`);
|
||||
@@ -176,6 +176,19 @@ await test("filterSavedSearches combines with the type pills", async () => {
|
||||
assert.equal(visibleSaved(), 3, "clearing both filters restores everything");
|
||||
});
|
||||
|
||||
// ── #194 : icône du dossier personnel ──
|
||||
await test("getVaultIcon returns the lucide house icon for home-* vaults", async () => {
|
||||
const homeIcon = viewerMod.getVaultIcon("home-bruno", 16);
|
||||
assert.equal(homeIcon.tagName.toLowerCase(), "i", "placeholder lucide (comme les autres icônes)");
|
||||
assert.equal(homeIcon.getAttribute("data-lucide"), "home", "icône maison lucide");
|
||||
assert.equal(homeIcon.style.width, "16px", "taille demandée respectée");
|
||||
});
|
||||
|
||||
await test("getVaultIcon is unchanged for regular vaults", async () => {
|
||||
const vaultIcon = viewerMod.getVaultIcon("Recettes", 16);
|
||||
assert.equal(vaultIcon.tagName.toLowerCase(), "svg", "gemme violette SVG d'origine");
|
||||
});
|
||||
|
||||
// ── Summary ──
|
||||
console.log(`\n${passCount}/${testCount} tests passed`);
|
||||
if (passCount !== testCount) {
|
||||
|
||||
@@ -39,7 +39,7 @@ test("viewer.js — group nav: TOC markdown-only, then pop-out, bookmark", () =>
|
||||
const m = viewer.match(/const navBtns = \[\];([\s\S]*?)const editBtns/);
|
||||
assert.ok(m, "navBtns block not found");
|
||||
assert.match(m[1], /if \(data\.is_markdown\) navBtns\.push\(tocBtn\);/);
|
||||
assert.match(m[1], /navBtns\.push\(openNewWindowBtn,\s*bookmarkBtn\);/);
|
||||
assert.ok(m[1].includes("navBtns.push(openNewWindowBtn, bookmarkBtn, chatBtn);"), "navBtns group order");
|
||||
});
|
||||
|
||||
test("viewer.js — group edit = [edit, source, .ext, forge?, pretty?] in order", () => {
|
||||
|
||||
@@ -2775,6 +2775,54 @@ await test("a read-only workbook keeps the view menus but drops the editing ones
|
||||
assert.ok(area.querySelector('[data-xlsx-action="toggle-grid"]'), "Gridlines stay");
|
||||
});
|
||||
|
||||
// ── #189 — mise en page mobile : barres repliées derrière un basculeur ──────
|
||||
|
||||
await test("the sheet strip carries the tools toggle that flips the toolbar (#189)", () => {
|
||||
const area = mountGrid();
|
||||
const strip = area.querySelector(".xlsx-sheetbar");
|
||||
const toggle = area.querySelector("#xlsx-tools-toggle");
|
||||
assert.ok(toggle, "toggle rendered");
|
||||
assert.equal(toggle.closest(".xlsx-sheetbar"), strip, "it lives in the sheet strip");
|
||||
assert.equal(toggle.getAttribute("aria-expanded"), "false", "starts collapsed");
|
||||
const toolbar = area.querySelector(".xlsx-toolbar");
|
||||
assert.ok(!toolbar.classList.contains("xlsx-tools-open"), "toolbar starts closed");
|
||||
toggle.click();
|
||||
assert.ok(toolbar.classList.contains("xlsx-tools-open"), "one click opens the tools");
|
||||
assert.equal(toggle.getAttribute("aria-expanded"), "true");
|
||||
assert.ok(toggle.classList.contains("active"), "the toggle shows its own state");
|
||||
toggle.click();
|
||||
assert.ok(!toolbar.classList.contains("xlsx-tools-open"), "a second click closes them");
|
||||
assert.equal(toggle.getAttribute("aria-expanded"), "false");
|
||||
});
|
||||
|
||||
await test("style.css hides menus/ribbon/find by default under 768px and reveals them on .xlsx-tools-open (#189)", () => {
|
||||
const css = readFileSync(path.join(REPO_ROOT, "frontend", "style.css"), "utf8");
|
||||
// The desktop default: the toggle itself is out of the way.
|
||||
assert.match(css, /\.xlsx-tools-toggle\s*\{\s*display:\s*none/, "toggle hidden on desktop");
|
||||
// Mobile block: collapsed by default… (CRLF-safe slice, from the #189
|
||||
// comment up to the next top-level rule).
|
||||
const from = css.indexOf("/* #189");
|
||||
assert.ok(from > 0, "the #189 mobile block exists after the coarse-pointer rules");
|
||||
const to = css.indexOf(".xlsx-context-item", from);
|
||||
assert.ok(to > from, "the #189 block is followed by the next rule");
|
||||
const block = css.slice(from, to);
|
||||
for (const cls of [".xlsx-menubar", ".xlsx-cmdbar", ".xlsx-find-group"]) {
|
||||
const hidden = new RegExp(cls.replace(".", "\\.") + "[^}]*display:\\s*none").test(block)
|
||||
|| new RegExp(cls.replace(".", "\\.") + ",[\\s\\S]{0,120}?display:\\s*none").test(block);
|
||||
assert.ok(hidden, `${cls} hidden by default on mobile`);
|
||||
assert.ok(
|
||||
block.includes(`.xlsx-toolbar.xlsx-tools-open ${cls}`),
|
||||
`${cls} revealed by .xlsx-tools-open`,
|
||||
);
|
||||
}
|
||||
// The formula bar always stays, and the open state can wrap it (find group).
|
||||
assert.ok(block.includes(".xlsx-toolbar.xlsx-tools-open .xlsx-formula-bar"), "formula bar wraps when open");
|
||||
// Touch: 44px targets, tabs on one scrollable row, 16px inputs (no iOS zoom).
|
||||
assert.match(block, /min-width:\s*44px/, "44px touch targets");
|
||||
assert.match(block, /\.xlsx-sheetbar \.xlsx-tabs\s*\{[^}]*overflow-x:\s*auto/, "tabs scroll horizontally");
|
||||
assert.match(block, /font-size:\s*16px/, "inputs at 16px avoid the iOS focus zoom");
|
||||
});
|
||||
|
||||
// ── Report ──────────────────────────────────────────────────────────────────
|
||||
console.log(`\n${passCount}/${testCount} tests passed\n`);
|
||||
process.exit(passCount === testCount ? 0 : 1);
|
||||
|
||||
+63
-1
@@ -481,4 +481,66 @@ class TestSecureCookies:
|
||||
assert "secure" in resp.headers.get("set-cookie", "").lower()
|
||||
finally:
|
||||
if hasattr(https_client, "close"):
|
||||
https_client.close()
|
||||
https_client.close()
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# Dossier personnel par utilisateur (#194)
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestUserHome:
|
||||
def _login(self, client, username, password):
|
||||
resp = client.post("/api/auth/login", json={
|
||||
"username": username, "password": password,
|
||||
})
|
||||
return resp.json()["access_token"]
|
||||
|
||||
def test_create_user_provisions_home(self, auth_client, monkeypatch):
|
||||
"""POST /admin/users → dossier + vault home-<user> + isolation + retrait."""
|
||||
from pathlib import Path
|
||||
|
||||
from backend import watcher_state
|
||||
|
||||
monkeypatch.setattr(watcher_state, "get_watcher", lambda: None)
|
||||
home = Path.cwd() / "Home"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("OBSIGATE_HOME_ROOT", str(home))
|
||||
|
||||
admin = {"Authorization": f"Bearer {self._login(auth_client, 'admin', 'chab30')}"}
|
||||
|
||||
resp = auth_client.post("/api/auth/admin/users", headers=admin, json={
|
||||
"username": "alice", "password": "testpass", "role": "user",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert (home / "alice").is_dir()
|
||||
|
||||
names = [v["name"] for v in auth_client.get("/api/vaults", headers=admin).json()]
|
||||
# #194 : « * » n'inclut pas les dossiers persos — l'admin ne voit pas
|
||||
# le home d'alice (il ne verrait sinon celui de chaque utilisateur).
|
||||
assert "home-alice" not in names
|
||||
assert "TestVault" in names
|
||||
# L'octroi atterrit bien dans le user
|
||||
assert "home-alice" in resp.json()["vaults"]
|
||||
|
||||
# Isolation : alice ne voit QUE son propre dossier
|
||||
alice = {"Authorization": f"Bearer {self._login(auth_client, 'alice', 'testpass')}"}
|
||||
alice_names = [v["name"] for v in auth_client.get("/api/vaults", headers=alice).json()]
|
||||
assert alice_names == ["home-alice"]
|
||||
|
||||
# Suppression du compte → vault refermé, dossier conservé
|
||||
resp = auth_client.delete("/api/auth/admin/users/alice", headers=admin)
|
||||
assert resp.status_code == 200
|
||||
assert (home / "alice").is_dir()
|
||||
names = [v["name"] for v in auth_client.get("/api/vaults", headers=admin).json()]
|
||||
assert "home-alice" not in names
|
||||
|
||||
def test_create_user_without_home_root_is_unchanged(self, auth_client):
|
||||
"""Sans OBSIGATE_HOME_ROOT : aucun dossier, comportement historique."""
|
||||
from pathlib import Path
|
||||
|
||||
assert Path.cwd().exists()
|
||||
admin = {"Authorization": f"Bearer {self._login(auth_client, 'admin', 'chab30')}"}
|
||||
resp = auth_client.post("/api/auth/admin/users", headers=admin, json={
|
||||
"username": "nohome", "password": "testpass", "role": "user",
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
assert not (Path.cwd() / "Home").exists()
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
# #196 — Partage dirigé entre utilisateurs : gate des pages /s/*, scope de
|
||||
# /api/shares, révocabilité, validation des destinataires.
|
||||
# Fixture admin_client (conftest.py) : users admin (role admin, vaults ["*"])
|
||||
# et normaluser (role user, vaults ["TestVault"]), auth activée.
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _logged_client(username, password):
|
||||
"""Fresh TestClient with a session (cookie) — /s/* reads the access_token cookie."""
|
||||
from fastapi.testclient import TestClient
|
||||
from backend.main import app
|
||||
|
||||
c = TestClient(app)
|
||||
resp = c.post("/api/auth/login", json={"username": username, "password": password})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return c
|
||||
|
||||
|
||||
def _ensure_shared_file():
|
||||
# admin_client chdir(tmp_path) mais le vault indexé est VAULT_1_PATH
|
||||
# (absolu, résolu avant le chdir) → écrire là, pas dans cwd.
|
||||
vault = Path(os.environ["VAULT_1_PATH"])
|
||||
vault.mkdir(parents=True, exist_ok=True)
|
||||
target = vault / "share_directed.md"
|
||||
target.write_text("# Partagé\n\ncontenu dirigé\n", encoding="utf-8")
|
||||
return "share_directed.md"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sessions(admin_client):
|
||||
"""admin (creator) and normaluser (recipient) clients, logged in via cookie."""
|
||||
return _logged_client("admin", "chab30"), _logged_client("normaluser", "normal123")
|
||||
|
||||
|
||||
class TestDirectedShareGate:
|
||||
def test_create_directed_share(self, sessions):
|
||||
admin_client, _ = sessions
|
||||
path = _ensure_shared_file()
|
||||
resp = admin_client.post("/api/share/TestVault", json={
|
||||
"path": path, "shared_with": ["normaluser"],
|
||||
})
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["shared_with"] == ["normaluser"]
|
||||
|
||||
def test_create_directed_unknown_recipient_rejected(self, sessions):
|
||||
admin_client, _ = sessions
|
||||
path = _ensure_shared_file()
|
||||
resp = admin_client.post("/api/share/TestVault", json={
|
||||
"path": path, "shared_with": ["ghost"],
|
||||
})
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_recipient_can_view_but_anon_cannot(self, sessions):
|
||||
admin_client, user_client = sessions
|
||||
path = _ensure_shared_file()
|
||||
resp = admin_client.post("/api/share/TestVault", json={
|
||||
"path": path, "shared_with": ["normaluser"],
|
||||
})
|
||||
token = resp.json()["token"]
|
||||
|
||||
# Anonymous → 404 (pas 401/403 : on ne fuite pas l'existence du token)
|
||||
from fastapi.testclient import TestClient
|
||||
from backend.main import app
|
||||
anon = TestClient(app)
|
||||
assert anon.get(f"/s/{token}").status_code == 404
|
||||
|
||||
# Recipient (cookie de session via Bearer) → 200
|
||||
assert user_client.get(f"/s/{token}").status_code == 200
|
||||
# Creator → 200
|
||||
assert admin_client.get(f"/s/{token}").status_code == 200
|
||||
|
||||
def test_other_user_cannot_view(self, sessions):
|
||||
admin_client, _ = sessions
|
||||
path = _ensure_shared_file()
|
||||
resp = admin_client.post("/api/share/TestVault", json={
|
||||
"path": path, "shared_with": ["admin"], # dirigé, mais pas normaluser
|
||||
})
|
||||
token = resp.json()["token"]
|
||||
other = _logged_client("normaluser", "normal123")
|
||||
assert other.get(f"/s/{token}").status_code == 404
|
||||
|
||||
def test_public_share_still_anonymous(self, sessions):
|
||||
admin_client, _ = sessions
|
||||
path = _ensure_shared_file()
|
||||
resp = admin_client.post("/api/share/TestVault", json={"path": path})
|
||||
token = resp.json()["token"]
|
||||
from fastapi.testclient import TestClient
|
||||
from backend.main import app
|
||||
anon = TestClient(app)
|
||||
assert anon.get(f"/s/{token}").status_code == 200
|
||||
|
||||
def test_shares_list_scoped_for_user(self, sessions):
|
||||
admin_client, user_client = sessions
|
||||
path = _ensure_shared_file()
|
||||
admin_client.post("/api/share/TestVault", json={
|
||||
"path": path, "shared_with": ["normaluser"],
|
||||
})
|
||||
# normaluser (non-admin) voit le partage reçu
|
||||
resp = user_client.get("/api/shares")
|
||||
assert resp.status_code == 200
|
||||
tokens = [s["token"] for s in resp.json()]
|
||||
assert tokens, "destinataire doit voir le partage reçu"
|
||||
|
||||
# Un admin voit tout
|
||||
resp = admin_client.get("/api/shares")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.json()) >= 1
|
||||
|
||||
def test_recipient_cannot_revoke(self, sessions):
|
||||
admin_client, user_client = sessions
|
||||
path = _ensure_shared_file()
|
||||
resp = admin_client.post("/api/share/TestVault", json={
|
||||
"path": path, "shared_with": ["normaluser"],
|
||||
})
|
||||
sid = resp.json()["id"]
|
||||
resp = user_client.delete(f"/api/share/{sid}")
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_creator_can_revoke(self, sessions):
|
||||
admin_client, _ = sessions
|
||||
path = _ensure_shared_file()
|
||||
resp = admin_client.post("/api/share/TestVault", json={
|
||||
"path": path, "shared_with": ["normaluser"],
|
||||
})
|
||||
sid = resp.json()["id"]
|
||||
assert admin_client.delete(f"/api/share/{sid}").status_code == 200
|
||||
|
||||
def test_revoke_cuts_recipient_access(self, sessions):
|
||||
admin_client, user_client = sessions
|
||||
path = _ensure_shared_file()
|
||||
resp = admin_client.post("/api/share/TestVault", json={
|
||||
"path": path, "shared_with": ["normaluser"],
|
||||
})
|
||||
body = resp.json()
|
||||
assert user_client.get(f"/s/{body['token']}").status_code == 200
|
||||
admin_client.delete(f"/api/share/{body['id']}")
|
||||
assert user_client.get(f"/s/{body['token']}").status_code == 404
|
||||
|
||||
def test_search_finds_received_share_for_recipient(self, sessions):
|
||||
# #196 : le contenu du document reçu est cherchable par le destinataire.
|
||||
admin_client, user_client = sessions
|
||||
path = _ensure_shared_file()
|
||||
resp = admin_client.post("/api/share/TestVault", json={
|
||||
"path": path, "shared_with": ["normaluser"],
|
||||
})
|
||||
token = resp.json()["token"]
|
||||
resp = user_client.get("/api/search", params={"q": "dirigé", "vault": "all"})
|
||||
assert resp.status_code == 200
|
||||
hits = [r for r in resp.json()["results"] if r.get("share_token") == token]
|
||||
assert hits, "le destinataire doit trouver le document reçu via la recherche"
|
||||
assert hits[0]["vault"] == "home-normaluser"
|
||||
assert hits[0]["path"].startswith("Partage/")
|
||||
|
||||
def test_search_does_not_leak_share_to_other_user(self, sessions):
|
||||
# Un utilisateur non destinataire ne voit JAMAIS le contenu partagé
|
||||
# dans ses résultats (le vault source lui est interdit).
|
||||
admin_client, user_client = sessions
|
||||
path = _ensure_shared_file()
|
||||
admin_client.post("/api/share/TestVault", json={
|
||||
"path": path, "shared_with": ["admin"], # dirigé à admin seul
|
||||
})
|
||||
resp = user_client.get("/api/search", params={"q": "dirigé", "vault": "all"})
|
||||
assert resp.status_code == 200
|
||||
assert not [r for r in resp.json()["results"] if r.get("share_token")]
|
||||
|
||||
def test_recipient_opens_shared_file_in_app(self, sessions):
|
||||
# #196 : /api/file résout home-<user>/Partage/<token>/<f> vers la
|
||||
# source — même quand le destinataire N'A PAS accès au vault source
|
||||
# (le partage dirigé EST l'autorisation).
|
||||
admin_client, user_client = sessions
|
||||
path = _ensure_shared_file()
|
||||
resp = admin_client.post("/api/share/TestVault", json={
|
||||
"path": path, "shared_with": ["normaluser"],
|
||||
})
|
||||
assert resp.status_code == 200
|
||||
token = resp.json()["token"]
|
||||
|
||||
# Retire l'accès direct au vault source : le partage doit suffire.
|
||||
from backend.auth.user_store import update_user
|
||||
update_user("normaluser", {"vaults": ["home-normaluser"]})
|
||||
|
||||
# Chemin canonique avec token
|
||||
r = user_client.get("/api/file/home-normaluser", params={"path": f"Partage/{token}/share_directed.md"})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["is_markdown"] is True
|
||||
assert "dirigé" in body["html"]
|
||||
|
||||
# Fallback par nom (liens sans token)
|
||||
r = user_client.get("/api/file/home-normaluser", params={"path": "Partage/share_directed.md"})
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# raw + download too
|
||||
r = user_client.get("/api/file/home-normaluser/raw", params={"path": f"Partage/{token}/share_directed.md"})
|
||||
assert r.status_code == 200
|
||||
assert "dirigé" in r.json()["raw"]
|
||||
|
||||
# Restore fixture state for other tests
|
||||
update_user("normaluser", {"vaults": ["TestVault"]})
|
||||
|
||||
def test_shared_file_resolution_is_user_scoped(self, sessions):
|
||||
# home-admin/Partage/x.md demandé par normaluser → pas de mapping
|
||||
# (le home d'un autre user lui est interdit, 403 avant tout).
|
||||
admin_client, user_client = sessions
|
||||
path = _ensure_shared_file()
|
||||
admin_client.post("/api/share/TestVault", json={
|
||||
"path": path, "shared_with": ["normaluser"],
|
||||
})
|
||||
r = user_client.get("/api/file/home-admin", params={"path": "Partage/share_directed.md"})
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_same_name_two_shares_token_disambiguates(self, sessions):
|
||||
# Deux partages de fichiers homonymes reçus : le token sélectionne
|
||||
# le bon, le fallback par nom prend le premier sans crash.
|
||||
admin_client, user_client = sessions
|
||||
import os as _os
|
||||
vault = Path(_os.environ["VAULT_1_PATH"])
|
||||
(vault / "a.md").write_text("contenu alpha unique", encoding="utf-8")
|
||||
(vault / "b.md").write_text("contenu beta unique", encoding="utf-8")
|
||||
# subdirectory with same basename to force ambiguity
|
||||
(vault / "d1").mkdir(exist_ok=True)
|
||||
(vault / "d2").mkdir(exist_ok=True)
|
||||
(vault / "d1" / "same.md").write_text("contenu GAMMA unique", encoding="utf-8")
|
||||
(vault / "d2" / "same.md").write_text("contenu DELTA unique", encoding="utf-8")
|
||||
r1 = admin_client.post("/api/share/TestVault", json={"path": "d1/same.md", "shared_with": ["normaluser"]})
|
||||
r2 = admin_client.post("/api/share/TestVault", json={"path": "d2/same.md", "shared_with": ["normaluser"]})
|
||||
t1, t2 = r1.json()["token"], r2.json()["token"]
|
||||
|
||||
r = user_client.get("/api/file/home-normaluser", params={"path": f"Partage/{t2}/same.md"})
|
||||
assert r.status_code == 200
|
||||
assert "DELTA" in r.json()["html"]
|
||||
r = user_client.get("/api/file/home-normaluser", params={"path": f"Partage/{t1}/same.md"})
|
||||
assert r.status_code == 200
|
||||
assert "GAMMA" in r.json()["html"]
|
||||
@@ -0,0 +1,552 @@
|
||||
# tests/test_file_chat.py — Chat par fichier (#169)
|
||||
"""Store + REST routes for the per-file chat history."""
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from backend import file_chat as _store
|
||||
|
||||
|
||||
def _login(client, username="admin", password="chab30") -> dict:
|
||||
"""Log in against the auth-enabled ``admin_client`` fixture (#191)."""
|
||||
resp = client.post("/api/auth/login", json={"username": username, "password": password})
|
||||
assert resp.status_code == 200, resp.text
|
||||
return {"Authorization": f"Bearer {resp.json()['access_token']}"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_chat_dir(tmp_path, monkeypatch):
|
||||
"""Keep every test (store AND routes) out of the real ``data/chats``/
|
||||
``data/chat_uploads``."""
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
monkeypatch.setattr(_store, "UPLOAD_DIR", tmp_path / "uploads")
|
||||
|
||||
|
||||
class TestStore:
|
||||
def test_get_messages_empty_when_missing(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
assert _store.get_messages("V", "a/b.md") == []
|
||||
|
||||
def test_add_then_get_roundtrip(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
msg = _store.add_message("V", "a/b.md", "bruno", "Bonjour !")
|
||||
assert msg["user"] == "bruno"
|
||||
assert msg["text"] == "Bonjour !"
|
||||
assert msg["id"]
|
||||
assert msg["ts"] <= time.time() + 1
|
||||
got = _store.get_messages("V", "a/b.md")
|
||||
assert len(got) == 1
|
||||
assert got[0]["id"] == msg["id"]
|
||||
|
||||
def test_conversation_is_per_file(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
_store.add_message("V", "a.md", "u", "pour a")
|
||||
_store.add_message("V", "b.md", "u", "pour b")
|
||||
assert [m["text"] for m in _store.get_messages("V", "a.md")] == ["pour a"]
|
||||
assert [m["text"] for m in _store.get_messages("V", "b.md")] == ["pour b"]
|
||||
|
||||
def test_path_traversal_never_escapes_chat_dir(self, tmp_path, monkeypatch):
|
||||
"""The chat file name is hashed: '../../etc' cannot escape CHAT_DIR."""
|
||||
chat_dir = tmp_path / "chats"
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", chat_dir)
|
||||
_store.add_message("V", "../../etc/passwd", "u", "x")
|
||||
files = list(chat_dir.iterdir())
|
||||
assert len(files) == 1
|
||||
assert files[0].parent == chat_dir
|
||||
assert "/" not in files[0].name and "\\" not in files[0].name
|
||||
|
||||
def test_retention_cap_drops_oldest(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
n = _store.MAX_MESSAGES + 25
|
||||
for i in range(n):
|
||||
_store.add_message("V", "a.md", "u", f"m{i}")
|
||||
got = _store.get_messages("V", "a.md")
|
||||
assert len(got) == _store.MAX_MESSAGES
|
||||
assert got[0]["text"] == f"m{25}" # first 25 dropped
|
||||
assert got[-1]["text"] == f"m{n - 1}"
|
||||
|
||||
def test_text_truncated_to_max(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
_store.add_message("V", "a.md", "u", "x" * (_store.MAX_TEXT + 500))
|
||||
got = _store.get_messages("V", "a.md")
|
||||
assert len(got[0]["text"]) == _store.MAX_TEXT
|
||||
|
||||
def test_corrupt_file_falls_back_to_empty(self, tmp_path, monkeypatch):
|
||||
chat_dir = tmp_path / "chats"
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", chat_dir)
|
||||
chat_dir.mkdir(parents=True)
|
||||
target = _store._chat_file("V", "a.md")
|
||||
target.write_text("{not json", encoding="utf-8")
|
||||
assert _store.get_messages("V", "a.md") == []
|
||||
|
||||
|
||||
class TestRoutes:
|
||||
def test_history_requires_existing_vault(self, client, test_vault_dir):
|
||||
r = client.get("/api/file/TestVault/chat", params={"path": "note1.md"})
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"messages": [], "read": {}}
|
||||
|
||||
def test_post_and_get_roundtrip(self, client, test_vault_dir):
|
||||
r = client.post(
|
||||
"/api/file/TestVault/chat",
|
||||
json={"path": "note1.md", "text": "Vu hier ?"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["message"]["text"] == "Vu hier ?"
|
||||
r2 = client.get("/api/file/TestVault/chat", params={"path": "note1.md"})
|
||||
assert [m["text"] for m in r2.json()["messages"]] == ["Vu hier ?"]
|
||||
|
||||
def test_post_requires_text(self, client, test_vault_dir):
|
||||
r = client.post("/api/file/TestVault/chat", json={"path": "note1.md", "text": " "})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_post_requires_path(self, client, test_vault_dir):
|
||||
r = client.post("/api/file/TestVault/chat", json={"path": "", "text": "hi"})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_unknown_vault_404(self, client, test_vault_dir):
|
||||
r = client.get("/api/file/Nope/chat", params={"path": "x.md"})
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_path_traversal_rejected(self, client, test_vault_dir):
|
||||
r = client.get(
|
||||
"/api/file/TestVault/chat", params={"path": "../../etc/passwd"}
|
||||
)
|
||||
assert r.status_code in (403, 500)
|
||||
r2 = client.post(
|
||||
"/api/file/TestVault/chat",
|
||||
json={"path": "../../etc/passwd", "text": "x"},
|
||||
)
|
||||
assert r2.status_code in (403, 500)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["note1.md", "sub/deep/file.md"])
|
||||
def test_history_per_path_isolated(client, test_vault_dir, path):
|
||||
client.post("/api/file/TestVault/chat", json={"path": path, "text": "m"})
|
||||
other = "note1.md" if path != "note1.md" else "note2.md"
|
||||
r = client.get("/api/file/TestVault/chat", params={"path": other})
|
||||
assert r.json()["messages"] == []
|
||||
|
||||
|
||||
# --- #190 : chat général + pièces jointes -----------------------------------
|
||||
|
||||
class TestGlobalChat:
|
||||
def test_history_empty_by_default(self, client, test_vault_dir):
|
||||
r = client.get("/api/chat")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"messages": [], "read": {}}
|
||||
|
||||
def test_post_and_get_roundtrip(self, client, test_vault_dir):
|
||||
r = client.post("/api/chat", json={"text": "Bonjour à tous"})
|
||||
assert r.status_code == 200
|
||||
msg = r.json()["message"]
|
||||
assert msg["text"] == "Bonjour à tous"
|
||||
assert msg["user"] # authenticated user recorded
|
||||
got = client.get("/api/chat").json()["messages"]
|
||||
assert [m["id"] for m in got] == [msg["id"]]
|
||||
|
||||
def test_empty_text_rejected(self, client, test_vault_dir):
|
||||
assert client.post("/api/chat", json={"text": " "}).status_code == 400
|
||||
|
||||
def test_general_chat_isolated_from_file_chats(self, client, test_vault_dir):
|
||||
client.post("/api/chat", json={"text": "général"})
|
||||
client.post("/api/file/TestVault/chat", json={"path": "note1.md", "text": "fichier"})
|
||||
assert len(client.get("/api/chat").json()["messages"]) == 1
|
||||
assert len(client.get("/api/file/TestVault/chat", params={"path": "note1.md"}).json()["messages"]) == 1
|
||||
|
||||
def test_unknown_attachment_rejected(self, client, test_vault_dir):
|
||||
r = client.post("/api/chat", json={
|
||||
"text": "pièce jointe",
|
||||
"attachment": {"name": "nope.png", "url": "javascript:alert(1)", "kind": "image"},
|
||||
})
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_external_url_attachment_accepted(self, client, test_vault_dir):
|
||||
r = client.post("/api/chat", json={
|
||||
"text": "un lien",
|
||||
"attachment": {"name": "", "url": "https://example.com/x.png", "kind": "image"},
|
||||
})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["message"]["attachment"]["url"] == "https://example.com/x.png"
|
||||
|
||||
|
||||
class TestAttachments:
|
||||
def test_upload_and_serve_roundtrip(self, client, test_vault_dir):
|
||||
files = {"file": ("shot.png", b"\x89PNG\r\n\x1a\nfake", "image/png")}
|
||||
r = client.post("/api/chat/upload", files=files)
|
||||
assert r.status_code == 200
|
||||
att = r.json()["attachment"]
|
||||
assert att["kind"] == "image"
|
||||
assert att["mime"] == "image/png"
|
||||
# served back with the right content type
|
||||
r2 = client.get(f"/api/chat/attachment/{att['name']}")
|
||||
assert r2.status_code == 200
|
||||
assert r2.headers["content-type"].startswith("image/png")
|
||||
|
||||
def test_video_kind_detection(self, client, test_vault_dir):
|
||||
files = {"file": ("clip.mp4", b"\x00\x00\x00\x18ftyp", "video/mp4")}
|
||||
att = client.post("/api/chat/upload", files=files).json()["attachment"]
|
||||
assert att["kind"] == "video"
|
||||
|
||||
def test_extension_not_allowed(self, client, test_vault_dir):
|
||||
files = {"file": ("evil.exe", b"MZ...", "application/octet-stream")}
|
||||
assert client.post("/api/chat/upload", files=files).status_code == 400
|
||||
|
||||
def test_empty_file_rejected(self, client, test_vault_dir):
|
||||
files = {"file": ("empty.png", b"", "image/png")}
|
||||
assert client.post("/api/chat/upload", files=files).status_code == 400
|
||||
|
||||
def test_attachment_traversal_rejected(self, client, test_vault_dir):
|
||||
# A traversal/unknown name must never produce a media response: the
|
||||
# route 404s, or the SPA catch-all answers HTML (200 + text/html).
|
||||
for bad in ("../secret.png", "sub/evil.png", "..%2Fsecret.png", "nope.png"):
|
||||
r = client.get(f"/api/chat/attachment/{bad}")
|
||||
ct = r.headers.get("content-type", "")
|
||||
assert not ct.startswith(("image/", "video/")), (bad, r.status_code, ct)
|
||||
assert r.status_code in (404, 400, 422, 200), bad
|
||||
|
||||
def test_message_with_uploaded_attachment(self, client, test_vault_dir):
|
||||
files = {"file": ("pic.webp", b"RIFF0000WEBP", "image/webp")}
|
||||
att = client.post("/api/chat/upload", files=files).json()["attachment"]
|
||||
r = client.post("/api/chat", json={"text": "voici", "attachment": att})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["message"]["attachment"]["kind"] == "image"
|
||||
|
||||
|
||||
# --- #191 : suppression, messages privés, link preview ----------------------
|
||||
|
||||
class TestDelete:
|
||||
def test_author_deletes_own_post(self, client, test_vault_dir):
|
||||
msg = client.post("/api/chat", json={"text": "a supprimer"}).json()["message"]
|
||||
r = client.delete(f"/api/chat/{msg['id']}")
|
||||
assert r.status_code == 200
|
||||
assert client.get("/api/chat").json()["messages"] == []
|
||||
|
||||
def test_delete_unknown_404(self, client, test_vault_dir):
|
||||
assert client.delete("/api/chat/nope").status_code == 404
|
||||
|
||||
def test_removed_message_cannot_be_deleted_twice(self, client, test_vault_dir):
|
||||
msg = client.post("/api/chat", json={"text": "x"}).json()["message"]
|
||||
client.delete(f"/api/chat/{msg['id']}")
|
||||
assert client.delete(f"/api/chat/{msg['id']}").status_code == 404
|
||||
|
||||
|
||||
class TestPrivateChat:
|
||||
"""DM flows need a real logged-in identity (auth enabled)."""
|
||||
|
||||
def test_dm_roundtrip(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
r = admin_client.post("/api/chat/dm/normaluser", json={"text": "salut"}, headers=h)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["message"]["text"] == "salut"
|
||||
|
||||
def test_dm_unknown_user_404(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
r = admin_client.post("/api/chat/dm/ghost", json={"text": "hi"}, headers=h)
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_dm_to_self_400(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
r = admin_client.post("/api/chat/dm/admin", json={"text": "hi"}, headers=h)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_dm_isolated_from_general(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
admin_client.post("/api/chat", json={"text": "public"}, headers=h)
|
||||
admin_client.post("/api/chat/dm/normaluser", json={"text": "prive"}, headers=h)
|
||||
gen = admin_client.get("/api/chat", headers=h).json()["messages"]
|
||||
assert [m["text"] for m in gen] == ["public"]
|
||||
dm = admin_client.get("/api/chat/dm/normaluser", headers=h).json()["messages"]
|
||||
assert [m["text"] for m in dm] == ["prive"]
|
||||
|
||||
def test_dm_history(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
admin_client.post("/api/chat/dm/normaluser", json={"text": "hystorique"}, headers=h)
|
||||
r = admin_client.get("/api/chat/dm/normaluser", headers=h)
|
||||
assert [m["text"] for m in r.json()["messages"]] == ["hystorique"]
|
||||
|
||||
def test_dm_pair_is_shared_not_duplicated(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
admin_client.post("/api/chat/dm/normaluser", json={"text": "a"}, headers=h)
|
||||
assert _store.dm_path("admin", "normaluser") == _store.dm_path("normaluser", "admin")
|
||||
assert len(admin_client.get("/api/chat/dm/normaluser", headers=h).json()["messages"]) == 1
|
||||
|
||||
def test_dm_delete(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
msg = admin_client.post(
|
||||
"/api/chat/dm/normaluser", json={"text": "adieu"}, headers=h
|
||||
).json()["message"]
|
||||
r = admin_client.delete(f"/api/chat/dm/normaluser/{msg['id']}", headers=h)
|
||||
assert r.status_code == 200
|
||||
assert admin_client.get("/api/chat/dm/normaluser", headers=h).json()["messages"] == []
|
||||
|
||||
def test_requires_auth(self, admin_client, test_vault_dir):
|
||||
assert admin_client.get("/api/chat/dm/normaluser").status_code == 401
|
||||
assert admin_client.get("/api/chat/users").status_code == 401
|
||||
|
||||
def test_user_list_for_picker(self, admin_client, test_vault_dir):
|
||||
h = _login(admin_client)
|
||||
r = admin_client.get("/api/chat/users", headers=h)
|
||||
assert r.status_code == 200
|
||||
names = [u["username"] for u in r.json()]
|
||||
assert "normaluser" in names
|
||||
assert "admin" not in names # oneself is not a DM target
|
||||
assert all("password" not in u and "hash" not in str(u) for u in r.json())
|
||||
|
||||
|
||||
class TestLinkPreview:
|
||||
def test_no_url_no_preview(self, client, test_vault_dir, monkeypatch):
|
||||
monkeypatch.setattr(_store, "build_preview", lambda text: None)
|
||||
msg = client.post("/api/chat", json={"text": "pas d url"}).json()["message"]
|
||||
# the schema always serialises the key (null) — it must stay falsy
|
||||
assert not msg.get("preview")
|
||||
|
||||
def test_preview_attached_when_fetched(self, client, test_vault_dir, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
_store, "build_preview",
|
||||
lambda text: {"url": "https://x.test", "title": "Titre", "description": "", "image": "", "site": "x.test"},
|
||||
)
|
||||
msg = client.post("/api/chat", json={"text": "voir https://x.test"}).json()["message"]
|
||||
assert msg["preview"]["title"] == "Titre"
|
||||
|
||||
def test_preview_persisted_in_history(self, client, test_vault_dir, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
_store, "build_preview",
|
||||
lambda text: {"url": "https://y.test", "title": "Y", "description": "", "image": "", "site": "y.test"},
|
||||
)
|
||||
client.post("/api/chat", json={"text": "https://y.test"})
|
||||
got = client.get("/api/chat").json()["messages"]
|
||||
assert got[-1]["preview"]["site"] == "y.test"
|
||||
|
||||
def test_build_preview_rejects_non_http(self):
|
||||
assert _store.build_preview("javascript:alert(1)") is None
|
||||
assert _store.build_preview("aucune URL") is None
|
||||
|
||||
def test_build_preview_blocks_private_hosts(self, monkeypatch):
|
||||
# SSRF guard rejects loopback before any network round-trip
|
||||
monkeypatch.setattr(_store, "_PREVIEW_CACHE", {})
|
||||
assert _store.build_preview("http://127.0.0.1/x") is None
|
||||
|
||||
def test_build_preview_happy_path_parses_og(self, monkeypatch):
|
||||
"""The fetch itself (a bad kwarg here used to fail silently)."""
|
||||
monkeypatch.setattr(_store, "_PREVIEW_CACHE", {})
|
||||
# the SSRF guard resolves DNS for real — out of scope for this test
|
||||
from backend.tools import web as _web
|
||||
|
||||
monkeypatch.setattr(_web, "_assert_public_http_url", lambda url: None)
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
text = (
|
||||
"<html><head><title>Fallback</title>"
|
||||
'<meta property="og:title" content="Titre OG">'
|
||||
'<meta property="og:description" content="Desc">'
|
||||
'<meta property="og:site_name" content="Site">'
|
||||
"</head></html>"
|
||||
)
|
||||
|
||||
called = {}
|
||||
|
||||
def _fake_get(url, **kw):
|
||||
called["url"] = url
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr(_store.httpx, "get", _fake_get)
|
||||
preview = _store.build_preview("voir https://example.test/page")
|
||||
assert called["url"] == "https://example.test/page"
|
||||
assert preview == {
|
||||
"url": "https://example.test/page",
|
||||
"title": "Titre OG",
|
||||
"description": "Desc",
|
||||
"image": "",
|
||||
"site": "Site",
|
||||
}
|
||||
|
||||
def test_preview_image_is_proxied_same_origin(self, monkeypatch):
|
||||
"""BUG-109: a remote og:image is blocked by ``img-src 'self'``.
|
||||
|
||||
A *relative* og:image must be resolved against the page and downloaded
|
||||
into ``chat_uploads`` so the browser can load it same-origin.
|
||||
"""
|
||||
monkeypatch.setattr(_store, "_PREVIEW_CACHE", {})
|
||||
from backend.tools import web as _web
|
||||
|
||||
monkeypatch.setattr(_web, "_assert_public_http_url", lambda url: None)
|
||||
|
||||
class _Page:
|
||||
status_code = 200
|
||||
text = (
|
||||
'<meta property="og:title" content="Titre">'
|
||||
'<meta property="og:image" content="/img/pic.png">'
|
||||
)
|
||||
|
||||
class _Img:
|
||||
status_code = 200
|
||||
content = b"\x89PNG-fake-bytes"
|
||||
headers = {"content-type": "image/png"}
|
||||
|
||||
def _fake_get(url, **kw):
|
||||
return _Img() if url.endswith(".png") else _Page()
|
||||
|
||||
monkeypatch.setattr(_store.httpx, "get", _fake_get)
|
||||
preview = _store.build_preview("voir https://site.test/a")
|
||||
assert preview["title"] == "Titre"
|
||||
assert preview["image"].startswith("/api/chat/attachment/")
|
||||
assert preview["image"].endswith(".png")
|
||||
stored = _store.UPLOAD_DIR / preview["image"].rsplit("/", 1)[1]
|
||||
assert stored.exists()
|
||||
assert stored.read_bytes() == b"\x89PNG-fake-bytes"
|
||||
|
||||
def test_preview_image_failure_keeps_the_card(self, monkeypatch):
|
||||
"""A dead thumbnail degrades to an empty image, never a lost card."""
|
||||
monkeypatch.setattr(_store, "_PREVIEW_CACHE", {})
|
||||
from backend.tools import web as _web
|
||||
|
||||
monkeypatch.setattr(_web, "_assert_public_http_url", lambda url: None)
|
||||
|
||||
class _Page:
|
||||
status_code = 200
|
||||
text = (
|
||||
'<meta property="og:title" content="Titre">'
|
||||
'<meta property="og:image" content="https://cdn.test/broken.jpg">'
|
||||
)
|
||||
|
||||
class _Dead:
|
||||
status_code = 404
|
||||
content = b""
|
||||
headers = {}
|
||||
|
||||
def _fake_get(url, **kw):
|
||||
return _Dead() if url.endswith(".jpg") else _Page()
|
||||
|
||||
monkeypatch.setattr(_store.httpx, "get", _fake_get)
|
||||
preview = _store.build_preview("voir https://site.test/b")
|
||||
assert preview["title"] == "Titre"
|
||||
assert preview["image"] == ""
|
||||
|
||||
|
||||
# --- #192 : accusé de réception ---------------------------------------------
|
||||
|
||||
class TestReadReceipts:
|
||||
def test_mark_read_roundtrip(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
assert _store.get_read("V", "a.md") == {}
|
||||
read = _store.mark_read("V", "a.md", "bruno")
|
||||
assert read["bruno"] > 0
|
||||
assert _store.get_read("V", "a.md") == read
|
||||
|
||||
def test_mark_read_preserves_messages(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
_store.add_message("V", "a.md", "alice", "salut")
|
||||
_store.mark_read("V", "a.md", "bruno")
|
||||
assert [m["text"] for m in _store.get_messages("V", "a.md")] == ["salut"]
|
||||
assert "bruno" in _store.get_read("V", "a.md")
|
||||
|
||||
def test_post_read_records_for_the_caller(self, client, test_vault_dir):
|
||||
r = client.post("/api/chat/read", json={"vault": "__global__", "path": "general"})
|
||||
assert r.status_code == 200, r.text
|
||||
read = r.json()["read"]
|
||||
assert read["anonymous"] > 0 # auth disabled in tests → fake user
|
||||
# the history endpoint exposes the same map (#192)
|
||||
assert client.get("/api/chat").json()["read"] == read
|
||||
|
||||
def test_post_read_refuses_a_dm_we_are_not_in(self, client, test_vault_dir):
|
||||
r = client.post("/api/chat/read", json={"vault": "__dm__", "path": "alice|bob"})
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_post_read_requires_vault_and_path(self, client, test_vault_dir):
|
||||
assert client.post("/api/chat/read", json={}).status_code == 400
|
||||
|
||||
def test_post_read_follows_vault_acl(self, client, test_vault_dir):
|
||||
r = client.post("/api/chat/read", json={"vault": "TestVault", "path": "note1.md"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert "anonymous" in r.json()["read"]
|
||||
|
||||
def test_post_read_broadcasts_chat_read(self, client, test_vault_dir, monkeypatch):
|
||||
from backend.routers import file_chat as _router
|
||||
|
||||
events = []
|
||||
|
||||
class _Spy:
|
||||
async def broadcast(self, event, data):
|
||||
events.append((event, data))
|
||||
|
||||
monkeypatch.setattr(_router, "sse_manager", _Spy())
|
||||
client.post("/api/chat/read", json={"vault": "__global__", "path": "general"})
|
||||
assert [e for e, _ in events] == ["chat_read"]
|
||||
assert events[0][1]["user"] == "anonymous"
|
||||
assert events[0][1]["vault"] == "__global__"
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# #193 — un post est rendu comme un document markdown
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
class TestMarkdownRendering:
|
||||
"""Le post porte son HTML (mistune + sanitizer), comme une prévisualisation
|
||||
de document : le client n'a plus à réimplémenter le markdown."""
|
||||
|
||||
def test_message_carries_rendered_html(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
msg = _store.add_message("V", "a.md", "u", "**gras** et `code`")
|
||||
assert "<strong>gras</strong>" in msg["html"]
|
||||
assert "<code>code</code>" in msg["html"]
|
||||
got = _store.get_messages("V", "a.md")
|
||||
assert "<strong>gras</strong>" in got[0]["html"]
|
||||
|
||||
def test_html_is_not_persisted(self, tmp_path, monkeypatch):
|
||||
"""`html` est recalculé à chaque lecture, jamais écrit dans le store."""
|
||||
chat_dir = tmp_path / "chats"
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", chat_dir)
|
||||
_store.add_message("V", "a.md", "u", "**gras**")
|
||||
raw = json.loads(next(chat_dir.glob("*.json")).read_text(encoding="utf-8"))
|
||||
assert "html" not in raw["messages"][0]
|
||||
|
||||
def test_fenced_code_block_keeps_its_language(self, tmp_path, monkeypatch):
|
||||
"""La classe `language-xxx` est ce sur quoi hljs se branche (comme dans
|
||||
le viewer)."""
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
msg = _store.add_message("V", "a.md", "u", "```python\nprint(1)\n```")
|
||||
assert 'class="language-python"' in msg["html"]
|
||||
|
||||
def test_tables_and_lists_are_rendered(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
msg = _store.add_message("V", "a.md", "u", "- un\n- deux")
|
||||
assert "<ul>" in msg["html"] and "<li>un</li>" in msg["html"]
|
||||
|
||||
def test_raw_html_is_sanitized(self, tmp_path, monkeypatch):
|
||||
"""Pas de XSS par le chat : le sanitizer des documents s'applique."""
|
||||
monkeypatch.setattr(_store, "CHAT_DIR", tmp_path / "chats")
|
||||
msg = _store.add_message("V", "a.md", "u", "<script>alert(1)</script>ok")
|
||||
assert "<script" not in msg["html"]
|
||||
|
||||
def test_routes_return_html(self, client, test_vault_dir):
|
||||
r = client.post(
|
||||
"/api/file/TestVault/chat",
|
||||
json={"path": "note1.md", "text": "## Titre\n\n- item"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
html = r.json()["message"]["html"]
|
||||
assert "<h2" in html and "<li>item</li>" in html
|
||||
r2 = client.get("/api/file/TestVault/chat", params={"path": "note1.md"})
|
||||
assert "<h2" in r2.json()["messages"][0]["html"]
|
||||
|
||||
def test_sse_broadcast_carries_html(self, client, test_vault_dir, monkeypatch):
|
||||
"""L'écho SSE part avec le html : les autres clients n'attendent pas un
|
||||
refresh pour voir le rendu."""
|
||||
from backend.routers import file_chat as _router
|
||||
|
||||
events = []
|
||||
|
||||
class _Spy:
|
||||
async def broadcast(self, event, data):
|
||||
events.append((event, data))
|
||||
|
||||
monkeypatch.setattr(_router, "sse_manager", _Spy())
|
||||
client.post("/api/chat", json={"text": "**fort**"})
|
||||
assert events and events[0][0] == "chat_message"
|
||||
assert "<strong>fort</strong>" in events[0][1]["message"]["html"]
|
||||
@@ -0,0 +1,266 @@
|
||||
# tests/test_user_home.py — #194 dossier personnel par utilisateur
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _run(coro):
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def home_env(tmp_path, monkeypatch):
|
||||
"""Racine Home + registre vaults + users.json isolés, globals sauvegardés."""
|
||||
root = tmp_path / "Home"
|
||||
root.mkdir()
|
||||
monkeypatch.setenv("OBSIGATE_HOME_ROOT", str(root))
|
||||
|
||||
from backend import indexer
|
||||
|
||||
monkeypatch.setattr(indexer, "DYNAMIC_VAULTS_FILE", tmp_path / "vaults.json")
|
||||
|
||||
from backend.auth import user_store
|
||||
|
||||
monkeypatch.setattr(user_store, "USERS_FILE", tmp_path / "users.json")
|
||||
|
||||
from backend import watcher_state
|
||||
|
||||
monkeypatch.setattr(watcher_state, "get_watcher", lambda: None)
|
||||
|
||||
saved_index = dict(indexer.index)
|
||||
saved_config = dict(indexer.vault_config)
|
||||
yield root
|
||||
indexer.index.clear()
|
||||
indexer.index.update(saved_index)
|
||||
indexer.vault_config.clear()
|
||||
indexer.vault_config.update(saved_config)
|
||||
|
||||
|
||||
def _create_user(name: str) -> dict:
|
||||
from backend.auth.user_store import create_user
|
||||
|
||||
return create_user(name, "Motdepasse1!", role="user")
|
||||
|
||||
|
||||
def _registry() -> dict:
|
||||
"""Contenu du registre dynamique (chemin monkeypatché par home_env)."""
|
||||
from backend import indexer
|
||||
|
||||
return json.loads(indexer.DYNAMIC_VAULTS_FILE.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class TestEnsureUserHome:
|
||||
def test_creates_folder_vault_and_grant(self, home_env):
|
||||
from backend.auth.user_store import get_user
|
||||
from backend.indexer import index
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
_create_user("alice")
|
||||
name = _run(ensure_user_home("alice"))
|
||||
|
||||
assert name == "home-alice"
|
||||
assert (home_env / "alice").is_dir()
|
||||
assert "home-alice" in index
|
||||
assert "home-alice" in get_user("alice")["vaults"]
|
||||
# Registre persistant rempli → survit au prochain rebuild/redémarrage
|
||||
assert "home-alice" in _registry()["vaults"]
|
||||
|
||||
def test_is_idempotent(self, home_env):
|
||||
from backend.auth.user_store import get_user
|
||||
from backend.indexer import index
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
_create_user("bob")
|
||||
_run(ensure_user_home("bob"))
|
||||
index_snapshot = dict(index)
|
||||
_run(ensure_user_home("bob"))
|
||||
|
||||
assert get_user("bob")["vaults"].count("home-bob") == 1
|
||||
assert index == index_snapshot
|
||||
|
||||
def test_invalid_username_never_touches_disk(self, home_env):
|
||||
from backend.indexer import index
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
assert _run(ensure_user_home("../evil")) is None
|
||||
assert _run(ensure_user_home("a/b")) is None
|
||||
assert not (home_env.parent / "evil").exists()
|
||||
assert not any(name.startswith("home-") for name in index)
|
||||
|
||||
def test_disabled_without_env(self, home_env, monkeypatch):
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
monkeypatch.delenv("OBSIGATE_HOME_ROOT")
|
||||
assert _run(ensure_user_home("alice")) is None
|
||||
assert not (home_env / "alice").exists()
|
||||
|
||||
|
||||
class TestPersistence:
|
||||
def test_registry_reloaded_by_load_vault_config(self, home_env):
|
||||
"""data/vaults.json doit être relu par load_vault_config (rebuild/restart)."""
|
||||
from backend import indexer
|
||||
|
||||
_create_user("carol")
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
_run(ensure_user_home("carol"))
|
||||
|
||||
# Simule un redémarrage : vault_config vidé, rechargé depuis la source
|
||||
indexer.vault_config.clear()
|
||||
config = indexer.load_vault_config()
|
||||
assert config["home-carol"]["path"] == str(home_env / "carol")
|
||||
|
||||
def test_unpersist_drops_registry_entry(self, home_env):
|
||||
from backend import indexer
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
_create_user("dan")
|
||||
_run(ensure_user_home("dan"))
|
||||
indexer.unpersist_vault("home-dan")
|
||||
assert "home-dan" not in _registry()["vaults"]
|
||||
|
||||
|
||||
class TestRelease:
|
||||
def test_release_closes_vault_but_keeps_folder(self, home_env):
|
||||
from backend.indexer import index
|
||||
from backend.user_home import ensure_user_home, release_user_home
|
||||
|
||||
_create_user("erin")
|
||||
_run(ensure_user_home("erin"))
|
||||
assert "home-erin" in index
|
||||
|
||||
_run(release_user_home("erin"))
|
||||
assert "home-erin" not in index
|
||||
assert (home_env / "erin").is_dir() # décision : dossier conservé
|
||||
assert "home-erin" not in _registry()["vaults"]
|
||||
|
||||
|
||||
class TestOrphanSweep:
|
||||
def test_orphan_home_released_when_user_deleted_outside_route(self, home_env):
|
||||
"""create_admin.py delete ne passe pas par la route → réparé au boot."""
|
||||
from backend.auth.user_store import delete_user
|
||||
from backend.indexer import index
|
||||
from backend.user_home import ensure_all_user_homes, ensure_user_home
|
||||
|
||||
_create_user("hank")
|
||||
_run(ensure_user_home("hank"))
|
||||
delete_user("hank")
|
||||
assert "home-hank" in index
|
||||
|
||||
_run(ensure_all_user_homes())
|
||||
assert "home-hank" not in index
|
||||
assert (home_env / "hank").is_dir()
|
||||
|
||||
def test_vault_named_home_outside_root_is_left_alone(self, home_env):
|
||||
"""Un vault admin nommé « home-… » ailleurs dans le FS n'est pas touché."""
|
||||
from backend.indexer import index
|
||||
from backend.user_home import ensure_all_user_homes
|
||||
|
||||
other = home_env.parent / "Elsewhere"
|
||||
other.mkdir()
|
||||
_run(add_vault("home-keep", str(other)))
|
||||
|
||||
_run(ensure_all_user_homes())
|
||||
assert "home-keep" in index
|
||||
|
||||
|
||||
def add_vault(name: str, path: str):
|
||||
from backend.indexer import add_vault_to_index
|
||||
|
||||
return add_vault_to_index(name, path)
|
||||
|
||||
|
||||
class TestIsolation:
|
||||
def test_user_cannot_access_another_home_vault(self, home_env):
|
||||
from backend.auth.middleware import check_vault_access
|
||||
from backend.auth.user_store import get_user
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
_create_user("alice")
|
||||
_create_user("bob")
|
||||
_run(ensure_user_home("alice"))
|
||||
_run(ensure_user_home("bob"))
|
||||
|
||||
assert check_vault_access("home-alice", get_user("alice")) is True
|
||||
assert check_vault_access("home-alice", get_user("bob")) is False
|
||||
assert check_vault_access("home-bob", get_user("alice")) is False
|
||||
|
||||
def test_wildcard_admin_does_not_cover_homes(self, home_env):
|
||||
"""#194 : « * » n'inclut JAMAIS un dossier perso, même pour l'admin."""
|
||||
from backend.auth.middleware import check_vault_access
|
||||
from backend.auth.user_store import create_user, get_user
|
||||
from backend.user_home import ensure_user_home
|
||||
|
||||
create_user("root", "Motdepasse1!", role="admin", vaults=["*"])
|
||||
_create_user("alice")
|
||||
_run(ensure_user_home("alice"))
|
||||
|
||||
admin = get_user("root")
|
||||
assert check_vault_access("home-alice", admin) is False # pas le sien
|
||||
assert check_vault_access("TestVault", admin) is True # « * » intact
|
||||
|
||||
# L'octroi explicite suffit (y compris pour l'admin, cf. _grant)
|
||||
_run(ensure_user_home("root"))
|
||||
assert check_vault_access("home-root", get_user("root")) is True
|
||||
|
||||
|
||||
class TestSearchFiltering:
|
||||
def test_search_service_filters_before_pagination(self, monkeypatch):
|
||||
"""#194 : /api/search ne doit pas renvoyer les notes d'un autre user."""
|
||||
import backend.search as search_mod
|
||||
from backend.services import search as search_service
|
||||
|
||||
hits = [
|
||||
{"vault": "home-bruno", "path": "secret.md"},
|
||||
{"vault": "TestVault", "path": "note.md"},
|
||||
{"vault": "home-bruno", "path": "autre.md"},
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
search_mod, "search",
|
||||
lambda q, vault_filter="all", tag_filter=None: list(hits),
|
||||
)
|
||||
|
||||
page = search_service.search_vaults("x", limit=50, is_allowed=lambda v: v == "TestVault")
|
||||
assert page["total"] == 1, "total = résultats filtrés"
|
||||
assert [r["vault"] for r in page["results"]] == ["TestVault"]
|
||||
|
||||
# Sans filtre (tool layer, qui filtre lui-même) : inchangé.
|
||||
assert search_service.search_vaults("x", limit=50)["total"] == 3
|
||||
|
||||
|
||||
class TestEnsureAll:
|
||||
def test_repairs_every_existing_user(self, home_env):
|
||||
from backend.auth.user_store import get_user
|
||||
from backend.user_home import ensure_all_user_homes
|
||||
|
||||
_create_user("fab")
|
||||
_create_user("gab")
|
||||
# Dossier supprimé à la main + user créé hors API (pas de vault)
|
||||
(home_env / "fab").mkdir()
|
||||
(home_env / "fab").rmdir()
|
||||
|
||||
created = _run(ensure_all_user_homes())
|
||||
|
||||
assert created == 2
|
||||
assert (home_env / "fab").is_dir()
|
||||
assert (home_env / "gab").is_dir()
|
||||
assert "home-fab" in get_user("fab")["vaults"]
|
||||
assert "home-gab" in get_user("gab")["vaults"]
|
||||
|
||||
|
||||
def test_home_root_env_contract():
|
||||
"""OBSIGATE_HOME_ROOT absent → fonctionnalité inactive (dev/tests/desktop)."""
|
||||
from backend.user_home import home_root
|
||||
|
||||
saved = os.environ.pop("OBSIGATE_HOME_ROOT", None)
|
||||
try:
|
||||
assert home_root() is None
|
||||
finally:
|
||||
if saved is not None:
|
||||
os.environ["OBSIGATE_HOME_ROOT"] = saved
|
||||
Reference in New Issue
Block a user