Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0ac6dfdb9 | ||
|
|
7f06606300 | ||
|
|
985af3a294 | ||
|
|
440cfdcc31 | ||
|
|
0ca92c3723 | ||
|
|
f434f30286 | ||
|
|
f4b8935189 | ||
|
|
014df322e7 | ||
|
|
39f9cc16e4 | ||
|
|
b4b3032456 | ||
|
|
edef1ea708 | ||
|
|
7523670949 | ||
|
|
d81f328859 | ||
|
|
1a39d601de | ||
|
|
68d3dd85a9 | ||
|
|
cb80b08f77 | ||
|
|
e5577b9996 | ||
|
|
9bc31d069b | ||
|
|
411667fd88 | ||
|
|
ed9029d760 | ||
|
|
1892062d88 | ||
|
|
cbe3d9bc75 | ||
|
|
2ebfe74459 | ||
|
|
6257adb46b |
+310
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
|||||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
> **En cours de développement** : les changements à venir sont listés dans la section
|
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||||
> [Unreleased](#unreleased). La dernière version livrée est **2.68.1**.
|
> [Unreleased](#unreleased). La dernière version livrée est **2.77.0**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,6 +14,315 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2.77.0] — 2026-10-23
|
||||||
|
|
||||||
|
### Modifié
|
||||||
|
|
||||||
|
- **Panneau Spark V3 : la console en surimpression pleine page
|
||||||
|
(#222, Fusion)** — le bouton « Console ⤢ » du panneau Spark
|
||||||
|
(asset vendangé, octet-identique dans les 4 apps) n'ouvre plus
|
||||||
|
la console web du service d'agents en nouvel onglet (#221) :
|
||||||
|
elle s'ouvre **dans la même page**, en surimpression née à la
|
||||||
|
largeur du panneau et étendue à toute la fenêtre par
|
||||||
|
transition, avec le lien profond conservé (`?disc=<id>` et
|
||||||
|
`?app=obsigate`). Sa barre offre les deux sorties : « Mode
|
||||||
|
sidebar » (retour à l'agent en barre latérale) et « Fermer
|
||||||
|
l'agent ». Les styles sont injectés par le panneau lui-même ;
|
||||||
|
côté service (0.20.0), la console autorise l'encadrement par
|
||||||
|
les 4 applications. Même livraison dans TikTak 0.60.0,
|
||||||
|
Flowdeck 7.81.0 et Kanbante 0.29.0. Redéploiement Docker
|
||||||
|
requis pour l'activer côté ObsiGate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.76.1] — 2026-10-22
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Panneau Spark : bouton « Console ↗ » (#221, Fusion)** — la
|
||||||
|
barre des discussions du panneau Spark (asset vendangé,
|
||||||
|
octet-identique dans les 4 apps) gagne un bouton « Console ↗ »
|
||||||
|
qui ouvre la console web du service d'agents
|
||||||
|
(https://agent.dracodev.net/) dans un nouvel onglet, sur la
|
||||||
|
discussion actuellement affichée (`?disc=<id>`, URL construite
|
||||||
|
au clic) et avec l'app d'origine (`?app=obsigate`, déduite du
|
||||||
|
sous-domaine `og`). Même livraison dans TikTak 0.59.1,
|
||||||
|
Flowdeck 7.80.1 et Kanbante 0.28.1. Redéploiement Docker
|
||||||
|
requis pour l'activer côté ObsiGate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.76.0] — 2026-10-22
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Émission d'événements vers le journal du service d'agents
|
||||||
|
(#220, Fusion — Lot 2 vague 5)** — ObsiGate émet ses mutations de
|
||||||
|
notes **explicites** au journal d'activité du service d'agents
|
||||||
|
(contrat ACC-0.2) : `obsigate.note.created / edited / moved /
|
||||||
|
deleted`, enveloppes signées HMAC-SHA256 (clé
|
||||||
|
`AGENT_EVENEMENTS_CLE`), envoyées en arrière-plan sans jamais
|
||||||
|
bloquer le métier, inactives tant que la clé n'est pas posée.
|
||||||
|
Points d'accroche : les endpoints d'écriture de l'API web (acteur
|
||||||
|
`user`) et la couche outils / serveur MCP (acteur `agent:mcp`).
|
||||||
|
**Garde-fou : jamais le watcher** — les modifications faites hors
|
||||||
|
API (Obsidian, éditeur externe) n'émettent rien. Domaine par vault
|
||||||
|
via `AGENT_EVENEMENTS_DOMAINES`, repli `pro` (Q-A ouverte).
|
||||||
|
Redéploiement Docker requis, puis pose de la clé des deux côtés
|
||||||
|
pour activer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.75.0] — 2026-10-22
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Panneau Spark v2 (#219, Fusion — Lot 1)** — les assets partagés
|
||||||
|
`frontend/draco-panneau.js` / `frontend/draco-panneau.css` passent
|
||||||
|
en v2, à l'identique dans les quatre applications : réponses de
|
||||||
|
l'agent en **Markdown rendu** (HTML produit par le service
|
||||||
|
d'agents 0.10.0, sanitizé côté serveur), **largeur réglable**
|
||||||
|
persistée, **thème clair/sombre** aligné sur l'application hôte
|
||||||
|
(suivi en direct de `html[data-theme]`), et **discussions façon
|
||||||
|
Muse** — discussion principale perpétuelle et discussions
|
||||||
|
parallèles créées, archivées, supprimées depuis le tiroir du
|
||||||
|
panneau. Aucun code propre à ObsiGate n'est modifié ;
|
||||||
|
redéploiement Docker requis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.74.0] — 2026-10-22
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Panneau Spark intégré (#218, Fusion)** — le bouton Spark de la
|
||||||
|
barre commune ouvre le panneau de l'agent commun (conversation,
|
||||||
|
Aujourd'hui, approbations, missions longues), comme dans TikTak,
|
||||||
|
Flowdeck et Kanbante : assets partagés `frontend/draco-panneau.js`
|
||||||
|
et `frontend/draco-panneau.css` vendangés à l'identique, action
|
||||||
|
`spark` branchée sur `DRACO_PANNEAU.toggle()`. La CSP nomme
|
||||||
|
`https://agent.dracodev.net` en `connect-src` (seul hôte ajouté).
|
||||||
|
Redéploiement Docker requis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.73.1] — 2026-10-21
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
|
||||||
|
- **Barre commune v0.1.4 — palier mobile élargi de 700 à 900 px**
|
||||||
|
(suite de BUG-118) : entre les deux seuils, la barre complète
|
||||||
|
ne tenait pas (fenêtres réduites, tablettes, webviews) alors
|
||||||
|
que les apps sont déjà en mise en page mobile ; le resserré
|
||||||
|
(app courante, alertes, Spark, avatar) s'applique maintenant
|
||||||
|
dès 900 px, et le décalage de `.app-container` suit le même
|
||||||
|
seuil. Le changement d'app reste assuré par le menu du ✦.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.73.0] — 2026-10-21
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Menu des applications par la marque ✦** (barre commune
|
||||||
|
**v0.1.3**, #215) : cliquer le ✦ — un peu plus gros (24 px) —
|
||||||
|
ouvre le menu des 4 applications (liens, icônes, badges), sur
|
||||||
|
mobile comme sur bureau. C'est le changement d'app en mobile,
|
||||||
|
où les onglets sont repliés depuis la v0.1.2. Fermeture au
|
||||||
|
clic extérieur ou à Échap. Choix de Bruno (2026-10-21).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.72.1] — 2026-10-21
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
|
||||||
|
- **BUG-118 — barre d'apps Fusion en mobile** : la barre commune
|
||||||
|
passe en **v0.1.2** (dracodev-commons) — au palier ≤ 700 px elle
|
||||||
|
ne garde que la marque, l'onglet de l'app courante, les alertes,
|
||||||
|
Spark et l'avatar (avant : onglets et pastilles domaines entiers,
|
||||||
|
barre débordante sur téléphone). Le bouton ☰ de la barre pilote
|
||||||
|
désormais le bon bouton selon la largeur : barre latérale bureau
|
||||||
|
au-delà de 768 px, **vrai hamburger mobile** (tiroir) en dessous —
|
||||||
|
avant, il cliquait le bouton bureau et ne faisait rien sur
|
||||||
|
téléphone, donnant l'impression d'un hamburger mort à côté de
|
||||||
|
celui d'ObsiGate. Test : `tests/test_draco_barre.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.72.0] — 2026-10-20
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Fusion ACC-3.2 (#217) — validation locale du jeton
|
||||||
|
`draco_session`** : `get_current_user` accepte le cookie partagé à
|
||||||
|
défaut de jeton natif valide ; le `sub` étant l'identifiant
|
||||||
|
ObsiGate, la correspondance vers le compte local est directe
|
||||||
|
(compte actif et garde-fou « changement de mot de passe » exigés).
|
||||||
|
Fiche : `docs/features/fusion-acc32-validation-locale-217.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.71.0] — 2026-10-20
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **Fusion ACC-3.1 (#216) — émettrice SSO `draco_session`** : ObsiGate
|
||||||
|
émet le cookie de session partagé de l'écosystème dracodev (JWS
|
||||||
|
HS256, claims minimaux, 12 h, domaine `.dracodev.net`) à chaque
|
||||||
|
connexion, le renouvelle de façon glissante (middleware dédié), le
|
||||||
|
révoque à la déconnexion (liste des `jti` exposée aux autres apps
|
||||||
|
sous clé machine) et offre `GET /sso/authorize` avec `return_to`
|
||||||
|
en liste blanche anti open-redirect. Inerte tant que
|
||||||
|
`DRACO_SESSION_SECRET` n'est pas configuré. Fiche :
|
||||||
|
`docs/features/fusion-acc31-sso-draco-session-216.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.70.1] — 2026-10-20
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
|
||||||
|
- **Sécurité des dépendances (CI `pip-audit` rouge sur 2.70.0)** —
|
||||||
|
planchers relevés dans `backend/requirements.txt`, selon le patron
|
||||||
|
déjà en place dans le fichier : `aiohttp>=3.14.4` (8 CVE corrigées en
|
||||||
|
3.14.4, dont CVE-2026-111063/111064), `mcp==1.30.0` (CVE-2026-59951),
|
||||||
|
`pillow>=13.0.0` (8 CVE corrigées en 13.0.0, dont CVE-2026-112895) et
|
||||||
|
plancher explicite `certifi>=2026.10.19` (PYSEC-2026-4320, dépendance
|
||||||
|
transitive). `pip-audit` redevient vierge ; aucune ligne de code
|
||||||
|
applicatif n'est touchée.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.70.0] — 2026-10-20
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **#215 — Fusion bloc 1 : barre d'apps commune et références croisées des
|
||||||
|
notes** (programme Fusion, issues Gitea #5/#6/#7 ; contrats
|
||||||
|
`dracodev-commons` v0.1.0) :
|
||||||
|
- **Barre d'apps commune** en tête de l'interface (snippet figé vendangé :
|
||||||
|
`frontend/draco-barre.css/.js`, markup dans `index.html`) — navigation
|
||||||
|
vers ObsiGate, Flowdeck, TikTak et Kanbante, ObsiGate marquée active,
|
||||||
|
sélecteur de domaine mémorisé ; la recherche, le rail et le menu délèguent
|
||||||
|
aux contrôles natifs d'ObsiGate.
|
||||||
|
- **Références croisées `external_ref`** : section « Liens » dans la vue
|
||||||
|
d'une note — coller une URL profonde (Flowdeck, TikTak, issue Gitea =
|
||||||
|
carte Kanbante, ObsiGate) crée la référence (libellé automatique ou
|
||||||
|
libre, pastille de couleur de l'app cible). Backend : stockage JSON par
|
||||||
|
utilisateur (`backend/external_refs.py`), endpoints `GET/POST /api/refs`
|
||||||
|
et `DELETE /api/refs/{id}`, parseur d'URL conforme au contrat figé
|
||||||
|
(422 explicite sinon), schémas et tag OpenAPI « ExternalRefs ».
|
||||||
|
- **Notes projetées TikTak reconnues** (frontmatter `tiktak_id`) : bandeau
|
||||||
|
« Tâche TikTak projetée » et bouton « Ouvrir dans TikTak » dans la même
|
||||||
|
section (module `frontend/draco-liens.js`, point d'extension
|
||||||
|
`obsigate:file-rendered` émis par `renderFile`).
|
||||||
|
- i18n FR/EN (clés `draco.links.*`) ; tests `test_external_refs.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.69.0] — 2026-10-15
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **#201 — menu contextuel « Déplacer » / « Dupliquer »** (fichiers *et* dossiers) dans la
|
||||||
|
sidebar : le modal de destination réutilise `promptTargetFolder` (celui de l'import, même
|
||||||
|
thème, sélecteur de voute + arbo filtrable), généralisé par un paramètre `opts` optionnel
|
||||||
|
(titre, résumé, libellé du bouton) ; l'emplacement actuel est pré-sélectionné ; cross-vault
|
||||||
|
refusé proprement. Nouveau endpoint `POST /api/duplicate/{vault}` (`duplicate_path` : copie
|
||||||
|
`X copie.ext`, collisions `copie 2`, `copie 3`… ; répertoires récursifs ; refus de dupliquer
|
||||||
|
un dossier dans lui-même ; index incrémental, SSE + webhooks). i18n FR/EN.
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
|
||||||
|
- **BUG-117** — drag & drop interne de la sidebar (déplacement d'un fichier/dossier vers un
|
||||||
|
autre) inopérant et silencieux depuis #89 : la détection « dépôt externe » dans `onDrop`
|
||||||
|
testait `items.length > 0`, qui comptait aussi les formats *string* (`tree:*`) posés par
|
||||||
|
`setData` au `dragstart` interne → chaque déplacement basculait dans la branche upload et
|
||||||
|
disparaissait sans appel API ni toast. Restreinte aux vrais fichiers (`kind === 'file'`,
|
||||||
|
type `Files`, `files` non vide), avec priorité au `dragState`.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.68.6] — 2026-10-14
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.68.5] — 2026-10-14
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.68.4] — 2026-10-14
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
|
||||||
|
- **BUG-116** — éditeur Forge : après insertion d'un tableau (ou bloc de code,
|
||||||
|
diagramme), la textarea restait d'une seule ligne — les mutations
|
||||||
|
programmatiques de `ta.value` ne déclenchent pas l'événement `input`, seul
|
||||||
|
chemin qui rappelait `autoHeight()`. `insertAtCursor` et `wrapSelection` (les
|
||||||
|
deux points de passage de toutes les insertions) appellent désormais
|
||||||
|
`autoHeight()` + `updateLineNumbers()` — le nombre de lignes du gutter suit.
|
||||||
|
Vérifié en Playwright : tableau 4 lignes sur document d'une ligne → hauteur
|
||||||
|
51 → 189 px, gutter cohérent.
|
||||||
|
|
||||||
|
- **BUG-115** — éditeur Forge (aperçu et menus) : trois défauts —
|
||||||
|
(1) collure case à cocher/texte dans l'aperçu des checklists : le HTML du
|
||||||
|
serveur ne sépare pas l'`<input>` du texte et `editor-poc.html` n'avait pas
|
||||||
|
la règle `.task-list-item` de `style.css` (ajoutée à son `<style>`) ;
|
||||||
|
(2) l'aperçu « flashait » : chaque frappe effaçait le rendu pour afficher
|
||||||
|
« Rendering... » — l'ancien contenu reste désormais affiché pendant le
|
||||||
|
rafraîchissement, et la dernière frappe est rejouée (flag `pending`) au lieu
|
||||||
|
d'être perdue ; (3) le menu « dimensions du tableau » (et le sélecteur de
|
||||||
|
langage) était posé en `position: absolute` avec des coordonnées viewport →
|
||||||
|
double décalage → menu coupé hors du champ sur un document d'une ligne ;
|
||||||
|
passés en `position: fixed` avec clamp viewport comme le menu slash, et
|
||||||
|
`stopPropagation` sur les clics des menus (la bulle fermait la forme
|
||||||
|
aussitôt ouverte). Vérifié en Playwright sur l'instance E2E : spacing 6 px,
|
||||||
|
aucun spinner pendant la frappe, forme entièrement visible + insertion OK.
|
||||||
|
|
||||||
|
- **BUG-114** — popup « `currentPath is not defined` » après suppression (ou
|
||||||
|
renommage) d'un répertoire : `frontend/js/ui.js` référençait `currentPath` nu
|
||||||
|
(non déclaré — la variable vit dans `state`) au lieu de `state.currentPath`
|
||||||
|
dans les branches répertoire de `confirmDeleteDirectory` et du renommage en
|
||||||
|
ligne. La `ReferenceError` était avalée par le `catch` et affichée en toast
|
||||||
|
d'erreur. Trois usages corrigés ; vérifié en Playwright (flux réel clic droit
|
||||||
|
→ Supprimer) : toast « Dossier supprimé », aucun fantôme, 0 erreur JS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.68.3] — 2026-10-14
|
||||||
|
|
||||||
|
### Amélioré
|
||||||
|
|
||||||
|
- **BUG-113 — Suppression de répertoire rapide** : `DELETE /api/directory/{vault}`
|
||||||
|
re-scannait la vault entière et reconstruisait l'index inversé global après le
|
||||||
|
`rmtree` (`reload_single_vault`) — le temps croissait avec toute la vault, pas
|
||||||
|
avec le dossier supprimé. Nouveau chemin incrémental
|
||||||
|
(`remove_directory_from_index`) qui retire uniquement les fichiers du
|
||||||
|
sous-arbre (index, paths, index inversé et sémantique, dossiers fantômes
|
||||||
|
purgés). Mesuré sur l'instance E2E : 60 fichiers supprimés en **63 ms** au
|
||||||
|
lieu de **668 ms** (~10×, écart qui grandit avec la taille du home-user).
|
||||||
|
Tests : `test_delete_directory_subtree_clears_index` (aucun fantôme dans
|
||||||
|
paths ni dans la recherche plein-texte après suppression).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.68.2] — 2026-10-14
|
||||||
|
|
||||||
|
### Corrigé
|
||||||
|
|
||||||
|
- **CI security — bandit bloquant sur B112** : `backend/services/search.py`
|
||||||
|
(`search_shared_notes` : un partage illisible était ignoré via
|
||||||
|
`except Exception: continue`) déclenchait `B112:try_except_continue`, le job
|
||||||
|
`security` sortait en rouge. Le `continue` est **délibéré** (un partage
|
||||||
|
inaccessible ne doit pas faire échouer la recherche) — `# nosec B112` posé
|
||||||
|
sur la ligne, comme les `# nosec B110` existants (`watcher.py`, `B105`,
|
||||||
|
`B324`). Bandit re-vérifié localement : 0 issue ; l'étape reste bloquante
|
||||||
|
(garde-fou `test_bandit_and_pip_audit_stay_blocking` intact).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.68.1] — 2026-10-14
|
## [2.68.1] — 2026-10-14
|
||||||
|
|
||||||
### Corrigé
|
### Corrigé
|
||||||
|
|||||||
+3
-3
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.68.1).
|
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.77.0).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Projet : ObsiGate | Version : 2.68.1 | Dernière mise à jour : Septembre 2026*
|
*Projet : ObsiGate | Version : 2.77.0 | Dernière mise à jour : Septembre 2026*
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 Changelog
|
||||||
|
|
||||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.68.1).
|
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.77.0).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Project: ObsiGate | Version: 2.68.1 | Last updated: September 2026*
|
*Project: ObsiGate | Version: 2.77.0 | Last updated: September 2026*
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Émission d'événements vers le journal du service d'agents
|
||||||
|
(Fusion, Lot 2 vague 5 — contrat ACC-0.2, Roadmap #220).
|
||||||
|
|
||||||
|
ObsiGate émet ses mutations de notes **explicites** vers
|
||||||
|
``POST /api/v1/evenements`` du service d'agents, qui les expose dans
|
||||||
|
le journal de sa console. L'enveloppe ne contient qu'une référence,
|
||||||
|
un libellé et un lien — jamais le contenu de la note :
|
||||||
|
``{id, type, occurred_at, actor, domain, source_app,
|
||||||
|
object{ref, label, url}}``.
|
||||||
|
|
||||||
|
**Garde-fou (conception validée)** : ce module n'est appelé que
|
||||||
|
depuis les points d'écriture explicites de l'API web —
|
||||||
|
:mod:`backend.routers.files_write` (session utilisateur, acteur
|
||||||
|
``user``) et la couche outils :mod:`backend.tools.service`
|
||||||
|
(serveur MCP d'ObsiGate, acteur ``agent:mcp`` ; assistant intégré,
|
||||||
|
acteur ``user``). Le **watcher** (:mod:`backend.watcher`) et
|
||||||
|
l'indexeur ne l'appellent **jamais** : le vault principal compte
|
||||||
|
plus de 220 000 fichiers, un branchement sur les événements du
|
||||||
|
système de fichiers inonderait le journal. Une modification faite
|
||||||
|
hors API (éditeur externe, synchro disque…) reste donc invisible
|
||||||
|
du journal — c'est voulu.
|
||||||
|
|
||||||
|
Authentification : signature HMAC-SHA256 du corps brut, en-tête
|
||||||
|
``X-Draco-Signature``, clé propre à ObsiGate
|
||||||
|
(``AGENT_EVENEMENTS_CLE``, jamais en dur). Sans clé, le module est
|
||||||
|
**inactif** : aucun appel, aucun bruit. URL surchargée par
|
||||||
|
``AGENT_EVENEMENTS_URL``.
|
||||||
|
|
||||||
|
Discipline « au mieux » (même esprit que TikTak et Kanbante) :
|
||||||
|
l'envoi part dans un fil d'arrière-plan démon, délai maximal 2 s,
|
||||||
|
et toute erreur est avalée avec une ligne de journal — une panne
|
||||||
|
du récepteur ne doit jamais casser ni ralentir le métier.
|
||||||
|
|
||||||
|
Domaine : ObsiGate ne modélise pas encore le domaine d'un vault
|
||||||
|
(question Q-A du programme Fusion toujours ouverte). La
|
||||||
|
correspondance peut être déclarée par ``AGENT_EVENEMENTS_DOMAINES``
|
||||||
|
(JSON ``{"NomDuVault": "famille"}``) ; repli ``pro``, qui est aussi
|
||||||
|
le défaut du journal du service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger("obsigate.agent_journal")
|
||||||
|
|
||||||
|
SOURCE_APP = "obsigate"
|
||||||
|
URL_DEFAUT = "https://agent.dracodev.net/api/v1/evenements"
|
||||||
|
DELAI_S = 2.0
|
||||||
|
|
||||||
|
TYPE_CREE = "obsigate.note.created"
|
||||||
|
TYPE_EDITE = "obsigate.note.edited"
|
||||||
|
TYPE_DEPLACE = "obsigate.note.moved"
|
||||||
|
TYPE_SUPPRIME = "obsigate.note.deleted"
|
||||||
|
|
||||||
|
ACTEUR_USER = "user"
|
||||||
|
ACTEUR_MCP = "agent:mcp"
|
||||||
|
|
||||||
|
|
||||||
|
def _cle() -> str:
|
||||||
|
return os.environ.get("AGENT_EVENEMENTS_CLE", "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _url() -> str:
|
||||||
|
return os.environ.get("AGENT_EVENEMENTS_URL", "").strip() or URL_DEFAUT
|
||||||
|
|
||||||
|
|
||||||
|
def actif() -> bool:
|
||||||
|
"""L'émission n'existe que si la clé d'ObsiGate est configurée."""
|
||||||
|
return bool(_cle())
|
||||||
|
|
||||||
|
|
||||||
|
def domaine_du_vault(vault: str) -> str:
|
||||||
|
"""Domaine journal du vault : correspondance déclarée dans
|
||||||
|
``AGENT_EVENEMENTS_DOMAINES`` (JSON), repli ``pro``."""
|
||||||
|
brut = os.environ.get("AGENT_EVENEMENTS_DOMAINES", "").strip()
|
||||||
|
if brut:
|
||||||
|
try:
|
||||||
|
correspondance = json.loads(brut)
|
||||||
|
except ValueError:
|
||||||
|
correspondance = {}
|
||||||
|
if isinstance(correspondance, dict):
|
||||||
|
domaine = correspondance.get(vault)
|
||||||
|
if isinstance(domaine, str) and domaine:
|
||||||
|
return domaine
|
||||||
|
return "pro"
|
||||||
|
|
||||||
|
|
||||||
|
def lien_fichier(vault: str, chemin: str) -> str:
|
||||||
|
"""Lien profond ObsiGate vers un fichier (format ``#file=`` lu
|
||||||
|
par le frontend : ``#file=<vault>:<chemin>``, décodé en URI)."""
|
||||||
|
return f"/#file={quote(vault, safe='')}:{quote(chemin, safe='/')}"
|
||||||
|
|
||||||
|
|
||||||
|
def construire_enveloppe(
|
||||||
|
type_: str,
|
||||||
|
vault: str,
|
||||||
|
chemin: str,
|
||||||
|
actor: str = ACTEUR_USER,
|
||||||
|
) -> tuple[dict, bytes]:
|
||||||
|
"""L'enveloppe ACC-0.2 et son corps JSON exact (celui qui est
|
||||||
|
signé puis envoyé — la signature porte sur ces octets-là).
|
||||||
|
``chemin`` est le chemin relatif au vault ; pour un déplacement,
|
||||||
|
c'est le **nouveau** chemin (la référence pointe sur l'objet tel
|
||||||
|
qu'il existe après l'opération)."""
|
||||||
|
env = {
|
||||||
|
"id": uuid.uuid4().hex,
|
||||||
|
"type": type_,
|
||||||
|
"occurred_at": datetime.now(UTC).isoformat(),
|
||||||
|
"actor": actor or ACTEUR_USER,
|
||||||
|
"domain": domaine_du_vault(vault),
|
||||||
|
"source_app": SOURCE_APP,
|
||||||
|
"object": {
|
||||||
|
"ref": f"fichier:{vault}/{chemin}",
|
||||||
|
"label": chemin.rsplit("/", 1)[-1] if chemin else vault,
|
||||||
|
"url": lien_fichier(vault, chemin),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
corps = json.dumps(env, ensure_ascii=False).encode("utf-8")
|
||||||
|
return env, corps
|
||||||
|
|
||||||
|
|
||||||
|
def _signer(corps: bytes) -> str:
|
||||||
|
return hmac.new(_cle().encode("utf-8"), corps, hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _envoyer(corps: bytes) -> None:
|
||||||
|
"""Le POST signé, dans le fil d'arrière-plan. Avale tout : le
|
||||||
|
journal distant est un confort, jamais une dépendance."""
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=DELAI_S, trust_env=False) as client:
|
||||||
|
client.post(
|
||||||
|
_url(),
|
||||||
|
content=corps,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Draco-Signature": _signer(corps),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as exc: # panne, délai, DNS… : on log et on passe
|
||||||
|
logger.warning("événement non livré au journal du service (%s)", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def emettre(
|
||||||
|
type_: str,
|
||||||
|
vault: str,
|
||||||
|
chemin: str,
|
||||||
|
actor: str = ACTEUR_USER,
|
||||||
|
) -> bool:
|
||||||
|
"""Émet un événement en tâche de fond. Retourne True si l'envoi
|
||||||
|
a été déclenché, False si le module est inactif (pas de clé) ou
|
||||||
|
si l'enveloppe n'a pas pu être construite. Ne lève jamais."""
|
||||||
|
if not actif():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
_env, corps = construire_enveloppe(type_, vault, chemin, actor)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("événement %s non construit", type_, exc_info=True)
|
||||||
|
return False
|
||||||
|
threading.Thread(
|
||||||
|
target=_envoyer, args=(corps,), daemon=True, name="obsigate-evenements"
|
||||||
|
).start()
|
||||||
|
return True
|
||||||
@@ -9,6 +9,7 @@ import sys
|
|||||||
from fastapi import Depends, HTTPException, Request
|
from fastapi import Depends, HTTPException, Request
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
from backend import draco_session
|
||||||
from backend.services.net import get_client_ip
|
from backend.services.net import get_client_ip
|
||||||
|
|
||||||
from .jwt_handler import decode_token, is_token_revoked, maybe_touch_api_token
|
from .jwt_handler import decode_token, is_token_revoked, maybe_touch_api_token
|
||||||
@@ -86,20 +87,22 @@ def get_current_user(
|
|||||||
token = request.cookies["access_token"]
|
token = request.cookies["access_token"]
|
||||||
|
|
||||||
if not token:
|
if not token:
|
||||||
return None
|
return _draco_fallback(request)
|
||||||
|
|
||||||
payload = decode_token(token)
|
payload = decode_token(token)
|
||||||
if not payload or payload.get("type") != "access":
|
if not payload or payload.get("type") != "access":
|
||||||
return None
|
return _draco_fallback(request)
|
||||||
|
|
||||||
# BUG-027: access tokens revoked at logout must be rejected immediately.
|
# BUG-027: access tokens revoked at logout must be rejected immediately.
|
||||||
|
# (Le repli draco reste sûr : la déconnexion globale révoque aussi
|
||||||
|
# le jti draco et efface son cookie.)
|
||||||
jti = payload.get("jti")
|
jti = payload.get("jti")
|
||||||
if jti and is_token_revoked(jti):
|
if jti and is_token_revoked(jti):
|
||||||
return None
|
return _draco_fallback(request)
|
||||||
|
|
||||||
user = get_user(payload["sub"])
|
user = get_user(payload["sub"])
|
||||||
if not user or not user.get("active"):
|
if not user or not user.get("active"):
|
||||||
return None
|
return _draco_fallback(request)
|
||||||
|
|
||||||
# BUG-028: a password change invalidates every token issued before it.
|
# BUG-028: a password change invalidates every token issued before it.
|
||||||
pca = user.get("password_changed_at")
|
pca = user.get("password_changed_at")
|
||||||
@@ -107,9 +110,9 @@ def get_current_user(
|
|||||||
if pca is not None and iat is not None:
|
if pca is not None and iat is not None:
|
||||||
try:
|
try:
|
||||||
if int(iat) < int(float(pca)):
|
if int(iat) < int(float(pca)):
|
||||||
return None
|
return _draco_fallback(request)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return _draco_fallback(request)
|
||||||
|
|
||||||
# Attach vault permissions from the token (snapshot at login time)
|
# Attach vault permissions from the token (snapshot at login time)
|
||||||
user["_token_vaults"] = payload.get("vaults", [])
|
user["_token_vaults"] = payload.get("vaults", [])
|
||||||
@@ -127,6 +130,17 @@ def get_current_user(
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _draco_fallback(request: Request) -> dict | None:
|
||||||
|
"""Fusion ACC-3.2 (#9) : à défaut de jeton natif valide, le cookie
|
||||||
|
partagé draco_session authentifie l'utilisateur (sub = id
|
||||||
|
ObsiGate, correspondance directe — voir draco_session.user_for_draco).
|
||||||
|
"""
|
||||||
|
user = draco_session.user_for_draco(request)
|
||||||
|
if user is not None:
|
||||||
|
user["_request_ip"] = get_client_ip(request)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
def require_auth(current_user=Depends(get_current_user)):
|
def require_auth(current_user=Depends(get_current_user)):
|
||||||
"""Dependency: require a valid authenticated user."""
|
"""Dependency: require a valid authenticated user."""
|
||||||
if not current_user:
|
if not current_user:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import re
|
|||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
|
||||||
from pydantic import BaseModel, validator
|
from pydantic import BaseModel, validator
|
||||||
|
|
||||||
|
from backend import draco_session
|
||||||
from backend.ratelimit import is_account_rate_limited, is_rate_limited
|
from backend.ratelimit import is_account_rate_limited, is_rate_limited
|
||||||
from backend.ratelimit import record_account_failure as rl_record_account_failure
|
from backend.ratelimit import record_account_failure as rl_record_account_failure
|
||||||
from backend.ratelimit import record_account_success as rl_record_account_success
|
from backend.ratelimit import record_account_success as rl_record_account_success
|
||||||
@@ -280,6 +281,11 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
|
|||||||
secure=secure,
|
secure=secure,
|
||||||
path="/",
|
path="/",
|
||||||
)
|
)
|
||||||
|
# Fusion ACC-3.1 (#216) : ObsiGate est l'émettrice SSO — chaque
|
||||||
|
# émission de jetons natifs s'accompagne du cookie partagé
|
||||||
|
# draco_session (inerte tant que DRACO_SESSION_SECRET n'est pas
|
||||||
|
# configuré).
|
||||||
|
draco_session.set_cookie(response, user, secure)
|
||||||
return {
|
return {
|
||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
# OAuth2 token_type, pas un mot de passe (B105) :
|
# OAuth2 token_type, pas un mot de passe (B105) :
|
||||||
@@ -396,6 +402,19 @@ async def logout(
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Fusion ACC-3.1 (#216) : déconnexion globale — révoquer le jti du
|
||||||
|
# cookie partagé et effacer le cookie sur le domaine parent.
|
||||||
|
draco_tok = request.cookies.get(draco_session.COOKIE_NAME)
|
||||||
|
if draco_tok:
|
||||||
|
draco_claims = draco_session.decode_claims(draco_tok)
|
||||||
|
if draco_claims and draco_claims.get("jti"):
|
||||||
|
try:
|
||||||
|
draco_session.revoke(str(draco_claims["jti"]),
|
||||||
|
int(draco_claims.get("exp", 0)))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
draco_session.clear_cookie(response)
|
||||||
|
|
||||||
response.delete_cookie("refresh_token", path="/api/auth/refresh")
|
response.delete_cookie("refresh_token", path="/api/auth/refresh")
|
||||||
response.delete_cookie("access_token", path="/")
|
response.delete_cookie("access_token", path="/")
|
||||||
response.delete_cookie("access_token", path="/api") # just in case
|
response.delete_cookie("access_token", path="/api") # just in case
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
# backend/draco_session.py
|
||||||
|
# Fusion ACC-3.1 (#216) — Émettrice SSO « draco_session » (option 2a, C2).
|
||||||
|
#
|
||||||
|
# ObsiGate est l'unique émettrice du cookie de session partagé
|
||||||
|
# ``draco_session`` (Domain=.dracodev.net) que les autres applications
|
||||||
|
# de l'écosystème (Flowdeck, TikTak, Kanbante) valideront localement
|
||||||
|
# (ACC-3.2, leurs propres tickets). Design : architecture Fusion §5.2 —
|
||||||
|
# JWS compact HS256, claims minimaux (sub, name, iat, exp 12 h, jti),
|
||||||
|
# aucun rôle dans le jeton, renouvellement glissant par l'émettrice,
|
||||||
|
# révocation par jti exposée aux autres apps.
|
||||||
|
#
|
||||||
|
# Configuration (environnement) :
|
||||||
|
# DRACO_SESSION_SECRET secret de signature partagé (obligatoire
|
||||||
|
# pour activer la fonction ; sans lui,
|
||||||
|
# aucun cookie n'est émis et rien ne change)
|
||||||
|
# DRACO_SESSION_SECRET_PREV ancien secret accepté en vérification
|
||||||
|
# pendant la fenêtre de rotation (24 h)
|
||||||
|
# DRACO_SESSION_COOKIE_DOMAIN domaine du cookie (défaut .dracodev.net ;
|
||||||
|
# vide = cookie lié à l'hôte, utile en dev)
|
||||||
|
# DRACO_SSO_MACHINE_KEY clé machine protégeant la lecture de la
|
||||||
|
# liste de révocation (GET /api/sso/revocations)
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
|
logger = logging.getLogger("obsigate.draco_session")
|
||||||
|
|
||||||
|
COOKIE_NAME = "draco_session"
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
TOKEN_TTL_SECONDS = 12 * 3600 # durée de vie du jeton (design §5.2)
|
||||||
|
RENEWAL_WINDOW_SECONDS = 2 * 3600 # réémettre si exp - now < 2 h
|
||||||
|
REVOKED_FILE = Path("data/draco_revoked.json")
|
||||||
|
ALLOWED_RETURN_SUFFIX = "dracodev.net"
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_revoked: dict[str, int] | None = None # jti -> exp (chargé paresseusement)
|
||||||
|
|
||||||
|
|
||||||
|
def _secret() -> str:
|
||||||
|
return os.environ.get("DRACO_SESSION_SECRET", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_prev() -> str:
|
||||||
|
return os.environ.get("DRACO_SESSION_SECRET_PREV", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _cookie_domain() -> str:
|
||||||
|
return os.environ.get("DRACO_SESSION_COOKIE_DOMAIN", ".dracodev.net")
|
||||||
|
|
||||||
|
|
||||||
|
def enabled() -> bool:
|
||||||
|
"""La fonction est active seulement si le secret partagé est configuré."""
|
||||||
|
return bool(_secret())
|
||||||
|
|
||||||
|
|
||||||
|
# ── Révocation (jti) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _load_revoked() -> dict[str, int]:
|
||||||
|
global _revoked
|
||||||
|
if _revoked is None:
|
||||||
|
try:
|
||||||
|
data = json.loads(REVOKED_FILE.read_text(encoding="utf-8"))
|
||||||
|
_revoked = {str(k): int(v) for k, v in data.items()} \
|
||||||
|
if isinstance(data, dict) else {}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
_revoked = {}
|
||||||
|
return _revoked
|
||||||
|
|
||||||
|
|
||||||
|
def _save_revoked() -> None:
|
||||||
|
REVOKED_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = REVOKED_FILE.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(_revoked or {}), encoding="utf-8")
|
||||||
|
tmp.replace(REVOKED_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def revoke(jti: str, exp: int) -> None:
|
||||||
|
"""Révoque un jeton par son jti (déconnexion globale)."""
|
||||||
|
with _lock:
|
||||||
|
store = _load_revoked()
|
||||||
|
store[jti] = int(exp)
|
||||||
|
now = int(time.time())
|
||||||
|
for k in [k for k, v in store.items() if v <= now]:
|
||||||
|
del store[k] # purge des expirés
|
||||||
|
_save_revoked()
|
||||||
|
|
||||||
|
|
||||||
|
def is_revoked(jti: str) -> bool:
|
||||||
|
with _lock:
|
||||||
|
return jti in _load_revoked()
|
||||||
|
|
||||||
|
|
||||||
|
def revoked_list() -> list[dict]:
|
||||||
|
"""Liste des révocations actives (pour les autres apps, cache 60 s)."""
|
||||||
|
with _lock:
|
||||||
|
now = int(time.time())
|
||||||
|
return [{"jti": jti, "exp": exp}
|
||||||
|
for jti, exp in _load_revoked().items() if exp > now]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Émission / vérification ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def issue_token(user: dict) -> tuple[str, str, int]:
|
||||||
|
"""Émet un JWS pour l'utilisateur. Retourne (jeton, jti, exp)."""
|
||||||
|
now = int(time.time())
|
||||||
|
exp = now + TOKEN_TTL_SECONDS
|
||||||
|
jti = uuid.uuid4().hex
|
||||||
|
claims = {
|
||||||
|
"sub": str(user.get("id") or user.get("username") or ""),
|
||||||
|
"name": str(user.get("display_name") or user.get("username") or ""),
|
||||||
|
"iat": now,
|
||||||
|
"exp": exp,
|
||||||
|
"jti": jti,
|
||||||
|
}
|
||||||
|
return jwt.encode(claims, _secret(), algorithm=ALGORITHM), jti, exp
|
||||||
|
|
||||||
|
|
||||||
|
def decode_claims(token: str) -> dict | None:
|
||||||
|
"""Vérifie signature et expiration (pas la révocation)."""
|
||||||
|
if not token or not enabled():
|
||||||
|
return None
|
||||||
|
for secret in (_secret(), _secret_prev()):
|
||||||
|
if not secret:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, secret, algorithms=[ALGORITHM])
|
||||||
|
except JWTError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def verify_token(token: str) -> dict | None:
|
||||||
|
"""decode_claims + contrôle de révocation du jti."""
|
||||||
|
claims = decode_claims(token)
|
||||||
|
if claims is None:
|
||||||
|
return None
|
||||||
|
if claims.get("jti") and is_revoked(str(claims["jti"])):
|
||||||
|
return None
|
||||||
|
return claims
|
||||||
|
|
||||||
|
|
||||||
|
def needs_renewal(claims: dict) -> bool:
|
||||||
|
"""Vrai si le jeton entre dans la fenêtre de renouvellement (< 2 h)."""
|
||||||
|
try:
|
||||||
|
return int(claims["exp"]) - int(time.time()) < RENEWAL_WINDOW_SECONDS
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def set_cookie(response, user: dict, secure: bool) -> None:
|
||||||
|
"""Pose le cookie draco_session sur une réponse (émission initiale)."""
|
||||||
|
if not enabled():
|
||||||
|
return
|
||||||
|
token, _jti, _exp = issue_token(user)
|
||||||
|
domain = _cookie_domain()
|
||||||
|
extra = {"domain": domain} if domain else {}
|
||||||
|
response.set_cookie(COOKIE_NAME, token, max_age=TOKEN_TTL_SECONDS,
|
||||||
|
httponly=True, samesite="lax", secure=secure,
|
||||||
|
path="/", **extra)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_cookie(response) -> None:
|
||||||
|
domain = _cookie_domain()
|
||||||
|
extra = {"domain": domain} if domain else {}
|
||||||
|
response.delete_cookie(COOKIE_NAME, path="/", **extra)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Validation locale (ACC-3.2, #9) ─────────────────────────────────────
|
||||||
|
|
||||||
|
def user_for_draco(request) -> dict | None:
|
||||||
|
"""Utilisateur ObsiGate prouvé par le cookie draco_session.
|
||||||
|
|
||||||
|
ObsiGate est à la fois émettrice et consommatrice : le `sub` du
|
||||||
|
jeton EST son identifiant d'utilisateur — la correspondance est
|
||||||
|
directe, par id. Le garde-fou « jeton antérieur au dernier
|
||||||
|
changement de mot de passe » (BUG-028 côté natif) est appliqué
|
||||||
|
à l'identique via le claim `iat`.
|
||||||
|
"""
|
||||||
|
claims = verify_token(request.cookies.get(COOKIE_NAME, ""))
|
||||||
|
if not claims:
|
||||||
|
return None
|
||||||
|
sub = str(claims.get("sub", ""))
|
||||||
|
if not sub:
|
||||||
|
return None
|
||||||
|
from backend.auth.user_store import get_all_users
|
||||||
|
|
||||||
|
for candidate in get_all_users():
|
||||||
|
if str(candidate.get("id")) != sub:
|
||||||
|
continue
|
||||||
|
if not candidate.get("active"):
|
||||||
|
return None
|
||||||
|
pca = candidate.get("password_changed_at")
|
||||||
|
iat = claims.get("iat")
|
||||||
|
if pca is not None and iat is not None:
|
||||||
|
try:
|
||||||
|
if int(iat) < int(float(pca)):
|
||||||
|
return None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
user = dict(candidate)
|
||||||
|
user["_token_vaults"] = candidate.get("vaults", [])
|
||||||
|
user["_token_scope"] = "full"
|
||||||
|
user["_draco_sub"] = sub
|
||||||
|
return user
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Renouvellement glissant (middleware) ────────────────────────────────
|
||||||
|
|
||||||
|
class DracoRenewalMiddleware:
|
||||||
|
"""Réémet le cookie quand l'utilisateur est actif chez l'émettrice.
|
||||||
|
|
||||||
|
Middleware ASGI pur (pas de BaseHTTPMiddleware : la pile d'ObsiGate
|
||||||
|
streame des réponses SSE/WS que BaseHTTPMiddleware mettrait en
|
||||||
|
tampon). On enveloppe l'envoi de la réponse : si la requête porte
|
||||||
|
un cookie d'accès natif valide et que le cookie draco est absent,
|
||||||
|
invalide ou dans sa fenêtre de renouvellement (< 2 h), on ajoute
|
||||||
|
un Set-Cookie frais aux en-têtes de la réponse. La révocation
|
||||||
|
ciblée d'un jti draco est ainsi « guérie » par la prochaine
|
||||||
|
activité authentifiée locale — la déconnexion globale, elle,
|
||||||
|
révoque aussi les jetons natifs, donc plus rien n'est réémis.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app):
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
if scope.get("type") != "http" or not enabled():
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
cookie_header = ""
|
||||||
|
for name, value in scope.get("headers") or []:
|
||||||
|
if name == b"cookie":
|
||||||
|
cookie_header = value.decode("latin-1")
|
||||||
|
break
|
||||||
|
cookies = {}
|
||||||
|
for part in cookie_header.split(";"):
|
||||||
|
if "=" in part:
|
||||||
|
k, v = part.strip().split("=", 1)
|
||||||
|
cookies[k] = v
|
||||||
|
user = self._user_from_native(cookies.get("access_token", ""))
|
||||||
|
if user is None:
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
claims = verify_token(cookies.get(COOKIE_NAME, "")) \
|
||||||
|
if cookies.get(COOKIE_NAME) else None
|
||||||
|
if claims is not None and not needs_renewal(claims):
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
token, _jti, _exp = issue_token(user)
|
||||||
|
domain = _cookie_domain()
|
||||||
|
cookie = (f"{COOKIE_NAME}={token}; Max-Age={TOKEN_TTL_SECONDS}; "
|
||||||
|
f"Path=/; HttpOnly; SameSite=lax")
|
||||||
|
if domain:
|
||||||
|
cookie += f"; Domain={domain}"
|
||||||
|
if scope.get("scheme") == "https":
|
||||||
|
cookie += "; Secure"
|
||||||
|
|
||||||
|
async def send_with_cookie(message):
|
||||||
|
if message.get("type") == "http.response.start":
|
||||||
|
headers = list(message.get("headers") or [])
|
||||||
|
headers.append((b"set-cookie", cookie.encode("latin-1")))
|
||||||
|
message = {**message, "headers": headers}
|
||||||
|
await send(message)
|
||||||
|
|
||||||
|
await self.app(scope, receive, send_with_cookie)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _user_from_native(access_token: str):
|
||||||
|
"""Utilisateur local prouvé par le cookie d'accès natif, ou None."""
|
||||||
|
if not access_token:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from backend.auth.jwt_handler import decode_token
|
||||||
|
from backend.auth.user_store import get_user
|
||||||
|
payload = decode_token(access_token)
|
||||||
|
if not payload or payload.get("type") != "access":
|
||||||
|
return None
|
||||||
|
return get_user(str(payload.get("sub", "")))
|
||||||
|
except Exception: # le renouvellement ne casse jamais une requête
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── return_to (anti open-redirect) ──────────────────────────────────────
|
||||||
|
|
||||||
|
def validate_return_to(url: str) -> str | None:
|
||||||
|
"""Accepte uniquement https vers dracodev.net ou un sous-domaine."""
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
parsed = urlparse(url)
|
||||||
|
host = (parsed.hostname or "").lower()
|
||||||
|
if parsed.scheme != "https" or not host:
|
||||||
|
return None
|
||||||
|
if host == ALLOWED_RETURN_SUFFIX or host.endswith("." + ALLOWED_RETURN_SUFFIX):
|
||||||
|
return url
|
||||||
|
return None
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""Références croisées ``external_ref`` des notes — Fusion #215 (ACC-1.3).
|
||||||
|
|
||||||
|
Modèle figé par le programme Fusion dans ``dracodev-commons``
|
||||||
|
(``contrats/external_ref.schema.json``, v0.1.0) : la référence vit chez
|
||||||
|
le propriétaire de l'objet source — ici la note ObsiGate, identifiée
|
||||||
|
par ``(vault, path)`` — et pointe vers un objet d'une autre application
|
||||||
|
(projet ou page Flowdeck, tâche ou liste TikTak, carte Kanbante, note
|
||||||
|
ObsiGate) par son URL profonde (``contrats/liens-profonds.md``). Une
|
||||||
|
seule source de vérité par objet : on stocke des références, jamais
|
||||||
|
des copies du contenu cible.
|
||||||
|
|
||||||
|
Stockage : JSON par utilisateur dans ``data/external_refs/``, même
|
||||||
|
patron que :mod:`backend.history` (pas de base de données, cf.
|
||||||
|
``AGENTS.md``). Le répertoire est surchargeable par la variable
|
||||||
|
d'environnement ``OBSIGATE_EXTERNAL_REFS_DIR`` (tests).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import parse_qs, unquote, urlparse
|
||||||
|
|
||||||
|
logger = logging.getLogger("obsigate.external_refs")
|
||||||
|
|
||||||
|
|
||||||
|
def _refs_dir() -> Path:
|
||||||
|
override = os.environ.get("OBSIGATE_EXTERNAL_REFS_DIR")
|
||||||
|
directory = Path(override) if override else Path("data/external_refs")
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
return directory
|
||||||
|
|
||||||
|
|
||||||
|
def _refs_file(username: str) -> Path:
|
||||||
|
return _refs_dir() / f"{username}_refs.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_refs(username: str) -> list[dict[str, Any]]:
|
||||||
|
file = _refs_file(username)
|
||||||
|
if not file.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
data = json.loads(file.read_text(encoding="utf-8"))
|
||||||
|
return data if isinstance(data, list) else []
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to read external refs from {file.name}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _write_refs(username: str, refs: list[dict[str, Any]]) -> None:
|
||||||
|
file = _refs_file(username)
|
||||||
|
try:
|
||||||
|
tmp = file.with_suffix(".tmp")
|
||||||
|
tmp.write_text(json.dumps(refs, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
shutil.move(str(tmp), str(file))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to write external refs to {file.name}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ parseur d'URL
|
||||||
|
|
||||||
|
def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
|
||||||
|
"""Reconnaît une URL profonde des applications dracodev.
|
||||||
|
|
||||||
|
Formes figées par ``dracodev-commons/contrats/liens-profonds.md``.
|
||||||
|
Retourne ``(target_app, target_type, target_id, label_par_défaut)``
|
||||||
|
ou ``None`` si l'URL ne correspond à aucune forme du contrat.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
u = urlparse((url or "").strip())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
host = (u.hostname or "").lower()
|
||||||
|
if host == "tiktak.dracodev.net":
|
||||||
|
q = parse_qs(u.query)
|
||||||
|
if q.get("task"):
|
||||||
|
return ("tiktak", "task", q["task"][0], f"TikTak · tâche {q['task'][0]}")
|
||||||
|
if q.get("list"):
|
||||||
|
return ("tiktak", "list", q["list"][0], f"TikTak · liste {q['list'][0]}")
|
||||||
|
return None
|
||||||
|
if host == "og.dracodev.net":
|
||||||
|
frag = u.fragment or ""
|
||||||
|
if frag.startswith("file="):
|
||||||
|
cible = unquote(frag[5:])
|
||||||
|
if ":" in cible:
|
||||||
|
nom = cible.rsplit("/", 1)[-1]
|
||||||
|
return ("obsigate", "note", cible, f"ObsiGate · {nom}")
|
||||||
|
return None
|
||||||
|
if host == "flowdeck.dracodev.net":
|
||||||
|
m = re.fullmatch(r"/pages/(\d+)", u.path or "")
|
||||||
|
if m:
|
||||||
|
return ("flowdeck", "page", m.group(1), f"Flowdeck · page {m.group(1)}")
|
||||||
|
m = re.fullmatch(r"/collections/(\d+)", u.path or "")
|
||||||
|
if m:
|
||||||
|
return ("flowdeck", "project", m.group(1), f"Flowdeck · projet {m.group(1)}")
|
||||||
|
return None
|
||||||
|
if host == "git.dracodev.net":
|
||||||
|
m = re.fullmatch(r"/([^/]+)/([^/]+)/issues/(\d+)", u.path or "")
|
||||||
|
if m:
|
||||||
|
owner, repo, num = m.groups()
|
||||||
|
return ("kanbante", "card", f"{owner}/{repo}#{num}", f"Kanbante · {repo} #{num}")
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ CRUD
|
||||||
|
|
||||||
|
def list_refs(username: str, vault: str, path: str) -> list[dict[str, Any]]:
|
||||||
|
"""Références posées sur une note, dans l'ordre de création."""
|
||||||
|
if not username:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
r for r in _read_refs(username)
|
||||||
|
if r.get("owner_vault") == vault and r.get("owner_path") == path
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_ref(username: str, ref_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Une référence par son identifiant, ou ``None``."""
|
||||||
|
for r in _read_refs(username):
|
||||||
|
if r.get("id") == ref_id:
|
||||||
|
return r
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def add_ref(
|
||||||
|
username: str,
|
||||||
|
vault: str,
|
||||||
|
path: str,
|
||||||
|
target_app: str,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
target_url: str,
|
||||||
|
label: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Ajoute une référence à une note et la retourne (avec son ``id``)."""
|
||||||
|
refs = _read_refs(username)
|
||||||
|
ref = {
|
||||||
|
"id": uuid.uuid4().hex,
|
||||||
|
"owner_type": "obsigate.note",
|
||||||
|
"owner_id": f"{vault}:{path}",
|
||||||
|
"owner_vault": vault,
|
||||||
|
"owner_path": path,
|
||||||
|
"target_app": target_app,
|
||||||
|
"target_type": target_type,
|
||||||
|
"target_id": target_id,
|
||||||
|
"target_url": target_url,
|
||||||
|
"label": label,
|
||||||
|
"domain": None,
|
||||||
|
"status": "ok",
|
||||||
|
"created_by": "user",
|
||||||
|
"created_at": datetime.now(UTC).isoformat(),
|
||||||
|
}
|
||||||
|
refs.append(ref)
|
||||||
|
_write_refs(username, refs)
|
||||||
|
return ref
|
||||||
|
|
||||||
|
|
||||||
|
def delete_ref(username: str, ref_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Retire une référence ; retourne la référence retirée ou ``None``."""
|
||||||
|
refs = _read_refs(username)
|
||||||
|
kept = [r for r in refs if r.get("id") != ref_id]
|
||||||
|
if len(kept) == len(refs):
|
||||||
|
return None
|
||||||
|
removed = next(r for r in refs if r.get("id") == ref_id)
|
||||||
|
_write_refs(username, kept)
|
||||||
|
return removed
|
||||||
@@ -1312,6 +1312,52 @@ async def handle_file_move(vault_name: str, src_abs: str, dest_abs: str) -> dict
|
|||||||
return await update_single_file(vault_name, dest_abs)
|
return await update_single_file(vault_name, dest_abs)
|
||||||
|
|
||||||
|
|
||||||
|
async def remove_directory_from_index(vault_name: str, dir_rel_path: str) -> int:
|
||||||
|
"""Remove a directory subtree from the index without a full vault re-scan.
|
||||||
|
|
||||||
|
Incremental equivalent of ``reload_single_vault`` for deletions : every
|
||||||
|
indexed file under *dir_rel_path* is dropped via
|
||||||
|
``_remove_file_from_structures`` (which notifies the inverted and semantic
|
||||||
|
indexes through the change hook), and the subtree's directory entries are
|
||||||
|
purged from ``path_index``. Returns the number of files removed.
|
||||||
|
|
||||||
|
Used by :func:`api_directory_delete` — previously a ``rmtree`` was followed
|
||||||
|
by a full vault re-scan + global inverted-index rebuild, which is linear in
|
||||||
|
the whole vault size on every single directory deletion.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vault_name: Name of the vault.
|
||||||
|
dir_rel_path: Directory path relative to the vault root.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of indexed files removed.
|
||||||
|
"""
|
||||||
|
vault_data = index.get(vault_name)
|
||||||
|
if not vault_data:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
prefix = dir_rel_path.rstrip("/") + "/"
|
||||||
|
targets = [f["path"] for f in vault_data.get("files", []) if f["path"].startswith(prefix)]
|
||||||
|
|
||||||
|
lock = _get_async_lock()
|
||||||
|
async with lock:
|
||||||
|
removed = 0
|
||||||
|
for rel_path in targets:
|
||||||
|
if _remove_file_from_structures(vault_name, rel_path):
|
||||||
|
removed += 1
|
||||||
|
# Purge the subtree's directory entries too, otherwise empty dirs stay
|
||||||
|
# in path_index as ghosts (file entries were removed per-file above).
|
||||||
|
if vault_name in path_index:
|
||||||
|
path_index[vault_name] = [
|
||||||
|
p for p in path_index[vault_name]
|
||||||
|
if not (p["path"] == dir_rel_path or p["path"].startswith(prefix))
|
||||||
|
]
|
||||||
|
|
||||||
|
if removed:
|
||||||
|
logger.info(f"Directory removed from index: {vault_name}/{dir_rel_path} ({removed} files)")
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
async def remove_vault_from_index(vault_name: str):
|
async def remove_vault_from_index(vault_name: str):
|
||||||
"""Remove an entire vault from the index.
|
"""Remove an entire vault from the index.
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -191,7 +191,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||||||
f"script-src 'self' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
|
f"script-src 'self' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
|
||||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
|
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
|
||||||
"img-src 'self' data: blob:; "
|
"img-src 'self' data: blob:; "
|
||||||
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
|
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://agent.dracodev.net; "
|
||||||
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
|
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
|
||||||
"worker-src 'self' blob:; "
|
"worker-src 'self' blob:; "
|
||||||
"frame-src 'self' blob:; "
|
"frame-src 'self' blob:; "
|
||||||
@@ -493,6 +493,12 @@ from backend.auth.scope import TokenScopeMiddleware
|
|||||||
|
|
||||||
app.add_middleware(TokenScopeMiddleware)
|
app.add_middleware(TokenScopeMiddleware)
|
||||||
|
|
||||||
|
# Fusion ACC-3.1 (#216) : renouvellement glissant du cookie SSO
|
||||||
|
# draco_session (inerte tant que DRACO_SESSION_SECRET n'est pas défini).
|
||||||
|
from backend.draco_session import DracoRenewalMiddleware
|
||||||
|
|
||||||
|
app.add_middleware(DracoRenewalMiddleware)
|
||||||
|
|
||||||
# Explicit same-origin CORS policy (#87 T8 — finit BUG-034).
|
# Explicit same-origin CORS policy (#87 T8 — finit BUG-034).
|
||||||
# `allow_origins=[]` : le navigateur n'émet aucun `Access-Control-Allow-*`,
|
# `allow_origins=[]` : le navigateur n'émet aucun `Access-Control-Allow-*`,
|
||||||
# donc toute lecture cross-origin est refusée (défense explicite, plus
|
# donc toute lecture cross-origin est refusée (défense explicite, plus
|
||||||
@@ -522,6 +528,7 @@ from backend.routers.config import _load_config
|
|||||||
from backend.routers.config import router as config_router
|
from backend.routers.config import router as config_router
|
||||||
from backend.routers.conflicts import router as conflicts_router
|
from backend.routers.conflicts import router as conflicts_router
|
||||||
from backend.routers.duplicates import router as duplicates_router
|
from backend.routers.duplicates import router as duplicates_router
|
||||||
|
from backend.routers.external_refs import router as external_refs_router
|
||||||
from backend.routers.file_chat import router as file_chat_router
|
from backend.routers.file_chat import router as file_chat_router
|
||||||
from backend.routers.files_media import router as files_media_router
|
from backend.routers.files_media import router as files_media_router
|
||||||
from backend.routers.files_read import router as files_read_router
|
from backend.routers.files_read import router as files_read_router
|
||||||
@@ -533,6 +540,7 @@ from backend.routers.realtime import router as realtime_router
|
|||||||
from backend.routers.scheduler import router as scheduler_router
|
from backend.routers.scheduler import router as scheduler_router
|
||||||
from backend.routers.search import router as search_router
|
from backend.routers.search import router as search_router
|
||||||
from backend.routers.sharing import router as sharing_router
|
from backend.routers.sharing import router as sharing_router
|
||||||
|
from backend.routers.sso import router as sso_router
|
||||||
from backend.routers.vaults import router as vaults_router
|
from backend.routers.vaults import router as vaults_router
|
||||||
from backend.routers.webhooks import router as webhooks_router
|
from backend.routers.webhooks import router as webhooks_router
|
||||||
from backend.skills_routes import router as skills_router
|
from backend.skills_routes import router as skills_router
|
||||||
@@ -543,6 +551,8 @@ app.include_router(bookslm_router)
|
|||||||
app.include_router(skills_router)
|
app.include_router(skills_router)
|
||||||
app.include_router(health_router) # ROADMAP #85 T1 — System / health
|
app.include_router(health_router) # ROADMAP #85 T1 — System / health
|
||||||
app.include_router(history_router) # ROADMAP #85 T8 — History
|
app.include_router(history_router) # ROADMAP #85 T8 — History
|
||||||
|
app.include_router(external_refs_router) # Fusion #215 — Références croisées (ACC-1.3)
|
||||||
|
app.include_router(sso_router) # Fusion #216 — Émettrice SSO draco_session (ACC-3.1)
|
||||||
app.include_router(realtime_router) # ROADMAP #85 T9 — SSE + collab WS
|
app.include_router(realtime_router) # ROADMAP #85 T9 — SSE + collab WS
|
||||||
app.include_router(search_router) # ROADMAP #85 T5 — Search
|
app.include_router(search_router) # ROADMAP #85 T5 — Search
|
||||||
app.include_router(backups_router) # ROADMAP #85 T4 — Backups
|
app.include_router(backups_router) # ROADMAP #85 T4 — Backups
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ TAGS_METADATA: list[dict[str, str]] = [
|
|||||||
{"name": "Vaults", "description": "List, add and remove vaults; per-vault display settings; attachment indexing."},
|
{"name": "Vaults", "description": "List, add and remove vaults; per-vault display settings; attachment indexing."},
|
||||||
{"name": "Search", "description": "Full-text and advanced search, tags, title suggestions and the link graph."},
|
{"name": "Search", "description": "Full-text and advanced search, tags, title suggestions and the link graph."},
|
||||||
{"name": "Bookmarks", "description": "Recently opened files, bookmarks and saved searches."},
|
{"name": "Bookmarks", "description": "Recently opened files, bookmarks and saved searches."},
|
||||||
|
{"name": "ExternalRefs", "description": "Cross-application references from a note to Flowdeck, TikTak, Kanbante or ObsiGate objects (Fusion program, ACC-1.3)."},
|
||||||
|
{"name": "SSO", "description": "draco_session single sign-on emitter: authorize entry point and jti revocation list for the other dracodev apps (Fusion program, ACC-3.1)."},
|
||||||
{"name": "Backups", "description": "Automatic file backups, diffs, restore, compression and purge."},
|
{"name": "Backups", "description": "Automatic file backups, diffs, restore, compression and purge."},
|
||||||
{"name": "Export", "description": "Export notes or whole vaults to HTML, Markdown bundle or ePub."},
|
{"name": "Export", "description": "Export notes or whole vaults to HTML, Markdown bundle or ePub."},
|
||||||
{"name": "Guide", "description": "Download the in-app user guide as Markdown or PDF (mirrors the help modal, FR/EN)."},
|
{"name": "Guide", "description": "Download the in-app user guide as Markdown or PDF (mirrors the help modal, FR/EN)."},
|
||||||
@@ -114,6 +116,8 @@ _TAG_RULES: list[tuple[re.Pattern[str], str]] = [
|
|||||||
(re.compile(r"^/api/graph"), "Search"),
|
(re.compile(r"^/api/graph"), "Search"),
|
||||||
(re.compile(r"^/api/recent"), "Bookmarks"),
|
(re.compile(r"^/api/recent"), "Bookmarks"),
|
||||||
(re.compile(r"^/api/bookmarks"), "Bookmarks"),
|
(re.compile(r"^/api/bookmarks"), "Bookmarks"),
|
||||||
|
(re.compile(r"^/api/refs"), "ExternalRefs"),
|
||||||
|
(re.compile(r"^/api/sso"), "SSO"),
|
||||||
(re.compile(r"^/api/saved-searches"), "Bookmarks"),
|
(re.compile(r"^/api/saved-searches"), "Bookmarks"),
|
||||||
(re.compile(r"^/api/vaults"), "Vaults"),
|
(re.compile(r"^/api/vaults"), "Vaults"),
|
||||||
(re.compile(r"^/api/vault/"), "Vaults"),
|
(re.compile(r"^/api/vault/"), "Vaults"),
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ python-frontmatter==1.1.0
|
|||||||
mistune==3.3.3
|
mistune==3.3.3
|
||||||
python-multipart==0.0.31
|
python-multipart==0.0.31
|
||||||
aiofiles==23.2.1
|
aiofiles==23.2.1
|
||||||
aiohttp>=3.9.0
|
# Plancher de sécurité : 3.14.3 est vulnérable à 8 CVE (dont
|
||||||
|
# CVE-2026-111063/111064 et GHSA-2g87-pp6c-29x5), toutes corrigées en
|
||||||
|
# 3.14.4 — pip-audit (CI, bloquant) exige de rester au-dessus.
|
||||||
|
aiohttp>=3.14.4
|
||||||
watchdog>=4.0.0
|
watchdog>=4.0.0
|
||||||
argon2-cffi>=23.1.0
|
argon2-cffi>=23.1.0
|
||||||
python-jose>=3.3.0
|
python-jose>=3.3.0
|
||||||
@@ -25,7 +28,9 @@ segno>=1.5.0
|
|||||||
webauthn==2.6.0
|
webauthn==2.6.0
|
||||||
psutil>=5.9
|
psutil>=5.9
|
||||||
pywebpush>=2.3.0
|
pywebpush>=2.3.0
|
||||||
mcp==1.28.1
|
# 1.28.1 vulnérable (CVE-2026-59951, fix 1.30.0) — épinglé sur la
|
||||||
|
# première version corrigée de la même ligne 1.x.
|
||||||
|
mcp==1.30.0
|
||||||
# Plancher de sécurité (BUG-091, BUG-095) : pyjwt est une dépendance transitive
|
# Plancher de sécurité (BUG-091, BUG-095) : pyjwt est une dépendance transitive
|
||||||
# (mcp). 2.12.x → PYSEC-2026-178 (fix 2.13.0) ; 2.13.0 → CVE-2026-102274
|
# (mcp). 2.12.x → PYSEC-2026-178 (fix 2.13.0) ; 2.13.0 → CVE-2026-102274
|
||||||
# (fix 2.14.0). pip-audit étant bloquant, on reste au-dessus du dernier correctif.
|
# (fix 2.14.0). pip-audit étant bloquant, on reste au-dessus du dernier correctif.
|
||||||
@@ -36,7 +41,9 @@ xlrd==2.0.2
|
|||||||
odfpy==1.4.1
|
odfpy==1.4.1
|
||||||
python-docx>=1.1
|
python-docx>=1.1
|
||||||
reportlab>=4.0
|
reportlab>=4.0
|
||||||
pillow>=10.0
|
# Plancher de sécurité : 12.3.0 est vulnérable à 8 CVE (dont
|
||||||
|
# CVE-2026-112895 et CVE-2026-112400), toutes corrigées en 13.0.0.
|
||||||
|
pillow>=13.0.0
|
||||||
# Plancher urllib3 >= 2.8.0 (CVE-2026-97687, CVE-2026-97688, CVE-2026-97689)
|
# Plancher urllib3 >= 2.8.0 (CVE-2026-97687, CVE-2026-97688, CVE-2026-97689)
|
||||||
urllib3>=2.8.0
|
urllib3>=2.8.0
|
||||||
# Plancher de sécurité (CVE-2026-104874, fix 6.9.1) : multidict est transitive
|
# Plancher de sécurité (CVE-2026-104874, fix 6.9.1) : multidict est transitive
|
||||||
@@ -44,3 +51,7 @@ urllib3>=2.8.0
|
|||||||
# l'image du runner — sans plancher, pip répond « already satisfied » et
|
# l'image du runner — sans plancher, pip répond « already satisfied » et
|
||||||
# n'aligne jamais (même piège que pypdf, BUG-093).
|
# n'aligne jamais (même piège que pypdf, BUG-093).
|
||||||
multidict>=6.9.1
|
multidict>=6.9.1
|
||||||
|
# Plancher de sécurité (PYSEC-2026-4320, fix 2026.10.19) : certifi est
|
||||||
|
# transitive (httpx/requests) — sans plancher explicite, pip-audit
|
||||||
|
# signale la version héritée de l'environnement.
|
||||||
|
certifi>=2026.10.19
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Endpoints des références croisées des notes — Fusion #215 (ACC-1.3).
|
||||||
|
|
||||||
|
Création par collage d'une URL profonde d'une autre application
|
||||||
|
(Flowdeck, TikTak, Kanbante, ObsiGate) — zéro couplage serveur :
|
||||||
|
l'URL est reconnue par les formes figées du contrat
|
||||||
|
``dracodev-commons/contrats/liens-profonds.md`` (voir
|
||||||
|
:mod:`backend.external_refs`), la référence est stockée chez la note
|
||||||
|
propriétaire et affichée dans sa section « Liens ».
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from backend.auth.middleware import check_vault_access, require_auth
|
||||||
|
from backend.external_refs import add_ref, delete_ref, get_ref, list_refs, parse_deep_link
|
||||||
|
from backend.schemas import ExternalRef, ExternalRefCreateRequest, ExternalRefsResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger("obsigate")
|
||||||
|
|
||||||
|
router = APIRouter(tags=["ExternalRefs"])
|
||||||
|
|
||||||
|
_FORMES_ACCEPTEES = (
|
||||||
|
"URL non reconnue — formes acceptées : TikTak ?task= ou ?list=, "
|
||||||
|
"ObsiGate #file=, Flowdeck /pages/ ou /collections/, "
|
||||||
|
"issue Gitea (carte Kanbante)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/refs", response_model=ExternalRefsResponse)
|
||||||
|
async def api_list_refs(
|
||||||
|
vault: str = Query(...),
|
||||||
|
path: str = Query(...),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Références croisées posées sur une note."""
|
||||||
|
if not check_vault_access(vault, current_user):
|
||||||
|
raise HTTPException(status_code=403, detail="Accès au vault refusé")
|
||||||
|
username = current_user.get("username") or ""
|
||||||
|
return {"refs": list_refs(username, vault, path)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/refs", response_model=ExternalRef, status_code=201)
|
||||||
|
async def api_add_ref(
|
||||||
|
payload: ExternalRefCreateRequest,
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Crée une référence sur une note à partir d'une URL profonde collée."""
|
||||||
|
if not check_vault_access(payload.vault, current_user):
|
||||||
|
raise HTTPException(status_code=403, detail="Accès au vault refusé")
|
||||||
|
parsed = parse_deep_link(payload.url)
|
||||||
|
if parsed is None:
|
||||||
|
raise HTTPException(status_code=422, detail=_FORMES_ACCEPTEES)
|
||||||
|
target_app, target_type, target_id, default_label = parsed
|
||||||
|
label = (payload.label or "").strip() or default_label
|
||||||
|
username = current_user.get("username") or ""
|
||||||
|
return add_ref(
|
||||||
|
username,
|
||||||
|
payload.vault,
|
||||||
|
payload.path,
|
||||||
|
target_app,
|
||||||
|
target_type,
|
||||||
|
target_id,
|
||||||
|
payload.url.strip(),
|
||||||
|
label,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/refs/{ref_id}", response_model=ExternalRef)
|
||||||
|
async def api_delete_ref(
|
||||||
|
ref_id: str,
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Retire une référence d'une note."""
|
||||||
|
username = current_user.get("username") or ""
|
||||||
|
ref = get_ref(username, ref_id)
|
||||||
|
if ref is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Lien introuvable")
|
||||||
|
if not check_vault_access(ref["owner_vault"], current_user):
|
||||||
|
raise HTTPException(status_code=403, detail="Accès au vault refusé")
|
||||||
|
removed = delete_ref(username, ref_id)
|
||||||
|
if removed is None: # course improbable : disparue entre get et delete
|
||||||
|
raise HTTPException(status_code=404, detail="Lien introuvable")
|
||||||
|
return removed
|
||||||
@@ -15,6 +15,7 @@ from typing import Any
|
|||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||||
|
|
||||||
|
from backend import agent_journal
|
||||||
from backend.audit import log_file_delete, log_file_save
|
from backend.audit import log_file_delete, log_file_save
|
||||||
from backend.auth.middleware import check_vault_access, require_auth
|
from backend.auth.middleware import check_vault_access, require_auth
|
||||||
from backend.history import (
|
from backend.history import (
|
||||||
@@ -34,6 +35,8 @@ from backend.schemas import (
|
|||||||
FileCreateRequest,
|
FileCreateRequest,
|
||||||
FileCreateResponse,
|
FileCreateResponse,
|
||||||
FileDeleteResponse,
|
FileDeleteResponse,
|
||||||
|
FileDuplicateRequest,
|
||||||
|
FileDuplicateResponse,
|
||||||
FileMoveRequest,
|
FileMoveRequest,
|
||||||
FileMoveResponse,
|
FileMoveResponse,
|
||||||
FileRenameRequest,
|
FileRenameRequest,
|
||||||
@@ -55,6 +58,9 @@ from backend.services.mutations import (
|
|||||||
from backend.services.mutations import (
|
from backend.services.mutations import (
|
||||||
delete_file as service_delete_file,
|
delete_file as service_delete_file,
|
||||||
)
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
duplicate_path as service_duplicate_path,
|
||||||
|
)
|
||||||
from backend.services.mutations import (
|
from backend.services.mutations import (
|
||||||
edit_file as service_edit_file,
|
edit_file as service_edit_file,
|
||||||
)
|
)
|
||||||
@@ -128,6 +134,9 @@ async def api_file_save(
|
|||||||
client_ip = current_user.get("_request_ip", "unknown")
|
client_ip = current_user.get("_request_ip", "unknown")
|
||||||
log_file_save(current_user["username"], vault_name, path, len(content), client_ip)
|
log_file_save(current_user["username"], vault_name, path, len(content), client_ip)
|
||||||
|
|
||||||
|
# Journal du service d'agents (#220) — émission « au mieux », API seulement.
|
||||||
|
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
|
||||||
|
|
||||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
||||||
|
|
||||||
|
|
||||||
@@ -196,6 +205,7 @@ def api_file_xlsx_save(
|
|||||||
sum(len(str(v)) for v in cells.values()),
|
sum(len(str(v)) for v in cells.values()),
|
||||||
current_user.get("_request_ip", "unknown"),
|
current_user.get("_request_ip", "unknown"),
|
||||||
)
|
)
|
||||||
|
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
|
||||||
return {
|
return {
|
||||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||||
"size": result["size"], "revision": result.get("revision"),
|
"size": result["size"], "revision": result.get("revision"),
|
||||||
@@ -245,6 +255,7 @@ def api_file_csv_save(
|
|||||||
sum(len(str(v)) for v in cells.values()),
|
sum(len(str(v)) for v in cells.values()),
|
||||||
current_user.get("_request_ip", "unknown"),
|
current_user.get("_request_ip", "unknown"),
|
||||||
)
|
)
|
||||||
|
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
|
||||||
return {
|
return {
|
||||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||||
"size": result["size"], "revision": result.get("revision"),
|
"size": result["size"], "revision": result.get("revision"),
|
||||||
@@ -308,6 +319,7 @@ def api_file_xlsx_structure(
|
|||||||
len(actions),
|
len(actions),
|
||||||
current_user.get("_request_ip", "unknown"),
|
current_user.get("_request_ip", "unknown"),
|
||||||
)
|
)
|
||||||
|
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
|
||||||
return {
|
return {
|
||||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||||
"size": len(result["applied"]), "revision": result.get("revision"),
|
"size": len(result["applied"]), "revision": result.get("revision"),
|
||||||
@@ -376,6 +388,7 @@ def api_file_xlsx_style(
|
|||||||
len(ops),
|
len(ops),
|
||||||
current_user.get("_request_ip", "unknown"),
|
current_user.get("_request_ip", "unknown"),
|
||||||
)
|
)
|
||||||
|
agent_journal.emettre(agent_journal.TYPE_EDITE, vault_name, result["path"])
|
||||||
return {
|
return {
|
||||||
"status": "ok", "vault": result["vault"], "path": result["path"],
|
"status": "ok", "vault": result["vault"], "path": result["path"],
|
||||||
"size": len(result["applied"]), "revision": result.get("revision"),
|
"size": len(result["applied"]), "revision": result.get("revision"),
|
||||||
@@ -400,6 +413,9 @@ async def api_file_delete(vault_name: str, path: str = Query(..., description="R
|
|||||||
|
|
||||||
result = service_delete_file(vault_name, path)
|
result = service_delete_file(vault_name, path)
|
||||||
|
|
||||||
|
# Journal du service d'agents (#220) — émission « au mieux », API seulement.
|
||||||
|
agent_journal.emettre(agent_journal.TYPE_SUPPRIME, vault_name, result["path"])
|
||||||
|
|
||||||
# Audit log
|
# Audit log
|
||||||
client_ip = current_user.get("_request_ip", "unknown")
|
client_ip = current_user.get("_request_ip", "unknown")
|
||||||
log_file_delete(current_user["username"], vault_name, path, client_ip)
|
log_file_delete(current_user["username"], vault_name, path, client_ip)
|
||||||
@@ -532,9 +548,10 @@ async def api_directory_delete(
|
|||||||
result = service_delete_directory(vault_name, path, recursive=True)
|
result = service_delete_directory(vault_name, path, recursive=True)
|
||||||
file_count = result["deleted_count"]
|
file_count = result["deleted_count"]
|
||||||
|
|
||||||
# Update index
|
# Update index — incremental subtree removal (no full vault re-scan, no
|
||||||
from backend.indexer import reload_single_vault
|
# global inverted-index rebuild).
|
||||||
await reload_single_vault(vault_name)
|
from backend.indexer import remove_directory_from_index
|
||||||
|
await remove_directory_from_index(vault_name, result["path"])
|
||||||
|
|
||||||
# Broadcast SSE event
|
# Broadcast SSE event
|
||||||
await sse_manager.broadcast("directory_deleted", {
|
await sse_manager.broadcast("directory_deleted", {
|
||||||
@@ -571,6 +588,9 @@ async def api_file_create(
|
|||||||
|
|
||||||
result = service_create_file(vault_name, body.path, body.content)
|
result = service_create_file(vault_name, body.path, body.content)
|
||||||
|
|
||||||
|
# Journal du service d'agents (#220) — émission « au mieux », API seulement.
|
||||||
|
agent_journal.emettre(agent_journal.TYPE_CREE, vault_name, result["path"])
|
||||||
|
|
||||||
# Update index
|
# Update index
|
||||||
await update_single_file(vault_name, result["path"])
|
await update_single_file(vault_name, result["path"])
|
||||||
|
|
||||||
@@ -680,6 +700,9 @@ async def api_file_rename(
|
|||||||
old_path_str = result["old_path"]
|
old_path_str = result["old_path"]
|
||||||
new_path_str = result["new_path"]
|
new_path_str = result["new_path"]
|
||||||
|
|
||||||
|
# Journal du service d'agents (#220) — la référence pointe sur le nouveau chemin.
|
||||||
|
agent_journal.emettre(agent_journal.TYPE_DEPLACE, vault_name, new_path_str)
|
||||||
|
|
||||||
# Update index
|
# Update index
|
||||||
await handle_file_move(vault_name, old_path_str, new_path_str)
|
await handle_file_move(vault_name, old_path_str, new_path_str)
|
||||||
|
|
||||||
@@ -725,6 +748,11 @@ async def api_file_move(
|
|||||||
new_path_str = result["new_path"]
|
new_path_str = result["new_path"]
|
||||||
item_type = result["item_type"]
|
item_type = result["item_type"]
|
||||||
|
|
||||||
|
# Journal du service d'agents (#220) — fichiers seulement (le catalogue
|
||||||
|
# est note.*) ; les déplacements de dossiers ne sont pas émis.
|
||||||
|
if item_type == "file":
|
||||||
|
agent_journal.emettre(agent_journal.TYPE_DEPLACE, vault_name, new_path_str)
|
||||||
|
|
||||||
# Update index
|
# Update index
|
||||||
if item_type == "directory":
|
if item_type == "directory":
|
||||||
from backend.indexer import reload_single_vault
|
from backend.indexer import reload_single_vault
|
||||||
@@ -742,3 +770,41 @@ async def api_file_move(
|
|||||||
await dispatch_webhooks("item_moved", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type})
|
await dispatch_webhooks("item_moved", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type})
|
||||||
|
|
||||||
return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}
|
return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}
|
||||||
|
|
||||||
|
@router.post("/api/duplicate/{vault_name}", response_model=FileDuplicateResponse)
|
||||||
|
async def api_file_duplicate(
|
||||||
|
vault_name: str,
|
||||||
|
body: FileDuplicateRequest,
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Duplicate (copy) a file or directory within the same vault."""
|
||||||
|
if not check_vault_access(vault_name, current_user):
|
||||||
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
|
|
||||||
|
result = service_duplicate_path(vault_name, body.source_path, body.destination_dir)
|
||||||
|
new_path_str = result["new_path"]
|
||||||
|
item_type = result["item_type"]
|
||||||
|
|
||||||
|
# Index: incrémental pour chaque fichier copié (évite un reload complet).
|
||||||
|
from backend.services.vaults import get_vault_root
|
||||||
|
root = get_vault_root(vault_name)
|
||||||
|
target = root / new_path_str
|
||||||
|
if item_type == "directory":
|
||||||
|
for fp in sorted(target.rglob("*")):
|
||||||
|
if fp.is_file():
|
||||||
|
await update_single_file(vault_name, str(fp.relative_to(root)).replace("\\", "/"))
|
||||||
|
else:
|
||||||
|
await update_single_file(vault_name, new_path_str)
|
||||||
|
|
||||||
|
await sse_manager.broadcast("item_duplicated", {
|
||||||
|
"vault": vault_name,
|
||||||
|
"source_path": result["source_path"],
|
||||||
|
"new_path": new_path_str,
|
||||||
|
"item_type": item_type,
|
||||||
|
})
|
||||||
|
await dispatch_webhooks(
|
||||||
|
"file_created" if item_type == "file" else "directory_created",
|
||||||
|
{"vault": vault_name, "path": new_path_str},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"success": True, "source_path": result["source_path"], "new_path": new_path_str, "item_type": item_type}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# backend/routers/sso.py
|
||||||
|
# Fusion ACC-3.1 (#216) — Points d'entrée SSO de l'émettrice.
|
||||||
|
#
|
||||||
|
# GET /sso/authorize?return_to=<url> : point d'entrée des autres
|
||||||
|
# applications — valide le return_to (liste blanche *.dracodev.net,
|
||||||
|
# anti open-redirect) puis redirige vers la page de connexion
|
||||||
|
# d'ObsiGate en conservant le paramètre ; après connexion, le
|
||||||
|
# frontend repart vers l'application demanderesse (le cookie
|
||||||
|
# draco_session posé à la connexion couvre tout le domaine parent).
|
||||||
|
#
|
||||||
|
# GET /api/sso/revocations : liste des jti révoqués, réservée aux
|
||||||
|
# autres applications (clé machine DRACO_SSO_MACHINE_KEY, en-tête
|
||||||
|
# X-Draco-Machine-Key) qui la mettent en cache 60 s (design §5.2).
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
|
from backend import draco_session
|
||||||
|
|
||||||
|
router = APIRouter(tags=["SSO"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sso/authorize")
|
||||||
|
async def sso_authorize(return_to: str = ""):
|
||||||
|
target = draco_session.validate_return_to(return_to)
|
||||||
|
if target:
|
||||||
|
return RedirectResponse(
|
||||||
|
url=f"/?return_to={quote(target, safe='')}", status_code=302)
|
||||||
|
return RedirectResponse(url="/", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/sso/revocations")
|
||||||
|
async def sso_revocations(request: Request):
|
||||||
|
machine_key = os.environ.get("DRACO_SSO_MACHINE_KEY", "")
|
||||||
|
if not machine_key:
|
||||||
|
raise HTTPException(404, "Non configuré")
|
||||||
|
if request.headers.get("X-Draco-Machine-Key") != machine_key:
|
||||||
|
raise HTTPException(401, "Clé machine invalide")
|
||||||
|
return {"revoked": draco_session.revoked_list()}
|
||||||
@@ -580,6 +580,20 @@ class FileMoveResponse(BaseModel):
|
|||||||
success: bool = Field(description="Whether move succeeded")
|
success: bool = Field(description="Whether move succeeded")
|
||||||
old_path: str = Field(description="Original path")
|
old_path: str = Field(description="Original path")
|
||||||
new_path: str = Field(description="New path after move")
|
new_path: str = Field(description="New path after move")
|
||||||
|
|
||||||
|
class FileDuplicateRequest(BaseModel):
|
||||||
|
"""Request to duplicate (copy) a file or directory."""
|
||||||
|
|
||||||
|
source_path: str = Field(description="Relative path of the item to duplicate")
|
||||||
|
destination_dir: str = Field(default="", description="Target directory relative path (empty = source parent)")
|
||||||
|
|
||||||
|
|
||||||
|
class FileDuplicateResponse(BaseModel):
|
||||||
|
"""Response after duplicating a file or directory."""
|
||||||
|
|
||||||
|
success: bool = Field(description="Whether duplication succeeded")
|
||||||
|
source_path: str = Field(description="Original path")
|
||||||
|
new_path: str = Field(description="Path of the created copy")
|
||||||
item_type: str = Field(description="Type of item moved: 'file' or 'directory'")
|
item_type: str = Field(description="Type of item moved: 'file' or 'directory'")
|
||||||
|
|
||||||
|
|
||||||
@@ -605,6 +619,44 @@ class BookmarkToggleRequest(BaseModel):
|
|||||||
title: str | None = None
|
title: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalRef(BaseModel):
|
||||||
|
"""Référence croisée d'une note vers un objet d'une autre application.
|
||||||
|
|
||||||
|
Modèle figé par dracodev-commons (contrats/external_ref.schema.json,
|
||||||
|
v0.1.0) — Fusion #215 (ACC-1.3).
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
owner_type: str = Field(description="Toujours 'obsigate.note' côté ObsiGate")
|
||||||
|
owner_id: str = Field(description="'<vault>:<chemin>' de la note propriétaire")
|
||||||
|
owner_vault: str
|
||||||
|
owner_path: str
|
||||||
|
target_app: str = Field(description="obsigate | flowdeck | tiktak | kanbante")
|
||||||
|
target_type: str = Field(description="note | project | page | task | list | card")
|
||||||
|
target_id: str
|
||||||
|
target_url: str = Field(description="URL profonde selon contrats/liens-profonds.md")
|
||||||
|
label: str = Field(description="Instantané d'affichage du titre de la cible")
|
||||||
|
domain: str | None = None
|
||||||
|
status: str = Field(default="ok", description="ok | target_missing")
|
||||||
|
created_by: str = Field(default="user")
|
||||||
|
created_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalRefCreateRequest(BaseModel):
|
||||||
|
"""Création d'une référence par collage d'URL profonde."""
|
||||||
|
|
||||||
|
vault: str
|
||||||
|
path: str
|
||||||
|
url: str
|
||||||
|
label: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalRefsResponse(BaseModel):
|
||||||
|
"""Liste des références d'une note."""
|
||||||
|
|
||||||
|
refs: list[ExternalRef]
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Search / suggest / graph (#85 — extrait de backend.main, inchangé)
|
# Search / suggest / graph (#85 — extrait de backend.main, inchangé)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1534,6 +1534,80 @@ def move_path(vault_name: str, source_path: str, destination_dir: str = "") -> d
|
|||||||
"item_type": item_type,
|
"item_type": item_type,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def duplicate_path(vault_name: str, source_path: str, destination_dir: str = "") -> dict[str, Any]:
|
||||||
|
"""Duplicate a file or directory (copy) into *destination_dir* (same vault).
|
||||||
|
|
||||||
|
The copy is named ``<stem> copie<ext>`` (then ``copie 2``, ``copie 3``… on
|
||||||
|
collision). Directories are copied recursively.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ServiceError: ``not_found`` (404), ``read_only`` (403),
|
||||||
|
``permission_denied`` (403), ``invalid_arguments`` (400) when the
|
||||||
|
destination is inside the source directory.
|
||||||
|
"""
|
||||||
|
root = get_vault_root(vault_name)
|
||||||
|
_ensure_writable(root)
|
||||||
|
|
||||||
|
source = resolve_safe_path(root, source_path)
|
||||||
|
if not source.exists():
|
||||||
|
raise ServiceError(
|
||||||
|
f"Source not found: {source_path}",
|
||||||
|
code="not_found",
|
||||||
|
status=404,
|
||||||
|
details={"vault": vault_name, "path": source_path},
|
||||||
|
)
|
||||||
|
|
||||||
|
is_directory = source.is_dir()
|
||||||
|
dest_clean = (destination_dir or "").strip("/")
|
||||||
|
if dest_clean:
|
||||||
|
dest_parent = resolve_safe_path(root, dest_clean)
|
||||||
|
if not dest_parent.exists() or not dest_parent.is_dir():
|
||||||
|
raise ServiceError(
|
||||||
|
f"Destination directory not found: {destination_dir}",
|
||||||
|
code="not_found",
|
||||||
|
status=404,
|
||||||
|
details={"vault": vault_name, "path": destination_dir},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
dest_parent = root
|
||||||
|
|
||||||
|
if is_directory and str(dest_parent.resolve()) in (str(source.resolve()), str(source.resolve()) + os.sep, str(source.resolve()) + "/"):
|
||||||
|
raise ServiceError(
|
||||||
|
"Cannot duplicate a directory inside itself",
|
||||||
|
code="invalid_arguments",
|
||||||
|
status=400,
|
||||||
|
details={"vault": vault_name, "path": source_path},
|
||||||
|
)
|
||||||
|
|
||||||
|
ext = "" if is_directory else source.suffix
|
||||||
|
base = dest_parent / (source.stem + " copie" + ext)
|
||||||
|
target = base
|
||||||
|
n = 2
|
||||||
|
while target.exists():
|
||||||
|
target = base.parent / (source.stem + " copie " + str(n) + ext)
|
||||||
|
n += 1
|
||||||
|
|
||||||
|
try:
|
||||||
|
if is_directory:
|
||||||
|
shutil.copytree(source, target)
|
||||||
|
else:
|
||||||
|
shutil.copy2(source, target)
|
||||||
|
except PermissionError as e:
|
||||||
|
raise ServiceError("Permission denied: cannot duplicate item", code="permission_denied", status=403) from e
|
||||||
|
|
||||||
|
new_rel = _rel(root, target)
|
||||||
|
item_type = "directory" if is_directory else "file"
|
||||||
|
logger.info(f"Item duplicated: {vault_name}/{source_path} -> {new_rel}")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"vault": vault_name,
|
||||||
|
"source_path": _rel(root, source),
|
||||||
|
"new_path": new_rel,
|
||||||
|
"item_type": item_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ── D3. Find & replace ─────────────────────────────────────────────────────
|
# ── D3. Find & replace ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ def _shared_results(username: str, q: str, vault: str) -> list[dict[str, Any]]:
|
|||||||
if not fp.exists() or fp.suffix.lower() != ".md":
|
if not fp.exists() or fp.suffix.lower() != ".md":
|
||||||
continue
|
continue
|
||||||
raw = fp.read_text(encoding="utf-8", errors="replace")
|
raw = fp.read_text(encoding="utf-8", errors="replace")
|
||||||
except Exception:
|
except Exception: # nosec B112 — partage illisible : ignoré, on continue
|
||||||
continue
|
continue
|
||||||
title = fp.stem
|
title = fp.stem
|
||||||
occurrences = raw.lower().count(q_lower)
|
occurrences = raw.lower().count(q_lower)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from backend import agent_journal
|
||||||
from backend.indexer import get_backlinks as _get_backlinks
|
from backend.indexer import get_backlinks as _get_backlinks
|
||||||
from backend.indexer import get_vault_names
|
from backend.indexer import get_vault_names
|
||||||
from backend.services.backups import diff_backup as _diff_backup
|
from backend.services.backups import diff_backup as _diff_backup
|
||||||
@@ -58,7 +59,7 @@ from backend.services.search import advanced_search_vaults, search_vaults
|
|||||||
from backend.services.search import list_tags as _list_tags
|
from backend.services.search import list_tags as _list_tags
|
||||||
from backend.services.search import search_paths as _search_paths
|
from backend.services.search import search_paths as _search_paths
|
||||||
from backend.services.vaults import browse_directory, list_accessible_vaults, list_all_files
|
from backend.services.vaults import browse_directory, list_accessible_vaults, list_all_files
|
||||||
from backend.tools.context import ToolContext, ToolRisk
|
from backend.tools.context import ToolContext, ToolMode, ToolRisk
|
||||||
from backend.tools.registry import tool
|
from backend.tools.registry import tool
|
||||||
from backend.tools.schemas import (
|
from backend.tools.schemas import (
|
||||||
AppendToFileInput,
|
AppendToFileInput,
|
||||||
@@ -360,6 +361,12 @@ def list_recent(ctx: ToolContext, params: ListRecentInput) -> dict[str, Any]:
|
|||||||
# ── D. Mutations ───────────────────────────────────────────────────────────
|
# ── D. Mutations ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _acteur_journal(ctx: ToolContext) -> str:
|
||||||
|
"""Acteur journal (#220) : ``agent:mcp`` quand l'appel vient du
|
||||||
|
serveur MCP d'ObsiGate, ``user`` pour l'assistant intégré."""
|
||||||
|
return agent_journal.ACTEUR_MCP if ctx.mode == ToolMode.MCP else agent_journal.ACTEUR_USER
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
name="create_file",
|
name="create_file",
|
||||||
description=(
|
description=(
|
||||||
@@ -374,7 +381,12 @@ def list_recent(ctx: ToolContext, params: ListRecentInput) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
def create_file(ctx: ToolContext, params: CreateFileInput) -> dict[str, Any]:
|
def create_file(ctx: ToolContext, params: CreateFileInput) -> dict[str, Any]:
|
||||||
"""Create a vault file (fails if it already exists)."""
|
"""Create a vault file (fails if it already exists)."""
|
||||||
return _create_file(params.vault, params.path, params.content)
|
result = _create_file(params.vault, params.path, params.content)
|
||||||
|
agent_journal.emettre(
|
||||||
|
agent_journal.TYPE_CREE, params.vault, result.get("path", params.path),
|
||||||
|
actor=_acteur_journal(ctx),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
@@ -402,7 +414,12 @@ def create_directory(ctx: ToolContext, params: CreateDirectoryInput) -> dict[str
|
|||||||
)
|
)
|
||||||
def edit_file(ctx: ToolContext, params: EditFileInput) -> dict[str, Any]:
|
def edit_file(ctx: ToolContext, params: EditFileInput) -> dict[str, Any]:
|
||||||
"""Replace a vault file's content."""
|
"""Replace a vault file's content."""
|
||||||
return _edit_file(params.vault, params.path, params.content)
|
result = _edit_file(params.vault, params.path, params.content)
|
||||||
|
agent_journal.emettre(
|
||||||
|
agent_journal.TYPE_EDITE, params.vault, result.get("path", params.path),
|
||||||
|
actor=_acteur_journal(ctx),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
@@ -414,7 +431,12 @@ def edit_file(ctx: ToolContext, params: EditFileInput) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
def append_to_file(ctx: ToolContext, params: AppendToFileInput) -> dict[str, Any]:
|
def append_to_file(ctx: ToolContext, params: AppendToFileInput) -> dict[str, Any]:
|
||||||
"""Append content to a vault file."""
|
"""Append content to a vault file."""
|
||||||
return _append_to_file(params.vault, params.path, params.content)
|
result = _append_to_file(params.vault, params.path, params.content)
|
||||||
|
chemin = result.get("path", params.path) if isinstance(result, dict) else params.path
|
||||||
|
agent_journal.emettre(
|
||||||
|
agent_journal.TYPE_EDITE, params.vault, chemin, actor=_acteur_journal(ctx),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
@@ -426,7 +448,12 @@ def append_to_file(ctx: ToolContext, params: AppendToFileInput) -> dict[str, Any
|
|||||||
)
|
)
|
||||||
def rename_file(ctx: ToolContext, params: RenameFileInput) -> dict[str, Any]:
|
def rename_file(ctx: ToolContext, params: RenameFileInput) -> dict[str, Any]:
|
||||||
"""Rename a vault file."""
|
"""Rename a vault file."""
|
||||||
return _rename_file(params.vault, params.path, params.new_name)
|
result = _rename_file(params.vault, params.path, params.new_name)
|
||||||
|
agent_journal.emettre(
|
||||||
|
agent_journal.TYPE_DEPLACE, params.vault,
|
||||||
|
result.get("new_path", params.path), actor=_acteur_journal(ctx),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
@@ -450,7 +477,14 @@ def rename_directory(ctx: ToolContext, params: RenameDirectoryInput) -> dict[str
|
|||||||
)
|
)
|
||||||
def move_path(ctx: ToolContext, params: MovePathInput) -> dict[str, Any]:
|
def move_path(ctx: ToolContext, params: MovePathInput) -> dict[str, Any]:
|
||||||
"""Move a vault file or directory."""
|
"""Move a vault file or directory."""
|
||||||
return _move_path(params.vault, params.source_path, params.destination_dir)
|
result = _move_path(params.vault, params.source_path, params.destination_dir)
|
||||||
|
# Journal (#220) : fichiers seulement — les dossiers sont hors catalogue.
|
||||||
|
if result.get("item_type") == "file":
|
||||||
|
agent_journal.emettre(
|
||||||
|
agent_journal.TYPE_DEPLACE, params.vault,
|
||||||
|
result.get("new_path", params.source_path), actor=_acteur_journal(ctx),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
@@ -497,7 +531,12 @@ def replace_in_files(ctx: ToolContext, params: ReplaceInFilesInput) -> dict[str,
|
|||||||
)
|
)
|
||||||
def delete_file(ctx: ToolContext, params: DeleteFileInput) -> dict[str, Any]:
|
def delete_file(ctx: ToolContext, params: DeleteFileInput) -> dict[str, Any]:
|
||||||
"""Delete a vault file."""
|
"""Delete a vault file."""
|
||||||
return _delete_file(params.vault, params.path)
|
result = _delete_file(params.vault, params.path)
|
||||||
|
chemin = result.get("path", params.path) if isinstance(result, dict) else params.path
|
||||||
|
agent_journal.emettre(
|
||||||
|
agent_journal.TYPE_SUPPRIME, params.vault, chemin, actor=_acteur_journal(ctx),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
|
|||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.68.1"
|
version = "2.77.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.68.1"
|
version = "2.77.0"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.68.1",
|
"version": "2.77.0",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
@@ -51,8 +51,6 @@ services:
|
|||||||
- VAULT_4_PATH=/vaults/Obsidian_WORKOUT
|
- VAULT_4_PATH=/vaults/Obsidian_WORKOUT
|
||||||
- VAULT_5_NAME=Sessions
|
- VAULT_5_NAME=Sessions
|
||||||
- VAULT_5_PATH=/vaults/SessionsManager
|
- VAULT_5_PATH=/vaults/SessionsManager
|
||||||
- DIR_1_NAME=Bruno
|
|
||||||
- DIR_1_PATH=/vaults/bruno
|
|
||||||
# #194 : dossier personnel par utilisateur = <racine>/home-<user>
|
# #194 : dossier personnel par utilisateur = <racine>/home-<user>
|
||||||
- OBSIGATE_HOME_ROOT=/vaults/Home
|
- OBSIGATE_HOME_ROOT=/vaults/Home
|
||||||
- OBSIGATE_AUTH_ENABLED=true
|
- OBSIGATE_AUTH_ENABLED=true
|
||||||
|
|||||||
+11
-2
@@ -220,8 +220,11 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
|||||||
| *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-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 |
|
| *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 |
|
||||||
| *BUG-112* | E2E local : `pwsh -File scripts/e2e-server.ps1 2>&1 \| head -n 20` ne se termine **jamais** — la commande semble bloquée des dizaines de minutes alors que le serveur est prêt en ~7 s | 🟢 corrigé | P1 | 🧪 E2E local / Windows | IA | `scripts/e2e-server.ps1` (`start`) | `pwsh -NoProfile -File scripts/e2e-server.ps1 start 2>&1 \| head -n 20` → pipeline ouvert ~500 s+ ; se termine en <2 s dès qu'uvicorn est tué (problème d'EOF du pipe, pas de lenteur du démarrage) | `Start-Process` laissait uvicorn hériter des handles stdout/stderr du pwsh parent → le pipe `\| head` reste ouvert tant que le serveur vit → `head` attend un EOF qui n'arrive jamais. Remplacé par `Win32_Process.Create` (WMI) : processus détaché sans handle hérité ; le processus WMI n'héritant pas de l'environnement, les `OBSIGATE_*` sont passés via `set "NOM=valeur"` dans `cmd /c` (les `""` doublés faisaient échouer le quote-stripping de cmd : forme sans guillemets extérieurs) | Vérifié : commande exacte de l'utilisateur → `real 0m6,883s`, `[OK] READY v2.67.2`, serveur toujours up (2 vaults, 137 fichiers indexés), `stop`/`status` OK, syntaxe PS validée, aucun orphelin |
|
| *BUG-112* | E2E local : `pwsh -File scripts/e2e-server.ps1 2>&1 \| head -n 20` ne se termine **jamais** — la commande semble bloquée des dizaines de minutes alors que le serveur est prêt en ~7 s | 🟢 corrigé | P1 | 🧪 E2E local / Windows | IA | `scripts/e2e-server.ps1` (`start`) | `pwsh -NoProfile -File scripts/e2e-server.ps1 start 2>&1 \| head -n 20` → pipeline ouvert ~500 s+ ; se termine en <2 s dès qu'uvicorn est tué (problème d'EOF du pipe, pas de lenteur du démarrage) | `Start-Process` laissait uvicorn hériter des handles stdout/stderr du pwsh parent → le pipe `\| head` reste ouvert tant que le serveur vit → `head` attend un EOF qui n'arrive jamais. Remplacé par `Win32_Process.Create` (WMI) : processus détaché sans handle hérité ; le processus WMI n'héritant pas de l'environnement, les `OBSIGATE_*` sont passés via `set "NOM=valeur"` dans `cmd /c` (les `""` doublés faisaient échouer le quote-stripping de cmd : forme sans guillemets extérieurs) | Vérifié : commande exacte de l'utilisateur → `real 0m6,883s`, `[OK] READY v2.67.2`, serveur toujours up (2 vaults, 137 fichiers indexés), `stop`/`status` OK, syntaxe PS validée, aucun orphelin |
|
||||||
| | | | | | | | | | |
|
| *BUG-113* | Perf : suppression d'un répertoire dans la vault home-user très lente — le temps croît avec la vault entière, pas avec le dossier supprimé | 🟢 corrigé | P1 | ⚙️ backend / perf | IA | `backend/routers/files_write.py` (`api_directory_delete`), `backend/indexer.py` (`remove_directory_from_index` nouveau) | Supprimer un gros répertoire du home-user → l'API met des secondes (re-scan complet de la vault + rebuild de l'index inversé global) | `DELETE /api/directory/{vault}` appelait `reload_single_vault` après le `rmtree` : re-scan de TOUTE la vault + `init_inverted_index()` global. Remplacé par `remove_directory_from_index` : retire du sous-arbre uniquement (fichiers + entrées dossiers de `path_index`, notification incrémentale de l'index inversé et sémantique via le hook existant) | Mesuré sur l'instance E2E : 60 fichiers supprimés → **63 ms** vs **668 ms** avant (~10×) ; aucun fantôme dans `/api/vault/{v}/paths` ni dans la recherche plein-texte après suppression (`test_delete_directory_subtree_clears_index`) ; 232 tests verts, ruff 0, mypy 0 |
|
||||||
### TODOs techniques (améliorations / nouvelles tâches)
|
| *BUG-114* | Popup « currentPath is not defined » après suppression (ou renommage) d'un répertoire | 🟢 corrigé | P1 | 📱 frontend | IA | `frontend/js/ui.js` (`confirmDeleteDirectory`, renommage inline) | Ouvrir un fichier d'un dossier, clic droit → Supprimer le dossier (ou Renommer) → toast d'erreur `currentPath is not defined` alors que l'opération réussit | Les branches répertoire testaient `currentPath` nu (non déclaré dans ui.js — la variable vit dans `state`) au lieu de `state.currentPath` : `ReferenceError` dans le `try`, avalée par le `catch` → toast. Trois usages corrigés (startsWith ×2, slice ×1) | Vérifié en Playwright (instance 2029, flux réel clic droit → Supprimer sur un dossier contenant le fichier ouvert) : toast « Dossier supprimé (1 fichiers) », 0 fantôme dans /api/vault/{v}/paths, 0 erreur JS ; validate-imports 42 modules, unit.test.mjs 13/13 |
|
||||||
|
| *BUG-115* | Éditeur Forge : cases à cocher collées au texte dans l'aperçu, aperçu qui flashe à la frappe, menu « dimensions du tableau » coupé hors écran sur un document court | 🟢 corrigé | P2 | 📱 frontend / éditeur | IA | `frontend/editor-poc.html` (CSS aperçu, `renderPreview`, `showTableForm`, `showLangPicker`) | Ouvrir un `.md` avec `- [ ]`, activer l'aperçu → texte collé au checkbox + flash « Rendering... » ; `/table` sur un document d'une ligne → forme partiellement visible | (1) règle `.task-list-item input[type=checkbox] { margin-right: 6px }` ajoutée au `<style>` de l'aperçu (miroir `style.css:2303`) ; (2) `renderPreview` ne remplace plus le contenu par le spinner (affiché seulement si vide) + flag `_previewDirty` pour rejouer la dernière frappe ; (3) `.inline-form`/`.lang-picker` en `position: fixed` (coordonnées viewport) avec clamp bas/droite comme le menu slash + `stopPropagation` sur les clics qi/slash (le listener document refermait la forme) | Vérifié en Playwright (instance E2E) : marginRight 6 px + `list-style: none`, MutationObserver : aucun « Rendering... » pendant 14 frappes et dernier état rendu, forme table `bottom 193 < vh 900` sur document 1 ligne + insertion OK |
|
||||||
|
| *BUG-116* | Éditeur Forge : après insertion d'un tableau (ex. 4 lignes), la textarea reste d'une seule ligne au lieu de s'agrandir | 🟢 corrigé | P2 | 📱 frontend / éditeur | IA | `frontend/editor-poc.html` (`insertAtCursor`, `wrapSelection`) | Insérer un tableau via le menu : le contenu contient bien les nouvelles lignes mais la hauteur de la textarea ne change pas | Les mutations programmatiques de `ta.value` ne déclenchent pas l'événement `input` — seul handler qui rappelait `autoHeight()`/`updateLineNumbers()` (les handlers clavier, eux, les appelaient à la main). Ajout des deux appels dans `insertAtCursor` et `wrapSelection`, les deux points de passage de toutes les insertions (tableau, code, mermaid, checklist, gras multi-ligne) | Vérifié en Playwright sur l'instance E2E (qi → Table, 4 lignes, sur doc d'une ligne) : hauteur 51 → 189 px, 8 lignes rendues, gutter aligné | ### TODOs techniques (améliorations / nouvelles tâches)
|
||||||
|
| *BUG-117* | Drag & drop des fichiers dans la sidebar : le déplacement d'un répertoire à un autre ne fait plus rien, sans message | 🟢 corrigé | P1 | 📱 frontend / sidebar | IA | `frontend/js/dragdrop.js` (`onDrop`) | Glisser un fichier sur un dossier : aucune requête `/api/move`, aucun toast | La détection `isExternalFiles` dans `onDrop` (`items.length > 0`, ajoutée par #89) comptait aussi les formats string du drag interne (`tree:vault/path/type` posés par `setData` au `dragstart`) : tout déplacement partait dans la branche upload où rien ne passe le filtre `kind === 'file'` → drop avalé silencieusement. Corrigé à la racine : `dragState` (interne) prioritaire + items réellement fichiers. Livré avec #201 : menu contextuel Déplacer/Dupliquer réutilisant `promptTargetFolder`, emplacement actuel pré-sélectionné | Vérifié Playwright instance E2E : glisser `note1.md` → `d1` = toast « Moved file to d1 » + chemin déplacé ; 4 tests `TestDuplicateApi` |
|
||||||
|
|
||||||
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|
||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
@@ -347,6 +350,12 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
|||||||
| 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 | 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-14 | BUG-112 | Correction | `scripts/e2e-server.ps1`, `docs/ISSUES_TODOLIST.md`, `CHANGELOG.md` | **BUG-112 — `pwsh -File scripts/e2e-server.ps1 2>&1 \| head -n 20` ne se termine jamais.** Constat : le démarrage n'est pas lent (~7 s, READY, health 200, 137 fichiers indexés) — c'est le pipe qui ne reçoit jamais d'EOF. Preuve : le pipeline survit 494 s+ puis se termine en <2 s dès qu'uvicorn est tué. Cause : `Start-Process` laisse l'enfant hériter des handles stdout/stderr du pwsh parent ; le serveur (immortel par design) maintient le pipe ouvert → `head` attend un EOF qui n'arrivera jamais. Correctif : `Win32_Process.Create` (WMI) lance uvicorn détaché sans handle hérité ; le processus WMI n'héritant pas de l'environnement, les `OBSIGATE_*` sont passés via `set "NOM=valeur"` dans `cmd /c` (les `""` doublés faisaient échouer le quote-stripping de cmd : forme sans guillemets extérieurs). Vérifié : commande exacte de l'utilisateur → `real 0m6,883s`, `[OK] READY v2.67.2`, serveur toujours up (2 vaults, 137 fichiers), `stop`/`status` OK, syntaxe PS validée, aucun orphelin | 🟢 corrigé (en attente vérif utilisateur) |
|
| 2026-10-14 | BUG-112 | Correction | `scripts/e2e-server.ps1`, `docs/ISSUES_TODOLIST.md`, `CHANGELOG.md` | **BUG-112 — `pwsh -File scripts/e2e-server.ps1 2>&1 \| head -n 20` ne se termine jamais.** Constat : le démarrage n'est pas lent (~7 s, READY, health 200, 137 fichiers indexés) — c'est le pipe qui ne reçoit jamais d'EOF. Preuve : le pipeline survit 494 s+ puis se termine en <2 s dès qu'uvicorn est tué. Cause : `Start-Process` laisse l'enfant hériter des handles stdout/stderr du pwsh parent ; le serveur (immortel par design) maintient le pipe ouvert → `head` attend un EOF qui n'arrivera jamais. Correctif : `Win32_Process.Create` (WMI) lance uvicorn détaché sans handle hérité ; le processus WMI n'héritant pas de l'environnement, les `OBSIGATE_*` sont passés via `set "NOM=valeur"` dans `cmd /c` (les `""` doublés faisaient échouer le quote-stripping de cmd : forme sans guillemets extérieurs). Vérifié : commande exacte de l'utilisateur → `real 0m6,883s`, `[OK] READY v2.67.2`, serveur toujours up (2 vaults, 137 fichiers), `stop`/`status` OK, syntaxe PS validée, aucun orphelin | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
|
| 2026-10-14 | BUG-113 | Perf | `backend/indexer.py`, `backend/routers/files_write.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-113 — suppression de répertoire ~10× plus rapide.** Constat : dans le home-user, supprimer un répertoire prenait des secondes — le temps croissait avec la **vault entière**, pas avec le dossier supprimé. Cause : `DELETE /api/directory/{vault}` appelait `reload_single_vault` après le `rmtree` : re-scan complet de la vault + rebuild de l'index inversé **global** (toutes les vaults). Correctif : `remove_directory_from_index` (indexer.py) retire uniquement les fichiers du sous-arbre (files, tags, `_file_lookup`, `path_index` avec purge des dossiers fantômes) et notifie l'index inversé + sémantique **incrémentalement** via le hook existant — le même chemin que le watcher pour une suppression de fichier. Mesuré sur l'instance E2E : 60 fichiers supprimés → **63 ms** vs **668 ms** avant. Tests : `test_delete_directory_subtree_clears_index` (aucun fantôme dans `/api/vault/{v}/paths` ni dans la recherche plein-texte après suppression). Vérifié : 232 passed / 1 skip (api_main + indexer_advanced + tools_mutations + search), ruff 0, mypy 0 | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
|
| 2026-10-14 | BUG-114 | Correction | `frontend/js/ui.js`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-114 — popup « currentPath is not defined » après suppression/renommage de répertoire.** Les branches répertoire de `confirmDeleteDirectory` (ligne ~1054) et du renommage inline (lignes ~967-968) référençaient `currentPath` nu au lieu de `state.currentPath` (ui.js ne déclare pas `currentPath`, contrairement à app.js) : `ReferenceError` levée dans le `try`, avalée par le `catch`, affichée en toast d'erreur alors que l'opération (DELETE/PATCH) avait réussi. Trois usages corrigés. Vérifié en Playwright sur l'instance E2E (flux réel clic droit → Supprimer) : toast de succès, 0 fantôme dans paths, 0 erreur JS ; validate-imports 42 modules / 373 exports, unit.test.mjs 13/13 | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
|
| 2026-10-14 | BUG-115 | Correction | `frontend/editor-poc.html`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-115 — trois ajustements de l'éditeur Forge (aperçu + menus).** (1) Cases à cocher collées au texte : le rendu serveur (mistune task_lists) ne met pas d'espace après l'`<input>` et l'`<style>` de `editor-poc.html` n'avait pas l'équivalent de la règle `.md-content .task-list-item` de `style.css` → deux règles ajoutées (margin-right 6 px, list-style none). (2) Flash de l'aperçu : `renderPreview` écrasait le contenu par « Rendering... » à chaque frappe (debounce 500 ms) — remplacé par un rafraîchissement silencieux (l'ancien rendu reste affiché, spinner seulement au premier rendu) + `_previewDirty` qui rejoue la dernière frappe tombée pendant un fetch (état fini correct, sans clignotement). (3) Menu « dimensions du tableau » coupé sur un document d'une ligne : `.inline-form`/`.lang-picker` en `position: absolute` recevaient des coordonnées **viewport** (`wr.left+pt.x`) → double décalage et clipping par `.editor-pane { overflow: hidden }` ; passés en `position: fixed` avec clamp viewport (convention du menu slash, déjà dans le fichier) ; au passage le clic sur un item qi/slash bullait jusqu'au listener document qui refermait la forme aussitôt ouverte → `stopPropagation`. Vérifié en Playwright sur l'instance E2E (serveur réel) : checkbox `marginRight: 6px`, aucun « Rendering... » observé pendant la frappe et dernier état rendu, forme table entièrement visible (`bottom 193 < vh 900`) et insertion OK. | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
|
| 2026-10-14 | BUG-116 | Correction | `frontend/editor-poc.html`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-116 — la textarea Forge ne grandissait pas après une insertion programmatique.** Après un tableau de 4 lignes inséré via le menu, l'éditeur restait d'une ligne : le contenu était bien multiligne mais `autoHeight()` n'était rappelé que par l'événement `input`, que `ta.value = …` ne déclenche pas. Les handlers clavier appelaient `autoHeight(); updateLineNumbers();` à la main ; `insertAtCursor` et `wrapSelection` — les deux fonctions par lesquelles passent toutes les insertions (tableau, bloc de code, templates mermaid, checklist, mise en forme) — oubliaient les deux appels. Corrigé à la source dans les deux fonctions plutôt que chez chaque appelant. Vérifié en Playwright (instance E2E, chemin réel qi → Table, 4 lignes sur document d'une ligne) : hauteur 51 → 189 px, gutter = nb de lignes | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
|
| 2026-10-21 | BUG-118 | Correction | `frontend/index.html`, `frontend/draco-barre.css`, `tests/test_draco_barre.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-118 — la barre d'apps Fusion était inutilisable en mobile** (retours de Bruno sur captures téléphone). Deux causes : (1) le palier ≤ 700 px de la CSS commune gardait onglets entiers et pastilles domaines — la barre débordait ; la source dracodev-commons passe en **v0.1.2** (spec des barres, § Mobile : marque, onglet de l'app courante, alertes, Spark, avatar ; rail/recherche/domaines/menu masqués au profit des contrôles natifs). (2) Le bouton ☰ de la barre cliquait `sidebar-toggle-btn` (bureau) au lieu de `hamburger-btn` (tiroir mobile) : sur téléphone il ne faisait rien, doublé du vrai hamburger d'ObsiGate juste en dessous. Rail devenu contextuel (`matchMedia` ≤ 768 px, point de bascule d'ObsiGate). Test de non-régression `tests/test_draco_barre.py` (2 tests) | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
|
| 2026-10-15 | BUG-117 + #201 | Correction + feature | `frontend/js/dragdrop.js`, `frontend/js/ui.js`, `frontend/js/upload.js`, `frontend/locales/fr.json`, `frontend/locales/en.json`, `backend/services/mutations.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **BUG-117 — le drag & drop interne de la sidebar était mort en silence.** `onDrop` classait tout dépôt « externe » dès que `dataTransfer.items` était non vide — or un glisser interne dépose 4 formats string (`tree:*`) : chaque déplacement partait dans la branche upload où le filtre `kind === 'file'` ne trouvait rien — aucun appel API, aucun toast. Détection corrigée (vrais fichiers seulement, `dragState` prioritaire). Livré avec **#201 : menu contextuel Déplacer/Dupliquer** (fichiers et dossiers), modal `promptTargetFolder` réutilisé (+ `opts`, emplacement actuel pré-sélectionné), endpoint `POST /api/duplicate/{vault}` (`duplicate_path`, index incrémental, SSE/webhooks, refus inside-self), i18n FR/EN, 4 tests `TestDuplicateApi` + validation Playwright E2E complète | 🟢 corrigé/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) |
|
| 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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+177
-1
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# ObsiGate — Roadmap
|
||||||
|
|
||||||
> **Version :** 2.68.1 | **Dernière mise à jour :** 2026-10-14
|
> **Version :** 2.77.0 | **Dernière mise à jour :** 2026-10-23
|
||||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||||
@@ -30,6 +30,170 @@
|
|||||||
|
|
||||||
## 🔵 En cours
|
## 🔵 En cours
|
||||||
|
|
||||||
|
### 222. Fusion — panneau Spark V3 : la console en surimpression pleine page
|
||||||
|
|
||||||
|
- **Effort :** ~0,1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-23 | **Statut :** ✅ livré le 2026-10-23 (2.77.0, redéploiement Docker en attente)
|
||||||
|
- **Fiche :** [features/fusion-panneau-console-surimpression-222.md](./features/fusion-panneau-console-surimpression-222.md)
|
||||||
|
- **Description :** le panneau Spark (asset vendangé `frontend/draco-panneau.js`,
|
||||||
|
octet-identique dans les 4 apps) passe en V3 (demande de Bruno,
|
||||||
|
2026-10-22) : le bouton « Console ⤢ » n'ouvre plus la console du
|
||||||
|
service d'agents en nouvel onglet (#221) mais en **surimpression
|
||||||
|
pleine page dans la même page** — montée à la largeur du panneau,
|
||||||
|
étendue à toute la fenêtre par transition, iframe gardant le lien
|
||||||
|
profond (`?disc=` / `?app=`), et deux sorties dans sa barre :
|
||||||
|
« Mode sidebar » (rétraction vers le panneau) et « Fermer l'agent ».
|
||||||
|
Styles injectés par le panneau ; côté service, la console autorise
|
||||||
|
l'encadrement par les 4 apps (0.20.0). Même livraison : TikTak
|
||||||
|
0.60.0, Flowdeck 7.81.0, Kanbante 0.29.0.
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [x] Surimpression + barre « Mode sidebar » / « Fermer l'agent »
|
||||||
|
dans le panneau canonique (TikTak), recopié octet-identique
|
||||||
|
dans les 4 apps
|
||||||
|
- [x] Test `tests/test_draco_barre.py::test_panneau_console_surimpression_222`
|
||||||
|
(et révision du test #221 : le nouvel onglet disparaît)
|
||||||
|
- [x] Release v2.77.0 publiée — CI à constater, redéploiement Docker (geste de Bruno)
|
||||||
|
|
||||||
|
### 221. Fusion — panneau Spark : bouton « Console ↗ » vers la console du service
|
||||||
|
|
||||||
|
- **Effort :** ~0,1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-22 | **Statut :** ✅ livré le 2026-10-22 (2.76.1, redéploiement Docker en attente)
|
||||||
|
- **Fiche :** [features/fusion-panneau-console-221.md](./features/fusion-panneau-console-221.md)
|
||||||
|
- **Description :** le panneau Spark (asset vendangé `frontend/draco-panneau.js`,
|
||||||
|
octet-identique dans les 4 apps) gagne un bouton « Console ↗ » dans sa barre
|
||||||
|
des discussions (demande de Bruno, 2026-10-22) : il ouvre la console web du
|
||||||
|
service d'agents (https://agent.dracodev.net/) dans un nouvel onglet, sur la
|
||||||
|
discussion actuellement affichée (`?disc=<id>`, URL construite au clic) et
|
||||||
|
avec l'app d'origine (`?app=obsigate`, déduite du sous-domaine `og`). La
|
||||||
|
console consomme déjà ces deux paramètres (lien profond de discussion +
|
||||||
|
puce « Ouvert depuis »). Même livraison : TikTak 0.59.1, Flowdeck 7.80.1,
|
||||||
|
Kanbante 0.28.1.
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [x] Bouton + `ouvrirConsole()` / `appHote()` dans le panneau canonique
|
||||||
|
(TikTak), recopié octet-identique dans les 4 apps
|
||||||
|
- [x] Test `tests/test_draco_barre.py::test_panneau_bouton_console_221`
|
||||||
|
- [x] Release v2.76.1 publiée — CI à constater, redéploiement Docker (geste de Bruno)
|
||||||
|
|
||||||
|
### 220. Fusion — émission d'événements vers le journal du service d'agents (Lot 2, vague 5)
|
||||||
|
|
||||||
|
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-22 | **Statut :** ✅ livré le 2026-10-22 (2.76.0, redéploiement Docker en attente)
|
||||||
|
- **Fiche :** [features/fusion-journal-evenements-220.md](./features/fusion-journal-evenements-220.md)
|
||||||
|
- **Description :** ObsiGate émet ses mutations de notes **explicites**
|
||||||
|
vers le journal d'activité du service d'agents (contrat ACC-0.2,
|
||||||
|
carte fusion-trois-apps #18) : `obsigate.note.created / edited /
|
||||||
|
moved / deleted`, signées HMAC, en arrière-plan, inactives sans la
|
||||||
|
clé `AGENT_EVENEMENTS_CLE`. Points d'accroche : les endpoints
|
||||||
|
d'écriture de l'API web (acteur `user`) et la couche outils /
|
||||||
|
serveur MCP (acteur `agent:mcp`). **Garde-fou : jamais le
|
||||||
|
watcher** — seules les opérations explicites via l'API émettent.
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [x] Module `backend/agent_journal.py` (enveloppe ACC-0.2,
|
||||||
|
signature HMAC-SHA256, envoi « au mieux »)
|
||||||
|
- [x] Branchements `backend/routers/files_write.py` (créer,
|
||||||
|
sauvegarder, renommer, déplacer, supprimer)
|
||||||
|
- [x] Branchements `backend/tools/service.py` (create/edit/
|
||||||
|
append/rename/move/delete, acteur selon le mode)
|
||||||
|
- [x] Tests `tests/test_agent_journal.py` — dont la preuve du
|
||||||
|
garde-fou watcher (écriture disque + indexation = silence)
|
||||||
|
|
||||||
|
### 218. Fusion — panneau Spark intégré (agent commun)
|
||||||
|
|
||||||
|
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-21 | **Statut :** ✅ livré le 2026-10-21 (2.74.0, redéploiement Docker en attente)
|
||||||
|
- **Fiche :** [features/fusion-panneau-spark-218.md](./features/fusion-panneau-spark-218.md)
|
||||||
|
- **Description :** le bouton Spark de la barre commune (#215) ouvre le
|
||||||
|
panneau de l'agent commun (service d'agents dracodev : conversation,
|
||||||
|
Aujourd'hui, approbations, missions longues), comme dans TikTak,
|
||||||
|
Flowdeck et Kanbante. Assets partagés vendangés à l'identique ;
|
||||||
|
la CSP nomme le service en `connect-src`.
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [x] Assets `frontend/draco-panneau.js` / `frontend/draco-panneau.css`
|
||||||
|
vendangés depuis le panneau partagé du programme
|
||||||
|
- [x] `frontend/index.html` : link CSS + script, action `spark` →
|
||||||
|
`DRACO_PANNEAU.toggle()` (repli honnête si le panneau ne charge pas)
|
||||||
|
- [x] CSP `connect-src` : `https://agent.dracodev.net` ajouté
|
||||||
|
(`backend/main.py`, seul hôte ajouté)
|
||||||
|
- [x] Tests `test_draco_barre.py` (intégration + CSP verrouillées)
|
||||||
|
|
||||||
|
### 219. Fusion — panneau Spark v2 (Lot 1)
|
||||||
|
|
||||||
|
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-22 | **Statut :** ✅ livré le 2026-10-22 (2.75.0, redéploiement Docker en attente)
|
||||||
|
- **Fiche :** [features/fusion-panneau-spark-v2-219.md](./features/fusion-panneau-spark-v2-219.md)
|
||||||
|
- **Description :** le panneau Spark (#218) passe en v2, à l'identique
|
||||||
|
dans les quatre applications (carte fusion-trois-apps #17) :
|
||||||
|
réponses de l'agent en **Markdown rendu** (HTML produit par le
|
||||||
|
service d'agents 0.10.0, sanitizé côté serveur), **largeur
|
||||||
|
réglable** persistée, **thème clair/sombre aligné** sur
|
||||||
|
l'application hôte, **discussions façon Muse** (principale
|
||||||
|
perpétuelle + parallèles créées/archivées/supprimées). Côté
|
||||||
|
ObsiGate : assets vendangés uniquement, aucun code propre touché.
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [x] Assets `frontend/draco-panneau.js` / `frontend/draco-panneau.css`
|
||||||
|
remplacés par la v2 partagée du programme
|
||||||
|
- [x] Fiche de conception `features/fusion-panneau-spark-v2-219.md`
|
||||||
|
|
||||||
|
### 215. Fusion bloc 1 — barre d'apps commune et références croisées des notes
|
||||||
|
|
||||||
|
- **Effort :** ~1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-20 | **Statut :** ✅ livré le 2026-10-20 (2.70.0)
|
||||||
|
- **Fiche :** [features/fusion-bloc1-barre-refs-215.md](./features/fusion-bloc1-barre-refs-215.md)
|
||||||
|
- **Description :** première tranche du programme **Fusion** (accès centralisé
|
||||||
|
ObsiGate / Flowdeck / TikTak / Kanbante — dépôt programme
|
||||||
|
`Projets/fusion-trois-apps`, épopée ACC-1 ; contrats versionnés dans
|
||||||
|
`Projets/dracodev-commons` v0.1.0). ID 215 choisi hors des plages déjà
|
||||||
|
utilisées ou proposées (#191–#201 livrées/Assistant V2, #202–#214
|
||||||
|
proposées par ASSISTANT-V2.1). Issues Gitea ObsiGate #5 (ACC-1.1),
|
||||||
|
#6 (ACC-1.2), #7 (ACC-1.3).
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [x] Barre d'apps commune vendangée du snippet figé de commons
|
||||||
|
(`frontend/draco-barre.css/.js`, markup dans `index.html`) :
|
||||||
|
ObsiGate marquée active, navigation vers les 4 apps, domaine mémorisé
|
||||||
|
- [x] Backend `external_ref` : stockage JSON par utilisateur
|
||||||
|
(`backend/external_refs.py`), endpoints `GET/POST /api/refs` et
|
||||||
|
`DELETE /api/refs/{id}` (schémas + tag OpenAPI), parseur d'URL
|
||||||
|
profondes conforme au contrat figé
|
||||||
|
- [x] Section « Liens » dans la vue d'une note (module
|
||||||
|
`frontend/js/draco-liens.js`, point d'extension
|
||||||
|
`obsigate:file-rendered` émis par `renderFile`)
|
||||||
|
- [x] Notes projetées TikTak reconnues (frontmatter `tiktak_id`) :
|
||||||
|
bandeau « Tâche TikTak projetée » + « Ouvrir dans TikTak »
|
||||||
|
- [x] i18n FR/EN (clés `draco.links.*`), tests `test_external_refs.py`
|
||||||
|
|
||||||
|
### 216. Fusion ACC-3.1 — émettrice SSO `draco_session`
|
||||||
|
|
||||||
|
- **Effort :** ~1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-21 | **Statut :** ✅ livré le 2026-10-21 (2.71.0)
|
||||||
|
- **Fiche :** [features/fusion-acc31-sso-draco-session-216.md](./features/fusion-acc31-sso-draco-session-216.md)
|
||||||
|
- **Description :** couche 2 (identité) du programme **Fusion** — décision C2
|
||||||
|
de Bruno : cookie de session partagé sous `.dracodev.net` (architecture
|
||||||
|
v2.0 §5.2, option 2a). ObsiGate est l'unique émettrice ; les autres apps
|
||||||
|
valideront localement (ACC-3.2). Issue Gitea ObsiGate #8 ; épopée
|
||||||
|
Projets/fusion-trois-apps#7.
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [x] Module `backend/draco_session.py` : JWS HS256, claims minimaux
|
||||||
|
(sub, name, iat, exp 12 h, jti — aucun rôle), rotation par
|
||||||
|
`DRACO_SESSION_SECRET_PREV`, inerte sans `DRACO_SESSION_SECRET`
|
||||||
|
- [x] Émission du cookie à chaque `_issue_tokens` (mot de passe ou MFA)
|
||||||
|
- [x] Renouvellement glissant (middleware ASGI `DracoRenewalMiddleware`,
|
||||||
|
fenêtre 2 h, conditionné au cookie d'accès natif valide)
|
||||||
|
- [x] Déconnexion globale : révocation du jti (`data/draco_revoked.json`)
|
||||||
|
+ effacement du cookie ; `GET /api/sso/revocations` sous clé machine
|
||||||
|
- [x] `GET /sso/authorize` : `return_to` en liste blanche
|
||||||
|
`*.dracodev.net` (anti open-redirect), retour post-connexion
|
||||||
|
re-validé côté frontend (`auth.js`)
|
||||||
|
- [x] Tag OpenAPI « SSO », tests `test_draco_session.py` (22)
|
||||||
|
|
||||||
|
### 217. Fusion ACC-3.2 — validation locale du jeton `draco_session`
|
||||||
|
|
||||||
|
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-21 | **Statut :** ✅ livré le 2026-10-21 (2.72.0)
|
||||||
|
- **Fiche :** [features/fusion-acc32-validation-locale-217.md](./features/fusion-acc32-validation-locale-217.md)
|
||||||
|
- **Description :** pendant consommateur de #216 — `get_current_user`
|
||||||
|
accepte le cookie partagé à défaut de jeton natif valide ; le `sub`
|
||||||
|
étant l'identifiant ObsiGate, la correspondance vers le compte local
|
||||||
|
est directe (compte actif exigé, garde-fou BUG-028 appliqué).
|
||||||
|
Issue Gitea ObsiGate #9 ; épopée Projets/fusion-trois-apps#7.
|
||||||
|
- **Sous-tâches :**
|
||||||
|
- [x] `draco_session.user_for_draco()` : résolution par id, `_draco_sub`
|
||||||
|
- [x] Repli `_draco_fallback()` sur toutes les sorties « non
|
||||||
|
authentifié » du chemin natif (`backend/auth/middleware.py`)
|
||||||
|
- [x] Tests `test_draco_session.py` (+4 : cookie seul, révoqué,
|
||||||
|
sub inconnu, inerte sans secret)
|
||||||
|
|
||||||
### 196. Partage dirigé entre utilisateurs (document → utilisateur)
|
### 196. Partage dirigé entre utilisateurs (document → utilisateur)
|
||||||
|
|
||||||
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-10 | **Statut :** ✅ livré le 2026-10-10
|
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-10 | **Statut :** ✅ livré le 2026-10-10
|
||||||
@@ -155,6 +319,18 @@
|
|||||||
compte de synchro + clé limitée (#199) + webhook filtré (#198) + recette `curl`
|
compte de synchro + clé limitée (#199) + webhook filtré (#198) + recette `curl`
|
||||||
d'acceptation. **Reste à exécuter sur l'hôte** (suivre le runbook, puis fermer Gitea #1).
|
d'acceptation. **Reste à exécuter sur l'hôte** (suivre le runbook, puis fermer Gitea #1).
|
||||||
|
|
||||||
|
### 201. Menu contextuel « Déplacer » / « Dupliquer » + réparation du drag & drop interne (sidebar, BUG-117)
|
||||||
|
|
||||||
|
- **Effort :** 0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-15 | **Statut :** ✅ livré le 2026-10-15
|
||||||
|
- **Description :** déplacement/duplication fichiers et répertoires via le menu contextuel
|
||||||
|
(modal `promptTargetFolder` réutilisé et harmonisé, emplacement actuel pré-sélectionné,
|
||||||
|
cross-vault refusé proprement) ; endpoint `POST /api/duplicate/{vault}` (`duplicate_path` :
|
||||||
|
copie `X copie.ext`, collisions numérotées, refus de dupliquer un dossier dans lui-même,
|
||||||
|
index incrémental, SSE + webhooks) ; drag & drop interne réparé (le drop était avalé en
|
||||||
|
silence par la détection `isExternalFiles` qui comptait les formats string `tree:*` du
|
||||||
|
glisser interne) ; i18n FR/EN ; 4 tests `TestDuplicateApi` + validation Playwright E2E.
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 77. Application Desktop native — Tauri (Windows / Linux / macOS)
|
### 77. Application Desktop native — Tauri (Windows / Linux / macOS)
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# #216 — Émettrice SSO `draco_session` (Fusion ACC-3.1)
|
||||||
|
|
||||||
|
Statut : ✅ livré le 2026-10-21 (2.71.0)
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Programme Fusion (accès centralisé ObsiGate / Flowdeck / TikTak /
|
||||||
|
Kanbante), couche 2 — identité. Décision **C2** de Bruno (2026-10-20) :
|
||||||
|
cookie de session partagé `draco_session` sous `.dracodev.net`
|
||||||
|
(architecture v2.0 §5.2, option 2a). ObsiGate est l'**unique
|
||||||
|
émettrice** ; les autres applications valideront le jeton localement
|
||||||
|
(ACC-3.2, leurs tickets respectifs) et redirigeront leur connexion
|
||||||
|
vers `GET /sso/authorize`.
|
||||||
|
|
||||||
|
## Ce qui est livré
|
||||||
|
|
||||||
|
- **`backend/draco_session.py`** — émission et vérification du JWS
|
||||||
|
(HS256, jose) : claims minimaux `sub` (id utilisateur ObsiGate),
|
||||||
|
`name`, `iat`, `exp` (**12 h**), `jti` ; **aucun rôle** dans le
|
||||||
|
jeton. Rotation : `DRACO_SESSION_SECRET_PREV` accepté en
|
||||||
|
vérification pendant la fenêtre de bascule.
|
||||||
|
- **Émission à la connexion** : `_issue_tokens` (auth/router.py) pose
|
||||||
|
le cookie à chaque émission de jetons natifs (mot de passe ou MFA) —
|
||||||
|
attributs `Domain=.dracodev.net; Path=/; HttpOnly; Secure (si la
|
||||||
|
requête est en https, via `is_secure_cookies`); SameSite=Lax`.
|
||||||
|
- **Renouvellement glissant** : `DracoRenewalMiddleware` (ASGI pur,
|
||||||
|
aucun tamponnage des flux SSE/WS) — si la requête porte un cookie
|
||||||
|
d'accès natif valide et que le cookie draco est absent, invalide ou
|
||||||
|
à moins de 2 h de son expiration, un cookie frais est ajouté à la
|
||||||
|
réponse.
|
||||||
|
- **Déconnexion globale** : le logout révoque le `jti` draco
|
||||||
|
(en plus des jetons natifs) et efface le cookie du domaine parent.
|
||||||
|
Révocations persistées dans `data/draco_revoked.json`
|
||||||
|
(jti → exp, purge des expirés), exposées aux autres apps par
|
||||||
|
**`GET /api/sso/revocations`** protégé par la clé machine
|
||||||
|
`DRACO_SSO_MACHINE_KEY` (en-tête `X-Draco-Machine-Key`, 404 si la
|
||||||
|
clé n'est pas configurée) — cache 60 s côté consommatrices.
|
||||||
|
- **`GET /sso/authorize?return_to=`** : valide le `return_to`
|
||||||
|
(https + `dracodev.net` ou sous-domaine, liste blanche stricte
|
||||||
|
anti open-redirect) puis redirige vers la connexion ObsiGate ;
|
||||||
|
après connexion, `frontend/js/auth.js` repart vers l'application
|
||||||
|
demanderesse (re-validation du domaine côté client).
|
||||||
|
- **Inerte par défaut** : sans `DRACO_SESSION_SECRET`, aucun cookie
|
||||||
|
n'est émis et le comportement d'ObsiGate est inchangé.
|
||||||
|
|
||||||
|
## Configuration (déploiement)
|
||||||
|
|
||||||
|
| Variable | Rôle |
|
||||||
|
|---|---|
|
||||||
|
| `DRACO_SESSION_SECRET` | Secret partagé de signature — **le même** devra être configuré dans Flowdeck et TikTak pour leurs validateurs ACC-3.2. À générer fort (ex. 64 hex) et à ne jamais committer. |
|
||||||
|
| `DRACO_SESSION_SECRET_PREV` | Ancien secret pendant une rotation (24 h). |
|
||||||
|
| `DRACO_SESSION_COOKIE_DOMAIN` | Domaine du cookie (défaut `.dracodev.net` ; vide = hôte seul, dev local). |
|
||||||
|
| `DRACO_SSO_MACHINE_KEY` | Clé machine de lecture des révocations, à partager avec les apps consommatrices. |
|
||||||
|
|
||||||
|
⚠️ **Déploiement** : la fonction ne s'active qu'au redéploiement
|
||||||
|
d'ObsiGate ≥ 2.71.0 **avec** `DRACO_SESSION_SECRET` posé dans
|
||||||
|
l'environnement du conteneur. Le SSO complet exige ensuite les
|
||||||
|
validateurs ACC-3.2 (ObsiGate #9, Flowdeck #21, TikTak #213).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`tests/test_draco_session.py` — 22 tests : claims minimaux, durée de
|
||||||
|
vie, falsification, expiration, rotation, révocation, fenêtre de
|
||||||
|
renouvellement, liste blanche `return_to` (8 cas), cookie posé à la
|
||||||
|
connexion (attributs), inactivité sans secret, révocation au logout,
|
||||||
|
renouvellement glissant via middleware, endpoint de révocations
|
||||||
|
(404/401/200).
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# #217 — Validation locale du jeton draco_session (Fusion ACC-3.2)
|
||||||
|
|
||||||
|
Statut : ✅ livré le 2026-10-21 (2.72.0)
|
||||||
|
|
||||||
|
## Contexte
|
||||||
|
|
||||||
|
Suite de l'émettrice SSO (#216, ACC-3.1). Ici, ObsiGate joue son rôle
|
||||||
|
de **consommatrice** : `get_current_user` accepte le cookie partagé
|
||||||
|
`draco_session` à défaut de jeton natif valide. C'est la brique qui
|
||||||
|
servira aussi à l'API de l'agent commun (ACC-4) pour authentifier les
|
||||||
|
requêtes SSO. Les validateurs des autres applications sont leurs
|
||||||
|
tickets respectifs (Flowdeck #21, TikTak #213 — TikTak 0.52.0).
|
||||||
|
|
||||||
|
## Correspondance sub → compte local
|
||||||
|
|
||||||
|
ObsiGate est émettrice ET consommatrice : le `sub` du jeton **est**
|
||||||
|
son identifiant d'utilisateur — la résolution est directe, par `id`,
|
||||||
|
dans `draco_session.user_for_draco()` :
|
||||||
|
|
||||||
|
- signature/expiration/révocation vérifiées par `verify_token` (#216) ;
|
||||||
|
- compte **actif** exigé ;
|
||||||
|
- garde-fou BUG-028 appliqué à l'identique : un jeton draco émis
|
||||||
|
avant le dernier changement de mot de passe (`password_changed_at`)
|
||||||
|
est refusé ;
|
||||||
|
- le dict utilisateur retourné porte `_token_vaults` (droits du
|
||||||
|
compte), `_token_scope = "full"` et `_draco_sub` (traçabilité).
|
||||||
|
|
||||||
|
## Branchement
|
||||||
|
|
||||||
|
Dans `backend/auth/middleware.py`, chaque sortie « non authentifié »
|
||||||
|
du chemin natif (aucun jeton, jeton illisible, jti natif révoqué,
|
||||||
|
compte introuvable/inactif, jeton antérieur au changement de mot de
|
||||||
|
passe) passe par `_draco_fallback()`. Sûreté du repli après
|
||||||
|
déconnexion globale : le logout révoque aussi le `jti` draco et
|
||||||
|
efface le cookie (#216), donc un ancien cookie rejoué est refusé par
|
||||||
|
`verify_token`.
|
||||||
|
|
||||||
|
## Limite connue
|
||||||
|
|
||||||
|
Le repli authentifie les **appels d'API**. Le bootstrap d'une
|
||||||
|
session complète du SPA (jetons natifs émis à partir du seul cookie
|
||||||
|
draco) n'est pas couvert : pour ObsiGate, émettrice, la connexion
|
||||||
|
native EST la connexion SSO — le cas d'usage du repli est la
|
||||||
|
continuité d'API quand le jeton natif a expiré avant le cookie
|
||||||
|
partagé, et les consommateurs machine de l'API.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`tests/test_draco_session.py` (+4) : `/api/auth/me` avec le seul
|
||||||
|
cookie draco (200, bon utilisateur), jti révoqué refusé, sub inconnu
|
||||||
|
refusé, inactivité sans secret configuré.
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# #215 — Fusion bloc 1 : barre d'apps commune et références croisées des notes
|
||||||
|
|
||||||
|
> Programme **Fusion** (accès centralisé entre ObsiGate, Flowdeck, TikTak et
|
||||||
|
> Kanbante). Dépôt programme : `Projets/fusion-trois-apps` (épopée ACC-1,
|
||||||
|
> issues ObsiGate #5, #6, #7). Contrats : `Projets/dracodev-commons` v0.1.0
|
||||||
|
> (`contrats/liens-profonds.md`, `contrats/external_ref.schema.json`,
|
||||||
|
> snippet `ui/barre-apps/`).
|
||||||
|
|
||||||
|
## Ce qui est livré
|
||||||
|
|
||||||
|
### ACC-1.1 — Barre d'apps commune (#5)
|
||||||
|
|
||||||
|
Le snippet figé de commons est **vendangé** (copié, jamais chargé depuis une
|
||||||
|
autre app — la barre survit à l'arrêt des autres applications) :
|
||||||
|
|
||||||
|
- `frontend/draco-barre.css` / `frontend/draco-barre.js` : copie v0.1.0 ;
|
||||||
|
- markup + configuration dans `frontend/index.html`, au-dessus de `#app`
|
||||||
|
(`body.has-draco-bar` ajuste la hauteur de `.app-container` dans
|
||||||
|
`style.css`).
|
||||||
|
|
||||||
|
Phase 1 : navigation entre les quatre applications (ObsiGate marquée
|
||||||
|
active), sélecteur de domaine mémorisé en `localStorage` (`draco_domain`,
|
||||||
|
même clé que TikTak), recherche = focus de la recherche native, rail =
|
||||||
|
bouton latéral natif, menu = menu d'en-tête natif. Alertes unifiées et
|
||||||
|
panneau Spark affichent un message d'arrivée (fédération et agent commun :
|
||||||
|
phases ultérieures du programme) plutôt que des boutons morts. Les compteurs
|
||||||
|
croisés viendront avec la fédération ; seul le compteur local de chaque app
|
||||||
|
est alimenté en phase 1.
|
||||||
|
|
||||||
|
### ACC-1.3 — Références croisées `external_ref` (#7)
|
||||||
|
|
||||||
|
- **Modèle** : le schéma figé de commons. La note est propriétaire de ses
|
||||||
|
références (`owner_type = obsigate.note`, `owner_id = "<vault>:<chemin>"`).
|
||||||
|
- **Stockage** : JSON par utilisateur dans `data/external_refs/`
|
||||||
|
(`backend/external_refs.py`, même patron que `backend/history.py` —
|
||||||
|
pas de base de données). Surchargable par `OBSIGATE_EXTERNAL_REFS_DIR`.
|
||||||
|
- **API** (auth + `check_vault_access`) :
|
||||||
|
- `GET /api/refs?vault=&path=` — références d'une note ;
|
||||||
|
- `POST /api/refs` `{vault, path, url, label?}` — création par collage
|
||||||
|
d'URL profonde ; le parseur reconnaît les formes du contrat (TikTak
|
||||||
|
`?task=`/`?list=`, ObsiGate `#file=`, Flowdeck `/pages/`/`/collections/`,
|
||||||
|
issue Gitea = carte Kanbante) et répond **422** explicite sinon ;
|
||||||
|
- `DELETE /api/refs/{id}`.
|
||||||
|
- **UI** : section « Liens » en fin de note — liste avec pastille de couleur
|
||||||
|
de l'app cible, formulaire d'ajout, retrait en un clic. Rendue par le
|
||||||
|
module autonome `frontend/draco-liens.js`, branché sur l'événement
|
||||||
|
`obsigate:file-rendered` que `renderFile` émet (point d'extension
|
||||||
|
documenté dans `frontend/app.js`).
|
||||||
|
|
||||||
|
### ACC-1.2 — Notes projetées TikTak (#6)
|
||||||
|
|
||||||
|
La synchro TikTak ↔ ObsiGate écrit `tiktak_id: <id>` dans le frontmatter des
|
||||||
|
fichiers projetés. Le module `draco-liens.js` le détecte (lecture du brut via
|
||||||
|
`/api/file/{vault}/raw`) et affiche un bandeau « Tâche TikTak projetée » avec
|
||||||
|
le bouton **« Ouvrir dans TikTak »** (`https://tiktak.dracodev.net/?task=<id>`),
|
||||||
|
au lieu de traiter la note comme une note ordinaire.
|
||||||
|
|
||||||
|
## Limites assumées (v1)
|
||||||
|
|
||||||
|
- Le libellé d'une référence est un instantané (titre par défaut dérivé de
|
||||||
|
l'URL ou libellé libre) ; le rafraîchissement automatique du titre viendra
|
||||||
|
avec la fédération MCP (phase 3 du programme).
|
||||||
|
- Un lien dont la cible a disparu n'est pas détecté automatiquement
|
||||||
|
(`status` reste `ok`) — la marque « cible introuvable » viendra avec la
|
||||||
|
couche données.
|
||||||
|
- La section « Liens » n'apparaît que dans la vue lecture d'une note
|
||||||
|
(pas dans l'éditeur).
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`tests/test_external_refs.py` : parseur (6 formes reconnues, 7 refusées),
|
||||||
|
stockage (cycle + isolation par utilisateur), API (cycle complet, 422, 404,
|
||||||
|
403 vault inconnu).
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# #220 — Fusion : émission d'événements vers le journal du service d'agents
|
||||||
|
|
||||||
|
**Contexte.** Le Lot 2 du programme Fusion (carte fusion-trois-apps
|
||||||
|
#18) dote le service d'agents d'un **journal d'activité** cross-apps :
|
||||||
|
chaque application y émet ses mutations significatives au contrat
|
||||||
|
**ACC-0.2** — enveloppe `{id, type, occurred_at, actor, domain,
|
||||||
|
source_app, object{ref, label, url}}` signée **HMAC-SHA256** du corps
|
||||||
|
brut (en-tête `X-Draco-Signature`, clé propre à chaque application),
|
||||||
|
que le service expose dans la vue Journal de sa console (agent-commun
|
||||||
|
0.11.0). TikTak (0.59.0) et Kanbante (0.28.0) émettent déjà ; ObsiGate
|
||||||
|
est la troisième source, après Flowdeck.
|
||||||
|
|
||||||
|
**Principe directeur : référence, jamais contenu.** L'enveloppe ne
|
||||||
|
contient que la référence de la note (`fichier:<vault>/<chemin>`),
|
||||||
|
son nom (libellé) et un lien profond (`/#file=<vault>:<chemin>`) —
|
||||||
|
jamais son contenu.
|
||||||
|
|
||||||
|
## Garde-fou central : jamais le watcher
|
||||||
|
|
||||||
|
ObsiGate surveille les vaults par un **watcher** de système de
|
||||||
|
fichiers (`backend/watcher.py`) qui réindexe chaque changement
|
||||||
|
disque — le vault principal compte **222 393 fichiers**. Brancher
|
||||||
|
l'émission sur le watcher inonderait le journal (et confondrait les
|
||||||
|
gestes d'Obsidian, de la synchro de fichiers ou d'un éditeur externe
|
||||||
|
avec des opérations faites *dans* ObsiGate).
|
||||||
|
|
||||||
|
L'émission n'a donc lieu que pour les **opérations explicites via
|
||||||
|
l'API web**, à deux points d'accroche, et nulle part ailleurs :
|
||||||
|
|
||||||
|
| Source | Point d'accroche | Acteur |
|
||||||
|
|---|---|---|
|
||||||
|
| Interface web / clients REST | `backend/routers/files_write.py` | `user` |
|
||||||
|
| Serveur MCP d'ObsiGate (outils) | `backend/tools/service.py`, mode `mcp` | `agent:mcp` |
|
||||||
|
| Assistant intégré (outils, mode `in_app`) | `backend/tools/service.py` | `user` |
|
||||||
|
|
||||||
|
Conséquences assumées :
|
||||||
|
|
||||||
|
- une note créée/modifiée **hors API** (Obsidian, éditeur externe,
|
||||||
|
copie de fichiers) n'apparaît **pas** au journal — c'est voulu ;
|
||||||
|
- les écritures internes qui passent par la couche mutations sans
|
||||||
|
passer par ces points (planificateur, crawler, fusion de doublons,
|
||||||
|
restauration de sauvegarde) n'émettent pas non plus ;
|
||||||
|
- le test `test_watcher_et_indexation_sans_evenement` verrouille le
|
||||||
|
garde-fou : écriture disque directe + gestionnaires du watcher +
|
||||||
|
`update_single_file` de l'indexeur → **zéro émission**.
|
||||||
|
|
||||||
|
## Catalogue d'événements émis
|
||||||
|
|
||||||
|
| Type | Déclencheur |
|
||||||
|
|---|---|
|
||||||
|
| `obsigate.note.created` | `POST /api/file/{vault}` ; outil `create_file` |
|
||||||
|
| `obsigate.note.edited` | `PUT /api/file/{vault}/save` (+ variantes xlsx/csv : cellules, structure, style) ; outils `edit_file`, `append_to_file` |
|
||||||
|
| `obsigate.note.moved` | `PATCH /api/file/{vault}` (renommage), `POST /api/move/{vault}` (**fichiers seulement**) ; outils `rename_file`, `move_path` (fichiers seulement) — la référence pointe sur le **nouveau** chemin |
|
||||||
|
| `obsigate.note.deleted` | `DELETE /api/file/{vault}` ; outil `delete_file` |
|
||||||
|
|
||||||
|
Hors catalogue (non émis, volontairement) : dossiers (création,
|
||||||
|
renommage, déplacement, suppression — le catalogue est `note.*`),
|
||||||
|
téléversement par lots (`batch-upload`), duplication, restauration
|
||||||
|
de sauvegarde, `replace_in_files` (multi-fichiers).
|
||||||
|
|
||||||
|
## Module `backend/agent_journal.py`
|
||||||
|
|
||||||
|
- **Activation** : `AGENT_EVENEMENTS_CLE` (clé propre à ObsiGate,
|
||||||
|
jamais en dur). Sans clé, le module est **inactif** : aucun appel,
|
||||||
|
aucun bruit. URL surchargée par `AGENT_EVENEMENTS_URL` (défaut
|
||||||
|
`https://agent.dracodev.net/api/v1/evenements`).
|
||||||
|
- **Discipline « au mieux »** (identique à TikTak/Kanbante) : envoi
|
||||||
|
dans un fil d'arrière-plan démon via httpx, délai maximal 2 s,
|
||||||
|
exceptions avalées avec une ligne de journal local — une panne du
|
||||||
|
service d'agents ne casse ni ne ralentit jamais le métier (prouvé
|
||||||
|
par `test_recepteur_en_panne_ne_casse_rien`).
|
||||||
|
- **Domaine** : ObsiGate ne modélise pas encore le domaine d'un
|
||||||
|
vault (la question Q-A du programme — les domaines sont-ils des
|
||||||
|
vaults distincts ? — est toujours ouverte). La correspondance peut
|
||||||
|
être déclarée par `AGENT_EVENEMENTS_DOMAINES` (JSON
|
||||||
|
`{"NomDuVault": "famille"}`) ; repli `pro`, qui est aussi le
|
||||||
|
défaut du journal du service.
|
||||||
|
|
||||||
|
## Activation en production (gestes de Bruno)
|
||||||
|
|
||||||
|
1. Générer une clé propre à ObsiGate (`openssl rand -hex 32`).
|
||||||
|
2. Service d'agents : ajouter l'entrée `obsigate` à
|
||||||
|
`AGENT_EVENEMENTS_CLES` (JSON des clés par application), recréer
|
||||||
|
le conteneur.
|
||||||
|
3. ObsiGate : poser `AGENT_EVENEMENTS_CLE=<même valeur>` dans son
|
||||||
|
environnement et **redéployer** (Docker).
|
||||||
|
|
||||||
|
Tant que la clé n'est pas posée des deux côtés, ObsiGate n'émet
|
||||||
|
rien — c'est le comportement voulu.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
`tests/test_agent_journal.py` (7 tests) : enveloppe et signature
|
||||||
|
revérifiées comme le fait le service ; cycle de vie complet en REST
|
||||||
|
(créer → éditer → renommer → déplacer → supprimer, acteur `user`) ;
|
||||||
|
chemin outils/MCP (`create/edit/rename/delete`, acteur
|
||||||
|
`agent:mcp`) ; inactivité sans clé ; garde-fou watcher/indexation ;
|
||||||
|
panne du récepteur sans effet métier ; correspondance de domaines.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# #221 — Fusion : bouton « Console ↗ » dans le panneau Spark
|
||||||
|
|
||||||
|
**Contexte.** Le panneau Spark (#218) et la console web du service
|
||||||
|
d'agents (Lot 2, https://agent.dracodev.net/) sont deux faces du même
|
||||||
|
service : mêmes discussions, mêmes approbations, mêmes missions. Il
|
||||||
|
manquait le lien de navigation entre les deux. Demande de Bruno
|
||||||
|
(2026-10-22) : un bouton dans le panneau qui ouvre la console.
|
||||||
|
|
||||||
|
**Changement.** Dans `frontend/draco-panneau.js` — asset **vendangé**,
|
||||||
|
maintenu en canonique dans TikTak et recopié **octet-identique** dans
|
||||||
|
les 4 applications (TikTak, Flowdeck, Kanbante, ObsiGate) :
|
||||||
|
|
||||||
|
- un bouton fantôme « Console ↗ » dans la barre des discussions, à
|
||||||
|
côté de « ☰ Discussions » et « + Nouvelle » (même classe `d-btn`,
|
||||||
|
donc visible en thème clair comme sombre, le panneau suivant le
|
||||||
|
thème de l'app hôte) ;
|
||||||
|
- au clic, `ouvrirConsole()` construit l'URL **à ce moment-là** (la
|
||||||
|
discussion peut avoir changé sans recharger le panneau) :
|
||||||
|
`https://agent.dracodev.net/?disc=<id de la discussion affichée>&app=<app hôte>` ;
|
||||||
|
- la discussion vient de l'état du panneau (`convId`, repli
|
||||||
|
`sessionStorage draco_panneau_conv`) ; l'app hôte est déduite du
|
||||||
|
sous-domaine par `appHote()` (`og` → `obsigate`, `tiktak`,
|
||||||
|
`flowdeck`, `kanbante` ; hôte inconnu → pas de paramètre) ;
|
||||||
|
- ouverture en **nouvel onglet** (`window.open(..., "_blank",
|
||||||
|
"noopener")`). Sans discussion connue : lien simple vers la
|
||||||
|
racine de la console.
|
||||||
|
|
||||||
|
La console consomme déjà les deux paramètres : `?disc=` ouvre la
|
||||||
|
discussion demandée (lien profond), `?app=` s'affiche en puce
|
||||||
|
« Ouvert depuis : … » dans « Ce que Spark voit ».
|
||||||
|
|
||||||
|
**Vérifications.** Syntaxe JS (`node --check`), test fonctionnel de
|
||||||
|
la construction d'URL (4 cas : app connue/inconnue × discussion
|
||||||
|
présente/absente), test de non-régression
|
||||||
|
`tests/test_draco_barre.py::test_panneau_bouton_console_221`
|
||||||
|
(marqueurs du bouton et de la logique dans l'asset servi), somme
|
||||||
|
md5 commune des 4 copies : `c67cc2ab8e657b4e545fa93b28c3a0c4`.
|
||||||
|
|
||||||
|
**Versions.** ObsiGate 2.76.1 (cette livraison) ; TikTak 0.59.1 et
|
||||||
|
Flowdeck 7.80.1 déployées automatiquement sur release ; Kanbante
|
||||||
|
0.28.1 publiée. **Activation côté ObsiGate : redéploiement Docker
|
||||||
|
par Bruno.**
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# #222 — Fusion : panneau Spark V3, la console en surimpression pleine page
|
||||||
|
|
||||||
|
**Contexte.** Le bouton « Console ↗ » du panneau Spark (#221) ouvrait
|
||||||
|
la console web du service d'agents (https://agent.dracodev.net/)
|
||||||
|
dans un nouvel onglet : on quittait l'application en cours pour un
|
||||||
|
autre onglet. Demande de Bruno (2026-10-22) : rester dans la même
|
||||||
|
page — la console s'ouvre par-dessus l'application, avec une
|
||||||
|
animation comme celle de la barre latérale mais qui recouvre la
|
||||||
|
page au complet, et deux sorties explicites : revenir en mode
|
||||||
|
sidebar, ou fermer l'agent.
|
||||||
|
|
||||||
|
**Changement.** Dans `frontend/draco-panneau.js` — asset **vendangé**,
|
||||||
|
maintenu en canonique dans TikTak et recopié **octet-identique** dans
|
||||||
|
les 4 applications (TikTak, Flowdeck, Kanbante, ObsiGate) :
|
||||||
|
|
||||||
|
- le bouton devient « Console ⤢ » (même emplacement, barre des
|
||||||
|
discussions) et `ouvrirConsole()` n'ouvre plus d'onglet : il
|
||||||
|
monte une **surimpression** `.draco-console-sur` ancrée à droite,
|
||||||
|
née à la largeur du panneau, qui **s'étend à 100 % de la fenêtre**
|
||||||
|
par transition CSS (double `requestAnimationFrame` pour laisser
|
||||||
|
peindre la géométrie de départ) ;
|
||||||
|
- la console est chargée dans une **iframe** dont l'URL reste
|
||||||
|
construite au premier clic par `urlConsole()` : discussion
|
||||||
|
affichée (`?disc=<id>`) et app d'origine (`?app=obsigate`, déduite
|
||||||
|
du sous-domaine `og`) — le lien profond de #221 est conservé ;
|
||||||
|
- la barre de la surimpression (« ✦ Console Spark ») porte les deux
|
||||||
|
sorties demandées : **« ◧ Mode sidebar »** (`retrecirConsole()` :
|
||||||
|
l'overlay se rétracte à la largeur du panneau puis se masque,
|
||||||
|
l'iframe reste chargée) et **« ✕ Fermer l'agent »** (`fermer()` +
|
||||||
|
rétraction : panneau et console fermés) ;
|
||||||
|
- les styles de la surimpression sont **injectés par le panneau
|
||||||
|
lui-même** (`<style id="draco-console-styles">`, variables du
|
||||||
|
thème du panneau) : aucun fichier CSS à toucher côté hôte, et la
|
||||||
|
surimpression suit le thème de l'app comme le panneau.
|
||||||
|
|
||||||
|
Côté service d'agents (0.20.0) : la console autorise désormais
|
||||||
|
l'encadrement par les 4 applications (`frame-ancestors` restreint
|
||||||
|
aux sous-domaines `dracodev.net` des apps) — sans quoi l'iframe
|
||||||
|
resterait blanche.
|
||||||
|
|
||||||
|
**Vérifications.** Syntaxe JS (`node --check`) ; test
|
||||||
|
`tests/test_draco_barre.py::test_panneau_console_surimpression_222`
|
||||||
|
(marqueurs de la surimpression, des deux sorties et de l'animation
|
||||||
|
dans l'asset) ; le test #221 est révisé en conséquence (le lien
|
||||||
|
profond `?disc=` / `?app=` subsiste, l'ouverture en nouvel onglet
|
||||||
|
disparaît) ; somme md5 commune des copies :
|
||||||
|
`93026ffa14bc8ee896349e3858e12edf`.
|
||||||
|
|
||||||
|
**Versions.** ObsiGate 2.77.0 (cette livraison) ; TikTak 0.60.0 et
|
||||||
|
Flowdeck 7.81.0 déployées automatiquement sur release ; Kanbante
|
||||||
|
0.29.0 publiée. **Activation côté ObsiGate : redéploiement Docker
|
||||||
|
par Bruno.**
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# #218 — Fusion : panneau Spark intégré (agent commun)
|
||||||
|
|
||||||
|
**Contexte.** Le programme Fusion a livré le service d'agents commun
|
||||||
|
(agent-commun : conversation LLM, lectures/écritures des quatre
|
||||||
|
applications via approbations, sous-agents, missions longues). Trois
|
||||||
|
applications affichaient déjà son panneau partagé (TikTak, Flowdeck,
|
||||||
|
Kanbante) ; ObsiGate, pourtant émettrice de l'identité `draco_session`,
|
||||||
|
gardait le message d'attente « arrive en phase 4 » sur le bouton Spark
|
||||||
|
de sa barre commune (#215).
|
||||||
|
|
||||||
|
**Livraison.**
|
||||||
|
|
||||||
|
- Assets vendangés à l'identique des autres applications :
|
||||||
|
`frontend/draco-panneau.js` et `frontend/draco-panneau.css`, à la
|
||||||
|
racine de `frontend/` comme les autres assets draco (servis par le
|
||||||
|
montage `/static`, `Cache-Control: no-cache` déjà en place — aucun
|
||||||
|
mécanisme de versionnage à ajouter, `index.html` n'est pas un
|
||||||
|
gabarit Jinja).
|
||||||
|
- `frontend/index.html` : lien CSS après celui de la barre, script
|
||||||
|
différé après `draco-liens.js`, action `spark` de
|
||||||
|
`DRACO_BAR_CONFIG.onAction` branchée sur
|
||||||
|
`window.DRACO_PANNEAU.toggle()` avec repli explicite si l'asset ne
|
||||||
|
charge pas.
|
||||||
|
- CSP (`backend/main.py`, `SecurityHeadersMiddleware`) :
|
||||||
|
`https://agent.dracodev.net` ajouté à `connect-src` — le panneau
|
||||||
|
appelle le service en `fetch` avec le cookie de session ; aucun
|
||||||
|
autre hôte n'est ouvert (précédent : Flowdeck, même exception).
|
||||||
|
|
||||||
|
**Vérifications.** Tests `tests/test_draco_barre.py` étendus
|
||||||
|
(présence et références des assets, branche spark, hôte CSP) ;
|
||||||
|
vérification visuelle et E2E à confirmer après redéploiement Docker
|
||||||
|
(le panneau, `z-index: 400`, cohabite avec des surfaces ObsiGate à
|
||||||
|
z-index plus élevé — à observer en réel).
|
||||||
|
|
||||||
|
**Hors périmètre.** Les textes du panneau vivent dans l'asset partagé
|
||||||
|
du programme (français, identique dans les quatre applications) ; ils
|
||||||
|
ne passent pas par les locales FR/EN d'ObsiGate — écart assumé au
|
||||||
|
niveau du programme, pas de cette fiche.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# #219 — Fusion : panneau Spark v2 (Lot 1)
|
||||||
|
|
||||||
|
**Contexte.** Le panneau Spark livré en 2.74.0 (#218) affichait les
|
||||||
|
réponses de l'agent en texte brut (les `##` et `**` du Markdown
|
||||||
|
restaient visibles), avait une largeur fixe, un thème clair codé en
|
||||||
|
dur déconnecté de l'application hôte, et une seule conversation sans
|
||||||
|
gestion. Le Lot 1 du « Panneau v2 » (carte fusion-trois-apps #17,
|
||||||
|
arbitrage de Bruno du 2026-10-22) corrige ces quatre points, à
|
||||||
|
l'identique dans les quatre applications du programme.
|
||||||
|
|
||||||
|
**Livraison (côté ObsiGate : assets vendangés uniquement).**
|
||||||
|
|
||||||
|
- `frontend/draco-panneau.js` / `frontend/draco-panneau.css`
|
||||||
|
remplacés par la v2 partagée (octet pour octet avec TikTak,
|
||||||
|
Flowdeck et Kanbante) :
|
||||||
|
- **Markdown rendu** : les réponses et les rapports de missions
|
||||||
|
affichent le HTML produit par le service d'agents (0.10.0 :
|
||||||
|
mistune + sanitizer côté serveur) ;
|
||||||
|
- **largeur réglable** à la souris (poignée, persistée,
|
||||||
|
double-clic = largeur par défaut) ;
|
||||||
|
- **thème clair/sombre aligné** sur l'application hôte
|
||||||
|
(`html[data-theme]`, ici celui d'ObsiGate, suivi en direct) ;
|
||||||
|
- **discussions façon Muse** : discussion principale
|
||||||
|
perpétuelle + discussions parallèles créées, archivées et
|
||||||
|
supprimées depuis le tiroir du panneau.
|
||||||
|
- Le travail de fond est dans le service d'agents (agent-commun
|
||||||
|
0.10.0) ; ObsiGate ne change aucun code propre. Le `/static` est
|
||||||
|
déjà servi en `Cache-Control: no-cache` : aucun versionnage
|
||||||
|
d'asset à ajouter. Redéploiement Docker requis.
|
||||||
@@ -3424,6 +3424,13 @@
|
|||||||
|
|
||||||
// Initialize outline/TOC for this document
|
// Initialize outline/TOC for this document
|
||||||
OutlineManager.init();
|
OutlineManager.init();
|
||||||
|
|
||||||
|
// Fusion #215 (ACC-1.2/1.3) — point d'extension : le module
|
||||||
|
// draco-liens.js rend la section « Liens » de la note
|
||||||
|
// (références croisées + « ouvrir dans TikTak » si projetée).
|
||||||
|
document.dispatchEvent(new CustomEvent("obsigate:file-rendered", {
|
||||||
|
detail: { vault: data.vault, path: data.path },
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2) ;
|
||||||
|
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
|
||||||
|
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
|
||||||
|
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21) ;
|
||||||
|
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée).
|
||||||
|
Jetons repris du prototype validé Console v7.2.
|
||||||
|
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
|
||||||
|
ou prefers-color-scheme si l'app intégratrice ne force rien. */
|
||||||
|
|
||||||
|
.draco-bar {
|
||||||
|
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
|
||||||
|
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
|
||||||
|
--agent: #c2437e; --danger: #cf4444;
|
||||||
|
position: relative;
|
||||||
|
height: 60px; box-sizing: border-box;
|
||||||
|
display: grid; grid-template-columns: auto minmax(200px, 520px) auto;
|
||||||
|
align-items: center; gap: 16px; padding: 0 12px;
|
||||||
|
background: var(--surface); border-bottom: 1px solid var(--line);
|
||||||
|
font: 500 14px/1.3 system-ui, "Segoe UI", sans-serif; color: var(--ink);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
|
||||||
|
--ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; }
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
|
||||||
|
--ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; }
|
||||||
|
|
||||||
|
.draco-bar-zone { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||||
|
.draco-bar-centre { justify-content: center; }
|
||||||
|
.draco-bar-droite { justify-content: flex-end; }
|
||||||
|
|
||||||
|
/* Marque — bouton du menu des applications (v0.1.3) */
|
||||||
|
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; cursor: pointer; border-radius: 10px; padding: 4px 6px; margin: -4px -6px; }
|
||||||
|
.draco-brand:hover { background: var(--surface-2); }
|
||||||
|
.draco-brand-mark { color: var(--agent); font-size: 24px; line-height: 1; }
|
||||||
|
|
||||||
|
/* Menu des applications — construit par le JS depuis les onglets */
|
||||||
|
.draco-apps-menu {
|
||||||
|
position: absolute; top: calc(100% + 6px); left: 10px; z-index: 400;
|
||||||
|
min-width: 248px; padding: 6px;
|
||||||
|
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
|
||||||
|
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
|
||||||
|
}
|
||||||
|
.draco-apps-menu[hidden] { display: none; }
|
||||||
|
.draco-apps-item {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 10px; border-radius: 10px;
|
||||||
|
color: var(--ink); text-decoration: none;
|
||||||
|
}
|
||||||
|
.draco-apps-item:hover, .draco-apps-item:focus-visible { background: var(--surface-2); }
|
||||||
|
.draco-apps-item[data-active="true"] { background: var(--surface-3); box-shadow: inset 2px 0 0 var(--agent); }
|
||||||
|
.draco-apps-item-txt { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||||
|
.draco-apps-item-nom { font-weight: 700; font-size: 14px; }
|
||||||
|
.draco-apps-item-role { font-size: 12px; color: var(--ink-2); }
|
||||||
|
|
||||||
|
/* Onglets d'apps — accordéon : seule l'app courante est déployée */
|
||||||
|
.draco-apptabs { display: flex; gap: 6px; }
|
||||||
|
.draco-apptab {
|
||||||
|
position: relative; display: flex; align-items: center; gap: 8px;
|
||||||
|
height: 44px; padding: 0 9px; border-radius: 12px;
|
||||||
|
color: var(--ink); text-decoration: none; background: transparent;
|
||||||
|
transition: background .18s ease;
|
||||||
|
}
|
||||||
|
.draco-apptab:hover { background: var(--surface-2); }
|
||||||
|
.draco-apptab-ico {
|
||||||
|
width: 30px; height: 30px; flex: none; display: grid; place-items: center;
|
||||||
|
border-radius: 9px; background: var(--app); color: #fff; font-size: 15px;
|
||||||
|
}
|
||||||
|
.draco-apptab-txt { display: none; flex-direction: column; line-height: 1.15; }
|
||||||
|
.draco-apptab-txt strong { font-size: 14px; font-weight: 700; }
|
||||||
|
.draco-apptab-txt small { font-size: 11.5px; color: var(--ink-2); }
|
||||||
|
.draco-apptab[data-active="true"] { background: var(--surface-3); box-shadow: inset 0 -2px 0 var(--app, var(--agent)); }
|
||||||
|
.draco-apptab[data-active="true"] .draco-apptab-txt { display: flex; }
|
||||||
|
|
||||||
|
/* Pastilles compteurs — petites, rouges, coin bas-droit (standard v7.1) */
|
||||||
|
.draco-badge {
|
||||||
|
position: absolute; right: -4px; bottom: -4px; min-width: 17px; height: 17px;
|
||||||
|
padding: 0 4px; border-radius: 9px; box-sizing: border-box;
|
||||||
|
background: #e5484d; color: #fff; font-size: 10.5px; font-weight: 700;
|
||||||
|
display: grid; place-items: center; border: 2px solid var(--surface);
|
||||||
|
}
|
||||||
|
.draco-badge[hidden] { display: none; }
|
||||||
|
|
||||||
|
/* Recherche */
|
||||||
|
.draco-search {
|
||||||
|
display: flex; align-items: center; gap: 8px; width: 100%; height: 38px;
|
||||||
|
padding: 0 12px; border-radius: 10px; border: 1px solid var(--line);
|
||||||
|
background: var(--surface-3); color: var(--ink-3); cursor: pointer; font: inherit;
|
||||||
|
}
|
||||||
|
.draco-search kbd {
|
||||||
|
margin-left: auto; font: 600 11px system-ui; color: var(--ink-2);
|
||||||
|
border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Domaines — contrôle segmenté */
|
||||||
|
.draco-domaines { display: flex; align-items: center; gap: 2px; background: var(--surface-3); border-radius: 10px; padding: 3px; }
|
||||||
|
.draco-domaines-lbl { font-size: 10.5px; letter-spacing: .06em; color: var(--ink-3); padding: 0 6px; text-transform: uppercase; }
|
||||||
|
.draco-domaines button {
|
||||||
|
border: 0; background: transparent; color: var(--ink-2); font: inherit; font-size: 13px;
|
||||||
|
height: 30px; padding: 0 10px; border-radius: 8px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.draco-domaines button[data-on="true"] { background: var(--surface); color: var(--ink); font-weight: 700; box-shadow: 0 1px 3px rgb(0 0 0 / .18); }
|
||||||
|
|
||||||
|
/* Alertes — la cloche ne disparaît JAMAIS (correctif v7.2) */
|
||||||
|
.draco-alertes {
|
||||||
|
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
|
||||||
|
padding: 0 11px; border-radius: 10px; border: 1px solid var(--line);
|
||||||
|
background: transparent; color: var(--ink); font: inherit; cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spark */
|
||||||
|
.draco-spark {
|
||||||
|
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
|
||||||
|
padding: 0 14px; border: 0; border-radius: 10px; cursor: pointer;
|
||||||
|
background: var(--agent); color: #fff; font: inherit; font-weight: 700;
|
||||||
|
}
|
||||||
|
.draco-spark .draco-badge { border-color: var(--agent); }
|
||||||
|
|
||||||
|
.draco-icon-btn {
|
||||||
|
width: 38px; height: 38px; border-radius: 10px; border: 1px solid transparent;
|
||||||
|
background: transparent; color: var(--ink-2); font: inherit; cursor: pointer;
|
||||||
|
}
|
||||||
|
.draco-icon-btn:hover { background: var(--surface-2); }
|
||||||
|
.draco-avatar {
|
||||||
|
width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;
|
||||||
|
background: #2f6fed; color: #fff; font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */
|
||||||
|
.draco-bar-rappel {
|
||||||
|
position: fixed; top: 0; left: 16px; z-index: 60; cursor: pointer;
|
||||||
|
background: var(--surface, #1a1c1a); border: 1px solid var(--line, #2e302c); border-top: 0;
|
||||||
|
border-radius: 0 0 10px 10px; padding: 5px 12px; font: 600 12px system-ui; color: var(--ink-2, #a6aba2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Paliers — les zones latérales cèdent, la barre ne casse jamais */
|
||||||
|
@media (max-width: 1440px) {
|
||||||
|
.draco-brand-name, .draco-domaines-lbl { display: none; }
|
||||||
|
.draco-bar { grid-template-columns: auto minmax(200px, 440px) auto; }
|
||||||
|
}
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.draco-search-txt, .draco-search kbd, .draco-alertes-txt, .draco-spark-txt { display: none; }
|
||||||
|
.draco-search { width: 38px; padding: 0; justify-content: center; }
|
||||||
|
.draco-bar { grid-template-columns: auto auto 1fr; }
|
||||||
|
.draco-bar-droite { justify-content: flex-end; }
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
/* Mobile (spec des barres, § Mobile) : la barre se resserre sur
|
||||||
|
l'essentiel — marque, onglet de l'app courante, alertes, Spark,
|
||||||
|
avatar. Le rail, la recherche, les domaines et le menu « ••• »
|
||||||
|
disparaissent : chaque app garde ses contrôles natifs (barre
|
||||||
|
basse, en-tête) — un seul hamburger visible, celui de l'app.
|
||||||
|
Le changement d'app passe par le menu du ✦ (v0.1.3).
|
||||||
|
(v0.1.2 — avant, les onglets entiers et les pastilles domaines
|
||||||
|
restaient affichés et la barre débordait sur téléphone.
|
||||||
|
v0.1.4 — palier élargi de 700 à 900 px : entre les deux, la
|
||||||
|
barre complète ne tenait pas (fenêtres réduites, tablettes,
|
||||||
|
webviews) alors que les apps sont déjà en mise en page mobile.) */
|
||||||
|
.draco-bar { height: 52px; grid-template-columns: auto 1fr auto; }
|
||||||
|
.draco-bar-centre, .draco-domaines, .draco-bar .draco-icon-btn { display: none; }
|
||||||
|
.draco-apptab { display: none; }
|
||||||
|
.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"],
|
||||||
|
.draco-bar[data-current="flowdeck"] .draco-apptab[data-app="flowdeck"],
|
||||||
|
.draco-bar[data-current="tiktak"] .draco-apptab[data-app="tiktak"],
|
||||||
|
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"] { display: flex; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2 ;
|
||||||
|
* v0.1.3 : menu des applications ouvert par la marque ✦).
|
||||||
|
*
|
||||||
|
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
|
||||||
|
* (patron validé par le prototype — les onclick du HTML généré sont
|
||||||
|
* proscrits dans tout le programme).
|
||||||
|
*
|
||||||
|
* Configuration attendue avant le chargement :
|
||||||
|
* window.DRACO_BAR_CONFIG = {
|
||||||
|
* current: "tiktak", // app courante
|
||||||
|
* domain: "pro", // domaine actif
|
||||||
|
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
|
||||||
|
* onAction: function (act, el) { … } // l'app relaie les actions
|
||||||
|
* } // qu'elle ne connaît pas
|
||||||
|
* Actions émises (data-act) : rail, palette, domaine (data-dom),
|
||||||
|
* alertes, spark, menu, rappel-entete. L'app intégratrice décide de
|
||||||
|
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…).
|
||||||
|
* Action INTERNE (jamais relayée) : apps — la marque ✦ ouvre le menu
|
||||||
|
* des applications, construit depuis les onglets ; c'est le moyen de
|
||||||
|
* changer d'app en mobile, où les onglets sont repliés (v0.1.2).
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
var cfg = window.DRACO_BAR_CONFIG || {};
|
||||||
|
|
||||||
|
function setCounts(counts) {
|
||||||
|
Object.keys(counts || {}).forEach(function (k) {
|
||||||
|
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
|
||||||
|
document.querySelectorAll('.draco-badge[data-badge="' + k + '"]').forEach(function (b) {
|
||||||
|
b.textContent = counts[k];
|
||||||
|
b.hidden = !(counts[k] > 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setCurrent(app) {
|
||||||
|
document.querySelectorAll(".draco-apptab, .draco-apps-item").forEach(function (t) {
|
||||||
|
if (t.dataset.app) t.dataset.active = t.dataset.app === app ? "true" : "false";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function setDomain(dom) {
|
||||||
|
document.querySelectorAll('.draco-domaines [data-dom]').forEach(function (b) {
|
||||||
|
b.dataset.on = b.dataset.dom === dom ? "true" : "false";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Menu des applications (v0.1.3) ──────────────────────────────
|
||||||
|
* Construit depuis les onglets présents dans la barre : mêmes
|
||||||
|
* liens, mêmes icônes, mêmes badges. Ouvert par la marque ✦
|
||||||
|
* (data-act="apps"), fermé par un clic extérieur, Échap, ou le
|
||||||
|
* choix d'une application (la navigation suit son cours). */
|
||||||
|
var bar = document.querySelector(".draco-bar");
|
||||||
|
var brand = bar ? bar.querySelector(".draco-brand") : null;
|
||||||
|
var menu = null;
|
||||||
|
|
||||||
|
function buildMenu() {
|
||||||
|
if (!bar || !brand || menu) return;
|
||||||
|
menu = document.createElement("div");
|
||||||
|
menu.className = "draco-apps-menu";
|
||||||
|
menu.hidden = true;
|
||||||
|
menu.setAttribute("role", "menu");
|
||||||
|
menu.setAttribute("aria-label", "Applications");
|
||||||
|
document.querySelectorAll(".draco-apptab").forEach(function (t) {
|
||||||
|
var a = document.createElement("a");
|
||||||
|
a.className = "draco-apps-item";
|
||||||
|
a.href = t.getAttribute("href");
|
||||||
|
a.dataset.app = t.dataset.app;
|
||||||
|
a.setAttribute("role", "menuitem");
|
||||||
|
var ico = t.querySelector(".draco-apptab-ico");
|
||||||
|
if (ico) a.appendChild(ico.cloneNode(true));
|
||||||
|
var txt = document.createElement("span");
|
||||||
|
txt.className = "draco-apps-item-txt";
|
||||||
|
var fort = t.querySelector(".draco-apptab-txt strong");
|
||||||
|
var petit = t.querySelector(".draco-apptab-txt small");
|
||||||
|
var nom = document.createElement("span");
|
||||||
|
nom.className = "draco-apps-item-nom";
|
||||||
|
nom.textContent = fort ? fort.textContent : t.dataset.app;
|
||||||
|
txt.appendChild(nom);
|
||||||
|
if (petit) {
|
||||||
|
var role = document.createElement("span");
|
||||||
|
role.className = "draco-apps-item-role";
|
||||||
|
role.textContent = petit.textContent;
|
||||||
|
txt.appendChild(role);
|
||||||
|
}
|
||||||
|
a.appendChild(txt);
|
||||||
|
var badge = t.querySelector(".draco-badge");
|
||||||
|
if (badge) a.appendChild(badge.cloneNode(true));
|
||||||
|
menu.appendChild(a);
|
||||||
|
});
|
||||||
|
bar.appendChild(menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMenu(force) {
|
||||||
|
buildMenu();
|
||||||
|
if (!menu) return;
|
||||||
|
var open = typeof force === "boolean" ? force : menu.hidden;
|
||||||
|
menu.hidden = !open;
|
||||||
|
if (brand) brand.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", function (ev) {
|
||||||
|
var el = ev.target.closest("[data-act]");
|
||||||
|
if (el && el.dataset.act === "apps") {
|
||||||
|
ev.preventDefault();
|
||||||
|
toggleMenu();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (menu && !menu.hidden && !ev.target.closest(".draco-apps-menu") && !ev.target.closest(".draco-brand")) {
|
||||||
|
toggleMenu(false);
|
||||||
|
}
|
||||||
|
if (!el) return;
|
||||||
|
var act = el.dataset.act;
|
||||||
|
if (act === "domaine") setDomain(el.dataset.dom);
|
||||||
|
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
|
||||||
|
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", function (ev) {
|
||||||
|
if (ev.key === "Escape") toggleMenu(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
/* API publique : l'app met les compteurs à jour sans recharger. */
|
||||||
|
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
|
||||||
|
|
||||||
|
buildMenu();
|
||||||
|
if (cfg.current) setCurrent(cfg.current);
|
||||||
|
if (cfg.domain) setDomain(cfg.domain);
|
||||||
|
if (cfg.counts) setCounts(cfg.counts);
|
||||||
|
})();
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
/* Section « Liens » des notes — Fusion #215 (ACC-1.2 + ACC-1.3).
|
||||||
|
*
|
||||||
|
* Module autonome (vanilla, zéro dépendance, zéro build) : il écoute
|
||||||
|
* l'événement « obsigate:file-rendered » émis par renderFile
|
||||||
|
* (frontend/app.js) et rend sa section à la fin de #content-area :
|
||||||
|
*
|
||||||
|
* - ACC-1.2 : si la note est une projection TikTak (frontmatter
|
||||||
|
* « tiktak_id » écrit par la synchro TikTak ↔ ObsiGate), un bandeau
|
||||||
|
* « Tâche TikTak projetée » avec le bouton « Ouvrir dans TikTak »
|
||||||
|
* (lien profond https://tiktak.dracodev.net/?task=<id>) ;
|
||||||
|
* - ACC-1.3 : les références croisées de la note (GET /api/refs),
|
||||||
|
* ajout par collage d'une URL profonde, retrait en un clic.
|
||||||
|
*
|
||||||
|
* Textes via window.t (i18n FR/EN — clés « draco.links.* » des locales).
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var COLORS = {
|
||||||
|
obsigate: "#7a5af5",
|
||||||
|
flowdeck: "#2f6fed",
|
||||||
|
tiktak: "#e8762c",
|
||||||
|
kanbante: "#0d9488",
|
||||||
|
};
|
||||||
|
|
||||||
|
function t(key, fallback) {
|
||||||
|
try {
|
||||||
|
var v = window.t && window.t(key);
|
||||||
|
return v && v !== key ? v : fallback;
|
||||||
|
} catch (e) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders() {
|
||||||
|
var tok = null;
|
||||||
|
try {
|
||||||
|
tok = sessionStorage.getItem("obsigate_access_token");
|
||||||
|
} catch (e) { /* stockage indisponible */ }
|
||||||
|
return tok ? { Authorization: "Bearer " + tok } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, opts) {
|
||||||
|
var options = opts || {};
|
||||||
|
options.headers = Object.assign(
|
||||||
|
{ "Content-Type": "application/json" },
|
||||||
|
authHeaders(),
|
||||||
|
options.headers || {}
|
||||||
|
);
|
||||||
|
options.credentials = "same-origin";
|
||||||
|
var resp = await fetch(path, options);
|
||||||
|
if (!resp.ok) {
|
||||||
|
var detail = "";
|
||||||
|
try {
|
||||||
|
detail = (await resp.json()).detail || "";
|
||||||
|
} catch (e) { /* corps non JSON */ }
|
||||||
|
throw new Error(detail || "HTTP " + resp.status);
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function el(tag, cls, text) {
|
||||||
|
var node = document.createElement(tag);
|
||||||
|
if (cls) node.className = cls;
|
||||||
|
if (text !== undefined) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render(vault, path) {
|
||||||
|
var area = document.getElementById("content-area");
|
||||||
|
if (!area || !vault || !path) return;
|
||||||
|
var old = document.getElementById("draco-liens");
|
||||||
|
if (old) old.remove();
|
||||||
|
|
||||||
|
var section = el("section", "draco-liens");
|
||||||
|
section.id = "draco-liens";
|
||||||
|
var title = el("h3", "draco-liens-title", t("draco.links.title", "Liens"));
|
||||||
|
section.appendChild(title);
|
||||||
|
area.appendChild(section);
|
||||||
|
|
||||||
|
// ACC-1.2 — note projetée depuis TikTak ?
|
||||||
|
if (/\.md$/i.test(path)) {
|
||||||
|
try {
|
||||||
|
var rawUrl = "/api/file/" + encodeURIComponent(vault) + "/raw?path=" +
|
||||||
|
encodeURIComponent(path);
|
||||||
|
var rawData = await api(rawUrl);
|
||||||
|
var m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(rawData.raw || "");
|
||||||
|
var tid = m && /^tiktak_id:\s*(\d+)\s*$/m.exec(m[1]);
|
||||||
|
if (tid) {
|
||||||
|
var bandeau = el("div", "draco-liens-projete");
|
||||||
|
bandeau.appendChild(el("span", "draco-liens-badge",
|
||||||
|
t("draco.links.projected", "Tâche TikTak projetée")));
|
||||||
|
var ouvrir = el("a", "btn-action draco-liens-ouvrir",
|
||||||
|
t("draco.links.openInTikTak", "Ouvrir dans TikTak"));
|
||||||
|
ouvrir.href = "https://tiktak.dracodev.net/?task=" + tid[1];
|
||||||
|
ouvrir.target = "_blank";
|
||||||
|
ouvrir.rel = "noopener";
|
||||||
|
bandeau.appendChild(ouvrir);
|
||||||
|
section.appendChild(bandeau);
|
||||||
|
}
|
||||||
|
} catch (e) { /* la détection est opportuniste : silence */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACC-1.3 — références croisées de la note
|
||||||
|
var list = el("ul", "draco-liens-list");
|
||||||
|
section.appendChild(list);
|
||||||
|
var erreur = el("p", "draco-liens-erreur");
|
||||||
|
erreur.hidden = true;
|
||||||
|
section.appendChild(erreur);
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
list.innerHTML = "";
|
||||||
|
try {
|
||||||
|
var data = await api("/api/refs?vault=" + encodeURIComponent(vault) +
|
||||||
|
"&path=" + encodeURIComponent(path));
|
||||||
|
(data.refs || []).forEach(function (r) {
|
||||||
|
var li = el("li", "draco-liens-item");
|
||||||
|
var dot = el("span", "draco-ref-dot");
|
||||||
|
dot.style.background = COLORS[r.target_app] || "#888";
|
||||||
|
li.appendChild(dot);
|
||||||
|
var a = el("a", "", r.label);
|
||||||
|
a.href = r.target_url;
|
||||||
|
a.target = "_blank";
|
||||||
|
a.rel = "noopener";
|
||||||
|
li.appendChild(a);
|
||||||
|
var del = el("button", "draco-liens-del", "✕");
|
||||||
|
del.type = "button";
|
||||||
|
del.title = t("draco.links.remove", "Retirer le lien");
|
||||||
|
del.addEventListener("click", async function () {
|
||||||
|
try {
|
||||||
|
await api("/api/refs/" + encodeURIComponent(r.id), { method: "DELETE" });
|
||||||
|
refresh();
|
||||||
|
} catch (e) {
|
||||||
|
erreur.textContent = e.message;
|
||||||
|
erreur.hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
li.appendChild(del);
|
||||||
|
list.appendChild(li);
|
||||||
|
});
|
||||||
|
} catch (e) { /* section facultative : ne jamais casser la note */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
var form = el("form", "draco-liens-form");
|
||||||
|
var urlInput = el("input");
|
||||||
|
urlInput.type = "url";
|
||||||
|
urlInput.required = true;
|
||||||
|
urlInput.placeholder = t("draco.links.urlPlaceholder",
|
||||||
|
"Coller une URL profonde (Flowdeck, TikTak, Gitea, ObsiGate)…");
|
||||||
|
var labelInput = el("input");
|
||||||
|
labelInput.type = "text";
|
||||||
|
labelInput.placeholder = t("draco.links.labelPlaceholder", "Libellé (optionnel)");
|
||||||
|
var submit = el("button", "btn-action", t("draco.links.add", "Lier"));
|
||||||
|
submit.type = "submit";
|
||||||
|
form.appendChild(urlInput);
|
||||||
|
form.appendChild(labelInput);
|
||||||
|
form.appendChild(submit);
|
||||||
|
form.addEventListener("submit", async function (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
erreur.hidden = true;
|
||||||
|
try {
|
||||||
|
await api("/api/refs", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
vault: vault,
|
||||||
|
path: path,
|
||||||
|
url: urlInput.value.trim(),
|
||||||
|
label: labelInput.value.trim() || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
urlInput.value = "";
|
||||||
|
labelInput.value = "";
|
||||||
|
refresh();
|
||||||
|
} catch (e) {
|
||||||
|
erreur.textContent = e.message;
|
||||||
|
erreur.hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
section.appendChild(form);
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("obsigate:file-rendered", function (ev) {
|
||||||
|
if (ev.detail) render(ev.detail.vault, ev.detail.path);
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
/* Panneau Spark — service d'agents (Fusion, J1 → Panneau v2 Lot 1).
|
||||||
|
Thème clair/sombre piloté par [data-draco-theme] sur le panneau :
|
||||||
|
le JS le cale sur le thème de l'app hôte (html[data-theme], repli
|
||||||
|
prefers-color-scheme) et suit les bascules en direct. Toutes les
|
||||||
|
couleurs passent par les variables ci-dessous — en-tête et fenêtre
|
||||||
|
partagent donc toujours le même mode. */
|
||||||
|
.draco-panneau {
|
||||||
|
--p-fond: #fbfbfa; --p-surface: #ffffff; --p-bord: #e4e5e0;
|
||||||
|
--p-texte: #242624; --p-doux: #6c7268; --p-faint: #8a8f84;
|
||||||
|
--p-chip: #f4f5f1; --p-chip-bord: #d6d8d1; --p-chip-survol: #e9ebe4;
|
||||||
|
--p-agent: #eef0ea; --p-champ: #ffffff; --p-code: #e8eae3;
|
||||||
|
--p-appro-fond: #fdf6ec; --p-danger-fond: #f6e3de; --p-danger: #a03a28;
|
||||||
|
--p-accent: #7c5cd6; --p-lien: #5b3df0;
|
||||||
|
position: fixed; top: 0; right: 0; height: 100dvh; width: 388px; max-width: 96vw;
|
||||||
|
background: var(--p-fond); color: var(--p-texte); z-index: 400;
|
||||||
|
box-shadow: -12px 0 36px rgb(0 0 0 / .22);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
transform: translateX(105%); transition: transform .28s ease;
|
||||||
|
font: 14px/1.5 system-ui, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
.draco-panneau[data-draco-theme="dark"] {
|
||||||
|
--p-fond: #17181a; --p-surface: #1f2023; --p-bord: #33353a;
|
||||||
|
--p-texte: #eceeeb; --p-doux: #a6aba0; --p-faint: #83887e;
|
||||||
|
--p-chip: #26272b; --p-chip-bord: #3a3c41; --p-chip-survol: #2e3036;
|
||||||
|
--p-agent: #26272b; --p-champ: #1f2023; --p-code: #2c2e33;
|
||||||
|
--p-appro-fond: #2a2118; --p-danger-fond: #40241f; --p-danger: #e8a08d;
|
||||||
|
--p-lien: #b39df5;
|
||||||
|
}
|
||||||
|
.draco-panneau.open { transform: translateX(0); }
|
||||||
|
.draco-poignee {
|
||||||
|
position: absolute; left: 0; top: 0; bottom: 0; width: 8px;
|
||||||
|
cursor: ew-resize; z-index: 5; touch-action: none;
|
||||||
|
}
|
||||||
|
.draco-poignee:hover { background: color-mix(in srgb, var(--p-accent) 35%, transparent); }
|
||||||
|
@media (max-width: 700px) { .draco-poignee { display: none; } }
|
||||||
|
.draco-panneau-hd {
|
||||||
|
display: flex; align-items: center; gap: 9px;
|
||||||
|
padding: 13px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||||
|
}
|
||||||
|
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; }
|
||||||
|
.draco-panneau-hd .soustitre { font-size: 12px; color: var(--p-doux); }
|
||||||
|
.draco-panneau-hd .fermer {
|
||||||
|
margin-left: auto; border: 0; background: var(--p-agent); color: var(--p-texte);
|
||||||
|
border-radius: 8px; width: 30px; height: 30px; cursor: pointer; font-size: 14px;
|
||||||
|
}
|
||||||
|
.draco-panneau-etat { display: flex; align-items: center; gap: 7px; font-size: 12.5px; }
|
||||||
|
.draco-panneau-etat .point { width: 9px; height: 9px; border-radius: 50%; background: #b9bdb2; flex: none; }
|
||||||
|
.draco-panneau-etat.ok .point { background: #2ea36b; }
|
||||||
|
.draco-panneau-etat.err .point { background: #cf4b32; }
|
||||||
|
/* Barre des discussions (façon Muse) */
|
||||||
|
.draco-disc-bar {
|
||||||
|
display: flex; align-items: center; gap: 7px;
|
||||||
|
padding: 8px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||||
|
}
|
||||||
|
.draco-disc-bar .d-titre {
|
||||||
|
flex: 1; font-weight: 600; font-size: 13px; min-width: 0;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.draco-disc-bar .d-btn {
|
||||||
|
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
|
||||||
|
border-radius: 999px; padding: 4px 10px; font: 600 12px system-ui; cursor: pointer; flex: none;
|
||||||
|
}
|
||||||
|
.draco-disc-bar .d-btn:hover { background: var(--p-chip-survol); }
|
||||||
|
.draco-discussions {
|
||||||
|
border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
|
||||||
|
max-height: 46%; overflow-y: auto; padding: 8px 12px;
|
||||||
|
}
|
||||||
|
.draco-discussions .d-section {
|
||||||
|
font-size: 11px; font-weight: 700; text-transform: uppercase;
|
||||||
|
letter-spacing: .5px; color: var(--p-doux); margin: 8px 2px 4px;
|
||||||
|
}
|
||||||
|
.draco-discussions .d-section:first-child { margin-top: 0; }
|
||||||
|
.draco-discussions .d-item {
|
||||||
|
border: 1px solid transparent; border-radius: 9px; padding: 6px 8px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.draco-discussions .d-item:hover { background: var(--p-chip); }
|
||||||
|
.draco-discussions .d-item.actif { border-color: var(--p-accent); background: var(--p-chip); }
|
||||||
|
.draco-discussions .d-meta { font-weight: 600; font-size: 13px; }
|
||||||
|
.draco-discussions .d-sous { font-size: 12px; color: var(--p-doux); }
|
||||||
|
.draco-discussions .d-actions { display: flex; gap: 6px; margin-top: 5px; }
|
||||||
|
.draco-discussions .d-actions button {
|
||||||
|
border: 1px solid var(--p-chip-bord); background: var(--p-surface); color: var(--p-texte);
|
||||||
|
border-radius: 7px; padding: 3px 9px; font: 600 11.5px system-ui; cursor: pointer;
|
||||||
|
}
|
||||||
|
.draco-discussions .d-actions button.danger { color: var(--p-danger); border-color: var(--p-danger-fond); }
|
||||||
|
.draco-discussions .d-vide, .draco-discussions .d-chargement {
|
||||||
|
font-size: 12.5px; color: var(--p-doux); padding: 2px 8px 6px;
|
||||||
|
}
|
||||||
|
.draco-panneau-outils { display: flex; gap: 8px; padding: 9px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface); }
|
||||||
|
.draco-panneau-outils button {
|
||||||
|
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
|
||||||
|
border-radius: 999px; padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer;
|
||||||
|
}
|
||||||
|
.draco-panneau-outils button:hover { background: var(--p-chip-survol); }
|
||||||
|
.draco-appro {
|
||||||
|
align-self: stretch; background: var(--p-appro-fond); border: 1.6px solid #e8a13c;
|
||||||
|
border-radius: 12px; padding: 10px 12px;
|
||||||
|
}
|
||||||
|
.draco-appro .titre { font-weight: 700; font-size: 12px; color: #9a6a1c; text-transform: uppercase; letter-spacing: .4px; }
|
||||||
|
.draco-panneau[data-draco-theme="dark"] .draco-appro .titre { color: #e8bc7a; }
|
||||||
|
.draco-appro .resume { margin: 5px 0 8px; }
|
||||||
|
.draco-appro .btns { display: flex; gap: 7px; }
|
||||||
|
.draco-appro .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: var(--p-agent); color: var(--p-texte); }
|
||||||
|
.draco-appro .btns button.go { background: #2ea36b; color: #fff; }
|
||||||
|
.draco-auj { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
|
||||||
|
.draco-auj h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 6px 0 4px; }
|
||||||
|
.draco-auj h5:first-child { margin-top: 0; }
|
||||||
|
.draco-auj ul { margin: 0; padding-left: 17px; }
|
||||||
|
.draco-auj .etat-source { color: var(--p-doux); font-size: 12.5px; }
|
||||||
|
.draco-panneau-msgs { flex: 1; overflow-y: auto; padding: 14px 15px; display: flex; flex-direction: column; gap: 9px; }
|
||||||
|
.draco-msg { max-width: 88%; padding: 9px 12px; border-radius: 13px; white-space: pre-wrap; word-wrap: break-word; }
|
||||||
|
.draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; }
|
||||||
|
.draco-msg.agent { align-self: flex-start; background: var(--p-agent); border-bottom-left-radius: 4px; }
|
||||||
|
.draco-msg.sys { align-self: center; background: none; color: var(--p-doux); font-size: 12.5px; text-align: center; max-width: 96%; }
|
||||||
|
.draco-panneau-ft { border-top: 1px solid var(--p-bord); padding: 11px 12px; background: var(--p-surface); }
|
||||||
|
.draco-panneau-form { display: flex; gap: 8px; }
|
||||||
|
.draco-panneau-form input {
|
||||||
|
flex: 1; border: 1px solid var(--p-chip-bord); border-radius: 10px; padding: 9px 11px;
|
||||||
|
font: inherit; background: var(--p-champ); color: var(--p-texte);
|
||||||
|
}
|
||||||
|
.draco-panneau-form button {
|
||||||
|
border: 0; border-radius: 10px; background: var(--p-accent); color: #fff;
|
||||||
|
padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer;
|
||||||
|
}
|
||||||
|
.draco-panneau-form button:disabled { opacity: .5; cursor: default; }
|
||||||
|
.draco-panneau-note { font-size: 11.5px; color: var(--p-faint); margin-top: 8px; text-align: center; }
|
||||||
|
.draco-missions { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
|
||||||
|
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 0 0 6px; }
|
||||||
|
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: var(--p-doux); margin: 8px 0 3px; }
|
||||||
|
.draco-missions select, .draco-missions input, .draco-missions textarea {
|
||||||
|
width: 100%; box-sizing: border-box; border: 1px solid var(--p-chip-bord); border-radius: 8px;
|
||||||
|
padding: 7px 9px; font: inherit; font-size: 13px; background: var(--p-champ); color: var(--p-texte);
|
||||||
|
}
|
||||||
|
.draco-missions .btns { display: flex; gap: 7px; margin-top: 10px; }
|
||||||
|
.draco-missions .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: var(--p-agent); color: var(--p-texte); }
|
||||||
|
.draco-missions .btns button.go { background: var(--p-accent); color: #fff; }
|
||||||
|
.draco-missions .m-liste { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.draco-missions .etat-source { color: var(--p-doux); font-size: 12.5px; }
|
||||||
|
.draco-missions .m-item { border: 1px solid var(--p-bord); border-radius: 10px; padding: 8px 10px; }
|
||||||
|
.draco-missions .m-entete { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; }
|
||||||
|
.draco-missions .m-statut { font-size: 11.5px; font-weight: 700; color: var(--p-doux); white-space: nowrap; }
|
||||||
|
.draco-missions .m-statut.st-succeeded { color: #2ea36b; }
|
||||||
|
.draco-missions .m-statut.st-failed { color: #cf4b32; }
|
||||||
|
.draco-missions .m-statut.st-running, .draco-missions .m-statut.st-paused_question { color: #9a6a1c; }
|
||||||
|
.draco-missions .m-meta { font-size: 12px; color: var(--p-doux); }
|
||||||
|
.draco-missions .m-rapport { font-size: 12.5px; margin-top: 5px; white-space: pre-wrap; word-wrap: break-word; }
|
||||||
|
.draco-missions .m-annuler { margin-top: 6px; border: 0; border-radius: 7px; padding: 4px 9px; font: 600 12px system-ui; cursor: pointer; background: var(--p-danger-fond); color: var(--p-danger); }
|
||||||
|
/* ── Rendu Markdown (HTML produit par le service) ──────────── */
|
||||||
|
.draco-msg.md, .draco-missions .m-rapport.md { white-space: normal; }
|
||||||
|
.draco-msg.md > *:first-child, .m-rapport.md > *:first-child { margin-top: 0; }
|
||||||
|
.draco-msg.md > *:last-child, .m-rapport.md > *:last-child { margin-bottom: 0; }
|
||||||
|
.draco-msg.md p, .m-rapport.md p { margin: 6px 0; }
|
||||||
|
.draco-msg.md h1, .draco-msg.md h2, .draco-msg.md h3,
|
||||||
|
.draco-msg.md h4, .draco-msg.md h5, .draco-msg.md h6 { margin: 10px 0 4px; line-height: 1.3; }
|
||||||
|
.draco-msg.md h1 { font-size: 16.5px; } .draco-msg.md h2 { font-size: 15.5px; }
|
||||||
|
.draco-msg.md h3 { font-size: 14.5px; } .draco-msg.md h4,
|
||||||
|
.draco-msg.md h5, .draco-msg.md h6 { font-size: 13.5px; }
|
||||||
|
.draco-msg.md ul, .draco-msg.md ol, .m-rapport.md ul, .m-rapport.md ol { margin: 6px 0; padding-left: 20px; }
|
||||||
|
.draco-msg.md li, .m-rapport.md li { margin: 2px 0; }
|
||||||
|
.draco-msg.md code, .m-rapport.md code {
|
||||||
|
background: var(--p-code); border-radius: 5px; padding: 1px 5px;
|
||||||
|
font: 12.5px ui-monospace, "Cascadia Mono", Consolas, monospace;
|
||||||
|
}
|
||||||
|
.draco-msg.md pre, .m-rapport.md pre {
|
||||||
|
background: var(--p-code); border-radius: 9px; padding: 9px 11px;
|
||||||
|
overflow-x: auto; margin: 7px 0;
|
||||||
|
}
|
||||||
|
.draco-msg.md pre code, .m-rapport.md pre code { background: none; padding: 0; }
|
||||||
|
.draco-msg.md blockquote, .m-rapport.md blockquote {
|
||||||
|
border-left: 3px solid var(--p-accent); margin: 7px 0; padding: 2px 0 2px 11px;
|
||||||
|
color: var(--p-doux);
|
||||||
|
}
|
||||||
|
.draco-msg.md blockquote.callout, .m-rapport.md blockquote.callout {
|
||||||
|
border-radius: 0 8px 8px 0; padding: 6px 11px; color: var(--p-texte);
|
||||||
|
background: var(--p-chip);
|
||||||
|
}
|
||||||
|
.draco-msg.md blockquote.callout-warning { border-left-color: #e8a13c; }
|
||||||
|
.draco-msg.md blockquote.callout-danger { border-left-color: #cf4b32; }
|
||||||
|
.draco-msg.md blockquote.callout-tip { border-left-color: #2ea36b; }
|
||||||
|
.draco-msg.md a, .m-rapport.md a { color: var(--p-lien); }
|
||||||
|
.draco-msg.md table, .m-rapport.md table {
|
||||||
|
border-collapse: collapse; margin: 7px 0; font-size: 12.5px;
|
||||||
|
display: block; overflow-x: auto; max-width: 100%;
|
||||||
|
}
|
||||||
|
.draco-msg.md th, .draco-msg.md td, .m-rapport.md th, .m-rapport.md td {
|
||||||
|
border: 1px solid var(--p-bord); padding: 4px 8px; text-align: left;
|
||||||
|
}
|
||||||
|
.draco-msg.md hr, .m-rapport.md hr { border: 0; border-top: 1px solid var(--p-bord); margin: 9px 0; }
|
||||||
|
.draco-msg.md input[type="checkbox"], .m-rapport.md input[type="checkbox"] { margin-right: 5px; }
|
||||||
@@ -0,0 +1,816 @@
|
|||||||
|
/* Panneau Spark — branchement au service d'agents (Fusion, J1 → Panneau v2).
|
||||||
|
*
|
||||||
|
* Parle à l'API du service d'agents (contrat de dracodev-commons) :
|
||||||
|
* GET /api/v1/me — identité (cookie draco_session)
|
||||||
|
* POST /api/v1/chat — conversation réelle du noyau (J2) :
|
||||||
|
* LLM DeepSeek + outils MCP des apps ;
|
||||||
|
* la réponse arrive avec son HTML
|
||||||
|
* Markdown rendu côté service (v2)
|
||||||
|
* GET /api/v1/conversations — discussions façon Muse (v2) : une
|
||||||
|
* principale perpétuelle + des
|
||||||
|
* parallèles créées/archivées/
|
||||||
|
* supprimées par l'utilisateur
|
||||||
|
* GET /api/v1/conversations/… — historique d'une discussion (les
|
||||||
|
* messages agent portent leur html)
|
||||||
|
* GET /api/v1/executors — registre (noyau, sous-agents J3,
|
||||||
|
* externes J4 : état de configuration)
|
||||||
|
* POST /api/v1/missions — missions longues (J5)
|
||||||
|
* Panneau v2 (Lot 1) : rendu Markdown, largeur réglable à la souris
|
||||||
|
* (persistée), thème clair/sombre aligné sur l'app hôte
|
||||||
|
* (html[data-theme], repli prefers-color-scheme, suivi en direct).
|
||||||
|
* Bouton « Console » (barre des discussions) : ouvre la console web
|
||||||
|
* du même service (agent.dracodev.net) DANS la page, en surimpression
|
||||||
|
* pleine page (iframe) qui naît à la géométrie du panneau puis
|
||||||
|
* s'étend à toute la fenêtre ; l'URL profonde (?disc= discussion
|
||||||
|
* courante, ?app= app d'origine déduite du sous-domaine) est celle
|
||||||
|
* du premier chargement — l'iframe est créé paresseusement et
|
||||||
|
* conservé, la console garde son état entre ouvertures. La barre
|
||||||
|
* d'outils de la surimpression offre « Mode sidebar » (retour au
|
||||||
|
* panneau seul, animation inverse) et « Fermer l'agent » (referme
|
||||||
|
* surimpression et panneau).
|
||||||
|
* États honnêtes : service injoignable / session non reconnue / LLM non
|
||||||
|
* configuré / exécuteur non configuré sont affichés tels quels. */
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
var API = "https://agent.dracodev.net";
|
||||||
|
var convId = null;
|
||||||
|
var convKind = null; // 'principale' | 'parallele'
|
||||||
|
var convTitre = "";
|
||||||
|
var etatConnu = false;
|
||||||
|
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
|
||||||
|
var titreEl = null, tiroirEl = null;
|
||||||
|
|
||||||
|
function domaine() {
|
||||||
|
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
|
||||||
|
}
|
||||||
|
function stockerConv() {
|
||||||
|
try {
|
||||||
|
if (convId) sessionStorage.setItem("draco_panneau_conv", convId);
|
||||||
|
else sessionStorage.removeItem("draco_panneau_conv");
|
||||||
|
} catch (e) { /* stockage indisponible */ }
|
||||||
|
}
|
||||||
|
function convStockee() {
|
||||||
|
try { return sessionStorage.getItem("draco_panneau_conv") || null; }
|
||||||
|
catch (e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Console web (même service, surimpression pleine page) ─── */
|
||||||
|
function appHote() {
|
||||||
|
var h = (window.location.hostname || "").split(".")[0];
|
||||||
|
var correspondance = { tiktak: "tiktak", flowdeck: "flowdeck",
|
||||||
|
kanbante: "kanbante", og: "obsigate" };
|
||||||
|
return correspondance[h] || null;
|
||||||
|
}
|
||||||
|
function urlConsole() {
|
||||||
|
var params = [];
|
||||||
|
var id = convId || convStockee();
|
||||||
|
if (id) params.push("disc=" + encodeURIComponent(id));
|
||||||
|
var app = appHote();
|
||||||
|
if (app) params.push("app=" + encodeURIComponent(app));
|
||||||
|
return API + "/" + (params.length ? "?" + params.join("&") : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* La surimpression vit entièrement dans ce fichier (DOM + styles
|
||||||
|
* injectés) : les apps hôtes n'ont que ce fichier à recopier. Son
|
||||||
|
* thème suit celui du panneau (data-draco-theme synchronisé par
|
||||||
|
* appliquerTheme) et sa palette reprend les mêmes valeurs que
|
||||||
|
* draco-panneau.css. L'iframe est créé au premier clic puis
|
||||||
|
* conservé : rouvrir la console ne le recharge pas. */
|
||||||
|
var surEl = null, surCadre = null, surOuverte = false;
|
||||||
|
var STYLES_CONSOLE =
|
||||||
|
".draco-console-sur{" +
|
||||||
|
"--p-fond:#fbfbfa;--p-surface:#ffffff;--p-bord:#e4e5e0;--p-texte:#242624;" +
|
||||||
|
"--p-chip:#f4f5f1;--p-chip-bord:#d6d8d1;--p-chip-survol:#e9ebe4;--p-accent:#7c5cd6;" +
|
||||||
|
"position:fixed;top:0;right:0;height:100dvh;width:388px;max-width:100vw;z-index:1000;" +
|
||||||
|
"background:var(--p-fond);color:var(--p-texte);" +
|
||||||
|
"box-shadow:-12px 0 36px rgb(0 0 0 / .22);" +
|
||||||
|
"display:flex;flex-direction:column;transition:width .3s ease;" +
|
||||||
|
"font:14px/1.5 system-ui,\"Segoe UI\",sans-serif}" +
|
||||||
|
".draco-console-sur[data-draco-theme=\"dark\"]{" +
|
||||||
|
"--p-fond:#17181a;--p-surface:#1f2023;--p-bord:#33353a;--p-texte:#eceeeb;" +
|
||||||
|
"--p-chip:#26272b;--p-chip-bord:#3a3c41;--p-chip-survol:#2e3036}" +
|
||||||
|
".draco-console-sur[hidden]{display:none}" +
|
||||||
|
".draco-console-barre{display:flex;align-items:center;gap:9px;flex:none;" +
|
||||||
|
"padding:9px 12px;border-bottom:1px solid var(--p-bord);background:var(--p-surface)}" +
|
||||||
|
".draco-console-barre .dc-titre{font-weight:700;font-size:14px;" +
|
||||||
|
"display:flex;align-items:center;gap:8px;white-space:nowrap}" +
|
||||||
|
".draco-console-barre .dc-etoile{color:var(--p-accent);font-size:17px}" +
|
||||||
|
".draco-console-barre .dc-actions{margin-left:auto;display:flex;gap:7px}" +
|
||||||
|
".draco-console-barre button{border:1px solid var(--p-chip-bord);" +
|
||||||
|
"background:var(--p-chip);color:var(--p-texte);border-radius:999px;" +
|
||||||
|
"padding:5px 12px;font:600 12.5px system-ui;cursor:pointer;white-space:nowrap}" +
|
||||||
|
".draco-console-barre button:hover{background:var(--p-chip-survol)}" +
|
||||||
|
".draco-console-cadre{flex:1;width:100%;border:0;display:block;background:var(--p-fond)}";
|
||||||
|
|
||||||
|
function assurerSurimpression() {
|
||||||
|
if (surEl) return;
|
||||||
|
if (!document.getElementById("draco-console-styles")) {
|
||||||
|
var st = document.createElement("style");
|
||||||
|
st.id = "draco-console-styles";
|
||||||
|
st.textContent = STYLES_CONSOLE;
|
||||||
|
document.head.appendChild(st);
|
||||||
|
}
|
||||||
|
surEl = document.createElement("div");
|
||||||
|
surEl.className = "draco-console-sur";
|
||||||
|
surEl.setAttribute("aria-label", "Console Spark");
|
||||||
|
surEl.hidden = true;
|
||||||
|
surEl.innerHTML =
|
||||||
|
'<div class="draco-console-barre">' +
|
||||||
|
' <span class="dc-titre"><span class="dc-etoile">✦</span> Console Spark</span>' +
|
||||||
|
' <span class="dc-actions">' +
|
||||||
|
' <button type="button" data-dc="sidebar" title="Réduire la console et revenir au panneau latéral">◧ Mode sidebar</button>' +
|
||||||
|
' <button type="button" data-dc="fermer" title="Fermer la console et le panneau Spark">✕ Fermer l\'agent</button>' +
|
||||||
|
' </span></div>' +
|
||||||
|
'<iframe class="draco-console-cadre" title="Console Spark" allow="microphone"></iframe>';
|
||||||
|
document.body.appendChild(surEl);
|
||||||
|
surCadre = surEl.querySelector("iframe");
|
||||||
|
surEl.querySelector('[data-dc="sidebar"]')
|
||||||
|
.addEventListener("click", modeSidebarConsole);
|
||||||
|
surEl.querySelector('[data-dc="fermer"]')
|
||||||
|
.addEventListener("click", fermerAgentConsole);
|
||||||
|
appliquerTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
function largeurPanneau() {
|
||||||
|
if (root) {
|
||||||
|
var w = root.getBoundingClientRect().width;
|
||||||
|
if (w > 0) return Math.round(w);
|
||||||
|
}
|
||||||
|
return 388;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ouvrirConsole() {
|
||||||
|
assurerSurimpression();
|
||||||
|
if (!surCadre.getAttribute("src")) surCadre.src = urlConsole();
|
||||||
|
if (surOuverte) return;
|
||||||
|
surOuverte = true;
|
||||||
|
/* Naissance à la géométrie du panneau (ancrée à droite), puis
|
||||||
|
* extension à toute la page ; le double rAF laisse le temps de
|
||||||
|
* peindre la largeur de départ, sinon la transition ne joue pas. */
|
||||||
|
surEl.style.width = largeurPanneau() + "px";
|
||||||
|
surEl.hidden = false;
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
requestAnimationFrame(function () { surEl.style.width = "100%"; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function retrecirConsole() {
|
||||||
|
if (!surEl || !surOuverte) return;
|
||||||
|
surOuverte = false;
|
||||||
|
var fini = false;
|
||||||
|
function terminer() {
|
||||||
|
if (fini) return;
|
||||||
|
fini = true;
|
||||||
|
surEl.removeEventListener("transitionend", surFin);
|
||||||
|
surEl.hidden = true; // l'iframe reste chargé, juste masqué
|
||||||
|
}
|
||||||
|
function surFin(ev) { if (ev.propertyName === "width") terminer(); }
|
||||||
|
surEl.addEventListener("transitionend", surFin);
|
||||||
|
setTimeout(terminer, 380); // filet si transitionend ne vient pas
|
||||||
|
surEl.style.width = largeurPanneau() + "px";
|
||||||
|
}
|
||||||
|
function modeSidebarConsole() { retrecirConsole(); }
|
||||||
|
function fermerAgentConsole() { fermer(); retrecirConsole(); }
|
||||||
|
|
||||||
|
/* ── Thème : suivre l'app hôte ─────────────────────────────── */
|
||||||
|
function themeHote() {
|
||||||
|
var t = document.documentElement.getAttribute("data-theme");
|
||||||
|
if (t === "dark" || t === "light") return t;
|
||||||
|
if (document.documentElement.classList.contains("dark") ||
|
||||||
|
document.body.classList.contains("dark")) return "dark";
|
||||||
|
try {
|
||||||
|
if (window.matchMedia("(prefers-color-scheme: dark)").matches) return "dark";
|
||||||
|
} catch (e) { /* pas de matchMedia */ }
|
||||||
|
return "light";
|
||||||
|
}
|
||||||
|
function appliquerTheme() {
|
||||||
|
if (root) root.setAttribute("data-draco-theme", themeHote());
|
||||||
|
if (surEl) surEl.setAttribute("data-draco-theme", themeHote());
|
||||||
|
}
|
||||||
|
function surveillerTheme() {
|
||||||
|
try {
|
||||||
|
new MutationObserver(appliquerTheme).observe(document.documentElement,
|
||||||
|
{ attributes: true, attributeFilter: ["data-theme", "class"] });
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)")
|
||||||
|
.addEventListener("change", appliquerTheme);
|
||||||
|
} catch (e) { /* surveillance facultative */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Largeur réglable ──────────────────────────────────────── */
|
||||||
|
function largeurStockee() {
|
||||||
|
try {
|
||||||
|
var v = parseInt(localStorage.getItem("draco_panneau_largeur") || "", 10);
|
||||||
|
return isNaN(v) ? null : v;
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
function appliquerLargeur() {
|
||||||
|
if (!root) return;
|
||||||
|
var v = largeurStockee();
|
||||||
|
root.style.width = v ? Math.min(v, Math.round(window.innerWidth * 0.96)) + "px" : "";
|
||||||
|
}
|
||||||
|
function brancherPoignee(poignee) {
|
||||||
|
poignee.addEventListener("pointerdown", function (ev) {
|
||||||
|
if (window.innerWidth <= 700) return; // mobile : pleine largeur
|
||||||
|
ev.preventDefault();
|
||||||
|
var x0 = ev.clientX, w0 = root.getBoundingClientRect().width;
|
||||||
|
function bouger(e2) {
|
||||||
|
var w = Math.max(300, Math.min(760, w0 + (x0 - e2.clientX)));
|
||||||
|
root.style.width = w + "px";
|
||||||
|
}
|
||||||
|
function lacher(e2) {
|
||||||
|
document.removeEventListener("pointermove", bouger);
|
||||||
|
document.removeEventListener("pointerup", lacher);
|
||||||
|
var w = Math.round(root.getBoundingClientRect().width);
|
||||||
|
try { localStorage.setItem("draco_panneau_largeur", String(w)); } catch (e) { /* ok */ }
|
||||||
|
}
|
||||||
|
document.addEventListener("pointermove", bouger);
|
||||||
|
document.addEventListener("pointerup", lacher);
|
||||||
|
});
|
||||||
|
poignee.addEventListener("dblclick", function () {
|
||||||
|
try { localStorage.removeItem("draco_panneau_largeur"); } catch (e) { /* ok */ }
|
||||||
|
appliquerLargeur();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function construire() {
|
||||||
|
root = document.createElement("aside");
|
||||||
|
root.className = "draco-panneau";
|
||||||
|
root.setAttribute("aria-label", "Spark, l'agent commun");
|
||||||
|
root.innerHTML =
|
||||||
|
'<div class="draco-poignee" title="Glisser pour régler la largeur — double-clic : largeur par défaut"></div>' +
|
||||||
|
'<div class="draco-panneau-hd">' +
|
||||||
|
' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
|
||||||
|
' <div><div class="titre">Spark — agent commun</div>' +
|
||||||
|
' <div class="soustitre">Service d\'agents dracodev</div></div>' +
|
||||||
|
' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="draco-panneau-hd draco-panneau-etat" style="padding-top:8px;padding-bottom:8px">' +
|
||||||
|
' <span class="point"></span><span class="txt">Connexion au service…</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="draco-disc-bar">' +
|
||||||
|
' <span class="d-titre">Discussion principale</span>' +
|
||||||
|
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
|
||||||
|
' <button type="button" class="d-btn" data-disc="nouvelle">+ Nouvelle</button>' +
|
||||||
|
' <button type="button" class="d-btn" data-disc="console" title="Ouvrir la console Spark (agent.dracodev.net) en pleine page par-dessus l\'application, sur cette discussion">Console ⤢</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="draco-discussions" hidden></div>' +
|
||||||
|
'<div class="draco-panneau-outils">' +
|
||||||
|
' <button type="button" data-outil="aujourdhui">☀ Aujourd\'hui</button>' +
|
||||||
|
' <button type="button" data-outil="approbations">Approbations</button>' +
|
||||||
|
' <button type="button" data-outil="missions">🚀 Missions</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="draco-panneau-msgs"></div>' +
|
||||||
|
'<div class="draco-panneau-ft"><form class="draco-panneau-form">' +
|
||||||
|
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
|
||||||
|
' <button type="submit">Envoyer</button></form>' +
|
||||||
|
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
|
||||||
|
'</div>';
|
||||||
|
document.body.appendChild(root);
|
||||||
|
msgsEl = root.querySelector(".draco-panneau-msgs");
|
||||||
|
etatEl = root.querySelector(".draco-panneau-etat");
|
||||||
|
inputEl = root.querySelector("input");
|
||||||
|
sendBtn = root.querySelector("button[type=submit]");
|
||||||
|
titreEl = root.querySelector(".d-titre");
|
||||||
|
tiroirEl = root.querySelector(".draco-discussions");
|
||||||
|
root.querySelector(".fermer").addEventListener("click", fermer);
|
||||||
|
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
|
||||||
|
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
|
||||||
|
root.querySelector('[data-disc="console"]').addEventListener("click", ouvrirConsole);
|
||||||
|
root.querySelector('[data-outil="aujourdhui"]').addEventListener("click", chargerAujourdhui);
|
||||||
|
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
|
||||||
|
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
|
||||||
|
root.querySelector("form").addEventListener("submit", envoyer);
|
||||||
|
brancherPoignee(root.querySelector(".draco-poignee"));
|
||||||
|
appliquerTheme();
|
||||||
|
surveillerTheme();
|
||||||
|
appliquerLargeur();
|
||||||
|
window.addEventListener("resize", appliquerLargeur);
|
||||||
|
document.addEventListener("keydown", function (ev) {
|
||||||
|
if (ev.key !== "Escape") return;
|
||||||
|
/* Surimpression ouverte : Échap = retour au panneau seul
|
||||||
|
* (« Mode sidebar »). Quand le focus est dans l'iframe de la
|
||||||
|
* console (autre origine), l'événement ne remonte pas
|
||||||
|
* jusqu'ici : il ne peut donc pas fermer par surprise. */
|
||||||
|
if (surOuverte) { modeSidebarConsole(); return; }
|
||||||
|
if (root.classList.contains("open")) fermer();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ajouterMsg(kind, texte) {
|
||||||
|
var d = document.createElement("div");
|
||||||
|
d.className = "draco-msg " + kind;
|
||||||
|
d.textContent = texte;
|
||||||
|
msgsEl.appendChild(d);
|
||||||
|
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message agent rendu en Markdown : le HTML vient du service
|
||||||
|
* (rendu + sanitizer côté serveur) ; repli texte si absent. */
|
||||||
|
function ajouterMsgMd(texte, html) {
|
||||||
|
var d = document.createElement("div");
|
||||||
|
d.className = "draco-msg agent md";
|
||||||
|
if (html) d.innerHTML = html;
|
||||||
|
else d.textContent = texte;
|
||||||
|
msgsEl.appendChild(d);
|
||||||
|
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function majEtat(cls, texte) {
|
||||||
|
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
|
||||||
|
etatEl.querySelector(".txt").textContent = texte;
|
||||||
|
}
|
||||||
|
function majTitre() {
|
||||||
|
titreEl.textContent = convTitre || "Discussion principale";
|
||||||
|
titreEl.title = titreEl.textContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifierIdentite() {
|
||||||
|
if (etatConnu) return;
|
||||||
|
fetch(API + "/api/v1/me", { credentials: "include" })
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.status === 401) {
|
||||||
|
majEtat("err", "Session partagée non reconnue");
|
||||||
|
ajouterMsg("sys", "Le service ne reconnaît pas ta session. Connecte-toi à ObsiGate (session unique draco_session), puis rouvre le panneau. Si le SSO n'est pas encore activé, c'est l'étape d'activation du programme.");
|
||||||
|
etatConnu = true;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function (me) {
|
||||||
|
if (!me) return;
|
||||||
|
etatConnu = true;
|
||||||
|
majEtat("ok", "Connecté au service — " + (me.name || ("utilisateur " + me.sub)));
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
majEtat("err", "Service d'agents injoignable");
|
||||||
|
ajouterMsg("sys", "Le service d'agents (agent.dracodev.net) ne répond pas. Vérifie que le conteneur dracodev-agent est démarré.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) {
|
||||||
|
return { "&": "&", "<": "<", ">": ">", '"': """ }[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Discussions (façon Muse) ────────────────────────────── */
|
||||||
|
function chargerListeDiscussions() {
|
||||||
|
return fetch(API + "/api/v1/conversations?domain=" + encodeURIComponent(domaine()),
|
||||||
|
{ credentials: "include" })
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function demarrerDiscussions() {
|
||||||
|
chargerListeDiscussions()
|
||||||
|
.then(function (d) {
|
||||||
|
var precedente = convStockee();
|
||||||
|
var candidates = (d.paralleles || []).concat(d.principale ? [d.principale] : []);
|
||||||
|
var trouvee = candidates.filter(function (c) { return c.id === precedente; })[0];
|
||||||
|
var active = trouvee || d.principale;
|
||||||
|
if (!active) return;
|
||||||
|
convId = active.id; convKind = active.kind; convTitre = active.title;
|
||||||
|
stockerConv(); majTitre();
|
||||||
|
chargerHistorique();
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
ajouterMsg("sys", "Discussions : service injoignable pour le moment.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function basculerDiscussion(c) {
|
||||||
|
convId = c.id; convKind = c.kind; convTitre = c.title;
|
||||||
|
stockerConv(); majTitre();
|
||||||
|
tiroirEl.hidden = true;
|
||||||
|
msgsEl.innerHTML = "";
|
||||||
|
ajouterMsg("sys", (c.kind === "principale"
|
||||||
|
? "Discussion principale — le fil sans fin."
|
||||||
|
: "Discussion parallèle : " + c.title) + " · domaine : " + domaine() + ".");
|
||||||
|
chargerHistorique();
|
||||||
|
inputEl.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function nouvelleDiscussion() {
|
||||||
|
fetch(API + "/api/v1/conversations", {
|
||||||
|
method: "POST", credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ domain: domaine() })
|
||||||
|
})
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||||
|
.then(function (c) {
|
||||||
|
basculerDiscussion(c);
|
||||||
|
ajouterMsg("sys", "Nouvelle discussion parallèle créée — elle prendra le titre de ton premier message.");
|
||||||
|
})
|
||||||
|
.catch(function () { ajouterMsg("sys", "Création impossible (service injoignable ?)."); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionDiscussion(id, action) {
|
||||||
|
var methode = action === "supprimer" ? "DELETE" : "POST";
|
||||||
|
var url = API + "/api/v1/conversations/" + id + (action === "supprimer" ? "" : "/" + action);
|
||||||
|
return fetch(url, { method: methode, credentials: "include" })
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) return r.json().catch(function () { return null; }).then(function (e) {
|
||||||
|
throw new Error(e && e.detail ? e.detail : "HTTP " + r.status);
|
||||||
|
});
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ligneDiscussion(c, actions) {
|
||||||
|
var ligne = document.createElement("div");
|
||||||
|
ligne.className = "d-item" + (c.id === convId ? " actif" : "");
|
||||||
|
var meta = document.createElement("div");
|
||||||
|
meta.className = "d-meta";
|
||||||
|
var quand = "";
|
||||||
|
try {
|
||||||
|
quand = new Date(c.updated_at * 1000).toLocaleDateString("fr-CA",
|
||||||
|
{ day: "numeric", month: "short" });
|
||||||
|
} catch (e) { /* date illisible */ }
|
||||||
|
meta.textContent = (c.kind === "principale" ? "📌 " : "") + c.title;
|
||||||
|
ligne.appendChild(meta);
|
||||||
|
var sous = document.createElement("div");
|
||||||
|
sous.className = "d-sous";
|
||||||
|
sous.textContent = c.nb_messages + " message(s)" + (quand ? " · " + quand : "") +
|
||||||
|
(c.kind === "principale" ? " · perpétuelle" : "");
|
||||||
|
ligne.appendChild(sous);
|
||||||
|
meta.addEventListener("click", function () { basculerDiscussion(c); });
|
||||||
|
sous.addEventListener("click", function () { basculerDiscussion(c); });
|
||||||
|
if (actions && actions.length) {
|
||||||
|
var btns = document.createElement("div");
|
||||||
|
btns.className = "d-actions";
|
||||||
|
actions.forEach(function (a) {
|
||||||
|
var b = document.createElement("button");
|
||||||
|
b.type = "button"; b.textContent = a.label;
|
||||||
|
if (a.danger) b.className = "danger";
|
||||||
|
b.addEventListener("click", function (ev) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
if (a.confirmer && !window.confirm(a.confirmer)) return;
|
||||||
|
actionDiscussion(c.id, a.action)
|
||||||
|
.then(function () {
|
||||||
|
if (c.id === convId && a.action !== "desarchiver") {
|
||||||
|
chargerListeDiscussions().then(function (d) {
|
||||||
|
if (d.principale) {
|
||||||
|
basculerDiscussion(d.principale);
|
||||||
|
ajouterMsg("sys", a.action === "supprimer"
|
||||||
|
? "Discussion supprimée — retour à la discussion principale."
|
||||||
|
: "Discussion archivée — retour à la discussion principale.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
remplirTiroir();
|
||||||
|
})
|
||||||
|
.catch(function (e) { ajouterMsg("sys", "Action refusée : " + e.message); });
|
||||||
|
});
|
||||||
|
btns.appendChild(b);
|
||||||
|
});
|
||||||
|
ligne.appendChild(btns);
|
||||||
|
}
|
||||||
|
return ligne;
|
||||||
|
}
|
||||||
|
|
||||||
|
function remplirTiroir() {
|
||||||
|
tiroirEl.innerHTML = "<div class='d-chargement'>Chargement des discussions…</div>";
|
||||||
|
chargerListeDiscussions()
|
||||||
|
.then(function (d) {
|
||||||
|
tiroirEl.innerHTML = "";
|
||||||
|
var h = document.createElement("div");
|
||||||
|
h.className = "d-section";
|
||||||
|
h.textContent = "Discussion principale";
|
||||||
|
tiroirEl.appendChild(h);
|
||||||
|
if (d.principale) tiroirEl.appendChild(ligneDiscussion(d.principale, []));
|
||||||
|
h = document.createElement("div");
|
||||||
|
h.className = "d-section";
|
||||||
|
h.textContent = "Discussions parallèles";
|
||||||
|
tiroirEl.appendChild(h);
|
||||||
|
if (!d.paralleles.length) {
|
||||||
|
var vide = document.createElement("div");
|
||||||
|
vide.className = "d-vide";
|
||||||
|
vide.textContent = "Aucune pour le moment — bouton « + Nouvelle ».";
|
||||||
|
tiroirEl.appendChild(vide);
|
||||||
|
}
|
||||||
|
d.paralleles.forEach(function (c) {
|
||||||
|
tiroirEl.appendChild(ligneDiscussion(c, [
|
||||||
|
{ label: "Archiver", action: "archive" },
|
||||||
|
{ label: "Supprimer", action: "supprimer", danger: true,
|
||||||
|
confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" }
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
if (d.archivees && d.archivees.length) {
|
||||||
|
h = document.createElement("div");
|
||||||
|
h.className = "d-section";
|
||||||
|
h.textContent = "Archivées";
|
||||||
|
tiroirEl.appendChild(h);
|
||||||
|
d.archivees.forEach(function (c) {
|
||||||
|
tiroirEl.appendChild(ligneDiscussion(c, [
|
||||||
|
{ label: "Désarchiver", action: "desarchiver" },
|
||||||
|
{ label: "Supprimer", action: "supprimer", danger: true,
|
||||||
|
confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" }
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
tiroirEl.innerHTML = "<div class='d-chargement'>Discussions : service injoignable.</div>";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function basculerTiroir() {
|
||||||
|
tiroirEl.hidden = !tiroirEl.hidden;
|
||||||
|
if (!tiroirEl.hidden) remplirTiroir();
|
||||||
|
}
|
||||||
|
|
||||||
|
function chargerHistorique() {
|
||||||
|
if (!convId) return;
|
||||||
|
fetch(API + "/api/v1/conversations/" + convId, { credentials: "include" })
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.status === 404) { convId = null; stockerConv(); return null; }
|
||||||
|
if (!r.ok) return null;
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d) return;
|
||||||
|
convKind = d.kind; convTitre = d.title || convTitre;
|
||||||
|
majTitre();
|
||||||
|
if (!msgsEl.querySelector(".draco-msg")) {
|
||||||
|
ajouterMsg("sys", (d.kind === "principale"
|
||||||
|
? "Discussion principale — le fil sans fin."
|
||||||
|
: "Discussion parallèle : " + (d.title || "")) + " · domaine : " + domaine() + ".");
|
||||||
|
}
|
||||||
|
var sys = msgsEl.querySelector(".draco-msg.sys");
|
||||||
|
(d.messages || []).forEach(function (m) {
|
||||||
|
var el = m.role === "user"
|
||||||
|
? ajouterMsg("user", m.content)
|
||||||
|
: ajouterMsgMd(m.content, m.html);
|
||||||
|
if (sys) { msgsEl.insertBefore(el, sys.nextSibling); sys = el; }
|
||||||
|
});
|
||||||
|
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||||
|
})
|
||||||
|
.catch(function () { /* historique indisponible : on repart à neuf */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
function chargerAujourdhui() {
|
||||||
|
var bloc = document.createElement("div");
|
||||||
|
bloc.className = "draco-auj";
|
||||||
|
bloc.innerHTML = "<h5>Aujourd'hui — vu du service d'agents</h5><div class='etat-source'>Chargement…</div>";
|
||||||
|
msgsEl.appendChild(bloc); msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||||
|
fetch(API + "/api/v1/aujourdhui", { credentials: "include" })
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
var h = "<h5>Aujourd'hui — " + esc(d.date) + " · domaine " + esc(d.domain) + "</h5>";
|
||||||
|
var t = d.sources.tiktak;
|
||||||
|
h += "<h5>Tâches du jour — TikTak</h5>";
|
||||||
|
if (t.status === "ok") {
|
||||||
|
if (!t.tasks.length) h += "<div class='etat-source'>Aucune tâche échue aujourd'hui. 🎉</div>";
|
||||||
|
else h += "<ul>" + t.tasks.map(function (x) { return "<li>" + esc(x.title) + "</li>"; }).join("") + "</ul>";
|
||||||
|
} else if (t.status === "non_configure") {
|
||||||
|
h += "<div class='etat-source'>Source non configurée : il manque la clé tck_ du service (voir le .env du conteneur agent).</div>";
|
||||||
|
} else if (t.status === "non_autorise") {
|
||||||
|
h += "<div class='etat-source'>Source non autorisée : la clé du service est refusée par TikTak (clé révoquée ?).</div>";
|
||||||
|
} else {
|
||||||
|
h += "<div class='etat-source'>Source TikTak " + esc(t.status) + ".</div>";
|
||||||
|
}
|
||||||
|
var k = d.sources.kanbante;
|
||||||
|
h += "<h5>Cartes en Révision — Kanbante</h5>";
|
||||||
|
if (k.status === "ok") {
|
||||||
|
if (!k.review.length) h += "<div class='etat-source'>Rien à valider. 🎉</div>";
|
||||||
|
else h += "<ul>" + k.review.map(function (x) { return "<li>#" + esc(x.number) + " — " + esc(x.title) + "</li>"; }).join("") + "</ul>";
|
||||||
|
} else if (k.status === "non_autorise") {
|
||||||
|
h += "<div class='etat-source'>Source non autorisée : Kanbante exige un jeton d'API pour le service — il arrive avec Kanbante 0.25.0, puis AGENT_MCP_KANBANTE_KEY dans le .env de l'agent.</div>";
|
||||||
|
} else {
|
||||||
|
h += "<div class='etat-source'>Source Kanbante " + esc(k.status) + ".</div>";
|
||||||
|
}
|
||||||
|
bloc.innerHTML = h;
|
||||||
|
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
bloc.innerHTML = "<h5>Aujourd'hui</h5><div class='etat-source'>Service injoignable.</div>";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function chargerApprobations() {
|
||||||
|
fetch(API + "/api/v1/approvals?status=pending", { credentials: "include" })
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d.approvals.length) { ajouterMsg("sys", "Aucune approbation en attente."); return; }
|
||||||
|
d.approvals.forEach(function (a) {
|
||||||
|
var bloc = document.createElement("div");
|
||||||
|
bloc.className = "draco-appro";
|
||||||
|
bloc.innerHTML = "<div class='titre'>Approbation demandée — " + esc(a.action) + "</div>" +
|
||||||
|
"<div class='resume'>" + esc(a.summary) + "</div>" +
|
||||||
|
"<div class='btns'><button type='button' class='go'>Approuver</button>" +
|
||||||
|
"<button type='button' class='non'>Rejeter</button></div>";
|
||||||
|
bloc.querySelector(".go").addEventListener("click", function () { decider(a.id, "approved", bloc); });
|
||||||
|
bloc.querySelector(".non").addEventListener("click", function () { decider(a.id, "rejected", bloc); });
|
||||||
|
msgsEl.appendChild(bloc);
|
||||||
|
});
|
||||||
|
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||||
|
})
|
||||||
|
.catch(function () { ajouterMsg("sys", "Approbations : service injoignable."); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function decider(id, decision, bloc) {
|
||||||
|
fetch(API + "/api/v1/approvals/" + id + "/decide", {
|
||||||
|
method: "POST", credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ decision: decision })
|
||||||
|
})
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
bloc.style.opacity = ".55";
|
||||||
|
bloc.querySelector(".btns").innerHTML = "<span style='font-size:12.5px'>Décision enregistrée : " + esc(d.status) + "</span>";
|
||||||
|
})
|
||||||
|
.catch(function () { ajouterMsg("sys", "La décision n'a pas pu être envoyée (service injoignable ?)."); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Missions longues (J5) ───────────────────────────────────
|
||||||
|
* Sélecteur d'exécuteur alimenté par le registre du service :
|
||||||
|
* les externes non configurés sont montrés mais désactivés —
|
||||||
|
* jamais un lancement qui simule. La liste se rafraîchit à
|
||||||
|
* l'ouverture et après chaque lancement/annulation. */
|
||||||
|
var STATUTS_MISSION = {
|
||||||
|
queued: "En file", running: "En cours…", paused_question: "Question en attente",
|
||||||
|
succeeded: "Terminée", failed: "Échouée", cancelled: "Annulée"
|
||||||
|
};
|
||||||
|
|
||||||
|
function ouvrirMissions() {
|
||||||
|
var bloc = document.createElement("div");
|
||||||
|
bloc.className = "draco-missions";
|
||||||
|
bloc.innerHTML =
|
||||||
|
"<h5>Missions longues</h5>" +
|
||||||
|
"<label>Exécuteur</label><select class='m-exec'><option>Chargement…</option></select>" +
|
||||||
|
"<label>Titre</label><input type='text' class='m-titre' placeholder='Ex. Veille des projets du matin'>" +
|
||||||
|
"<label>Consigne</label><textarea class='m-prompt' rows='3' placeholder='Ce que l\u2019exécuteur doit faire…'></textarea>" +
|
||||||
|
"<label>Carte Kanbante liée (n°, optionnel)</label><input type='number' class='m-carte' min='1' placeholder='—'>" +
|
||||||
|
"<div class='btns'><button type='button' class='go'>Lancer la mission</button>" +
|
||||||
|
"<button type='button' class='rafraichir'>Rafraîchir la liste</button></div>" +
|
||||||
|
"<div class='m-liste'><div class='etat-source'>Chargement des missions…</div></div>";
|
||||||
|
msgsEl.appendChild(bloc);
|
||||||
|
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||||
|
fetch(API + "/api/v1/executors", { credentials: "include" })
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
var sel = bloc.querySelector(".m-exec");
|
||||||
|
sel.innerHTML = "";
|
||||||
|
d.executors.forEach(function (e) {
|
||||||
|
if (e.family !== "sous-agent" && e.family !== "externe") return;
|
||||||
|
var opt = document.createElement("option");
|
||||||
|
opt.value = e.id;
|
||||||
|
opt.textContent = e.name + (e.family === "sous-agent" ? " (sous-agent)" : "") +
|
||||||
|
(e.status === "non_configure" ? " — non configuré" : "");
|
||||||
|
if (e.status === "non_configure") opt.disabled = true;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (!sel.options.length) sel.innerHTML = "<option value=''>Aucun exécuteur lançable</option>";
|
||||||
|
})
|
||||||
|
.catch(function () { bloc.querySelector(".m-exec").innerHTML = "<option value=''>Registre injoignable</option>"; });
|
||||||
|
bloc.querySelector(".go").addEventListener("click", function () { lancerMission(bloc); });
|
||||||
|
bloc.querySelector(".rafraichir").addEventListener("click", function () { chargerMissions(bloc); });
|
||||||
|
chargerMissions(bloc);
|
||||||
|
}
|
||||||
|
|
||||||
|
function chargerMissions(bloc) {
|
||||||
|
var zone = bloc.querySelector(".m-liste");
|
||||||
|
fetch(API + "/api/v1/missions", { credentials: "include" })
|
||||||
|
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d.missions.length) { zone.innerHTML = "<div class='etat-source'>Aucune mission pour le moment.</div>"; return; }
|
||||||
|
zone.innerHTML = "";
|
||||||
|
d.missions.slice(0, 10).forEach(function (m) {
|
||||||
|
var ligne = document.createElement("div");
|
||||||
|
ligne.className = "m-item";
|
||||||
|
var entete = document.createElement("div");
|
||||||
|
entete.className = "m-entete";
|
||||||
|
entete.innerHTML = "<b>" + esc(m.title) + "</b>" +
|
||||||
|
"<span class='m-statut st-" + esc(m.status) + "'>" + esc(STATUTS_MISSION[m.status] || m.status) + "</span>";
|
||||||
|
ligne.appendChild(entete);
|
||||||
|
var meta = document.createElement("div");
|
||||||
|
meta.className = "m-meta";
|
||||||
|
meta.textContent = m.executor +
|
||||||
|
(m.meta && m.meta.duree_s ? " · " + m.meta.duree_s + " s" : "") +
|
||||||
|
(m.meta && m.meta.cout ? " · coût : " + m.meta.cout : "");
|
||||||
|
ligne.appendChild(meta);
|
||||||
|
if (m.result) {
|
||||||
|
var rapport = document.createElement("div");
|
||||||
|
rapport.className = "m-rapport md";
|
||||||
|
if (m.result_html) rapport.innerHTML = m.result_html;
|
||||||
|
else rapport.textContent = String(m.result).slice(0, 1200);
|
||||||
|
ligne.appendChild(rapport);
|
||||||
|
}
|
||||||
|
if (m.status === "queued" || m.status === "running" || m.status === "paused_question") {
|
||||||
|
var btn = document.createElement("button");
|
||||||
|
btn.type = "button"; btn.className = "m-annuler"; btn.textContent = "Annuler";
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
fetch(API + "/api/v1/missions/" + m.id + "/cancel", {
|
||||||
|
method: "POST", credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" }, body: "{}"
|
||||||
|
}).then(function () { chargerMissions(bloc); })
|
||||||
|
.catch(function () { ajouterMsg("sys", "Annulation non envoyée (service injoignable ?)."); });
|
||||||
|
});
|
||||||
|
ligne.appendChild(btn);
|
||||||
|
}
|
||||||
|
zone.appendChild(ligne);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function () { zone.innerHTML = "<div class='etat-source'>Missions : service injoignable.</div>"; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function lancerMission(bloc) {
|
||||||
|
var executor = bloc.querySelector(".m-exec").value;
|
||||||
|
var titre = bloc.querySelector(".m-titre").value.trim();
|
||||||
|
var prompt = bloc.querySelector(".m-prompt").value.trim();
|
||||||
|
var carte = bloc.querySelector(".m-carte").value;
|
||||||
|
if (!executor) { ajouterMsg("sys", "Choisis un exécuteur configuré pour lancer une mission."); return; }
|
||||||
|
if (!titre || !prompt) { ajouterMsg("sys", "Mission : il faut un titre et une consigne."); return; }
|
||||||
|
var corps = { executor: executor, title: titre, prompt: prompt, domain: domaine() };
|
||||||
|
if (carte) corps.kanbante_number = parseInt(carte, 10);
|
||||||
|
fetch(API + "/api/v1/missions", {
|
||||||
|
method: "POST", credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(corps)
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) return r.json().catch(function () { return null; }).then(function (e) {
|
||||||
|
throw new Error(e && e.detail ? e.detail : "HTTP " + r.status);
|
||||||
|
});
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
ajouterMsg("sys", "Mission « " + titre + " » lancée — suis-la dans la liste des missions.");
|
||||||
|
chargerMissions(bloc);
|
||||||
|
})
|
||||||
|
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function envoyer(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
var texte = inputEl.value.trim();
|
||||||
|
if (!texte) return;
|
||||||
|
inputEl.value = "";
|
||||||
|
ajouterMsg("user", texte);
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
var attente = ajouterMsg("agent", "Spark réfléchit…");
|
||||||
|
fetch(API + "/api/v1/chat", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ message: texte, domain: domaine(), conversation_id: convId })
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.status === 401) {
|
||||||
|
attente.textContent = "Le service ne reconnaît pas ta session partagée (401). Connecte-toi à ObsiGate puis réessaie.";
|
||||||
|
majEtat("err", "Session partagée non reconnue");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!r.ok) {
|
||||||
|
return r.json().catch(function () { return null; }).then(function (e) {
|
||||||
|
attente.textContent = (e && e.detail) ? e.detail :
|
||||||
|
"Le service a répondu HTTP " + r.status + " — réessaie dans un instant.";
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function (d) {
|
||||||
|
if (!d) return;
|
||||||
|
convId = d.conversation_id;
|
||||||
|
convKind = d.kind || convKind;
|
||||||
|
if (d.title) convTitre = d.title;
|
||||||
|
stockerConv(); majTitre();
|
||||||
|
if (d.html) {
|
||||||
|
attente.className = "draco-msg agent md";
|
||||||
|
attente.innerHTML = d.html;
|
||||||
|
} else {
|
||||||
|
attente.textContent = d.reply;
|
||||||
|
}
|
||||||
|
msgsEl.scrollTop = msgsEl.scrollHeight;
|
||||||
|
if (d.tools_used && d.tools_used.length) {
|
||||||
|
ajouterMsg("sys", "Outils utilisés : " + d.tools_used.join(", ") + " · modèle : " + (d.model || "?"));
|
||||||
|
}
|
||||||
|
majEtat("ok", "Connecté au service — domaine " + d.domain);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
attente.textContent = "Service injoignable. Vérifie que le conteneur dracodev-agent est démarré, puis réessaie.";
|
||||||
|
majEtat("err", "Service d'agents injoignable");
|
||||||
|
})
|
||||||
|
.finally(function () { sendBtn.disabled = false; inputEl.focus(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function ouvrir() {
|
||||||
|
if (!root) { construire(); demarrerDiscussions(); }
|
||||||
|
root.classList.add("open");
|
||||||
|
appliquerTheme();
|
||||||
|
verifierIdentite();
|
||||||
|
setTimeout(function () { inputEl.focus(); }, 300);
|
||||||
|
}
|
||||||
|
function fermer() { if (root) root.classList.remove("open"); }
|
||||||
|
|
||||||
|
window.DRACO_PANNEAU = {
|
||||||
|
open: ouvrir,
|
||||||
|
close: fermer,
|
||||||
|
toggle: function () {
|
||||||
|
if (root && root.classList.contains("open")) fermer(); else ouvrir();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -88,6 +88,8 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
|||||||
.preview-content p { margin: 6px 0; }
|
.preview-content p { margin: 6px 0; }
|
||||||
.preview-content ul, .preview-content ol { margin: 6px 0; padding-left: 24px; }
|
.preview-content ul, .preview-content ol { margin: 6px 0; padding-left: 24px; }
|
||||||
.preview-content li { margin: 2px 0; }
|
.preview-content li { margin: 2px 0; }
|
||||||
|
.preview-content .task-list-item { list-style: none; margin-left: -1.2em; }
|
||||||
|
.preview-content .task-list-item input[type="checkbox"] { margin-right: 6px; }
|
||||||
.preview-content code { background: var(--bg3); padding: 1px 5px; border-radius: 3px; font-family: var(--mono); font-size: 12px; color: var(--ai); }
|
.preview-content code { background: var(--bg3); padding: 1px 5px; border-radius: 3px; font-family: var(--mono); font-size: 12px; color: var(--ai); }
|
||||||
.preview-content pre { background: var(--bg3); padding: 12px 16px; border-radius: var(--R); overflow-x: auto; margin: 10px 0; border: 1px solid var(--border); }
|
.preview-content pre { background: var(--bg3); padding: 12px 16px; border-radius: var(--R); overflow-x: auto; margin: 10px 0; border: 1px solid var(--border); }
|
||||||
.preview-content pre code { background: none; padding: 0; color: var(--text); }
|
.preview-content pre code { background: none; padding: 0; color: var(--text); }
|
||||||
@@ -134,7 +136,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
|||||||
.qi-item:hover { background: var(--bg-hover); color: var(--text); }
|
.qi-item:hover { background: var(--bg-hover); color: var(--text); }
|
||||||
|
|
||||||
/* Inline form (table dims, code lang) */
|
/* Inline form (table dims, code lang) */
|
||||||
.inline-form { position: absolute; z-index: 195; background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); display: none; padding: 8px 10px; min-width: 200px; }
|
.inline-form { position: fixed; z-index: 195; background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); display: none; padding: 8px 10px; min-width: 200px; }
|
||||||
.inline-form.on { display: block; animation: fadeIn 100ms; }
|
.inline-form.on { display: block; animation: fadeIn 100ms; }
|
||||||
.inline-form-label { font-size: 10px; font-weight: 600; color: var(--text3); text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 6px; }
|
.inline-form-label { font-size: 10px; font-weight: 600; color: var(--text3); text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 6px; }
|
||||||
.inline-form-row { display: flex; gap: 6px; align-items: center; margin-bottom: 5px; }
|
.inline-form-row { display: flex; gap: 6px; align-items: center; margin-bottom: 5px; }
|
||||||
@@ -148,7 +150,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
|||||||
.inline-form-actions button.ok:hover { background: #5a8fc5; }
|
.inline-form-actions button.ok:hover { background: #5a8fc5; }
|
||||||
|
|
||||||
/* Lang picker (in QI menu style) */
|
/* Lang picker (in QI menu style) */
|
||||||
.lang-picker { position: absolute; z-index: 195; width: 200px; max-height: 320px; overflow-y: auto; background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); display: none; padding: 3px; }
|
.lang-picker { position: fixed; z-index: 195; width: 200px; max-height: 320px; overflow-y: auto; background: var(--bg4); border: 1px solid var(--border2); border-radius: var(--R); box-shadow: var(--shadow); display: none; padding: 3px; }
|
||||||
.lang-picker.on { display: block; animation: fadeIn 100ms; }
|
.lang-picker.on { display: block; animation: fadeIn 100ms; }
|
||||||
.lang-item { display: flex; align-items: center; gap: 7px; padding: 6px 9px; font-size: 12px; color: var(--text2); cursor: pointer; border-radius: var(--r); font-family: var(--mono); }
|
.lang-item { display: flex; align-items: center; gap: 7px; padding: 6px 9px; font-size: 12px; color: var(--text2); cursor: pointer; border-radius: var(--r); font-family: var(--mono); }
|
||||||
.lang-item:hover, .lang-item.sel { background: var(--bg-hover); color: var(--text); }
|
.lang-item:hover, .lang-item.sel { background: var(--bg-hover); color: var(--text); }
|
||||||
@@ -498,6 +500,10 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
|||||||
ta.value = val().substring(0, p.s) + text + val().substring(p.e);
|
ta.value = val().substring(0, p.s) + text + val().substring(p.e);
|
||||||
var np = cursorOffset != null ? p.s + cursorOffset : p.s + text.length;
|
var np = cursorOffset != null ? p.s + cursorOffset : p.s + text.length;
|
||||||
setPos(np);
|
setPos(np);
|
||||||
|
// Mutation programmatique : pas d'evenement 'input' → sans autoHeight/updateLineNumbers,
|
||||||
|
// sinon la textarea reste d'une ligne quand on insère un tableau/code.
|
||||||
|
autoHeight();
|
||||||
|
updateLineNumbers();
|
||||||
markDirty();
|
markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,6 +513,8 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
|||||||
var sel = val().substring(p.s, p.e);
|
var sel = val().substring(p.s, p.e);
|
||||||
ta.value = val().substring(0, p.s) + before + sel + (after || before) + val().substring(p.e);
|
ta.value = val().substring(0, p.s) + before + sel + (after || before) + val().substring(p.e);
|
||||||
setPos(p.s + before.length + sel.length + (after || before).length);
|
setPos(p.s + before.length + sel.length + (after || before).length);
|
||||||
|
autoHeight();
|
||||||
|
updateLineNumbers();
|
||||||
markDirty();
|
markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -764,6 +772,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
|||||||
// ---- Markdown Preview (server-side rendering via ObsiGate API) ----
|
// ---- Markdown Preview (server-side rendering via ObsiGate API) ----
|
||||||
var previewTimer = null;
|
var previewTimer = null;
|
||||||
var _previewLoading = false;
|
var _previewLoading = false;
|
||||||
|
var _previewDirty = false;
|
||||||
function schedulePreview() {
|
function schedulePreview() {
|
||||||
if (!previewVisible) return;
|
if (!previewVisible) return;
|
||||||
clearTimeout(previewTimer);
|
clearTimeout(previewTimer);
|
||||||
@@ -772,7 +781,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
|||||||
|
|
||||||
function renderPreview() {
|
function renderPreview() {
|
||||||
if (!previewVisible) return;
|
if (!previewVisible) return;
|
||||||
if (_previewLoading) return;
|
if (_previewLoading) { _previewDirty = true; return; }
|
||||||
// Demo mode: fallback to client-side marked
|
// Demo mode: fallback to client-side marked
|
||||||
if (!fileVault || !filePath) {
|
if (!fileVault || !filePath) {
|
||||||
try {
|
try {
|
||||||
@@ -784,7 +793,11 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
|||||||
}
|
}
|
||||||
// Save first, then fetch server-rendered HTML
|
// Save first, then fetch server-rendered HTML
|
||||||
_previewLoading = true;
|
_previewLoading = true;
|
||||||
previewContent.innerHTML = '<div class="preview-loading"><span class="spin"></span> Rendering...</div>';
|
// Ne jamais effacer le rendu affiché : un update garde l'ancien contenu
|
||||||
|
// jusqu'à l'arrivée du nouveau (sinon l'aperçu flashe à chaque frappe).
|
||||||
|
if (!previewContent.firstChild) {
|
||||||
|
previewContent.innerHTML = '<div class="preview-loading"><span class="spin"></span> Rendering...</div>';
|
||||||
|
}
|
||||||
var content = val();
|
var content = val();
|
||||||
fetch('/api/file/' + encodeURIComponent(fileVault) + '/save?path=' + encodeURIComponent(filePath) + '&backup=false', {
|
fetch('/api/file/' + encodeURIComponent(fileVault) + '/save?path=' + encodeURIComponent(filePath) + '&backup=false', {
|
||||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -799,6 +812,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
|||||||
previewContent.innerHTML = data.html || '<div class="preview-loading">Empty document</div>';
|
previewContent.innerHTML = data.html || '<div class="preview-loading">Empty document</div>';
|
||||||
processPreviewBlocks();
|
processPreviewBlocks();
|
||||||
_previewLoading = false;
|
_previewLoading = false;
|
||||||
|
if (_previewDirty) { _previewDirty = false; schedulePreview(); }
|
||||||
}).catch(function(e) {
|
}).catch(function(e) {
|
||||||
previewContent.innerHTML = '<div class="preview-loading">Erreur de rendu</div>';
|
previewContent.innerHTML = '<div class="preview-loading">Erreur de rendu</div>';
|
||||||
_previewLoading = false;
|
_previewLoading = false;
|
||||||
@@ -1082,8 +1096,11 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
|||||||
ifCols.value = '3'; ifRows.value = '3';
|
ifCols.value = '3'; ifRows.value = '3';
|
||||||
var pt = caretToPixel(null);
|
var pt = caretToPixel(null);
|
||||||
var wr = editorWrap.getBoundingClientRect();
|
var wr = editorWrap.getBoundingClientRect();
|
||||||
inlineForm.style.left = (wr.left + pt.x) + 'px';
|
var _ftop = wr.top + pt.y + 8, _fleft = wr.left + pt.x;
|
||||||
inlineForm.style.top = (wr.top + pt.y + 8) + 'px';
|
if (_ftop + 130 > window.innerHeight) _ftop = window.innerHeight - 140;
|
||||||
|
if (_fleft + 210 > window.innerWidth) _fleft = window.innerWidth - 220;
|
||||||
|
inlineForm.style.left = _fleft + 'px';
|
||||||
|
inlineForm.style.top = _ftop + 'px';
|
||||||
inlineForm.classList.add('on');
|
inlineForm.classList.add('on');
|
||||||
ifCols.focus(); ifCols.select();
|
ifCols.focus(); ifCols.select();
|
||||||
}
|
}
|
||||||
@@ -1130,8 +1147,11 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
|||||||
langPicker.innerHTML = html;
|
langPicker.innerHTML = html;
|
||||||
var pt = caretToPixel(null);
|
var pt = caretToPixel(null);
|
||||||
var wr = editorWrap.getBoundingClientRect();
|
var wr = editorWrap.getBoundingClientRect();
|
||||||
langPicker.style.left = (wr.left + pt.x) + 'px';
|
var _ltop = wr.top + pt.y + 8, _lleft = wr.left + pt.x;
|
||||||
langPicker.style.top = (wr.top + pt.y + 8) + 'px';
|
if (_ltop + 320 > window.innerHeight) _ltop = window.innerHeight - 330;
|
||||||
|
if (_lleft + 200 > window.innerWidth) _lleft = window.innerWidth - 210;
|
||||||
|
langPicker.style.left = _lleft + 'px';
|
||||||
|
langPicker.style.top = _ltop + 'px';
|
||||||
langPicker.classList.add('on');
|
langPicker.classList.add('on');
|
||||||
// Click handler
|
// Click handler
|
||||||
langPicker.querySelectorAll('.lang-item').forEach(function(item) {
|
langPicker.querySelectorAll('.lang-item').forEach(function(item) {
|
||||||
@@ -1428,7 +1448,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
|||||||
slashSearch.addEventListener('input', function() { filterSlash(slashSearch.value); });
|
slashSearch.addEventListener('input', function() { filterSlash(slashSearch.value); });
|
||||||
slashMenu.addEventListener('click', function(e) {
|
slashMenu.addEventListener('click', function(e) {
|
||||||
var item = e.target.closest('.slash-item');
|
var item = e.target.closest('.slash-item');
|
||||||
if (item) selectSlashAction(item.getAttribute('data-action'));
|
if (item) { e.stopPropagation(); selectSlashAction(item.getAttribute('data-action')); }
|
||||||
});
|
});
|
||||||
function navSlash(dir) {
|
function navSlash(dir) {
|
||||||
var vis = getVisibleSlash(); if (vis.length === 0) return;
|
var vis = getVisibleSlash(); if (vis.length === 0) return;
|
||||||
@@ -1452,6 +1472,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
|||||||
document.getElementById('qi-btn').addEventListener('click', function(e) { e.stopPropagation(); toggleQI(); });
|
document.getElementById('qi-btn').addEventListener('click', function(e) { e.stopPropagation(); toggleQI(); });
|
||||||
qiMenu.addEventListener('click', function(e) {
|
qiMenu.addEventListener('click', function(e) {
|
||||||
var item = e.target.closest('.qi-item'); if (!item) return;
|
var item = e.target.closest('.qi-item'); if (!item) return;
|
||||||
|
e.stopPropagation();
|
||||||
var action = item.getAttribute('data-qi');
|
var action = item.getAttribute('data-qi');
|
||||||
qiMenu.classList.remove('on');
|
qiMenu.classList.remove('on');
|
||||||
selectSlashAction(action);
|
selectSlashAction(action);
|
||||||
|
|||||||
+128
-1
@@ -75,6 +75,8 @@
|
|||||||
@media (prefers-reduced-motion: reduce) { .bs-spinner { animation-duration: 1.8s; } }
|
@media (prefers-reduced-motion: reduce) { .bs-spinner { animation-duration: 1.8s; } }
|
||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="/static/style.css" />
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
|
<link rel="stylesheet" href="/static/draco-barre.css" />
|
||||||
|
<link rel="stylesheet" href="/static/draco-panneau.css" />
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"
|
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"
|
||||||
@@ -222,7 +224,7 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="has-draco-bar">
|
||||||
<!-- Boot splash : visible tant que l'app n'a pas booté avec son CSS.
|
<!-- Boot splash : visible tant que l'app n'a pas booté avec son CSS.
|
||||||
Sur desktop, la page embarquée (tauri://) n'a pas /static/style.css →
|
Sur desktop, la page embarquée (tauri://) n'a pas /static/style.css →
|
||||||
sans ce splash, le squelette HTML brut serait visible pendant le
|
sans ce splash, le squelette HTML brut serait visible pendant le
|
||||||
@@ -329,6 +331,128 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Application (wrapped for show/hide) -->
|
<!-- Main Application (wrapped for show/hide) -->
|
||||||
|
<!-- Barre d'apps commune dracodev — Fusion #215 (ACC-1.1).
|
||||||
|
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.4
|
||||||
|
(ui/barre-apps/) : ne pas modifier la structure ici ; toute
|
||||||
|
évolution passe par une nouvelle version de commons.
|
||||||
|
CSS/JS : /static/draco-barre.css, /static/draco-barre.js. -->
|
||||||
|
<header class="draco-bar" data-current="obsigate">
|
||||||
|
<div class="draco-bar-zone draco-bar-gauche">
|
||||||
|
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
|
||||||
|
<a class="draco-brand" href="/" data-act="apps" aria-haspopup="true" aria-expanded="false" aria-label="dracodev — changer d'application" title="Changer d'application">
|
||||||
|
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
|
||||||
|
</a>
|
||||||
|
<nav class="draco-apptabs" aria-label="Applications">
|
||||||
|
<a class="draco-apptab" data-app="obsigate" href="https://og.dracodev.net/">
|
||||||
|
<span class="draco-apptab-ico" style="--app:#7a5af5">▤</span>
|
||||||
|
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
|
||||||
|
</a>
|
||||||
|
<a class="draco-apptab" data-app="flowdeck" href="https://flowdeck.dracodev.net/">
|
||||||
|
<span class="draco-apptab-ico" style="--app:#2f6fed">▦</span>
|
||||||
|
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
|
||||||
|
</a>
|
||||||
|
<a class="draco-apptab" data-app="tiktak" href="https://tiktak.dracodev.net/">
|
||||||
|
<span class="draco-apptab-ico" style="--app:#e8762c">✓</span>
|
||||||
|
<span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span>
|
||||||
|
<span class="draco-badge" data-badge="tiktak" hidden>0</span>
|
||||||
|
</a>
|
||||||
|
<a class="draco-apptab" data-app="kanbante" href="https://kanbante.dracodev.net/">
|
||||||
|
<span class="draco-apptab-ico" style="--app:#0d9488">▥</span>
|
||||||
|
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
|
||||||
|
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="draco-bar-zone draco-bar-centre">
|
||||||
|
<button type="button" class="draco-search" data-act="palette" aria-label="Rechercher partout (Ctrl+K)">
|
||||||
|
<span class="draco-search-ico">⌕</span>
|
||||||
|
<span class="draco-search-txt">Rechercher partout…</span>
|
||||||
|
<kbd>⌘K</kbd>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="draco-bar-zone draco-bar-droite">
|
||||||
|
<div class="draco-domaines" role="group" aria-label="Domaine">
|
||||||
|
<span class="draco-domaines-lbl">Domaine</span>
|
||||||
|
<button type="button" data-act="domaine" data-dom="pro">Pro</button>
|
||||||
|
<button type="button" data-act="domaine" data-dom="famille">Famille</button>
|
||||||
|
<button type="button" data-act="domaine" data-dom="perso">Perso</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="draco-alertes" data-act="alertes" aria-label="Alertes unifiées">
|
||||||
|
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
|
||||||
|
<span class="draco-alertes-txt">Alertes</span>
|
||||||
|
<span class="draco-badge" data-badge="alertes" hidden>0</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="draco-spark" data-act="spark" aria-label="Ouvrir Spark, l'agent commun">
|
||||||
|
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
|
||||||
|
<span class="draco-badge" data-badge="spark" hidden>0</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu">•••</button>
|
||||||
|
<span class="draco-avatar" id="draco-avatar" title="">✦</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
|
||||||
|
<script>
|
||||||
|
/* Configuration ObsiGate de la barre commune (Fusion #215).
|
||||||
|
Phase 1 : navigation entre apps et domaine mémorisé
|
||||||
|
localement ; la recherche focus la recherche native
|
||||||
|
d'ObsiGate ; Alertes unifiées et Spark arrivent avec la
|
||||||
|
fédération / l'agent commun (phases suivantes). */
|
||||||
|
window.DRACO_BAR_CONFIG = {
|
||||||
|
current: "obsigate",
|
||||||
|
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
|
||||||
|
counts: {},
|
||||||
|
onAction: function (act, el) {
|
||||||
|
var dire = function (m) {
|
||||||
|
var d = document.createElement("div");
|
||||||
|
d.textContent = m;
|
||||||
|
d.style.cssText = "position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:20000;background:#161b22;color:#e6edf3;padding:10px 16px;border-radius:10px;font:500 13.5px system-ui;box-shadow:0 6px 24px rgb(0 0 0/.35)";
|
||||||
|
document.body.appendChild(d);
|
||||||
|
setTimeout(function () { d.remove(); }, 3400);
|
||||||
|
};
|
||||||
|
if (act === "domaine") { try { localStorage.setItem("draco_domain", el.dataset.dom); } catch (e) { } return; }
|
||||||
|
if (act === "rail") {
|
||||||
|
// Bureau : afficher/masquer la barre latérale.
|
||||||
|
// Mobile (≤ 768 px, point de bascule d'ObsiGate) :
|
||||||
|
// le vrai hamburger, qui ouvre le tiroir — avant
|
||||||
|
// ce correctif (BUG-118), le rail cliquait le
|
||||||
|
// bouton bureau et ne faisait rien sur téléphone.
|
||||||
|
var mobile = window.matchMedia("(max-width: 768px)").matches;
|
||||||
|
var b = document.getElementById(mobile ? "hamburger-btn" : "sidebar-toggle-btn");
|
||||||
|
if (b) b.click();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (act === "palette") { var s = document.getElementById("search-input"); if (s) { s.focus(); s.scrollIntoView({ block: "center" }); } return; }
|
||||||
|
if (act === "menu") { var m = document.getElementById("header-menu-btn"); if (m) m.click(); return; }
|
||||||
|
if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; }
|
||||||
|
if (act === "spark") {
|
||||||
|
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
|
||||||
|
else { dire("Spark : le panneau n'a pas pu se charger."); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (act === "rappel-entete") {
|
||||||
|
var bar = document.querySelector(".draco-bar");
|
||||||
|
if (bar) bar.style.display = "";
|
||||||
|
el.hidden = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/* Avatar : initiale de l'utilisateur courant (best effort). */
|
||||||
|
window.addEventListener("DOMContentLoaded", function () {
|
||||||
|
var tok = null;
|
||||||
|
try { tok = sessionStorage.getItem("obsigate_access_token"); } catch (e) { }
|
||||||
|
fetch("/api/auth/me", {
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: tok ? { Authorization: "Bearer " + tok } : {}
|
||||||
|
}).then(function (r) { return r.ok ? r.json() : null; })
|
||||||
|
.then(function (u) {
|
||||||
|
var nom = u && (u.display_name || u.username);
|
||||||
|
if (nom) {
|
||||||
|
var av = document.getElementById("draco-avatar");
|
||||||
|
if (av) { av.textContent = nom.charAt(0).toUpperCase(); av.title = nom; }
|
||||||
|
}
|
||||||
|
}).catch(function () { /* avatar générique conservé */ });
|
||||||
|
});
|
||||||
|
</script>
|
||||||
<div id="app" class="app-container">
|
<div id="app" class="app-container">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="header">
|
<header class="header">
|
||||||
@@ -5931,5 +6055,8 @@ curl -X POST https://votre-serveur.com/webhook \
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script defer src="/static/draco-barre.js"></script>
|
||||||
|
<script defer src="/static/draco-liens.js"></script>
|
||||||
|
<script defer src="/static/draco-panneau.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -121,6 +121,25 @@ const AuthManager = {
|
|||||||
if (tokenData.user) {
|
if (tokenData.user) {
|
||||||
sessionStorage.setItem(this.USER_KEY, JSON.stringify(tokenData.user));
|
sessionStorage.setItem(this.USER_KEY, JSON.stringify(tokenData.user));
|
||||||
}
|
}
|
||||||
|
this._honorReturnTo();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Fusion ACC-3.1 (#216) : après connexion chez l'émettrice SSO,
|
||||||
|
// repartir vers l'application demanderesse. Le return_to est
|
||||||
|
// re-validé ici (https + domaine dracodev.net, jamais l'hôte
|
||||||
|
// courant) en plus de la validation serveur de /sso/authorize.
|
||||||
|
_honorReturnTo() {
|
||||||
|
try {
|
||||||
|
const rt = new URLSearchParams(window.location.search).get("return_to");
|
||||||
|
if (!rt) return;
|
||||||
|
const u = new URL(rt);
|
||||||
|
const okHost = u.hostname === "dracodev.net"
|
||||||
|
|| u.hostname.endsWith(".dracodev.net");
|
||||||
|
if (u.protocol === "https:" && okHost
|
||||||
|
&& u.hostname !== window.location.hostname) {
|
||||||
|
window.location.replace(rt);
|
||||||
|
}
|
||||||
|
} catch (e) { /* return_to illisible : on reste sur place */ }
|
||||||
},
|
},
|
||||||
|
|
||||||
getToken() {
|
getToken() {
|
||||||
|
|||||||
Vendored
+7
-2
@@ -239,9 +239,14 @@ async function onDrop(e) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
const isExternalFiles = e.dataTransfer && (
|
// BUG-117: 'items.length > 0' comptait aussi les formats STRING (tree:vault/path/
|
||||||
|
// type) ajoutés par setData au dragstart interne → tout déplacement interne était
|
||||||
|
// classé à tort « upload externe » et avalé silencieusement. Seuls les items de
|
||||||
|
// kind 'file' (ou le type 'Files', ou files non vide) désignent un drop externe,
|
||||||
|
// et un dragState actif (drag interne) prime toujours.
|
||||||
|
const isExternalFiles = !dragState && e.dataTransfer && (
|
||||||
(e.dataTransfer.files && e.dataTransfer.files.length > 0) ||
|
(e.dataTransfer.files && e.dataTransfer.files.length > 0) ||
|
||||||
(e.dataTransfer.items && e.dataTransfer.items.length > 0) ||
|
Array.from(e.dataTransfer.items || []).some((it) => it.kind === 'file') ||
|
||||||
Array.from(e.dataTransfer.types || []).includes('Files')
|
Array.from(e.dataTransfer.types || []).includes('Files')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+77
-3
@@ -964,8 +964,8 @@ const FileOperations = {
|
|||||||
|
|
||||||
if (type === 'file' && state.currentVault === vault && state.currentPath === path) {
|
if (type === 'file' && state.currentVault === vault && state.currentPath === path) {
|
||||||
await openFile(vault, nextPath);
|
await openFile(vault, nextPath);
|
||||||
} else if (type === 'directory' && state.currentVault === vault && state.currentPath && (state.currentPath === path || currentPath.startsWith(`${path}/`))) {
|
} else if (type === 'directory' && state.currentVault === vault && state.currentPath && (state.currentPath === path || state.currentPath.startsWith(`${path}/`))) {
|
||||||
const suffix = state.currentPath === path ? '' : currentPath.slice(path.length);
|
const suffix = state.currentPath === path ? '' : state.currentPath.slice(path.length);
|
||||||
state.currentPath = `${nextPath}${suffix}`;
|
state.currentPath = `${nextPath}${suffix}`;
|
||||||
await focusPathInSidebar(vault, state.currentPath, { alignToTop: false });
|
await focusPathInSidebar(vault, state.currentPath, { alignToTop: false });
|
||||||
}
|
}
|
||||||
@@ -1051,7 +1051,7 @@ const FileOperations = {
|
|||||||
this._closeModal(overlay);
|
this._closeModal(overlay);
|
||||||
await refreshSidebarTreePreservingState();
|
await refreshSidebarTreePreservingState();
|
||||||
|
|
||||||
if (state.currentVault === vault && state.currentPath && currentPath.startsWith(path)) {
|
if (state.currentVault === vault && state.currentPath && state.currentPath.startsWith(path)) {
|
||||||
showWelcome();
|
showWelcome();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1826,9 +1826,13 @@ export const ContextMenuManager = {
|
|||||||
this._addSeparator();
|
this._addSeparator();
|
||||||
this._addItem('brain', '🧠 BooksLM', () => { import('./bookslm.js').then(m => m.default.open(this._targetVault, this._targetPath)); }, false);
|
this._addItem('brain', '🧠 BooksLM', () => { import('./bookslm.js').then(m => m.default.open(this._targetVault, this._targetPath)); }, false);
|
||||||
this._addSeparator();
|
this._addSeparator();
|
||||||
|
this._addItem('move', t('common.move'), () => this._moveItem(), isReadonly);
|
||||||
|
this._addItem('copy', t('common.duplicate'), () => this._duplicateItem(), isReadonly);
|
||||||
this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly);
|
this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly);
|
||||||
this._addItem('trash-2', 'Supprimer', () => this._deleteDirectory(), isReadonly);
|
this._addItem('trash-2', 'Supprimer', () => this._deleteDirectory(), isReadonly);
|
||||||
} else if (type === 'file') {
|
} else if (type === 'file') {
|
||||||
|
this._addItem('move', t('common.move'), () => this._moveItem(), isReadonly);
|
||||||
|
this._addItem('copy', t('common.duplicate'), () => this._duplicateItem(), isReadonly);
|
||||||
this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly);
|
this._addItem('edit', 'Renommer', () => this._renameItem(), isReadonly);
|
||||||
this._addItem('trash-2', 'Supprimer', () => this._deleteFile(), isReadonly);
|
this._addItem('trash-2', 'Supprimer', () => this._deleteFile(), isReadonly);
|
||||||
this._addSeparator();
|
this._addSeparator();
|
||||||
@@ -1918,6 +1922,76 @@ export const ContextMenuManager = {
|
|||||||
FileOperations.confirmDeleteFile(this._targetVault, this._targetPath);
|
FileOperations.confirmDeleteFile(this._targetVault, this._targetPath);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// #BUG-117 — Déplacer / Dupliquer via le modal harmonisé promptTargetFolder
|
||||||
|
// (réutilisé de upload.js, opts pour titre/bouton). Le backend ne supporte
|
||||||
|
// que le même vault ; choix d'un autre vault = toast d'erreur.
|
||||||
|
async _pickDestination(title) {
|
||||||
|
const vault = this._targetVault;
|
||||||
|
const name = this._targetPath.split('/').pop();
|
||||||
|
const parent = this._targetPath.includes('/') ? this._targetPath.slice(0, this._targetPath.lastIndexOf('/')) : '';
|
||||||
|
const { promptTargetFolder } = await import('./upload.js');
|
||||||
|
// Destination pré-sélectionnée = emplacement actuel (sinon '' = racine,
|
||||||
|
// ce qui dupliquait/mouvait à la racine par défaut).
|
||||||
|
const picked = await promptTargetFolder(vault, parent, [], {
|
||||||
|
title: title + ' — ' + name,
|
||||||
|
summaryText: t('ui.move_summary', { name: name }),
|
||||||
|
confirmLabel: title,
|
||||||
|
});
|
||||||
|
if (!picked) return null;
|
||||||
|
if (picked.vault !== vault) {
|
||||||
|
showToast(t('toast.move_cross_vault'), 'error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return picked;
|
||||||
|
},
|
||||||
|
|
||||||
|
async _moveItem() {
|
||||||
|
const vault = this._targetVault;
|
||||||
|
const path = this._targetPath;
|
||||||
|
const type = this._targetType;
|
||||||
|
let picked = null;
|
||||||
|
try {
|
||||||
|
picked = await this._pickDestination(t('common.move'));
|
||||||
|
if (!picked) return;
|
||||||
|
const result = await api(`/api/move/${encodeURIComponent(vault)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ source_path: path, destination_dir: picked.targetDir }),
|
||||||
|
});
|
||||||
|
await refreshSidebarTreePreservingState();
|
||||||
|
const nextPath = result.new_path;
|
||||||
|
if (state.currentVault === vault && state.currentPath && (state.currentPath === path || (type === 'directory' && state.currentPath.startsWith(path + '/')))) {
|
||||||
|
if (type === 'file') {
|
||||||
|
await openFile(vault, nextPath);
|
||||||
|
} else {
|
||||||
|
state.currentPath = nextPath + state.currentPath.slice(path.length);
|
||||||
|
syncActiveFileTreeItem();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showToast(picked.targetDir ? t('toast.moved', { dir: picked.targetDir }) : t('toast.move_root'), 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || t('common.error'), 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async _duplicateItem() {
|
||||||
|
const vault = this._targetVault;
|
||||||
|
const path = this._targetPath;
|
||||||
|
try {
|
||||||
|
const picked = await this._pickDestination(t('common.duplicate'));
|
||||||
|
if (!picked) return;
|
||||||
|
const result = await api(`/api/duplicate/${encodeURIComponent(vault)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ source_path: path, destination_dir: picked.targetDir }),
|
||||||
|
});
|
||||||
|
await refreshSidebarTreePreservingState();
|
||||||
|
showToast(t('toast.duplicated', { name: result.new_path.split('/').pop() }), 'success');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || t('common.error'), 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
_copyPath(path) {
|
_copyPath(path) {
|
||||||
// Try modern clipboard API first, fall back to execCommand for non-secure contexts
|
// Try modern clipboard API first, fall back to execCommand for non-secure contexts
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ export async function handleVaultDropUpload(vault, targetDir, items) {
|
|||||||
* @param {Array} items - Items to upload
|
* @param {Array} items - Items to upload
|
||||||
* @returns {Promise<{ vault: string, targetDir: string }|null>} Selected target or null if cancelled.
|
* @returns {Promise<{ vault: string, targetDir: string }|null>} Selected target or null if cancelled.
|
||||||
*/
|
*/
|
||||||
export async function promptTargetFolder(initialVault, initialDir = '', items = []) {
|
export async function promptTargetFolder(initialVault, initialDir = '', items = [], opts = {}) {
|
||||||
let vaultsList = [];
|
let vaultsList = [];
|
||||||
try {
|
try {
|
||||||
const vRes = await api('/api/vaults');
|
const vRes = await api('/api/vaults');
|
||||||
@@ -316,12 +316,12 @@ export async function promptTargetFolder(initialVault, initialDir = '', items =
|
|||||||
overlay.innerHTML = `
|
overlay.innerHTML = `
|
||||||
<div class="obsigate-modal" style="min-width: 460px; max-width: 560px;">
|
<div class="obsigate-modal" style="min-width: 460px; max-width: 560px;">
|
||||||
<div class="obsigate-modal-header">
|
<div class="obsigate-modal-header">
|
||||||
<span class="obsigate-modal-title">📁 ${escapeHtml(t('upload.choose_target_folder') || "Destination de l'importation")}</span>
|
<span class="obsigate-modal-title">📁 ${escapeHtml(opts.title || t('upload.choose_target_folder') || "Destination de l'importation")}</span>
|
||||||
<button class="modal-close modal-btn" type="button" aria-label="Fermer" style="padding: 4px 8px; line-height: 1;">×</button>
|
<button class="modal-close modal-btn" type="button" aria-label="Fermer" style="padding: 4px 8px; line-height: 1;">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="obsigate-modal-body">
|
<div class="obsigate-modal-body">
|
||||||
<p style="margin: 0 0 16px 0; color: var(--text-muted); font-size: 0.9rem;">
|
<p style="margin: 0 0 16px 0; color: var(--text-muted); font-size: 0.9rem;">
|
||||||
${escapeHtml(t('upload.import_summary', { files: fileCount, dirs: dirCount }) || `Éléments à importer : ${fileCount} fichier(s)${dirCount ? `, ${dirCount} dossier(s)` : ''}`)}
|
${opts.summaryText ? escapeHtml(opts.summaryText) : escapeHtml(t('upload.import_summary', { files: fileCount, dirs: dirCount }) || `Éléments à importer : ${fileCount} fichier(s)${dirCount ? `, ${dirCount} dossier(s)` : ''}`)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="modal-form-group">
|
<div class="modal-form-group">
|
||||||
@@ -352,7 +352,7 @@ export async function promptTargetFolder(initialVault, initialDir = '', items =
|
|||||||
</div>
|
</div>
|
||||||
<div class="obsigate-modal-footer">
|
<div class="obsigate-modal-footer">
|
||||||
<button type="button" class="modal-btn modal-cancel-btn">${escapeHtml(t('button.cancel') || 'Annuler')}</button>
|
<button type="button" class="modal-btn modal-cancel-btn">${escapeHtml(t('button.cancel') || 'Annuler')}</button>
|
||||||
<button type="button" class="modal-btn primary modal-confirm-btn">${escapeHtml(t('upload.import_button') || 'Importer')}</button>
|
<button type="button" class="modal-btn primary modal-confirm-btn">${escapeHtml(opts.confirmLabel || t('upload.import_button') || 'Importer')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -358,6 +358,7 @@
|
|||||||
"common.error": "Error",
|
"common.error": "Error",
|
||||||
"common.loading": "Loading...",
|
"common.loading": "Loading...",
|
||||||
"common.move": "Move",
|
"common.move": "Move",
|
||||||
|
"common.duplicate": "Duplicate",
|
||||||
"common.never": "Never",
|
"common.never": "Never",
|
||||||
"common.next": "Next",
|
"common.next": "Next",
|
||||||
"common.no": "No",
|
"common.no": "No",
|
||||||
@@ -751,6 +752,13 @@
|
|||||||
"upload.custom_folder": "Or enter a new subfolder path...",
|
"upload.custom_folder": "Or enter a new subfolder path...",
|
||||||
"upload.import_summary": "Items to import: {files} file(s), {dirs} folder(s)",
|
"upload.import_summary": "Items to import: {files} file(s), {dirs} folder(s)",
|
||||||
"upload.import_button": "Import",
|
"upload.import_button": "Import",
|
||||||
|
"draco.links.add": "Link",
|
||||||
|
"draco.links.labelPlaceholder": "Label (optional)",
|
||||||
|
"draco.links.openInTikTak": "Open in TikTak",
|
||||||
|
"draco.links.projected": "Projected TikTak task",
|
||||||
|
"draco.links.remove": "Remove link",
|
||||||
|
"draco.links.title": "Links",
|
||||||
|
"draco.links.urlPlaceholder": "Paste a deep link (Flowdeck, TikTak, Gitea, ObsiGate)…",
|
||||||
"editor.cancel": "Cancel",
|
"editor.cancel": "Cancel",
|
||||||
"editor.close": "Close",
|
"editor.close": "Close",
|
||||||
"editor.delete_error": "Delete error",
|
"editor.delete_error": "Delete error",
|
||||||
@@ -1834,6 +1842,11 @@
|
|||||||
"toast.no_open_file": "No file open",
|
"toast.no_open_file": "No file open",
|
||||||
"toast.not_found_tree": "Item not found in tree",
|
"toast.not_found_tree": "Item not found in tree",
|
||||||
"toast.path_copied": "Path copied: {path}",
|
"toast.path_copied": "Path copied: {path}",
|
||||||
|
"ui.move_summary": "Item to move: {name}",
|
||||||
|
"toast.moved": "Moved to: {dir}",
|
||||||
|
"toast.move_root": "Moved to vault root",
|
||||||
|
"toast.duplicated": "Duplicated: {name}",
|
||||||
|
"toast.move_cross_vault": "Cross-vault move/duplicate not supported",
|
||||||
"toast.reindex_done": "Reindexing complete",
|
"toast.reindex_done": "Reindexing complete",
|
||||||
"toast.reindex_started": "Reindexing...",
|
"toast.reindex_started": "Reindexing...",
|
||||||
"toast.rename_error": "Rename error",
|
"toast.rename_error": "Rename error",
|
||||||
|
|||||||
@@ -358,6 +358,7 @@
|
|||||||
"common.error": "Erreur",
|
"common.error": "Erreur",
|
||||||
"common.loading": "Chargement...",
|
"common.loading": "Chargement...",
|
||||||
"common.move": "Déplacer",
|
"common.move": "Déplacer",
|
||||||
|
"common.duplicate": "Dupliquer",
|
||||||
"common.never": "Jamais",
|
"common.never": "Jamais",
|
||||||
"common.next": "Suivant",
|
"common.next": "Suivant",
|
||||||
"common.no": "Non",
|
"common.no": "Non",
|
||||||
@@ -751,6 +752,13 @@
|
|||||||
"upload.custom_folder": "Ou saisir un nouveau sous-dossier...",
|
"upload.custom_folder": "Ou saisir un nouveau sous-dossier...",
|
||||||
"upload.import_summary": "Éléments à importer : {files} fichier(s), {dirs} dossier(s)",
|
"upload.import_summary": "Éléments à importer : {files} fichier(s), {dirs} dossier(s)",
|
||||||
"upload.import_button": "Importer",
|
"upload.import_button": "Importer",
|
||||||
|
"draco.links.add": "Lier",
|
||||||
|
"draco.links.labelPlaceholder": "Libellé (optionnel)",
|
||||||
|
"draco.links.openInTikTak": "Ouvrir dans TikTak",
|
||||||
|
"draco.links.projected": "Tâche TikTak projetée",
|
||||||
|
"draco.links.remove": "Retirer le lien",
|
||||||
|
"draco.links.title": "Liens",
|
||||||
|
"draco.links.urlPlaceholder": "Coller une URL profonde (Flowdeck, TikTak, Gitea, ObsiGate)…",
|
||||||
"editor.cancel": "Annuler",
|
"editor.cancel": "Annuler",
|
||||||
"editor.close": "Fermer",
|
"editor.close": "Fermer",
|
||||||
"editor.delete_error": "Erreur de suppression",
|
"editor.delete_error": "Erreur de suppression",
|
||||||
@@ -1834,6 +1842,11 @@
|
|||||||
"toast.no_open_file": "Aucun fichier ouvert",
|
"toast.no_open_file": "Aucun fichier ouvert",
|
||||||
"toast.not_found_tree": "Élément introuvable dans l'arborescence",
|
"toast.not_found_tree": "Élément introuvable dans l'arborescence",
|
||||||
"toast.path_copied": "Chemin copié : {path}",
|
"toast.path_copied": "Chemin copié : {path}",
|
||||||
|
"ui.move_summary": "Élément à déplacer : {name}",
|
||||||
|
"toast.moved": "Déplacé vers : {dir}",
|
||||||
|
"toast.move_root": "Déplacé vers la racine de la voute",
|
||||||
|
"toast.duplicated": "Dupliqué : {name}",
|
||||||
|
"toast.move_cross_vault": "Déplacement/duplication entre voutes non supporté",
|
||||||
"toast.reindex_done": "Réindexation terminée",
|
"toast.reindex_done": "Réindexation terminée",
|
||||||
"toast.reindex_started": "Réindexation en cours...",
|
"toast.reindex_started": "Réindexation en cours...",
|
||||||
"toast.rename_error": "Erreur lors du renommage",
|
"toast.rename_error": "Erreur lors du renommage",
|
||||||
|
|||||||
@@ -14292,3 +14292,42 @@ textarea.file-chat-input {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* — Barre d'apps commune dracodev + section « Liens » des notes
|
||||||
|
(Fusion #215, snippet figé dracodev-commons v0.1.0) — */
|
||||||
|
body.has-draco-bar .app-container { height: calc(100vh - 60px); }
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
body.has-draco-bar .app-container { height: calc(100vh - 52px); }
|
||||||
|
}
|
||||||
|
.draco-liens {
|
||||||
|
margin: 28px 0 8px; padding: 16px 18px;
|
||||||
|
border: 1px solid var(--border); border-radius: 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
.draco-liens-title { margin: 0 0 10px; font-size: 15px; color: var(--text-primary); }
|
||||||
|
.draco-liens-projete {
|
||||||
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||||
|
margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
.draco-liens-badge { font-weight: 700; color: #e8762c; font-size: 13px; }
|
||||||
|
.draco-liens-ouvrir { text-decoration: none; }
|
||||||
|
.draco-liens-list { list-style: none; margin: 0 0 12px; padding: 0; }
|
||||||
|
.draco-liens-item { display: flex; align-items: center; gap: 4px; padding: 4px 0; }
|
||||||
|
.draco-ref-dot {
|
||||||
|
display: inline-block; width: 9px; height: 9px; border-radius: 50%;
|
||||||
|
margin-right: 7px; flex: none;
|
||||||
|
}
|
||||||
|
.draco-liens-item a { color: var(--text-primary); }
|
||||||
|
.draco-liens-del {
|
||||||
|
margin-left: 8px; border: 0; background: transparent; cursor: pointer;
|
||||||
|
color: var(--text-secondary); font-size: 12px;
|
||||||
|
}
|
||||||
|
.draco-liens-del:hover { color: var(--err); }
|
||||||
|
.draco-liens-form { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.draco-liens-form input {
|
||||||
|
flex: 1 1 200px; min-width: 0; padding: 7px 10px; border-radius: 8px;
|
||||||
|
border: 1px solid var(--border); background: var(--bg-primary);
|
||||||
|
color: var(--text-primary); font: inherit; font-size: 13.5px;
|
||||||
|
}
|
||||||
|
.draco-liens-erreur { color: var(--err); font-size: 13px; margin: 8px 0 0; }
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"name": "obsigate",
|
||||||
"version": "2.68.1",
|
"version": "2.77.0",
|
||||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
||||||
"main": "patch.js",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
"""Émission d'événements vers le journal du service d'agents (#220,
|
||||||
|
Fusion Lot 2 vague 5 — contrat ACC-0.2).
|
||||||
|
|
||||||
|
Un faux récepteur local capture les POST signés HMAC et les vérifie
|
||||||
|
comme le fait le service d'agents ; on prouve aussi l'inactivité sans
|
||||||
|
clé, la non-régression métier quand le récepteur est en panne, et le
|
||||||
|
garde-fou central : le watcher / l'indexation n'émettent JAMAIS
|
||||||
|
(seules les écritures explicites via l'API ou les outils le font).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
CLE = "cle-obsigate-de-test"
|
||||||
|
|
||||||
|
|
||||||
|
class _Recepteur(BaseHTTPRequestHandler):
|
||||||
|
recus = []
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
n = int(self.headers.get("Content-Length") or 0)
|
||||||
|
corps = self.rfile.read(n)
|
||||||
|
type(self).recus.append(
|
||||||
|
{"headers": {k.lower(): v for k, v in self.headers.items()},
|
||||||
|
"corps": corps})
|
||||||
|
self.send_response(201)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, *a):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _demarrer(monkeypatch):
|
||||||
|
_Recepteur.recus = []
|
||||||
|
serveur = HTTPServer(("127.0.0.1", 0), _Recepteur)
|
||||||
|
port = serveur.server_address[1]
|
||||||
|
threading.Thread(target=serveur.serve_forever, daemon=True).start()
|
||||||
|
monkeypatch.setenv("AGENT_EVENEMENTS_URL",
|
||||||
|
f"http://127.0.0.1:{port}/api/v1/evenements")
|
||||||
|
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
|
||||||
|
return serveur
|
||||||
|
|
||||||
|
|
||||||
|
def _attendre(types, delai=6.0):
|
||||||
|
"""Attend que chaque type demandé soit arrivé ; retourne
|
||||||
|
{type: enveloppe} — la signature HMAC de CHAQUE envoi est
|
||||||
|
revérifiée ici, comme le fait le service."""
|
||||||
|
vus = {}
|
||||||
|
fin = time.time() + delai
|
||||||
|
while time.time() < fin and not all(t in vus for t in types):
|
||||||
|
for recu in list(_Recepteur.recus):
|
||||||
|
env = json.loads(recu["corps"].decode("utf-8"))
|
||||||
|
sig = recu["headers"].get("x-draco-signature", "")
|
||||||
|
attendu = hmac.new(CLE.encode(), recu["corps"],
|
||||||
|
hashlib.sha256).hexdigest()
|
||||||
|
assert hmac.compare_digest(sig.lower(), attendu), \
|
||||||
|
"signature HMAC invalide"
|
||||||
|
vus.setdefault(env["type"], env)
|
||||||
|
if not all(t in vus for t in types):
|
||||||
|
time.sleep(0.05)
|
||||||
|
manquants = [t for t in types if t not in vus]
|
||||||
|
assert not manquants, f"événements jamais reçus : {manquants}"
|
||||||
|
return vus
|
||||||
|
|
||||||
|
|
||||||
|
# ── Enveloppe ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_enveloppe_et_signature(monkeypatch):
|
||||||
|
from backend import agent_journal
|
||||||
|
|
||||||
|
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
|
||||||
|
env, corps = agent_journal.construire_enveloppe(
|
||||||
|
agent_journal.TYPE_CREE, "TestVault", "Journal/nouvelle.md")
|
||||||
|
assert env["source_app"] == "obsigate"
|
||||||
|
assert env["domain"] == "pro" # repli : aucun domaine modélisé (Q-A)
|
||||||
|
assert env["actor"] == "user"
|
||||||
|
assert env["object"] == {
|
||||||
|
"ref": "fichier:TestVault/Journal/nouvelle.md",
|
||||||
|
"label": "nouvelle.md",
|
||||||
|
"url": "/#file=TestVault:Journal/nouvelle.md",
|
||||||
|
}
|
||||||
|
assert json.loads(corps.decode("utf-8")) == env
|
||||||
|
assert env["id"] and env["occurred_at"]
|
||||||
|
sig = hmac.new(CLE.encode(), corps, hashlib.sha256).hexdigest()
|
||||||
|
assert len(sig) == 64
|
||||||
|
|
||||||
|
|
||||||
|
def test_domaine_par_vault(monkeypatch):
|
||||||
|
from backend import agent_journal
|
||||||
|
|
||||||
|
monkeypatch.setenv("AGENT_EVENEMENTS_DOMAINES",
|
||||||
|
json.dumps({"TestVault": "famille"}))
|
||||||
|
assert agent_journal.domaine_du_vault("TestVault") == "famille"
|
||||||
|
assert agent_journal.domaine_du_vault("AutreVault") == "pro"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Écritures REST (acteur « user ») ─────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_cycle_de_vie_rest(client, monkeypatch):
|
||||||
|
serveur = _demarrer(monkeypatch)
|
||||||
|
try:
|
||||||
|
r = client.post("/api/file/TestVault", json={
|
||||||
|
"path": "Journal/nouvelle.md", "content": "# Nouvelle\n"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
r = client.put("/api/file/TestVault/save",
|
||||||
|
params={"path": "Journal/nouvelle.md"},
|
||||||
|
json={"content": "# Nouvelle v2\n"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
r = client.patch("/api/file/TestVault", json={
|
||||||
|
"path": "Journal/nouvelle.md", "new_name": "renommee.md"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
r = client.post("/api/move/TestVault", json={
|
||||||
|
"source_path": "Journal/renommee.md", "destination_dir": "Projets"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
r = client.delete("/api/file/TestVault",
|
||||||
|
params={"path": "Projets/renommee.md"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
vus = _attendre([
|
||||||
|
"obsigate.note.created", "obsigate.note.edited",
|
||||||
|
"obsigate.note.moved", "obsigate.note.deleted"])
|
||||||
|
|
||||||
|
env = vus["obsigate.note.created"]
|
||||||
|
assert env["actor"] == "user"
|
||||||
|
assert env["object"]["ref"] == "fichier:TestVault/Journal/nouvelle.md"
|
||||||
|
assert env["object"]["label"] == "nouvelle.md"
|
||||||
|
assert env["object"]["url"] == "/#file=TestVault:Journal/nouvelle.md"
|
||||||
|
|
||||||
|
env = vus["obsigate.note.edited"]
|
||||||
|
assert env["object"]["ref"] == "fichier:TestVault/Journal/nouvelle.md"
|
||||||
|
|
||||||
|
# moved : émis deux fois (renommage puis déplacement). Les
|
||||||
|
# envois partent en fils d'arrière-plan — l'ordre d'arrivée
|
||||||
|
# n'est pas garanti, on compare l'ensemble des références.
|
||||||
|
refs_deplacees = {
|
||||||
|
json.loads(recu["corps"].decode("utf-8"))["object"]["ref"]
|
||||||
|
for recu in _Recepteur.recus
|
||||||
|
if json.loads(recu["corps"].decode("utf-8"))["type"]
|
||||||
|
== "obsigate.note.moved"}
|
||||||
|
assert refs_deplacees == {
|
||||||
|
"fichier:TestVault/Journal/renommee.md",
|
||||||
|
"fichier:TestVault/Projets/renommee.md"}
|
||||||
|
|
||||||
|
env = vus["obsigate.note.deleted"]
|
||||||
|
assert env["object"]["ref"] == "fichier:TestVault/Projets/renommee.md"
|
||||||
|
finally:
|
||||||
|
serveur.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Écritures par les outils / MCP (acteur « agent:mcp ») ───────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def vault_outils(tmp_path, monkeypatch):
|
||||||
|
"""Vault minimal pour la couche outils (patron test_document_tools)."""
|
||||||
|
import backend.indexer
|
||||||
|
|
||||||
|
vault_dir = tmp_path / "Vault"
|
||||||
|
vault_dir.mkdir()
|
||||||
|
monkeypatch.setitem(
|
||||||
|
backend.indexer.index, "Vault",
|
||||||
|
{"name": "Vault", "path": str(vault_dir), "config": {}})
|
||||||
|
return vault_dir
|
||||||
|
|
||||||
|
|
||||||
|
def test_outils_mcp_emettent_agent_mcp(vault_outils, monkeypatch):
|
||||||
|
from backend.tools.api import ToolContext, ToolMode, call_tool
|
||||||
|
|
||||||
|
serveur = _demarrer(monkeypatch)
|
||||||
|
try:
|
||||||
|
ctx = ToolContext(
|
||||||
|
user={"username": "tester", "role": "admin", "vaults": ["*"]},
|
||||||
|
mode=ToolMode.MCP, audit_enabled=False)
|
||||||
|
|
||||||
|
out = call_tool("create_file", ctx, {
|
||||||
|
"vault": "Vault", "path": "par-mcp.md", "content": "# MCP\n",
|
||||||
|
}, confirm=True)
|
||||||
|
assert out.ok
|
||||||
|
|
||||||
|
out = call_tool("edit_file", ctx, {
|
||||||
|
"vault": "Vault", "path": "par-mcp.md", "content": "# MCP v2\n",
|
||||||
|
}, confirm=True)
|
||||||
|
assert out.ok
|
||||||
|
|
||||||
|
out = call_tool("rename_file", ctx, {
|
||||||
|
"vault": "Vault", "path": "par-mcp.md", "new_name": "renomme-mcp.md",
|
||||||
|
}, confirm=True)
|
||||||
|
assert out.ok
|
||||||
|
|
||||||
|
out = call_tool("delete_file", ctx, {
|
||||||
|
"vault": "Vault", "path": "renomme-mcp.md",
|
||||||
|
}, confirm=True)
|
||||||
|
assert out.ok
|
||||||
|
|
||||||
|
vus = _attendre([
|
||||||
|
"obsigate.note.created", "obsigate.note.edited",
|
||||||
|
"obsigate.note.moved", "obsigate.note.deleted"])
|
||||||
|
for env in vus.values():
|
||||||
|
assert env["actor"] == "agent:mcp"
|
||||||
|
assert vus["obsigate.note.created"]["object"]["ref"] == \
|
||||||
|
"fichier:Vault/par-mcp.md"
|
||||||
|
assert vus["obsigate.note.moved"]["object"]["ref"] == \
|
||||||
|
"fichier:Vault/renomme-mcp.md"
|
||||||
|
finally:
|
||||||
|
serveur.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Inactivité sans clé ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_inactif_sans_cle(client, monkeypatch):
|
||||||
|
serveur = _demarrer(monkeypatch)
|
||||||
|
monkeypatch.delenv("AGENT_EVENEMENTS_CLE")
|
||||||
|
try:
|
||||||
|
from backend import agent_journal
|
||||||
|
|
||||||
|
assert agent_journal.actif() is False
|
||||||
|
assert agent_journal.emettre(
|
||||||
|
agent_journal.TYPE_CREE, "TestVault", "x.md") is False
|
||||||
|
r = client.post("/api/file/TestVault", json={
|
||||||
|
"path": "sans-journal.md", "content": "# Rien\n"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
time.sleep(0.6)
|
||||||
|
assert _Recepteur.recus == []
|
||||||
|
finally:
|
||||||
|
serveur.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Garde-fou : watcher et indexation n'émettent jamais ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_watcher_et_indexation_sans_evenement(client, test_vault_dir,
|
||||||
|
monkeypatch):
|
||||||
|
"""Une écriture disque externe (éditeur, synchro de fichiers…)
|
||||||
|
traitée par le watcher puis l'indexeur ne produit AUCUN
|
||||||
|
événement : seuls les appels explicites à l'API émettent."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
appels = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"backend.agent_journal.emettre",
|
||||||
|
lambda *a, **k: appels.append((a, k)) or False)
|
||||||
|
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
|
||||||
|
|
||||||
|
cible = Path(test_vault_dir) / "externe.md"
|
||||||
|
cible.write_text("# Écrit hors API\n", encoding="utf-8")
|
||||||
|
|
||||||
|
# Chemin watcher : les gestionnaires d'événements filesystem.
|
||||||
|
from backend.watcher import VaultEventHandler
|
||||||
|
|
||||||
|
boucle = asyncio.new_event_loop()
|
||||||
|
try:
|
||||||
|
file_attente: asyncio.Queue = asyncio.Queue()
|
||||||
|
handler = VaultEventHandler("TestVault", file_attente, boucle)
|
||||||
|
evt = SimpleNamespace(is_directory=False, src_path=str(cible),
|
||||||
|
dest_path=None)
|
||||||
|
handler.on_created(evt)
|
||||||
|
handler.on_modified(evt)
|
||||||
|
|
||||||
|
# Chemin indexation : la même fonction que le watcher appelle
|
||||||
|
# après debounce pour intégrer le fichier à l'index.
|
||||||
|
from backend.indexer import update_single_file
|
||||||
|
|
||||||
|
boucle.run_until_complete(update_single_file("TestVault", "externe.md"))
|
||||||
|
finally:
|
||||||
|
boucle.close()
|
||||||
|
|
||||||
|
assert appels == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Panne du récepteur : le métier n'est jamais affecté ─────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_recepteur_en_panne_ne_casse_rien(client, monkeypatch):
|
||||||
|
# Port fermé : connexion refusée dans le fil d'arrière-plan.
|
||||||
|
monkeypatch.setenv("AGENT_EVENEMENTS_URL",
|
||||||
|
"http://127.0.0.1:59999/api/v1/evenements")
|
||||||
|
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
|
||||||
|
r = client.post("/api/file/TestVault", json={
|
||||||
|
"path": "malgre-panne.md", "content": "# OK\n"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = client.put("/api/file/TestVault/save",
|
||||||
|
params={"path": "malgre-panne.md"},
|
||||||
|
json={"content": "# OK v2\n"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = client.get("/api/file/TestVault", params={"path": "malgre-panne.md"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert "OK v2" in r.json()["html"] # le GET renvoie le Markdown rendu
|
||||||
@@ -340,6 +340,42 @@ class TestDirectoryCRUD:
|
|||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["success"] is True
|
assert data["success"] is True
|
||||||
|
|
||||||
|
def test_delete_directory_subtree_clears_index(self, client):
|
||||||
|
"""BUG-113 : suppression incrémentale — aucun fantôme dans paths ni
|
||||||
|
dans l'index inversé, sans re-scan complet de la vault."""
|
||||||
|
# Create files inside the directory
|
||||||
|
for name in ("alpha.md", "nested/beta.md"):
|
||||||
|
r = client.post("/api/file/TestVault", json={
|
||||||
|
"path": f"Projets/{name}",
|
||||||
|
"content": f"contenu unique {name}",
|
||||||
|
})
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
# Indexed before deletion
|
||||||
|
paths_before = client.get("/api/vault/TestVault/paths").json()["results"]
|
||||||
|
assert any(p["path"] == "Projets/alpha.md" for p in paths_before)
|
||||||
|
hit = client.get("/api/search", params={"q": "alpha", "vault": "TestVault"}).json()
|
||||||
|
results = hit.get("results", hit if isinstance(hit, list) else [])
|
||||||
|
assert any(r["path"] == "Projets/alpha.md" for r in results)
|
||||||
|
|
||||||
|
# Delete the whole directory
|
||||||
|
resp = client.delete("/api/directory/TestVault", params={"path": "Projets"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["deleted_count"] >= 2
|
||||||
|
|
||||||
|
# No ghost in flat paths
|
||||||
|
paths_after = client.get("/api/vault/TestVault/paths").json()["results"]
|
||||||
|
ghost_dirs = [p["path"] for p in paths_after if p["path"].startswith("Projets")]
|
||||||
|
assert ghost_dirs == [], f"fantômes dans paths : {ghost_dirs}"
|
||||||
|
|
||||||
|
# No ghost in inverted index (full-text search)
|
||||||
|
hit = client.get("/api/search", params={"q": "alpha", "vault": "TestVault"}).json()
|
||||||
|
results = hit.get("results", hit if isinstance(hit, list) else [])
|
||||||
|
assert all(r["path"] != "Projets/alpha.md" for r in results)
|
||||||
|
assert all(r["path"] != "Projets/nested/beta.md" for r in results)
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════
|
||||||
# Search & Tags (supplement existing tests)
|
# Search & Tags (supplement existing tests)
|
||||||
@@ -967,3 +1003,37 @@ class TestStaticCaching:
|
|||||||
resp = client.get("/static/style.css")
|
resp = client.get("/static/style.css")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "no-cache" in resp.headers.get("cache-control", "")
|
assert "no-cache" in resp.headers.get("cache-control", "")
|
||||||
|
|
||||||
|
|
||||||
|
class TestDuplicateApi:
|
||||||
|
"""BUG-117 : endpoint POST /api/duplicate — copie fichiers/répertoires."""
|
||||||
|
|
||||||
|
def test_duplicate_file_and_collision(self, client):
|
||||||
|
r = client.post("/api/duplicate/TestVault", json={"source_path": "note1.md", "destination_dir": ""})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["new_path"] == "note1 copie.md"
|
||||||
|
# collision -> copie 2
|
||||||
|
r2 = client.post("/api/duplicate/TestVault", json={"source_path": "note1.md", "destination_dir": ""})
|
||||||
|
assert r2.json()["new_path"] == "note1 copie 2.md"
|
||||||
|
orig = client.get("/api/file/TestVault", params={"path": "note1.md"}).json()["html"]
|
||||||
|
copy = client.get("/api/file/TestVault", params={"path": "note1 copie.md"}).json()["html"]
|
||||||
|
assert copy == orig
|
||||||
|
|
||||||
|
def test_duplicate_directory_into_target_and_index(self, client):
|
||||||
|
assert client.post("/api/directory/TestVault", json={"path": "notes/sub"}).status_code == 200
|
||||||
|
assert client.post("/api/file/TestVault", json={"path": "notes/a.md", "content": "secret note A"}).status_code == 200
|
||||||
|
r = client.post("/api/duplicate/TestVault", json={"source_path": "notes", "destination_dir": "Projets"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
new = r.json()["new_path"]
|
||||||
|
assert new == "Projets/notes copie"
|
||||||
|
paths = [p["path"] for p in client.get("/api/vault/TestVault/paths").json()["results"]]
|
||||||
|
assert any(p.startswith(new + "/") for p in paths), "copy not indexed"
|
||||||
|
|
||||||
|
def test_duplicate_directory_inside_itself_rejected(self, client):
|
||||||
|
assert client.post("/api/directory/TestVault", json={"path": "notes"}).status_code == 200
|
||||||
|
r = client.post("/api/duplicate/TestVault", json={"source_path": "notes", "destination_dir": "notes"})
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
def test_duplicate_not_found(self, client):
|
||||||
|
r = client.post("/api/duplicate/TestVault", json={"source_path": "nonexistent.md", "destination_dir": ""})
|
||||||
|
assert r.status_code == 404
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""BUG-118 — barre d'apps commune en mobile (Fusion ACC-1, #215).
|
||||||
|
|
||||||
|
Retours de Bruno (2026-10-21) sur téléphone : la barre débordait
|
||||||
|
(pastilles domaines et onglets entiers affichés) et son bouton ☰
|
||||||
|
ne faisait rien — il cliquait le bouton bureau
|
||||||
|
``sidebar-toggle-btn`` au lieu du vrai hamburger mobile
|
||||||
|
``hamburger-btn``. Correctifs verrouillés ici : CSS commune v0.1.2
|
||||||
|
(palier 700 px resserré selon la spec) et rail contextuel dans
|
||||||
|
index.html (mobile ≤ 768 px → hamburger-btn).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
FRONTEND = Path(__file__).resolve().parent.parent / "frontend"
|
||||||
|
|
||||||
|
|
||||||
|
def _css() -> str:
|
||||||
|
return (FRONTEND / "draco-barre.css").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _index() -> str:
|
||||||
|
return (FRONTEND / "index.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_palier_mobile_resserre_v012():
|
||||||
|
css = _css()
|
||||||
|
assert "@media (max-width: 900px)" in css
|
||||||
|
# Seul l'onglet de l'app courante reste visible en mobile.
|
||||||
|
assert '.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"]' in css
|
||||||
|
# Domaines et boutons icônes (rail, menu) masqués en mobile.
|
||||||
|
bloc = css.split("@media (max-width: 900px)", 1)[1]
|
||||||
|
assert ".draco-domaines" in bloc
|
||||||
|
assert "display: none" in bloc
|
||||||
|
|
||||||
|
|
||||||
|
def test_menu_des_applications_par_la_marque():
|
||||||
|
# v0.1.3 : la marque ✦ ouvre le menu des 4 applications
|
||||||
|
# (changement d'app en mobile), logo à 24 px.
|
||||||
|
html = _index()
|
||||||
|
assert 'data-act="apps"' in html
|
||||||
|
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
|
||||||
|
assert "draco-apps-menu" in js
|
||||||
|
assert "font-size: 24px" in _css()
|
||||||
|
|
||||||
|
|
||||||
|
def test_panneau_spark_integre_218():
|
||||||
|
# #218 : le bouton Spark de la barre ouvre le panneau de l'agent
|
||||||
|
# commun (mêmes assets partagés que TikTak / Flowdeck / Kanbante).
|
||||||
|
html = _index()
|
||||||
|
assert (FRONTEND / "draco-panneau.js").is_file()
|
||||||
|
assert (FRONTEND / "draco-panneau.css").is_file()
|
||||||
|
assert '<link rel="stylesheet" href="/static/draco-panneau.css" />' in html
|
||||||
|
assert '<script defer src="/static/draco-panneau.js"></script>' in html
|
||||||
|
# L'action spark bascule sur le panneau — plus le message d'attente.
|
||||||
|
assert "window.DRACO_PANNEAU.toggle()" in html
|
||||||
|
assert "arrive en phase 4 du programme Fusion" not in html
|
||||||
|
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
|
||||||
|
assert "https://agent.dracodev.net" in panneau
|
||||||
|
|
||||||
|
|
||||||
|
def test_panneau_bouton_console_221():
|
||||||
|
# #221 : le panneau Spark (asset vendangé, octet-identique dans
|
||||||
|
# les 4 apps) porte un bouton « Console » qui mène à la console
|
||||||
|
# web du service, sur la discussion courante (?disc=) et avec
|
||||||
|
# l'app d'origine (?app= déduite du sous-domaine : og → obsigate).
|
||||||
|
# Révisé par #222 : l'ouverture en nouvel onglet a disparu (la
|
||||||
|
# console s'ouvre en surimpression, voir le test #222) ; le lien
|
||||||
|
# profond, lui, subsiste via urlConsole().
|
||||||
|
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
|
||||||
|
assert 'data-disc="console"' in panneau
|
||||||
|
assert "ouvrirConsole" in panneau
|
||||||
|
assert "urlConsole" in panneau
|
||||||
|
assert '"disc=" + encodeURIComponent(id)' in panneau
|
||||||
|
assert '"app=" + encodeURIComponent(app)' in panneau
|
||||||
|
assert 'og: "obsigate"' in panneau
|
||||||
|
assert 'window.open(' not in panneau
|
||||||
|
|
||||||
|
|
||||||
|
def test_panneau_console_surimpression_222():
|
||||||
|
# #222 : le bouton « Console ⤢ » ouvre la console dans la même
|
||||||
|
# page, en surimpression née à la largeur du panneau et étendue
|
||||||
|
# à toute la fenêtre (transition sur la largeur), avec deux
|
||||||
|
# sorties : « Mode sidebar » et « Fermer l'agent ». L'URL de
|
||||||
|
# l'iframe garde le lien profond de #221 (urlConsole()).
|
||||||
|
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
|
||||||
|
assert "Console ⤢" in panneau
|
||||||
|
assert "draco-console-sur" in panneau
|
||||||
|
assert "assurerSurimpression" in panneau
|
||||||
|
assert "draco-console-styles" in panneau
|
||||||
|
assert 'surCadre.src = urlConsole()' in panneau
|
||||||
|
assert 'surEl.style.width = "100%"' in panneau
|
||||||
|
assert 'data-dc="sidebar"' in panneau
|
||||||
|
assert "Mode sidebar" in panneau
|
||||||
|
assert "modeSidebarConsole" in panneau
|
||||||
|
assert 'data-dc="fermer"' in panneau
|
||||||
|
assert "Fermer l'agent" in panneau
|
||||||
|
assert "fermerAgentConsole" in panneau
|
||||||
|
|
||||||
|
|
||||||
|
def test_csp_ouvre_le_service_d_agents_218():
|
||||||
|
# Le panneau parle au service d'agents : la CSP doit nommer son
|
||||||
|
# hôte en connect-src, sans ouvrir de générique supplémentaire.
|
||||||
|
main = (Path(__file__).resolve().parent.parent / "backend" / "main.py").read_text(encoding="utf-8")
|
||||||
|
ligne = next(l for l in main.splitlines() if "connect-src" in l)
|
||||||
|
assert "https://agent.dracodev.net" in ligne
|
||||||
|
|
||||||
|
|
||||||
|
def test_rail_mobile_vers_le_vrai_hamburger():
|
||||||
|
html = _index()
|
||||||
|
assert 'matchMedia("(max-width: 768px)")' in html
|
||||||
|
assert '"hamburger-btn"' in html
|
||||||
|
assert '"sidebar-toggle-btn"' in html
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# tests/test_draco_session.py
|
||||||
|
# Fusion ACC-3.1 (#216) — émettrice SSO draco_session : émission à la
|
||||||
|
# connexion, vérification (signature, expiration, révocation, rotation),
|
||||||
|
# renouvellement glissant, return_to en liste blanche, révocations.
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from jose import jwt as jose_jwt
|
||||||
|
|
||||||
|
from backend import draco_session
|
||||||
|
|
||||||
|
USER = {"id": "admin-1", "username": "admin", "display_name": "Admin"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _draco_env(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("DRACO_SESSION_SECRET", "secret-de-test")
|
||||||
|
monkeypatch.setattr(draco_session, "REVOKED_FILE",
|
||||||
|
tmp_path / "draco_revoked.json")
|
||||||
|
draco_session._revoked = None
|
||||||
|
yield
|
||||||
|
draco_session._revoked = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Jeton : émission / vérification ─────────────────────────────────────
|
||||||
|
|
||||||
|
def test_roundtrip_claims_minimaux():
|
||||||
|
token, jti, exp = draco_session.issue_token(USER)
|
||||||
|
claims = draco_session.verify_token(token)
|
||||||
|
assert claims is not None
|
||||||
|
assert claims["sub"] == "admin-1"
|
||||||
|
assert claims["name"] == "Admin"
|
||||||
|
assert claims["jti"] == jti
|
||||||
|
assert exp - claims["iat"] == 12 * 3600 # durée de vie du design §5.2
|
||||||
|
assert "role" not in claims # aucun rôle dans le jeton
|
||||||
|
|
||||||
|
|
||||||
|
def test_jeton_falsifie_rejete():
|
||||||
|
token, _j, _e = draco_session.issue_token(USER)
|
||||||
|
assert draco_session.verify_token(token + "x") is None
|
||||||
|
autre = jose_jwt.encode({"sub": "admin-1"}, "mauvais-secret",
|
||||||
|
algorithm="HS256")
|
||||||
|
assert draco_session.verify_token(autre) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_jeton_expire_rejete():
|
||||||
|
now = int(time.time())
|
||||||
|
token = jose_jwt.encode(
|
||||||
|
{"sub": "admin-1", "name": "Admin", "iat": now - 7200,
|
||||||
|
"exp": now - 3600, "jti": "ancien"},
|
||||||
|
"secret-de-test", algorithm="HS256")
|
||||||
|
assert draco_session.verify_token(token) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotation_secret_precedent_accepte(monkeypatch):
|
||||||
|
token = jose_jwt.encode(
|
||||||
|
{"sub": "admin-1", "name": "Admin", "iat": int(time.time()),
|
||||||
|
"exp": int(time.time()) + 3600, "jti": "rotation"},
|
||||||
|
"ancien-secret", algorithm="HS256")
|
||||||
|
assert draco_session.verify_token(token) is None # pas encore configuré
|
||||||
|
monkeypatch.setenv("DRACO_SESSION_SECRET_PREV", "ancien-secret")
|
||||||
|
assert draco_session.verify_token(token)["jti"] == "rotation"
|
||||||
|
|
||||||
|
|
||||||
|
def test_revocation():
|
||||||
|
token, jti, exp = draco_session.issue_token(USER)
|
||||||
|
assert draco_session.verify_token(token) is not None
|
||||||
|
draco_session.revoke(jti, exp)
|
||||||
|
assert draco_session.verify_token(token) is None
|
||||||
|
assert {"jti": jti, "exp": exp} in draco_session.revoked_list()
|
||||||
|
|
||||||
|
|
||||||
|
def test_inerte_sans_secret(monkeypatch):
|
||||||
|
monkeypatch.delenv("DRACO_SESSION_SECRET")
|
||||||
|
assert draco_session.enabled() is False
|
||||||
|
assert draco_session.verify_token("peu-importe") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_fenetre_de_renouvellement():
|
||||||
|
assert draco_session.needs_renewal({"exp": int(time.time()) + 1800})
|
||||||
|
assert not draco_session.needs_renewal({"exp": int(time.time()) + 12 * 3600})
|
||||||
|
|
||||||
|
|
||||||
|
# ── return_to : liste blanche ────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("url,ok", [
|
||||||
|
("https://tiktak.dracodev.net/?task=3", True),
|
||||||
|
("https://og.dracodev.net/", True),
|
||||||
|
("https://dracodev.net/x", True),
|
||||||
|
("http://tiktak.dracodev.net/", False), # https seulement
|
||||||
|
("https://evil.com/", False),
|
||||||
|
("https://tiktak.dracodev.net.evil.com/", False),
|
||||||
|
("https://notdracodev.net/", False),
|
||||||
|
("", False),
|
||||||
|
])
|
||||||
|
def test_validate_return_to(url, ok):
|
||||||
|
assert (draco_session.validate_return_to(url) is not None) == ok
|
||||||
|
|
||||||
|
|
||||||
|
# ── Intégration : connexion / déconnexion / renouvellement ──────────────
|
||||||
|
|
||||||
|
def _login(client):
|
||||||
|
r = client.post("/api/auth/login",
|
||||||
|
json={"username": "admin", "password": "chab30"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def _draco_cookie(response):
|
||||||
|
for raw in response.headers.get_list("set-cookie"):
|
||||||
|
if raw.startswith("draco_session="):
|
||||||
|
return raw
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_pose_le_cookie(admin_client):
|
||||||
|
r = _login(admin_client)
|
||||||
|
cookie = _draco_cookie(r)
|
||||||
|
assert cookie is not None
|
||||||
|
assert "Domain=.dracodev.net" in cookie
|
||||||
|
assert "HttpOnly" in cookie and "SameSite=lax" in cookie
|
||||||
|
token = cookie.split(";")[0].split("=", 1)[1]
|
||||||
|
assert draco_session.verify_token(token)["sub"] == "admin-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_sans_secret_aucun_cookie(admin_client, monkeypatch):
|
||||||
|
monkeypatch.delenv("DRACO_SESSION_SECRET")
|
||||||
|
r = _login(admin_client)
|
||||||
|
assert _draco_cookie(r) is None
|
||||||
|
|
||||||
|
|
||||||
|
def _token_depuis_login(client):
|
||||||
|
# Le jar du TestClient refuse le cookie Domain=.dracodev.net (hôte
|
||||||
|
# de test différent) : on lit le jeton dans l'en-tête set-cookie.
|
||||||
|
cookie = _draco_cookie(_login(client))
|
||||||
|
assert cookie is not None
|
||||||
|
return cookie.split(";")[0].split("=", 1)[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_revoque_le_jti(admin_client):
|
||||||
|
token = _token_depuis_login(admin_client)
|
||||||
|
assert draco_session.verify_token(token)
|
||||||
|
r = admin_client.post("/api/auth/logout",
|
||||||
|
headers={"Cookie": f"draco_session={token}"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert draco_session.verify_token(token) is None # révoqué
|
||||||
|
|
||||||
|
|
||||||
|
def test_renouvellement_glissant(admin_client):
|
||||||
|
_login(admin_client)
|
||||||
|
# Cookie draco presque expiré (dans la fenêtre des 2 h) :
|
||||||
|
now = int(time.time())
|
||||||
|
vieux = jose_jwt.encode(
|
||||||
|
{"sub": "admin-1", "name": "Admin", "iat": now - 11 * 3600,
|
||||||
|
"exp": now + 600, "jti": "vieux-jti"},
|
||||||
|
"secret-de-test", algorithm="HS256")
|
||||||
|
admin_client.cookies.set("draco_session", vieux)
|
||||||
|
r = admin_client.get("/api/auth/me")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
cookie = _draco_cookie(r)
|
||||||
|
assert cookie is not None # réémis
|
||||||
|
nouveau = cookie.split(";")[0].split("=", 1)[1]
|
||||||
|
assert draco_session.verify_token(nouveau)["jti"] != "vieux-jti"
|
||||||
|
|
||||||
|
|
||||||
|
def test_pas_de_renouvellement_si_frais(admin_client):
|
||||||
|
token = _token_depuis_login(admin_client)
|
||||||
|
admin_client.cookies.set("draco_session", token) # cookie frais porté
|
||||||
|
r = admin_client.get("/api/auth/me")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert _draco_cookie(r) is None # cookie frais : rien
|
||||||
|
|
||||||
|
|
||||||
|
def test_sso_authorize_liste_blanche(client):
|
||||||
|
r = client.get("/sso/authorize",
|
||||||
|
params={"return_to": "https://tiktak.dracodev.net/?task=3"},
|
||||||
|
follow_redirects=False)
|
||||||
|
assert r.status_code == 302
|
||||||
|
assert "return_to=" in r.headers["location"]
|
||||||
|
r = client.get("/sso/authorize",
|
||||||
|
params={"return_to": "https://evil.com/phish"},
|
||||||
|
follow_redirects=False)
|
||||||
|
assert r.status_code == 302
|
||||||
|
assert r.headers["location"] == "/" # refusé, pas de relais
|
||||||
|
|
||||||
|
|
||||||
|
# ── Validation locale (ACC-3.2, #9) ──────────────────────────────────────
|
||||||
|
|
||||||
|
def test_me_avec_cookie_draco_seul(admin_client):
|
||||||
|
# Aucune session native : le cookie partagé suffit (sub = id).
|
||||||
|
token, _jti, _exp = draco_session.issue_token(USER)
|
||||||
|
admin_client.cookies.set("draco_session", token)
|
||||||
|
r = admin_client.get("/api/auth/me")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["username"] == "admin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_draco_revoque_refuse(admin_client):
|
||||||
|
token, jti, exp = draco_session.issue_token(USER)
|
||||||
|
draco_session.revoke(jti, exp)
|
||||||
|
admin_client.cookies.set("draco_session", token)
|
||||||
|
r = admin_client.get("/api/auth/me")
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_draco_sub_inconnu_refuse(admin_client):
|
||||||
|
token, _j, _e = draco_session.issue_token(
|
||||||
|
{"id": "personne-99", "username": "x", "display_name": "X"})
|
||||||
|
admin_client.cookies.set("draco_session", token)
|
||||||
|
r = admin_client.get("/api/auth/me")
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_me_draco_inerte_sans_secret(admin_client, monkeypatch):
|
||||||
|
token, _j, _e = draco_session.issue_token(USER)
|
||||||
|
monkeypatch.delenv("DRACO_SESSION_SECRET")
|
||||||
|
admin_client.cookies.set("draco_session", token)
|
||||||
|
r = admin_client.get("/api/auth/me")
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_revocations_protegees_par_cle_machine(admin_client, monkeypatch):
|
||||||
|
token, jti, exp = draco_session.issue_token(USER)
|
||||||
|
draco_session.revoke(jti, exp)
|
||||||
|
r = admin_client.get("/api/sso/revocations")
|
||||||
|
assert r.status_code == 404 # clé non configurée
|
||||||
|
monkeypatch.setenv("DRACO_SSO_MACHINE_KEY", "cle-machine")
|
||||||
|
r = admin_client.get("/api/sso/revocations")
|
||||||
|
assert r.status_code == 401 # en-tête absent
|
||||||
|
r = admin_client.get("/api/sso/revocations",
|
||||||
|
headers={"X-Draco-Machine-Key": "cle-machine"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert {"jti": jti, "exp": exp} in r.json()["revoked"]
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""Références croisées des notes — Fusion #215 (ACC-1.3).
|
||||||
|
|
||||||
|
Parseur d'URL profondes (formes figées de dracodev-commons
|
||||||
|
``contrats/liens-profonds.md``), stockage JSON par utilisateur et
|
||||||
|
endpoints ``/api/refs`` (création par collage d'URL, liste, retrait).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.external_refs import (
|
||||||
|
add_ref,
|
||||||
|
delete_ref,
|
||||||
|
get_ref,
|
||||||
|
list_refs,
|
||||||
|
parse_deep_link,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def refs_env(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("OBSIGATE_EXTERNAL_REFS_DIR", str(tmp_path / "refs"))
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ parseur
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("url,attendu", [
|
||||||
|
("https://tiktak.dracodev.net/?task=42",
|
||||||
|
("tiktak", "task", "42", "TikTak · tâche 42")),
|
||||||
|
("https://tiktak.dracodev.net/?list=7",
|
||||||
|
("tiktak", "list", "7", "TikTak · liste 7")),
|
||||||
|
("https://og.dracodev.net/#file=home-bruno:Projets%2FAlpha.md",
|
||||||
|
("obsigate", "note", "home-bruno:Projets/Alpha.md", "ObsiGate · Alpha.md")),
|
||||||
|
("https://flowdeck.dracodev.net/pages/15",
|
||||||
|
("flowdeck", "page", "15", "Flowdeck · page 15")),
|
||||||
|
("https://flowdeck.dracodev.net/collections/3",
|
||||||
|
("flowdeck", "project", "3", "Flowdeck · projet 3")),
|
||||||
|
("https://git.dracodev.net/Projets/fusion-trois-apps/issues/5",
|
||||||
|
("kanbante", "card", "Projets/fusion-trois-apps#5", "Kanbante · fusion-trois-apps #5")),
|
||||||
|
])
|
||||||
|
def test_parse_formes_reconnues(url, attendu):
|
||||||
|
assert parse_deep_link(url) == attendu
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("url", [
|
||||||
|
"https://example.com/?task=1",
|
||||||
|
"https://og.dracodev.net/#file=sansdeuxpoints",
|
||||||
|
"https://og.dracodev.net/",
|
||||||
|
"https://flowdeck.dracodev.net/autre/12",
|
||||||
|
"https://git.dracodev.net/Projets/fusion-trois-apps",
|
||||||
|
"pas une url",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
def test_parse_formes_refusees(url):
|
||||||
|
assert parse_deep_link(url) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ stockage
|
||||||
|
|
||||||
|
def test_stockage_cycle(refs_env):
|
||||||
|
ref = add_ref("alice", "TestVault", "Note A.md", "tiktak", "task", "42",
|
||||||
|
"https://tiktak.dracodev.net/?task=42", "TikTak · tâche 42")
|
||||||
|
assert ref["owner_type"] == "obsigate.note"
|
||||||
|
assert ref["owner_id"] == "TestVault:Note A.md"
|
||||||
|
assert get_ref("alice", ref["id"])["target_id"] == "42"
|
||||||
|
|
||||||
|
assert [r["id"] for r in list_refs("alice", "TestVault", "Note A.md")] == [ref["id"]]
|
||||||
|
assert list_refs("alice", "TestVault", "Autre.md") == []
|
||||||
|
assert list_refs("bob", "TestVault", "Note A.md") == [] # par utilisateur
|
||||||
|
|
||||||
|
removed = delete_ref("alice", ref["id"])
|
||||||
|
assert removed["id"] == ref["id"]
|
||||||
|
assert delete_ref("alice", ref["id"]) is None
|
||||||
|
assert list_refs("alice", "TestVault", "Note A.md") == []
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ API
|
||||||
|
|
||||||
|
def test_api_cycle_complet(client, refs_env):
|
||||||
|
r = client.post("/api/refs", json={
|
||||||
|
"vault": "TestVault", "path": "Note A.md",
|
||||||
|
"url": "https://flowdeck.dracodev.net/collections/3",
|
||||||
|
})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
ref = r.json()
|
||||||
|
assert ref["target_app"] == "flowdeck"
|
||||||
|
assert ref["target_type"] == "project"
|
||||||
|
assert ref["label"] == "Flowdeck · projet 3"
|
||||||
|
|
||||||
|
r = client.post("/api/refs", json={
|
||||||
|
"vault": "TestVault", "path": "Note A.md",
|
||||||
|
"url": "https://tiktak.dracodev.net/?task=9", "label": "Ma tâche",
|
||||||
|
})
|
||||||
|
assert r.status_code == 201, r.text
|
||||||
|
assert r.json()["label"] == "Ma tâche"
|
||||||
|
|
||||||
|
r = client.get("/api/refs", params={"vault": "TestVault", "path": "Note A.md"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert [x["target_app"] for x in r.json()["refs"]] == ["flowdeck", "tiktak"]
|
||||||
|
|
||||||
|
r = client.delete(f"/api/refs/{ref['id']}")
|
||||||
|
assert r.status_code == 200
|
||||||
|
r = client.get("/api/refs", params={"vault": "TestVault", "path": "Note A.md"})
|
||||||
|
assert [x["target_app"] for x in r.json()["refs"]] == ["tiktak"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_url_inconnue_422(client, refs_env):
|
||||||
|
r = client.post("/api/refs", json={
|
||||||
|
"vault": "TestVault", "path": "Note A.md",
|
||||||
|
"url": "https://example.com/whatever",
|
||||||
|
})
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_delete_introuvable_404(client, refs_env):
|
||||||
|
r = client.delete("/api/refs/inexistant")
|
||||||
|
assert r.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_vault_sans_acces_403(admin_client, refs_env):
|
||||||
|
"""Auth activée : un utilisateur limité à TestVault reçoit 403 ailleurs."""
|
||||||
|
r = admin_client.post("/api/auth/login",
|
||||||
|
json={"username": "normaluser", "password": "normal123"})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
headers = {"Authorization": f"Bearer {r.json()['access_token']}"}
|
||||||
|
r = admin_client.get("/api/refs", headers=headers,
|
||||||
|
params={"vault": "VaultFantome", "path": "x.md"})
|
||||||
|
assert r.status_code == 403
|
||||||
|
r = admin_client.get("/api/refs", headers=headers,
|
||||||
|
params={"vault": "TestVault", "path": "x.md"})
|
||||||
|
assert r.status_code == 200
|
||||||
Reference in New Issue
Block a user