Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
858649e3c3 | ||
|
|
400a827e3f | ||
|
|
699165469d | ||
|
|
3a6445ca4e | ||
|
|
033490c464 | ||
|
|
f132885b08 | ||
|
|
a79bdc1641 | ||
|
|
a67bd252aa | ||
|
|
39d34ac866 | ||
|
|
efa3d57e93 | ||
|
|
992200a90a | ||
|
|
21b96afa12 | ||
|
|
16f73fe39e | ||
|
|
f8acb906e5 | ||
|
|
1d7750bda0 | ||
|
|
18c72d77fe | ||
|
|
64c85caffc | ||
|
|
a68e8848ea | ||
|
|
18a2d45d46 | ||
|
|
f271ac9b7a | ||
|
|
f006880394 | ||
|
|
81746d9440 | ||
|
|
f53205bc45 | ||
|
|
3706689eca | ||
|
|
7dbaefb381 | ||
|
|
15b6b163f4 | ||
|
|
2e5efcfe07 | ||
|
|
1d1cdbd618 | ||
|
|
894004a1f5 | ||
|
|
6582df3bcb | ||
|
|
aa88cf6665 | ||
|
|
b2ea8ec537 | ||
|
|
1051a72b52 | ||
|
|
afbc236cc2 | ||
|
|
c20aeaada1 | ||
|
|
6d7af3fb64 | ||
|
|
b0af1bbfb6 | ||
|
|
de751ffe35 | ||
|
|
48b5551b93 | ||
|
|
e4552c3763 | ||
|
|
0f86294abc |
@@ -1,6 +1,10 @@
|
|||||||
# ── Gitea ──
|
# ── Gitea ──
|
||||||
GITEA_URL=https://git.dracodev.net
|
GITEA_URL=https://git.dracodev.net
|
||||||
GITEA_TOKEN=change-me
|
GITEA_TOKEN=change-me
|
||||||
|
# Laissez VIDES pour activer le login local seul : depuis v7.46.0 les valeurs de
|
||||||
|
# substitution (`test-id`, `test-secret`, `change-me`) comptent comme « provider
|
||||||
|
# non configuré » — avant, /auth/login redirigeait vers Gitea avec un client_id
|
||||||
|
# invalide (authentification OAuth impossible).
|
||||||
GITEA_OAUTH_CLIENT_ID=
|
GITEA_OAUTH_CLIENT_ID=
|
||||||
GITEA_OAUTH_CLIENT_SECRET=
|
GITEA_OAUTH_CLIENT_SECRET=
|
||||||
GITEA_WEBHOOK_SECRET=
|
GITEA_WEBHOOK_SECRET=
|
||||||
@@ -16,6 +20,16 @@ GITHUB_OAUTH_CLIENT_SECRET=
|
|||||||
# Exemple : OAUTH_REDIRECT_URI=https://flowdeck.dracodev.net/auth/callback
|
# Exemple : OAUTH_REDIRECT_URI=https://flowdeck.dracodev.net/auth/callback
|
||||||
OAUTH_REDIRECT_URI=
|
OAUTH_REDIRECT_URI=
|
||||||
|
|
||||||
|
# ── Connecteurs OAuth (v7.56.0) — Google / Microsoft 365 ──
|
||||||
|
# Vidés = connecteur « non configuré » (le menu + affiche la raison).
|
||||||
|
# Le redirect URI à enregistrer est dérivé d'APP_BASE_URL :
|
||||||
|
# {APP_BASE_URL}/api/agent/connectors/oauth/google/callback
|
||||||
|
# {APP_BASE_URL}/api/agent/connectors/oauth/ms365/callback
|
||||||
|
GOOGLE_CLIENT_ID=
|
||||||
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
MS_CLIENT_ID=
|
||||||
|
MS_CLIENT_SECRET=
|
||||||
|
|
||||||
# ── App ──
|
# ── App ──
|
||||||
APP_SECRET_KEY=change-me-to-random
|
APP_SECRET_KEY=change-me-to-random
|
||||||
APP_HOST=0.0.0.0
|
APP_HOST=0.0.0.0
|
||||||
@@ -82,3 +96,15 @@ APP_BASE_URL=http://localhost:8080
|
|||||||
# SSO_ATTRIBUTE_MAPPING={"email":"email","full_name":"name","groups":"groups"}
|
# SSO_ATTRIBUTE_MAPPING={"email":"email","full_name":"name","groups":"groups"}
|
||||||
# SSO_GROUPS_MAPPING=[{"sso_group":"FlowDeck Admins","workspace_role":"admin","workspace_id":1}]
|
# SSO_GROUPS_MAPPING=[{"sso_group":"FlowDeck Admins","workspace_role":"admin","workspace_id":1}]
|
||||||
# SSO_DEFAULT_WORKSPACE_ID=0
|
# SSO_DEFAULT_WORKSPACE_ID=0
|
||||||
|
|
||||||
|
# ── Web tools de l'agent (v7.46.0) ──
|
||||||
|
# Skills « recherche-marche », « veille-techno », « debug-web ».
|
||||||
|
# web_search → EXA (recherche sémantique + extraits, ~10 $/mois offerts)
|
||||||
|
# fetch_url → aucune config : lecture d'une page, garde-fou SSRF actif
|
||||||
|
# search_code → GitHub public (10 req/min sans jeton, 30 avec)
|
||||||
|
# Sans EXA_API_KEY, web_search bascule sur DuckDuckGo (repli dégradé, sans
|
||||||
|
# compte mais parsé au HTML) : utilisable, moins fiable.
|
||||||
|
WEB_SEARCH_PROVIDER=exa
|
||||||
|
EXA_API_KEY=
|
||||||
|
GITHUB_TOKEN=
|
||||||
|
WEB_FETCH_MAX_CHARS=20000
|
||||||
|
|||||||
+1643
File diff suppressed because it is too large
Load Diff
+422
-5
@@ -175,6 +175,360 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## v7.46.0 — Sidebar : onglets compressibles Home / Chat / Meeting / Inbox ✅ (2026-10-04)
|
||||||
|
> **Objectif** : remplacer la rangée d'icônes par une navigation par onglets
|
||||||
|
> (Home, Chat, Meeting, Inbox dans cet ordre) où l'onglet actif est une pilule
|
||||||
|
> étiquetée, les autres sont réduits à leur icône, et chaque sélection remplace
|
||||||
|
> le contenu de la barre latérale. **COMPLETED**.
|
||||||
|
|
||||||
|
- [x] **Onglets compressibles** — `sidebar-nav-row` : 4 onglets dans l'ordre
|
||||||
|
**Home → Chat → Meeting → Inbox**, libellé visible uniquement sur l'onglet
|
||||||
|
actif (pilule), `role="tab"` + `aria-selected`, onglet mémorisé
|
||||||
|
(`fd_sidebar_tab`, valeur validée)
|
||||||
|
- [x] **Panneaux contextuels** — 4 panneaux Alpine (`sidebar-tab-panel`) :
|
||||||
|
Home (arbre + sections + libraries), Chat, Meeting, Inbox ; un seul visible
|
||||||
|
à la fois, footer commun conservé
|
||||||
|
- [x] **Onglet Chat branché sur l'historique réel** — raccourcis circulaires
|
||||||
|
d'agents + conversations groupées *Past 30 days* / *Older* ; clic →
|
||||||
|
`window.fdAgent.openConversation(id)` ouvre le panneau agent sur la
|
||||||
|
conversation
|
||||||
|
- [x] **Onglet Meeting branché sur le calendrier réel** — nouveau
|
||||||
|
`GET /api/v2/meetings/upcoming` (collections calendaires liées, propriété
|
||||||
|
date, horizon paramétrable et clampé), filtres *Upcoming* / *Today*,
|
||||||
|
actions *Transcribe a podcast* et *New AI meeting note*
|
||||||
|
- [x] **Onglet Inbox branché sur les notifications réelles** — All / Unread,
|
||||||
|
marquage lu au clic, *Mark all read*, état *Inbox Zero*, badge non lus- [x] **Verrouillé** — `tests/test_sidebar_tabs.py` (15 tests) +
|
||||||
|
`e2e/sidebar_tabs.spec.js` (3 scénarios, mesure de largeur de la pilule,
|
||||||
|
clic Home n'affiche que le libellé Home, CSS périmée en cache) ;
|
||||||
|
suite complète 1182 tests verts
|
||||||
|
|
||||||
|
## v7.46.0 — Audit de fonctionnement : 10 anomalies corrigées ✅ (2026-10-04)
|
||||||
|
> **Objectif** : passer l'instance au crible (routes, sécurité, navigation,
|
||||||
|
> schémas) et supprimer les anomalies constatées. **COMPLETED**.
|
||||||
|
|
||||||
|
- [x] **Audited** : 677 routes, 280 endpoints GET sondés anonymement, 64
|
||||||
|
templates/JS croisés avec les routes enregistrées, crawl Playwright
|
||||||
|
authentifié (12 pages), intégrité SQLite. **10 anomalies**, toutes corrigées.
|
||||||
|
- [x] **Écritures anonymes closes** — `POST/DELETE /api/workspaces`,
|
||||||
|
`PUT /api/workspaces/{id}`, `select` et `local-workspace/items` répondaient
|
||||||
|
200 **sans session** (repli `uid = … else 1`) : un anonyme pouvait renommer
|
||||||
|
ou supprimer un espace. → `_require_user_id()` + `_require_ws_owner()`
|
||||||
|
- [x] **Router `/workspace` authentifié** — `export/csv`, historique,
|
||||||
|
commentaires, sprints, dashboards et templates servaient des données réelles
|
||||||
|
en anonyme (500 inclus). → `Depends(_require_session)` sur le router, la page
|
||||||
|
de partage public bascule dans un `public_router` sans dépendance
|
||||||
|
- [x] **`password_hash` retiré** de `/auth/user` **et du cookie de session**
|
||||||
|
(signé mais non chiffré) → `app.auth.session.public_user()`
|
||||||
|
- [x] **OAuth Gitea** — placeholders `test-id`/`test-secret` traités comme
|
||||||
|
« provider non configuré » (avant : redirection Gitea avec un client_id
|
||||||
|
invalide) ; `GITEA_OAUTH_CLIENT_ID/SECRET` documentés dans `.env.example`
|
||||||
|
- [x] **Bouton Home** — pointait sur `local_workspaces[0]` (1er espace **trié
|
||||||
|
par nom**) au lieu de l'espace actif : le clic pouvait changer d'espace sans
|
||||||
|
annonce. → `local_ws_id` exposé par les 2 sidebars (`gitea_mirror_ws_id`
|
||||||
|
séparé) et prioritaire dans `base.html`
|
||||||
|
- [x] **`/api/v2/agents/conversations`** — 422 car masqué par
|
||||||
|
`/agents/{agent_id}` → `{agent_id:int}`
|
||||||
|
- [x] **Sélecteur de blocs synchronisés** — `/api/synced-blocks` (404) →
|
||||||
|
`/board/api/synced-blocks`, message d'aide corrigé
|
||||||
|
- [x] **`/workspace/favorites`** — 500 permanent (`no such column:
|
||||||
|
f.collection_id`, dérive de la migration v2.2.0) → schéma actuel + propriété
|
||||||
|
du favori
|
||||||
|
- [x] **Synced blocks** — 500 anonyme (`NoneType.get`) + absence de contrôle de
|
||||||
|
propriété sur PUT/DELETE → 401 / 403 (auteur ou admin)
|
||||||
|
- [x] **Jinja non silencieux** — `LoggingUndefined` : rendu identique, mais un
|
||||||
|
`WARNING` journalise toute variable absente du contexte (c'est ce silence
|
||||||
|
qui avait masqué le bug du bouton Home)
|
||||||
|
- [x] **Tests** — `tests/test_anomalies_v746.py` : **34 tests** (fuites,
|
||||||
|
écriture anonyme, propriété, ordre de routes, schéma, navigation, logs).
|
||||||
|
**Suite complète : 1167 tests verts**, `ruff check app/ tests/` propre
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v7.46.0 — Web tools de l'agent & 11 nouveaux skills ✅ (2026-10-04)
|
||||||
|
> **Objectif** : doter l'agent d'un accès au web (recherche en ligne, lecture de
|
||||||
|
> page, recherche GitHub) et élargir la galerie de skills. **COMPLETED**.
|
||||||
|
|
||||||
|
- [x] **3 nouveaux tools agent** — `app/services/tool_registry.py` (25 tools, +3) :
|
||||||
|
- `web_search` — recherche web sourcée (titre + URL + extrait), provider piloté
|
||||||
|
par `WEB_SEARCH_PROVIDER`
|
||||||
|
- `fetch_url` — télécharge une page et la rend en Markdown (titres, listes,
|
||||||
|
code, tableaux) pour que l'IA puisse la citer
|
||||||
|
- `search_code` — GitHub `repositories | code | issues`, sans PAT (10 req/min)
|
||||||
|
ou avec `GITHUB_TOKEN` (30 req/min)
|
||||||
|
- [x] **Service réseau** — `app/services/web_search.py` (nouveau) : Exa (recherche
|
||||||
|
sémantique + extraits) avec repli DuckDuckGo sans compte, client partagé A42,
|
||||||
|
**aucune dépendance ajoutée**, couche `transport` injectable pour les tests
|
||||||
|
- [x] **Garde-fou SSRF réutilisé** — `fetch_url` s'appuie sur
|
||||||
|
`importers.url_fetch._validate_url` / `_is_public_host` : schémas http(s)
|
||||||
|
uniquement, résolution DNS puis refus des IP loopback / privées / lien-local
|
||||||
|
/ réservées, **re-validation à chaque saut de redirection** (une URL publique
|
||||||
|
ne peut pas rediriger vers `169.254.169.254`)
|
||||||
|
- [x] **Config** — `WEB_SEARCH_PROVIDER`, `EXA_API_KEY`, `GITHUB_TOKEN`,
|
||||||
|
`WEB_FETCH_MAX_CHARS` (`app/config.py` + `.env.example`)
|
||||||
|
- [x] **Galerie : 6 → 17 presets** — `app/services/skill_gallery.py` :
|
||||||
|
- **vague 1 (locaux)** : veille-documentaire, chasse-decisions, onboarding-nouveau,
|
||||||
|
audit-base, sprint-review
|
||||||
|
- **vague 2 (web)** : recherche-marche, veille-techno, debug-web — chaque
|
||||||
|
affirmation doit porter sa source `[titre — URL]`, sinon « non vérifié »
|
||||||
|
- **vague 3 (pouvoir)** : triage-incident, nettoyage-base (aperçu avant toute
|
||||||
|
écriture), briefing-quotidien
|
||||||
|
- [x] **UI** — libellés d'action du journal agent pour les 3 tools
|
||||||
|
(`Recherche web effectuée`, `Page web lue`, `Recherche GitHub effectuée`)
|
||||||
|
- [x] **Tests** — `tests/test_agent_web_tools.py` : **29 tests**, réseau **et DNS**
|
||||||
|
jamais touchés (`httpx.MockTransport` + résolution neutralisée) : registre +
|
||||||
|
schémas + périmètre, cohérence galerie/registre, export→import des presets
|
||||||
|
web, provider + repli + filtrage des URLs anti-bot, markdown + troncature +
|
||||||
|
**6 vecteurs SSRF refusés** (localhost, 127.0.0.1, metadata cloud, IPv6
|
||||||
|
loopback, `file:`, `ftp:`) + redirection vers metadata cloud + redirection
|
||||||
|
publique suivie, GitHub repos/issues/quota. **Suite complète : 1128 tests verts.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-8 ✅ 2026-10-06 — menu + complet)
|
||||||
|
|
||||||
|
> **Objectif** : faire du bouton **+** du panneau agent (à gauche de la zone d'édition)
|
||||||
|
> un menu à sections, tel que demandé :
|
||||||
|
>
|
||||||
|
> ```
|
||||||
|
> + ├─ Ajouter des fichiers ou répertoires … → picker actuel + parcours de l'arborescence
|
||||||
|
> ├─ Compétences-skills … … … … … … … … → Deep research, Skill-creator, … + Gérer + Parcourir
|
||||||
|
> ├─ Connecteurs … … … … … … … … … … … → Parcourir + Ajouter un connecteur personnalisé
|
||||||
|
> ├─ Design System – Canevas … … … … … → galerie de canevas (templates de page)
|
||||||
|
> ├─ Add plugins … … … … … … … … … … … → catalogue de modules on/off
|
||||||
|
> └─ Mémoire (on/off) … … … … … … … … → mémoire persistante de l'agent
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> **Doc de design** : [`docs/V74_Agent_Plus_Menu.md`](docs/V74_Agent_Plus_Menu.md)
|
||||||
|
> — maquette, découpage front/back, modèle de données, endpoints, décisions, pièges.
|
||||||
|
|
||||||
|
**État des lieux lu dans le code le 2026-10-06** (constat **avant** la phase 1) :
|
||||||
|
|
||||||
|
- Le **+** = `toggleAddPicker()` (`static/js/agent_panel_2.js:1074`) → menu **plat** de
|
||||||
|
mentions (`GET /api/agent/mentions`, `app/routers/agent.py:596`) : documents,
|
||||||
|
collections, pages. **Pas de dossiers, pas de sous-menu, pas de section.**
|
||||||
|
- **Compétences** : 11 skills builtin (`FD_SKILLS`, `static/js/agent_panel_2.js:11`) +
|
||||||
|
table `agent_skills` (CRUD `/api/agent/skills`) + galerie de 17 presets — mais
|
||||||
|
**aucune UI « Gérer » ni « Parcourir »** : tout passe par la palette `/`.
|
||||||
|
- **Canevas** : `GET /api/page-templates` (built-ins `app/services/block_templates.py` +
|
||||||
|
personnels) et `POST /api/page-templates/{id}/use` existent déjà, **jamais appelés
|
||||||
|
depuis le panneau agent**.
|
||||||
|
- **Connecteurs / Plugins / Mémoire** : **inexistants** — 0 hit `connector|plugin|memory`
|
||||||
|
dans `app/` hors « in-memory ».
|
||||||
|
|
||||||
|
**Ordre** : phases 1 → 8, chacune indépendante et livrable (version + tests verts +
|
||||||
|
CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j.
|
||||||
|
|
||||||
|
### Phase 1 — v7.51.0 — Menu hub + fichiers/répertoires · effort S–M ✅ (livrée 2026-10-06)
|
||||||
|
|
||||||
|
- [x] **Structure du menu** — sections + sous-menus, navigation clavier (↑/↓/Entrée/Échap),
|
||||||
|
styles réutilisés depuis `.fd-ap-mention-menu` ; les sections pas encore livrées
|
||||||
|
s'affichent « bientôt » et désactivées (le menu ne promet rien que le code ne fait)
|
||||||
|
→ `FD_PLUS_MENU` / `FD_PLUS_FILES` (`static/js/agent_panel_2.js`), markup
|
||||||
|
`.fd-ap-plus-menu` (`agent_panel.html`), Échap remonte d'un niveau, la frappe
|
||||||
|
referme le menu
|
||||||
|
- [x] **Fichiers & répertoires** — picker actuel (recherche) **+ mode « Parcourir… »** :
|
||||||
|
arborescence **un niveau par appel via `GET /api/nav/menu?parent_id=`**
|
||||||
|
(contrat : dossier = `icon === 'folder'` — plus simple que le tree récursif,
|
||||||
|
déjà testé), fil d'Ariane cliquable ; un répertoire s'épingle comme un fichier
|
||||||
|
(ligne « 📌 Épingler le dossier »)
|
||||||
|
- [x] **Jeton `folder:<id>`** résolu par `ContextBuilder._mentions_context` →
|
||||||
|
`_single_folder()` : titre du dossier + documents directs, budget ~12k caractères
|
||||||
|
(marqueur « tronqué ») ; enfants directs seulement (récursion si besoin réel)
|
||||||
|
- [x] **Tests** — `tests/test_v751_plus_menu.py` : **7 tests** (résolution, budget,
|
||||||
|
id inconnu, contrat `/api/nav/menu`, rendu du menu, 6 sections / 5 désactivées
|
||||||
|
à la livraison de la phase 1, jeton `folder:` front+back) ; **suite complète
|
||||||
|
1273 verts** à la livraison, `ruff` 0, `eslint` 0 erreur
|
||||||
|
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.51.0**, CHANGELOG, ce ROADMAP
|
||||||
|
|
||||||
|
### Phase 2 — v7.52.0 — Compétences : « Gérer » + « Parcourir » · effort M ✅ (livrée 2026-10-06)
|
||||||
|
|
||||||
|
- [x] **Sous-menu Compétences** — 11 builtin + skills enregistrés listés, le clic
|
||||||
|
épingle le skill au contexte (même chemin que la palette `/`, comportement
|
||||||
|
conservé)
|
||||||
|
- [x] **Libellés affichés** — alias « Deep research » (`/research`) et
|
||||||
|
« Skill-creator » (`/create-new-skill`) via `FD_SKILL_ALIAS`, slugs intacts
|
||||||
|
- [x] **« Gérer les compétences »** — **section du menu + formulaire intégré**
|
||||||
|
(nom, description, prompt) plutôt que modale : CRUD `POST`/`PATCH`/`DELETE`
|
||||||
|
sur `/api/agent/skills` — **`PATCH /api/agent/skills/{id}` créé** (champs
|
||||||
|
présents seuls, whitelist de colonnes, 400/401/404/409) + **export JSON**
|
||||||
|
(`/skills/{id}/export` → téléchargement) et **import fichier**
|
||||||
|
(`/skills/import`, `overwrite:true` → pas de doublon) ; suppression = nettoyage
|
||||||
|
du chip épinglé
|
||||||
|
- [x] **« Parcourir les compétences »** — section galerie sur
|
||||||
|
`GET /api/agent/skills/gallery`, **filtre** (nom/description/slug),
|
||||||
|
installation en 1 clic (réutilise `installGallerySkill`)
|
||||||
|
- [x] **En-tête ← + titre** dans le menu (navigation à 3 niveaux)
|
||||||
|
- [x] **Fix** — épingler un skill enregistré n'efface plus le message tapé
|
||||||
|
- [x] **Tests** — `tests/test_v752_skills_menu.py` : **7 tests** (PATCH complet +
|
||||||
|
partiel, 400/404, 401 sans session, import sans doublon, export→import,
|
||||||
|
câblage sections/alias, markup) ; `test_v751` adapté (4 sections « bientôt ») ;
|
||||||
|
**suite complète 1280 verts**, `ruff` 0, `eslint` 0 problème
|
||||||
|
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.52.0**, OpenAPI régénéré
|
||||||
|
(515 chemins), CHANGELOG, ce ROADMAP
|
||||||
|
|
||||||
|
### Phase 3 — v7.53.0 — Design System – Canevas · effort M ✅ (livrée 2026-10-06)
|
||||||
|
|
||||||
|
- [x] **Sous-menu Canevas** — `GET /board/api/page-templates` (built-ins **+
|
||||||
|
personnels**) ; **piège confirmé** : le router est sous `prefix="/board"` →
|
||||||
|
l'URL réelle est `/board/api/page-templates`, pas `/api/page-templates`
|
||||||
|
(404 silencieux corrigé dans le front)
|
||||||
|
- [x] **Deux actions (3 en réalité)** — page de détail par canevas :
|
||||||
|
« Créer une page à partir du canevas » (`POST …/0/use` avec `{key}` **dans le
|
||||||
|
body** — la clé en query est ignorée par la route), « Insérer dans le document
|
||||||
|
ouvert » (`GET …/blocks` **nouvelle route** → `ensureBlockIds()` global →
|
||||||
|
concat dans `E.blocks` + `autoSave/render`, désactivé sans document ouvert)
|
||||||
|
et « Enregistrer le document ouvert comme canevas » (`POST` + `page_id`)
|
||||||
|
- [x] **Canevas « design system »** — preset `design_system` dans
|
||||||
|
`block_templates.py` (callout tokens, TOC, toggles composants, grille,
|
||||||
|
checklist revue UI) ; `design-tokens.css` référencé dans le callout
|
||||||
|
- [x] **Sauver le doc ouvert comme canevas** — `POST /board/api/page-templates`
|
||||||
|
(existante) branchée depuis le menu, nom via `prompt()` natif
|
||||||
|
- [x] **Tests** — `tests/test_v753_canvases.py` : **5 tests** (preset listé,
|
||||||
|
route blocks intégré/personnel + 404, création de page vérifiée en base avec
|
||||||
|
ids, câblage 3 « bientôt » + garde-fou `key`-dans-le-body) ; `test_v751`
|
||||||
|
adapté ; **suite complète 1285 verts**, `ruff` 0, `eslint` 0 problème
|
||||||
|
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.53.0**, OpenAPI régénéré
|
||||||
|
(516 chemins), CHANGELOG, ce ROADMAP
|
||||||
|
|
||||||
|
### Phase 4 — v7.54.0 — Mémoire de l'agent · effort M ✅ (livrée 2026-10-06)
|
||||||
|
|
||||||
|
- [x] **Table `agent_memory`** — **une ligne résumé par conversation**
|
||||||
|
(`conversation_id` PK, upsert) + colonne `agent_conversations.memory_enabled`
|
||||||
|
(migration **31**, défaut `1`) ; la variante « mémoire d'espace » prévue au
|
||||||
|
design est **remise** (YAGNI : rien ne l'écrit — `ponytail:` noté dans le service)
|
||||||
|
- [x] **Service `app/services/agent_memory.py`** — **extraction déterministe**
|
||||||
|
(pas d'LLM) : 1 note par échange (objectif 180 car. → réponse 700 car.),
|
||||||
|
20 notes max, écrite en fin de run via `agent_memory.remember()` ; toute
|
||||||
|
erreur est journalisée, **un run ne peut pas échouer à cause de la mémoire**
|
||||||
|
- [x] **Injection** — `AgentEngine.run()` appelle `agent_memory.context_for()` après
|
||||||
|
`ContextBuilder.build()` : bloc « ## Mémoire de la conversation », **budget
|
||||||
|
4 000 caractères** (troncature par la gauche + marqueur) ; OFF = ni lecture ni
|
||||||
|
écriture → contexte strict du run courant
|
||||||
|
- [x] **Toggle « Mémoire »** dans le menu + — entrée racine à l'état réel
|
||||||
|
(🧠/💭 + libellé), `PATCH /api/agent/conversations/{id}` (`memory_enabled`
|
||||||
|
ajouté au whitelist), état lu à l'ouverture et à la création, défaut
|
||||||
|
`AGENT_MEMORY_DEFAULT` (config + réponse de création)
|
||||||
|
- [x] **Tests** — `tests/test_v754_memory.py` : **8 tests** dont **2 runs réels du
|
||||||
|
moteur** (FakeLLM capture le prompt : mémoire présente ON / absente OFF),
|
||||||
|
budget et nombre de notes bornés, toggle relu en base ; `test_v751` +
|
||||||
|
`test_v753` adaptés (2 sections « bientôt ») ; **suite complète 1293 verts**,
|
||||||
|
`ruff` 0, `eslint` 0 problème
|
||||||
|
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.54.0**, OpenAPI régénéré
|
||||||
|
(516 chemins), CHANGELOG, ce ROADMAP
|
||||||
|
|
||||||
|
### Phase 5 — v7.55.0 — Connecteurs : socle · effort L ✅ (livrée 2026-10-06)
|
||||||
|
|
||||||
|
- [x] **Table `agent_connectors`** (migration **32**) — colonnes **plates**
|
||||||
|
(`name`, `url`, `secret_encrypted`, `enabled`, `status`, `detail`) ; le
|
||||||
|
`config_json` prévu est **remis** (YAGNI : rien ne le remplissait — les scopes
|
||||||
|
OAuth des phases 6-7 ajouteront le leur)
|
||||||
|
- [x] **`app/services/connectors.py`** — **1 fichier au lieu du package
|
||||||
|
`connectors/`** promis (3 probes + 1 fetch ne justifient pas une arborescence) ;
|
||||||
|
3 natifs `gitea`/`github`/`web` servis à la volée, statut **sans réseau**
|
||||||
|
(`native_state()`) ; `probe()` et `connector_fetch()` passent par `_get()`
|
||||||
|
(gardé SSRF + re-vérification après redirection, timeout 10 s, borne 20 000 car.)
|
||||||
|
- [x] **Menu « Connecteurs »** — « Parcourir » = catalogue avec badge
|
||||||
|
(✓ ok / ✗ error / ⚠ missing / ? inconnu) + fiche (Tester, Activer/Désactiver,
|
||||||
|
Supprimer — masqués pour les natifs) ; « Ajouter un connecteur personnalisé » =
|
||||||
|
formulaire nom + URL + clé (`type="password"`)
|
||||||
|
- [x] **Tool agent** — **un seul `connector_fetch`** (id/nom/kind + `path` +
|
||||||
|
`query`) au lieu d'un outil par connecteur : même capacité, moins de bruit de
|
||||||
|
schéma — l'outil dispatche vers Gitea/GitHub/web/personnalisé. Connecteur
|
||||||
|
désactivé ou inconnu = erreur outil, jamais une exception de run
|
||||||
|
- [x] **Sécurité** — `_validate_url` (SSRF) à la création **et** à chaque appel,
|
||||||
|
clé **chiffrée Fernet** et jamais renvoyée (`has_secret` seulement), 401 sans
|
||||||
|
session sur les 5 routes, CSRF global
|
||||||
|
- [x] **Tests** — `tests/test_v755_connectors.py` : **13 tests** (3 natifs, clé
|
||||||
|
chiffrée roundtrip, **5 URLs refusées** : localhost / 127.0.0.1 / metadata
|
||||||
|
cloud / ftp: / file:, toggle persisté, 401, probe OK + probe erreur persistée,
|
||||||
|
outil complet, câblage menu) ; 3 autres fichiers de phase adaptés (1 section
|
||||||
|
« bientôt » restante) ; **suite complète 1306 verts**, `ruff` 0, `eslint` 0
|
||||||
|
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.55.0**, OpenAPI régénéré
|
||||||
|
(519 chemins), CHANGELOG, ce ROADMAP
|
||||||
|
|
||||||
|
### Phase 6 — v7.56.0 — Connecteurs : Google + Microsoft 365 · effort L ✅ (livrée 2026-10-06)
|
||||||
|
|
||||||
|
- [x] **OAuth2 PKCE Google** — Drive / Gmail / Calendar **en lecture seule** ;
|
||||||
|
connexion (`begin()` → URL + state + `code_verifier` S256), échange du code,
|
||||||
|
**refresh automatique** (60 s de marge, `refresh_token` conservé si absent de
|
||||||
|
la réponse), déconnexion — table `connector_tokens` (migration **33**,
|
||||||
|
PK `(kind, user_id)`), tokens chiffrés Fernet **réutilisant**
|
||||||
|
`calendar_sync._encrypt_tokens` (zéro dépendance ajoutée)
|
||||||
|
- [x] **OAuth2 Microsoft (Graph)** — `Files.Read` / `Mail.Read` / `Calendars.Read`
|
||||||
|
+ `offline_access`, même code (2 providers décrits par **1 dict**)
|
||||||
|
- [x] **Écran connecteur** — **pas de page dédiée** : l'état vit dans la fiche du
|
||||||
|
menu + (badge « connecté · N scope(s) » / « non connecté »), boutons
|
||||||
|
**Se connecter / Déconnecter**, toast au retour du flux (`?oauth=connected` /
|
||||||
|
`?oauth_error=` nettoyés par `history.replaceState`)
|
||||||
|
- [x] **Tests** — `tests/test_v756_oauth_connectors.py` : **13 tests**, **0 appel
|
||||||
|
réseau réel** (`_post_form` / `_api_get` monkeypatchés) — URL d'autorisation
|
||||||
|
(PKCE, scopes, redirect URI, cookies), 400 non configuré, callback → tokens
|
||||||
|
chiffrés + redirection, **state forgé refusé sans échange**, callback sans
|
||||||
|
session, refresh, `api_get` (Bearer, URL absolue refusée), déconnexion,
|
||||||
|
catalogue, outil LLM, 401/404, câblage menu ; `test_v755` adapté (5 natifs) ;
|
||||||
|
**suite complète 1319 verts**, `ruff` 0, `eslint` 0
|
||||||
|
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.56.0**, `.env.example`
|
||||||
|
(GOOGLE_/MS_ CLIENT_ID/SECRET + redirect URI), OpenAPI régénéré (523 chemins),
|
||||||
|
CHANGELOG, ce ROADMAP
|
||||||
|
|
||||||
|
### Phase 7 — v7.57.0 — Connecteurs : Discord, Telegram, Teams, MCP · effort L ✅
|
||||||
|
|
||||||
|
- [x] **Presets Discord / Telegram** — champ **Type** dans le formulaire
|
||||||
|
« Ajouter un connecteur » : `kind` + `auth` (`bearer`/`bot`/`none`) ajoutés à
|
||||||
|
`agent_connectors` (migration **34**) ; Discord = en-tête d'autorisation avec
|
||||||
|
le jeton préfixé, Telegram = jeton **dans l'URL** via le substitut `{secret}`
|
||||||
|
(remplacé à l'appel, **jamais stocké en clair** — colonne url = `…/bot{secret}`)
|
||||||
|
- [x] **Teams** — scope `ChannelMessage.Read.All` ajouté aux Graph scopes M365
|
||||||
|
(consentement admin)
|
||||||
|
- [x] **MCP complet** — `app/services/mcp_client.py` : `initialize` →
|
||||||
|
`notifications/initialized` → `tools/list` → `tools/call` (JSON-RPC 2.0,
|
||||||
|
réponse JSON **ou** 1er `data:` d'un `text/event-stream`), garde SSRF,
|
||||||
|
outils **cachés** en base (`tools_json`, maj par le bouton « Tester »)
|
||||||
|
- [x] **Outils dynamiques au registre** — `ToolRegistry._all()` merge le cache
|
||||||
|
MCP à chaque run : nom `mcp_<serveur>_<outil>` + `inputSchema` exposés au
|
||||||
|
LLM, `execute()` dispatche sur `tools/call` ; serveur désactivé → outil
|
||||||
|
**absent du schéma** ; erreur RPC → `ToolResult(error)` (jamais de run perdu)
|
||||||
|
- [x] **Tests** — `tests/test_v757_mcp_discord.py` : **12 tests**, 0 appel
|
||||||
|
réseau (`_rpc` / `_get` monkeypatchés) — presets, `{secret}` absent de la
|
||||||
|
base, kind/auth invalides → 400, handshake + séquence d'appels, cache
|
||||||
|
`tools_json`, dispatch LLM, serveur désactivé, erreur handshake → probe
|
||||||
|
error, parse SSE/erreurs JSON-RPC, slug, scopes Teams, câblage menu
|
||||||
|
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.57.0**, OpenAPI régénéré,
|
||||||
|
CHANGELOG, ce ROADMAP
|
||||||
|
|
||||||
|
### Phase 8 — v7.58.0 — Add plugins · effort M ✅
|
||||||
|
|
||||||
|
- [x] **Registre `plugins`** (`slug`, `name`, `description`, `enabled`) —
|
||||||
|
migration **35**, table pré-remplie avec les **3 modules** ; ligne absente =
|
||||||
|
activé (défaut sûr)
|
||||||
|
- [x] **Catalogue dans le menu +** — l'entrée « Add plugins » devient vivante
|
||||||
|
(fini le `disabled:true`) : liste des 3 plugins + bascule via
|
||||||
|
`GET/PATCH /api/agent/plugins[/slug]`
|
||||||
|
- [x] **Bascule on/off à effet réel** :
|
||||||
|
- `automations` → **toutes** les routes `/workspace/automations*` refusées
|
||||||
|
(dépendance FastAPI posée **à l'`include_router`**, aucun router touché) +
|
||||||
|
scheduler de fond en veille (garde par tick) ;
|
||||||
|
- `web-clipper` → `GET /extensions` + `/api/v2/web-clipper/*` refusés ;
|
||||||
|
- `web-tools` → `web_search` / `fetch_url` retirés du **schéma** et de
|
||||||
|
`execute()` (`ToolRegistry._all()`) ;
|
||||||
|
- UI → nav « Extensions » / « Automations » rendues sous
|
||||||
|
`{% if plugin_enabled(...) %}` (global Jinja, élément **absent** du DOM),
|
||||||
|
sections conditionnées en `x-show`
|
||||||
|
- [x] **Tests** — `tests/test_v758_plugins.py` : **10 tests** — plugin OFF =
|
||||||
|
route refusée (302 hors `/api`, 404 JSON pour `/api*`) + UI absente + outil
|
||||||
|
retiré, persistance, 401 sans session
|
||||||
|
- [x] **Livraison** — `VERSION` + `app/main.py` = **7.58.0**, OpenAPI régénéré
|
||||||
|
(525 chemins), CHANGELOG, ce ROADMAP
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Plan produit le 2026-10-06 à partir du code réel — **phases 1 à 8 livrées le
|
||||||
|
2026-10-06 (v7.51.0 → v7.58.0) : le menu + est complet** (aucune section
|
||||||
|
« bientôt »). Toute extension ultérieure = nouvelle phase à valider.
|
||||||
|
Chaque phase se clôt par tests verts, bump de version, CHANGELOG et ROADMAP à jour.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🔜 Prochaines versions
|
## 🔜 Prochaines versions
|
||||||
|
|
||||||
### v4.0.1 — Onboarding & Polish ✅ (2026-07-18)
|
### v4.0.1 — Onboarding & Polish ✅ (2026-07-18)
|
||||||
@@ -987,7 +1341,7 @@ Détails livrés :
|
|||||||
- [x] **Migrations 25** — `semantic_embeddings`, `semantic_index_state`, colonne `pages.search_excluded`
|
- [x] **Migrations 25** — `semantic_embeddings`, `semantic_index_state`, colonne `pages.search_excluded`
|
||||||
- [x] **Tests** — `tests/test_v69_search_ask.py` (**24 tests** : migration, chunk/overlap, déterminisme/norme, cosinus, index idempotent, exclusion, purge, rappel vectoriel partiel, hybride keyword/auth/400/pagination/isolation ACL/exclusion/collections, ask citations/auth/400/cache/ACL/rate-limit, index-status)
|
- [x] **Tests** — `tests/test_v69_search_ask.py` (**24 tests** : migration, chunk/overlap, déterminisme/norme, cosinus, index idempotent, exclusion, purge, rappel vectoriel partiel, hybride keyword/auth/400/pagination/isolation ACL/exclusion/collections, ask citations/auth/400/cache/ACL/rate-limit, index-status)
|
||||||
- [x] **Version** — 6.9.0 (`VERSION` + `app/main.py`) · `ruff check` OK
|
- [x] **Version** — 6.9.0 (`VERSION` + `app/main.py`) · `ruff check` OK
|
||||||
- [ ] **UI** — palette `Ctrl+K` onglets `Pages / Fichiers / ✨ Réponses IA` (reporté : backend livré, frontend en follow-up)
|
- [x] **UI** — palette `Ctrl+K` : **onglets `Pages / ✨ Réponses IA`** livrés (v7.44.0) — onglet IA = `POST /api/v2/search/ask` (CSRF via `getCsrf()`, debounce 150 ms, échappement AVANT injection des liens `[[fdpage:ID]]` → citations cliquables + bloc « Sources ») ; gate E2E étendu (onglet cliqué → réponse non-échappée). **Reporté** : onglet `Fichiers` — aucun backend (`/api/search` ne renvoie que `pages`/`collections`) → à créer d'abord.
|
||||||
|
|
||||||
### v7.0.0 — Automations v2 + Workers ✅ (2026-09-28)
|
### v7.0.0 — Automations v2 + Workers ✅ (2026-09-28)
|
||||||
> **Objectif** : automatiser au-delà du if-this-then-that + custom code sandboxé.
|
> **Objectif** : automatiser au-delà du if-this-then-that + custom code sandboxé.
|
||||||
@@ -1001,7 +1355,14 @@ Détails livrés :
|
|||||||
- [x] **Migrations 26** — `automation_steps`, `workers`, `worker_runs`, `automations.trigger_mode`, `collection_properties.button_automation_id`
|
- [x] **Migrations 26** — `automation_steps`, `workers`, `worker_runs`, `automations.trigger_mode`, `collection_properties.button_automation_id`
|
||||||
- [x] **Tests** — `tests/test_v70_automations_workers.py` (**31 tests** : migration, steps CRUD/validation/auth, mode any/all, `form.submitted`, chaînes + interpolation, condition, delay, slack + secret chiffré, email no-SMTP, forge mock + sans-token, agent mock + 404, button press/validation, legacy single-run, workers CRUD/auth/rejet code/run ok/error/timeout/budget/fork/privacy/usage/cron/masquage code)
|
- [x] **Tests** — `tests/test_v70_automations_workers.py` (**31 tests** : migration, steps CRUD/validation/auth, mode any/all, `form.submitted`, chaînes + interpolation, condition, delay, slack + secret chiffré, email no-SMTP, forge mock + sans-token, agent mock + 404, button press/validation, legacy single-run, workers CRUD/auth/rejet code/run ok/error/timeout/budget/fork/privacy/usage/cron/masquage code)
|
||||||
- [x] **Version** — 7.0.0 (`VERSION` + `app/main.py`) · `ruff check` OK
|
- [x] **Version** — 7.0.0 (`VERSION` + `app/main.py`) · `ruff check` OK
|
||||||
- [ ] **Éditeur visuel** — canvas Settings → Automations (reporté : API steps livrée, UI en follow-up)
|
- [x] **Éditeur visuel** — **pipeline steps** dans Settings → Automations (v7.45.0) : cartes ordonnées (trigger/condition/delay/action) avec **config typée par type** (8 actions : webhook/set_property/create_page/notify/slack/email/forge_issue/agent_trigger + ops condition + datalist événements), ajout/édition/suppression/**↑↓** via l'API `/steps` + bouton **✨ Convertir le JSON en pipeline** (legacy → steps ordonnés). **Bonus trouvé par le gate** : **51 expressions multi-instructions** (`a=1; b()` — `;` = SEULE expression par directive interdit sous le parseur CSP, non couvert par le scan `tokens`) → converties en méthodes dans **11 fichiers** (nav settings ×8, menu section base ×7, partages/éditeur ×13, breadcrumb ×5, lib/local ×6, board ×3, ctx/agent/workspaces/card/gitea ×6). Nouveau scanner `scan_semi` ajouté au lot.
|
||||||
|
- [x] **Fix SW + CSRF uploads** (v7.45.1) — logout ne sert plus la page « hors ligne » (le fetch event de navigation est en `redirect:'manual'` → 302 lue `opaqueredirect` → rejet ; rejoué en `redirect:'follow'` + **302 synthétique vers l'URL finale**, Chromium refusant les responses `redirected` servies à une navigation) et drag & drop de fichiers/dossiers réparé (`_doUpload` + `toggleFavorite` sans `X-CSRF-Token` = oublis A19, derniers appels mutants du front). `timeoutFetch` annule enfin ses requêtes. Gate `e2e/regression_logout_dnd.spec.js` (2 tests verts).
|
||||||
|
- [x] **Navigation partielle Alpine** (v7.45.2) — les swaps fdLoad initialisaient `.main-wrapper` avant l'exécution des scripts de page (courses : « Undefined variable » sur l'éditeur, `reading 'has'` sur `$store.fdCtx`, `SyntaxError: 'LW' already declared` à la 2ᵉ visite). Fix maison dans `app.js` : `x-ignore` sur la zone swapée (nœud remplacé uniquement) → démontage unique à tous `<script src>` chargés via `Alpine.initTree` idempotent ; garde de fichier sur 5 scripts à `const` de haut niveau ; `if (window.Alpine)` sur 8 scripts + 7 templates inline ; polices Inter orphelines purgées d'`app.css`. Gates : `regression_editor_mount` + `regression_partial_nav` (7 pages × complet/partiel/2ᵉ = 0 erreur).
|
||||||
|
- [x] **Flashs de navigation** (v7.45.3) — pendant la fenêtre `x-ignore` (swap fdLoad → `Alpine.initTree`), la zone `.main-wrapper` était peinte **non montée** : tous ses `[x-show]` visibles à leur valeur brute (menus/panneaux/états loading+empty+table superposés) puis masqués d'un coup au montage = la rafale de fenêtres qui flashent à chaque changement de page/section. Fix 6 lignes dans `app.js` : `opacity:0` + `pointer-events:none` posés dans `htmx:afterSwap` (même task que le swap → aucun paint intermédiaire), retirés **après** `initTree` sur les 3 chemins de démontage. Mesure avant : 19 éléments bruts/87 ms sur /library (CLS 0.149), 80 éléments/732 ms sur /settings — après : 0 frame de contenu brut. Gate maison `e2e/probe_nav_perf.spec.js` (marques htmx + fenêtre x-ignore + échantillon par frame + layout-shift).
|
||||||
|
- Suivis restants (mesurés, non traités) : CLS résiduel **0.058** sur /library (états `lib-loading`/`lib-empty` → `lib-table` : le remplacement du spinner par les lignes décale le contenu — nécessiterait un squelette de lignes, pas un réglage CSS) · fenêtre `x-ignore` de **~150–300 ms** sur /settings (coût des scripts de page avant `initTree`, pas de gain sans changer l'ordre de montage) · la **topbar est dans `.main-wrapper`** (base.html) → re-swappée et re-montée à chaque navigation (avec le masquage elle disparaît/revient au lieu de flasher — la sortir demanderait de la swapper séparément).
|
||||||
|
- Suite v7.45.4 : `x-cloak` posé sur `.app-layout` (racine Alpine) → les **chargements complets** (F5, SW, hors ligne) ne peignent plus la sidebar brute ni la bande rouge hors ligne — le masquage fdLoad ne couvrait que les swaps partiels. `fd-navigating` (classe sans règle CSS) supprimée. Probe étendu à 4 scénarios (nav / clic Home réel / Home hors ligne / plein chargement) avec assertions. **Symptôme Home non rejouable** en navigateur frais (0 frame brut, 0 erreur console) → à confirmer sur le navigateur signalé.
|
||||||
|
- [x] **Poubelle** (v7.45.5) — page /trash alignée sur ce que fait ce type de section : **sélection multiple** (select all porté au filtre courant + barre Restore/Delete/Clear), **Empty Trash** (`POST /board/api/trash/empty`, purge en masse en 1 requête), **tri** (récents/anciens/nom) et **filtre emplacement** réels (les deux boutons décoratifs « Last edited by ▾ » / « In ▾ » qui ne faisaient rien sont remplacés), **date de suppression + jours restants** par élément (rétention 30 j), compteur de résultats, états vides distincts, toasts sur chaque action. 4 bugs trouvés en route : (1) **Restore/Delete ne marchaient jamais** (`getCsrf()` = chaîne mais code lu `csrf?.[1]` → 403 CSRF silencieux), (2) **« Move to Trash » de l'éditeur = 404** (route `/board/api/pages/{id}/trash` inexistante, `.then()` naviguait quand même → la page n'allait jamais à la poubelle), (3) **page restaurée invisible** en Library/Recents/Private (`parent_section='Trash'` réécrit au delete, jamais remis → `deleted_at` = source de vérité unique + réparation au boot), (4) **routes trash sans auth** (401 ajouté, vérifié anonyme). Gates : `tests/test_trash_api.py` (5) + `e2e/trash_ui.spec.js` (1) + suite 1099 passed.
|
||||||
|
- [x] **Fenêtre de confirmation thématisée** (v7.45.6) — les 3 confirmations destructives de /trash (suppression unitaire, groupée, **Empty Trash**) ne passent plus par le `confirm()` du navigateur : fenêtre FlowDeck réutilisant les classes `flowdeck-modal*` déjà présentes dans `base.html` (variables du thème, overlay cliquable + Échap pour annuler, Cancel + bouton de confirmation coloré porteur du libellé exact, titre/message avec le nom de la page ou le nombre de pages, `role="alertdialog"`). Composant dans `trashData()` : `askConfirm` / `cancelConfirm` / `acceptConfirm` (mutations de propriétés individuelles, pitfall réactivité Alpine). Gate e2e : ouverture + titre + message + **annulation (page toujours là)** + confirmation (contenu réellement effacé) + purge, et le test échoue si un dialog natif revient (`NATIVE_DIALOG`).
|
||||||
|
|
||||||
### v7.1.0 — Calendar sync + Meeting Notes ✅ (2026-09-28)
|
### v7.1.0 — Calendar sync + Meeting Notes ✅ (2026-09-28)
|
||||||
> **Objectif** : calendrier bidirectionnel + transcription → agents (cf. Notion 07/2026 : Meeting Notes trigger Custom Agents).
|
> **Objectif** : calendrier bidirectionnel + transcription → agents (cf. Notion 07/2026 : Meeting Notes trigger Custom Agents).
|
||||||
@@ -1047,6 +1408,62 @@ Détails livrés :
|
|||||||
- [x] **Tests follow-ups** — `tests/test_v73_wiki_polish.py` (58 → **72 tests**) : sidebar cross-workspace, page teamspace (owner 200 / outsider 404 API), auto-follow comment, notif throttlée, unfurl gitea/github + endpoint, KPI + dashboards multi-DB
|
- [x] **Tests follow-ups** — `tests/test_v73_wiki_polish.py` (58 → **72 tests**) : sidebar cross-workspace, page teamspace (owner 200 / outsider 404 API), auto-follow comment, notif throttlée, unfurl gitea/github + endpoint, KPI + dashboards multi-DB
|
||||||
- [x] **Suite complète** — `python -m pytest -n auto` → **1016 passed** (était 981 passed / 21 failed en SSO)
|
- [x] **Suite complète** — `python -m pytest -n auto` → **1016 passed** (était 981 passed / 21 failed en SSO)
|
||||||
|
|
||||||
|
### v7.62.0 — Menu contextuel au highlight de texte ✅ (2026-10-08)
|
||||||
|
> **Objectif** : menu façon Notion (mode sombre) au surlignage de texte dans l'éditeur. **COMPLETED**.
|
||||||
|
|
||||||
|
- [x] **Toolbar de sélection 4 lignes** — `T` + type de bloc dynamique + `›` (sous-menu Turn into existant) / `A` `B` `I` `U` `T` barré `Tx` / 🔗 `S` `</>` `√x` `…` / 💬 `✨`, section grise **Skills** (Improve writing, Proofread, Explain, Reformat → `runSkill`) et pied **Edit with AI** `Alt+⇧+E` — affichée **sous** la sélection (au-dessus si pas de place), clampée au viewport, fermeture au clic hors sélection
|
||||||
|
- [x] **`…` → menu contextuel de bloc** déjà existant (recherche « Search actions… », groupe Text, 10 actions + raccourcis, pied métadonnées auteur/heure/taille) — pas de doublon de menu
|
||||||
|
- [x] **Persistance du formatage inline** — sérialiseur `gtMd()` (DOM → source markdown) branché sur `sync()` ; `mdEsc()` rend `__…__`, `==…==`, `$$…$$` (KaTeX inline) — avant, `sync()` relisait du texte brut et perdait tout le formatage au prochain rendu
|
||||||
|
- [x] **Info-bulle claire à droite** — `data-tooltip-html` + `.tooltip-light` dans le tooltip global (`app.js`)
|
||||||
|
- [x] **Fix `askAI(idx)` écrasé par `askAI()`** — « Ask AI » / `Ctrl+J` ouvraient l'IA de page → `askAIBlock(idx)`
|
||||||
|
- [x] **Fix « toolbar muette sous Alpine v3 »** — `ed.__x` (API v2) n'existe plus : `showFmt`/`hideCommentBtn`/mentions ne s'exécutaient jamais → accès via `_xed()` (`_x_dataStack[0]`) ; ancre de sélection élément gérée ; `checkMd()` recevait un id au lieu d'un index (exception à chaque espace)
|
||||||
|
- [x] **Tests** — `tests/test_v762_selection_toolbar.py` (roundtrip `gtMd`/`mdEsc` réel sous node) + **e2e Playwright** `e2e/v762_selection_toolbar.spec.js` (structure, sous-menus, géométrie, persistance bold/highlight après reload) ; `VERSION` + `app/main.py` = 7.62.0
|
||||||
|
|
||||||
|
### v7.63.0 — Menus mobiles façon Notion ✅ (2026-10-08)
|
||||||
|
> **Objectif** : parité des menus mobiles (Android sombre) — barre contextuelle au
|
||||||
|
> « / » + menu « Insert block » plein écran. **COMPLETED**.
|
||||||
|
|
||||||
|
- [x] **Barre horizontale défilante** sous le curseur (mobile ≤ 768 px) — 15 boutons
|
||||||
|
dans l'ordre Notion : `+▾`, `Turn into▾`, `@`, 💬, 🖼, 🗑, `←→↑↓`, `↩↪`, `Color▾`,
|
||||||
|
`More▾`, `⌨` ; `overflow-x:auto`, fermeture auto quand le `/` disparaît, taps
|
||||||
|
exclus du handler « clic hors-zone »
|
||||||
|
- [x] **Feuille plein écran « Insert block »** (`+`) — titre centré + « Cancel » bleu,
|
||||||
|
groupes CMDS avec raccourcis markdown (items partagés avec la popup desktop) ;
|
||||||
|
`Turn into` / `Color` réutilisent les générateurs extraits (`_blockTurnItems`,
|
||||||
|
`_blockColorItems`) ; `More` ouvre le menu de bloc existant
|
||||||
|
- [x] **`moveBlock(bid, dir)`** — ↑↓ ordre (racine + enfants toggle/columns),
|
||||||
|
→ indent dans le frère précédent, ← outdent, garde-fous toast
|
||||||
|
- [x] **Raccourcis slash** alignés sur les préfixes markdown (`-`, `[]`, `>`, `---`)
|
||||||
|
- [x] **Tests** — `tests/test_v763_mobile_menus.py` (+ `moveBlock` exécuté sous node),
|
||||||
|
e2e mobile `e2e/v763_mobile_menus.spec.js` (390×844) ; `VERSION` + `app/main.py`
|
||||||
|
= 7.63.0
|
||||||
|
|
||||||
|
### v7.64.0 — Commentaires & réactions façon Notion ✅ (2026-10-08)
|
||||||
|
> **Objectif** : affichage/prise de commentaire identique à Notion + réaction emoji
|
||||||
|
> sur le texte sélectionné. **COMPLETED**.
|
||||||
|
|
||||||
|
- [x] **Réaction sur texte** — bouton 🙂+ (« React to selected text ») dans la
|
||||||
|
toolbar de sélection → sélecteur d'émoji **existant** en mode réaction (en-tête =
|
||||||
|
texte sélectionné + ✋, onglets masqués, Recent au-dessus de People, Filter…,
|
||||||
|
catégories + `+` custom) ; plage jaune + pastille (emoji + compte), clic pastille
|
||||||
|
= rouvrir le sélecteur pour la plage
|
||||||
|
- [x] **Backend** — migration 37 `text_reactions` (UNIQUE plage × emoji × user) +
|
||||||
|
`GET/POST /api/pages/{id}/reactions` (GET groupé, POST toggle) ; `gtTok`/`gtMd`
|
||||||
|
ignorent les pastilles et `_srcOffset` exclut les annotations des offsets → le
|
||||||
|
texte sauvé est identique
|
||||||
|
- [x] **Ancres jaunes** — `_paintAnchors()` après chaque `render()`/chargement ;
|
||||||
|
clic sur l'ancre → tiroir ouvert + fil centré (flash) ; résolu = gris
|
||||||
|
- [x] **Tiroir refondu** — carte de fil (avatar, nom, horodatage, ✓ toggle
|
||||||
|
resolve/reopen, ⋯ → Copy link `#cmt-<id>` / Delete), saisie « Add a comment... »
|
||||||
|
avec `@` et envoi `↑`/Entrée ; `resolveComment(c)` corrige l'impossibilité de
|
||||||
|
rouvrir
|
||||||
|
- [x] **Tests** — `tests/test_v764_comments_reactions.py` (8, dont toggle API réel),
|
||||||
|
e2e `e2e/v764_comments_reactions.spec.js` ; `VERSION` + `app/main.py` = 7.64.0
|
||||||
|
- [x] **v7.64.1 — Fix Delete sur bloc vide** : la garde « texte vide → supprimer
|
||||||
|
le bloc » couvre `Backspace` **et** `Delete` (une seule branche dans `onKd`),
|
||||||
|
vide jugé via `gtTok()` (ignore les pastilles) ; e2e
|
||||||
|
`e2e/v7641_delete_key.spec.js`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
|
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
|
||||||
@@ -1137,13 +1554,13 @@ Quality DB views, Agent IA Palette → Realtime + E
|
|||||||
- [x] **A17 — Router legacy `/api` qui mute sans auth** : `move_card` (`api.py:98`), `set_col_mapping` (177), `delete_col_mapping` (207), `create_issue`/`update_issue` (281/322), `delete_checklist[_item]` (522/531), `PUT /users/me` (558) → seul garde = `_check_rate_limit`. *Fix : un `dependencies=[Depends(...)]` au niveau du router (session **ou** Bearer). Effort : **S**.*
|
- [x] **A17 — Router legacy `/api` qui mute sans auth** : `move_card` (`api.py:98`), `set_col_mapping` (177), `delete_col_mapping` (207), `create_issue`/`update_issue` (281/322), `delete_checklist[_item]` (522/531), `PUT /users/me` (558) → seul garde = `_check_rate_limit`. *Fix : un `dependencies=[Depends(...)]` au niveau du router (session **ou** Bearer). Effort : **S**.*
|
||||||
- [x] **A18 — Collection publiée quelconque + stocké XSS** : `GET /workspace/public/{collection_id}` (`workspace.py:699-719`) « no auth required », **ignore les flags `restricted/private`**, et interpole `coll['name']`/`p['title']` dans un `HTMLResponse(f"""…""")` sans `html.escape`. *Fix : respecter les flags de partage + `html.escape`. Effort : **S**.*
|
- [x] **A18 — Collection publiée quelconque + stocké XSS** : `GET /workspace/public/{collection_id}` (`workspace.py:699-719`) « no auth required », **ignore les flags `restricted/private`**, et interpole `coll['name']`/`p['title']` dans un `HTMLResponse(f"""…""")` sans `html.escape`. *Fix : respecter les flags de partage + `html.escape`. Effort : **S**.*
|
||||||
- [x] **A19 — Liste CSRF trop large (34 préfixes) — TERMINÉ 2026-09-30 : 17 préfixes retirés, aucun préfixe cookie-auth n'est plus exempté** (12 en v7.3.5 après scan + 5 en v7.3.6 après équipement des 46 call sites) (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding`) puis les 5 derniers en v7.3.6 (46 call sites équipés de `X-CSRF-Token` dans agent_panel, settings, local_workspace, library, gitea_workspace, workspace, workspaces, welcome) — il ne reste que du Bearer/callback/public/infra : `csrf.py:21,25` couvre `/api/v2`, `/api/admin`, `/db/`, `/workspace`, `/api/user`, `/api/settings`, `/board/api/pages`, `/api/local-workspace`, `/api/comments`, `/api/agent`, `/api/automations`, `/auth/2fa` — tous **cookie-auth**. Seul `/scim/v2` est justifié par le commentaire de la ligne 19-20. Bonus : `/api/workspace` exempt aussi `/api/workspaces/*`. Filet restant = `SameSite=Lax` par défaut (jamais déclaré explicitement dans `main.py:150`). *Fix : garder un petit ensemble SAFE (webhooks, `/api/v1`, `/api/v2` Bearer, `/scim/v2`, callbacks OAuth/SSO) + ancrer les préfixes ; ajouter le header sur les 49 `fetch()` concernés (helper `csrfFetch` existe déjà : `base.html:892`). Effort : **M**.*
|
- [x] **A19 — Liste CSRF trop large (34 préfixes) — TERMINÉ 2026-09-30 : 17 préfixes retirés, aucun préfixe cookie-auth n'est plus exempté** (12 en v7.3.5 après scan + 5 en v7.3.6 après équipement des 46 call sites) (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding`) puis les 5 derniers en v7.3.6 (46 call sites équipés de `X-CSRF-Token` dans agent_panel, settings, local_workspace, library, gitea_workspace, workspace, workspaces, welcome) — il ne reste que du Bearer/callback/public/infra : `csrf.py:21,25` couvre `/api/v2`, `/api/admin`, `/db/`, `/workspace`, `/api/user`, `/api/settings`, `/board/api/pages`, `/api/local-workspace`, `/api/comments`, `/api/agent`, `/api/automations`, `/auth/2fa` — tous **cookie-auth**. Seul `/scim/v2` est justifié par le commentaire de la ligne 19-20. Bonus : `/api/workspace` exempt aussi `/api/workspaces/*`. Filet restant = `SameSite=Lax` par défaut (jamais déclaré explicitement dans `main.py:150`). *Fix : garder un petit ensemble SAFE (webhooks, `/api/v1`, `/api/v2` Bearer, `/scim/v2`, callbacks OAuth/SSO) + ancrer les préfixes ; ajouter le header sur les 49 `fetch()` concernés (helper `csrfFetch` existe déjà : `base.html:892`). Effort : **M**.*
|
||||||
- [x] **A20 — CSP sans filet — PARTIEL : `script-src 'unsafe-inline' 'unsafe-eval'`** (`security.py:67`) → aucun nonce/hash ; combiné à A10, chaque sink XSS ci-dessus tourne sans violation CSP. *Fix : externaliser le JS inline (A27), passer à `'nonce-…'`, retirer `'unsafe-eval'`, resserrer `img-src`/`connect-src`.* — **fait 2026-10-01 (nonce)** : `script-src 'self' 'unsafe-eval' 'nonce-<per-request>'` — `unsafe-inline` retiré de script-src (fin des XSS injectés en JS) ; ContextVar `CSP_NONCE` posée par le middleware avant `call_next`, lue par `{{ csp_nonce() }}` (38 tags inline dans les templates + `_with_nonce()` pour la constante `LOCAL_LOGIN_HTML` + 3 scripts Python dans collections.py) ; htmx re-çoit le nonce via `<meta name="htmx-config">` (réponses boostées) ; les 74 handlers `onclick=` restent couverts par `script-src-attr 'unsafe-inline'` ; chart.js/leaflet (CDN, déjà utilisés par les vues chart/map et BLOQUÉS par CSP depuis toujours) ajoutés à `script-src`/`style-src` avec commentaire `ponytail:` (upgrade : vendoriser). **Phase 2 faite 2026-10-01** : **CDN vendorisé + connect-src fermé**. chart.js 4.5.1, leaflet 1.9 (js/css + 5 png) téléchargés vers `static/js/vendor/` (déjà ignoré par eslint) ; les 3 URL de `collections.py` pointent en local ; **`script-src` n'a plus aucun hôte tiers** (jsdelivr/unpkg retirés), idem `style-src` ; **`connect-src` = `'self' ws://{host} wss://{host}`** (Host de la requête, caractères filtrés — uvicorn rejette déjà les Host invalides) : le `https:` universel (canal d'exfil) et les `ws:`/`wss:` tout-hôtes disparaissent ; grep négatif = **0 fetch cross-origin côté front** ; Google Fonts étaient **morts** dans la CSP (0 ref) → retirés ; `img-src https:` **gardé volontairement** (unfurls YouTube/Vimeo… + tuiles OSM inénumérables, `ponytail:` commenté dans le code). Test `test_csp_no_cdn_and_vendor` (CSP sans CDN, connect-src exact, 4 assets vendor 200, source collections.py) + `test_view_chart_renders` mis à jour (vendor path) → 1090/1090. **Reste A20 → phase 3 scopée par probes (2026-10-01)** : htmx = **FINI** (`allowEval:false` dans le meta htmx-config — 0 `hx-on`/`hx-vars`/`hx-vals` grep) ; **`unsafe-eval` reste uniquement pour Alpine standard**. Le build `@alpinejs/csp` a été téléchargé et **testé** (0 `eval`/`new Function`, parseur maison, tourne sous CSP strict) mais est **bloqué** sur FlowDeck : (a) **13 expressions non parsables** par la grammaire restreinte (arrows ×2, `typeof` ×1, `new Date` ×4, optional-chaining ×6 — base, library, local_workspace, settings, gitea_workspace), (b) **24 `x-html` réactifs** (icônes SVG `getSvgIcon`/`_fileIcon`, markdown agent, preview) = **interdits** par le build CSP (innerHTML), (c) le scope des expressions CSP = **données du composant uniquement** (0 variable globale ni `document` — probe : `Undefined variable: fmtDate`) → chaque site devient une méthode enregistrée via `Alpine.data`. **Plan** : migration composant par composant avec gate E2E dédiée par surface, puis retrait `unsafe-eval`.
|
- [x] **A20 — CSP sans filet — ✅ TERMINÉ 2026-10-01 (v7.43.0)** : `script-src = 'self' 'nonce-…'` (**plus aucun `unsafe-eval`**) (`security.py:67`) → aucun nonce/hash ; combiné à A10, chaque sink XSS ci-dessus tourne sans violation CSP. *Fix : externaliser le JS inline (A27), passer à `'nonce-…'`, retirer `'unsafe-eval'`, resserrer `img-src`/`connect-src`.* — **fait 2026-10-01 (nonce)** : `script-src 'self' 'unsafe-eval' 'nonce-<per-request>'` — `unsafe-inline` retiré de script-src (fin des XSS injectés en JS) ; ContextVar `CSP_NONCE` posée par le middleware avant `call_next`, lue par `{{ csp_nonce() }}` (38 tags inline dans les templates + `_with_nonce()` pour la constante `LOCAL_LOGIN_HTML` + 3 scripts Python dans collections.py) ; htmx re-çoit le nonce via `<meta name="htmx-config">` (réponses boostées) ; les 74 handlers `onclick=` restent couverts par `script-src-attr 'unsafe-inline'` ; chart.js/leaflet (CDN, déjà utilisés par les vues chart/map et BLOQUÉS par CSP depuis toujours) ajoutés à `script-src`/`style-src` avec commentaire `ponytail:` (upgrade : vendoriser). **Phase 2 faite 2026-10-01** : **CDN vendorisé + connect-src fermé**. chart.js 4.5.1, leaflet 1.9 (js/css + 5 png) téléchargés vers `static/js/vendor/` (déjà ignoré par eslint) ; les 3 URL de `collections.py` pointent en local ; **`script-src` n'a plus aucun hôte tiers** (jsdelivr/unpkg retirés), idem `style-src` ; **`connect-src` = `'self' ws://{host} wss://{host}`** (Host de la requête, caractères filtrés — uvicorn rejette déjà les Host invalides) : le `https:` universel (canal d'exfil) et les `ws:`/`wss:` tout-hôtes disparaissent ; grep négatif = **0 fetch cross-origin côté front** ; Google Fonts étaient **morts** dans la CSP (0 ref) → retirés ; `img-src https:` **gardé volontairement** (unfurls YouTube/Vimeo… + tuiles OSM inénumérables, `ponytail:` commenté dans le code). Test `test_csp_no_cdn_and_vendor` (CSP sans CDN, connect-src exact, 4 assets vendor 200, source collections.py) + `test_view_chart_renders` mis à jour (vendor path) → 1090/1090. **Reste A20 → phase 3 scopée par probes (2026-10-01)** : htmx = **FINI** (`allowEval:false` dans le meta htmx-config — 0 `hx-on`/`hx-vars`/`hx-vals` grep) ; **`unsafe-eval` reste uniquement pour Alpine standard**. Le build `@alpinejs/csp` a été téléchargé et **testé** (0 `eval`/`new Function`, parseur maison, tourne sous CSP strict) mais est **bloqué** sur FlowDeck : (a) **13 expressions non parsables** par la grammaire restreinte (arrows ×2, `typeof` ×1, `new Date` ×4, optional-chaining ×6 — base, library, local_workspace, settings, gitea_workspace), (b) **24 `x-html` réactifs** (icônes SVG `getSvgIcon`/`_fileIcon`, markdown agent, preview) = **interdits** par le build CSP (innerHTML), (c) le scope des expressions CSP = **données du composant uniquement** (0 variable globale ni `document` — probe : `Undefined variable: fmtDate`) → chaque site devient une méthode enregistrée via `Alpine.data`. **Plan** : migration composant par composant avec gate E2E dédiée par surface, puis retrait `unsafe-eval`.
|
||||||
**LOT 1 fait 2026-10-01 (v7.38.0)** : (1) **harnais `e2e/csp_preview.spec.js`** — sert le build CSP à la place de `alpine.min.js` par interception Playwright (0 déploiement, tout parse-error = pageerror) ; (2) **les 16 composants `x-data="fn()"` non enregistrés → `Alpine.data`** (registre = seule résolution du build CSP) ; (3) **shell base.html migré** : x-effect document → `syncSidebarClass()`, $nextTick arrow → `initSidebarSort()`, `window.FlowDeck.*` → méthodes, Object/Math/window dans x-for/:style → `sidebarSections()`/`sectionMenuPos()` ; (4) **x-html du shell migrés** → `x-init`+`Alpine.effect` (agent, carte projet, library ×3) + recherche library (`toggleSearch`) ; (5) **eslint 0/0 restauré** (globals `getCsrf` A38 + `;;` résiduels) ; **gate library VERT** (0 pageerror, icônes + recherche vérifiées) → 1093/1093.
|
**LOT 1 fait 2026-10-01 (v7.38.0)** : (1) **harnais `e2e/csp_preview.spec.js`** — sert le build CSP à la place de `alpine.min.js` par interception Playwright (0 déploiement, tout parse-error = pageerror) ; (2) **les 16 composants `x-data="fn()"` non enregistrés → `Alpine.data`** (registre = seule résolution du build CSP) ; (3) **shell base.html migré** : x-effect document → `syncSidebarClass()`, $nextTick arrow → `initSidebarSort()`, `window.FlowDeck.*` → méthodes, Object/Math/window dans x-for/:style → `sidebarSections()`/`sectionMenuPos()` ; (4) **x-html du shell migrés** → `x-init`+`Alpine.effect` (agent, carte projet, library ×3) + recherche library (`toggleSearch`) ; (5) **eslint 0/0 restauré** (globals `getCsrf` A38 + `;;` résiduels) ; **gate library VERT** (0 pageerror, icônes + recherche vérifiées) → 1093/1093.
|
||||||
**LOT 2 fait 2026-10-01 (v7.39.0)** : **settings + local_workspace VERTS** en csp_preview (3/5 surfaces) — voir CHANGELOG pour les 2 pièges structurels résolus (snapshot `ji` = objet `window` pré-boot banni → const lexicale partagée ; bloc preview hors racine → composant `wsPreview` déléguant, wrapper unique à cause des magics) + `RATE_LIMIT_REQUESTS=600` en `.env` local (rafales E2E vs 60/min).
|
**LOT 2 fait 2026-10-01 (v7.39.0)** : **settings + local_workspace VERTS** en csp_preview (3/5 surfaces) — voir CHANGELOG pour les 2 pièges structurels résolus (snapshot `ji` = objet `window` pré-boot banni → const lexicale partagée ; bloc preview hors racine → composant `wsPreview` déléguant, wrapper unique à cause des magics) + `RATE_LIMIT_REQUESTS=600` en `.env` local (rafales E2E vs 60/min).
|
||||||
**LOT 3a fait 2026-10-01 (v7.40.0)** : gate « surfaces simples » = **welcome, trash, accounts, workspace, import VERTS** (4 sans aucune modification — scan propre + registres lot 1) + **fix bug pré-existant `/import`** (x-text `report.relations` non garde → pageerror sous les 2 builds). **8 surfaces couvertes** au total.
|
**LOT 3a fait 2026-10-01 (v7.40.0)** : gate « surfaces simples » = **welcome, trash, accounts, workspace, import VERTS** (4 sans aucune modification — scan propre + registres lot 1) + **fix bug pré-existant `/import`** (x-text `report.relations` non garde → pageerror sous les 2 builds). **8 surfaces couvertes** au total.
|
||||||
**LOT 3b fait 2026-10-01 (v7.41.0)** : **gitea_workspace + agent_panel + page_editor VERTS** (éditeur = 15 sites : 12 `window.E` → délégués `edCall`/`edTimeAgo`/`edCommentCount`/`edShared`/`bindStar` dans appState + 3 dans `_page_editor_content` → `openBacklink`/`fmtImportSize`/`bindIconHtml` ; piège Jinja : quotes `\'` dans le `set`). **12 surfaces vertes** au total (7 gates).
|
**LOT 3b fait 2026-10-01 (v7.41.0)** : **gitea_workspace + agent_panel + page_editor VERTS** (éditeur = 15 sites : 12 `window.E` → délégués `edCall`/`edTimeAgo`/`edCommentCount`/`edShared`/`bindStar` dans appState + 3 dans `_page_editor_content` → `openBacklink`/`fmtImportSize`/`bindIconHtml` ; piège Jinja : quotes `\'` dans le `set`). **12 surfaces vertes** au total (7 gates).
|
||||||
**Reste ph3** : board/table_view/teamload/card_detail (scan propre, gates liés au contexte Gitea — gitea actuellement down) → csp_preview vert partout, puis bascule réelle (retrait unsafe-eval).
|
**BASCULE RÉELLE 2026-10-01 (v7.43.0)** : `alpine.csp.min.js` servi partout (base/import/welcome + sw), `unsafe-eval` retiré de la CSP (assert test inversée), scan statique final = **0** (4 faux positifs dans des chaînes), E2E 7/7 **sous CSP réel** + suite 1094/1094. board/table_view/teamload/card_detail : scan propre, non gateés (gitea down) — à vérifier au premier usage avec gitea remonté.
|
||||||
**🐛 BUG pré-existant à analyser (trouvé v7.41.0)** : **`right_actions` du topbar servi ÉCHAPPÉ sur TOUTES les pages** (entities `"`/`<`, boutons Share/Star/Settings en texte brut — repro `curl /workspaces`); `_header:141` a bien `|safe`, l'ENV est standard, rendu local du même motif = PARSED → cause serveur à cerner (piste : valeur échappée à la construction du set). Effort : **M**. `img-src` : si un proxy d'images local arrive. Effort : **L** (reste : L, plan ci-dessus).
|
**🐛 BUG TOPBAR — CORRIGÉ 2026-10-01 (v7.42.0)** : cause racine = `'literal' ~ fd_icon(…)` — `fd_icon` = macro → `Markup`, et `Markup.__radd__/__add__` **échappe les segments `str` littéraux** → boutons en entities partout ; `|safe` no-op sur Markup déjà échappé. Fix : **block-set** `{% set right_actions %}…{{ fd_icon() }}…{% endset %}` dans les 5 templates (gitea, page_editor ×2, workspace, workspaces) + **test permanent** `tests/test_topbar_right_actions.py` + assertion `.star-btn` rétablie dans le gate éditeur. Probable régression depuis A10 (autoescape). `img-src` : si un proxy d'images local arrive. Effort : **L** (reste : L, plan ci-dessus).
|
||||||
- [x] **A21 — `sqlite3` synchrone sur l'event loop — PARTIEL 2026-09-30 → phase 1 le 2026-10-01 : `PRAGMA busy_timeout=5000` ajouté au point d'entrée unique `get_conn()` (db.py)** ; reste le wrapper async `anyio.to_thread` + la migration des 510 call sites : `get_conn()` (`db.py:833-843`) est synchrone et **510 des 689 `async def` de routes** l'appellent (805 occurrences au total ; 0 `run_in_threadpool`, 1 seul `asyncio.to_thread` dans tout le dépôt : `semantic_search.py:262`) ; connexion neuve par requête (`connect` + 2 PRAGMA), **aucun `busy_timeout`**. Chaque requête bloque la boucle. *Fix : wrapper async (`anyio.to_thread.run_sync`) partagé, migrer d'abord `api_v2`/`dashboard`/`collections`/`board` + `PRAGMA busy_timeout=5000`.* — **phase 1 faite 2026-10-01** : **352 routes `async def` SANS aucun `await`** converties en `def` (scan corps par corps : ni `await`/`async with`/`async for`, ni `asyncio`) → FastAPI les exécute dans son threadpool, donc tout leur travail SQLite quitte l'event loop, sans changer une ligne de logique (api_v2 : 60, dashboard : 40, collections : 25, board : 23, + main.py : 6 ; aucune occurrence `asyncio`/`run_coroutine` dans les corps convertis). **Phase 2a faite 2026-10-01 (api_v2)** : les routes dont le SEUL await était `body = await request.json()` (36) → paramètre FastAPI `body: dict = Body(default={})` (parsing fait par FastAPI avant l'appel, équivalences vérifiées : corps absent → `{}` comme le try/except d'avant, JSON invalide → 422 au lieu d'un silencieux `{}`) + conversion en `def` → **api_v2 passe à 96/115 routes hors loop**. **Phase 2b faite 2026-10-01 (api_v2 bouclé)** : helper `run_event_sync(coro)` (automations.py) — `asyncio.run` sur une boucle dédiée dans le worker threadpool : l'événement est EXÉCUTÉ ET ATTENDU avant la réponse (déterministe comme l'await) mais ne bloque jamais la boucle ; les 15 routes dont les seuls awaits étaient `json`/`_fire_event`/`fire_published`/`fire_unpublished` passent en `def`. **api_v2 : 111/115 routes hors loop**, les 4 restantes ont de vrais awaits réseau (`import_csv_v2` multipart, `project_tree_v2` (gitea), `test_webhook_v2`, `retry_webhook_deliveries`). **Phase 2c faite 2026-10-01 (repo-wide)** : **283 → 93 routes async** (**86 % des 667 routes hors loop**, avant 61 %) en 4 passes — (A) racine auth : `get_current_user` (session.py) + ses clones async (`agent._current_user_id/_workspace_id/_current_admin`, `sso._require_admin`) étaient `async` **sans aucun await** → `def`, **47 `await` supprimés** dont 3 via l'alias `gcu` (piège : grep littéral aveugle, rattrapé par la suite) ; (B) re-scan → 19 routes flipées ; (C/D) **155 routes** `request.json`/événements → `Body(default={})` (3 formes : try/except `body = {}` intact, try/except `raise HTTPException(400)` → `Body(...)` requis (422 FastAPI, **0 test sur le 400**), forme conditionnelle content-type ×54 → défaut `{}`) + `run_event_sync` → `def`. **Reste async (93, justifié)** : form/upload/file (22), gitea/llm/oidc réseau (~25), `_json_body` 9, 2 JSON inline en argument, 1 fallback logique, 1 lecture conditionnelle web_clipper, + mixtes json+réseau. **A21 : fait** (sauf l'idée initiale d'`anyio.to_thread` par bloc DB — **inutile** : les routes sont DÉJÀ hors loop, le SQLite synchrone n'est plus sur la boucle). Effort : **L** (fait).
|
- [x] **A21 — `sqlite3` synchrone sur l'event loop — PARTIEL 2026-09-30 → phase 1 le 2026-10-01 : `PRAGMA busy_timeout=5000` ajouté au point d'entrée unique `get_conn()` (db.py)** ; reste le wrapper async `anyio.to_thread` + la migration des 510 call sites : `get_conn()` (`db.py:833-843`) est synchrone et **510 des 689 `async def` de routes** l'appellent (805 occurrences au total ; 0 `run_in_threadpool`, 1 seul `asyncio.to_thread` dans tout le dépôt : `semantic_search.py:262`) ; connexion neuve par requête (`connect` + 2 PRAGMA), **aucun `busy_timeout`**. Chaque requête bloque la boucle. *Fix : wrapper async (`anyio.to_thread.run_sync`) partagé, migrer d'abord `api_v2`/`dashboard`/`collections`/`board` + `PRAGMA busy_timeout=5000`.* — **phase 1 faite 2026-10-01** : **352 routes `async def` SANS aucun `await`** converties en `def` (scan corps par corps : ni `await`/`async with`/`async for`, ni `asyncio`) → FastAPI les exécute dans son threadpool, donc tout leur travail SQLite quitte l'event loop, sans changer une ligne de logique (api_v2 : 60, dashboard : 40, collections : 25, board : 23, + main.py : 6 ; aucune occurrence `asyncio`/`run_coroutine` dans les corps convertis). **Phase 2a faite 2026-10-01 (api_v2)** : les routes dont le SEUL await était `body = await request.json()` (36) → paramètre FastAPI `body: dict = Body(default={})` (parsing fait par FastAPI avant l'appel, équivalences vérifiées : corps absent → `{}` comme le try/except d'avant, JSON invalide → 422 au lieu d'un silencieux `{}`) + conversion en `def` → **api_v2 passe à 96/115 routes hors loop**. **Phase 2b faite 2026-10-01 (api_v2 bouclé)** : helper `run_event_sync(coro)` (automations.py) — `asyncio.run` sur une boucle dédiée dans le worker threadpool : l'événement est EXÉCUTÉ ET ATTENDU avant la réponse (déterministe comme l'await) mais ne bloque jamais la boucle ; les 15 routes dont les seuls awaits étaient `json`/`_fire_event`/`fire_published`/`fire_unpublished` passent en `def`. **api_v2 : 111/115 routes hors loop**, les 4 restantes ont de vrais awaits réseau (`import_csv_v2` multipart, `project_tree_v2` (gitea), `test_webhook_v2`, `retry_webhook_deliveries`). **Phase 2c faite 2026-10-01 (repo-wide)** : **283 → 93 routes async** (**86 % des 667 routes hors loop**, avant 61 %) en 4 passes — (A) racine auth : `get_current_user` (session.py) + ses clones async (`agent._current_user_id/_workspace_id/_current_admin`, `sso._require_admin`) étaient `async` **sans aucun await** → `def`, **47 `await` supprimés** dont 3 via l'alias `gcu` (piège : grep littéral aveugle, rattrapé par la suite) ; (B) re-scan → 19 routes flipées ; (C/D) **155 routes** `request.json`/événements → `Body(default={})` (3 formes : try/except `body = {}` intact, try/except `raise HTTPException(400)` → `Body(...)` requis (422 FastAPI, **0 test sur le 400**), forme conditionnelle content-type ×54 → défaut `{}`) + `run_event_sync` → `def`. **Reste async (93, justifié)** : form/upload/file (22), gitea/llm/oidc réseau (~25), `_json_body` 9, 2 JSON inline en argument, 1 fallback logique, 1 lecture conditionnelle web_clipper, + mixtes json+réseau. **A21 : fait** (sauf l'idée initiale d'`anyio.to_thread` par bloc DB — **inutile** : les routes sont DÉJÀ hors loop, le SQLite synchrone n'est plus sur la boucle). Effort : **L** (fait).
|
||||||
- [x] **A22 — Validateur d'upload = code mort** : `validate_upload()` + `ALLOWED_EXTENSIONS` + `MAX_UPLOAD_SIZE` (`security.py:33-40`) n'ont **aucun appelant** ; `upload_local_workspace_file` (`dashboard.py:1507-1574`) fait `file_path.write_bytes(content)` après seulement `Path(filename).name` — ni taille, ni extension, ni auth, et `/api/local-workspace` est exclu du CSRF. Chemin aussi codé en dur `Path(f"/data/uploads/...")` au lieu de `FLOWDECK_DATA_DIR`. *Fix : appeler `validate_upload()` avant `read()` + session. Effort : **S**.*
|
- [x] **A22 — Validateur d'upload = code mort** : `validate_upload()` + `ALLOWED_EXTENSIONS` + `MAX_UPLOAD_SIZE` (`security.py:33-40`) n'ont **aucun appelant** ; `upload_local_workspace_file` (`dashboard.py:1507-1574`) fait `file_path.write_bytes(content)` après seulement `Path(filename).name` — ni taille, ni extension, ni auth, et `/api/local-workspace` est exclu du CSRF. Chemin aussi codé en dur `Path(f"/data/uploads/...")` au lieu de `FLOWDECK_DATA_DIR`. *Fix : appeler `validate_upload()` avant `read()` + session. Effort : **S**.*
|
||||||
- [x] **A23 — N+1 avérés** : `dashboard.py:905` (`COUNT(*) FROM pages` par page dans une boucle de 20), `board.py:2141-2146` (INSERT + `_extract_ai_keywords` par issue), `collections.py:378` (INSERT par propriété en boucle). *Fix : une agrégation `GROUP BY` / un `executemany`. Effort : **S**.*
|
- [x] **A23 — N+1 avérés** : `dashboard.py:905` (`COUNT(*) FROM pages` par page dans une boucle de 20), `board.py:2141-2146` (INSERT + `_extract_ai_keywords` par issue), `collections.py:378` (INSERT par propriété en boucle). *Fix : une agrégation `GROUP BY` / un `executemany`. Effort : **S**.*
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# WORKLOAD — FlowDeck Notion Clone
|
# WORKLOAD — FlowDeck Notion Clone
|
||||||
|
|
||||||
> **Début**: 2026-07-08 | **Version**: v7.41.0 (A20 ph3 LOT 3b : gitea + agent + éditeur verts — 12 sites window.E délégués; BUG pré-existant : right_actions servi échappé partout) | **Statut**: EN COURS 🔄
|
> **Début**: 2026-07-08 | **Version**: v7.64.1 (fix Delete sur bloc vide → suppression du bloc) | **Statut**: EN COURS 🔄
|
||||||
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
||||||
|
|
||||||
## Avancement Global
|
## Avancement Global
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ from app.services.http_client import shared_client
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Valeurs de substitution livrées dans app/config.py : elles sont NON VIDES,
|
||||||
|
# donc un simple `bool(client_id and client_secret)` les considérait comme
|
||||||
|
# configurées et /auth/login redirigeait vers Gitea avec client_id=test-id
|
||||||
|
# (échec d'authentification garanti). Un placeholder == provider non configuré.
|
||||||
|
_PLACEHOLDER_CREDS = {"test-id", "test-secret", "change-me", "changeme", "your-client-id"}
|
||||||
|
|
||||||
|
|
||||||
|
def _real(value: str | None) -> bool:
|
||||||
|
"""True when ``value`` is a real credential (not empty, not a placeholder)."""
|
||||||
|
return bool(value) and value.strip().lower() not in _PLACEHOLDER_CREDS
|
||||||
|
|
||||||
|
|
||||||
class OAuthProvider(ABC):
|
class OAuthProvider(ABC):
|
||||||
"""Abstract OAuth2 provider interface."""
|
"""Abstract OAuth2 provider interface."""
|
||||||
@@ -52,7 +63,7 @@ class GiteaProvider(OAuthProvider):
|
|||||||
self.redirect_uri = redirect_uri
|
self.redirect_uri = redirect_uri
|
||||||
|
|
||||||
def is_enabled(self) -> bool:
|
def is_enabled(self) -> bool:
|
||||||
return bool(self.client_id and self.client_secret)
|
return _real(self.client_id) and _real(self.client_secret)
|
||||||
|
|
||||||
def get_authorize_url(self, state: str, redirect_uri: str | None = None, force_login: bool = False) -> str:
|
def get_authorize_url(self, state: str, redirect_uri: str | None = None, force_login: bool = False) -> str:
|
||||||
params = {
|
params = {
|
||||||
@@ -144,7 +155,7 @@ class GitHubProvider(OAuthProvider):
|
|||||||
self.api_url = "https://api.github.com"
|
self.api_url = "https://api.github.com"
|
||||||
|
|
||||||
def is_enabled(self) -> bool:
|
def is_enabled(self) -> bool:
|
||||||
return bool(self.client_id and self.client_secret)
|
return _real(self.client_id) and _real(self.client_secret)
|
||||||
|
|
||||||
def get_authorize_url(self, state: str, redirect_uri: str | None = None, force_login: bool = False) -> str:
|
def get_authorize_url(self, state: str, redirect_uri: str | None = None, force_login: bool = False) -> str:
|
||||||
return (
|
return (
|
||||||
|
|||||||
+15
-2
@@ -13,6 +13,19 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
_serializer = URLSafeTimedSerializer(settings.app_secret_key)
|
_serializer = URLSafeTimedSerializer(settings.app_secret_key)
|
||||||
|
|
||||||
|
# Colonnes de la table ``users`` qui ne doivent JAMAIS quitter le serveur :
|
||||||
|
# le cookie de session est signé (HMAC) mais pas chiffré — son payload est
|
||||||
|
# lisible en base64 par quiconque détient le cookie, et ``/auth/user`` le
|
||||||
|
# renvoyait tel quel au client.
|
||||||
|
_SECRET_USER_FIELDS = ("password_hash",)
|
||||||
|
|
||||||
|
|
||||||
|
def public_user(user_data: dict | None) -> dict | None:
|
||||||
|
"""Return a copy of ``user_data`` stripped of credential material."""
|
||||||
|
if user_data is None:
|
||||||
|
return None
|
||||||
|
return {k: v for k, v in user_data.items() if k not in _SECRET_USER_FIELDS}
|
||||||
|
|
||||||
|
|
||||||
class SessionManager:
|
class SessionManager:
|
||||||
"""Manages user sessions via signed cookies (v5.2.0: revocable).
|
"""Manages user sessions via signed cookies (v5.2.0: revocable).
|
||||||
@@ -30,7 +43,7 @@ class SessionManager:
|
|||||||
``user_sessions`` table (ip + user agent) and becomes revocable.
|
``user_sessions`` table (ip + user agent) and becomes revocable.
|
||||||
"""
|
"""
|
||||||
payload = {
|
payload = {
|
||||||
"user": user_data,
|
"user": public_user(user_data),
|
||||||
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||||
}
|
}
|
||||||
user_id = user_data.get("id")
|
user_id = user_data.get("id")
|
||||||
@@ -93,7 +106,7 @@ class SessionManager:
|
|||||||
"""Re-sign a cookie keeping its session id (used after profile edits)."""
|
"""Re-sign a cookie keeping its session id (used after profile edits)."""
|
||||||
sid = SessionManager.session_id(cookie) if cookie else None
|
sid = SessionManager.session_id(cookie) if cookie else None
|
||||||
payload = {
|
payload = {
|
||||||
"user": user_data,
|
"user": public_user(user_data),
|
||||||
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||||
}
|
}
|
||||||
user_id = user_data.get("id")
|
user_id = user_data.get("id")
|
||||||
|
|||||||
@@ -126,6 +126,27 @@ class Settings(BaseSettings):
|
|||||||
agent_max_tokens_budget: int = 500000
|
agent_max_tokens_budget: int = 500000
|
||||||
agent_run_timeout_seconds: int = 300
|
agent_run_timeout_seconds: int = 300
|
||||||
|
|
||||||
|
# ── Connecteurs OAuth (v7.56.0) — Google / Microsoft 365 ──
|
||||||
|
# Client OAuth2 à créer dans les consoles : Google Cloud (OAuth client web)
|
||||||
|
# et Entra ID (application web). Le redirect URI est dérivé d'APP_BASE_URL.
|
||||||
|
google_client_id: str = ""
|
||||||
|
google_client_secret: str = ""
|
||||||
|
ms_client_id: str = ""
|
||||||
|
ms_client_secret: str = ""
|
||||||
|
|
||||||
|
# ── Mémoire de l'agent (v7.54.0) ──
|
||||||
|
# État initial du toggle « Mémoire » à la création d'une conversation ;
|
||||||
|
# le basculement se fait ensuite par PATCH /api/agent/conversations/{id}.
|
||||||
|
agent_memory_default: bool = True
|
||||||
|
|
||||||
|
# ── Web tools de l'agent (web_search / fetch_url / search_code) ──
|
||||||
|
# `web_search_provider` : « exa » (recherche sémantique + snippets, clé
|
||||||
|
# requise) ou « duckduckgo » (repli sans compte, parsing HTML — dégradé).
|
||||||
|
web_search_provider: str = "exa"
|
||||||
|
exa_api_key: str = ""
|
||||||
|
github_token: str = "" # PAT GitHub : 30 req/min au lieu de 10
|
||||||
|
web_fetch_max_chars: int = 20000 # texte renvoyé par fetch_url
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def db_path(self) -> Path:
|
def db_path(self) -> Path:
|
||||||
if self.database_url == "sqlite:///:memory:":
|
if self.database_url == "sqlite:///:memory:":
|
||||||
|
|||||||
@@ -348,6 +348,16 @@ def init_db():
|
|||||||
conn.execute("ALTER TABLE pages ADD COLUMN deleted_at TEXT")
|
conn.execute("ALTER TABLE pages ADD COLUMN deleted_at TEXT")
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
pass
|
pass
|
||||||
|
# v7.45.5 : réparation des pages restaurées marquées 'Trash' — l'ancien
|
||||||
|
# soft-delete (éditeur) réécrivait parent_section='Trash' et la
|
||||||
|
# restauration ne le remettait pas → page invisible en Library/Recents/
|
||||||
|
# Private. Idempotent (WHERE restrictif), rejoué à chaque boot.
|
||||||
|
# ponytail: un dossier restauré repasse en 'Private' (l'état d'origine
|
||||||
|
# n'est pas stocké) → icône/dossier à remettre à 'Workspace' si besoin.
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pages SET parent_section='Private' "
|
||||||
|
"WHERE parent_section='Trash' AND deleted_at IS NULL"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
conn.execute("ALTER TABLE pages ADD COLUMN share_mode TEXT DEFAULT 'private'")
|
conn.execute("ALTER TABLE pages ADD COLUMN share_mode TEXT DEFAULT 'private'")
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
|
|||||||
+13
-5
@@ -5,7 +5,7 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
from fastapi import FastAPI, Request
|
from fastapi import Depends, FastAPI, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from starlette.exceptions import HTTPException as _StarHTTPException
|
from starlette.exceptions import HTTPException as _StarHTTPException
|
||||||
@@ -62,6 +62,7 @@ from app.routers.web_clipper import router as web_clipper_router
|
|||||||
from app.routers.webauthn import router as webauthn_router
|
from app.routers.webauthn import router as webauthn_router
|
||||||
from app.routers.wiki import router as wiki_router
|
from app.routers.wiki import router as wiki_router
|
||||||
from app.routers.workers import router as workers_router
|
from app.routers.workers import router as workers_router
|
||||||
|
from app.services import plugins as plugins_service
|
||||||
from app.services.webhook_outbound import init_webhook_tables
|
from app.services.webhook_outbound import init_webhook_tables
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -185,7 +186,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="7.41.0",
|
version="7.64.1",
|
||||||
docs_url="/docs",
|
docs_url="/docs",
|
||||||
redoc_url="/redoc",
|
redoc_url="/redoc",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
@@ -224,6 +225,8 @@ app.include_router(webhooks.router)
|
|||||||
app.include_router(collections.router)
|
app.include_router(collections.router)
|
||||||
app.include_router(my_tasks.router)
|
app.include_router(my_tasks.router)
|
||||||
app.include_router(workspace.router)
|
app.include_router(workspace.router)
|
||||||
|
# Partage public :-exempt de la dépendance d'authentification du router.
|
||||||
|
app.include_router(workspace.public_router)
|
||||||
app.include_router(library.router)
|
app.include_router(library.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
app.include_router(gitea_router)
|
app.include_router(gitea_router)
|
||||||
@@ -233,7 +236,9 @@ app.include_router(sharing.router)
|
|||||||
app.include_router(sidebar_config.router)
|
app.include_router(sidebar_config.router)
|
||||||
app.include_router(export.router)
|
app.include_router(export.router)
|
||||||
app.include_router(notifications_router)
|
app.include_router(notifications_router)
|
||||||
app.include_router(automations_router)
|
# Plugin « automations » OFF → chaque route de ce router renvoie 404
|
||||||
|
app.include_router(automations_router,
|
||||||
|
dependencies=[Depends(plugins_service.plugin_required("automations"))])
|
||||||
app.include_router(collaboration_router)
|
app.include_router(collaboration_router)
|
||||||
app.include_router(emoji_router)
|
app.include_router(emoji_router)
|
||||||
app.include_router(realtime_router)
|
app.include_router(realtime_router)
|
||||||
@@ -245,8 +250,11 @@ app.include_router(sync.router)
|
|||||||
app.include_router(imports_router)
|
app.include_router(imports_router)
|
||||||
app.include_router(import_page_router)
|
app.include_router(import_page_router)
|
||||||
app.include_router(permissions_router)
|
app.include_router(permissions_router)
|
||||||
app.include_router(web_clipper_api_router)
|
# Plugin « web-clipper » OFF → page /extensions + API clip refusées
|
||||||
app.include_router(web_clipper_router)
|
app.include_router(web_clipper_api_router,
|
||||||
|
dependencies=[Depends(plugins_service.plugin_required("web-clipper"))])
|
||||||
|
app.include_router(web_clipper_router,
|
||||||
|
dependencies=[Depends(plugins_service.plugin_required("web-clipper"))])
|
||||||
app.include_router(api_v2_router)
|
app.include_router(api_v2_router)
|
||||||
app.include_router(api_v2_agent_router)
|
app.include_router(api_v2_agent_router)
|
||||||
app.include_router(sites_router)
|
app.include_router(sites_router)
|
||||||
|
|||||||
@@ -71,17 +71,15 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
|||||||
# requête — un XSS injecté dans une page ne tourne plus). Les 74 handlers
|
# requête — un XSS injecté dans une page ne tourne plus). Les 74 handlers
|
||||||
# inline `onclick=` restent couverts par `script-src-attr`, explicitement
|
# inline `onclick=` restent couverts par `script-src-attr`, explicitement
|
||||||
# détaché de script-src (sinon le nonce les désactiverait aussi).
|
# détaché de script-src (sinon le nonce les désactiverait aussi).
|
||||||
# `unsafe-eval` : Alpine STANDARD (x-data) en a besoin. htmx n'y touche
|
# A20 TERMINÉ : `unsafe-eval` retiré — Alpine tourne en build CSP
|
||||||
# plus (`allowEval: false` dans le meta htmx-config — 0 hx-on/hx-vars).
|
# (static/js/alpine.csp.min.js, 0 eval) ; htmx allowEval=false.
|
||||||
# Retrait = A20 phase 3 : build `@alpinejs/csp` (testé : 0 eval, OK sur
|
# 15 surfaces en csp_preview vert + scan statique 0 (board/gitea/cards
|
||||||
# probe) mais bloqué par 13 expressions non parsables (arrows/typeof/new/
|
# = props propres, gitea down empêche un gate dédié).
|
||||||
# ?.) + 24 `x-html` réactifs (icônes SVG) → refonte des composants en
|
|
||||||
# Alpine.data — voir ROADMAP.
|
|
||||||
CSP_VALUE = (
|
CSP_VALUE = (
|
||||||
"default-src 'self'; "
|
"default-src 'self'; "
|
||||||
# A20 phase 2 : chart.js/leaflet vendorisés dans /static/js/vendor
|
# A20 phase 2 : chart.js/leaflet vendorisés dans /static/js/vendor
|
||||||
# (test_csp_no_cdn_and_vendor), plus aucun hôte CDN tiers.
|
# (test_csp_no_cdn_and_vendor), plus aucun hôte CDN tiers.
|
||||||
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}'; "
|
"script-src 'self' 'nonce-{nonce}'; "
|
||||||
"script-src-attr 'unsafe-inline'; "
|
"script-src-attr 'unsafe-inline'; "
|
||||||
# ponytail: aucun @font-face Google (grep négatif) → les deux
|
# ponytail: aucun @font-face Google (grep négatif) → les deux
|
||||||
# hôtes fonts étaient morts, supprimés.
|
# hôtes fonts étaient morts, supprimés.
|
||||||
|
|||||||
@@ -1553,3 +1553,175 @@ def _migration_sso_enterprise_auth(conn: sqlite3.Connection) -> None:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_sso_requests_created ON sso_requests(created_at)"
|
"CREATE INDEX IF NOT EXISTS idx_sso_requests_created ON sso_requests(created_at)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@register(30, "v7.47.0: My Tasks — mapping des propriétés de base de tâches")
|
||||||
|
def _migration_my_tasks_mapping(conn: sqlite3.Connection) -> None:
|
||||||
|
"""My Tasks façon Notion : une base ne diffuse ses tâches qu'après
|
||||||
|
conversion explicite, et les trois propriétés requises sont **nommées**
|
||||||
|
(pas devinées d'après leur type).
|
||||||
|
|
||||||
|
``collections.is_task`` existait déjà mais ne mémorisait rien : My Tasks
|
||||||
|
devait deviner « la colonne personne = Assigné à » et n'avait aucun moyen
|
||||||
|
de distinguer deux bases connectées. Trois colonnes nullable REFERENCES
|
||||||
|
fixent le mapping ; suppression de la propriété → `SET NULL`, et la base
|
||||||
|
redevient « non configurée » au lieu de pointer sur du vide.
|
||||||
|
"""
|
||||||
|
cols = columns(conn, "collections")
|
||||||
|
for name, target in (
|
||||||
|
("task_assignee_prop", "collection_properties"),
|
||||||
|
("task_status_prop", "collection_properties"),
|
||||||
|
("task_due_prop", "collection_properties"),
|
||||||
|
):
|
||||||
|
if name in cols:
|
||||||
|
continue
|
||||||
|
conn.execute(
|
||||||
|
f"ALTER TABLE collections ADD COLUMN {name} INTEGER "
|
||||||
|
f"REFERENCES {target}(id) ON DELETE SET NULL"
|
||||||
|
)
|
||||||
|
# Index de lecture : « les bases de tâches de cet utilisateur ».
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_collections_task "
|
||||||
|
"ON collections(is_task, workspace_id)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@register(35, "v7.58.0: registre des plugins (on/off à effet réel)")
|
||||||
|
def _migration_plugins(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Catalogue de l'instance : 3 modules câblés (routes/UI/outils réels)."""
|
||||||
|
conn.execute(
|
||||||
|
"""CREATE TABLE IF NOT EXISTS plugins (
|
||||||
|
slug TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO plugins (slug, name, description, enabled) VALUES (?,?,?,1)",
|
||||||
|
[
|
||||||
|
("web-tools", "Outils web de l'agent",
|
||||||
|
"Retire web_search et fetch_url du registre d'outils de l'agent"),
|
||||||
|
("web-clipper", "Web Clipper",
|
||||||
|
"Coupe la page /extensions et l'API /api/v2/web-clipper/*"),
|
||||||
|
("automations", "Automations",
|
||||||
|
"Coupe /workspace/automations* et le scheduler en arrière-plan"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@register(34, "v7.57.0: connecteurs — kind/auth/tools_json (Discord, Telegram, MCP)")
|
||||||
|
def _migration_connector_kinds(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Discord (auth « Bot »), Telegram (jeton dans l'URL via `{secret}`) et
|
||||||
|
serveurs MCP (kind='mcp', cache d'outils) réutilisent la même table."""
|
||||||
|
cols = columns(conn, "agent_connectors")
|
||||||
|
if "kind" not in cols:
|
||||||
|
conn.execute("ALTER TABLE agent_connectors ADD COLUMN kind TEXT NOT NULL DEFAULT 'custom'")
|
||||||
|
if "auth" not in cols:
|
||||||
|
conn.execute("ALTER TABLE agent_connectors ADD COLUMN auth TEXT NOT NULL DEFAULT 'bearer'")
|
||||||
|
if "tools_json" not in cols:
|
||||||
|
conn.execute("ALTER TABLE agent_connectors ADD COLUMN tools_json TEXT NOT NULL DEFAULT '[]'")
|
||||||
|
|
||||||
|
|
||||||
|
@register(33, "v7.56.0: tokens OAuth des connecteurs (Google / M365)")
|
||||||
|
def _migration_connector_tokens(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Tokens OAuth par (kind, utilisateur), chiffrés Fernet côté service."""
|
||||||
|
conn.execute(
|
||||||
|
"""CREATE TABLE IF NOT EXISTS connector_tokens (
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
tokens_enc TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (kind, user_id)
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@register(32, "v7.55.0: connecteurs de l'agent (socle)")
|
||||||
|
def _migration_agent_connectors(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Catalogue de connecteurs : 3 natifs servis à la volée + personnels ici.
|
||||||
|
|
||||||
|
Colonne ``url`` plate (pas de ``config_json``) : les scopes/OAuth des
|
||||||
|
phases 6-7 ajouteront leur propre colonne le moment venu.
|
||||||
|
"""
|
||||||
|
conn.execute(
|
||||||
|
"""CREATE TABLE IF NOT EXISTS agent_connectors (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
secret_encrypted TEXT NOT NULL DEFAULT '',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
status TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
detail TEXT NOT NULL DEFAULT '',
|
||||||
|
created_by INTEGER
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@register(31, "v7.54.0: agent memory (mémoire de l'agent)")
|
||||||
|
def _migration_agent_memory(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Toggle par conversation + table des résumés mémorisés.
|
||||||
|
|
||||||
|
Une ligne résumé par conversation (upsert) — voir `app/services/agent_memory.py`.
|
||||||
|
"""
|
||||||
|
if "memory_enabled" not in columns(conn, "agent_conversations"):
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE agent_conversations "
|
||||||
|
"ADD COLUMN memory_enabled INTEGER NOT NULL DEFAULT 1"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""CREATE TABLE IF NOT EXISTS agent_memory (
|
||||||
|
conversation_id INTEGER PRIMARY KEY
|
||||||
|
REFERENCES agent_conversations(id) ON DELETE CASCADE,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'summary',
|
||||||
|
content TEXT NOT NULL DEFAULT '',
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@register(37, "v7.64.0: réactions emoji sur un texte sélectionné")
|
||||||
|
def _migration_text_reactions(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Réactions emoji ancrées sur une plage de texte (façon Notion) : miroir des
|
||||||
|
ancres de commentaires (block_id + offsets dans le texte rendu). Une ligne par
|
||||||
|
(plage, emoji, utilisateur) → le POST agit comme un toggle et le GET regroupe
|
||||||
|
par plage pour afficher la pastille d'émojis."""
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS text_reactions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||||
|
block_id TEXT NOT NULL,
|
||||||
|
anchor_start INTEGER NOT NULL,
|
||||||
|
anchor_end INTEGER NOT NULL,
|
||||||
|
emoji TEXT NOT NULL,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(page_id, block_id, anchor_start, anchor_end, emoji, user_id)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_text_reactions_page ON text_reactions(page_id)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@register(36, "v7.59.1: mistral — modèles par défaut hors plan basique")
|
||||||
|
def _migration_mistral_default_model(conn: sqlite3.Connection) -> None:
|
||||||
|
"""mistral-large-latest / pixtral-large-latest renvoient 403
|
||||||
|
« tier_not_allowed » sur les plans d'abonnement basiques : la clé est
|
||||||
|
valide mais le test de connexion et l'agent échouaient. Reset du
|
||||||
|
default_model stocké → vide = nouveau défaut du provider (small-latest,
|
||||||
|
servi par tous les plans).
|
||||||
|
"""
|
||||||
|
blocked = ("mistral-large-latest", "pixtral-large-latest")
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE user_llm_keys SET default_model='' "
|
||||||
|
"WHERE provider='mistral' AND default_model IN (?,?)",
|
||||||
|
blocked,
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE llm_config SET model='mistral-small-latest' "
|
||||||
|
"WHERE provider='mistral' AND model IN (?,?)",
|
||||||
|
blocked,
|
||||||
|
)
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ def update_user(user_id: int, request: Request, _admin=Depends(admin_required),
|
|||||||
def delete_user(user_id: int, _admin=Depends(admin_required)):
|
def delete_user(user_id: int, _admin=Depends(admin_required)):
|
||||||
"""Delete a user and cascade their data."""
|
"""Delete a user and cascade their data."""
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
|
from app.services.collection_lifecycle import (
|
||||||
|
delete_collections,
|
||||||
|
workspace_collection_ids,
|
||||||
|
)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||||
if not user:
|
if not user:
|
||||||
@@ -121,6 +125,9 @@ def delete_user(user_id: int, _admin=Depends(admin_required)):
|
|||||||
# Delete workspaces owned by this user
|
# Delete workspaces owned by this user
|
||||||
ws_rows = conn.execute("SELECT id FROM workspaces WHERE owner_id=?", (user_id,)).fetchall()
|
ws_rows = conn.execute("SELECT id FROM workspaces WHERE owner_id=?", (user_id,)).fetchall()
|
||||||
for ws in ws_rows:
|
for ws in ws_rows:
|
||||||
|
# Les bases du workspace tombent avec lui (sinon : collections
|
||||||
|
# orphelines listées par /db et My Tasks).
|
||||||
|
delete_collections(conn, workspace_collection_ids(conn, ws["id"]))
|
||||||
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws["id"],))
|
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws["id"],))
|
||||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws["id"],))
|
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws["id"],))
|
||||||
conn.execute("DELETE FROM workspaces WHERE owner_id=?", (user_id,))
|
conn.execute("DELETE FROM workspaces WHERE owner_id=?", (user_id,))
|
||||||
|
|||||||
+237
-6
@@ -7,15 +7,17 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
from datetime import UTC
|
from datetime import UTC
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, HTTPException, Request
|
from fastapi import APIRouter, Body, HTTPException, Request
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
|
||||||
|
|
||||||
from app.auth.session import get_current_user
|
from app.auth.session import get_current_user
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
from app.services import skill_gallery
|
from app.services import connectors, oauth_connectors, plugins, skill_gallery
|
||||||
from app.services.agent_engine import AgentEngine, undo_action
|
from app.services.agent_engine import AgentEngine, undo_action
|
||||||
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient
|
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient
|
||||||
from app.services.llm_config import (
|
from app.services.llm_config import (
|
||||||
@@ -199,15 +201,17 @@ def create_conversation(request: Request, body: dict = Body(default={})):
|
|||||||
ws = _workspace_id(request)
|
ws = _workspace_id(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
agent = _default_agent(conn, user_id)
|
agent = _default_agent(conn, user_id)
|
||||||
|
mem_default = 1 if settings.agent_memory_default else 0
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json, provider, model)
|
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json, provider, model, memory_enabled)
|
||||||
VALUES (?,?,?,?,?,?)""",
|
VALUES (?,?,?,?,?,?,?)""",
|
||||||
(agent["id"], user_id, body.get("title", "New conversation"),
|
(agent["id"], user_id, body.get("title", "New conversation"),
|
||||||
json.dumps({"workspace_id": ws}),
|
json.dumps({"workspace_id": ws}),
|
||||||
body.get("provider", ""), body.get("model", "")),
|
body.get("provider", ""), body.get("model", ""), mem_default),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return {"id": cur.lastrowid, "title": body.get("title", "New conversation"), "status": "created"}
|
return {"id": cur.lastrowid, "title": body.get("title", "New conversation"),
|
||||||
|
"status": "created", "memory_enabled": mem_default}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/conversations/{conversation_id}")
|
@router.get("/conversations/{conversation_id}")
|
||||||
@@ -249,6 +253,10 @@ def patch_conversation(request: Request, conversation_id: int, body: dict = Body
|
|||||||
if body.get(col) is not None:
|
if body.get(col) is not None:
|
||||||
sets.append(f"{col}=?")
|
sets.append(f"{col}=?")
|
||||||
params.append(str(body[col]))
|
params.append(str(body[col]))
|
||||||
|
# v7.54.0 — toggle « Mémoire » de la conversation (0/1).
|
||||||
|
if body.get("memory_enabled") is not None:
|
||||||
|
sets.append("memory_enabled=?")
|
||||||
|
params.append(1 if body["memory_enabled"] else 0)
|
||||||
params.append(conversation_id)
|
params.append(conversation_id)
|
||||||
conn.execute(f"UPDATE agent_conversations SET {', '.join(sets)} WHERE id=?", params)
|
conn.execute(f"UPDATE agent_conversations SET {', '.join(sets)} WHERE id=?", params)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -498,6 +506,43 @@ def create_skill(request: Request, body: dict = Body(default={})):
|
|||||||
return {"id": cur.lastrowid, "name": name, "status": "created"}
|
return {"id": cur.lastrowid, "name": name, "status": "created"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/skills/{skill_id}")
|
||||||
|
def update_skill(request: Request, skill_id: int, body: dict = Body(default={})):
|
||||||
|
"""Édition d'un skill enregistré (v7.52.0 — « Gérer les compétences »).
|
||||||
|
|
||||||
|
Seuls les champs présents dans ``body`` sont mis à jour ; la liste des
|
||||||
|
colonnes est figée ici (jamais interpolée depuis la requête).
|
||||||
|
"""
|
||||||
|
_current_user_id(request)
|
||||||
|
fields: dict = {}
|
||||||
|
if "name" in body:
|
||||||
|
name = str(body.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="name est requis")
|
||||||
|
fields["name"] = name
|
||||||
|
if "description" in body:
|
||||||
|
fields["description"] = str(body.get("description") or "")
|
||||||
|
if "prompt_template" in body:
|
||||||
|
fields["prompt_template"] = str(body.get("prompt_template") or "")
|
||||||
|
if "allowed_tools" in body:
|
||||||
|
fields["allowed_tools_json"] = json.dumps(body.get("allowed_tools") or [])
|
||||||
|
if not fields:
|
||||||
|
raise HTTPException(status_code=400, detail="Aucun champ à mettre à jour")
|
||||||
|
cols = ", ".join(f"{k}=?" for k in fields)
|
||||||
|
with get_conn() as conn:
|
||||||
|
if not conn.execute("SELECT id FROM agent_skills WHERE id=?", (skill_id,)).fetchone():
|
||||||
|
raise HTTPException(status_code=404, detail="Skill introuvable")
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
f"UPDATE agent_skills SET {cols} WHERE id=?",
|
||||||
|
(*fields.values(), skill_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception as exc: # noqa: BLE001 — contrainte UNIQUE(name)
|
||||||
|
raise HTTPException(status_code=409, detail=f"Skill existe déjà: {exc}") from exc
|
||||||
|
return {"id": skill_id, "status": "updated", "fields": sorted(fields)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/skills/{skill_id}/apply")
|
@router.post("/skills/{skill_id}/apply")
|
||||||
def apply_skill(request: Request, skill_id: int):
|
def apply_skill(request: Request, skill_id: int):
|
||||||
"""Create a conversation pre-loaded with a skill, ready to run."""
|
"""Create a conversation pre-loaded with a skill, ready to run."""
|
||||||
@@ -590,6 +635,192 @@ def delete_skill(request: Request, skill_id: int):
|
|||||||
return {"id": skill_id, "status": "deleted"}
|
return {"id": skill_id, "status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Connecteurs (v7.55.0) ──
|
||||||
|
# ponytail : catalogue partagé au même titre que les skills (`list_skills`
|
||||||
|
# n'a pas non plus de filtre par créateur) — la clé n'est jamais renvoyée.
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/connectors")
|
||||||
|
def list_connectors_route(request: Request):
|
||||||
|
"""Catalogue : natifs (statut dérivé de la config) + personnels."""
|
||||||
|
user_id = _current_user_id(request)
|
||||||
|
return {"connectors": connectors.list_connectors(user_id)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/plugins")
|
||||||
|
def list_plugins_route(request: Request):
|
||||||
|
"""Catalogue des plugins de l'instance (menu + → « Add plugins »)."""
|
||||||
|
_current_user_id(request)
|
||||||
|
return {"plugins": plugins.list_plugins()}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/plugins/{slug}")
|
||||||
|
def set_plugin_route(request: Request, slug: str, body: dict = Body(default={})):
|
||||||
|
"""Bascule un plugin : l'effet est réel (routes/UI/outils), pas un drapeau."""
|
||||||
|
_current_user_id(request)
|
||||||
|
try:
|
||||||
|
return plugins.set_enabled(slug, bool(body.get("enabled")))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connectors")
|
||||||
|
def create_connectors_route(request: Request, body: dict = Body(default={})):
|
||||||
|
"""Ajoute un connecteur personnalisé — l'URL est validée (garde SSRF)."""
|
||||||
|
_current_user_id(request)
|
||||||
|
try:
|
||||||
|
return connectors.create_connector(
|
||||||
|
body.get("name") or "", body.get("url") or "", str(body.get("secret") or ""),
|
||||||
|
kind=str(body.get("kind") or "custom"),
|
||||||
|
auth=str(body.get("auth") or "bearer"),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/connectors/{connector_id}")
|
||||||
|
def update_connectors_route(request: Request, connector_id: int, body: dict = Body(default={})):
|
||||||
|
_current_user_id(request)
|
||||||
|
try:
|
||||||
|
row = connectors.update_connector(
|
||||||
|
connector_id,
|
||||||
|
name=body.get("name"),
|
||||||
|
enabled=body.get("enabled"),
|
||||||
|
secret=body.get("secret"),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Connecteur introuvable")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/connectors/{connector_id}")
|
||||||
|
def delete_connectors_route(request: Request, connector_id: int):
|
||||||
|
_current_user_id(request)
|
||||||
|
if not connectors.delete_connector(connector_id):
|
||||||
|
raise HTTPException(status_code=404, detail="Connecteur introuvable")
|
||||||
|
return {"id": connector_id, "status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connectors/probe")
|
||||||
|
async def probe_connectors_route(request: Request, body: dict = Body(default={})):
|
||||||
|
"""Teste un connecteur (id, nom ou kind natif) et persiste le résultat."""
|
||||||
|
target = str(body.get("connector") or "").strip()
|
||||||
|
if not target:
|
||||||
|
raise HTTPException(status_code=400, detail="connector est requis")
|
||||||
|
user_id = _current_user_id(request)
|
||||||
|
try:
|
||||||
|
return await connectors.probe(target, user_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
# ── Connecteurs OAuth : Google / Microsoft 365 (v7.56.0) ──
|
||||||
|
# Flow : POST authorize (cookies state/verifier/next) → fournisseur →
|
||||||
|
# GET callback (échange du code, tokens chiffrés) → redirection vers la page
|
||||||
|
# d'origine. Le GET est en SAFE_METHODS : pas de CSRF (comme /auth/callback).
|
||||||
|
|
||||||
|
|
||||||
|
def _oauth_kind(kind: str) -> str:
|
||||||
|
if kind not in oauth_connectors.OAUTH_KINDS:
|
||||||
|
raise HTTPException(status_code=404, detail="Connecteur OAuth inconnu")
|
||||||
|
return kind
|
||||||
|
|
||||||
|
|
||||||
|
def _oauth_next(request: Request, kind: str, key: str, default: str = "/") -> str:
|
||||||
|
"""Chemin de retour same-origin (cookie `key` pour le callback)."""
|
||||||
|
raw = request.cookies.get(key, "") or default
|
||||||
|
path = urlparse(raw).path if raw.startswith("http") else raw
|
||||||
|
if not path.startswith("/") or path.startswith("//"):
|
||||||
|
return default
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/connectors/oauth/{kind}/status")
|
||||||
|
def connector_oauth_status(request: Request, kind: str):
|
||||||
|
"""État du connecteur OAuth pour l'utilisateur courant."""
|
||||||
|
_oauth_kind(kind)
|
||||||
|
user_id = _current_user_id(request)
|
||||||
|
try:
|
||||||
|
oauth_connectors._client(kind)
|
||||||
|
configured, detail = True, ""
|
||||||
|
except ValueError as exc:
|
||||||
|
configured, detail = False, str(exc)
|
||||||
|
tok = oauth_connectors.tokens(kind, user_id)
|
||||||
|
return {
|
||||||
|
"kind": kind, "configured": configured, "configured_detail": detail,
|
||||||
|
"connected": bool(tok.get("access_token")), "scope": tok.get("scope", ""),
|
||||||
|
"expires_at": tok.get("expires_at", 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connectors/oauth/{kind}/authorize")
|
||||||
|
def connector_oauth_authorize(request: Request, kind: str):
|
||||||
|
"""URL d'autorisation (PKCE) + cookies d'état éphémères (10 min)."""
|
||||||
|
_oauth_kind(kind)
|
||||||
|
_current_user_id(request)
|
||||||
|
try:
|
||||||
|
flow = oauth_connectors.begin(kind)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
referer = request.headers.get("referer") or ""
|
||||||
|
next_path = urlparse(referer).path if referer else "/"
|
||||||
|
if not next_path.startswith("/") or next_path.startswith("//"):
|
||||||
|
next_path = "/"
|
||||||
|
secure = request.url.scheme == "https"
|
||||||
|
resp = JSONResponse({"url": flow["url"], "kind": kind})
|
||||||
|
for key, value in (
|
||||||
|
(f"fd_oauth_state_{kind}", flow["state"]),
|
||||||
|
(f"fd_oauth_verifier_{kind}", flow["verifier"]),
|
||||||
|
(f"fd_oauth_next_{kind}", next_path),
|
||||||
|
):
|
||||||
|
resp.set_cookie(key, value, max_age=600, httponly=True, samesite="lax", secure=secure)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/connectors/oauth/{kind}/callback")
|
||||||
|
async def connector_oauth_callback(
|
||||||
|
request: Request, kind: str, code: str = "", state: str = "", error: str = ""
|
||||||
|
):
|
||||||
|
"""Reçoit le code du fournisseur, échange, stocke, renvoie sur la page."""
|
||||||
|
_oauth_kind(kind)
|
||||||
|
next_path = _oauth_next(request, kind, f"fd_oauth_next_{kind}")
|
||||||
|
expected = request.cookies.get(f"fd_oauth_state_{kind}", "")
|
||||||
|
verifier = request.cookies.get(f"fd_oauth_verifier_{kind}", "")
|
||||||
|
|
||||||
|
def _back(flag: str) -> RedirectResponse:
|
||||||
|
sep = "&" if "?" in next_path else "?"
|
||||||
|
resp = RedirectResponse(f"{next_path}{sep}{flag}", status_code=302)
|
||||||
|
for key in (f"fd_oauth_state_{kind}", f"fd_oauth_verifier_{kind}",
|
||||||
|
f"fd_oauth_next_{kind}"):
|
||||||
|
resp.delete_cookie(key, samesite="lax")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
if error:
|
||||||
|
return _back("oauth_error=" + error[:80])
|
||||||
|
if not code or not expected or not secrets.compare_digest(expected, state):
|
||||||
|
return _back("oauth_error=state")
|
||||||
|
user = get_current_user(request)
|
||||||
|
if not user or not user.get("id"):
|
||||||
|
return _back("oauth_error=session")
|
||||||
|
try:
|
||||||
|
tokens = await oauth_connectors.complete(kind, code, verifier)
|
||||||
|
except ValueError as exc:
|
||||||
|
return _back("oauth_error=" + str(exc)[:80].replace(" ", "+"))
|
||||||
|
oauth_connectors.save(kind, int(user["id"]), tokens)
|
||||||
|
logger.info("Connector OAuth connected: %s (user #%s)", kind, user["id"])
|
||||||
|
return _back("oauth=connected")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connectors/oauth/{kind}/disconnect")
|
||||||
|
def connector_oauth_disconnect(request: Request, kind: str):
|
||||||
|
_oauth_kind(kind)
|
||||||
|
user_id = _current_user_id(request)
|
||||||
|
removed = oauth_connectors.clear(kind, user_id)
|
||||||
|
return {"kind": kind, "status": "disconnected" if removed else "not-connected"}
|
||||||
|
|
||||||
|
|
||||||
# ── Mentions (commande @ / +) & feedback (boutons 👍 / 👎) ──
|
# ── Mentions (commande @ / +) & feedback (boutons 👍 / 👎) ──
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est ut
|
|||||||
)
|
)
|
||||||
from app.services.automations import fire_event as _fire_event
|
from app.services.automations import fire_event as _fire_event
|
||||||
from app.services.automations import run_event_sync
|
from app.services.automations import run_event_sync
|
||||||
|
from app.services.collection_lifecycle import delete_collections
|
||||||
|
|
||||||
from ._common import _v2_rate_check
|
from ._common import _v2_rate_check
|
||||||
|
|
||||||
@@ -152,7 +153,7 @@ def delete_collection_v2(collection_id: int, request: Request, authorization: st
|
|||||||
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "Collection not found")
|
raise HTTPException(404, "Collection not found")
|
||||||
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
|
delete_collections(conn, [collection_id])
|
||||||
conn.commit()
|
conn.commit()
|
||||||
audit_log(user, "collection.delete", "collection", collection_id, "", request)
|
audit_log(user, "collection.delete", "collection", collection_id, "", request)
|
||||||
return {"id": collection_id, "status": "deleted"}
|
return {"id": collection_id, "status": "deleted"}
|
||||||
|
|||||||
@@ -156,7 +156,10 @@ async def create_agent_v2(request: Request, authorization: str | None = Header(d
|
|||||||
return JSONResponse(content=data, status_code=201)
|
return JSONResponse(content=data, status_code=201)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/agents/{agent_id}")
|
# v7.46.0 : `{agent_id:int}` — sans le contrainte de type, la routeparametrée
|
||||||
|
# enregistree AVANT `/agents/conversations` capturait le segment « conversations »
|
||||||
|
# et renvoyait 422 (int_parsing) au lieu de la liste des conversations.
|
||||||
|
@router.get("/agents/{agent_id:int}")
|
||||||
def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||||
_guard(request, authorization)
|
_guard(request, authorization)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -166,7 +169,7 @@ def get_agent_v2(agent_id: int, request: Request, authorization: str | None = He
|
|||||||
return row_to_dict(row)
|
return row_to_dict(row)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/agents/{agent_id}")
|
@router.put("/agents/{agent_id:int}")
|
||||||
async def update_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
async def update_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||||
user = _guard(request, authorization, write=True)
|
user = _guard(request, authorization, write=True)
|
||||||
body = await _json_body(request)
|
body = await _json_body(request)
|
||||||
@@ -194,7 +197,7 @@ async def update_agent_v2(agent_id: int, request: Request, authorization: str |
|
|||||||
return {"id": agent_id, "status": "updated"}
|
return {"id": agent_id, "status": "updated"}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/agents/{agent_id}")
|
@router.delete("/agents/{agent_id:int}")
|
||||||
def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||||
user = _guard(request, authorization, write=True)
|
user = _guard(request, authorization, write=True)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
|||||||
+2
-1
@@ -475,10 +475,11 @@ def logout(request: Request):
|
|||||||
def current_user(request: Request):
|
def current_user(request: Request):
|
||||||
"""Return current user info as JSON."""
|
"""Return current user info as JSON."""
|
||||||
from app.auth.session import get_current_user as gcu
|
from app.auth.session import get_current_user as gcu
|
||||||
|
from app.auth.session import public_user
|
||||||
user = gcu(request)
|
user = gcu(request)
|
||||||
if not user:
|
if not user:
|
||||||
return {"authenticated": False}
|
return {"authenticated": False}
|
||||||
return {"authenticated": True, "user": user}
|
return {"authenticated": True, "user": public_user(user)}
|
||||||
|
|
||||||
|
|
||||||
# ── v7.2.0 — TOTP 2FA ─────────────────────────────────────────────────────
|
# ── v7.2.0 — TOTP 2FA ─────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -485,6 +485,10 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
|||||||
active_ws_name = row["name"]
|
active_ws_name = row["name"]
|
||||||
workspace_pages = _load_workspace_pages(ws_cookie)
|
workspace_pages = _load_workspace_pages(ws_cookie)
|
||||||
has_active_workspace = True
|
has_active_workspace = True
|
||||||
|
# v7.46.0 : comme pour le sidebar dashboard, exposer l'ID de
|
||||||
|
# l'espace ACTIF (le bouton Home de base.html pointait sinon
|
||||||
|
# sur ``local_workspaces[0]``, premier espace trie par nom).
|
||||||
|
local_ws_id = wsi
|
||||||
except (ValueError, Exception):
|
except (ValueError, Exception):
|
||||||
pass
|
pass
|
||||||
recent = []
|
recent = []
|
||||||
|
|||||||
@@ -226,4 +226,38 @@ def use_page_template(request: Request, template_id: int, body: dict = Body(defa
|
|||||||
return {"status": "ok", "id": page_id, "title": title or name}
|
return {"status": "ok", "id": page_id, "title": title or name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/page-templates/{template_id}/blocks")
|
||||||
|
def page_template_blocks(request: Request, template_id: int, key: str = ""):
|
||||||
|
"""v7.53.0 : blocs d'un canevas, pour l'insérer dans le document ouvert.
|
||||||
|
|
||||||
|
Builtin : ``template_id=0`` + ``?key=`` (même convention que ``/use``).
|
||||||
|
Canevas personnel : son ``id``. Les blocs builtin sont sans ``id`` — le
|
||||||
|
front appelle ``ensureBlockIds()`` (déjà global côté éditeur).
|
||||||
|
"""
|
||||||
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
|
if template_id == 0:
|
||||||
|
from app.services.block_templates import blocks_json_for
|
||||||
|
raw = blocks_json_for(key or "empty")
|
||||||
|
if raw is None:
|
||||||
|
raise HTTPException(404, "Unknown built-in template")
|
||||||
|
else:
|
||||||
|
uid = (user or {}).get("id")
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT blocks_json FROM page_global_templates "
|
||||||
|
"WHERE id=? AND (created_by IS NULL OR created_by=?)",
|
||||||
|
(template_id, uid),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Template not found")
|
||||||
|
raw = row["blocks_json"]
|
||||||
|
try:
|
||||||
|
blocks = json.loads(raw)
|
||||||
|
except (json.JSONDecodeError, TypeError) as exc:
|
||||||
|
raise HTTPException(500, "Template content corrupted") from exc
|
||||||
|
if not isinstance(blocks, list):
|
||||||
|
raise HTTPException(500, "Template content corrupted")
|
||||||
|
return {"blocks": blocks}
|
||||||
|
|
||||||
|
|
||||||
# ── Core helpers ──
|
# ── Core helpers ──
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ from fastapi.responses import HTMLResponse
|
|||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
from app.services.automations import fire_event, run_event_sync
|
from app.services.automations import fire_event, run_event_sync
|
||||||
|
from app.services.collection_lifecycle import (
|
||||||
|
collections_hosted_by_page,
|
||||||
|
collections_hosted_by_pages,
|
||||||
|
delete_collections,
|
||||||
|
)
|
||||||
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||||
|
|
||||||
from ._common import _sidebar_data
|
from ._common import _sidebar_data
|
||||||
@@ -125,21 +130,41 @@ def unpublish_page(request: Request, page_id: int):
|
|||||||
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
||||||
|
|
||||||
|
|
||||||
|
def _trash_session(request: Request):
|
||||||
|
"""Trash : session obligatoire (les 3 routes mutaient/ lisaient toutes les
|
||||||
|
pages sans aucune vérification — le CSRF seul ne protège pas, le cookie est
|
||||||
|
lisible par JS et un attaquant fixe cookie ET en-tête)."""
|
||||||
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401, "Authentication required")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
|
||||||
|
|
||||||
@router.get("/api/trash")
|
@router.get("/api/trash")
|
||||||
def list_trash(request: Request):
|
def list_trash(request: Request):
|
||||||
|
_trash_session(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall()
|
rows = conn.execute(
|
||||||
return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows]
|
"SELECT id, title, workspace, parent_section, deleted_at FROM pages "
|
||||||
|
"WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC"
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"name": r["title"] or "Untitled",
|
||||||
|
"icon": "📁" if r["parent_section"] == "Workspace" else "📄",
|
||||||
|
"path": r["workspace"] or "Private",
|
||||||
|
"deleted_at": r["deleted_at"],
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/trash/{page_id}/restore")
|
@router.post("/api/trash/{page_id}/restore")
|
||||||
def restore_page(request: Request, page_id: int):
|
def restore_page(request: Request, page_id: int):
|
||||||
|
_trash_session(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
|
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -154,11 +179,47 @@ def restore_page(request: Request, page_id: int):
|
|||||||
|
|
||||||
@router.delete("/api/trash/{page_id}")
|
@router.delete("/api/trash/{page_id}")
|
||||||
def permanent_delete(request: Request, page_id: int):
|
def permanent_delete(request: Request, page_id: int):
|
||||||
|
_trash_session(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
# La page hôte d'une base disparaît définitivement → sa base et ses
|
||||||
|
# lignes aussi, sinon My Tasks (et /db) continuaient de lister des
|
||||||
|
# tâches sans document.
|
||||||
|
collections = collections_hosted_by_page(conn, page_id)
|
||||||
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
|
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
|
||||||
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
|
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
|
||||||
|
delete_collections(conn, collections)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return {"status": "ok", "deleted": page_id}
|
return {"status": "ok", "deleted": page_id, "collections": len(collections)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/trash/empty")
|
||||||
|
def empty_trash(request: Request):
|
||||||
|
"""Empty Trash : purge en masse (les enfants des pages supprimées passent
|
||||||
|
en racine, comme permanent_delete — comportement historique conservé)."""
|
||||||
|
_trash_session(request)
|
||||||
|
with get_conn() as conn:
|
||||||
|
n = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM pages WHERE deleted_at IS NOT NULL"
|
||||||
|
).fetchone()[0]
|
||||||
|
# Bases portées par les pages purgées (les pages contenu de ligne
|
||||||
|
# n'hébergent aucune base et ne remontent rien).
|
||||||
|
trashed = [
|
||||||
|
r["id"]
|
||||||
|
for r in conn.execute(
|
||||||
|
"SELECT id FROM pages WHERE deleted_at IS NOT NULL"
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
collections = collections_hosted_by_pages(conn, trashed)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE pages SET parent_id=NULL WHERE parent_id IN "
|
||||||
|
"(SELECT id FROM pages WHERE deleted_at IS NOT NULL)"
|
||||||
|
)
|
||||||
|
conn.execute("DELETE FROM pages WHERE deleted_at IS NOT NULL")
|
||||||
|
delete_collections(conn, collections)
|
||||||
|
conn.commit()
|
||||||
|
return {"status": "ok", "deleted": n, "collections": len(collections)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,23 +16,41 @@ router = APIRouter(tags=["board"], prefix="/board")
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════
|
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════# These routes MUST be registered before the catch-all /{owner}/{repo} below.
|
||||||
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
|
|
||||||
|
def _session_user(request: Request) -> dict:
|
||||||
|
"""Session obligatoire — sans garde, un appel anonyme levait
|
||||||
|
``AttributeError: 'NoneType' object has no attribute 'get'`` (HTTP 500)
|
||||||
|
au lieu d'un 401."""
|
||||||
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
|
if not user or not user.get("id"):
|
||||||
|
raise HTTPException(401, "Authentication required")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_can_edit_block(request: Request, sb: dict) -> None:
|
||||||
|
"""Seul l'auteur du bloc (ou un admin global) peut le modifier/supprimer."""
|
||||||
|
user = _session_user(request)
|
||||||
|
if sb.get("created_by") in (None, user.get("id")):
|
||||||
|
return
|
||||||
|
if user.get("is_admin"):
|
||||||
|
return
|
||||||
|
raise HTTPException(403, "Not the author of this synced block")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/synced-blocks")
|
@router.get("/api/synced-blocks")
|
||||||
def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
|
def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
|
||||||
"""List synced blocks for a workspace."""
|
"""List synced blocks for a workspace."""
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = _session_user(request)
|
||||||
from app.services.synced_blocks import list_synced_blocks
|
from app.services.synced_blocks import list_synced_blocks
|
||||||
return {"synced_blocks": list_synced_blocks(workspace or user.get("login", ""))}
|
return {"synced_blocks": list_synced_blocks(workspace or user.get("login", ""))}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/api/synced-blocks")
|
@router.post("/api/synced-blocks")
|
||||||
def create_synced_block_api(request: Request, body: dict = Body(...)):
|
def create_synced_block_api(request: Request, body: dict = Body(...)):
|
||||||
"""Create a new synced block."""
|
"""Create a new synced block."""
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = _session_user(request)
|
||||||
from app.services.synced_blocks import create_synced_block
|
from app.services.synced_blocks import create_synced_block
|
||||||
sid = create_synced_block(
|
sid = create_synced_block(
|
||||||
workspace=body.get("workspace", ""),
|
workspace=body.get("workspace", ""),
|
||||||
@@ -61,6 +79,7 @@ async def update_synced_block_api(request: Request, sid: int):
|
|||||||
sb = get_synced_block(sid)
|
sb = get_synced_block(sid)
|
||||||
if not sb:
|
if not sb:
|
||||||
raise HTTPException(404, "Synced block not found")
|
raise HTTPException(404, "Synced block not found")
|
||||||
|
_assert_can_edit_block(request, sb)
|
||||||
update_synced_block(sid, body.get("title", sb["title"]), body.get("content", []))
|
update_synced_block(sid, body.get("title", sb["title"]), body.get("content", []))
|
||||||
# v6.5.0: rewrite every referencing page's stored content first (DB row
|
# v6.5.0: rewrite every referencing page's stored content first (DB row
|
||||||
# content pages included), THEN push the realtime update so open rooms
|
# content pages included), THEN push the realtime update so open rooms
|
||||||
@@ -86,6 +105,7 @@ async def delete_synced_block_api(request: Request, sid: int):
|
|||||||
sb = get_synced_block(sid)
|
sb = get_synced_block(sid)
|
||||||
if not sb:
|
if not sb:
|
||||||
raise HTTPException(404, "Synced block not found")
|
raise HTTPException(404, "Synced block not found")
|
||||||
|
_assert_can_edit_block(request, sb)
|
||||||
# v6.5.0: collect referencing pages BEFORE the FK cascade wipes the
|
# v6.5.0: collect referencing pages BEFORE the FK cascade wipes the
|
||||||
# refs, rewrite their stored content (deleted state), then broadcast.
|
# refs, rewrite their stored content (deleted state), then broadcast.
|
||||||
pids = page_ids_for_synced(sid)
|
pids = page_ids_for_synced(sid)
|
||||||
@@ -99,8 +119,9 @@ async def delete_synced_block_api(request: Request, sid: int):
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/api/synced-blocks/{sid}")
|
@router.get("/api/synced-blocks/{sid}")
|
||||||
def get_synced_block_api(sid: int):
|
def get_synced_block_api(request: Request, sid: int):
|
||||||
"""Get a synced block by id."""
|
"""Get a synced block by id."""
|
||||||
|
_session_user(request)
|
||||||
from app.services.synced_blocks import get_synced_block
|
from app.services.synced_blocks import get_synced_block
|
||||||
sb = get_synced_block(sid)
|
sb = get_synced_block(sid)
|
||||||
if not sb:
|
if not sb:
|
||||||
|
|||||||
@@ -206,3 +206,67 @@ def delete_comment(request: Request, comment_id: int):
|
|||||||
conn.execute("DELETE FROM comments WHERE id=? OR parent_id=?", (comment_id, comment_id))
|
conn.execute("DELETE FROM comments WHERE id=? OR parent_id=?", (comment_id, comment_id))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return {"id": comment_id, "status": "deleted"}
|
return {"id": comment_id, "status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── v7.64.0 : réactions emoji sur un texte sélectionné ──────────────────────
|
||||||
|
|
||||||
|
@router.get("/pages/{page_id}/reactions")
|
||||||
|
def list_reactions(request: Request, page_id: int):
|
||||||
|
"""Réactions regroupées par plage de texte : {block_id, start, end, emoji, count, mine}."""
|
||||||
|
user = _current_user(request)
|
||||||
|
with get_conn() as conn:
|
||||||
|
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
||||||
|
raise HTTPException(404, "Page not found")
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT block_id, anchor_start, anchor_end, emoji,
|
||||||
|
COUNT(*) AS count,
|
||||||
|
MAX(CASE WHEN user_id=? THEN 1 ELSE 0 END) AS mine
|
||||||
|
FROM text_reactions
|
||||||
|
WHERE page_id=?
|
||||||
|
GROUP BY block_id, anchor_start, anchor_end, emoji
|
||||||
|
ORDER BY MIN(id)""",
|
||||||
|
(user["id"], page_id),
|
||||||
|
).fetchall()
|
||||||
|
return {"page_id": page_id, "reactions": [dict(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pages/{page_id}/reactions")
|
||||||
|
def toggle_reaction(request: Request, page_id: int, body: dict = Body(default={})):
|
||||||
|
"""Ajoute ou retire la réaction de l'utilisateur sur une plage de texte (toggle)."""
|
||||||
|
user = _current_user(request)
|
||||||
|
block = (body.get("block_id") or "").strip()
|
||||||
|
emoji = (body.get("emoji") or "").strip()
|
||||||
|
try:
|
||||||
|
start = int(body.get("anchor_start"))
|
||||||
|
end = int(body.get("anchor_end"))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(400, "anchor offsets required") from None
|
||||||
|
if not block or not emoji or start < 0 or end <= start:
|
||||||
|
raise HTTPException(400, "block_id, emoji and a non-empty range are required")
|
||||||
|
uid = user["id"]
|
||||||
|
with get_conn() as conn:
|
||||||
|
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
||||||
|
raise HTTPException(404, "Page not found")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
|
||||||
|
(uid, user.get("login", "admin"), user.get("full_name", "Admin")),
|
||||||
|
)
|
||||||
|
existing = conn.execute(
|
||||||
|
"""SELECT id FROM text_reactions
|
||||||
|
WHERE page_id=? AND block_id=? AND anchor_start=? AND anchor_end=?
|
||||||
|
AND emoji=? AND user_id=?""",
|
||||||
|
(page_id, block, start, end, emoji, uid),
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
conn.execute("DELETE FROM text_reactions WHERE id=?", (existing["id"],))
|
||||||
|
status = "removed"
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO text_reactions
|
||||||
|
(page_id, block_id, anchor_start, anchor_end, emoji, user_id)
|
||||||
|
VALUES (?,?,?,?,?,?)""",
|
||||||
|
(page_id, block, start, end, emoji, uid),
|
||||||
|
)
|
||||||
|
status = "added"
|
||||||
|
conn.commit()
|
||||||
|
return {"status": status, "emoji": emoji}
|
||||||
|
|||||||
@@ -17,11 +17,13 @@ from . import ( # ordre = ordre d'enregistrement d'origine
|
|||||||
crud,
|
crud,
|
||||||
dashboard_views,
|
dashboard_views,
|
||||||
data_api,
|
data_api,
|
||||||
|
index_page,
|
||||||
linked,
|
linked,
|
||||||
meta,
|
meta,
|
||||||
pages,
|
pages,
|
||||||
properties,
|
properties,
|
||||||
structure,
|
structure,
|
||||||
|
task_db,
|
||||||
views,
|
views,
|
||||||
)
|
)
|
||||||
from ._common import _validate_page_properties # noqa: F401
|
from ._common import _validate_page_properties # noqa: F401
|
||||||
@@ -34,7 +36,14 @@ from ._renderers import ( # noqa: F401 — ré-exports tests
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
# `task_db` est enregistré **en premier** : sa route statique
|
||||||
|
# `GET /db/task-dbs/api` serait sinon capturée par `data_api`'s
|
||||||
|
# `GET /db/{collection_id}/api` (`collection_id="task-dbs"` → 422).
|
||||||
|
# `index_page` (la page HTML `/db`) vient ensuite, avant tout module
|
||||||
|
# déclarant `/db/{collection_id}` — sinon la racine serait capturée en 422.
|
||||||
for _mod in (
|
for _mod in (
|
||||||
|
task_db,
|
||||||
|
index_page,
|
||||||
crud,
|
crud,
|
||||||
pages,
|
pages,
|
||||||
boards,
|
boards,
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, HTTPException, Request
|
from fastapi import APIRouter, Body, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse
|
|
||||||
|
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
|
from app.routers.dashboard._common import _get_active_workspace
|
||||||
from app.services.automations import fire_event, run_event_sync
|
from app.services.automations import fire_event, run_event_sync
|
||||||
|
from app.services.collection_lifecycle import delete_collections
|
||||||
from app.services.db_templates import materialize_properties
|
from app.services.db_templates import materialize_properties
|
||||||
from app.services.permission_manager import PermissionManager
|
from app.services.permission_manager import PermissionManager
|
||||||
|
|
||||||
@@ -24,22 +25,10 @@ router = APIRouter(tags=["collections"], prefix="/db")
|
|||||||
|
|
||||||
|
|
||||||
# ── API: List & Create (no path params) ──
|
# ── API: List & Create (no path params) ──
|
||||||
|
#
|
||||||
|
# `GET /db` (la page HTML) vit dans `index_page.py` : elle était auparavant
|
||||||
@router.get("", response_class=HTMLResponse)
|
# un dump JSON de debug, sans layout — alors qu'elle sert de destination au
|
||||||
def list_collections(request: Request):
|
# bouton « Ouvrir mes bases » de My Tasks.
|
||||||
"""Page listing all collections in the workspace."""
|
|
||||||
with get_conn() as conn:
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT * FROM collections ORDER BY name"
|
|
||||||
).fetchall()
|
|
||||||
collections = [dict(r) for r in rows]
|
|
||||||
return HTMLResponse(
|
|
||||||
f"<div class='collections-list'>"
|
|
||||||
f"<h2>Collections ({len(collections)})</h2>"
|
|
||||||
f"<pre>{json.dumps(collections, indent=2, default=str)}</pre>"
|
|
||||||
f"</div>"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -71,6 +60,24 @@ def create_collection_api(request: Request, body: dict = Body(default={})):
|
|||||||
schema = body.get("schema", [])
|
schema = body.get("schema", [])
|
||||||
is_locked = body.get("is_locked", False)
|
is_locked = body.get("is_locked", False)
|
||||||
|
|
||||||
|
# Workspace d'appartenance : celui du body si fourni et autorisé, sinon le
|
||||||
|
# workspace actif de l'utilisateur. Sans cela la collection reste orpheline
|
||||||
|
# et n'apparaît dans aucune vue scopée (ex. /my-tasks).
|
||||||
|
user = _session_user(request)
|
||||||
|
workspace_id = body.get("workspace_id")
|
||||||
|
if workspace_id is not None:
|
||||||
|
try:
|
||||||
|
workspace_id = int(workspace_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(status_code=400, detail="workspace_id must be an integer") from None
|
||||||
|
with get_conn() as conn:
|
||||||
|
exists = conn.execute("SELECT 1 FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||||
|
if not exists:
|
||||||
|
raise HTTPException(status_code=400, detail="workspace_id does not exist")
|
||||||
|
else:
|
||||||
|
active = _get_active_workspace(request, (user or {}).get("id"))
|
||||||
|
workspace_id = active["id"] if active else None
|
||||||
|
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
# Apply a template if requested (provides schema + icon).
|
# Apply a template if requested (provides schema + icon).
|
||||||
tpl = _apply_template(conn, body.get("template"))
|
tpl = _apply_template(conn, body.get("template"))
|
||||||
@@ -88,9 +95,11 @@ def create_collection_api(request: Request, body: dict = Body(default={})):
|
|||||||
|
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"""INSERT INTO collections
|
"""INSERT INTO collections
|
||||||
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked)
|
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
workspace_id)
|
||||||
(name, description, icon, schema_json, gitea_owner, gitea_repo, int(is_locked)),
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(name, description, icon, schema_json, gitea_owner, gitea_repo, int(is_locked),
|
||||||
|
workspace_id),
|
||||||
)
|
)
|
||||||
collection_id = cur.lastrowid
|
collection_id = cur.lastrowid
|
||||||
|
|
||||||
@@ -171,7 +180,11 @@ def delete_collection_api(request: Request, collection_id: int):
|
|||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(status_code=404, detail="Collection not found")
|
raise HTTPException(status_code=404, detail="Collection not found")
|
||||||
|
|
||||||
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
|
# `delete_collections` détache la page hôte et les vues liées avant la
|
||||||
|
# suppression : `pages.collection_id` et
|
||||||
|
# `collection_data_sources.source_collection_id` sont en NO ACTION, la
|
||||||
|
# suppression brute levait un IntegrityError.
|
||||||
|
delete_collections(conn, [collection_id])
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
run_event_sync(fire_event("collection.deleted", {"collection_id": collection_id,
|
run_event_sync(fire_event("collection.deleted", {"collection_id": collection_id,
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
"""FlowDeck — Collections : la page « /db » (liste des bases).
|
||||||
|
|
||||||
|
`GET /db` était un **dump JSON de debug** (le HTML renvoyé était un
|
||||||
|
`<pre>` dans le vide, sans layout) : c'est pourtant la destination du bouton
|
||||||
|
« Ouvrir mes bases » de My Tasks, et le point d'entrée pour convertir une base
|
||||||
|
en base de tâches. On le remplace par une vraie page :
|
||||||
|
|
||||||
|
* bases **du workspace courant** d'abord (ce que voit l'utilisateur dans la
|
||||||
|
sidebar), puis celles de ses **autres workspaces** dans une section séparée :
|
||||||
|
sans cela, un utilisateur dont le workspace actif ne contient aucune base
|
||||||
|
tombe sur une page vide alors qu'il en possède ailleurs ;
|
||||||
|
* indicateur « Base de tâches » / « À configurer » par base ;
|
||||||
|
* accès direct à la page de la base, où se trouve le bouton de conversion.
|
||||||
|
|
||||||
|
**Rattachement d'une base à un workspace.** `collections.workspace_id` n'est
|
||||||
|
renseigné que pour les bases créées via `/db/api`. Une base née d'un document
|
||||||
|
(page convertie en base, base inline) a `workspace_id` **NULL** et n'appartient
|
||||||
|
qu'à travers sa page hôte : la résout par `COALESCE(workspace_id, page hôte)`
|
||||||
|
(`app/services.collection_lifecycle.effective_workspace_sql`). Filtrer sur
|
||||||
|
`workspace_id`, ou faire un `JOIN workspaces`, faisait disparaître ces bases de
|
||||||
|
la page alors qu'elles sont listées dans la sidebar du workspace — l'utilisateur
|
||||||
|
les voyait sans pouvoir les ouvrir.
|
||||||
|
|
||||||
|
Le filtre de vivacité (`live_collection_ids`) est appliqué : une base dont la
|
||||||
|
page hôte est à la corbeille n'est pas proposée, puisqu'elle est inaccessible.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
|
||||||
|
from app.db import get_conn
|
||||||
|
from app.routers.dashboard._common import _get_active_workspace
|
||||||
|
from app.services.collection_lifecycle import (
|
||||||
|
effective_workspace_sql,
|
||||||
|
live_collection_ids,
|
||||||
|
user_workspace_ids,
|
||||||
|
)
|
||||||
|
from app.templating import ENV
|
||||||
|
|
||||||
|
from ._common import _session_user
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(tags=["collections"], prefix="/db")
|
||||||
|
|
||||||
|
#: Colonnes communes aux deux listes (carte de base). `c.` qualification
|
||||||
|
#: obligatoire : le nom de la collection d'icônes homonyme rendrait `rows` et
|
||||||
|
#: `id` ambigus.
|
||||||
|
_CARD_COLUMNS = """
|
||||||
|
c.id, c.name, c.icon, c.parent_page_id, c.workspace_id, c.is_task,
|
||||||
|
c.task_assignee_prop, c.task_status_prop, c.task_due_prop,
|
||||||
|
(SELECT COUNT(*) FROM collection_pages cp
|
||||||
|
WHERE cp.collection_id = c.id AND cp.parent_id IS NULL) AS rows
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _esc(value) -> str:
|
||||||
|
return (
|
||||||
|
str(value if value is not None else "")
|
||||||
|
.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
.replace('"', """).replace("'", "'")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _card(r: dict) -> str:
|
||||||
|
"""Une carte de base : état My Tasks + ce qu'il reste à faire."""
|
||||||
|
target = (f"/pages/{r['parent_page_id']}" if r["parent_page_id"]
|
||||||
|
else f"/db/{r['id']}")
|
||||||
|
labels = {"assignee": "Assigné à", "status": "Statut", "due": "Échéance"}
|
||||||
|
missing = [role for role in ("assignee", "status", "due")
|
||||||
|
if not r.get(f"task_{role}_prop")]
|
||||||
|
|
||||||
|
if r["is_task"] and not missing:
|
||||||
|
badge = '<span class="db-card-badge ok">Base de tâches</span>'
|
||||||
|
hint = "Alimente My Tasks."
|
||||||
|
elif r["is_task"]:
|
||||||
|
badge = '<span class="db-card-badge warn">À configurer</span>'
|
||||||
|
hint = ("Il manque : " + ", ".join(labels[m] for m in missing)
|
||||||
|
+ ". Ouvrez la base puis « Configurer » dans sa barre.")
|
||||||
|
else:
|
||||||
|
badge = ""
|
||||||
|
hint = "Ouvrez-la puis cliquez sur « Convertir en base de tâches »."
|
||||||
|
|
||||||
|
# `workspace_id` est NULL pour une base portée par une page : on affiche le
|
||||||
|
# nom du workspace effectif plutôt que celui de la colonne brute.
|
||||||
|
ws_label = r.get("ws_label") or ""
|
||||||
|
meta = f'{r["rows"]} ligne(s)'
|
||||||
|
if ws_label:
|
||||||
|
meta += f" · {ws_label}"
|
||||||
|
|
||||||
|
return f"""<a class="db-card" href="{_esc(target)}">
|
||||||
|
<div class="db-card-head">
|
||||||
|
<span class="db-card-icon">{_esc(r["icon"] or "📋")}</span>
|
||||||
|
<span class="db-card-name">{_esc(r["name"])}</span>
|
||||||
|
{badge}
|
||||||
|
</div>
|
||||||
|
<div class="db-card-meta">{_esc(meta)}</div>
|
||||||
|
<div class="db-card-hint">{_esc(hint)}</div>
|
||||||
|
</a>"""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_class=HTMLResponse)
|
||||||
|
def list_collections(request: Request):
|
||||||
|
"""Liste des bases du workspace, dans le layout de l'application."""
|
||||||
|
user = _session_user(request)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
|
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
workspace = _get_active_workspace(request, user.get("id")) or {}
|
||||||
|
ws_id = workspace.get("id")
|
||||||
|
ws_name = workspace.get("name") or "Workspace"
|
||||||
|
|
||||||
|
rows = _collections_of(conn, ws_id) if ws_id else []
|
||||||
|
# Bases des autres workspaces : la page ne doit jamais laisser
|
||||||
|
# l'utilisateur sans piste s'il en possède ailleurs.
|
||||||
|
current_ids = {r["id"] for r in rows}
|
||||||
|
others = [r for r in _all_collections(conn, user.get("id"), ws_id)
|
||||||
|
if r["id"] not in current_ids]
|
||||||
|
|
||||||
|
connected = sum(1 for r in rows if r["is_task"])
|
||||||
|
|
||||||
|
return HTMLResponse(_render(request, ws_name, rows, connected, bool(ws_id),
|
||||||
|
others))
|
||||||
|
|
||||||
|
|
||||||
|
def _collections_of(conn, ws_id) -> list[dict]:
|
||||||
|
"""Bases d'un workspace, vivantes seulement.
|
||||||
|
|
||||||
|
`live_collection_ids` écarte les bases dont la page hôte a disparu (corbeille
|
||||||
|
ou suppression) : elles sont inaccessibles, les proposer serait trompeur.
|
||||||
|
|
||||||
|
Le rattachement passe par le workspace **effectif** : une base créée depuis
|
||||||
|
un document a `workspace_id` NULL et n'est repérée que via sa page hôte.
|
||||||
|
"""
|
||||||
|
live = set(live_collection_ids(conn, ws_id))
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""SELECT {_CARD_COLUMNS}
|
||||||
|
FROM collections c
|
||||||
|
WHERE {effective_workspace_sql("c")} = ?
|
||||||
|
ORDER BY c.name""",
|
||||||
|
(ws_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows if r["id"] in live]
|
||||||
|
|
||||||
|
|
||||||
|
def _all_collections(conn, user_id, current_ws_id=None) -> list[dict]:
|
||||||
|
"""Toutes les bases accessibles à l'utilisateur, tous workspaces confondus.
|
||||||
|
|
||||||
|
Sert à la section « Autres workspaces » : l'utilisateur doit pouvoir
|
||||||
|
retrouver une base même si elle n'est pas dans le workspace actif (My Tasks
|
||||||
|
étant transverse, ces bases l'alimentent aussi).
|
||||||
|
|
||||||
|
Le périmètre est celui de `user_workspace_ids` (propriétaire **ou** membre) :
|
||||||
|
sans ce filtre, la page exposait les bases de tous les utilisateurs de
|
||||||
|
l'instance. Les bases sans workspace rattachable sont exclues — elles
|
||||||
|
n'appartiennent à personne.
|
||||||
|
"""
|
||||||
|
ws_ids = user_workspace_ids(conn, user_id)
|
||||||
|
if not ws_ids:
|
||||||
|
return []
|
||||||
|
qs = ",".join("?" * len(ws_ids))
|
||||||
|
names = {
|
||||||
|
r["id"]: r["name"]
|
||||||
|
for r in conn.execute(
|
||||||
|
f"SELECT id, name FROM workspaces WHERE id IN ({qs})", ws_ids
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""SELECT {_CARD_COLUMNS},
|
||||||
|
{effective_workspace_sql("c")} AS ws_effective
|
||||||
|
FROM collections c
|
||||||
|
WHERE {effective_workspace_sql("c")} IN ({qs})
|
||||||
|
ORDER BY c.name""",
|
||||||
|
ws_ids,
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
live: dict[int, set[int]] = {}
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
ws = d["ws_effective"]
|
||||||
|
if ws not in live:
|
||||||
|
live[ws] = set(live_collection_ids(conn, ws))
|
||||||
|
if d["id"] not in live[ws]:
|
||||||
|
continue
|
||||||
|
# Le workspace courant est déjà indiqué dans l'en-tête de la page.
|
||||||
|
d["ws_label"] = "" if ws == current_ws_id else names.get(ws, "")
|
||||||
|
out.append(d)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _render(request: Request, ws_name: str, rows: list[dict], connected: int,
|
||||||
|
has_ws: bool, others: list[dict] | None = None) -> str:
|
||||||
|
"""Rendu de la page, dans le layout commun (sidebar + base.html)."""
|
||||||
|
from app.routers.dashboard import _sidebar_data
|
||||||
|
|
||||||
|
others = others or []
|
||||||
|
blocks = [_card(r) for r in rows]
|
||||||
|
other_blocks = [_card(r) for r in others]
|
||||||
|
|
||||||
|
if rows or others:
|
||||||
|
total = len(rows) + len(others)
|
||||||
|
summary = (f"{connected} base(s) de tâches connectée(s) sur {total}"
|
||||||
|
if connected else
|
||||||
|
f"Aucune base de tâches connectée sur {total}")
|
||||||
|
sections = f"""<div class="db-grid">{''.join(blocks)}</div>"""
|
||||||
|
if other_blocks:
|
||||||
|
sections += ('<h2 class="db-section-title">Autres workspaces</h2>'
|
||||||
|
'<p class="db-section-sub">Ces bases appartiennent à '
|
||||||
|
"d'autres workspaces — elles alimentent tout de même "
|
||||||
|
f'My Tasks.</p><div class="db-grid">'
|
||||||
|
f"{''.join(other_blocks)}</div>")
|
||||||
|
body = f"""<div class="db-index">
|
||||||
|
<div class="db-index-head">
|
||||||
|
<h1>📋 Bases de données</h1>
|
||||||
|
<span class="db-index-ws">{_esc(ws_name)}</span>
|
||||||
|
</div>
|
||||||
|
<p class="db-index-sub">{summary} — les bases de tâches alimentent
|
||||||
|
<a href="/my-tasks">My Tasks</a>.</p>
|
||||||
|
{sections}
|
||||||
|
</div>"""
|
||||||
|
elif has_ws:
|
||||||
|
body = """<div class="db-empty">
|
||||||
|
<div class="db-empty-icon">📋</div>
|
||||||
|
<h1>Aucune base dans ce workspace</h1>
|
||||||
|
<p>Créez une page, puis convertissez-la en base de données — ou partez
|
||||||
|
d'un modèle depuis le menu d'ajout.</p>
|
||||||
|
<p class="db-empty-hint">Une base reliée à My Tasks apparaît ici avec son
|
||||||
|
état de configuration.</p>
|
||||||
|
</div>"""
|
||||||
|
else:
|
||||||
|
body = """<div class="db-empty">
|
||||||
|
<div class="db-empty-icon">📋</div>
|
||||||
|
<h1>Aucun workspace sélectionné</h1>
|
||||||
|
<p>Créez ou ouvrez d'abord un workspace : vos bases y seront rattachées.</p>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
# `my_tasks.css` porte les styles `.db-*` de cette page (cartes, sections,
|
||||||
|
# états vides) **et** ceux de My Tasks. Elle est liée par `base.html`
|
||||||
|
# (le shell, jamais swappé) : la lier ici exposait la page à un <link>
|
||||||
|
# retiré par htmx lors d'une navigation partielle.
|
||||||
|
block_tpl = ENV.from_string(
|
||||||
|
'{% extends "base.html" %}'
|
||||||
|
"{% block content %}{{ content_html|safe }}{% endblock %}"
|
||||||
|
)
|
||||||
|
return block_tpl.render(
|
||||||
|
**_sidebar_data(request, []),
|
||||||
|
request=request,
|
||||||
|
content_html=body,
|
||||||
|
page_title="Bases de données",
|
||||||
|
title_prefix="Bases de données",
|
||||||
|
page_icon="grid",
|
||||||
|
)
|
||||||
@@ -211,14 +211,25 @@ def create_inline_database(request: Request, body: dict = Body(default={})):
|
|||||||
|
|
||||||
@router.put("/{collection_id}/toggle-task/api")
|
@router.put("/{collection_id}/toggle-task/api")
|
||||||
def toggle_task(request: Request, collection_id: int):
|
def toggle_task(request: Request, collection_id: int):
|
||||||
"""API: toggle is_task flag on a collection (Turn into Tasks)."""
|
"""API: toggle is_task flag on a collection (Turn into Tasks).
|
||||||
|
|
||||||
|
Rétrocompatible : l'activation passe désormais par la même conversion que
|
||||||
|
``POST /db/{id}/task-db/api``, donc les trois colonnes requises sont liées
|
||||||
|
(créées si besoin). Sans cela, cette route laissait une base « connectée »
|
||||||
|
mais muette — elle n'émettait aucune tâche faute de mapping.
|
||||||
|
"""
|
||||||
|
from .task_db import disable_task_db_in_conn, enable_task_db_in_conn
|
||||||
|
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
|
coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||||
if not coll:
|
if not coll:
|
||||||
raise HTTPException(status_code=404, detail="Collection not found")
|
raise HTTPException(status_code=404, detail="Collection not found")
|
||||||
new_val = 0 if coll["is_task"] else 1
|
if coll["is_task"]:
|
||||||
conn.execute("UPDATE collections SET is_task=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (new_val, collection_id))
|
disable_task_db_in_conn(conn, collection_id)
|
||||||
conn.commit()
|
new_val = 0
|
||||||
|
else:
|
||||||
|
enable_task_db_in_conn(conn, collection_id)
|
||||||
|
new_val = 1
|
||||||
return {"collection_id": collection_id, "is_task": bool(new_val), "mode": "tasks" if new_val else "standard"}
|
return {"collection_id": collection_id, "is_task": bool(new_val), "mode": "tasks" if new_val else "standard"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
"""FlowDeck — Collections : « base de tâches » (My Tasks façon Notion).
|
||||||
|
|
||||||
|
Unlike Notion — where any base can be added to the dashboard widget from
|
||||||
|
scratch — a base doit **explicitement** alimenter My Tasks :
|
||||||
|
``POST /db/{id}/task-db`` bascule ``collections.is_task`` et enregistre le
|
||||||
|
mapping des trois propriétés requises (Assigné à / Statut / Échéance).
|
||||||
|
Tant que le mapping est incomplet, la base est connectée mais **n'émet
|
||||||
|
aucune tâche** (`task_databases.collect_tasks` la saute) — l'UI le signale
|
||||||
|
plutôt que de deviner une colonne.
|
||||||
|
|
||||||
|
Limite : 10 bases connectées au tableau de bord (`MAX_TASK_SOURCES`).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, HTTPException, Request
|
||||||
|
|
||||||
|
from app.db import get_conn
|
||||||
|
from app.services.task_databases import (
|
||||||
|
MAX_TASK_SOURCES,
|
||||||
|
ROLE_LABELS,
|
||||||
|
ROLE_TYPES,
|
||||||
|
TASK_ROLES,
|
||||||
|
list_task_sources,
|
||||||
|
source_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ._common import _require_edit, _session_user
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(tags=["collections"], prefix="/db")
|
||||||
|
|
||||||
|
#: Trio de statuts amorcé quand une colonne Statut est créée à la conversion.
|
||||||
|
DEFAULT_STATUS_OPTIONS = [
|
||||||
|
{"name": "À faire", "color": "gray"},
|
||||||
|
{"name": "En cours", "color": "blue"},
|
||||||
|
{"name": "Terminée", "color": "green"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _user_id(request: Request) -> int:
|
||||||
|
"""ID de l'appelant — session obligatoire.
|
||||||
|
|
||||||
|
`_current_user` retombe sur l'admin (id 1) en cas d'absence de session :
|
||||||
|
wrong pour une route qui liste *les bases de l'utilisateur* (un anonyme
|
||||||
|
verrait le tableau de bord de l'admin). On passe donc par `_session_user`.
|
||||||
|
"""
|
||||||
|
user = _session_user(request)
|
||||||
|
if not user or not user.get("id"):
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
return user["id"]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/task-dbs/api")
|
||||||
|
def list_connected_task_databases(request: Request):
|
||||||
|
"""Bases de tâches connectées au tableau de bord My Tasks."""
|
||||||
|
uid = _user_id(request)
|
||||||
|
with get_conn() as conn:
|
||||||
|
sources = list_task_sources(conn, uid)
|
||||||
|
return {
|
||||||
|
"sources": [
|
||||||
|
{k: s[k] for k in (
|
||||||
|
"id", "name", "icon", "workspace_id", "workspace_name",
|
||||||
|
"configured", "missing_roles", "status_options",
|
||||||
|
)}
|
||||||
|
for s in sources
|
||||||
|
],
|
||||||
|
"total": len(sources),
|
||||||
|
"max_sources": MAX_TASK_SOURCES,
|
||||||
|
"limit_reached": len(sources) >= MAX_TASK_SOURCES,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{collection_id}/task-db/api")
|
||||||
|
def get_task_db_state(request: Request, collection_id: int):
|
||||||
|
"""État de conversion + colonnes candidates pour la modale."""
|
||||||
|
_require_edit(collection_id, _session_user(request))
|
||||||
|
with get_conn() as conn:
|
||||||
|
state = source_state(conn, collection_id)
|
||||||
|
if not state["exists"]:
|
||||||
|
raise HTTPException(status_code=404, detail="Collection not found")
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{collection_id}/task-db/api")
|
||||||
|
def enable_task_db(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||||
|
"""Convertit la base en base de tâches (mapping explicite des 3 rôles).
|
||||||
|
|
||||||
|
``mapping`` : ``{"assignee": <prop_id>, "status": …, "due": …}``.
|
||||||
|
``create_missing`` : ``{"status": {"name": "Statut", "options": [...]}}``
|
||||||
|
pour qu'une colonne absente soit créée dans la transaction.
|
||||||
|
"""
|
||||||
|
_require_edit(collection_id, _session_user(request))
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
state = enable_task_db_in_conn(
|
||||||
|
conn,
|
||||||
|
collection_id,
|
||||||
|
mapping=body.get("mapping") or {},
|
||||||
|
create_missing=body.get("create_missing") or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"status": "enabled", "collection_id": collection_id,
|
||||||
|
"mapping": state["mapping"], "configured": state["configured"]}
|
||||||
|
|
||||||
|
|
||||||
|
def enable_task_db_in_conn(conn, collection_id: int, *, mapping: dict | None = None,
|
||||||
|
create_missing: dict | None = None) -> dict:
|
||||||
|
"""Active ``is_task`` et enregistre le mapping — dans la transaction
|
||||||
|
appelante.
|
||||||
|
|
||||||
|
Point d'entrée unique de la conversion : la route `/task-db/api` comme
|
||||||
|
l'ancien `PUT /db/{id}/toggle-task/api` (qui aurait sinon laissé une base
|
||||||
|
« connectée » sans aucune colonne liée, donc muette).
|
||||||
|
"""
|
||||||
|
state = source_state(conn, collection_id)
|
||||||
|
if not state["exists"]:
|
||||||
|
raise HTTPException(status_code=404, detail="Collection not found")
|
||||||
|
|
||||||
|
# ── Limite de sources (on recompte : la base elle-même peut déjà être
|
||||||
|
# connectée, auquel cas on ne la compte pas deux fois).
|
||||||
|
if not state["is_task"] and state["sources_count"] >= MAX_TASK_SOURCES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=(
|
||||||
|
f"Limite de {MAX_TASK_SOURCES} bases de tâches atteinte. "
|
||||||
|
"Déconnectez une base avant d'en ajouter une autre."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Création des colonnes manquantes ──
|
||||||
|
create_missing = create_missing or {}
|
||||||
|
mapping = dict(mapping or {})
|
||||||
|
by_name = {p["name"]: p for p in state["all_properties"]}
|
||||||
|
for role in TASK_ROLES:
|
||||||
|
if mapping.get(role):
|
||||||
|
continue
|
||||||
|
spec = create_missing.get(role) or {}
|
||||||
|
name = (spec.get("name") or ROLE_LABELS[role]).strip()
|
||||||
|
prop_type = spec.get("prop_type") or ROLE_TYPES[role][0]
|
||||||
|
if prop_type not in ROLE_TYPES[role]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Type '{prop_type}' incompatible avec le rôle '{role}'",
|
||||||
|
)
|
||||||
|
if name in by_name:
|
||||||
|
# La colonne existe déjà (nom saisi) → on la lie.
|
||||||
|
mapping[role] = by_name[name]["id"]
|
||||||
|
continue
|
||||||
|
created = _create_property(conn, collection_id, name, prop_type,
|
||||||
|
spec.get("options"))
|
||||||
|
mapping[role] = created["id"]
|
||||||
|
|
||||||
|
# ── Validation : les 3 rôles pointent une colonne de la bonne base ──
|
||||||
|
props = {
|
||||||
|
p["id"]: p
|
||||||
|
for p in conn.execute(
|
||||||
|
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=?",
|
||||||
|
(collection_id,),
|
||||||
|
).fetchall()
|
||||||
|
}
|
||||||
|
seen: dict[int, str] = {}
|
||||||
|
for role in TASK_ROLES:
|
||||||
|
prop_id = mapping.get(role)
|
||||||
|
if not prop_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Colonne « {ROLE_LABELS[role]} » manquante",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
prop_id = int(prop_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise HTTPException(status_code=400,
|
||||||
|
detail="Identifiant de propriété invalide") from None
|
||||||
|
prop = props.get(prop_id)
|
||||||
|
if prop is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"« {ROLE_LABELS[role]} » n'appartient pas à cette base",
|
||||||
|
)
|
||||||
|
if prop["prop_type"] not in ROLE_TYPES[role]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=(
|
||||||
|
f"« {prop['name']} » est de type {prop['prop_type']}, "
|
||||||
|
f"incompatible avec « {ROLE_LABELS[role]} »"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if prop_id in seen:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"« {prop['name']} » est déjà utilisé pour « {seen[prop_id]} »",
|
||||||
|
)
|
||||||
|
seen[prop_id] = ROLE_LABELS[role]
|
||||||
|
mapping[role] = prop_id
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE collections
|
||||||
|
SET is_task = 1,
|
||||||
|
task_assignee_prop = ?, task_status_prop = ?, task_due_prop = ?,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?""",
|
||||||
|
(mapping["assignee"], mapping["status"], mapping["due"], collection_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return source_state(conn, collection_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{collection_id}/task-db/api")
|
||||||
|
def disable_task_db(request: Request, collection_id: int):
|
||||||
|
"""Déconnecte la base du tableau de bord (les tâches sont conservées)."""
|
||||||
|
_require_edit(collection_id, _session_user(request))
|
||||||
|
with get_conn() as conn:
|
||||||
|
disable_task_db_in_conn(conn, collection_id)
|
||||||
|
return {"status": "disabled", "collection_id": collection_id}
|
||||||
|
|
||||||
|
|
||||||
|
def disable_task_db_in_conn(conn, collection_id: int) -> None:
|
||||||
|
"""Déconnexion dans la transaction appelante : efface aussi le mapping,
|
||||||
|
pour ne pas laisser des identifiants de colonnes mortes."""
|
||||||
|
row = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Collection not found")
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE collections
|
||||||
|
SET is_task = 0, task_assignee_prop = NULL, task_status_prop = NULL,
|
||||||
|
task_due_prop = NULL, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ?""",
|
||||||
|
(collection_id,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _create_property(conn, collection_id: int, name: str, prop_type: str,
|
||||||
|
options=None) -> dict:
|
||||||
|
"""Crée une propriété (à la position suivante) et la renvoie.
|
||||||
|
|
||||||
|
Une colonne Statut amorcée sans options reçoit le trio classique
|
||||||
|
« À faire / En cours / Terminée » : sans options, le Kanban n'aurait
|
||||||
|
aucune colonne à afficher.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties "
|
||||||
|
"WHERE collection_id = ?",
|
||||||
|
(collection_id,),
|
||||||
|
).fetchone()
|
||||||
|
position = row[0] if row else 0
|
||||||
|
|
||||||
|
opts = []
|
||||||
|
for opt in options or []:
|
||||||
|
if isinstance(opt, str):
|
||||||
|
opts.append({"name": opt, "color": "gray"})
|
||||||
|
elif isinstance(opt, dict) and opt.get("name"):
|
||||||
|
opts.append({"name": opt["name"], "color": opt.get("color") or "gray"})
|
||||||
|
if not opts and prop_type in ("status", "select"):
|
||||||
|
opts = list(DEFAULT_STATUS_OPTIONS)
|
||||||
|
|
||||||
|
cur = conn.execute(
|
||||||
|
"""INSERT INTO collection_properties
|
||||||
|
(collection_id, name, prop_type, options_json, position, required,
|
||||||
|
visible_in_views)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 0, 1)""",
|
||||||
|
(collection_id, name, prop_type, json.dumps(opts), position),
|
||||||
|
)
|
||||||
|
prop_id = cur.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
return {"id": prop_id, "name": name, "prop_type": prop_type}
|
||||||
@@ -174,6 +174,11 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
|||||||
active_ws_name = row["name"]
|
active_ws_name = row["name"]
|
||||||
workspace_pages = _load_workspace_pages(ws_cookie)
|
workspace_pages = _load_workspace_pages(ws_cookie)
|
||||||
has_active_workspace = True
|
has_active_workspace = True
|
||||||
|
# v7.46.0 : exposer l'ID de l'espace ACTIF. Avant, le sidebar
|
||||||
|
# ne fournissait que son nom et le bouton Home de la sidebar
|
||||||
|
# retombait sur ``local_workspaces[0]`` (premier espace TRIE
|
||||||
|
# PAR NOM) → clic sur Home = changement d'espace surprise.
|
||||||
|
local_ws_id = wsi
|
||||||
# else: stale cookie from another user — ignore
|
# else: stale cookie from another user — ignore
|
||||||
except (ValueError, Exception):
|
except (ValueError, Exception):
|
||||||
pass
|
pass
|
||||||
@@ -199,8 +204,9 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("_sidebar_data")
|
logger.exception("_sidebar_data")
|
||||||
|
|
||||||
# Get local workspace ID for Gitea workspace mirror
|
# Get local workspace ID for Gitea workspace mirror (le miroir Gitea est un
|
||||||
local_ws_id = 0
|
# espace DISTINCT : on ne doit pas écraser ``local_ws_id`` (espace actif).
|
||||||
|
gitea_mirror_ws_id = 0
|
||||||
if gitea_workspace and gitea_owner and gitea_repo:
|
if gitea_workspace and gitea_owner and gitea_repo:
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
@@ -209,18 +215,18 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
|||||||
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
|
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if row:
|
if row:
|
||||||
local_ws_id = row["id"]
|
gitea_mirror_ws_id = row["id"]
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("_sidebar_data")
|
logger.exception("_sidebar_data")
|
||||||
|
|
||||||
# Private pages for mirror workspace (when Gitea remote active)
|
# Private pages for mirror workspace (when Gitea remote active)
|
||||||
private_pages = []
|
private_pages = []
|
||||||
if gitea_workspace and local_ws_id:
|
if gitea_workspace and gitea_mirror_ws_id:
|
||||||
try:
|
try:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
pp_rows = conn.execute(
|
pp_rows = conn.execute(
|
||||||
"SELECT id, title FROM pages WHERE parent_section='Private' AND workspace_id=? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 20",
|
"SELECT id, title FROM pages WHERE parent_section='Private' AND workspace_id=? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 20",
|
||||||
(local_ws_id,)
|
(gitea_mirror_ws_id,)
|
||||||
).fetchall()
|
).fetchall()
|
||||||
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
|
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from app.config import settings
|
|||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
|
|
||||||
from ._common import (
|
from ._common import (
|
||||||
|
WORKSPACE_COOKIE,
|
||||||
_build_breadcrumb,
|
_build_breadcrumb,
|
||||||
_build_tree_children,
|
_build_tree_children,
|
||||||
_file_page_disk_path,
|
_file_page_disk_path,
|
||||||
@@ -34,10 +35,16 @@ router = APIRouter(tags=["dashboard"])
|
|||||||
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
|
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
|
||||||
|
|
||||||
@router.get("/local-workspace", response_class=HTMLResponse)
|
@router.get("/local-workspace", response_class=HTMLResponse)
|
||||||
def local_workspace_page(request: Request, folder: int = None):
|
def local_workspace_page(request: Request, folder: int = None, ws: int = None):
|
||||||
"""Local workspace page with file/folder tree.
|
"""Local workspace page with file/folder tree.
|
||||||
|
|
||||||
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
|
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
|
||||||
|
If ?ws=ID is provided, shows that workspace — c'est ce que fait le bouton
|
||||||
|
« Home » de la sidebar (``/local-workspace?ws=<id>``). Le paramètre était
|
||||||
|
produit par le sidebar mais **ignoré** par la route : le contenu affiché
|
||||||
|
restait celui du workspace *actif* (cookie), donc Home pouvait montrer un
|
||||||
|
autre workspace que celui annoncé dans l'URL. Un ``ws`` qui n'appartient
|
||||||
|
pas à l'utilisateur est ignoré (et le workspace actif conservé).
|
||||||
"""
|
"""
|
||||||
from app.templating import ENV
|
from app.templating import ENV
|
||||||
env = ENV
|
env = ENV
|
||||||
@@ -46,13 +53,46 @@ def local_workspace_page(request: Request, folder: int = None):
|
|||||||
if not user:
|
if not user:
|
||||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||||
|
|
||||||
ws = _get_active_workspace(request, user_id=user["id"])
|
ws_row = _get_active_workspace(request, user_id=user["id"])
|
||||||
ws_id = ws["id"] if ws else None
|
if ws is not None:
|
||||||
|
# Workspace demandé par l'URL : on ne l'accepte que s'il appartient à
|
||||||
|
# l'utilisateur (même règle de propriété que _get_active_workspace).
|
||||||
|
with get_conn() as conn:
|
||||||
|
requested = conn.execute(
|
||||||
|
"SELECT id, name FROM workspaces WHERE id=? AND owner_id=?",
|
||||||
|
(ws, user["id"]),
|
||||||
|
).fetchone()
|
||||||
|
if requested is not None:
|
||||||
|
ws_row = dict(requested)
|
||||||
|
# L'URL fait foi : on aligne le cookie pour que le reste de
|
||||||
|
# l'application (sidebar, API tree, breadcrumbs) suive le même
|
||||||
|
# workspace que celui affiché.
|
||||||
|
response = _render_local_workspace(env, sidebar, user, ws_row, folder)
|
||||||
|
response.set_cookie(WORKSPACE_COOKIE, str(ws_row["id"]),
|
||||||
|
max_age=86400 * 30, httponly=True, path="/")
|
||||||
|
return response
|
||||||
|
|
||||||
|
ws_id = ws_row["id"] if ws_row else None
|
||||||
|
|
||||||
# If no workspace exists for this user, redirect to workspaces page
|
# If no workspace exists for this user, redirect to workspaces page
|
||||||
if not ws_id:
|
if not ws_id:
|
||||||
return RedirectResponse("/workspaces", status_code=302)
|
return RedirectResponse("/workspaces", status_code=302)
|
||||||
|
|
||||||
|
return _render_local_workspace(env, sidebar, user, ws_row, folder)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_local_workspace(env, sidebar: dict, user: dict, ws_row: dict,
|
||||||
|
folder: int | None) -> HTMLResponse:
|
||||||
|
"""Render the local-workspace page for ``ws_row`` (shared by both paths).
|
||||||
|
|
||||||
|
``sidebar`` a été calculé **avant** le éventuel changement de workspace
|
||||||
|
(``?ws=``) : on force donc le nom affiché et la sidebar sur ``ws_row``,
|
||||||
|
sinon la page afficherait le contenu d'un workspace sous le titre d'un
|
||||||
|
autre.
|
||||||
|
"""
|
||||||
|
ws_id = ws_row["id"]
|
||||||
|
ws_name = (ws_row.get("name") or sidebar.get("active_ws_name") or "My Workspace")
|
||||||
|
|
||||||
# Build breadcrumb if navigating into a folder
|
# Build breadcrumb if navigating into a folder
|
||||||
breadcrumb = []
|
breadcrumb = []
|
||||||
current_folder_id = folder
|
current_folder_id = folder
|
||||||
@@ -63,7 +103,9 @@ def local_workspace_page(request: Request, folder: int = None):
|
|||||||
ctx = {
|
ctx = {
|
||||||
**sidebar,
|
**sidebar,
|
||||||
"user": user,
|
"user": user,
|
||||||
"workspace_name": sidebar.get("active_ws_name", "My Workspace"),
|
"workspace_name": ws_name,
|
||||||
|
"active_ws_name": ws_name,
|
||||||
|
"active_workspace_id": ws_id,
|
||||||
"current_folder_id": current_folder_id or 0,
|
"current_folder_id": current_folder_id or 0,
|
||||||
"workspace_id": ws_id or 0,
|
"workspace_id": ws_id or 0,
|
||||||
"nav_workspace_id": ws_id or 0,
|
"nav_workspace_id": ws_id or 0,
|
||||||
@@ -255,6 +297,7 @@ def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
|
|||||||
@router.post("/api/local-workspace/items")
|
@router.post("/api/local-workspace/items")
|
||||||
def create_local_workspace_item(request: Request, body: dict = Body(default={})):
|
def create_local_workspace_item(request: Request, body: dict = Body(default={})):
|
||||||
"""Create a new file in the active workspace."""
|
"""Create a new file in the active workspace."""
|
||||||
|
_require_user_id(request) # A3/A4 : pas de creation de page anonyme
|
||||||
name = (body.get("name") or "").strip() or "Untitled"
|
name = (body.get("name") or "").strip() or "Untitled"
|
||||||
item_type = body.get("type", "page")
|
item_type = body.get("type", "page")
|
||||||
parent_id = body.get("parent_id")
|
parent_id = body.get("parent_id")
|
||||||
@@ -279,6 +322,7 @@ def create_local_workspace_item(request: Request, body: dict = Body(default={}))
|
|||||||
@router.put("/api/local-workspace/items/{item_id:int}")
|
@router.put("/api/local-workspace/items/{item_id:int}")
|
||||||
def rename_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
def rename_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
||||||
"""Rename a file."""
|
"""Rename a file."""
|
||||||
|
_require_user_id(request)
|
||||||
name = body.get("name", "Untitled").strip()
|
name = body.get("name", "Untitled").strip()
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute("UPDATE pages SET title=? WHERE id=?", (name, item_id))
|
conn.execute("UPDATE pages SET title=? WHERE id=?", (name, item_id))
|
||||||
@@ -291,6 +335,7 @@ def rename_local_workspace_item(request: Request, item_id: int, body: dict = Bod
|
|||||||
@router.delete("/api/local-workspace/items/{item_id:int}")
|
@router.delete("/api/local-workspace/items/{item_id:int}")
|
||||||
def delete_local_workspace_item(request: Request, item_id: int):
|
def delete_local_workspace_item(request: Request, item_id: int):
|
||||||
"""Soft-delete a file/folder (sets deleted_at)."""
|
"""Soft-delete a file/folder (sets deleted_at)."""
|
||||||
|
_require_user_id(request)
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -306,6 +351,7 @@ def delete_local_workspace_item(request: Request, item_id: int):
|
|||||||
@router.post("/api/local-workspace/items/{item_id:int}/restore")
|
@router.post("/api/local-workspace/items/{item_id:int}/restore")
|
||||||
def restore_local_workspace_item(request: Request, item_id: int):
|
def restore_local_workspace_item(request: Request, item_id: int):
|
||||||
"""Restore a soft-deleted file/folder."""
|
"""Restore a soft-deleted file/folder."""
|
||||||
|
_require_user_id(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE pages SET deleted_at=NULL WHERE id=?",
|
"UPDATE pages SET deleted_at=NULL WHERE id=?",
|
||||||
@@ -342,6 +388,7 @@ def serve_uploaded_file(ws_id: int, filename: str):
|
|||||||
@router.put("/api/local-workspace/items/{item_id:int}/move")
|
@router.put("/api/local-workspace/items/{item_id:int}/move")
|
||||||
def move_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
def move_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
||||||
"""Move an item to a new parent (drag & drop)."""
|
"""Move an item to a new parent (drag & drop)."""
|
||||||
|
_require_user_id(request)
|
||||||
new_parent_id = body.get("parent_id") # None = move to root
|
new_parent_id = body.get("parent_id") # None = move to root
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Request
|
from fastapi import APIRouter, Body, HTTPException, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
@@ -62,11 +62,20 @@ def api_rename_page(page_id: int, request: Request, body: dict = Body(default={}
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/api/pages/{page_id:int}/trash")
|
@router.post("/api/pages/{page_id:int}/trash")
|
||||||
def api_trash_page(page_id: int):
|
def api_trash_page(page_id: int, request: Request):
|
||||||
"""Soft-delete a page (move to trash)."""
|
"""Soft-delete a page (move to trash).
|
||||||
|
|
||||||
|
v7.45.5 : ``parent_section`` n'est plus réécrit en 'Trash' — ``deleted_at``
|
||||||
|
est la seule source de vérité. L'ancienne écriture marquait définitivement
|
||||||
|
la page : à la restauration elle restait 'Trash' et disparaissait des
|
||||||
|
listings Library / Recents / Private (``parent_section != 'Trash'``).
|
||||||
|
"""
|
||||||
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(401, "Authentication required")
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE pages SET parent_section='Trash', deleted_at=CURRENT_TIMESTAMP WHERE id=?",
|
"UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||||
(page_id,),
|
(page_id,),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
@@ -29,15 +29,16 @@ def trash_page(request: Request, owner: str = Query(default=""), repo: str = Que
|
|||||||
sidebar = board_sidebar(request, owner, repo)
|
sidebar = board_sidebar(request, owner, repo)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||||
# Pages are soft-deleted via parent_section='Trash'
|
# Pages are soft-deleted via deleted_at (parent_section n'est plus
|
||||||
|
# touché par le trash → source de vérité unique, v7.45.5)
|
||||||
if ws_key:
|
if ws_key:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, title, workspace FROM pages WHERE parent_section='Trash' AND workspace=? ORDER BY updated_at DESC",
|
"SELECT id, title, workspace FROM pages WHERE deleted_at IS NOT NULL AND workspace=? ORDER BY updated_at DESC",
|
||||||
(ws_key,),
|
(ws_key,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
else:
|
else:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, title, workspace FROM pages WHERE parent_section='Trash' ORDER BY updated_at DESC",
|
"SELECT id, title, workspace FROM pages WHERE deleted_at IS NOT NULL ORDER BY updated_at DESC",
|
||||||
).fetchall()
|
).fetchall()
|
||||||
sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows]
|
sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows]
|
||||||
# Pass active workspace for breadcrumb nav menu
|
# Pass active workspace for breadcrumb nav menu
|
||||||
@@ -216,8 +217,10 @@ def view_page_root(request: Request, page_id: int):
|
|||||||
"{% from '_icons.html' import fd_icon %}{{ fd_icon('paperclip', 14) }}"
|
"{% from '_icons.html' import fd_icon %}{{ fd_icon('paperclip', 14) }}"
|
||||||
).render(),
|
).render(),
|
||||||
)
|
)
|
||||||
# Select template: collection pages use database table view
|
# Select template: collection pages use database table view (aussi en
|
||||||
if page.get("content_format") == "collection" and not embed:
|
# mode embed : `page_editor_collection.html` + body.embed-mode = le
|
||||||
|
# tableau SANS sidebar ni barre — le peek d'une base reste le tableau).
|
||||||
|
if page.get("content_format") == "collection":
|
||||||
template = env.get_template("page_editor_collection.html")
|
template = env.get_template("page_editor_collection.html")
|
||||||
else:
|
else:
|
||||||
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
|
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
|
||||||
@@ -248,209 +251,10 @@ def accounts_page(request: Request):
|
|||||||
|
|
||||||
@router.get("/help", response_class=HTMLResponse)
|
@router.get("/help", response_class=HTMLResponse)
|
||||||
def help_page(request: Request):
|
def help_page(request: Request):
|
||||||
"""Comprehensive help & documentation page."""
|
"""Help & documentation page — settings-style panel with side navigation."""
|
||||||
from app.templating import ENV
|
from app.templating import ENV
|
||||||
env = ENV
|
|
||||||
sidebar = _sidebar_data(request, [])
|
sidebar = _sidebar_data(request, [])
|
||||||
# Render via a block-based template so content_html lands in {% block content %}
|
return ENV.get_template("help.html").render(**sidebar, request=request)
|
||||||
block_tpl = env.from_string(
|
|
||||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
|
||||||
)
|
|
||||||
return HTMLResponse(block_tpl.render(
|
|
||||||
**sidebar,
|
|
||||||
request=request,
|
|
||||||
page_title="Help",
|
|
||||||
title_prefix="Help",
|
|
||||||
page_icon="❓",
|
|
||||||
content_html="""<style>
|
|
||||||
.help-page{max-width:900px;margin:0 auto;padding:40px 24px 80px;}
|
|
||||||
.help-hero{text-align:center;margin-bottom:48px;}
|
|
||||||
.help-hero h1{font-size:32px;font-weight:800;margin:0 0 8px;}
|
|
||||||
.help-hero p{font-size:16px;color:var(--text-dim);max-width:500px;margin:0 auto;}
|
|
||||||
.help-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin-bottom:48px;}
|
|
||||||
.help-card{background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:24px;transition:border-color .15s;}
|
|
||||||
.help-card:hover{border-color:rgba(255,255,255,.12);}
|
|
||||||
.help-card h3{font-size:15px;font-weight:600;margin:0 0 4px;display:flex;align-items:center;gap:8px;}
|
|
||||||
.help-card .icon{font-size:20px;}
|
|
||||||
.help-card p{font-size:13px;color:var(--text-dim);line-height:1.5;margin:8px 0 0;}
|
|
||||||
.help-card ul{list-style:none;padding:0;margin:12px 0 0;}
|
|
||||||
.help-card li{font-size:13px;padding:3px 0;color:var(--text-dim);}
|
|
||||||
.help-card li::before{content:'• ';color:var(--accent);}
|
|
||||||
.help-section{margin-bottom:48px;}
|
|
||||||
.help-section h2{font-size:20px;font-weight:700;margin:0 0 16px;padding-bottom:8px;border-bottom:1px solid var(--border);}
|
|
||||||
.help-kbd{display:inline-block;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:12px;color:var(--text);min-width:16px;text-align:center;}
|
|
||||||
.help-shortcut-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border-radius:6px;margin-bottom:2px;}
|
|
||||||
.help-shortcut-row:hover{background:var(--bg-hover);}
|
|
||||||
.help-shortcut-row .keys{display:flex;gap:4px;min-width:140px;}
|
|
||||||
.help-shortcut-row .desc{font-size:13px;color:var(--text-dim);}
|
|
||||||
.help-badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:11px;font-weight:600;}
|
|
||||||
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
|
|
||||||
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
|
|
||||||
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
|
|
||||||
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
|
|
||||||
</style>
|
|
||||||
<div class="help-page">
|
|
||||||
<div class="help-hero">
|
|
||||||
<h1>❓ FlowDeck Help</h1>
|
|
||||||
<p>Everything you need to know about your Notion-style workspace with Gitea & GitHub integration.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-grid">
|
|
||||||
<div class="help-card">
|
|
||||||
<h3><span class="icon">🚀</span>Getting Started</h3>
|
|
||||||
<p>FlowDeck is your private, self-hosted workspace. Create pages, organize projects, and integrate with your Git forge.</p>
|
|
||||||
<ul>
|
|
||||||
<li>Create a workspace from the <b>Workspaces</b> page</li>
|
|
||||||
<li>Click <b>📄 New Page</b> in the sidebar to start writing</li>
|
|
||||||
<li>Use <span class="help-kbd">Ctrl+N</span> anywhere to create a page</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-card">
|
|
||||||
<h3><span class="icon">📝</span>Pages & Editor</h3>
|
|
||||||
<p>Notion-style block editor with slash commands, markdown shortcuts, and rich formatting.</p>
|
|
||||||
<ul>
|
|
||||||
<li>Type <span class="help-kbd">/</span> for the slash command menu</li>
|
|
||||||
<li>Drag & drop pages in the sidebar to reorganize</li>
|
|
||||||
<li>Right-click for context menu (duplicate, rename, delete)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-card">
|
|
||||||
<h3><span class="icon">{{ fd_icon("folder",16) }}</span>Workspaces</h3>
|
|
||||||
<p>Organize your work into separate workspaces. Each has its own pages and files.</p>
|
|
||||||
<ul>
|
|
||||||
<li><span class="help-badge local">Local</span> Files stored on your server</li>
|
|
||||||
<li><span class="help-badge gitea">Gitea</span> Connect to browse & edit repos</li>
|
|
||||||
<li><span class="help-badge github">GitHub</span> Connect via Settings → Integrations</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-card">
|
|
||||||
<h3><span class="icon">🦎</span>Gitea Integration</h3>
|
|
||||||
<p>Connect your Gitea account to access repositories directly from FlowDeck.</p>
|
|
||||||
<ul>
|
|
||||||
<li>Go to <b>Settings → Integrations</b> to connect</li>
|
|
||||||
<li>Browse repo file trees in the sidebar</li>
|
|
||||||
<li>Create & edit files with commit messages</li>
|
|
||||||
<li>Sync labels as tags</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-card">
|
|
||||||
<h3><span class="icon">🌐</span>Sharing & Publishing</h3>
|
|
||||||
<p>Share pages with collaborators or publish them to the web.</p>
|
|
||||||
<ul>
|
|
||||||
<li>Click <b>Share</b> in the page editor top-right</li>
|
|
||||||
<li>Share with specific users or get a public link</li>
|
|
||||||
<li>Publish to make a page visible at <code>/p/your-slug</code></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-card">
|
|
||||||
<h3><span class="icon">📚</span>Library, Trash & Tasks</h3>
|
|
||||||
<p>Find all your content in one place with powerful filtering.</p>
|
|
||||||
<ul>
|
|
||||||
<li><b>Library</b> — Tabs for Recents, Favorites, Shared, Published</li>
|
|
||||||
<li><b>Trash</b> — Soft-deleted pages (30-day retention)</li>
|
|
||||||
<li><b>My Tasks</b> — Aggregated tasks from all collections</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-card">
|
|
||||||
<h3><span class="icon">📶</span>Offline & PWA</h3>
|
|
||||||
<p>Install FlowDeck as an app and keep working without a connection.</p>
|
|
||||||
<ul>
|
|
||||||
<li><b>Install</b> — browser menu → <i>Install app</i> / <i>Add to Home Screen</i></li>
|
|
||||||
<li>Edits made offline are queued locally and synced automatically</li>
|
|
||||||
<li>A <b>⟳</b> marker shows pages with pending changes</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-section">
|
|
||||||
<h2>⌨️ Keyboard Shortcuts</h2>
|
|
||||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">N</span></div><div class="desc">Create new page</div></div>
|
|
||||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">K</span></div><div class="desc">Quick find / command palette</div></div>
|
|
||||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">S</span></div><div class="desc">Save current page</div></div>
|
|
||||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">F2</span></div><div class="desc">Rename selected item</div></div>
|
|
||||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Delete</span></div><div class="desc">Move selected item to trash</div></div>
|
|
||||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Escape</span></div><div class="desc">Close modal / cancel editing</div></div>
|
|
||||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Enter</span></div><div class="desc">Open selected page</div></div>
|
|
||||||
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">O</span></div><div class="desc">New AI chat (in footer)</div></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-section">
|
|
||||||
<h2>🔐 Authentication</h2>
|
|
||||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
|
||||||
FlowDeck supports three authentication methods:<br>
|
|
||||||
<span class="help-badge local">Local</span> Email + password — create an account on the login page.<br>
|
|
||||||
<span class="help-badge gitea">Gitea OAuth</span> Login with your Gitea account. Your repos appear as workspaces.<br>
|
|
||||||
<span class="help-badge github">GitHub OAuth</span> Login or link your GitHub account in Settings → Integrations.<br><br>
|
|
||||||
<b>Tip:</b> You can connect Gitea/GitHub to an existing local account — your identity stays as your local user.
|
|
||||||
</p>
|
|
||||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
|
||||||
<span class="help-badge sso">SSO</span> <b>Enterprise SSO</b> (v6.7.0) — sign in with your organization account.<br>
|
|
||||||
<i>For administrators:</i> open <b>Settings → Admin → SSO / Enterprise</b> and pick a provider:<br>
|
|
||||||
• <b>SAML 2.0</b> — paste the IdP <i>Entity ID</i>, <i>SSO URL</i> and signing certificate, then give the IdP this
|
|
||||||
<code>/auth/saml/metadata</code> link (it contains the SP Entity ID, ACS URL and certificate).<br>
|
|
||||||
• <b>OpenID Connect</b> — paste the <i>Issuer URL</i>, <i>Client ID</i> and <i>Client Secret</i> (PKCE is used, scopes default to <code>openid profile email</code>).<br>
|
|
||||||
• <b>Provisioning</b> — accounts are created automatically on first login, groups from the IdP map to workspace roles,
|
|
||||||
and <i>SSO only</i> disables local login (admins keep their local door). Every attempt is audited in
|
|
||||||
<b>Settings → Admin → SSO / Enterprise</b> (login history).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-section">
|
|
||||||
<h2>📶 Offline mode (PWA)</h2>
|
|
||||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
|
||||||
FlowDeck is a Progressive Web App: pages you visited stay available offline and your
|
|
||||||
edits are saved locally, then synchronised when the connection returns.<br><br>
|
|
||||||
<b>Install:</b> open your browser menu and choose <i>Install app</i> (Chrome/Edge) or
|
|
||||||
<i>Add to Home Screen</i> (Safari/iOS). FlowDeck then opens in its own window.<br>
|
|
||||||
<b>Offline editing:</b> while offline, the editor stores changes in the browser
|
|
||||||
(IndexedDB) and shows an offline banner with the number of pending changes. A
|
|
||||||
<b>⟳</b> icon appears next to pages that have unsynced edits.<br>
|
|
||||||
<b>Reconnection:</b> the queue is replayed automatically (and via Background Sync).
|
|
||||||
A spinner badge appears while syncing, followed by a confirmation toast.<br>
|
|
||||||
<b>Conflicts:</b> if a page changed on the server, the latest edit wins and a notice is
|
|
||||||
shown. If a page was deleted server-side, your offline copy is recreated as an orphan
|
|
||||||
page. If a page with the same title already exists, the offline copy is renamed
|
|
||||||
<i>“Title (copie offline)”</i>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-section">
|
|
||||||
<h2>🔌 API publique v2</h2>
|
|
||||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
|
||||||
FlowDeck exposes a full REST API under <b>/api/v2</b> for third-party integrations.<br>
|
|
||||||
<b>Auth:</b> create a token in Settings → API tokens, then send it as
|
|
||||||
<code>Authorization: Bearer <token></code>. Tokens carry scopes
|
|
||||||
<code>read</code>, <code>write</code> or <code>admin</code> (a higher scope implies the lower ones).<br>
|
|
||||||
<b>Features:</b> CRUD on collections, pages, properties, views, comments, notifications,
|
|
||||||
favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&offset=</code> +
|
|
||||||
<code>X-Total-Count</code>), filters (<code>filter[prop]=value</code>), sorting, full-text search
|
|
||||||
(<code>/api/v2/search</code>), idempotency (<code>Idempotency-Key</code>) and RFC 7807 error bodies.<br>
|
|
||||||
<b>Reference:</b> interactive OpenAPI docs at <a href="/docs" target="_blank" rel="noopener">/docs</a>
|
|
||||||
(also <code>/redoc</code>, <code>docs/openapi-v2.json</code>).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="help-section">
|
|
||||||
<h2>💡 Tips</h2>
|
|
||||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
|
||||||
• Toggle the sidebar with the <b>«</b> button in the top-left corner.<br>
|
|
||||||
• Switch between workspaces using the dropdown menu in the sidebar header.<br>
|
|
||||||
• The <b>Private</b> section appears when a remote workspace is active — files here stay local.<br>
|
|
||||||
• Hover over any sidebar item to see action buttons (favorite, share, delete).<br>
|
|
||||||
• Use <b>Ctrl+Click</b> or <b>Shift+Click</b> to multi-select items in the sidebar.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>"""
|
|
||||||
))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/accounts/settings", response_class=HTMLResponse)
|
@router.get("/accounts/settings", response_class=HTMLResponse)
|
||||||
|
|||||||
@@ -6,18 +6,47 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Request
|
from fastapi import APIRouter, Body, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
|
|
||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
|
from app.services.collection_lifecycle import (
|
||||||
|
delete_collections,
|
||||||
|
workspace_collection_ids,
|
||||||
|
)
|
||||||
|
|
||||||
from ._common import WORKSPACE_COOKIE, _get_active_workspace, _get_user_id, _sidebar_data
|
from ._common import (
|
||||||
|
WORKSPACE_COOKIE,
|
||||||
|
_get_active_workspace,
|
||||||
|
_get_user_id,
|
||||||
|
_require_user_id,
|
||||||
|
_sidebar_data,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(tags=["dashboard"])
|
router = APIRouter(tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
def _require_ws_owner(request: Request, ws_id: int) -> int:
|
||||||
|
"""A3/A4 — session obligatoire + l'appelant doit posseder l'espace
|
||||||
|
(ou être admin global). Sans ce garde, un POST/DELETE anonyme créait ou
|
||||||
|
supprimait des espaces au nom de l'utilisateur 1."""
|
||||||
|
uid = _require_user_id(request)
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT owner_id FROM workspaces WHERE id=?", (ws_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Workspace not found")
|
||||||
|
is_admin = conn.execute(
|
||||||
|
"SELECT is_admin FROM users WHERE id=?", (uid,)
|
||||||
|
).fetchone()
|
||||||
|
if row["owner_id"] != uid and not (is_admin and is_admin["is_admin"]):
|
||||||
|
raise HTTPException(403, "Not your workspace")
|
||||||
|
return uid
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/workspaces", response_class=HTMLResponse)
|
@router.get("/workspaces", response_class=HTMLResponse)
|
||||||
@@ -42,8 +71,7 @@ def workspaces_page(request: Request):
|
|||||||
@router.get("/api/workspaces")
|
@router.get("/api/workspaces")
|
||||||
def list_workspaces(request: Request):
|
def list_workspaces(request: Request):
|
||||||
"""List all workspaces for the current user."""
|
"""List all workspaces for the current user."""
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
uid = _require_user_id(request)
|
||||||
uid = user["id"] if user and user.get("id") else 1
|
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id AND collection_row_id IS NULL) as page_count "
|
"SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id AND collection_row_id IS NULL) as page_count "
|
||||||
@@ -67,7 +95,7 @@ def create_workspace(request: Request, body: dict = Body(default={})):
|
|||||||
if not name:
|
if not name:
|
||||||
return {"error": "Name required"}
|
return {"error": "Name required"}
|
||||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
uid = user["id"] if user and user.get("id") else 1
|
uid = _require_user_id(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
# Ensure user exists (FK constraint)
|
# Ensure user exists (FK constraint)
|
||||||
uid_ok = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone()
|
uid_ok = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone()
|
||||||
@@ -99,6 +127,7 @@ def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})
|
|||||||
name = body.get("name", "").strip()
|
name = body.get("name", "").strip()
|
||||||
if not name:
|
if not name:
|
||||||
return {"error": "Name required"}
|
return {"error": "Name required"}
|
||||||
|
_require_ws_owner(request, ws_id)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute("UPDATE workspaces SET name=? WHERE id=?", (name, ws_id))
|
conn.execute("UPDATE workspaces SET name=? WHERE id=?", (name, ws_id))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -109,13 +138,19 @@ def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})
|
|||||||
|
|
||||||
@router.delete("/api/workspaces/{ws_id:int}")
|
@router.delete("/api/workspaces/{ws_id:int}")
|
||||||
def delete_workspace(request: Request, ws_id: int):
|
def delete_workspace(request: Request, ws_id: int):
|
||||||
"""Delete a workspace and all its pages."""
|
"""Delete a workspace, its pages and its databases."""
|
||||||
|
_require_ws_owner(request, ws_id)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
# Les bases du workspace partaient avec ses pages : les laisser créait
|
||||||
|
# des collections orphelines (workspace_id pointant sur rien) que
|
||||||
|
# /db et My Tasks continuaient de lister.
|
||||||
|
collections = workspace_collection_ids(conn, ws_id)
|
||||||
|
delete_collections(conn, collections)
|
||||||
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,))
|
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,))
|
||||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,))
|
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,))
|
||||||
conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,))
|
conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return {"status": "ok"}
|
return {"status": "ok", "collections": len(collections)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -123,6 +158,7 @@ def delete_workspace(request: Request, ws_id: int):
|
|||||||
@router.post("/api/workspaces/{ws_id:int}/select")
|
@router.post("/api/workspaces/{ws_id:int}/select")
|
||||||
def select_workspace(request: Request, ws_id: int):
|
def select_workspace(request: Request, ws_id: int):
|
||||||
"""Set the active workspace via cookie."""
|
"""Set the active workspace via cookie."""
|
||||||
|
_require_ws_owner(request, ws_id)
|
||||||
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
|
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
|
||||||
response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/")
|
response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/")
|
||||||
return response
|
return response
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ See ``docs/V71_Calendar_Meetings.md``.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
|
from datetime import UTC, date, datetime, timedelta
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, HTTPException, Request
|
from fastapi import APIRouter, Body, HTTPException, Request
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
@@ -115,6 +117,72 @@ def freebusy(collection_id: int, request: Request):
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ── upcoming (sidebar "Meeting" tab) ────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/api/v2/meetings/upcoming")
|
||||||
|
def upcoming_meetings(request: Request, days: int = 30):
|
||||||
|
"""Upcoming calendar events for the sidebar Meeting tab.
|
||||||
|
|
||||||
|
Reads every calendar collection linked to the current user, extracts the
|
||||||
|
date property of each linked row and returns the ones falling in the next
|
||||||
|
``days`` days (today included), sorted chronologically. Rows imported from
|
||||||
|
Google/CalDAV carry an ``external_event_id`` and are flagged ``synced``.
|
||||||
|
"""
|
||||||
|
user = _auth_user(request)
|
||||||
|
horizon = max(1, min(days, 365))
|
||||||
|
today = datetime.now(UTC).date()
|
||||||
|
end = today + timedelta(days=horizon)
|
||||||
|
events: list[dict] = []
|
||||||
|
with get_conn() as conn:
|
||||||
|
links = conn.execute(
|
||||||
|
"SELECT id, collection_id, date_property, calendar_id, provider "
|
||||||
|
"FROM calendar_links WHERE user_id=? ORDER BY id",
|
||||||
|
(user["id"],),
|
||||||
|
).fetchall()
|
||||||
|
for link in links:
|
||||||
|
collection_id = link["collection_id"]
|
||||||
|
if not collection_id:
|
||||||
|
continue
|
||||||
|
date_prop = cal.date_prop_id(conn, collection_id,
|
||||||
|
link["date_property"] or "")
|
||||||
|
if not date_prop:
|
||||||
|
continue
|
||||||
|
prop_id, prop_name = date_prop
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, title, property_values_json, external_event_id "
|
||||||
|
"FROM collection_pages WHERE collection_id=?",
|
||||||
|
(collection_id,),
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
values = json.loads(row["property_values_json"] or "{}")
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
raw = cal.row_date(values, prop_id, prop_name)[:10]
|
||||||
|
if len(raw) != 10:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
day = date.fromisoformat(raw)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
if not (today <= day <= end):
|
||||||
|
continue
|
||||||
|
events.append({
|
||||||
|
"id": row["id"],
|
||||||
|
"title": row["title"] or "Untitled",
|
||||||
|
"date": raw,
|
||||||
|
"today": day == today,
|
||||||
|
"collection_id": collection_id,
|
||||||
|
"calendar_id": link["calendar_id"] or "primary",
|
||||||
|
"provider": link["provider"],
|
||||||
|
"synced": bool(row["external_event_id"]),
|
||||||
|
"url": f"/db/{collection_id}",
|
||||||
|
})
|
||||||
|
events.sort(key=lambda e: (e["date"], e["title"].lower()))
|
||||||
|
return {"today": today.isoformat(), "days": horizon,
|
||||||
|
"events": events[:200], "count": len(events)}
|
||||||
|
|
||||||
|
|
||||||
# ── meetings ───────────────────────────────────────────────────────────────
|
# ── meetings ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@router.post("/api/v2/meetings/transcribe")
|
@router.post("/api/v2/meetings/transcribe")
|
||||||
|
|||||||
+374
-98
@@ -1,147 +1,423 @@
|
|||||||
"""FlowDeck — My Tasks router (v1.9.0)."""
|
"""FlowDeck — My Tasks (tableau de bord de tâches façon Notion).
|
||||||
|
|
||||||
|
Contrairement à Notion, où *My Tasks* vit dans l'onglet **Home** sans être
|
||||||
|
rattaché à une page, cette page est un **tableau de bord transverse** : elle
|
||||||
|
réunit toutes les tâches qui vous sont assignées, **tous workspaces
|
||||||
|
confondus**. Le cookie `flowdeck_workspace` ne cadre donc plus cette vue (il
|
||||||
|
reste utilisé par la sidebar).
|
||||||
|
|
||||||
|
Les sources sont opt-in : une base n'alimente My Tasks qu'après conversion
|
||||||
|
explicite (`collections.is_task` + mapping des trois propriétés requises —
|
||||||
|
`app/services/task_databases.py`).
|
||||||
|
|
||||||
|
Trois vues (tableau / kanban / calendrier), filtres globaux (échéance
|
||||||
|
aujourd'hui, masquer les terminées), tri (échéance, statut, source), édition
|
||||||
|
en direct du statut et de l'échéance, et création rapide dans n'importe
|
||||||
|
quelle base connectée.
|
||||||
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Body, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
|
||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
|
from app.services.property_types import apply_auto_properties
|
||||||
|
from app.services.task_databases import (
|
||||||
|
MAX_TASK_SOURCES,
|
||||||
|
collect_tasks,
|
||||||
|
filter_and_sort,
|
||||||
|
list_task_sources,
|
||||||
|
user_today,
|
||||||
|
)
|
||||||
from app.templating import ENV
|
from app.templating import ENV
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
|
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
|
||||||
|
|
||||||
|
VIEWS = ("table", "board", "calendar")
|
||||||
|
DUE_FILTERS = ("all", "today", "overdue", "week")
|
||||||
|
SORTS = ("due", "status", "source", "created")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Session ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _get_current_user(request: Request) -> dict | None:
|
def _get_current_user(request: Request) -> dict | None:
|
||||||
session = request.cookies.get("flowdeck_session", "")
|
session = request.cookies.get("flowdeck_session", "")
|
||||||
return SessionManager.decode_session(session)
|
return SessionManager.decode_session(session)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_class=HTMLResponse)
|
def _require_user(request: Request) -> dict:
|
||||||
def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
|
"""Session obligatoire, sans repli « admin » (id 1) : ce tableau de bord
|
||||||
"""My Tasks — aggregates all pages assigned to the current user across all collections."""
|
est strictement celui de l'appelant."""
|
||||||
user = _get_current_user(request)
|
user = _get_current_user(request)
|
||||||
user_login = user.get("login", "admin") if user else "admin"
|
if not user or not user.get("id"):
|
||||||
|
raise HTTPException(status_code=401, detail="Authentication required")
|
||||||
|
return user
|
||||||
|
|
||||||
with get_conn() as conn:
|
|
||||||
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
|
|
||||||
grouped: dict[str, list[dict]] = {}
|
|
||||||
|
|
||||||
for col in collections:
|
# ── Données ──────────────────────────────────────────────────────────────
|
||||||
col_id = col["id"]
|
|
||||||
pages = conn.execute(
|
|
||||||
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
|
|
||||||
(col_id,),
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
collection_tasks = []
|
def _workspace_names(conn, user_id: int) -> dict[int, str]:
|
||||||
for p in pages:
|
rows = conn.execute(
|
||||||
props = {}
|
"SELECT id, name FROM workspaces WHERE owner_id = ?", (user_id,)
|
||||||
try:
|
).fetchall()
|
||||||
props = json.loads(p["property_values_json"] or "{}")
|
return {r["id"]: r["name"] for r in rows}
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
pass
|
|
||||||
due_date = props.get("due_date", "")
|
|
||||||
status = props.get("status", "—")
|
|
||||||
assignee = props.get("assignee", "")
|
|
||||||
if view != "all" and assignee and assignee != user_login:
|
|
||||||
continue
|
|
||||||
collection_tasks.append({
|
|
||||||
"id": p["id"],
|
|
||||||
"title": p["title"] or "Untitled",
|
|
||||||
"icon": p["icon"] or "📋",
|
|
||||||
"status": status,
|
|
||||||
"due_date": due_date,
|
|
||||||
})
|
|
||||||
if collection_tasks:
|
|
||||||
grouped[col["name"]] = collection_tasks
|
|
||||||
|
|
||||||
total = sum(len(t) for t in grouped.values())
|
|
||||||
|
|
||||||
# Build content HTML
|
def _validated(view: str, due: str, sort: str) -> tuple[str, str, str]:
|
||||||
sections = ""
|
"""Paramètres d'URL bornés aux valeurs réellement implémentées."""
|
||||||
for col_name, tasks in grouped.items():
|
return (
|
||||||
items = ""
|
view if view in VIEWS else "table",
|
||||||
for t in tasks:
|
due if due in DUE_FILTERS else "all",
|
||||||
due_badge = f"<span class='mt-due'>{t['due_date']}</span>" if t.get("due_date") else ""
|
sort if sort in SORTS else "due",
|
||||||
status_cls = t['status'].lower().replace(' ', '-') if t.get('status') else 'none'
|
)
|
||||||
status_badge = f"<span class='mt-status mt-{status_cls}'>{t.get('status', '—')}</span>"
|
|
||||||
items += f"<div class='mt-item'><span class='mt-icon'>{t['icon']}</span><span class='mt-title'>{t['title']}</span>{status_badge}{due_badge}</div>"
|
|
||||||
|
|
||||||
sections += f"<div class='mt-section'><div class='mt-section-header'>{col_name} <span class='mt-count'>{len(tasks)}</span></div>{items}</div>"
|
|
||||||
|
|
||||||
content_html = f"""<div style="max-width:800px;margin:0 auto;padding:40px 24px;">
|
def _public_source(s: dict) -> dict:
|
||||||
<h1 style="font-size:24px;margin:0 0 8px;">📋 My Tasks</h1>
|
return {
|
||||||
<p style="color:var(--text-dim);font-size:14px;margin-bottom:24px;">{total} tasks across {len(grouped)} collections</p>
|
"id": s["id"],
|
||||||
<div class="view-tabs" style="display:flex;gap:4px;margin-bottom:24px;border-bottom:1px solid var(--border);padding-bottom:12px;">
|
"name": s["name"],
|
||||||
<a class="tab{' active' if view=='all' else ''}" href="/my-tasks?view=all" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">All</a>
|
"icon": s["icon"],
|
||||||
<a class="tab{' active' if view=='today' else ''}" href="/my-tasks?view=today" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Today</a>
|
"workspace_id": s["workspace_id"],
|
||||||
<a class="tab{' active' if view=='overdue' else ''}" href="/my-tasks?view=overdue" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Overdue</a>
|
"workspace_name": s.get("workspace_name") or "",
|
||||||
<a class="tab{' active' if view=='upcoming' else ''}" href="/my-tasks?view=upcoming&days=7" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Next 7 days</a>
|
"configured": s["configured"],
|
||||||
</div>
|
"missing_roles": s["missing_roles"],
|
||||||
<style>
|
"status_options": s["status_options"],
|
||||||
.tab.active{{background:var(--accent);color:#fff!important;}}
|
}
|
||||||
.tab:hover{{background:var(--bg-hover);color:var(--text);}}
|
|
||||||
.mt-section{{margin-bottom:24px;}}
|
|
||||||
.mt-section-header{{font-size:13px;font-weight:600;color:var(--text-dim);margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px;}}
|
|
||||||
.mt-count{{color:var(--text-dim);font-weight:400;opacity:.5;}}
|
|
||||||
.mt-item{{display:flex;align-items:center;gap:10px;padding:10px 12px;background:var(--bg-card);border-radius:8px;margin-bottom:3px;border:1px solid var(--border);}}
|
|
||||||
.mt-item:hover{{border-color:var(--accent);}}
|
|
||||||
.mt-icon{{font-size:16px;}}
|
|
||||||
.mt-title{{flex:1;font-size:14px;}}
|
|
||||||
.mt-status{{font-size:11px;padding:2px 8px;border-radius:4px;}}
|
|
||||||
.mt-due{{font-size:11px;color:var(--text-dim);}}
|
|
||||||
.mt-todo,.mt-none{{background:rgba(255,255,255,.05);color:var(--text-dim);}}
|
|
||||||
.mt-in-progress{{background:rgba(35,131,226,.15);color:#2C8CEB;}}
|
|
||||||
.mt-done,.mt-completed,.mt-complete{{background:rgba(0,200,100,.15);color:#00CC66;}}
|
|
||||||
</style>
|
|
||||||
{sections}
|
|
||||||
</div>"""
|
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(conn, request: Request, user_id: int, *, due: str, sort: str,
|
||||||
|
hide_done: bool) -> dict:
|
||||||
|
"""Charge et normalise tout ce dont les trois vues ont besoin."""
|
||||||
|
user = _get_current_user(request) or {}
|
||||||
|
tasks = collect_tasks(conn, user_id, mine_only=True,
|
||||||
|
login=user.get("login", ""))
|
||||||
|
# Même jour de référence que `due_state` (fuseau de l'utilisateur).
|
||||||
|
tasks = filter_and_sort(tasks, due=due, hide_done=hide_done, sort=sort,
|
||||||
|
today=user_today(conn, user_id))
|
||||||
|
sources = list_task_sources(conn, user_id)
|
||||||
|
return {
|
||||||
|
"tasks": tasks,
|
||||||
|
"total": len(tasks),
|
||||||
|
"sources": [_public_source(s) for s in sources],
|
||||||
|
"sources_total": len(sources),
|
||||||
|
"sources_configured": sum(1 for s in sources if s["configured"]),
|
||||||
|
"max_sources": MAX_TASK_SOURCES,
|
||||||
|
"filters": {"due": due, "sort": sort, "hide_done": hide_done},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Rendu HTML ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _render_shell(request: Request, content_html: str, *, app_js: bool = True):
|
||||||
|
"""Enveloppe le contenu dans le layout commun (sidebar + base.html).
|
||||||
|
|
||||||
|
`static/css/my_tasks.css` est lié par `base.html` (le shell, jamais swappé)
|
||||||
|
: un `<link>` placé dans la zone swappée pouvait être retiré par htmx lors
|
||||||
|
d'une navigation partielle, et la page revenait sans aucun style. Seul
|
||||||
|
`static/js/my_tasks.js` reste conditionné à ``app_js`` : sans base
|
||||||
|
connectée, il n'y a rien à monter côté client.
|
||||||
|
"""
|
||||||
from app.routers.dashboard import _sidebar_data
|
from app.routers.dashboard import _sidebar_data
|
||||||
env = ENV
|
|
||||||
sidebar = _sidebar_data(request, [])
|
sidebar = _sidebar_data(request, [])
|
||||||
block_tpl = env.from_string(
|
# `assets` est rendu par un mini-template : `asset_version` (cache-busting)
|
||||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
# n'est pas substitutionné si on le concatène à la main.
|
||||||
|
assets = ENV.from_string(
|
||||||
|
"{% if app_js %}"
|
||||||
|
'<script src="/static/js/my_tasks.js?v={{ asset_version }}" defer></script>'
|
||||||
|
"{% endif %}"
|
||||||
|
).render(app_js=app_js)
|
||||||
|
|
||||||
|
block_tpl = ENV.from_string(
|
||||||
|
'{% extends "base.html" %}'
|
||||||
|
"{% block content %}{{ assets|safe }}{{ content_html|safe }}{% endblock %}"
|
||||||
)
|
)
|
||||||
return block_tpl.render(
|
return block_tpl.render(
|
||||||
**sidebar,
|
**sidebar,
|
||||||
request=request,
|
request=request,
|
||||||
content_html=content_html,
|
content_html=content_html,
|
||||||
|
assets=assets,
|
||||||
page_title="My Tasks",
|
page_title="My Tasks",
|
||||||
title_prefix="My Tasks",
|
title_prefix="My Tasks",
|
||||||
page_icon="📋",
|
page_icon="📋",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api")
|
def _json_attr(obj) -> str:
|
||||||
def my_tasks_api(request: Request, view: str = "all", days: int = 7):
|
"""JSON échappé pour un attribut HTML."""
|
||||||
"""API: return my tasks as JSON."""
|
return html.escape(json.dumps(obj, ensure_ascii=False), quote=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_app(data: dict, view: str) -> str:
|
||||||
|
"""Coquille du tableau de bord : barre d'outils, filtres et zone de vue
|
||||||
|
sont rendus côté client à partir de la configuration ci-dessous."""
|
||||||
|
config = {
|
||||||
|
"view": view,
|
||||||
|
"filters": data["filters"],
|
||||||
|
"sources": data["sources"],
|
||||||
|
"maxSources": data["max_sources"],
|
||||||
|
"views": list(VIEWS),
|
||||||
|
}
|
||||||
|
return ('<div id="my-tasks-app" class="my-tasks-app" '
|
||||||
|
f'data-config="{_json_attr(config)}"></div>')
|
||||||
|
|
||||||
|
|
||||||
|
def _render_empty(data: dict) -> str:
|
||||||
|
"""État vide « aucune source » : explique la conversion en base de tâches.
|
||||||
|
|
||||||
|
Gabarit centré (`my_tasks-empty*` dans `static/css/my_tasks.css`), sans
|
||||||
|
style inline : la mise en page suit le thème clair/sombre et reste
|
||||||
|
modifiable en un seul endroit. Les autres états vides (filtres actifs,
|
||||||
|
rien à faire) sont rendus par le client, qui connaît l'état des filtres.
|
||||||
|
"""
|
||||||
|
return f"""<div class="my-tasks-empty">
|
||||||
|
<div class="my-tasks-empty-icon">📋</div>
|
||||||
|
<h2>My Tasks</h2>
|
||||||
|
<p>Aucune base n'alimente encore ce tableau de bord. Pour en ajouter une :</p>
|
||||||
|
<ol class="my-tasks-empty-steps">
|
||||||
|
<li><span class="n">1</span><span>Ouvrez une base de données depuis
|
||||||
|
<a href="/db">la liste de vos bases</a>.</span></li>
|
||||||
|
<li><span class="n">2</span><span>Dans la barre de la base, cliquez sur
|
||||||
|
<b>« Convertir en base de tâches »</b> — il se trouve à gauche du bouton
|
||||||
|
<b>New</b>.</span></li>
|
||||||
|
<li><span class="n">3</span><span>Reliez les colonnes <b>Assigné à</b>,
|
||||||
|
<b>Statut</b> et <b>Échéance</b>, puis validez.</span></li>
|
||||||
|
</ol>
|
||||||
|
<p class="my-tasks-empty-hint">Jusqu'à {data["max_sources"]} bases
|
||||||
|
peuvent alimenter ce tableau de bord.</p>
|
||||||
|
<a href="/db" class="my-tasks-empty-btn">Ouvrir mes bases</a>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
|
||||||
|
def _render_no_workspace() -> str:
|
||||||
|
return """<div class="my-tasks-empty">
|
||||||
|
<div class="my-tasks-empty-icon">📋</div>
|
||||||
|
<h2>My Tasks</h2>
|
||||||
|
<p>Aucun workspace. Créez un workspace, puis une base de tâches : elle
|
||||||
|
alimentera ce tableau de bord.</p>
|
||||||
|
<a href="/workspaces" class="my-tasks-empty-btn">Créer un workspace</a>
|
||||||
|
</div>"""
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ── Routes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("", response_class=HTMLResponse)
|
||||||
|
def my_tasks_dashboard(request: Request, view: str = "table", due: str = "all",
|
||||||
|
sort: str = "due", hide_done: bool = False):
|
||||||
|
"""My Tasks — toutes vos tâches, tous workspaces confondus."""
|
||||||
user = _get_current_user(request)
|
user = _get_current_user(request)
|
||||||
user.get("login", "admin") if user else "admin"
|
if not user or not user.get("id"):
|
||||||
|
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||||
|
|
||||||
|
view, due, sort = _validated(view, due, sort)
|
||||||
|
with get_conn() as conn:
|
||||||
|
if not _workspace_names(conn, user["id"]):
|
||||||
|
return _render_shell(request, _render_no_workspace(), app_js=False)
|
||||||
|
data = _payload(conn, request, user["id"], due=due, sort=sort,
|
||||||
|
hide_done=hide_done)
|
||||||
|
|
||||||
|
# Dès qu'au moins une base alimente le tableau de bord, le rendu est pris
|
||||||
|
# en charge par le client (3 vues + états vides contextualisés). Sans
|
||||||
|
# aucune source, le serveur affiche l'invite à convertir une base — le
|
||||||
|
# fichier JS n'est alors pas chargé.
|
||||||
|
if data["total"] or data["sources_total"]:
|
||||||
|
return _render_shell(request, _render_app(data, view))
|
||||||
|
return _render_shell(request, _render_empty(data), app_js=False)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api")
|
||||||
|
def my_tasks_api(request: Request, view: str = "table", due: str = "all",
|
||||||
|
sort: str = "due", hide_done: bool = False):
|
||||||
|
"""API: tâches normalisées + sources connectées (alimente les 3 vues)."""
|
||||||
|
user = _require_user(request)
|
||||||
|
view, due, sort = _validated(view, due, sort)
|
||||||
|
with get_conn() as conn:
|
||||||
|
return _payload(conn, request, user["id"], due=due, sort=sort,
|
||||||
|
hide_done=hide_done)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/task")
|
||||||
|
def my_tasks_create(request: Request, body: dict = Body(default={})):
|
||||||
|
"""Création rapide multi-destinations : la tâche part dans la base choisie.
|
||||||
|
|
||||||
|
Elle est **auto-assignée** : sans colonne « Assigné à » renseignée, une
|
||||||
|
tâche nouvelle n'apparaîtrait pas dans « mes tâches », ce qui serait
|
||||||
|
illisible pour l'utilisateur qui vient de la créer.
|
||||||
|
"""
|
||||||
|
user = _require_user(request)
|
||||||
|
collection_id = body.get("collection_id")
|
||||||
|
title = str(body.get("title") or "").strip()
|
||||||
|
if not collection_id:
|
||||||
|
raise HTTPException(status_code=400, detail="collection_id is required")
|
||||||
|
if not title:
|
||||||
|
raise HTTPException(status_code=400, detail="title is required")
|
||||||
|
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
|
coll = conn.execute(
|
||||||
result = {}
|
"SELECT id, task_assignee_prop, task_due_prop FROM collections "
|
||||||
|
"WHERE id=? AND is_task=1",
|
||||||
|
(collection_id,),
|
||||||
|
).fetchone()
|
||||||
|
if coll is None:
|
||||||
|
raise HTTPException(status_code=400,
|
||||||
|
detail="This database does not feed My Tasks")
|
||||||
|
|
||||||
for col in collections:
|
props_meta = [
|
||||||
pages = conn.execute(
|
dict(p) for p in conn.execute(
|
||||||
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
|
"SELECT id, name, prop_type FROM collection_properties "
|
||||||
(col["id"],),
|
"WHERE collection_id=?",
|
||||||
|
(collection_id,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
]
|
||||||
|
|
||||||
tasks = []
|
properties: dict = {}
|
||||||
for p in pages:
|
title_prop = next((p for p in props_meta if p["prop_type"] == "title"), None)
|
||||||
props = json.loads(p["property_values_json"])
|
if title_prop:
|
||||||
tasks.append({
|
properties[str(title_prop["id"])] = title
|
||||||
"id": p["id"], "title": p["title"], "icon": p["icon"],
|
|
||||||
"properties": props,
|
|
||||||
})
|
|
||||||
|
|
||||||
if tasks:
|
if body.get("due") and coll["task_due_prop"]:
|
||||||
result[col["name"]] = tasks
|
properties[str(coll["task_due_prop"])] = str(body["due"])[:10]
|
||||||
|
|
||||||
return {"tasks": result, "total": sum(len(t) for t in result.values())}
|
if coll["task_assignee_prop"]:
|
||||||
|
member = conn.execute(
|
||||||
|
"SELECT id, login, full_name, avatar_url, avatar_color "
|
||||||
|
"FROM users WHERE id=?",
|
||||||
|
(user["id"],),
|
||||||
|
).fetchone()
|
||||||
|
if member:
|
||||||
|
properties[str(coll["task_assignee_prop"])] = [{
|
||||||
|
"id": member["id"],
|
||||||
|
"login": member["login"],
|
||||||
|
"full_name": member["full_name"],
|
||||||
|
"avatar_url": member["avatar_url"] or "",
|
||||||
|
"avatar_color": member["avatar_color"] or "",
|
||||||
|
}]
|
||||||
|
|
||||||
|
apply_auto_properties(props_meta, properties, user, is_create=True)
|
||||||
|
|
||||||
|
nxt = conn.execute(
|
||||||
|
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages "
|
||||||
|
"WHERE collection_id=?",
|
||||||
|
(collection_id,),
|
||||||
|
).fetchone()[0]
|
||||||
|
cur = conn.execute(
|
||||||
|
"""INSERT INTO collection_pages
|
||||||
|
(collection_id, title, icon, position, property_values_json)
|
||||||
|
VALUES (?, ?, '📄', ?, ?)""",
|
||||||
|
(collection_id, title, nxt, json.dumps(properties)),
|
||||||
|
)
|
||||||
|
page_id = cur.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
if title_prop:
|
||||||
|
from app.services.row_pages import sync_row_title_to_page
|
||||||
|
|
||||||
|
sync_row_title_to_page(conn, page_id)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
logger.info("my-tasks: task %s created in collection %s", page_id, collection_id)
|
||||||
|
return {"status": "created", "id": page_id, "collection_id": collection_id,
|
||||||
|
"title": title}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/task/{page_id}")
|
||||||
|
def my_tasks_update(request: Request, page_id: int, body: dict = Body(default={})):
|
||||||
|
"""Édition en direct du statut / de l'échéance / du titre.
|
||||||
|
|
||||||
|
L'écriture passe par le **mapping enregistré de la base source** (et non
|
||||||
|
par une devinette « première colonne de type X »), puis la tâche est relue
|
||||||
|
et renvoyée normalisée : le front n'a rien à recalculer.
|
||||||
|
"""
|
||||||
|
user = _require_user(request)
|
||||||
|
field = body.get("field")
|
||||||
|
if field not in ("status", "due", "title", "assignee"):
|
||||||
|
raise HTTPException(status_code=400, detail="Unsupported field")
|
||||||
|
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, collection_id, title, property_values_json "
|
||||||
|
"FROM collection_pages WHERE id=?",
|
||||||
|
(page_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Task not found")
|
||||||
|
|
||||||
|
coll = conn.execute(
|
||||||
|
"SELECT id, is_task, task_assignee_prop, task_status_prop, task_due_prop "
|
||||||
|
"FROM collections WHERE id=?",
|
||||||
|
(row["collection_id"],),
|
||||||
|
).fetchone()
|
||||||
|
if coll is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Source not found")
|
||||||
|
if not coll["is_task"]:
|
||||||
|
raise HTTPException(status_code=400,
|
||||||
|
detail="This database no longer feeds My Tasks")
|
||||||
|
|
||||||
|
column = {"status": coll["task_status_prop"],
|
||||||
|
"due": coll["task_due_prop"],
|
||||||
|
"assignee": coll["task_assignee_prop"]}.get(field)
|
||||||
|
if field != "title" and not column:
|
||||||
|
raise HTTPException(status_code=400,
|
||||||
|
detail=f"This database has no {field} column")
|
||||||
|
|
||||||
|
try:
|
||||||
|
pv = json.loads(row["property_values_json"] or "{}")
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pv = {}
|
||||||
|
if not isinstance(pv, dict):
|
||||||
|
pv = {}
|
||||||
|
|
||||||
|
new_title = None
|
||||||
|
if field == "title":
|
||||||
|
new_title = str(body.get("value") or "").strip()
|
||||||
|
title_prop = conn.execute(
|
||||||
|
"SELECT id FROM collection_properties "
|
||||||
|
"WHERE collection_id=? AND prop_type='title' ORDER BY position LIMIT 1",
|
||||||
|
(coll["id"],),
|
||||||
|
).fetchone()
|
||||||
|
if title_prop:
|
||||||
|
pv[str(title_prop["id"])] = new_title
|
||||||
|
elif field == "assignee":
|
||||||
|
# La valeur est une liste de personnes : elle est écrite telle
|
||||||
|
# quelle (booleenner écraserait la colonne).
|
||||||
|
pv[str(column)] = body.get("value") or []
|
||||||
|
elif field == "due":
|
||||||
|
pv[str(column)] = str(body.get("value") or "")[:10]
|
||||||
|
else:
|
||||||
|
pv[str(column)] = str(body.get("value") or "")
|
||||||
|
|
||||||
|
if new_title is not None:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE collection_pages SET title=?, property_values_json=?, "
|
||||||
|
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||||
|
(new_title, json.dumps(pv), page_id),
|
||||||
|
)
|
||||||
|
from app.services.row_pages import sync_row_title_to_page
|
||||||
|
|
||||||
|
sync_row_title_to_page(conn, page_id)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE collection_pages SET property_values_json=?, "
|
||||||
|
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||||
|
(json.dumps(pv), page_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
tasks = collect_tasks(conn, user["id"], mine_only=True,
|
||||||
|
login=user.get("login", ""))
|
||||||
|
updated = next((t for t in tasks if t["id"] == page_id), None)
|
||||||
|
|
||||||
|
if updated is None:
|
||||||
|
# La tâche vient de cesser d'être « mienne » (désassignée) : le front
|
||||||
|
# doit la retirer, pas la re-rendre à l'identique.
|
||||||
|
return {"status": "updated", "task": None, "left_mytasks": True}
|
||||||
|
|
||||||
|
return {"status": "updated", "task": updated, "left_mytasks": False}
|
||||||
|
|||||||
+50
-19
@@ -8,7 +8,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, HTTPException, Request
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||||
|
|
||||||
from app.auth.session import SessionManager
|
from app.auth.session import SessionManager
|
||||||
@@ -16,14 +16,32 @@ from app.db import get_conn
|
|||||||
from app.services.automations import fire_event, run_event_sync
|
from app.services.automations import fire_event, run_event_sync
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(tags=["workspace"], prefix="/workspace")
|
|
||||||
|
|
||||||
ROLES = ["admin", "editor", "commenter", "viewer"]
|
ROLES = ["admin", "editor", "commenter", "viewer"]
|
||||||
|
|
||||||
|
|
||||||
|
def _require_session(request: Request) -> dict:
|
||||||
|
"""A3/A4 — session obligatoire sur TOUT le router `/workspace`.
|
||||||
|
|
||||||
|
Avant, ces routes s'exécutaient sans session : `GET /workspace/collections/
|
||||||
|
{id}/export/csv` renvoyait le contenu réel d'une collection à un visiteur
|
||||||
|
anonyme, et les POST créaient des données au nom de l'utilisateur 1.
|
||||||
|
La seule route publique (`/workspace/public/...`) vit désormais dans
|
||||||
|
``public_router``, sans dépendance.
|
||||||
|
"""
|
||||||
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||||
|
if not user or not user.get("id"):
|
||||||
|
raise HTTPException(401, "Authentication required")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(tags=["workspace"], prefix="/workspace", dependencies=[Depends(_require_session)])
|
||||||
|
# Route de partage public : AUCUNE dépendance d'authentification.
|
||||||
|
public_router = APIRouter(tags=["workspace-public"], prefix="/workspace")
|
||||||
|
|
||||||
|
|
||||||
def _current_user(request: Request) -> dict:
|
def _current_user(request: Request) -> dict:
|
||||||
s = request.cookies.get("flowdeck_session", "")
|
"""Session obligatoire — plus de repli « admin implicite » (A3/A4)."""
|
||||||
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
|
return _require_session(request)
|
||||||
|
|
||||||
|
|
||||||
def _require_admin(request: Request) -> dict:
|
def _require_admin(request: Request) -> dict:
|
||||||
@@ -209,45 +227,58 @@ def record_history(request: Request, page_id: int, body: dict = Body(default={})
|
|||||||
|
|
||||||
@router.get("/favorites")
|
@router.get("/favorites")
|
||||||
def list_favorites(request: Request):
|
def list_favorites(request: Request):
|
||||||
user = _current_user(request)
|
"""List the caller's favorites.
|
||||||
uid = user.get("id", 1)
|
|
||||||
|
v7.46.0 — aligne sur le schéma v2.2.0 (``app/db.py``) : la table
|
||||||
|
``favorites`` n'a plus de colonne ``collection_id`` et ``page_id``
|
||||||
|
référence ``pages(id)``. L'ancienne requête (jointure ``collection_pages``)
|
||||||
|
levait ``sqlite3.OperationalError: no such column: f.collection_id`` → 500.
|
||||||
|
"""
|
||||||
|
user = _require_session(request)
|
||||||
|
uid = user["id"]
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"""SELECT f.*, cp.title as page_title, c.name as collection_name
|
"""SELECT f.*, p.title AS page_title
|
||||||
FROM favorites f
|
FROM favorites f
|
||||||
LEFT JOIN collection_pages cp ON f.page_id=cp.id
|
LEFT JOIN pages p ON f.page_id=p.id
|
||||||
LEFT JOIN collections c ON f.collection_id=c.id
|
|
||||||
WHERE f.user_id=? ORDER BY f.position""",
|
WHERE f.user_id=? ORDER BY f.position""",
|
||||||
(uid,),
|
(uid,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return {"favorites": [dict(r) for r in rows]}
|
return {"favorites": [dict(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/favorites")
|
@router.post("/favorites")
|
||||||
def add_favorite(request: Request, body: dict = Body(default={})):
|
def add_favorite(request: Request, body: dict = Body(default={})):
|
||||||
user = _current_user(request)
|
user = _require_session(request)
|
||||||
uid = user.get("id", 1)
|
uid = user["id"]
|
||||||
page_id = body.get("page_id")
|
page_id = body.get("page_id")
|
||||||
collection_id = body.get("collection_id")
|
if not page_id:
|
||||||
|
raise HTTPException(400, "page_id required")
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute("INSERT OR IGNORE INTO users (id, login, is_admin) VALUES (?,?,1)", (uid, user.get("login", "admin")))
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT OR IGNORE INTO favorites (user_id, page_id, collection_id) VALUES (?,?,?)",
|
"INSERT OR IGNORE INTO favorites (user_id, page_id) VALUES (?,?)",
|
||||||
(uid, page_id, collection_id),
|
(uid, page_id),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
try:
|
try:
|
||||||
run_event_sync(fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid}))
|
run_event_sync(fire_event("favorite.added", {"page_id": page_id, "user_id": uid}))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("add_favorite")
|
logger.exception("add_favorite")
|
||||||
return {"status": "favorited"}
|
return {"status": "favorited"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/favorites/{fav_id}")
|
@router.delete("/favorites/{fav_id}")
|
||||||
def remove_favorite(request: Request, fav_id: int):
|
def remove_favorite(request: Request, fav_id: int):
|
||||||
|
user = _require_session(request)
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,))
|
cur = conn.execute(
|
||||||
|
"DELETE FROM favorites WHERE id=? AND user_id=?", (fav_id, user["id"])
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
if cur.rowcount == 0:
|
||||||
|
raise HTTPException(404, "Favorite not found")
|
||||||
return {"status": "removed"}
|
return {"status": "removed"}
|
||||||
|
|
||||||
|
|
||||||
@@ -714,7 +745,7 @@ def delete_webhook(request: Request, wh_id: int):
|
|||||||
|
|
||||||
# ── Public Sharing ──
|
# ── Public Sharing ──
|
||||||
|
|
||||||
@router.get("/public/{collection_id}")
|
@public_router.get("/public/{collection_id}")
|
||||||
def public_view(request: Request, collection_id: int):
|
def public_view(request: Request, collection_id: int):
|
||||||
"""Simple public read-only view — no auth required.
|
"""Simple public read-only view — no auth required.
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import re
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
|
from app.services import agent_memory
|
||||||
from app.services.agent_policies import check_tool, get_policy
|
from app.services.agent_policies import check_tool, get_policy
|
||||||
from app.services.context_builder import ContextBuilder
|
from app.services.context_builder import ContextBuilder
|
||||||
from app.services.llm_client import LLMClient
|
from app.services.llm_client import LLMClient
|
||||||
@@ -156,6 +157,11 @@ class AgentEngine:
|
|||||||
skills = [s for s in (self._load_skill(i, scope) for i in ids) if s]
|
skills = [s for s in (self._load_skill(i, scope) for i in ids) if s]
|
||||||
system = self._build_system_prompt(agent, skills)
|
system = self._build_system_prompt(agent, skills)
|
||||||
context = self.ctx.build(mentions=mentions, files=files)
|
context = self.ctx.build(mentions=mentions, files=files)
|
||||||
|
# v7.54.0 — mémoire de la conversation (toggle) : le moteur n'envoie
|
||||||
|
# jamais l'historique, sans elle chaque run repart de zéro.
|
||||||
|
memory = agent_memory.context_for(conversation_id)
|
||||||
|
if memory:
|
||||||
|
context = (context + "\n\n" + memory) if context else memory
|
||||||
if extra_context and extra_context.strip():
|
if extra_context and extra_context.strip():
|
||||||
context += "\n\n## Document / contexte fourni par l'utilisateur\n" + extra_context.strip()
|
context += "\n\n## Document / contexte fourni par l'utilisateur\n" + extra_context.strip()
|
||||||
|
|
||||||
@@ -304,6 +310,8 @@ class AgentEngine:
|
|||||||
self._persist_message(conversation_id, "assistant", final_text,
|
self._persist_message(conversation_id, "assistant", final_text,
|
||||||
model=used_model, tokens=self._tokens)
|
model=used_model, tokens=self._tokens)
|
||||||
await self._autotitle(conversation_id, objective, final_text)
|
await self._autotitle(conversation_id, objective, final_text)
|
||||||
|
# v7.54.0 — memorise l'echange (no-op si le toggle memoire est OFF).
|
||||||
|
agent_memory.remember(conversation_id, objective, final_text)
|
||||||
# v6.4.0: emit agent.run.finished (outbound webhooks only).
|
# v6.4.0: emit agent.run.finished (outbound webhooks only).
|
||||||
await _fire_agent_webhook("agent.run.finished", {
|
await _fire_agent_webhook("agent.run.finished", {
|
||||||
"conversation_id": conversation_id,
|
"conversation_id": conversation_id,
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Mémoire de l'agent (v7.54.0).
|
||||||
|
|
||||||
|
Une ligne résumé **par conversation**, mise à jour à chaque run et ré-injectée
|
||||||
|
au contexte du run suivant — le moteur n'envoie jamais l'historique des messages
|
||||||
|
(`AgentEngine.run()` ne construit que system + objectif courant), donc sans
|
||||||
|
mémoire chaque run repart de zéro.
|
||||||
|
|
||||||
|
Toggle : colonne `agent_conversations.memory_enabled` — OFF = aucune lecture ni
|
||||||
|
écriture (contexte strict du run courant).
|
||||||
|
|
||||||
|
ponytail : mémoire par conversation seulement. Si besoin de notes partagées
|
||||||
|
entre conversations, ajouter des lignes avec ``conversation_id NULL`` +
|
||||||
|
``workspace_id`` et élargir la lecture dans ``context_for()``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
from app.db import get_conn
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
INJECT_BUDGET = 4000 # caractères de mémoire max injectés dans un prompt
|
||||||
|
NOTE_LINES = 20 # échanges max conservés
|
||||||
|
NOTE_OBJECTIVE = 180 # troncature de l'objectif
|
||||||
|
NOTE_ANSWER = 700 # troncature de la réponse
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled(conn: sqlite3.Connection, conversation_id: int) -> bool:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT memory_enabled FROM agent_conversations WHERE id=?",
|
||||||
|
(conversation_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
return bool(row["memory_enabled"])
|
||||||
|
|
||||||
|
|
||||||
|
def _one_line(text: str, limit: int) -> str:
|
||||||
|
return " ".join((text or "").split())[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def context_for(conversation_id: int) -> str:
|
||||||
|
"""Bloc « mémoire » à préfixer au contexte, ou ``''`` (toggle OFF / vide)."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
if not _enabled(conn, conversation_id):
|
||||||
|
return ""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT content FROM agent_memory WHERE conversation_id=?",
|
||||||
|
(conversation_id,),
|
||||||
|
).fetchone()
|
||||||
|
content = ((row["content"] if row else "") or "").strip()
|
||||||
|
if not content:
|
||||||
|
return ""
|
||||||
|
if len(content) > INJECT_BUDGET:
|
||||||
|
content = "…(début de mémoire tronqué)\n" + content[-INJECT_BUDGET:]
|
||||||
|
return "## Mémoire de la conversation (échanges précédents)\n" + content
|
||||||
|
|
||||||
|
|
||||||
|
def remember(conversation_id: int, objective: str, final_text: str) -> None:
|
||||||
|
"""Ajoute un échange à la mémoire — no-op si le toggle est OFF.
|
||||||
|
|
||||||
|
Ne doit **jamais** faire échouer un run : toute erreur est journalisée.
|
||||||
|
"""
|
||||||
|
note = f"- **{_one_line(objective, NOTE_OBJECTIVE)}** → {_one_line(final_text, NOTE_ANSWER)}"
|
||||||
|
try:
|
||||||
|
with get_conn() as conn:
|
||||||
|
if not _enabled(conn, conversation_id):
|
||||||
|
return
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT content FROM agent_memory WHERE conversation_id=?",
|
||||||
|
(conversation_id,),
|
||||||
|
).fetchone()
|
||||||
|
lines = [ln for ln in ((row["content"] if row else "") or "").split("\n") if ln.strip()]
|
||||||
|
lines.append(note)
|
||||||
|
if len(lines) > NOTE_LINES:
|
||||||
|
lines = lines[-NOTE_LINES:]
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO agent_memory (conversation_id, content, updated_at) "
|
||||||
|
"VALUES (?, ?, CURRENT_TIMESTAMP) "
|
||||||
|
"ON CONFLICT(conversation_id) DO UPDATE SET "
|
||||||
|
"content=excluded.content, updated_at=CURRENT_TIMESTAMP",
|
||||||
|
(conversation_id, "\n".join(lines)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception: # noqa: BLE001 — la mémoire ne casse jamais un run
|
||||||
|
logger.exception("Agent memory save failed for conversation #%s", conversation_id)
|
||||||
@@ -27,7 +27,7 @@ import time
|
|||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
from app.services import notifications
|
from app.services import notifications, plugins
|
||||||
from app.services.http_client import shared_client
|
from app.services.http_client import shared_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -479,6 +479,9 @@ async def automation_scheduler():
|
|||||||
"""Background loop: fire due cron automations (checked every 60s)."""
|
"""Background loop: fire due cron automations (checked every 60s)."""
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
|
if not plugins.is_enabled("automations"): # plugin OFF = rien de planifié
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
continue
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT * FROM automations WHERE trigger_type='cron' AND enabled=1"
|
"SELECT * FROM automations WHERE trigger_type='cron' AND enabled=1"
|
||||||
|
|||||||
@@ -86,6 +86,34 @@ BUILTIN_TEMPLATES: dict[str, dict] = {
|
|||||||
_b("bulleted_list"),
|
_b("bulleted_list"),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
"design_system": {
|
||||||
|
"name": "Design System",
|
||||||
|
"icon": "🎨",
|
||||||
|
"description": "Tokens, composants et grille — canevas de référence UI.",
|
||||||
|
"blocks": [
|
||||||
|
_b("heading_1", "Design System"),
|
||||||
|
_b("callout",
|
||||||
|
"Tokens : couleur, espacement, typographie, rayon, ombres "
|
||||||
|
"(static/css/design-tokens.css).",
|
||||||
|
icon="🎨"),
|
||||||
|
_b("table_of_contents"),
|
||||||
|
_b("heading_2", "Composants"),
|
||||||
|
_b("toggle", "Boutons & actions", expanded=False,
|
||||||
|
children=[_b("paragraph", "Primaire · secondaire · danger — états idle, hover, focus, disabled.")]),
|
||||||
|
_b("toggle", "Cartes & panneaux", expanded=False,
|
||||||
|
children=[_b("paragraph", "Élévation, rayon, bordure, padding, zones de clic.")]),
|
||||||
|
_b("toggle", "Formulaires", expanded=False,
|
||||||
|
children=[_b("paragraph", "Champs, labels, hints, erreurs, focus visible.")]),
|
||||||
|
_b("heading_2", "Grille & espacement"),
|
||||||
|
_b("bulleted_list", "Base 4 px — pas d'espacement hors échelle."),
|
||||||
|
_b("bulleted_list", "Colonnes : 12 / 8 / 4 selon le point de rupture."),
|
||||||
|
_b("heading_2", "Revue UI"),
|
||||||
|
_b("to_do", "Contraste AA vérifié", checked=False),
|
||||||
|
_b("to_do", "États hover / focus / disabled", checked=False),
|
||||||
|
_b("to_do", "Responsive mobile", checked=False),
|
||||||
|
_b("quote", "Une décision de design vaut mieux qu'une justification après coup."),
|
||||||
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -278,6 +278,12 @@ def _row_date(values: dict, prop_id: str, prop_name: str) -> str:
|
|||||||
return str(raw or "")
|
return str(raw or "")
|
||||||
|
|
||||||
|
|
||||||
|
# Public aliases — read-only helpers reused by the router layer (sidebar
|
||||||
|
# "Meeting" tab needs the date property + value of a collection row).
|
||||||
|
date_prop_id = _date_prop_id
|
||||||
|
row_date = _row_date
|
||||||
|
|
||||||
|
|
||||||
def _to_epoch(value: str | None) -> float:
|
def _to_epoch(value: str | None) -> float:
|
||||||
if not value:
|
if not value:
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""FlowDeck — cohérence collections ↔ workspace.
|
||||||
|
|
||||||
|
Une database (`collections`) peut être :
|
||||||
|
|
||||||
|
* **de workspace** — créée via `/db/api`, rattachée par `workspace_id` seul ;
|
||||||
|
* **de page** — page convertie en base (`collections.parent_page_id` +
|
||||||
|
`pages.collection_id`) ou base inline insérée dans une page.
|
||||||
|
|
||||||
|
Ce module centralise les deux sens de la relation :
|
||||||
|
|
||||||
|
* **lecture** — quelles collections sont encore *vivantes* dans un workspace
|
||||||
|
(leur page hôte n'est pas à la corbeille / n'a pas été supprimée) et
|
||||||
|
quelles lignes sont encore vivantes (leur page contenu n'est pas à la
|
||||||
|
corbeille). Sans ce filtre, ``/my-tasks`` continue d'afficher les tâches
|
||||||
|
d'un workspace dont tous les documents ont été supprimés ;
|
||||||
|
* **écriture** — la suppression en cascade d'une collection. L'ordre des
|
||||||
|
écritures est dicté par le schéma SQLite :
|
||||||
|
|
||||||
|
- ``pages.collection_id`` référence ``collections(id)`` en ``NO ACTION`` :
|
||||||
|
la page hôte doit être détachée **avant** la suppression ;
|
||||||
|
- ``collection_data_sources.source_collection_id`` référence également en
|
||||||
|
``NO ACTION`` : les vues liées qui pointent vers la collection doivent
|
||||||
|
être détachées ;
|
||||||
|
- ``collection_pages`` (lignes), ``collection_properties``,
|
||||||
|
``collection_views``, ``collection_permissions`` et les pages contenu
|
||||||
|
(``pages.collection_row_id``) partent en ``CASCADE``.
|
||||||
|
|
||||||
|
Sans ce détachement, ``DELETE FROM collections`` lève un ``IntegrityError``
|
||||||
|
dès qu'une page hôte (ou une vue liée) existe encore.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Lecture ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#: SQL du workspace **effectif** d'une collection.
|
||||||
|
#:
|
||||||
|
#: ``collections.workspace_id`` est renseigné pour les databases créées via
|
||||||
|
#: ``/db/api``, mais reste **NULL** pour une base portée par une page (page
|
||||||
|
#: convertie en base, base inline insérée dans un document) : son workspace
|
||||||
|
#: est alors celui de la page hôte. Sans ce repli, la requête ``c.workspace_id
|
||||||
|
#: = ?`` de `live_collection_ids` ne trouve jamais ces bases — et un JOIN
|
||||||
|
#: ``workspaces`` les exclut définitivement.
|
||||||
|
EFFECTIVE_WORKSPACE_SQL = (
|
||||||
|
"COALESCE({a}.workspace_id,"
|
||||||
|
" (SELECT p.workspace_id FROM pages p WHERE p.id = {a}.parent_page_id))"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def effective_workspace_sql(alias: str = "c") -> str:
|
||||||
|
"""Workspace effectif d'une collection (colonne ou ``NULL`` si orpheline)."""
|
||||||
|
return EFFECTIVE_WORKSPACE_SQL.format(a=alias)
|
||||||
|
|
||||||
|
|
||||||
|
def user_workspace_ids(conn, user_id: int) -> list[int]:
|
||||||
|
"""IDs des workspaces accessibles à l'utilisateur.
|
||||||
|
|
||||||
|
Propriétaire **ou** membre : c'est le même périmètre que la recherche
|
||||||
|
globale (`app/services/search._scope_where`), à la différence près qu'on
|
||||||
|
n'inclut pas les éléments sans workspace — une base non rattachable
|
||||||
|
n'appartient à personne et ne doit donc apparaître chez personne.
|
||||||
|
"""
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id FROM workspaces WHERE owner_id = ?"
|
||||||
|
" UNION SELECT workspace_id FROM workspace_members WHERE user_id = ?",
|
||||||
|
(user_id, user_id),
|
||||||
|
).fetchall()
|
||||||
|
return [r[0] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def live_collection_ids(conn, workspace_id: int) -> list[int]:
|
||||||
|
"""IDs des collections rattachées au workspace qui sont encore vivantes.
|
||||||
|
|
||||||
|
Une collection est exclue dès que sa page hôte a disparu :
|
||||||
|
|
||||||
|
* ``parent_page_id`` renseigné mais page absente ou à la corbeille ;
|
||||||
|
* une page ``content_format='collection'`` qui pointe encore vers elle
|
||||||
|
mais est à la corbeille.
|
||||||
|
|
||||||
|
Les collections sans page hôte (databases de workspace créées via
|
||||||
|
``/db/api``) restent toujours visibles.
|
||||||
|
|
||||||
|
Le rattachement passe par le workspace **effectif** : une base créée depuis
|
||||||
|
un document a ``workspace_id`` NULL et n'est repérée que via sa page hôte.
|
||||||
|
"""
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT c.id
|
||||||
|
FROM collections c
|
||||||
|
WHERE {effective_workspace_sql("c")} = ?
|
||||||
|
AND (c.parent_page_id IS NULL
|
||||||
|
OR EXISTS (SELECT 1 FROM pages p
|
||||||
|
WHERE p.id = c.parent_page_id AND p.deleted_at IS NULL))
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM pages p
|
||||||
|
WHERE p.collection_id = c.id AND p.deleted_at IS NOT NULL)
|
||||||
|
ORDER BY c.name
|
||||||
|
""",
|
||||||
|
(workspace_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [r["id"] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def live_row_ids(conn, collection_id: int) -> list[int]:
|
||||||
|
"""IDs des lignes racines d'une collection dont la page contenu vit.
|
||||||
|
|
||||||
|
La page contenu d'une ligne est créée paresseusement (``ensure_row_page``)
|
||||||
|
: son absence ne dit rien, mais son passage à la corbeille signifie que
|
||||||
|
l'utilisateur a supprimé le document → la ligne sort de My Tasks.
|
||||||
|
"""
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT cp.id
|
||||||
|
FROM collection_pages cp
|
||||||
|
WHERE cp.collection_id = ?
|
||||||
|
AND cp.parent_id IS NULL
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM pages p
|
||||||
|
WHERE p.collection_row_id = cp.id AND p.deleted_at IS NOT NULL)
|
||||||
|
ORDER BY cp.position
|
||||||
|
""",
|
||||||
|
(collection_id,),
|
||||||
|
).fetchall()
|
||||||
|
return [r["id"] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def live_document_count(conn, workspace_id: int) -> int:
|
||||||
|
"""Nombre de documents visibles du workspace (mêmes règles que l'arbre
|
||||||
|
de la sidebar : racines, non supprimés, hors pages contenu de ligne)."""
|
||||||
|
return conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM pages "
|
||||||
|
"WHERE workspace_id = ? AND parent_id IS NULL "
|
||||||
|
"AND deleted_at IS NULL AND collection_row_id IS NULL",
|
||||||
|
(workspace_id,),
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def collections_hosted_by_pages(conn, page_ids) -> list[int]:
|
||||||
|
"""IDs des collections dont l'une de ces pages est la page hôte.
|
||||||
|
|
||||||
|
Les deux relations sont couvertes : ``collections.parent_page_id`` (page
|
||||||
|
convertie en base, base inline) et ``pages.collection_id`` (page hôte).
|
||||||
|
Les pages contenu de ligne (``collection_row_id`` renseigné) n'hébergent
|
||||||
|
aucune collection et ne remontent donc rien.
|
||||||
|
|
||||||
|
À appeler **avant** de supprimer les pages si l'on veut aussi capturer la
|
||||||
|
relation ``pages.collection_id``.
|
||||||
|
"""
|
||||||
|
ids = [int(p) for p in page_ids if p is not None]
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
qs = ",".join("?" * len(ids))
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id FROM collections WHERE parent_page_id IN ({qs})
|
||||||
|
UNION
|
||||||
|
SELECT collection_id FROM pages
|
||||||
|
WHERE id IN ({qs}) AND collection_id IS NOT NULL
|
||||||
|
""",
|
||||||
|
[*ids, *ids],
|
||||||
|
).fetchall()
|
||||||
|
return [r["id"] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def collections_hosted_by_page(conn, page_id: int) -> list[int]:
|
||||||
|
"""``collections_hosted_by_pages`` pour une seule page."""
|
||||||
|
return collections_hosted_by_pages(conn, [page_id])
|
||||||
|
|
||||||
|
|
||||||
|
def workspace_collection_ids(conn, workspace_id: int) -> list[int]:
|
||||||
|
"""IDs de **toutes** les collections du workspace (sans filtre de
|
||||||
|
vivacité) — utilisé lors de la suppression du workspace."""
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id FROM collections WHERE workspace_id = ?", (workspace_id,)
|
||||||
|
).fetchall()
|
||||||
|
return [r["id"] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Écriture ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def delete_collections(conn, collection_ids) -> int:
|
||||||
|
"""Supprime définitivement des collections et tout ce qu'elles portent.
|
||||||
|
|
||||||
|
Détache d'abord les références ``NO ACTION`` (pages hôtes, sources des
|
||||||
|
vues liées) puis supprime : le reste part en ``CASCADE``.
|
||||||
|
|
||||||
|
Ne fait **pas** de ``commit`` — l'appelant garde la main sur la
|
||||||
|
transaction. Retourne le nombre de collections supprimées.
|
||||||
|
"""
|
||||||
|
ids = [int(i) for i in collection_ids if i is not None]
|
||||||
|
if not ids:
|
||||||
|
return 0
|
||||||
|
qs = ",".join("?" * len(ids))
|
||||||
|
|
||||||
|
# 1. Pages hôtes : `pages.collection_id` est en NO ACTION. Une page dont le
|
||||||
|
# contenu EST la base redevient une page blocs normale.
|
||||||
|
conn.execute(
|
||||||
|
f"""UPDATE pages
|
||||||
|
SET collection_id = NULL,
|
||||||
|
content_format = CASE WHEN content_format = 'collection'
|
||||||
|
THEN 'blocks' ELSE content_format END
|
||||||
|
WHERE collection_id IN ({qs})""",
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
# 2. Vues liées : `source_collection_id` est en NO ACTION également. La vue
|
||||||
|
# liée perd sa source (elle n'a plus rien à afficher) mais survit.
|
||||||
|
conn.execute(
|
||||||
|
f"DELETE FROM collection_data_sources WHERE source_collection_id IN ({qs})",
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
# 3. Suppression : lignes, propriétés, vues, permissions et pages contenu
|
||||||
|
# suivent en CASCADE.
|
||||||
|
cur = conn.execute(f"DELETE FROM collections WHERE id IN ({qs})", ids)
|
||||||
|
return cur.rowcount or 0
|
||||||
|
|
||||||
|
|
||||||
|
def delete_collections_hosted_by_pages(conn, page_ids) -> int:
|
||||||
|
"""Supprime les databases portées par des pages définitivement supprimées
|
||||||
|
(corbeille purgée, suppression définitive, workspace effacé)."""
|
||||||
|
return delete_collections(conn, collections_hosted_by_pages(conn, page_ids))
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
"""Connecteurs de l'agent (v7.55.0) — socle : catalogue, statut, fetch gardé SSRF.
|
||||||
|
|
||||||
|
5 connecteurs **natifs** (gitea, github, web, google, ms365) servis à la volée,
|
||||||
|
plus des connecteurs **personnels** persistés (URL + clé) en base avec un
|
||||||
|
``kind`` : ``custom`` | ``discord`` (auth « Bot ») | ``telegram`` (jeton dans
|
||||||
|
l'URL via ``{secret}``) | ``mcp`` (serveur Model Context Protocol, cache
|
||||||
|
d'outils dans ``tools_json``).
|
||||||
|
|
||||||
|
ponytail : un seul fichier (pas de package ``connectors/`` ni de classe par
|
||||||
|
provider) — 3 probes dans un dict + 1 fetch. Les adapters lourds des phases 6-7
|
||||||
|
(Google, M365, Discord/Telegram, MCP) arriveront avec leur propre module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.db import get_conn
|
||||||
|
from app.services import oauth_connectors
|
||||||
|
from app.services.sso_provisioning import decrypt_secret, encrypt_secret
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
NATIVE_KINDS = ("gitea", "github", "web", "google", "ms365")
|
||||||
|
CUSTOM_KINDS = ("custom", "discord", "telegram", "mcp")
|
||||||
|
AUTH_SCHEMES = ("bearer", "bot", "none")
|
||||||
|
# Presets proposés par le formulaire du menu + (le jeton reste toujours saisi
|
||||||
|
# par l'utilisateur — jamais codé en dur ici).
|
||||||
|
PRESETS = {
|
||||||
|
"discord": {"url": "https://discord.com/api/v10", "auth": "bot",
|
||||||
|
"hint": "Bot Discord Developers → Token"},
|
||||||
|
"telegram": {"url": "https://api.telegram.org/bot{secret}", "auth": "none",
|
||||||
|
"hint": "BotFather → Bot Token (jeton dans l'URL)"},
|
||||||
|
"mcp": {"url": "https://", "auth": "bearer",
|
||||||
|
"hint": "URL du endpoint MCP (streamable HTTP)"},
|
||||||
|
}
|
||||||
|
OAUTH_KINDS = oauth_connectors.OAUTH_KINDS
|
||||||
|
FETCH_LIMIT = 20000 # caractères max renvoyés au LLM (ponytail : borne fixe)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Natifs (config, sans réseau) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def native_state(kind: str, user_id: int | None = None) -> tuple[str, str]:
|
||||||
|
"""(status, detail) d'un connecteur natif — dérivé de la config, sans réseau."""
|
||||||
|
if kind in OAUTH_KINDS:
|
||||||
|
try:
|
||||||
|
oauth_connectors._client(kind) # lève si client_id/secret absents
|
||||||
|
except ValueError as exc:
|
||||||
|
return ("missing", str(exc))
|
||||||
|
tok = oauth_connectors.tokens(kind, user_id)
|
||||||
|
if tok.get("access_token"):
|
||||||
|
scope = (tok.get("scope") or "").split()
|
||||||
|
return ("ok", f"connecté · {len(scope)} scope(s)")
|
||||||
|
return ("missing", "non connecté — lancer la connexion OAuth")
|
||||||
|
if kind == "gitea":
|
||||||
|
ok = bool(settings.gitea_url) and settings.gitea_token not in ("", "change-me")
|
||||||
|
return ("ok", settings.gitea_url if ok else "GITEA_TOKEN non configuré")
|
||||||
|
if kind == "github":
|
||||||
|
if settings.github_token:
|
||||||
|
return ("ok", "PAT configuré (30 req/min)")
|
||||||
|
return ("missing", "aucun GITHUB_TOKEN (10 req/min anonyme)")
|
||||||
|
return ("ok", f"recherche web via {settings.web_search_provider or 'duckduckgo'}")
|
||||||
|
|
||||||
|
|
||||||
|
def _row(r) -> dict:
|
||||||
|
"""Ligne `agent_connectors` → dict API (le jeton n'y figure jamais)."""
|
||||||
|
try:
|
||||||
|
tools = json.loads(r["tools_json"] or "[]") if "tools_json" in r.keys() else []
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
tools = []
|
||||||
|
return {
|
||||||
|
"id": r["id"], "builtin": False, "kind": r["kind"], "name": r["name"],
|
||||||
|
"url": r["url"], "auth": r["auth"], "enabled": bool(r["enabled"]),
|
||||||
|
"status": r["status"], "detail": r["detail"] or "",
|
||||||
|
"has_secret": bool(r["secret_encrypted"]), "oauth": False,
|
||||||
|
"tools_count": len(tools),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_connectors(user_id: int | None = None) -> list[dict]:
|
||||||
|
"""Catalogue : natifs (toujours présents) + connecteurs personnels."""
|
||||||
|
out: list[dict] = []
|
||||||
|
for kind in NATIVE_KINDS:
|
||||||
|
status, detail = native_state(kind, user_id)
|
||||||
|
out.append({
|
||||||
|
"id": None, "builtin": True, "kind": kind,
|
||||||
|
"name": (oauth_connectors.PROVIDERS[kind]["name"] if kind in OAUTH_KINDS
|
||||||
|
else kind.capitalize()),
|
||||||
|
"url": "", "enabled": True, "status": status, "detail": detail,
|
||||||
|
"has_secret": False, "oauth": kind in OAUTH_KINDS,
|
||||||
|
})
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, name, url, secret_encrypted, enabled, status, detail, "
|
||||||
|
"kind, auth, tools_json FROM agent_connectors ORDER BY id"
|
||||||
|
).fetchall()
|
||||||
|
return out + [_row(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get(connector_id: int) -> dict | None:
|
||||||
|
with get_conn() as conn:
|
||||||
|
r = conn.execute(
|
||||||
|
"SELECT id, name, url, secret_encrypted, enabled, status, detail, "
|
||||||
|
"kind, auth, tools_json FROM agent_connectors WHERE id=?",
|
||||||
|
(connector_id,),
|
||||||
|
).fetchone()
|
||||||
|
return _row(r) if r else None
|
||||||
|
|
||||||
|
|
||||||
|
# ── CRUD (personnels) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create_connector(name: str, url: str, secret: str = "", *,
|
||||||
|
kind: str = "custom", auth: str = "bearer") -> dict:
|
||||||
|
"""Valide l'URL (garde SSRF) puis stocke la clé **chiffrée** (Fernet)."""
|
||||||
|
from app.services.importers.url_fetch import _validate_url
|
||||||
|
|
||||||
|
name = (name or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise ValueError("name est requis")
|
||||||
|
if kind not in CUSTOM_KINDS:
|
||||||
|
raise ValueError(f"kind invalide: {kind} (attendu {', '.join(CUSTOM_KINDS)})")
|
||||||
|
if auth not in AUTH_SCHEMES:
|
||||||
|
raise ValueError(f"auth invalide: {auth} (attendu {', '.join(AUTH_SCHEMES)})")
|
||||||
|
url = (url or "").strip()
|
||||||
|
if "{secret}" in url and not (secret or "").strip():
|
||||||
|
raise ValueError("clé requise : l'URL contient {secret}")
|
||||||
|
url = _validate_url(url) # lève ValueError si hôte interne
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO agent_connectors (name, url, secret_encrypted, status, "
|
||||||
|
"detail, kind, auth) VALUES (?,?,?,?,?,?,?)",
|
||||||
|
(name, url, encrypt_secret(secret or ""), "unknown", "", kind, auth),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
cid = cur.lastrowid
|
||||||
|
return get(cid)
|
||||||
|
|
||||||
|
|
||||||
|
def update_connector(connector_id: int, *, name=None, enabled=None, secret=None) -> dict | None:
|
||||||
|
row = get(connector_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
sets, params = [], []
|
||||||
|
if name is not None:
|
||||||
|
name = str(name).strip()
|
||||||
|
if not name:
|
||||||
|
raise ValueError("name est requis")
|
||||||
|
sets.append("name=?")
|
||||||
|
params.append(name)
|
||||||
|
if enabled is not None:
|
||||||
|
sets.append("enabled=?")
|
||||||
|
params.append(1 if enabled else 0)
|
||||||
|
if secret is not None:
|
||||||
|
sets.append("secret_encrypted=?")
|
||||||
|
params.append(encrypt_secret(str(secret)))
|
||||||
|
if sets:
|
||||||
|
with get_conn() as conn:
|
||||||
|
params.append(connector_id)
|
||||||
|
conn.execute(f"UPDATE agent_connectors SET {', '.join(sets)} WHERE id=?", params)
|
||||||
|
conn.commit()
|
||||||
|
return get(connector_id)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_connector(connector_id: int) -> bool:
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.execute("DELETE FROM agent_connectors WHERE id=?", (connector_id,))
|
||||||
|
conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Réseau (toujours gardé SSRF) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _headers(connector_id: int | None = None) -> dict:
|
||||||
|
"""En-têtes d'appel : la clé n'est lue (et déchiffrée) qu'ici, jamais renvoyée.
|
||||||
|
|
||||||
|
``auth`` = bearer (défaut) | bot (Discord, jeton préfixé) | none (jeton ailleurs).
|
||||||
|
"""
|
||||||
|
headers = {"User-Agent": "FlowDeck-Connectors/1.0"}
|
||||||
|
secret, auth = "", "bearer"
|
||||||
|
if connector_id:
|
||||||
|
with get_conn() as conn:
|
||||||
|
r = conn.execute(
|
||||||
|
"SELECT secret_encrypted, auth FROM agent_connectors WHERE id=?",
|
||||||
|
(connector_id,),
|
||||||
|
).fetchone()
|
||||||
|
if r:
|
||||||
|
secret = decrypt_secret(r["secret_encrypted"] or "")
|
||||||
|
auth = r["auth"] or "bearer"
|
||||||
|
if secret and auth == "bot":
|
||||||
|
headers["Authorization"] = f"Bot {secret}"
|
||||||
|
elif secret and auth != "none":
|
||||||
|
headers["Authorization"] = f"Bearer {secret}"
|
||||||
|
headers["X-Api-Key"] = secret
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _with_secret(url: str, connector_id: int) -> str:
|
||||||
|
"""Substitue ``{secret}`` (Telegram : le jeton vit dans l'URL, jamais stocké clair)."""
|
||||||
|
if "{secret}" not in url:
|
||||||
|
return url
|
||||||
|
with get_conn() as conn:
|
||||||
|
r = conn.execute(
|
||||||
|
"SELECT secret_encrypted FROM agent_connectors WHERE id=?",
|
||||||
|
(connector_id,),
|
||||||
|
).fetchone()
|
||||||
|
secret = decrypt_secret((r["secret_encrypted"] if r else "") or "")
|
||||||
|
if not secret:
|
||||||
|
raise ValueError("clé manquante pour une URL contenant {secret}")
|
||||||
|
return url.replace("{secret}", secret)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get(url: str, headers: dict | None = None) -> tuple[int, str]:
|
||||||
|
"""GET gardé SSRF (validation + re-vérification après redirection).
|
||||||
|
|
||||||
|
Point d'injection des tests : on monkeypatche ``connectors._get``.
|
||||||
|
"""
|
||||||
|
from app.services.http_client import shared_client
|
||||||
|
from app.services.importers.url_fetch import _is_public_host, _validate_url
|
||||||
|
|
||||||
|
safe = _validate_url(url)
|
||||||
|
async with shared_client(timeout=10, follow_redirects=True, headers=headers or {}) as client:
|
||||||
|
resp = await client.get(safe)
|
||||||
|
if resp.url.host and not _is_public_host(resp.url.host):
|
||||||
|
raise ValueError("Redirection vers un hôte non autorisé")
|
||||||
|
return resp.status_code, (resp.text or "")[:FETCH_LIMIT]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve(connector: str) -> tuple[str, str | int]:
|
||||||
|
"""« 3 », « Ma clé API » ou « gitea » → (kind, id_custom|kind)."""
|
||||||
|
token = str(connector or "").strip()
|
||||||
|
if not token:
|
||||||
|
raise ValueError("connector est requis")
|
||||||
|
if token.isdigit():
|
||||||
|
row = get(int(token))
|
||||||
|
if row is None:
|
||||||
|
raise ValueError(f"Connecteur inconnu: {token}")
|
||||||
|
return (row["kind"], row["id"])
|
||||||
|
low = token.lower()
|
||||||
|
if low in NATIVE_KINDS:
|
||||||
|
return (low, low)
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id FROM agent_connectors WHERE lower(name)=?", (low,)
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
return (get(row["id"])["kind"], row["id"])
|
||||||
|
raise ValueError(f"Connecteur inconnu: {token}")
|
||||||
|
|
||||||
|
|
||||||
|
async def probe(connector: str, user_id: int | None = None) -> dict:
|
||||||
|
"""Teste un connecteur et **persiste** le résultat (personnel uniquement)."""
|
||||||
|
kind, ref = _resolve(connector)
|
||||||
|
try:
|
||||||
|
if kind == "mcp":
|
||||||
|
from app.services import mcp_client
|
||||||
|
tools = await mcp_client.initialize_and_list(int(ref))
|
||||||
|
status, detail = "ok", f"MCP · {len(tools)} outil(s)"
|
||||||
|
elif kind in OAUTH_KINDS:
|
||||||
|
res = await oauth_connectors.api_get(kind, user_id,
|
||||||
|
oauth_connectors.PROVIDERS[kind]["probe_path"])
|
||||||
|
status, detail = res["status"], res["text"][:200]
|
||||||
|
elif kind == "gitea":
|
||||||
|
code, _ = await _get(f"{settings.gitea_url.rstrip('/')}/api/v1/version",
|
||||||
|
{"Authorization": f"token {settings.gitea_token}"})
|
||||||
|
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
|
||||||
|
elif kind == "github":
|
||||||
|
code, _ = await _get("https://api.github.com/rate_limit", _gh_headers())
|
||||||
|
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
|
||||||
|
elif kind == "web":
|
||||||
|
status, detail = "ok", native_state("web")[1]
|
||||||
|
else:
|
||||||
|
row = get(int(ref))
|
||||||
|
url = _with_secret(row["url"], int(ref))
|
||||||
|
code, _ = await _get(url, _headers(int(ref)))
|
||||||
|
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
|
||||||
|
except Exception as exc: # noqa: BLE001 — un probe ne casse jamais l'UI
|
||||||
|
logger.info("Connector probe failed (%s): %s", connector, exc)
|
||||||
|
status, detail = "error", str(exc)[:200]
|
||||||
|
if kind not in NATIVE_KINDS:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE agent_connectors SET status=?, detail=? WHERE id=?",
|
||||||
|
(status, detail, int(ref)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return {"connector": connector, "status": status, "detail": detail}
|
||||||
|
|
||||||
|
|
||||||
|
def _gh_headers() -> dict:
|
||||||
|
headers = {"User-Agent": "FlowDeck-Connectors/1.0",
|
||||||
|
"Accept": "application/vnd.github+json"}
|
||||||
|
if settings.github_token:
|
||||||
|
headers["Authorization"] = f"Bearer {settings.github_token}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
async def connector_fetch(connector: str, path: str = "", query: str = "",
|
||||||
|
user_id: int | None = None) -> dict:
|
||||||
|
"""Lit un connecteur pour l'LLM : natif = API, web = recherche, perso = GET."""
|
||||||
|
kind, ref = _resolve(connector)
|
||||||
|
path = (path or "").strip()
|
||||||
|
if kind in OAUTH_KINDS:
|
||||||
|
return await oauth_connectors.api_get(kind, user_id, path)
|
||||||
|
if kind == "mcp":
|
||||||
|
from app.services import mcp_client
|
||||||
|
return {"status": "ok", "text": mcp_client.tools_text(int(ref))}
|
||||||
|
if kind == "gitea":
|
||||||
|
base = settings.gitea_url.rstrip("/")
|
||||||
|
url = f"{base}/{path.lstrip('/')}" if path else f"{base}/api/v1/version"
|
||||||
|
code, text = await _get(url, {"Authorization": f"token {settings.gitea_token}"})
|
||||||
|
elif kind == "github":
|
||||||
|
url = "https://api.github.com/" + path.lstrip("/") if path else "https://api.github.com/rate_limit"
|
||||||
|
code, text = await _get(url, _gh_headers())
|
||||||
|
elif kind == "web":
|
||||||
|
from app.services.web_search import search_web
|
||||||
|
if not (query or "").strip():
|
||||||
|
return {"status": "error", "text": "query est requis pour le connecteur web"}
|
||||||
|
results, provider = await search_web(query.strip(), 5)
|
||||||
|
text = "\n".join(f"- {r.get('title', '')} — {r.get('url', '')}\n {r.get('snippet', '')}"
|
||||||
|
for r in results) or "Aucun résultat."
|
||||||
|
return {"status": "ok", "text": f"provider: {provider}\n{text}"[:FETCH_LIMIT]}
|
||||||
|
else:
|
||||||
|
row = get(int(ref))
|
||||||
|
if row is None:
|
||||||
|
return {"status": "error", "text": "Connecteur supprimé"}
|
||||||
|
if not row["enabled"]:
|
||||||
|
return {"status": "error", "text": "Connecteur désactivé"}
|
||||||
|
url = _with_secret(row["url"], int(ref)).rstrip("/")
|
||||||
|
if path:
|
||||||
|
url += "/" + path.lstrip("/")
|
||||||
|
code, text = await _get(url, _headers(int(ref)))
|
||||||
|
if code >= 400:
|
||||||
|
return {"status": "error", "text": f"HTTP {code} — {text[:400]}"}
|
||||||
|
return {"status": "ok", "text": text[:FETCH_LIMIT]}
|
||||||
@@ -118,7 +118,7 @@ class ContextBuilder:
|
|||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
def _mentions_context(self, mentions: list[str]) -> str:
|
def _mentions_context(self, mentions: list[str]) -> str:
|
||||||
"""Resolve @document:x / @collection:x / @page:y / @repo:o/r mentions.
|
"""Resolve @document:x / @collection:x / @page:y / @folder:d / @repo:o/r mentions.
|
||||||
|
|
||||||
Mentions bring the *actual content* of the referenced object into the
|
Mentions bring the *actual content* of the referenced object into the
|
||||||
context so the LLM can summarise / rewrite / analyse it directly without
|
context so the LLM can summarise / rewrite / analyse it directly without
|
||||||
@@ -135,6 +135,8 @@ class ContextBuilder:
|
|||||||
elif m.startswith("page:"):
|
elif m.startswith("page:"):
|
||||||
pid = m.split(":", 1)[1]
|
pid = m.split(":", 1)[1]
|
||||||
lines.append(self._single_page(pid))
|
lines.append(self._single_page(pid))
|
||||||
|
elif m.startswith("folder:"):
|
||||||
|
lines.append(self._single_folder(m.split(":", 1)[1]))
|
||||||
elif m.startswith("repo:"):
|
elif m.startswith("repo:"):
|
||||||
lines.append(f"- @repo: {m.split(':', 1)[1]} (Gitea issues available via read_gitea_issues)")
|
lines.append(f"- @repo: {m.split(':', 1)[1]} (Gitea issues available via read_gitea_issues)")
|
||||||
elif m == "ws":
|
elif m == "ws":
|
||||||
@@ -209,6 +211,37 @@ class ContextBuilder:
|
|||||||
return f"{head}:\n{body[:9000]}"
|
return f"{head}:\n{body[:9000]}"
|
||||||
return head
|
return head
|
||||||
|
|
||||||
|
def _single_folder(self, fid: str) -> str:
|
||||||
|
"""Folder (répertoire) mention : titre du dossier + ses documents directs.
|
||||||
|
|
||||||
|
ponytail : enfants directs seulement, budget ~12k caractères —
|
||||||
|
ajouter une profondeur récursive si les arbres profonds deviennent réels.
|
||||||
|
"""
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL",
|
||||||
|
(fid,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return f"- dossier #{fid}: introuvable"
|
||||||
|
kids = conn.execute(
|
||||||
|
"SELECT id FROM pages WHERE parent_id=? AND deleted_at IS NULL "
|
||||||
|
"ORDER BY sort_order ASC, created_at DESC",
|
||||||
|
(fid,),
|
||||||
|
).fetchall()
|
||||||
|
title = row["title"] or "Sans titre"
|
||||||
|
head = f"- dossier #{row['id']} **{title}** ({len(kids)} élément(s))"
|
||||||
|
out = [head]
|
||||||
|
used = len(head)
|
||||||
|
for k in kids:
|
||||||
|
part = self._single_document(str(k["id"]))
|
||||||
|
if used + len(part) + 1 > 12000:
|
||||||
|
out.append("- … (contenu du dossier tronqué)")
|
||||||
|
break
|
||||||
|
used += len(part) + 1
|
||||||
|
out.append(part)
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
def _single_collection(self, cid: str) -> str:
|
def _single_collection(self, cid: str) -> str:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
row = conn.execute("SELECT id, name, icon, schema_json FROM collections WHERE id=?", (cid,)).fetchone()
|
row = conn.execute("SELECT id, name, icon, schema_json FROM collections WHERE id=?", (cid,)).fetchone()
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ logger = logging.getLogger(__name__)
|
|||||||
PROVIDERS = {
|
PROVIDERS = {
|
||||||
"openai": ("https://api.openai.com/v1", "gpt-4o"),
|
"openai": ("https://api.openai.com/v1", "gpt-4o"),
|
||||||
"anthropic": ("https://api.anthropic.com/v1", "claude-opus-4-8"),
|
"anthropic": ("https://api.anthropic.com/v1", "claude-opus-4-8"),
|
||||||
"mistral": ("https://api.mistral.ai/v1", "mistral-large-latest"),
|
# mistral-small est servi par tous les plans d'abonnement ;
|
||||||
|
# mistral-large → 403 tier_not_allowed sur les plans basiques.
|
||||||
|
"mistral": ("https://api.mistral.ai/v1", "mistral-small-latest"),
|
||||||
"cohere": ("https://api.cohere.ai/compatibility/v1", "command-a-plus-05-2026"),
|
"cohere": ("https://api.cohere.ai/compatibility/v1", "command-a-plus-05-2026"),
|
||||||
"google": ("https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.0-flash"),
|
"google": ("https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.0-flash"),
|
||||||
"groq": ("https://api.groq.com/openai/v1", "llama-3.3-70b-versatile"),
|
"groq": ("https://api.groq.com/openai/v1", "llama-3.3-70b-versatile"),
|
||||||
@@ -94,8 +96,11 @@ PROVIDER_LABELS: dict[str, str] = {
|
|||||||
PROVIDER_MODELS: dict[str, list[str]] = {
|
PROVIDER_MODELS: dict[str, list[str]] = {
|
||||||
"openai": ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "o3-mini", "gpt-4-turbo"],
|
"openai": ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "o3-mini", "gpt-4-turbo"],
|
||||||
"anthropic": ["claude-opus-4-8", "claude-sonnet-4-5", "claude-3-5-sonnet", "claude-haiku-4-5"],
|
"anthropic": ["claude-opus-4-8", "claude-sonnet-4-5", "claude-3-5-sonnet", "claude-haiku-4-5"],
|
||||||
"mistral": ["mistral-large-latest", "mistral-medium-latest", "mistral-small-latest",
|
# Ordre = ordre de sélection du test de connexion : les modèles servis par
|
||||||
"codestral-latest", "open-mistral-nemo", "pixtral-large-latest"],
|
# tous les plans d'abord (large/pixtral-large = 403 tier_not_allowed en plan
|
||||||
|
# basique), les modèles à plan élevé en fin de liste.
|
||||||
|
"mistral": ["mistral-small-latest", "mistral-medium-latest", "open-mistral-nemo",
|
||||||
|
"codestral-latest", "mistral-large-latest", "pixtral-large-latest"],
|
||||||
"cohere": ["command-a-plus-05-2026", "command-r-plus", "command-r", "command-a-03-2025"],
|
"cohere": ["command-a-plus-05-2026", "command-r-plus", "command-r", "command-a-03-2025"],
|
||||||
"google": ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-pro", "gemini-1.5-flash"],
|
"google": ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-pro", "gemini-1.5-flash"],
|
||||||
"groq": ["llama-3.3-70b-versatile", "llama-3.1-8b-instant",
|
"groq": ["llama-3.3-70b-versatile", "llama-3.1-8b-instant",
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ from app.services.llm_client import PROVIDER_LABELS, PROVIDER_MODELS, PROVIDERS
|
|||||||
# before being exposed as "usable models". NVIDIA exposes all of its catalog
|
# before being exposed as "usable models". NVIDIA exposes all of its catalog
|
||||||
# (embeddings, rerank, image/video/audio gen…) many of which answer 404 on
|
# (embeddings, rerank, image/video/audio gen…) many of which answer 404 on
|
||||||
# chat completions — the exact failure the user hit.
|
# chat completions — the exact failure the user hit.
|
||||||
_CHAT_VALIDATED_PROVIDERS = frozenset({"nvidia"})
|
# Mistral : le /models liste des modèles hors du plan d'abonnement du compte
|
||||||
|
# (403 « tier_not_allowed » ex. mistral-large sur plan basique) — la sonde
|
||||||
|
# chat ne garde que ceux réellement servis par la CLÉ de l'utilisateur.
|
||||||
|
_CHAT_VALIDATED_PROVIDERS = frozenset({"nvidia", "mistral"})
|
||||||
|
|
||||||
# Markers that identify clearly non-chat models (embeddings, rerank, media gen…).
|
# Markers that identify clearly non-chat models (embeddings, rerank, media gen…).
|
||||||
_NON_CHAT_MARKERS = (
|
_NON_CHAT_MARKERS = (
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Client MCP (Model Context Protocol) — streamable HTTP, v7.57.0.
|
||||||
|
|
||||||
|
JSON-RPC 2.0 : ``initialize`` → ``notifications/initialized`` → ``tools/list``
|
||||||
|
→ ``tools/call``. Les outils sont **cachés** dans
|
||||||
|
``agent_connectors.tools_json`` (kind ``mcp``) puis exposés au LLM par
|
||||||
|
``ToolRegistry`` sous des noms ``mcp_<serveur>_<outil>`` — outils dynamiques,
|
||||||
|
sans état en mémoire.
|
||||||
|
|
||||||
|
ponytail : **1 seul seam** ``_rpc()`` (monkeypatché en test) ; on lit soit la
|
||||||
|
réponse JSON directe, soit la première ligne ``data:`` d'une
|
||||||
|
``text/event-stream`` — pas de client SSE à l'état (les méthodes utilisées
|
||||||
|
répondent en JSON chez la grande majorité des serveurs).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from app.db import get_conn
|
||||||
|
from app.services.connectors import _headers, _with_secret, get
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Version la plus répandue côté serveurs (un serveur plus récent rétrograde).
|
||||||
|
PROTOCOL_VERSION = "2024-11-05"
|
||||||
|
CLIENT_INFO = {"name": "FlowDeck", "version": "7"}
|
||||||
|
|
||||||
|
|
||||||
|
def tool_name(server_name: str, tool_name: str) -> str:
|
||||||
|
"""Nom LLM stable : ``mcp_<serveur>_<outil>`` (slug minuscules/underscores)."""
|
||||||
|
def slug(text: str) -> str:
|
||||||
|
return re.sub(r"[^a-z0-9]+", "_", str(text).lower()).strip("_")
|
||||||
|
joined = f"mcp_{slug(server_name)}_{slug(tool_name)}"
|
||||||
|
return re.sub(r"_{2,}", "_", joined)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_body(content_type: str, body: str, status: int) -> dict:
|
||||||
|
"""Corps MCP → dict JSON (JSON direct ou événement ``data:`` d'un flux SSE)."""
|
||||||
|
text = body or ""
|
||||||
|
if "text/event-stream" in (content_type or ""):
|
||||||
|
for line in text.splitlines():
|
||||||
|
if line.startswith("data:"):
|
||||||
|
text = line[5:].strip()
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(f"Réponse MCP illisible (HTTP {status})") from exc
|
||||||
|
if isinstance(data, dict) and data.get("error"):
|
||||||
|
err = data["error"] if isinstance(data["error"], dict) else {}
|
||||||
|
raise ValueError(f"MCP {err.get('code', '?')} : {err.get('message', 'erreur')}")
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _rpc(url: str, payload: dict, headers: dict | None = None) -> dict:
|
||||||
|
"""POST JSON-RPC gardé SSRF → réponse décodée. Point d'injection des tests."""
|
||||||
|
from app.services.http_client import shared_client
|
||||||
|
from app.services.importers.url_fetch import _validate_url
|
||||||
|
|
||||||
|
safe = _validate_url(url)
|
||||||
|
hdrs = {"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json, text/event-stream",
|
||||||
|
**(headers or {})}
|
||||||
|
async with shared_client(timeout=15, headers=hdrs) as client:
|
||||||
|
resp = await client.post(safe, json=payload)
|
||||||
|
return parse_body(resp.headers.get("content-type", ""), resp.text or "",
|
||||||
|
resp.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
async def _notify(url: str, payload: dict, headers: dict | None = None) -> None:
|
||||||
|
"""Notification JSON-RPC (202 sans corps) — les erreurs sont ignorées."""
|
||||||
|
try:
|
||||||
|
await _rpc(url, payload, headers)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.debug("MCP notification ignorée: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _server(connector_id: int) -> dict:
|
||||||
|
row = get(connector_id)
|
||||||
|
if row is None:
|
||||||
|
raise ValueError("Serveur MCP introuvable")
|
||||||
|
if row["kind"] != "mcp":
|
||||||
|
raise ValueError("Ce connecteur n'est pas un serveur MCP")
|
||||||
|
if not row["enabled"]:
|
||||||
|
raise ValueError("Serveur MCP désactivé")
|
||||||
|
return {"id": row["id"], "name": row["name"], "url": row["url"],
|
||||||
|
"headers": _headers(connector_id)}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(server: dict, raw: list) -> list[dict]:
|
||||||
|
out = []
|
||||||
|
for t in raw if isinstance(raw, list) else []:
|
||||||
|
if not isinstance(t, dict) or not t.get("name"):
|
||||||
|
continue
|
||||||
|
params = t.get("inputSchema")
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
params = {"type": "object", "properties": {}}
|
||||||
|
out.append({
|
||||||
|
"name": tool_name(server["name"], t["name"]),
|
||||||
|
"original": str(t["name"]),
|
||||||
|
"description": str(t.get("description") or "Outil MCP"),
|
||||||
|
"parameters": params,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _save_tools(connector_id: int, tools: list[dict]) -> None:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute("UPDATE agent_connectors SET tools_json=? WHERE id=?",
|
||||||
|
(json.dumps(tools), connector_id))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def initialize_and_list(connector_id: int) -> list[dict]:
|
||||||
|
"""Handshake + ``tools/list`` → met à jour le cache de la base."""
|
||||||
|
srv = _server(connector_id)
|
||||||
|
url = _with_secret(srv["url"], connector_id)
|
||||||
|
await _rpc(url, {
|
||||||
|
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||||
|
"params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {},
|
||||||
|
"clientInfo": CLIENT_INFO},
|
||||||
|
}, srv["headers"])
|
||||||
|
await _notify(url, {"jsonrpc": "2.0", "method": "notifications/initialized"},
|
||||||
|
srv["headers"])
|
||||||
|
data = await _rpc(url, {"jsonrpc": "2.0", "id": 2, "method": "tools/list",
|
||||||
|
"params": {}}, srv["headers"])
|
||||||
|
result = data.get("result") if isinstance(data.get("result"), dict) else {}
|
||||||
|
tools = _normalize(srv, result.get("tools") or [])
|
||||||
|
_save_tools(connector_id, tools)
|
||||||
|
return tools
|
||||||
|
|
||||||
|
|
||||||
|
def cached_tools() -> list[dict]:
|
||||||
|
"""Outils MCP des serveurs **activés** (cache en base) — lu par ToolRegistry."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, tools_json FROM agent_connectors WHERE kind='mcp' AND enabled=1"
|
||||||
|
).fetchall()
|
||||||
|
out: list[dict] = []
|
||||||
|
for r in rows:
|
||||||
|
try:
|
||||||
|
tools = json.loads(r["tools_json"] or "[]")
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
tools = []
|
||||||
|
for t in tools if isinstance(tools, list) else []:
|
||||||
|
if isinstance(t, dict) and t.get("name"):
|
||||||
|
out.append({**t, "connector_id": r["id"]})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def tools_text(connector_id: int) -> str:
|
||||||
|
"""Liste lisible des outils cachés (utilisée par ``connector_fetch``)."""
|
||||||
|
tools = [t for t in cached_tools() if t["connector_id"] == connector_id]
|
||||||
|
if not tools:
|
||||||
|
return "Aucun outil en cache — lancez « Tester le connecteur » (tools/list)."
|
||||||
|
return "\n".join(f"- {t['name']} : {t.get('description', '')}" for t in tools)
|
||||||
|
|
||||||
|
|
||||||
|
def _content_text(result: dict) -> str:
|
||||||
|
parts = []
|
||||||
|
for item in result.get("content") or []:
|
||||||
|
if isinstance(item, dict) and item.get("type") == "text":
|
||||||
|
parts.append(str(item.get("text") or ""))
|
||||||
|
else:
|
||||||
|
parts.append(json.dumps(item, ensure_ascii=False)[:2000])
|
||||||
|
return "\n".join(p for p in parts if p) or json.dumps(result, ensure_ascii=False)[:4000]
|
||||||
|
|
||||||
|
|
||||||
|
async def call_tool(connector_id: int, tool: str, arguments: dict | None = None) -> dict:
|
||||||
|
"""``tools/call`` → {status, text} (isError → error, borné à 20 000 car.)."""
|
||||||
|
srv = _server(connector_id)
|
||||||
|
url = _with_secret(srv["url"], connector_id)
|
||||||
|
data = await _rpc(url, {
|
||||||
|
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
|
||||||
|
"params": {"name": tool, "arguments": arguments or {}},
|
||||||
|
}, srv["headers"])
|
||||||
|
result = data.get("result") if isinstance(data.get("result"), dict) else {}
|
||||||
|
text = _content_text(result)[:20000]
|
||||||
|
return {"status": "error" if result.get("isError") else "ok", "text": text}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
"""Connecteurs OAuth Google / Microsoft 365 (v7.56.0 — phase 6).
|
||||||
|
|
||||||
|
Flow : ``begin()`` (URL d'autorisation PKCE S256 + state) → callback
|
||||||
|
``complete()`` (échange du code) → tokens chiffrés Fernet → ``api_get()`` avec
|
||||||
|
refresh automatique.
|
||||||
|
|
||||||
|
Réutilise : `_encrypt_tokens` / `_decrypt_tokens` (`calendar_sync`, déjà Fernet)
|
||||||
|
et `shared_client` (A42). Les scopes sont **figés en lecture seule**.
|
||||||
|
|
||||||
|
ponytail : 2 providers décrits par 1 dict (pas de classe par provider) ; les
|
||||||
|
scopes au choix et un écran de connexion dédié arriveront si le besoin se
|
||||||
|
présente — l'état vit dans le catalogue du menu +.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.db import get_conn
|
||||||
|
from app.services.calendar_sync import _decrypt_tokens, _encrypt_tokens
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PROVIDERS: dict[str, dict] = {
|
||||||
|
"google": {
|
||||||
|
"name": "Google (Drive · Gmail · Calendar)",
|
||||||
|
"authorize": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||||
|
"token": "https://oauth2.googleapis.com/token",
|
||||||
|
"api": "https://www.googleapis.com",
|
||||||
|
"scopes": [
|
||||||
|
"openid", "email", "profile",
|
||||||
|
"https://www.googleapis.com/auth/drive.readonly",
|
||||||
|
"https://www.googleapis.com/auth/gmail.readonly",
|
||||||
|
"https://www.googleapis.com/auth/calendar.readonly",
|
||||||
|
],
|
||||||
|
"extra": {"access_type": "offline", "include_granted_scopes": "true"},
|
||||||
|
"probe_path": "/oauth2/v3/userinfo",
|
||||||
|
},
|
||||||
|
"ms365": {
|
||||||
|
"name": "Microsoft 365 (Outlook · OneDrive)",
|
||||||
|
"authorize": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
|
||||||
|
"token": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
||||||
|
"api": "https://graph.microsoft.com/v1.0",
|
||||||
|
"scopes": [
|
||||||
|
"openid", "email", "profile", "offline_access",
|
||||||
|
"User.Read", "Files.Read", "Mail.Read", "Calendars.Read",
|
||||||
|
"ChannelMessage.Read.All", # Teams (phase 7) — consentement admin
|
||||||
|
],
|
||||||
|
"extra": {"response_mode": "query", "prompt": "consent"},
|
||||||
|
"probe_path": "/me",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
OAUTH_KINDS = tuple(PROVIDERS)
|
||||||
|
# l'access token est renouvelé 60 s avant expiration
|
||||||
|
_REFRESH_SKEW = 60
|
||||||
|
|
||||||
|
|
||||||
|
# ── Config client ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _client(kind: str) -> tuple[str, str, str]:
|
||||||
|
"""(client_id, client_secret, redirect_uri) — lève si non configuré."""
|
||||||
|
if kind not in PROVIDERS:
|
||||||
|
raise ValueError(f"Connecteur OAuth inconnu: {kind}")
|
||||||
|
if kind == "google":
|
||||||
|
cid, secret = settings.google_client_id, settings.google_client_secret
|
||||||
|
else:
|
||||||
|
cid, secret = settings.ms_client_id, settings.ms_client_secret
|
||||||
|
if not cid or not secret:
|
||||||
|
raise ValueError(
|
||||||
|
f"Connecteur {kind} non configuré (GOOGLE_CLIENT_ID/SECRET ou MS_CLIENT_ID/SECRET)"
|
||||||
|
)
|
||||||
|
redirect = f"{settings.app_base_url.rstrip('/')}/api/agent/connectors/oauth/{kind}/callback"
|
||||||
|
return cid, secret, redirect
|
||||||
|
|
||||||
|
|
||||||
|
def callback_path(kind: str) -> str:
|
||||||
|
return f"/api/agent/connectors/oauth/{kind}/callback"
|
||||||
|
|
||||||
|
|
||||||
|
def _pkce_pair() -> tuple[str, str]:
|
||||||
|
verifier = secrets.token_urlsafe(48)
|
||||||
|
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
||||||
|
return verifier, base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Flow ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def begin(kind: str) -> dict:
|
||||||
|
"""URL d'autorisation + state + code_verifier (le route met les cookies)."""
|
||||||
|
cid, _secret, redirect = _client(kind)
|
||||||
|
verifier, challenge = _pkce_pair()
|
||||||
|
state = secrets.token_urlsafe(24)
|
||||||
|
params = {
|
||||||
|
"client_id": cid,
|
||||||
|
"redirect_uri": redirect,
|
||||||
|
"response_type": "code",
|
||||||
|
"scope": " ".join(PROVIDERS[kind]["scopes"]),
|
||||||
|
"state": state,
|
||||||
|
"code_challenge": challenge,
|
||||||
|
"code_challenge_method": "S256",
|
||||||
|
}
|
||||||
|
params.update(PROVIDERS[kind].get("extra", {}))
|
||||||
|
return {
|
||||||
|
"url": f"{PROVIDERS[kind]['authorize']}?{urlencode(params)}",
|
||||||
|
"state": state,
|
||||||
|
"verifier": verifier,
|
||||||
|
"redirect_uri": redirect,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(data: dict) -> dict:
|
||||||
|
expires_in = int(data.get("expires_in") or 3600)
|
||||||
|
return {
|
||||||
|
"access_token": str(data.get("access_token") or ""),
|
||||||
|
"refresh_token": str(data.get("refresh_token") or ""),
|
||||||
|
"scope": str(data.get("scope") or ""),
|
||||||
|
"expires_at": int(time.time()) + expires_in,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def complete(kind: str, code: str, verifier: str) -> dict:
|
||||||
|
"""Échange le code contre des tokens (aucun réseau ici hors `_post_form`)."""
|
||||||
|
cid, secret, redirect = _client(kind)
|
||||||
|
data = await _post_form(PROVIDERS[kind]["token"], {
|
||||||
|
"client_id": cid,
|
||||||
|
"client_secret": secret,
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": redirect,
|
||||||
|
"code_verifier": verifier,
|
||||||
|
})
|
||||||
|
tokens = _normalize(data)
|
||||||
|
if not tokens["access_token"]:
|
||||||
|
raise ValueError(str(data.get("error_description") or data.get("error") or "échec de l'échange du code"))
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
# ── Stockage (chiffré Fernet, comme calendar_sync) ──────────────────────────
|
||||||
|
|
||||||
|
def save(kind: str, user_id: int, tokens: dict) -> None:
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO connector_tokens (kind, user_id, tokens_enc, updated_at) "
|
||||||
|
"VALUES (?,?,?,CURRENT_TIMESTAMP) "
|
||||||
|
"ON CONFLICT(kind, user_id) DO UPDATE SET "
|
||||||
|
"tokens_enc=excluded.tokens_enc, updated_at=CURRENT_TIMESTAMP",
|
||||||
|
(kind, user_id, _encrypt_tokens(tokens)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def tokens(kind: str, user_id: int | None) -> dict:
|
||||||
|
if not user_id:
|
||||||
|
return {}
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT tokens_enc FROM connector_tokens WHERE kind=? AND user_id=?",
|
||||||
|
(kind, user_id),
|
||||||
|
).fetchone()
|
||||||
|
return _decrypt_tokens(row["tokens_enc"]) if row else {}
|
||||||
|
|
||||||
|
|
||||||
|
def clear(kind: str, user_id: int) -> bool:
|
||||||
|
with get_conn() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"DELETE FROM connector_tokens WHERE kind=? AND user_id=?", (kind, user_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
|
||||||
|
|
||||||
|
def is_connected(kind: str, user_id: int | None) -> bool:
|
||||||
|
return bool(tokens(kind, user_id).get("access_token"))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Réseau ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _post_form(url: str, data: dict) -> dict:
|
||||||
|
"""POST x-www-form-urlencoded vers le token endpoint → dict JSON.
|
||||||
|
|
||||||
|
Point d'injection des tests (on monkeypatche ``oauth_connectors._post_form``).
|
||||||
|
"""
|
||||||
|
from app.services.http_client import shared_client
|
||||||
|
|
||||||
|
async with shared_client(timeout=15, headers={"Accept": "application/json"}) as client:
|
||||||
|
resp = await client.post(url, data=data)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def _api_get(url: str, headers: dict) -> tuple[int, str]:
|
||||||
|
"""GET d'une API fournisseur — 2ᵉ point d'injection des tests."""
|
||||||
|
from app.services.http_client import shared_client
|
||||||
|
|
||||||
|
async with shared_client(timeout=15, headers=headers) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
return resp.status_code, (resp.text or "")[:20000]
|
||||||
|
|
||||||
|
|
||||||
|
async def access_token(kind: str, user_id: int | None) -> str:
|
||||||
|
"""Access token valide, rafraîchi (refresh_token) si nécessaire."""
|
||||||
|
tok = tokens(kind, user_id)
|
||||||
|
if not tok.get("access_token"):
|
||||||
|
return ""
|
||||||
|
if tok.get("expires_at", 0) > time.time() + _REFRESH_SKEW:
|
||||||
|
return tok["access_token"]
|
||||||
|
if not tok.get("refresh_token"):
|
||||||
|
return "" # expiré sans refresh → à reconnexion
|
||||||
|
try:
|
||||||
|
cid, secret, redirect = _client(kind)
|
||||||
|
data = await _post_form(PROVIDERS[kind]["token"], {
|
||||||
|
"client_id": cid,
|
||||||
|
"client_secret": secret,
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": tok["refresh_token"],
|
||||||
|
"redirect_uri": redirect,
|
||||||
|
})
|
||||||
|
fresh = _normalize(data)
|
||||||
|
if not fresh["access_token"]:
|
||||||
|
raise ValueError(data.get("error_description") or "refresh refusé")
|
||||||
|
# Google ne renvoie parfois pas de nouveau refresh_token : on garde l'ancien
|
||||||
|
fresh["refresh_token"] = fresh["refresh_token"] or tok["refresh_token"]
|
||||||
|
save(kind, int(user_id), fresh)
|
||||||
|
return fresh["access_token"]
|
||||||
|
except Exception as exc: # noqa: BLE001 — jamais d'exception qui casse un run
|
||||||
|
logger.warning("OAuth refresh failed (%s): %s", kind, exc)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def api_get(kind: str, user_id: int | None, path: str = "") -> dict:
|
||||||
|
"""GET sur l'API du fournisseur avec le token de l'utilisateur."""
|
||||||
|
if kind not in PROVIDERS:
|
||||||
|
return {"status": "error", "text": f"Connecteur OAuth inconnu: {kind}"}
|
||||||
|
access = await access_token(kind, user_id)
|
||||||
|
if not access:
|
||||||
|
return {"status": "error", "text": f"{kind} non connecté (lancez la connexion OAuth)"}
|
||||||
|
if "://" in (path or ""):
|
||||||
|
return {"status": "error", "text": "path doit être relatif (pas d'URL absolue)"}
|
||||||
|
# base fixe + chemin : pas d'urljoin (une URL absolue ne peut pas dévier
|
||||||
|
# l'hôte), puis validation SSRF par principe.
|
||||||
|
url = PROVIDERS[kind]["api"].rstrip("/") + "/" + (path or "").lstrip("/")
|
||||||
|
from app.services.importers.url_fetch import _validate_url
|
||||||
|
try:
|
||||||
|
_validate_url(url)
|
||||||
|
except ValueError as exc:
|
||||||
|
return {"status": "error", "text": str(exc)}
|
||||||
|
code, text = await _api_get(url, {"Authorization": f"Bearer {access}"})
|
||||||
|
if code >= 400:
|
||||||
|
return {"status": "error", "text": f"HTTP {code} — {text[:400]}"}
|
||||||
|
return {"status": "ok", "text": text[:20000]}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Plugins de l'instance — on/off à **effet réel** (v7.58.0).
|
||||||
|
|
||||||
|
Chaque plugin câble une chose concrète, jamais un simple drapeau :
|
||||||
|
|
||||||
|
- ``web-tools`` → retiré du registre d'outils de l'agent (schéma + exécution)
|
||||||
|
- ``web-clipper`` → routers ``/extensions`` et ``/api/v2/web-clipper/*`` refusés
|
||||||
|
- ``automations`` → routes ``/workspace/automations*`` refusées + scheduler
|
||||||
|
|
||||||
|
Le garde de route est une **dépendance FastAPI posée à l'`include_router`**
|
||||||
|
(``main.py``) : 3 lignes, aucun fichier de router touché.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
|
from app.db import get_conn
|
||||||
|
|
||||||
|
# slug → (nom affiché, description affichée)
|
||||||
|
CATALOG: list[tuple[str, str, str]] = [
|
||||||
|
("web-tools", "Outils web de l'agent",
|
||||||
|
"Retire web_search et fetch_url du registre d'outils de l'agent"),
|
||||||
|
("web-clipper", "Web Clipper",
|
||||||
|
"Coupe la page /extensions et l'API /api/v2/web-clipper/*"),
|
||||||
|
("automations", "Automations",
|
||||||
|
"Coupe /workspace/automations* et le scheduler en arrière-plan"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# slug → outils LLM retirés du registre quand le plugin est OFF
|
||||||
|
PLUGIN_TOOLS: dict[str, tuple[str, ...]] = {"web-tools": ("web_search", "fetch_url")}
|
||||||
|
|
||||||
|
|
||||||
|
def is_enabled(slug: str) -> bool:
|
||||||
|
"""Ligne absente = activé (défaut sûr : ne rien couper par accident)."""
|
||||||
|
with get_conn() as conn:
|
||||||
|
row = conn.execute("SELECT enabled FROM plugins WHERE slug=?", (slug,)).fetchone()
|
||||||
|
return True if row is None else bool(row["enabled"])
|
||||||
|
|
||||||
|
|
||||||
|
def list_plugins() -> list[dict]:
|
||||||
|
with get_conn() as conn:
|
||||||
|
rows = {r["slug"]: bool(r["enabled"])
|
||||||
|
for r in conn.execute("SELECT slug, enabled FROM plugins")}
|
||||||
|
return [{"slug": slug, "name": name, "description": desc,
|
||||||
|
"enabled": rows.get(slug, True)}
|
||||||
|
for slug, name, desc in CATALOG]
|
||||||
|
|
||||||
|
|
||||||
|
def set_enabled(slug: str, enabled: bool) -> dict:
|
||||||
|
if not any(s == slug for s, _, _ in CATALOG):
|
||||||
|
raise ValueError(f"Plugin inconnu: {slug}")
|
||||||
|
name = next(n for s, n, _ in CATALOG if s == slug)
|
||||||
|
desc = next(d for s, _, d in CATALOG if s == slug)
|
||||||
|
with get_conn() as conn:
|
||||||
|
# SQLite vérifie NOT NULL AVANT l'upsert : il faut fournir name/description.
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO plugins (slug, name, description, enabled) VALUES (?,?,?,?) "
|
||||||
|
"ON CONFLICT(slug) DO UPDATE SET enabled=excluded.enabled",
|
||||||
|
(slug, name, desc, 1 if enabled else 0))
|
||||||
|
conn.commit()
|
||||||
|
return next(p for p in list_plugins() if p["slug"] == slug)
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_required(slug: str):
|
||||||
|
"""Dépendance FastAPI : 404 dès que le plugin est désactivé.
|
||||||
|
|
||||||
|
`Request` doit être importé au **module** : les annotations sont des
|
||||||
|
chaînes (`from __future__ import annotations`) et FastAPI les résout dans
|
||||||
|
les globales du module — sinon il lit un query param → 422.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def dep(request: Request) -> None:
|
||||||
|
# annotation Request OBLIGATOIRE : sans elle FastAPI lit un query param → 422
|
||||||
|
if not is_enabled(slug):
|
||||||
|
raise HTTPException(status_code=404, detail=f"Plugin désactivé: {slug}")
|
||||||
|
|
||||||
|
return dep
|
||||||
@@ -109,6 +109,201 @@ GALLERY: dict[str, dict] = {
|
|||||||
),
|
),
|
||||||
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# ── Vague 1 : skills 100 % locaux (aucun nouveau tool requis) ──────────
|
||||||
|
|
||||||
|
"veille-documentaire": {
|
||||||
|
"name": "Veille documentaire",
|
||||||
|
"icon": "🔎",
|
||||||
|
"description": "Repère les pages du workspace sans mise à jour récente et génère un digest d'entrée.",
|
||||||
|
"prompt_template": (
|
||||||
|
"Fais une veille documentaire du workspace :\n"
|
||||||
|
"1. Repère les documents dont le titre évoque une décision, un chantier ou un arbitrage "
|
||||||
|
"(search_workspace : « décision », « ADR », « roadmap », « spec », « bilan »).\n"
|
||||||
|
"2. Lis les plus pertinents (read_document) et note pour chacun : date de mise à jour, "
|
||||||
|
"statut (vivant / obsolète / contradictoire), propriétaire mentionné quand il existe.\n"
|
||||||
|
"3. Crée un document « Veille — <date> » (create_document) en 3 sections : "
|
||||||
|
"À valider (décisions non actées), À mettre à jour (docs dépassés par d'autres), "
|
||||||
|
"Cohérences (points de vue contradictoires entre documents).\n"
|
||||||
|
"4. Ne modifie aucun document : tu proposes, l'humain décide."
|
||||||
|
),
|
||||||
|
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||||
|
},
|
||||||
|
"chasse-decisions": {
|
||||||
|
"name": "Chasse aux décisions",
|
||||||
|
"icon": "⚖️",
|
||||||
|
"description": " Rassemble en une page toutes les décisions prises dans le workspace, avec leur statut.",
|
||||||
|
"prompt_template": (
|
||||||
|
"Construis le registre des décisions du workspace :\n"
|
||||||
|
"1. Cherche les documents de décision (search_workspace : « décision », « arbitrage », "
|
||||||
|
"« choix de », « remplacement de »).\n"
|
||||||
|
"2. Lis-les (read_document) et extrais pour chacune : la décision, la date, la motivation, "
|
||||||
|
"les options écartées.\n"
|
||||||
|
"3. Crée un document « Registre des décisions » (create_document) en tableau : "
|
||||||
|
"Décision · Date · Motivation · Statut (actif / révoqué / jamais tranché).\n"
|
||||||
|
"4. Signale explicitement les sujets récurrents où aucune décision n'a été actée, "
|
||||||
|
"plutôt que de combler les trous."
|
||||||
|
),
|
||||||
|
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||||
|
},
|
||||||
|
"onboarding-nouveau": {
|
||||||
|
"name": "Onboarding nouveau",
|
||||||
|
"icon": "🧭",
|
||||||
|
"description": "Génère un parcours de lecture ordonné pour une personne qui rejoint l'équipe.",
|
||||||
|
"prompt_template": (
|
||||||
|
"Prépare l'onboarding d'une nouvelle personne :\n"
|
||||||
|
"1. Repère les documents d'entrée : onboarding existant, README, architecture, process, "
|
||||||
|
"glossaire (search_workspace).\n"
|
||||||
|
"2. Lis-les (read_document) et classe-les par jour d'arrivée : Jour 1, Semaine 1, "
|
||||||
|
"Puis plus tard.\n"
|
||||||
|
"3. Crée un document « Onboarding — <prénom> » (create_document) : pour chaque étape, "
|
||||||
|
"le document à lire, ce qu'on doit en retenir, et 2 questions pour vérifier la compréhension.\n"
|
||||||
|
"4. Termine par les 5 questions qu'une personne doit savoir répondre à la fin de la semaine 1."
|
||||||
|
),
|
||||||
|
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||||
|
},
|
||||||
|
"audit-base": {
|
||||||
|
"name": "Audit de base",
|
||||||
|
"icon": "🧹",
|
||||||
|
"description": "Audite une collection : colonnes vides, doublons, lignes sans propriétaire, propriétés inutilisées.",
|
||||||
|
"prompt_template": (
|
||||||
|
"Audite la collection demandée :\n"
|
||||||
|
"1. Lis son schéma et ses lignes (read_collection).\n"
|
||||||
|
"2. Détecte : propriétés jamais remplies, lignes sans propriétaire, doublons de titre, "
|
||||||
|
"valeurs incohérentes dans une même colonne, colonnes ajoutées puis abandonnées.\n"
|
||||||
|
"3. Crée un document « Audit — <collection> » (create_document) avec, par anomalie : "
|
||||||
|
"l'ampleur (nombre de lignes), des exemples concrets, et la correction proposée.\n"
|
||||||
|
"4. N'applique AUCUNE correction : liste d'abord, l'humain valide ensuite."
|
||||||
|
),
|
||||||
|
"allowed_tools": ["search_workspace", "read_collection", "read_page", "create_document", "write_blocks"],
|
||||||
|
},
|
||||||
|
"sprint-review": {
|
||||||
|
"name": "Revue de sprint",
|
||||||
|
"icon": "🏁",
|
||||||
|
"description": "Construit une revue de sprint depuis les issues Gitea : accompli, en retard, risques.",
|
||||||
|
"prompt_template": (
|
||||||
|
"Prépare la revue de sprint à partir des issues Gitea :\n"
|
||||||
|
"1. Lis les issues du dépôt (read_gitea_issues, state=all) et resynchronise si nécessaire (sync_gitea).\n"
|
||||||
|
"2. Classe-les : accompli (fermées), en retard (ouvertes et dépassées), à trier (sans label).\n"
|
||||||
|
"3. Croise avec les documents du workspace (search_workspace) pour voir si des décisions "
|
||||||
|
"changent la priorisation.\n"
|
||||||
|
"4. Crée un document « Revue de sprint — <période> » (create_document) : Accompli · En retard · "
|
||||||
|
"Risques · Prochaines priorités, avec le numéro d'issue pour chaque affirmation.\n"
|
||||||
|
"5. Ne ferme ni ne modifie aucune issue."
|
||||||
|
),
|
||||||
|
"allowed_tools": ["read_gitea_issues", "sync_gitea", "search_workspace",
|
||||||
|
"create_document", "write_blocks"],
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── Vague 2 : skills web (web_search / fetch_url / search_code) ────────
|
||||||
|
|
||||||
|
"recherche-marche": {
|
||||||
|
"name": "Recherche marché",
|
||||||
|
"icon": "🌍",
|
||||||
|
"description": "Étudie un sujet de marché sur le web et produit une note sourcée avec alternatives.",
|
||||||
|
"prompt_template": (
|
||||||
|
"Produis une étude de marché sourcée sur « <sujet> » :\n"
|
||||||
|
"1. Cherche 3 requêtes distinctes (web_search) : le sujet lui-même, « <sujet> alternatives », "
|
||||||
|
"« <sujet> prix tarifs 2026 ».\n"
|
||||||
|
"2. Approfondis les 3 pages les plus prometteuses (fetch_url).\n"
|
||||||
|
"3. Crée un document « Marché — <sujet> » (create_document) : acteurs et positionnement, "
|
||||||
|
"grille tarifaire, forces/faiblesses, et 3 recommandations.\n"
|
||||||
|
"4. OBLIGATION : chaque affirmation porte sa source au format [titre — URL]. "
|
||||||
|
"Si une information n'est pas sourcée, écris « non vérifié » plutôt que d'inventer.\n"
|
||||||
|
"5. Indique la date de la recherche : ces données périment vite."
|
||||||
|
),
|
||||||
|
"allowed_tools": ["web_search", "fetch_url", "create_document", "write_blocks"],
|
||||||
|
},
|
||||||
|
"veille-techno": {
|
||||||
|
"name": "Veille technologique",
|
||||||
|
"icon": "🛰️",
|
||||||
|
"description": "Surveille l'actualité d'une techno : versions, ruptures, dépendances et advisories.",
|
||||||
|
"prompt_template": (
|
||||||
|
"Fais une veille technologique sur « <techno> » :\n"
|
||||||
|
"1. Web : dernières annonces et sorties de version (web_search).\n"
|
||||||
|
"2. GitHub : dépôts de référence et issues récentes résolues (search_code, kind=issues).\n"
|
||||||
|
"3. Approfondis les annonces importantes (fetch_url).\n"
|
||||||
|
"4. Crée un document « Veille — <techno> — <date> » (create_document) : "
|
||||||
|
"Nouveautés depuis le dernier point · Risques de rupture · Actions recommandées (mettre à jour / "
|
||||||
|
"surveiller / ignorer).\n"
|
||||||
|
"5. Une ligne par fait, avec [titre — URL]. Ne présente jamais une rumeur comme une release."
|
||||||
|
),
|
||||||
|
"allowed_tools": ["web_search", "fetch_url", "search_code",
|
||||||
|
"create_document", "write_blocks"],
|
||||||
|
},
|
||||||
|
"debug-web": {
|
||||||
|
"name": "Debug web",
|
||||||
|
"icon": "🐛",
|
||||||
|
"description": "Diagnostique un bug technique en croisant les docs, le code GitHub et les discussions.",
|
||||||
|
"prompt_template": (
|
||||||
|
"Diagnostique le problème suivant : <erreur / symptôme>.\n"
|
||||||
|
"1. Reformule le symptôme en une recherche web précise (web_search) : message d'erreur exact "
|
||||||
|
"+ bibliothèque + version.\n"
|
||||||
|
"2. Cherche si c'est un bug connu (search_code, kind=issues) et lis les discussions pertinentes (fetch_url).\n"
|
||||||
|
"3. Crée un document « Debug — <symptôme> » (create_document) : Cause probable (classée par "
|
||||||
|
"probabilité) · Vérifications à faire dans cet ordre · Correctif proposé.\n"
|
||||||
|
"4. Distingue clairement ce qui est vérifié par une source de ce qui est ton hypothèse."
|
||||||
|
),
|
||||||
|
"allowed_tools": ["web_search", "search_code", "fetch_url",
|
||||||
|
"create_document", "write_blocks"],
|
||||||
|
},
|
||||||
|
|
||||||
|
# ── Vague 3 : skills « pouvoir » ──────────────────────────────────────
|
||||||
|
|
||||||
|
"triage-incident": {
|
||||||
|
"name": "Triage d'incident",
|
||||||
|
"icon": "🚨",
|
||||||
|
"description": "Prend un incident décrit, ouvre un runbook pré-rempli et pose les premières questions de qualification.",
|
||||||
|
"prompt_template": (
|
||||||
|
"Traite l'incident suivant : <description>.\n"
|
||||||
|
"1. Qualifie avant de chercher : quel service, depuis quand, quel périmètre d'impact, "
|
||||||
|
"erreur observable. Les inconnues deviennent des questions, pas des suppositions.\n"
|
||||||
|
"2. Cherche les incidents similaires déjà résolus dans le workspace (search_workspace) "
|
||||||
|
"et les runbooks existants.\n"
|
||||||
|
"3. Crée un document « Incident — <titre court> » (create_document) : Symptôme · Impact · "
|
||||||
|
"Cause probable · Hypothèses classées · Étapes de résolution · Questions à poser à l'utilisateur.\n"
|
||||||
|
"4. Ne modifie aucun code ni aucune configuration : tu produis un runbook, pas un correctif.\n"
|
||||||
|
"5. Termine par les 3 informations qui feraient le plus progresser le diagnostic."
|
||||||
|
),
|
||||||
|
"allowed_tools": ["search_workspace", "read_document", "web_search",
|
||||||
|
"create_document", "write_blocks"],
|
||||||
|
},
|
||||||
|
"nettoyage-base": {
|
||||||
|
"name": "Nettoyage de base",
|
||||||
|
"icon": "🧽",
|
||||||
|
"description": "Prépare un plan de nettoyage d'une collection (doublons, valeurs vides) avec aperçu avant écriture.",
|
||||||
|
"prompt_template": (
|
||||||
|
"Prépare le nettoyage de la collection « <collection> » :\n"
|
||||||
|
"1. Lis son schéma et ses lignes (read_collection).\n"
|
||||||
|
"2. Identifie les corrections : lignes vides à supprimer, doublons à fusionner, "
|
||||||
|
"valeurs parasites à normaliser, propriétés obsolètes.\n"
|
||||||
|
"3. PRODUIRE UN APERÇU D'AVANT : pour chaque catégorie de correction, le nombre de lignes "
|
||||||
|
"concernées et 3 exemples concrets.\n"
|
||||||
|
"4. Règle absolue : n'applique AUCUNE modification avant validation explicite de l'utilisateur. "
|
||||||
|
"Le plan est le livrable.\n"
|
||||||
|
"5. Crée le document « Plan de nettoyage — <collection> » (create_document) avec le plan, "
|
||||||
|
"puis demande la validation."
|
||||||
|
),
|
||||||
|
"allowed_tools": ["search_workspace", "read_collection", "read_page",
|
||||||
|
"create_document", "write_blocks"],
|
||||||
|
},
|
||||||
|
"briefing-quotidien": {
|
||||||
|
"name": "Briefing quotidien",
|
||||||
|
"icon": "🌅",
|
||||||
|
"description": "Prépare le point du matin : activités de la veille, décisions en attente, blocages.",
|
||||||
|
"prompt_template": (
|
||||||
|
"Prépare le briefing du jour :\n"
|
||||||
|
"1. Activités : ce qui a bougé dans le workspace et le dépôt (read_gitea_issues, "
|
||||||
|
"search_workspace) — uniquement des faits datés.\n"
|
||||||
|
"2. Décisions en attente : documents de décision sans statut clair.\n"
|
||||||
|
"3. Blocages : tickets ouverts sans mouvement, sujets qui reviennent dans plusieurs documents.\n"
|
||||||
|
"4. Crée un document « Briefing — <date> » (create_document) en moins d'une page : "
|
||||||
|
"Hier en bref · À trancher aujourd'hui · Bloquants · Une chose à surveiller.\n"
|
||||||
|
"5. Si une section n'a aucun élément, écris « rien à signaler » plutôt que d'inventer."
|
||||||
|
),
|
||||||
|
"allowed_tools": ["search_workspace", "read_document", "read_gitea_issues",
|
||||||
|
"create_document", "write_blocks"],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
|
from app.services.collection_lifecycle import delete_collections
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -381,7 +382,7 @@ class SyncEngine:
|
|||||||
def _mut_collection_delete(self, user_id: int, payload: dict) -> dict:
|
def _mut_collection_delete(self, user_id: int, payload: dict) -> dict:
|
||||||
cid = payload.get("collection_id")
|
cid = payload.get("collection_id")
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
conn.execute("DELETE FROM collections WHERE id=?", (cid,))
|
delete_collections(conn, [cid])
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return {"collection_id": cid, "server_version": None}
|
return {"collection_id": cid, "server_version": None}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,438 @@
|
|||||||
|
"""FlowDeck — My Tasks : sources de tâches et normalisation.
|
||||||
|
|
||||||
|
Une base ne diffuse ses tâches dans My Tasks qu'après **conversion
|
||||||
|
explicite** (``collections.is_task``) et le mapping explicite de ses trois
|
||||||
|
propriétés requises :
|
||||||
|
|
||||||
|
=========================== ==========================================
|
||||||
|
Rôle Colonne sur ``collections``
|
||||||
|
=========================== ==========================================
|
||||||
|
``assignee`` (Assigné à) ``task_assignee_prop``
|
||||||
|
``status`` (Statut) ``task_status_prop``
|
||||||
|
``due`` (Échéance) ``task_due_prop``
|
||||||
|
=========================== ==========================================
|
||||||
|
|
||||||
|
Ce module est la lecture unique de ce mapping : My Tasks, les filtres, les
|
||||||
|
vues Kanban/calendrier et l'ajout rapide s'appuient tous sur
|
||||||
|
``collect_tasks`` — donc une même tâche ne peut pas être rendue
|
||||||
|
différemment selon l'appelant.
|
||||||
|
|
||||||
|
Périmètre : **tous les workspaces de l'utilisateur** (mode Notion *Home*),
|
||||||
|
pas seulement le workspace actif — le tableau de bord est un poste de
|
||||||
|
travail transverse.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import unicodedata
|
||||||
|
from datetime import UTC, date, datetime, timedelta
|
||||||
|
|
||||||
|
from app.services.collection_lifecycle import (
|
||||||
|
effective_workspace_sql,
|
||||||
|
live_collection_ids,
|
||||||
|
live_row_ids,
|
||||||
|
user_workspace_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: Notion refuse au-delà de 10 bases connectées au widget My Tasks.
|
||||||
|
MAX_TASK_SOURCES = 10
|
||||||
|
|
||||||
|
#: Rôles requis, dans l'ordre d'affichage de la modale de conversion.
|
||||||
|
TASK_ROLES = ("assignee", "status", "due")
|
||||||
|
|
||||||
|
#: Types acceptés pour chaque rôle (une base « bien formée » est refusée
|
||||||
|
#: sinon : une colonne Date n'a pas de sens comme Assigné à).
|
||||||
|
ROLE_TYPES = {
|
||||||
|
"assignee": ("person",),
|
||||||
|
"status": ("status", "select"),
|
||||||
|
"due": ("date",),
|
||||||
|
}
|
||||||
|
|
||||||
|
ROLE_LABELS = {
|
||||||
|
"assignee": "Assigné à",
|
||||||
|
"status": "Statut",
|
||||||
|
"due": "Date d'échéance",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Premier mot d'un statut valant « terminé », une fois normalisé (minuscules,
|
||||||
|
#: sans accents). On compare des formes normalisées et non des libellés exacts :
|
||||||
|
#: « Terminée », « Terminé », « Complete » ou « Closed » doivent tous être
|
||||||
|
#: reconnus, quelle que soit la casse et l'accentuation.
|
||||||
|
#:
|
||||||
|
#: On matche le **premier mot** et non un simple préfixe : « Clôture »
|
||||||
|
#: commence comme « clos » mais n'est pas un statut terminé.
|
||||||
|
DONE_WORDS = frozenset({
|
||||||
|
# anglais
|
||||||
|
"done", "complete", "completed", "closed", "closed?", "terminated", "finished",
|
||||||
|
# français (masculin / féminin, singulier / pluriel)
|
||||||
|
"clos", "close", "termine", "terminee", "termines", "terminees",
|
||||||
|
"annule", "annulee", "annules", "annulees", "cancelled", "canceled",
|
||||||
|
"resolu", "resolue", "resolus", "resolues", "acheve", "achevee",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(value) -> str:
|
||||||
|
"""Minuscules, accents retirés, espaces compactés."""
|
||||||
|
text = unicodedata.normalize("NFKD", str(value or ""))
|
||||||
|
text = "".join(c for c in text if not unicodedata.combining(c))
|
||||||
|
return " ".join(text.lower().split())
|
||||||
|
|
||||||
|
|
||||||
|
def is_done(status: str) -> bool:
|
||||||
|
"""Le statut vaut-il « terminé » ?
|
||||||
|
|
||||||
|
Le premier mot suffit : « Terminée », « terminated », « Complete! » et
|
||||||
|
« done (closed) » sont reconnus ; « Clôture » ne l'est pas.
|
||||||
|
"""
|
||||||
|
norm = _normalize(status)
|
||||||
|
if not norm:
|
||||||
|
return False
|
||||||
|
first = norm.split()[0] if norm.split() else ""
|
||||||
|
return first.strip("!?.,:;()") in DONE_WORDS
|
||||||
|
|
||||||
|
|
||||||
|
def user_today(conn, user_id: int) -> date:
|
||||||
|
"""« Aujourd'hui » dans le fuseau de l'utilisateur.
|
||||||
|
|
||||||
|
Le projet stocke déjà ce fuseau (`users.timezone`, cf. migration v5.8.0) et
|
||||||
|
l'utilise déjà pour le calendrier des bases. My Tasks s'en sert aussi :
|
||||||
|
sans cela, une tâche due « aujourd'hui » bascule dans « demain » (ou
|
||||||
|
l'inverse) dès que l'utilisateur est loin d'UTC.
|
||||||
|
"""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT timezone FROM users WHERE id=?", (user_id,)
|
||||||
|
).fetchone()
|
||||||
|
tz_name = (row["timezone"] if row and "timezone" in row.keys() else "") or ""
|
||||||
|
if tz_name:
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
return datetime.now(ZoneInfo(tz_name)).date()
|
||||||
|
except Exception: # fuseau inconnu / base tz absente → UTC
|
||||||
|
logger.debug("timezone « %s » illisible, repli sur UTC", tz_name)
|
||||||
|
return datetime.now(UTC).date()
|
||||||
|
|
||||||
|
|
||||||
|
def task_source_columns() -> str:
|
||||||
|
"""Colonnes de ``collections`` lues par les requêtes de sources.
|
||||||
|
|
||||||
|
Les 3 colonnes de mapping n'existent que depuis la migration 30 : sur une
|
||||||
|
base antérieure elles vaudront NULL de toute façon.
|
||||||
|
|
||||||
|
Préfixées par ``c.`` car `list_task_sources` joint ``pages`` : sans
|
||||||
|
qualification, ``id`` est ambigu.
|
||||||
|
"""
|
||||||
|
return ("c.id, c.name, c.icon, c.workspace_id, c.is_task, "
|
||||||
|
"c.task_assignee_prop, c.task_status_prop, c.task_due_prop")
|
||||||
|
|
||||||
|
|
||||||
|
def list_task_sources(conn, user_id: int) -> list[dict]:
|
||||||
|
"""Bases de tâches configurées par l'utilisateur, tous workspaces
|
||||||
|
confondus. Renvoie aussi ``configured`` (les 3 rôles mappés) et
|
||||||
|
``missing_roles`` pour piloter l'invite de conversion.
|
||||||
|
|
||||||
|
Deux exclusions :
|
||||||
|
|
||||||
|
* une base dont la page hôte est à la corbeille (ou supprimée) est
|
||||||
|
**retirée** : sans ce filtre, une base portée par un document supprimé
|
||||||
|
continuerait d'alimenter My Tasks — le bug d'origine ;
|
||||||
|
* une base dont le workspace n'est pas accessible à l'utilisateur n'est
|
||||||
|
pas listée (une base sans workspace rattachable n'appartient à personne).
|
||||||
|
|
||||||
|
Le workspace retenu est l'**effectif** (`collections.workspace_id`, sinon
|
||||||
|
celui de la page hôte) : une base créée depuis un document a
|
||||||
|
``workspace_id`` NULL, et un ``JOIN workspaces`` la faisait disparaître de
|
||||||
|
My Tasks alors qu'elle venait d'être convertie.
|
||||||
|
"""
|
||||||
|
ws_ids = user_workspace_ids(conn, user_id)
|
||||||
|
if not ws_ids:
|
||||||
|
return []
|
||||||
|
qs = ",".join("?" * len(ws_ids))
|
||||||
|
ws_sql = effective_workspace_sql("c")
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""SELECT {task_source_columns()}, {ws_sql} AS ws_effective,
|
||||||
|
(SELECT w.name FROM workspaces w WHERE w.id = {ws_sql})
|
||||||
|
AS workspace_name
|
||||||
|
FROM collections c
|
||||||
|
WHERE c.is_task = 1 AND {ws_sql} IN ({qs})
|
||||||
|
ORDER BY c.name""",
|
||||||
|
[*ws_ids],
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# Un workspace peut apparaître plusieurs fois : on ne recalcule le
|
||||||
|
# jeu de bases vivantes qu'une fois par workspace.
|
||||||
|
live_by_ws: dict[int, set[int]] = {}
|
||||||
|
sources = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
ws = d.get("ws_effective")
|
||||||
|
if ws not in live_by_ws:
|
||||||
|
live_by_ws[ws] = set(live_collection_ids(conn, ws))
|
||||||
|
if d["id"] not in live_by_ws[ws]:
|
||||||
|
logger.debug("base %s exclue : sa page hôte n'est plus vivante", d["id"])
|
||||||
|
continue
|
||||||
|
|
||||||
|
missing = [role for role in TASK_ROLES if not d.get(f"task_{role}_prop")]
|
||||||
|
d["configured"] = not missing
|
||||||
|
d["missing_roles"] = missing
|
||||||
|
d["status_options"] = _status_options(conn, d.get("task_status_prop"))
|
||||||
|
sources.append(d)
|
||||||
|
return sources
|
||||||
|
|
||||||
|
|
||||||
|
def _status_options(conn, prop_id) -> list[str]:
|
||||||
|
"""Libellés du statut de la base source (colonnes du Kanban)."""
|
||||||
|
if not prop_id:
|
||||||
|
return []
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT options_json FROM collection_properties WHERE id=?", (prop_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
opts = json.loads(row["options_json"] or "[]")
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return []
|
||||||
|
return [o if isinstance(o, str) else (o or {}).get("name", "") for o in opts
|
||||||
|
if isinstance(o, str) or isinstance(o, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def source_state(conn, collection_id: int) -> dict:
|
||||||
|
"""État de conversion d'une base pour la modale « convertir en base de
|
||||||
|
tâches » : mapping courant + colonnes candidates pour chaque rôle."""
|
||||||
|
coll = conn.execute(
|
||||||
|
f"SELECT {task_source_columns()} FROM collections c WHERE c.id=?",
|
||||||
|
(collection_id,),
|
||||||
|
).fetchone()
|
||||||
|
if coll is None:
|
||||||
|
return {"exists": False}
|
||||||
|
|
||||||
|
d = dict(coll)
|
||||||
|
props = conn.execute(
|
||||||
|
"SELECT id, name, prop_type, options_json, position FROM collection_properties "
|
||||||
|
"WHERE collection_id=? ORDER BY position, id",
|
||||||
|
(collection_id,),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
missing = [role for role in TASK_ROLES if not d.get(f"task_{role}_prop")]
|
||||||
|
used = {d.get(f"task_{role}_prop") for role in TASK_ROLES if d.get(f"task_{role}_prop")}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"exists": True,
|
||||||
|
"collection_id": collection_id,
|
||||||
|
"name": d.get("name"),
|
||||||
|
"is_task": bool(d.get("is_task")),
|
||||||
|
"mapping": {role: d.get(f"task_{role}_prop") for role in TASK_ROLES},
|
||||||
|
"missing_roles": missing,
|
||||||
|
"configured": not missing,
|
||||||
|
# Par rôle : les colonnes compatibles (une colonne déjà affectée à un
|
||||||
|
# autre rôle est exclue — Notion n'autorise pas le double emploi).
|
||||||
|
"candidates": {
|
||||||
|
role: [
|
||||||
|
{"id": p["id"], "name": p["name"], "prop_type": p["prop_type"]}
|
||||||
|
for p in props
|
||||||
|
if p["prop_type"] in ROLE_TYPES[role] and p["id"] not in used
|
||||||
|
]
|
||||||
|
for role in TASK_ROLES
|
||||||
|
},
|
||||||
|
"all_properties": [
|
||||||
|
{"id": p["id"], "name": p["name"], "prop_type": p["prop_type"]}
|
||||||
|
for p in props
|
||||||
|
],
|
||||||
|
"suggested_names": {role: ROLE_LABELS[role] for role in TASK_ROLES},
|
||||||
|
"max_sources": MAX_TASK_SOURCES,
|
||||||
|
"sources_count": _sources_count(conn, d.get("workspace_id"), collection_id),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _sources_count(conn, workspace_id, exclude_id=None) -> int:
|
||||||
|
"""Nombre de bases déjà connectées (pour la limite de 10)."""
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM collections WHERE is_task = 1 AND workspace_id = ?",
|
||||||
|
(workspace_id,),
|
||||||
|
).fetchone()
|
||||||
|
n = row[0] if row else 0
|
||||||
|
if exclude_id is not None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT is_task FROM collections WHERE id=?", (exclude_id,)
|
||||||
|
).fetchone()
|
||||||
|
if row and row[0]:
|
||||||
|
n -= 1
|
||||||
|
return max(n, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_value(raw: dict, prop_id):
|
||||||
|
"""Valeur d'une propriété de ligne, indexée par ID (ou par nom, pour les
|
||||||
|
lignes historicisées écrites avant le passage aux clés indexées)."""
|
||||||
|
if prop_id is None:
|
||||||
|
return None
|
||||||
|
if prop_id in raw:
|
||||||
|
return raw[prop_id]
|
||||||
|
return raw.get(str(prop_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _person_values(value) -> list:
|
||||||
|
"""Aplatit une valeur `person` en liste d'éléments (dicts `person` ou
|
||||||
|
chaînes), sans perdre l'id — c'est lui qui identifie l'assigné."""
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return [value]
|
||||||
|
if isinstance(value, list):
|
||||||
|
out: list = []
|
||||||
|
for v in value:
|
||||||
|
out.extend(_person_values(v))
|
||||||
|
return out
|
||||||
|
if isinstance(value, str) and value.strip():
|
||||||
|
return [value]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _person_label(v) -> str:
|
||||||
|
if isinstance(v, dict):
|
||||||
|
return str(v.get("login") or v.get("full_name") or "")
|
||||||
|
return str(v)
|
||||||
|
|
||||||
|
|
||||||
|
def _assigned_to_me(values: list, user_id: int, login: str) -> bool:
|
||||||
|
"""L'assigné est-il l'utilisateur courant ?
|
||||||
|
|
||||||
|
Une valeur `person` porte `{id, login, full_name}` : on compare l'id en
|
||||||
|
priorité (stable), puis le login en repli pour les lignes historicisées
|
||||||
|
qui ne stockent qu'un nom.
|
||||||
|
"""
|
||||||
|
if not login:
|
||||||
|
return False
|
||||||
|
for v in values:
|
||||||
|
if isinstance(v, dict):
|
||||||
|
if v.get("id") is not None and str(v["id"]) == str(user_id):
|
||||||
|
return True
|
||||||
|
if _person_label(v).strip().lower() == login.strip().lower():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def collect_tasks(conn, user_id: int, *, mine_only: bool = True,
|
||||||
|
login: str = "") -> list[dict]:
|
||||||
|
"""Tâches normalisées de toutes les bases connectées.
|
||||||
|
|
||||||
|
Chaque entrée porte sa source (``collection_id``/``collection_name``) et
|
||||||
|
les identifiants des propriétés à écrire pour une édition en direct
|
||||||
|
(``status_prop``/``due_prop``/``assignee_prop``) : le front n'a plus à
|
||||||
|
deviner quoi écrire.
|
||||||
|
"""
|
||||||
|
tasks: list[dict] = []
|
||||||
|
today = user_today(conn, user_id)
|
||||||
|
|
||||||
|
for src in list_task_sources(conn, user_id):
|
||||||
|
if not src["configured"]:
|
||||||
|
# Base connectée mais mapping incomplet : elle n'alimente pas
|
||||||
|
# My Tasks (l'UI le signale), plutôt que d'inventer des colonnes.
|
||||||
|
continue
|
||||||
|
status_prop = src["task_status_prop"]
|
||||||
|
due_prop = src["task_due_prop"]
|
||||||
|
assignee_prop = src["task_assignee_prop"]
|
||||||
|
|
||||||
|
for row_id in live_row_ids(conn, src["id"]):
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, title, icon, created_at, property_values_json "
|
||||||
|
"FROM collection_pages WHERE id=?",
|
||||||
|
(row_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
raw = json.loads(row["property_values_json"] or "{}")
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
raw = {}
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raw = {}
|
||||||
|
|
||||||
|
persons = _person_values(_row_value(raw, assignee_prop))
|
||||||
|
assignees = [_person_label(p) for p in persons if _person_label(p)]
|
||||||
|
if mine_only and persons and not _assigned_to_me(persons, user_id, login):
|
||||||
|
continue
|
||||||
|
|
||||||
|
status = _row_value(raw, status_prop)
|
||||||
|
if isinstance(status, list):
|
||||||
|
status = status[0] if status else ""
|
||||||
|
status = str(status or "")
|
||||||
|
|
||||||
|
due_raw = _row_value(raw, due_prop)
|
||||||
|
due = str(due_raw or "")[:10]
|
||||||
|
|
||||||
|
due_state = ""
|
||||||
|
if due:
|
||||||
|
try:
|
||||||
|
d = date.fromisoformat(due)
|
||||||
|
except ValueError:
|
||||||
|
d = None
|
||||||
|
if d:
|
||||||
|
due_state = ("overdue" if d < today
|
||||||
|
else "today" if d == today
|
||||||
|
else "upcoming")
|
||||||
|
|
||||||
|
tasks.append({
|
||||||
|
"id": row["id"],
|
||||||
|
"title": row["title"] or "Untitled",
|
||||||
|
"icon": row["icon"] or "📄",
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"collection_id": src["id"],
|
||||||
|
"collection_name": src["name"],
|
||||||
|
"collection_icon": src["icon"] or "📋",
|
||||||
|
"workspace_id": src["workspace_id"],
|
||||||
|
"workspace_name": src["workspace_name"],
|
||||||
|
"assignees": assignees,
|
||||||
|
"status": status,
|
||||||
|
"status_options": src["status_options"],
|
||||||
|
"done": is_done(status),
|
||||||
|
"due": due,
|
||||||
|
"due_state": due_state,
|
||||||
|
"status_prop": status_prop,
|
||||||
|
"due_prop": due_prop,
|
||||||
|
"assignee_prop": assignee_prop,
|
||||||
|
})
|
||||||
|
|
||||||
|
return tasks
|
||||||
|
|
||||||
|
|
||||||
|
def filter_and_sort(tasks: list[dict], *, due: str = "all", hide_done: bool = False,
|
||||||
|
sort: str = "due", today: date | None = None) -> list[dict]:
|
||||||
|
"""Filtres et tri globaux de My Tasks (portés par l'URL).
|
||||||
|
|
||||||
|
``today`` est le jour de référence de l'utilisateur (fuseau compris) : il
|
||||||
|
doit être celui utilisé par `collect_tasks`, sinon « aujourd'hui » et
|
||||||
|
« cette semaine » ne cibleront pas le même jour que `due_state`.
|
||||||
|
"""
|
||||||
|
out = tasks
|
||||||
|
if hide_done:
|
||||||
|
out = [t for t in out if not t["done"]]
|
||||||
|
if due and due != "all":
|
||||||
|
if due == "today":
|
||||||
|
out = [t for t in out if t["due_state"] == "today"]
|
||||||
|
elif due == "overdue":
|
||||||
|
out = [t for t in out if t["due_state"] in ("today", "overdue")]
|
||||||
|
elif due == "week":
|
||||||
|
# « Cette semaine » est **borné** à J+7 : sans borne, une tâche
|
||||||
|
# datée dans six mois apparaîtrait sous « Cette semaine ».
|
||||||
|
ref = today or datetime.now(UTC).date()
|
||||||
|
limit = (ref + timedelta(days=7)).isoformat()
|
||||||
|
out = [t for t in out
|
||||||
|
if t["due"] and (t["due_state"] in ("today", "overdue")
|
||||||
|
or t["due"] <= limit)]
|
||||||
|
|
||||||
|
if sort == "status":
|
||||||
|
return sorted(out, key=lambda t: (t["done"], t["status"].lower(),
|
||||||
|
(t["due"] or "9999")))
|
||||||
|
if sort == "source":
|
||||||
|
return sorted(out, key=lambda t: (t["collection_name"].lower(),
|
||||||
|
(t["due"] or "9999"), t["title"].lower()))
|
||||||
|
if sort == "created":
|
||||||
|
return sorted(out, key=lambda t: str(t.get("created_at") or ""))
|
||||||
|
# "due" : les tâches datées d'abord (les plus urgentes en tête), puis
|
||||||
|
# les tâches sans date — comme une liste de rappels.
|
||||||
|
return sorted(out, key=lambda t: ((t["due"] or "9999-12-31"),
|
||||||
|
t["done"], t["title"].lower()))
|
||||||
@@ -15,12 +15,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC
|
from datetime import UTC
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
|
from app.services.collection_lifecycle import delete_collections
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -785,7 +787,7 @@ class DeleteCollection(Tool):
|
|||||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
|
coll = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
|
||||||
if not coll:
|
if not coll:
|
||||||
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
|
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
|
||||||
conn.execute("DELETE FROM collections WHERE id=?", (cid,))
|
delete_collections(conn, [cid])
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return ToolResult(
|
return ToolResult(
|
||||||
status="success", tool=self.name, target_type="collection", target_id=cid,
|
status="success", tool=self.name, target_type="collection", target_id=cid,
|
||||||
@@ -877,8 +879,272 @@ class CreateGiteaIssue(Tool):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════════ Web tools ══════════════════════════
|
||||||
|
# Outils réseau : recherche web, lecture d'une page, recherche GitHub.
|
||||||
|
# Ils réutilisent les garde-fous SSRF déjà éprouvés par l'importeur URL
|
||||||
|
# (``importers.url_fetch`` / ``og_fetcher``) — aucune adresse privée,
|
||||||
|
# re-vérifiée à chaque saut de redirection.
|
||||||
|
|
||||||
|
|
||||||
|
class WebSearch(Tool):
|
||||||
|
name = "web_search"
|
||||||
|
description = (
|
||||||
|
"Recherche sur le web (actualités, docs, libs, comparatifs) et "
|
||||||
|
"renvoie titre + URL + extrait pour chaque résultat. Utilise-la AVANT de "
|
||||||
|
"répondre sur un fait récent ou technique, puis CITE les URL obtenues."
|
||||||
|
)
|
||||||
|
parameters = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string", "description": "requête de recherche"},
|
||||||
|
"num_results": {"type": "integer",
|
||||||
|
"description": "nombre de résultats (1-10, défaut 5)"},
|
||||||
|
},
|
||||||
|
"required": ["query"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||||
|
from app.services.web_search import available_providers, search_web
|
||||||
|
|
||||||
|
query = str(args.get("query") or "").strip()
|
||||||
|
if not query:
|
||||||
|
return ToolResult(status="error", tool=self.name, message="query vide")
|
||||||
|
try:
|
||||||
|
num = max(1, min(int(args.get("num_results") or 5), 10))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
num = 5
|
||||||
|
try:
|
||||||
|
results, provider = await search_web(query, num)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return ToolResult(status="error", tool=self.name,
|
||||||
|
message=f"Recherche web indisponible: {exc}")
|
||||||
|
if not results:
|
||||||
|
hint = ""
|
||||||
|
if not available_providers().get(provider, True):
|
||||||
|
hint = " — configurez EXA_API_KEY dans les réglages FlowDeck"
|
||||||
|
return ToolResult(status="error", tool=self.name,
|
||||||
|
message=f"Aucun résultat pour « {query} » (provider: {provider}){hint}")
|
||||||
|
return ToolResult(
|
||||||
|
status="success", tool=self.name, target_type="web_search",
|
||||||
|
data={"query": query, "provider": provider, "results": results,
|
||||||
|
"count": len(results)},
|
||||||
|
message=f"{len(results)} résultat(s) via {provider}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FetchUrl(Tool):
|
||||||
|
name = "fetch_url"
|
||||||
|
description = (
|
||||||
|
"Télécharge une page web et la convertit en Markdown (titres, listes, "
|
||||||
|
"code, tableaux) pour pouvoir la lire et la citer. Utilise-la après "
|
||||||
|
"web_search pour approfondir un résultat. Les URLs internes / privées "
|
||||||
|
"sont refusées."
|
||||||
|
)
|
||||||
|
parameters = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"url": {"type": "string", "description": "URL http(s) de la page"},
|
||||||
|
"max_chars": {"type": "integer",
|
||||||
|
"description": "longueur max du markdown renvoyé (défaut 20000)"},
|
||||||
|
},
|
||||||
|
"required": ["url"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||||
|
from app.config import settings
|
||||||
|
from app.services.http_client import shared_client
|
||||||
|
from app.services.importers.html_notes import _html_to_markdown
|
||||||
|
from app.services.importers.url_fetch import _validate_url
|
||||||
|
from app.services.og_fetcher import parse_og
|
||||||
|
|
||||||
|
raw = str(args.get("url") or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return ToolResult(status="error", tool=self.name, message="url vide")
|
||||||
|
# « example.com/page » → https://example.com/page, mais un schéma
|
||||||
|
# explicite (file:, ftp:, …) est conservé pour être REJETÉ par le
|
||||||
|
# garde-fou, au lieu d'être transformé en requête réseau hasardeuse.
|
||||||
|
if not re.match(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://", raw):
|
||||||
|
raw = "https://" + raw
|
||||||
|
try:
|
||||||
|
url = _validate_url(raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
return ToolResult(status="error", tool=self.name, message=str(exc))
|
||||||
|
|
||||||
|
try:
|
||||||
|
max_chars = max(500, min(int(args.get("max_chars")
|
||||||
|
or settings.web_fetch_max_chars), 60000))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
max_chars = int(settings.web_fetch_max_chars or 20000)
|
||||||
|
|
||||||
|
current = url
|
||||||
|
resp = None
|
||||||
|
try:
|
||||||
|
async with shared_client(timeout=15.0, follow_redirects=False,
|
||||||
|
headers={"User-Agent": "FlowDeck-Agent/1.0 (fetch_url)"}) as client:
|
||||||
|
for _ in range(6):
|
||||||
|
resp = await client.get(current)
|
||||||
|
if resp.status_code in (301, 302, 303, 307, 308):
|
||||||
|
loc = resp.headers.get("location")
|
||||||
|
if not loc:
|
||||||
|
raise ValueError("redirection sans cible")
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
current = urljoin(current, loc)
|
||||||
|
# re-validation : une URL publique peut rediriger vers 169.254.x
|
||||||
|
current = _validate_url(current)
|
||||||
|
continue
|
||||||
|
resp.raise_for_status()
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise ValueError("trop de redirections")
|
||||||
|
except ValueError as exc:
|
||||||
|
return ToolResult(status="error", tool=self.name, message=str(exc))
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return ToolResult(status="error", tool=self.name,
|
||||||
|
message=f"Page injoignable: {exc}")
|
||||||
|
if resp is None:
|
||||||
|
return ToolResult(status="error", tool=self.name, message="Aucune réponse")
|
||||||
|
|
||||||
|
ctype = (resp.headers.get("content-type") or "").lower()
|
||||||
|
body = resp.text
|
||||||
|
if "html" not in ctype and "xhtml" not in ctype:
|
||||||
|
# JSON / texte brut : on le renvoie tel quel, c'est souvent une API.
|
||||||
|
markdown = body
|
||||||
|
meta: dict = {}
|
||||||
|
else:
|
||||||
|
meta = parse_og(body, current)
|
||||||
|
markdown = _html_to_markdown(body)
|
||||||
|
|
||||||
|
truncated = len(markdown) > max_chars
|
||||||
|
if truncated:
|
||||||
|
markdown = markdown[:max_chars] + "\n\n[contenu tronqué]"
|
||||||
|
|
||||||
|
title = (meta.get("title") or "").strip() or current
|
||||||
|
return ToolResult(
|
||||||
|
status="success", tool=self.name, target_type="web_page", target_id=current,
|
||||||
|
data={"url": current, "title": title[:200], "markdown": markdown,
|
||||||
|
"truncated": truncated, "chars": len(markdown)},
|
||||||
|
message=f"Page lue : {title[:80]} ({len(markdown)} car.)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchCode(Tool):
|
||||||
|
name = "search_code"
|
||||||
|
description = (
|
||||||
|
"Recherche GitHub : dépôts, code source ou issues (uniquement GitHub "
|
||||||
|
"public, sans clé possible mais limitée à 10 requêtes/min). Sert à "
|
||||||
|
"trouver une implémentation de référence, une lib ou l'historique d'un bug."
|
||||||
|
)
|
||||||
|
parameters = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string", "description": "termes GitHub (lang:python, repo:, etc.)"},
|
||||||
|
"kind": {"type": "string",
|
||||||
|
"description": "repositories | code | issues (défaut repositories)"},
|
||||||
|
"limit": {"type": "integer", "description": "nombre de résultats (1-10, défaut 5)"},
|
||||||
|
},
|
||||||
|
"required": ["query"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||||
|
from app.services.web_search import search_github
|
||||||
|
|
||||||
|
query = str(args.get("query") or "").strip()
|
||||||
|
if not query:
|
||||||
|
return ToolResult(status="error", tool=self.name, message="query vide")
|
||||||
|
kind = str(args.get("kind") or "repositories")
|
||||||
|
try:
|
||||||
|
limit = max(1, min(int(args.get("limit") or 5), 10))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
limit = 5
|
||||||
|
try:
|
||||||
|
results = await search_github(query, kind, limit)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return ToolResult(status="error", tool=self.name,
|
||||||
|
message=f"Recherche GitHub impossible: {exc}")
|
||||||
|
return ToolResult(
|
||||||
|
status="success", tool=self.name, target_type="github",
|
||||||
|
target_id=f"{kind}:{query}",
|
||||||
|
data={"query": query, "kind": kind, "results": results, "count": len(results)},
|
||||||
|
message=f"{len(results)} résultat(s) GitHub ({kind})",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════ Registry ══════════════════════════
|
# ══════════════════════════ Registry ══════════════════════════
|
||||||
|
|
||||||
|
class ConnectorFetch(Tool):
|
||||||
|
name = "connector_fetch"
|
||||||
|
description = (
|
||||||
|
"Lit un connecteur configuré dans FlowDeck : API Gitea / GitHub (natives), "
|
||||||
|
"recherche web (natif « web »), ou un connecteur personnalisé (URL + clé). "
|
||||||
|
"`path` est relatif à l'URL du connecteur (ex. « /api/v1/repos ») ; "
|
||||||
|
"`query` sert au connecteur web. Les hôtes privés sont refusés."
|
||||||
|
)
|
||||||
|
parameters = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"connector": {"type": "string",
|
||||||
|
"description": "id, nom ou kind (gitea / github / web)"},
|
||||||
|
"path": {"type": "string", "description": "chemin relatif (API natives)"},
|
||||||
|
"query": {"type": "string", "description": "terme de recherche (web)"},
|
||||||
|
},
|
||||||
|
"required": ["connector"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||||
|
from app.services import connectors
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = await connectors.connector_fetch(
|
||||||
|
str(args.get("connector") or ""),
|
||||||
|
str(args.get("path") or ""),
|
||||||
|
str(args.get("query") or ""),
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
return ToolResult(status="error", tool=self.name, message=str(exc))
|
||||||
|
except Exception as exc: # noqa: BLE001 — réseau : erreur outil, pas run
|
||||||
|
logger.warning("connector_fetch failed: %s", exc)
|
||||||
|
return ToolResult(status="error", tool=self.name,
|
||||||
|
message=f"Connecteur indisponible: {exc}")
|
||||||
|
if res.get("status") != "ok":
|
||||||
|
return ToolResult(status="error", tool=self.name,
|
||||||
|
message=(res.get("text") or "erreur")[:500])
|
||||||
|
text = res.get("text") or ""
|
||||||
|
return ToolResult(status="success", tool=self.name, target_type="connector",
|
||||||
|
message=text[:800], data={"text": text})
|
||||||
|
|
||||||
|
|
||||||
|
class McpTool(Tool):
|
||||||
|
"""Outil MCP dynamique — instance construite à la volée depuis le cache
|
||||||
|
en base (`mcp_client.cached_tools()`), pas enregistrée dans TOOL_CLASSES."""
|
||||||
|
|
||||||
|
def __init__(self, entry: dict):
|
||||||
|
self.name = entry["name"]
|
||||||
|
self.description = entry.get("description") or "Outil MCP"
|
||||||
|
self.parameters = entry.get("parameters") or {"type": "object", "properties": {}}
|
||||||
|
self.entry = entry
|
||||||
|
|
||||||
|
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||||
|
from app.services import mcp_client
|
||||||
|
|
||||||
|
try:
|
||||||
|
res = await mcp_client.call_tool(self.entry["connector_id"],
|
||||||
|
self.entry.get("original") or "", args)
|
||||||
|
except ValueError as exc:
|
||||||
|
return ToolResult(status="error", tool=self.name, message=str(exc))
|
||||||
|
except Exception as exc: # noqa: BLE001 — réseau : erreur outil, pas run
|
||||||
|
logger.warning("MCP tool failed (%s): %s", self.name, exc)
|
||||||
|
return ToolResult(status="error", tool=self.name,
|
||||||
|
message=f"MCP indisponible: {exc}")
|
||||||
|
if res.get("status") != "ok":
|
||||||
|
return ToolResult(status="error", tool=self.name,
|
||||||
|
message=(res.get("text") or "erreur MCP")[:500])
|
||||||
|
text = res.get("text") or ""
|
||||||
|
return ToolResult(status="success", tool=self.name, target_type="mcp",
|
||||||
|
message=text[:800], data={"text": text})
|
||||||
|
|
||||||
|
|
||||||
TOOL_CLASSES = [
|
TOOL_CLASSES = [
|
||||||
SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument,
|
SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument,
|
||||||
CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument,
|
CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument,
|
||||||
@@ -887,6 +1153,7 @@ TOOL_CLASSES = [
|
|||||||
DeletePage, DeleteCollection,
|
DeletePage, DeleteCollection,
|
||||||
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
|
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
|
||||||
DeleteDocument,
|
DeleteDocument,
|
||||||
|
WebSearch, FetchUrl, SearchCode, ConnectorFetch,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -896,24 +1163,47 @@ class ToolRegistry:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.tools: dict[str, Tool] = {t.name: t() for t in TOOL_CLASSES}
|
self.tools: dict[str, Tool] = {t.name: t() for t in TOOL_CLASSES}
|
||||||
|
|
||||||
|
def _all(self) -> dict[str, Tool]:
|
||||||
|
"""Outils statiques + **outils MCP dynamiques** (cache en base).
|
||||||
|
|
||||||
|
Les MCP ne sont jamais mis en cache en mémoire : un run re-lit la base,
|
||||||
|
donc « Tester le connecteur » suffit à rendre un outil disponible.
|
||||||
|
"""
|
||||||
|
tools = dict(self.tools)
|
||||||
|
try:
|
||||||
|
from app.services import mcp_client
|
||||||
|
from app.services import plugins as plugins_service
|
||||||
|
for entry in mcp_client.cached_tools():
|
||||||
|
tools[entry["name"]] = McpTool(entry)
|
||||||
|
# plugin « web-tools » OFF → outils web retirés du schéma ET de execute()
|
||||||
|
for slug, names in plugins_service.PLUGIN_TOOLS.items():
|
||||||
|
if not plugins_service.is_enabled(slug):
|
||||||
|
for name in names:
|
||||||
|
tools.pop(name, None)
|
||||||
|
except Exception: # noqa: BLE001 — la base ne doit jamais casser un run
|
||||||
|
logger.exception("dynamic tools load failed")
|
||||||
|
return tools
|
||||||
|
|
||||||
def list(self, scope: dict | None = None) -> list[str]:
|
def list(self, scope: dict | None = None) -> list[str]:
|
||||||
"""Tool names allowed by an agent scope (default: all)."""
|
"""Tool names allowed by an agent scope (default: all)."""
|
||||||
|
tools = self._all()
|
||||||
allowed = (scope or {}).get("tools")
|
allowed = (scope or {}).get("tools")
|
||||||
if allowed is None:
|
if allowed is None:
|
||||||
return list(self.tools.keys())
|
return list(tools.keys())
|
||||||
return [n for n in self.tools if n in allowed]
|
return [n for n in tools if n in allowed]
|
||||||
|
|
||||||
def schema(self, scope: dict | None = None) -> list[dict]:
|
def schema(self, scope: dict | None = None) -> list[dict]:
|
||||||
"""Function-calling schema for the LLM, filtered by scope."""
|
"""Function-calling schema for the LLM, filtered by scope."""
|
||||||
|
tools = self._all()
|
||||||
return [
|
return [
|
||||||
{"name": self.tools[n].name,
|
{"name": tools[n].name,
|
||||||
"description": self.tools[n].description,
|
"description": tools[n].description,
|
||||||
"parameters": self.tools[n].parameters}
|
"parameters": tools[n].parameters}
|
||||||
for n in self.list(scope)
|
for n in self.list(scope)
|
||||||
]
|
]
|
||||||
|
|
||||||
async def execute(self, tool: str, args: dict, *, user_id: int | None = None) -> ToolResult:
|
async def execute(self, tool: str, args: dict, *, user_id: int | None = None) -> ToolResult:
|
||||||
impl = self.tools.get(tool)
|
impl = self._all().get(tool)
|
||||||
if not impl:
|
if not impl:
|
||||||
return ToolResult(status="error", tool=tool, message=f"Outil inconnu: {tool}")
|
return ToolResult(status="error", tool=tool, message=f"Outil inconnu: {tool}")
|
||||||
return await impl.execute(args, user_id=user_id)
|
return await impl.execute(args, user_id=user_id)
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ import re
|
|||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
|
from app.services.collection_lifecycle import (
|
||||||
|
collections_hosted_by_page,
|
||||||
|
delete_collections,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -57,6 +61,9 @@ def purge_expired(days: int = 30) -> dict:
|
|||||||
if dt is None or dt >= cutoff:
|
if dt is None or dt >= cutoff:
|
||||||
continue
|
continue
|
||||||
page_id = row["id"]
|
page_id = row["id"]
|
||||||
|
# A purged host page takes its database with it, otherwise the
|
||||||
|
# workspace kept advertising tasks whose document is gone.
|
||||||
|
collections = collections_hosted_by_page(conn, page_id)
|
||||||
# Promote direct children to the deleted page's parent so no live
|
# Promote direct children to the deleted page's parent so no live
|
||||||
# sub-tree is orphaned (matches the permanent-delete semantics).
|
# sub-tree is orphaned (matches the permanent-delete semantics).
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -65,6 +72,7 @@ def purge_expired(days: int = 30) -> dict:
|
|||||||
(page_id, page_id),
|
(page_id, page_id),
|
||||||
)
|
)
|
||||||
conn.execute("DELETE FROM pages WHERE id=?", (page_id,))
|
conn.execute("DELETE FROM pages WHERE id=?", (page_id,))
|
||||||
|
delete_collections(conn, collections)
|
||||||
purged.append(page_id)
|
purged.append(page_id)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
if purged:
|
if purged:
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"""FlowDeck — Web search + GitHub search pour les tools agent (v7.46.0).
|
||||||
|
|
||||||
|
Alimente les deux tools réseau qui manquaient au registre : la recherche
|
||||||
|
**en ligne** (`web_search`) et la recherche **GitHub** (`search_code`).
|
||||||
|
|
||||||
|
Choix de conception :
|
||||||
|
|
||||||
|
* **Provider configurable** (``settings.web_search_provider``) : Exa
|
||||||
|
(recherche sémantique + snippets, clé requise) en priorité, DuckDuckGo en
|
||||||
|
repli sans compte. Le repli parse du HTML — c'est volontairement *dégradé* :
|
||||||
|
il n'est jamais utilisé si une clé Exa est configurée, et sert surtout à
|
||||||
|
garder le skill utilisable sur une instance sans compte externe.
|
||||||
|
* **Aucune dépendance ajoutée** : httpx déjà présent, ``shared_client`` pour le
|
||||||
|
pool de connexions (A42), et les garde-fous SSRF déjà éprouvés par
|
||||||
|
``app.services.importers.url_fetch`` / ``og_fetcher``.
|
||||||
|
* **Toujours des sources** : chaque résultat porte titre + URL + extrait, pour
|
||||||
|
que l'IA puisse citer au lieu d'inventer.
|
||||||
|
|
||||||
|
La couche réseau est injectable (``transport``) pour que les tests n'aient
|
||||||
|
jamais besoin du réseau.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import html as htmlmod
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.services.http_client import shared_client
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
USER_AGENT = "FlowDeck-Agent/1.0 (+web_search)"
|
||||||
|
|
||||||
|
#: Domaines de repli interdits : réponses « sous conditions » / anti-bot qui
|
||||||
|
#: pollueraient les résultats (DuckDuckGo sert parfois une page de challenge).
|
||||||
|
_BLOCKED_RESULT_HOSTS = frozenset({
|
||||||
|
"duckduckgo.com", "lite.duckduckgo.com", "duckduckgo.com.yahoo.net",
|
||||||
|
})
|
||||||
|
|
||||||
|
EXA_ENDPOINT = "https://api.exa.ai/search"
|
||||||
|
DDG_ENDPOINT = "https://lite.duckduckgo.com/lite/"
|
||||||
|
GITHUB_API = "https://api.github.com"
|
||||||
|
|
||||||
|
MAX_RESULTS = 10
|
||||||
|
_SNIPPET_CHARS = 300
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(text: str | None) -> str:
|
||||||
|
return re.sub(r"\s+", " ", (text or "")).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _clip(text: str, limit: int = _SNIPPET_CHARS) -> str:
|
||||||
|
text = _clean(text)
|
||||||
|
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_resultable(url: str) -> bool:
|
||||||
|
"""Ignore les redirections vers le moteur lui-même et les URLs non http."""
|
||||||
|
parsed = urlparse(url or "")
|
||||||
|
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||||
|
return False
|
||||||
|
return parsed.hostname.lower() not in _BLOCKED_RESULT_HOSTS
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════ Exa ══════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_exa(query: str, num_results: int, transport=None) -> list[dict]:
|
||||||
|
key = (settings.exa_api_key or "").strip()
|
||||||
|
if not key:
|
||||||
|
return []
|
||||||
|
payload = {
|
||||||
|
"query": query,
|
||||||
|
"numResults": max(1, min(num_results, MAX_RESULTS)),
|
||||||
|
"contents": {"text": {"maxCharacters": 1200}},
|
||||||
|
}
|
||||||
|
kwargs: dict[str, Any] = {"timeout": 15.0}
|
||||||
|
if transport is not None:
|
||||||
|
kwargs["transport"] = transport
|
||||||
|
async with shared_client(**kwargs) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
EXA_ENDPOINT,
|
||||||
|
json=payload,
|
||||||
|
headers={"x-api-key": key, "Content-Type": "application/json",
|
||||||
|
"User-Agent": USER_AGENT},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise RuntimeError(f"Exa HTTP {resp.status_code}")
|
||||||
|
results = []
|
||||||
|
for item in (resp.json() or {}).get("results", []):
|
||||||
|
url = item.get("url") or ""
|
||||||
|
if not _is_resultable(url):
|
||||||
|
continue
|
||||||
|
results.append({
|
||||||
|
"title": _clip(item.get("title") or url, 160),
|
||||||
|
"url": url,
|
||||||
|
"snippet": _clip(item.get("text") or item.get("summary") or ""),
|
||||||
|
"published": (item.get("publishedDate") or "")[:10],
|
||||||
|
"source": "exa",
|
||||||
|
})
|
||||||
|
return results[:MAX_RESULTS]
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════ DuckDuckGo (repli sans compte) ══════════════════════
|
||||||
|
|
||||||
|
_DDG_ROW_RE = re.compile(
|
||||||
|
r"<a[^>]+class=\"[^\"]*result-link[^\"]*\"[^>]+href=\"(?P<href>[^\"]+)\"[^>]*>(?P<title>.*?)</a>",
|
||||||
|
re.I | re.S,
|
||||||
|
)
|
||||||
|
_DDG_SNIPPET_RE = re.compile(
|
||||||
|
r"class=\"[^\"]*result-snippet[^\"]*\"[^>]*>(?P<snippet>.*?)</td>", re.I | re.S
|
||||||
|
)
|
||||||
|
_TAG_RE = re.compile(r"<[^>]+>")
|
||||||
|
|
||||||
|
|
||||||
|
def _unwrap_ddg(href: str) -> str:
|
||||||
|
"""DuckDuckGo emballe les résultats dans ``/l/?uddg=<url encodé>``."""
|
||||||
|
if not href:
|
||||||
|
return ""
|
||||||
|
parsed = urlparse(htmlmod.unescape(href))
|
||||||
|
if "uddg" in (parsed.query or ""):
|
||||||
|
values = parse_qs(parsed.query).get("uddg")
|
||||||
|
if values:
|
||||||
|
return values[0]
|
||||||
|
return href
|
||||||
|
|
||||||
|
|
||||||
|
async def _search_duckduckgo(query: str, num_results: int, transport=None) -> list[dict]:
|
||||||
|
kwargs: dict[str, Any] = {"timeout": 12.0}
|
||||||
|
if transport is not None:
|
||||||
|
kwargs["transport"] = transport
|
||||||
|
async with shared_client(**kwargs) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
DDG_ENDPOINT,
|
||||||
|
data={"q": query, "kl": "wt-wt"},
|
||||||
|
headers={"User-Agent": USER_AGENT,
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise RuntimeError(f"DuckDuckGo HTTP {resp.status_code}")
|
||||||
|
body = resp.text
|
||||||
|
links = list(_DDG_ROW_RE.finditer(body))
|
||||||
|
snippets = [_clean(_TAG_RE.sub("", m.group("snippet"))) for m in _DDG_SNIPPET_RE.finditer(body)]
|
||||||
|
results = []
|
||||||
|
for idx, match in enumerate(links):
|
||||||
|
url = _unwrap_ddg(match.group("href"))
|
||||||
|
if not _is_resultable(url):
|
||||||
|
continue
|
||||||
|
results.append({
|
||||||
|
"title": _clean(_TAG_RE.sub("", match.group("title"))),
|
||||||
|
"url": url,
|
||||||
|
"snippet": _clip(snippets[idx]) if idx < len(snippets) else "",
|
||||||
|
"published": "",
|
||||||
|
"source": "duckduckgo",
|
||||||
|
})
|
||||||
|
if len(results) >= min(num_results, MAX_RESULTS):
|
||||||
|
break
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════ API publique ══════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
async def search_web(query: str, num_results: int = 5,
|
||||||
|
transport=None) -> tuple[list[dict], str]:
|
||||||
|
"""Recherche web → ``(results, provider)``.
|
||||||
|
|
||||||
|
Essaie le provider configuré puis l'autre ; si aucun ne répond, renvoie
|
||||||
|
une liste vide — c'est au tool de traduire ça en message actionnable pour
|
||||||
|
le modèle (« configurez EXA_API_KEY ») plutôt qu'en erreur réseau opaque.
|
||||||
|
"""
|
||||||
|
q = (query or "").strip()
|
||||||
|
if not q:
|
||||||
|
return [], ""
|
||||||
|
|
||||||
|
provider = (settings.web_search_provider or "exa").strip().lower()
|
||||||
|
order = ("exa", "duckduckgo") if provider == "exa" else ("duckduckgo", "exa")
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
for name in order:
|
||||||
|
try:
|
||||||
|
if name == "exa":
|
||||||
|
results = await _search_exa(q, num_results, transport)
|
||||||
|
else:
|
||||||
|
results = await _search_duckduckgo(q, num_results, transport)
|
||||||
|
except Exception as exc: # noqa: BLE001 — un provider qui tombe ne doit pas tuer l'autre
|
||||||
|
logger.info("web_search provider %s failed: %s", name, exc)
|
||||||
|
errors.append(f"{name}: {exc}")
|
||||||
|
continue
|
||||||
|
if results:
|
||||||
|
return results, name
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
logger.warning("web_search sans résultat — %s", "; ".join(errors))
|
||||||
|
return [], order[0]
|
||||||
|
return [], order[0]
|
||||||
|
|
||||||
|
|
||||||
|
def available_providers() -> dict[str, bool]:
|
||||||
|
"""Quel provider est réellement utilisable (affiché dans les settings)."""
|
||||||
|
return {
|
||||||
|
"exa": bool((settings.exa_api_key or "").strip()),
|
||||||
|
"duckduckgo": True,
|
||||||
|
"github": True, # sans PAT : 10 req/min suffisent pour un usage agent
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ══════════════════════ GitHub ══════════════════════
|
||||||
|
|
||||||
|
_GH_KINDS = {
|
||||||
|
"repositories": "repos",
|
||||||
|
"repos": "repos",
|
||||||
|
"code": "code",
|
||||||
|
"issues": "issues",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _gh_headers() -> dict[str, str]:
|
||||||
|
headers = {"Accept": "application/vnd.github+json",
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28"}
|
||||||
|
token = (settings.github_token or "").strip()
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
def _gh_item_name(item: dict, kind: str) -> str:
|
||||||
|
if kind == "repos":
|
||||||
|
return item.get("full_name") or item.get("name") or ""
|
||||||
|
if kind == "code":
|
||||||
|
return item.get("path") or ""
|
||||||
|
return item.get("title") or f"#{item.get('number')}"
|
||||||
|
|
||||||
|
|
||||||
|
def _gh_item_url(item: dict, kind: str) -> str:
|
||||||
|
if kind == "code":
|
||||||
|
# L'API code search ne renvoie pas d'URL exploitable : on reconstruit.
|
||||||
|
repo = (item.get("repository") or {}).get("full_name", "")
|
||||||
|
return f"https://github.com/{repo}/blob/HEAD/{item.get('path', '')}" if repo else ""
|
||||||
|
return item.get("html_url") or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _gh_item_snippet(item: dict, kind: str) -> str:
|
||||||
|
if kind == "code":
|
||||||
|
return ""
|
||||||
|
if kind == "repos":
|
||||||
|
return _clip(item.get("description") or "")
|
||||||
|
body = _clean(item.get("body") or "")
|
||||||
|
return _clip(body) if body else _clip(item.get("title") or "")
|
||||||
|
|
||||||
|
|
||||||
|
async def search_github(query: str, kind: str = "repositories", limit: int = 5,
|
||||||
|
transport=None) -> list[dict]:
|
||||||
|
"""Recherche GitHub (repos | code | issues) → liste de résultats sourcés.
|
||||||
|
|
||||||
|
Fonctionne sans PAT (limite 10 req/min) ; avec ``GITHUB_TOKEN``, 30 req/min.
|
||||||
|
"""
|
||||||
|
q = (query or "").strip()
|
||||||
|
if not q:
|
||||||
|
return []
|
||||||
|
k = _GH_KINDS.get((kind or "").strip().lower(), "repos")
|
||||||
|
per_page = max(1, min(int(limit or 5), MAX_RESULTS))
|
||||||
|
|
||||||
|
kwargs: dict[str, Any] = {"timeout": 15.0}
|
||||||
|
if transport is not None:
|
||||||
|
kwargs["transport"] = transport
|
||||||
|
async with shared_client(**kwargs) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{GITHUB_API}/search/{k}",
|
||||||
|
params={"q": q, "per_page": per_page},
|
||||||
|
headers=_gh_headers(),
|
||||||
|
)
|
||||||
|
if resp.status_code == 403:
|
||||||
|
raise RuntimeError("GitHub: quota dépassé (403) — configurez GITHUB_TOKEN")
|
||||||
|
if resp.status_code == 422:
|
||||||
|
raise RuntimeError("GitHub: requête de recherche invalide (422)")
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise RuntimeError(f"GitHub HTTP {resp.status_code}")
|
||||||
|
|
||||||
|
items = (resp.json() or {}).get("items", [])
|
||||||
|
results = []
|
||||||
|
for item in items[:per_page]:
|
||||||
|
results.append({
|
||||||
|
"title": _gh_item_name(item, k),
|
||||||
|
"url": _gh_item_url(item, k),
|
||||||
|
"snippet": _gh_item_snippet(item, k),
|
||||||
|
"stars": item.get("stargazers_count") if k == "repos" else None,
|
||||||
|
"state": item.get("state") if k == "issues" else "",
|
||||||
|
"source": f"github/{k}",
|
||||||
|
})
|
||||||
|
return results
|
||||||
@@ -163,9 +163,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:4px;padding:0 4px;">
|
<div style="display:flex;gap:4px;padding:0 4px;">
|
||||||
<input class="ctx-input" x-ref="ctxTagInput" placeholder="Tag name…" @click.stop
|
<input class="ctx-input" x-ref="ctxTagInput" placeholder="Tag name…" @click.stop
|
||||||
@keydown.enter.prevent="$store.fdCtx.addNewTag($event.target.value, $store.fdCtx.newTagColor); $event.target.value = ''"
|
@keydown.enter.prevent="$store.fdCtx.addTagAndClear($event.target.value, $store.fdCtx.newTagColor, $event.target)"
|
||||||
@keydown.escape.stop="$store.fdCtx.tagAdding = false">
|
@keydown.escape.stop="$store.fdCtx.tagAdding = false">
|
||||||
<button type="button" class="ctx-btn" @click.stop="$store.fdCtx.addNewTag($refs.ctxTagInput.value, $store.fdCtx.newTagColor); $refs.ctxTagInput.value = ''">Add</button>
|
<button type="button" class="ctx-btn" @click.stop="$store.fdCtx.addTagAndClear($refs.ctxTagInput.value, $store.fdCtx.newTagColor, $refs.ctxTagInput)">Add</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -125,13 +125,7 @@
|
|||||||
<div class="db-col-menu-item" id="db-col-insert-right"><span>→┤ Insert right</span></div>
|
<div class="db-col-menu-item" id="db-col-insert-right"><span>→┤ Insert right</span></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Side Peek Panel for Database Pages -->
|
<!-- Le panneau de peek (#db-side-peek) est créé à la demande par
|
||||||
<div id="db-side-peek" class="db-side-peek" style="display:none">
|
database_table.js (ensureDbPeek) : une seule source de markup pour
|
||||||
<div class="db-side-peek-resize" id="db-side-peek-resize"></div>
|
la page de base et les bases embarquées dans un document. -->
|
||||||
<div class="db-side-peek-header">
|
|
||||||
<button id="db-side-peek-close" class="db-side-peek-close">×</button>
|
|
||||||
<button id="db-side-peek-full" class="db-side-peek-full" title="Open full page">↗</button>
|
|
||||||
</div>
|
|
||||||
<iframe id="db-side-peek-iframe" src="" style="width:100%;height:100%;border:none"></iframe>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -107,4 +107,6 @@
|
|||||||
.db-list-cell{color:var(--text-secondary);font-size:12px;min-width:110px}
|
.db-list-cell{color:var(--text-secondary);font-size:12px;min-width:110px}
|
||||||
</style>
|
</style>
|
||||||
<script type="application/json" id="db-config" nonce="{{ csp_nonce() }}">{{ ({"collection": collection_data.collection, "properties": collection_data.properties, "pages": collection_data.pages, "views": collection_data.views if collection_data.views is defined else []} if collection_data else none) | tojson }}</script>
|
<script type="application/json" id="db-config" nonce="{{ csp_nonce() }}">{{ ({"collection": collection_data.collection, "properties": collection_data.properties, "pages": collection_data.pages, "views": collection_data.views if collection_data.views is defined else []} if collection_data else none) | tojson }}</script>
|
||||||
|
<link rel="stylesheet" href="/static/css/my_tasks.css?v={{ asset_version }}">
|
||||||
<script data-cfasync="false" src="/static/js/database_table.js?v={{ asset_version }}"></script>
|
<script data-cfasync="false" src="/static/js/database_table.js?v={{ asset_version }}"></script>
|
||||||
|
<script data-cfasync="false" src="/static/js/task_db_link.js?v={{ asset_version }}" defer></script>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
{% if not hide_hamburger %}
|
{% if not hide_hamburger %}
|
||||||
<button class="hamburger-btn"
|
<button class="hamburger-btn"
|
||||||
:class="{ 'hamburger-desktop-show': sidebarCollapsed }"
|
:class="{ 'hamburger-desktop-show': sidebarCollapsed }"
|
||||||
@click="{{ hamburger_click|default('sidebarCollapsed ? toggleSidebar() : (mobileSidebarOpen = true, sidebarCollapsed = false)') }}"
|
@click="{{ hamburger_click|default('fdHamburgerClick()') }}"
|
||||||
title="Toggle sidebar">
|
title="Toggle sidebar">
|
||||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||||
@@ -60,14 +60,14 @@
|
|||||||
{# ── Collapsed "…" segment ── #}
|
{# ── Collapsed "…" segment ── #}
|
||||||
<template x-if="crumb.ellipsis">
|
<template x-if="crumb.ellipsis">
|
||||||
<span class="crumb-anchor"
|
<span class="crumb-anchor"
|
||||||
@mouseenter="cancelCloseTimer(); ellipsisOpen = true"
|
@mouseenter="hoverEllipsis()"
|
||||||
@mouseleave="dragCloseTimer()">
|
@mouseleave="dragCloseTimer()">
|
||||||
<button type="button" class="breadcrumb-link crumb-ellipsis">…</button>
|
<button type="button" class="breadcrumb-link crumb-ellipsis">…</button>
|
||||||
<div class="fd-nav-menu" x-show="ellipsisOpen" x-cloak x-transition.opacity
|
<div class="fd-nav-menu" x-show="ellipsisOpen" x-cloak x-transition.opacity
|
||||||
@mouseenter="cancelCloseTimer()"
|
@mouseenter="cancelCloseTimer()"
|
||||||
@mouseleave="dragCloseTimer()">
|
@mouseleave="dragCloseTimer()">
|
||||||
<template x-for="h in crumb.hidden" :key="h.id">
|
<template x-for="h in crumb.hidden" :key="h.id">
|
||||||
<div class="fd-nav-item" @click.stop="go(h.url); closeAll()">
|
<div class="fd-nav-item" @click.stop="goClose(h.url)">
|
||||||
<span class="fd-nav-ico" :title="h.icon || 'file'"></span>
|
<span class="fd-nav-ico" :title="h.icon || 'file'"></span>
|
||||||
<span class="fd-nav-label" x-text="h.label"></span>
|
<span class="fd-nav-label" x-text="h.label"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
{# ── Interactive crumb with navigation dropdown (Notion-style hover) ── #}
|
{# ── Interactive crumb with navigation dropdown (Notion-style hover) ── #}
|
||||||
<template x-if="!crumb.ellipsis && crumb.menu">
|
<template x-if="!crumb.ellipsis && crumb.menu">
|
||||||
<span class="crumb-anchor"
|
<span class="crumb-anchor"
|
||||||
@mouseenter="cancelCloseTimer(); openMenu(crumb.origIndex)"
|
@mouseenter="hoverMenu(crumb.origIndex)"
|
||||||
@mouseleave="dragCloseTimer()">
|
@mouseleave="dragCloseTimer()">
|
||||||
<button type="button" class="breadcrumb-link"
|
<button type="button" class="breadcrumb-link"
|
||||||
:class="{ 'breadcrumb-current': crumb.origIndex === crumbs.length - 1 }"
|
:class="{ 'breadcrumb-current': crumb.origIndex === crumbs.length - 1 }"
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
<template x-for="item in activeItems" :key="item.id">
|
<template x-for="item in activeItems" :key="item.id">
|
||||||
<div class="fd-nav-item"
|
<div class="fd-nav-item"
|
||||||
@mouseenter="openSub(item)"
|
@mouseenter="openSub(item)"
|
||||||
@click.stop="go(item.url); closeAll()">
|
@click.stop="goClose(item.url)">
|
||||||
<span class="fd-nav-ico" :title="item.icon || 'file'"></span>
|
<span class="fd-nav-ico" :title="item.icon || 'file'"></span>
|
||||||
<span class="fd-nav-label" x-text="item.name"></span>
|
<span class="fd-nav-label" x-text="item.name"></span>
|
||||||
<span class="fd-nav-arrow" x-show="item.has_children">›</span>
|
<span class="fd-nav-arrow" x-show="item.has_children">›</span>
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
@mouseenter="cancelCloseTimer()"
|
@mouseenter="cancelCloseTimer()"
|
||||||
@mouseleave="dragCloseTimer()">
|
@mouseleave="dragCloseTimer()">
|
||||||
<template x-for="s in subItems" :key="s.id">
|
<template x-for="s in subItems" :key="s.id">
|
||||||
<div class="fd-nav-item" @click.stop="go(s.url); closeAll()">
|
<div class="fd-nav-item" @click.stop="goClose(s.url)">
|
||||||
<span class="fd-nav-ico" :title="s.icon || 'file'"></span>
|
<span class="fd-nav-ico" :title="s.icon || 'file'"></span>
|
||||||
<span class="fd-nav-label" x-text="s.name"></span>
|
<span class="fd-nav-label" x-text="s.name"></span>
|
||||||
<span class="fd-nav-arrow" x-show="s.has_children">›</span>
|
<span class="fd-nav-arrow" x-show="s.has_children">›</span>
|
||||||
@@ -152,6 +152,9 @@ document.addEventListener('alpine:init', function () {
|
|||||||
wsId: 0,
|
wsId: 0,
|
||||||
openIdx: null,
|
openIdx: null,
|
||||||
ellipsisOpen: false,
|
ellipsisOpen: false,
|
||||||
|
hoverEllipsis(){ this.cancelCloseTimer(); this.ellipsisOpen = true; },
|
||||||
|
hoverMenu(i){ this.cancelCloseTimer(); this.openMenu(i); },
|
||||||
|
goClose(u){ this.go(u); this.closeAll(); },
|
||||||
loading: false,
|
loading: false,
|
||||||
cache: {},
|
cache: {},
|
||||||
activeItems: [],
|
activeItems: [],
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.fd-icon-picker{position:fixed;top:0;left:0;width:344px;background:var(--bg-modal);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.28);z-index:2200;display:flex;flex-direction:column;max-height:440px;overflow:hidden;padding:0;}
|
.fd-icon-picker{position:fixed;top:0;left:0;width:344px;background:var(--bg-modal);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.28);z-index:2200;display:flex;flex-direction:column;max-height:440px;overflow:hidden;padding:0;}
|
||||||
|
.fd-icon-picker .fdip-title{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border);}
|
||||||
|
.fd-icon-picker .fdip-title-text{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px;font-weight:600;color:var(--text-primary);}
|
||||||
|
.fd-icon-picker .fdip-title-grip{opacity:.6;font-size:14px;}
|
||||||
.fd-icon-picker .fdip-tabs{display:flex;align-items:center;gap:2px;padding:8px 10px 6px;border-bottom:1px solid var(--border);}
|
.fd-icon-picker .fdip-tabs{display:flex;align-items:center;gap:2px;padding:8px 10px 6px;border-bottom:1px solid var(--border);}
|
||||||
.fd-icon-picker .fdip-tab{background:transparent;border:none;color:var(--text-secondary);font-size:13px;padding:5px 10px;border-radius:6px;cursor:pointer;}
|
.fd-icon-picker .fdip-tab{background:transparent;border:none;color:var(--text-secondary);font-size:13px;padding:5px 10px;border-radius:6px;cursor:pointer;}
|
||||||
.fd-icon-picker .fdip-tab:hover{background:var(--bg-hover);}
|
.fd-icon-picker .fdip-tab:hover{background:var(--bg-hover);}
|
||||||
@@ -70,7 +73,11 @@
|
|||||||
@click.outside="$store.fdIconPicker.customModal || $store.fdIconPicker.close()"
|
@click.outside="$store.fdIconPicker.customModal || $store.fdIconPicker.close()"
|
||||||
@keydown.escape.window="$store.fdIconPicker.close()"
|
@keydown.escape.window="$store.fdIconPicker.close()"
|
||||||
:style="'top:' + $store.fdIconPicker.y + 'px;left:' + $store.fdIconPicker.x + 'px'">
|
:style="'top:' + $store.fdIconPicker.y + 'px;left:' + $store.fdIconPicker.x + 'px'">
|
||||||
<div class="fdip-tabs">
|
<div class="fdip-title" x-show="$store.fdIconPicker.title" x-cloak>
|
||||||
|
<span class="fdip-title-text" x-text="$store.fdIconPicker.title"></span>
|
||||||
|
<span class="fdip-title-grip" title="Drag">✋</span>
|
||||||
|
</div>
|
||||||
|
<div class="fdip-tabs" x-show="!$store.fdIconPicker.title">
|
||||||
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'emoji' }" @click="$store.fdIconPicker.setTab('emoji')">Emoji</button>
|
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'emoji' }" @click="$store.fdIconPicker.setTab('emoji')">Emoji</button>
|
||||||
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'icons' }" @click="$store.fdIconPicker.setTab('icons')">Icons</button>
|
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'icons' }" @click="$store.fdIconPicker.setTab('icons')">Icons</button>
|
||||||
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'upload' }" @click="$store.fdIconPicker.setTab('upload')">Upload</button>
|
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'upload' }" @click="$store.fdIconPicker.setTab('upload')">Upload</button>
|
||||||
@@ -93,6 +100,15 @@
|
|||||||
<div class="fdip-body">
|
<div class="fdip-body">
|
||||||
<template x-if="$store.fdIconPicker.tab === 'emoji'">
|
<template x-if="$store.fdIconPicker.tab === 'emoji'">
|
||||||
<div>
|
<div>
|
||||||
|
<!-- v7.64 : mode réaction — les récents au-dessus de la grille (façon Notion) -->
|
||||||
|
<div x-show="$store.fdIconPicker.title && $store.fdIconPicker.category !== 'recent' && !$store.fdIconPicker.query && $store.fdIconPicker.recent.length">
|
||||||
|
<div class="fdip-section">Recent</div>
|
||||||
|
<div class="fdip-grid">
|
||||||
|
<template x-for="e in $store.fdIconPicker.recent.slice(0, 8)" :key="'rt' + e">
|
||||||
|
<button class="fdip-emoji" @click="$store.fdIconPicker.pick($store.fdIconPicker.withTone(e))" x-text="$store.fdIconPicker.withTone(e)"></button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="fdip-section" x-show="$store.fdIconPicker.category === 'recent' && !$store.fdIconPicker.query">Recent</div>
|
<div class="fdip-section" x-show="$store.fdIconPicker.category === 'recent' && !$store.fdIconPicker.query">Recent</div>
|
||||||
<div class="fdip-grid">
|
<div class="fdip-grid">
|
||||||
<template x-for="e in $store.fdIconPicker.items()" :key="e">
|
<template x-for="e in $store.fdIconPicker.items()" :key="e">
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
@keydown.enter.prevent="inviteEnter()"
|
@keydown.enter.prevent="inviteEnter()"
|
||||||
@keydown.down.prevent="inviteMove(1)"
|
@keydown.down.prevent="inviteMove(1)"
|
||||||
@keydown.up.prevent="inviteMove(-1)"
|
@keydown.up.prevent="inviteMove(-1)"
|
||||||
@keydown.escape="inviteSuggestOpen = false; inviteUsers = []"
|
@keydown.escape="closeInviteSuggest()"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
<button class="sd-btn-primary" @click="shareInvite()">
|
<button class="sd-btn-primary" @click="shareInvite()">
|
||||||
@@ -169,7 +169,7 @@
|
|||||||
<div
|
<div
|
||||||
class="sd-perm-option"
|
class="sd-perm-option"
|
||||||
:class="{ active: a.permission === 'edit' }"
|
:class="{ active: a.permission === 'edit' }"
|
||||||
@click="updateShare(a.id, 'edit'); a.permOpen=false"
|
@click="setSharePerm(a, 'edit')"
|
||||||
>
|
>
|
||||||
<span>Can edit</span>
|
<span>Can edit</span>
|
||||||
<span class="sd-perm-desc"
|
<span class="sd-perm-desc"
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
<div
|
<div
|
||||||
class="sd-perm-option"
|
class="sd-perm-option"
|
||||||
:class="{ active: a.permission === 'comment' }"
|
:class="{ active: a.permission === 'comment' }"
|
||||||
@click="updateShare(a.id, 'comment'); a.permOpen=false"
|
@click="setSharePerm(a, 'comment')"
|
||||||
>
|
>
|
||||||
<span>Can comment</span>
|
<span>Can comment</span>
|
||||||
<span class="sd-perm-desc"
|
<span class="sd-perm-desc"
|
||||||
@@ -189,7 +189,7 @@
|
|||||||
<div
|
<div
|
||||||
class="sd-perm-option"
|
class="sd-perm-option"
|
||||||
:class="{ active: a.permission === 'view' }"
|
:class="{ active: a.permission === 'view' }"
|
||||||
@click="updateShare(a.id, 'view'); a.permOpen=false"
|
@click="setSharePerm(a, 'view')"
|
||||||
>
|
>
|
||||||
<span>Can view</span>
|
<span>Can view</span>
|
||||||
<span class="sd-perm-desc"
|
<span class="sd-perm-desc"
|
||||||
@@ -199,7 +199,7 @@
|
|||||||
<div class="sd-perm-sep"></div>
|
<div class="sd-perm-sep"></div>
|
||||||
<div
|
<div
|
||||||
class="sd-perm-option danger"
|
class="sd-perm-option danger"
|
||||||
@click="removeShare(a.id); a.permOpen=false"
|
@click="removeShareAndClose(a)"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</div>
|
</div>
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
<div
|
<div
|
||||||
class="sd-perm-option"
|
class="sd-perm-option"
|
||||||
:class="{ active: generalAccess === 'invited' }"
|
:class="{ active: generalAccess === 'invited' }"
|
||||||
@click="generalAccess='invited'; accessMenuOpen=false"
|
@click="pickGeneralAccess('invited')"
|
||||||
>
|
>
|
||||||
<span>{{ fd_icon('lock',14) }} Only people invited</span>
|
<span>{{ fd_icon('lock',14) }} Only people invited</span>
|
||||||
<span class="sd-perm-desc"
|
<span class="sd-perm-desc"
|
||||||
@@ -243,7 +243,7 @@
|
|||||||
<div
|
<div
|
||||||
class="sd-perm-option"
|
class="sd-perm-option"
|
||||||
:class="{ active: generalAccess === 'anyone' }"
|
:class="{ active: generalAccess === 'anyone' }"
|
||||||
@click="generalAccess='anyone'; accessMenuOpen=false"
|
@click="pickGeneralAccess('anyone')"
|
||||||
>
|
>
|
||||||
<span>{{ fd_icon('globe',14) }} Anyone with the link</span>
|
<span>{{ fd_icon('globe',14) }} Anyone with the link</span>
|
||||||
<span class="sd-perm-desc"
|
<span class="sd-perm-desc"
|
||||||
@@ -312,7 +312,7 @@
|
|||||||
<template x-for="(ic, ici) in ['📄','📝','📋','📊','🗂️','📁','📂','🗒️','🗓️','📌','🔖','⭐','🚀','💡','🎯','🔑','📚','🧪','🌍','💰','📝','🧩','🎨','🔧','⚙️','🗃️','📦','🏷️','📍','🏠','👤']" :key="ici">
|
<template x-for="(ic, ici) in ['📄','📝','📋','📊','🗂️','📁','📂','🗒️','🗓️','📌','🔖','⭐','🚀','💡','🎯','🔑','📚','🧪','🌍','💰','📝','🧩','🎨','🔧','⚙️','🗃️','📦','🏷️','📍','🏠','👤']" :key="ici">
|
||||||
<button type="button" class="sd-icon-btn" @click="setIcon(ic)" :class="{ active: iconValue === ic }" style="width:36px;height:36px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);font-size:18px;display:flex;align-items:center;justify-content:center;cursor:pointer;" x-text="ic"></button>
|
<button type="button" class="sd-icon-btn" @click="setIcon(ic)" :class="{ active: iconValue === ic }" style="width:36px;height:36px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);font-size:18px;display:flex;align-items:center;justify-content:center;cursor:pointer;" x-text="ic"></button>
|
||||||
</template>
|
</template>
|
||||||
<input type="text" class="sd-input" x-model="iconValue" placeholder="Custom emoji" style="max-width:80px;" @keydown.enter.prevent="setIcon(iconValue);iconOpen=false;">
|
<input type="text" class="sd-input" x-model="iconValue" placeholder="Custom emoji" style="max-width:80px;" @keydown.enter.prevent="applyIcon()">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -498,16 +498,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="more-menu-item" @click="movePage">↗ Move to</div>
|
<div class="more-menu-item" @click="movePage">↗ Move to</div>
|
||||||
<div class="more-menu-sep"></div>
|
<div class="more-menu-sep"></div>
|
||||||
<div class="more-menu-item" @click="moreOpen=false; toggleLock()">
|
<div class="more-menu-item" @click="moreToggleLock()">
|
||||||
<span x-text="isLocked ? '🔓 Unlock page' : '🔒 Lock page'"></span>
|
<span x-text="isLocked ? '🔓 Unlock page' : '🔒 Lock page'"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="more-menu-item" @click="moreOpen=false; setPageOption('full_width', !fullWidth)">
|
<div class="more-menu-item" @click="moreFullWidth()">
|
||||||
↔ Full width <span x-show="fullWidth" style="margin-left:auto;font-size:11px">✓</span>
|
↔ Full width <span x-show="fullWidth" style="margin-left:auto;font-size:11px">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="more-menu-item" @click="moreOpen=false; setPageOption('font_small', !fontSmall)">
|
<div class="more-menu-item" @click="moreFontSmall()">
|
||||||
Aa Small text <span x-show="fontSmall" style="margin-left:auto;font-size:11px">✓</span>
|
Aa Small text <span x-show="fontSmall" style="margin-left:auto;font-size:11px">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="more-menu-item" @click="moreOpen=false; saveAsTemplate()">
|
<div class="more-menu-item" @click="moreSaveTemplate()">
|
||||||
📑 Save as template
|
📑 Save as template
|
||||||
</div>
|
</div>
|
||||||
<div class="more-menu-sep"></div>
|
<div class="more-menu-sep"></div>
|
||||||
@@ -552,7 +552,7 @@
|
|||||||
contenteditable="true"
|
contenteditable="true"
|
||||||
id="_titleEl"
|
id="_titleEl"
|
||||||
data-placeholder="New Page"
|
data-placeholder="New Page"
|
||||||
@input="dirty=true;save()"
|
@input="markAndSave()"
|
||||||
@keydown.enter.prevent="focusBlock()"
|
@keydown.enter.prevent="focusBlock()"
|
||||||
@paste.prevent="pastePlain($event)"
|
@paste.prevent="pastePlain($event)"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
@@ -587,15 +587,15 @@
|
|||||||
💬 Comment
|
💬 Comment
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- ═══════════ Comments drawer (v4.9.0) ═══════════ -->
|
<!-- ═══════════ Comments drawer (v4.9.0 · refonte v7.64 façon Notion) ═══════════ -->
|
||||||
<div class="comments-drawer" x-show="commentsOpen" x-cloak x-transition
|
<div class="comments-drawer" x-show="commentsOpen" x-cloak x-transition
|
||||||
style="position:fixed;top:var(--topbar-height,44px);right:0;bottom:0;width:320px;max-width:92vw;
|
style="position:fixed;top:var(--topbar-height,44px);right:0;bottom:0;width:340px;max-width:92vw;
|
||||||
background:var(--bg-primary,#1c1c1c);border-left:1px solid var(--border,#333);
|
background:var(--bg-primary,#1c1c1c);border-left:1px solid var(--border,#333);
|
||||||
box-shadow:-8px 0 30px rgba(0,0,0,.25);z-index:900;display:flex;flex-direction:column;">
|
box-shadow:-8px 0 30px rgba(0,0,0,.25);z-index:900;display:flex;flex-direction:column;">
|
||||||
<div class="comments-drawer-header"
|
<div class="comments-drawer-header"
|
||||||
style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;
|
style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;
|
||||||
border-bottom:1px solid var(--border,#333);font-weight:600;font-size:14px;">
|
border-bottom:1px solid var(--border,#333);font-weight:600;font-size:14px;">
|
||||||
<span>Comments</span>
|
<span>Comments <span style="color:var(--text-dim);font-weight:400;" x-text="'(' + commentCount + ')'"></span></span>
|
||||||
<button type="button" class="topbar-btn" @click="toggleComments()" title="Close">✕</button>
|
<button type="button" class="topbar-btn" @click="toggleComments()" title="Close">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="comments-drawer-list" style="flex:1;overflow-y:auto;padding:12px 16px;">
|
<div class="comments-drawer-list" style="flex:1;overflow-y:auto;padding:12px 16px;">
|
||||||
@@ -603,49 +603,101 @@
|
|||||||
No comments yet. Select some text and click 💬 Comment.
|
No comments yet. Select some text and click 💬 Comment.
|
||||||
</div>
|
</div>
|
||||||
<template x-for="c in comments" :key="c.id">
|
<template x-for="c in comments" :key="c.id">
|
||||||
<div class="comment-thread" style="margin-bottom:18px;"
|
<div class="comment-thread" :data-thread="c.id"
|
||||||
:style="c.anchor_block_id ? 'border-left:3px solid var(--accent);padding-left:10px;' : ''">
|
:class="{ 'is-resolved': c.resolved, 'has-anchor': !!c.anchor_block_id }">
|
||||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
|
<div class="ct-head">
|
||||||
<div style="width:22px;height:22px;border-radius:50%;background:#3A3A3A;color:#fff;
|
<div class="ct-avatar"
|
||||||
display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;"
|
|
||||||
:style="{ background: c.author && c.author.avatar_color ? c.author.avatar_color : '#3A3A3A' }">
|
:style="{ background: c.author && c.author.avatar_color ? c.author.avatar_color : '#3A3A3A' }">
|
||||||
<span x-text="(c.author ? (c.author.full_name || c.author.login || '?') : '?').charAt(0).toUpperCase()"></span>
|
<span x-text="(c.author ? (c.author.full_name || c.author.login || '?') : '?').charAt(0).toUpperCase()"></span>
|
||||||
</div>
|
</div>
|
||||||
<span style="font-size:12px;font-weight:600;" x-text="c.author ? (c.author.full_name || c.author.login) : 'Unknown'"></span>
|
<span class="ct-name" x-text="c.author ? (c.author.full_name || c.author.login) : 'Unknown'"></span>
|
||||||
<span style="font-size:11px;color:var(--text-dim,#999);margin-left:auto;" x-text="fmtTime(c.created_at)"></span>
|
<span class="ct-time" x-text="fmtTime(c.created_at)"></span>
|
||||||
</div>
|
<div class="ct-actions">
|
||||||
<div style="font-size:13px;white-space:pre-wrap;margin-bottom:6px;" x-text="c.body"></div>
|
<button type="button" class="ct-act" :title="c.resolved ? 'Reopen' : 'Resolve'"
|
||||||
<div style="display:flex;gap:8px;align-items:center;">
|
@click="resolveComment(c)"
|
||||||
<button class="btn-sm" style="font-size:11px;" @click="resolveComment(c.id)"
|
:class="{ 'is-on': c.resolved }">✓</button>
|
||||||
x-text="c.resolved ? '↪ Reopen' : '✔ Resolve'"></button>
|
<button type="button" class="ct-act" title="More"
|
||||||
<button class="btn-sm" style="font-size:11px;" x-show="c.user_id === currentUserId"
|
x-show="c.user_id === currentUserId"
|
||||||
@click="deleteComment(c.id)">🗑 Delete</button>
|
@click.stop="commentMenu = commentMenu === c.id ? null : c.id">⋯</button>
|
||||||
|
</div>
|
||||||
|
<div class="ct-menu" x-cloak x-show="commentMenu === c.id" @click.outside="commentMenu = null">
|
||||||
|
<button type="button" class="ct-menu-item" @click="copyCommentLink(c.id)">🔗 Copy link</button>
|
||||||
|
<button type="button" class="ct-menu-item is-danger" @click="deleteComment(c.id)">🗑 Delete</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ct-body" x-text="c.body"></div>
|
||||||
|
<div class="ct-anchor-label" x-show="!!c.anchor_block_id && !c.resolved">💬 on selected text</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="comments-drawer-input" style="border-top:1px solid var(--border,#333);padding:12px 16px;">
|
<div class="comments-drawer-input">
|
||||||
<textarea x-model="commentDraft" rows="2"
|
<div class="ct-input-row">
|
||||||
placeholder="Add a comment... Use @ to mention someone."
|
<textarea x-model="commentDraft" rows="1"
|
||||||
style="width:100%;background:var(--bg-secondary,#2a2a2a);border:1px solid var(--border,#333);
|
placeholder="Add a comment..."
|
||||||
border-radius:8px;color:var(--text);font-size:13px;padding:8px 10px;resize:none;outline:none;"></textarea>
|
@keydown.enter.exact.prevent="addPageComment()"></textarea>
|
||||||
<div style="display:flex;justify-content:flex-end;margin-top:8px;">
|
<div class="ct-input-actions">
|
||||||
<button class="btn btn-primary" style="font-size:12px;padding:6px 14px;" @click="addPageComment()">Comment</button>
|
<button type="button" class="ct-act" title="Mention" @click="commentAt()">@</button>
|
||||||
|
<button type="button" class="ct-send" title="Send"
|
||||||
|
:disabled="!(commentDraft || '').trim()" @click="addPageComment()">↑</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════ Menu contextuel au highlight de texte (v7.62.0, façon Notion) ═══════════ -->
|
||||||
<div
|
<div
|
||||||
class="format-toolbar"
|
class="format-toolbar"
|
||||||
x-show="fmt.open"
|
x-show="fmt.open"
|
||||||
|
x-cloak
|
||||||
:style="{top:fmt.top+'px',left:fmt.left+'px'}"
|
:style="{top:fmt.top+'px',left:fmt.left+'px'}"
|
||||||
@mousedown.prevent
|
@mousedown.prevent
|
||||||
>
|
>
|
||||||
<button @click="fmtApply('bold')"><strong>B</strong></button>
|
<!-- Ligne 1 : style de texte -->
|
||||||
<button @click="fmtApply('italic')"><em>I</em></button>
|
<div class="ft-row">
|
||||||
<button @click="fmtApply('underline')"><u>U</u></button>
|
<button type="button" class="ft-item ft-style"
|
||||||
<button @click="fmtApply('strikeThrough')"><s>S</s></button>
|
@click="fmtStyle($event)"
|
||||||
<button @click="fmtLink()">{{ fd_icon("link",14) }}</button>
|
data-tooltip-html="<em>To be the foremost driver…</em><br>Just start writing with plain text">
|
||||||
|
<span class="ft-style-ico">T</span>
|
||||||
|
<span class="ft-style-name" x-text="fmtStyleName()"></span>
|
||||||
|
<span class="ft-caret">›</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Ligne 2 : formatage de base -->
|
||||||
|
<div class="ft-row">
|
||||||
|
<button type="button" class="ft-item ft-a" @click="fmtColor($event)" data-tooltip="Color — text and background colors"><span class="ft-a-letter">A</span></button>
|
||||||
|
<button type="button" class="ft-item" @click="fmtApply('bold')" data-tooltip="Bold — Ctrl+B"><strong>B</strong></button>
|
||||||
|
<button type="button" class="ft-item" @click="fmtApply('italic')" data-tooltip="Italic — Ctrl+I"><em>I</em></button>
|
||||||
|
<button type="button" class="ft-item" @click="fmtApply('underline')" data-tooltip="Underline"><u>U</u></button>
|
||||||
|
<button type="button" class="ft-item" @click="fmtApply('strikeThrough')" data-tooltip="Strikethrough"><s>T</s></button>
|
||||||
|
<button type="button" class="ft-item ft-tx" @click="fmtCode()" data-tooltip="Inline code">Tx</button>
|
||||||
|
</div>
|
||||||
|
<!-- Ligne 3 : outils avancés -->
|
||||||
|
<div class="ft-row">
|
||||||
|
<button type="button" class="ft-item" @click="fmtLink()" data-tooltip="Link">{{ fd_icon("link",15) }}</button>
|
||||||
|
<button type="button" class="ft-item" @click="fmtHighlight()" data-tooltip="Highlight"><span class="ft-hl">S</span></button>
|
||||||
|
<button type="button" class="ft-item ft-react" @click="fmtReact()" data-tooltip="React to selected text"><span class="ft-react-ico">🙂<span class="ft-react-plus">+</span></span></button>
|
||||||
|
<button type="button" class="ft-item ft-code-ico" @click="fmtCodeBlock()" data-tooltip="Code block"></></button>
|
||||||
|
<button type="button" class="ft-item" @click="fmtEquation()" data-tooltip="Equation">√x</button>
|
||||||
|
<button type="button" class="ft-item ft-more" @click="fmtMore($event)" data-tooltip="More actions">…</button>
|
||||||
|
</div>
|
||||||
|
<!-- Ligne 4 : actions -->
|
||||||
|
<div class="ft-row">
|
||||||
|
<button type="button" class="ft-item" @click="fmtComment()" data-tooltip="Comment — Ctrl+⇧+M">{{ fd_icon("message-square",15) }}</button>
|
||||||
|
<button type="button" class="ft-item" @click="fmtAskAI()" data-tooltip="Ask AI — Ctrl+J">{{ fd_icon("sparkles",15) }}</button>
|
||||||
|
</div>
|
||||||
|
<!-- Section « Skills » -->
|
||||||
|
<div class="ft-sep"></div>
|
||||||
|
<div class="ft-group">Skills</div>
|
||||||
|
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Improve the writing of this text (wording, clarity, style). Keep the meaning, language and format. Reply ONLY with the improved text.')" data-tooltip="Run on this block">Improve writing</button>
|
||||||
|
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Proofread this text: fix spelling and grammar mistakes only. Reply ONLY with the corrected text.')" data-tooltip="Run on this block">Proofread</button>
|
||||||
|
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Explain this text in simpler terms, in the same language. Reply ONLY with the explanation.')" data-tooltip="Run on this block">Explain</button>
|
||||||
|
<button type="button" class="ft-item ft-skill" @click="fmtSkill('Reformat this text for readability (structure, spacing, lists where useful) without changing its meaning. Reply ONLY with the reformatted text.')" data-tooltip="Run on this block">Reformat</button>
|
||||||
|
<!-- Pied de page -->
|
||||||
|
<div class="ft-sep"></div>
|
||||||
|
<button type="button" class="ft-item ft-foot" @click="fmtEditAI()" data-tooltip="Edit with AI">
|
||||||
|
<span>Edit with AI</span>
|
||||||
|
<kbd>Alt+⇧+E</kbd>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══════════ Copy Link Toast ═══════════ -->
|
<!-- ═══════════ Copy Link Toast ═══════════ -->
|
||||||
|
|||||||
@@ -109,7 +109,8 @@
|
|||||||
|
|
||||||
// globales window — probe « Undefined variable: accountsData »).
|
// globales window — probe « Undefined variable: accountsData »).
|
||||||
|
|
||||||
document.addEventListener('alpine:init', function () { Alpine.data('accountsData', accountsData); });
|
if (window.Alpine) { Alpine.data('accountsData', accountsData); }
|
||||||
|
else document.addEventListener('alpine:init', function () { Alpine.data('accountsData', accountsData); });
|
||||||
function accountsData() {
|
function accountsData() {
|
||||||
return {
|
return {
|
||||||
profile: { full_name: '', email: '' },
|
profile: { full_name: '', email: '' },
|
||||||
|
|||||||
@@ -2,12 +2,19 @@
|
|||||||
<style>
|
<style>
|
||||||
#fd-agent-fab{position:fixed;right:22px;bottom:22px;width:54px;height:54px;border-radius:50%;background:var(--text-primary,#37352F);color:var(--bg-primary,#fff);border:none;cursor:pointer;box-shadow:0 8px 26px rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;z-index:1700;transition:transform .15s,box-shadow .15s}
|
#fd-agent-fab{position:fixed;right:22px;bottom:22px;width:54px;height:54px;border-radius:50%;background:var(--text-primary,#37352F);color:var(--bg-primary,#fff);border:none;cursor:pointer;box-shadow:0 8px 26px rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;z-index:1700;transition:transform .15s,box-shadow .15s}
|
||||||
#fd-agent-fab:hover{transform:scale(1.06);box-shadow:0 12px 34px rgba(0,0,0,.45)}
|
#fd-agent-fab:hover{transform:scale(1.06);box-shadow:0 12px 34px rgba(0,0,0,.45)}
|
||||||
#fd-agent-panel{position:fixed;top:0;right:0;bottom:0;width:470px;max-width:100vw;background:var(--bg-primary,#191919);border-left:1px solid var(--border,rgba(255,255,255,.1));z-index:1850;display:flex;flex-direction:column;box-shadow:-16px 0 48px rgba(0,0,0,.55);transform:translateX(102%);transition:transform .24s cubic-bezier(.16,1,.3,1)}
|
#fd-agent-panel{position:fixed;top:0;right:0;bottom:0;width:var(--fd-agent-width,470px);max-width:100vw;background:var(--bg-primary,#191919);border-left:1px solid var(--border,rgba(255,255,255,.1));z-index:1850;display:flex;flex-direction:column;box-shadow:-16px 0 48px rgba(0,0,0,.55);transform:translateX(102%);transition:transform .24s cubic-bezier(.16,1,.3,1)}
|
||||||
#fd-agent-panel.open{transform:translateX(0)}
|
#fd-agent-panel.open{transform:translateX(0)}
|
||||||
#fd-agent-panel ::-webkit-scrollbar{width:8px;height:8px}
|
#fd-agent-panel ::-webkit-scrollbar{width:8px;height:8px}
|
||||||
#fd-agent-panel ::-webkit-scrollbar-thumb{background:rgba(128,128,128,.35);border-radius:4px}
|
#fd-agent-panel ::-webkit-scrollbar-thumb{background:rgba(128,128,128,.35);border-radius:4px}
|
||||||
#fd-agent-panel ::-webkit-scrollbar-track{background:transparent}
|
#fd-agent-panel ::-webkit-scrollbar-track{background:transparent}
|
||||||
|
|
||||||
|
/* Resize handle (left edge) */
|
||||||
|
.fd-ap-resize{position:absolute;top:0;bottom:0;left:0;width:6px;cursor:col-resize;z-index:5}
|
||||||
|
.fd-ap-resize::before{content:"";position:absolute;top:0;bottom:0;left:2px;width:2px;background:transparent;border-radius:2px;transition:background .12s ease}
|
||||||
|
.fd-ap-resize:hover::before,body.fd-ap-resizing .fd-ap-resize::before{background:var(--accent,#2383E2)}
|
||||||
|
body.fd-ap-resizing{cursor:col-resize!important;user-select:none}
|
||||||
|
body.fd-ap-resizing *{cursor:col-resize!important}
|
||||||
|
|
||||||
/* Header */
|
/* Header */
|
||||||
.fd-ap-head{display:flex;align-items:center;gap:12px;padding:16px 18px 12px}
|
.fd-ap-head{display:flex;align-items:center;gap:12px;padding:16px 18px 12px}
|
||||||
.fd-ap-logo{width:36px;height:36px;border-radius:50%;background:var(--text-primary,#37352F);color:var(--bg-primary,#fff);display:flex;align-items:center;justify-content:center;flex-shrink:0;box-shadow:0 2px 8px rgba(0,0,0,.25)}
|
.fd-ap-logo{width:36px;height:36px;border-radius:50%;background:var(--text-primary,#37352F);color:var(--bg-primary,#fff);display:flex;align-items:center;justify-content:center;flex-shrink:0;box-shadow:0 2px 8px rgba(0,0,0,.25)}
|
||||||
@@ -173,6 +180,26 @@
|
|||||||
.fd-mention-sep{font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--text-dim,#888);padding:8px 14px 2px}
|
.fd-mention-sep{font-size:10px;text-transform:uppercase;letter-spacing:.6px;color:var(--text-dim,#888);padding:8px 14px 2px}
|
||||||
.fd-mention-empty,.fd-mention-load{padding:11px 12px;font-size:12px;color:var(--text-dim,#aaa);text-align:center}
|
.fd-mention-empty,.fd-mention-load{padding:11px 12px;font-size:12px;color:var(--text-dim,#aaa);text-align:center}
|
||||||
|
|
||||||
|
/* Menu + (sections) — réutilise le skin du menu de mentions */
|
||||||
|
.fd-ap-plus-menu{max-height:min(340px,60vh)}
|
||||||
|
.fd-ap-plus-menu .fd-mention-item.disabled{opacity:.45;cursor:default;background:none}
|
||||||
|
.fd-ap-plus-menu .fd-mention-item.disabled:hover{background:none}
|
||||||
|
.fd-plus-head{display:flex;align-items:center;gap:8px;padding:7px 11px 6px;border-bottom:1px solid var(--border,rgba(255,255,255,.08));margin-bottom:3px}
|
||||||
|
.fd-plus-back{background:none;border:1px solid var(--border,rgba(255,255,255,.14));color:var(--text,#ddd);border-radius:7px;width:26px;height:22px;cursor:pointer;line-height:1;flex-shrink:0}
|
||||||
|
.fd-plus-back:hover{background:var(--bg-hover,#2b2b2b)}
|
||||||
|
.fd-plus-title{font-size:11px;font-weight:600;color:var(--text-secondary,#ddd);text-transform:uppercase;letter-spacing:.4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.fd-plus-filter{padding:7px 11px 2px}
|
||||||
|
.fd-plus-filter input{width:100%;background:var(--bg-tertiary,#232323);border:1px solid var(--border,rgba(255,255,255,.14));border-radius:8px;color:var(--text,#fff);font-size:12.5px;padding:6px 8px;outline:none;box-sizing:border-box}
|
||||||
|
.fd-plus-form{padding:4px 11px 12px}
|
||||||
|
.fd-plus-form label{display:block;font-size:10.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim,#999);margin:9px 0 3px}
|
||||||
|
.fd-plus-form input,.fd-plus-form textarea{width:100%;background:var(--bg-tertiary,#232323);border:1px solid var(--border,rgba(255,255,255,.14));border-radius:8px;color:var(--text,#fff);font-size:12.5px;padding:7px 8px;outline:none;font-family:inherit;box-sizing:border-box}
|
||||||
|
.fd-plus-form textarea{min-height:110px;resize:vertical;line-height:1.5}
|
||||||
|
.fd-plus-file{display:none}
|
||||||
|
.fd-plus-chev{margin-left:auto;color:var(--text-dim,#999);opacity:.75;font-size:15px;flex-shrink:0}
|
||||||
|
.fd-plus-crumb{display:flex;gap:5px;flex-wrap:wrap;padding:7px 11px 3px;font-size:11px;border-bottom:1px solid var(--border,rgba(255,255,255,.08));margin-bottom:3px}
|
||||||
|
.fd-plus-crumb-it{color:var(--accent,#2383E2);cursor:pointer;white-space:nowrap}
|
||||||
|
.fd-plus-crumb-it:hover{text-decoration:underline}
|
||||||
|
|
||||||
/* Toast */
|
/* Toast */
|
||||||
.fd-ap-toast{position:absolute;left:14px;right:14px;bottom:calc(100% + 10px);background:var(--bg-modal,#1c1c1c);border:1px solid rgba(35,131,226,.5);color:var(--text,#fff);font-size:12px;border-radius:9px;padding:8px 12px;box-shadow:0 10px 30px rgba(0,0,0,.45);z-index:90;display:flex;gap:8px;align-items:center;justify-content:space-between}
|
.fd-ap-toast{position:absolute;left:14px;right:14px;bottom:calc(100% + 10px);background:var(--bg-modal,#1c1c1c);border:1px solid rgba(35,131,226,.5);color:var(--text,#fff);font-size:12px;border-radius:9px;padding:8px 12px;box-shadow:0 10px 30px rgba(0,0,0,.45);z-index:90;display:flex;gap:8px;align-items:center;justify-content:space-between}
|
||||||
.fd-ap-toast.err{border-color:rgba(227,98,98,.55)}
|
.fd-ap-toast.err{border-color:rgba(227,98,98,.55)}
|
||||||
@@ -189,7 +216,7 @@
|
|||||||
.fd-agent-msg-markdown ul,.fd-agent-msg-markdown ol{margin:0 0 8px;padding-left:20px}
|
.fd-agent-msg-markdown ul,.fd-agent-msg-markdown ol{margin:0 0 8px;padding-left:20px}
|
||||||
.fd-agent-msg-markdown li{margin:2px 0}
|
.fd-agent-msg-markdown li{margin:2px 0}
|
||||||
.fd-agent-msg-markdown code{background:rgba(127,127,127,.18);border-radius:4px;padding:1px 5px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em}
|
.fd-agent-msg-markdown code{background:rgba(127,127,127,.18);border-radius:4px;padding:1px 5px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:.9em}
|
||||||
.fd-agent-msg-markdown pre{background:var(--bg-code,#111);border:1px solid var(--border,#333);border-radius:8px;padding:12px;overflow-x:auto;margin:8px 0}
|
.fd-agent-msg-markdown pre{background:var(--bg-code,var(--bg-tertiary,#1f1f1f));border:1px solid var(--border,rgba(255,255,255,.12));border-radius:8px;padding:12px;overflow-x:auto;margin:8px 0;color:var(--text,#ececec)}
|
||||||
.fd-agent-msg-markdown pre code{background:none;padding:0;font-size:12px;line-height:1.5}
|
.fd-agent-msg-markdown pre code{background:none;padding:0;font-size:12px;line-height:1.5}
|
||||||
.fd-md-table-wrap{overflow-x:auto;margin:8px 0}
|
.fd-md-table-wrap{overflow-x:auto;margin:8px 0}
|
||||||
.fd-md-table{border-collapse:collapse;width:100%;font-size:13px}
|
.fd-md-table{border-collapse:collapse;width:100%;font-size:13px}
|
||||||
@@ -211,6 +238,7 @@
|
|||||||
|
|
||||||
{# ── Panel ── #}
|
{# ── Panel ── #}
|
||||||
<div id="fd-agent-panel" class="fd-ap" :class="{open: open}" x-data="agentPanel()" @keydown.escape.window="close()" role="dialog" aria-label="Agent FlowDeck">
|
<div id="fd-agent-panel" class="fd-ap" :class="{open: open}" x-data="agentPanel()" @keydown.escape.window="close()" role="dialog" aria-label="Agent FlowDeck">
|
||||||
|
<div class="fd-ap-resize" x-ref="resizeHandle" role="separator" aria-orientation="vertical" aria-label="Redimensionner le panneau" tabindex="0" title="Glisser pour redimensionner — double-clic pour réinitialiser"></div>
|
||||||
<div class="fd-ap-head">
|
<div class="fd-ap-head">
|
||||||
<span class="fd-ap-logo"><svg viewBox="0 0 24 24" width="19" height="19" fill="currentColor" aria-hidden="true"><path d="M12 3l1.9 5.8a2 2 0 0 0 1.3 1.3L21 12l-5.8 1.9a2 2 0 0 0-1.3 1.3L12 21l-1.9-5.8a2 2 0 0 0-1.3-1.3L3 12l5.8-1.9a2 2 0 0 0 1.3-1.3L12 3Z"/></svg></span>
|
<span class="fd-ap-logo"><svg viewBox="0 0 24 24" width="19" height="19" fill="currentColor" aria-hidden="true"><path d="M12 3l1.9 5.8a2 2 0 0 0 1.3 1.3L21 12l-5.8 1.9a2 2 0 0 0-1.3 1.3L12 21l-1.9-5.8a2 2 0 0 0-1.3-1.3L3 12l5.8-1.9a2 2 0 0 0 1.3-1.3L12 3Z"/></svg></span>
|
||||||
<div class="fd-ap-head-main">
|
<div class="fd-ap-head-main">
|
||||||
@@ -231,7 +259,7 @@
|
|||||||
|
|
||||||
<div class="fd-ap-tabs">
|
<div class="fd-ap-tabs">
|
||||||
<button class="fd-ap-tab" :class="{active: tab==='chat'}" @click="tab='chat'">Conversation</button>
|
<button class="fd-ap-tab" :class="{active: tab==='chat'}" @click="tab='chat'">Conversation</button>
|
||||||
<button class="fd-ap-tab" :class="{active: tab==='history'}" @click="tab='history'; loadConversations()">Historique</button>
|
<button class="fd-ap-tab" :class="{active: tab==='history'}" @click="showHistory()">Historique</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="fd-agent-body">
|
<div class="fd-agent-body">
|
||||||
@@ -372,6 +400,71 @@
|
|||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="fd-ap-mention-menu fd-ap-plus-menu" :class="{show: plusOpen}" x-ref="plusMenu">
|
||||||
|
<div class="fd-plus-head" x-show="plusSection!=='root'" x-cloak>
|
||||||
|
<button class="fd-plus-back" type="button" @click="plusBack()" title="Retour" aria-label="Retour">←</button>
|
||||||
|
<span class="fd-plus-title" x-text="plusTitle"></span>
|
||||||
|
</div>
|
||||||
|
<div class="fd-plus-filter" x-show="plusSection==='skills-gallery'" x-cloak>
|
||||||
|
<input type="search" placeholder="Filtrer les compétences…" aria-label="Filtrer les compétences" x-model="galleryFilter">
|
||||||
|
</div>
|
||||||
|
<div class="fd-plus-crumb" x-show="plusSection==='browse'">
|
||||||
|
<span class="fd-plus-crumb-it" @click="plusGoto(-1)">Racine</span>
|
||||||
|
<template x-for="(c, ci) in plusCrumb" :key="c.id">
|
||||||
|
<span class="fd-plus-crumb-it" @click="plusGoto(ci)" x-text="'› ' + c.name"></span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<div class="fd-mention-load" x-show="plusSection==='browse' && plusLoading" x-cloak>Chargement…</div>
|
||||||
|
<div class="fd-mention-empty" x-show="plusSection==='browse' && !plusLoading && !plusItems.length" x-cloak>Dossier vide</div>
|
||||||
|
<template x-for="(it, i) in plusItems" :key="it.key">
|
||||||
|
<div>
|
||||||
|
<div class="fd-mention-sep" x-show="it._sep" x-text="it._sep"></div>
|
||||||
|
<div class="fd-mention-item" x-show="!it._sep" :class="{focus: i===plusFocus, disabled: !!it.disabled}"
|
||||||
|
@click="plusAction(it)" @mouseenter="it.disabled ? null : plusFocus = i">
|
||||||
|
<span class="fd-mention-ico" x-text="it.icon"></span>
|
||||||
|
<div class="fd-mention-main">
|
||||||
|
<div class="fd-mention-lbl" x-text="it.label"></div>
|
||||||
|
<div class="fd-mention-sub" x-text="it.sub"></div>
|
||||||
|
</div>
|
||||||
|
<span class="fd-plus-chev" x-show="it.chev || it._folder">›</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="fd-plus-form" x-show="plusSection==='skills-edit'" x-cloak>
|
||||||
|
<label for="fd-plus-skill-name">Nom</label>
|
||||||
|
<input id="fd-plus-skill-name" type="text" x-model="skillForm.name" placeholder="ex. synthese-reunion" @keydown.enter.prevent="saveSkill()">
|
||||||
|
<label for="fd-plus-skill-desc">Description</label>
|
||||||
|
<input id="fd-plus-skill-desc" type="text" x-model="skillForm.description" placeholder="Une phrase">
|
||||||
|
<label for="fd-plus-skill-prompt">Prompt (consigne appliquée à la demande)</label>
|
||||||
|
<textarea id="fd-plus-skill-prompt" x-model="skillForm.prompt_template" placeholder="Fais…"></textarea>
|
||||||
|
<div class="fd-proposal-bar">
|
||||||
|
<button class="fd-proposal-btn primary" type="button" @click="saveSkill()">✓ Enregistrer</button>
|
||||||
|
<button class="fd-proposal-btn" type="button" x-show="skillForm.id" @click="deleteSkill()" x-cloak>✕ Supprimer</button>
|
||||||
|
<button class="fd-proposal-btn" type="button" x-show="skillForm.id" @click="exportSkill()" x-cloak>⤓ Export</button>
|
||||||
|
<button class="fd-proposal-btn" type="button" @click="plusBack()">← Retour</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="fd-plus-form" x-show="plusSection==='connector-new'" x-cloak>
|
||||||
|
<label for="fd-plus-cn-kind">Type</label>
|
||||||
|
<select id="fd-plus-cn-kind" x-model="connectorForm.kind" @change="connectorPreset()">
|
||||||
|
<option value="custom">Connecteur personnalisé (HTTP + clé)</option>
|
||||||
|
<option value="discord">Discord (bot)</option>
|
||||||
|
<option value="telegram">Telegram (bot)</option>
|
||||||
|
<option value="mcp">Serveur MCP (streamable HTTP)</option>
|
||||||
|
</select>
|
||||||
|
<label for="fd-plus-cn-name">Nom</label>
|
||||||
|
<input id="fd-plus-cn-name" type="text" x-model="connectorForm.name" placeholder="ex. Base de connaissances" @keydown.enter.prevent="connectorCreate()">
|
||||||
|
<label for="fd-plus-cn-url" x-text="connectorForm.hint || 'URL publique (hôtes privés refusés)'"></label>
|
||||||
|
<input id="fd-plus-cn-url" type="text" x-model="connectorForm.url" placeholder="https://api.exemple.com">
|
||||||
|
<label for="fd-plus-cn-secret">Clé / jeton — chiffrée, jamais renvoyée</label>
|
||||||
|
<input id="fd-plus-cn-secret" type="password" x-model="connectorForm.secret" placeholder="sk-…" autocomplete="off">
|
||||||
|
<div class="fd-proposal-bar">
|
||||||
|
<button class="fd-proposal-btn primary" type="button" @click="connectorCreate()">✓ Ajouter</button>
|
||||||
|
<button class="fd-proposal-btn" type="button" @click="plusBack()">← Retour</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="file" accept="application/json,.json" x-ref="skillImport" class="fd-plus-file" aria-label="Importer un skill JSON" @change="importSkillFile($event)">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="fd-ap-chips" x-show="contextChips.length" x-cloak>
|
<div class="fd-ap-chips" x-show="contextChips.length" x-cloak>
|
||||||
<div class="fd-chip-row" x-show="mentionChips.length">
|
<div class="fd-chip-row" x-show="mentionChips.length">
|
||||||
@@ -398,7 +491,7 @@
|
|||||||
data-placeholder="Demandez à l'agent… Tapez @ pour référencer une page/base, / pour un skill. Entrée = envoyer, Maj+Entrée = ligne"
|
data-placeholder="Demandez à l'agent… Tapez @ pour référencer une page/base, / pour un skill. Entrée = envoyer, Maj+Entrée = ligne"
|
||||||
@input="onInput()" @keydown="onComposerKeydown($event)" @paste="onPaste($event)"></div>
|
@input="onInput()" @keydown="onComposerKeydown($event)" @paste="onPaste($event)"></div>
|
||||||
<div class="fd-ap-toolbar">
|
<div class="fd-ap-toolbar">
|
||||||
<button class="fd-ap-add" type="button" title="Ajouter un élément au contexte (@ / +)" @click="toggleAddPicker()">+</button>
|
<button class="fd-ap-add" type="button" title="Ajouter au contexte : fichiers, répertoires, compétences…" aria-label="Menu d'ajout au contexte" aria-haspopup="menu" :aria-expanded="plusOpen" @click="toggleAddPicker()">+</button>
|
||||||
<span class="fd-ap-tip" x-text="composerHint"></span>
|
<span class="fd-ap-tip" x-text="composerHint"></span>
|
||||||
<div class="fd-ap-tools">
|
<div class="fd-ap-tools">
|
||||||
<button class="fd-ap-model" :class="{off: !hasActiveProvider}" type="button" @click="modelOpen=!modelOpen" title="Choisir fournisseur et modèle">
|
<button class="fd-ap-model" :class="{off: !hasActiveProvider}" type="button" @click="modelOpen=!modelOpen" title="Choisir fournisseur et modèle">
|
||||||
|
|||||||
+428
-20
@@ -13,6 +13,12 @@
|
|||||||
<link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}">
|
<link rel="stylesheet" href="/static/css/app.css?v={{ asset_version }}">
|
||||||
<link rel="stylesheet" href="/static/css/design-tokens.css?v={{ asset_version }}">
|
<link rel="stylesheet" href="/static/css/design-tokens.css?v={{ asset_version }}">
|
||||||
<link rel="stylesheet" href="/static/css/components.css?v={{ asset_version }}">
|
<link rel="stylesheet" href="/static/css/components.css?v={{ asset_version }}">
|
||||||
|
<link rel="stylesheet" href="/static/css/settings.css?v={{ asset_version }}">
|
||||||
|
<!-- My Tasks + page « /db » : lies dans le SHELL et non dans la zone
|
||||||
|
swappée. Un <link> à l'intérieur de `.main-wrapper` peut être retiré par
|
||||||
|
htmx lors d'une navigation partielle : la page revenait alors sans
|
||||||
|
aucune feuille de style (composants blancs en thème sombre). -->
|
||||||
|
<link rel="stylesheet" href="/static/css/my_tasks.css?v={{ asset_version }}">
|
||||||
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
|
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
|
||||||
<style>
|
<style>
|
||||||
/* ── Mobile responsive (v4.0.2) ── */
|
/* ── Mobile responsive (v4.0.2) ── */
|
||||||
@@ -126,11 +132,11 @@
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
<script src="/static/js/htmx.min.js" data-cfasync="false"></script>
|
<script src="/static/js/htmx.min.js" data-cfasync="false"></script>
|
||||||
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
|
||||||
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
|
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
|
||||||
</head>
|
</head>
|
||||||
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% endif %}>
|
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% endif %}>
|
||||||
<div class="app-layout" x-data="appState()">
|
<div class="app-layout" x-data="appState()" x-cloak>
|
||||||
|
|
||||||
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
|
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
|
||||||
<div class="sidebar-overlay"
|
<div class="sidebar-overlay"
|
||||||
@@ -241,24 +247,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation icons row -->
|
<!-- Navigation tabs row — collapsible pills: the active tab expands with
|
||||||
<div class="sidebar-nav-row">
|
its label, the others collapse to their icon. Selecting a tab replaces
|
||||||
<a href="{% if local_workspaces and local_workspaces|length > 0 %}/local-workspace?ws={{ local_workspaces[0].id }}{% else %}/workspaces{% endif %}" class="nav-icon-btn home-btn" :class="{ active: currentView === 'home' }" title="Home">
|
the whole sidebar content (Home tree / Chat history / Meetings / Inbox). -->
|
||||||
|
<div class="sidebar-nav-row" role="tablist" aria-label="Sidebar sections">
|
||||||
|
<a href="{% if local_ws_id %}/local-workspace?ws={{ local_ws_id }}{% elif local_workspaces and local_workspaces|length > 0 %}/local-workspace?ws={{ local_workspaces[0].id }}{% else %}/workspaces{% endif %}" class="nav-icon-btn home-btn nav-tab-btn" data-tab="home" :class="{ active: sidebarTab === 'home' }" @click="setSidebarTab('home')" :aria-selected="sidebarTab === 'home'" role="tab" title="Home">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg>
|
||||||
Home
|
<span class="nav-tab-label" :style="sidebarTab === 'home' ? 'display:inline-block' : 'display:none'">Home</span>
|
||||||
</a>
|
</a>
|
||||||
<button class="nav-icon-btn" title="Chat (Ctrl+J)" onclick="window.fdAgent&&window.fdAgent.open()">
|
<button type="button" class="nav-icon-btn nav-tab-btn" data-tab="chat" :class="{ active: sidebarTab === 'chat' }" @click="setSidebarTab('chat')" :aria-selected="sidebarTab === 'chat'" role="tab" title="Chat (Ctrl+J)">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
|
||||||
|
<span class="nav-tab-label" :style="sidebarTab === 'chat' ? 'display:inline-block' : 'display:none'">Chat</span>
|
||||||
|
<span class="nav-tab-badge" x-show="chatHistory.length" x-cloak x-text="chatHistory.length" :style="sidebarTab === 'chat' ? 'display:none' : ''"></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-icon-btn" title="Inbox">
|
<button type="button" class="nav-icon-btn nav-tab-btn" data-tab="meetings" :class="{ active: sidebarTab === 'meetings' }" @click="setSidebarTab('meetings')" :aria-selected="sidebarTab === 'meetings'" role="tab" title="Meetings">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||||
|
<span class="nav-tab-label" :style="sidebarTab === 'meetings' ? 'display:inline-block' : 'display:none'">Meeting</span>
|
||||||
|
<span class="nav-tab-badge" x-show="meetingToday.length" x-cloak x-text="meetingToday.length" :style="sidebarTab === 'meetings' ? 'display:none' : ''"></span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="nav-icon-btn nav-tab-btn" data-tab="inbox" :class="{ active: sidebarTab === 'inbox' }" @click="setSidebarTab('inbox')" :aria-selected="sidebarTab === 'inbox'" role="tab" title="Inbox">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z"/></svg>
|
||||||
|
<span class="nav-tab-label" :style="sidebarTab === 'inbox' ? 'display:inline-block' : 'display:none'">Inbox</span>
|
||||||
|
<span class="nav-tab-badge nav-tab-badge-accent" x-show="inboxUnread > 0" x-cloak x-text="inboxUnread > 99 ? '99+' : inboxUnread" :style="sidebarTab === 'inbox' ? 'display:none' : ''"></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-icon-btn" @click="openQuickFind" title="Search" style="margin-left:auto;">
|
<button type="button" class="nav-icon-btn" @click="openQuickFind" title="Search" style="margin-left:auto;">
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-scroll">
|
<div class="sidebar-scroll">
|
||||||
|
|
||||||
|
<!-- ── TAB: Home (workspace tree, recents, favorites, libraries…) ── -->
|
||||||
|
<div class="sidebar-tab-panel" x-show="sidebarTab === 'home'" x-cloak>
|
||||||
<!-- Workspace / Private -->
|
<!-- Workspace / Private -->
|
||||||
<div class="sidebar-section" x-show="isSectionVisible('workspace')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
|
<div class="sidebar-section" x-show="isSectionVisible('workspace')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
|
||||||
<div class="sidebar-section-header" @click="toggleSection('workspace')" @contextmenu.prevent="openSectionMenu($event, 'workspace')">
|
<div class="sidebar-section-header" @click="toggleSection('workspace')" @contextmenu.prevent="openSectionMenu($event, 'workspace')">
|
||||||
@@ -571,6 +591,123 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div><!-- /sidebar-tab-panel home -->
|
||||||
|
|
||||||
|
<!-- ── TAB: Chat (agent shortcuts + conversation history) ── -->
|
||||||
|
<div class="sidebar-tab-panel" x-show="sidebarTab === 'chat'" x-cloak>
|
||||||
|
<div class="sb-panel-header">
|
||||||
|
<span class="sb-panel-title">Chat</span>
|
||||||
|
<button type="button" class="sb-panel-action" @click="chatNewConversation()" title="New conversation">{{ fd_icon("plus",14) }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Circular agent shortcuts -->
|
||||||
|
<div class="sb-agent-row" x-show="agentList.length">
|
||||||
|
<template x-for="a in agentList.slice(0, 8)" :key="a.id">
|
||||||
|
<button type="button" class="sb-agent-avatar" :title="a.name" @click="agentOpen(a.id)">
|
||||||
|
<span x-text="a.icon || '\u{1F916}'"></span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-subgroup">
|
||||||
|
<div class="sidebar-subgroup-label">Past 30 days</div>
|
||||||
|
<ul class="sidebar-items">
|
||||||
|
<template x-for="c in chatRecent" :key="c.id">
|
||||||
|
<li class="sidebar-item" @click="chatOpenConversation(c.id)">
|
||||||
|
<span class="page-icon page-icon-svg">{{ fd_icon("message-square",14) }}</span>
|
||||||
|
<span class="page-name" x-text="c.title || 'New conversation'"></span>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
<li class="sidebar-item empty-hint" x-show="!chatLoading && !chatRecent.length">
|
||||||
|
<span class="page-name text-dim">No recent conversations</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-subgroup" x-show="chatOlder.length">
|
||||||
|
<div class="sidebar-subgroup-label">Older</div>
|
||||||
|
<ul class="sidebar-items">
|
||||||
|
<template x-for="c in chatOlder" :key="c.id">
|
||||||
|
<li class="sidebar-item" @click="chatOpenConversation(c.id)">
|
||||||
|
<span class="page-icon page-icon-svg">{{ fd_icon("message-square",14) }}</span>
|
||||||
|
<span class="page-name" x-text="c.title || 'New conversation'"></span>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-item empty-hint" x-show="chatLoading" style="padding:8px;">
|
||||||
|
<span class="page-name text-dim">Loading…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TAB: Meeting (upcoming events + quick actions) ── -->
|
||||||
|
<div class="sidebar-tab-panel" x-show="sidebarTab === 'meetings'" x-cloak>
|
||||||
|
<div class="sb-panel-header">
|
||||||
|
<span class="sb-panel-title">Meeting</span>
|
||||||
|
<button type="button" class="sb-panel-action" @click="loadMeetings(true)" title="Refresh">{{ fd_icon("refresh",14) }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sb-filter-row">
|
||||||
|
<button type="button" class="sb-filter-chip" :class="{ active: meetingFilter === 'upcoming' }" @click="meetingFilter = 'upcoming'">Upcoming</button>
|
||||||
|
<button type="button" class="sb-filter-chip" :class="{ active: meetingFilter === 'today' }" @click="meetingFilter = 'today'">Today</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sb-quick-actions">
|
||||||
|
<button type="button" class="sb-quick-btn" @click="meetingTranscribe()">{{ fd_icon("upload",14) }} Transcribe a podcast</button>
|
||||||
|
<button type="button" class="sb-quick-btn" @click="meetingNote()">{{ fd_icon("sparkles",14) }} New AI meeting note</button>
|
||||||
|
<input type="file" x-ref="meetingAudio" accept="audio/*" class="sb-hidden-input" @change="meetingUpload($event)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="sidebar-items">
|
||||||
|
<template x-for="ev in meetingVisible" :key="ev.collection_id + '-' + ev.id">
|
||||||
|
<li class="sidebar-item" @click="meetingOpen(ev)">
|
||||||
|
<span class="page-icon page-icon-svg">{{ fd_icon("calendar",14) }}</span>
|
||||||
|
<span class="page-name" x-text="ev.title"></span>
|
||||||
|
<span class="sb-item-meta" x-text="ev.today ? 'Today' : ev.date"></span>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
<li class="sidebar-item empty-hint" x-show="!meetingsLoading && !meetingVisible.length">
|
||||||
|
<span class="page-name text-dim" x-text="meetingFilter === 'today' ? 'Nothing scheduled today' : 'No upcoming meetings'"></span>
|
||||||
|
</li>
|
||||||
|
<li class="sidebar-item empty-hint" x-show="meetingsLoading">
|
||||||
|
<span class="page-name text-dim">Loading…</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── TAB: Inbox (notifications) ── -->
|
||||||
|
<div class="sidebar-tab-panel" x-show="sidebarTab === 'inbox'" x-cloak>
|
||||||
|
<div class="sb-panel-header">
|
||||||
|
<span class="sb-panel-title">Inbox <span class="sb-panel-count" x-show="inboxUnread > 0" x-text="inboxUnread"></span></span>
|
||||||
|
<div style="display:flex;gap:4px;">
|
||||||
|
<button type="button" class="sb-panel-action" x-show="inboxUnread > 0" @click="inboxMarkAllRead()" title="Mark all read">{{ fd_icon("check-square",14) }}</button>
|
||||||
|
<button type="button" class="sb-panel-action" @click="inboxFilter = (inboxFilter === 'all' ? 'unread' : 'all')"
|
||||||
|
:title="inboxFilter === 'all' ? 'Show unread only' : 'Show all'" x-text="inboxFilter === 'all' ? 'All' : 'Unread'"></button>
|
||||||
|
<button type="button" class="sb-panel-action" @click="loadInbox()" title="Refresh">{{ fd_icon("refresh",14) }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="sidebar-items">
|
||||||
|
<template x-for="n in inboxVisible" :key="n.id">
|
||||||
|
<li class="sidebar-item" :class="{ 'sb-item-unread': !n.is_read }" @click="inboxOpen(n)">
|
||||||
|
<span class="page-icon page-icon-svg">{{ fd_icon("bell",14) }}</span>
|
||||||
|
<span style="flex:1;min-width:0;">
|
||||||
|
<span class="page-name" style="display:block;" x-text="n.title"></span>
|
||||||
|
<span class="sb-item-sub" x-text="n.message"></span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
<li class="sidebar-item empty-hint" x-show="!inboxLoading && !inboxVisible.length" style="flex-direction:column;align-items:center;gap:4px;padding:22px 8px;">
|
||||||
|
<span style="font-size:13px;font-weight:600;color:var(--text-secondary);">Inbox Zero</span>
|
||||||
|
<span class="page-name text-dim">You’re all caught up</span>
|
||||||
|
</li>
|
||||||
|
<li class="sidebar-item empty-hint" x-show="inboxLoading">
|
||||||
|
<span class="page-name text-dim">Loading…</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<div style="display:flex; gap:6px;">
|
<div style="display:flex; gap:6px;">
|
||||||
@@ -592,38 +729,38 @@
|
|||||||
<div class="section-menu" x-show="sectionMenu.visible" x-cloak
|
<div class="section-menu" x-show="sectionMenu.visible" x-cloak
|
||||||
:style="sectionMenuPos()"
|
:style="sectionMenuPos()"
|
||||||
@click.outside="closeSectionMenu()">
|
@click.outside="closeSectionMenu()">
|
||||||
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 5)">
|
<div class="section-menu-item" @click="closeAndSetCount(5)">
|
||||||
<span class="smi-label">Show 5 items</span>
|
<span class="smi-label">Show 5 items</span>
|
||||||
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 5">✓</span>
|
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 5">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 10)">
|
<div class="section-menu-item" @click="closeAndSetCount(10)">
|
||||||
<span class="smi-label">Show 10 items</span>
|
<span class="smi-label">Show 10 items</span>
|
||||||
<span class="smi-check" x-show="!sidebarConfig[sectionMenu.section] || sidebarConfig[sectionMenu.section].show_count === 10 || sidebarConfig[sectionMenu.section].show_count == null">✓</span>
|
<span class="smi-check" x-show="!sidebarConfig[sectionMenu.section] || sidebarConfig[sectionMenu.section].show_count === 10 || sidebarConfig[sectionMenu.section].show_count == null">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 15)">
|
<div class="section-menu-item" @click="closeAndSetCount(15)">
|
||||||
<span class="smi-label">Show 15 items</span>
|
<span class="smi-label">Show 15 items</span>
|
||||||
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 15">✓</span>
|
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 15">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 20)">
|
<div class="section-menu-item" @click="closeAndSetCount(20)">
|
||||||
<span class="smi-label">Show 20 items</span>
|
<span class="smi-label">Show 20 items</span>
|
||||||
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 20">✓</span>
|
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 20">✓</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-menu-sep"></div>
|
<div class="section-menu-sep"></div>
|
||||||
<div class="section-menu-item" @click="closeSectionMenu(); moveSection(sectionMenu.section, 'up')">
|
<div class="section-menu-item" @click="closeAndMove('up')">
|
||||||
<span class="smi-icon">↑</span>
|
<span class="smi-icon">↑</span>
|
||||||
<span class="smi-label">Move up</span>
|
<span class="smi-label">Move up</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-menu-item" @click="closeSectionMenu(); moveSection(sectionMenu.section, 'down')">
|
<div class="section-menu-item" @click="closeAndMove('down')">
|
||||||
<span class="smi-icon">↓</span>
|
<span class="smi-icon">↓</span>
|
||||||
<span class="smi-label">Move down</span>
|
<span class="smi-label">Move down</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-menu-sep"></div>
|
<div class="section-menu-sep"></div>
|
||||||
<div class="section-menu-item" @click="closeSectionMenu(); toggleSectionVisibility(sectionMenu.section)">
|
<div class="section-menu-item" @click="closeAndToggleVisibility()">
|
||||||
<span class="smi-icon">👁</span>
|
<span class="smi-icon">👁</span>
|
||||||
<span class="smi-label" x-text="isSectionVisible(sectionMenu.section) ? 'Hide section' : 'Show section'"></span>
|
<span class="smi-label" x-text="isSectionVisible(sectionMenu.section) ? 'Hide section' : 'Show section'"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-menu-sep"></div>
|
<div class="section-menu-sep"></div>
|
||||||
<div class="section-menu-item section-menu-customize" @click="closeSectionMenu(); toggleCustomize()">
|
<div class="section-menu-item section-menu-customize" @click="menuCustomize()">
|
||||||
<span class="smi-icon">⚙️</span>
|
<span class="smi-icon">⚙️</span>
|
||||||
<span class="smi-label">Customize sidebar</span>
|
<span class="smi-label">Customize sidebar</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1128,6 +1265,15 @@
|
|||||||
Alpine.data('appState', appState);
|
Alpine.data('appState', appState);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Parse a SQL/ISO timestamp ("2026-10-04 12:00:00") into a ms epoch.
|
||||||
|
Shared by the sidebar Chat tab (grouping by recency). */
|
||||||
|
window.fdTime = function(value) {
|
||||||
|
if (!value) return 0;
|
||||||
|
var t = new Date(String(value).replace(' ', 'T'));
|
||||||
|
if (isNaN(t.getTime())) t = new Date(value);
|
||||||
|
return isNaN(t.getTime()) ? 0 : t.getTime();
|
||||||
|
};
|
||||||
|
|
||||||
function appState() {
|
function appState() {
|
||||||
return {
|
return {
|
||||||
init() {
|
init() {
|
||||||
@@ -1135,6 +1281,8 @@
|
|||||||
this.loadSidebarConfig();
|
this.loadSidebarConfig();
|
||||||
this.loadAgents();
|
this.loadAgents();
|
||||||
this.loadTeamspaces();
|
this.loadTeamspaces();
|
||||||
|
// Badge counts on the collapsed tabs (lazy fetch, no blocking).
|
||||||
|
this.loadInbox();
|
||||||
this.initSidebarWidth();
|
this.initSidebarWidth();
|
||||||
document.addEventListener('fd-toggle-sidebar', () => this.toggleSidebar());
|
document.addEventListener('fd-toggle-sidebar', () => this.toggleSidebar());
|
||||||
this.startClipAutoRefresh();
|
this.startClipAutoRefresh();
|
||||||
@@ -1240,6 +1388,176 @@
|
|||||||
giteaRepo: '{{ gitea_repo|default("") }}',
|
giteaRepo: '{{ gitea_repo|default("") }}',
|
||||||
giteaTree: [], // loaded client-side for sidebar
|
giteaTree: [], // loaded client-side for sidebar
|
||||||
currentView: 'home',
|
currentView: 'home',
|
||||||
|
|
||||||
|
// ── Collapsible sidebar tabs (Home / Chat / Meeting / Inbox) ──
|
||||||
|
// The active tab renders as a pill with its label, the others collapse
|
||||||
|
// to an icon; selecting one replaces the whole sidebar content.
|
||||||
|
sidebarTab: (function() {
|
||||||
|
try {
|
||||||
|
var saved = localStorage.getItem('fd_sidebar_tab');
|
||||||
|
return ['home', 'chat', 'meetings', 'inbox'].indexOf(saved) >= 0 ? saved : 'home';
|
||||||
|
} catch (e) { return 'home'; }
|
||||||
|
})(),
|
||||||
|
setSidebarTab(tab) {
|
||||||
|
this.sidebarTab = tab;
|
||||||
|
this.currentView = tab;
|
||||||
|
try { localStorage.setItem('fd_sidebar_tab', tab); } catch (e) {}
|
||||||
|
if (tab === 'chat') this.loadChatHistory();
|
||||||
|
else if (tab === 'meetings') this.loadMeetings();
|
||||||
|
else if (tab === 'inbox') this.loadInbox();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Chat tab ──
|
||||||
|
chatHistory: [],
|
||||||
|
chatLoading: false,
|
||||||
|
get chatRecent() {
|
||||||
|
var cut = Date.now() - 30 * 86400000;
|
||||||
|
return this.chatHistory.filter(function(c) { return fdTime(c.updated_at) >= cut; });
|
||||||
|
},
|
||||||
|
get chatOlder() {
|
||||||
|
var cut = Date.now() - 30 * 86400000;
|
||||||
|
return this.chatHistory.filter(function(c) { return fdTime(c.updated_at) < cut; });
|
||||||
|
},
|
||||||
|
loadChatHistory() {
|
||||||
|
var self = this;
|
||||||
|
this.chatLoading = true;
|
||||||
|
return fetch('/api/agent/conversations', { credentials: 'same-origin' })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(d) {
|
||||||
|
self.chatHistory = (d && d.conversations) || [];
|
||||||
|
self.chatLoading = false;
|
||||||
|
})
|
||||||
|
.catch(function() { self.chatLoading = false; });
|
||||||
|
},
|
||||||
|
chatOpenConversation(id) {
|
||||||
|
if (window.fdAgent && window.fdAgent.openConversation) window.fdAgent.openConversation(id);
|
||||||
|
else this.agentOpen();
|
||||||
|
},
|
||||||
|
chatNewConversation() {
|
||||||
|
if (window.fdAgent && window.fdAgent.open) window.fdAgent.open();
|
||||||
|
else this.agentNew();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Meeting tab ──
|
||||||
|
meetings: [],
|
||||||
|
meetingsLoading: false,
|
||||||
|
meetingFilter: 'upcoming',
|
||||||
|
get meetingVisible() {
|
||||||
|
if (this.meetingFilter === 'today') {
|
||||||
|
return this.meetings.filter(function(e) { return e.today; });
|
||||||
|
}
|
||||||
|
return this.meetings.filter(function(e) { return !e.today; });
|
||||||
|
},
|
||||||
|
get meetingToday() { return this.meetings.filter(function(e) { return e.today; }); },
|
||||||
|
loadMeetings(force) {
|
||||||
|
var self = this;
|
||||||
|
if (!force && this.meetings.length) return Promise.resolve();
|
||||||
|
this.meetingsLoading = true;
|
||||||
|
return fetch('/api/v2/meetings/upcoming?days=30', { credentials: 'same-origin' })
|
||||||
|
.then(function(r) { return r.ok ? r.json() : { events: [] }; })
|
||||||
|
.then(function(d) {
|
||||||
|
self.meetings = (d && d.events) || [];
|
||||||
|
self.meetingsLoading = false;
|
||||||
|
})
|
||||||
|
.catch(function() { self.meetings = []; self.meetingsLoading = false; });
|
||||||
|
},
|
||||||
|
meetingOpen(ev) {
|
||||||
|
var target = ev.url || ('/db/' + ev.collection_id);
|
||||||
|
if (window.fdNavigate) window.fdNavigate(target);
|
||||||
|
else window.location.href = target;
|
||||||
|
},
|
||||||
|
meetingTranscribe() {
|
||||||
|
var input = this.$refs && this.$refs.meetingAudio;
|
||||||
|
if (input) input.click();
|
||||||
|
},
|
||||||
|
meetingNote() {
|
||||||
|
if (window.fdAgent && window.fdAgent.ask) {
|
||||||
|
window.fdAgent.ask('/meeting-note');
|
||||||
|
} else if (window.fdAgent && window.fdAgent.open) {
|
||||||
|
window.fdAgent.open();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
meetingUpload(ev) {
|
||||||
|
var self = this;
|
||||||
|
var input = ev.target;
|
||||||
|
var file = input && input.files && input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
var title = 'Podcast ' + new Date().toISOString().slice(0, 10);
|
||||||
|
// A transcript row is attached to a page: create the meeting page first,
|
||||||
|
// then upload the audio against it.
|
||||||
|
fetch('/api/local-workspace/items', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
||||||
|
body: JSON.stringify({ name: title, type: 'page' })
|
||||||
|
})
|
||||||
|
.then(function(r) { if (!r.ok) throw new Error('page'); return r.json(); })
|
||||||
|
.then(function(page) {
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('audio', file);
|
||||||
|
fd.append('page_id', String(page.id));
|
||||||
|
return fetch('/api/v2/meetings/transcribe', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'X-CSRF-Token': self.getCsrfToken() },
|
||||||
|
body: fd
|
||||||
|
}).then(function(r) {
|
||||||
|
if (!r.ok) throw new Error('transcribe');
|
||||||
|
return r.json();
|
||||||
|
}).then(function() { return page; });
|
||||||
|
})
|
||||||
|
.then(function(page) {
|
||||||
|
self.toast('Audio uploaded — transcription attached to "' + title + '"', 'success');
|
||||||
|
window.location.href = '/pages/' + page.id;
|
||||||
|
})
|
||||||
|
.catch(function() { self.toast('Upload failed', 'error'); })
|
||||||
|
.then(function() { if (input) input.value = ''; });
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Inbox tab ──
|
||||||
|
inboxItems: [],
|
||||||
|
inboxUnread: 0,
|
||||||
|
inboxLoading: false,
|
||||||
|
inboxFilter: 'all',
|
||||||
|
get inboxVisible() {
|
||||||
|
if (this.inboxFilter === 'unread') {
|
||||||
|
return this.inboxItems.filter(function(n) { return !n.is_read; });
|
||||||
|
}
|
||||||
|
return this.inboxItems;
|
||||||
|
},
|
||||||
|
loadInbox() {
|
||||||
|
var self = this;
|
||||||
|
this.inboxLoading = true;
|
||||||
|
return fetch('/api/notifications?limit=50', { credentials: 'same-origin' })
|
||||||
|
.then(function(r) { return r.ok ? r.json() : { notifications: [], unread: 0 }; })
|
||||||
|
.then(function(d) {
|
||||||
|
self.inboxItems = (d && d.notifications) || [];
|
||||||
|
self.inboxUnread = (d && d.unread) || 0;
|
||||||
|
self.inboxLoading = false;
|
||||||
|
})
|
||||||
|
.catch(function() { self.inboxLoading = false; });
|
||||||
|
},
|
||||||
|
inboxOpen(n) {
|
||||||
|
if (!n.is_read) {
|
||||||
|
var self = this;
|
||||||
|
fetch('/api/notifications/read', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
||||||
|
body: JSON.stringify({ id: n.id })
|
||||||
|
}).then(function() { return self.loadInbox(); });
|
||||||
|
}
|
||||||
|
if (n.url) window.location.href = n.url;
|
||||||
|
},
|
||||||
|
inboxMarkAllRead() {
|
||||||
|
var self = this;
|
||||||
|
fetch('/api/notifications/read', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
}).then(function() { return self.loadInbox(); });
|
||||||
|
},
|
||||||
contextMenu: { visible: false, x: 0, y: 0, pageId: null, pageName: '' },
|
contextMenu: { visible: false, x: 0, y: 0, pageId: null, pageName: '' },
|
||||||
contextActions: {},
|
contextActions: {},
|
||||||
showNewPageMenu: false,
|
showNewPageMenu: false,
|
||||||
@@ -1463,6 +1781,10 @@
|
|||||||
this.sectionMenu = { section: section, visible: true, x: ev.clientX, y: ev.clientY };
|
this.sectionMenu = { section: section, visible: true, x: ev.clientX, y: ev.clientY };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
menuCustomize() { this.closeSectionMenu(); this.toggleCustomize(); },
|
||||||
|
closeAndSetCount(n) { this.closeSectionMenu(); this.setShowCount(this.sectionMenu.section, n); },
|
||||||
|
closeAndMove(dir) { this.closeSectionMenu(); this.moveSection(this.sectionMenu.section, dir); },
|
||||||
|
closeAndToggleVisibility() { this.closeSectionMenu(); this.toggleSectionVisibility(this.sectionMenu.section); },
|
||||||
closeSectionMenu() {
|
closeSectionMenu() {
|
||||||
this.sectionMenu.visible = false;
|
this.sectionMenu.visible = false;
|
||||||
},
|
},
|
||||||
@@ -1623,6 +1945,17 @@
|
|||||||
this.sidebarCollapsed = !this.sidebarCollapsed;
|
this.sidebarCollapsed = !this.sidebarCollapsed;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Hamburger: mobile opens the drawer (assignment expressions are illegal
|
||||||
|
// in the Alpine CSP build, hence this method), desktop toggles collapse.
|
||||||
|
fdHamburgerClick() {
|
||||||
|
if (window.matchMedia('(max-width: 768px)').matches) {
|
||||||
|
this.mobileSidebarOpen = true;
|
||||||
|
this.sidebarCollapsed = false;
|
||||||
|
} else {
|
||||||
|
this.toggleSidebar();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
closeSidebar() {
|
closeSidebar() {
|
||||||
if (this.sidebarPeek) { this.sidebarPeek = false; return; }
|
if (this.sidebarPeek) { this.sidebarPeek = false; return; }
|
||||||
this.sidebarCollapsed = true;
|
this.sidebarCollapsed = true;
|
||||||
@@ -1772,9 +2105,16 @@
|
|||||||
case 'openTab':
|
case 'openTab':
|
||||||
window.open('/' + pageId, '_blank');
|
window.open('/' + pageId, '_blank');
|
||||||
break;
|
break;
|
||||||
case 'openPeek':
|
case 'openPeek': {
|
||||||
this.toast('Side peek not available in browser', 'info');
|
// Route vers le panneau de peek DE LA PAGE courante (même
|
||||||
|
// éditeur allégé `?embed=1` que le bouton « ↢ Open »), jamais une
|
||||||
|
// navigation. En dehors de ces pages : repli sur un nouvel onglet.
|
||||||
|
var num = pageId.replace('page/', '');
|
||||||
|
if (window._libData && window._libData.openPeekById) window._libData.openPeekById(parseInt(num));
|
||||||
|
else if (window._wsData && window._wsData.openSidePeek) window._wsData.openSidePeek(parseInt(num));
|
||||||
|
else window.open('/pages/' + num, '_blank');
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.contextMenu.visible = false;
|
this.contextMenu.visible = false;
|
||||||
},
|
},
|
||||||
@@ -2234,6 +2574,14 @@
|
|||||||
.cmd-palette-overlay.open .cmd-palette{transform:translateY(0)}
|
.cmd-palette-overlay.open .cmd-palette{transform:translateY(0)}
|
||||||
.cmd-palette-input{width:100%;box-sizing:border-box;padding:16px 18px;background:transparent;border:none;outline:none;color:var(--text,#fff);font-size:16px}
|
.cmd-palette-input{width:100%;box-sizing:border-box;padding:16px 18px;background:transparent;border:none;outline:none;color:var(--text,#fff);font-size:16px}
|
||||||
.cmd-palette-input::placeholder{color:var(--text-dim,rgba(255,255,255,.4))}
|
.cmd-palette-input::placeholder{color:var(--text-dim,rgba(255,255,255,.4))}
|
||||||
|
.cmd-palette-tabs{display:flex;gap:6px;padding:10px 16px 0}
|
||||||
|
.cp-tab{background:none;border:1px solid var(--border,rgba(255,255,255,.12));color:var(--text-dim,rgba(255,255,255,.55));border-radius:999px;padding:4px 12px;font-size:12px;cursor:pointer}
|
||||||
|
.cp-tab.active{background:var(--bg-hover,#2a2a2a);color:var(--text,#eee);border-color:var(--accent,#2383e2)}
|
||||||
|
.cmd-palette-ai{padding:14px 18px;font-size:13.5px;line-height:1.55;white-space:pre-wrap;color:var(--text,#eee)}
|
||||||
|
.cmd-palette-ai .cp-cites{margin-top:10px;display:flex;flex-direction:column;gap:4px;font-size:12.5px;white-space:normal}
|
||||||
|
a.cp-cite{color:var(--accent,#529ffa);text-decoration:none}
|
||||||
|
a.cp-cite:hover{text-decoration:underline}
|
||||||
|
.cp-loading{color:var(--text-dim,rgba(255,255,255,.5))}
|
||||||
.cmd-palette-list{overflow-y:auto;border-top:1px solid var(--border,rgba(255,255,255,.06))}
|
.cmd-palette-list{overflow-y:auto;border-top:1px solid var(--border,rgba(255,255,255,.06))}
|
||||||
.cmd-palette-group{display:flex;align-items:center;gap:8px;padding:10px 18px 4px;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-dim,rgba(255,255,255,.4))}
|
.cmd-palette-group{display:flex;align-items:center;gap:8px;padding:10px 18px 4px;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-dim,rgba(255,255,255,.4))}
|
||||||
.cmd-palette-item{display:flex;align-items:center;gap:10px;padding:9px 18px;cursor:pointer;font-size:14px;color:var(--text,#fff)}
|
.cmd-palette-item{display:flex;align-items:center;gap:10px;padding:9px 18px;cursor:pointer;font-size:14px;color:var(--text,#fff)}
|
||||||
@@ -2255,6 +2603,10 @@
|
|||||||
<input id="fd-cp-input" class="cmd-palette-input" type="text"
|
<input id="fd-cp-input" class="cmd-palette-input" type="text"
|
||||||
placeholder="Search pages, databases, or type a command…"
|
placeholder="Search pages, databases, or type a command…"
|
||||||
autocomplete="off" spellcheck="false">
|
autocomplete="off" spellcheck="false">
|
||||||
|
<div class="cmd-palette-tabs" id="fd-cp-tabs" role="tablist">
|
||||||
|
<button type="button" class="cp-tab active" data-tab="pages">Pages</button>
|
||||||
|
<button type="button" class="cp-tab" data-tab="ai">✨ Réponses IA</button>
|
||||||
|
</div>
|
||||||
<div id="fd-cp-list" class="cmd-palette-list"></div>
|
<div id="fd-cp-list" class="cmd-palette-list"></div>
|
||||||
<div class="cmd-palette-footer">
|
<div class="cmd-palette-footer">
|
||||||
<span class="cpf-kbd"><b>↑</b> / <b>↓</b> navigate</span>
|
<span class="cpf-kbd"><b>↑</b> / <b>↓</b> navigate</span>
|
||||||
@@ -2275,7 +2627,7 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
var overlay, input, list;
|
var overlay, input, list;
|
||||||
var state = { open:false, items:[], index:0, query:'', timer:null, actions:false };
|
var state = { open:false, items:[], index:0, query:'', timer:null, actions:false, tab:'pages', ai:null };
|
||||||
|
|
||||||
function esc(s){ return String(s==null?'':s).replace(/[&<>"']/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); }
|
function esc(s){ return String(s==null?'':s).replace(/[&<>"']/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); }
|
||||||
function highlight(text, q){
|
function highlight(text, q){
|
||||||
@@ -2299,6 +2651,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function render(){
|
function render(){
|
||||||
|
if(state.tab==='ai'){ renderAi(); return; }
|
||||||
if(!state.open) return;
|
if(!state.open) return;
|
||||||
if(!state.items.length){
|
if(!state.items.length){
|
||||||
list.innerHTML = '<div class="cmd-palette-empty">No results for “'+esc(state.query)+'”</div>';
|
list.innerHTML = '<div class="cmd-palette-empty">No results for “'+esc(state.query)+'”</div>';
|
||||||
@@ -2335,6 +2688,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function runSearch(){
|
function runSearch(){
|
||||||
|
if(state.tab==='ai'){ runAi(); return; }
|
||||||
var q = input.value.trim();
|
var q = input.value.trim();
|
||||||
state.query = q;
|
state.query = q;
|
||||||
state.index = 0;
|
state.index = 0;
|
||||||
@@ -2359,6 +2713,50 @@
|
|||||||
.catch(function(){ state.items=[]; render(); });
|
.catch(function(){ state.items=[]; render(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runAi(){
|
||||||
|
var q = input.value.trim();
|
||||||
|
state.ai = null;
|
||||||
|
if(!q){ state.ai = {hint:1}; renderAi(); return; }
|
||||||
|
state.ai = {loading:1}; renderAi();
|
||||||
|
fetch('/api/v2/search/ask', {
|
||||||
|
method:'POST',
|
||||||
|
headers: {'Content-Type':'application/json', 'X-CSRF-Token': getCsrf()},
|
||||||
|
body: JSON.stringify({question: q})
|
||||||
|
}).then(function(r){ if(!r.ok) throw 0; return r.json(); })
|
||||||
|
.then(function(d){
|
||||||
|
if(state.tab!=='ai' || input.value.trim()!==q) return;
|
||||||
|
state.ai = d; renderAi();
|
||||||
|
})
|
||||||
|
.catch(function(){
|
||||||
|
if(state.tab!=='ai') return;
|
||||||
|
state.ai = {error:1}; renderAi();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderAi(){
|
||||||
|
var a = state.ai;
|
||||||
|
if(!a){ list.innerHTML=''; return; }
|
||||||
|
if(a.loading){ list.innerHTML='<div class="cmd-palette-ai"><span class="cp-loading">Recherche de la réponse…</span></div>'; return; }
|
||||||
|
if(a.error){ list.innerHTML='<div class="cmd-palette-ai">Demande impossible — réessayez.</div>'; return; }
|
||||||
|
if(a.hint){ list.innerHTML='<div class="cmd-palette-ai">Posez une question sur votre espace : la réponse cite vos pages.</div>'; return; }
|
||||||
|
var cits = a.citations || [], byId = {};
|
||||||
|
cits.forEach(function(c){ byId[String(c.id)] = c; });
|
||||||
|
// échappement AVANT injection des liens (ids = chiffres, type = [a-z]+)
|
||||||
|
var md = esc(a.answer_markdown || '')
|
||||||
|
.replace(/\[\[([a-z]+):(\d+)\]\]/g, function(m, type, id){
|
||||||
|
var c = byId[id];
|
||||||
|
var label = esc((c && (c.title || c.name)) || (type + ' #' + id));
|
||||||
|
var href = type === 'collection' ? '/db/' + id : '/pages/' + id;
|
||||||
|
return '<a class="cp-cite" href="' + href + '">' + label + '</a>';
|
||||||
|
})
|
||||||
|
.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
|
||||||
|
var cites = cits.map(function(c){
|
||||||
|
var href = c.type === 'collection' ? '/db/' + c.id : '/pages/' + c.id;
|
||||||
|
return '<a class="cp-cite" href="' + href + '">' + esc(c.title || c.name || 'page #' + c.id) + '</a>';
|
||||||
|
}).join('');
|
||||||
|
list.innerHTML = '<div class="cmd-palette-ai">' + md +
|
||||||
|
(cites ? '<div class="cp-cites"><b>Sources</b> ' + cites + '</div>' : '') + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
function open(){
|
function open(){
|
||||||
if(state.open) return;
|
if(state.open) return;
|
||||||
state.open=true; overlay.classList.add('open');
|
state.open=true; overlay.classList.add('open');
|
||||||
@@ -2413,6 +2811,16 @@
|
|||||||
list = document.getElementById('fd-cp-list');
|
list = document.getElementById('fd-cp-list');
|
||||||
if(!overlay) return;
|
if(!overlay) return;
|
||||||
input.addEventListener('input', function(){ clearTimeout(state.timer); state.timer=setTimeout(runSearch, 150); });
|
input.addEventListener('input', function(){ clearTimeout(state.timer); state.timer=setTimeout(runSearch, 150); });
|
||||||
|
var tabs = document.getElementById('fd-cp-tabs');
|
||||||
|
if(tabs) tabs.addEventListener('click', function(e){
|
||||||
|
var b = e.target && e.target.closest ? e.target.closest('.cp-tab') : null;
|
||||||
|
if(!b) return;
|
||||||
|
state.tab = b.getAttribute('data-tab');
|
||||||
|
var all = tabs.querySelectorAll('.cp-tab');
|
||||||
|
for(var i=0;i<all.length;i++) all[i].classList.toggle('active', all[i]===b);
|
||||||
|
state.index = 0;
|
||||||
|
runSearch();
|
||||||
|
});
|
||||||
document.addEventListener('keydown', onKey);
|
document.addEventListener('keydown', onKey);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
<template x-for="(f, i) in activeFilters" :key="i">
|
<template x-for="(f, i) in activeFilters" :key="i">
|
||||||
<div class="filter-pill">
|
<div class="filter-pill">
|
||||||
<span x-text="f.property + ': ' + f.value"></span>
|
<span x-text="f.property + ': ' + f.value"></span>
|
||||||
<button class="remove-filter" @click="removeFilter(i); refreshView()">✕</button>
|
<button class="remove-filter" @click="removeFilterAndRefresh(i)">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="filter-pill" style="cursor:pointer; position:relative;" @click="showStatusMenu = !showStatusMenu">
|
<div class="filter-pill" style="cursor:pointer; position:relative;" @click="showStatusMenu = !showStatusMenu">
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
<div class="dropdown-panel" :class="{ visible: showStatusMenu }" style="top:100%; left:0;"
|
<div class="dropdown-panel" :class="{ visible: showStatusMenu }" style="top:100%; left:0;"
|
||||||
@click.stop>
|
@click.stop>
|
||||||
<template x-for="s in statusOptions" :key="s.value">
|
<template x-for="s in statusOptions" :key="s.value">
|
||||||
<div class="dropdown-option" @click="toggleStatus(s.value); showStatusMenu = false; refreshView()">
|
<div class="dropdown-option" @click="pickStatus(s.value)">
|
||||||
<span class="option-checkbox" :class="{ selected: statusFilters.includes(s.value) }">
|
<span class="option-checkbox" :class="{ selected: statusFilters.includes(s.value) }">
|
||||||
<span x-show="statusFilters.includes(s.value)">✓</span>
|
<span x-show="statusFilters.includes(s.value)">✓</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="filter-add-btn" @click="addFilter()">+ Filter</button>
|
<button class="filter-add-btn" @click="addFilter()">+ Filter</button>
|
||||||
<button class="filter-reset-btn" @click="resetFilters(); refreshView()" x-show="activeFilters.length > 0">Reset</button>
|
<button class="filter-reset-btn" @click="resetFiltersAndRefresh()" x-show="activeFilters.length > 0">Reset</button>
|
||||||
|
|
||||||
<div class="view-toolbar-spacer"></div>
|
<div class="view-toolbar-spacer"></div>
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,7 @@
|
|||||||
style="width:100%; min-height:60px; background:var(--bg-secondary); border:1px solid var(--border);
|
style="width:100%; min-height:60px; background:var(--bg-secondary); border:1px solid var(--border);
|
||||||
border-radius:6px; padding:8px; color:var(--text-primary); font-family:inherit; font-size:13px;
|
border-radius:6px; padding:8px; color:var(--text-primary); font-family:inherit; font-size:13px;
|
||||||
resize:vertical; margin-top:8px;"
|
resize:vertical; margin-top:8px;"
|
||||||
@keydown.ctrl.enter="addComment($event.target.value); $event.target.value=''"></textarea>
|
@keydown.ctrl.enter="addCommentAndClear($event.target)"></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -104,7 +104,8 @@
|
|||||||
|
|
||||||
// globales window — probe « Undefined variable: cardDetail »).
|
// globales window — probe « Undefined variable: cardDetail »).
|
||||||
|
|
||||||
document.addEventListener('alpine:init', function () { Alpine.data('cardDetail', cardDetail); });
|
if (window.Alpine) { Alpine.data('cardDetail', cardDetail); }
|
||||||
|
else document.addEventListener('alpine:init', function () { Alpine.data('cardDetail', cardDetail); });
|
||||||
function cardDetail() {
|
function cardDetail() {
|
||||||
return {
|
return {
|
||||||
updateField(field, value) {
|
updateField(field, value) {
|
||||||
@@ -138,6 +139,7 @@
|
|||||||
})
|
})
|
||||||
.then(() => htmx.ajax('GET', `/api/issues/${owner}/${repo}/${issue_id}?format=html`, { target: '#card-modal-content', swap: 'innerHTML' }));
|
.then(() => htmx.ajax('GET', `/api/issues/${owner}/${repo}/${issue_id}?format=html`, { target: '#card-modal-content', swap: 'innerHTML' }));
|
||||||
},
|
},
|
||||||
|
addCommentAndClear(el) { this.addComment(el.value); el.value = ''; },
|
||||||
addComment(body) {
|
addComment(body) {
|
||||||
if (!body.trim()) return;
|
if (!body.trim()) return;
|
||||||
console.log('Add comment:', body);
|
console.log('Add comment:', body);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
{% block topbar %}
|
{% block topbar %}
|
||||||
{% set breadcrumb_items = [{"label": owner ~ "/" ~ repo, "url": None}] %}
|
{% set breadcrumb_items = [{"label": owner ~ "/" ~ repo, "url": None}] %}
|
||||||
{% set page_icon = "link" %}
|
{% set page_icon = "link" %}
|
||||||
{% set right_actions = '<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">' ~ fd_icon("file",14) ~ '+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">' ~ fd_icon("upload",14) ~ '</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">' ~ fd_icon("refresh",14) ~ '</button><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
|
{% set right_actions %}<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">{{ fd_icon("file",14) }}+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">{{ fd_icon("upload",14) }}</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">{{ fd_icon("refresh",14) }}</button><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
|
||||||
{% include '_header.html' %}
|
{% include '_header.html' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
<strong>Editing: <span x-text="editingPrivateTitle || 'Untitled'"></span></strong>
|
<strong>Editing: <span x-text="editingPrivateTitle || 'Untitled'"></span></strong>
|
||||||
<span style="flex:1;"></span>
|
<span style="flex:1;"></span>
|
||||||
<button class="btn" @click="savePrivate()">{{ fd_icon("save",14) }} Save</button>
|
<button class="btn" @click="savePrivate()">{{ fd_icon("save",14) }} Save</button>
|
||||||
<button class="btn" @click="editingPrivate=null;editingPrivateTitle='';editingPrivateContent='';">Cancel</button>
|
<button class="btn" @click="closePrivateEdit()">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="gw-content">
|
<div class="gw-content">
|
||||||
<input x-model="editingPrivateTitle" placeholder="Page title" style="width:100%;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:16px;margin-bottom:12px;outline:none;">
|
<input x-model="editingPrivateTitle" placeholder="Page title" style="width:100%;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:16px;margin-bottom:12px;outline:none;">
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block page_title %}Help{% endblock %}
|
||||||
|
{% block title_prefix %}Help{% endblock %}
|
||||||
|
{% block page_icon %}{{ fd_icon("help-circle",18) }}{% endblock %}
|
||||||
|
|
||||||
|
{% block topbar %}
|
||||||
|
{% set page_icon = "help-circle" %}
|
||||||
|
{% set page_title = "Help" %}
|
||||||
|
{% include '_header.html' %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{# Same panel skeleton as settings (shared /static/css/settings.css) #}
|
||||||
|
<style>
|
||||||
|
.help-body{margin-bottom:24px;max-width:760px;}
|
||||||
|
.help-body p{font-size:14px;color:var(--text-secondary);line-height:1.65;margin:0 0 10px;}
|
||||||
|
.help-body ul{margin:0 0 14px;padding-left:18px;}
|
||||||
|
.help-body li{font-size:14px;color:var(--text-secondary);line-height:1.6;margin-bottom:4px;}
|
||||||
|
.help-body code{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;padding:1px 6px;font-size:12.5px;}
|
||||||
|
.help-body h3{font-size:13px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);margin:22px 0 8px;padding-bottom:4px;border-bottom:1px solid var(--border);}
|
||||||
|
.help-kbd{display:inline-block;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:12px;color:var(--text);min-width:16px;text-align:center;}
|
||||||
|
.help-shortcut-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border-radius:6px;margin-bottom:2px;}
|
||||||
|
.help-shortcut-row:hover{background:var(--bg-hover);}
|
||||||
|
.help-shortcut-row .keys{display:flex;gap:4px;min-width:140px;}
|
||||||
|
.help-shortcut-row .desc{font-size:13px;color:var(--text-dim);}
|
||||||
|
.help-badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:11px;font-weight:600;}
|
||||||
|
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
|
||||||
|
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
|
||||||
|
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
|
||||||
|
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="settings-overlay" x-data="helpInit()" style="background:none;backdrop-filter:none;position:static;z-index:1;padding:0;">
|
||||||
|
<div class="settings-panel" style="width:100%;max-width:1050px;height:calc(100vh - 120px);margin:0 auto;" @keydown.escape="navOpen = false">
|
||||||
|
<button class="settings-menu-btn" @click="navOpen = !navOpen" title="Sections" aria-label="Open sections menu">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="settings-close" @click="close()" title="Close" aria-label="Close help">×</button>
|
||||||
|
|
||||||
|
<!-- Mobile drawer backdrop -->
|
||||||
|
<div class="settings-nav-backdrop" x-show="navOpen" x-cloak @click="navOpen = false"></div>
|
||||||
|
|
||||||
|
<!-- Left navigation -->
|
||||||
|
<div class="settings-nav" :class="{ 'nav-open': navOpen }" @click.capture="closeNavOnMobile()">
|
||||||
|
<div class="settings-nav-header">Basics</div>
|
||||||
|
<div class="settings-nav-item" :class="{ active: section==='start' }" @click="go('start')">🚀 Getting Started</div>
|
||||||
|
<div class="settings-nav-item" :class="{ active: section==='editor' }" @click="go('editor')">📝 Pages & Editor</div>
|
||||||
|
<div class="settings-nav-item" :class="{ active: section==='databases' }" @click="go('databases')">🗄️ Databases & Views</div>
|
||||||
|
<div class="settings-nav-item" :class="{ active: section==='tasks' }" @click="go('tasks')">✅ Tasks & Dependencies</div>
|
||||||
|
<div class="settings-nav-header">Workspace</div>
|
||||||
|
<div class="settings-nav-item" :class="{ active: section==='workspaces' }" @click="go('workspaces')">📁 Workspaces & Forges</div>
|
||||||
|
<div class="settings-nav-item" :class="{ active: section==='collab' }" @click="go('collab')">👥 Collaboration</div>
|
||||||
|
<div class="settings-nav-item" :class="{ active: section==='nav' }" @click="go('nav')">📚 Library, Trash & Search</div>
|
||||||
|
<div class="settings-nav-header">Features</div>
|
||||||
|
<div class="settings-nav-item" :class="{ active: section==='agent' }" @click="go('agent')">🤖 AI Agent & Automations</div>
|
||||||
|
<div class="settings-nav-item" :class="{ active: section==='integrations' }" @click="go('integrations')">🔌 Integrations & API</div>
|
||||||
|
<div class="settings-nav-item" :class="{ active: section==='pwa' }" @click="go('pwa')">📶 Offline & PWA</div>
|
||||||
|
<div class="settings-nav-item" :class="{ active: section==='auth' }" @click="go('auth')">🔐 Accounts & SSO</div>
|
||||||
|
<div class="settings-nav-header">Reference</div>
|
||||||
|
<div class="settings-nav-item" :class="{ active: section==='shortcuts' }" @click="go('shortcuts')">⌨️ Keyboard Shortcuts</div>
|
||||||
|
<div class="settings-nav-item" :class="{ active: section==='tips' }" @click="go('tips')">💡 Tips</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="settings-content">
|
||||||
|
|
||||||
|
<div class="help-body" x-show="section==='start'">
|
||||||
|
<h2>Getting Started</h2>
|
||||||
|
<p>FlowDeck is your private, self-hosted Notion-style workspace — pages, databases, and Git-forge (Gitea / GitHub) integration in one app.</p>
|
||||||
|
<h3>First steps</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Open <b>Workspaces</b> (<code>/workspaces</code>) to pick or create a workspace — local or connected to a forge.</li>
|
||||||
|
<li>Click <b>+ New page</b> in the sidebar to start writing, or press <span class="help-kbd">Ctrl</span>+<span class="help-kbd">N</span>.</li>
|
||||||
|
<li>Use the <b>Agent & IA</b> section in Settings to plug an LLM provider (OpenAI, Ollama, DeepInfra…) if you want AI features.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>The sidebar</h3>
|
||||||
|
<ul>
|
||||||
|
<li>The workspace header (top-left) switches workspaces and opens your account menu.</li>
|
||||||
|
<li>Tabs <b>Home / Chat / Meetings / Inbox</b> swap the sidebar content; pages are a drag-and-drop tree.</li>
|
||||||
|
<li>On mobile the sidebar is a drawer: open it with the hamburger button, close it by tapping outside or swiping left.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-body" x-show="section==='editor'">
|
||||||
|
<h2>Pages & Editor</h2>
|
||||||
|
<p>Notion-style block editor: every line is a block you can drag, convert, and nest.</p>
|
||||||
|
<h3>Blocks</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Type <code>/</code> for the slash menu — headings, callouts, toggles, code, math (KaTeX), tables, images, embeds, ToC…</li>
|
||||||
|
<li>Markdown shortcuts as you type: <code># </code> heading, <code>- </code> bullet, <code>1. </code> numbered, <code>> </code> quote, <code>[] </code> todo, <code>``` </code> code.</li>
|
||||||
|
<li>Drag the handle left of a block to move or nest it; <span class="help-kbd">Tab</span>/<span class="help-kbd">Shift+Tab</span> indent/outdent.</li>
|
||||||
|
<li>Inline formatting: bold, italic, code, links, colors via the selection toolbar.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Page features</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Cover image and icon on every page (click the top of the page).</li>
|
||||||
|
<li>Inline databases: add a table/board/calendar collection directly in a page.</li>
|
||||||
|
<li>Comments (threaded), page history with snapshots, backlinks.</li>
|
||||||
|
<li>Right-click any sidebar item for context menu: duplicate, rename, move, delete.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-body" x-show="section==='databases'">
|
||||||
|
<h2>Databases & Views</h2>
|
||||||
|
<p>Collections are databases with a JSON schema, independent of Gitea. <b>21 property types</b>: title, text, number, select, multi-select, status, date, person, checkbox, url, email, phone, files, unique_id, relation, rollup, formula, and auto-properties.</p>
|
||||||
|
<h3>Views</h3>
|
||||||
|
<ul>
|
||||||
|
<li><b>Table</b> — sortable columns, inline editing.</li>
|
||||||
|
<li><b>Board (Kanban)</b> — group by select/status, drag & drop between columns.</li>
|
||||||
|
<li><b>Calendar</b> — month grid by date property.</li>
|
||||||
|
<li><b>Gallery</b> — visual cards, configurable card size.</li>
|
||||||
|
<li><b>List</b> — compact rows with preview.</li>
|
||||||
|
<li><b>Timeline</b> — Gantt bars from date ranges.</li>
|
||||||
|
<li><b>Status Overview / Team Load</b> — donut + stacked-bar dashboards.</li>
|
||||||
|
</ul>
|
||||||
|
<h3>Relations, rollups, formulas</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Relations are bidirectional: link rows across collections.</li>
|
||||||
|
<li>Rollups aggregate related rows (12 aggregations: sum, count, average, min, max…).</li>
|
||||||
|
<li>Formulas support 19 functions over row properties.</li>
|
||||||
|
<li>Filters, sorts and grouping apply per-view.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-body" x-show="section==='tasks'">
|
||||||
|
<h2>Tasks & Dependencies</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Todo blocks anywhere in pages become checkboxes; the <b>My Tasks</b> page aggregates every task in every collection you can access.</li>
|
||||||
|
<li>My Tasks views: <b>All / Today / Overdue / Next 7 days</b>.</li>
|
||||||
|
<li><b>Sub-items</b>: unlimited hierarchy — a parent row is Done when all children are Done (status aggregate).</li>
|
||||||
|
<li><b>Dependencies</b>: Blocking / Blocked by between rows; transitioning a blocked task is refused until its blocker is done.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-body" x-show="section==='workspaces'">
|
||||||
|
<h2>Workspaces & Forges</h2>
|
||||||
|
<p>Organize work into separate workspaces, each with its own page tree, collections and members.</p>
|
||||||
|
<ul>
|
||||||
|
<li><span class="help-badge local">Local</span> Pages and files stored on your server (SQLite under <code>/data</code>).</li>
|
||||||
|
<li><span class="help-badge gitea">Gitea</span> Connect your Gitea account (Settings → Integrations): repos appear as workspaces, browse the file tree in the sidebar, create/edit files with commit messages, sync labels as tags.</li>
|
||||||
|
<li><span class="help-badge github">GitHub</span> Connect via Settings → Integrations for repo browsing.</li>
|
||||||
|
<li>Gitea legacy boards are auto-adapted into FlowDeck collections (GiteaBoardCompat).</li>
|
||||||
|
<li>The <b>Private</b> section appears when a remote workspace is active — those pages stay local.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-body" x-show="section==='collab'">
|
||||||
|
<h2>Collaboration</h2>
|
||||||
|
<ul>
|
||||||
|
<li><b>Roles</b>: workspace members are admin / editor / commenter / viewer.</li>
|
||||||
|
<li><b>Sharing</b>: the Share button (top-right of a page) — share with specific users or publish a read-only public link (<code>/p/your-slug</code>).</li>
|
||||||
|
<li><b>Teamspaces</b> (wiki): shared knowledge spaces with their own pages.</li>
|
||||||
|
<li><b>Meetings</b>: meeting notes with an action-items panel.</li>
|
||||||
|
<li><b>Sprints</b>: time-box collections of tasks, with burndown-ready statuses.</li>
|
||||||
|
<li><b>Notifications</b>: bell in the topbar for mentions, assignments, comments.</li>
|
||||||
|
<li>Live editing: changes propagate to other open tabs (real-time sync).</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-body" x-show="section==='nav'">
|
||||||
|
<h2>Library, Trash & Search</h2>
|
||||||
|
<ul>
|
||||||
|
<li><b>Library</b> (<code>/library</code>) — tabs for Recents, Favorites, Shared, Published, with filtering.</li>
|
||||||
|
<li><b>Trash</b> (<code>/trash</code>) — soft-deleted pages, 30-day retention, restore or purge.</li>
|
||||||
|
<li><b>Ctrl+K</b> — command palette: fuzzy search across pages, jump anywhere, start an AI answer.</li>
|
||||||
|
<li>Full-text search API: <code>/api/v2/search</code>.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-body" x-show="section==='agent'">
|
||||||
|
<h2>AI Agent & Automations</h2>
|
||||||
|
<ul>
|
||||||
|
<li><b>Chat sidebar</b> (Chat tab): conversations with your configured LLM; pages can be attached as context.</li>
|
||||||
|
<li><b>Agent panel</b> on pages: propose edits, generate content, answer questions about the workspace.</li>
|
||||||
|
<li><b>Skill marketplace</b>: export/import portable skills, install presets from the <code>/</code> gallery.</li>
|
||||||
|
<li><b>Automations</b> (Settings → Automations): visual pipeline of trigger → condition → delay → action (webhook, notification, page update…), with run history and legacy JSON conversion.</li>
|
||||||
|
<li>Configure providers in <b>Settings → Agent & IA</b>: per-provider API keys, model lists, offline mode without any provider.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-body" x-show="section==='integrations'">
|
||||||
|
<h2>Integrations & API</h2>
|
||||||
|
<ul>
|
||||||
|
<li><b>Public API v2</b> — <code>/api/v2</code>: CRUD on collections, pages, properties, views, comments, notifications, favorites, tags, sharing, sprints, templates. Bearer tokens (Settings → API tokens) with scopes <code>read</code>/<code>write</code>/<code>admin</code>, pagination, filters, sorting, idempotency keys, RFC 7807 errors. Interactive docs at <a href="/docs" target="_blank" rel="noopener">/docs</a>.</li>
|
||||||
|
<li><b>Agent API</b> — <code>/api/v2/agents/*</code>: agents, conversations, synchronous JSON run, action journal with rollback, external trigger.</li>
|
||||||
|
<li><b>Webhooks</b>: outgoing events (create/update/delete) managed in Settings → Integrations; incoming Gitea webhooks keep boards in sync.</li>
|
||||||
|
<li><b>Web Clipper</b> — browser extension (Manifest V3): clip article / selection / bookmark / screenshot straight into FlowDeck.</li>
|
||||||
|
<li><b>Import / Export</b>: CSV import, JSON/CSV/Markdown export.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-body" x-show="section==='pwa'">
|
||||||
|
<h2>Offline mode (PWA)</h2>
|
||||||
|
<p>FlowDeck is a Progressive Web App: pages you visited stay available offline and your edits are saved locally, then synchronised when the connection returns.</p>
|
||||||
|
<ul>
|
||||||
|
<li><b>Install:</b> browser menu → <i>Install app</i> (Chrome/Edge) or <i>Add to Home Screen</i> (Safari/iOS).</li>
|
||||||
|
<li><b>Offline editing:</b> while offline, the editor stores changes in the browser (IndexedDB) and shows an offline banner with the number of pending changes. A ⟳ icon marks pages with unsynced edits.</li>
|
||||||
|
<li><b>Reconnection:</b> the queue is replayed automatically (and via Background Sync); a spinner badge appears while syncing, then a confirmation toast.</li>
|
||||||
|
<li><b>Conflicts:</b> the latest edit wins with a notice; server-side-deleted pages are recreated as orphan pages; title collisions get an <i>“…(copie offline)”</i> suffix.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-body" x-show="section==='auth'">
|
||||||
|
<h2>Accounts & SSO</h2>
|
||||||
|
<p>
|
||||||
|
<span class="help-badge local">Local</span> Email + password — create an account on the login page.<br><br>
|
||||||
|
<span class="help-badge gitea">Gitea OAuth</span> / <span class="help-badge github">GitHub OAuth</span> — sign in with your forge; your repos become workspaces. You can link a forge to an existing local account — your identity stays local.<br><br>
|
||||||
|
<span class="help-badge sso">SSO</span> <b>Enterprise SSO</b> — in <b>Settings → Admin → SSO / Enterprise</b>:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li><b>SAML 2.0</b> — paste IdP Entity ID, SSO URL and signing certificate; give the IdP your <code>/auth/saml/metadata</code> link.</li>
|
||||||
|
<li><b>OpenID Connect</b> — Issuer URL, Client ID and Client Secret (PKCE, scopes <code>openid profile email</code>).</li>
|
||||||
|
<li>Just-in-time provisioning, IdP groups mapped to workspace roles, <i>SSO only</i> mode disables local login (admins keep their door). Every attempt is audited in the login history.</li>
|
||||||
|
</ul>
|
||||||
|
<p>Sessions and API tokens are managed in Settings → Sessions / API tokens.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-body" x-show="section==='shortcuts'">
|
||||||
|
<h2>Keyboard Shortcuts</h2>
|
||||||
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">K</span></div><div class="desc">Command palette / quick find</div></div>
|
||||||
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">N</span></div><div class="desc">Create new page</div></div>
|
||||||
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">S</span></div><div class="desc">Save current page</div></div>
|
||||||
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">O</span></div><div class="desc">New AI chat (footer)</div></div>
|
||||||
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">/</span></div><div class="desc">Slash command menu (in editor)</div></div>
|
||||||
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">F2</span></div><div class="desc">Rename selected item</div></div>
|
||||||
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Delete</span></div><div class="desc">Move selected item to trash</div></div>
|
||||||
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Enter</span></div><div class="desc">Open selected page</div></div>
|
||||||
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Tab</span> / <span class="help-kbd">Shift</span>+<span class="help-kbd">Tab</span></div><div class="desc">Indent / outdent block</div></div>
|
||||||
|
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Escape</span></div><div class="desc">Close modal / cancel editing</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="help-body" x-show="section==='tips'">
|
||||||
|
<h2>Tips</h2>
|
||||||
|
<ul>
|
||||||
|
<li>Toggle the desktop sidebar with the « button; collapsed, hover the left edge to peek it.</li>
|
||||||
|
<li><b>Ctrl+Click</b> / <b>Shift+Click</b> multi-select in the sidebar tree.</li>
|
||||||
|
<li>Hover any sidebar item for quick actions (favorite, share, delete).</li>
|
||||||
|
<li>Drag pages into the tree to reorganize; drop onto a page to nest it.</li>
|
||||||
|
<li>The section <b>⋮</b> menus reorder or hide sidebar sections to taste.</li>
|
||||||
|
<li>Dark/light theme toggle is in the topbar and persists across reloads.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script data-cfasync="false" src="/static/js/help.js?v={{ asset_version }}"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Importer — FlowDeck</title>
|
<title>Importer — FlowDeck</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||||
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
|
||||||
<style>
|
<style>
|
||||||
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;--warn:#D9730D;}
|
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;--warn:#D9730D;}
|
||||||
*{margin:0;padding:0;box-sizing:border-box;}
|
*{margin:0;padding:0;box-sizing:border-box;}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ── Library layout ── */
|
/* ── Library layout ── */
|
||||||
.lib-container{max-width:1100px;margin:0 auto;padding:20px 24px;}
|
/* .lib-container est défini dans app.css (largeur standard des pages) */
|
||||||
.lib-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;}
|
.lib-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;}
|
||||||
.lib-header h1{font-size:24px;font-weight:700;color:var(--text-primary);margin:0;}
|
.lib-header h1{font-size:24px;font-weight:700;color:var(--text-primary);margin:0;}
|
||||||
.lib-header-actions{display:flex;align-items:center;gap:8px;}
|
.lib-header-actions{display:flex;align-items:center;gap:8px;}
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
.peek-header button{background:transparent;border:none;color:var(--text-secondary);cursor:pointer;padding:6px;border-radius:4px;font-size:13px;display:flex;align-items:center;}
|
.peek-header button{background:transparent;border:none;color:var(--text-secondary);cursor:pointer;padding:6px;border-radius:4px;font-size:13px;display:flex;align-items:center;}
|
||||||
.peek-header button:hover{background:var(--bg-hover);color:var(--text-primary);}
|
.peek-header button:hover{background:var(--bg-hover);color:var(--text-primary);}
|
||||||
.peek-header button svg{width:15px;height:15px;}
|
.peek-header button svg{width:15px;height:15px;}
|
||||||
.peek-body{flex:1;overflow-y:auto;padding:16px 20px;}
|
.peek-body{flex:1;overflow:hidden;padding:0;}
|
||||||
.peek-body .peek-empty{text-align:center;padding:60px 20px;color:var(--text-dim);}
|
.peek-body .peek-empty{text-align:center;padding:60px 20px;color:var(--text-dim);}
|
||||||
.peek-loading{text-align:center;padding:40px;color:var(--text-dim);}
|
.peek-loading{text-align:center;padding:40px;color:var(--text-dim);}
|
||||||
|
|
||||||
@@ -233,7 +233,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="lib-toolbar">
|
<div class="lib-toolbar">
|
||||||
<div class="lib-toolbar-wrap" x-show="tab !== 'repository'">
|
<div class="lib-toolbar-wrap" x-show="tab !== 'repository'">
|
||||||
<button class="lib-icon-btn" :class="{active: filterOpen}" title="Filter" @click.stop="filterOpen=!filterOpen; sortOpen=false; viewOpen=false">
|
<button class="lib-icon-btn" :class="{active: filterOpen}" title="Filter" @click.stop="toggleFilterMenu()">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="lib-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition>
|
<div class="lib-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition>
|
||||||
@@ -245,7 +245,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="lib-toolbar-wrap">
|
<div class="lib-toolbar-wrap">
|
||||||
<button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="sortOpen=!sortOpen; filterOpen=false; viewOpen=false">
|
<button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSortMenu()">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/><polyline points="17 5 17 19"/><polyline points="13 16 17 20 21 16"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/><polyline points="17 5 17 19"/><polyline points="13 16 17 20 21 16"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="lib-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition>
|
<div class="lib-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition>
|
||||||
@@ -259,7 +259,7 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="lib-toolbar-wrap">
|
<div class="lib-toolbar-wrap">
|
||||||
<button class="lib-icon-btn" :class="{active: viewOpen}" title="View options" @click.stop="viewOpen=!viewOpen; filterOpen=false; sortOpen=false">
|
<button class="lib-icon-btn" :class="{active: viewOpen}" title="View options" @click.stop="toggleViewMenu()">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/><circle cx="9" cy="6" r="1.5" fill="currentColor" stroke="none"/><circle cx="15" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="9" cy="18" r="1.5" fill="currentColor" stroke="none"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/><circle cx="9" cy="6" r="1.5" fill="currentColor" stroke="none"/><circle cx="15" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="9" cy="18" r="1.5" fill="currentColor" stroke="none"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="lib-dropdown" x-show="viewOpen" @click.outside="viewOpen=false" x-transition>
|
<div class="lib-dropdown" x-show="viewOpen" @click.outside="viewOpen=false" x-transition>
|
||||||
@@ -408,7 +408,7 @@
|
|||||||
<div class="peek-overlay" :class="{ open: peekItem !== null }" @click.outside="closePeek()">
|
<div class="peek-overlay" :class="{ open: peekItem !== null }" @click.outside="closePeek()">
|
||||||
<div id="lib-peek-resize-handle" style="position:absolute;left:-4px;top:0;width:8px;height:100%;cursor:col-resize;z-index:501;" title="Drag to resize — click to close"></div>
|
<div id="lib-peek-resize-handle" style="position:absolute;left:-4px;top:0;width:8px;height:100%;cursor:col-resize;z-index:501;" title="Drag to resize — click to close"></div>
|
||||||
<template x-if="peekItem">
|
<template x-if="peekItem">
|
||||||
<div style="display:flex;flex-direction:column;height:100%;">
|
<div style="flex-shrink:0;">
|
||||||
<div class="peek-header">
|
<div class="peek-header">
|
||||||
<button @click="closePeek()" title="Close">
|
<button @click="closePeek()" title="Close">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
@@ -445,11 +445,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="peek-body" style="flex:1;display:flex;flex-direction:column;">
|
|
||||||
<iframe id="lib-peek-iframe" :src="peekItem ? '/pages/' + peekItem.id + '?embed=1' : ''" style="width:100%;height:100%;border:none;flex:1;"></iframe>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- Le corps et l'iframe sont HORS du <template x-if> : ils existent donc en
|
||||||
|
permanence, ce qui permet de piloter `src` en JS (le build CSP
|
||||||
|
d'Alpine n'evalue pas le binding `:src` — l'iframe restait donc vide).
|
||||||
|
Charger `/pages/{id}?embed=1` : le peek affiche uniquement le
|
||||||
|
document en mode edition, sans sidebar ni barre de navigation. -->
|
||||||
|
<div class="peek-body" style="flex:1;display:flex;flex-direction:column;min-height:0;">
|
||||||
|
<div class="peek-loading" id="lib-peek-loading">Chargement…</div>
|
||||||
|
<iframe id="lib-peek-iframe" src="about:blank" title="Document" style="width:100%;height:100%;border:none;flex:1;min-height:0;"></iframe>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- /x-data -->
|
</div><!-- /x-data -->
|
||||||
|
|||||||
@@ -553,7 +553,7 @@
|
|||||||
<div class="ws-toolbar" style="margin-left:auto;">
|
<div class="ws-toolbar" style="margin-left:auto;">
|
||||||
<!-- View dropdown -->
|
<!-- View dropdown -->
|
||||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||||
<button class="ws-icon-btn" :class="{active: viewMenuOpen}" title="View" @click.stop="viewMenuOpen=!viewMenuOpen; sortOpen=false; searchOpen=false">
|
<button class="ws-icon-btn" :class="{active: viewMenuOpen}" title="View" @click.stop="toggleViewMenu()">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="ws-dropdown" x-show="viewMenuOpen" @click.outside="viewMenuOpen=false" x-transition style="right:0;">
|
<div class="ws-dropdown" x-show="viewMenuOpen" @click.outside="viewMenuOpen=false" x-transition style="right:0;">
|
||||||
@@ -567,7 +567,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Sort -->
|
<!-- Sort -->
|
||||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||||
<button class="ws-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="sortOpen=!sortOpen; viewMenuOpen=false">
|
<button class="ws-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSortMenu()">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/><polyline points="17 5 17 19"/><polyline points="13 16 17 20 21 16"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/><polyline points="17 5 17 19"/><polyline points="13 16 17 20 21 16"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="ws-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition style="right:0;">
|
<div class="ws-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition style="right:0;">
|
||||||
@@ -581,7 +581,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- Filter -->
|
<!-- Filter -->
|
||||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||||
<button class="ws-icon-btn" :class="{active: filterOpen || filterType}" title="Filter" @click.stop="filterOpen=!filterOpen; viewMenuOpen=false; sortOpen=false">
|
<button class="ws-icon-btn" :class="{active: filterOpen || filterType}" title="Filter" @click.stop="toggleFilterMenu()">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="ws-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition style="right:0;">
|
<div class="ws-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition style="right:0;">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
|||||||
{% set page_icon = "file" if page.content_format != 'file' else "paperclip" %}
|
{% set page_icon = "file" if page.content_format != 'file' else "paperclip" %}
|
||||||
{% set page_title = page.title %}
|
{% set page_title = page.title %}
|
||||||
{% set nav_page_id = page.id %}
|
{% set nav_page_id = page.id %}
|
||||||
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="edCall(\'toggleActivityOpen\')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn" @click="edCall(\'toggleComments\')" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="edCommentCount()"></span></button><button class="topbar-btn share-btn" @click="edCall(\'toggleShareOpen\')"><span x-show="!edShared()">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall(\'copyPageLink\')" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="edCall(\'toggleFavorite\')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall(\'toggleMoreOpen\')">⋯</button>' %}
|
{% set right_actions %}<span class="topbar-edited" style="cursor:pointer;" @click="edCall('toggleActivityOpen')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn" @click="edCall('toggleComments')" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="edCommentCount()"></span></button><button class="topbar-btn share-btn" @click="edCall('toggleShareOpen')"><span x-show="!edShared()">{{ fd_icon("lock",14) }} Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall('copyPageLink')" title="Copy link">{{ fd_icon("link",14) }}</button><button class="topbar-btn star-btn" @click="edCall('toggleFavorite')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall('toggleMoreOpen')">⋯</button>{% endset %}
|
||||||
{% include '_header.html' %}
|
{% include '_header.html' %}
|
||||||
{% endblock %} {% block content %}
|
{% endblock %} {% block content %}
|
||||||
{% include "_page_editor_content.html" %}
|
{% include "_page_editor_content.html" %}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||||
{% set page_icon = "file" %}
|
{% set page_icon = "file" %}
|
||||||
{% set page_title = page.title %}
|
{% set page_title = page.title %}
|
||||||
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="edCall(\'toggleActivityOpen\')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn share-btn" @click="edCall(\'toggleShareOpen\')"><span x-show="!edShared()">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall(\'copyPageLink\')" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="edCall(\'toggleFavorite\')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall(\'toggleMoreOpen\')">⋯</button>' %}
|
{% set right_actions %}<span class="topbar-edited" style="cursor:pointer;" @click="edCall('toggleActivityOpen')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn share-btn" @click="edCall('toggleShareOpen')"><span x-show="!edShared()">{{ fd_icon("lock",14) }} Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall('copyPageLink')" title="Copy link">{{ fd_icon("link",14) }}</button><button class="topbar-btn star-btn" @click="edCall('toggleFavorite')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall('toggleMoreOpen')">⋯</button>{% endset %}
|
||||||
{% include '_header.html' %}
|
{% include '_header.html' %}
|
||||||
{% endblock %} {% block content %}
|
{% endblock %} {% block content %}
|
||||||
|
<h1 class="database-page-title">{{ page.title }}</h1>
|
||||||
{% include "_database_table.html" %}
|
{% include "_database_table.html" %}
|
||||||
{% endblock %} {% block scripts %}
|
{% endblock %} {% block scripts %}
|
||||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
|
|||||||
@@ -8,4 +8,9 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
{% include '_page_editor_scripts.html' %}
|
{% include '_page_editor_scripts.html' %}
|
||||||
|
{# database_table.js = FlowDeckDB + le panneau de peek des bases : sans cet
|
||||||
|
include, une base embarquée dans un document restait bloquée à
|
||||||
|
« Loading database… » dans le peek et l'icône Open de fin de ligne ne
|
||||||
|
faisait rien. #}
|
||||||
|
{% include '_database_table_scripts.html' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+152
-32
@@ -11,16 +11,6 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<style>
|
<style>
|
||||||
.settings-overlay{position:fixed;inset:0;z-index:500;display:flex;background:rgba(0,0,0,.4);backdrop-filter:blur(2px);}
|
|
||||||
.settings-panel{display:flex;width:1050px;max-width:98vw;height:85vh;margin:auto;background:var(--bg-primary);border:1px solid var(--border-strong);border-radius:14px;box-shadow:var(--shadow-modal);overflow:hidden;}
|
|
||||||
.settings-nav{width:200px;min-width:200px;background:var(--bg-sidebar);border-right:1px solid var(--border);padding:8px 0;overflow-y:auto;}
|
|
||||||
.settings-nav-header{padding:12px 16px;font-size:13px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px;}
|
|
||||||
.settings-nav-item{display:flex;align-items:center;gap:8px;padding:6px 16px;font-size:13px;color:var(--text-secondary);cursor:pointer;transition:background 100ms;border-radius:0;}
|
|
||||||
.settings-nav-item:hover{background:var(--bg-hover);}
|
|
||||||
.settings-nav-item.active{background:rgba(35,131,226,.12);color:var(--text);font-weight:500;}
|
|
||||||
.settings-content{flex:1;overflow-y:auto;padding:24px 32px;}
|
|
||||||
.settings-content h2{font-size:18px;font-weight:600;margin-bottom:4px;}
|
|
||||||
.settings-content .section-desc{font-size:12px;color:var(--text-dim);margin-bottom:24px;}
|
|
||||||
.setting-group{margin-bottom:28px;}
|
.setting-group{margin-bottom:28px;}
|
||||||
.setting-group h3{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);margin-bottom:8px;padding-bottom:4px;border-bottom:1px solid var(--border);}
|
.setting-group h3{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-dim);margin-bottom:8px;padding-bottom:4px;border-bottom:1px solid var(--border);}
|
||||||
.setting-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border);}
|
.setting-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border);}
|
||||||
@@ -69,9 +59,7 @@
|
|||||||
.status-dot.active{background:var(--toast-success-bg);}
|
.status-dot.active{background:var(--toast-success-bg);}
|
||||||
.status-dot.inactive{background:var(--danger);}
|
.status-dot.inactive{background:var(--danger);}
|
||||||
|
|
||||||
/* Close button */
|
/* Close button — shared styles in /static/css/settings.css */
|
||||||
.settings-close{position:absolute;top:12px;right:12px;width:32px;height:32px;background:none;border:none;color:var(--text-dim);font-size:20px;cursor:pointer;border-radius:6px;display:flex;align-items:center;justify-content:center;}
|
|
||||||
.settings-close:hover{background:var(--bg-hover);color:var(--text);}
|
|
||||||
|
|
||||||
/* Agent & IA — status summary + provider cards */
|
/* Agent & IA — status summary + provider cards */
|
||||||
.llm-summary{display:flex;gap:14px;align-items:flex-start;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:12px;padding:14px 16px;margin-bottom:22px;flex-wrap:wrap;}
|
.llm-summary{display:flex;gap:14px;align-items:flex-start;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:12px;padding:14px 16px;margin-bottom:22px;flex-wrap:wrap;}
|
||||||
@@ -125,35 +113,41 @@
|
|||||||
.llm-detail .prov-field-row{margin-top:12px;}
|
.llm-detail .prov-field-row{margin-top:12px;}
|
||||||
.llm-detail-empty{display:flex;align-items:center;justify-content:center;height:260px;font-size:13px;color:var(--text-dim);text-align:center;padding:0 20px;}
|
.llm-detail-empty{display:flex;align-items:center;justify-content:center;height:260px;font-size:13px;color:var(--text-dim);text-align:center;padding:0 20px;}
|
||||||
@media (max-width:760px){.llm-layout{grid-template-columns:1fr;}.llm-list{max-height:220px;}}
|
@media (max-width:760px){.llm-layout{grid-template-columns:1fr;}.llm-list{max-height:220px;}}
|
||||||
|
|
||||||
|
/* ── Mobile side-nav drawer: shared settings.css handles overlay/nav/panel ── */
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="historyBack()">
|
<div class="settings-overlay" x-data="settingsInit()">
|
||||||
<div class="settings-panel" style="position:relative;">
|
<div class="settings-panel" style="position:relative;" @keydown.escape="historyBack()">
|
||||||
|
<button class="settings-menu-btn" @click="navOpen = !navOpen" title="Sections" aria-label="Open sections menu">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
|
||||||
|
</button>
|
||||||
<button class="settings-close" @click="closeSettings()" title="Close">×</button>
|
<button class="settings-close" @click="closeSettings()" title="Close">×</button>
|
||||||
|
|
||||||
<!-- Left navigation -->
|
<!-- Left navigation (mobile: side-navigation drawer via hamburger + backdrop) -->
|
||||||
<div class="settings-nav">
|
<div class="settings-nav-backdrop" x-show="navOpen" x-cloak @click="navOpen = false"></div>
|
||||||
|
<div class="settings-nav" :class="{ 'nav-open': navOpen }" @click.capture="closeNavOnMobile()">
|
||||||
<div class="settings-nav-header">Account</div>
|
<div class="settings-nav-header">Account</div>
|
||||||
<div class="settings-nav-item" :class="{ active: activeSection==='account' }" @click="activeSection='account'"><span class="nav-icon-inline">{{ fd_icon("user",14) }}</span> My account</div>
|
<div class="settings-nav-item" :class="{ active: activeSection==='account' }" @click="activeSection='account'"><span class="nav-icon-inline">{{ fd_icon("user",14) }}</span> My account</div>
|
||||||
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'"><span class="nav-icon-inline">{{ fd_icon("bell",14) }}</span> Notifications</div>
|
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'"><span class="nav-icon-inline">{{ fd_icon("bell",14) }}</span> Notifications</div>
|
||||||
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="activeSection='api-tokens'; loadApiTokens()"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
|
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="navTo('api-tokens')"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
|
||||||
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="activeSection='sessions'; loadSessions()"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
|
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="navTo('sessions')"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
|
||||||
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="activeSection='extensions'; loadClipperDevices()"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>
|
{% if plugin_enabled('web-clipper') %}<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="navTo('extensions')"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>{% endif %}
|
||||||
<div class="settings-nav-header">Workspace</div>
|
<div class="settings-nav-header">Workspace</div>
|
||||||
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">{{ fd_icon("file",14) }} General</div>
|
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">{{ fd_icon("file",14) }} General</div>
|
||||||
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
|
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
|
||||||
<div class="settings-nav-header">Features</div>
|
<div class="settings-nav-header">Features</div>
|
||||||
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div>
|
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div>
|
||||||
<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="activeSection='automations'; loadAutomations()">{{ fd_icon("zap",14) }} Automations</div>
|
{% if plugin_enabled('automations') %}<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="navTo('automations')">{{ fd_icon("zap",14) }} Automations</div>{% endif %}
|
||||||
<div class="settings-nav-item" :class="{ active: activeSection==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent & IA</div>
|
<div class="settings-nav-item" :class="{ active: activeSection==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent & IA</div>
|
||||||
<!-- Admin nav: only visible to admins -->
|
<!-- Admin nav: only visible to admins -->
|
||||||
<template x-if="userIsAdmin">
|
<template x-if="userIsAdmin">
|
||||||
<div>
|
<div>
|
||||||
<div class="settings-nav-header">Admin</div>
|
<div class="settings-nav-header">Admin</div>
|
||||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users & Roles</div>
|
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="navTo('admin-users')">{{ fd_icon("users",14) }} Users & Roles</div>
|
||||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="activeSection='admin-audit'; loadAdminAudit()">{{ fd_icon("file-text",14) }} Audit Log</div>
|
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="navTo('admin-audit')">{{ fd_icon("file-text",14) }} Audit Log</div>
|
||||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-backups' }" @click="activeSection='admin-backups'; loadBackups()">{{ fd_icon("download",14) }} Backups</div>
|
<div class="settings-nav-item" :class="{ active: activeSection==='admin-backups' }" @click="navTo('admin-backups')">{{ fd_icon("download",14) }} Backups</div>
|
||||||
<div class="settings-nav-item" :class="{ active: activeSection==='admin-sso' }" @click="activeSection='admin-sso'; loadSsoConfig()">{{ fd_icon("lock",14) }} SSO / Enterprise</div>
|
<div class="settings-nav-item" :class="{ active: activeSection==='admin-sso' }" @click="navTo('admin-sso')">{{ fd_icon("lock",14) }} SSO / Enterprise</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -423,7 +417,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Extensions (Web Clipper) -->
|
<!-- Extensions (Web Clipper) -->
|
||||||
<div x-show="activeSection==='extensions'">
|
<div x-show="{{ 'true' if plugin_enabled('web-clipper') else 'false' }} && activeSection==='extensions'">
|
||||||
<h2>Extensions</h2>
|
<h2>Extensions</h2>
|
||||||
<p class="section-desc">FlowDeck Web Clipper — capture web content directly into FlowDeck. Manage connected browsers.</p>
|
<p class="section-desc">FlowDeck Web Clipper — capture web content directly into FlowDeck. Manage connected browsers.</p>
|
||||||
<div class="setting-group">
|
<div class="setting-group">
|
||||||
@@ -584,7 +578,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Automations -->
|
<!-- Automations -->
|
||||||
<div x-show="activeSection==='automations'">
|
<div x-show="{{ 'true' if plugin_enabled('automations') else 'false' }} && activeSection==='automations'">
|
||||||
<h2>Automations</h2>
|
<h2>Automations</h2>
|
||||||
<p class="section-desc">Règles if-this-then-that : déclencheur + condition + action. Utilisables aussi via le bloc bouton dans l'éditeur de page.</p>
|
<p class="section-desc">Règles if-this-then-that : déclencheur + condition + action. Utilisables aussi via le bloc bouton dans l'éditeur de page.</p>
|
||||||
|
|
||||||
@@ -635,16 +629,142 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
|
<div class="setting-row" x-show="!editSteps.length" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||||
<div class="setting-label">Conditions (JSON — toutes AND)</div>
|
<div class="setting-label">Conditions (JSON — toutes AND)</div>
|
||||||
<textarea class="settings-input" style="width:100%;min-height:60px;font-family:monospace;font-size:12px;" x-model="af.condition_json" placeholder='[{"property":"Status","op":"eq","value":"Done"}]'></textarea>
|
<textarea class="settings-input" style="width:100%;min-height:60px;font-family:monospace;font-size:12px;" x-model="af.condition_json" placeholder='[{"property":"Status","op":"eq","value":"Done"}]'></textarea>
|
||||||
<div class="setting-desc">Ops : eq, neq, contains, not_contains, is_empty, is_not_empty, changed</div>
|
<div class="setting-desc">Ops : eq, neq, contains, not_contains, is_empty, is_not_empty, changed</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
|
<div class="setting-row" x-show="!editSteps.length" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||||
<div class="setting-label">Actions (JSON)</div>
|
<div class="setting-label">Actions (JSON)</div>
|
||||||
<textarea class="settings-input" style="width:100%;min-height:80px;font-family:monospace;font-size:12px;" x-model="af.actions_json" placeholder='[{"type":"webhook","url":"https://..."}, {"type":"set_property","property":"Status","value":"Done"}, {"type":"create_page","collection_id":1,"title":"New task"}, {"type":"notify","message":"Automation fired"}]'></textarea>
|
<textarea class="settings-input" style="width:100%;min-height:80px;font-family:monospace;font-size:12px;" x-model="af.actions_json" placeholder='[{"type":"webhook","url":"https://..."}, {"type":"set_property","property":"Status","value":"Done"}, {"type":"create_page","collection_id":1,"title":"New task"}, {"type":"notify","message":"Automation fired"}]'></textarea>
|
||||||
<div class="setting-desc">Types : webhook, set_property, create_page, notify</div>
|
<div class="setting-desc">Types : webhook, set_property, create_page, notify</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pipeline visuel (steps API v7.0) -->
|
||||||
|
<div class="setting-row" x-show="editAutomationId" style="flex-direction:column;align-items:stretch;gap:6px;">
|
||||||
|
<div class="setting-label">Pipeline visuel (steps)</div>
|
||||||
|
<div class="setting-desc" x-show="!editSteps.length">Aucun step : automation en mode legacy (JSON ci-dessus). Ajoutez une étape (ou convertissez le JSON) pour basculer en pipeline.</div>
|
||||||
|
<template x-for="(s, i) in editSteps" :key="s.id">
|
||||||
|
<div style="border:1px solid var(--border);border-radius:6px;padding:8px 10px;margin-bottom:6px;">
|
||||||
|
<div style="display:flex;gap:8px;align-items:center;">
|
||||||
|
<span class="setting-label" style="font-weight:500;" x-text="(i+1) + '. ' + stepSummary(s)"></span>
|
||||||
|
<span style="flex:1"></span>
|
||||||
|
<button class="btn-sm" title="Monter" @click="moveStep(i, -1)">↑</button>
|
||||||
|
<button class="btn-sm" title="Descendre" @click="moveStep(i, 1)">↓</button>
|
||||||
|
<button class="btn-sm" @click="stepEdit = stepEdit===i ? -1 : i" x-text="stepEdit===i ? 'Fermer' : 'Modifier'"></button>
|
||||||
|
<button class="btn-sm" style="color:#e5534b;" title="Supprimer" @click="delStep(i)">✕</button>
|
||||||
|
</div>
|
||||||
|
<div x-show="stepEdit===i" style="margin-top:8px;display:flex;flex-direction:column;gap:6px;">
|
||||||
|
<template x-if="s.kind==='trigger'">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">Événement</div>
|
||||||
|
<input class="settings-input" style="width:100%;" x-model="s.config.event" list="auto-events">
|
||||||
|
<datalist id="auto-events">
|
||||||
|
<option value="page.created"></option><option value="page.updated"></option><option value="page.deleted"></option>
|
||||||
|
<option value="collection.created"></option><option value="collection.updated"></option><option value="collection.deleted"></option>
|
||||||
|
<option value="form.submitted"></option><option value="meeting.summarized"></option><option value="site.viewed"></option>
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="s.kind==='condition'">
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||||
|
<input class="settings-input" placeholder="Propriété" x-model="s.config.property">
|
||||||
|
<select class="settings-input" x-model="s.config.op">
|
||||||
|
<option value="eq">=</option><option value="neq">≠</option>
|
||||||
|
<option value="contains">contient</option><option value="not_contains">ne contient pas</option>
|
||||||
|
<option value="is_empty">vide</option><option value="is_not_empty">non vide</option>
|
||||||
|
<option value="changed">changé</option>
|
||||||
|
</select>
|
||||||
|
<input class="settings-input" placeholder="Valeur" x-model="s.config.value">
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="s.kind==='delay'">
|
||||||
|
<div>
|
||||||
|
<div class="setting-label">Secondes (0-86400 · exécution plafonnée à 300 s)</div>
|
||||||
|
<input class="settings-input" type="number" min="0" max="86400" x-model.number="s.config.seconds">
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="s.kind==='action'">
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px;">
|
||||||
|
<select class="settings-input" x-model="s.config.type">
|
||||||
|
<option value="webhook">Webhook</option>
|
||||||
|
<option value="set_property">Définir une propriété</option>
|
||||||
|
<option value="create_page">Créer une page</option>
|
||||||
|
<option value="notify">Notification</option>
|
||||||
|
<option value="slack">Slack</option>
|
||||||
|
<option value="email">Email</option>
|
||||||
|
<option value="forge_issue">Issue Gitea/GitHub</option>
|
||||||
|
<option value="agent_trigger">Déclencher un agent</option>
|
||||||
|
</select>
|
||||||
|
<template x-if="s.config.type==='webhook'">
|
||||||
|
<input class="settings-input" placeholder="https://…" x-model="s.config.url">
|
||||||
|
</template>
|
||||||
|
<template x-if="s.config.type==='set_property'">
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<input class="settings-input" placeholder="Propriété" x-model="s.config.property">
|
||||||
|
<input class="settings-input" placeholder="Valeur" x-model="s.config.value">
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="s.config.type==='create_page'">
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<select class="settings-input" x-model="s.config.collection_id">
|
||||||
|
<option value="">Base (optionnel)</option>
|
||||||
|
<template x-for="c in globalCollections" :key="c.id">
|
||||||
|
<option x-bind:value="c.id" x-text="c.icon + ' ' + c.name"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
<input class="settings-input" placeholder="Titre" x-model="s.config.title">
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="s.config.type==='notify'">
|
||||||
|
<input class="settings-input" placeholder="Message" x-model="s.config.message">
|
||||||
|
</template>
|
||||||
|
<template x-if="s.config.type==='slack'">
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<input class="settings-input" placeholder="Webhook URL (chiffrée)" x-model="s.config.webhook_url">
|
||||||
|
<input class="settings-input" placeholder="Texte" x-model="s.config.text">
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="s.config.type==='email'">
|
||||||
|
<div style="display:flex;gap:8px;flex-direction:column;">
|
||||||
|
<input class="settings-input" placeholder="À (to)" x-model="s.config.to">
|
||||||
|
<input class="settings-input" placeholder="Sujet" x-model="s.config.subject">
|
||||||
|
<textarea class="settings-input" style="min-height:56px;" placeholder="Corps" x-model="s.config.body"></textarea>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="s.config.type==='forge_issue'">
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||||
|
<input class="settings-input" placeholder="owner" x-model="s.config.owner">
|
||||||
|
<input class="settings-input" placeholder="repo" x-model="s.config.repo">
|
||||||
|
<input class="settings-input" placeholder="Titre" x-model="s.config.title">
|
||||||
|
<input class="settings-input" placeholder="Labels (virgules)" x-model="s.config.labels">
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="s.config.type==='agent_trigger'">
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<input class="settings-input" type="number" placeholder="Agent ID" x-model.number="s.config.agent_id">
|
||||||
|
<input class="settings-input" placeholder="Message" x-model="s.config.message">
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div><button class="btn-sm" @click="saveStep(s)">Enregistrer l'étape</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||||
|
<select class="settings-input" style="min-width:150px;" x-model="stepNewKind">
|
||||||
|
<option value="trigger">Déclencheur</option>
|
||||||
|
<option value="condition">Condition</option>
|
||||||
|
<option value="delay">Attente</option>
|
||||||
|
<option value="action">Action</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn-sm" @click="addStep()">+ Ajouter l'étape</button>
|
||||||
|
<button class="btn-sm" style="color:var(--accent,#2383e2);" x-show="!editSteps.length && editAutomationId"
|
||||||
|
@click="convertToSteps()">✨ Convertir le JSON en pipeline</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||||
<button class="btn-sm" @click="saveAutomation()" x-text="editAutomationId ? 'Enregistrer' : 'Créer'"></button>
|
<button class="btn-sm" @click="saveAutomation()" x-text="editAutomationId ? 'Enregistrer' : 'Créer'"></button>
|
||||||
<button class="btn-sm" style="color:var(--text-secondary);" x-show="editAutomationId" @click="cancelEditAutomation()">Annuler</button>
|
<button class="btn-sm" style="color:var(--text-secondary);" x-show="editAutomationId" @click="cancelEditAutomation()">Annuler</button>
|
||||||
@@ -774,7 +894,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div style="display:flex;gap:4px;">
|
<div style="display:flex;gap:4px;">
|
||||||
<button class="btn-sm" @click="editingUser=u; editUserForm={login:u.login,name:u.full_name,email:u.email,is_admin:u.is_admin,is_active:u.is_active}" title="Edit">{{ fd_icon("edit",14) }}</button>
|
<button class="btn-sm" @click="editUserRow(u)" title="Edit">{{ fd_icon("edit",14) }}</button>
|
||||||
<button class="btn-sm btn-sm-danger" @click="adminDeleteUser(u.id)" title="Delete" x-show="adminUsers.length > 1">{{ fd_icon("trash",14) }}</button>
|
<button class="btn-sm btn-sm-danger" @click="adminDeleteUser(u.id)" title="Delete" x-show="adminUsers.length > 1">{{ fd_icon("trash",14) }}</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -813,7 +933,7 @@
|
|||||||
<p class="section-desc">Actions API, changements de permissions et connexions SSO (unifiés).</p>
|
<p class="section-desc">Actions API, changements de permissions et connexions SSO (unifiés).</p>
|
||||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center;">
|
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center;">
|
||||||
<template x-for="s in ['all','api','permissions','sso']" :key="s">
|
<template x-for="s in ['all','api','permissions','sso']" :key="s">
|
||||||
<button class="btn" :class="{ 'btn-primary': auditSource===s }" style="font-size:12px;" @click="auditSource=s; loadAuditLogs()" x-text="s"></button>
|
<button class="btn" :class="{ 'btn-primary': auditSource===s }" style="font-size:12px;" @click="setAuditSource(s)" x-text="s"></button>
|
||||||
</template>
|
</template>
|
||||||
<input type="text" placeholder="actor (user id)" x-model="auditActor" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
|
<input type="text" placeholder="actor (user id)" x-model="auditActor" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
|
||||||
<input type="text" placeholder="action (LIKE)" x-model="auditAction" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
|
<input type="text" placeholder="action (LIKE)" x-model="auditAction" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
|
||||||
@@ -853,7 +973,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;justify-content:center;margin-top:12px;">
|
<div style="display:flex;justify-content:center;margin-top:12px;">
|
||||||
<button class="btn" style="font-size:12px;" x-show="auditHasMore" @click="auditOffset+=auditPageSize; loadAuditLogs(false)">Load more</button>
|
<button class="btn" style="font-size:12px;" x-show="auditHasMore" @click="auditNext()">Load more</button>
|
||||||
</div>
|
</div>
|
||||||
<style>
|
<style>
|
||||||
.audit-src{display:inline-block;padding:1px 7px;border-radius:8px;font-weight:600;}
|
.audit-src{display:inline-block;padding:1px 7px;border-radius:8px;font-weight:600;}
|
||||||
|
|||||||
@@ -104,7 +104,8 @@
|
|||||||
|
|
||||||
// globales window — probe « Undefined variable: tableView »).
|
// globales window — probe « Undefined variable: tableView »).
|
||||||
|
|
||||||
document.addEventListener('alpine:init', function () { Alpine.data('tableView', tableView); });
|
if (window.Alpine) { Alpine.data('tableView', tableView); }
|
||||||
|
else document.addEventListener('alpine:init', function () { Alpine.data('tableView', tableView); });
|
||||||
function tableView() {
|
function tableView() {
|
||||||
return {
|
return {
|
||||||
sortField: '',
|
sortField: '',
|
||||||
|
|||||||
@@ -48,7 +48,8 @@
|
|||||||
|
|
||||||
// globales window — probe « Undefined variable: teamLoad »).
|
// globales window — probe « Undefined variable: teamLoad »).
|
||||||
|
|
||||||
document.addEventListener('alpine:init', function () { Alpine.data('teamLoad', teamLoad); });
|
if (window.Alpine) { Alpine.data('teamLoad', teamLoad); }
|
||||||
|
else document.addEventListener('alpine:init', function () { Alpine.data('teamLoad', teamLoad); });
|
||||||
function teamLoad() {
|
function teamLoad() {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|||||||
+270
-35
@@ -11,46 +11,94 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div x-data="trashData()" style="padding: 0 24px; max-width: 800px;">
|
<div x-data="trashData()" class="lib-container">
|
||||||
<!-- Search -->
|
<!-- Toolbar : recherche + tri + emplacement + purge -->
|
||||||
<div style="position:relative; margin-bottom:12px;">
|
<div style="display:flex; align-items:center; gap:8px; margin-bottom:12px;">
|
||||||
<span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">{{ fd_icon("search",14) }}</span>
|
<div style="position:relative; flex:1;">
|
||||||
<input type="text" placeholder="Search pages in Trash" x-model="search"
|
<span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">{{ fd_icon("search",14) }}</span>
|
||||||
style="width:100%; padding:8px 12px 8px 36px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary); font-size:14px; outline:none; box-sizing:border-box;">
|
<input type="text" placeholder="Search pages in Trash" x-model="search"
|
||||||
|
style="width:100%; padding:8px 12px 8px 36px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary); font-size:14px; outline:none; box-sizing:border-box;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tri -->
|
||||||
|
<div class="lib-toolbar-wrap">
|
||||||
|
<button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSort()">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="lib-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition>
|
||||||
|
<div class="dd-label">Sort by</div>
|
||||||
|
<div class="dd-item" :class="{active: sort==='recent'}" @click="setSort('recent')"><span class="check" x-text="sort==='recent' ? '✓' : ''"></span> Recently deleted</div>
|
||||||
|
<div class="dd-item" :class="{active: sort==='oldest'}" @click="setSort('oldest')"><span class="check" x-text="sort==='oldest' ? '✓' : ''"></span> Oldest first</div>
|
||||||
|
<div class="dd-item" :class="{active: sort==='name'}" @click="setSort('name')"><span class="check" x-text="sort==='name' ? '✓' : ''"></span> Page name A → Z</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Emplacement -->
|
||||||
|
<div class="lib-toolbar-wrap" x-show="locations.length > 1">
|
||||||
|
<button class="lib-icon-btn" :class="{active: locOpen || locFilter !== 'all'}" title="Filter by location" @click.stop="toggleLoc()">
|
||||||
|
{{ fd_icon("folder",16) }}
|
||||||
|
</button>
|
||||||
|
<div class="lib-dropdown" x-show="locOpen" @click.outside="locOpen=false" x-transition>
|
||||||
|
<div class="dd-label">Location</div>
|
||||||
|
<div class="dd-item" :class="{active: locFilter==='all'}" @click="setLoc('all')"><span class="check" x-text="locFilter==='all' ? '✓' : ''"></span> All locations</div>
|
||||||
|
<template x-for="l in locations" :key="l">
|
||||||
|
<div class="dd-item" :class="{active: locFilter===l}" @click="setLoc(l)"><span class="check" x-text="locFilter===l ? '✓' : ''"></span> <span x-text="l"></span></div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Purge totale -->
|
||||||
|
<button class="btn-sm btn-sm-danger" x-show="items.length" @click="emptyTrash()">Empty Trash</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Sélection + compteur + actions groupées -->
|
||||||
<div style="display:flex; gap:8px; margin-bottom:16px;">
|
<div style="display:flex; align-items:center; gap:14px; margin-bottom:8px; font-size:13px; color:var(--text-dim);" x-show="items.length">
|
||||||
<button class="trash-filter active">
|
<label style="display:flex; align-items:center; gap:6px; cursor:pointer;">
|
||||||
<span style="color:var(--accent);">{{ fd_icon("user",14) }}</span> Last edited by ▾
|
<input type="checkbox" style="accent-color:var(--accent); cursor:pointer;" :checked="allSelected" @change="toggleAll()">
|
||||||
</button>
|
<span x-text="selected.length ? selected.length + ' selected' : 'Select all'"></span>
|
||||||
<button class="trash-filter">
|
</label>
|
||||||
<span>{{ fd_icon("folder",14) }}</span> In ▾
|
<span x-text="countLabel"></span>
|
||||||
</button>
|
<div style="margin-left:auto; display:flex; gap:6px;" x-show="selected.length">
|
||||||
|
<button class="btn-sm" @click="restoreSelected()">Restore</button>
|
||||||
|
<button class="btn-sm btn-sm-danger" @click="deleteSelected()">Delete</button>
|
||||||
|
<button class="btn-sm" @click="clearSel()">Clear</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Items -->
|
<!-- Items -->
|
||||||
<div style="min-height:200px;">
|
<div style="min-height:200px;">
|
||||||
<template x-for="item in filteredItems" :key="item.id">
|
<template x-for="item in filteredItems" :key="item.id">
|
||||||
<div class="trash-item">
|
<div class="trash-item">
|
||||||
|
<input type="checkbox" style="accent-color:var(--accent); cursor:pointer; flex-shrink:0;"
|
||||||
|
:checked="selected.includes(item.id)" @change="toggle(item.id)">
|
||||||
<span class="trash-item-icon" x-text="item.icon"></span>
|
<span class="trash-item-icon" x-text="item.icon"></span>
|
||||||
<div class="trash-item-info">
|
<div class="trash-item-info">
|
||||||
<span class="trash-item-name" x-text="item.name"></span>
|
<span class="trash-item-name" x-text="item.name"></span>
|
||||||
<span class="trash-item-path" x-text="item.path"></span>
|
<span class="trash-item-path">
|
||||||
|
<span x-text="item.path"></span> · <span x-text="ago(item.deleted_at)"></span><span x-text="leftLabel(item.deleted_at)"></span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="trash-item-btn" title="Restore" @click="restore(item.id)">
|
<button class="trash-item-btn" title="Restore" @click="restore(item.id)">↩️</button>
|
||||||
↩️
|
|
||||||
</button>
|
|
||||||
<button class="trash-item-btn" title="Delete permanently" @click="deleteForever(item.id)">
|
<button class="trash-item-btn" title="Delete permanently" @click="deleteForever(item.id)">
|
||||||
{{ fd_icon("trash",14) }}
|
{{ fd_icon("trash",14) }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div x-show="filteredItems.length === 0" class="lib-empty">
|
|
||||||
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("trash",14) }}</div>
|
<!-- État vide : aucun contenu -->
|
||||||
|
<div x-show="items.length === 0" class="lib-empty">
|
||||||
|
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("trash",24) }}</div>
|
||||||
<h3>Trash is empty</h3>
|
<h3>Trash is empty</h3>
|
||||||
<p>Deleted pages will appear here for 30 days.</p>
|
<p>Deleted pages will appear here for 30 days.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- État vide : filtres sans résultat -->
|
||||||
|
<div x-show="items.length > 0 && filteredItems.length === 0" class="lib-empty">
|
||||||
|
<div style="font-size:48px;margin-bottom:16px;">{{ fd_icon("search",24) }}</div>
|
||||||
|
<h3>No pages match</h3>
|
||||||
|
<p x-text="items.length + ' page(s) in Trash — none matches the current search/location filter.'"></p>
|
||||||
|
<button class="btn-sm" style="margin-top:12px;" @click="clearFilters()">Clear filters</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Info -->
|
<!-- Info -->
|
||||||
@@ -58,42 +106,229 @@
|
|||||||
<span>Once a page has been in Trash for 30 days, it will be automatically deleted</span>
|
<span>Once a page has been in Trash for 30 days, it will be automatically deleted</span>
|
||||||
<span style="font-size:16px;">ⓘ</span>
|
<span style="font-size:16px;">ⓘ</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Confirmation thématique (remplace confirm() du navigateur) — classes
|
||||||
|
flowdeck-modal définies dans base.html, thème variables du site -->
|
||||||
|
<div id="fd-trash-confirm" class="flowdeck-modal-overlay" x-show="confirm.show" x-transition.opacity
|
||||||
|
@click.self="cancelConfirm()" @keydown.escape.window="cancelConfirm()" style="display:none;">
|
||||||
|
<div class="flowdeck-modal" x-transition role="alertdialog" aria-modal="true" aria-labelledby="fd-trash-confirm-title">
|
||||||
|
<div class="flowdeck-modal-header">
|
||||||
|
<h3 id="fd-trash-confirm-title" x-text="confirm.title"></h3>
|
||||||
|
<button class="flowdeck-modal-close" @click="cancelConfirm()" aria-label="Close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="flowdeck-modal-body">
|
||||||
|
<p x-text="confirm.message"></p>
|
||||||
|
</div>
|
||||||
|
<div class="flowdeck-modal-footer">
|
||||||
|
<button class="flowdeck-modal-btn cancel" @click="cancelConfirm()">Cancel</button>
|
||||||
|
<button class="flowdeck-modal-btn confirm" @click="acceptConfirm()" x-text="confirm.okText"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||||
|
|
||||||
// globales window — probe « Undefined variable: trashData »).
|
// globales window — probe « Undefined variable: trashData »).
|
||||||
|
if (window.Alpine) { Alpine.data('trashData', trashData); }
|
||||||
|
else document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); });
|
||||||
|
|
||||||
document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); });
|
|
||||||
function trashData() {
|
function trashData() {
|
||||||
|
// Rétention alignée sur app/services/trash.py (purge auto quotidienne).
|
||||||
|
var RETENTION_DAYS = 30;
|
||||||
return {
|
return {
|
||||||
search: '',
|
search: '',
|
||||||
|
sort: 'recent',
|
||||||
|
locFilter: 'all',
|
||||||
|
sortOpen: false,
|
||||||
|
locOpen: false,
|
||||||
|
selected: [],
|
||||||
items: [],
|
items: [],
|
||||||
|
// Confirmation thématique (remplace confirm() du navigateur)
|
||||||
|
confirm: { show: false, title: '', message: '', okText: 'Delete', action: null },
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
const csrf = getCsrf();;
|
|
||||||
const token = csrf;
|
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': token } });
|
const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': getCsrf() } });
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
this.items = await r.json();
|
this.items = await r.json();
|
||||||
} catch(e) { this.items = []; }
|
} catch (e) {
|
||||||
|
this.items = [];
|
||||||
|
window.showToast('Could not load the Trash: ' + e.message, 'error');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
get locations() {
|
||||||
|
const set = {};
|
||||||
|
this.items.forEach(function (i) { set[i.path || 'Private'] = true; });
|
||||||
|
return Object.keys(set).sort();
|
||||||
|
},
|
||||||
|
|
||||||
get filteredItems() {
|
get filteredItems() {
|
||||||
const q = this.search.toLowerCase();
|
const q = this.search.toLowerCase();
|
||||||
return this.items.filter(i => !q || i.name.toLowerCase().includes(q) || (i.path||'').toLowerCase().includes(q));
|
const loc = this.locFilter;
|
||||||
|
const out = this.items.filter(function (i) {
|
||||||
|
const hitQ = !q || i.name.toLowerCase().includes(q) || (i.path || '').toLowerCase().includes(q);
|
||||||
|
return hitQ && (loc === 'all' || (i.path || 'Private') === loc);
|
||||||
|
});
|
||||||
|
const key = function (i) { return i.deleted_at || ''; };
|
||||||
|
if (this.sort === 'recent') out.sort(function (a, b) { return key(b).localeCompare(key(a)); });
|
||||||
|
else if (this.sort === 'oldest') out.sort(function (a, b) { return key(a).localeCompare(key(b)); });
|
||||||
|
else out.sort(function (a, b) { return a.name.localeCompare(b.name); });
|
||||||
|
return out;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
get countLabel() {
|
||||||
|
const n = this.filteredItems.length, t = this.items.length;
|
||||||
|
return n === t ? t + ' page' + (t === 1 ? '' : 's') : n + ' of ' + t + ' pages';
|
||||||
|
},
|
||||||
|
|
||||||
|
get allSelected() {
|
||||||
|
const list = this.filteredItems;
|
||||||
|
const self = this;
|
||||||
|
return list.length > 0 && list.every(function (i) { return self.selected.includes(i.id); });
|
||||||
|
},
|
||||||
|
|
||||||
|
toggle(id) {
|
||||||
|
const i = this.selected.indexOf(id);
|
||||||
|
if (i >= 0) this.selected.splice(i, 1); else this.selected.push(id);
|
||||||
|
},
|
||||||
|
toggleAll() {
|
||||||
|
this.selected = this.allSelected ? [] : this.filteredItems.map(function (i) { return i.id; });
|
||||||
|
},
|
||||||
|
clearSel() { this.selected = []; },
|
||||||
|
clearFilters() { this.search = ''; this.locFilter = 'all'; },
|
||||||
|
toggleSort() { this.sortOpen = !this.sortOpen; },
|
||||||
|
toggleLoc() { this.locOpen = !this.locOpen; },
|
||||||
|
setSort(v) { this.sort = v; this.sortOpen = false; },
|
||||||
|
setLoc(v) { this.locFilter = v; this.locOpen = false; },
|
||||||
|
|
||||||
|
// ── Confirmation (modal thème FlowDeck, classes flowdeck-modal de base) ──
|
||||||
|
// Mutations de propriétés individuelles (remplacer l'objet entier tue la
|
||||||
|
// réactivité Alpine — pitfall connu).
|
||||||
|
askConfirm(title, message, okText, action) {
|
||||||
|
this.confirm.title = title;
|
||||||
|
this.confirm.message = message;
|
||||||
|
this.confirm.okText = okText || 'Delete';
|
||||||
|
this.confirm.action = action;
|
||||||
|
this.confirm.show = true;
|
||||||
|
},
|
||||||
|
cancelConfirm() {
|
||||||
|
this.confirm.show = false;
|
||||||
|
this.confirm.action = null;
|
||||||
|
},
|
||||||
|
acceptConfirm() {
|
||||||
|
const action = this.confirm.action;
|
||||||
|
this.confirm.show = false;
|
||||||
|
this.confirm.action = null;
|
||||||
|
if (action) action();
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── API ──────────────────────────────────────────────────────────
|
||||||
|
// getCsrf() renvoie la CHAÎNE du jeton (base.html) : l'ancien code faisait
|
||||||
|
// csrf?.[1] → 2e caractère → 403 CSRF sur restore/delete, silencieux.
|
||||||
|
_headers() { return { 'X-CSRF-Token': getCsrf() }; },
|
||||||
|
_drop(ids) {
|
||||||
|
this.items = this.items.filter(function (i) { return !ids.includes(i.id); });
|
||||||
|
this.selected = this.selected.filter(function (id) { return !ids.includes(id); });
|
||||||
|
},
|
||||||
|
|
||||||
|
async _restore(id) {
|
||||||
|
const r = await fetch('/board/api/trash/' + id + '/restore', { method: 'POST', headers: this._headers() });
|
||||||
|
if (r.ok) this._drop([id]);
|
||||||
|
return r.ok;
|
||||||
|
},
|
||||||
|
async _delete(id) {
|
||||||
|
const r = await fetch('/board/api/trash/' + id, { method: 'DELETE', headers: this._headers() });
|
||||||
|
if (r.ok) this._drop([id]);
|
||||||
|
return r.ok;
|
||||||
|
},
|
||||||
|
|
||||||
async restore(id) {
|
async restore(id) {
|
||||||
const csrf = getCsrf();;
|
try {
|
||||||
const r = await fetch(`/board/api/trash/${id}/restore`, { method: 'POST', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
|
if (await this._restore(id)) window.showToast('Page restored', 'success');
|
||||||
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
|
else window.showToast('Restore failed', 'error');
|
||||||
|
} catch (e) { window.showToast('Restore failed: ' + e.message, 'error'); }
|
||||||
},
|
},
|
||||||
async deleteForever(id) {
|
async restoreSelected() {
|
||||||
if (!confirm('Permanently delete this page? This cannot be undone.')) return;
|
const ids = this.selected.slice();
|
||||||
const csrf = getCsrf();;
|
let ok = 0;
|
||||||
const r = await fetch(`/board/api/trash/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
|
for (const id of ids) {
|
||||||
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
|
try { if (await this._restore(id)) ok++; } catch (e) { /* compté en échec */ }
|
||||||
|
}
|
||||||
|
window.showToast(ok + ' of ' + ids.length + ' page(s) restored', ok === ids.length ? 'success' : 'error');
|
||||||
|
},
|
||||||
|
async _deleteAndToast(ids) {
|
||||||
|
let ok = 0;
|
||||||
|
for (const id of ids) {
|
||||||
|
try { if (await this._delete(id)) ok++; } catch (e) { /* compté en échec */ }
|
||||||
|
}
|
||||||
|
const msg = ids.length === 1
|
||||||
|
? (ok ? 'Page permanently deleted' : 'Delete failed')
|
||||||
|
: ok + ' of ' + ids.length + ' page(s) deleted';
|
||||||
|
window.showToast(msg, ok === ids.length ? 'success' : 'error');
|
||||||
|
},
|
||||||
|
deleteForever(id) {
|
||||||
|
const self = this;
|
||||||
|
const item = this.items.find(function (i) { return i.id === id; });
|
||||||
|
const name = item ? item.name : 'this page';
|
||||||
|
this.askConfirm('Delete permanently',
|
||||||
|
'“' + name + '” will be deleted for good. This cannot be undone.',
|
||||||
|
'Delete',
|
||||||
|
function () { self._deleteAndToast([id]); });
|
||||||
|
},
|
||||||
|
deleteSelected() {
|
||||||
|
const self = this;
|
||||||
|
const ids = this.selected.slice();
|
||||||
|
this.askConfirm('Delete ' + ids.length + ' page(s) for good?',
|
||||||
|
'These pages will be permanently deleted. This cannot be undone.',
|
||||||
|
'Delete all',
|
||||||
|
function () { self._deleteAndToast(ids); });
|
||||||
|
},
|
||||||
|
async emptyTrash() {
|
||||||
|
const self = this;
|
||||||
|
const n = this.items.length;
|
||||||
|
this.askConfirm('Empty Trash?',
|
||||||
|
'All ' + n + ' page(s) in the Trash will be permanently deleted. This cannot be undone.',
|
||||||
|
'Empty Trash',
|
||||||
|
function () { self._emptyAndToast(n); });
|
||||||
|
},
|
||||||
|
async _emptyAndToast(n) {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/board/api/trash/empty', { method: 'POST', headers: this._headers() });
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
this.items = [];
|
||||||
|
this.selected = [];
|
||||||
|
window.showToast(n + ' page(s) purged', 'success');
|
||||||
|
} catch (e) { window.showToast('Empty Trash failed: ' + e.message, 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Dates (deleted_at : CURRENT_TIMESTAMP UTC ou ISO sans fuseau) ──
|
||||||
|
_ts(s) {
|
||||||
|
if (!s) return null;
|
||||||
|
let t = String(s).replace(' ', 'T');
|
||||||
|
if (!/[zZ]|[+-]\d{2}:?\d{2}$/.test(t)) t += 'Z';
|
||||||
|
const d = new Date(t);
|
||||||
|
return isNaN(d.getTime()) ? null : d;
|
||||||
|
},
|
||||||
|
ago(s) {
|
||||||
|
const d = this._ts(s);
|
||||||
|
if (!d) return 'Deleted';
|
||||||
|
const sec = (Date.now() - d.getTime()) / 1000;
|
||||||
|
if (sec < 60) return 'Deleted just now';
|
||||||
|
if (sec < 3600) return 'Deleted ' + Math.floor(sec / 60) + ' min ago';
|
||||||
|
if (sec < 86400) return 'Deleted ' + Math.floor(sec / 3600) + ' h ago';
|
||||||
|
const days = Math.floor(sec / 86400);
|
||||||
|
return 'Deleted ' + days + ' day' + (days === 1 ? '' : 's') + ' ago';
|
||||||
|
},
|
||||||
|
leftLabel(s) {
|
||||||
|
const d = this._ts(s);
|
||||||
|
if (!d) return '';
|
||||||
|
const left = RETENTION_DAYS - Math.floor((Date.now() - d.getTime()) / 86400000);
|
||||||
|
if (left <= 0) return ' · auto-delete soon';
|
||||||
|
return ' · ' + left + ' day' + (left === 1 ? '' : 's') + ' left';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Bienvenue sur FlowDeck</title>
|
<title>Bienvenue sur FlowDeck</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||||
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
|
||||||
<style>
|
<style>
|
||||||
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;}
|
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;}
|
||||||
*{margin:0;padding:0;box-sizing:border-box;}
|
*{margin:0;padding:0;box-sizing:border-box;}
|
||||||
@@ -138,7 +138,8 @@ h1{font-size:22px;font-weight:700;margin-bottom:6px;}
|
|||||||
|
|
||||||
// globales window — probe « Undefined variable: onboarding »).
|
// globales window — probe « Undefined variable: onboarding »).
|
||||||
|
|
||||||
document.addEventListener('alpine:init', function () { Alpine.data('onboarding', onboarding); });
|
if (window.Alpine) { Alpine.data('onboarding', onboarding); }
|
||||||
|
else document.addEventListener('alpine:init', function () { Alpine.data('onboarding', onboarding); });
|
||||||
function onboarding() {
|
function onboarding() {
|
||||||
return {
|
return {
|
||||||
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
|
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
{% block topbar %}
|
{% block topbar %}
|
||||||
{% set page_icon = "home" %}
|
{% set page_icon = "home" %}
|
||||||
{% set page_title = "Workspace — Projects" %}
|
{% set page_title = "Workspace — Projects" %}
|
||||||
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">' ~ fd_icon("key",16) ~ '</a><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
|
{% set right_actions %}<a href="/auth/login?provider=local" class="topbar-btn" title="Login">{{ fd_icon("key",16) }}</a><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
|
||||||
{% include '_header.html' %}
|
{% include '_header.html' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -145,7 +145,8 @@
|
|||||||
|
|
||||||
// globales window — probe « Undefined variable: workspacePage »).
|
// globales window — probe « Undefined variable: workspacePage »).
|
||||||
|
|
||||||
document.addEventListener('alpine:init', function () { Alpine.data('workspacePage', workspacePage); });
|
if (window.Alpine) { Alpine.data('workspacePage', workspacePage); }
|
||||||
|
else document.addEventListener('alpine:init', function () { Alpine.data('workspacePage', workspacePage); });
|
||||||
function workspacePage() {
|
function workspacePage() {
|
||||||
return {
|
return {
|
||||||
builtinProjects: [],
|
builtinProjects: [],
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
{% block topbar %}
|
{% block topbar %}
|
||||||
{% set breadcrumb_items = [{"label": "Workspaces", "url": None}] %}
|
{% set breadcrumb_items = [{"label": "Workspaces", "url": None}] %}
|
||||||
{% set page_icon = "home" %}
|
{% set page_icon = "home" %}
|
||||||
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">' ~ fd_icon("key",16) ~ '</a><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
|
{% set right_actions %}<a href="/auth/login?provider=local" class="topbar-btn" title="Login">{{ fd_icon("key",16) }}</a><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
|
||||||
{% include '_header.html' %}
|
{% include '_header.html' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -159,12 +159,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create/Rename dialog -->
|
<!-- Create/Rename dialog -->
|
||||||
<div class="dialog-overlay" x-show="showCreate||showRename" @click.outside="showCreate=false;showRename=false">
|
<div class="dialog-overlay" x-show="showCreate||showRename" @click.outside="closeCreateRename()">
|
||||||
<div class="dialog-box">
|
<div class="dialog-box">
|
||||||
<h3 x-text="showRename?'Rename Workspace':'New Workspace'"></h3>
|
<h3 x-text="showRename?'Rename Workspace':'New Workspace'"></h3>
|
||||||
<input class="dialog-input" x-model="wsName" placeholder="Workspace name" @keydown.enter="showRename?doRename():doCreate()">
|
<input class="dialog-input" x-model="wsName" placeholder="Workspace name" @keydown.enter="showRename?doRename():doCreate()">
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
<button class="btn btn-secondary" @click="showCreate=false;showRename=false">Cancel</button>
|
<button class="btn btn-secondary" @click="closeCreateRename()">Cancel</button>
|
||||||
<button class="btn btn-primary" @click="showRename?doRename():doCreate()" x-text="showRename?'Rename':'Create'"></button>
|
<button class="btn btn-primary" @click="showRename?doRename():doCreate()" x-text="showRename?'Rename':'Create'"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+46
-1
@@ -6,10 +6,46 @@ les `|safe` du codebase étaient des no-op.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import pathlib
|
import pathlib
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
|
|
||||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
from jinja2 import Environment, FileSystemLoader, Undefined, select_autoescape
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LoggingUndefined(Undefined):
|
||||||
|
"""Undefined qui se rend comme une chaîne vide mais **journalise**.
|
||||||
|
|
||||||
|
v7.46.0 : avec l'`Undefined` par défaut, une variable mal orthographiée
|
||||||
|
(ou absente du contexte de la route) rendait `""` sans aucune trace — c'est
|
||||||
|
exactement ce qui a rendu le bug du bouton Home invisible (``local_workspaces``
|
||||||
|
absent ⇒ `/workspaces`` en silence). ``StrictUndefined`` est inutilisable ici
|
||||||
|
(40+ templates optionnelles, ex. ``embed_mode``), donc on garde le rendu et
|
||||||
|
on rend le silence visible dans les logs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
def _warn(self) -> None:
|
||||||
|
logger.warning("Jinja: variable non définie utilisée dans un template: %r", self._undefined_name)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
self._warn()
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def __bool__(self) -> bool:
|
||||||
|
self._warn()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
self._warn()
|
||||||
|
return iter(())
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
self._warn()
|
||||||
|
return 0
|
||||||
|
|
||||||
# A20 : nonce de script par requête, posé par le middleware CSP, lu par les
|
# A20 : nonce de script par requête, posé par le middleware CSP, lu par les
|
||||||
# templates via `{{ csp_nonce() }}` (vide hors requête — pas de header CSP
|
# templates via `{{ csp_nonce() }}` (vide hors requête — pas de header CSP
|
||||||
@@ -25,6 +61,7 @@ CSRF_TOKEN: ContextVar[str] = ContextVar("csrf_token", default="")
|
|||||||
ENV = Environment(
|
ENV = Environment(
|
||||||
loader=FileSystemLoader("app/templates"),
|
loader=FileSystemLoader("app/templates"),
|
||||||
autoescape=select_autoescape(["html"]),
|
autoescape=select_autoescape(["html"]),
|
||||||
|
undefined=LoggingUndefined,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -40,3 +77,11 @@ except OSError: # pragma: no cover
|
|||||||
ENV.globals["asset_version"] = ASSET_VERSION
|
ENV.globals["asset_version"] = ASSET_VERSION
|
||||||
ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get()
|
ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get()
|
||||||
ENV.globals["csrf_token"] = lambda: CSRF_TOKEN.get()
|
ENV.globals["csrf_token"] = lambda: CSRF_TOKEN.get()
|
||||||
|
# Plugins activés : conditionne les blocs de UI rendus côté serveur.
|
||||||
|
ENV.globals["plugin_enabled"] = lambda slug: _plugin_enabled(slug)
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_enabled(slug: str) -> bool:
|
||||||
|
from app.services.plugins import is_enabled
|
||||||
|
|
||||||
|
return is_enabled(slug)
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
# V74 — Menu + de l'assistant : hub de contexte (design)
|
||||||
|
|
||||||
|
> **Statut** : design — **phases 1 à 6 livrées (v7.51.0 → v7.56.0, 2026-10-06)** ;
|
||||||
|
> phases 7-8 restent à livrer. La **mémoire** a été simplifiée par rapport au
|
||||||
|
> modèle ici : une seule ligne résumé **par conversation** (pas de lignes
|
||||||
|
> `workspace_id NULL` — rien ne les écrivait), extraction déterministe sans LLM. Adaptations faites en cours de route : le parcours
|
||||||
|
> utilise `GET /api/nav/menu?parent_id=` (un niveau par appel, contrat
|
||||||
|
> dossier = `icon === 'folder'`), la gestion des skills est une **section du menu
|
||||||
|
> avec formulaire intégré** (pas une modale — `PATCH /api/agent/skills/{id}`
|
||||||
|
> ajouté), et les canevas vivent sous **`/board/api/page-templates`** (prefix
|
||||||
|
> `/board`) avec la nouvelle route `GET …/{id}/blocks` pour l'insertion.
|
||||||
|
> **Plan phasé** : `ROADMAP.md` → section « v7.51.0 → v7.58.0 — Menu + de l'assistant ».
|
||||||
|
> **Date** : 2026-10-06 · Version cible de départ : **v7.51.0** (phase 1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Objectif
|
||||||
|
|
||||||
|
Le bouton **+** du panneau agent (`app/templates/agent_panel.html:409`) ouvre
|
||||||
|
aujourd'hui une liste plate de fichiers/pages/bases à épingler en contexte. On le
|
||||||
|
transforme en **menu à sections à deux niveaux** :
|
||||||
|
|
||||||
|
```
|
||||||
|
+ ┌──────────────────────────────────────────────────────┐
|
||||||
|
│ 📎 Ajouter des fichiers ou répertoires › │ → picker actuel + « Parcourir… »
|
||||||
|
│ ✨ Compétences-skills › │ → Deep research, Skill-creator, …
|
||||||
|
│ │ ────────────────
|
||||||
|
│ │ Gérer les compétences
|
||||||
|
│ │ Parcourir les compétences
|
||||||
|
│ 🔌 Connecteurs › │ → Parcourir les connecteurs
|
||||||
|
│ │ Ajouter un connecteur personnalisé
|
||||||
|
│ 🎨 Design System – Canevas › │ → liste des canevas
|
||||||
|
│ 🧩 Add plugins │ → catalogue on/off (modale)
|
||||||
|
│ 🧠 Mémoire [◉/○] │ → toggle persisté
|
||||||
|
└──────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Règle d'or : **chaque entrée vit dans la même phase qui la rend réelle**. Tant qu'une
|
||||||
|
section n'est pas livrée, elle s'affiche grisée « Bientôt (phase N) » — le menu ne
|
||||||
|
promet rien que le code ne fait.
|
||||||
|
|
||||||
|
## 2. État des lieux (lu dans le code, 2026-10-06)
|
||||||
|
|
||||||
|
| Section | Existe | Manque |
|
||||||
|
|---|---|---|
|
||||||
|
| Fichiers / répertoires | `GET /api/agent/mentions` (`app/routers/agent.py:596`) → documents, collections, pages ; jetons `document:id`, `collection:id`, `page:id` résolus par `ContextBuilder._mentions_context` (`app/services/context_builder.py:120`) | Sous-menu, mode parcours d'arborescence, jeton `folder:<id>` |
|
||||||
|
| Compétences | `agent_skills` CRUD (`/api/agent/skills` : GET/POST/DELETE, apply, import/export), galerie de 17 presets (`app/services/skill_gallery.py`), 11 skills builtin (`FD_SKILLS`, `static/js/agent_panel_2.js:11`) | UI « Gérer » (CRUD complet : **`PATCH /skills/{id}` à créer**), UI « Parcourir » (la galerie n'est accessible que via la palette `/`), libellés affichés type « Deep research » |
|
||||||
|
| Canevas | `GET /api/page-templates` (built-ins `app/services/block_templates.py` + personnels), `POST /api/page-templates/{id}/use` (`app/routers/board/page_api.py:75,135`) | Menu dans le panneau agent, action « insérer dans le document ouvert », canevas « design system » |
|
||||||
|
| Connecteurs | Rien (0 hit `connector` dans `app/`). Natifs déjà branchés ailleurs : Gitea (`gitea_client.py`), GitHub (`github_adapter.py`), Web (`web_search.py` + tool `fetch_url` v7.46) | Table, service d'adapters, catalogue UI, tools agent, OAuth Google/M365, Discord/Telegram, MCP |
|
||||||
|
| Plugins | Rien | Registre, catalogue UI, **câblage réel** (désactivation effective) |
|
||||||
|
| Mémoire | Rien (l'historique de la conversation est déjà envoyé, ce n'est pas de la mémoire) | Table, service d'extraction, injection conditionnelle, toggle persisté |
|
||||||
|
|
||||||
|
## 3. Découpage front (Alpine)
|
||||||
|
|
||||||
|
Tout vit dans `static/js/agent_panel_2.js` (composant `agentPanel()`) + un bloc de
|
||||||
|
markup dans `app/templates/agent_panel.html`, sans nouveau fichier JS.
|
||||||
|
|
||||||
|
**État ajouté**
|
||||||
|
|
||||||
|
```js
|
||||||
|
plusOpen: false, // menu ouvert
|
||||||
|
plusSection: 'root', // 'root' | 'files' | 'skills' | 'connectors' | 'canvases'
|
||||||
|
plusFocus: -1, // index de l'item focalisé
|
||||||
|
plusItems: [], // items de la section courante (fetch si besoin)
|
||||||
|
memoryOn: true, // état du toggle (phase 4)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Items = données, pas du markup en dur** : un tableau `FD_PLUS_MENU`
|
||||||
|
`{key, icon, label, sub, action, phase}` → le rendu et la navigation clavier
|
||||||
|
(↑/↓/Entrée/Échap) sont une seule boucle. Les sections `phase > livrée` sont
|
||||||
|
`disabled: true`.
|
||||||
|
|
||||||
|
**Réemploi** : le conteneur et les styles `.fd-ap-mention-menu` /
|
||||||
|
`.fd-mention-item` (`agent_panel.html:171-181`) servent déjà exactement ce
|
||||||
|
comportement — on duplique le CSS minimal (`.fd-plus-*`) et on garde la logique de
|
||||||
|
focus/scroll (`moveMentionFocus`, `_scrollMentionItem`) comme modèle.
|
||||||
|
|
||||||
|
**Entrées existantes préservées** : la touche `@` dans le composer et le mode
|
||||||
|
recherche du `+` continuent de fonctionner à l'identique (régression zéro) ; le `+`
|
||||||
|
devient seulement *l'entrée en menu* au lieu d'ouvrir directement la liste.
|
||||||
|
|
||||||
|
## 4. Backend par phase
|
||||||
|
|
||||||
|
| Phase | Existant réutilisé | À créer |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 — menu + fichiers/répertoires | `/api/agent/mentions`, `/api/local-workspace/tree?folder=`, enfants de page (`parent_id`, `local_workspace.py:243`) | Route(s) de parcours adaptée(s) au panneau (shape items compatible mention), jeton `folder:<id>` dans `ContextBuilder` |
|
||||||
|
| 2 — compétences | `/api/agent/skills` (GET/POST/DELETE/apply/import/export), `/api/agent/skills/gallery` + install | `PATCH /api/agent/skills/{id}` (édition), 2 modales |
|
||||||
|
| 3 — canevas | `/api/page-templates` (GET/POST/use) | Insersion dans l'éditeur ouvert (API d'insertion de blocs de l'éditeur), presets « design system » dans `block_templates.py` |
|
||||||
|
| 4 — mémoire | `ContextBuilder`, `PATCH /conversations/{id}` (pattern du toggle) | Table `agent_memory`, `app/services/agent_memory.py`, colonne `conversations.memory_enabled`, config `AGENT_MEMORY_DEFAULT` |
|
||||||
|
| 5 — connecteurs (socle) | `_is_public_host` (SSRF), `encrypt_secret` (`sso_provisioning.py:51`), `http_client.shared_client`, `tool_registry` | Table `agent_connectors`, `app/services/connectors/`, 3 adapters natifs (gitea/github/web), catalogue UI |
|
||||||
|
| 6 — Google + M365 | `auth/oauth.py` (client OAuth2), `encrypt_secret`, `shared_client` | Flows PKCE Google + Microsoft Graph, table de tokens (motif `calendar_sync.py:38`), écran connecteur |
|
||||||
|
| 7 — Discord / Telegram / Teams / MCP | Graph (phase 6), pattern de tools dynamiques du registre | 3 adapters + client MCP (`initialize`, `tools/list`, `tools/call`) |
|
||||||
|
| 8 — plugins | — | Table `plugins`, catalogue UI, désactivation réelle (routes + UI) |
|
||||||
|
|
||||||
|
## 5. Modèle de données
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Phase 4 — mémoire
|
||||||
|
ALTER TABLE conversations ADD COLUMN memory_enabled INTEGER NOT NULL DEFAULT 1;
|
||||||
|
CREATE TABLE agent_memory (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
workspace_id INTEGER NOT NULL,
|
||||||
|
conversation_id INTEGER, -- NULL = mémoire d'espace (partagée)
|
||||||
|
kind TEXT NOT NULL DEFAULT 'summary', -- summary | fact
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_agent_memory_ws ON agent_memory(workspace_id, conversation_id);
|
||||||
|
|
||||||
|
-- Phase 5 — connecteurs
|
||||||
|
CREATE TABLE agent_connectors (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
workspace_id INTEGER,
|
||||||
|
kind TEXT NOT NULL, -- gitea | github | web | google | ms365 | discord | telegram | mcp | custom
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
config_json TEXT NOT NULL DEFAULT '{}', -- URL, scopes, channels…
|
||||||
|
secret_encrypted TEXT, -- Fernet (encrypt_secret, sso_provisioning.py:51)
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
status TEXT NOT NULL DEFAULT 'unknown', -- ok | error | unknown
|
||||||
|
last_probe_at TEXT,
|
||||||
|
created_by INTEGER
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Phase 8 — plugins
|
||||||
|
CREATE TABLE plugins (
|
||||||
|
slug TEXT PRIMARY KEY, -- web-tools | web-clipper | automations | …
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
Migrations ajoutées dans `app/migrations.py` (motif `_migration_*` existant,
|
||||||
|
transaction par migration — A31).
|
||||||
|
|
||||||
|
## 6. Sécurité (non négociable)
|
||||||
|
|
||||||
|
- **SSRF** : toute URL de connecteur passée par `_is_public_host`
|
||||||
|
(`app/services/importers/url_fetch.py`), re-vérifiée à chaque saut de redirection.
|
||||||
|
- **Secrets** : jamais en clair, jamais renvoyés par l'API (champ retiré des
|
||||||
|
sérialiseurs) — `encrypt_secret()` existant, dérivé de `app_secret_key`.
|
||||||
|
- **Auth + CSRF** : session requise sur tout CUD ; `/api/agent` est sur la liste des
|
||||||
|
préfixes CSRF cookie → header `X-CSRF-Token` sur les nouveaux `fetch` (helper
|
||||||
|
`getCsrf()`).
|
||||||
|
- **Borne de tokens** : la mémoire et les sources de connecteurs injectées dans le
|
||||||
|
contexte sont tronquées (budget explicite) — sinon la phase 4 fait exploser le
|
||||||
|
coût de chaque run.
|
||||||
|
- **Plugins OFF = effet réel** : un simple drapeau affiché serait un menu qui ment
|
||||||
|
(règle du projet).
|
||||||
|
|
||||||
|
## 7. Décisions techniques
|
||||||
|
|
||||||
|
- **D1 — Un seul menu, deux niveaux** (pas de modale racine) : ouverture instantanée,
|
||||||
|
clavier, et le `+` reste un geste unique. Les modales ne servent qu'aux écrans
|
||||||
|
lourds (Gérer / Parcourir / Connecteurs / Plugins).
|
||||||
|
- **D2 — Pas de nouveau fichier JS** : tout dans `agent_panel_2.js` + markup du
|
||||||
|
template, conforme à l'extraction A27 (un fichier par bloc).
|
||||||
|
- **D3 — États « Bientôt » visibles** plutôt que sections masquées : le menu sert de
|
||||||
|
spec vivante et évite d'implémenter6 features d'un coup.
|
||||||
|
- **D4 — La mémoire est un résumé structuré, pas l'historique brut** : réinvoquer
|
||||||
|
tout l'historique à chaque run coûterait plus cher que la réponse ; un résumé borné
|
||||||
|
(~1–2 k tokens) + les faits saillants suffisent, avec repli déterministe hors-ligne
|
||||||
|
(motif `ai_writing.py`).
|
||||||
|
- **D5 — Connecteurs = registry + adapters** (interface `probe/list_sources/fetch`),
|
||||||
|
pas un if par provider : la phase 7 (MCP) ajoute des adapters sans toucher au socle.
|
||||||
|
- **D6 — Canevas : « créer » par défaut, « insérer » seulement si un document est
|
||||||
|
ouvert** — l'insertion dans l'éditeur est le point le plus fragile (blocs +
|
||||||
|
sélection), donc livrée en second dans la phase 3.
|
||||||
|
- **D7 — Pas de dépendance ajoutée** : OAuth via `httpx` (`shared_client`), chiffrement
|
||||||
|
via `cryptography` déjà présent, MCP en HTTP direct.
|
||||||
|
|
||||||
|
## 8. Pièges du repo à respecter
|
||||||
|
|
||||||
|
- `ROADMAP.md` / `CHANGELOG.md` sont en **CRLF** : `patch` exige des lignes complètes.
|
||||||
|
- **Routes statiques avant les wildcards** (`/skills/gallery` avant `/skills/{id}`),
|
||||||
|
sinon 422 sur un id non numérique.
|
||||||
|
- **Events `AgentEngine.run()` plats** (`{"type": …, "content": …}`), pas enveloppés
|
||||||
|
dans `data`.
|
||||||
|
- Bumper **`VERSION` + `app/main.py`** à chaque phase, régénérer
|
||||||
|
`docs/openapi-v2.json` (CRLF, sans newline final) si de nouvelles routes sortent.
|
||||||
|
- Le panneau agent est rendu sur **toutes les pages** (`base.html:2555`) : le JS doit
|
||||||
|
rester défensif (`window.appState` absent, page non authentifiée).
|
||||||
|
|
||||||
|
## 9. Critères d'acceptation par phase
|
||||||
|
|
||||||
|
1. **Phase 1** : le `+` ouvre le menu, « Parcourir… » montre l'arborescence, un
|
||||||
|
dossier épinglé apparaît en chip et son contenu est bien dans le contexte du run.
|
||||||
|
2. **Phase 2** : créer/éditer/supprimer un skill depuis la modale, l'installer depuis
|
||||||
|
la galerie, il apparaît dans la palette `/` et le sous-menu.
|
||||||
|
3. **Phase 3** : choisir un canevas crée la page (et l'insertion dans le document
|
||||||
|
ouvert fonctionne quand un doc est édité).
|
||||||
|
4. **Phase 4** : ON → la mémoire est injectée (test qui le prouve) ; OFF → absente ;
|
||||||
|
l'état survit au rechargement et au changement de conversation.
|
||||||
|
5. **Phase 5** : connecteur créé/testé/supprimé, URL privée refusée (SSRF), tool
|
||||||
|
exposé au LLM avec le bon schéma.
|
||||||
|
6. **Phase 6** : connexion Google et Microsoft, refresh, déconnexion, aucun appel
|
||||||
|
réseau réel dans les tests.
|
||||||
|
7. **Phase 7** : Discord/Telegram branchés, MCP `tools/list` → outils visibles dans le
|
||||||
|
registre.
|
||||||
|
8. **Phase 8** : plugin OFF = route refusée + UI disparue (test de non-réintroduction).
|
||||||
|
|
||||||
|
Chaque phase se clôt par : `ruff` + suite verte, bump de version, CHANGELOG, ROADMAP
|
||||||
|
à jour, tag.
|
||||||
|
|
||||||
|
## 9bis. Avenant phase 7 (v7.57.0) — décisions livrées
|
||||||
|
|
||||||
|
- **D5 (`probe/list_sources/fetch` par adapter) écartée** : 1 switch sur
|
||||||
|
`agent_connectors.kind` dans `probe()` / `connector_fetch()` — une interface
|
||||||
|
à 3 méthodes pour 4 kinds tordrait plus qu'elle ne simplifie ; ajouter un
|
||||||
|
kind = 1 branche de plus dans le même switch.
|
||||||
|
- **Discord / Telegram = presets du socle**, pas 3 adapters dédiés : le champ
|
||||||
|
**Type** du formulaire pré-remplit l'URL + le schéma d'auth
|
||||||
|
(`bearer`/`bot`/`none`). La Bot API de Telegram impose le jeton **dans l'URL**
|
||||||
|
→ substitut `{secret}` remplacé à l'appel (`connectors._with_secret`),
|
||||||
|
stockage = motif seul, test « le jeton n'est jamais en base ».
|
||||||
|
- **MCP : outils dynamiques sans état** — `ToolRegistry._all()` relit le cache
|
||||||
|
`tools_json` à chaque run (rien à invalider, « Tester » suffit) ; nom LLM
|
||||||
|
`mcp_<serveur>_<outil>` (slug sans double soulignement) ; dispatch
|
||||||
|
`tools/call` dans `McpTool.execute()`. Serveur désactivé → outil **absent du
|
||||||
|
schéma**, échec réseau → `ToolResult(error)` : le run continue toujours.
|
||||||
|
- **Pas de client SSE à l'état** : `parse_body()` lit le JSON direct ou le
|
||||||
|
premier `data:` d'un `text/event-stream`, ce qui couvre
|
||||||
|
`initialize` / `tools/list` / `tools/call` des serveurs streamables courants.
|
||||||
|
Upgrade : client SSE persistant si un serveur ne répond qu'en flux.
|
||||||
|
|
||||||
|
## 9ter. Avenant phase 8 (v7.58.0) — décisions livrées
|
||||||
|
|
||||||
|
- **Garde de route = dépendance posée à l'`include_router`** (`main.py`), pas
|
||||||
|
de décorateur par route ni de middleware : 3 lignes, aucun router modifié.
|
||||||
|
⚠️ l'annotation `request: Request` doit être importée **au module** : avec
|
||||||
|
`from __future__ import annotations` les annotations sont des chaînes et
|
||||||
|
FastAPI les résout dans les globales du module — sinon il lit un query param
|
||||||
|
→ 422 (piège réel, corrigé en cours de phase).
|
||||||
|
- **Effet réel aux 4 niveaux** : routes (dépendance), arrière-plan (garde de
|
||||||
|
tick du scheduler), outils (`ToolRegistry._all()`), UI (rendu serveur).
|
||||||
|
- **UI : `{% if %}` pour la nav, `x-show` pour les sections** — la nav est
|
||||||
|
**absente du DOM** (test « UI absente » au sens propre) ; les grosses
|
||||||
|
sections ne sont pas restructurées, leur booléen est rendu au serveur.
|
||||||
|
- **Handler unifié des 404** (comportement historique conservé) : hors `/api`
|
||||||
|
une route refusée **redirige 302 → /workspaces**, `/api*` répond 404 JSON —
|
||||||
|
les tests assertent les deux formes au lieu de réécrire le handler.
|
||||||
|
- **SQLite** : `INSERT … ON CONFLICT` évalue NOT NULL **avant** l'upsert →
|
||||||
|
fournir toutes les colonnes NOT NULL, même pour un simple changement d'état.
|
||||||
|
- `ponytail:` plafond assumé — pas de classe-adapter par plugin ni de
|
||||||
|
« marketplace » : ajouter un plugin = 1 tuple dans `CATALOG` (+ 1 garde si
|
||||||
|
son effet n'est pas déjà couvert).
|
||||||
|
|
||||||
|
## 10. Hors périmètre
|
||||||
|
|
||||||
|
- Écriture dans les sources distantes (Google Docs, Slack…) — lecture seule au départ.
|
||||||
|
- Synchronisation d'arborescence locale ↔ connecteur.
|
||||||
|
- Marketplace de plugins tiers (le registre est interne à l'instance).
|
||||||
|
- Refonte du composer / de la palette `/` (compatibilité maintenue à l'identique).
|
||||||
+1027
-26
File diff suppressed because it is too large
Load Diff
@@ -1,57 +0,0 @@
|
|||||||
// Où atterrit right_actions sur /pages/{id} ? (parsé ou texte ?)
|
|
||||||
const { chromium } = require('playwright-core');
|
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
function findChromium() {
|
|
||||||
const root = path.join(process.env.LOCALAPPDATA, 'ms-playwright');
|
|
||||||
const dirs = fs.readdirSync(root).filter((d) => d.startsWith('chromium-') && !d.includes('headless'));
|
|
||||||
dirs.sort();
|
|
||||||
return path.join(root, dirs[dirs.length - 1], 'chrome-win64', 'chrome.exe');
|
|
||||||
}
|
|
||||||
const BASE = 'http://localhost:8080';
|
|
||||||
const CSP_JS = path.join(__dirname, 'fixtures', 'alpine.csp.js');
|
|
||||||
(async () => {
|
|
||||||
const browser = await chromium.launch({ headless: true, executablePath: findChromium() });
|
|
||||||
const ctx = await browser.newContext({ serviceWorkers: 'block' });
|
|
||||||
const page = await ctx.newPage();
|
|
||||||
const errs = [];
|
|
||||||
page.on('pageerror', (e) => errs.push(e.message.slice(0, 110)));
|
|
||||||
await page.goto(`${BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
|
||||||
if (await page.locator('#email').count()) {
|
|
||||||
await page.fill('#email', process.env.FD_USER || '[email protected]');
|
|
||||||
await page.fill('#password', process.env.FD_PASS || 'e2e-secret-123');
|
|
||||||
await page.click('.btn-primary');
|
|
||||||
await page.waitForURL('**/workspaces', { timeout: 15000 }).catch(() => {});
|
|
||||||
}
|
|
||||||
await page.route('**/static/js/alpine.min.js', (r) =>
|
|
||||||
r.fulfill({ path: CSP_JS, contentType: 'application/javascript' })
|
|
||||||
);
|
|
||||||
const coll = await page.evaluate(async () => {
|
|
||||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
|
||||||
const r = await fetch('/db/api', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
|
||||||
body: JSON.stringify({ name: 'e2e-probe-editor' }),
|
|
||||||
});
|
|
||||||
return r.json();
|
|
||||||
});
|
|
||||||
await page.goto(`${BASE}/pages/${coll.id}`, { waitUntil: 'domcontentloaded' });
|
|
||||||
await page.waitForTimeout(800);
|
|
||||||
const info = await page.evaluate(() => {
|
|
||||||
const star = document.querySelector('.star-btn');
|
|
||||||
const anyText = document.body.innerHTML.includes('edCall(');
|
|
||||||
const parsed = document.querySelector('button.star-btn') ? 'parse' : 'pas-de-bouton';
|
|
||||||
const parent = star ? star.parentElement.className : null;
|
|
||||||
const txt = document.body.innerHTML.indexOf('toggleActivityOpen');
|
|
||||||
const ctx = txt >= 0 ? document.body.innerHTML.slice(Math.max(0, txt - 160), txt + 60) : null;
|
|
||||||
return { parsed: parsed, starParent: parent, edCallInHTML: anyText, ctx: ctx };
|
|
||||||
});
|
|
||||||
console.log(JSON.stringify(info, null, 1));
|
|
||||||
console.log('erreurs:', errs.length ? errs : 'aucune');
|
|
||||||
// cleanup
|
|
||||||
await page.evaluate(async (id) => {
|
|
||||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
|
||||||
await fetch(`/db/api/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf } });
|
|
||||||
}, coll.id);
|
|
||||||
await browser.close();
|
|
||||||
})();
|
|
||||||
+38
-4
@@ -134,10 +134,9 @@ test('A20-ph3 : éditeur de page (right_actions) sous build CSP', async ({ page
|
|||||||
});
|
});
|
||||||
expect(editor.delegates).toBe('ok');
|
expect(editor.delegates).toBe('ok');
|
||||||
expect(editor.ed).toBe('ok');
|
expect(editor.ed).toBe('ok');
|
||||||
// (le rendu des boutons right_actions est cassé côté SERVEUR —
|
// les boutons du topbar sont réellement servis (bug A10 « ~ + Markup »
|
||||||
// entities " sur TOUTES les pages, régression pré-existante
|
// corrigé : block-set) ET leurs expressions passent le filet CSP
|
||||||
// documentée au ROADMAP — donc on n'asserte pas leur présence ;
|
await expect(page.locator('.star-btn').first()).toBeAttached();
|
||||||
// le filet 0-erreur = les expressions évaluées sont parsables.)
|
|
||||||
} finally {
|
} finally {
|
||||||
await page.evaluate(async (id) => {
|
await page.evaluate(async (id) => {
|
||||||
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||||
@@ -146,6 +145,41 @@ test('A20-ph3 : éditeur de page (right_actions) sous build CSP', async ({ page
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('UI : éditeur visuel de steps automations (API v7.0)', async ({ page }) => {
|
||||||
|
// crée une automation, ouvre l'édition, ajoute une étape (POST /steps),
|
||||||
|
// vérifie la carte résumée — sous CSP réel (expressions du nouveau bloc)
|
||||||
|
await login(page);
|
||||||
|
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
await page.click('.settings-nav-item:has-text("Automations")');
|
||||||
|
const name = 'e2e-steps-' + Date.now();
|
||||||
|
await page.fill('input[placeholder*="Notifier le statut"]', name);
|
||||||
|
await page.click('button:has-text("Créer")');
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
const row = page.locator('.setting-row', { hasText: name }).last();
|
||||||
|
await row.locator('button:has-text("✎")').click();
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
await expect(page.locator('text=Pipeline visuel (steps)')).toBeVisible();
|
||||||
|
await page.click('button:has-text("Ajouter l\'étape")');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => Array.from(document.querySelectorAll('.setting-label'))
|
||||||
|
.some((e) => (e.textContent || '').includes('Action · webhook')),
|
||||||
|
null,
|
||||||
|
{ timeout: 8000 }
|
||||||
|
);
|
||||||
|
// nettoyage API
|
||||||
|
await page.evaluate(async (n) => {
|
||||||
|
const d = await (await fetch('/workspace/automations')).json();
|
||||||
|
const a = (d.automations || []).find((x) => x.name === n);
|
||||||
|
if (a) {
|
||||||
|
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||||
|
await fetch('/workspace/automations/' + a.id,
|
||||||
|
{ method: 'DELETE', headers: { 'X-CSRF-Token': csrf } });
|
||||||
|
await fetch('/workspace/automations/' + a.id + '/steps', { method: 'DELETE' }).catch(() => {});
|
||||||
|
}
|
||||||
|
}, name);
|
||||||
|
});
|
||||||
|
|
||||||
test('A20-ph3 : settings sous build Alpine CSP', async ({ page }) => {
|
test('A20-ph3 : settings sous build Alpine CSP', async ({ page }) => {
|
||||||
await login(page);
|
await login(page);
|
||||||
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
|
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// Mobile regression: hamburger drawer, wheel scroll, settings side-nav, help page.
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||||
|
const USER = process.env.FD_USER || '[email protected]';
|
||||||
|
const PASS = process.env.FD_PASS || 'e2e-pass-123';
|
||||||
|
|
||||||
|
// One login for the whole file (app rate limiter: 60 req/min per IP) — reused
|
||||||
|
// as storageState by every test.
|
||||||
|
let STATE = null;
|
||||||
|
test.beforeAll(async ({ browser }) => {
|
||||||
|
const ctx = await browser.newContext();
|
||||||
|
const page = await ctx.newPage();
|
||||||
|
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.fill('#email', USER);
|
||||||
|
await page.fill('#password', PASS);
|
||||||
|
await page.click('.btn-primary');
|
||||||
|
const ok = await page.waitForURL('**/workspaces', { timeout: 10000 }).then(() => true).catch(() => false);
|
||||||
|
if (!ok) {
|
||||||
|
const resp = await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E Mobile' } });
|
||||||
|
if (!resp.ok() && resp.status() !== 409) throw new Error(`register ${resp.status()}`);
|
||||||
|
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.fill('#email', USER);
|
||||||
|
await page.fill('#password', PASS);
|
||||||
|
await page.click('.btn-primary');
|
||||||
|
await page.waitForURL('**/workspaces', { timeout: 10000 });
|
||||||
|
}
|
||||||
|
STATE = await ctx.storageState();
|
||||||
|
await ctx.close();
|
||||||
|
});
|
||||||
|
async function login(page) {
|
||||||
|
// Session reused from the single beforeAll login (rate limiter: 60 req/min/IP)
|
||||||
|
await page.context().addCookies(STATE.cookies);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('mobile', () => {
|
||||||
|
test.use({ viewport: { width: 390, height: 844 } });
|
||||||
|
|
||||||
|
test('hamburger opens mobile sidebar drawer', async ({ page }) => {
|
||||||
|
const errs = [];
|
||||||
|
page.on('pageerror', (e) => errs.push(e.message));
|
||||||
|
await login(page);
|
||||||
|
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
const burger = page.locator('.hamburger-btn');
|
||||||
|
await expect(burger).toBeVisible();
|
||||||
|
|
||||||
|
// Sidebar starts off-canvas
|
||||||
|
const sidebar = page.locator('#sidebar');
|
||||||
|
const boxBefore = await sidebar.boundingBox();
|
||||||
|
expect(boxBefore.x).toBeLessThan(0);
|
||||||
|
|
||||||
|
await burger.click();
|
||||||
|
await expect(sidebar).toHaveClass(/mobile-open/, { timeout: 5000 });
|
||||||
|
const boxAfter = await sidebar.boundingBox();
|
||||||
|
expect(boxAfter.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(errs).toEqual([]);
|
||||||
|
|
||||||
|
// Overlay tap closes it
|
||||||
|
await page.locator('.sidebar-overlay').click({ position: { x: 350, y: 400 } });
|
||||||
|
await expect(sidebar).not.toHaveClass(/mobile-open/, { timeout: 5000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile sidebar drawer scrolls with mouse wheel', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.locator('.hamburger-btn').click();
|
||||||
|
await page.waitForSelector('#sidebar.mobile-open');
|
||||||
|
const sc = page.locator('.sidebar-scroll');
|
||||||
|
await expect.poll(async () => sc.evaluate((el) => el.scrollHeight > el.clientHeight), { timeout: 3000 }).toBe(true);
|
||||||
|
const box = await sc.boundingBox();
|
||||||
|
await page.mouse.move(box.x + box.width / 2, box.y + Math.min(box.height / 2, 400));
|
||||||
|
await page.mouse.wheel(0, 300);
|
||||||
|
await expect.poll(async () => sc.evaluate((el) => el.scrollTop), { timeout: 3000 }).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings page fits mobile viewport', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
|
||||||
|
const panel = page.locator('.settings-panel');
|
||||||
|
await expect(panel).toBeVisible();
|
||||||
|
|
||||||
|
const box = await panel.boundingBox();
|
||||||
|
expect(box.width).toBeLessThanOrEqual(390);
|
||||||
|
expect(box.height).toBeLessThanOrEqual(844);
|
||||||
|
|
||||||
|
// No horizontal page overflow
|
||||||
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||||
|
expect(overflow).toBeLessThanOrEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('settings mobile nav is a hamburger side drawer', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
|
||||||
|
const burger = page.locator('.settings-menu-btn');
|
||||||
|
await expect(burger).toBeVisible();
|
||||||
|
const nav = page.locator('.settings-nav');
|
||||||
|
const before = await nav.boundingBox();
|
||||||
|
expect(before.x + before.width).toBeLessThanOrEqual(1);
|
||||||
|
|
||||||
|
await burger.click();
|
||||||
|
await expect(nav).toHaveClass(/nav-open/);
|
||||||
|
|
||||||
|
// Nav item switches section AND closes the drawer
|
||||||
|
await page.locator('.settings-nav-item', { hasText: 'Tags' }).click();
|
||||||
|
await expect(nav).not.toHaveClass(/nav-open/);
|
||||||
|
await expect(page.locator('h2', { hasText: 'Tags' }).first()).toBeVisible();
|
||||||
|
|
||||||
|
// Re-open and dismiss via backdrop
|
||||||
|
await burger.click();
|
||||||
|
await expect(nav).toHaveClass(/nav-open/);
|
||||||
|
await page.locator('.settings-nav-backdrop').click({ position: { x: 375, y: 500 } });
|
||||||
|
await expect(nav).not.toHaveClass(/nav-open/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('help page uses settings-style side navigation', async ({ page }) => {
|
||||||
|
const errs = [];
|
||||||
|
page.on('pageerror', (e) => errs.push(e.message));
|
||||||
|
await login(page);
|
||||||
|
await page.goto(`${FD_BASE}/help`, { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
// Same panel skeleton as settings
|
||||||
|
await expect(page.locator('.settings-panel')).toBeVisible();
|
||||||
|
await expect(page.locator('.settings-menu-btn')).toBeVisible();
|
||||||
|
|
||||||
|
// Mobile: nav drawer behind hamburger
|
||||||
|
const nav = page.locator('.settings-nav');
|
||||||
|
await expect(nav).not.toHaveClass(/nav-open/);
|
||||||
|
await page.locator('.settings-menu-btn').click();
|
||||||
|
await expect(nav).toHaveClass(/nav-open/);
|
||||||
|
await page.locator('.settings-nav-item', { hasText: 'Databases' }).click();
|
||||||
|
await expect(nav).not.toHaveClass(/nav-open/);
|
||||||
|
await expect(page.locator('h2', { hasText: 'Databases & Views' })).toBeVisible();
|
||||||
|
|
||||||
|
// App-wide coverage: all sections exist in the nav
|
||||||
|
const sections = ['Getting Started', 'Pages & Editor', 'Databases & Views', 'Tasks & Dependencies',
|
||||||
|
'Workspaces & Forges', 'Collaboration', 'Library, Trash & Search', 'AI Agent & Automations',
|
||||||
|
'Integrations & API', 'Offline & PWA', 'Accounts & SSO', 'Keyboard Shortcuts', 'Tips'];
|
||||||
|
const labels = (await nav.locator('.settings-nav-item').allInnerTexts()).join(' ');
|
||||||
|
for (const s of sections) expect(labels, s).toContain(s);
|
||||||
|
|
||||||
|
expect(errs).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('desktop', () => {
|
||||||
|
test.use({ viewport: { width: 1440, height: 900 } });
|
||||||
|
|
||||||
|
test('settings and help keep the 2-column layout on desktop', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page.locator('.settings-menu-btn')).toBeHidden();
|
||||||
|
const nav = page.locator('.settings-nav');
|
||||||
|
await expect(nav).toBeVisible();
|
||||||
|
const navBox = await nav.boundingBox();
|
||||||
|
expect(navBox.height).toBeGreaterThan(navBox.width);
|
||||||
|
|
||||||
|
await page.goto(`${FD_BASE}/help`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page.locator('.settings-menu-btn')).toBeHidden();
|
||||||
|
await page.locator('.settings-nav-item', { hasText: 'SSO' }).click();
|
||||||
|
await expect(page.locator('h2', { hasText: 'Accounts & SSO' })).toBeVisible();
|
||||||
|
await expect(page.locator('.settings-content')).toContainText('IndexedDB');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
/**
|
||||||
|
* Probe de fluidité de navigation partielle (fdLoad) — mesure brute, pas un test gate.
|
||||||
|
*
|
||||||
|
* Pour chaque navigation il enregistre :
|
||||||
|
* - les marques htmx (beforeRequest / beforeSwap / afterSwap / afterSettle)
|
||||||
|
* - la fenêtre x-ignore (zone non montée Alpine)
|
||||||
|
* - un échantillon par frame : nb d'éléments [x-show] ENCORE VISIBLES non montés
|
||||||
|
* (= le contenu « brut » qui se voit avant qu'Alpine ne le masque → les flashes
|
||||||
|
* de fenêtres) et nb d'éléments [x-cloak] encore masqués (= contenu qui pop)
|
||||||
|
* - les layout-shift (CLS) et leur source
|
||||||
|
*
|
||||||
|
* Lancement : npx playwright test probe_nav_perf --reporter=list
|
||||||
|
*/
|
||||||
|
const { test } = require('@playwright/test');
|
||||||
|
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||||
|
const USER = process.env.FD_USER || '[email protected]';
|
||||||
|
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||||
|
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.fill('#email', USER);
|
||||||
|
await page.fill('#password', PASS);
|
||||||
|
await page.click('.btn-primary');
|
||||||
|
const ok = await page.waitForURL('**/workspaces', { timeout: 8000 }).then(() => true).catch(() => false);
|
||||||
|
if (!ok) {
|
||||||
|
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
|
||||||
|
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instrumentation posée UNE fois dans la page (avant le clic).
|
||||||
|
const INSTALL = () => {
|
||||||
|
const P = (window.__probe = { t0: performance.now(), marks: [], frames: [], shifts: [], rawMax: 0, rawFrames: 0 });
|
||||||
|
const mark = (n) => P.marks.push([n, Math.round(performance.now() - P.t0)]);
|
||||||
|
|
||||||
|
['htmx:beforeRequest', 'htmx:beforeSwap', 'htmx:afterSwap', 'htmx:afterSettle'].forEach((ev) =>
|
||||||
|
document.addEventListener(ev, () => mark(ev))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fenêtre x-ignore sur .main-wrapper (le nœud change à chaque swap → on observe tout le doc)
|
||||||
|
new MutationObserver((muts) => {
|
||||||
|
for (const m of muts) {
|
||||||
|
if (m.type !== 'attributes' || m.attributeName !== 'x-ignore') continue;
|
||||||
|
const t = m.target;
|
||||||
|
if (!(t instanceof Element) || !t.classList.contains('main-wrapper')) continue;
|
||||||
|
mark(t.hasAttribute('x-ignore') ? 'x-ignore ON' : 'x-ignore OFF');
|
||||||
|
}
|
||||||
|
}).observe(document.documentElement, { attributes: true, attributeFilter: ['x-ignore'], subtree: true });
|
||||||
|
|
||||||
|
new PerformanceObserver((list) => {
|
||||||
|
for (const e of list.getEntries()) {
|
||||||
|
P.shifts.push({
|
||||||
|
v: +e.value.toFixed(4),
|
||||||
|
t: Math.round(e.startTime - P.t0),
|
||||||
|
sources: (e.sources || []).map((s) => {
|
||||||
|
const n = s.node;
|
||||||
|
return n ? `${n.nodeName}.${(n.className || '').toString().split(' ')[0]}` : '?';
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).observe({ type: 'layout-shift', buffered: false });
|
||||||
|
|
||||||
|
// Échantillonnage par frame. « visible » = peint à l'écran : la zone peut
|
||||||
|
// porter opacity:0 (fix v7.45.3) → rects toujours là, mais rien n'est peint.
|
||||||
|
const zonePainted = (z) => z && parseFloat(getComputedStyle(z).opacity || '1') > 0;
|
||||||
|
const tick = () => {
|
||||||
|
let raw = 0, rawVisible = 0, cloak = 0;
|
||||||
|
const zone = document.querySelector('.main-wrapper');
|
||||||
|
const painted = zonePainted(zone);
|
||||||
|
if (zone) {
|
||||||
|
for (const el of zone.querySelectorAll('[x-show]')) {
|
||||||
|
if (el._x_marker) continue; // monté par Alpine
|
||||||
|
raw++;
|
||||||
|
if (painted && el.getClientRects().length) rawVisible++; // peint ET en flow
|
||||||
|
}
|
||||||
|
cloak = zone.querySelectorAll('[x-cloak]').length;
|
||||||
|
}
|
||||||
|
P.rawMax = Math.max(P.rawMax, rawVisible);
|
||||||
|
if (rawVisible) P.rawFrames++;
|
||||||
|
P.frames.push([Math.round(performance.now() - P.t0), raw, rawVisible, cloak, painted ? 1 : 0]);
|
||||||
|
if (performance.now() - P.t0 < 3000) requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
test('probe fluidité nav', async ({ page }) => {
|
||||||
|
test.setTimeout(120000);
|
||||||
|
await login(page);
|
||||||
|
|
||||||
|
const NAVS = ['/library', '/settings', '/workspaces', '/trash', '/local-workspace'];
|
||||||
|
const results = [];
|
||||||
|
const SHOTS = require('path').join(__dirname, 'shots');
|
||||||
|
require('fs').mkdirSync(SHOTS, { recursive: true });
|
||||||
|
|
||||||
|
for (const dest of NAVS) {
|
||||||
|
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'load' });
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
await page.evaluate(INSTALL);
|
||||||
|
await page.evaluate((p) => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = p;
|
||||||
|
a.textContent = 'probe';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
}, dest);
|
||||||
|
// Filet : si du contenu brut est peint, on capture pour voir CE qui flash
|
||||||
|
let shot = null;
|
||||||
|
const tEnd = Date.now() + 3200;
|
||||||
|
while (Date.now() < tEnd && !shot) {
|
||||||
|
const last = await page.evaluate(() => {
|
||||||
|
const f = window.__probe.frames;
|
||||||
|
return f.length ? f[f.length - 1] : null;
|
||||||
|
});
|
||||||
|
if (last && last[2] > 0) {
|
||||||
|
shot = `navflash_${dest.replace(/\W+/g, '')}.png`;
|
||||||
|
await page.screenshot({ path: require('path').join(SHOTS, shot) });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(25);
|
||||||
|
}
|
||||||
|
const P = await page.evaluate(() => window.__probe);
|
||||||
|
// État final : la zone doit être révélée (opacity>0) ET montée Alpine,
|
||||||
|
// et .app-layout (x-cloak anti-FOUC) doit être retiré → pas de page blanche
|
||||||
|
const final = await page.evaluate(() => {
|
||||||
|
const z = document.querySelector('.main-wrapper');
|
||||||
|
const a = document.querySelector('.app-layout');
|
||||||
|
if (!z) return { opacity: 'NO-ZONE', unmounted: -1, xIgnore: null };
|
||||||
|
return {
|
||||||
|
opacity: getComputedStyle(z).opacity,
|
||||||
|
pointerEvents: getComputedStyle(z).pointerEvents,
|
||||||
|
unmounted: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length,
|
||||||
|
xIgnore: z.hasAttribute('x-ignore'),
|
||||||
|
layout: a ? { cloak: a.hasAttribute('x-cloak'), display: getComputedStyle(a).display } : 'ABSENT',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Résumé : première et dernière frame où du contenu brut visible subsiste
|
||||||
|
const rawFrames = P.frames.filter((f) => f[2] > 0);
|
||||||
|
const results_summary = {
|
||||||
|
dest,
|
||||||
|
marks: Object.fromEntries(P.marks),
|
||||||
|
rawVisibleFrames: rawFrames.length,
|
||||||
|
rawFirst: rawFrames.length ? rawFrames[0] : null,
|
||||||
|
rawLast: rawFrames.length ? rawFrames[rawFrames.length - 1] : null,
|
||||||
|
rawMaxElements: P.rawMax,
|
||||||
|
hiddenFrames: P.frames.filter((f) => f[4] === 0).length,
|
||||||
|
cloakMax: Math.max(0, ...P.frames.map((f) => f[3])),
|
||||||
|
shifts: P.shifts,
|
||||||
|
final,
|
||||||
|
frameCount: P.frames.length,
|
||||||
|
};
|
||||||
|
results.push(results_summary);
|
||||||
|
console.log('NAV ' + dest + ' => ' + JSON.stringify(results_summary));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GATE : aucun contenu brut peint + zone révélée et montée à la fin
|
||||||
|
const paintedRaw = results.filter((r) => r.rawVisibleFrames > 0);
|
||||||
|
const notRevealed = results.filter((r) => parseFloat(r.final.opacity) !== 1 || r.final.unmounted !== 0 || r.final.xIgnore
|
||||||
|
|| !r.final.layout || r.final.layout.cloak === true || r.final.layout.display === 'none');
|
||||||
|
console.log('SOMMAIRE ' + JSON.stringify(results.map((r) => ({ d: r.dest, raw: r.rawVisibleFrames, hidden: r.hiddenFrames, op: r.final.opacity, un: r.final.unmounted, shift: r.shifts.length }))));
|
||||||
|
if (paintedRaw.length) throw new Error('contenu brut peint: ' + paintedRaw.map((r) => `${r.dest} (${r.rawVisibleFrames} frames, max ${r.rawMaxElements})`).join(' ; '));
|
||||||
|
if (notRevealed.length) throw new Error('zone non révélée/montée: ' + JSON.stringify(notRevealed.map((r) => [r.dest, r.final])));
|
||||||
|
console.log('GATE OK — 0 frame de contenu brut, zone révélée et montée sur ' + results.length + ' navigations');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Le geste réel du rapport : clic sur le bouton Home de la sidebar (et non un
|
||||||
|
// ancre synthétique) — on mesure ce qui peint et on garde une capture si flash.
|
||||||
|
test('probe bouton Home', async ({ page }) => {
|
||||||
|
test.setTimeout(120000);
|
||||||
|
const SHOTS = require('path').join(__dirname, 'shots');
|
||||||
|
require('fs').mkdirSync(SHOTS, { recursive: true });
|
||||||
|
await login(page);
|
||||||
|
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'load' });
|
||||||
|
await page.waitForTimeout(1200);
|
||||||
|
await page.evaluate(INSTALL);
|
||||||
|
|
||||||
|
const console_ = [];
|
||||||
|
page.on('console', (m) => { if (m.type() !== 'log') console_.push(m.type() + ' | ' + m.text().slice(0, 180).replace(/\s+/g, ' ')); });
|
||||||
|
page.on('pageerror', (e) => console_.push('PAGEERROR | ' + String(e).slice(0, 180)));
|
||||||
|
|
||||||
|
const before = await page.evaluate(() => ({ onLine: navigator.onLine, home: !!document.querySelector('.home-btn'), href: (document.querySelector('.home-btn') || {}).href }));
|
||||||
|
console.log('HOME avant clic ' + JSON.stringify(before));
|
||||||
|
|
||||||
|
await page.click('.home-btn');
|
||||||
|
let shot = null;
|
||||||
|
const tEnd = Date.now() + 4000;
|
||||||
|
while (Date.now() < tEnd && !shot) {
|
||||||
|
const last = await page.evaluate(() => {
|
||||||
|
const f = window.__probe.frames;
|
||||||
|
return f.length ? f[f.length - 1] : null;
|
||||||
|
});
|
||||||
|
if (last && last[2] > 0) {
|
||||||
|
shot = 'home_flash.png';
|
||||||
|
await page.screenshot({ path: require('path').join(SHOTS, shot) });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(25);
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
const P = await page.evaluate(() => window.__probe);
|
||||||
|
const rawFrames = P.frames.filter((f) => f[2] > 0);
|
||||||
|
const after = await page.evaluate(() => {
|
||||||
|
const b = document.querySelector('.offline-banner');
|
||||||
|
const z = document.querySelector('.main-wrapper');
|
||||||
|
return {
|
||||||
|
url: location.pathname + location.search,
|
||||||
|
onLine: navigator.onLine,
|
||||||
|
banner: b ? { cloak: b.hasAttribute('x-cloak'), display: getComputedStyle(b).display, mounted: !!b._x_marker } : 'absente',
|
||||||
|
zone: z ? { op: getComputedStyle(z).opacity, un: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length, xIgnore: z.hasAttribute('x-ignore') } : null,
|
||||||
|
mounts: typeof window.__fdLocalWorkspaceScriptsLoaded !== 'undefined' ? window.__fdLocalWorkspaceScriptsLoaded : 'n/a',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const res = { marks: Object.fromEntries(P.marks), rawVisibleFrames: rawFrames.length, rawFirst: rawFrames[0] || null, rawMax: P.rawMax, shot, shifts: P.shifts, console: console_, after };
|
||||||
|
console.log('HOME résultat => ' + JSON.stringify(res));
|
||||||
|
if (res.rawVisibleFrames > 0) throw new Error(`contenu brut peint au clic Home: ${res.rawVisibleFrames} frames (max ${res.rawMax})`);
|
||||||
|
console.log('GATE HOME OK — 0 frame de contenu brut');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scénario du rapport : la bande rouge dit « hors ligne » → on rejoue le clic
|
||||||
|
// Home AVEC le réseau coupé (SW actif) pour voir ce qui peint.
|
||||||
|
test('probe bouton Home hors ligne', async ({ page, context }) => {
|
||||||
|
test.setTimeout(120000);
|
||||||
|
const SHOTS = require('path').join(__dirname, 'shots');
|
||||||
|
require('fs').mkdirSync(SHOTS, { recursive: true });
|
||||||
|
await login(page);
|
||||||
|
await page.addInitScript(INSTALL);
|
||||||
|
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'load' });
|
||||||
|
await page.waitForTimeout(1500); // laisse le SW s'installer et cacher les pages
|
||||||
|
await page.evaluate(() => { window.__probe = null; window.__probe = undefined; });
|
||||||
|
await page.evaluate(INSTALL);
|
||||||
|
|
||||||
|
await context.setOffline(true);
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
const onLine = await page.evaluate(() => navigator.onLine);
|
||||||
|
|
||||||
|
await page.click('.home-btn');
|
||||||
|
const shots = [];
|
||||||
|
for (const [delay, name] of [[700, 't700'], [2500, 't2500']]) {
|
||||||
|
await page.waitForTimeout(delay === 700 ? 700 : 1800);
|
||||||
|
const p = `home_offline_${name}.png`;
|
||||||
|
await page.screenshot({ path: require('path').join(SHOTS, p) }).catch(() => {});
|
||||||
|
shots.push(p);
|
||||||
|
}
|
||||||
|
const P = await page.evaluate(() => window.__probe || null).catch(() => null);
|
||||||
|
const after = await page.evaluate(() => {
|
||||||
|
const b = document.querySelector('.offline-banner');
|
||||||
|
const z = document.querySelector('.main-wrapper');
|
||||||
|
const visible = (sel) => Array.from(document.querySelectorAll(sel)).filter((e) => e.getClientRects().length && getComputedStyle(e).display !== 'none').map((e) => (e.className || '').toString().split(' ')[0]).slice(0, 12);
|
||||||
|
return {
|
||||||
|
url: location.pathname + location.search,
|
||||||
|
onLine: navigator.onLine,
|
||||||
|
banner: b ? { display: getComputedStyle(b).display, mounted: !!b._x_marker } : 'absente',
|
||||||
|
zone: z ? { op: getComputedStyle(z).opacity, un: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length, xIgnore: z.hasAttribute('x-ignore') } : null,
|
||||||
|
overlays: visible('.modal-overlay, .ctx-menu, .context-menu, .user-menu-dropdown, .dropdown-menu, .popover'),
|
||||||
|
};
|
||||||
|
}).catch((e) => ({ err: String(e) }));
|
||||||
|
const rawFrames = P ? P.frames.filter((f) => f[2] > 0) : [];
|
||||||
|
const res = { onLineBefore: onLine, marks: P ? Object.fromEntries(P.marks) : null, rawVisibleFrames: rawFrames.length, rawFirst: rawFrames[0] || null, rawMax: P ? P.rawMax : null, shots, shifts: P ? P.shifts : null, after };
|
||||||
|
console.log('HOME OFFLINE => ' + JSON.stringify(res));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Charge COMPLÈTE (F5 / premier visit / SW) : le masquage fdLoad ne s'applique
|
||||||
|
// pas là, seul x-cloak protège — on mesure ce qui peint avant Alpine.start().
|
||||||
|
test('probe fluidité plein chargement', async ({ page }) => {
|
||||||
|
test.setTimeout(120000);
|
||||||
|
const SHOTS = require('path').join(__dirname, 'shots');
|
||||||
|
require('fs').mkdirSync(SHOTS, { recursive: true });
|
||||||
|
await login(page);
|
||||||
|
await page.addInitScript(INSTALL);
|
||||||
|
|
||||||
|
const OUT = [];
|
||||||
|
for (const dest of ['/local-workspace', '/library', '/workspaces']) {
|
||||||
|
await page.goto(FD_BASE + dest, { waitUntil: 'commit' });
|
||||||
|
let shot = null;
|
||||||
|
const tEnd = Date.now() + 4000;
|
||||||
|
while (Date.now() < tEnd && !shot) {
|
||||||
|
const last = await page.evaluate(() => {
|
||||||
|
const f = window.__probe && window.__probe.frames;
|
||||||
|
return f && f.length ? f[f.length - 1] : null;
|
||||||
|
}).catch(() => null);
|
||||||
|
if (last && last[2] > 0) {
|
||||||
|
shot = `fullflash_${dest.replace(/\W+/g, '')}.png`;
|
||||||
|
await page.screenshot({ path: require('path').join(SHOTS, shot) });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(25);
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
const P = await page.evaluate(() => window.__probe);
|
||||||
|
const rawFrames = P.frames.filter((f) => f[2] > 0);
|
||||||
|
const res = {
|
||||||
|
dest,
|
||||||
|
rawVisibleFrames: rawFrames.length,
|
||||||
|
rawFirst: rawFrames[0] || null,
|
||||||
|
rawLast: rawFrames[rawFrames.length - 1] || null,
|
||||||
|
rawMax: P.rawMax,
|
||||||
|
shot,
|
||||||
|
shifts: P.shifts,
|
||||||
|
final: await page.evaluate(() => {
|
||||||
|
const z = document.querySelector('.main-wrapper');
|
||||||
|
const a = document.querySelector('.app-layout');
|
||||||
|
return z ? {
|
||||||
|
op: getComputedStyle(z).opacity,
|
||||||
|
un: Array.from(z.querySelectorAll('[x-show]')).filter((e) => !e._x_marker).length,
|
||||||
|
layout: a ? { cloak: a.hasAttribute('x-cloak'), display: getComputedStyle(a).display } : 'ABSENT',
|
||||||
|
} : null;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
OUT.push(res);
|
||||||
|
console.log('FULL ' + dest + ' => ' + JSON.stringify(res));
|
||||||
|
}
|
||||||
|
console.log('SOMMAIRE_FULL ' + JSON.stringify(OUT.map((r) => [r.dest, r.rawVisibleFrames, r.rawMax, r.shot])));
|
||||||
|
const notRevealed = OUT.filter((r) => !r.final || parseFloat(r.final.op) !== 1 || r.final.un !== 0
|
||||||
|
|| !r.final.layout || r.final.layout.cloak === true || r.final.layout.display === 'none');
|
||||||
|
if (notRevealed.length) throw new Error('page blanche/non montée au chargement: ' + JSON.stringify(notRevealed.map((r) => [r.dest, r.final])));
|
||||||
|
if (OUT.some((r) => r.rawVisibleFrames > 0)) {
|
||||||
|
throw new Error('contenu brut peint au plein chargement: ' + OUT.filter((r) => r.rawVisibleFrames > 0).map((r) => `${r.dest} (${r.rawVisibleFrames} frames, max ${r.rawMax})`).join(' ; '));
|
||||||
|
}
|
||||||
|
console.log('GATE FULL OK — 0 frame de contenu brut au chargement complet');
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const { test } = require('@playwright/test');
|
||||||
|
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
|
test.use({ viewport: { width: 390, height: 844 } });
|
||||||
|
test('diagnose mobile sidebar wheel scroll', async ({ page }) => {
|
||||||
|
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.fill('#email', process.env.FD_USER || '[email protected]');
|
||||||
|
await page.fill('#password', process.env.FD_PASS || 'e2e-pass-123');
|
||||||
|
await page.click('.btn-primary');
|
||||||
|
await page.waitForURL('**/workspaces', { timeout: 10000 }).catch(() => {});
|
||||||
|
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(() => document.querySelector('.hamburger-btn'));
|
||||||
|
await page.locator('.hamburger-btn').click();
|
||||||
|
await page.waitForSelector('#sidebar.mobile-open');
|
||||||
|
|
||||||
|
const info = await page.evaluate(() => {
|
||||||
|
const sb = document.getElementById('sidebar');
|
||||||
|
const sc = document.querySelector('.sidebar-scroll');
|
||||||
|
const cs = getComputedStyle(sb);
|
||||||
|
const ccs = sc ? getComputedStyle(sc) : null;
|
||||||
|
return {
|
||||||
|
sidebar: { display: cs.display, height: cs.height, overflow: cs.overflow, sh: sb.scrollHeight, ch: sb.clientHeight },
|
||||||
|
scroll: sc ? { top: sc.scrollTop, sh: sc.scrollHeight, ch: sc.clientHeight, oy: ccs.overflowY, flex: ccs.flex, rect: JSON.stringify(sc.getBoundingClientRect()) } : null,
|
||||||
|
wheelTarget: (() => { const el = document.elementFromPoint(150, 400); return el ? el.className : 'none'; })(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log('DIAG:', JSON.stringify(info, null, 1));
|
||||||
|
|
||||||
|
await page.mouse.move(150, 400);
|
||||||
|
await page.mouse.wheel(0, 300);
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const after = await page.evaluate(() => {
|
||||||
|
const sc = document.querySelector('.sidebar-scroll');
|
||||||
|
return { scTop: sc.scrollTop, scSH: sc.scrollHeight, scCH: sc.clientHeight, bodyTop: document.scrollingElement.scrollTop };
|
||||||
|
});
|
||||||
|
console.log('AFTER WHEEL:', JSON.stringify(after));
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/** Diag temporaire : navigation partielle vers l'éditeur, erreurs par phase */
|
||||||
|
const { test } = require('@playwright/test');
|
||||||
|
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||||
|
const USER = process.env.FD_USER || '[email protected]';
|
||||||
|
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||||
|
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.fill('#email', USER);
|
||||||
|
await page.fill('#password', PASS);
|
||||||
|
await page.click('.btn-primary');
|
||||||
|
const ok = await page.waitForURL('**/workspaces', { timeout: 8000 }).then(() => true).catch(() => false);
|
||||||
|
if (!ok) {
|
||||||
|
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
|
||||||
|
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function alpineErrs(msgs) {
|
||||||
|
return msgs.filter((m) => m.includes('Alpine Expression Error') || m.includes('PAGEERROR'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function partialClick(page, pid) {
|
||||||
|
await page.evaluate((id) => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = '/pages/' + id;
|
||||||
|
a.textContent = 'x';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
}, pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('diag phases', async ({ page }) => {
|
||||||
|
test.setTimeout(90000);
|
||||||
|
const msgs = [];
|
||||||
|
page.on('console', (m) => msgs.push(m.type() + ' | ' + m.text().slice(0, 160).replace(/\s+/g, ' ')));
|
||||||
|
page.on('pageerror', (e) => msgs.push('PAGEERROR | ' + String(e).slice(0, 200)));
|
||||||
|
|
||||||
|
await login(page);
|
||||||
|
const pid = await page.evaluate(async () => {
|
||||||
|
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||||
|
const r = await fetch('/api/local-workspace/items', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||||
|
body: JSON.stringify({ name: 'Diag2', type: 'page' }),
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
return d.id || (d.item && d.item.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
msgs.length = 0;
|
||||||
|
|
||||||
|
// PHASE A : navigation partielle (clic intercepte par fdLoad)
|
||||||
|
await partialClick(page, pid);
|
||||||
|
await page.waitForTimeout(4000);
|
||||||
|
const stackA = await page.evaluate(() => {
|
||||||
|
const el = document.querySelector('[x-data="editorState()"]');
|
||||||
|
const st = el && el._x_dataStack;
|
||||||
|
return { stack: st ? st.map((o) => (o ? Object.keys(o).length : -1)) : 'none', refreshed: !!(el && el.__fdRefreshed) };
|
||||||
|
});
|
||||||
|
const trace = await page.evaluate(() => window.__fdTrace || []);
|
||||||
|
stackA.trace = trace;
|
||||||
|
console.log('PHASE A (partielle): alpineErrs=', alpineErrs(msgs).length, 'total=', msgs.length, JSON.stringify(stackA));
|
||||||
|
console.log('TRACE:', JSON.stringify(trace));
|
||||||
|
alpineErrs(msgs).slice(0, 6).forEach((m) => console.log(' A>', m));
|
||||||
|
msgs.filter((m) => !m.includes('Alpine Expression')).slice(0, 5).forEach((m) => console.log(' A-other>', m));
|
||||||
|
|
||||||
|
// A2 : les erreurs s'arretent-elles apres le montage (fenetre transitoire) ?
|
||||||
|
const a1 = alpineErrs(msgs).length;
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
const a2 = alpineErrs(msgs).length;
|
||||||
|
const ui = await page.evaluate(() => ({
|
||||||
|
title: !!document.getElementById('_titleEl'),
|
||||||
|
blocks: document.querySelectorAll('[data-bid]').length,
|
||||||
|
editorVisible: !!document.querySelector('.page-editor-wrapper, .editor-wrap, [x-data^="editorState"]'),
|
||||||
|
}));
|
||||||
|
console.log('PHASE A2: erreurs a t0=', a1, '-> apres +3s=', a2, 'UI=', JSON.stringify(ui));
|
||||||
|
|
||||||
|
// PHASE B : rechargement complet de la meme page
|
||||||
|
msgs.length = 0;
|
||||||
|
await page.goto(`${FD_BASE}/pages/${pid}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
console.log('PHASE B (complet): alpineErrs=', alpineErrs(msgs).length, 'total=', msgs.length);
|
||||||
|
alpineErrs(msgs).slice(0, 6).forEach((m) => console.log(' B>', m));
|
||||||
|
|
||||||
|
// PHASE C : deuxieme navigation partielle vers la meme page
|
||||||
|
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
msgs.length = 0;
|
||||||
|
await partialClick(page, pid);
|
||||||
|
await page.waitForTimeout(3500);
|
||||||
|
console.log('PHASE C (2e partielle): alpineErrs=', alpineErrs(msgs).length, 'total=', msgs.length);
|
||||||
|
alpineErrs(msgs).slice(0, 6).forEach((m) => console.log(' C>', m));
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Régression — 2 bugs rapportés (post v7.45.0) :
|
||||||
|
* 1. Logout : le SW servait sa page « hors ligne » sur TOUTE navigation
|
||||||
|
* redirigée (fetch event en redirect:'manual' → opaqueredirect → rejet).
|
||||||
|
* 2. Drag & drop de fichiers : POST /api/local-workspace/upload sans
|
||||||
|
* X-CSRF-Token → 403 (A19 a retiré l'exemption sans équiper l'appel).
|
||||||
|
*
|
||||||
|
* Instance attendue sur FD_BASE_URL (défaut 8080), compte e2e documenté.
|
||||||
|
*/
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||||
|
const USER = process.env.FD_USER || '[email protected]';
|
||||||
|
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||||
|
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.fill('#email', USER);
|
||||||
|
await page.fill('#password', PASS);
|
||||||
|
await page.click('.btn-primary');
|
||||||
|
const ok = await page
|
||||||
|
.waitForURL('**/workspaces', { timeout: 8000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
if (!ok) {
|
||||||
|
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
|
||||||
|
data: { email: USER, password: PASS, name: 'E2E' },
|
||||||
|
});
|
||||||
|
if (!resp.ok() && resp.status() !== 409) {
|
||||||
|
throw new Error(`register ${resp.status()}: ${await resp.text()}`);
|
||||||
|
}
|
||||||
|
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||||
|
}
|
||||||
|
// workspace actif requis par /api/local-workspace/upload (A22)
|
||||||
|
const ws = await page.evaluate(async () => (await fetch('/api/workspaces')).json());
|
||||||
|
if (!ws.workspaces || ws.workspaces.length === 0) {
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||||
|
const r = await fetch('/api/workspaces', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||||
|
body: JSON.stringify({ name: 'E2E workspace' }),
|
||||||
|
});
|
||||||
|
const w = await r.json();
|
||||||
|
await fetch(`/api/workspaces/${w.id}/select`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'X-CSRF-Token': csrf },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('logout avec SW : atterrit sur la page login, pas hors ligne', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
await page.evaluate(async () => {
|
||||||
|
if ('serviceWorker' in navigator) await navigator.serviceWorker.ready;
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
expect(await page.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true);
|
||||||
|
|
||||||
|
await page.goto(`${FD_BASE}/auth/logout`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForURL(/\/auth\/login/, { timeout: 10000 });
|
||||||
|
const body = await page.evaluate(() => document.body.innerText);
|
||||||
|
expect(body).not.toContain('hors ligne');
|
||||||
|
expect(page.url()).toContain('/auth/login');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drop de fichier : upload accepté (CSRF), 200 et item créé', async ({ page }) => {
|
||||||
|
test.setTimeout(60000);
|
||||||
|
await login(page);
|
||||||
|
await page.goto(`${FD_BASE}/local-workspace`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
const uploadResp = [];
|
||||||
|
page.on('response', (r) => {
|
||||||
|
if (r.url().includes('/api/local-workspace/upload')) {
|
||||||
|
uploadResp.push(
|
||||||
|
r.text().then((body) => ({ status: r.status(), body })).catch(() => ({ status: r.status(), body: '' }))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const zone = document.querySelector('[x-data*="wsInitData"]') || document.body;
|
||||||
|
const dt = new DataTransfer();
|
||||||
|
dt.items.add(new File(['hello world'], 'e2e-drop.txt', { type: 'text/plain' }));
|
||||||
|
zone.dispatchEvent(new DragEvent('dragover', { dataTransfer: dt, bubbles: true, cancelable: true }));
|
||||||
|
zone.dispatchEvent(new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.poll(() => uploadResp.length, { timeout: 15000 }).toBeGreaterThan(0);
|
||||||
|
const resp = await uploadResp[0];
|
||||||
|
expect(resp.status, resp.body).toBe(200);
|
||||||
|
|
||||||
|
// Nettoyage : l'upload crée une page fichier → on la supprime.
|
||||||
|
const parsed = JSON.parse(resp.body || '{}');
|
||||||
|
for (const item of parsed.items || []) {
|
||||||
|
if (!item.id) continue;
|
||||||
|
await page.evaluate(async (pid) => {
|
||||||
|
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||||
|
await fetch('/api/local-workspace/items/' + pid, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'X-CSRF-Token': csrf },
|
||||||
|
});
|
||||||
|
}, item.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
/** Sweep diag : nav partielle vs complet sur N pages — erreurs + état fdCtx/appState */
|
||||||
|
const { test } = require('@playwright/test');
|
||||||
|
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||||
|
const USER = process.env.FD_USER || '[email protected]';
|
||||||
|
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||||
|
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.fill('#email', USER);
|
||||||
|
await page.fill('#password', PASS);
|
||||||
|
await page.click('.btn-primary');
|
||||||
|
const ok = await page.waitForURL('**/workspaces', { timeout: 8000 }).then(() => true).catch(() => false);
|
||||||
|
if (!ok) {
|
||||||
|
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
|
||||||
|
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGES = ['/workspaces', '/library', '/local-workspace', '/settings', '/trash', '/accounts', '/import'];
|
||||||
|
|
||||||
|
test('sweep', async ({ page }) => {
|
||||||
|
test.setTimeout(180000);
|
||||||
|
const msgs = [];
|
||||||
|
page.on('console', (m) => msgs.push(m.type() + ' | ' + m.text().slice(0, 200).replace(/\s+/g, ' ')));
|
||||||
|
page.on('pageerror', (e) => msgs.push('PAGEERROR | ' + String(e).slice(0, 160)));
|
||||||
|
|
||||||
|
await login(page);
|
||||||
|
|
||||||
|
for (const path of PAGES) {
|
||||||
|
// baseline : chargement complet
|
||||||
|
msgs.length = 0;
|
||||||
|
await page.goto(FD_BASE + path, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForTimeout(2200);
|
||||||
|
const fullErrs = msgs.filter((m) => m.includes('PAGEERROR') || m.includes('Alpine Expression Error') || m.includes('Uncaught'));
|
||||||
|
const stFull = await page.evaluate(() => ({
|
||||||
|
fdCtx: (() => { try { return typeof Alpine.store('fdCtx'); } catch (e) { return 'ERR'; } })(),
|
||||||
|
fdCtxHas: (() => { try { var s = Alpine.store('fdCtx'); return s ? typeof s.has + '/' + typeof s.avail : 'no store'; } catch (e) { return 'ERR'; } })(),
|
||||||
|
appState: typeof window.appState,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// nav partielle : depuis /workspaces
|
||||||
|
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForTimeout(700);
|
||||||
|
msgs.length = 0;
|
||||||
|
await page.evaluate((p) => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = p;
|
||||||
|
a.textContent = 'x';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
}, path);
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
const partErrs = msgs.filter((m) => m.includes('PAGEERROR') || m.includes('Alpine Expression Error') || m.includes('Uncaught'));
|
||||||
|
// 2e visite partielle dans LE MEME document (declencheur du SyntaxError
|
||||||
|
// « Identifier ... has already been declared » sur script sans garde)
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = '/workspaces';
|
||||||
|
a.textContent = 'x';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
});
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
msgs.length = 0;
|
||||||
|
await page.evaluate((p) => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = p;
|
||||||
|
a.textContent = 'x';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
}, path);
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
const part2Errs = msgs.filter((m) => m.includes('PAGEERROR') || m.includes('Alpine Expression Error') || m.includes('Uncaught') || m.includes('already been declared'));
|
||||||
|
const stPart = await page.evaluate(() => ({
|
||||||
|
fdCtx: (() => { try { return typeof Alpine.store('fdCtx'); } catch (e) { return 'ERR'; } })(),
|
||||||
|
fdCtxHas: (() => { try { var s = Alpine.store('fdCtx'); return s ? typeof s.has + '/' + typeof s.avail : 'no store'; } catch (e) { return 'ERR'; } })(),
|
||||||
|
appState: typeof window.appState,
|
||||||
|
lwGuard: !!window.__fdLocalWorkspaceScriptsLoaded,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(`PAGE ${path}`);
|
||||||
|
console.log(` complet : errs=${fullErrs.length} ${JSON.stringify(stFull)}`);
|
||||||
|
fullErrs.slice(0, 2).forEach((m) => console.log(' F>', m));
|
||||||
|
console.log(` 1ere partiel : errs=${partErrs.length} ${JSON.stringify(stPart)}`);
|
||||||
|
partErrs.slice(0, 3).forEach((m) => console.log(' P>', m));
|
||||||
|
console.log(` 2e partiel : errs=${part2Errs.length}`);
|
||||||
|
part2Errs.slice(0, 3).forEach((m) => console.log(' P2>', m));
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar — navigation par onglets compressibles (Home / Chat / Meeting / Inbox).
|
||||||
|
*
|
||||||
|
* - ordre exact des onglets ;
|
||||||
|
* - l'onglet actif est une pilule avec son libellé, les autres sont réduits à
|
||||||
|
* l'icône (largeur mesurée) ;
|
||||||
|
* - chaque sélection remplace le contenu de la barre latérale ;
|
||||||
|
* - les panneaux Chat / Meeting / Inbox interrogent leurs vraies API.
|
||||||
|
*/
|
||||||
|
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||||
|
const USER = process.env.FD_USER || '[email protected]';
|
||||||
|
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||||
|
|
||||||
|
test.use({ serviceWorkers: 'block' });
|
||||||
|
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.fill('#email', USER);
|
||||||
|
await page.fill('#password', PASS);
|
||||||
|
await page.click('.btn-primary');
|
||||||
|
try {
|
||||||
|
await page.waitForURL('**/workspaces', { timeout: 8000 });
|
||||||
|
} catch (e) {
|
||||||
|
// Compte absent de l'instance (harnais de test documenté du repo, comme
|
||||||
|
// smoke.spec.js) : on le crée puis on rejoue la connexion.
|
||||||
|
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
|
||||||
|
data: { email: USER, password: PASS, name: 'E2E' },
|
||||||
|
});
|
||||||
|
if (!resp.ok()) throw new Error(`register ${resp.status()}: ${await resp.text()}`);
|
||||||
|
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.fill('#email', USER);
|
||||||
|
await page.fill('#password', PASS);
|
||||||
|
await page.click('.btn-primary');
|
||||||
|
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const panelText = (page, tab) =>
|
||||||
|
page.evaluate(
|
||||||
|
(t) => {
|
||||||
|
const el = document.querySelector(`.sidebar-tab-panel[x-show*="'${t}'"]`);
|
||||||
|
if (!el) return null;
|
||||||
|
const hidden = el.offsetParent === null;
|
||||||
|
return { hidden, text: (el.innerText || '').trim().slice(0, 200) };
|
||||||
|
},
|
||||||
|
tab
|
||||||
|
);
|
||||||
|
|
||||||
|
test('les quatre onglets se succèdent et le contenu bascule', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('.sidebar-nav-row');
|
||||||
|
|
||||||
|
// ordre des onglets
|
||||||
|
const order = await page.$$eval('.sidebar-nav-row [data-tab]', (els) =>
|
||||||
|
els.map((e) => e.getAttribute('data-tab'))
|
||||||
|
);
|
||||||
|
expect(order).toEqual(['home', 'chat', 'meetings', 'inbox']);
|
||||||
|
|
||||||
|
// Home actif par défaut : pilule (libellé visible), les autres réduite à l'icône
|
||||||
|
let labels = await page.$$eval('.sidebar-nav-row [data-tab]', (els) =>
|
||||||
|
els.map((e) => ({
|
||||||
|
tab: e.getAttribute('data-tab'),
|
||||||
|
active: e.classList.contains('active'),
|
||||||
|
labelVisible: e.querySelector('.nav-tab-label').offsetParent !== null,
|
||||||
|
width: Math.round(e.getBoundingClientRect().width),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
expect(labels.find((l) => l.tab === 'home').labelVisible).toBe(true);
|
||||||
|
labels.filter((l) => l.tab !== 'home').forEach((l) => {
|
||||||
|
expect(l.active).toBe(false);
|
||||||
|
expect(l.labelVisible).toBe(false);
|
||||||
|
});
|
||||||
|
const homeWidth = labels.find((l) => l.tab === 'home').width;
|
||||||
|
const chatWidth = labels.find((l) => l.tab === 'chat').width;
|
||||||
|
expect(homeWidth).toBeGreaterThan(chatWidth + 15);
|
||||||
|
|
||||||
|
// Chat : le panneau Home disparaît, celui de Chat est visible
|
||||||
|
await page.click('.sidebar-nav-row [data-tab="chat"]');
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
expect((await panelText(page, 'home')).hidden).toBe(true);
|
||||||
|
const chat = await panelText(page, 'chat');
|
||||||
|
expect(chat.hidden).toBe(false);
|
||||||
|
expect(chat.text).toMatch(/CHAT/i);
|
||||||
|
|
||||||
|
labels = await page.$$eval('.sidebar-nav-row [data-tab]', (els) =>
|
||||||
|
els.map((e) => ({
|
||||||
|
tab: e.getAttribute('data-tab'),
|
||||||
|
active: e.classList.contains('active'),
|
||||||
|
labelVisible: e.querySelector('.nav-tab-label').offsetParent !== null,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
expect(labels.find((l) => l.tab === 'chat')).toEqual({
|
||||||
|
tab: 'chat',
|
||||||
|
active: true,
|
||||||
|
labelVisible: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Meeting
|
||||||
|
await page.click('.sidebar-nav-row [data-tab="meetings"]');
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
const meetings = await panelText(page, 'meetings');
|
||||||
|
expect(meetings.hidden).toBe(false);
|
||||||
|
expect(meetings.text).toMatch(/MEETING/i);
|
||||||
|
expect(await page.locator('.sb-quick-btn').count()).toBe(2);
|
||||||
|
|
||||||
|
// Inbox
|
||||||
|
await page.click('.sidebar-nav-row [data-tab="inbox"]');
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
const inbox = await panelText(page, 'inbox');
|
||||||
|
expect(inbox.hidden).toBe(false);
|
||||||
|
expect(inbox.text).toMatch(/INBOX/i);
|
||||||
|
|
||||||
|
// retour Home : l'arbre du workspace est de nouveau là, et UNIQUEMENT Home
|
||||||
|
// porte son libellé (régression : clic Home → texte sur tous les boutons)
|
||||||
|
await page.click('.sidebar-nav-row [data-tab="home"]');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
expect((await panelText(page, 'home')).hidden).toBe(false);
|
||||||
|
await expect(page.locator('#sidebar-workspace-items, .sidebar-static-links').first()).toBeVisible();
|
||||||
|
labels = await page.$$eval('.sidebar-nav-row [data-tab]', (els) =>
|
||||||
|
els.map((e) => ({
|
||||||
|
tab: e.getAttribute('data-tab'),
|
||||||
|
labelVisible: e.querySelector('.nav-tab-label').offsetParent !== null,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
expect(labels.find((l) => l.tab === 'home').labelVisible).toBe(true);
|
||||||
|
labels.filter((l) => l.tab !== 'home').forEach((l) => {
|
||||||
|
expect(l.labelVisible).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le libellé suit l’onglet actif même avec une CSS périmée (cache)', async ({ page }) => {
|
||||||
|
// ?v={{ asset_version }} = fichier VERSION : une feuille de style peut donc
|
||||||
|
// être servie depuis le cache du navigateur. On rejoue ce cas en neutralisant
|
||||||
|
// les règles .nav-tab-label de la feuille courante.
|
||||||
|
await page.route('**/static/css/app.css*', async (route) => {
|
||||||
|
const res = await route.fetch();
|
||||||
|
let body = await res.text();
|
||||||
|
body = body.replace(/\.nav-tab-btn \.nav-tab-label \{[^}]*\}/g, '');
|
||||||
|
body = body.replace(/\.nav-tab-btn\.active \.nav-tab-label \{[^}]*\}/g, '');
|
||||||
|
body = body.replace(/\.nav-tab-btn\.active \.nav-tab-badge \{[^}]*\}/g, '');
|
||||||
|
await route.fulfill({ response: res, body });
|
||||||
|
});
|
||||||
|
|
||||||
|
await login(page);
|
||||||
|
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('.sidebar-nav-row');
|
||||||
|
const visible = async () =>
|
||||||
|
page.$$eval('.sidebar-nav-row [data-tab]', (els) =>
|
||||||
|
els.map((e) => ({
|
||||||
|
tab: e.getAttribute('data-tab'),
|
||||||
|
labelVisible: e.querySelector('.nav-tab-label').offsetParent !== null,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// CSS sans les règles de libellé → seul l'onglet actif garde son texte
|
||||||
|
for (const active of ['home', 'chat', 'meetings', 'inbox']) {
|
||||||
|
await page.click(`.sidebar-nav-row [data-tab="${active}"]`);
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
const state = await visible();
|
||||||
|
expect(state.filter((s) => s.labelVisible).map((s) => s.tab)).toEqual([active]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('le panneau Meeting appelle /api/v2/meetings/upcoming', async ({ page }) => {
|
||||||
|
const calls = [];
|
||||||
|
page.on('request', (r) => {
|
||||||
|
if (r.url().includes('/api/v2/meetings/upcoming')) calls.push(r.url());
|
||||||
|
});
|
||||||
|
await login(page);
|
||||||
|
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.click('.sidebar-nav-row [data-tab="meetings"]');
|
||||||
|
await page.waitForResponse((r) => r.url().includes('/api/v2/meetings/upcoming'), { timeout: 10000 });
|
||||||
|
expect(calls.length).toBeGreaterThan(0);
|
||||||
|
const status = await page.evaluate(async () => {
|
||||||
|
const r = await fetch('/api/v2/meetings/upcoming?days=30', { credentials: 'same-origin' });
|
||||||
|
return r.status;
|
||||||
|
});
|
||||||
|
expect(status).toBe(200);
|
||||||
|
});
|
||||||
@@ -164,4 +164,23 @@ test('gate A20 : palette Ctrl+K (Alpine + recherche GET)', async ({ page }) => {
|
|||||||
return !ov || !ov.classList.contains('open');
|
return !ov || !ov.classList.contains('open');
|
||||||
});
|
});
|
||||||
expect(closed).toBe(true);
|
expect(closed).toBe(true);
|
||||||
|
|
||||||
|
// onglet ✨ Réponses IA : POST /api/v2/search/ask (extractif hors-LM)
|
||||||
|
await page.keyboard.press('Control+k');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
await page.click('.cp-tab[data-tab="ai"]');
|
||||||
|
await page.waitForSelector('.cmd-palette-ai', { timeout: 10000 });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => {
|
||||||
|
const el = document.querySelector('.cmd-palette-ai');
|
||||||
|
return el && !el.querySelector('.cp-loading');
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
{ timeout: 15000 }
|
||||||
|
);
|
||||||
|
const ai = await page.evaluate(() => {
|
||||||
|
const el = document.querySelector('.cmd-palette-ai');
|
||||||
|
return { text: ((el && el.textContent) || '').trim() };
|
||||||
|
});
|
||||||
|
expect(ai.text.length).toBeGreaterThan(5);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* Page /trash (v7.45.5) : rendu, dates, sélection groupée, restauration, purge.
|
||||||
|
*
|
||||||
|
* Couvre aussi le fix CSRF du front : l'ancien code faisait `csrf?.[1]` alors
|
||||||
|
* que getCsrf() renvoie une CHAÎNE → 403 silencieux, les boutons ne marchaient
|
||||||
|
* pas. Ici on clique pour de vrai et on vérifie l'état via l'API.
|
||||||
|
*/
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
|
||||||
|
const USER = process.env.FD_USER || '[email protected]';
|
||||||
|
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||||
|
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.fill('#email', USER);
|
||||||
|
await page.fill('#password', PASS);
|
||||||
|
await page.click('.btn-primary');
|
||||||
|
const ok = await page.waitForURL('**/workspaces', { timeout: 8000 }).then(() => true).catch(() => false);
|
||||||
|
if (!ok) {
|
||||||
|
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
|
||||||
|
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForURL('**/workspaces', { timeout: 15000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appel API depuis la page (cookies + jeton CSRF du document).
|
||||||
|
async function api(page, method, url) {
|
||||||
|
return page.evaluate(async ([m, u]) => {
|
||||||
|
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||||
|
const r = await fetch(u, { method: m, headers: { 'X-CSRF-Token': csrf } });
|
||||||
|
return { ok: r.ok, status: r.status, body: await r.json().catch(() => null) };
|
||||||
|
}, [method, url]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mkTrashed(page, name) {
|
||||||
|
const c = await api(page, 'POST', '/board/api/pages?title=' + encodeURIComponent(name));
|
||||||
|
expect(c.ok, 'create page').toBeTruthy();
|
||||||
|
const t = await api(page, 'POST', '/api/pages/' + c.body.id + '/trash');
|
||||||
|
expect(t.ok, 'soft delete').toBeTruthy();
|
||||||
|
return c.body.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trashIds = async (page) => {
|
||||||
|
const r = await api(page, 'GET', '/board/api/trash');
|
||||||
|
expect(r.ok).toBeTruthy();
|
||||||
|
return (r.body || []).map((i) => i.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
test('trash : rendu, dates, sélection groupée, restauration, purge', async ({ page }) => {
|
||||||
|
test.setTimeout(90000);
|
||||||
|
const errs = [];
|
||||||
|
page.on('pageerror', (e) => errs.push('PAGEERROR ' + String(e).slice(0, 160)));
|
||||||
|
page.on('console', (m) => { if (m.type() === 'error') errs.push('CONSOLE ' + m.text().slice(0, 160)); });
|
||||||
|
// Aucun dialog système acceptable : on échoue si le navigateur ouvre un
|
||||||
|
// confirm()/alert() natif (c'est tout l'objet du changement).
|
||||||
|
page.on('dialog', (d) => { errs.push('NATIVE_DIALOG ' + d.message()); d.dismiss().catch(() => {}); });
|
||||||
|
|
||||||
|
await login(page);
|
||||||
|
|
||||||
|
// DB persistante : nommage unique par exécution, on ne dépend jamais des
|
||||||
|
// restes des runs précédents.
|
||||||
|
const TAG = 'E2E Trash ' + Date.now().toString(36).toUpperCase();
|
||||||
|
const nameA = TAG + ' A', nameB = TAG + ' B', nameC = TAG + ' C', nameD = TAG + ' D', nameE = TAG + ' E';
|
||||||
|
|
||||||
|
const idA = await mkTrashed(page, nameA);
|
||||||
|
const idB = await mkTrashed(page, nameB);
|
||||||
|
|
||||||
|
await page.goto(`${FD_BASE}/trash`, { waitUntil: 'load' });
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// On ne travaille QUE sur nos pages : la poubelle partagée avec l'instance
|
||||||
|
// de test peut contenir d'autres éléments (jamais on ne touche aux leurs).
|
||||||
|
await page.fill('input[placeholder="Search pages in Trash"]', TAG);
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
|
||||||
|
const rowA = page.locator('.trash-item', { hasText: nameA });
|
||||||
|
const rowB = page.locator('.trash-item', { hasText: nameB });
|
||||||
|
await expect(rowA).toHaveCount(1);
|
||||||
|
await expect(rowB).toHaveCount(1);
|
||||||
|
|
||||||
|
// Date de suppression + jours restants affichés (standard « trash »)
|
||||||
|
await expect(rowA.first()).toContainText('Deleted');
|
||||||
|
await expect(rowA.first()).toContainText('left');
|
||||||
|
// Compteur + purge totale présents
|
||||||
|
await expect(page.locator('label input[type=checkbox]')).toHaveCount(1);
|
||||||
|
await expect(page.getByRole('button', { name: 'Empty Trash' })).toBeVisible();
|
||||||
|
|
||||||
|
// Sélection groupée (portée au filtre courant) → restauration
|
||||||
|
const rowCount = await page.locator('.trash-item').count();
|
||||||
|
expect(rowCount).toBeGreaterThanOrEqual(2);
|
||||||
|
await page.locator('label input[type=checkbox]').check();
|
||||||
|
await expect(page.locator('span', { hasText: rowCount + ' selected' })).toHaveCount(1);
|
||||||
|
await page.locator('button.btn-sm', { hasText: 'Restore' }).click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
|
||||||
|
await expect(page.locator('.trash-item', { hasText: nameA })).toHaveCount(0);
|
||||||
|
await expect(page.locator('.trash-item', { hasText: nameB })).toHaveCount(0);
|
||||||
|
let ids = await trashIds(page);
|
||||||
|
expect(ids).not.toContain(idA);
|
||||||
|
expect(ids).not.toContain(idB);
|
||||||
|
|
||||||
|
// Restauration unitaire (le vrai test du fix CSRF : avant = 403 silencieux)
|
||||||
|
const idC = await mkTrashed(page, nameC);
|
||||||
|
await page.reload({ waitUntil: 'load' });
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
const rowC = page.locator('.trash-item', { hasText: nameC });
|
||||||
|
await expect(rowC).toHaveCount(1);
|
||||||
|
await rowC.first().locator('button[title="Restore"]').click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
await expect(rowC).toHaveCount(0);
|
||||||
|
ids = await trashIds(page);
|
||||||
|
expect(ids).not.toContain(idC);
|
||||||
|
|
||||||
|
// Suppression définitive unitaire → fenêtre de confirmation THÉMATIQUE
|
||||||
|
// (plus de confirm() du navigateur)
|
||||||
|
const idE = await mkTrashed(page, nameE);
|
||||||
|
await page.reload({ waitUntil: 'load' });
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
const rowE = page.locator('.trash-item', { hasText: nameE });
|
||||||
|
await expect(rowE).toHaveCount(1);
|
||||||
|
await rowE.first().locator('button[title="Delete permanently"]').click();
|
||||||
|
|
||||||
|
const modal = page.locator('#fd-trash-confirm'); // id sinon 2e match #fd-global-folder-modal
|
||||||
|
await expect(modal).toBeVisible();
|
||||||
|
await expect(modal.locator('h3')).toHaveText('Delete permanently');
|
||||||
|
await expect(modal.locator('.flowdeck-modal-body p')).toContainText(nameE);
|
||||||
|
|
||||||
|
// Annulation → la page est toujours là
|
||||||
|
await modal.locator('button.cancel').click();
|
||||||
|
await expect(modal).toBeHidden();
|
||||||
|
await expect(rowE).toHaveCount(1);
|
||||||
|
|
||||||
|
// Confirmation → suppression réelle (plus dans la poubelle, contenu effacé)
|
||||||
|
await rowE.first().locator('button[title="Delete permanently"]').click();
|
||||||
|
await expect(modal).toBeVisible();
|
||||||
|
await modal.locator('button.confirm').click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
await expect(rowE).toHaveCount(0);
|
||||||
|
expect(await trashIds(page)).not.toContain(idE);
|
||||||
|
// Le SW transforme les réponses API non-ok (404 ici) en erreur réseau →
|
||||||
|
// `api()` peut rejeter ; dans les deux cas la page n'est plus servable.
|
||||||
|
const content = await api(page, 'GET', '/api/pages/' + idE + '/content').catch(() => ({ ok: false, status: 0 }));
|
||||||
|
expect(content.ok).toBeFalsy();
|
||||||
|
|
||||||
|
// Purge totale — seulement si la poubelle ne contient QUE des pages E2E
|
||||||
|
// (l'instance de test est partagée : on ne vide jamais la poubelle d'autrui).
|
||||||
|
await mkTrashed(page, nameD);
|
||||||
|
await page.reload({ waitUntil: 'load' });
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
await expect(page.locator('.trash-item', { hasText: nameD })).toHaveCount(1);
|
||||||
|
const all = (await api(page, 'GET', '/board/api/trash')).body || [];
|
||||||
|
// Fenêtre Empty Trash : ouverture + titre + annulation — testée quel que soit
|
||||||
|
// le contenu (l'instance est partagée, on n'a pas le droit de tout vider).
|
||||||
|
await page.locator('button.btn-sm', { hasText: 'Empty Trash' }).click();
|
||||||
|
await expect(modal).toBeVisible();
|
||||||
|
await expect(modal.locator('h3')).toHaveText('Empty Trash?');
|
||||||
|
await expect(modal.locator('.flowdeck-modal-body p')).toContainText(String(all.length));
|
||||||
|
await modal.locator('button.cancel').click();
|
||||||
|
await expect(modal).toBeHidden();
|
||||||
|
await expect(page.locator('.trash-item', { hasText: nameD })).toHaveCount(1);
|
||||||
|
|
||||||
|
if (all.every((i) => i.name.startsWith('E2E Trash'))) {
|
||||||
|
await page.locator('button.btn-sm', { hasText: 'Empty Trash' }).click();
|
||||||
|
await expect(modal).toBeVisible();
|
||||||
|
await modal.locator('button.confirm').click();
|
||||||
|
await page.waitForTimeout(900);
|
||||||
|
expect(await trashIds(page)).toHaveLength(0);
|
||||||
|
await expect(page.locator('.lib-empty', { hasText: 'Trash is empty' })).toBeVisible();
|
||||||
|
} else {
|
||||||
|
console.log('SKIP purge : la poubelle contient des pages non-E2E ' +
|
||||||
|
JSON.stringify(all.filter((i) => !i.name.startsWith('E2E Trash')).map((i) => i.name)));
|
||||||
|
}
|
||||||
|
|
||||||
|
const fatal = errs.filter((e) =>
|
||||||
|
e.startsWith('PAGEERROR') || e.includes('Alpine Expression Error') || e.startsWith('NATIVE_DIALOG'));
|
||||||
|
expect(fatal, fatal.join('\n')).toHaveLength(0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v7.62 — menu contextuel au highlight de texte (toolbar façon Notion).
|
||||||
|
* Vérifie : les 4 lignes + Skills + pied, le menu « … », le sous-menu
|
||||||
|
* « Turn into », et surtout la PERSISTANCE du formatage après reload
|
||||||
|
* (sync() relit le DOM formaté via gtMd → source markdown).
|
||||||
|
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
|
||||||
|
*/
|
||||||
|
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
|
||||||
|
const USER = process.env.FD_USER || '[email protected]';
|
||||||
|
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||||
|
|
||||||
|
test.use({ serviceWorkers: 'block' });
|
||||||
|
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.fill('#email', USER);
|
||||||
|
await page.fill('#password', PASS);
|
||||||
|
await page.click('.btn-primary');
|
||||||
|
const ok = await page
|
||||||
|
.waitForURL('**/workspaces', { timeout: 8000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
if (!ok) {
|
||||||
|
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
|
||||||
|
data: { email: USER, password: PASS, name: 'E2E' },
|
||||||
|
});
|
||||||
|
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
|
||||||
|
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sélectionne tout le bloc et simule la fin du glisser-souris
|
||||||
|
async function selectBlock(page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const el = document.querySelector('#_blocksCt [data-bid]');
|
||||||
|
const r = document.createRange();
|
||||||
|
r.selectNodeContents(el);
|
||||||
|
const s = getSelection();
|
||||||
|
s.removeAllRanges();
|
||||||
|
s.addRange(r);
|
||||||
|
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||||
|
});
|
||||||
|
await expect(page.locator('.format-toolbar')).toBeVisible({ timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('toolbar de sélection : structure, sous-menus, persistance', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
const cookies = await page.context().cookies();
|
||||||
|
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
|
||||||
|
const created = await page.request.post(
|
||||||
|
`${FD_BASE}/board/api/pages?title=Menu highlight ${Date.now()}`,
|
||||||
|
{ headers: { 'X-CSRF-Token': csrf } }
|
||||||
|
);
|
||||||
|
expect(created.status(), await created.text()).toBeLessThan(400);
|
||||||
|
const { id } = await created.json();
|
||||||
|
|
||||||
|
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
||||||
|
await page.click('#_blocksCt [data-bid]');
|
||||||
|
await page.keyboard.type('Menu lors d un highlight de texte');
|
||||||
|
|
||||||
|
// ── 1. structure : 4 lignes + Skills + pied ──
|
||||||
|
await selectBlock(page);
|
||||||
|
const bar = page.locator('.format-toolbar');
|
||||||
|
await expect(bar.locator('.ft-row')).toHaveCount(4);
|
||||||
|
await expect(bar.locator('.ft-style')).toContainText('Normal Text');
|
||||||
|
await expect(bar.locator('.ft-group')).toHaveText('Skills');
|
||||||
|
await expect(bar.locator('.ft-skill')).toHaveCount(4);
|
||||||
|
await expect(bar.locator('.ft-skill').first()).toHaveText('Improve writing');
|
||||||
|
await expect(bar.locator('.ft-foot')).toContainText('Edit with AI');
|
||||||
|
// géométrie : sous la sélection, entièrement dans le viewport
|
||||||
|
const barBox = await bar.boundingBox();
|
||||||
|
const selBox = await page.evaluate(() => {
|
||||||
|
const r = getSelection().getRangeAt(0).getBoundingClientRect();
|
||||||
|
return { bottom: r.bottom };
|
||||||
|
});
|
||||||
|
expect(barBox.y).toBeGreaterThanOrEqual(selBox.bottom);
|
||||||
|
expect(barBox.y + barBox.height).toBeLessThanOrEqual(900);
|
||||||
|
expect(barBox.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(barBox.x + barBox.width).toBeLessThanOrEqual(1440);
|
||||||
|
await page.screenshot({ path: 'shots/v762-toolbar.png' });
|
||||||
|
|
||||||
|
// ── 2. ligne 1 → sous-menu « Turn into » ──
|
||||||
|
await bar.locator('.ft-style').click();
|
||||||
|
const sub = page.locator('#blockMenuSub');
|
||||||
|
await expect(sub).toBeVisible();
|
||||||
|
await expect(sub).toContainText('Heading 4');
|
||||||
|
await expect(sub.locator('.bm-check')).toHaveCount(1); // type courant coché
|
||||||
|
await page.screenshot({ path: 'shots/v762-turn-submenu.png' });
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
|
// ── 3. « … » → menu contextuel de bloc (recherche + pied) ──
|
||||||
|
await selectBlock(page);
|
||||||
|
await bar.locator('.ft-more').click();
|
||||||
|
const menu = page.locator('#blockMenu');
|
||||||
|
await expect(menu).toBeVisible();
|
||||||
|
await expect(menu.locator('.bm-search')).toHaveAttribute('placeholder', 'Search actions...');
|
||||||
|
await expect(menu).toContainText('Copy link to block');
|
||||||
|
await expect(menu.locator('.bm-foot')).toContainText('Last edited by');
|
||||||
|
await page.screenshot({ path: 'shots/v762-more-menu.png' });
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(menu).toBeHidden();
|
||||||
|
|
||||||
|
// ── 4. persistance : B (gras) survit au reload ──
|
||||||
|
await selectBlock(page);
|
||||||
|
await page.getByRole('button', { name: 'B', exact: true }).click();
|
||||||
|
await expect(page.locator('#_blocksCt [data-bid] b, #_blocksCt [data-bid] strong')).toHaveCount(1);
|
||||||
|
await page.waitForTimeout(2500); // autosave 1,5 s
|
||||||
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
||||||
|
await expect(page.locator('#_blocksCt [data-bid] strong')).toHaveCount(1); // rendu mdEsc
|
||||||
|
|
||||||
|
// ── 5. persistance : S (surlignage) survit au reload ──
|
||||||
|
await selectBlock(page);
|
||||||
|
await bar.locator('.ft-hl').click();
|
||||||
|
await expect(page.locator('#_blocksCt [data-bid] mark')).toHaveCount(1);
|
||||||
|
await page.waitForTimeout(2500);
|
||||||
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
||||||
|
await expect(page.locator('#_blocksCt [data-bid] mark')).toHaveCount(1);
|
||||||
|
|
||||||
|
// ── 6. fermeture hors sélection ──
|
||||||
|
await page.mouse.click(10, 400);
|
||||||
|
await expect(page.locator('.format-toolbar')).toBeHidden();
|
||||||
|
});
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v7.63 — menus mobiles façon Notion (viewport 390×844) :
|
||||||
|
* - « / » → barre horizontale défilante sous le curseur (15 boutons, ordre du doc)
|
||||||
|
* - « + » → feuille plein écran « Insert block » (titre centré + Cancel, groupes,
|
||||||
|
* raccourcis) → choix d'un type = applySlash
|
||||||
|
* - « Turn into » / « Color » → feuilles réutilisant les sous-menus de bloc
|
||||||
|
* - « More » → menu contextuel de bloc (desktop)
|
||||||
|
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
|
||||||
|
*/
|
||||||
|
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
|
||||||
|
const USER = process.env.FD_USER || '[email protected]';
|
||||||
|
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||||
|
|
||||||
|
test.use({ serviceWorkers: 'block', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
||||||
|
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.fill('#email', USER);
|
||||||
|
await page.fill('#password', PASS);
|
||||||
|
await page.click('.btn-primary');
|
||||||
|
const ok = await page
|
||||||
|
.waitForURL('**/workspaces', { timeout: 8000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
if (!ok) {
|
||||||
|
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
|
||||||
|
data: { email: USER, password: PASS, name: 'E2E' },
|
||||||
|
});
|
||||||
|
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
|
||||||
|
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newPage(page) {
|
||||||
|
const cookies = await page.context().cookies();
|
||||||
|
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
|
||||||
|
const created = await page.request.post(
|
||||||
|
`${FD_BASE}/board/api/pages?title=Mobile menus ${Date.now()}`,
|
||||||
|
{ headers: { 'X-CSRF-Token': csrf } }
|
||||||
|
);
|
||||||
|
expect(created.status(), await created.text()).toBeLessThan(400);
|
||||||
|
const { id } = await created.json();
|
||||||
|
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('barre mobile au / + feuilles Insert block / Turn into / More', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
await newPage(page);
|
||||||
|
await page.click('#_blocksCt [data-bid]');
|
||||||
|
await page.keyboard.type('/');
|
||||||
|
|
||||||
|
// ── 1. la barre apparaît, 15 boutons dans l'ordre de la description ──
|
||||||
|
const bar = page.locator('#fdMobileBar');
|
||||||
|
await expect(bar).toBeVisible({ timeout: 5000 });
|
||||||
|
const keys = await page.evaluate(() =>
|
||||||
|
Array.from(document.querySelectorAll('#fdMobileBar [data-mtb]')).map((b) => b.dataset.mtb));
|
||||||
|
expect(keys).toEqual(['plus', 'turn', 'at', 'comment', 'image', 'trash',
|
||||||
|
'left', 'right', 'up', 'down', 'undo', 'redo', 'color', 'more', 'kbd']);
|
||||||
|
// défilement latéral
|
||||||
|
const overflowX = await bar.evaluate((el) => getComputedStyle(el).overflowX);
|
||||||
|
expect(overflowX).toBe('auto');
|
||||||
|
// pas de popup desktop en parallèle
|
||||||
|
await expect(page.locator('#_slashMenu')).toBeHidden();
|
||||||
|
await page.screenshot({ path: 'shots/v763-bar.png' });
|
||||||
|
|
||||||
|
// ── 2. « + » → feuille plein écran « Insert block » ──
|
||||||
|
await bar.locator('[data-mtb="plus"]').click();
|
||||||
|
const sheet = page.locator('.fd-msheet');
|
||||||
|
await expect(sheet).toBeVisible();
|
||||||
|
await expect(sheet.locator('.fd-msheet-title')).toHaveText('Insert block');
|
||||||
|
await expect(sheet.locator('.fd-msheet-cancel')).toHaveText('Cancel');
|
||||||
|
await expect(sheet.locator('.sm-group').first()).toHaveText('Basic blocks');
|
||||||
|
await expect(sheet.locator('.slash-item', { hasText: 'Heading 1' })).toHaveCount(1);
|
||||||
|
await page.screenshot({ path: 'shots/v763-insert.png' });
|
||||||
|
|
||||||
|
// ── 3. choix d'un type → applySlash (le bloc devient un titre) ──
|
||||||
|
await sheet.locator('.slash-item', { hasText: 'Heading 1' }).click();
|
||||||
|
await expect(sheet).toHaveCount(0);
|
||||||
|
await expect(bar).toBeHidden();
|
||||||
|
expect(await page.evaluate(() => window.E.blocks[0].type)).toBe('heading_1');
|
||||||
|
|
||||||
|
// ── 4. « Turn into » → feuille réutilisant les items du sous-menu de bloc ──
|
||||||
|
await page.keyboard.type('/');
|
||||||
|
await expect(bar).toBeVisible();
|
||||||
|
await bar.locator('[data-mtb="turn"]').click();
|
||||||
|
await expect(sheet.locator('.fd-msheet-title')).toHaveText('Turn into');
|
||||||
|
await expect(sheet.locator('.bm-item', { hasText: 'Bulleted list' })).toHaveCount(1);
|
||||||
|
await expect(sheet.locator('.bm-check')).toHaveCount(1); // type courant coché
|
||||||
|
await page.screenshot({ path: 'shots/v763-turn.png' });
|
||||||
|
await sheet.locator('.fd-msheet-cancel').click();
|
||||||
|
await expect(sheet).toHaveCount(0);
|
||||||
|
expect(await page.evaluate(() => window.E.blocks[0].type)).toBe('heading_1');
|
||||||
|
|
||||||
|
// ── 5. « Color » → feuille avec les 3 groupes ──
|
||||||
|
await page.evaluate(() => localStorage.setItem('fdLastBlockColor', 'color|#E5484D'));
|
||||||
|
await bar.locator('[data-mtb="color"]').click();
|
||||||
|
for (const g of ['Last used', 'Text color', 'Background color']) {
|
||||||
|
await expect(sheet.locator('.bm-group', { hasText: g })).toHaveCount(1);
|
||||||
|
}
|
||||||
|
await sheet.locator('.fd-msheet-cancel').click();
|
||||||
|
|
||||||
|
// ── 6. « More » → menu contextuel de bloc (recherche + pied) ──
|
||||||
|
await bar.locator('[data-mtb="more"]').click();
|
||||||
|
const menu = page.locator('#blockMenu');
|
||||||
|
await expect(menu).toBeVisible();
|
||||||
|
await expect(menu.locator('.bm-search')).toHaveAttribute('placeholder', 'Search actions...');
|
||||||
|
await expect(menu.locator('.bm-foot')).toContainText('Last edited by');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await expect(menu).toBeHidden();
|
||||||
|
|
||||||
|
// ── 7. ↑ déplace réellement le bloc ── (bloc 0 = titre, bloc 1 = paragraphe)
|
||||||
|
await page.click('#_blocksCt [data-bid]'); // bloc 0 (contient '/')
|
||||||
|
await page.keyboard.press('Enter');
|
||||||
|
await page.waitForTimeout(150); // le focus du nouveau bloc est différé (60 ms)
|
||||||
|
await page.keyboard.type('/'); // bloc neuf et vide → slash exact = ouverture
|
||||||
|
await expect(bar).toBeVisible(); // SM._i = 1 (bloc du bas)
|
||||||
|
await bar.locator('[data-mtb="up"]').click();
|
||||||
|
expect(await page.evaluate(() => window.E.blocks.map((b) => b.type).join(',')))
|
||||||
|
.toBe('paragraph,heading_1');
|
||||||
|
await page.screenshot({ path: 'shots/v763-moved.png' });
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v7.64.1 — Delete sur un bloc vide supprime le bloc (comme Backspace).
|
||||||
|
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
|
||||||
|
*/
|
||||||
|
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
|
||||||
|
const USER = process.env.FD_USER || '[email protected]';
|
||||||
|
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||||
|
|
||||||
|
test.use({ serviceWorkers: 'block' });
|
||||||
|
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.fill('#email', USER);
|
||||||
|
await page.fill('#password', PASS);
|
||||||
|
await page.click('.btn-primary');
|
||||||
|
const ok = await page
|
||||||
|
.waitForURL('**/workspaces', { timeout: 8000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
if (!ok) {
|
||||||
|
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
|
||||||
|
data: { email: USER, password: PASS, name: 'E2E' },
|
||||||
|
});
|
||||||
|
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
|
||||||
|
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Delete : vide le bloc puis supprime le bloc vide', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
const cookies = await page.context().cookies();
|
||||||
|
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
|
||||||
|
const created = await page.request.post(
|
||||||
|
`${FD_BASE}/board/api/pages?title=Delete key ${Date.now()}`,
|
||||||
|
{ headers: { 'X-CSRF-Token': csrf } }
|
||||||
|
);
|
||||||
|
expect(created.status(), await created.text()).toBeLessThan(400);
|
||||||
|
const { id } = await created.json();
|
||||||
|
const bs = await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
|
||||||
|
headers: { 'X-CSRF-Token': csrf },
|
||||||
|
data: { title: 'Delete key', blocks: [
|
||||||
|
{ id: 'b1', type: 'paragraph', content: 'hello' },
|
||||||
|
{ id: 'b2', type: 'paragraph', content: 'world' },
|
||||||
|
] },
|
||||||
|
});
|
||||||
|
expect(bs.status(), await bs.text()).toBeLessThan(400);
|
||||||
|
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
||||||
|
|
||||||
|
// ── 1. Delete avec du texte : suppression avant le curseur (comportement natif) ──
|
||||||
|
await page.locator('#_blocksCt [data-bid]').nth(1).click();
|
||||||
|
await page.keyboard.press('Control+a');
|
||||||
|
await page.keyboard.press('Delete');
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
const contents = await page.evaluate(() => {
|
||||||
|
window.E.sync();
|
||||||
|
return window.E.blocks.map((b) => b.content);
|
||||||
|
});
|
||||||
|
expect(contents[0]).toBe('hello');
|
||||||
|
expect(contents[1].trim()).toBe(''); // bloc vidé (un <br> résiduel), mais toujours là
|
||||||
|
|
||||||
|
// ── 2. Delete sur le bloc vide : le bloc est supprimé ──
|
||||||
|
await page.keyboard.press('Delete');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
expect(await page.evaluate(() => window.E.blocks.length)).toBe(1);
|
||||||
|
expect(await page.evaluate(() => window.E.blocks[0].content)).toBe('hello');
|
||||||
|
await page.screenshot({ path: 'shots/v7641-delete.png' });
|
||||||
|
|
||||||
|
// ── 3. dernier bloc vide : jamais supprimé (l'éditeur garde un bloc) ──
|
||||||
|
await page.locator('#_blocksCt [data-bid]').first().click();
|
||||||
|
await page.keyboard.press('Control+a');
|
||||||
|
await page.keyboard.press('Delete');
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
await page.keyboard.press('Delete');
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
expect(await page.evaluate(() => window.E.blocks.length)).toBe(1);
|
||||||
|
expect(await page.evaluate(() => window.E.blocks[0].type)).toBe('paragraph');
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v7.64 — commentaires & réactions façon Notion :
|
||||||
|
* - sélection → bouton 🙂+ (« React to selected text ») → sélecteur d'émoji avec
|
||||||
|
* le texte sélectionné en en-tête → pastille + plage jaune, contenu inchangé
|
||||||
|
* - sélection → 💬 → tiroir refondu (avatar, nom, ✓, ⋯, saisie avec @ et ↑) →
|
||||||
|
* fil ancré + plage jaune, clic sur l'ancre rouvre le fil, ✓ résout
|
||||||
|
* - reload : ancres et pastilles repeintes depuis la base
|
||||||
|
* Instance de test attendue sur FD_BASE_URL (défaut 8081).
|
||||||
|
*/
|
||||||
|
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8081';
|
||||||
|
const USER = process.env.FD_USER || '[email protected]';
|
||||||
|
const PASS = process.env.FD_PASS || 'e2e-secret-123';
|
||||||
|
|
||||||
|
test.use({ serviceWorkers: 'block' });
|
||||||
|
|
||||||
|
async function login(page) {
|
||||||
|
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.fill('#email', USER);
|
||||||
|
await page.fill('#password', PASS);
|
||||||
|
await page.click('.btn-primary');
|
||||||
|
const ok = await page
|
||||||
|
.waitForURL('**/workspaces', { timeout: 8000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false);
|
||||||
|
if (!ok) {
|
||||||
|
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
|
||||||
|
data: { email: USER, password: PASS, name: 'E2E' },
|
||||||
|
});
|
||||||
|
if (resp.status() !== 409) expect(resp.ok()).toBeTruthy();
|
||||||
|
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newPage(page, body) {
|
||||||
|
const cookies = await page.context().cookies();
|
||||||
|
const csrf = (cookies.find((c) => c.name === 'csrf_token') || {}).value || '';
|
||||||
|
const created = await page.request.post(
|
||||||
|
`${FD_BASE}/board/api/pages?title=Comments ${Date.now()}`,
|
||||||
|
{ headers: { 'X-CSRF-Token': csrf } }
|
||||||
|
);
|
||||||
|
expect(created.status(), await created.text()).toBeLessThan(400);
|
||||||
|
const { id } = await created.json();
|
||||||
|
if (body) {
|
||||||
|
const bs = await page.request.post(`${FD_BASE}/board/api/pages/${id}/blocks`, {
|
||||||
|
headers: { 'X-CSRF-Token': csrf },
|
||||||
|
data: { title: 'Comments', blocks: body },
|
||||||
|
});
|
||||||
|
expect(bs.status(), await bs.text()).toBeLessThan(400);
|
||||||
|
}
|
||||||
|
await page.goto(`${FD_BASE}/pages/${id}`, { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// sélectionne tout le bloc et simule la fin du glisser-souris
|
||||||
|
async function selectBlock(page) {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const el = document.querySelector('#_blocksCt [data-bid]');
|
||||||
|
const r = document.createRange();
|
||||||
|
r.selectNodeContents(el);
|
||||||
|
const s = getSelection();
|
||||||
|
s.removeAllRanges();
|
||||||
|
s.addRange(r);
|
||||||
|
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||||
|
});
|
||||||
|
await expect(page.locator('.format-toolbar')).toBeVisible({ timeout: 5000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('réaction emoji + commentaires ancrés', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
await newPage(page, [{ id: 'b1', type: 'paragraph', content: 'Prise de reaction a un texte' }]);
|
||||||
|
await expect(page.locator('#_blocksCt [data-bid]')).toHaveText('Prise de reaction a un texte');
|
||||||
|
|
||||||
|
// ── 1. bouton réaction dans la toolbar ──
|
||||||
|
await selectBlock(page);
|
||||||
|
const react = page.locator('.format-toolbar .ft-react');
|
||||||
|
await expect(react).toHaveAttribute('data-tooltip', 'React to selected text');
|
||||||
|
await page.screenshot({ path: 'shots/v764-toolbar-react.png' });
|
||||||
|
await react.click();
|
||||||
|
|
||||||
|
// ── 2. sélecteur : en-tête = texte sélectionné, Filter…, Recent/People ──
|
||||||
|
const picker = page.locator('.fd-icon-picker');
|
||||||
|
await expect(picker).toBeVisible();
|
||||||
|
await expect(picker.locator('.fdip-title-text')).toHaveText('Prise de reaction a un texte');
|
||||||
|
await expect(picker.locator('.fdip-search')).toHaveAttribute('placeholder', 'Filter...');
|
||||||
|
await expect(picker.locator('.fdip-tabs')).toBeHidden(); // mode réaction : emoji only
|
||||||
|
await page.screenshot({ path: 'shots/v764-picker.png' });
|
||||||
|
await picker.locator('.fdip-emoji').first().click();
|
||||||
|
await expect(picker).toBeHidden();
|
||||||
|
|
||||||
|
// ── 3. pastille + plage jaune, contenu du bloc inchangé ──
|
||||||
|
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1);
|
||||||
|
await expect(page.locator('#_blocksCt .fd-anchor-react')).toHaveCount(1);
|
||||||
|
const content = await page.evaluate(() => {
|
||||||
|
window.E.sync();
|
||||||
|
return window.E.blocks[0].content;
|
||||||
|
});
|
||||||
|
expect(content).toBe('Prise de reaction a un texte'); // la pastille n'est pas du contenu
|
||||||
|
await page.screenshot({ path: 'shots/v764-chip.png' });
|
||||||
|
|
||||||
|
// ── 4. commentaire : bouton 💬 → tiroir refondu → envoi ──
|
||||||
|
await selectBlock(page);
|
||||||
|
await expect(page.locator('#_commentSelBtn')).toBeVisible();
|
||||||
|
await page.locator('#_commentSelBtn').click();
|
||||||
|
const drawer = page.locator('.comments-drawer');
|
||||||
|
await expect(drawer).toBeVisible();
|
||||||
|
await drawer.locator('textarea').fill('Ceci est mon premier commentaire');
|
||||||
|
await expect(drawer.locator('.ct-send')).toBeEnabled();
|
||||||
|
await drawer.locator('.ct-send').click();
|
||||||
|
const thread = page.locator('.comment-thread');
|
||||||
|
await expect(thread).toHaveCount(1);
|
||||||
|
await expect(thread.locator('.ct-avatar')).toHaveCount(1);
|
||||||
|
await expect(thread.locator('.ct-name')).not.toHaveText('');
|
||||||
|
await expect(thread.locator('.ct-act')).toHaveCount(2); // ✓ + ⋯
|
||||||
|
await expect(thread.locator('.ct-body')).toHaveText('Ceci est mon premier commentaire');
|
||||||
|
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1);
|
||||||
|
await page.screenshot({ path: 'shots/v764-drawer.png' });
|
||||||
|
|
||||||
|
// ── 5. menu ⋯ du fil (Copy link / Delete) ──
|
||||||
|
await thread.locator('.ct-act').nth(1).click();
|
||||||
|
await expect(thread.locator('.ct-menu')).toBeVisible();
|
||||||
|
await expect(thread.locator('.ct-menu-item').first()).toContainText('Copy link');
|
||||||
|
await page.keyboard.press('Escape');
|
||||||
|
await page.locator('.comments-drawer-header .topbar-btn').click();
|
||||||
|
await expect(drawer).toBeHidden();
|
||||||
|
|
||||||
|
// ── 6. clic sur l'ancre jaune → le fil se rouvre ──
|
||||||
|
await page.locator('#_blocksCt .fd-anchor[data-cid]').click();
|
||||||
|
await expect(drawer).toBeVisible();
|
||||||
|
await expect(page.locator('.comment-thread')).toHaveCount(1);
|
||||||
|
|
||||||
|
// ── 7. ✓ résout le fil (ancre en gris) ──
|
||||||
|
await page.locator('.comment-thread .ct-act').first().click();
|
||||||
|
await expect(page.locator('.comment-thread.is-resolved')).toHaveCount(1);
|
||||||
|
await expect(page.locator('#_blocksCt .fd-anchor.fd-anchor-done')).toHaveCount(1);
|
||||||
|
|
||||||
|
// ── 8. reload : ancres + pastilles rechargées depuis la base ──
|
||||||
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
|
||||||
|
await expect(page.locator('#_blocksCt .fd-anchor[data-cid]')).toHaveCount(1, { timeout: 10000 });
|
||||||
|
await expect(page.locator('#_blocksCt .fd-react-chip')).toHaveCount(1, { timeout: 10000 });
|
||||||
|
});
|
||||||
+529
-89
@@ -23,68 +23,7 @@
|
|||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 400;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/static/fonts/inter-400-latin.woff2') format('woff2');
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/static/fonts/inter-500-latin-ext.woff2') format('woff2');
|
|
||||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/static/fonts/inter-500-latin.woff2') format('woff2');
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/static/fonts/inter-600-latin-ext.woff2') format('woff2');
|
|
||||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 600;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/static/fonts/inter-600-latin.woff2') format('woff2');
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/static/fonts/inter-700-latin-ext.woff2') format('woff2');
|
|
||||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'Inter';
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 700;
|
|
||||||
font-display: swap;
|
|
||||||
src: url('/static/fonts/inter-700-latin.woff2') format('woff2');
|
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* ===== LIGHT THEME (default) ===== */
|
/* ===== LIGHT THEME (default) ===== */
|
||||||
@@ -494,9 +433,12 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
|
|||||||
padding: 6px 6px; margin: 0 2px;
|
padding: 6px 6px; margin: 0 2px;
|
||||||
}
|
}
|
||||||
.nav-icon-btn {
|
.nav-icon-btn {
|
||||||
|
position: relative;
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
padding: 6px; border-radius: 6px;
|
padding: 6px; border-radius: 6px;
|
||||||
color: var(--text-secondary); transition: background 0.15s, color 0.15s;
|
color: var(--text-secondary); transition: background 0.15s, color 0.15s;
|
||||||
|
border: none; background: none; cursor: pointer; font-family: inherit;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.nav-icon-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
|
.nav-icon-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||||
.nav-icon-btn.active { background: var(--bg-active); color: var(--text-primary); }
|
.nav-icon-btn.active { background: var(--bg-active); color: var(--text-primary); }
|
||||||
@@ -508,6 +450,91 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
|
|||||||
/* Collapse button on the right */
|
/* Collapse button on the right */
|
||||||
.nav-icon-btn.collapse-btn { margin-left: auto; }
|
.nav-icon-btn.collapse-btn { margin-left: auto; }
|
||||||
|
|
||||||
|
/* ── Collapsible tabs (Home / Chat / Meeting / Inbox) ──
|
||||||
|
The active tab expands into a pill showing icon + label, the inactive ones
|
||||||
|
collapse to the bare icon so the row stays compact. */
|
||||||
|
.nav-tab-btn {
|
||||||
|
gap: 0;
|
||||||
|
padding: 6px;
|
||||||
|
min-width: 30px;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 15px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.nav-tab-btn .nav-tab-label {
|
||||||
|
display: none;
|
||||||
|
max-width: 110px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.nav-tab-btn.active {
|
||||||
|
background: var(--bg-active);
|
||||||
|
color: var(--text-primary);
|
||||||
|
gap: 7px;
|
||||||
|
padding: 6px 12px 6px 10px;
|
||||||
|
}
|
||||||
|
.nav-tab-btn.active .nav-tab-label { display: inline-block; }
|
||||||
|
.nav-tab-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -1px; right: -1px;
|
||||||
|
min-width: 15px; height: 15px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--text-dim);
|
||||||
|
color: var(--bg-primary);
|
||||||
|
font-size: 10px; font-weight: 700; line-height: 15px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.nav-tab-badge-accent { background: #E03E3E; color: #fff; }
|
||||||
|
.nav-tab-btn.active .nav-tab-badge { display: none; }
|
||||||
|
|
||||||
|
/* Tab panels — one replaces the whole sidebar content at a time */
|
||||||
|
.sidebar-tab-panel { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.sb-panel-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 6px 10px 4px;
|
||||||
|
}
|
||||||
|
.sb-panel-title { font-size: 12px; font-weight: 700; color: var(--text-dim); text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
.sb-panel-count { color: var(--text-secondary); font-weight: 600; }
|
||||||
|
.sb-panel-action {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
min-width: 22px; height: 22px; padding: 0 5px;
|
||||||
|
border: none; border-radius: 5px; background: none;
|
||||||
|
color: var(--text-secondary); font-size: 11px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.sb-panel-action:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||||
|
.sb-filter-row { display: flex; gap: 4px; padding: 2px 8px 6px; }
|
||||||
|
.sb-filter-chip {
|
||||||
|
padding: 3px 10px; border: 1px solid var(--border); border-radius: 12px;
|
||||||
|
background: none; color: var(--text-secondary);
|
||||||
|
font-size: 11px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.sb-filter-chip.active { background: var(--bg-active); color: var(--text-primary); border-color: transparent; }
|
||||||
|
.sb-quick-actions { display: flex; flex-direction: column; gap: 2px; padding: 2px 6px 8px; }
|
||||||
|
.sb-quick-btn {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 5px 8px; border: none; border-radius: 6px; background: none;
|
||||||
|
color: var(--text-secondary); font-size: 12px; text-align: left; cursor: pointer;
|
||||||
|
}
|
||||||
|
.sb-quick-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||||
|
.sb-hidden-input { display: none; }
|
||||||
|
.sb-agent-row { display: flex; flex-wrap: wrap; gap: 6px; padding: 4px 10px 10px; }
|
||||||
|
.sb-agent-avatar {
|
||||||
|
width: 30px; height: 30px; border-radius: 50%; border: 1px solid var(--border);
|
||||||
|
background: var(--bg-hover); cursor: pointer; font-size: 15px; line-height: 1;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.sb-agent-avatar:hover { border-color: var(--accent); }
|
||||||
|
.sb-item-meta { margin-left: auto; font-size: 11px; color: var(--text-dim); flex-shrink: 0; }
|
||||||
|
.sb-item-sub {
|
||||||
|
display: block; font-size: 11px; color: var(--text-dim);
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sb-item-unread { background: rgba(35, 131, 226, .10); }
|
||||||
|
|
||||||
/* Empty hint */
|
/* Empty hint */
|
||||||
.sidebar-item.empty-hint { color: var(--text-dim); font-size: 12px; cursor: default; }
|
.sidebar-item.empty-hint { color: var(--text-dim); font-size: 12px; cursor: default; }
|
||||||
.sidebar-item.empty-hint:hover { background: none; }
|
.sidebar-item.empty-hint:hover { background: none; }
|
||||||
@@ -1103,6 +1130,11 @@ html.fd-sidebar-collapsed .editor-statusbar { left: 0; }
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Conteneur standard des pages listes (Library, Trash, …) — largeur unique
|
||||||
|
1100px, centrée. Défini ici (global) et non dans le <style> de
|
||||||
|
library.html, sinon /trash rendait la classe sans style = pleine largeur. */
|
||||||
|
.lib-container{max-width:1100px;margin:0 auto;padding:20px 24px;}
|
||||||
|
|
||||||
/* Topbar */
|
/* Topbar */
|
||||||
.topbar {
|
.topbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2212,6 +2244,34 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
min-height: 300px;
|
min-height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Page de base (premier bloc = embed collection) : le titre et le tableau
|
||||||
|
s'alignent sur la marge gauche de la topbar (16px) au lieu d'être centrés
|
||||||
|
dans la colonne 900px — le tableau démarre au ras du sidebar et prend la
|
||||||
|
LARGEUR DU DOCUMENT (les colonnes dictent le scroll horizontal). */
|
||||||
|
.page-editor-wrapper.db-first .page-cover-area,
|
||||||
|
.page-editor-wrapper.db-first .page-title-block,
|
||||||
|
.page-editor-wrapper.db-first .blocks-container {
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: var(--space-lg);
|
||||||
|
padding-right: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Une base embarquée ne doit jamais déborder de sa colonne de blocs :
|
||||||
|
.db-table-wrapper interne gère déjà le scroll horizontal. */
|
||||||
|
.block-embed-collection { max-width: 100%; overflow: hidden; }
|
||||||
|
|
||||||
|
/* Titre des pages de base dédiées (page_editor_collection.html) — même
|
||||||
|
liseré gauche que .database-table-container. */
|
||||||
|
.database-page-title {
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 28px var(--space-lg) 0;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.blocks-empty {
|
.blocks-empty {
|
||||||
padding: 40px 0;
|
padding: 40px 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -2281,6 +2341,36 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* + « ajouter un bloc dessous » — juste à gauche du handle (gouttière 64px) */
|
||||||
|
.block-insert-btn {
|
||||||
|
position: absolute;
|
||||||
|
left: -64px;
|
||||||
|
top: 5px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 17px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
.block-wrapper:hover .block-insert-btn,
|
||||||
|
.block-wrapper:focus-within .block-insert-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.block-insert-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── v5.10.0: selection multiple + menu contexte bloc ── */
|
/* ── v5.10.0: selection multiple + menu contexte bloc ── */
|
||||||
.block-wrapper.selected {
|
.block-wrapper.selected {
|
||||||
background: rgba(35,131,226,0.06);
|
background: rgba(35,131,226,0.06);
|
||||||
@@ -2316,6 +2406,30 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
.block-menu .bm-item.bm-danger .bm-ico { color: #e5484d; }
|
.block-menu .bm-item.bm-danger .bm-ico { color: #e5484d; }
|
||||||
.block-menu .bm-item.bm-danger:hover { background: rgba(229,72,77,.12); }
|
.block-menu .bm-item.bm-danger:hover { background: rgba(229,72,77,.12); }
|
||||||
.block-menu .bm-sep { height: 1px; background: var(--border, #2a2a2a); margin: 5px 4px; }
|
.block-menu .bm-sep { height: 1px; background: var(--border, #2a2a2a); margin: 5px 4px; }
|
||||||
|
/* ── v7.61 : menu contextuel type Notion — recherche, sections, pied, sous-menus ── */
|
||||||
|
.block-menu .bm-group {
|
||||||
|
padding: 9px 9px 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .5px;
|
||||||
|
}
|
||||||
|
.block-menu .bm-foot {
|
||||||
|
border-top: 1px solid var(--border, #2a2a2a);
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 8px 9px 6px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
.block-menu .bm-check { margin-left: auto; color: var(--accent, #2383e2); font-size: 12px; }
|
||||||
|
.block-menu .bm-item.bm-current { background: var(--bg-secondary); }
|
||||||
|
.block-menu .bm-dot {
|
||||||
|
display: inline-block; width: 12px; height: 12px; border-radius: 3px;
|
||||||
|
border: 1px solid var(--border, #333); vertical-align: -2px;
|
||||||
|
}
|
||||||
|
.block-menu .bm-search:focus { border-color: var(--accent, #2383e2); }
|
||||||
.block-menu .bm-colors { padding: 6px 9px; }
|
.block-menu .bm-colors { padding: 6px 9px; }
|
||||||
.block-menu .bm-colors-label { font-size: 10px; font-weight: 600; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: .4px; margin-bottom: 6px; }
|
.block-menu .bm-colors-label { font-size: 10px; font-weight: 600; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: .4px; margin-bottom: 6px; }
|
||||||
.block-menu .bm-swatches { display: flex; flex-wrap: wrap; gap: 6px; }
|
.block-menu .bm-swatches { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||||
@@ -2346,7 +2460,7 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
color: var(--text-secondary); cursor: pointer; font-size: 12px; border-radius: 6px;
|
color: var(--text-secondary); cursor: pointer; font-size: 12px; border-radius: 6px;
|
||||||
}
|
}
|
||||||
.block-menu .bm-cancel:hover { background: var(--bg-hover); }
|
.block-menu .bm-cancel:hover { background: var(--bg-hover); }
|
||||||
.block-menu-sub { min-width: 180px; max-height: 320px; overflow-y: auto; }
|
.block-menu-sub { min-width: 230px; max-height: 60vh; overflow-y: auto; }
|
||||||
|
|
||||||
.ftable-hdr-toggle { margin-left: 12px; font-size: 12px; color: var(--text-tertiary); display:inline-flex; align-items:center; gap:2px; }
|
.ftable-hdr-toggle { margin-left: 12px; font-size: 12px; color: var(--text-tertiary); display:inline-flex; align-items:center; gap:2px; }
|
||||||
.ftable-hdr-toggle label { display:inline-flex; align-items:center; gap:4px; cursor:pointer; }
|
.ftable-hdr-toggle label { display:inline-flex; align-items:center; gap:4px; cursor:pointer; }
|
||||||
@@ -2416,11 +2530,20 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Numbered list */
|
/* Numbered list — data-num est calculé au rendu (rangée contiguë) */
|
||||||
.block-numbered {
|
.block-numbered {
|
||||||
padding-left: 22px;
|
padding-left: 32px;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
.block-numbered[data-num]::before {
|
||||||
|
content: attr(data-num) ". ";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
min-width: 24px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
/* To-do */
|
/* To-do */
|
||||||
.block-todo {
|
.block-todo {
|
||||||
@@ -2792,35 +2915,333 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* FORMAT TOOLBAR (FLOATING) */
|
/* FORMAT TOOLBAR (FLOATING) — menu contextuel au highlight (v7.62, façon Notion) */
|
||||||
.format-toolbar {
|
.format-toolbar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 998;
|
z-index: 998;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 2px;
|
align-items: stretch;
|
||||||
|
gap: 1px;
|
||||||
|
min-width: 216px;
|
||||||
|
max-height: calc(100vh - 16px);
|
||||||
|
overflow-y: auto;
|
||||||
background: var(--bg-modal);
|
background: var(--bg-modal);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid var(--border-light);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||||
padding: 4px 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.format-toolbar button {
|
.format-toolbar .ft-row {
|
||||||
width: 30px;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-toolbar .ft-item {
|
||||||
height: 28px;
|
height: 28px;
|
||||||
|
min-width: 28px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 7px;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
}
|
}
|
||||||
.format-toolbar button:hover {
|
.format-toolbar .ft-item:hover {
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
}
|
}
|
||||||
|
.format-toolbar .ft-item svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ligne 1 : style de texte (libellé du type de bloc + sous-menu) */
|
||||||
|
.format-toolbar .ft-style {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.format-toolbar .ft-style-ico {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.format-toolbar .ft-style-name {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.format-toolbar .ft-caret {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* « A » : couleur du texte (barre colorée sous la lettre) */
|
||||||
|
.format-toolbar .ft-a-letter {
|
||||||
|
position: relative;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.format-toolbar .ft-a-letter::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 1px;
|
||||||
|
right: 1px;
|
||||||
|
bottom: -4px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
.format-toolbar .ft-tx,
|
||||||
|
.format-toolbar .ft-code-ico {
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
.format-toolbar .ft-hl {
|
||||||
|
border-radius: 2px;
|
||||||
|
padding: 0 3px;
|
||||||
|
background: rgba(255,197,61,.28);
|
||||||
|
}
|
||||||
|
.format-toolbar .ft-more {
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Séparateurs, groupe « Skills », pied de page */
|
||||||
|
.format-toolbar .ft-sep {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
margin: 5px 2px;
|
||||||
|
}
|
||||||
|
.format-toolbar .ft-group {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
padding: 3px 7px 2px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
.format-toolbar .ft-skill {
|
||||||
|
height: 26px;
|
||||||
|
justify-content: flex-start;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.format-toolbar .ft-foot {
|
||||||
|
height: 26px;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.format-toolbar .ft-foot kbd {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Info-bulle des éléments du menu de sélection : bulle claire, à droite */
|
||||||
|
.tooltip.tooltip-light {
|
||||||
|
background: #ffffff;
|
||||||
|
border-color: #e3e3e3;
|
||||||
|
color: #191919;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.25);
|
||||||
|
white-space: normal;
|
||||||
|
max-width: 240px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
.tooltip.tooltip-light em {
|
||||||
|
color: #6b6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══ v7.63 — Menus mobiles façon Notion : barre horizontale au « / » ══
|
||||||
|
(défilement latéral) + feuille plein écran Insert block / Turn into / Color ══ */
|
||||||
|
.fd-mtb {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1300;
|
||||||
|
display: none;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 5px 6px;
|
||||||
|
max-width: calc(100vw - 16px);
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
background: var(--bg-modal);
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 6px 20px rgba(0,0,0,.45);
|
||||||
|
}
|
||||||
|
.fd-mtb::-webkit-scrollbar { display: none; }
|
||||||
|
.fd-mtb .fd-mtb-item {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.fd-mtb .fd-mtb-item:active { background: var(--bg-hover); }
|
||||||
|
.fd-mtb .fd-mtb-wide { font-size: 13px; font-weight: 600; padding: 0 10px; }
|
||||||
|
.fd-mtb .fd-mtb-caret { font-size: 10px; color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* Feuille plein écran (titre centré + Cancel bleu à droite, liste qui défile) */
|
||||||
|
.fd-msheet {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1500;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--bg-secondary, #191919);
|
||||||
|
}
|
||||||
|
.fd-msheet-head {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.fd-msheet-title { font-size: 15px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.fd-msheet-cancel {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent, #2383E2);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
/* la liste porte la classe block-menu (hérite des styles .bm-item/.bm-group)
|
||||||
|
mais doit couvrir tout l'écran, sans la chrome du menu flottant */
|
||||||
|
.fd-msheet-list.block-menu {
|
||||||
|
max-width: none;
|
||||||
|
min-width: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
padding: 6px 8px 40px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
.fd-msheet .bm-item { padding: 12px 10px; font-size: 15px; }
|
||||||
|
.fd-msheet .bm-group { padding: 14px 10px 4px; }
|
||||||
|
.fd-msheet .sm-group {
|
||||||
|
padding: 14px 10px 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: .4px;
|
||||||
|
}
|
||||||
|
.fd-msheet .slash-item { padding: 12px 10px; font-size: 15px; }
|
||||||
|
.fd-msheet .slash-item:active { background: var(--bg-hover); }
|
||||||
|
|
||||||
|
/* ══ v7.64 — Commentaires & réactions façon Notion ══
|
||||||
|
Ancres jaunes (texte commenté), pastilles de réaction, tiroir refondu ══ */
|
||||||
|
.fd-anchor {
|
||||||
|
background: rgba(255, 213, 0, .32);
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.fd-anchor-done { background: rgba(255, 255, 255, .10); }
|
||||||
|
.fd-anchor-react { background: rgba(255, 213, 0, .28); }
|
||||||
|
.fd-react-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
margin-left: 4px;
|
||||||
|
padding: 0 5px;
|
||||||
|
height: 19px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
vertical-align: middle;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.fd-react-chip:hover { background: var(--bg-hover); }
|
||||||
|
.fd-react-chip img { width: 12px; height: 12px; object-fit: contain; }
|
||||||
|
.fd-react-n { font-size: 10px; color: var(--text-dim); }
|
||||||
|
|
||||||
|
/* Tiroir de commentaires (fil + saisie) */
|
||||||
|
.comment-thread {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--bg-secondary, #2a2a2a);
|
||||||
|
border: 1px solid var(--border, #333);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.comment-thread.has-anchor { border-left: 3px solid var(--accent, #2383E2); }
|
||||||
|
.comment-thread.is-resolved { opacity: .55; }
|
||||||
|
.thread-flash { outline: 2px solid var(--accent, #2383E2); outline-offset: 2px; }
|
||||||
|
.ct-head { display: flex; align-items: center; gap: 8px; position: relative; }
|
||||||
|
.ct-avatar {
|
||||||
|
width: 24px; height: 24px; border-radius: 50%; color: #fff; flex-shrink: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 700;
|
||||||
|
}
|
||||||
|
.ct-name { font-size: 12px; font-weight: 600; color: var(--text-primary); }
|
||||||
|
.ct-time { font-size: 11px; color: var(--text-dim); margin-left: auto; }
|
||||||
|
.ct-actions { display: flex; gap: 2px; margin-left: 2px; }
|
||||||
|
.ct-act {
|
||||||
|
width: 24px; height: 24px; border: none; background: transparent; border-radius: 5px;
|
||||||
|
color: var(--text-dim); font-size: 13px; line-height: 1; cursor: pointer;
|
||||||
|
}
|
||||||
|
.ct-act:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||||
|
.ct-act.is-on { color: var(--accent, #2383E2); }
|
||||||
|
.ct-menu {
|
||||||
|
position: absolute; right: 0; top: 28px; z-index: 6; min-width: 156px; padding: 4px;
|
||||||
|
background: var(--bg-modal); border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,.4);
|
||||||
|
}
|
||||||
|
.ct-menu-item {
|
||||||
|
display: block; width: 100%; text-align: left; padding: 7px 10px; font-size: 13px;
|
||||||
|
color: var(--text-primary); background: none; border: none; border-radius: 5px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.ct-menu-item:hover { background: var(--bg-hover); }
|
||||||
|
.ct-menu-item.is-danger { color: #e5484d; }
|
||||||
|
.ct-body { font-size: 13px; white-space: pre-wrap; margin-top: 6px; color: var(--text-primary); }
|
||||||
|
.ct-anchor-label { font-size: 10px; color: var(--text-dim); margin-top: 6px; }
|
||||||
|
.comments-drawer-input { border-top: 1px solid var(--border, #333); padding: 12px 16px; }
|
||||||
|
.ct-input-row { display: flex; gap: 6px; align-items: flex-end; }
|
||||||
|
.ct-input-row textarea {
|
||||||
|
flex: 1; min-width: 0; background: var(--bg-secondary, #2a2a2a);
|
||||||
|
border: 1px solid var(--border, #333); border-radius: 8px; color: var(--text-primary);
|
||||||
|
font-size: 13px; font-family: inherit; line-height: 1.4; padding: 8px 10px; resize: none; outline: none;
|
||||||
|
}
|
||||||
|
.ct-input-row textarea:focus { border-color: var(--accent, #2383E2); }
|
||||||
|
.ct-input-actions { display: flex; gap: 4px; align-items: center; padding-bottom: 2px; }
|
||||||
|
.ct-send {
|
||||||
|
width: 30px; height: 30px; border-radius: 50%; background: var(--accent, #2383E2); color: #fff;
|
||||||
|
border: none; font-size: 15px; line-height: 1; cursor: pointer; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ct-send:disabled { opacity: .4; cursor: default; }
|
||||||
|
|
||||||
|
/* Bouton « React to selected text » de la toolbar de sélection */
|
||||||
|
.ft-react-ico { position: relative; font-size: 15px; line-height: 1; }
|
||||||
|
.ft-react-plus {
|
||||||
|
position: absolute; right: -5px; top: -4px; font-size: 9px; font-weight: 700; color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
/* EDITOR STATUSBAR */
|
/* EDITOR STATUSBAR */
|
||||||
.editor-statusbar {
|
.editor-statusbar {
|
||||||
@@ -2865,6 +3286,7 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
.page-title-input { font-size: 28px; }
|
.page-title-input { font-size: 28px; }
|
||||||
.blocks-container { padding: 8px 16px 96px; }
|
.blocks-container { padding: 8px 16px 96px; }
|
||||||
.block-handle { display: none; }
|
.block-handle { display: none; }
|
||||||
|
.block-insert-btn { display: none; }
|
||||||
.block-actions { display: none; }
|
.block-actions { display: none; }
|
||||||
.editor-statusbar { left: 0; }
|
.editor-statusbar { left: 0; }
|
||||||
}
|
}
|
||||||
@@ -3340,6 +3762,7 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
.page-cover-area { padding: 32px 32px 8px; }
|
.page-cover-area { padding: 32px 32px 8px; }
|
||||||
.blocks-container { padding: 8px 32px 80px; }
|
.blocks-container { padding: 8px 32px 80px; }
|
||||||
.block-handle { left: -32px; }
|
.block-handle { left: -32px; }
|
||||||
|
.block-insert-btn { left: -56px; }
|
||||||
.page-title-block::before { left: -32px; }
|
.page-title-block::before { left: -32px; }
|
||||||
.page-title-input { font-size: 32px; }
|
.page-title-input { font-size: 32px; }
|
||||||
|
|
||||||
@@ -3364,7 +3787,7 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
|
|
||||||
/* ── Sidebar mobile ── */
|
/* ── Sidebar mobile ── */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
display: block;
|
display: flex; flex-direction: column; /* block cassait flex:1 de .sidebar-scroll → drawer impossible à scroller */
|
||||||
position: fixed !important;
|
position: fixed !important;
|
||||||
top: 0; left: 0;
|
top: 0; left: 0;
|
||||||
bottom: 0; width: 85vw; max-width: 320px;
|
bottom: 0; width: 85vw; max-width: 320px;
|
||||||
@@ -3430,6 +3853,7 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
.page-title-input { font-size: 24px; }
|
.page-title-input { font-size: 24px; }
|
||||||
.blocks-container { padding: 8px 16px 96px; }
|
.blocks-container { padding: 8px 16px 96px; }
|
||||||
.block-handle { display: none; }
|
.block-handle { display: none; }
|
||||||
|
.block-insert-btn { display: none; }
|
||||||
.block-actions { display: none; }
|
.block-actions { display: none; }
|
||||||
.page-title-block::before { display: none; }
|
.page-title-block::before { display: none; }
|
||||||
.page-title-block .page-icon-emoji { margin-left: 0; }
|
.page-title-block .page-icon-emoji { margin-left: 0; }
|
||||||
@@ -3517,7 +3941,7 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
.slash-menu { width: 90vw; max-width: 90vw; left: 5vw !important; }
|
.slash-menu { width: 90vw; max-width: 90vw; left: 5vw !important; }
|
||||||
|
|
||||||
/* ── Format Toolbar ── */
|
/* ── Format Toolbar ── */
|
||||||
.format-toolbar { left: 8px !important; right: 8px; flex-wrap: wrap; justify-content: center; }
|
.format-toolbar { left: 8px !important; right: 8px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════
|
||||||
@@ -3840,11 +4264,15 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
Database Table View (Notion-style)
|
Database Table View (Notion-style)
|
||||||
═══════════════════════════════════════════════════════════ */
|
═══════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* Base = viewport du contenu ; les vues tabulaires larges (bases de
|
||||||
|
données) prennent la LARGEUR DU DOCUMENT (les colonnes dictent le scroll
|
||||||
|
horizontal), alignées à gauche au ras de la marge. */
|
||||||
.database-table-container {
|
.database-table-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
margin: 0;
|
||||||
padding: 0 96px;
|
/* var(--space-lg) = padding de .topbar : le bord gauche du tableau
|
||||||
margin-top: 24px;
|
s'aligne sur le titre du fil d'ariane, au ras de la marge. */
|
||||||
|
padding: 0 var(--space-lg);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4358,6 +4786,7 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
/* ── Responsive ── */
|
/* ── Responsive ── */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.database-table-container { padding: 0 16px; }
|
.database-table-container { padding: 0 16px; }
|
||||||
|
.db-side-peek { width: 100vw; max-width: 100vw; }
|
||||||
.db-th-property { min-width: 120px; }
|
.db-th-property { min-width: 120px; }
|
||||||
.db-td-cell { min-width: 120px; }
|
.db-td-cell { min-width: 120px; }
|
||||||
}
|
}
|
||||||
@@ -4365,22 +4794,27 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
/* ── Database Side Peek Panel ── */
|
/* ── Database Side Peek Panel ── */
|
||||||
.db-side-peek {
|
.db-side-peek {
|
||||||
position: fixed; top: 0; right: 0;
|
position: fixed; top: 0; right: 0;
|
||||||
width: 560px; height: 100vh;
|
width: 560px; max-width: 90vw; height: 100vh;
|
||||||
background: var(--bg-primary);
|
background: var(--bg-modal);
|
||||||
box-shadow: -4px 0 20px rgba(0,0,0,0.12);
|
border-left: 1px solid var(--border);
|
||||||
z-index: 900;
|
box-shadow: -8px 0 32px rgba(0,0,0,0.25);
|
||||||
|
z-index: 500;
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.2s cubic-bezier(0.16,1,0.3,1);
|
||||||
}
|
}
|
||||||
.db-side-peek-resize {
|
.db-side-peek.open { transform: translateX(0); }
|
||||||
position: absolute; top: 0; left: 0;
|
/* Poignée partagée des side peeks (bases) — les autres peeks portent la
|
||||||
width: 6px; height: 100%;
|
même géométrie en style en ligne. */
|
||||||
cursor: col-resize;
|
.peek-resize-handle {
|
||||||
z-index: 2;
|
position: absolute; left: -4px; top: 0;
|
||||||
|
width: 8px; height: 100%; cursor: col-resize;
|
||||||
|
z-index: 501;
|
||||||
}
|
}
|
||||||
.db-side-peek-resize:hover { background: var(--accent, #2383E2); }
|
.peek-resize-handle:hover { background: var(--accent, #2383E2); }
|
||||||
.db-side-peek-header {
|
.db-side-peek-header {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.db-side-peek-close, .db-side-peek-full {
|
.db-side-peek-close, .db-side-peek-full {
|
||||||
@@ -4390,7 +4824,13 @@ button.breadcrumb-current { max-width: 260px; }
|
|||||||
font-size: 18px; color: var(--text-dim);
|
font-size: 18px; color: var(--text-dim);
|
||||||
}
|
}
|
||||||
.db-side-peek-close:hover, .db-side-peek-full:hover { background: var(--bg-hover); }
|
.db-side-peek-close:hover, .db-side-peek-full:hover { background: var(--bg-hover); }
|
||||||
.db-side-peek iframe { flex: 1; }
|
.db-side-peek-body { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||||
|
.db-side-peek-loading { text-align: center; padding: 40px; color: var(--text-dim); }
|
||||||
|
.db-side-peek iframe { flex: 1; min-height: 0; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.db-side-peek { width: 100vw; max-width: 100vw; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── Simple Table block (v4.8.0) ─── */
|
/* ─── Simple Table block (v4.8.0) ─── */
|
||||||
.ftable-editor { flex: 1; min-width: 0; min-height: 0; padding: 4px 2px; }
|
.ftable-editor { flex: 1; min-width: 0; min-height: 0; padding: 4px 2px; }
|
||||||
|
|||||||
@@ -0,0 +1,654 @@
|
|||||||
|
/* ══════════════════════════════════════════════════════════════════════
|
||||||
|
* FlowDeck — My Tasks (tableau de bord transverse façon Notion)
|
||||||
|
* Préfixe `mt-`, conteneur `#my-tasks-app` (rendu par app/routers/my_tasks.py,
|
||||||
|
* comportement par static/js/my_tasks.js).
|
||||||
|
* ══════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.my-tasks-app { max-width: 1100px; margin: 0 auto; padding: 20px 24px 64px; }
|
||||||
|
|
||||||
|
/* ── En-tête ──────────────────────────────────────────────────────────── */
|
||||||
|
.mt-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 12px; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.mt-head h1 {
|
||||||
|
margin: 0; font-size: 24px; font-weight: 700; color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.mt-count-total { font-size: 13px; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* ── Barre d'outils ─────────────────────────────────────────────────── */
|
||||||
|
.mt-toolbar {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 12px; align-items: center;
|
||||||
|
justify-content: space-between; margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.mt-toolbar-left,
|
||||||
|
.mt-toolbar-right {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
|
||||||
|
}
|
||||||
|
.mt-view-tabs { display: flex; gap: 2px; align-items: center; }
|
||||||
|
.mt-view-tab {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
padding: 6px 12px; font-size: 13px; font-weight: 500;
|
||||||
|
color: var(--text-secondary); background: transparent;
|
||||||
|
border: none; border-radius: 6px; cursor: pointer;
|
||||||
|
text-decoration: none; white-space: nowrap; transition: all .12s;
|
||||||
|
}
|
||||||
|
.mt-view-tab:hover { color: var(--text-primary); background: var(--bg-hover); }
|
||||||
|
.mt-view-tab.active { color: var(--text-primary); background: var(--bg-active); }
|
||||||
|
.mt-select {
|
||||||
|
font: inherit; font-size: 13px; padding: 5px 8px; border-radius: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-primary); color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.mt-toggle {
|
||||||
|
font-size: 13px; color: var(--text-secondary);
|
||||||
|
display: inline-flex; align-items: center; gap: 5px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.mt-new-btn,
|
||||||
|
.mt-empty-btn {
|
||||||
|
font: inherit; font-size: 13px; font-weight: 600;
|
||||||
|
padding: 6px 14px; border-radius: 6px; border: none; cursor: pointer;
|
||||||
|
text-decoration: none; display: inline-block;
|
||||||
|
background: var(--accent, #2383e2); color: #fff;
|
||||||
|
}
|
||||||
|
.mt-new-btn:hover,
|
||||||
|
.mt-empty-btn:hover { filter: brightness(1.08); }
|
||||||
|
|
||||||
|
/* ── Sources connectées ─────────────────────────────────────────────── */
|
||||||
|
.mt-sources {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
|
||||||
|
padding: 8px 0 14px; margin-bottom: 16px;
|
||||||
|
border-bottom: 1px solid var(--border, #eee);
|
||||||
|
}
|
||||||
|
.mt-sources-label { font-size: 12px; color: var(--text-dim, #999); margin-right: 4px; }
|
||||||
|
.mt-source-chip {
|
||||||
|
font-size: 12px; padding: 3px 9px; border-radius: 20px; text-decoration: none;
|
||||||
|
border: 1px solid var(--border, #e3e3e0);
|
||||||
|
color: var(--text, #333); background: var(--bg-primary, #fff);
|
||||||
|
}
|
||||||
|
.mt-source-chip.pending { border-style: dashed; color: var(--text-dim, #999); }
|
||||||
|
.mt-source-chip:hover { border-color: var(--accent, #2383e2); }
|
||||||
|
.mt-sources-warn { font-size: 12px; color: #c9820a; }
|
||||||
|
|
||||||
|
/* ── Vue tableau ──────────────────────────────────────────────────────────
|
||||||
|
* Lignes en `div` (et non `<table>`) : meme choix que Library, qui le permet
|
||||||
|
* d'afficher les actions de ligne UNIQUEMENT au survol (`.hover-only`).
|
||||||
|
*/
|
||||||
|
.mt-table { width: 100%; }
|
||||||
|
.mt-thead {
|
||||||
|
display: flex; align-items: center; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.mt-th {
|
||||||
|
display: flex; align-items: center; padding: 6px 8px;
|
||||||
|
font-size: 12px; font-weight: 500; color: var(--text-dim);
|
||||||
|
white-space: nowrap; user-select: none;
|
||||||
|
}
|
||||||
|
.mt-row {
|
||||||
|
display: flex; align-items: center; height: 38px;
|
||||||
|
border-bottom: 1px solid var(--border); transition: background .1s;
|
||||||
|
}
|
||||||
|
.mt-row:last-child { border-bottom: none; }
|
||||||
|
.mt-row:hover { background: var(--bg-hover); }
|
||||||
|
.mt-cell {
|
||||||
|
display: flex; align-items: center; padding: 0 8px;
|
||||||
|
height: 100%; overflow: hidden;
|
||||||
|
}
|
||||||
|
.mt-cell-name { flex: 1; min-width: 0; gap: 6px; }
|
||||||
|
.mt-cell-status,
|
||||||
|
.mt-cell-due { width: 165px; flex-shrink: 0; }
|
||||||
|
.mt-cell-src { width: 210px; flex-shrink: 0; }
|
||||||
|
.mt-icon {
|
||||||
|
font-size: 15px; flex-shrink: 0; width: 20px; text-align: center;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.mt-title-text {
|
||||||
|
font-size: 13px; color: var(--text-primary); cursor: text;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
min-width: 0; flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
.mt-title-text:hover { text-decoration: underline; }
|
||||||
|
.mt-title-input {
|
||||||
|
font: inherit; width: 100%; padding: 3px 6px; border-radius: 4px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
background: var(--bg-primary); color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.mt-status-select,
|
||||||
|
.mt-due-input {
|
||||||
|
font: inherit; font-size: 13px; padding: 4px 6px; border-radius: 5px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-primary); color: var(--text-primary);
|
||||||
|
max-width: 100%; width: 100%;
|
||||||
|
}
|
||||||
|
.mt-due-overdue { border-color: #e05252; color: #e05252; }
|
||||||
|
.mt-due-today { border-color: #c9820a; color: #c9820a; font-weight: 600; }
|
||||||
|
.mt-chip {
|
||||||
|
font-size: 12px; padding: 3px 8px; border-radius: 5px; text-decoration: none;
|
||||||
|
background: var(--bg-hover); color: var(--text-secondary);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.mt-chip:hover { color: var(--accent); }
|
||||||
|
.mt-static { font-size: 13px; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
/* ── Bouton « Open » + actions visibles au survol ──────────────────────────
|
||||||
|
* Mêmes valeurs que `.btn-open` / `.hover-only` de Library
|
||||||
|
* (`app/templates/library.html`) : même aspect, même révélation au survol.
|
||||||
|
*/
|
||||||
|
.mt-hover-only { opacity: 0; transition: opacity .12s; flex-shrink: 0; }
|
||||||
|
.mt-row:hover .mt-hover-only,
|
||||||
|
.mt-row:focus-within .mt-hover-only { opacity: 1; }
|
||||||
|
.mt-open-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
padding: 3px 8px; border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
font-size: 11px; font-weight: 500; color: var(--text-primary);
|
||||||
|
background: var(--bg-tertiary); cursor: pointer; white-space: nowrap;
|
||||||
|
transition: all .1s; flex-shrink: 0; text-decoration: none; margin-left: auto;
|
||||||
|
}
|
||||||
|
.mt-open-btn:hover { background: var(--bg-active); }
|
||||||
|
.mt-open-btn svg { width: 12px; height: 12px; }
|
||||||
|
@media (hover: none) {
|
||||||
|
/* Écrans tactiles : pas de survol, l'action doit rester visible. */
|
||||||
|
.mt-hover-only { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Side peek (panneau latéral, sur le modèle de Library) ───────────────
|
||||||
|
* `?embed=1` charge l'éditeur du document dans l'iframe, comme le peek de
|
||||||
|
* Library : on lit la tâche sans quitter le tableau de bord.
|
||||||
|
*/
|
||||||
|
.mt-peek {
|
||||||
|
position: fixed; top: 0; right: 0; width: 560px; max-width: 90vw;
|
||||||
|
height: 100vh; z-index: 500; display: flex; flex-direction: column;
|
||||||
|
background: var(--bg-modal); border-left: 1px solid var(--border);
|
||||||
|
box-shadow: -8px 0 32px rgba(0, 0, 0, .25);
|
||||||
|
transform: translateX(100%); transition: transform .2s cubic-bezier(.16, 1, .3, 1);
|
||||||
|
}
|
||||||
|
.mt-peek.open { transform: translateX(0); }
|
||||||
|
.mt-peek-handle {
|
||||||
|
position: absolute; left: -4px; top: 0; width: 8px; height: 100%;
|
||||||
|
cursor: col-resize; z-index: 501;
|
||||||
|
}
|
||||||
|
.mt-peek-head {
|
||||||
|
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
|
||||||
|
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.mt-peek-title {
|
||||||
|
flex: 1; min-width: 0; font-size: 15px; font-weight: 600;
|
||||||
|
color: var(--text-primary); display: flex; align-items: center; gap: 8px;
|
||||||
|
overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.mt-peek-btn {
|
||||||
|
background: transparent; border: none; color: var(--text-secondary);
|
||||||
|
cursor: pointer; padding: 6px; border-radius: 4px;
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
}
|
||||||
|
.mt-peek-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
|
||||||
|
.mt-peek-btn svg { width: 15px; height: 15px; }
|
||||||
|
.mt-peek-body { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||||
|
.mt-peek-body iframe { flex: 1; width: 100%; border: none; min-height: 0; }
|
||||||
|
.mt-peek-loading {
|
||||||
|
padding: 40px; text-align: center; color: var(--text-dim); font-size: 13px;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.mt-peek { width: 100vw; max-width: 100vw; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Vue kanban ─────────────────────────────────────────────────────── */
|
||||||
|
.mt-board { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 12px; }
|
||||||
|
.mt-col {
|
||||||
|
min-width: 240px; flex: 1 0 240px; padding: 10px; border-radius: 10px;
|
||||||
|
background: var(--bg-secondary, #f7f7f5);
|
||||||
|
}
|
||||||
|
.mt-col-head { font-size: 12px; font-weight: 600; color: var(--text-dim, #777); margin-bottom: 8px; }
|
||||||
|
.mt-count { opacity: .6; font-weight: 400; }
|
||||||
|
.mt-col-body { display: flex; flex-direction: column; gap: 6px; min-height: 60px; }
|
||||||
|
.mt-card {
|
||||||
|
display: flex; flex-direction: column; gap: 4px; padding: 9px 10px;
|
||||||
|
border: 1px solid var(--border, #e3e3e0); border-radius: 7px;
|
||||||
|
background: var(--bg-primary, #fff); font-size: 13px; cursor: grab;
|
||||||
|
}
|
||||||
|
.mt-card:active { cursor: grabbing; opacity: .7; }
|
||||||
|
.mt-card-title { font-weight: 500; }
|
||||||
|
/* Bouton « Open » en overlay au survol de la tuile (la carte est une
|
||||||
|
colonne flex : une place en flux déformerait la tuile). */
|
||||||
|
.mt-card { position: relative; }
|
||||||
|
.mt-card .mt-open-btn {
|
||||||
|
position: absolute; top: 6px; right: 6px; margin-left: 0;
|
||||||
|
}
|
||||||
|
.mt-card:hover .mt-hover-only,
|
||||||
|
.mt-card:focus-within .mt-hover-only { opacity: 1; }
|
||||||
|
.mt-card-due,
|
||||||
|
.mt-card-src { font-size: 11px; color: var(--text-dim, #999); }
|
||||||
|
.mt-card-src { opacity: .8; }
|
||||||
|
|
||||||
|
/* ── Vue calendrier ─────────────────────────────────────────────────── */
|
||||||
|
.mt-cal {
|
||||||
|
border: 1px solid var(--border, #eee); border-radius: 10px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.mt-cal-head {
|
||||||
|
display: flex; align-items: center; justify-content: center; gap: 12px;
|
||||||
|
padding: 10px; border-bottom: 1px solid var(--border, #eee);
|
||||||
|
}
|
||||||
|
.mt-cal-title { font-size: 14px; font-weight: 600; text-transform: capitalize; }
|
||||||
|
.mt-cal-nav {
|
||||||
|
padding: 2px 9px; border: 1px solid var(--border, #e3e3e0); border-radius: 5px;
|
||||||
|
background: var(--bg-primary, #fff); color: var(--text, #333);
|
||||||
|
font-size: 15px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.mt-cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); }
|
||||||
|
.mt-cal-dow {
|
||||||
|
padding: 6px 0; text-align: center; font-size: 11px;
|
||||||
|
color: var(--text-dim, #999); border-bottom: 1px solid var(--border, #eee);
|
||||||
|
}
|
||||||
|
.mt-cal-cell {
|
||||||
|
min-height: 92px; padding: 5px 6px; font-size: 12px;
|
||||||
|
border-right: 1px solid var(--border, #f0f0f0);
|
||||||
|
border-bottom: 1px solid var(--border, #f0f0f0);
|
||||||
|
}
|
||||||
|
.mt-cal-cell.out { opacity: .4; }
|
||||||
|
.mt-cal-cell.today { background: var(--bg-hover, rgba(35, 131, 226, .07)); }
|
||||||
|
.mt-cal-day { margin-bottom: 4px; font-size: 11px; color: var(--text-dim, #999); }
|
||||||
|
.mt-cal-ev:hover { background: var(--bg-hover); }
|
||||||
|
.mt-cal-ev:hover .mt-cal-open { opacity: 1; }
|
||||||
|
|
||||||
|
/* ── Calendrier : sélecteur de vue + 4 vues supplémentaires ───────────── */
|
||||||
|
.mt-cal-body { padding: 8px; }
|
||||||
|
.mt-cal-tabs {
|
||||||
|
display: flex; gap: 2px; padding: 6px 8px 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.mt-cal-tab {
|
||||||
|
padding: 5px 11px; font-size: 12.5px; font-weight: 500;
|
||||||
|
color: var(--text-secondary); background: transparent;
|
||||||
|
border: none; border-bottom: 2px solid transparent;
|
||||||
|
cursor: pointer; transition: all .12s;
|
||||||
|
}
|
||||||
|
.mt-cal-tab:hover { color: var(--text-primary); }
|
||||||
|
.mt-cal-tab.active { color: var(--text-primary); border-bottom-color: var(--accent); }
|
||||||
|
|
||||||
|
/* Événement : titre + bouton d'ouverture révélé au survol. */
|
||||||
|
.mt-cal-ev {
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
margin-bottom: 3px; padding: 2px 5px; border-radius: 4px;
|
||||||
|
background: var(--bg-hover); font-size: 12px; min-width: 0;
|
||||||
|
}
|
||||||
|
.mt-cal-ev.drag { cursor: grab; }
|
||||||
|
.mt-cal-ev-text {
|
||||||
|
flex: 1; min-width: 0; overflow: hidden;
|
||||||
|
text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mt-cal-open {
|
||||||
|
flex-shrink: 0; display: inline-flex; align-items: center;
|
||||||
|
padding: 1px; border: none; border-radius: 3px; cursor: pointer;
|
||||||
|
background: transparent; color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.mt-cal-open svg { width: 11px; height: 11px; display: block; }
|
||||||
|
.mt-cal-open:hover { background: var(--bg-active); color: var(--text-primary); }
|
||||||
|
|
||||||
|
/* Vue année : 12 mini-mois. */
|
||||||
|
.mt-cal-year {
|
||||||
|
display: grid; grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 14px; padding: 10px;
|
||||||
|
}
|
||||||
|
.mt-cal-mini-title {
|
||||||
|
font-size: 12px; font-weight: 600; color: var(--text-secondary);
|
||||||
|
text-transform: capitalize; margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.mt-cal-mini-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px;
|
||||||
|
}
|
||||||
|
.mt-cal-mini-dow {
|
||||||
|
font-size: 9px; color: var(--text-dim); text-align: center; padding: 1px 0;
|
||||||
|
}
|
||||||
|
.mt-cal-mini-day {
|
||||||
|
font: inherit; font-size: 10.5px; padding: 3px 0; border: none;
|
||||||
|
border-radius: 4px; cursor: pointer; position: relative;
|
||||||
|
background: transparent; color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.mt-cal-mini-day:hover { background: var(--bg-hover); }
|
||||||
|
.mt-cal-mini-day.out { visibility: hidden; }
|
||||||
|
.mt-cal-mini-day.today { background: var(--accent); color: #fff; }
|
||||||
|
.mt-cal-mini-day.busy::after {
|
||||||
|
content: ''; position: absolute; left: 50%; bottom: 1px;
|
||||||
|
width: 3px; height: 3px; margin-left: -1.5px; border-radius: 50%;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
.mt-cal-mini-day.today.busy::after { background: #fff; }
|
||||||
|
|
||||||
|
/* Vue semaine : 7 colonnes. */
|
||||||
|
.mt-cal-week {
|
||||||
|
display: grid; grid-template-columns: repeat(7, 1fr); gap: 1px;
|
||||||
|
background: var(--border); min-height: 180px;
|
||||||
|
}
|
||||||
|
.mt-cal-wcol { background: var(--bg-primary); padding: 6px; min-width: 0; }
|
||||||
|
.mt-cal-wcol.today { background: var(--bg-hover); }
|
||||||
|
.mt-cal-wcol-head {
|
||||||
|
font-size: 11px; font-weight: 600; color: var(--text-secondary);
|
||||||
|
margin-bottom: 6px; padding-bottom: 4px; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.mt-cal-wcol-body { display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.mt-cal-wempty { font-size: 12px; color: var(--text-dim); text-align: center; }
|
||||||
|
|
||||||
|
/* Vue jour : liste détaillée. */
|
||||||
|
.mt-cal-dayview { padding: 10px 12px 14px; }
|
||||||
|
.mt-cal-dayview-head {
|
||||||
|
font-size: 12px; color: var(--text-secondary); margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.mt-cal-dayview-empty {
|
||||||
|
font-size: 13px; color: var(--text-dim); padding: 26px 0; text-align: center;
|
||||||
|
}
|
||||||
|
.mt-cal-task {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 7px 8px; border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.mt-cal-task:hover { background: var(--bg-hover); }
|
||||||
|
.mt-cal-task-title {
|
||||||
|
flex: 1; min-width: 0; font-size: 13px; color: var(--text-primary);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.mt-cal-task-src, .mt-cal-task-status { font-size: 12px; color: var(--text-dim); }
|
||||||
|
.mt-cal-task-status { width: 130px; flex-shrink: 0; }
|
||||||
|
|
||||||
|
/* Vue planning : colonnes = statuts, lignes = jours. */
|
||||||
|
.mt-cal-plan { overflow-x: auto; max-height: 60vh; overflow-y: auto; }
|
||||||
|
.mt-cal-plan-row { display: flex; align-items: stretch; min-width: 520px; }
|
||||||
|
.mt-cal-plan-row.head { position: sticky; top: 0; z-index: 2; background: var(--bg-primary); }
|
||||||
|
.mt-cal-plan-gutter {
|
||||||
|
width: 44px; flex-shrink: 0; padding: 4px 6px;
|
||||||
|
font-size: 11px; color: var(--text-dim); text-align: right;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.mt-cal-plan-col-title {
|
||||||
|
flex: 1; min-width: 0; padding: 4px 8px;
|
||||||
|
font-size: 11px; font-weight: 600; color: var(--text-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.mt-cal-plan-cell {
|
||||||
|
flex: 1; min-width: 0; padding: 3px 5px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.mt-cal-plan-row.today .mt-cal-plan-gutter {
|
||||||
|
color: var(--accent); font-weight: 700;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.mt-cal-year { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── États vides ──────────────────────────────────────────────────────────
|
||||||
|
* Bloc centré dans la surface disponible : invite de conversion
|
||||||
|
* (`.my-tasks-empty`, rendu serveur) comme états vides du tableau de bord
|
||||||
|
* (`.mt-empty`, rendu client). Même gabarit pour que les deux se ressemblent.
|
||||||
|
*/
|
||||||
|
.my-tasks-empty,
|
||||||
|
.mt-empty {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
text-align: center; padding: 72px 24px 80px;
|
||||||
|
max-width: 620px; margin: 0 auto;
|
||||||
|
}
|
||||||
|
.my-tasks-empty-icon,
|
||||||
|
.mt-empty-icon {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 64px; height: 64px; margin-bottom: 20px; font-size: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-hover, rgba(0, 0, 0, .04));
|
||||||
|
border: 1px solid var(--border, #e3e3e0);
|
||||||
|
}
|
||||||
|
.my-tasks-empty h2,
|
||||||
|
.mt-empty h2 {
|
||||||
|
margin: 0 0 10px; font-size: 19px; font-weight: 600; color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
.my-tasks-empty p,
|
||||||
|
.mt-empty p {
|
||||||
|
margin: 0 0 8px; font-size: 14px; line-height: 1.6;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
.my-tasks-empty-hint,
|
||||||
|
.mt-empty-hint {
|
||||||
|
font-size: 13px; color: var(--text-dim, #888);
|
||||||
|
}
|
||||||
|
.my-tasks-empty-steps,
|
||||||
|
.mt-empty-steps {
|
||||||
|
margin: 20px 0 0; padding: 14px 18px; list-style: none;
|
||||||
|
display: inline-flex; flex-direction: column; gap: 8px; text-align: left;
|
||||||
|
border: 1px solid var(--border, #e3e3e0);
|
||||||
|
border-radius: 10px; background: var(--bg-secondary, #fafafa);
|
||||||
|
font-size: 13px; color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
.my-tasks-empty-steps li,
|
||||||
|
.mt-empty-steps li { display: flex; align-items: flex-start; gap: 9px; }
|
||||||
|
.my-tasks-empty-steps .n,
|
||||||
|
.mt-empty-steps .n {
|
||||||
|
flex-shrink: 0; width: 20px; height: 20px; margin-top: -1px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: 50%; background: var(--bg-primary, #fff);
|
||||||
|
border: 1px solid var(--border-strong, #d3d3d0);
|
||||||
|
font-size: 11px; font-weight: 700; color: var(--text-dim, #999);
|
||||||
|
}
|
||||||
|
.my-tasks-empty-btn,
|
||||||
|
.mt-empty-btn {
|
||||||
|
display: inline-block; margin-top: 22px; padding: 9px 20px; border-radius: 8px;
|
||||||
|
background: var(--accent, #2383e2); color: #fff;
|
||||||
|
font-size: 14px; font-weight: 600; text-decoration: none; border: none;
|
||||||
|
cursor: pointer; font-family: inherit;
|
||||||
|
}
|
||||||
|
.my-tasks-empty-btn:hover,
|
||||||
|
.mt-empty-btn:hover { background: var(--accent-hover, #1a6bc0); filter: none; }
|
||||||
|
|
||||||
|
/* ── Modale « nouvelle tâche » (multi-destinations) ─────────────────── */
|
||||||
|
.mt-modal-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 1000; padding-top: 12vh;
|
||||||
|
display: flex; align-items: flex-start; justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, .4);
|
||||||
|
}
|
||||||
|
.mt-modal {
|
||||||
|
width: 460px; max-width: 92vw; overflow: hidden; border-radius: 12px;
|
||||||
|
background: var(--bg-primary, #fff); box-shadow: 0 18px 60px rgba(0, 0, 0, .25);
|
||||||
|
}
|
||||||
|
.mt-modal-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 14px 16px; font-size: 14px; font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--border, #eee);
|
||||||
|
}
|
||||||
|
.mt-modal-close {
|
||||||
|
border: none; background: none; color: var(--text-dim, #888);
|
||||||
|
font-size: 14px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.mt-modal-body {
|
||||||
|
display: flex; flex-direction: column; gap: 12px;
|
||||||
|
max-height: 55vh; overflow-y: auto; padding: 16px;
|
||||||
|
}
|
||||||
|
.mt-field {
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
font-size: 12px; color: var(--text-dim, #888);
|
||||||
|
}
|
||||||
|
.mt-field input {
|
||||||
|
font: inherit; font-size: 14px; padding: 7px 9px; border-radius: 6px;
|
||||||
|
border: 1px solid var(--border, #e3e3e0);
|
||||||
|
background: var(--bg-primary, #fff); color: var(--text, #333);
|
||||||
|
}
|
||||||
|
.mt-modal-group {
|
||||||
|
margin-top: 4px; font-size: 11px; text-transform: uppercase;
|
||||||
|
letter-spacing: .5px; color: var(--text-dim, #999);
|
||||||
|
}
|
||||||
|
.mt-dest {
|
||||||
|
display: flex; align-items: center; gap: 8px; padding: 6px 8px;
|
||||||
|
border-radius: 6px; font-size: 13px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.mt-dest:hover { background: var(--bg-hover, rgba(0, 0, 0, .04)); }
|
||||||
|
.mt-modal-foot {
|
||||||
|
display: flex; justify-content: flex-end; gap: 8px;
|
||||||
|
padding: 12px 16px; border-top: 1px solid var(--border, #eee);
|
||||||
|
}
|
||||||
|
.mt-btn-cancel,
|
||||||
|
.mt-btn-primary {
|
||||||
|
font: inherit; font-size: 13px; padding: 7px 16px; border-radius: 6px;
|
||||||
|
border: 1px solid var(--border, #e3e3e0); background: transparent;
|
||||||
|
color: var(--text, #333); cursor: pointer;
|
||||||
|
}
|
||||||
|
.mt-btn-primary {
|
||||||
|
border-color: transparent; background: var(--accent, #2383e2);
|
||||||
|
color: #fff; font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Modale « Convertir en base de tâches » (task_db_link.js) ─────────── */
|
||||||
|
.tdb-overlay {
|
||||||
|
position: fixed; inset: 0; z-index: 1000; padding-top: 10vh;
|
||||||
|
display: flex; align-items: flex-start; justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, .4);
|
||||||
|
}
|
||||||
|
.tdb-modal {
|
||||||
|
width: 520px; max-width: 94vw; overflow: hidden; border-radius: 12px;
|
||||||
|
background: var(--bg-modal, var(--bg-primary, #fff));
|
||||||
|
box-shadow: 0 18px 60px rgba(0, 0, 0, .25); color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
.tdb-head {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 14px 16px; font-size: 14px; font-weight: 600;
|
||||||
|
border-bottom: 1px solid var(--border, #eee);
|
||||||
|
}
|
||||||
|
.tdb-close {
|
||||||
|
border: none; background: none; color: var(--text-dim, #888);
|
||||||
|
font-size: 14px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.tdb-intro {
|
||||||
|
padding: 12px 16px 0; font-size: 12.5px; line-height: 1.5;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
.tdb-intro a { color: var(--accent, #2383e2); }
|
||||||
|
.tdb-body { display: flex; flex-direction: column; gap: 14px; padding: 14px 16px; }
|
||||||
|
.tdb-role-head { display: flex; align-items: baseline; gap: 8px; margin-bottom: 5px; }
|
||||||
|
.tdb-role-head label { font-size: 13px; font-weight: 600; }
|
||||||
|
.tdb-role-hint { font-size: 11.5px; color: var(--text-dim, #999); }
|
||||||
|
.tdb-role-body { display: flex; gap: 8px; }
|
||||||
|
.tdb-select,
|
||||||
|
.tdb-new-name {
|
||||||
|
font: inherit; font-size: 13px; padding: 6px 8px; border-radius: 6px;
|
||||||
|
border: 1px solid var(--border, #e3e3e0);
|
||||||
|
background: var(--bg-primary, #fff); color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
.tdb-select { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.tdb-new-name { flex: 1 1 auto; min-width: 0; }
|
||||||
|
.tdb-warn {
|
||||||
|
margin: 0 16px 4px; padding: 8px 10px; border-radius: 6px; font-size: 12px;
|
||||||
|
background: rgba(201, 130, 10, .12); color: #c9820a;
|
||||||
|
}
|
||||||
|
.tdb-foot {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 12px 16px; border-top: 1px solid var(--border, #eee);
|
||||||
|
}
|
||||||
|
.tdb-spacer { flex: 1; }
|
||||||
|
.tdb-btn,
|
||||||
|
.tdb-btn-primary,
|
||||||
|
.tdb-btn-danger {
|
||||||
|
font: inherit; font-size: 13px; padding: 7px 16px; border-radius: 6px;
|
||||||
|
border: 1px solid var(--border, #e3e3e0); background: transparent;
|
||||||
|
color: var(--text-primary, #333); cursor: pointer;
|
||||||
|
}
|
||||||
|
.tdb-btn-primary {
|
||||||
|
border-color: transparent; background: var(--accent, #2383e2);
|
||||||
|
color: #fff; font-weight: 600;
|
||||||
|
}
|
||||||
|
.tdb-btn-primary:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.tdb-btn-danger { color: #e5484d; border-color: rgba(229, 72, 77, .4); }
|
||||||
|
|
||||||
|
/* ── Page « /db » : liste des bases du workspace ─────────────────────────
|
||||||
|
* En-tête recentré (page de destination du bouton « Ouvrir mes bases »),
|
||||||
|
* grille de cartes à gauche pour rester lisible sur les longues listes.
|
||||||
|
*/
|
||||||
|
.db-index { padding: 40px 24px 64px; max-width: 1100px; margin: 0 auto; }
|
||||||
|
.db-index-head {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-wrap: wrap; gap: 10px; margin-bottom: 6px; text-align: center;
|
||||||
|
}
|
||||||
|
.db-index-head h1 { margin: 0; font-size: 24px; }
|
||||||
|
.db-index-ws {
|
||||||
|
font-size: 12px; color: var(--text-dim, #999);
|
||||||
|
padding: 2px 9px; border-radius: 20px;
|
||||||
|
border: 1px solid var(--border, #e3e3e0);
|
||||||
|
}
|
||||||
|
.db-index-sub {
|
||||||
|
max-width: 640px; margin: 0 auto 26px; text-align: center;
|
||||||
|
font-size: 13.5px; line-height: 1.6; color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
.db-index-sub a { color: var(--accent, #2383e2); }
|
||||||
|
.db-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px;
|
||||||
|
}
|
||||||
|
.db-card {
|
||||||
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
|
padding: 16px 16px; border-radius: 12px; text-decoration: none;
|
||||||
|
border: 1px solid var(--border, #e3e3e0);
|
||||||
|
background: var(--bg-primary, #fff); color: var(--text, #333);
|
||||||
|
transition: border-color .12s, box-shadow .12s, transform .12s;
|
||||||
|
}
|
||||||
|
.db-card:hover {
|
||||||
|
border-color: var(--accent, #2383e2);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, .09);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.db-card-head { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.db-card-icon { font-size: 19px; }
|
||||||
|
.db-card-name {
|
||||||
|
flex: 1; min-width: 0; font-size: 14px; font-weight: 600;
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.db-card-badge {
|
||||||
|
flex-shrink: 0; font-size: 11px; font-weight: 600;
|
||||||
|
padding: 2px 8px; border-radius: 20px;
|
||||||
|
}
|
||||||
|
.db-card-badge.ok { background: rgba(0, 200, 100, .14); color: #00a35a; }
|
||||||
|
.db-card-badge.warn { background: rgba(201, 130, 10, .14); color: #c9820a; }
|
||||||
|
.db-card-meta { font-size: 11.5px; color: var(--text-dim, #999); }
|
||||||
|
.db-card-hint { font-size: 12px; line-height: 1.45; color: var(--text-secondary, #666); }
|
||||||
|
|
||||||
|
/* État vide de `/db` : bloc centré dans la surface disponible. */
|
||||||
|
.db-empty {
|
||||||
|
display: flex; flex-direction: column; align-items: center;
|
||||||
|
text-align: center; padding: 72px 24px 80px;
|
||||||
|
max-width: 620px; margin: 0 auto;
|
||||||
|
}
|
||||||
|
.db-empty-icon {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 64px; height: 64px; margin-bottom: 20px; font-size: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bg-hover, rgba(0, 0, 0, .04));
|
||||||
|
border: 1px solid var(--border, #e3e3e0);
|
||||||
|
}
|
||||||
|
.db-empty h1 { margin: 0 0 10px; font-size: 19px; font-weight: 600; }
|
||||||
|
.db-empty p {
|
||||||
|
margin: 0 0 8px; font-size: 14px; line-height: 1.6;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
.db-empty-hint { max-width: 520px; font-size: 13px; }
|
||||||
|
.db-empty-hint code {
|
||||||
|
padding: 1px 5px; border-radius: 4px; font-size: 12px;
|
||||||
|
background: var(--bg-hover, rgba(0, 0, 0, .05));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bouton « Convertir en base de tâches » (barre de la base) ────────── */
|
||||||
|
.db-task-db-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 5px 11px; border-radius: 6px;
|
||||||
|
border: 1px solid var(--border, #e3e3e0);
|
||||||
|
background: transparent; color: var(--text-secondary, #555);
|
||||||
|
font-size: 12.5px; font-weight: 500; cursor: pointer; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.db-task-db-btn:hover {
|
||||||
|
border-color: var(--accent, #2383e2);
|
||||||
|
color: var(--accent, #2383e2);
|
||||||
|
background: var(--bg-hover, rgba(35, 131, 226, .07));
|
||||||
|
}
|
||||||
|
.mt-source-fix {
|
||||||
|
font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 20px;
|
||||||
|
border: 1px solid rgba(201, 130, 10, .45); background: transparent;
|
||||||
|
color: #c9820a; cursor: pointer;
|
||||||
|
}
|
||||||
|
.mt-source-fix:hover { background: rgba(201, 130, 10, .12); }
|
||||||
|
.db-section-title {
|
||||||
|
margin: 38px 0 4px; font-size: 15px; font-weight: 600; color: var(--text, #333);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.db-section-sub {
|
||||||
|
max-width: 640px; margin: 0 auto 14px; text-align: center;
|
||||||
|
font-size: 12.5px; line-height: 1.55; color: var(--text-dim, #888);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user