Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12beab01c9 | ||
|
|
090174df0a | ||
|
|
fc8548194a | ||
|
|
d0452e10dd | ||
|
|
57c6cbec03 | ||
|
|
1fedafa808 | ||
|
|
d95178156f | ||
|
|
9ea0cddf3c | ||
|
|
0218d8f5e5 | ||
|
|
7468e497c6 | ||
|
|
162eb257c7 | ||
|
|
b9a776b626 | ||
|
|
674baf6954 | ||
|
|
3d3b46b58b | ||
|
|
7cf922106f | ||
|
|
d58c321c07 | ||
|
|
db6bf03a00 | ||
|
|
c031bf67de | ||
|
|
ab6977733d | ||
|
|
00c5e2a60d | ||
|
|
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 | ||
|
|
0f86294abc |
@@ -1,6 +1,10 @@
|
||||
# ── Gitea ──
|
||||
GITEA_URL=https://git.dracodev.net
|
||||
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_SECRET=
|
||||
GITEA_WEBHOOK_SECRET=
|
||||
@@ -16,6 +20,16 @@ GITHUB_OAUTH_CLIENT_SECRET=
|
||||
# Exemple : OAUTH_REDIRECT_URI=https://flowdeck.dracodev.net/auth/callback
|
||||
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_SECRET_KEY=change-me-to-random
|
||||
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_GROUPS_MAPPING=[{"sso_group":"FlowDeck Admins","workspace_role":"admin","workspace_id":1}]
|
||||
# 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
|
||||
|
||||
@@ -23,6 +23,10 @@ uv.lock
|
||||
*.db-*
|
||||
test-commit.md
|
||||
upload_test.txt
|
||||
commit_msg.txt
|
||||
e2e/node_modules/
|
||||
e2e/shots/
|
||||
e2e/test-results/
|
||||
|
||||
# Artefacts du navigateur Playwright MCP (logs / snapshots)
|
||||
.playwright-mcp/
|
||||
|
||||
+871
-1513
File diff suppressed because it is too large
Load Diff
+2095
File diff suppressed because it is too large
Load Diff
+573
@@ -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
|
||||
|
||||
### v4.0.1 — Onboarding & Polish ✅ (2026-07-18)
|
||||
@@ -1008,6 +1362,7 @@ Détails livrés :
|
||||
- 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)
|
||||
> **Objectif** : calendrier bidirectionnel + transcription → agents (cf. Notion 07/2026 : Meeting Notes trigger Custom Agents).
|
||||
@@ -1053,6 +1408,224 @@ 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] **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`
|
||||
|
||||
### v7.65.0 — Comment ancré sur la sélection de texte ✅ (2026-10-08)
|
||||
> **Objectif** : le commentaire porte sur le texte sélectionné dans le bloc,
|
||||
> pas sur le bloc entier, et le bouton bleu flottant disparaît. **COMPLETED**.
|
||||
|
||||
- [x] **Ancrage sur la plage** — `commentBlock()` prend la sélection vivante dans
|
||||
le bloc (repli : la plage mémorisée par la toolbar, qui survit au clic du
|
||||
bouton), sinon tout le bloc ; offsets calculés par `_srcOffset()` →
|
||||
`anchor_start/anchor_end` exacts dans le texte source
|
||||
- [x] **Bouton bleu « 💬 Comment » retiré** — `#_commentSelBtn`,
|
||||
`showCommentBtn()`, `hideCommentBtn()` et leur listener `mousedown` supprimés ;
|
||||
déclencheurs restants = 💬 de la toolbar de sélection, menu de bloc,
|
||||
Ctrl+⇧+M
|
||||
- [x] **Tiroir** — fermer remet `_commentSel` à zéro (pas d'ancrage périmé)
|
||||
- [x] **Tests** — e2e `e2e/v764_comments_reactions.spec.js` : sous-sélection
|
||||
`reaction a un` → `.fd-anchor[data-cid]` = la plage seule, avant/après
|
||||
reload, `#_commentSelBtn` absent ; `pytest` 1383 passed, `ruff` OK ;
|
||||
`VERSION` + `app/main.py` = 7.65.0
|
||||
|
||||
### v7.66.0 — Escape du menu slash + bouton commenter à droite ✅ (2026-10-08)
|
||||
> **Objectif** : Esc efface le `/` qui a ouvert le menu slash, et chaque bloc
|
||||
> gagne un bouton de commentaire à droite. **COMPLETED**.
|
||||
|
||||
- [x] **`SM.escClose()`** — un seul implément, appelé par les deux chemins
|
||||
(focus dans le bloc → `onKd`, focus dans l'entrée du menu → listener
|
||||
`document`) ; retire le `/` d'ouverture seulement, remet le caret, `sync` +
|
||||
`autoSave`
|
||||
- [x] **Bouton à droite du bloc** — `.block-comment-btn` (bulle + `+`,
|
||||
`E.commentBlock(idx)`), gouttière 64px, visible au survol, masqué mobile +
|
||||
page verrouillée ; remplace l'ancien `.block-actions` mort (opacity:0 sans
|
||||
survol → `+` invisible qui capait les clics)
|
||||
- [x] **`fmtComment()`** restaure la plage de la toolbar avant d'ancrer ;
|
||||
`commentBlock()` ne se replie plus sur `this._sel` périmé
|
||||
- [x] **Tests** — e2e `e2e/v766_slash_esc_comment_btn.spec.js` (Esc → menu
|
||||
fermé + bloc vide ; bouton à droite mesuré, ancre = bloc entier),
|
||||
`tests/test_v766_slash_esc_comment_btn.py` (3) ; `pytest` **1386 passed**,
|
||||
`ruff` OK ; `VERSION` + `app/main.py` = 7.66.0
|
||||
|
||||
### v7.67.0 — Réactions dans le tiroir + suppression + refonte du menu de sélection ✅ (2026-10-08)
|
||||
> **Objectif** : une réaction s'ajoute/supprime depuis le tiroir Comments, et
|
||||
> le menu de sélection correspond à la maquette ASCII fournie. **COMPLETED**.
|
||||
|
||||
- [x] **Section « Reactions » du tiroir** — `reactionList()` + `_anchorText()`
|
||||
(texte de la plage lu dans le DOM, hors pastilles), compte si >1, état vide
|
||||
adapté ; `removeReaction()` = POST toggle
|
||||
- [x] **Suppression** — ✕ dans le tiroir (mes réactions seulement) **et**
|
||||
croix `::after` au survol de la pastille sur l'émoji que j'ai posé (aucun
|
||||
nœud ajouté → offsets source intacts)
|
||||
- [x] **Refonte du menu** — ligne 4 scannée `💬 Comment | 🙂 réaction`,
|
||||
réaction sortie de la ligne 3, entête `Skills ≡`, liste Skills scrollable,
|
||||
`✨ Ask AI` retiré (doublon exact du pied)
|
||||
- écart volontaire à la maquette : le **barré (T)** de la ligne 2 est
|
||||
conservé (il n'y figure pas) pour ne pas perdre la fonction
|
||||
- [x] **Flake de gate corrigé** — `_is_public_host` attrape `OSError` (le
|
||||
garde SSRF ne lève plus) + fixture `stub_dns` sur `TestOGParser`
|
||||
(le test faisait un vrai `getaddrinfo("example.com")`, échec sous `-n auto`)
|
||||
- [x] **Tests** — `tests/test_v767_reactions_drawer.py` (3), e2e v764 étendu
|
||||
(réactions dans le tiroir + suppression A/B), e2e v762 (nouvelle ligne 4) ;
|
||||
`pytest` **1390 passed / 0 failed**, `ruff` OK ; `VERSION` +
|
||||
`app/main.py` = 7.67.0
|
||||
|
||||
### v7.68.0 — Colonnes rendues, tables IA, copier/couper/coller multi-blocs ✅ (2026-10-08)
|
||||
> **Objectif** : les colonnes (slash + Turn into) marchent, l'IA crée de vrais
|
||||
> tableaux, et une sélection multi-blocs se copie/coupe/colle. **COMPLETED**.
|
||||
|
||||
- [x] **Renderers morts branchés** — `renderBlock()` n'appelait jamais
|
||||
`renderColumnsBlock()` / `renderMathBlock()` / `renderTOC()` → bloc colonnes,
|
||||
équation et TOC rendaient un paragraphe vide (bug rapporté) ; × de
|
||||
suppression de colonne visible au survol (était invisible mais cliquable)
|
||||
- [x] **Tables IA** — `_tblIsData`/`_tblIsSep` acceptent le GFM **sans pipe de
|
||||
tête** (séparateur ≥2 cellules pour ne pas avaler « texte » + « --- ») ;
|
||||
`md2b` et `paste2b` alignés d'office
|
||||
- [x] **Coller** un seul bloc structuré (table/code/titre) = vraie insertion ;
|
||||
« Copy link to block » réparé (`link` → `copy-link`)
|
||||
- [x] **Copier/couper multi-blocs** — `_clipState()`/`_clipMarkdown()`/
|
||||
`clipCopy()`/`clipCut()`, listeners `copy`/`cut` (Ctrl+C/X), groupe
|
||||
**Clipboard** dans le menu du clic droit (sélection figée à l'ouverture),
|
||||
clic droit sur un bloc sélectionné = sélection multi-blocs conservée
|
||||
- [x] **Tests** — `tests/test_v768_columns_clipboard.py` (4), e2e
|
||||
`v768_columns_clipboard.spec.js` (4) ; `pytest` **1394 passed / 0 failed**,
|
||||
`ruff` OK ; `VERSION` + `app/main.py` = 7.68.0
|
||||
|
||||
### v7.69.0 — La sélection multi-blocs fonctionne enfin ✅ (2026-10-08)
|
||||
|
||||
**Contexte** : v7.68.0 livrait la mécanique Clipboard (plage multi-blocs, handlers
|
||||
`copy`/`cut`, groupe Clipboard du menu contextuel) mais elle était **inutilisable en
|
||||
pratique** — trois rapports d'usage : Ctrl+C ne copie rien, le glisser souris ne
|
||||
sélectionne qu'un bloc, le clic droit efface le surlignage.
|
||||
|
||||
**Causes racines trouvées (trace d'événements à l'appui, pas de supposition)** :
|
||||
|
||||
| Symptôme | Cause réelle |
|
||||
|----------|--------------|
|
||||
| Ctrl+C/X muet | le navigateur **n'émet aucun événement `copy`** quand la sélection traverse deux `contenteditable` : `getSelection().toString()` renvoie `""`, donc nos handlers n'étaient jamais atteints |
|
||||
| Glisser = 1 bloc | séquence `mouseup` → `click` → `focusin` → **collapse** : la plage reconstruite mourait dans la foulée |
|
||||
| Clic droit efface | `.bm-search` prenait le focus 10 ms après l'ouverture du menu |
|
||||
|
||||
**Corrections** : interception du raccourci au `keydown` (capture `document`,
|
||||
garde `closest('#_blocksCt')`) ; plage reconstruite mise en attente
|
||||
(`_mdSelRange`) et restaurée au tour suivant, remise à zéro à chaque nouveau
|
||||
geste ; le menu ne vole plus le focus quand un texte est sélectionné ;
|
||||
`preventDefault` du clic droit remonté sur `mousedown` (sur `contextmenu` c'est
|
||||
trop tard).
|
||||
|
||||
**Identité visuelle** : bannière + logo intégrés — WebP 22,5 Ko / 5,3 Ko (les
|
||||
originaux faisaient 1,6 Mo / 1,1 Mo), JPEG de secours, icônes PWA régénérées à
|
||||
partir du vrai logo, `<picture>` sur la landing, `og:image`/`twitter:card`.
|
||||
|
||||
**Vérification** : `tests/test_v769_selection_persistence.py` (19 statiques) +
|
||||
`e2e/v769_selection_persistence.spec.js` (5, **vrais gestes**) ; le spec v768 a
|
||||
été corrigé pour remplacer `execCommand` et les plages fabriquées par de vrais
|
||||
gestes.
|
||||
|
||||
**Piège de test relevé au passage** : les `···` dans les échecs Playwright sont
|
||||
le marqueur de troncature du message d'erreur, **pas** un bug produit — une heure
|
||||
passée à chercher une fuite inexistante. Vérifier `JSON.stringify()` de la valeur
|
||||
réelle avant de croire un affichage tronqué.
|
||||
|
||||
---
|
||||
|
||||
### v7.69.3 — Aide alignée sur Settings ✅ · double-clic close ⏳ ouvert (2026-10-08)
|
||||
|
||||
**Livré** : la section Aide partage maintenant le positionnement exact de la
|
||||
section Settings. `help.html` neutralisait son overlay en style inline
|
||||
(`position:static`, fond transparent, sans blur, sans padding) et forçait un
|
||||
panneau `1050px / 100vh-120px` — les deux pages pourtant identiques au CSS
|
||||
près produisaient deux rendus. Surcharges retirées ; e2e compare les
|
||||
géométries calculées : **identiques des deux côtés** (overlay `fixed` avec
|
||||
fond, panneau 1050×765 centré, close à 13/13 px du coin).
|
||||
|
||||
**Terminé en v7.69.4** — double-clic sur le bouton close de Settings.
|
||||
|
||||
Le diagnostic posé en v7.69.3 était **FAUX** : je pensais qu'Alpine ne liait
|
||||
pas le composant (`_x_dataStack` absent), donc que le handler était inerte.
|
||||
La question à l'utilisateur a tranché : *« après le premier clic la page est
|
||||
réactive »* → Alpine lie bien le composant, le handler s'exécute. Le scénario
|
||||
`target="_blank"` qui m'avait donné `_x_dataStack` absent était un artefact de
|
||||
mon propre banc d'essai, pas le parcours réel. **Ne pas relire ce diagnostic.**
|
||||
|
||||
Cause réelle : `closeSettings()` faisait `history.back()` puis
|
||||
`window.location.reload()` après un délai **fixe de 100 ms**. Entre les deux,
|
||||
le retour est une navigation asynchrone ; quand elle n'est pas terminée en
|
||||
100 ms, le `reload()` retombe sur `/settings` — le panneau « se ferme puis
|
||||
revient ». Depuis `/workspaces`, la profondeur d'historique diffère et le
|
||||
retour arrive à temps : d'où l'absence de symptôme sur ce seul parcours.
|
||||
|
||||
Correctif : `location.replace()` vers la page d'origine. Une seule navigation
|
||||
déterministe, ni `history.back()`, ni `popstate`, ni rechargement différé.
|
||||
Effet secondaire assumé et souhaitable : « Retour » ne rouvre plus Settings.
|
||||
|
||||
**Preuve avant/après (contrôle négatif fait)** : même spec, deux builds.
|
||||
- ancien `closeSettings()` → le test du parcours menu-utilisateur **ÉCHOUE**
|
||||
(`Received: "…/settings"`), celui de `/workspaces` passe ;
|
||||
- correctif → **les deux passent**.
|
||||
|
||||
**Piège relevé** : ma « réfutation » de la thèse de course était fausse. J'avais
|
||||
interposé 400 ms via `page.route()`, qui intercepte les requêtes de sous-ressources
|
||||
— pas la navigation du document lors d'un `history.back()`, servie depuis le
|
||||
bfcache. Le délai n'a donc jamais été appliqué. Un contrôle négatif (rebuild
|
||||
avec l'ancien code) aurait tranché en un tour ; il a fallu plusieurs tours pour
|
||||
le faire.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v7.45.5 (poubelle : sélection groupée + Empty Trash + tri/filtres réels + dates par élément ; fix CSRF restore/delete, fix 404 « Move to Trash » éditeur, fix pages restaurées invisibles, auth sur les routes trash) | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.69.4 (sélection multi-blocs fonctionnelle ; identité visuelle logo/bannière/favicon ; Aide alignée sur Settings ; close Settings réparé)
|
||||
> **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
|
||||
|
||||
@@ -11,6 +11,17 @@ from app.services.http_client import shared_client
|
||||
|
||||
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):
|
||||
"""Abstract OAuth2 provider interface."""
|
||||
@@ -52,7 +63,7 @@ class GiteaProvider(OAuthProvider):
|
||||
self.redirect_uri = redirect_uri
|
||||
|
||||
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:
|
||||
params = {
|
||||
@@ -144,7 +155,7 @@ class GitHubProvider(OAuthProvider):
|
||||
self.api_url = "https://api.github.com"
|
||||
|
||||
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:
|
||||
return (
|
||||
|
||||
+15
-2
@@ -13,6 +13,19 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_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:
|
||||
"""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.
|
||||
"""
|
||||
payload = {
|
||||
"user": user_data,
|
||||
"user": public_user(user_data),
|
||||
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||
}
|
||||
user_id = user_data.get("id")
|
||||
@@ -93,7 +106,7 @@ class SessionManager:
|
||||
"""Re-sign a cookie keeping its session id (used after profile edits)."""
|
||||
sid = SessionManager.session_id(cookie) if cookie else None
|
||||
payload = {
|
||||
"user": user_data,
|
||||
"user": public_user(user_data),
|
||||
"created_at": datetime.now(UTC).replace(tzinfo=None).isoformat(),
|
||||
}
|
||||
user_id = user_data.get("id")
|
||||
|
||||
@@ -126,6 +126,35 @@ class Settings(BaseSettings):
|
||||
agent_max_tokens_budget: int = 500000
|
||||
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
|
||||
|
||||
# ── Vues Notion : géocodage + plafonds (§8.2, §13.4, §20) ──
|
||||
GEOCODER_PROVIDER: str = "none" # none | nominatim | custom
|
||||
GEOCODER_ENDPOINT: str = "https://nominatim.openstreetmap.org"
|
||||
VIEW_ROWS_MAX: int = 1000
|
||||
MAP_POINTS_MAX: int = 100
|
||||
TIMELINE_LOAD_MAX: int = 200
|
||||
CHART_MAX_SUBGROUPS: int = 50
|
||||
|
||||
@property
|
||||
def db_path(self) -> Path:
|
||||
if self.database_url == "sqlite:///:memory:":
|
||||
|
||||
@@ -833,6 +833,17 @@ def init_db():
|
||||
from app.migrations import apply_migrations
|
||||
apply_migrations(conn)
|
||||
|
||||
# ── Templates unifiés (§9.5, §12) : backfill idempotent des silos
|
||||
# legacy + presets système. Non bloquant : un échec laisse les silos
|
||||
# legacy servir leurs anciennes surfaces (dégradation, pas panne).
|
||||
try:
|
||||
from app.services.fd_templates import ensure_registry, reset_registry_cache
|
||||
reset_registry_cache()
|
||||
ensure_registry(conn)
|
||||
except Exception:
|
||||
import logging as _logging
|
||||
_logging.getLogger(__name__).exception("ensure_registry templates")
|
||||
|
||||
# Webhook subscriptions (v2.1.0) — created here (idempotent) so the full
|
||||
# schema exists without depending on the FastAPI lifespan startup.
|
||||
from app.services.webhook_outbound import init_webhook_tables
|
||||
|
||||
+21
-6
@@ -5,7 +5,7 @@ import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.exceptions import HTTPException as _StarHTTPException
|
||||
@@ -44,6 +44,7 @@ from app.routers.audit import router as audit_router
|
||||
from app.routers.automations import router as automations_router
|
||||
from app.routers.collaboration import router as collaboration_router
|
||||
from app.routers.emoji import router as emoji_router
|
||||
from app.routers.fd_templates import router as fd_templates_router
|
||||
from app.routers.gitea import router as gitea_router
|
||||
from app.routers.github_routes import router as github_router
|
||||
from app.routers.governance import router as governance_router
|
||||
@@ -62,6 +63,7 @@ from app.routers.web_clipper import router as web_clipper_router
|
||||
from app.routers.webauthn import router as webauthn_router
|
||||
from app.routers.wiki import router as wiki_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
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -153,6 +155,10 @@ async def lifespan(_app: FastAPI):
|
||||
from app.services.reminders import reminder_scheduler
|
||||
reminder_task = _spawn("reminder_scheduler", reminder_scheduler)
|
||||
|
||||
# ── Templates récurrents (§11.2) : scan 60 s dédupliqué ──
|
||||
from app.services.fd_templates import template_recurrence_scheduler
|
||||
template_rec_task = _spawn("template_recurrence_scheduler", template_recurrence_scheduler)
|
||||
|
||||
# ── Semantic search (v6.9.0): incremental vector indexing ──
|
||||
from app.services.semantic_search import semantic_index_scheduler
|
||||
semantic_task = _spawn("semantic_index_scheduler", semantic_index_scheduler)
|
||||
@@ -171,7 +177,7 @@ async def lifespan(_app: FastAPI):
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_tasks = (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task, semantic_task, calendar_task)
|
||||
_tasks = (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task, template_rec_task, semantic_task, calendar_task)
|
||||
if webhook_task is not None:
|
||||
_tasks = _tasks + (webhook_task,)
|
||||
for task in _tasks:
|
||||
@@ -185,7 +191,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.45.5",
|
||||
version="7.71.5",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
@@ -224,6 +230,8 @@ app.include_router(webhooks.router)
|
||||
app.include_router(collections.router)
|
||||
app.include_router(my_tasks.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(admin.router)
|
||||
app.include_router(gitea_router)
|
||||
@@ -233,7 +241,9 @@ app.include_router(sharing.router)
|
||||
app.include_router(sidebar_config.router)
|
||||
app.include_router(export.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(emoji_router)
|
||||
app.include_router(realtime_router)
|
||||
@@ -245,8 +255,11 @@ app.include_router(sync.router)
|
||||
app.include_router(imports_router)
|
||||
app.include_router(import_page_router)
|
||||
app.include_router(permissions_router)
|
||||
app.include_router(web_clipper_api_router)
|
||||
app.include_router(web_clipper_router)
|
||||
# Plugin « web-clipper » OFF → page /extensions + API clip refusées
|
||||
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_agent_router)
|
||||
app.include_router(sites_router)
|
||||
@@ -260,6 +273,8 @@ app.include_router(audit_router)
|
||||
app.include_router(governance_router)
|
||||
# v7.3.0 — teamspaces + verified wiki
|
||||
app.include_router(wiki_router)
|
||||
# Templates unifiés façon Notion (registre + gestionnaire + API interne)
|
||||
app.include_router(fd_templates_router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
@@ -20,16 +20,54 @@ ALLOWED_EXTENSIONS: frozenset[str] = frozenset({
|
||||
# Images
|
||||
".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico",
|
||||
# Documents
|
||||
".pdf", ".md", ".markdown", ".txt", ".log", ".csv",
|
||||
# Code
|
||||
".py", ".js", ".jsx", ".ts", ".tsx", ".html", ".htm", ".xml", ".css",
|
||||
".json", ".yaml", ".yml", ".toml", ".sql", ".sh", ".bash", ".zsh",
|
||||
".ps1", ".rs", ".go", ".java", ".rb", ".php", ".c", ".cpp", ".h",
|
||||
".swift", ".kt", ".scala", ".r",
|
||||
".pdf", ".md", ".markdown", ".mkd", ".mdown", ".mdwn", ".rmd", ".qmd",
|
||||
".txt", ".log", ".csv", ".tsv", ".tab", ".nfo", ".asc",
|
||||
".tex", ".latex", ".sty", ".cls", ".dtx", ".bib", ".bbl",
|
||||
# Code — kept in sync with app/services/export.py::_TEXTUAL_EXTS
|
||||
".py", ".pyw", ".pyi", ".gyp", ".gypi",
|
||||
".js", ".mjs", ".cjs", ".jsx", ".ts", ".mts", ".cts", ".tsx",
|
||||
".html", ".htm", ".xhtml", ".xht", ".xml", ".xsd", ".xsl", ".xslt",
|
||||
".rss", ".atom", ".plist", ".wsf", ".axml",
|
||||
".vue", ".svelte", ".astro",
|
||||
".css", ".scss", ".sass", ".less",
|
||||
".json", ".json5", ".jsonc", ".geojson", ".webmanifest",
|
||||
".har", ".avsc",
|
||||
".yaml", ".yml", ".toml", ".ini", ".cfg", ".conf", ".env",
|
||||
".properties", ".prop", ".inf", ".reg", ".url",
|
||||
".gitconfig", ".gitmodules", ".editorconfig",
|
||||
".sql", ".pgsql", ".psql", ".mysql", ".mssql",
|
||||
".sh", ".bash", ".zsh", ".ksh", ".mksh", ".yash", ".fish",
|
||||
".ps1", ".psm1", ".psd1", ".bat", ".cmd",
|
||||
".rb", ".gemspec", ".rake", ".rbw",
|
||||
".rs", ".go", ".java", ".jsp", ".kt", ".kts",
|
||||
".scala", ".sc", ".groovy", ".gvy", ".gradle",
|
||||
".c", ".cc", ".cpp", ".c++", ".cxx", ".hh", ".hxx", ".hcc", ".h", ".hpp",
|
||||
".cs", ".csx", ".cake", ".ino", ".pde",
|
||||
".php", ".phtml", ".swift", ".r", ".jl", ".lua", ".dart",
|
||||
".pl", ".pm", ".pod",
|
||||
".hs", ".lhs", ".ex", ".exs", ".eex", ".heex", ".leex",
|
||||
".erl", ".hrl", ".escript",
|
||||
".clj", ".cljs", ".cljc", ".edn",
|
||||
".ml", ".mli", ".fs", ".fsi", ".fsx", ".fsscript",
|
||||
".nim", ".nims", ".d", ".di",
|
||||
".m", ".mm", ".vb", ".vbs", ".bas", ".pas", ".pp", ".dpr",
|
||||
".vhd", ".vhdl", ".v", ".vh", ".sv", ".svh",
|
||||
".asm", ".s", ".nasm", ".inc",
|
||||
".ada", ".ads", ".adb",
|
||||
".f", ".for", ".f90", ".f95", ".f03", ".f08",
|
||||
".cob", ".cbl", ".cpy",
|
||||
".dockerfile", ".containerfile",
|
||||
".tf", ".tfvars", ".hcl",
|
||||
".cmake", ".makefile", ".mk", ".mak", ".ninja",
|
||||
".gql", ".graphql", ".graphqls", ".proto", ".thrift",
|
||||
".vim", ".vimrc", ".gvimrc", ".awk",
|
||||
".gcode", ".gco", ".nc", ".ngc",
|
||||
".http", ".rest", ".nginx", ".apache", ".htaccess", ".wat",
|
||||
".diff", ".patch", ".rej",
|
||||
".mod", ".sum", ".lock", ".text",
|
||||
".gitignore", ".gitattributes", ".gitkeep", ".dockerignore", ".npmignore",
|
||||
# Archives
|
||||
".zip", ".tar", ".gz", ".rar", ".7z",
|
||||
# Misc
|
||||
".env", ".cfg", ".conf", ".ini", ".dockerfile", ".makefile",
|
||||
})
|
||||
|
||||
MAX_UPLOAD_SIZE = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
@@ -1553,3 +1553,475 @@ def _migration_sso_enterprise_auth(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"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,
|
||||
)
|
||||
|
||||
|
||||
@register(38, "v7.1.1: meeting block Notion — états, segments, consentement, partage")
|
||||
def _migration_meeting_notion_block(conn: sqlite3.Connection) -> None:
|
||||
"""v7.1.1 — refonte Meetings façon Notion (§3A/3B du doc d'architecture).
|
||||
|
||||
``meeting_transcripts`` gagne la machine à états du bloc
|
||||
(idle/recording/paused/processing/done/failed), les segments groupés par
|
||||
source, la photo d'instructions, le journal de consentement résumé, les
|
||||
étapes Thinking persistées, le résumé structuré + titre généré + classe de
|
||||
complétude. Deux tables append-only : consentement et diffusions.
|
||||
Règle d'unicité : une occurrence calendrier = au plus une note.
|
||||
"""
|
||||
cols = columns(conn, "meeting_transcripts")
|
||||
for ddl in (
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN status TEXT NOT NULL DEFAULT 'idle'",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN mode TEXT NOT NULL DEFAULT 'mic_only'",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN channels_json TEXT NOT NULL DEFAULT '[]'",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN instruction_id TEXT NOT NULL DEFAULT 'auto'",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN instruction_snapshot TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN consent_json TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN segments_json TEXT NOT NULL DEFAULT '[]'",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN processing_steps_json TEXT NOT NULL DEFAULT '[]'",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN summary_json TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN generated_title TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN title_source TEXT NOT NULL DEFAULT 'user'",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN completeness_class TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN quality_score REAL",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN duration_ms INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN paused_ms INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN started_at TIMESTAMP",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN stopped_at TIMESTAMP",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN event_occurrence_id TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN notes_text TEXT NOT NULL DEFAULT ''",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN banner_dismissed INTEGER NOT NULL DEFAULT 0",
|
||||
"ALTER TABLE meeting_transcripts ADD COLUMN share_bar_dismissed INTEGER NOT NULL DEFAULT 0",
|
||||
):
|
||||
name = ddl.split("ADD COLUMN")[1].strip().split()[0]
|
||||
if name not in cols:
|
||||
conn.execute(ddl)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS meeting_consent_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transcript_id INTEGER NOT NULL REFERENCES meeting_transcripts(id) ON DELETE CASCADE,
|
||||
method TEXT NOT NULL DEFAULT 'start_attestation',
|
||||
message_ref TEXT NOT NULL DEFAULT '',
|
||||
attested_by INTEGER REFERENCES users(id),
|
||||
participants_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS meeting_distributions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
transcript_id INTEGER NOT NULL REFERENCES meeting_transcripts(id) ON DELETE CASCADE,
|
||||
channel TEXT NOT NULL DEFAULT 'copy_link',
|
||||
actor_id INTEGER REFERENCES users(id),
|
||||
recipients_json TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'created',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_meeting_consent_tr ON meeting_consent_log(transcript_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_meeting_distr_tr ON meeting_distributions(transcript_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS ux_meeting_one_per_occurrence "
|
||||
"ON meeting_transcripts(event_occurrence_id) "
|
||||
"WHERE event_occurrence_id <> ''"
|
||||
)
|
||||
|
||||
|
||||
# ── Vues Notion §10 : migrations 44 à 47 (après 39-43 réservées Agents&Skills) ──
|
||||
|
||||
@register(44, "vues-notion: view_config_v2 (config_version + index view_type)")
|
||||
def _migration_view_config_v2(conn: sqlite3.Connection) -> None:
|
||||
"""Migration 44 — versionnage des config_json de vues (phase 1, Chart)."""
|
||||
cols = columns(conn, "collection_views")
|
||||
if "config_version" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collection_views ADD COLUMN config_version INTEGER NOT NULL DEFAULT 1"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_collection_views_type "
|
||||
"ON collection_views(collection_id, view_type)"
|
||||
)
|
||||
|
||||
|
||||
@register(45, "vues-notion: place_geocode_cache")
|
||||
def _migration_place_geocode_cache(conn: sqlite3.Connection) -> None:
|
||||
"""Migration 45 — cache partage du Geocoding Service (phase 3, Map)."""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS geocode_cache (
|
||||
query_hash TEXT PRIMARY KEY,
|
||||
provider TEXT NOT NULL DEFAULT 'nominatim',
|
||||
query_text TEXT NOT NULL DEFAULT '',
|
||||
result_json TEXT NOT NULL DEFAULT '{}',
|
||||
lat REAL,
|
||||
lng REAL,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
hit_count INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_geocode_cache_created ON geocode_cache(created_at)"
|
||||
)
|
||||
|
||||
|
||||
@register(46, "vues-notion: dashboard_widgets")
|
||||
def _migration_dashboard_widgets(conn: sqlite3.Connection) -> None:
|
||||
"""Migration 46 — widgets de la vue Dashboard (phase 4)."""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS dashboard_widgets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
view_id INTEGER NOT NULL REFERENCES collection_views(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
row_index INTEGER NOT NULL DEFAULT 0,
|
||||
col_span INTEGER NOT NULL DEFAULT 1,
|
||||
height_units INTEGER NOT NULL DEFAULT 2,
|
||||
source_collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL,
|
||||
source_view_id INTEGER REFERENCES collection_views(id) ON DELETE SET NULL,
|
||||
inline_config_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_view "
|
||||
"ON dashboard_widgets(view_id, row_index, position)"
|
||||
)
|
||||
|
||||
|
||||
@register(47, "vues-notion: form_questions_v2")
|
||||
def _migration_form_questions_v2(conn: sqlite3.Connection) -> None:
|
||||
"""Migration 47 — questions du Form builder + repondant (phase 5)."""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS form_questions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
view_id INTEGER NOT NULL REFERENCES collection_views(id) ON DELETE CASCADE,
|
||||
property_name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
label_override TEXT,
|
||||
sync_label INTEGER NOT NULL DEFAULT 1,
|
||||
help_text TEXT NOT NULL DEFAULT '',
|
||||
required INTEGER NOT NULL DEFAULT 0,
|
||||
widget TEXT NOT NULL DEFAULT 'auto',
|
||||
max_selections INTEGER,
|
||||
conditional_json TEXT,
|
||||
UNIQUE(view_id, property_name)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_form_questions_view "
|
||||
"ON form_questions(view_id, position)"
|
||||
)
|
||||
for table, ddl, col in (
|
||||
("form_responses", "ALTER TABLE form_responses ADD COLUMN view_id INTEGER", "view_id"),
|
||||
("form_responses", "ALTER TABLE form_responses ADD COLUMN respondent_user_id INTEGER REFERENCES users(id)", "respondent_user_id"),
|
||||
("form_responses", "ALTER TABLE form_responses ADD COLUMN anonymous INTEGER NOT NULL DEFAULT 0", "anonymous"),
|
||||
):
|
||||
try:
|
||||
existing = columns(conn, table)
|
||||
except Exception:
|
||||
continue
|
||||
if col not in existing:
|
||||
try:
|
||||
conn.execute(ddl)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Templates Notion §9 : migrations 48 à 49 (registre unifié + journal) ──
|
||||
|
||||
@register(48, "templates-notion: registre unifié `templates`")
|
||||
def _migration_templates_registry(conn: sqlite3.Connection) -> None:
|
||||
"""Migration 48 — registre unifié des templates (phase 1).
|
||||
|
||||
Catalogue les trois silos legacy (``database_templates``,
|
||||
``page_templates``, ``page_global_templates``) SANS les détruire :
|
||||
colonnes ``source_kind``/``source_id`` + backfill idempotent côté
|
||||
service (``TemplateService.backfill_registry``), rejouable au boot.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id INTEGER NOT NULL DEFAULT 1,
|
||||
teamspace_id INTEGER,
|
||||
kind TEXT NOT NULL DEFAULT 'page'
|
||||
CHECK (kind IN ('page','row','database','blocks')),
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
icon TEXT NOT NULL DEFAULT '📄',
|
||||
cover_url TEXT NOT NULL DEFAULT '',
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
scope TEXT NOT NULL DEFAULT 'personal'
|
||||
CHECK (scope IN ('personal','workspace','teamspace','system')),
|
||||
owner_id INTEGER,
|
||||
target_collection_id INTEGER,
|
||||
content_page_id INTEGER,
|
||||
source_kind TEXT NOT NULL DEFAULT 'native'
|
||||
CHECK (source_kind IN ('native','legacy_database','legacy_page','legacy_global')),
|
||||
source_id TEXT NOT NULL DEFAULT '',
|
||||
manifest_json TEXT NOT NULL DEFAULT '{}',
|
||||
variables_json TEXT NOT NULL DEFAULT '[]',
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_by INTEGER,
|
||||
created_at TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT '',
|
||||
archived_at TEXT,
|
||||
deleted_at TEXT,
|
||||
sync_version INTEGER NOT NULL DEFAULT 1
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_templates_ws_kind "
|
||||
"ON templates(workspace_id, kind)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_templates_target "
|
||||
"ON templates(target_collection_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_templates_source "
|
||||
"ON templates(source_kind, source_id)"
|
||||
)
|
||||
# Un seul défaut actif par base (les lignes soft-deleted/archivées ne comptent pas).
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_templates_default "
|
||||
"ON templates(target_collection_id) "
|
||||
"WHERE is_default=1 AND deleted_at IS NULL AND archived_at IS NULL"
|
||||
)
|
||||
|
||||
|
||||
@register(49, "templates-notion: récurrences + journal `template_runs`")
|
||||
def _migration_template_runs(conn: sqlite3.Connection) -> None:
|
||||
"""Migration 49 — récurrences des templates de ligne + journal des
|
||||
instanciations (compteurs du gestionnaire, dédup scheduler, audit)."""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS template_recurrences (
|
||||
template_id TEXT PRIMARY KEY REFERENCES templates(id) ON DELETE CASCADE,
|
||||
rrule TEXT NOT NULL DEFAULT '',
|
||||
timezone TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
next_run_at TEXT,
|
||||
last_run_at TEXT,
|
||||
end_count INTEGER,
|
||||
updated_by INTEGER,
|
||||
updated_at TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS template_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
template_id TEXT NOT NULL REFERENCES templates(id) ON DELETE CASCADE,
|
||||
run_source TEXT NOT NULL DEFAULT 'ui'
|
||||
CHECK (run_source IN ('ui','api','agent','automation','recurrence')),
|
||||
actor_id INTEGER,
|
||||
occurrence_key TEXT,
|
||||
created_type TEXT NOT NULL DEFAULT '',
|
||||
created_id TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'ok'
|
||||
CHECK (status IN ('ok','error','undone')),
|
||||
warnings_json TEXT NOT NULL DEFAULT '[]',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
idempotency_key TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_template_runs_occurrence "
|
||||
"ON template_runs(occurrence_key) WHERE occurrence_key IS NOT NULL"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS uq_template_runs_idem "
|
||||
"ON template_runs(idempotency_key) WHERE idempotency_key IS NOT NULL"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_template_runs_tpl "
|
||||
"ON template_runs(template_id, created_at)"
|
||||
)
|
||||
|
||||
@@ -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)):
|
||||
"""Delete a user and cascade their data."""
|
||||
from app.db import get_conn
|
||||
from app.services.collection_lifecycle import (
|
||||
delete_collections,
|
||||
workspace_collection_ids,
|
||||
)
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not user:
|
||||
@@ -121,6 +125,9 @@ def delete_user(user_id: int, _admin=Depends(admin_required)):
|
||||
# Delete workspaces owned by this user
|
||||
ws_rows = conn.execute("SELECT id FROM workspaces WHERE owner_id=?", (user_id,)).fetchall()
|
||||
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 workspace_members WHERE workspace_id=?", (ws["id"],))
|
||||
conn.execute("DELETE FROM workspaces WHERE owner_id=?", (user_id,))
|
||||
|
||||
+237
-6
@@ -7,15 +7,17 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import UTC
|
||||
from urllib.parse import urlparse
|
||||
|
||||
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.config import settings
|
||||
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.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient
|
||||
from app.services.llm_config import (
|
||||
@@ -199,15 +201,17 @@ def create_conversation(request: Request, body: dict = Body(default={})):
|
||||
ws = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
agent = _default_agent(conn, user_id)
|
||||
mem_default = 1 if settings.agent_memory_default else 0
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json, provider, model)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json, provider, model, memory_enabled)
|
||||
VALUES (?,?,?,?,?,?,?)""",
|
||||
(agent["id"], user_id, body.get("title", "New conversation"),
|
||||
json.dumps({"workspace_id": ws}),
|
||||
body.get("provider", ""), body.get("model", "")),
|
||||
body.get("provider", ""), body.get("model", ""), mem_default),
|
||||
)
|
||||
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}")
|
||||
@@ -249,6 +253,10 @@ def patch_conversation(request: Request, conversation_id: int, body: dict = Body
|
||||
if body.get(col) is not None:
|
||||
sets.append(f"{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)
|
||||
conn.execute(f"UPDATE agent_conversations SET {', '.join(sets)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
@@ -498,6 +506,43 @@ def create_skill(request: Request, body: dict = Body(default={})):
|
||||
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")
|
||||
def apply_skill(request: Request, skill_id: int):
|
||||
"""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"}
|
||||
|
||||
|
||||
# ── 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 👍 / 👎) ──
|
||||
|
||||
|
||||
|
||||
@@ -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 run_event_sync
|
||||
from app.services.collection_lifecycle import delete_collections
|
||||
|
||||
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()
|
||||
if not row:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
|
||||
delete_collections(conn, [collection_id])
|
||||
conn.commit()
|
||||
audit_log(user, "collection.delete", "collection", collection_id, "", request)
|
||||
return {"id": collection_id, "status": "deleted"}
|
||||
|
||||
@@ -203,3 +203,144 @@ async def import_csv_v2(collection_id: int, request: Request, authorization: str
|
||||
created += 1
|
||||
conn.commit()
|
||||
return {"imported": created, "status": "ok"}
|
||||
|
||||
|
||||
# ── Registre unifié (§10.2) : liste / détail / instantiate / export / import ──
|
||||
# (chemins /fd-templates/* : les /templates/* historiques restent inchangés)
|
||||
|
||||
def _v2_actor(user) -> dict:
|
||||
return {"id": user.get("id"), "login": user.get("login", ""),
|
||||
"full_name": user.get("full_name", ""),
|
||||
"is_admin": bool(user.get("is_admin"))}
|
||||
|
||||
|
||||
@router.get("/fd-templates")
|
||||
def list_fd_templates_v2(request: Request, authorization: str | None = Header(default=None),
|
||||
kind: str | None = None, q: str | None = None):
|
||||
from app.services.fd_templates import TemplateService
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
items = TemplateService.list_templates(conn, _v2_actor(user), kind=kind, query=q)
|
||||
return {"templates": items}
|
||||
|
||||
|
||||
@router.get("/fd-templates/{template_id}")
|
||||
def get_fd_template_v2(template_id: str, request: Request, authorization: str | None = Header(default=None)):
|
||||
from app.services.fd_templates import TemplateService, _row_to_dto
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
row = TemplateService.get_template(conn, _v2_actor(user), template_id)
|
||||
return _row_to_dto(conn, row)
|
||||
except LookupError:
|
||||
raise HTTPException(404, "Template not found")
|
||||
except PermissionError:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
|
||||
|
||||
@router.post("/fd-templates/{template_id}/instantiate")
|
||||
def instantiate_fd_template_v2(template_id: str, request: Request,
|
||||
authorization: str | None = Header(default=None),
|
||||
body: dict = Body(default={})):
|
||||
from app.services.fd_templates import TemplateService
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
idem = request.headers.get("Idempotency-Key") or body.get("idempotency_key")
|
||||
if idem:
|
||||
hit = check_idempotency(request, user["id"])
|
||||
if hit:
|
||||
return hit["data"]
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
result = TemplateService.instantiate(
|
||||
conn, _v2_actor(user), template_id, body.get("variables") or {},
|
||||
body.get("context") or {}, idempotency_key=idem, run_source="api")
|
||||
except LookupError:
|
||||
raise HTTPException(404, "Template not found")
|
||||
except PermissionError:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc))
|
||||
audit_log(user, "template.instantiate", "template", template_id, "")
|
||||
if idem:
|
||||
store_idempotency(idem, user["id"], result, 200)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/fd-templates/{template_id}/export")
|
||||
def export_fd_template_v2(template_id: str, request: Request, authorization: str | None = Header(default=None)):
|
||||
"""Export au format manifeste flowdeck-template v1 (annexe B)."""
|
||||
from app.services.fd_templates import TemplateService, _read_holder_blocks, _row_to_dto
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
row = TemplateService.get_template(conn, _v2_actor(user), template_id)
|
||||
dto = _row_to_dto(conn, row)
|
||||
blocks, _w = _read_holder_blocks(conn, row)
|
||||
except LookupError:
|
||||
raise HTTPException(404, "Template not found")
|
||||
except PermissionError:
|
||||
raise HTTPException(403, "Forbidden")
|
||||
manifest = {
|
||||
"format": "flowdeck-template", "version": 1, "kind": dto["kind"],
|
||||
"name": dto["name"], "description": dto["description"], "icon": dto["icon"],
|
||||
"category": dto["category"], "variables": dto["variables"],
|
||||
"manifest": dto["manifest"], "content": {"content_format": "blocks", "blocks": blocks},
|
||||
}
|
||||
if dto.get("recurrence"):
|
||||
manifest["recurrence"] = {"rrule": dto["recurrence"].get("rrule"),
|
||||
"timezone": dto["recurrence"].get("timezone", "")}
|
||||
return manifest
|
||||
|
||||
|
||||
@router.post("/fd-templates/import")
|
||||
def import_fd_template_v2(request: Request, authorization: str | None = Header(default=None),
|
||||
body: dict = Body(default={})):
|
||||
"""Import d'un manifeste flowdeck-template v1 (scope personnel, §14)."""
|
||||
from app.services.fd_templates import KINDS, TemplateService
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if body.get("format") != "flowdeck-template":
|
||||
raise HTTPException(400, "format must be flowdeck-template")
|
||||
try:
|
||||
version = int(body.get("version") or 0)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "version invalide")
|
||||
if version > 1:
|
||||
raise HTTPException(400, "version de manifeste non supportée")
|
||||
kind = body.get("kind") or "page"
|
||||
if kind not in KINDS:
|
||||
raise HTTPException(400, "kind inconnu")
|
||||
blocks = ((body.get("content") or {}).get("blocks") or [])
|
||||
if not isinstance(blocks, list) or len(blocks) > 2000:
|
||||
raise HTTPException(400, "content.blocks invalide ou trop volumineux")
|
||||
target = body.get("target") or {}
|
||||
spec = {"kind": kind, "name": (body.get("name") or "Sans titre")[:200],
|
||||
"description": (body.get("description") or "")[:2000],
|
||||
"icon": body.get("icon") or "📄", "category": (body.get("category") or "")[:120],
|
||||
"scope": "personal", "blocks": blocks,
|
||||
"manifest": body.get("manifest") or {}, "variables": body.get("variables") or [],
|
||||
"title_template": (body.get("manifest") or {}).get("title_template", "")}
|
||||
if kind == "row":
|
||||
# La base cible est résolue par nom dans le workspace, jamais par id
|
||||
# externe ; si absente, le template est créé « orphelin » et signalé.
|
||||
cname = target.get("collection_name", "")
|
||||
with get_conn() as conn:
|
||||
actor = _v2_actor(user)
|
||||
row = conn.execute("SELECT id FROM collections WHERE name=?", (cname,)).fetchone() if cname else None
|
||||
if row:
|
||||
spec["target_collection_id"] = row["id"]
|
||||
dto = TemplateService.create_template(conn, actor, spec)
|
||||
conn.commit()
|
||||
return {"id": dto["id"], "status": "imported"}
|
||||
# Orphelin personnel : on choisit la première base comme pis-aller ? Non :
|
||||
# le contrat exige un signalement — on refuse proprement.
|
||||
raise HTTPException(400, "collection cible introuvable : match_by=collection_name")
|
||||
with get_conn() as conn:
|
||||
dto = TemplateService.create_template(conn, _v2_actor(user), spec)
|
||||
conn.commit()
|
||||
audit_log(user, "template.import", "template", dto["id"], "")
|
||||
return {"id": dto["id"], "status": "imported"}
|
||||
|
||||
@@ -156,7 +156,10 @@ async def create_agent_v2(request: Request, authorization: str | None = Header(d
|
||||
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)):
|
||||
_guard(request, authorization)
|
||||
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)
|
||||
|
||||
|
||||
@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)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
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"}
|
||||
|
||||
|
||||
@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)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
|
||||
+2
-1
@@ -475,10 +475,11 @@ def logout(request: Request):
|
||||
def current_user(request: Request):
|
||||
"""Return current user info as JSON."""
|
||||
from app.auth.session import get_current_user as gcu
|
||||
from app.auth.session import public_user
|
||||
user = gcu(request)
|
||||
if not user:
|
||||
return {"authenticated": False}
|
||||
return {"authenticated": True, "user": user}
|
||||
return {"authenticated": True, "user": public_user(user)}
|
||||
|
||||
|
||||
# ── v7.2.0 — TOTP 2FA ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -252,7 +252,12 @@ def _file_icon(name: str, content_format: str = "") -> str:
|
||||
|
||||
|
||||
def _load_workspace_pages(ws_cookie: str) -> list:
|
||||
"""Load top-level pages with children for the active workspace."""
|
||||
"""Load top-level pages with children for the active workspace.
|
||||
|
||||
v7.1.1 : les pages contenant un bloc AI Meeting Notes sont des notes de
|
||||
réunion — elles vivent dans la section Meetings de la sidebar
|
||||
(``GET /api/v2/meetings/notes``), pas dans l'arbre Workspace.
|
||||
"""
|
||||
if not ws_cookie:
|
||||
return []
|
||||
try:
|
||||
@@ -261,7 +266,8 @@ def _load_workspace_pages(ws_cookie: str) -> list:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format, "
|
||||
"is_shared, share_mode, COALESCE(published,0) AS published "
|
||||
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY created_at DESC",
|
||||
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL AND collection_row_id IS NULL "
|
||||
"AND REPLACE(COALESCE(content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%' ORDER BY created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
items = []
|
||||
@@ -289,12 +295,13 @@ def _load_workspace_pages(ws_cookie: str) -> list:
|
||||
|
||||
|
||||
def _load_children(parent_id: int) -> list:
|
||||
"""Recursively load children of a page."""
|
||||
"""Recursively load children of a page (meeting notes excluded, § ci-dessus)."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format, "
|
||||
"is_shared, share_mode, COALESCE(published,0) AS published "
|
||||
"FROM pages WHERE parent_id=? AND deleted_at IS NULL ORDER BY created_at",
|
||||
"FROM pages WHERE parent_id=? AND deleted_at IS NULL "
|
||||
"AND REPLACE(COALESCE(content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%' ORDER BY created_at",
|
||||
(parent_id,),
|
||||
).fetchall()
|
||||
children = []
|
||||
@@ -382,7 +389,7 @@ def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]:
|
||||
).fetchall()
|
||||
published_rows = conn.execute(
|
||||
f"SELECT id, title, workspace, updated_at FROM pages "
|
||||
f"WHERE published=1 AND deleted_at IS NULL AND {scope_cond} "
|
||||
f"WHERE (published=1 OR is_published=1) AND deleted_at IS NULL AND {scope_cond} "
|
||||
f"ORDER BY updated_at DESC LIMIT 20",
|
||||
scope_params,
|
||||
).fetchall()
|
||||
@@ -449,10 +456,10 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
has_active_workspace = False
|
||||
local_ws_id = 0
|
||||
|
||||
if ws_cookie and ws_cookie.startswith("gitea:"):
|
||||
# Gitea workspace: preserve context across pages. Also load the local
|
||||
# mirror workspace so it appears in "My Workspaces" in the top section
|
||||
# of the sidebar, in parallel with the Gitea repository tree.
|
||||
if ws_cookie and (ws_cookie.startswith("gitea:") or ws_cookie.startswith("github:")):
|
||||
# Forge workspace (Gitea/GitHub): preserve context across pages. Also
|
||||
# load the local mirror workspace so it appears in Workspace in
|
||||
# parallel with the Repository tree (same navigation as workspace-local).
|
||||
parts = ws_cookie.split(":", 2)
|
||||
if len(parts) >= 3:
|
||||
gitea_owner = parts[1]
|
||||
@@ -465,8 +472,9 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
|
||||
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
|
||||
"SELECT id FROM workspaces WHERE owner_id=? AND name=? "
|
||||
"AND (settings_json LIKE '%gitea_repo%' OR settings_json LIKE '%github_repo%')",
|
||||
(user["id"], f"{gitea_owner}/{gitea_repo}"),
|
||||
).fetchone()
|
||||
if row:
|
||||
local_ws_id = row["id"]
|
||||
@@ -485,6 +493,10 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
active_ws_name = row["name"]
|
||||
workspace_pages = _load_workspace_pages(ws_cookie)
|
||||
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):
|
||||
pass
|
||||
recent = []
|
||||
@@ -517,17 +529,21 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
fav_rows = conn.execute(
|
||||
"SELECT p.id, p.title, p.workspace, p.updated_at FROM favorites f "
|
||||
"SELECT p.id, p.title, p.workspace, p.updated_at, p.parent_section, "
|
||||
"p.content_format FROM favorites f "
|
||||
"JOIN pages p ON p.id = f.page_id "
|
||||
"WHERE f.user_id=? ORDER BY f.position", (uid,)
|
||||
"WHERE f.user_id=? AND p.deleted_at IS NULL ORDER BY f.position", (uid,)
|
||||
).fetchall()
|
||||
favorites = []
|
||||
for r in fav_rows:
|
||||
# v7.69.6 : mêmes icônes que le Workspace (noms SVG, pas d'emoji).
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
favorites.append({
|
||||
"id": f"page/{r['id']}",
|
||||
"db_id": r["id"],
|
||||
"name": r["title"] or "New page",
|
||||
"icon": "📄",
|
||||
"icon": "folder" if is_folder else _file_icon(
|
||||
r["title"] or "", r["content_format"]),
|
||||
"url": f"/pages/{r['id']}",
|
||||
"active": False,
|
||||
"indent": 0,
|
||||
|
||||
@@ -34,8 +34,19 @@ router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
||||
def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
|
||||
from app.auth.session import SessionManager
|
||||
from app.routers.dashboard._common import _cleanup_empty_mirrors, _ensure_forge_mirror
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
# Selecting a repository also opens a workspace-local of the same name:
|
||||
# ensure the mirror exists, prune other empty mirrors.
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if user and owner and repo:
|
||||
try:
|
||||
mid = _ensure_forge_mirror(user["id"], owner, repo, forge="gitea")
|
||||
_cleanup_empty_mirrors(user["id"], keep_name=f"{owner}/{repo}", keep_id=mid or 0)
|
||||
except Exception:
|
||||
logger.exception("board mirror ensure")
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
template = env.get_template("board.html")
|
||||
return template.render(request=request, owner=owner, repo=repo, groups=[],
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@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 ──
|
||||
|
||||
@@ -12,6 +12,11 @@ from fastapi.responses import HTMLResponse
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
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 ._common import _sidebar_data
|
||||
@@ -90,9 +95,22 @@ def update_share(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
mode = body.get("mode", "private")
|
||||
published = body.get("published", False)
|
||||
with get_conn() as conn:
|
||||
# v7.69.6 : les deux drapeaux ensemble (voir services/publish), plus
|
||||
# un slug garanti quand on publie depuis le share dialog.
|
||||
from app.services.publish import _unique_slug
|
||||
row = conn.execute(
|
||||
"SELECT publish_slug FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
slug = (row["publish_slug"] if row and row["publish_slug"] else "")
|
||||
if published and not slug:
|
||||
title_row = conn.execute(
|
||||
"SELECT title FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
slug = _unique_slug(
|
||||
conn, page_id, title_row["title"] if title_row else "")
|
||||
conn.execute(
|
||||
"UPDATE pages SET share_mode=?, published=? WHERE id=?",
|
||||
(mode, 1 if published else 0, page_id),
|
||||
"UPDATE pages SET share_mode=?, published=?, is_published=?, publish_slug=? WHERE id=?",
|
||||
(mode, 1 if published else 0, 1 if published else 0, slug, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok", "share_mode": mode, "published": published}
|
||||
@@ -176,10 +194,15 @@ def restore_page(request: Request, page_id: int):
|
||||
def permanent_delete(request: Request, page_id: int):
|
||||
_trash_session(request)
|
||||
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("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
|
||||
delete_collections(conn, collections)
|
||||
conn.commit()
|
||||
return {"status": "ok", "deleted": page_id}
|
||||
return {"status": "ok", "deleted": page_id, "collections": len(collections)}
|
||||
|
||||
|
||||
|
||||
@@ -193,13 +216,23 @@ def empty_trash(request: Request):
|
||||
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}
|
||||
return {"status": "ok", "deleted": n, "collections": len(collections)}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -16,23 +16,41 @@ router = APIRouter(tags=["board"], prefix="/board")
|
||||
|
||||
|
||||
|
||||
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════
|
||||
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
|
||||
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════# 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")
|
||||
def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
|
||||
"""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
|
||||
return {"synced_blocks": list_synced_blocks(workspace or user.get("login", ""))}
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/api/synced-blocks")
|
||||
def create_synced_block_api(request: Request, body: dict = Body(...)):
|
||||
"""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
|
||||
sid = create_synced_block(
|
||||
workspace=body.get("workspace", ""),
|
||||
@@ -61,6 +79,7 @@ async def update_synced_block_api(request: Request, sid: int):
|
||||
sb = get_synced_block(sid)
|
||||
if not sb:
|
||||
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", []))
|
||||
# v6.5.0: rewrite every referencing page's stored content first (DB row
|
||||
# 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)
|
||||
if not sb:
|
||||
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
|
||||
# refs, rewrite their stored content (deleted state), then broadcast.
|
||||
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}")
|
||||
def get_synced_block_api(sid: int):
|
||||
def get_synced_block_api(request: Request, sid: int):
|
||||
"""Get a synced block by id."""
|
||||
_session_user(request)
|
||||
from app.services.synced_blocks import get_synced_block
|
||||
sb = get_synced_block(sid)
|
||||
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.commit()
|
||||
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,
|
||||
dashboard_views,
|
||||
data_api,
|
||||
index_page,
|
||||
linked,
|
||||
meta,
|
||||
pages,
|
||||
properties,
|
||||
structure,
|
||||
task_db,
|
||||
views,
|
||||
)
|
||||
from ._common import _validate_page_properties # noqa: F401
|
||||
@@ -34,7 +36,14 @@ from ._renderers import ( # noqa: F401 — ré-exports tests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
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 (
|
||||
task_db,
|
||||
index_page,
|
||||
crud,
|
||||
pages,
|
||||
boards,
|
||||
|
||||
@@ -8,10 +8,11 @@ import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
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.collection_lifecycle import delete_collections
|
||||
from app.services.db_templates import materialize_properties
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
@@ -24,22 +25,10 @@ router = APIRouter(tags=["collections"], prefix="/db")
|
||||
|
||||
|
||||
# ── API: List & Create (no path params) ──
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def list_collections(request: Request):
|
||||
"""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>"
|
||||
)
|
||||
#
|
||||
# `GET /db` (la page HTML) vit dans `index_page.py` : elle était auparavant
|
||||
# un dump JSON de debug, sans layout — alors qu'elle sert de destination au
|
||||
# bouton « Ouvrir mes bases » de My Tasks.
|
||||
|
||||
|
||||
|
||||
@@ -71,6 +60,24 @@ def create_collection_api(request: Request, body: dict = Body(default={})):
|
||||
schema = body.get("schema", [])
|
||||
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:
|
||||
# Apply a template if requested (provides schema + icon).
|
||||
tpl = _apply_template(conn, body.get("template"))
|
||||
@@ -88,9 +95,11 @@ def create_collection_api(request: Request, body: dict = Body(default={})):
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections
|
||||
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, description, icon, schema_json, gitea_owner, gitea_repo, int(is_locked)),
|
||||
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked,
|
||||
workspace_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(name, description, icon, schema_json, gitea_owner, gitea_repo, int(is_locked),
|
||||
workspace_id),
|
||||
)
|
||||
collection_id = cur.lastrowid
|
||||
|
||||
@@ -171,7 +180,11 @@ def delete_collection_api(request: Request, collection_id: int):
|
||||
if not existing:
|
||||
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()
|
||||
|
||||
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")
|
||||
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:
|
||||
coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
new_val = 0 if coll["is_task"] else 1
|
||||
conn.execute("UPDATE collections SET is_task=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (new_val, collection_id))
|
||||
conn.commit()
|
||||
if coll["is_task"]:
|
||||
disable_task_db_in_conn(conn, collection_id)
|
||||
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"}
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -48,7 +48,18 @@ def update_view_config(request: Request, view_id: int, body: dict = Body(default
|
||||
for key in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property",
|
||||
"cover_mode", "card_properties", "visible_properties", "filters", "sorts",
|
||||
"filter_conjunction", "date_property", "date_range_property",
|
||||
"property_groups", "view_type"):
|
||||
"property_groups", "view_type",
|
||||
# Vues Notion (Chart/Timeline/Feed/Map/Dashboard/Form) — §10.4
|
||||
"version", "chart_type", "chart_property", "measure", "omit_zero",
|
||||
"cumulative", "hidden_groups", "group_order", "style", "order",
|
||||
"aggregate", "title", "center_value", "palette", "height",
|
||||
"data_labels", "legend", "grid", "smooth", "gradient",
|
||||
"color_by_value", "separate_end_property", "scale", "show_table",
|
||||
"table_properties", "load_limit", "show_dependencies", "sub_items",
|
||||
"row_order", "show_view_count", "excerpt", "place_property",
|
||||
"cluster", "default_style", "global_filters", "row_heights",
|
||||
"excluded_widget_ids", "sharing", "submit_screen", "notify_email",
|
||||
"description", "form_view_id"):
|
||||
if key in body:
|
||||
config[key] = body[key]
|
||||
|
||||
@@ -184,4 +195,321 @@ def duplicate_view_api(request: Request, view_id: int, body: dict = Body(default
|
||||
"status": "duplicated"}
|
||||
|
||||
|
||||
# ── Vues Notion : donnees de vues, agregation, geocodage, dashboard, form ──
|
||||
|
||||
def _load_view_collection(conn, collection_id: int, view_id: int):
|
||||
view = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not view or int(view["collection_id"]) != int(collection_id):
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
try:
|
||||
config = json.loads(view["config_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
config = {}
|
||||
props = [dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,)).fetchall()]
|
||||
pages = [dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,)).fetchall()]
|
||||
return dict(view), config, props, pages
|
||||
|
||||
|
||||
@router.get("/{collection_id}/views/{view_id}/rows")
|
||||
def view_rows_api(request: Request, collection_id: int, view_id: int,
|
||||
cursor: str | None = None, limit: int = 50,
|
||||
window: str | None = None):
|
||||
"""Lignes filtrees/triees/projetees d'une vue (Feed, Timeline, Map...)."""
|
||||
from app.services.view_query import query_view_rows
|
||||
with get_conn() as conn:
|
||||
view, config, props, pages = _load_view_collection(conn, collection_id, view_id)
|
||||
if window:
|
||||
# Fenetre temporelle Timeline : "2026-01-01..2026-12-31" — filtree cote client.
|
||||
config = dict(config)
|
||||
limit = max(1, min(int(limit or 50), 1000))
|
||||
rows, next_cursor = query_view_rows(pages, props, config, limit=limit, cursor=cursor)
|
||||
undated = 0
|
||||
date_prop = (config.get("date_property") or config.get("date_range_property"))
|
||||
if date_prop and (view.get("view_type") == "timeline"):
|
||||
from app.services.view_query import prop_value, text_of
|
||||
by_name = {p["name"]: p for p in props}
|
||||
by_id = {str(p["id"]): p for p in props}
|
||||
dp = by_name.get(date_prop) or by_id.get(str(date_prop))
|
||||
if dp:
|
||||
for p in pages:
|
||||
if not text_of(dp, prop_value(p, dp)):
|
||||
undated += 1
|
||||
return {"rows": rows, "next_cursor": next_cursor, "total": len(pages),
|
||||
"undated": undated, "view_type": view.get("view_type")}
|
||||
|
||||
|
||||
@router.post("/{collection_id}/views/{view_id}/aggregate")
|
||||
def view_aggregate_api(request: Request, collection_id: int, view_id: int,
|
||||
body: dict = Body(default={})):
|
||||
"""Agregat groupe (Chart, KPI, widgets, drilldown) — §8.3."""
|
||||
from app.services.view_aggregate import aggregate
|
||||
with get_conn() as conn:
|
||||
view, config, props, pages = _load_view_collection(conn, collection_id, view_id)
|
||||
spec = {
|
||||
"group_by": body.get("group_by", config.get("group_by")),
|
||||
"sub_group_by": body.get("sub_group_by", config.get("sub_group_by")),
|
||||
"measure": body.get("measure", config.get("measure", {"kind": "count"})),
|
||||
"cumulative": body.get("cumulative", config.get("cumulative", False)),
|
||||
"omit_zero": body.get("omit_zero", config.get("omit_zero", True)),
|
||||
"hidden_groups": body.get("hidden_groups", config.get("hidden_groups", [])),
|
||||
"order": body.get("order", config.get("order", config.get("group_order", "group_order"))),
|
||||
"filters": (config.get("filters") or []) + (body.get("filters") or []),
|
||||
"filter_conjunction": config.get("filter_conjunction", "and"),
|
||||
"sorts": config.get("sorts"),
|
||||
}
|
||||
# Compat : ancienne config SSR {chart_property, aggregate}
|
||||
if not spec["group_by"] and (config.get("chart_property") or body.get("chart_property")):
|
||||
spec["group_by"] = config.get("chart_property") or body.get("chart_property")
|
||||
if isinstance(spec["measure"], str):
|
||||
spec["measure"] = {"kind": spec["measure"]}
|
||||
if isinstance(spec["order"], str):
|
||||
spec["order"] = {"by": spec["order"], "direction": "asc"}
|
||||
return aggregate(pages, props, spec)
|
||||
|
||||
|
||||
@router.get("/{collection_id}/views/{view_id}/drilldown")
|
||||
def view_drilldown_api(request: Request, collection_id: int, view_id: int,
|
||||
group: str = "", sub: str | None = None, limit: int = 100):
|
||||
"""Tableau restreint des lignes d'un segment de Chart (§8.4)."""
|
||||
from app.services.view_query import query_view_rows
|
||||
with get_conn() as conn:
|
||||
view, config, props, pages = _load_view_collection(conn, collection_id, view_id)
|
||||
group_by = config.get("group_by") or config.get("chart_property")
|
||||
extra = [{"property": group_by, "operator": "is", "value": group}] if group_by and group else []
|
||||
if sub and config.get("sub_group_by"):
|
||||
extra.append({"property": config["sub_group_by"], "operator": "is", "value": sub})
|
||||
rows, _ = query_view_rows(pages, props, config, extra_filters=extra,
|
||||
limit=max(1, min(int(limit or 100), 500)))
|
||||
return {"rows": rows, "group": group, "sub": sub, "count": len(rows)}
|
||||
|
||||
|
||||
@router.get("/geocode/search")
|
||||
def geocode_search_api(request: Request, q: str = "", limit: int = 5):
|
||||
"""Autocompletion d'adresses (editeur place). Provider none -> []."""
|
||||
from app.services import geocoding as _geo
|
||||
with get_conn() as conn:
|
||||
return {"suggestions": _geo.search(conn, q, limit=min(int(limit or 5), 10))}
|
||||
|
||||
|
||||
@router.post("/geocode/resolve")
|
||||
def geocode_resolve_api(request: Request, body: dict = Body(default={})):
|
||||
"""Adresse -> coordonnees (cache migration 45)."""
|
||||
from app.services import geocoding as _geo
|
||||
with get_conn() as conn:
|
||||
result = _geo.resolve(conn, body.get("q") or body.get("address") or "")
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Place not resolved")
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/{collection_id}/views/{view_id}/widgets")
|
||||
def dashboard_widgets_get(request: Request, collection_id: int, view_id: int):
|
||||
with get_conn() as conn:
|
||||
view, config, _, _ = _load_view_collection(conn, collection_id, view_id)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM dashboard_widgets WHERE view_id=? ORDER BY row_index, position",
|
||||
(view_id,)).fetchall()
|
||||
widgets = [dict(r) for r in rows]
|
||||
except Exception:
|
||||
widgets = json.loads(config.get("widgets_json") or "[]") if isinstance(config.get("widgets_json"), str) else (config.get("widgets_json") or [])
|
||||
return {"widgets": widgets, "config": config}
|
||||
|
||||
|
||||
@router.put("/{collection_id}/views/{view_id}/widgets")
|
||||
def dashboard_widgets_put(request: Request, collection_id: int, view_id: int,
|
||||
body: dict = Body(default={})):
|
||||
from app.routers.collections._common import _require_edit
|
||||
from app.routers.collections._common import _session_user as _su
|
||||
_require_edit(collection_id, _su(request))
|
||||
widgets = body.get("widgets") or []
|
||||
if len(widgets) > 12:
|
||||
raise HTTPException(status_code=400, detail="12 widgets maximum")
|
||||
with get_conn() as conn:
|
||||
view = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not view or int(view["collection_id"]) != int(collection_id):
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
try:
|
||||
conn.execute("DELETE FROM dashboard_widgets WHERE view_id=?", (view_id,))
|
||||
for i, w in enumerate(widgets):
|
||||
conn.execute(
|
||||
"""INSERT INTO dashboard_widgets
|
||||
(view_id, position, row_index, col_span, height_units,
|
||||
source_collection_id, source_view_id, inline_config_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(view_id, i, int(w.get("row_index", i // 4)), int(w.get("col_span", 1) or 1),
|
||||
int(w.get("height_units", 2) or 2),
|
||||
w.get("source_collection_id"), w.get("source_view_id"),
|
||||
json.dumps(w.get("inline_config") or w.get("inline_config_json") or {})),
|
||||
)
|
||||
conn.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
# Repli : stockage JSON dans le config (pre-migration 46).
|
||||
config = json.loads(view["config_json"] or "{}")
|
||||
config["widgets_json"] = widgets
|
||||
config["global_filters"] = body.get("global_filters", config.get("global_filters", []))
|
||||
if body.get("global_filters") is not None:
|
||||
config["global_filters"] = body["global_filters"]
|
||||
conn.execute("UPDATE collection_views SET config_json=? WHERE id=?",
|
||||
(json.dumps(config), view_id))
|
||||
conn.commit()
|
||||
return {"widgets": widgets, "fallback": "config_json"}
|
||||
if body.get("global_filters") is not None:
|
||||
config = json.loads(view["config_json"] or "{}")
|
||||
config["global_filters"] = body["global_filters"]
|
||||
conn.execute("UPDATE collection_views SET config_json=? WHERE id=?",
|
||||
(json.dumps(config), view_id))
|
||||
conn.commit()
|
||||
try:
|
||||
run_event_sync(fire_event("dashboard.layout_updated", {"view_id": view_id}))
|
||||
except Exception:
|
||||
pass
|
||||
return {"widgets": widgets, "status": "saved"}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/views/{view_id}/widgets/{widget_id}/data")
|
||||
def dashboard_widget_data(request: Request, collection_id: int, view_id: int, widget_id: int):
|
||||
from app.services.view_aggregate import aggregate
|
||||
from app.services.view_query import query_view_rows
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
w = conn.execute("SELECT * FROM dashboard_widgets WHERE id=? AND view_id=?",
|
||||
(widget_id, view_id)).fetchone()
|
||||
widget = dict(w) if w else None
|
||||
except Exception:
|
||||
widget = None
|
||||
if widget is None:
|
||||
raise HTTPException(status_code=404, detail="Widget not found")
|
||||
src_cid = widget.get("source_collection_id") or collection_id
|
||||
props = [dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(src_cid,)).fetchall()]
|
||||
pages = [dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
|
||||
(src_cid,)).fetchall()]
|
||||
src_view = None
|
||||
if widget.get("source_view_id"):
|
||||
src_view = conn.execute("SELECT * FROM collection_views WHERE id=?",
|
||||
(widget["source_view_id"],)).fetchone()
|
||||
try:
|
||||
inline = json.loads(widget.get("inline_config_json") or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
inline = {}
|
||||
cfg = dict(inline)
|
||||
if src_view:
|
||||
try:
|
||||
base = json.loads(src_view["config_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
base = {}
|
||||
cfg = {**base, **inline}
|
||||
vtype = (cfg.get("view_type") or src_view["view_type"] if src_view else cfg.get("view_type", "table"))
|
||||
if (cfg.get("group_by") or cfg.get("chart_type")) and vtype in ("chart", "dashboard", None):
|
||||
return aggregate(pages, props, {
|
||||
"group_by": cfg.get("group_by"), "sub_group_by": cfg.get("sub_group_by"),
|
||||
"measure": cfg.get("measure", {"kind": "count"}),
|
||||
"omit_zero": cfg.get("omit_zero", True), "hidden_groups": cfg.get("hidden_groups", []),
|
||||
"order": cfg.get("order", "group_order"), "filters": cfg.get("filters") or [],
|
||||
})
|
||||
rows, _ = query_view_rows(pages, props, cfg, limit=50)
|
||||
return {"rows": rows, "view_type": vtype or "table"}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/views/{view_id}/form")
|
||||
def form_builder_get(request: Request, collection_id: int, view_id: int):
|
||||
from app.services.form_projection import default_questions_for
|
||||
with get_conn() as conn:
|
||||
view, config, props, _ = _load_view_collection(conn, collection_id, view_id)
|
||||
try:
|
||||
qs = [dict(r) for r in conn.execute(
|
||||
"SELECT * FROM form_questions WHERE view_id=? ORDER BY position", (view_id,)).fetchall()]
|
||||
except Exception:
|
||||
qs = []
|
||||
if not qs:
|
||||
qs = default_questions_for([{"name": p["name"], "prop_type": p["prop_type"]} for p in props])
|
||||
return {"config": config, "questions": qs}
|
||||
|
||||
|
||||
@router.put("/{collection_id}/views/{view_id}/form")
|
||||
def form_builder_put(request: Request, collection_id: int, view_id: int,
|
||||
body: dict = Body(default={})):
|
||||
from app.routers.collections._common import _require_edit
|
||||
from app.routers.collections._common import _session_user as _su
|
||||
from app.services.form_projection import project_form_config
|
||||
_require_edit(collection_id, _su(request))
|
||||
questions = body.get("questions") or []
|
||||
form_cfg = body.get("config") or {}
|
||||
with get_conn() as conn:
|
||||
view = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not view or int(view["collection_id"]) != int(collection_id):
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
try:
|
||||
base = json.loads(view["config_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
base = {}
|
||||
base.update({k: v for k, v in form_cfg.items()
|
||||
if k in ("title", "description", "sharing", "submit_screen", "notify_email")})
|
||||
base["version"] = 2
|
||||
try:
|
||||
conn.execute("DELETE FROM form_questions WHERE view_id=?", (view_id,))
|
||||
for i, q in enumerate(questions):
|
||||
conn.execute(
|
||||
"""INSERT INTO form_questions
|
||||
(view_id, property_name, position, label_override, sync_label,
|
||||
help_text, required, widget, max_selections, conditional_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(view_id, q.get("property_name"), i,
|
||||
q.get("label_override"), 1 if q.get("sync_label", True) else 0,
|
||||
q.get("help_text", ""), 1 if q.get("required") else 0,
|
||||
q.get("widget", "auto"), q.get("max_selections"),
|
||||
json.dumps(q.get("conditional")) if q.get("conditional") else q.get("conditional_json")),
|
||||
)
|
||||
stored_qs = [dict(r) for r in conn.execute(
|
||||
"SELECT * FROM form_questions WHERE view_id=? ORDER BY position", (view_id,)).fetchall()]
|
||||
except Exception:
|
||||
base["form_questions_json"] = questions
|
||||
stored_qs = questions
|
||||
conn.execute("UPDATE collection_views SET config_json=? WHERE id=?",
|
||||
(json.dumps(base), view_id))
|
||||
conn.commit()
|
||||
return {"questions": stored_qs, "status": "saved", "fallback": "config_json"}
|
||||
projected = project_form_config(base, [{
|
||||
"property_name": r["property_name"], "position": r["position"],
|
||||
"label_override": r["label_override"], "sync_label": r["sync_label"],
|
||||
"help_text": r["help_text"], "required": r["required"],
|
||||
"widget": r["widget"], "max_selections": r["max_selections"],
|
||||
"conditional_json": r["conditional_json"],
|
||||
} for r in stored_qs])
|
||||
base["projected_form_config"] = projected
|
||||
# Synchro libelle -> nom de propriete (defaut Notion).
|
||||
for r in stored_qs:
|
||||
if r["sync_label"] and r["label_override"]:
|
||||
conn.execute(
|
||||
"UPDATE collection_properties SET name=? WHERE collection_id=? AND name=?",
|
||||
(r["label_override"], collection_id, r["property_name"]))
|
||||
conn.execute("UPDATE collection_views SET config_json=? WHERE id=?",
|
||||
(json.dumps(base), view_id))
|
||||
# La projection alimente aussi le pipeline public /f/ existant (meme collection).
|
||||
try:
|
||||
conn.execute("UPDATE collections SET form_config_json=? WHERE id=?",
|
||||
(json.dumps(projected), collection_id))
|
||||
except Exception:
|
||||
pass
|
||||
conn.commit()
|
||||
return {"questions": stored_qs, "status": "saved",
|
||||
"projected_fields": len(projected.get("fields", []))}
|
||||
|
||||
|
||||
@router.post("/{collection_id}/views/{view_id}/form/preview-token")
|
||||
def form_preview_token(request: Request, collection_id: int, view_id: int):
|
||||
"""Jeton d'apercu non-soumissible (soumissions refusees cote serveur)."""
|
||||
import secrets
|
||||
return {"preview_token": "preview_" + secrets.token_hex(8), "submittable": False}
|
||||
|
||||
|
||||
# ── v1.8.0 Sub-items & Dependencies ──
|
||||
|
||||
@@ -90,7 +90,52 @@ def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list
|
||||
|
||||
|
||||
|
||||
def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True) -> dict:
|
||||
def _recent_sidebar_pages(uid: int, limit: int = 8) -> list:
|
||||
"""Fichiers réellement récents (dernière édition) pour la sidebar.
|
||||
|
||||
v7.69.6 : avant, seuls les dépôts Gitea alimentaient la section Recents
|
||||
(vide sans dépôt). Icônes identiques au Workspace (noms SVG, pas
|
||||
d'emoji). Notes de réunion exclues → section Meetings.
|
||||
"""
|
||||
if not uid:
|
||||
return []
|
||||
try:
|
||||
from app.routers.board import _file_icon
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT p.id, p.title, p.parent_section, p.content_format
|
||||
FROM pages p
|
||||
LEFT JOIN workspaces w ON w.id = p.workspace_id
|
||||
WHERE p.deleted_at IS NULL AND p.parent_section != 'Trash'
|
||||
AND (w.owner_id = ? OR p.workspace_id IS NULL)
|
||||
AND REPLACE(COALESCE(p.content,''), ' ', '')
|
||||
NOT LIKE '%"type":"meeting"%'
|
||||
ORDER BY p.updated_at DESC LIMIT ?""",
|
||||
(uid, limit),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
is_folder = r["parent_section"] == "Workspace"
|
||||
out.append({
|
||||
"id": f"page/{r['id']}",
|
||||
"name": r["title"] or "Untitled",
|
||||
"icon": "folder" if is_folder else _file_icon(
|
||||
r["title"] or "", r["content_format"]),
|
||||
"url": f"/pages/{r['id']}",
|
||||
"active": False,
|
||||
"indent": 0,
|
||||
"depth": 0,
|
||||
"has_children": False,
|
||||
"children": [],
|
||||
})
|
||||
return out
|
||||
except Exception:
|
||||
logger.exception("_recent_sidebar_pages")
|
||||
return []
|
||||
|
||||
|
||||
def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True,
|
||||
gitea_owner: str = "", gitea_repo: str = "") -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
ws = user.get("login", "Bruno") if user else "Bruno"
|
||||
initial = ws[0].upper() if ws else "B"
|
||||
@@ -108,7 +153,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
recent_pages = []
|
||||
recent_pages = _recent_sidebar_pages(user["id"] if user else 0) if user else []
|
||||
for repo in repos[:10]:
|
||||
full_name = repo.get("full_name", "")
|
||||
recent_pages.append({
|
||||
@@ -130,37 +175,45 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
active_ws_name = "Workspace"
|
||||
workspace_pages = []
|
||||
gitea_workspace = False
|
||||
gitea_owner = ""
|
||||
gitea_repo = ""
|
||||
has_active_workspace = False
|
||||
local_ws_id = 0
|
||||
|
||||
if ws_cookie and ws_cookie.startswith("gitea:"):
|
||||
# Gitea workspace: set owner/repo for client-side tree loading
|
||||
# AND open the local workspace mirror of the same name in the
|
||||
# sidebar's top "My Workspaces" section, in parallel with the
|
||||
# Gitea repository tree.
|
||||
# Explicit project context (e.g. /gitea-workspace?owner=…&repo=…) wins over
|
||||
# the cookie: on a direct navigation / bookmark the cookie may be missing,
|
||||
# yet the project is unambiguously identified by the URL. Without this the
|
||||
# sidebar showed "No workspace open" even though the route just created the
|
||||
# local mirror.
|
||||
eff_owner, eff_repo = gitea_owner, gitea_repo
|
||||
if not (eff_owner and eff_repo) and (ws_cookie.startswith("gitea:") or ws_cookie.startswith("github:")):
|
||||
parts = ws_cookie.split(":", 2)
|
||||
if len(parts) >= 3:
|
||||
gitea_owner = parts[1]
|
||||
gitea_repo = parts[2]
|
||||
active_ws_name = f"{gitea_owner}/{gitea_repo}"
|
||||
gitea_workspace = True
|
||||
has_active_workspace = True
|
||||
# Load the local mirror workspace tree so it appears in "My
|
||||
# Workspaces" alongside the Gitea repository section.
|
||||
if user:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
|
||||
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
|
||||
).fetchone()
|
||||
if row:
|
||||
local_ws_id = row["id"]
|
||||
workspace_pages = _load_workspace_pages(str(local_ws_id))
|
||||
except (ValueError, Exception):
|
||||
pass
|
||||
eff_owner, eff_repo = parts[1], parts[2]
|
||||
|
||||
if eff_owner and eff_repo:
|
||||
# Forge workspace (Gitea or GitHub): set owner/repo for client-side
|
||||
# tree loading AND open the local workspace mirror of the same name
|
||||
# in the sidebar's top Workspace section, in parallel with the
|
||||
# Repository tree (same navigation structure as workspace-local).
|
||||
gitea_owner = eff_owner
|
||||
gitea_repo = eff_repo
|
||||
active_ws_name = f"{gitea_owner}/{gitea_repo}"
|
||||
gitea_workspace = True
|
||||
has_active_workspace = True
|
||||
# Load the local mirror workspace tree so it appears in Workspace
|
||||
# alongside the Repository section (gitea_repo or github_repo marker).
|
||||
if user:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=? AND name=? "
|
||||
"AND (settings_json LIKE '%gitea_repo%' OR settings_json LIKE '%github_repo%')",
|
||||
(user["id"], f"{gitea_owner}/{gitea_repo}"),
|
||||
).fetchone()
|
||||
if row:
|
||||
local_ws_id = row["id"]
|
||||
workspace_pages = _load_workspace_pages(str(local_ws_id))
|
||||
except (ValueError, Exception):
|
||||
pass
|
||||
elif include_workspace and ws_cookie and user:
|
||||
try:
|
||||
wsi = int(ws_cookie)
|
||||
@@ -174,6 +227,11 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
active_ws_name = row["name"]
|
||||
workspace_pages = _load_workspace_pages(ws_cookie)
|
||||
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
|
||||
except (ValueError, Exception):
|
||||
pass
|
||||
@@ -199,28 +257,30 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Get local workspace ID for Gitea workspace mirror
|
||||
local_ws_id = 0
|
||||
# Get local workspace ID for forge workspace mirror (le miroir est un
|
||||
# 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:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
|
||||
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
|
||||
"SELECT id FROM workspaces WHERE owner_id=? AND name=? "
|
||||
"AND (settings_json LIKE '%gitea_repo%' OR settings_json LIKE '%github_repo%')",
|
||||
(user["id"], f"{gitea_owner}/{gitea_repo}"),
|
||||
).fetchone()
|
||||
if row:
|
||||
local_ws_id = row["id"]
|
||||
gitea_mirror_ws_id = row["id"]
|
||||
except Exception:
|
||||
logger.exception("_sidebar_data")
|
||||
|
||||
# Private pages for mirror workspace (when Gitea remote active)
|
||||
private_pages = []
|
||||
if gitea_workspace and local_ws_id:
|
||||
if gitea_workspace and gitea_mirror_ws_id:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
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",
|
||||
(local_ws_id,)
|
||||
(gitea_mirror_ws_id,)
|
||||
).fetchall()
|
||||
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
|
||||
except Exception:
|
||||
@@ -353,21 +413,26 @@ def _require_page_view(request: Request, page_id: int) -> None:
|
||||
|
||||
def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | None = None) -> list:
|
||||
"""Recursively build the tree of children for a node."""
|
||||
# v7.69.6 : notes de réunion exclues (section Meetings, comme la sidebar).
|
||||
no_meet = "AND REPLACE(COALESCE(content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%'"
|
||||
if parent_id is None:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format, content, page_icon, "
|
||||
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
|
||||
"COALESCE(is_published,0) AS pub1, COALESCE(published,0) AS pub2, "
|
||||
"(is_shared OR share_mode != 'private' OR COALESCE(published,0) OR COALESCE(is_published,0)) as is_shared, "
|
||||
"created_at, updated_at FROM pages "
|
||||
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL "
|
||||
f"{no_meet}"
|
||||
"ORDER BY created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format, content, page_icon, "
|
||||
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
|
||||
"COALESCE(is_published,0) AS pub1, COALESCE(published,0) AS pub2, "
|
||||
"(is_shared OR share_mode != 'private' OR COALESCE(published,0) OR COALESCE(is_published,0)) as is_shared, "
|
||||
"created_at, updated_at FROM pages "
|
||||
"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL "
|
||||
f"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL {no_meet} "
|
||||
"ORDER BY created_at DESC",
|
||||
(parent_id, ws_id),
|
||||
).fetchall()
|
||||
@@ -435,6 +500,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | Non
|
||||
"author": author,
|
||||
"tags": tags_map.get(r["id"], []),
|
||||
"is_shared": bool(r["is_shared"]),
|
||||
"published": bool(r["pub1"] or r["pub2"]),
|
||||
"favorited": r["id"] in favorited_ids,
|
||||
})
|
||||
return tree
|
||||
@@ -558,6 +624,103 @@ def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
|
||||
|
||||
|
||||
|
||||
def _ensure_forge_mirror(user_id: int, owner: str, repo: str, forge: str = "gitea") -> int | None:
|
||||
"""Ensure the local workspace mirror ``owner/repo`` exists, return its id.
|
||||
|
||||
Shared by /gitea-workspace and /github-workspace so selecting a repository
|
||||
always opens a workspace-local of the same name (section Workspace).
|
||||
Only auto-created mirrors carry ``settings_json`` with ``gitea_repo`` /
|
||||
``github_repo`` — never touch regular user workspaces.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
if not ws_key or not user_id:
|
||||
return None
|
||||
marker = f"{forge}_repo"
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
|
||||
(user_id, ws_key, f"%{marker}%"),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return existing["id"]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)",
|
||||
(ws_key, user_id, _json.dumps({marker: ws_key, f"{forge}_owner": owner})),
|
||||
)
|
||||
mirror_id = cur.lastrowid
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
|
||||
(mirror_id, user_id, "admin"),
|
||||
)
|
||||
conn.commit()
|
||||
return mirror_id
|
||||
|
||||
|
||||
def _is_mirror_empty(conn, ws_id: int) -> bool:
|
||||
"""True when no file/folder was ever created in this mirror workspace.
|
||||
|
||||
« Vide » = zéro page active (``deleted_at IS NULL``). Trash / soft-deleted
|
||||
rows don't count — an emptied-then-trashed mirror is still collectable.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM pages WHERE workspace_id=? AND deleted_at IS NULL",
|
||||
(ws_id,),
|
||||
).fetchone()
|
||||
return (row["c"] if row else 0) == 0
|
||||
|
||||
|
||||
def _cleanup_empty_mirrors(user_id: int, keep_name: str = "", keep_id: int = 0) -> int:
|
||||
"""Delete empty auto-created forge mirrors, except the one being opened.
|
||||
|
||||
Called when switching workspace / repository so empty workspace-locals
|
||||
don't accumulate. Only workspaces whose ``settings_json`` contains
|
||||
``gitea_repo`` or ``github_repo`` are eligible — regular workspaces are
|
||||
never deleted here. Returns number of removed mirrors.
|
||||
"""
|
||||
if not user_id:
|
||||
return 0
|
||||
removed = 0
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name FROM workspaces WHERE owner_id=? "
|
||||
"AND (settings_json LIKE '%gitea_repo%' OR settings_json LIKE '%github_repo%')",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
wid, name = r["id"], r["name"]
|
||||
if keep_id and wid == keep_id:
|
||||
continue
|
||||
if keep_name and name == keep_name:
|
||||
continue
|
||||
try:
|
||||
if not _is_mirror_empty(conn, wid):
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
from app.services.collection_lifecycle import (
|
||||
delete_collections,
|
||||
workspace_collection_ids,
|
||||
)
|
||||
try:
|
||||
collections = workspace_collection_ids(conn, wid)
|
||||
if collections:
|
||||
delete_collections(conn, collections)
|
||||
except Exception:
|
||||
logger.exception("_cleanup_empty_mirrors collections")
|
||||
conn.execute("DELETE FROM pages WHERE workspace_id=?", (wid,))
|
||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (wid,))
|
||||
conn.execute("DELETE FROM workspaces WHERE id=?", (wid,))
|
||||
removed += 1
|
||||
except Exception:
|
||||
logger.exception("_cleanup_empty_mirrors")
|
||||
continue
|
||||
conn.commit()
|
||||
return removed
|
||||
|
||||
|
||||
def _sanitize_id(block_id: str) -> str:
|
||||
"""Sanitize a block id for use as an HTML anchor (only alnum kept)."""
|
||||
if not block_id:
|
||||
@@ -665,12 +828,17 @@ def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
|
||||
)
|
||||
elif t == "code":
|
||||
lang = b.get("language", "")
|
||||
try:
|
||||
from app.services.export import normalize_code_lang
|
||||
hljs_lang = normalize_code_lang(lang)
|
||||
except Exception: # noqa: BLE001 — never break public rendering
|
||||
hljs_lang = "plaintext"
|
||||
lang_label = f"<div style='font-size:11px;opacity:.4;margin-bottom:8px;'>{lang}</div>" if lang else ""
|
||||
html_parts.append(
|
||||
f'<pre style="background:rgba(255,255,255,.05);padding:16px 20px;border-radius:8px;'
|
||||
f'overflow-x:auto;font-size:14px;line-height:1.5;margin:12px 0;">'
|
||||
f'{lang_label}'
|
||||
f'<code>{c}</code></pre>'
|
||||
f'<code class="hljs language-{hljs_lang}" data-hljs-lang="{hljs_lang}">{c}</code></pre>'
|
||||
)
|
||||
elif t == "divider":
|
||||
html_parts.append('<hr style="border:none;border-top:1px solid rgba(255,255,255,.1);margin:16px 0;">')
|
||||
|
||||
@@ -395,9 +395,33 @@ def sidebar_workspace_tree(request: Request):
|
||||
if not ws_cookie:
|
||||
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li>')
|
||||
|
||||
# Gitea workspace — no server-side tree, loaded client-side
|
||||
if ws_cookie.startswith("gitea:"):
|
||||
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">🔗</span><span class="page-name text-dim">Remote workspace</span></li>')
|
||||
# Forge workspace (Gitea/GitHub) — the sidebar Workspace section shows the
|
||||
# LOCAL mirror of the repository (the "parallel local workspace"), not a
|
||||
# remote placeholder. Older behaviour returned "Remote workspace", which
|
||||
# wiped the mirror tree on every CRUD refresh.
|
||||
if ws_cookie.startswith("gitea:") or ws_cookie.startswith("github:"):
|
||||
parts = ws_cookie.split(":", 2)
|
||||
if len(parts) >= 3:
|
||||
mirror = None
|
||||
with get_conn() as conn:
|
||||
mirror = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=? AND name=? "
|
||||
"AND (settings_json LIKE '%gitea_repo%' OR settings_json LIKE '%github_repo%')",
|
||||
(user["id"], f"{parts[1]}/{parts[2]}"),
|
||||
).fetchone()
|
||||
if mirror:
|
||||
pages = _load_workspace_pages(str(mirror["id"]))
|
||||
if pages:
|
||||
env = ENV
|
||||
template = env.from_string(
|
||||
"{% from '_workspace_tree_macro.html' import render_workspace_tree %}"
|
||||
"{{ render_workspace_tree(pages) }}"
|
||||
)
|
||||
return HTMLResponse(template.render(pages=pages))
|
||||
return HTMLResponse(
|
||||
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
|
||||
'<span class="page-name text-dim">No pages yet</span></li>'
|
||||
)
|
||||
|
||||
try:
|
||||
ws_id = int(ws_cookie)
|
||||
|
||||
@@ -15,8 +15,10 @@ from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
from ._common import (
|
||||
WORKSPACE_COOKIE,
|
||||
_build_breadcrumb,
|
||||
_build_tree_children,
|
||||
_cleanup_empty_mirrors,
|
||||
_file_page_disk_path,
|
||||
_get_active_workspace,
|
||||
_get_user_id,
|
||||
@@ -34,10 +36,16 @@ router = APIRouter(tags=["dashboard"])
|
||||
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
|
||||
|
||||
@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.
|
||||
|
||||
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
|
||||
env = ENV
|
||||
@@ -46,13 +54,52 @@ def local_workspace_page(request: Request, folder: int = None):
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
|
||||
ws = _get_active_workspace(request, user_id=user["id"])
|
||||
ws_id = ws["id"] if ws else None
|
||||
ws_row = _get_active_workspace(request, user_id=user["id"])
|
||||
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)
|
||||
# Switching workspace: drop other EMPTY forge mirrors (no file or
|
||||
# folder ever created there) instead of keeping empty locals.
|
||||
try:
|
||||
_cleanup_empty_mirrors(user["id"], keep_id=ws_row["id"])
|
||||
except Exception:
|
||||
pass
|
||||
# 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 not ws_id:
|
||||
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
|
||||
breadcrumb = []
|
||||
current_folder_id = folder
|
||||
@@ -60,15 +107,40 @@ def local_workspace_page(request: Request, folder: int = None):
|
||||
with get_conn() as conn:
|
||||
breadcrumb = _build_breadcrumb(conn, folder)
|
||||
|
||||
# En-tête Notion-style (même mécanique que les pages) :
|
||||
# Home / <workspace> / <dossier> / <sous-dossier> / …
|
||||
# Chaque niveau cliquable renvoie à /local-workspace?folder=<id> — sans
|
||||
# ça le header restait bloqué sur « Home / <workspace> » même quand le
|
||||
# contenu affiché était celui d'un sous-dossier.
|
||||
nav_crumbs: list = [{
|
||||
"id": None,
|
||||
"label": ws_name,
|
||||
"url": "/local-workspace",
|
||||
"icon": "folder",
|
||||
"menu": False,
|
||||
}]
|
||||
for i, bcrumb in enumerate(breadcrumb):
|
||||
is_last = i == len(breadcrumb) - 1
|
||||
nav_crumbs.append({
|
||||
"id": bcrumb["id"],
|
||||
"label": bcrumb["name"],
|
||||
"url": None if is_last else f"/local-workspace?folder={bcrumb['id']}",
|
||||
"icon": "folder",
|
||||
"menu": True,
|
||||
})
|
||||
|
||||
ctx = {
|
||||
**sidebar,
|
||||
"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,
|
||||
"workspace_id": ws_id or 0,
|
||||
"nav_workspace_id": ws_id or 0,
|
||||
"breadcrumb": breadcrumb,
|
||||
"breadcrumbs": breadcrumb,
|
||||
"breadcrumb_items": nav_crumbs,
|
||||
}
|
||||
template = env.get_template("local_workspace.html")
|
||||
return HTMLResponse(
|
||||
@@ -255,6 +327,7 @@ def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
|
||||
@router.post("/api/local-workspace/items")
|
||||
def create_local_workspace_item(request: Request, body: dict = Body(default={})):
|
||||
"""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"
|
||||
item_type = body.get("type", "page")
|
||||
parent_id = body.get("parent_id")
|
||||
@@ -279,6 +352,7 @@ def create_local_workspace_item(request: Request, body: dict = Body(default={}))
|
||||
@router.put("/api/local-workspace/items/{item_id:int}")
|
||||
def rename_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Rename a file."""
|
||||
_require_user_id(request)
|
||||
name = body.get("name", "Untitled").strip()
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET title=? WHERE id=?", (name, item_id))
|
||||
@@ -291,6 +365,7 @@ def rename_local_workspace_item(request: Request, item_id: int, body: dict = Bod
|
||||
@router.delete("/api/local-workspace/items/{item_id:int}")
|
||||
def delete_local_workspace_item(request: Request, item_id: int):
|
||||
"""Soft-delete a file/folder (sets deleted_at)."""
|
||||
_require_user_id(request)
|
||||
from datetime import datetime
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -306,6 +381,7 @@ def delete_local_workspace_item(request: Request, item_id: int):
|
||||
@router.post("/api/local-workspace/items/{item_id:int}/restore")
|
||||
def restore_local_workspace_item(request: Request, item_id: int):
|
||||
"""Restore a soft-deleted file/folder."""
|
||||
_require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET deleted_at=NULL WHERE id=?",
|
||||
@@ -342,6 +418,7 @@ def serve_uploaded_file(ws_id: int, filename: str):
|
||||
@router.put("/api/local-workspace/items/{item_id:int}/move")
|
||||
def move_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Move an item to a new parent (drag & drop)."""
|
||||
_require_user_id(request)
|
||||
new_parent_id = body.get("parent_id") # None = move to root
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
|
||||
@@ -94,6 +94,24 @@ def view_page_root(request: Request, page_id: int):
|
||||
if not row:
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
page = dict(row)
|
||||
# v7.69.6 : tracer la visite (colonne Library « Last visited », table recents).
|
||||
try:
|
||||
from datetime import UTC, datetime
|
||||
_sess = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if _sess and _sess.get("id"):
|
||||
with get_conn() as _conn:
|
||||
_conn.execute(
|
||||
"""INSERT INTO recents (user_id, page_id, workspace, source_type, accessed_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id, page_id)
|
||||
DO UPDATE SET workspace=excluded.workspace,
|
||||
accessed_at=excluded.accessed_at""",
|
||||
(_sess["id"], page_id, page.get("workspace", "") or "",
|
||||
"local", datetime.now(UTC).replace(tzinfo=None).isoformat()),
|
||||
)
|
||||
_conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
# v6.5.0: synced blocks resolve server-side at read time.
|
||||
from app.services.synced_blocks import resolve_content_json
|
||||
page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format"))
|
||||
@@ -119,7 +137,7 @@ def view_page_root(request: Request, page_id: int):
|
||||
_sess_user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
_uid = _sess_user.get("id") if _sess_user and _sess_user.get("id") else None
|
||||
_can_edit = (not _locked) or bool(_sess_user and _sess_user.get("is_admin")) or (_locked_by and _uid and _locked_by == _uid)
|
||||
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)),
|
||||
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("is_published", 0) or page.get("published", 0)),
|
||||
"is_locked": _locked,
|
||||
"locked_by": _locked_by,
|
||||
"can_edit": _can_edit,
|
||||
@@ -217,8 +235,10 @@ def view_page_root(request: Request, page_id: int):
|
||||
"{% from '_icons.html' import fd_icon %}{{ fd_icon('paperclip', 14) }}"
|
||||
).render(),
|
||||
)
|
||||
# Select template: collection pages use database table view
|
||||
if page.get("content_format") == "collection" and not embed:
|
||||
# Select template: collection pages use database table view (aussi en
|
||||
# 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")
|
||||
else:
|
||||
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
|
||||
@@ -249,209 +269,10 @@ def accounts_page(request: Request):
|
||||
|
||||
@router.get("/help", response_class=HTMLResponse)
|
||||
def help_page(request: Request):
|
||||
"""Comprehensive help & documentation page."""
|
||||
"""Help & documentation page — settings-style panel with side navigation."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
# Render via a block-based template so content_html lands in {% block content %}
|
||||
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>"""
|
||||
))
|
||||
|
||||
|
||||
return ENV.get_template("help.html").render(**sidebar, request=request)
|
||||
|
||||
|
||||
@router.get("/accounts/settings", response_class=HTMLResponse)
|
||||
|
||||
@@ -13,7 +13,13 @@ from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.gitea_client import get_user_gitea_client, gitea
|
||||
|
||||
from ._common import _get_active_workspace, _get_user_id, _sidebar_data
|
||||
from ._common import (
|
||||
_cleanup_empty_mirrors,
|
||||
_ensure_forge_mirror,
|
||||
_get_active_workspace,
|
||||
_get_user_id,
|
||||
_sidebar_data,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
@@ -125,12 +131,9 @@ def workspace_page(request: Request):
|
||||
|
||||
@router.get("/gitea-workspace", response_class=HTMLResponse)
|
||||
def gitea_workspace_page(request: Request):
|
||||
"""Gitea workspace — browse repo files."""
|
||||
import json
|
||||
|
||||
"""Gitea workspace — browse repo files (same layout as /local-workspace)."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, [])
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
@@ -138,27 +141,16 @@ def gitea_workspace_page(request: Request):
|
||||
repo = request.query_params.get("repo", "")
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
ws_name = ws_key or "Gitea Workspace"
|
||||
# Auto-create local workspace mirror for storing local files
|
||||
local_ws_id = None
|
||||
# Ensure the local workspace mirror of the same name BEFORE building the
|
||||
# sidebar, then drop other EMPTY mirrors so switching repositories never
|
||||
# accumulates empty workspace-locals.
|
||||
local_ws_id = _ensure_forge_mirror(user["id"], owner, repo, forge="gitea") if ws_key else None
|
||||
if ws_key:
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id, owner_id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
|
||||
(user["id"], ws_key, "%gitea_repo%")
|
||||
).fetchone()
|
||||
if existing:
|
||||
local_ws_id = existing["id"]
|
||||
else:
|
||||
c = conn.execute(
|
||||
"INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)",
|
||||
(ws_key, user["id"], json.dumps({"gitea_repo": ws_key, "gitea_owner": owner}))
|
||||
)
|
||||
local_ws_id = c.lastrowid
|
||||
conn.execute(
|
||||
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
|
||||
(local_ws_id, user["id"], "admin")
|
||||
)
|
||||
conn.commit()
|
||||
_cleanup_empty_mirrors(user["id"], keep_name=ws_key, keep_id=local_ws_id or 0)
|
||||
# Build the sidebar with the explicit project context so the local mirror
|
||||
# (''Workspace'' section) is resolved even when the flowdeck_workspace
|
||||
# cookie is absent (direct navigation / bookmark).
|
||||
sidebar = _sidebar_data(request, [], gitea_owner=owner, gitea_repo=repo)
|
||||
ctx = {
|
||||
**sidebar,
|
||||
"user": user,
|
||||
@@ -167,6 +159,7 @@ def gitea_workspace_page(request: Request):
|
||||
"gitea_workspace": True, # always true on this page
|
||||
"gitea_owner": owner,
|
||||
"gitea_repo": repo,
|
||||
"forge": "gitea",
|
||||
"workspace_name": ws_name,
|
||||
"workspace_initial": repo[0].upper() if repo else "G",
|
||||
"owner": owner,
|
||||
@@ -179,6 +172,45 @@ def gitea_workspace_page(request: Request):
|
||||
return resp
|
||||
|
||||
|
||||
@router.get("/github-workspace", response_class=HTMLResponse)
|
||||
def github_workspace_page(request: Request):
|
||||
"""GitHub workspace — same layout as /local-workspace and /gitea-workspace."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
owner = request.query_params.get("owner", "")
|
||||
repo = request.query_params.get("repo", "")
|
||||
ws_key = f"{owner}/{repo}" if owner and repo else ""
|
||||
ws_name = ws_key or "GitHub Workspace"
|
||||
local_ws_id = _ensure_forge_mirror(user["id"], owner, repo, forge="github") if ws_key else None
|
||||
if ws_key:
|
||||
_cleanup_empty_mirrors(user["id"], keep_name=ws_key, keep_id=local_ws_id or 0)
|
||||
# Reuse the same sidebar mechanism (Repository section + parallel local
|
||||
# mirror). The cookie prefix carries the forge so refresh APIs resolve it.
|
||||
sidebar = _sidebar_data(request, [], gitea_owner=owner, gitea_repo=repo)
|
||||
ctx = {
|
||||
**sidebar,
|
||||
"user": user,
|
||||
"active_ws_name": ws_name,
|
||||
"workspace_key": ws_key,
|
||||
"gitea_workspace": True, # sidebar Repository section visible
|
||||
"gitea_owner": owner,
|
||||
"gitea_repo": repo,
|
||||
"forge": "github",
|
||||
"workspace_name": ws_name,
|
||||
"workspace_initial": repo[0].upper() if repo else "G",
|
||||
"owner": owner,
|
||||
"repo": repo,
|
||||
"nav_workspace_id": local_ws_id or 0,
|
||||
}
|
||||
template = env.get_template("github_workspace.html")
|
||||
resp = HTMLResponse(content=template.render(**ctx))
|
||||
resp.set_cookie("flowdeck_workspace", f"github:{owner}:{repo}", path="/", samesite="lax")
|
||||
return resp
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/api/workspace/projects")
|
||||
|
||||
@@ -6,18 +6,48 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
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,
|
||||
_cleanup_empty_mirrors,
|
||||
_get_active_workspace,
|
||||
_get_user_id,
|
||||
_require_user_id,
|
||||
_sidebar_data,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
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)
|
||||
@@ -42,8 +72,7 @@ def workspaces_page(request: Request):
|
||||
@router.get("/api/workspaces")
|
||||
def list_workspaces(request: Request):
|
||||
"""List all workspaces for the current user."""
|
||||
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:
|
||||
rows = conn.execute(
|
||||
"SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id AND collection_row_id IS NULL) as page_count "
|
||||
@@ -67,7 +96,7 @@ def create_workspace(request: Request, body: dict = Body(default={})):
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
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:
|
||||
# Ensure user exists (FK constraint)
|
||||
uid_ok = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone()
|
||||
@@ -99,6 +128,7 @@ def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
_require_ws_owner(request, ws_id)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE workspaces SET name=? WHERE id=?", (name, ws_id))
|
||||
conn.commit()
|
||||
@@ -109,13 +139,19 @@ def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})
|
||||
|
||||
@router.delete("/api/workspaces/{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:
|
||||
# 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 workspace_members WHERE workspace_id=?", (ws_id,))
|
||||
conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
return {"status": "ok", "collections": len(collections)}
|
||||
|
||||
|
||||
|
||||
@@ -123,6 +159,12 @@ def delete_workspace(request: Request, ws_id: int):
|
||||
@router.post("/api/workspaces/{ws_id:int}/select")
|
||||
def select_workspace(request: Request, ws_id: int):
|
||||
"""Set the active workspace via cookie."""
|
||||
uid = _require_ws_owner(request, ws_id)
|
||||
# Switching workspace: prune other empty forge mirrors.
|
||||
try:
|
||||
_cleanup_empty_mirrors(uid, keep_id=ws_id)
|
||||
except Exception:
|
||||
pass
|
||||
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
|
||||
response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/")
|
||||
return response
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
"""FlowDeck — Templates unifiés : gestionnaire SSR + API interne (cookie).
|
||||
|
||||
Cahier des charges : ``docs/architecture-templates-notion-flowdeck.md`` §10.1.
|
||||
Toutes les surfaces (UI, API v2, agent, scheduler) passent par le même
|
||||
``TemplateService`` — jamais d'accès direct aux silos legacy (P4, brief §0).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Query, Request
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.fd_templates import TemplateService, ensure_registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["fd-templates"])
|
||||
|
||||
|
||||
def _actor(request: Request) -> dict | None:
|
||||
try:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
except Exception:
|
||||
user = None
|
||||
return user
|
||||
|
||||
|
||||
def _require_actor(request: Request) -> dict:
|
||||
user = _actor(request)
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
def _as_http(exc: Exception) -> HTTPException:
|
||||
if isinstance(exc, LookupError):
|
||||
return HTTPException(404, str(exc) or "Template introuvable")
|
||||
if isinstance(exc, PermissionError):
|
||||
return HTTPException(403, str(exc) or "Accès refusé")
|
||||
if isinstance(exc, ValueError):
|
||||
return HTTPException(400, str(exc) or "Requête invalide")
|
||||
logger.exception("templates API")
|
||||
return HTTPException(500, "Erreur interne")
|
||||
|
||||
|
||||
# ═══════════ Page SSR — gestionnaire /templates (§7.2) ═══════════
|
||||
|
||||
@router.get("/templates", response_class=HTMLResponse)
|
||||
def templates_manager_page(request: Request):
|
||||
"""Gestionnaire de templates : liste filtrable + side peek d'aperçu."""
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
from app.templating import ENV
|
||||
|
||||
sidebar = board_sidebar(request, "", "")
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
ensure_registry(conn)
|
||||
except Exception:
|
||||
logger.exception("ensure_registry /templates")
|
||||
template = ENV.get_template("fd_templates_manager.html")
|
||||
return template.render(**sidebar)
|
||||
|
||||
|
||||
# ═══════════ API interne — lecture ═══════════
|
||||
|
||||
@router.get("/api/templates")
|
||||
def list_templates(
|
||||
request: Request,
|
||||
kind: str | None = Query(default=None),
|
||||
scope: str | None = Query(default=None),
|
||||
target_collection_id: int | None = Query(default=None),
|
||||
q: str | None = Query(default=None),
|
||||
include_archived: bool = Query(default=False),
|
||||
):
|
||||
actor = _actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
items = TemplateService.list_templates(
|
||||
conn, actor, kind=kind, scope=scope,
|
||||
target_collection_id=target_collection_id, query=q,
|
||||
include_archived=include_archived,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return {"templates": items}
|
||||
|
||||
|
||||
@router.get("/api/templates/{template_id}")
|
||||
def get_template(request: Request, template_id: str):
|
||||
actor = _actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
row = TemplateService.get_template(conn, actor, template_id)
|
||||
from app.services.fd_templates import _row_to_dto
|
||||
|
||||
dto = _row_to_dto(conn, row)
|
||||
preview = TemplateService.preview(conn, actor, template_id)
|
||||
dto["blocks_preview"] = preview.get("blocks_preview", [])
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.get("/api/collections/{collection_id}/templates")
|
||||
def list_collection_templates(request: Request, collection_id: int):
|
||||
"""Menu *New ▾* d'une base : templates de lignes + défaut (§7.3)."""
|
||||
actor = _actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
items = TemplateService.list_templates(
|
||||
conn, actor, kind="row", target_collection_id=collection_id
|
||||
)
|
||||
default_id = next(
|
||||
(t["id"] for t in items if t.get("is_default")), None
|
||||
)
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return {"templates": items, "default_id": default_id}
|
||||
|
||||
|
||||
@router.get("/api/templates/{template_id}/runs")
|
||||
def list_template_runs(request: Request, template_id: str, limit: int = Query(default=20)):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
TemplateService.get_template(conn, actor, template_id)
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM template_runs WHERE template_id=? ORDER BY created_at DESC LIMIT ?",
|
||||
(template_id, max(1, min(int(limit), 100))),
|
||||
).fetchall()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return {"runs": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
# ═══════════ API interne — écriture ═══════════
|
||||
|
||||
@router.post("/api/templates")
|
||||
def create_template(request: Request, body: dict = Body(default={})):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
# Création depuis une source existante (create_from_*, §2.1).
|
||||
if body.get("from_page_id"):
|
||||
dto = _create_from_page(conn, actor, int(body["from_page_id"]), body)
|
||||
elif body.get("from_collection_id"):
|
||||
dto = _create_from_collection(
|
||||
conn, actor, int(body["from_collection_id"]), body
|
||||
)
|
||||
elif body.get("from_row"):
|
||||
dto = _create_from_row(conn, actor, body["from_row"], body)
|
||||
else:
|
||||
dto = TemplateService.create_template(conn, actor, body)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.patch("/api/templates/{template_id}")
|
||||
def update_template(request: Request, template_id: str, body: dict = Body(default={})):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.update_template(conn, actor, template_id, body)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.post("/api/templates/{template_id}/duplicate")
|
||||
def duplicate_template(request: Request, template_id: str, body: dict = Body(default={})):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.duplicate_template(conn, actor, template_id, body)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.delete("/api/templates/{template_id}")
|
||||
def delete_template(request: Request, template_id: str):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
result = TemplateService.delete_template(conn, actor, template_id)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/templates/{template_id}/archive")
|
||||
def archive_template(request: Request, template_id: str):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.archive_template(conn, actor, template_id, archived=True)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.post("/api/templates/{template_id}/restore")
|
||||
def restore_template(request: Request, template_id: str):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.archive_template(conn, actor, template_id, archived=False)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.put("/api/templates/{template_id}/property-defaults")
|
||||
def set_property_defaults(request: Request, template_id: str, body: dict = Body(default={})):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.update_template(
|
||||
conn, actor, template_id,
|
||||
{"property_defaults": body.get("property_defaults") or body},
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.put("/api/templates/{template_id}/variables")
|
||||
def set_variables(request: Request, template_id: str, body: dict = Body(default={})):
|
||||
actor = _require_actor(request)
|
||||
variables = body.get("variables") if isinstance(body.get("variables"), list) else body.get("variables_json")
|
||||
if variables is None and isinstance(body, list):
|
||||
variables = body
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.update_template(
|
||||
conn, actor, template_id, {"variables": variables or []}
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.post("/api/templates/{template_id}/set-default")
|
||||
def set_default(request: Request, template_id: str):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.set_default(conn, actor, template_id)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.post("/api/templates/{template_id}/unset-default")
|
||||
def unset_default(request: Request, template_id: str):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
dto = TemplateService.unset_default(conn, actor, template_id)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return dto
|
||||
|
||||
|
||||
@router.put("/api/templates/{template_id}/recurrence")
|
||||
def set_recurrence(request: Request, template_id: str, body: dict = Body(default={})):
|
||||
actor = _require_actor(request)
|
||||
spec = None
|
||||
if body and body.get("rrule"):
|
||||
spec = {
|
||||
"rrule": body.get("rrule"),
|
||||
"timezone": body.get("timezone") or "",
|
||||
"enabled": body.get("enabled", True),
|
||||
}
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
recurrence = TemplateService.set_recurrence(conn, actor, template_id, spec)
|
||||
conn.commit()
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return {"template_id": template_id, "recurrence": recurrence}
|
||||
|
||||
|
||||
@router.post("/api/templates/{template_id}/preview")
|
||||
def preview_template(request: Request, template_id: str, body: dict = Body(default={})):
|
||||
actor = _actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
result = TemplateService.preview(
|
||||
conn, actor, template_id, body.get("variables") or {},
|
||||
title=(body.get("title") or ""),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/templates/{template_id}/instantiate")
|
||||
def instantiate_template(request: Request, template_id: str, body: dict = Body(default={})):
|
||||
actor = _actor(request)
|
||||
idempotency_key = request.headers.get("Idempotency-Key") or body.get("idempotency_key")
|
||||
context = body.get("context") or {}
|
||||
# La cible « page courante » porte l'id de page pour ajout/remplacement.
|
||||
if body.get("page_id") and "target" not in context:
|
||||
context["target"] = {"type": "current_page", "page_id": body.get("page_id"),
|
||||
"mode": body.get("mode") or "fill"}
|
||||
if body.get("collection_id") and "collection_id" not in context:
|
||||
context["collection_id"] = body.get("collection_id")
|
||||
if body.get("parent_page_id") and "parent_page_id" not in context:
|
||||
context["parent_page_id"] = body.get("parent_page_id")
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
result = TemplateService.instantiate(
|
||||
conn, actor, template_id, body.get("variables") or {}, context,
|
||||
idempotency_key=idempotency_key,
|
||||
run_source="api" if request.headers.get("authorization") else "ui",
|
||||
)
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/api/templates/runs/{run_id}/undo")
|
||||
def undo_run(request: Request, run_id: str):
|
||||
actor = _require_actor(request)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
result = TemplateService.mark_undone(conn, actor, run_id)
|
||||
except Exception as exc:
|
||||
raise _as_http(exc) from None
|
||||
return result
|
||||
|
||||
|
||||
# ═══════════ Création depuis l'existant (create_from_*) ═══════════
|
||||
|
||||
def _create_from_page(conn, actor, page_id: int, spec: dict) -> dict:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise LookupError("Page source introuvable")
|
||||
blocks: list = []
|
||||
try:
|
||||
if (row["content_format"] or "") == "blocks" and row["content"]:
|
||||
blocks = json.loads(row["content"]) or []
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
blocks = []
|
||||
return TemplateService.create_template(
|
||||
conn, actor,
|
||||
{
|
||||
"kind": spec.get("kind") or "page",
|
||||
"name": spec.get("name") or f"{row['title'] or 'Sans titre'} (template)",
|
||||
"description": spec.get("description") or "",
|
||||
"icon": spec.get("icon") or (row["page_icon"] if "page_icon" in row.keys() else "📄") or "📄",
|
||||
"category": spec.get("category") or "",
|
||||
"scope": spec.get("scope") or "personal",
|
||||
"workspace_id": spec.get("workspace_id"),
|
||||
"target_collection_id": spec.get("target_collection_id"),
|
||||
"blocks": blocks if isinstance(blocks, list) else [],
|
||||
"variables": spec.get("variables") or [],
|
||||
"title_template": spec.get("title_template") or f"{row['title'] or ''}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _create_from_collection(conn, actor, collection_id: int, spec: dict) -> dict:
|
||||
collection = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not collection:
|
||||
raise LookupError("Base source introuvable")
|
||||
props = [
|
||||
dict(p)
|
||||
for p in conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
schema = [
|
||||
{
|
||||
"name": p["name"],
|
||||
"type": p["prop_type"] if "prop_type" in p else p.get("type", "text"),
|
||||
"options": json.loads(p.get("options_json") or "[]"),
|
||||
}
|
||||
for p in props
|
||||
]
|
||||
manifest = {
|
||||
"format": "flowdeck-template",
|
||||
"version": 1,
|
||||
"collection": {
|
||||
"name": collection["name"],
|
||||
"icon": collection["icon"] if "icon" in collection.keys() else "📋",
|
||||
"schema": schema,
|
||||
},
|
||||
}
|
||||
if spec.get("with_seed_rows"):
|
||||
manifest["collection"]["seed_rows"] = []
|
||||
return TemplateService.create_template(
|
||||
conn, actor,
|
||||
{
|
||||
"kind": "database",
|
||||
"name": spec.get("name") or f"{collection['name']} (template)",
|
||||
"description": spec.get("description") or (collection["description"] or ""),
|
||||
"icon": (collection["icon"] if "icon" in collection.keys() else "") or "🗂",
|
||||
"category": spec.get("category") or "Base",
|
||||
"scope": spec.get("scope") or "personal",
|
||||
"workspace_id": spec.get("workspace_id"),
|
||||
"manifest": manifest,
|
||||
"variables": spec.get("variables") or [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _create_from_row(conn, actor, from_row: dict | int, spec: dict) -> dict:
|
||||
if isinstance(from_row, dict):
|
||||
collection_id = from_row.get("collection_id")
|
||||
row_id = from_row.get("row_id")
|
||||
else:
|
||||
collection_id, row_id = spec.get("target_collection_id"), from_row
|
||||
if not collection_id or not row_id:
|
||||
raise ValueError("from_row exige {collection_id, row_id}")
|
||||
source = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE id=? AND collection_id=?",
|
||||
(row_id, collection_id),
|
||||
).fetchone()
|
||||
if not source:
|
||||
raise LookupError("Ligne source introuvable")
|
||||
try:
|
||||
pv = json.loads(source["property_values_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pv = {}
|
||||
# Les valeurs sont re-cléées par nom de propriété pour survivre à l'export.
|
||||
props = {
|
||||
str(p["id"]): p["name"]
|
||||
for p in conn.execute(
|
||||
"SELECT id, name FROM collection_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
}
|
||||
defaults = {props.get(k, k): v for k, v in (pv if isinstance(pv, dict) else {}).items()}
|
||||
shadow_blocks: list = []
|
||||
try:
|
||||
shadow = conn.execute(
|
||||
"SELECT content, content_format FROM pages WHERE collection_row_id=?",
|
||||
(row_id,),
|
||||
).fetchone()
|
||||
if shadow and (shadow["content_format"] or "") == "blocks" and shadow["content"]:
|
||||
shadow_blocks = json.loads(shadow["content"]) or []
|
||||
except Exception:
|
||||
shadow_blocks = []
|
||||
return TemplateService.create_template(
|
||||
conn, actor,
|
||||
{
|
||||
"kind": "row",
|
||||
"name": spec.get("name") or f"{source['title'] or 'Sans titre'} (template)",
|
||||
"description": spec.get("description") or "",
|
||||
"icon": spec.get("icon") or "📄",
|
||||
"category": spec.get("category") or "",
|
||||
"scope": spec.get("scope") or "personal",
|
||||
"workspace_id": spec.get("workspace_id"),
|
||||
"target_collection_id": collection_id,
|
||||
"blocks": shadow_blocks if isinstance(shadow_blocks, list) else [],
|
||||
"manifest": {"property_defaults": defaults},
|
||||
"title_template": spec.get("title_template") or (source["title"] or ""),
|
||||
"variables": spec.get("variables") or [],
|
||||
},
|
||||
)
|
||||
@@ -1,10 +1,30 @@
|
||||
"""GitHub OAuth — status and disconnect routes."""
|
||||
from fastapi import APIRouter, Request
|
||||
"""GitHub OAuth + repository browsing (mirrors /api/gitea shape for shared UI)."""
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(tags=["github"], prefix="/api/github")
|
||||
|
||||
|
||||
def _require_github(request: Request):
|
||||
"""Return a per-user GitHubAdapter or raise 401 (same contract as Gitea)."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.github_adapter import GitHubAdapter
|
||||
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='github' "
|
||||
"AND access_token IS NOT NULL AND access_token != '' ORDER BY updated_at DESC LIMIT 1",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=401, detail="GitHub account not linked. Go to Settings → Integrations to connect.")
|
||||
return GitHubAdapter(row["access_token"])
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def github_status(request: Request):
|
||||
"""Check if the current user has GitHub linked."""
|
||||
@@ -33,3 +53,68 @@ def disconnect_github(request: Request):
|
||||
conn.execute("DELETE FROM user_oauth_tokens WHERE user_id=? AND provider='github'", (user["id"],))
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── Repo tree (same shape as /api/gitea/projects/{owner}/{repo}/tree) ──
|
||||
@router.get("/projects/{owner}/{repo}/tree")
|
||||
async def github_repo_tree(request: Request, owner: str, repo: str, path: str = ""):
|
||||
"""List one level of a GitHub repo (folders first client-side)."""
|
||||
adapter = _require_github(request)
|
||||
try:
|
||||
tree = await adapter.get_repo_tree(owner, repo)
|
||||
prefix = path.strip("/").strip()
|
||||
items = []
|
||||
seen_dirs: set[str] = set()
|
||||
for entry in tree:
|
||||
p = (entry.get("path") or "").strip("/")
|
||||
if not p:
|
||||
continue
|
||||
if prefix:
|
||||
if p != prefix and not p.startswith(prefix + "/"):
|
||||
continue
|
||||
rel = p[len(prefix) + 1:] if p != prefix else ""
|
||||
else:
|
||||
rel = p
|
||||
if not rel:
|
||||
continue
|
||||
if "/" in rel:
|
||||
top = rel.split("/")[0]
|
||||
dir_path = f"{prefix}/{top}".strip("/") if prefix else top
|
||||
if dir_path not in seen_dirs:
|
||||
seen_dirs.add(dir_path)
|
||||
items.append({"name": top, "path": dir_path, "type": "folder", "size": 0, "sha": ""})
|
||||
else:
|
||||
is_dir = entry.get("type") == "tree"
|
||||
items.append({
|
||||
"name": rel,
|
||||
"path": p,
|
||||
"type": "folder" if is_dir else "file",
|
||||
"size": entry.get("size", 0),
|
||||
"sha": entry.get("sha", ""),
|
||||
})
|
||||
# De-duplicate (recursive tree can list both tree + blob entries)
|
||||
dedup: dict[str, dict] = {}
|
||||
for it in items:
|
||||
key = it["path"]
|
||||
if key not in dedup or (dedup[key]["type"] == "file" and it["type"] == "folder"):
|
||||
dedup[key] = it
|
||||
return {"tree": sorted(dedup.values(), key=lambda x: (x["type"] != "folder", x["name"].lower()))}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
|
||||
|
||||
@router.get("/projects/{owner}/{repo}/file")
|
||||
async def github_get_file(request: Request, owner: str, repo: str, path: str = ""):
|
||||
"""Get decoded file content (same shape as Gitea endpoint)."""
|
||||
adapter = _require_github(request)
|
||||
if not path:
|
||||
return JSONResponse({"error": "Path required"}, status_code=400)
|
||||
try:
|
||||
content = await adapter.get_file_content(owner, repo, path)
|
||||
return {"content": content, "path": path}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=502)
|
||||
|
||||
+124
-15
@@ -52,21 +52,18 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
|
||||
else:
|
||||
url = f"/pages/{page_id}"
|
||||
|
||||
# Icon
|
||||
# Icon — convention Workspace (noms SVG, comme la sidebar) : la liste
|
||||
# des favoris de la sidebar consomme item.icon via fdIconHtml.
|
||||
content_format = db_row.get("content_format", "blocks")
|
||||
if db_row.get("is_folder"):
|
||||
icon = "📁"
|
||||
icon = "folder"
|
||||
elif content_format == "file":
|
||||
icon = "📄"
|
||||
icon = "file"
|
||||
fn = title.lower()
|
||||
if fn.endswith(".pdf"):
|
||||
icon = "📕"
|
||||
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
|
||||
icon = "🖼️"
|
||||
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
|
||||
icon = "📜"
|
||||
if any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
|
||||
icon = "image"
|
||||
else:
|
||||
icon = "📝"
|
||||
icon = "edit"
|
||||
|
||||
return {
|
||||
"id": page_id,
|
||||
@@ -75,6 +72,8 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
|
||||
"page_icon": db_row.get("page_icon") or "",
|
||||
"is_folder": bool(db_row.get("is_folder", 0)),
|
||||
"source_type": source_type,
|
||||
"source_icon": {"local": "file", "gitea": "link",
|
||||
"github": "external-link"}.get(source_type, "file"),
|
||||
"source_label": _source_label(source_type, workspace),
|
||||
"workspace": workspace,
|
||||
"workspace_name": _source_label(source_type, workspace),
|
||||
@@ -104,7 +103,63 @@ def _apply_source_filter(query: str, params: list, source_type: str) -> tuple[st
|
||||
|
||||
def _rows_to_items(rows, uid: int = 1) -> list:
|
||||
items = [_build_item(dict(r), uid) for r in rows]
|
||||
return _attach_tags(items)
|
||||
items = _attach_tags(items)
|
||||
return _attach_provenance(items, uid)
|
||||
|
||||
|
||||
def _attach_provenance(items: list, uid: int = 1) -> list:
|
||||
"""Created by / Last edited by / Last visited (colonnes Library).
|
||||
|
||||
- created_by : premier auteur des versions, sinon propriétaire du workspace ;
|
||||
- edited_by : dernier auteur des versions, sinon created_by ;
|
||||
- visited_at : dernier accès tracé de l'utilisateur (table recents).
|
||||
Requêtes groupées : 3 allers-retours quelle que soit la taille du lot.
|
||||
"""
|
||||
if not items:
|
||||
return items
|
||||
ids = [it["id"] for it in items]
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
ver = conn.execute(
|
||||
f"SELECT v.page_id, COALESCE(NULLIF(u.full_name,''), u.login, '') AS name "
|
||||
f"FROM page_versions v LEFT JOIN users u ON u.id = v.user_id "
|
||||
f"WHERE v.page_id IN ({placeholders}) ORDER BY v.id", ids,
|
||||
).fetchall()
|
||||
first: dict = {}
|
||||
last: dict = {}
|
||||
for r in ver:
|
||||
first.setdefault(r["page_id"], r["name"] or "")
|
||||
last[r["page_id"]] = r["name"] or ""
|
||||
own = conn.execute(
|
||||
f"SELECT p.id, COALESCE(NULLIF(u.full_name,''), u.login, '') AS owner "
|
||||
f"FROM pages p LEFT JOIN workspaces w ON w.id = p.workspace_id "
|
||||
f"LEFT JOIN users u ON u.id = w.owner_id "
|
||||
f"WHERE p.id IN ({placeholders})", ids,
|
||||
).fetchall()
|
||||
owners = {r["id"]: r["owner"] or "" for r in own}
|
||||
try:
|
||||
vis = conn.execute(
|
||||
f"SELECT page_id, MAX(accessed_at) AS visited_at FROM recents "
|
||||
f"WHERE user_id = ? AND page_id IN ({placeholders}) "
|
||||
f"GROUP BY page_id", [uid, *ids],
|
||||
).fetchall()
|
||||
except Exception:
|
||||
vis = []
|
||||
visited = {r["page_id"]: r["visited_at"] or "" for r in vis}
|
||||
except Exception:
|
||||
logger.exception("_attach_provenance")
|
||||
return items
|
||||
for it in items:
|
||||
pid = it["id"]
|
||||
author = first.get(pid) or owners.get(pid, "")
|
||||
it["author"] = author
|
||||
it["author_initial"] = (author or "?")[0].upper()
|
||||
edited_by = last.get(pid) or author
|
||||
it["edited_by"] = edited_by
|
||||
it["edited_by_initial"] = (edited_by or "?")[0].upper()
|
||||
it["visited_at"] = visited.get(pid, "")
|
||||
return items
|
||||
|
||||
|
||||
def _attach_tags(items: list) -> list:
|
||||
@@ -156,6 +211,59 @@ BASE_SELECT = (
|
||||
)
|
||||
|
||||
|
||||
# Lignes contenant un bloc AI Meeting Notes (formats JSON navigateur/Python).
|
||||
MEETING_BLOCK_LIKE = "REPLACE(COALESCE(p.content,''), ' ', '') LIKE '%\"type\":\"meeting\"%'"
|
||||
NO_MEETING_BLOCK = (
|
||||
"REPLACE(COALESCE(p.content,''), ' ', '') NOT LIKE '%\"type\":\"meeting\"%'"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/meetings")
|
||||
def library_meetings(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
):
|
||||
"""Onglet Library « AI Meeting Notes » : notes de réunion de l'utilisateur.
|
||||
|
||||
Regroupées côté client par date (Today / Yesterday / …). Les notes sont
|
||||
exclues des onglets de navigation courante (recents/workspace/private).
|
||||
"""
|
||||
if source_type not in SOURCE_TYPES:
|
||||
source_type = "all"
|
||||
|
||||
uid = _get_user_id(request)
|
||||
query = (
|
||||
f"{BASE_SELECT}, "
|
||||
"(SELECT mt.status FROM meeting_transcripts mt WHERE mt.page_id = p.id "
|
||||
" ORDER BY mt.id DESC LIMIT 1) AS meeting_status, "
|
||||
"(SELECT mt.summary FROM meeting_transcripts mt WHERE mt.page_id = p.id "
|
||||
" ORDER BY mt.id DESC LIMIT 1) AS meeting_summary "
|
||||
"FROM pages p LEFT JOIN workspaces w ON w.id = p.workspace_id "
|
||||
"WHERE p.deleted_at IS NULL AND (w.owner_id = ? OR p.workspace_id IS NULL) "
|
||||
f"AND {MEETING_BLOCK_LIKE}"
|
||||
)
|
||||
params: list = [uid]
|
||||
query, params = _apply_source_filter(query, params, source_type)
|
||||
query += " ORDER BY p.updated_at DESC LIMIT 100"
|
||||
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
items = _rows_to_items(rows, uid)
|
||||
for it, r in zip(items, rows, strict=True):
|
||||
d = dict(r)
|
||||
st = d.get("meeting_status") or "idle"
|
||||
it["is_meeting"] = True
|
||||
it["meeting_status"] = st
|
||||
it["source_label"] = (
|
||||
"Summary ready" if (d.get("meeting_summary") or "").strip()
|
||||
else ("Ready to record" if st == "idle"
|
||||
else st.replace("_", " ").capitalize())
|
||||
)
|
||||
_enrich_children(items)
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.get("/recents")
|
||||
def library_recents(
|
||||
request: Request,
|
||||
@@ -167,7 +275,7 @@ def library_recents(
|
||||
source_type = "all"
|
||||
|
||||
uid = _get_user_id(request)
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}"
|
||||
params: list = []
|
||||
|
||||
# Hierarchical view: show only root-level pages at the top
|
||||
@@ -301,7 +409,8 @@ def library_published(
|
||||
source_type = "all"
|
||||
|
||||
uid = _get_user_id(request)
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE p.published = 1 AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
# v7.69.6 : les deux drapeaux (lignes historiques issues d'un seul chemin).
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE (p.is_published = 1 OR p.published = 1) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
params: list = []
|
||||
if tree:
|
||||
query += " AND p.parent_id IS NULL"
|
||||
@@ -326,7 +435,7 @@ def library_private(
|
||||
source_type = "all"
|
||||
|
||||
uid = _get_user_id(request)
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL"
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}"
|
||||
params: list = []
|
||||
if tree:
|
||||
query += " AND p.parent_id IS NULL"
|
||||
@@ -392,7 +501,7 @@ def library_workspace(
|
||||
source_type = "all"
|
||||
|
||||
uid = _get_user_id(request)
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE (p.workspace != '' OR p.workspace_id IS NOT NULL) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
|
||||
query = f"{BASE_SELECT} FROM pages p WHERE (p.workspace != '' OR p.workspace_id IS NOT NULL) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}"
|
||||
params: list = []
|
||||
|
||||
if tree:
|
||||
|
||||
+397
-2
@@ -9,7 +9,9 @@ See ``docs/V71_Calendar_Meetings.md``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -115,8 +117,109 @@ def freebusy(collection_id: int, request: Request):
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts")
|
||||
def create_transcript(request: Request, body: dict = Body(default={})):
|
||||
"""Crée une note de réunion vide (état idle) pour une page — §6.5.
|
||||
|
||||
Idempotent par occurrence calendrier : renvoie la note existante.
|
||||
"""
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
page_id = int(body.get("page_id", 0))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(400, "page_id required") from None
|
||||
occ = (body.get("event_occurrence_id") or "").strip()[:200]
|
||||
with get_conn() as conn:
|
||||
if occ:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM meeting_transcripts WHERE event_occurrence_id=?",
|
||||
(occ,)).fetchone()
|
||||
if row:
|
||||
return {**row_to_dict(row), "already_existed": True}
|
||||
try:
|
||||
tid = meet.save_transcript(
|
||||
page_id, "", (body.get("language") or "fr")[:10],
|
||||
mode=(body.get("mode") or "mic_only")
|
||||
if (body.get("mode") or "mic_only") in meet.CAPTURE_MODES else "mic_only",
|
||||
instruction_id=(body.get("instruction_id") or "auto")[:40],
|
||||
event_occurrence_id=occ)
|
||||
except ValueError as exc:
|
||||
msg = str(exc)
|
||||
raise HTTPException(404 if "not found" in msg else 400, msg) from None
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?", (tid,)).fetchone()
|
||||
audit_log(user, "meeting.create", "page", page_id, f"transcript={tid}", request)
|
||||
return JSONResponse(status_code=201,
|
||||
content={**row_to_dict(row), "already_existed": False})
|
||||
|
||||
@router.post("/api/v2/meetings/transcribe")
|
||||
async def upload_and_transcribe(request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
@@ -186,13 +289,305 @@ def set_transcript_text(transcript_id: int, request: Request, body: dict = Body(
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/summarize")
|
||||
async def summarize(transcript_id: int, request: Request):
|
||||
async def summarize(transcript_id: int, request: Request,
|
||||
body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
out = await meet.summarize_transcript(transcript_id, user.get("id"))
|
||||
out = await meet.summarize_transcript(
|
||||
transcript_id, user.get("id"),
|
||||
allow_empty=bool((body or {}).get("allow_empty")))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(502, str(exc)) from None
|
||||
audit_log(user, "meeting.summarize", "transcript", transcript_id, "", request)
|
||||
return out
|
||||
|
||||
|
||||
# ── Bloc Notion v7.1.1 : machine à états, consentement, segments, partage ───
|
||||
|
||||
@router.get("/api/v2/meetings/instructions")
|
||||
def meeting_instructions(request: Request):
|
||||
_auth_user(request)
|
||||
return {"instructions": [
|
||||
{"id": k, "label": v["label"]} for k, v in meet.INSTRUCTIONS.items()
|
||||
]}
|
||||
|
||||
|
||||
# Une page est une « note de réunion » dès qu'elle contient un bloc
|
||||
# AI Meeting Notes. Le contenu est du JSON (``"type":"meeting"`` côté
|
||||
# navigateur, ``"type": "meeting"`` côté Python) → comparaison sans espaces.
|
||||
MEETING_BLOCK_PREDICATE = (
|
||||
"REPLACE(COALESCE({col},''), ' ', '') LIKE '%\"type\":\"meeting\"%'"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/v2/meetings/notes")
|
||||
def meeting_notes(request: Request, limit: int = 50):
|
||||
"""Notes de réunion : pages contenant un bloc AI Meeting Notes.
|
||||
|
||||
Alimente la section Meetings de la sidebar (ces pages sont exclues de
|
||||
l'arbre Workspace, voir ``_load_workspace_pages``). Inclut les notes
|
||||
jamais enregistrées (aucune ligne ``meeting_transcripts``) avec
|
||||
``status = idle``.
|
||||
"""
|
||||
user = _auth_user(request)
|
||||
uid = user.get("id")
|
||||
lim = max(1, min(int(limit or 50), 200))
|
||||
pred = MEETING_BLOCK_PREDICATE.format(col="p.content")
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"""SELECT p.id, p.title, p.updated_at,
|
||||
(SELECT mt.status FROM meeting_transcripts mt
|
||||
WHERE mt.page_id = p.id ORDER BY mt.id DESC LIMIT 1) AS status,
|
||||
(SELECT mt.summary FROM meeting_transcripts mt
|
||||
WHERE mt.page_id = p.id ORDER BY mt.id DESC LIMIT 1) AS summary
|
||||
FROM pages p
|
||||
LEFT JOIN workspaces w ON w.id = p.workspace_id
|
||||
WHERE p.deleted_at IS NULL
|
||||
AND (w.owner_id = ? OR p.workspace_id IS NULL)
|
||||
AND {pred}
|
||||
ORDER BY p.updated_at DESC LIMIT ?""",
|
||||
(uid, lim),
|
||||
).fetchall()
|
||||
notes = []
|
||||
for r in rows:
|
||||
d = row_to_dict(r)
|
||||
notes.append({
|
||||
"id": d["id"],
|
||||
"title": d.get("title") or "Untitled",
|
||||
"updated_at": d.get("updated_at") or "",
|
||||
"status": d.get("status") or "idle",
|
||||
"has_summary": bool((d.get("summary") or "").strip()),
|
||||
})
|
||||
return {"notes": notes, "count": len(notes)}
|
||||
|
||||
|
||||
@router.get("/api/v2/meetings/history")
|
||||
def meetings_history(request: Request, query: str = "", limit: int = 50):
|
||||
"""Historique des notes de réunion (passé + recherche plein texte)."""
|
||||
user = _auth_user(request)
|
||||
_ = user
|
||||
like = f"%{query.strip()}%" if query.strip() else "%"
|
||||
lim = max(1, min(int(limit or 50), 200))
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT mt.*, p.title AS page_title FROM meeting_transcripts mt
|
||||
JOIN pages p ON p.id = mt.page_id
|
||||
WHERE (p.title LIKE ? OR mt.transcript LIKE ?
|
||||
OR mt.summary LIKE ? OR mt.generated_title LIKE ?)
|
||||
ORDER BY mt.id DESC LIMIT ?""",
|
||||
(like, like, like, like, lim),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = row_to_dict(r)
|
||||
d["title"] = d.get("generated_title") or d.get("page_title") or "Meeting"
|
||||
out.append(d)
|
||||
return {"meetings": out, "count": len(out)}
|
||||
|
||||
|
||||
@router.get("/api/v2/meetings/transcripts/{transcript_id}")
|
||||
def get_transcript_row(transcript_id: int, request: Request):
|
||||
_auth_user(request)
|
||||
try:
|
||||
return meet.get_transcript(transcript_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc)) from None
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/consents")
|
||||
def post_consent(transcript_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
entry = meet.record_consent(
|
||||
transcript_id, method=(body.get("method") or "start_attestation"),
|
||||
message_ref=(body.get("message_ref") or "")[:200],
|
||||
attested_by=user.get("id"),
|
||||
participants=body.get("participants") or [])
|
||||
except ValueError as exc:
|
||||
msg = str(exc)
|
||||
raise HTTPException(404 if "not found" in msg else 400, msg) from None
|
||||
audit_log(user, "meeting.consent", "transcript", transcript_id,
|
||||
entry["method"], request)
|
||||
return entry
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/sessions")
|
||||
def start_session(transcript_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
out = meet.start_session(
|
||||
transcript_id, mode=(body.get("mode") or "mic_only"),
|
||||
channels=body.get("channels"), language=(body.get("language") or "fr"),
|
||||
instruction_id=(body.get("instruction_id") or "auto"),
|
||||
user_id=user.get("id"))
|
||||
except ValueError as exc:
|
||||
msg = str(exc)
|
||||
raise HTTPException(404 if "not found" in msg else 400, msg) from None
|
||||
audit_log(user, "meeting.session.start", "transcript", transcript_id,
|
||||
out.get("mode", ""), request)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/pause")
|
||||
def pause_session(transcript_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
out = meet.pause_session(transcript_id,
|
||||
paused_ms_delta=int(body.get("paused_ms") or 0))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
audit_log(user, "meeting.session.pause", "transcript", transcript_id, "", request)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/resume")
|
||||
def resume_session(transcript_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
out = meet.resume_session(transcript_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
audit_log(user, "meeting.session.resume", "transcript", transcript_id, "", request)
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/stop")
|
||||
async def stop_session(transcript_id: int, request: Request,
|
||||
body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
stopped = meet.stop_session(transcript_id,
|
||||
duration_ms=int((body or {}).get("duration_ms") or 0))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
audit_log(user, "meeting.session.stop", "transcript", transcript_id, "", request)
|
||||
# Le traitement démarre aussitôt (étapes Thinking persistées) ; en cas
|
||||
# d'échec LLM le transcript reste consultable (état failed, retry).
|
||||
# allow_empty : arrêt sans rien de capté → diagnostic honnête (§12.8),
|
||||
# jamais une ligne coincée en « processing ».
|
||||
try:
|
||||
out = await meet.run_processing(
|
||||
transcript_id, user.get("id"),
|
||||
allow_empty=bool((body or {}).get("allow_empty")))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(502, str(exc)) from None
|
||||
return {"session": {"id": stopped["id"], "status": "done"}, **out}
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/segments")
|
||||
def append_segments(transcript_id: int, request: Request,
|
||||
body: dict = Body(default={})):
|
||||
_auth_user(request, require_write=True)
|
||||
try:
|
||||
return meet.append_segments(transcript_id, body.get("segments") or [])
|
||||
except ValueError as exc:
|
||||
msg = str(exc)
|
||||
raise HTTPException(404 if "not found" in msg else 400, msg) from None
|
||||
|
||||
|
||||
@router.get("/api/v2/meetings/transcripts/{transcript_id}/transcript")
|
||||
def grouped_transcript(transcript_id: int, request: Request):
|
||||
_auth_user(request)
|
||||
try:
|
||||
return meet.grouped_transcript(transcript_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc)) from None
|
||||
|
||||
|
||||
@router.patch("/api/v2/meetings/transcripts/{transcript_id}/segments/{seq}/speaker")
|
||||
def patch_speaker(transcript_id: int, seq: int, request: Request,
|
||||
body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
return meet.set_segment_speaker(transcript_id, seq,
|
||||
body.get("speaker") or "",
|
||||
user.get("id"))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
|
||||
|
||||
@router.get("/api/v2/meetings/transcripts/{transcript_id}/processing-steps")
|
||||
def processing_steps(transcript_id: int, request: Request):
|
||||
_auth_user(request)
|
||||
try:
|
||||
tr = meet.get_transcript(transcript_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc)) from None
|
||||
import json as _json
|
||||
try:
|
||||
steps = _json.loads(tr.get("processing_steps_json") or "[]")
|
||||
except (TypeError, ValueError):
|
||||
steps = []
|
||||
return {"transcript_id": transcript_id, "status": tr.get("status") or "idle",
|
||||
"steps": steps}
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/processing:retry")
|
||||
async def retry_processing(transcript_id: int, request: Request,
|
||||
body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
tr = meet.get_transcript(transcript_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(404, str(exc)) from None
|
||||
if tr.get("status") not in ("failed", "processing", "done"):
|
||||
raise HTTPException(400, "nothing to retry — no failed processing")
|
||||
try:
|
||||
return await meet.run_processing(
|
||||
transcript_id, user.get("id"),
|
||||
allow_empty=bool((body or {}).get("allow_empty")))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(502, str(exc)) from None
|
||||
|
||||
|
||||
@router.patch("/api/v2/meetings/transcripts/{transcript_id}/generated-title")
|
||||
def patch_title(transcript_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
title = (body.get("title") or "").strip()[:200]
|
||||
if not title:
|
||||
raise HTTPException(400, "title required")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM meeting_transcripts WHERE id=?",
|
||||
(transcript_id,)).fetchone():
|
||||
raise HTTPException(404, "Transcript not found")
|
||||
conn.execute("UPDATE meeting_transcripts SET generated_title=?,"
|
||||
" title_source='user' WHERE id=?", (title, transcript_id))
|
||||
conn.commit()
|
||||
audit_log(user, "meeting.retitle", "transcript", transcript_id, title, request)
|
||||
return {"transcript_id": transcript_id, "generated_title": title}
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/distributions")
|
||||
def post_distribution(transcript_id: int, request: Request,
|
||||
body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
out = meet.record_distribution(
|
||||
transcript_id, channel=(body.get("channel") or "copy_link"),
|
||||
actor_id=user.get("id"), recipients=(body.get("recipients") or "")[:500],
|
||||
status=body.get("status") or "created")
|
||||
except ValueError as exc:
|
||||
msg = str(exc)
|
||||
raise HTTPException(404 if "not found" in msg else 400, msg) from None
|
||||
audit_log(user, "meeting.share", "transcript", transcript_id,
|
||||
out["channel"], request)
|
||||
return out
|
||||
|
||||
|
||||
@router.patch("/api/v2/meetings/transcripts/{transcript_id}/notes")
|
||||
def patch_notes(transcript_id: int, request: Request, body: dict = Body(default={})):
|
||||
_auth_user(request, require_write=True)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM meeting_transcripts WHERE id=?",
|
||||
(transcript_id,)).fetchone():
|
||||
raise HTTPException(404, "Transcript not found")
|
||||
conn.execute("UPDATE meeting_transcripts SET notes_text=? WHERE id=?",
|
||||
((body.get("notes") or "")[:50000], transcript_id))
|
||||
conn.commit()
|
||||
return {"transcript_id": transcript_id, "saved": True}
|
||||
|
||||
+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
|
||||
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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:
|
||||
session = request.cookies.get("flowdeck_session", "")
|
||||
return SessionManager.decode_session(session)
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
|
||||
"""My Tasks — aggregates all pages assigned to the current user across all collections."""
|
||||
def _require_user(request: Request) -> dict:
|
||||
"""Session obligatoire, sans repli « admin » (id 1) : ce tableau de bord
|
||||
est strictement celui de l'appelant."""
|
||||
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:
|
||||
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()
|
||||
# ── Données ──────────────────────────────────────────────────────────────
|
||||
|
||||
collection_tasks = []
|
||||
for p in pages:
|
||||
props = {}
|
||||
try:
|
||||
props = json.loads(p["property_values_json"] or "{}")
|
||||
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
|
||||
def _workspace_names(conn, user_id: int) -> dict[int, str]:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name FROM workspaces WHERE owner_id = ?", (user_id,)
|
||||
).fetchall()
|
||||
return {r["id"]: r["name"] for r in rows}
|
||||
|
||||
total = sum(len(t) for t in grouped.values())
|
||||
|
||||
# Build content HTML
|
||||
sections = ""
|
||||
for col_name, tasks in grouped.items():
|
||||
items = ""
|
||||
for t in tasks:
|
||||
due_badge = f"<span class='mt-due'>{t['due_date']}</span>" if t.get("due_date") else ""
|
||||
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>"
|
||||
def _validated(view: str, due: str, sort: str) -> tuple[str, str, str]:
|
||||
"""Paramètres d'URL bornés aux valeurs réellement implémentées."""
|
||||
return (
|
||||
view if view in VIEWS else "table",
|
||||
due if due in DUE_FILTERS else "all",
|
||||
sort if sort in SORTS else "due",
|
||||
)
|
||||
|
||||
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;">
|
||||
<h1 style="font-size:24px;margin:0 0 8px;">📋 My Tasks</h1>
|
||||
<p style="color:var(--text-dim);font-size:14px;margin-bottom:24px;">{total} tasks across {len(grouped)} collections</p>
|
||||
<div class="view-tabs" style="display:flex;gap:4px;margin-bottom:24px;border-bottom:1px solid var(--border);padding-bottom:12px;">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
<style>
|
||||
.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 _public_source(s: dict) -> dict:
|
||||
return {
|
||||
"id": s["id"],
|
||||
"name": s["name"],
|
||||
"icon": s["icon"],
|
||||
"workspace_id": s["workspace_id"],
|
||||
"workspace_name": s.get("workspace_name") or "",
|
||||
"configured": s["configured"],
|
||||
"missing_roles": s["missing_roles"],
|
||||
"status_options": s["status_options"],
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
env = ENV
|
||||
|
||||
sidebar = _sidebar_data(request, [])
|
||||
block_tpl = env.from_string(
|
||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||
# `assets` est rendu par un mini-template : `asset_version` (cache-busting)
|
||||
# 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(
|
||||
**sidebar,
|
||||
request=request,
|
||||
content_html=content_html,
|
||||
assets=assets,
|
||||
page_title="My Tasks",
|
||||
title_prefix="My Tasks",
|
||||
page_icon="📋",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
def my_tasks_api(request: Request, view: str = "all", days: int = 7):
|
||||
"""API: return my tasks as JSON."""
|
||||
def _json_attr(obj) -> str:
|
||||
"""JSON échappé pour un attribut HTML."""
|
||||
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("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:
|
||||
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
|
||||
result = {}
|
||||
coll = conn.execute(
|
||||
"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:
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
|
||||
(col["id"],),
|
||||
props_meta = [
|
||||
dict(p) for p in conn.execute(
|
||||
"SELECT id, name, prop_type FROM collection_properties "
|
||||
"WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
tasks = []
|
||||
for p in pages:
|
||||
props = json.loads(p["property_values_json"])
|
||||
tasks.append({
|
||||
"id": p["id"], "title": p["title"], "icon": p["icon"],
|
||||
"properties": props,
|
||||
})
|
||||
properties: dict = {}
|
||||
title_prop = next((p for p in props_meta if p["prop_type"] == "title"), None)
|
||||
if title_prop:
|
||||
properties[str(title_prop["id"])] = title
|
||||
|
||||
if tasks:
|
||||
result[col["name"]] = tasks
|
||||
if body.get("due") and coll["task_due_prop"]:
|
||||
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 sqlite3
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["workspace"], prefix="/workspace")
|
||||
|
||||
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:
|
||||
s = request.cookies.get("flowdeck_session", "")
|
||||
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
|
||||
"""Session obligatoire — plus de repli « admin implicite » (A3/A4)."""
|
||||
return _require_session(request)
|
||||
|
||||
|
||||
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")
|
||||
def list_favorites(request: Request):
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
"""List the caller's favorites.
|
||||
|
||||
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:
|
||||
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
|
||||
LEFT JOIN collection_pages cp ON f.page_id=cp.id
|
||||
LEFT JOIN collections c ON f.collection_id=c.id
|
||||
LEFT JOIN pages p ON f.page_id=p.id
|
||||
WHERE f.user_id=? ORDER BY f.position""",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
return {"favorites": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
|
||||
@router.post("/favorites")
|
||||
def add_favorite(request: Request, body: dict = Body(default={})):
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
user = _require_session(request)
|
||||
uid = user["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:
|
||||
conn.execute("INSERT OR IGNORE INTO users (id, login, is_admin) VALUES (?,?,1)", (uid, user.get("login", "admin")))
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO favorites (user_id, page_id, collection_id) VALUES (?,?,?)",
|
||||
(uid, page_id, collection_id),
|
||||
"INSERT OR IGNORE INTO favorites (user_id, page_id) VALUES (?,?)",
|
||||
(uid, page_id),
|
||||
)
|
||||
conn.commit()
|
||||
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:
|
||||
logger.exception("add_favorite")
|
||||
return {"status": "favorited"}
|
||||
|
||||
|
||||
|
||||
@router.delete("/favorites/{fav_id}")
|
||||
def remove_favorite(request: Request, fav_id: int):
|
||||
user = _require_session(request)
|
||||
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()
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(404, "Favorite not found")
|
||||
return {"status": "removed"}
|
||||
|
||||
|
||||
@@ -714,7 +745,7 @@ def delete_webhook(request: Request, wh_id: int):
|
||||
|
||||
# ── Public Sharing ──
|
||||
|
||||
@router.get("/public/{collection_id}")
|
||||
@public_router.get("/public/{collection_id}")
|
||||
def public_view(request: Request, collection_id: int):
|
||||
"""Simple public read-only view — no auth required.
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import re
|
||||
|
||||
from app.config import settings
|
||||
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.context_builder import ContextBuilder
|
||||
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]
|
||||
system = self._build_system_prompt(agent, skills)
|
||||
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():
|
||||
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,
|
||||
model=used_model, tokens=self._tokens)
|
||||
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).
|
||||
await _fire_agent_webhook("agent.run.finished", {
|
||||
"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 app.db import get_conn
|
||||
from app.services import notifications
|
||||
from app.services import notifications, plugins
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -479,6 +479,9 @@ async def automation_scheduler():
|
||||
"""Background loop: fire due cron automations (checked every 60s)."""
|
||||
while True:
|
||||
try:
|
||||
if not plugins.is_enabled("automations"): # plugin OFF = rien de planifié
|
||||
await asyncio.sleep(60)
|
||||
continue
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM automations WHERE trigger_type='cron' AND enabled=1"
|
||||
|
||||
@@ -86,6 +86,34 @@ BUILTIN_TEMPLATES: dict[str, dict] = {
|
||||
_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 "")
|
||||
|
||||
|
||||
# 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:
|
||||
if not value:
|
||||
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)
|
||||
|
||||
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
|
||||
context so the LLM can summarise / rewrite / analyse it directly without
|
||||
@@ -135,6 +135,8 @@ class ContextBuilder:
|
||||
elif m.startswith("page:"):
|
||||
pid = m.split(":", 1)[1]
|
||||
lines.append(self._single_page(pid))
|
||||
elif m.startswith("folder:"):
|
||||
lines.append(self._single_folder(m.split(":", 1)[1]))
|
||||
elif m.startswith("repo:"):
|
||||
lines.append(f"- @repo: {m.split(':', 1)[1]} (Gitea issues available via read_gitea_issues)")
|
||||
elif m == "ws":
|
||||
@@ -209,6 +211,37 @@ class ContextBuilder:
|
||||
return f"{head}:\n{body[:9000]}"
|
||||
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:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, name, icon, schema_json FROM collections WHERE id=?", (cid,)).fetchone()
|
||||
|
||||
+224
-23
@@ -66,28 +66,226 @@ def _block_text(b: dict) -> str:
|
||||
# ── Source resolution: read real textual content for ANY page type ──
|
||||
|
||||
# Extensions whose content is plain text / code / markdown (textual exportable).
|
||||
# Mirrors static/js/code-highlight.js EXT_TO_LANG (single source of truth for
|
||||
# the viewer lives client-side; this set gates server-side text handling:
|
||||
# file viewer, copy-to-clipboard, exports, imports).
|
||||
_TEXTUAL_EXTS = {
|
||||
"md", "markdown", "txt", "log", "text",
|
||||
"py", "js", "ts", "jsx", "tsx", "html", "htm", "css", "json", "xml",
|
||||
"yaml", "yml", "toml", "ini", "cfg", "conf", "env", "sh", "bash", "zsh",
|
||||
"ps1", "bat", "cmd", "rb", "go", "rs", "java", "c", "cpp", "h", "hpp",
|
||||
"php", "swift", "kt", "scala", "sql", "r", "vue", "svelte", "astro",
|
||||
"properties", "gitignore", "dockerfile", "makefile",
|
||||
"md", "markdown", "mkd", "mdown", "mdwn", "rmd", "qmd",
|
||||
"txt", "log", "text", "csv", "tsv", "tab", "nfo", "asc",
|
||||
"py", "pyw", "pyi", "gyp", "gypi",
|
||||
"js", "mjs", "cjs", "jsx", "ts", "mts", "cts", "tsx",
|
||||
"html", "htm", "xhtml", "xht", "xml", "xsd", "xsl", "xslt",
|
||||
"rss", "atom", "plist", "wsf", "svg", "axml",
|
||||
"vue", "svelte", "astro",
|
||||
"css", "scss", "sass", "less",
|
||||
"json", "json5", "jsonc", "geojson", "webmanifest", "har", "avsc",
|
||||
"yaml", "yml", "toml", "ini", "cfg", "conf", "env", "properties",
|
||||
"inf", "reg", "url", "prop",
|
||||
"gitconfig", "gitmodules", "editorconfig",
|
||||
"sh", "bash", "zsh", "ksh", "mksh", "yash", "fish",
|
||||
"ps1", "psm1", "psd1", "bat", "cmd",
|
||||
"rb", "gemspec", "rake", "rbw",
|
||||
"go", "rs", "java", "jsp", "kt", "kts",
|
||||
"scala", "sc", "groovy", "gvy", "gradle",
|
||||
"c", "cpp", "cc", "cxx", "c++", "hh", "hxx", "hcc", "h", "hpp",
|
||||
"cs", "csx", "cake", "ino", "pde",
|
||||
"php", "phtml", "swift", "sql", "pgsql", "psql", "mysql", "mssql",
|
||||
"r", "jl", "lua", "dart",
|
||||
"pl", "pm", "pod",
|
||||
"hs", "lhs", "ex", "exs", "eex", "heex", "leex",
|
||||
"erl", "hrl", "escript",
|
||||
"clj", "cljs", "cljc", "edn",
|
||||
"ml", "mli", "fs", "fsi", "fsx", "fsscript",
|
||||
"nim", "nims", "d", "di",
|
||||
"m", "mm", "vb", "vbs", "bas", "pas", "pp", "dpr",
|
||||
"vhd", "vhdl", "v", "vh", "sv", "svh",
|
||||
"asm", "s", "nasm", "inc",
|
||||
"ada", "ads", "adb",
|
||||
"f", "for", "f90", "f95", "f03", "f08",
|
||||
"cob", "cbl", "cpy",
|
||||
"dockerfile", "containerfile", "tf", "tfvars", "hcl",
|
||||
"cmake", "makefile", "mk", "mak", "ninja",
|
||||
"gql", "graphql", "graphqls", "proto", "thrift",
|
||||
"vim", "vimrc", "gvimrc", "awk", "gcode", "gco", "nc", "ngc",
|
||||
"http", "rest", "nginx", "apache", "htaccess", "wat",
|
||||
"diff", "patch", "rej",
|
||||
"tex", "latex", "sty", "cls", "dtx", "bib", "bbl",
|
||||
"gitignore", "gitattributes", "gitkeep", "dockerignore", "npmignore",
|
||||
"lock", "mod", "sum",
|
||||
}
|
||||
# Well-known extensionless / dotfile basenames that are textual code.
|
||||
_TEXTUAL_FILENAMES = {
|
||||
"dockerfile", "containerfile", "makefile", "gnumakefile",
|
||||
"cmakelists.txt", "rakefile", "gemfile", "vagrantfile",
|
||||
"berksfile", "brewfile", "dangerfile", "fastfile", "podfile",
|
||||
"go.mod", "go.sum",
|
||||
".bashrc", ".bash_profile", ".bash_login", ".bash_logout",
|
||||
".zshrc", ".zshenv", ".profile", ".shrc", ".kshrc",
|
||||
".vimrc", ".gvimrc", ".viminfo",
|
||||
".gitconfig", ".editorconfig", ".htaccess",
|
||||
}
|
||||
# Exact basenames → highlight.js id (checked before the extension).
|
||||
_FILENAME_LANG = {
|
||||
"dockerfile": "dockerfile", "containerfile": "dockerfile",
|
||||
"makefile": "makefile", "gnumakefile": "makefile",
|
||||
"cmakelists.txt": "cmake",
|
||||
"rakefile": "ruby", "gemfile": "ruby", "vagrantfile": "ruby",
|
||||
"berksfile": "ruby", "brewfile": "ruby", "dangerfile": "ruby",
|
||||
"fastfile": "ruby", "podfile": "ruby",
|
||||
"go.mod": "go",
|
||||
"nginx.conf": "nginx",
|
||||
".bashrc": "bash", ".bash_profile": "bash", ".bash_login": "bash",
|
||||
".bash_logout": "bash", ".zshrc": "bash", ".zshenv": "bash",
|
||||
".profile": "bash", ".shrc": "bash", ".kshrc": "bash",
|
||||
".vimrc": "vim", ".gvimrc": "vim",
|
||||
".gitconfig": "ini", ".editorconfig": "ini", ".htaccess": "apache",
|
||||
}
|
||||
_CODE_LANG = {
|
||||
"py": "python", "js": "javascript", "ts": "typescript", "jsx": "javascript",
|
||||
"tsx": "typescript", "html": "html", "htm": "html", "css": "css",
|
||||
"json": "json", "xml": "xml", "yaml": "yaml", "yml": "yaml",
|
||||
"toml": "toml", "ini": "ini", "cfg": "ini", "conf": "ini", "env": "ini",
|
||||
"sh": "bash", "bash": "bash", "zsh": "bash", "ps1": "powershell",
|
||||
"bat": "batch", "cmd": "batch", "rb": "ruby", "go": "go", "rs": "rust",
|
||||
"java": "java", "c": "c", "cpp": "cpp", "h": "c", "hpp": "cpp",
|
||||
"php": "php", "swift": "swift", "kt": "kotlin", "scala": "scala",
|
||||
"sql": "sql", "r": "r", "vue": "html", "svelte": "html",
|
||||
"astro": "html", "properties": "ini", "md": "markdown",
|
||||
"markdown": "markdown", "txt": "plaintext", "log": "plaintext",
|
||||
"text": "plaintext",
|
||||
"py": "python", "pyw": "python", "pyi": "python",
|
||||
"gyp": "python", "gypi": "python",
|
||||
"js": "javascript", "mjs": "javascript", "cjs": "javascript",
|
||||
"jsx": "javascript",
|
||||
"ts": "typescript", "mts": "typescript", "cts": "typescript",
|
||||
"tsx": "typescript",
|
||||
"html": "xml", "htm": "xml", "xhtml": "xml", "xht": "xml",
|
||||
"xml": "xml", "xsd": "xml", "xsl": "xml", "xslt": "xml",
|
||||
"rss": "xml", "atom": "xml", "plist": "xml", "wsf": "xml",
|
||||
"svg": "xml", "axml": "xml",
|
||||
"vue": "xml", "svelte": "xml", "astro": "xml",
|
||||
"css": "css", "scss": "scss", "sass": "scss", "less": "less",
|
||||
"json": "json", "json5": "json", "jsonc": "json",
|
||||
"geojson": "json", "webmanifest": "json", "har": "json", "avsc": "json",
|
||||
"yaml": "yaml", "yml": "yaml",
|
||||
"toml": "ini", "ini": "ini", "cfg": "ini", "conf": "ini",
|
||||
"env": "ini", "properties": "ini", "prop": "ini",
|
||||
"inf": "ini", "reg": "ini", "url": "ini",
|
||||
"gitconfig": "ini", "gitmodules": "ini", "editorconfig": "ini",
|
||||
"ninja": "ini",
|
||||
"md": "markdown", "markdown": "markdown", "mkd": "markdown",
|
||||
"mdown": "markdown", "mdwn": "markdown", "rmd": "markdown",
|
||||
"qmd": "markdown",
|
||||
"tex": "latex", "latex": "latex", "sty": "latex", "cls": "latex",
|
||||
"dtx": "latex",
|
||||
"sql": "sql", "pgsql": "sql", "psql": "sql", "mysql": "sql",
|
||||
"mssql": "sql",
|
||||
"sh": "bash", "bash": "bash", "zsh": "bash", "ksh": "bash",
|
||||
"mksh": "bash", "yash": "bash",
|
||||
"fish": "shell",
|
||||
"ps1": "powershell", "psm1": "powershell", "psd1": "powershell",
|
||||
"bat": "dos", "cmd": "dos",
|
||||
"rb": "ruby", "gemspec": "ruby", "rake": "ruby", "rbw": "ruby",
|
||||
"go": "go", "rs": "rust",
|
||||
"java": "java", "jsp": "java",
|
||||
"kt": "kotlin", "kts": "kotlin",
|
||||
"scala": "scala", "sc": "scala",
|
||||
"groovy": "groovy", "gvy": "groovy", "gradle": "groovy",
|
||||
"c": "c", "h": "c",
|
||||
"cpp": "cpp", "cc": "cpp", "cxx": "cpp", "c++": "cpp",
|
||||
"hh": "cpp", "hxx": "cpp", "hcc": "cpp", "hpp": "cpp",
|
||||
"ino": "cpp", "pde": "cpp",
|
||||
"cs": "csharp", "csx": "csharp", "cake": "csharp",
|
||||
"php": "php", "phtml": "php-template",
|
||||
"swift": "swift", "r": "r", "jl": "julia", "lua": "lua",
|
||||
"dart": "dart", "pl": "perl", "pm": "perl", "pod": "perl",
|
||||
"hs": "haskell", "lhs": "haskell",
|
||||
"ex": "elixir", "exs": "elixir", "eex": "elixir",
|
||||
"heex": "elixir", "leex": "elixir",
|
||||
"erl": "erlang", "hrl": "erlang", "escript": "erlang",
|
||||
"clj": "clojure", "cljs": "clojure", "cljc": "clojure", "edn": "clojure",
|
||||
"ml": "ocaml", "mli": "ocaml",
|
||||
"fs": "fsharp", "fsi": "fsharp", "fsx": "fsharp", "fsscript": "fsharp",
|
||||
"nim": "nim", "nims": "nim",
|
||||
"d": "d", "di": "d",
|
||||
"m": "objectivec", "mm": "objectivec",
|
||||
"vb": "vbnet", "vbs": "vbnet", "bas": "vbnet",
|
||||
"pas": "delphi", "pp": "delphi", "dpr": "delphi",
|
||||
"vhd": "vhdl", "vhdl": "vhdl",
|
||||
"v": "verilog", "vh": "verilog", "sv": "verilog", "svh": "verilog",
|
||||
"asm": "x86asm", "s": "x86asm", "nasm": "x86asm", "inc": "x86asm",
|
||||
"ada": "ada", "ads": "ada", "adb": "ada",
|
||||
"f": "fortran", "for": "fortran", "f90": "fortran", "f95": "fortran",
|
||||
"f03": "fortran", "f08": "fortran",
|
||||
"cob": "plaintext", "cbl": "plaintext", "cpy": "plaintext",
|
||||
"dockerfile": "dockerfile",
|
||||
"tf": "plaintext", "tfvars": "plaintext", "hcl": "plaintext",
|
||||
"cmake": "cmake",
|
||||
"makefile": "makefile", "mk": "makefile", "mak": "makefile",
|
||||
"gql": "graphql", "graphql": "graphql", "graphqls": "graphql",
|
||||
"proto": "protobuf", "thrift": "thrift",
|
||||
"vim": "vim", "vimrc": "vim", "gvimrc": "vim",
|
||||
"awk": "awk",
|
||||
"gcode": "gcode", "gco": "gcode", "nc": "gcode", "ngc": "gcode",
|
||||
"http": "http", "rest": "http",
|
||||
"nginx": "nginx", "apache": "apache", "htaccess": "apache",
|
||||
"wat": "wasm",
|
||||
"diff": "diff", "patch": "diff", "rej": "diff",
|
||||
"bib": "plaintext", "bbl": "plaintext",
|
||||
"txt": "plaintext", "log": "plaintext", "text": "plaintext",
|
||||
"csv": "plaintext", "tsv": "plaintext", "tab": "plaintext",
|
||||
"nfo": "plaintext", "asc": "plaintext",
|
||||
"lock": "plaintext",
|
||||
"gitignore": "plaintext", "gitattributes": "plaintext",
|
||||
"gitkeep": "plaintext", "dockerignore": "plaintext",
|
||||
"npmignore": "plaintext",
|
||||
}
|
||||
# Aliases users may type in markdown fences / old block labels.
|
||||
_CODE_ALIASES = {
|
||||
"py": "python", "js": "javascript", "jsx": "javascript",
|
||||
"ts": "typescript", "tsx": "typescript",
|
||||
"html": "xml", "htm": "xml", "markup": "xml",
|
||||
"yml": "yaml", "md": "markdown",
|
||||
"sh": "bash", "zsh": "bash", "console": "shell",
|
||||
"ps1": "powershell", "ps": "powershell",
|
||||
"bat": "dos", "cmd": "dos", "batch": "dos",
|
||||
"c++": "cpp", "c#": "csharp", "cs": "csharp",
|
||||
"kt": "kotlin", "rb": "ruby", "rs": "rust", "pl": "perl",
|
||||
"toml": "ini", "cfg": "ini", "conf": "ini", "env": "ini",
|
||||
"tex": "latex", "docker": "dockerfile", "make": "makefile",
|
||||
"plain text": "plaintext", "plain": "plaintext", "text": "plaintext",
|
||||
"none": "plaintext", "": "plaintext",
|
||||
}
|
||||
# highlight.js ids served by static/js/highlight.min.js + highlight-extra.min.js.
|
||||
_HLJS_LANGS = frozenset({
|
||||
"ada", "apache", "armasm", "awk", "bash", "c", "clojure", "cmake", "cpp",
|
||||
"csharp", "css", "d", "dart", "delphi", "diff", "dockerfile", "dos",
|
||||
"elixir", "erlang", "fsharp", "fortran", "gcode", "go",
|
||||
"graphql", "groovy", "haskell", "http", "ini", "java", "javascript",
|
||||
"json", "julia", "kotlin", "latex", "less", "lua", "makefile",
|
||||
"markdown", "matlab", "nginx", "nim", "objectivec", "ocaml", "perl",
|
||||
"php", "php-template", "plaintext", "powershell", "protobuf", "python",
|
||||
"python-repl", "r", "ruby", "rust", "scala", "scss", "shell", "sql",
|
||||
"stan", "stata", "swift", "thrift", "typescript", "vbnet", "verilog",
|
||||
"vhdl", "vim", "wasm", "x86asm", "xml", "yaml",
|
||||
})
|
||||
|
||||
|
||||
def normalize_code_lang(lang: str | None) -> str:
|
||||
"""Normalize any code language label to a bundled highlight.js id.
|
||||
|
||||
Accepts highlight.js ids, file extensions, markdown fence names and the
|
||||
legacy ``Plain Text`` label. Unknown values degrade to ``plaintext``.
|
||||
"""
|
||||
lang = (lang or "").strip().lower()
|
||||
if not lang or lang in ("plain text", "none"):
|
||||
return "plaintext"
|
||||
if lang in _CODE_ALIASES:
|
||||
lang = _CODE_ALIASES[lang]
|
||||
if lang in _CODE_LANG.values() or lang in _HLJS_LANGS:
|
||||
return lang
|
||||
return "plaintext"
|
||||
|
||||
|
||||
def code_lang_for_file(filename: str | None) -> str:
|
||||
"""Best highlight.js id for an uploaded / repo file name."""
|
||||
name = (filename or "").replace("\\", "/").rsplit("/", 1)[-1].lower()
|
||||
if not name:
|
||||
return "plaintext"
|
||||
if name in _FILENAME_LANG:
|
||||
return _FILENAME_LANG[name]
|
||||
ext = name.rsplit(".", 1)[-1] if "." in name else ""
|
||||
return _CODE_LANG.get(ext, "plaintext")
|
||||
|
||||
|
||||
_MARKDOWN_MIMES = {"text/markdown", "text/x-markdown", "application/octet-stream"}
|
||||
|
||||
|
||||
@@ -119,7 +317,8 @@ def _file_text(page: dict) -> str | None:
|
||||
name = (rel.rsplit("/", 1)[-1] or "").lower()
|
||||
ext = name.rsplit(".", 1)[-1] if "." in name else ""
|
||||
mime = (meta.get("mime_type") or "").lower()
|
||||
if not (ext in _TEXTUAL_EXTS or mime.startswith("text/")):
|
||||
if not (ext in _TEXTUAL_EXTS or name in _TEXTUAL_FILENAMES
|
||||
or mime.startswith("text/")):
|
||||
return None
|
||||
try:
|
||||
full = (_data_root() / rel).resolve()
|
||||
@@ -161,8 +360,7 @@ def _page_source(page: dict):
|
||||
mime = (meta.get("mime_type") or "").lower()
|
||||
if ext in ("md", "markdown") or mime in _MARKDOWN_MIMES or mime.startswith("text/markdown"):
|
||||
return "md", text
|
||||
lang = _CODE_LANG.get(ext, "plaintext")
|
||||
return "code", (text, lang)
|
||||
return "code", (text, code_lang_for_file(name))
|
||||
|
||||
# Unknown format (e.g. legacy) -> try to dump as raw text
|
||||
if content.strip():
|
||||
@@ -497,7 +695,7 @@ def blocks_to_markdown(blocks: list) -> str:
|
||||
out.append("---")
|
||||
elif t == "code":
|
||||
lang = b.get("language") or ""
|
||||
out.append(f"```{lang}\n{c}\n```")
|
||||
out.append(f"```{normalize_code_lang(lang) if lang else ''}\n{c}\n```")
|
||||
elif t == "toggle":
|
||||
out.append(f"### {c}")
|
||||
if b.get("children"):
|
||||
@@ -615,8 +813,11 @@ def blocks_to_html(blocks: list) -> str:
|
||||
parts.append("<hr>")
|
||||
elif t == "code":
|
||||
lang = b.get("language") or ""
|
||||
hljs_lang = normalize_code_lang(lang)
|
||||
label = f'<div class="code-lang">{_text(lang)}</div>' if lang else ""
|
||||
parts.append(f"<pre>{label}<code>{c}</code></pre>")
|
||||
parts.append(
|
||||
f'<pre>{label}<code class="language-{hljs_lang}">{c}</code></pre>'
|
||||
)
|
||||
elif t == "math":
|
||||
parts.append(f'<div class="math">\\[{c}\\]</div>')
|
||||
elif t == "table_of_contents":
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
"""FlowDeck — Projection Form builder -> pipeline public /f/ (§16.5).
|
||||
|
||||
Le builder ecrit ``form_questions`` + reglages de la vue form ; le
|
||||
projecteur regenere ``collections.form_config_json`` (format v6.8 inchange)
|
||||
consomme par GET/POST /f/<token>. Le public ne lit jamais les tables du
|
||||
builder : zero regression possible sur les liens deja diffuses.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def project_form_config(view_config: dict, questions: list[dict]) -> dict:
|
||||
"""Construit le ``form_config_json`` v6.8 depuis le builder."""
|
||||
cfg = dict(view_config or {})
|
||||
sharing = cfg.get("sharing") or {}
|
||||
submit = cfg.get("submit_screen") or {}
|
||||
fields = []
|
||||
for q in sorted(questions, key=lambda x: x.get("position", 0)):
|
||||
fields.append({
|
||||
"property": q.get("property_name"),
|
||||
"label": q.get("label_override") or q.get("property_name"),
|
||||
"required": bool(q.get("required")),
|
||||
"help": q.get("help_text") or "",
|
||||
"widget": q.get("widget") or "auto",
|
||||
"max_selections": q.get("max_selections"),
|
||||
"conditional": json.loads(q["conditional_json"]) if q.get("conditional_json") else None,
|
||||
})
|
||||
return {
|
||||
"title": cfg.get("title") or "Form",
|
||||
"description": cfg.get("description") or "",
|
||||
"fields": fields,
|
||||
"sharing": {
|
||||
"audience": sharing.get("audience", "workspace"),
|
||||
"anonymous": bool(sharing.get("anonymous", False)),
|
||||
"respondent_access": sharing.get("respondent_access", "none"),
|
||||
"branding": sharing.get("branding", True),
|
||||
},
|
||||
"submit": {
|
||||
"button_text": submit.get("button_text", "Envoyer"),
|
||||
"button_color": submit.get("button_color", "blue"),
|
||||
"confirmation_title": submit.get("confirmation_title", "Merci !"),
|
||||
"confirmation_body": submit.get("confirmation_body", "Votre réponse a été enregistrée."),
|
||||
},
|
||||
"form_view_id": cfg.get("form_view_id"),
|
||||
}
|
||||
|
||||
|
||||
def default_questions_for(properties: list[dict]) -> list[dict]:
|
||||
"""Questions initiales depuis les proprietes exposables (hors formules)."""
|
||||
skip = {"formula", "rollup", "button", "unique_id",
|
||||
"created_time", "created_by", "last_edited_time", "last_edited_by"}
|
||||
out = []
|
||||
for i, p in enumerate(properties):
|
||||
if p.get("prop_type") in skip:
|
||||
continue
|
||||
out.append({
|
||||
"property_name": p.get("name"),
|
||||
"position": i,
|
||||
"label_override": None,
|
||||
"sync_label": 1,
|
||||
"help_text": "",
|
||||
"required": 0,
|
||||
"widget": "auto",
|
||||
"max_selections": None,
|
||||
"conditional_json": None,
|
||||
})
|
||||
return out
|
||||
@@ -0,0 +1,154 @@
|
||||
"""FlowDeck — Geocoding Service (vue Map §13.4).
|
||||
|
||||
Interface de fournisseur unique : search / resolve / reverse.
|
||||
Implementations : ``none`` (defaut — saisie manuelle uniquement, aucun
|
||||
appel externe), ``nominatim`` (OpenStreetMap, User-Agent identifie,
|
||||
<= 1 req/s, cache obligatoire), ``custom`` (endpoint compatible Nominatim).
|
||||
|
||||
Le cache SQLite (migration 45, table ``geocode_cache``) est consulte
|
||||
avant tout appel. Les journaux ne contiennent jamais d'adresses en clair
|
||||
au-dela de compteurs (le texte reste dans le cache, purgeable).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_last_call_ts = 0.0
|
||||
|
||||
|
||||
def _norm(query: str) -> str:
|
||||
return " ".join((query or "").lower().split())
|
||||
|
||||
|
||||
def query_hash(query: str, provider: str) -> str:
|
||||
return hashlib.sha256(f"{provider}:{_norm(query)}".encode()).hexdigest()
|
||||
|
||||
|
||||
def get_settings(conn=None) -> dict:
|
||||
"""Lit la config geocodeur (instance). Defauts surs : provider=none."""
|
||||
try:
|
||||
from app.config import settings as _s # type: ignore
|
||||
provider = getattr(_s, "GEOCODER_PROVIDER", None) or "none"
|
||||
endpoint = getattr(_s, "GEOCODER_ENDPOINT", None) or "https://nominatim.openstreetmap.org"
|
||||
return {"provider": provider, "endpoint": endpoint.rstrip("/")}
|
||||
except Exception:
|
||||
return {"provider": "none", "endpoint": "https://nominatim.openstreetmap.org"}
|
||||
|
||||
|
||||
def cache_get(conn, qhash: str) -> dict | None:
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT result_json, lat, lng, hit_count FROM geocode_cache WHERE query_hash=?",
|
||||
(qhash,),
|
||||
).fetchone()
|
||||
except Exception:
|
||||
return None
|
||||
if not row:
|
||||
return None
|
||||
try:
|
||||
conn.execute("UPDATE geocode_cache SET hit_count = hit_count + 1 WHERE query_hash=?", (qhash,))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
data = json.loads(row["result_json"]) if row["result_json"] else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
data = {}
|
||||
data.setdefault("lat", row["lat"])
|
||||
data.setdefault("lng", row["lng"])
|
||||
return data
|
||||
|
||||
|
||||
def cache_put(conn, qhash: str, provider: str, query_text: str, result: dict) -> None:
|
||||
try:
|
||||
conn.execute(
|
||||
"""INSERT OR REPLACE INTO geocode_cache
|
||||
(query_hash, provider, query_text, result_json, lat, lng, created_at, hit_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, datetime('now'), COALESCE(
|
||||
(SELECT hit_count FROM geocode_cache WHERE query_hash=?), 0))""",
|
||||
(qhash, provider, query_text[:300], json.dumps(result),
|
||||
result.get("lat"), result.get("lng"), qhash),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _http_json(url: str, params: dict, timeout: float = 3.0) -> Any:
|
||||
global _last_call_ts
|
||||
# Politesse Nominatim : <= 1 req/s.
|
||||
wait = 1.0 - (time.time() - _last_call_ts)
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
full = url + "?" + urllib.parse.urlencode(params)
|
||||
req = urllib.request.Request(full, headers={
|
||||
"User-Agent": "FlowDeck/1.0 (self-hosted; contact: admin)",
|
||||
"Accept": "application/json",
|
||||
})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
_last_call_ts = time.time()
|
||||
return json.loads(resp.read().decode("utf-8", "replace"))
|
||||
|
||||
|
||||
def search(conn, query: str, limit: int = 5) -> list[dict]:
|
||||
"""Autocompletion d'adresses. Vide quand provider=none."""
|
||||
cfg = get_settings(conn)
|
||||
provider = cfg["provider"]
|
||||
if not query or len(query.strip()) < 3 or provider == "none":
|
||||
return []
|
||||
qhash = query_hash(f"search:{query}:{limit}", provider)
|
||||
cached = cache_get(conn, qhash)
|
||||
if cached and isinstance(cached.get("suggestions"), list):
|
||||
return cached["suggestions"][:limit]
|
||||
base = cfg["endpoint"]
|
||||
try:
|
||||
if provider in ("nominatim", "custom"):
|
||||
data = _http_json(f"{base}/search", {
|
||||
"q": query, "format": "jsonv2", "limit": limit,
|
||||
"addressdetails": 1,
|
||||
})
|
||||
suggestions = [{
|
||||
"name": (d.get("display_name") or "").split(",")[0].strip(),
|
||||
"address": d.get("display_name", ""),
|
||||
"lat": float(d["lat"]), "lng": float(d["lon"]),
|
||||
"provider": provider, "place_id": str(d.get("place_id", "")),
|
||||
} for d in (data or []) if d.get("lat") and d.get("lon")]
|
||||
else:
|
||||
suggestions = []
|
||||
except Exception as exc:
|
||||
logger.warning("geocode search failed: %s", type(exc).__name__)
|
||||
return []
|
||||
try:
|
||||
cache_put(conn, qhash, provider, query, {"suggestions": suggestions})
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return suggestions
|
||||
|
||||
|
||||
def resolve(conn, query: str) -> dict | None:
|
||||
"""Adresse -> {lat, lng, ...}. Cache d'abord, None si echec."""
|
||||
cfg = get_settings(conn)
|
||||
provider = cfg["provider"]
|
||||
if not query or provider == "none":
|
||||
return None
|
||||
qhash = query_hash(f"resolve:{query}", provider)
|
||||
cached = cache_get(conn, qhash)
|
||||
if cached and cached.get("lat") is not None:
|
||||
return cached
|
||||
results = search(conn, query, limit=1)
|
||||
if not results:
|
||||
return None
|
||||
first = dict(results[0])
|
||||
try:
|
||||
cache_put(conn, qhash, provider, query, first)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return first
|
||||
@@ -7,7 +7,11 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.export import _CODE_LANG, _TEXTUAL_EXTS
|
||||
from app.services.export import (
|
||||
_TEXTUAL_EXTS,
|
||||
_TEXTUAL_FILENAMES,
|
||||
code_lang_for_file,
|
||||
)
|
||||
from app.services.importers.base import ImportPage, ImportResult
|
||||
|
||||
_MD_EXTS = {"md", "markdown"}
|
||||
@@ -17,6 +21,14 @@ def _ext(path: str) -> str:
|
||||
return Path(path).suffix.lower().lstrip(".")
|
||||
|
||||
|
||||
def _basename(path: str) -> str:
|
||||
return path.replace("\\", "/").rsplit("/", 1)[-1].lower()
|
||||
|
||||
|
||||
def _is_textual(path: str) -> bool:
|
||||
return _ext(path) in _TEXTUAL_EXTS or _basename(path) in _TEXTUAL_FILENAMES
|
||||
|
||||
|
||||
def build_repo_result(
|
||||
files: list[tuple[str, str]],
|
||||
*,
|
||||
@@ -34,7 +46,7 @@ def build_repo_result(
|
||||
if ext in _MD_EXTS:
|
||||
markdown = content
|
||||
else:
|
||||
lang = _CODE_LANG.get(ext, "")
|
||||
lang = code_lang_for_file(clean)
|
||||
markdown = f"```{lang}\n{content.rstrip()}\n```"
|
||||
parts = clean.split("/")
|
||||
result.pages.append(ImportPage(
|
||||
@@ -69,7 +81,7 @@ async def fetch_forge_repo(
|
||||
files: list[tuple[str, str]] = []
|
||||
for meta in metas:
|
||||
file_path = meta.get("path") or ""
|
||||
if _ext(file_path) not in _TEXTUAL_EXTS:
|
||||
if not _is_textual(file_path):
|
||||
continue
|
||||
if int(meta.get("size") or 0) > max_file_bytes:
|
||||
continue
|
||||
|
||||
@@ -11,8 +11,8 @@ from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from app.services import http_client
|
||||
from app.services.export import markdown_to_blocks
|
||||
from app.services.http_client import shared_client
|
||||
from app.services.importers.base import ImportPage, ImportResult
|
||||
from app.services.importers.html_notes import _html_to_markdown
|
||||
|
||||
@@ -26,7 +26,10 @@ def _is_public_host(host: str) -> bool:
|
||||
return False
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
except socket.gaierror:
|
||||
except OSError:
|
||||
# gaierror, socket.timeout, herror… : le garde-fou doit TOUJOURS
|
||||
# renvoyer un booléen, jamais lever — sinon l'appelant tombe dans son
|
||||
# `except Exception` (bookmarks) au lieu d'un refus explicite.
|
||||
return False
|
||||
for info in infos:
|
||||
address = info[4][0]
|
||||
@@ -62,7 +65,10 @@ async def fetch_url_result(url: str, *, transport: httpx.BaseTransport | None =
|
||||
safe_url = _validate_url(url)
|
||||
result = ImportResult(source="url")
|
||||
try:
|
||||
async with shared_client(
|
||||
# Résolu à l'APPEL (et non capturé à l'import) : un import tardif de
|
||||
# ce module pendant qu'un test patche `http_client.shared_client`
|
||||
# figerait la factory moquée pour tout le reste du processus.
|
||||
async with http_client.shared_client(
|
||||
timeout=15, follow_redirects=True, transport=transport,
|
||||
headers={"User-Agent": "FlowDeck-Importer/1.0"},
|
||||
) as client:
|
||||
|
||||
@@ -37,7 +37,9 @@ logger = logging.getLogger(__name__)
|
||||
PROVIDERS = {
|
||||
"openai": ("https://api.openai.com/v1", "gpt-4o"),
|
||||
"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"),
|
||||
"google": ("https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.0-flash"),
|
||||
"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]] = {
|
||||
"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"],
|
||||
"mistral": ["mistral-large-latest", "mistral-medium-latest", "mistral-small-latest",
|
||||
"codestral-latest", "open-mistral-nemo", "pixtral-large-latest"],
|
||||
# Ordre = ordre de sélection du test de connexion : les modèles servis par
|
||||
# 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"],
|
||||
"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",
|
||||
|
||||
@@ -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
|
||||
# (embeddings, rerank, image/video/audio gen…) many of which answer 404 on
|
||||
# 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…).
|
||||
_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}
|
||||
+668
-49
@@ -1,19 +1,25 @@
|
||||
"""FlowDeck — AI Meeting Notes v2 (v7.1.0).
|
||||
"""FlowDeck — AI Meeting Notes façon Notion (v7.1.1).
|
||||
|
||||
Upload audio → optional server transcription (``STT_COMMAND``, e.g. whisper)
|
||||
→ AI summary (``AIWritingService.summarize``, offline-capable) → fires
|
||||
``meeting.summarized`` so custom agents pick it up (Notion 07/2026 pattern).
|
||||
Refonte alignée sur ``docs/architecture-meeting-notion-flowdeck.md`` (§3A/3B) :
|
||||
machine à états du bloc, révélation progressive des onglets, consentement
|
||||
bloquant journalisé, segments groupés par source, étapes Thinking persistées,
|
||||
résumé structuré avec citations obligatoires, classification de complétude,
|
||||
titre généré (diagnostic honnête si bref/incomplet), barre de partage tracée.
|
||||
|
||||
Without ``STT_COMMAND`` the server stores the audio and accepts a manual
|
||||
``transcript`` (client-side transcription). Nothing here requires new pip
|
||||
dependencies. See ``docs/V71_Calendar_Meetings.md``.
|
||||
Stockage SQLite monolithique : ``meeting_transcripts`` + colonnes JSON
|
||||
(migration 38) + ``meeting_consent_log`` / ``meeting_distributions``
|
||||
append-only. Rien n'exige de dépendance pip supplémentaire.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
@@ -24,6 +30,37 @@ logger = logging.getLogger(__name__)
|
||||
AUDIO_EXTENSIONS = {"mp3", "wav", "m4a", "ogg", "flac", "aac"}
|
||||
MAX_AUDIO_BYTES = 100 * 1024 * 1024
|
||||
|
||||
# États du bloc (§6.5) — transitions toujours via le serveur.
|
||||
BLOCK_STATES = ("idle", "recording", "paused", "processing", "done", "failed")
|
||||
CAPTURE_MODES = ("mic_only", "tab_plus_mic", "import")
|
||||
|
||||
# Instructions de résumé (§12.4) : auto + presets par type + custom libre.
|
||||
INSTRUCTIONS = {
|
||||
"auto": {"label": "Auto", "prompt": "Résumé automatique adapté au contenu détecté."},
|
||||
"standup": {"label": "Standup", "prompt": "Format standup : hier / aujourd'hui / blocages, par personne."},
|
||||
"team": {"label": "Team meeting", "prompt": "Format équipe : sujets, décisions, actions avec responsables."},
|
||||
"sales": {"label": "Sales call", "prompt": "Format commercial : besoin, objections, next steps, montant."},
|
||||
"one_to_one": {"label": "1:1", "prompt": "Format 1:1 : points du collaborateur, feedback, engagements."},
|
||||
"interview": {"label": "Interview", "prompt": "Format entretien : parcours, forces, doutes, décision."},
|
||||
}
|
||||
|
||||
# Étapes Thinking exposées (§12.7), dans l'ordre — libellés FR/EN gérés côté UI.
|
||||
PROCESSING_STEPS = (
|
||||
("reading_transcript", "Reading transcript…"),
|
||||
("analyzing_transcript", "Analyzing the transcript"),
|
||||
("resolving_references", "Thinking about how to refer to people"),
|
||||
("understanding_content", "Understanding the content"),
|
||||
("classifying_completeness", "Checking completeness"),
|
||||
("generating_summary", "Writing the summary"),
|
||||
("generating_title", "Choosing a title"),
|
||||
("validating_citations", "Checking citations"),
|
||||
)
|
||||
|
||||
CONSENT_METHODS = (
|
||||
"start_attestation", "verbal", "chat_text", "audio_message",
|
||||
"meet_addon", "workspace_enforced",
|
||||
)
|
||||
|
||||
|
||||
class TranscriptionUnavailable(RuntimeError):
|
||||
"""Raised when no transcription backend is configured."""
|
||||
@@ -36,26 +73,609 @@ def meetings_dir() -> Path:
|
||||
return d
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(UTC).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def _loads(raw: str, default):
|
||||
try:
|
||||
v = json.loads(raw or "")
|
||||
return v if v is not None else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def get_transcript(transcript_id: int) -> dict:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM meeting_transcripts WHERE id=?", (transcript_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError("transcript not found")
|
||||
return dict(row)
|
||||
|
||||
|
||||
def _update(transcript_id: int, **fields) -> None:
|
||||
if not fields:
|
||||
return
|
||||
cols = ", ".join(f"{k}=?" for k in fields)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
f"UPDATE meeting_transcripts SET {cols} WHERE id=?",
|
||||
(*fields.values(), transcript_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def save_transcript(page_id: int, transcript: str, language: str = "fr",
|
||||
audio_path: str = "") -> int:
|
||||
audio_path: str = "", **kw) -> int:
|
||||
"""Crée une note de réunion (état ``idle``). Rétro-compatible v7.1.0."""
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise ValueError("page not found")
|
||||
occ = (kw.get("event_occurrence_id") or "").strip()
|
||||
if occ and conn.execute(
|
||||
"SELECT id FROM meeting_transcripts WHERE event_occurrence_id=?", (occ,)
|
||||
).fetchone():
|
||||
raise ValueError("a meeting note already exists for this calendar occurrence")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO meeting_transcripts (page_id, audio_path, transcript, language)
|
||||
VALUES (?,?,?,?)""",
|
||||
(page_id, audio_path, transcript or "", language or "fr"))
|
||||
"""INSERT INTO meeting_transcripts
|
||||
(page_id, audio_path, transcript, language, status, mode,
|
||||
instruction_id, event_occurrence_id, notes_text)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||
(page_id, audio_path, transcript or "", language or "fr",
|
||||
"idle", kw.get("mode") or "mic_only",
|
||||
(kw.get("instruction_id") or "auto")[:40], occ,
|
||||
kw.get("notes_text") or ""),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def transcribe_audio(audio_path: str, language: str = "fr") -> str:
|
||||
"""Transcribe with ``STT_COMMAND`` (``{cmd} {file}` → stdout text).
|
||||
# ── Consentement (§14.1) : porte bloquante, journal append-only ──────────────
|
||||
|
||||
Example: ``STT_COMMAND="whisper --language fr --output_format txt --output_dir /tmp"``
|
||||
(command must print or be adapted — stdout is preferred). Raises
|
||||
:class:`TranscriptionUnavailable` when unconfigured.
|
||||
def record_consent(transcript_id: int, *, method: str = "start_attestation",
|
||||
message_ref: str = "", attested_by: int | None = None,
|
||||
participants: list | None = None) -> dict:
|
||||
if method not in CONSENT_METHODS:
|
||||
raise ValueError(f"unknown consent method: {method}")
|
||||
get_transcript(transcript_id) # 404 si inconnu
|
||||
entry = {
|
||||
"method": method,
|
||||
"message_ref": message_ref or "",
|
||||
"attested_by": attested_by,
|
||||
"participants": participants or [],
|
||||
"attested_at": _now(),
|
||||
}
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO meeting_consent_log
|
||||
(transcript_id, method, message_ref, attested_by, participants_json)
|
||||
VALUES (?,?,?,?,?)""",
|
||||
(transcript_id, method, message_ref or "",
|
||||
attested_by, json.dumps(participants or [], ensure_ascii=False)),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE meeting_transcripts SET consent_json=? WHERE id=?",
|
||||
(json.dumps(entry, ensure_ascii=False), transcript_id),
|
||||
)
|
||||
conn.commit()
|
||||
return entry
|
||||
|
||||
|
||||
def require_consent(tr: dict) -> dict:
|
||||
consent = _loads(tr.get("consent_json") or "", None)
|
||||
if consent:
|
||||
return consent
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM meeting_consent_log WHERE transcript_id=? "
|
||||
"ORDER BY id DESC LIMIT 1", (tr["id"],)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise ValueError("consent required — record consent before capturing")
|
||||
return {
|
||||
"method": row["method"], "message_ref": row["message_ref"],
|
||||
"attested_by": row["attested_by"],
|
||||
"participants": _loads(row["participants_json"], []),
|
||||
}
|
||||
|
||||
|
||||
# ── Sessions de capture : start / pause / resume / stop ─────────────────────
|
||||
|
||||
def start_session(transcript_id: int, *, mode: str = "mic_only",
|
||||
channels: list | None = None, language: str = "fr",
|
||||
instruction_id: str = "auto", user_id: int | None = None) -> dict:
|
||||
if mode not in CAPTURE_MODES:
|
||||
raise ValueError(f"unknown capture mode: {mode}")
|
||||
tr = get_transcript(transcript_id)
|
||||
if tr.get("status") in ("recording", "processing"):
|
||||
raise ValueError(f"meeting is {tr['status']} — stop it first")
|
||||
consent = require_consent(tr)
|
||||
instr = (instruction_id or "auto").strip()[:40]
|
||||
snapshot = {
|
||||
"instruction_id": instr if instr in INSTRUCTIONS else "custom",
|
||||
"instruction_raw": instr,
|
||||
"definition": INSTRUCTIONS.get(instr, {"label": instr, "prompt": instr}),
|
||||
"frozen_at": _now(),
|
||||
}
|
||||
chans = channels or (
|
||||
[{"kind": "self", "label": "Microphone"}] if mode == "mic_only"
|
||||
else [{"kind": "self", "label": "My microphone"},
|
||||
{"kind": "remote", "label": "Tab audio"}] if mode == "tab_plus_mic"
|
||||
else []
|
||||
)
|
||||
_update(transcript_id, status="recording", mode=mode,
|
||||
channels_json=json.dumps(chans, ensure_ascii=False),
|
||||
language=(language or tr.get("language") or "fr")[:10],
|
||||
instruction_id=instr,
|
||||
instruction_snapshot=json.dumps(snapshot, ensure_ascii=False),
|
||||
segments_json="[]", processing_steps_json="[]", summary="",
|
||||
summary_json="", generated_title="", completeness_class="",
|
||||
duration_ms=0, paused_ms=0, started_at=_now(), stopped_at=None)
|
||||
return get_transcript(transcript_id) | {"_consent": consent}
|
||||
|
||||
|
||||
def pause_session(transcript_id: int, *, paused_ms_delta: int = 0) -> dict:
|
||||
tr = get_transcript(transcript_id)
|
||||
if tr.get("status") != "recording":
|
||||
raise ValueError("only a recording session can be paused")
|
||||
_update(transcript_id, status="paused",
|
||||
paused_ms=int(tr.get("paused_ms") or 0) + max(0, int(paused_ms_delta or 0)))
|
||||
return get_transcript(transcript_id)
|
||||
|
||||
|
||||
def resume_session(transcript_id: int) -> dict:
|
||||
tr = get_transcript(transcript_id)
|
||||
if tr.get("status") != "paused":
|
||||
raise ValueError("only a paused session can be resumed")
|
||||
require_consent(tr)
|
||||
_update(transcript_id, status="recording")
|
||||
return get_transcript(transcript_id)
|
||||
|
||||
|
||||
def stop_session(transcript_id: int, *, duration_ms: int = 0) -> dict:
|
||||
tr = get_transcript(transcript_id)
|
||||
if tr.get("status") not in ("recording", "paused"):
|
||||
raise ValueError("nothing to stop — no live session")
|
||||
require_consent(tr)
|
||||
_update(transcript_id, status="processing", stopped_at=_now(),
|
||||
duration_ms=max(int(duration_ms or 0), int(tr.get("duration_ms") or 0)))
|
||||
return get_transcript(transcript_id)
|
||||
|
||||
|
||||
# ── Segments : append (temps réel), correction du locuteur ───────────────────
|
||||
|
||||
def _next_seq(segments: list) -> int:
|
||||
return (max((s.get("seq", 0) for s in segments), default=0) + 1) if segments else 1
|
||||
|
||||
|
||||
def append_segments(transcript_id: int, segments: list) -> dict:
|
||||
"""Persiste les segments finaux du client (STT navigateur / import).
|
||||
|
||||
Chaque item : {channel, source_label, speaker, text, start_ms, end_ms,
|
||||
starts_mid, ends_mid}. Le transcript texte est reconstruit pour compat.
|
||||
"""
|
||||
tr = get_transcript(transcript_id)
|
||||
# Retry/regen après un échec : le client reverse le manuel collé
|
||||
# entre-temps — l'ajout reste possible hors session live.
|
||||
if tr.get("status") not in ("recording", "paused", "processing",
|
||||
"failed", "done"):
|
||||
raise ValueError("session is not live — start recording first")
|
||||
require_consent(tr)
|
||||
if not isinstance(segments, list) or not segments:
|
||||
raise ValueError("segments must be a non-empty list")
|
||||
if len(segments) > 200:
|
||||
raise ValueError("too many segments per batch (max 200)")
|
||||
current = _loads(tr.get("segments_json") or "[]", [])
|
||||
seq = _next_seq(current)
|
||||
for item in segments:
|
||||
if not isinstance(item, dict) or not (item.get("text") or "").strip():
|
||||
raise ValueError("each segment needs a non-empty text")
|
||||
text = item["text"].strip()[:4000]
|
||||
current.append({
|
||||
"seq": seq,
|
||||
"channel": (item.get("channel") or "self")[:12],
|
||||
"source_label": (item.get("source_label") or "")[:120],
|
||||
"speaker": (item.get("speaker") or "")[:120],
|
||||
"start_ms": max(0, int(item.get("start_ms") or 0)),
|
||||
"end_ms": max(0, int(item.get("end_ms") or 0)),
|
||||
"text": ("--" if item.get("starts_mid") else "") + text,
|
||||
"is_final": True,
|
||||
"starts_mid_utterance": bool(item.get("starts_mid")),
|
||||
"ends_mid_utterance": bool(item.get("ends_mid")),
|
||||
})
|
||||
seq += 1
|
||||
flat = "\n".join(
|
||||
f"[{s['start_ms'] // 60000}:{(s['start_ms'] // 1000) % 60:02d}] "
|
||||
f"{s['speaker'] or s['channel']} : {s['text']}" for s in current
|
||||
)
|
||||
if tr.get("transcript"):
|
||||
flat = (tr["transcript"] + "\n" + flat) if flat else tr["transcript"]
|
||||
_update(transcript_id, segments_json=json.dumps(current, ensure_ascii=False),
|
||||
transcript=flat[:200000])
|
||||
return {"transcript_id": transcript_id, "segments": len(current)}
|
||||
|
||||
|
||||
def set_segment_speaker(transcript_id: int, seq: int, speaker: str,
|
||||
user_id: int | None = None) -> dict:
|
||||
tr = get_transcript(transcript_id)
|
||||
segments = _loads(tr.get("segments_json") or "[]", [])
|
||||
found = False
|
||||
for s in segments:
|
||||
if int(s.get("seq") or 0) == int(seq):
|
||||
s["speaker"] = (speaker or "").strip()[:120]
|
||||
s["speaker_corrected_by"] = user_id
|
||||
found = True
|
||||
if not found:
|
||||
raise ValueError("segment not found")
|
||||
_update(transcript_id, segments_json=json.dumps(segments, ensure_ascii=False))
|
||||
return {"transcript_id": transcript_id, "seq": int(seq), "speaker": speaker}
|
||||
|
||||
|
||||
def grouped_transcript(transcript_id: int) -> dict:
|
||||
"""Transcript regroupé par piste source (§3A.7) + rail temporel."""
|
||||
tr = get_transcript(transcript_id)
|
||||
segments = _loads(tr.get("segments_json") or "[]", [])
|
||||
groups: dict[str, list] = {}
|
||||
order: list[str] = []
|
||||
for s in sorted(segments, key=lambda x: (x.get("seq") or 0)):
|
||||
key = s.get("source_label") or s.get("channel") or "self"
|
||||
if key not in groups:
|
||||
groups[key] = []
|
||||
order.append(key)
|
||||
groups[key].append(s)
|
||||
# Repli : transcript texte brut découpé en un groupe unique.
|
||||
if not groups and (tr.get("transcript") or "").strip():
|
||||
groups = {"audio": [{"seq": 1, "channel": "self", "source_label": "",
|
||||
"speaker": "", "start_ms": 0, "end_ms": 0,
|
||||
"text": tr["transcript"][:4000]}]}
|
||||
order = ["audio"]
|
||||
return {"transcript_id": transcript_id,
|
||||
"groups": [{"source": k, "segments": groups[k]} for k in order]}
|
||||
|
||||
|
||||
# ── Classification de complétude + titre (§12.8) ─────────────────────────────
|
||||
|
||||
def classify_completeness(*, text: str, segments: list, notes: str,
|
||||
duration_ms: int = 0) -> str:
|
||||
words = len(re.findall(r"\S+", text or ""))
|
||||
n_seg = len(segments or [])
|
||||
truncated = any(s.get("starts_mid_utterance") or s.get("ends_mid_utterance")
|
||||
for s in (segments or []))
|
||||
has_agenda = bool((notes or "").strip())
|
||||
if n_seg == 0 or words < 8:
|
||||
return "fragmentary"
|
||||
if words < 60 or (duration_ms and duration_ms < 45_000):
|
||||
return "brief" if not truncated else "incomplete"
|
||||
if truncated or not has_agenda and words < 150:
|
||||
return "incomplete"
|
||||
if words < 150:
|
||||
return "brief"
|
||||
return "complete"
|
||||
|
||||
|
||||
def _first_topic(text: str) -> str:
|
||||
sentences = re.split(r"(?<=[.!?])\s+", re.sub(r"\s+", " ", text or "").strip())
|
||||
for s in sentences:
|
||||
s = s.strip(" -–—\"'«»")
|
||||
if len(s) > 25:
|
||||
return s[:90].rstrip(" ,;:")
|
||||
return ""
|
||||
|
||||
|
||||
def generate_title(*, text: str, completeness: str, notes: str = "") -> str:
|
||||
if completeness in ("brief", "incomplete", "fragmentary"):
|
||||
topic = _first_topic(text)
|
||||
if not topic or len(re.findall(r"\S+", text or "")) < 25:
|
||||
return "Brief or Incomplete Meeting Recording"
|
||||
return topic[:90]
|
||||
topic = _first_topic(notes + " " + text)
|
||||
return topic[:90] or "Meeting notes"
|
||||
|
||||
|
||||
# ── Résumé structuré (§12.4) : JSON contraint + citations ────────────────────
|
||||
|
||||
_SUMMARY_SCHEMA_HINT = (
|
||||
"Réponds STRICTEMENT par un objet JSON : "
|
||||
'{"overview": ["..."], "decisions": ["..."], "action_items": '
|
||||
'[{"title": "...", "owner": null, "due_hint": null}], '
|
||||
'"open_questions": ["..."], "limits": ["..."]}. '
|
||||
"Chaque puce overview/decisions/action_items DOIT citer le transcript "
|
||||
"(suffixe entre parenthèses avec un extrait repris mot pour mot). "
|
||||
"N'invente ni responsable ni échéance : owner/due_hint null si absents. "
|
||||
"Si le contenu est insuffisant, overview décrit les manques et "
|
||||
"action_items vaut []."
|
||||
)
|
||||
|
||||
|
||||
def _cite(text: str, segments: list) -> str:
|
||||
"""Trouve un segment source pour une affirmation (citation vérifiable)."""
|
||||
words = [w.lower() for w in re.findall(r"[A-Za-zÀ-ÿa-zà-ÿ']{4,}", text or "")]
|
||||
best, best_score = None, 0
|
||||
for s in segments or []:
|
||||
hay = (s.get("text") or "").lower()
|
||||
score = sum(1 for w in words[:12] if w in hay)
|
||||
if score > best_score:
|
||||
best, best_score = s, score
|
||||
if best and best_score > 0:
|
||||
return f" ¹ [seg {best.get('seq')}]"
|
||||
if segments:
|
||||
return f" ¹ [seg {segments[0].get('seq')}]"
|
||||
return " ¹"
|
||||
|
||||
|
||||
def _offline_structured(*, text: str, notes: str, segments: list,
|
||||
completeness: str) -> dict:
|
||||
sentences = [s.strip() for s in
|
||||
re.split(r"(?<=[.!?])\s+", re.sub(r"\s+", " ", text).strip()) if s.strip()]
|
||||
overview: list[str] = []
|
||||
if completeness == "fragmentary":
|
||||
overview = [
|
||||
"The recording captured only a short, fragmentary exchange with no "
|
||||
f"discernible meeting topic, agenda, or context{_cite(text, segments)}",
|
||||
"The content appears to be mid-conversation and lacks the beginning "
|
||||
f"of the discussion{_cite(text, segments)}",
|
||||
]
|
||||
if not (notes or "").strip():
|
||||
overview.append("The user's notes were empty, providing no additional "
|
||||
"context about the meeting's purpose")
|
||||
else:
|
||||
for s in sentences[:4]:
|
||||
overview.append(f"{s[:220]}{_cite(s, segments)}")
|
||||
if not overview:
|
||||
overview = [f"No meaningful content could be extracted{_cite(text, segments)}"]
|
||||
if completeness in ("brief", "incomplete"):
|
||||
overview.append(
|
||||
"This was a brief or incomplete recording — treat this summary "
|
||||
f"as partial{_cite(text, segments)}")
|
||||
decisions = []
|
||||
for s in sentences:
|
||||
if re.search(r"\b(decid|décid|agreed|approuv|retain|retenu|chosen|choisi)\b", s, re.I):
|
||||
decisions.append(f"{s[:220]}{_cite(s, segments)}")
|
||||
actions = []
|
||||
for s in sentences:
|
||||
m = re.search(r"\b([A-ZÀ-Þ][a-zà-ÿ'\-]+)\s+(will|va|doit|should|must|s'occupe)\b", s)
|
||||
if re.search(r"\b(action|todo|to-?do|next step|prochaine étape|faire|envoyer|préparer)\b", s, re.I) or m:
|
||||
owner = m.group(1) if m and len(m.group(1)) > 2 else None
|
||||
actions.append({"title": s[:180], "owner": owner,
|
||||
"due_hint": None, "evidence": _cite(s, segments)})
|
||||
return {"overview": overview[:6], "decisions": decisions[:8],
|
||||
"action_items": actions[:10], "open_questions": [],
|
||||
"limits": [] if completeness == "complete" else overview[-2:]}
|
||||
|
||||
|
||||
def _render_markdown(struct: dict, completeness: str) -> str:
|
||||
L = ["## Overview", ""]
|
||||
for b in struct.get("overview") or []:
|
||||
L.append(f"• {b}")
|
||||
L += ["", "## Decisions", ""]
|
||||
if struct.get("decisions"):
|
||||
for b in struct["decisions"]:
|
||||
L.append(f"• {b}")
|
||||
else:
|
||||
L.append("• No decisions could be identified from the available transcript")
|
||||
L += ["", "## Action Items", ""]
|
||||
if struct.get("action_items"):
|
||||
for a in struct["action_items"]:
|
||||
if isinstance(a, dict):
|
||||
owner = f" — owner: {a['owner']}" if a.get("owner") else ""
|
||||
L.append(f"• {a.get('title', '')}{owner}{a.get('evidence', ' ¹')}")
|
||||
else:
|
||||
L.append(f"• {a}")
|
||||
else:
|
||||
L.append("• No action items could be identified from the available transcript")
|
||||
if struct.get("open_questions"):
|
||||
L += ["", "## Open questions", ""]
|
||||
for b in struct["open_questions"]:
|
||||
L.append(f"• {b}")
|
||||
if completeness in ("brief", "incomplete", "fragmentary"):
|
||||
L += ["", f"_Summary class: {completeness} — partial recording, verify against the transcript._"]
|
||||
return "\n".join(L).strip()
|
||||
|
||||
|
||||
def _write_steps(transcript_id: int, states: dict) -> None:
|
||||
steps = [{"key": k, "label": label,
|
||||
"state": states.get(k, "pending"),
|
||||
"at": _now() if states.get(k) in ("running", "done", "failed") else ""}
|
||||
for k, label in PROCESSING_STEPS]
|
||||
_update(transcript_id, processing_steps_json=json.dumps(steps, ensure_ascii=False))
|
||||
|
||||
|
||||
async def _llm_structured(*, text: str, notes: str, instruction: str,
|
||||
user_id: int | None) -> tuple[dict | None, bool, str]:
|
||||
from app.services.ai_writing import AIWritingService
|
||||
|
||||
svc = AIWritingService(user_id=user_id)
|
||||
if svc._offline_hint():
|
||||
return None, True, ""
|
||||
prompt = (
|
||||
"Rédige le résumé structuré de cette réunion (format AI Meeting Note). "
|
||||
f"Instructions de l'utilisateur : {instruction or 'Auto'}. "
|
||||
"Notes humaines (contexte prioritaire, signale tout conflit) : "
|
||||
f"{(notes or '(vides)')[:2000]}. " + _SUMMARY_SCHEMA_HINT
|
||||
)
|
||||
try:
|
||||
res = await svc.run("summarize", context=text[:18000], prompt=prompt)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("meeting LLM summary failed: %s", exc)
|
||||
return None, False, str(exc)
|
||||
if not res.get("ok") or not (res.get("text") or "").strip():
|
||||
return None, bool(res.get("offline")), res.get("error") or "empty"
|
||||
parsed = AIWritingService._parse_json_object(res["text"])
|
||||
if not parsed or not isinstance(parsed.get("overview"), list):
|
||||
# Le modèle a répondu en texte libre : on le conserve en overview sourcé.
|
||||
return {"overview": [res["text"][:1500]], "decisions": [],
|
||||
"action_items": [], "open_questions": [], "limits": []}, False, ""
|
||||
return parsed, False, ""
|
||||
|
||||
|
||||
async def run_processing(transcript_id: int, user_id: int | None = None,
|
||||
allow_empty: bool = False) -> dict:
|
||||
"""Pipeline §12 : étapes Thinking → classification → résumé → titre.
|
||||
|
||||
Jamais de refus sur contenu insuffisant : résumé dégradé + classe +
|
||||
titre-diagnostic (§12.8). Avec ``allow_empty`` (arrêt sans rien de
|
||||
capté), un diagnostic honnête est produit au lieu d'une erreur —
|
||||
l'utilisateur ne perd ni l'enregistrement ni le transcript.
|
||||
Sans ``allow_empty``, un transcript vide reste une 400 (l'appelant
|
||||
affiche alors un guide de reprise, pas un dump HTTP).
|
||||
"""
|
||||
tr = get_transcript(transcript_id)
|
||||
segments = _loads(tr.get("segments_json") or "[]", [])
|
||||
notes = tr.get("notes_text") or ""
|
||||
text = (tr.get("transcript") or "").strip()
|
||||
snapshot = _loads(tr.get("instruction_snapshot") or "", {})
|
||||
instruction = (snapshot.get("instruction_raw")
|
||||
or tr.get("instruction_id") or "auto")
|
||||
|
||||
order = [k for k, _ in PROCESSING_STEPS]
|
||||
states: dict[str, str] = {}
|
||||
try:
|
||||
for key in ("reading_transcript", "analyzing_transcript",
|
||||
"resolving_references", "understanding_content"):
|
||||
states[key] = "running"
|
||||
_write_steps(transcript_id, states)
|
||||
time.sleep(0) # point de cession : étapes réellement exécutées
|
||||
states[key] = "done"
|
||||
_write_steps(transcript_id, states)
|
||||
|
||||
states["classifying_completeness"] = "running"
|
||||
_write_steps(transcript_id, states)
|
||||
completeness = classify_completeness(
|
||||
text=text, segments=segments, notes=notes,
|
||||
duration_ms=int(tr.get("duration_ms") or 0))
|
||||
states["classifying_completeness"] = "done"
|
||||
_write_steps(transcript_id, states)
|
||||
|
||||
if not text:
|
||||
if not allow_empty:
|
||||
raise ValueError("transcript is empty — transcribe first")
|
||||
# Arrêt sans rien de capté (micro coupé, aucun mot reconnu) :
|
||||
# diagnostic honnête au lieu d'une erreur — rien n'est perdu,
|
||||
# l'utilisateur peut coller un transcript et régénérer.
|
||||
for key in ("generating_summary", "generating_title",
|
||||
"validating_citations"):
|
||||
states[key] = "running"
|
||||
_write_steps(transcript_id, states)
|
||||
states[key] = "done"
|
||||
_write_steps(transcript_id, states)
|
||||
struct = {
|
||||
"overview": [
|
||||
"No audio was captured during this session — no speech "
|
||||
"reached the transcript. Check that the microphone is "
|
||||
"allowed for this site and that the correct input is selected.",
|
||||
("If the call ran in another tab, restart with “Tab + "
|
||||
"microphone” and tick “Share audio” when choosing the tab."),
|
||||
],
|
||||
"decisions": [], "action_items": [], "open_questions": [],
|
||||
"limits": ["empty session — paste a transcript, then Regenerate"],
|
||||
}
|
||||
md = _render_markdown(struct, "fragmentary")
|
||||
_update(transcript_id, status="done", summary=md,
|
||||
summary_json=json.dumps(struct, ensure_ascii=False),
|
||||
generated_title="Brief or Incomplete Meeting Recording",
|
||||
title_source="ai", completeness_class="fragmentary")
|
||||
return await _finish_processing(transcript_id, offline=True)
|
||||
|
||||
states["generating_summary"] = "running"
|
||||
_write_steps(transcript_id, states)
|
||||
struct, offline, _err = await _llm_structured(
|
||||
text=text, notes=notes, instruction=instruction, user_id=user_id)
|
||||
if struct is None:
|
||||
struct = _offline_structured(text=text, notes=notes,
|
||||
segments=segments,
|
||||
completeness=completeness)
|
||||
offline = True
|
||||
else:
|
||||
# Garantir les citations même sur réponse LLM.
|
||||
for section in ("overview", "decisions"):
|
||||
fixed = []
|
||||
for b in struct.get(section) or []:
|
||||
s = b if isinstance(b, str) else str(b)
|
||||
fixed.append(s if "¹" in s or "[seg" in s else f"{s}{_cite(s, segments)}")
|
||||
struct[section] = fixed
|
||||
acts = []
|
||||
for a in struct.get("action_items") or []:
|
||||
if isinstance(a, dict):
|
||||
if "evidence" not in a:
|
||||
a["evidence"] = _cite(a.get("title", ""), segments)
|
||||
acts.append(a)
|
||||
else:
|
||||
acts.append({"title": str(a), "owner": None,
|
||||
"due_hint": None,
|
||||
"evidence": _cite(str(a), segments)})
|
||||
struct["action_items"] = acts
|
||||
states["generating_summary"] = "done"
|
||||
_write_steps(transcript_id, states)
|
||||
|
||||
states["generating_title"] = "running"
|
||||
_write_steps(transcript_id, states)
|
||||
title = generate_title(text=text, completeness=completeness, notes=notes)
|
||||
states["generating_title"] = "done"
|
||||
_write_steps(transcript_id, states)
|
||||
|
||||
states["validating_citations"] = "running"
|
||||
_write_steps(transcript_id, states)
|
||||
seqs = {int(s.get("seq") or 0) for s in segments}
|
||||
# Toute citation [seg N] pointe un segment réel, sinon repli seg 1.
|
||||
md = _render_markdown(struct, completeness)
|
||||
|
||||
def _fix_seg(m: re.Match) -> str:
|
||||
try:
|
||||
n = int(m.group(1))
|
||||
except (TypeError, ValueError):
|
||||
return "[seg 1]"
|
||||
return m.group(0) if n in seqs else "[seg 1]"
|
||||
md = re.sub(r"\[seg (\d+)\]", _fix_seg, md)
|
||||
states["validating_citations"] = "done"
|
||||
_write_steps(transcript_id, states)
|
||||
|
||||
_update(transcript_id, status="done", summary=md,
|
||||
summary_json=json.dumps(struct, ensure_ascii=False),
|
||||
generated_title=title, title_source="ai",
|
||||
completeness_class=completeness)
|
||||
except Exception as exc: # noqa: BLE001 — état failed récupérable, segments conservés
|
||||
for k in order:
|
||||
states.setdefault(k, "pending")
|
||||
# Marquer l'étape courante en échec.
|
||||
for k in order:
|
||||
if states.get(k) == "running":
|
||||
states[k] = "failed"
|
||||
break
|
||||
_write_steps(transcript_id, states)
|
||||
# Toujours sortir de « processing » : une ligne coincée est pire
|
||||
# qu'une erreur affichée (l'utilisateur peut réessayer).
|
||||
_update(transcript_id, status="failed")
|
||||
msg = str(exc) or "processing failed"
|
||||
if "transcript is empty" in msg:
|
||||
raise ValueError(msg) from exc
|
||||
logger.warning("meeting processing %s failed: %s", transcript_id, exc)
|
||||
raise RuntimeError(msg) from exc
|
||||
|
||||
return await _finish_processing(transcript_id, offline)
|
||||
|
||||
|
||||
async def _finish_processing(transcript_id: int, offline: bool) -> dict:
|
||||
"""Événement + réponse du pipeline (chemin nominal et diagnostic)."""
|
||||
out = get_transcript(transcript_id)
|
||||
try:
|
||||
from app.services.automations import fire_event
|
||||
await fire_event("meeting.summarized", {
|
||||
"page_id": out["page_id"], "transcript_id": transcript_id,
|
||||
"language": out.get("language") or "fr",
|
||||
"completeness": out.get("completeness_class") or "",
|
||||
})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.debug("meeting.summarized dispatch failed: %s", exc)
|
||||
return {"transcript_id": transcript_id, "summary": out["summary"],
|
||||
"completeness": out.get("completeness_class") or "",
|
||||
"generated_title": out.get("generated_title") or "",
|
||||
"offline": bool(offline)}
|
||||
|
||||
|
||||
def transcribe_audio(audio_path: str, language: str = "fr") -> str:
|
||||
"""Transcribe with ``STT_COMMAND`` (``{cmd} {file}` → stdout text)."""
|
||||
cmd_template = os.environ.get("STT_COMMAND", "").strip()
|
||||
if not cmd_template:
|
||||
raise TranscriptionUnavailable(
|
||||
@@ -74,40 +694,39 @@ def transcribe_audio(audio_path: str, language: str = "fr") -> str:
|
||||
return text
|
||||
|
||||
|
||||
async def summarize_transcript(transcript_id: int, user_id: int | None = None) -> dict:
|
||||
"""Summarize a stored transcript + fire ``meeting.summarized``.
|
||||
async def summarize_transcript(transcript_id: int, user_id: int | None = None,
|
||||
allow_empty: bool = False) -> dict:
|
||||
"""Point d'entrée historique (v7.1.0) — bascule sur le pipeline Notion."""
|
||||
tr = get_transcript(transcript_id)
|
||||
if tr.get("status") in ("recording", "paused"):
|
||||
raise ValueError("stop the recording before generating the summary")
|
||||
if not ((tr.get("transcript") or "").strip()
|
||||
or _loads(tr.get("segments_json") or "[]", [])):
|
||||
if not allow_empty:
|
||||
raise ValueError("transcript is empty — transcribe first")
|
||||
if tr.get("status") not in ("processing", "done"):
|
||||
_update(transcript_id, status="processing")
|
||||
out = await run_processing(transcript_id, user_id=user_id,
|
||||
allow_empty=allow_empty)
|
||||
return {"transcript_id": transcript_id, "summary": out["summary"],
|
||||
"offline": out["offline"]}
|
||||
|
||||
Returns {transcript_id, summary, offline}. Emits the automation event so
|
||||
custom agents (update tracker, post recap, file tickets) trigger.
|
||||
"""
|
||||
from app.services.ai_writing import AIWritingService
|
||||
|
||||
# ── Partage tracé (§3A.5 : Copy link / Email / Slack) ─────────────────────────
|
||||
|
||||
def record_distribution(transcript_id: int, *, channel: str,
|
||||
actor_id: int | None = None,
|
||||
recipients: str = "", status: str = "created") -> dict:
|
||||
if channel not in ("copy_link", "email", "slack"):
|
||||
raise ValueError("channel must be copy_link|email|slack")
|
||||
get_transcript(transcript_id)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM meeting_transcripts WHERE id=?",
|
||||
(transcript_id,)).fetchone()
|
||||
if not row:
|
||||
raise ValueError("transcript not found")
|
||||
tr = dict(row)
|
||||
if not (tr.get("transcript") or "").strip():
|
||||
raise ValueError("transcript is empty — transcribe first")
|
||||
svc = AIWritingService(user_id=user_id)
|
||||
res = await svc.run("summarize", context=tr["transcript"])
|
||||
summary = (res.get("text") or "").strip() if res.get("ok") else ""
|
||||
if not summary:
|
||||
raise RuntimeError(f"summarization failed: {res.get('error', 'unknown')}")
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE meeting_transcripts SET summary=? WHERE id=?",
|
||||
(summary, transcript_id))
|
||||
page = conn.execute("SELECT id FROM pages WHERE id=?", (tr["page_id"],)).fetchone()
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO meeting_distributions
|
||||
(transcript_id, channel, actor_id, recipients_json, status)
|
||||
VALUES (?,?,?,?,?)""",
|
||||
(transcript_id, channel, actor_id, recipients or "", status),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
from app.services.automations import fire_event
|
||||
await fire_event("meeting.summarized", {
|
||||
"page_id": tr["page_id"] if page else 0,
|
||||
"transcript_id": transcript_id,
|
||||
"language": tr.get("language") or "fr",
|
||||
})
|
||||
except Exception as exc: # noqa: BLE001 — summary stands even if dispatch fails
|
||||
logger.debug("meeting.summarized dispatch failed: %s", exc)
|
||||
return {"transcript_id": transcript_id, "summary": summary,
|
||||
"offline": bool(res.get("offline"))}
|
||||
return {"id": cur.lastrowid, "transcript_id": transcript_id,
|
||||
"channel": channel, "status": status}
|
||||
|
||||
@@ -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
|
||||
@@ -97,14 +97,20 @@ PROPERTY_TYPES: dict[str, dict] = {
|
||||
"default": None,
|
||||
},
|
||||
"button": {
|
||||
"storage": "none — runs linked automation (button_automation_id)",
|
||||
"storage": "none - runs linked automation (button_automation_id)",
|
||||
"default": None,
|
||||
},
|
||||
"place": {
|
||||
"storage": "json object {name, address, lat, lng, provider, place_id, resolved_at, manual}",
|
||||
"validation": "name or address required, lat in [-90,90], lng in [-180,180]",
|
||||
"default": None,
|
||||
},
|
||||
}
|
||||
|
||||
# CSV-friendly subset (no relation/rollup/formula)
|
||||
SIMPLE_TYPES = ["title", "text", "number", "select", "multi_select", "status",
|
||||
"date", "person", "checkbox", "url", "email", "phone", "files"]
|
||||
"date", "person", "checkbox", "url", "email", "phone", "files",
|
||||
"place"]
|
||||
|
||||
# Auto-property types (computed server-side)
|
||||
AUTO_TYPES = ["created_time", "created_by", "last_edited_time", "last_edited_by"]
|
||||
@@ -136,10 +142,19 @@ def validate_property_value(prop_type: str, value: Any, options: list | None = N
|
||||
return False, f"'{v}' is not a valid option"
|
||||
elif prop_type == "date":
|
||||
if isinstance(value, str) and value:
|
||||
try:
|
||||
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except (ValueError, TypeError):
|
||||
# Les plages « start...end » (Timeline, auto-shift) sont valides :
|
||||
# chaque borne est controlee separement.
|
||||
parts = value.split("...") if "..." in value else [value]
|
||||
if len(parts) > 2 or not any(p.strip() for p in parts):
|
||||
return False, f"'{value}' is not a valid ISO 8601 date"
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
try:
|
||||
datetime.fromisoformat(part.replace("Z", "+00:00"))
|
||||
except (ValueError, TypeError):
|
||||
return False, f"'{value}' is not a valid ISO 8601 date"
|
||||
elif prop_type == "person":
|
||||
if not isinstance(value, list):
|
||||
return False, "Person must be a list of workspace members"
|
||||
@@ -152,6 +167,31 @@ def validate_property_value(prop_type: str, value: Any, options: list | None = N
|
||||
elif prop_type == "phone":
|
||||
if not isinstance(value, str):
|
||||
return False, "Phone must be a string"
|
||||
elif prop_type == "place":
|
||||
if value in ("", {}):
|
||||
return True, ""
|
||||
if isinstance(value, str):
|
||||
# Plain-text address (unresolved place, e.g. CSV import) is valid.
|
||||
return True, ""
|
||||
if not isinstance(value, dict):
|
||||
return False, "Place must be an object {name, address, lat, lng}"
|
||||
if not (value.get("name") or value.get("address")):
|
||||
return False, "Place requires a name or an address"
|
||||
lat, lng = value.get("lat"), value.get("lng")
|
||||
if lat is not None and lat != "":
|
||||
try:
|
||||
lat = float(lat)
|
||||
except (ValueError, TypeError):
|
||||
return False, "Place latitude must be a number"
|
||||
if not -90 <= lat <= 90:
|
||||
return False, "Place latitude must be between -90 and 90"
|
||||
if lng is not None and lng != "":
|
||||
try:
|
||||
lng = float(lng)
|
||||
except (ValueError, TypeError):
|
||||
return False, "Place longitude must be a number"
|
||||
if not -180 <= lng <= 180:
|
||||
return False, "Place longitude must be between -180 and 180"
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
+14
-6
@@ -9,10 +9,14 @@ Les trois surfaces divergeaient avant cette passe :
|
||||
- `/api/v2/pages/{id}/publish` : slug fourni par le corps ou aléatoire,
|
||||
`is_shared=1` en bonus (alors que v2 le remet à 0 quand aucun partage)
|
||||
|
||||
Canonical (comportement du front) : `is_published` + `publish_slug` seulement,
|
||||
404 si la page n'existe pas. `share_mode`/`is_shared`/`published` restent la
|
||||
propriété du share dialog (`/board/api/share/{pid}`) : dépublier ne révoque
|
||||
donc pas un partage manuel.
|
||||
Canonical (comportement du front) : `is_published` + `published` +
|
||||
`publish_slug`, maintenus ensemble par toutes les écritures (v7.69.6 —
|
||||
avant, chaque surface n'écrivait qu'un drapeau et chaque lecteur ne
|
||||
regardait qu'une colonne, donc l'état publié ne « tenait » nulle part).
|
||||
`share_mode`/`is_shared` restent la propriété du share dialog
|
||||
(`/board/api/share/{pid}`) : dépublier ne révoque donc pas un partage manuel.
|
||||
Les lecteurs acceptent `is_published=1 OR published=1` (lignes historiques
|
||||
issues d'un seul chemin).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -58,8 +62,12 @@ def publish(page_id: int, explicit_slug: str | None = None) -> tuple[str, str]:
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
slug = explicit_slug or _unique_slug(conn, page_id, page["title"])
|
||||
# v7.69.6 : les deux drapeaux sont maintenus ensemble — le share
|
||||
# dialog écrit ``published``, les routes /publish écrivaient seulement
|
||||
# ``is_published``, et chaque lecteur ne regardait qu'une colonne :
|
||||
# l'état publié ne « tenait » nulle part.
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=1, publish_slug=? WHERE id=?", (slug, page_id)
|
||||
"UPDATE pages SET is_published=1, published=1, publish_slug=? WHERE id=?", (slug, page_id)
|
||||
)
|
||||
conn.commit()
|
||||
return slug, page["title"] or ""
|
||||
@@ -71,7 +79,7 @@ def unpublish(page_id: int) -> None:
|
||||
if not conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise HTTPException(404, "Page not found")
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?", (page_id,)
|
||||
"UPDATE pages SET is_published=0, published=0, publish_slug='' WHERE id=?", (page_id,)
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
@@ -109,6 +109,201 @@ GALLERY: dict[str, dict] = {
|
||||
),
|
||||
"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
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.collection_lifecycle import delete_collections
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -381,7 +382,7 @@ class SyncEngine:
|
||||
def _mut_collection_delete(self, user_id: int, payload: dict) -> dict:
|
||||
cid = payload.get("collection_id")
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM collections WHERE id=?", (cid,))
|
||||
delete_collections(conn, [cid])
|
||||
conn.commit()
|
||||
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()))
|
||||
+416
-24
@@ -15,12 +15,14 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC
|
||||
from typing import Any
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.collection_lifecycle import delete_collections
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -701,32 +703,134 @@ class AddDependency(Tool):
|
||||
|
||||
class ApplyTemplate(Tool):
|
||||
name = "apply_template"
|
||||
description = "Applique un template de page dans une collection (crée une page)."
|
||||
description = (
|
||||
"Applique un template du registre unifié (crée une page, une ligne, "
|
||||
"une base ou des blocs). Même service que l'UI (TemplateService)."
|
||||
)
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {"collection_id": {"type": "integer"}, "template_id": {"type": "integer"}, "title": {"type": "string"}},
|
||||
"required": ["collection_id", "template_id"],
|
||||
"properties": {
|
||||
"collection_id": {"type": "integer"},
|
||||
"template_id": {"type": ["integer", "string"]},
|
||||
"title": {"type": "string"},
|
||||
"variables": {"type": "object"},
|
||||
},
|
||||
"required": ["template_id"],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
cid, tid = args.get("collection_id"), args.get("template_id")
|
||||
from app.services.fd_templates import TemplateService, registry_ready
|
||||
|
||||
tid = args.get("template_id")
|
||||
cid = args.get("collection_id")
|
||||
# Compat legacy : un id numérique de page_templates.
|
||||
template_ref: str = str(tid)
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute("SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, cid)).fetchone()
|
||||
if not tmpl:
|
||||
return ToolResult(status="error", tool=self.name, message="Template introuvable")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (cid,)).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
|
||||
(cid, args.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
|
||||
)
|
||||
conn.commit()
|
||||
actor = _actor_for(conn, user_id)
|
||||
if registry_ready(conn) and isinstance(tid, int):
|
||||
legacy = conn.execute(
|
||||
"SELECT id FROM templates WHERE source_kind='legacy_page' AND source_id=?",
|
||||
(str(tid),),
|
||||
).fetchone()
|
||||
if legacy:
|
||||
template_ref = legacy["id"]
|
||||
variables = dict(args.get("variables") or {})
|
||||
if args.get("title"):
|
||||
variables.setdefault("title", args["title"])
|
||||
context: dict = {}
|
||||
if cid:
|
||||
context["collection_id"] = cid
|
||||
try:
|
||||
result = TemplateService.instantiate(
|
||||
conn, actor, template_ref, variables, context,
|
||||
run_source="agent",
|
||||
)
|
||||
except LookupError as exc:
|
||||
return ToolResult(status="error", tool=self.name, message=str(exc))
|
||||
except (ValueError, PermissionError) as exc:
|
||||
return ToolResult(status="error", tool=self.name, message=str(exc))
|
||||
except Exception as exc: # noqa: BLE001 — l'agent doit recevoir l'erreur
|
||||
logger.exception("apply_template agent")
|
||||
return ToolResult(status="error", tool=self.name, message=str(exc))
|
||||
created_id = result.get("created_id") or None
|
||||
try:
|
||||
target_id = int(created_id) if created_id else 0
|
||||
except (TypeError, ValueError):
|
||||
target_id = 0
|
||||
undo = None
|
||||
if result.get("created_type") in ("page", "row", "collection") and target_id:
|
||||
table = {"page": "pages", "row": "collection_pages",
|
||||
"collection": "collections"}[result["created_type"]]
|
||||
undo = {"action": "delete", "table": table, "id": target_id}
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="page", target_id=cur.lastrowid,
|
||||
data={"page_id": cur.lastrowid},
|
||||
undo={"action": "delete", "table": "collection_pages", "id": cur.lastrowid},
|
||||
status="success", tool=self.name,
|
||||
target_type=result.get("created_type") or "page",
|
||||
target_id=target_id,
|
||||
data={"page_id": target_id, "url": result.get("url"),
|
||||
"warnings": result.get("warnings", []),
|
||||
"run_id": result.get("run_id")},
|
||||
undo=undo,
|
||||
)
|
||||
|
||||
|
||||
class ListTemplates(Tool):
|
||||
name = "list_templates"
|
||||
description = "Liste les templates visibles par l'utilisateur (registre unifié)."
|
||||
parameters = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {"type": "string"},
|
||||
"query": {"type": "string"},
|
||||
"target_collection_id": {"type": "integer"},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
|
||||
async def execute(self, args, *, user_id=None) -> ToolResult:
|
||||
from app.services.fd_templates import TemplateService
|
||||
|
||||
with get_conn() as conn:
|
||||
actor = _actor_for(conn, user_id)
|
||||
try:
|
||||
items = TemplateService.list_templates(
|
||||
conn, actor, kind=args.get("kind"),
|
||||
query=args.get("query"),
|
||||
target_collection_id=args.get("target_collection_id"),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("list_templates agent")
|
||||
return ToolResult(status="error", tool=self.name, message=str(exc))
|
||||
summary = [
|
||||
{"id": t["id"], "kind": t["kind"], "name": t["name"],
|
||||
"scope": t["scope"], "is_default": t.get("is_default", False)}
|
||||
for t in items
|
||||
]
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="templates",
|
||||
data={"templates": summary},
|
||||
)
|
||||
|
||||
|
||||
def _actor_for(conn, user_id: int | None) -> dict:
|
||||
"""Acteur minimal pour le service (l'agent n'agit jamais au-delà de
|
||||
l'utilisateur appelant, §6.1 de l'architecture)."""
|
||||
actor: dict = {"id": user_id}
|
||||
if user_id:
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT id, login, full_name, is_admin FROM users WHERE id=?",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if row:
|
||||
actor.update(
|
||||
{"login": row["login"], "full_name": row["full_name"],
|
||||
"is_admin": bool(row["is_admin"])}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return actor
|
||||
|
||||
|
||||
# ══════════════════════════ Destructive tools ══════════════════════════
|
||||
|
||||
|
||||
@@ -785,7 +889,7 @@ class DeleteCollection(Tool):
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
|
||||
if not coll:
|
||||
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()
|
||||
return ToolResult(
|
||||
status="success", tool=self.name, target_type="collection", target_id=cid,
|
||||
@@ -877,16 +981,281 @@ 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 ══════════════════════════
|
||||
|
||||
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 = [
|
||||
SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument,
|
||||
CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument,
|
||||
UpdatePage, WriteBlocks,
|
||||
AddRelation, CreateSubItem, AddDependency, ApplyTemplate,
|
||||
AddRelation, CreateSubItem, AddDependency, ApplyTemplate, ListTemplates,
|
||||
DeletePage, DeleteCollection,
|
||||
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
|
||||
DeleteDocument,
|
||||
WebSearch, FetchUrl, SearchCode, ConnectorFetch,
|
||||
]
|
||||
|
||||
|
||||
@@ -896,24 +1265,47 @@ class ToolRegistry:
|
||||
def __init__(self):
|
||||
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]:
|
||||
"""Tool names allowed by an agent scope (default: all)."""
|
||||
tools = self._all()
|
||||
allowed = (scope or {}).get("tools")
|
||||
if allowed is None:
|
||||
return list(self.tools.keys())
|
||||
return [n for n in self.tools if n in allowed]
|
||||
return list(tools.keys())
|
||||
return [n for n in tools if n in allowed]
|
||||
|
||||
def schema(self, scope: dict | None = None) -> list[dict]:
|
||||
"""Function-calling schema for the LLM, filtered by scope."""
|
||||
tools = self._all()
|
||||
return [
|
||||
{"name": self.tools[n].name,
|
||||
"description": self.tools[n].description,
|
||||
"parameters": self.tools[n].parameters}
|
||||
{"name": tools[n].name,
|
||||
"description": tools[n].description,
|
||||
"parameters": tools[n].parameters}
|
||||
for n in self.list(scope)
|
||||
]
|
||||
|
||||
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:
|
||||
return ToolResult(status="error", tool=tool, message=f"Outil inconnu: {tool}")
|
||||
return await impl.execute(args, user_id=user_id)
|
||||
|
||||
@@ -13,6 +13,10 @@ import re
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.collection_lifecycle import (
|
||||
collections_hosted_by_page,
|
||||
delete_collections,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,6 +61,9 @@ def purge_expired(days: int = 30) -> dict:
|
||||
if dt is None or dt >= cutoff:
|
||||
continue
|
||||
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
|
||||
# sub-tree is orphaned (matches the permanent-delete semantics).
|
||||
conn.execute(
|
||||
@@ -65,6 +72,7 @@ def purge_expired(days: int = 30) -> dict:
|
||||
(page_id, page_id),
|
||||
)
|
||||
conn.execute("DELETE FROM pages WHERE id=?", (page_id,))
|
||||
delete_collections(conn, collections)
|
||||
purged.append(page_id)
|
||||
conn.commit()
|
||||
if purged:
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""FlowDeck — Aggregation Service (vues Notion §8.3).
|
||||
|
||||
Un seul moteur d'agregation cote serveur pour Chart, KPI, widgets de
|
||||
Dashboard et drilldown : grouper / sous-grouper / mesurer / cumuler /
|
||||
limiter, avec contrat de sortie normalise.
|
||||
|
||||
Mesures : count | sum | average | median | min | max (definitions alignees
|
||||
sur le Rollup Engine). Groupes : ordre du schema pour select/status/
|
||||
multi_select (couleurs des options incluses), chronologique pour les dates.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from .view_query import (
|
||||
CHART_MAX_GROUPS,
|
||||
CHART_MAX_SUBGROUPS,
|
||||
apply_filters,
|
||||
apply_sorts,
|
||||
prop_value,
|
||||
text_of,
|
||||
)
|
||||
|
||||
NON_GROUPABLE = {"rollup", "button", "unique_id", "files"}
|
||||
NON_MEASURABLE_AXES = {"rollup", "button", "unique_id", "files"}
|
||||
|
||||
|
||||
def _schema_options(prop: dict) -> list[dict]:
|
||||
try:
|
||||
opts = json.loads(prop.get("options_json") or "[]")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
opts = []
|
||||
out = []
|
||||
for o in opts or []:
|
||||
if isinstance(o, str):
|
||||
out.append({"name": o, "color": "gray"})
|
||||
elif isinstance(o, dict):
|
||||
out.append({"name": o.get("name", ""), "color": o.get("color", "gray")})
|
||||
return out
|
||||
|
||||
|
||||
def _measure_values(rows: list[dict], measure_prop: dict | None) -> list[float]:
|
||||
vals: list[float] = []
|
||||
for p in rows:
|
||||
if measure_prop is None:
|
||||
vals.append(1.0)
|
||||
continue
|
||||
v = prop_value(p, measure_prop)
|
||||
if v is None or v == "":
|
||||
continue
|
||||
try:
|
||||
vals.append(float(v) if not isinstance(v, bool) else float(v))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
return vals
|
||||
|
||||
|
||||
def _reduce(kind: str, vals: list[float], count: int) -> float:
|
||||
if kind == "count":
|
||||
return float(count)
|
||||
if not vals:
|
||||
return 0.0
|
||||
if kind == "sum":
|
||||
return float(sum(vals))
|
||||
if kind in ("average", "avg"):
|
||||
return float(sum(vals) / len(vals))
|
||||
if kind == "median":
|
||||
s = sorted(vals)
|
||||
mid = len(s) // 2
|
||||
return float(s[mid] if len(s) % 2 else (s[mid - 1] + s[mid]) / 2)
|
||||
if kind == "min":
|
||||
return float(min(vals))
|
||||
if kind == "max":
|
||||
return float(max(vals))
|
||||
return float(count)
|
||||
|
||||
|
||||
def _date_bucket(value: Any, span_days: int) -> str:
|
||||
s = str(value or "")[:10]
|
||||
if len(s) < 10:
|
||||
return s or "(empty)"
|
||||
if span_days <= 62:
|
||||
return s
|
||||
if span_days <= 371:
|
||||
# week bucket: keep ISO week label
|
||||
try:
|
||||
from datetime import date as _d
|
||||
d = _d.fromisoformat(s)
|
||||
iso = d.isocalendar()
|
||||
return f"{iso.year}-W{iso.week:02d}"
|
||||
except ValueError:
|
||||
return s[:7]
|
||||
return s[:7]
|
||||
|
||||
|
||||
def aggregate(pages: list[dict], properties: list[dict], spec: dict) -> dict:
|
||||
"""Calcule l'agregat normalise ``{groups, total, truncated, scanned}``."""
|
||||
by_name = {p["name"]: p for p in properties}
|
||||
by_id = {str(p["id"]): p for p in properties}
|
||||
group_prop = by_name.get(spec.get("group_by")) or by_id.get(str(spec.get("group_by", "")))
|
||||
sub_prop = None
|
||||
if spec.get("sub_group_by"):
|
||||
sub_prop = by_name.get(spec["sub_group_by"]) or by_id.get(str(spec["sub_group_by"]))
|
||||
measure = spec.get("measure") or {"kind": "count"}
|
||||
kind = measure.get("kind", "count")
|
||||
measure_prop = None
|
||||
if kind != "count" and measure.get("property"):
|
||||
measure_prop = by_name.get(measure["property"]) or by_id.get(str(measure["property"]))
|
||||
|
||||
filters = spec.get("filters") or []
|
||||
rows = apply_filters(pages, properties, filters, spec.get("filter_conjunction", "and"))
|
||||
rows = apply_sorts(rows, properties, spec.get("sorts"))
|
||||
scanned = len(rows)
|
||||
|
||||
hidden = set(spec.get("hidden_groups") or [])
|
||||
omit_zero = spec.get("omit_zero", True)
|
||||
cumulative = bool(spec.get("cumulative"))
|
||||
order_cfg = spec.get("order") or {"by": "group_order", "direction": "asc"}
|
||||
if isinstance(order_cfg, str):
|
||||
order_cfg = {"by": order_cfg, "direction": "asc"}
|
||||
|
||||
# Date grouping needs the span first.
|
||||
is_date = group_prop is not None and group_prop.get("prop_type") == "date"
|
||||
span_days = 0
|
||||
if is_date:
|
||||
dates = sorted(text_of(group_prop, prop_value(p, group_prop))[:10]
|
||||
for p in rows if text_of(group_prop, prop_value(p, group_prop)))
|
||||
if len(dates) >= 2:
|
||||
try:
|
||||
from datetime import date as _d
|
||||
span_days = (_d.fromisoformat(dates[-1][:10]) - _d.fromisoformat(dates[0][:10])).days
|
||||
except ValueError:
|
||||
span_days = 0
|
||||
|
||||
buckets: dict[str, list[dict]] = {}
|
||||
for p in rows:
|
||||
if group_prop is None:
|
||||
key = "All"
|
||||
elif group_prop.get("prop_type") == "multi_select":
|
||||
v = prop_value(p, group_prop)
|
||||
keys = v if isinstance(v, list) and v else ["(empty)"]
|
||||
for k in keys:
|
||||
buckets.setdefault(str(k or "(empty)"), []).append(p)
|
||||
continue
|
||||
elif is_date:
|
||||
key = _date_bucket(prop_value(p, group_prop), span_days)
|
||||
else:
|
||||
t = text_of(group_prop, prop_value(p, group_prop))
|
||||
key = t if t else "(empty)"
|
||||
buckets.setdefault(key, []).append(p)
|
||||
|
||||
# Empty schema options (omit_zero=False) for select/status.
|
||||
if group_prop is not None and not omit_zero and group_prop.get("prop_type") in ("select", "status"):
|
||||
for o in _schema_options(group_prop):
|
||||
buckets.setdefault(o["name"], [])
|
||||
|
||||
schema_order = [o["name"] for o in _schema_options(group_prop)] if group_prop else []
|
||||
opt_colors = {o["name"]: o.get("color", "gray") for o in _schema_options(group_prop)} if group_prop else {}
|
||||
|
||||
def sort_key(item: tuple[str, list]) -> Any:
|
||||
k, members = item
|
||||
by = order_cfg.get("by", "group_order")
|
||||
if by == "value":
|
||||
mvals = _measure_values(members, measure_prop)
|
||||
return _reduce(kind, mvals, len(members))
|
||||
if by == "label":
|
||||
return k.lower()
|
||||
if group_prop is not None and group_prop.get("prop_type") == "date":
|
||||
return k
|
||||
if k in schema_order:
|
||||
return (0, schema_order.index(k))
|
||||
return (1, k.lower())
|
||||
|
||||
items = sorted(buckets.items(), key=sort_key)
|
||||
if str(order_cfg.get("direction", "asc")).lower() in ("desc", "descending"):
|
||||
items = list(reversed(items))
|
||||
|
||||
truncated = False
|
||||
if len(items) > CHART_MAX_GROUPS:
|
||||
items = items[:CHART_MAX_GROUPS]
|
||||
truncated = True
|
||||
|
||||
groups = []
|
||||
running = 0.0
|
||||
for key, members in items:
|
||||
if key in hidden:
|
||||
continue
|
||||
mvals = _measure_values(members, measure_prop)
|
||||
value = _reduce(kind, mvals, len(members))
|
||||
if cumulative and kind in ("count", "sum"):
|
||||
running += value
|
||||
value = running
|
||||
sub = []
|
||||
if sub_prop is not None:
|
||||
sub_buckets: dict[str, list] = {}
|
||||
for p in members:
|
||||
sk = text_of(sub_prop, prop_value(p, sub_prop)) or "(empty)"
|
||||
sub_buckets.setdefault(sk, []).append(p)
|
||||
for sk, sp in list(sub_buckets.items())[:CHART_MAX_SUBGROUPS]:
|
||||
svals = _measure_values(sp, measure_prop)
|
||||
sub.append({"key": sk, "label": sk, "value": _reduce(kind, svals, len(sp))})
|
||||
sub.sort(key=lambda g: -g["value"])
|
||||
groups.append({
|
||||
"key": key,
|
||||
"label": key,
|
||||
"color": opt_colors.get(key, "gray"),
|
||||
"value": value,
|
||||
"count": len(members),
|
||||
"row_ids": [p.get("id") for p in members[:500]],
|
||||
"sub": sub,
|
||||
})
|
||||
|
||||
total_vals = _measure_values(rows, measure_prop)
|
||||
total = _reduce(kind, total_vals, len(rows))
|
||||
grand = sum(g["value"] for g in groups) or 1
|
||||
for g in groups:
|
||||
g["percent"] = g["value"] / grand if grand else 0
|
||||
|
||||
return {"groups": groups, "total": total, "truncated": truncated,
|
||||
"scanned": scanned, "measure": kind}
|
||||
@@ -0,0 +1,166 @@
|
||||
"""FlowDeck — View Query Service (vues Notion §8.1).
|
||||
|
||||
Extraction partagee de la chaine de rendu des vues : charger le schema,
|
||||
appliquer filtres (and/or) -> tris -> projection des proprietes visibles.
|
||||
Toutes les vues clientes (Table/Board/... + chart/timeline/feed/map/
|
||||
dashboard/form) en sont des consommatrices.
|
||||
|
||||
ACL : le filtrage des proprietes se fait AVANT tout calcul — une vue
|
||||
n'agrege / ne geocode / n'affiche jamais une propriete invisible.
|
||||
En v1 on s'appuie sur les ACL de collection (le granulair propriete est
|
||||
applique quand `property_acls` est disponible, sinon tout est visible).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
CHART_MAX_GROUPS = 200
|
||||
CHART_MAX_SUBGROUPS = 50
|
||||
VIEW_ROWS_MAX = 1000
|
||||
MAP_POINTS_MAX = 100
|
||||
TIMELINE_LOAD_MAX = 200
|
||||
FEED_PAGE_SIZE = 20
|
||||
|
||||
|
||||
def parse_values(page: dict) -> dict:
|
||||
try:
|
||||
v = page.get("property_values_json", "{}")
|
||||
return json.loads(v) if isinstance(v, str) else (v or {})
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
def prop_value(page: dict, prop: dict) -> Any:
|
||||
if prop.get("prop_type") == "title":
|
||||
return page.get("title", "")
|
||||
vals = parse_values(page)
|
||||
v = vals.get(str(prop.get("id")))
|
||||
if v is None:
|
||||
v = vals.get(prop.get("name"))
|
||||
return v
|
||||
|
||||
|
||||
def text_of(prop: dict, value: Any) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, dict):
|
||||
# place / person-single / user ref
|
||||
return str(value.get("name") or value.get("address")
|
||||
or value.get("login") or value.get("full_name") or "")
|
||||
if isinstance(value, list):
|
||||
parts = []
|
||||
for x in value:
|
||||
if isinstance(x, dict):
|
||||
parts.append(str(x.get("login") or x.get("full_name")
|
||||
or x.get("name") or x.get("url") or ""))
|
||||
else:
|
||||
parts.append(str(x))
|
||||
return ", ".join(parts)
|
||||
return str(value)
|
||||
|
||||
|
||||
def _match_op(actual: Any, op: str, expected: Any, prop: dict) -> bool:
|
||||
actual_text = text_of(prop, actual).lower()
|
||||
if op in ("is", "equals", "is exactly"):
|
||||
if isinstance(actual, list):
|
||||
return str(expected) in [str(x.get("name", x) if isinstance(x, dict) else x) for x in actual]
|
||||
return actual_text == str(expected or "").lower()
|
||||
if op in ("is_not", "is not", "not_equals"):
|
||||
return actual_text != str(expected or "").lower()
|
||||
if op == "contains":
|
||||
return str(expected or "").lower() in actual_text
|
||||
if op in ("not_contains", "does_not_contain"):
|
||||
return str(expected or "").lower() not in actual_text
|
||||
if op == "is_empty":
|
||||
return actual in (None, "", []) or actual_text == ""
|
||||
if op == "is_not_empty":
|
||||
return not (actual in (None, "", []) or actual_text == "")
|
||||
if op in ("gt", ">", "after"):
|
||||
try:
|
||||
return float(actual_text) > float(expected) if actual_text else False
|
||||
except (ValueError, TypeError):
|
||||
return str(actual_text) > str(expected or "")
|
||||
if op in ("lt", "<", "before"):
|
||||
try:
|
||||
return float(actual_text) < float(expected) if actual_text else False
|
||||
except (ValueError, TypeError):
|
||||
return str(actual_text) < str(expected or "")
|
||||
if op == "is_resolved":
|
||||
return isinstance(actual, dict) and actual.get("lat") not in (None, "")
|
||||
if op == "is_not_resolved":
|
||||
return not (isinstance(actual, dict) and actual.get("lat") not in (None, ""))
|
||||
return True
|
||||
|
||||
|
||||
def apply_filters(pages: list[dict], properties: list[dict],
|
||||
filters: list[dict] | None,
|
||||
conjunction: str = "and") -> list[dict]:
|
||||
if not filters:
|
||||
return list(pages)
|
||||
by_name = {p["name"]: p for p in properties}
|
||||
by_id = {str(p["id"]): p for p in properties}
|
||||
out = []
|
||||
for page in pages:
|
||||
results = []
|
||||
for f in filters:
|
||||
prop = by_name.get(f.get("property")) or by_id.get(str(f.get("property", "")))
|
||||
if prop is None:
|
||||
continue
|
||||
actual = prop_value(page, prop)
|
||||
results.append(_match_op(actual, f.get("operator", "contains"), f.get("value"), prop))
|
||||
if not results:
|
||||
out.append(page)
|
||||
elif (conjunction or "and").lower() == "or":
|
||||
if any(results):
|
||||
out.append(page)
|
||||
elif all(results):
|
||||
out.append(page)
|
||||
return out
|
||||
|
||||
|
||||
def apply_sorts(pages: list[dict], properties: list[dict],
|
||||
sorts: list[dict] | None) -> list[dict]:
|
||||
if not sorts:
|
||||
return list(pages)
|
||||
by_name = {p["name"]: p for p in properties}
|
||||
by_id = {str(p["id"]): p for p in properties}
|
||||
rows = list(pages)
|
||||
for s in reversed(sorts):
|
||||
prop = by_name.get(s.get("property")) or by_id.get(str(s.get("property", "")))
|
||||
if prop is None:
|
||||
continue
|
||||
reverse = str(s.get("direction", "asc")).lower() in ("desc", "descending")
|
||||
rows.sort(key=lambda p: text_of(prop, prop_value(p, prop)).lower(), reverse=reverse)
|
||||
return rows
|
||||
|
||||
|
||||
def query_view_rows(pages: list[dict], properties: list[dict],
|
||||
config: dict, extra_filters: list[dict] | None = None,
|
||||
limit: int = VIEW_ROWS_MAX,
|
||||
cursor: str | None = None) -> tuple[list[dict], str | None]:
|
||||
"""Filtre + tri + projection + pagination par curseur (id)."""
|
||||
filters = list(config.get("filters") or [])
|
||||
if extra_filters:
|
||||
filters.extend(extra_filters)
|
||||
conjunction = config.get("filter_conjunction", "and")
|
||||
rows = apply_filters(pages, properties, filters, conjunction)
|
||||
rows = apply_sorts(pages and rows, properties, config.get("sorts"))
|
||||
start = 0
|
||||
if cursor:
|
||||
for i, p in enumerate(rows):
|
||||
if str(p.get("id")) == str(cursor):
|
||||
start = i + 1
|
||||
break
|
||||
window = rows[start:start + limit]
|
||||
next_cursor = str(window[-1]["id"]) if len(rows) > start + limit and window else None
|
||||
return window, next_cursor
|
||||
|
||||
|
||||
def visible_props(properties: list[dict], config: dict) -> list[dict]:
|
||||
vis = config.get("visible_properties")
|
||||
if not vis:
|
||||
return list(properties)
|
||||
wanted = {str(v) for v in vis}
|
||||
ordered = [p for p in properties if str(p.get("id")) in wanted or p.get("name") in wanted]
|
||||
return ordered or list(properties)
|
||||
@@ -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
|
||||
@@ -125,13 +125,7 @@
|
||||
<div class="db-col-menu-item" id="db-col-insert-right"><span>→┤ Insert right</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Side Peek Panel for Database Pages -->
|
||||
<div id="db-side-peek" class="db-side-peek" style="display:none">
|
||||
<div class="db-side-peek-resize" id="db-side-peek-resize"></div>
|
||||
<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>
|
||||
<!-- Le panneau de peek (#db-side-peek) est créé à la demande par
|
||||
database_table.js (ensureDbPeek) : une seule source de markup pour
|
||||
la page de base et les bases embarquées dans un document. -->
|
||||
</div>
|
||||
|
||||
@@ -107,4 +107,6 @@
|
||||
.db-list-cell{color:var(--text-secondary);font-size:12px;min-width:110px}
|
||||
</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>
|
||||
<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/task_db_link.js?v={{ asset_version }}" defer></script>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
{% if not hide_hamburger %}
|
||||
<button class="hamburger-btn"
|
||||
:class="{ 'hamburger-desktop-show': sidebarCollapsed }"
|
||||
@click="{{ hamburger_click|default('sidebarCollapsed ? toggleSidebar() : (mobileSidebarOpen = true, sidebarCollapsed = false)') }}"
|
||||
@click="{{ hamburger_click|default('fdHamburgerClick()') }}"
|
||||
title="Toggle sidebar">
|
||||
<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"/>
|
||||
@@ -181,6 +181,16 @@ document.addEventListener('alpine:init', function () {
|
||||
return String(c.id) === id ? Object.assign({}, c, { label: d.title || 'Untitled' }) : c;
|
||||
});
|
||||
});
|
||||
// Navigation in-place (ex. /local-workspace qui descend dans ses
|
||||
// dossiers sans recharger) : le chemin du header est recalé sur la
|
||||
// pile de dossiers courante. Même contrat que le rendu serveur.
|
||||
window.addEventListener('flowdeck:breadcrumb-changed', function (e) {
|
||||
var d = e && e.detail;
|
||||
if (!d || !d.crumbs || !d.crumbs.length) return;
|
||||
self.crumbs = d.crumbs.map(function (c, i) { c.origIndex = i; return c; });
|
||||
if (d.workspace_id) self.wsId = d.workspace_id;
|
||||
self.closeAll();
|
||||
});
|
||||
},
|
||||
|
||||
get display() {
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
|
||||
<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 .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-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);}
|
||||
@@ -70,7 +73,11 @@
|
||||
@click.outside="$store.fdIconPicker.customModal || $store.fdIconPicker.close()"
|
||||
@keydown.escape.window="$store.fdIconPicker.close()"
|
||||
: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 === '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>
|
||||
@@ -93,6 +100,15 @@
|
||||
<div class="fdip-body">
|
||||
<template x-if="$store.fdIconPicker.tab === 'emoji'">
|
||||
<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-grid">
|
||||
<template x-for="e in $store.fdIconPicker.items()" :key="e">
|
||||
|
||||
@@ -510,6 +510,9 @@
|
||||
<div class="more-menu-item" @click="moreSaveTemplate()">
|
||||
📑 Save as template
|
||||
</div>
|
||||
<div class="more-menu-item" @click="openTplPicker()">
|
||||
{{ fd_icon("file",14) }} Apply template…
|
||||
</div>
|
||||
<div class="more-menu-sep"></div>
|
||||
<div class="more-menu-item danger" @click="deletePage">
|
||||
{{ fd_icon("trash",14) }} Move to Trash
|
||||
@@ -578,74 +581,132 @@
|
||||
background:var(--bg-modal,#1f1f1f);border:1px solid var(--border,#333);border-radius:10px;
|
||||
box-shadow:var(--shadow-modal);padding:6px;"></div>
|
||||
|
||||
<!-- ═══════════ Inline comment trigger on selection (v4.9.0) ═══════════ -->
|
||||
<button type="button" id="_commentSelBtn"
|
||||
style="display:none;position:fixed;z-index:1100;padding:7px 12px;font-size:13px;font-weight:600;
|
||||
color:#fff;background:var(--accent,#2383E2);border:none;border-radius:8px;cursor:pointer;
|
||||
box-shadow:0 4px 16px rgba(0,0,0,.35);" title="Comment on selection"
|
||||
@click="edCall('commentOnSelection')">
|
||||
💬 Comment
|
||||
</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
|
||||
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);
|
||||
box-shadow:-8px 0 30px rgba(0,0,0,.25);z-index:900;display:flex;flex-direction:column;">
|
||||
<div class="comments-drawer-header"
|
||||
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;">
|
||||
<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>
|
||||
</div>
|
||||
<div class="comments-drawer-list" style="flex:1;overflow-y:auto;padding:12px 16px;">
|
||||
<div x-show="comments.length === 0" style="text-align:center;color:var(--text-dim,#999);padding:32px 0;">
|
||||
<!-- v7.67 : les réactions emoji du texte vivent aussi ici (✕ = retirer) -->
|
||||
<div class="ct-reactions" x-show="reactionList().length" x-cloak style="margin-bottom:14px;">
|
||||
<div class="ct-section-title">Reactions</div>
|
||||
<template x-for="r in reactionList()" :key="r.key">
|
||||
<div class="ct-reaction">
|
||||
<span class="ct-re-emoji" x-text="r.emoji"></span>
|
||||
<span class="ct-re-count" x-show="r.count > 1" x-text="r.count"></span>
|
||||
<span class="ct-re-text" x-text="r.text"></span>
|
||||
<button type="button" class="ct-act" title="Remove reaction"
|
||||
x-show="r.mine" @click="removeReaction(r)">✕</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div x-show="comments.length === 0 && !reactionList().length" style="text-align:center;color:var(--text-dim,#999);padding:32px 0;">
|
||||
No comments yet. Select some text and click 💬 Comment.
|
||||
</div>
|
||||
<template x-for="c in comments" :key="c.id">
|
||||
<div class="comment-thread" style="margin-bottom:18px;"
|
||||
:style="c.anchor_block_id ? 'border-left:3px solid var(--accent);padding-left:10px;' : ''">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
|
||||
<div style="width:22px;height:22px;border-radius:50%;background:#3A3A3A;color:#fff;
|
||||
display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;"
|
||||
<div class="comment-thread" :data-thread="c.id"
|
||||
:class="{ 'is-resolved': c.resolved, 'has-anchor': !!c.anchor_block_id }">
|
||||
<div class="ct-head">
|
||||
<div class="ct-avatar"
|
||||
: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>
|
||||
</div>
|
||||
<span style="font-size:12px;font-weight:600;" 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>
|
||||
</div>
|
||||
<div style="font-size:13px;white-space:pre-wrap;margin-bottom:6px;" x-text="c.body"></div>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<button class="btn-sm" style="font-size:11px;" @click="resolveComment(c.id)"
|
||||
x-text="c.resolved ? '↪ Reopen' : '✔ Resolve'"></button>
|
||||
<button class="btn-sm" style="font-size:11px;" x-show="c.user_id === currentUserId"
|
||||
@click="deleteComment(c.id)">🗑 Delete</button>
|
||||
<span class="ct-name" x-text="c.author ? (c.author.full_name || c.author.login) : 'Unknown'"></span>
|
||||
<span class="ct-time" x-text="fmtTime(c.created_at)"></span>
|
||||
<div class="ct-actions">
|
||||
<button type="button" class="ct-act" :title="c.resolved ? 'Reopen' : 'Resolve'"
|
||||
@click="resolveComment(c)"
|
||||
:class="{ 'is-on': c.resolved }">✓</button>
|
||||
<button type="button" class="ct-act" title="More"
|
||||
x-show="c.user_id === currentUserId"
|
||||
@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 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>
|
||||
</template>
|
||||
</div>
|
||||
<div class="comments-drawer-input" style="border-top:1px solid var(--border,#333);padding:12px 16px;">
|
||||
<textarea x-model="commentDraft" rows="2"
|
||||
placeholder="Add a comment... Use @ to mention someone."
|
||||
style="width:100%;background:var(--bg-secondary,#2a2a2a);border:1px solid var(--border,#333);
|
||||
border-radius:8px;color:var(--text);font-size:13px;padding:8px 10px;resize:none;outline:none;"></textarea>
|
||||
<div style="display:flex;justify-content:flex-end;margin-top:8px;">
|
||||
<button class="btn btn-primary" style="font-size:12px;padding:6px 14px;" @click="addPageComment()">Comment</button>
|
||||
<div class="comments-drawer-input">
|
||||
<div class="ct-input-row">
|
||||
<textarea x-model="commentDraft" rows="1"
|
||||
placeholder="Add a comment..."
|
||||
@keydown.enter.exact.prevent="addPageComment()"></textarea>
|
||||
<div class="ct-input-actions">
|
||||
<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>
|
||||
|
||||
<!-- ═══════════ Menu contextuel au highlight de texte (v7.62.0, façon Notion) ═══════════ -->
|
||||
<div
|
||||
class="format-toolbar"
|
||||
x-show="fmt.open"
|
||||
x-cloak
|
||||
:style="{top:fmt.top+'px',left:fmt.left+'px'}"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<button @click="fmtApply('bold')"><strong>B</strong></button>
|
||||
<button @click="fmtApply('italic')"><em>I</em></button>
|
||||
<button @click="fmtApply('underline')"><u>U</u></button>
|
||||
<button @click="fmtApply('strikeThrough')"><s>S</s></button>
|
||||
<button @click="fmtLink()">{{ fd_icon("link",14) }}</button>
|
||||
<!-- Ligne 1 : style de texte -->
|
||||
<div class="ft-row">
|
||||
<button type="button" class="ft-item ft-style"
|
||||
@click="fmtStyle($event)"
|
||||
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-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 : Comment à gauche, réaction à droite (le ✨ Ask AI sauté :
|
||||
doublon exact du bouton du pied, même action) -->
|
||||
<div class="ft-row ft-row-split">
|
||||
<button type="button" class="ft-item ft-comment" @click="fmtComment()" data-tooltip="Comment — Ctrl+⇧+M">{{ fd_icon("message-square",15) }}<span>Comment</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>
|
||||
</div>
|
||||
<!-- Section « Skills » -->
|
||||
<div class="ft-sep"></div>
|
||||
<div class="ft-group ft-skills-head">Skills</div>
|
||||
<div class="ft-skills">
|
||||
<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>
|
||||
</div>
|
||||
<!-- 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>
|
||||
|
||||
<!-- ═══════════ Copy Link Toast ═══════════ -->
|
||||
@@ -682,51 +743,17 @@
|
||||
</button>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="createForm()"
|
||||
@click="openFormModal()"
|
||||
>
|
||||
<span class="gs-icon">{{ fd_icon("copy",14) }}</span> Form
|
||||
</button>
|
||||
<button
|
||||
class="gs-pill"
|
||||
@click="gsMoreOpen = !gsMoreOpen"
|
||||
@click="openTplPicker()"
|
||||
>
|
||||
<span class="gs-icon">{{ fd_icon("file",14) }}</span> Templates
|
||||
</button>
|
||||
<div style="position: relative">
|
||||
<button class="gs-pill" @click="gsMoreOpen = !gsMoreOpen">
|
||||
<span>⋯</span>
|
||||
</button>
|
||||
<div
|
||||
class="gs-more-dropdown"
|
||||
x-show="gsMoreOpen"
|
||||
@click.outside="gsMoreOpen = false"
|
||||
x-transition
|
||||
>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("bar-chart",14) }} Table
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("copy",14) }} Board
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon('list',14) }} List
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("calendar",14) }} Timeline
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("calendar",14) }} Calendar
|
||||
</div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("image",14) }} Gallery
|
||||
</div>
|
||||
<div class="gs-more-sep"></div>
|
||||
<div class="gs-more-item" @click="gsMoreOpen=false">
|
||||
{{ fd_icon("download",14) }} Import
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-statusbar">
|
||||
<span x-show="saving">Saving...</span>
|
||||
@@ -739,8 +766,8 @@
|
||||
|
||||
<!-- ═══════════ Database template picker (v5.3.0) ═══════════ -->
|
||||
<div class="fd-dbtpl-overlay" id="fd-dbtpl" x-show="dbTplOpen"
|
||||
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;
|
||||
align-items:flex-start;justify-content:center;padding-top:10vh;opacity:0;transition:opacity .15s ease;"
|
||||
style="position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;display:flex;align-items:center;justify-content:center;padding:20px;"
|
||||
x-transition.opacity
|
||||
@click.self="closeDbTemplatePicker()">
|
||||
<div class="fd-dbtpl" style="width:620px;max-width:94vw;max-height:76vh;display:flex;flex-direction:column;
|
||||
background:var(--bg-secondary,#232323);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;
|
||||
@@ -775,6 +802,84 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ Unified template picker (registre unifié, §7.1) ═══════════
|
||||
Toujours une issue visible (P5) : liste, état vide avec création,
|
||||
ou erreur explicite avec Réessayer — jamais un no-op silencieux. -->
|
||||
<div class="fd-tpl-overlay" id="fd-tpl-picker" data-testid="tpl-picker" x-show="tplOpen"
|
||||
style="position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2200;"
|
||||
x-transition.opacity
|
||||
@click.self="closeTplPicker()">
|
||||
<!-- Enveloppe flex SÉPARÉE du x-show : Alpine restaure display:block
|
||||
sur l'overlay (pas le flex inline) — sans cette enveloppe, le
|
||||
dialogue reste collé en haut à gauche au lieu d'être centré. -->
|
||||
<div style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;padding:20px;box-sizing:border-box;">
|
||||
<div class="fd-tpl" style="width:760px;max-width:96vw;max-height:80vh;display:flex;flex-direction:column;
|
||||
background:var(--bg-secondary,#232323);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;
|
||||
box-shadow:0 24px 70px rgba(0,0,0,.6);overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 20px;border-bottom:1px solid var(--border);">
|
||||
<h3 style="margin:0;font-size:16px;color:var(--text,#fff);">Appliquer un template</h3>
|
||||
<button class="flowdeck-modal-close" @click="closeTplPicker()">×</button>
|
||||
</div>
|
||||
<div style="padding:12px 20px 0;">
|
||||
<input type="text" x-model="tplQuery" @input.debounce.300ms="loadTplList()" placeholder="🔍 Rechercher un template…"
|
||||
style="width:100%;padding:9px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);font-size:14px;outline:none;box-sizing:border-box;">
|
||||
<div class="fd-gtpl-filters" style="margin-top:10px;">
|
||||
<template x-for="k in tplKindOptionsList" :key="k">
|
||||
<button type="button" class="fd-tpl-filter" :class="tplKind === k ? 'active' : ''"
|
||||
@click="setTplKind(k)" x-text="tplKindLabel(k)"></button>
|
||||
</template>
|
||||
<a href="/templates" style="margin-left:auto;font-size:12px;color:var(--text-dim);align-self:center;text-decoration:none;padding-right:6px;">Gérer les templates…</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:0;flex:1;min-height:0;padding:0 20px 12px;">
|
||||
<div style="flex:1;overflow-y:auto;min-height:120px;max-height:44vh;padding-right:8px;">
|
||||
<div x-show="tplLoading" style="color:var(--text-dim,#999);font-size:13px;padding:20px 0;text-align:center;">Chargement…</div>
|
||||
<div x-show="tplError" style="color:#e5484d;font-size:13px;padding:12px;background:rgba(229,72,77,.1);border-radius:8px;margin-bottom:8px;">
|
||||
<span x-text="tplError"></span>
|
||||
<button @click="loadTplList()" style="margin-left:8px;">Réessayer</button>
|
||||
</div>
|
||||
<template x-for="t in tplItems" :key="t.id">
|
||||
<div data-testid="tpl-item" @click="selectTpl(t)" :style="tplSelected && tplSelected.id === t.id ? 'border-color:var(--accent);' : ''"
|
||||
style="display:flex;align-items:center;gap:10px;padding:9px 12px;border:1px solid var(--border);border-radius:8px;margin-bottom:8px;cursor:pointer;">
|
||||
<span style="font-size:18px;" x-text="t.icon || '📄'"></span>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-weight:600;color:var(--text,#fff);font-size:13px;" x-text="t.name"></div>
|
||||
<div style="font-size:11px;color:var(--text-dim,#999);" x-text="(t.kind + ' · ' + t.scope)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div x-show="!tplLoading && !tplError && !tplItems.length" style="color:var(--text-dim,#999);font-size:13px;padding:16px 0;text-align:center;">
|
||||
Aucun template pour ce filtre — <a href="/templates">créez-en un</a> ou choisissez un preset système.
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex:1;overflow-y:auto;min-height:120px;max-height:44vh;border-left:1px solid var(--border);padding-left:12px;" x-show="tplSelected">
|
||||
<div style="font-weight:600;color:var(--text,#fff);font-size:14px;margin-bottom:4px;" x-text="tplPreviewTitle"></div>
|
||||
<div x-show="tplPreviewWarnings.length" style="color:#f5a524;font-size:12px;margin-bottom:6px;" x-text="tplPreviewWarnings.join(' · ')"></div>
|
||||
<template x-for="b in tplPreviewBlocks.slice(0,8)" :key="b.id">
|
||||
<div style="font-size:12px;color:var(--text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" x-text="('• ' + (b.content || ('[' + (b.type||'?') + ']')))"></div>
|
||||
</template>
|
||||
<div x-show="tplVarDefs.length" style="margin-top:8px;">
|
||||
<div style="font-size:12px;font-weight:600;color:var(--text,#fff);margin-bottom:4px;">Variables</div>
|
||||
<template x-for="v in tplVarDefs" :key="v.name">
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
|
||||
<span style="font-size:12px;color:var(--text-dim);min-width:80px;" x-text="v.label || v.name"></span>
|
||||
<input type="text" :placeholder="v.default || ''" @input="setTplVar(v.name, $event.target.value)"
|
||||
style="flex:1;padding:5px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-size:12px;">
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:flex-end;gap:8px;padding:12px 20px;border-top:1px solid var(--border);">
|
||||
<button class="flowdeck-modal-close" @click="closeTplPicker()" style="padding:8px 16px;position:static;">Annuler</button>
|
||||
<button data-testid="tpl-apply" @click="applyTpl()" :disabled="!tplSelected || tplApplying"
|
||||
style="padding:8px 20px;border-radius:8px;border:none;background:var(--accent);color:#fff;font-weight:600;cursor:pointer;opacity:.95;"
|
||||
x-text="tplApplying ? 'Application…' : 'Utiliser'"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ v5.4.0: Version History Popover ═══════════ -->
|
||||
<div class="versions-popover"
|
||||
x-show="versionsOpen"
|
||||
@@ -871,4 +976,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<!-- ═══════════ Get Started Form name modal ═══════════ -->
|
||||
<div class="flowdeck-modal-overlay"
|
||||
x-show="formOpen"
|
||||
style="position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;display:flex;align-items:center;justify-content:center;padding:20px;"
|
||||
@click.self="formOpen = false"
|
||||
x-transition.opacity>
|
||||
<div class="flowdeck-modal" @keydown.escape="formOpen = false">
|
||||
<div class="flowdeck-modal-header">
|
||||
<h3>{{ fd_icon("copy",16) }} Create form</h3>
|
||||
<button class="flowdeck-modal-close" @click="formOpen = false">×</button>
|
||||
</div>
|
||||
<div class="flowdeck-modal-body">
|
||||
<p>Enter a name for the new form.</p>
|
||||
<input class="flowdeck-modal-input" type="text" x-model="formName" x-ref="formNameInput"
|
||||
placeholder="Form name" @keydown.enter="confirmCreateForm()">
|
||||
</div>
|
||||
<div class="flowdeck-modal-footer">
|
||||
<button class="flowdeck-modal-btn cancel" @click="formOpen = false">Cancel</button>
|
||||
<button class="flowdeck-modal-btn confirm" @click="confirmCreateForm()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<script type="application/json" id="page-data" nonce="{{ csp_nonce() }}">
|
||||
{{ page_data | tojson }}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/static/css/highlight.css?v={{ asset_version }}">
|
||||
<script data-cfasync="false" src="/static/js/highlight.min.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" src="/static/js/highlight-extra.min.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" src="/static/js/code-highlight.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" src="/static/js/page_editor_scripts.js?v={{ asset_version }}"></script>
|
||||
<link rel="stylesheet" href="/static/css/meeting.css?v={{ asset_version }}">
|
||||
<script data-cfasync="false" src="/static/js/meeting_block.js?v={{ asset_version }}"></script>
|
||||
|
||||
@@ -2,12 +2,19 @@
|
||||
<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: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 ::-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-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 */
|
||||
.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)}
|
||||
@@ -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-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 */
|
||||
.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)}
|
||||
@@ -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 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 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-md-table-wrap{overflow-x:auto;margin:8px 0}
|
||||
.fd-md-table{border-collapse:collapse;width:100%;font-size:13px}
|
||||
@@ -211,6 +238,7 @@
|
||||
|
||||
{# ── 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 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">
|
||||
<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">
|
||||
@@ -372,6 +400,71 @@
|
||||
</template>
|
||||
</template>
|
||||
</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 class="fd-ap-chips" x-show="contextChips.length" x-cloak>
|
||||
<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"
|
||||
@input="onInput()" @keydown="onComposerKeydown($event)" @paste="onPaste($event)"></div>
|
||||
<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>
|
||||
<div class="fd-ap-tools">
|
||||
<button class="fd-ap-model" :class="{off: !hasActiveProvider}" type="button" @click="modelOpen=!modelOpen" title="Choisir fournisseur et modèle">
|
||||
|
||||
+807
-130
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,368 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_icon %}{{ fd_icon("file",18) }}{% endblock %}
|
||||
{% block page_title %}Templates{% endblock %}
|
||||
{% block title_prefix %}Templates{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-title-area">
|
||||
<div class="page-title">
|
||||
<span class="page-icon-lg">{{ fd_icon("file",24) }}</span>
|
||||
<h1>Templates</h1>
|
||||
</div>
|
||||
<p style="color:var(--text-dim);font-size:13px;margin:4px 0 0;">
|
||||
Modèles réutilisables : pages, lignes de base, bases entières, blocs.
|
||||
<span x-show="loading">Chargement…</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div x-data="fdTemplatesManager()" x-init="init()" class="lib-container">
|
||||
<!-- Toolbar -->
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap;">
|
||||
<div style="position:relative;flex:1;min-width:180px;">
|
||||
<span style="position:absolute;left:12px;top:50%;transform:translateY(-50%);color:var(--text-dim);">{{ fd_icon("search",14) }}</span>
|
||||
<input type="text" placeholder="Rechercher un template…" x-model="search" @input.debounce.300ms="reload()"
|
||||
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>
|
||||
<select x-model="kindFilter" @change="reload()" style="padding:8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
|
||||
<option value="">Tous genres</option>
|
||||
<option value="page">Pages</option>
|
||||
<option value="row">Lignes</option>
|
||||
<option value="database">Bases</option>
|
||||
<option value="blocks">Blocs</option>
|
||||
</select>
|
||||
<select x-model="scopeFilter" @change="reload()" style="padding:8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
|
||||
<option value="">Toutes portées</option>
|
||||
<option value="personal">Personnel</option>
|
||||
<option value="workspace">Workspace</option>
|
||||
<option value="teamspace">Teamspace</option>
|
||||
<option value="system">Système</option>
|
||||
</select>
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text-dim);">
|
||||
<input type="checkbox" x-model="showArchived" @change="reload()" style="accent-color:var(--accent);"> Archivés
|
||||
</label>
|
||||
<button class="btn-sm" @click="createOpen = !createOpen" style="padding:8px 14px;">+ Nouveau ▾</button>
|
||||
</div>
|
||||
|
||||
<!-- Panneau création -->
|
||||
<div x-show="createOpen" @click.outside="createOpen = false" x-transition
|
||||
style="background:var(--bg-secondary,#232323);border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin-bottom:12px;">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;max-width:640px;">
|
||||
<input x-model="form.name" placeholder="Nom du template"
|
||||
style="padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
|
||||
<select x-model="form.kind" style="padding:8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
|
||||
<option value="page">Template de page</option>
|
||||
<option value="row">Template de ligne</option>
|
||||
<option value="database">Template de base</option>
|
||||
<option value="blocks">Template de blocs</option>
|
||||
</select>
|
||||
<input x-model="form.target_collection_id" x-show="form.kind === 'row'" placeholder="ID de la base cible"
|
||||
style="padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
|
||||
<input x-model="form.from_page_id" placeholder="Depuis la page n°… (optionnel)"
|
||||
style="padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);">
|
||||
</div>
|
||||
<div x-show="formError" x-text="formError" style="color:#e5484d;font-size:13px;margin-top:8px;"></div>
|
||||
<div style="display:flex;gap:8px;margin-top:10px;">
|
||||
<button class="btn-sm" @click="create()" style="padding:8px 16px;">Créer</button>
|
||||
<button class="btn-sm" @click="createOpen = false" style="padding:8px 16px;">Annuler</button>
|
||||
<label style="margin-left:auto;font-size:13px;color:var(--text-dim);cursor:pointer;">
|
||||
Importer un .json <input type="file" accept=".json,application/json" @change="importFile($event)" style="display:none;">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Erreur globale -->
|
||||
<div x-show="error" style="background:rgba(229,72,77,.12);border:1px solid #e5484d;border-radius:8px;padding:10px 14px;margin-bottom:12px;font-size:13px;">
|
||||
<span x-text="error"></span>
|
||||
<button @click="reload()" style="margin-left:8px;">Réessayer</button>
|
||||
</div>
|
||||
|
||||
<!-- Liste -->
|
||||
<div x-show="!loading && !items.length" style="color:var(--text-dim);font-size:13px;padding:24px 0;text-align:center;">
|
||||
Aucun template. Créez-en un avec « + Nouveau », ou dupliquez un preset système.
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px;">
|
||||
<template x-for="t in items" :key="t.id">
|
||||
<div @click="select(t)" :class="{selected: selected && selected.id === t.id}"
|
||||
style="display:flex;align-items:center;gap:12px;padding:10px 14px;border:1px solid var(--border);border-radius:10px;cursor:pointer;background:var(--bg-secondary,#232323);">
|
||||
<span style="font-size:20px;" x-text="t.icon || '📄'"></span>
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-weight:600;font-size:14px;color:var(--text,#fff);display:flex;align-items:center;gap:8px;">
|
||||
<span x-text="t.name"></span>
|
||||
<span x-show="t.is_system" style="font-size:11px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;padding:1px 6px;color:var(--text-dim);">système</span>
|
||||
<span x-show="t.is_default" style="font-size:11px;color:#f5a524;">★ défaut</span>
|
||||
<span x-show="t.recurrence && t.recurrence.enabled" style="font-size:11px;color:var(--text-dim);">↻ <span x-text="t.recurrence.rrule"></span></span>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-dim);" x-text="(kindLabel(t.kind) + ' · ' + t.scope + (t.usage_count ? ' · ' + t.usage_count + ' utilisations' : ''))"></div>
|
||||
</div>
|
||||
<button class="btn-sm" @click.stop="useTemplate(t)" style="padding:6px 12px;">Utiliser</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Side peek : détail + réglages -->
|
||||
<div x-show="selected" x-transition
|
||||
style="position:fixed;top:0;right:0;bottom:0;width:min(480px,94vw);background:var(--bg-secondary,#232323);border-left:1px solid var(--border);z-index:1200;padding:18px;overflow-y:auto;box-shadow:-16px 0 50px rgba(0,0,0,.5);">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
|
||||
<span style="font-size:24px;" x-text="selected.icon"></span>
|
||||
<input x-model="selected.name" @change="saveMeta()" :disabled="selected.is_system"
|
||||
style="flex:1;font-size:17px;font-weight:700;background:transparent;border:none;color:var(--text,#fff);outline:none;">
|
||||
<button @click="selected = null" style="background:none;border:none;color:var(--text-dim);font-size:18px;cursor:pointer;">✕</button>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text-dim);margin-bottom:10px;" x-text="(kindLabel(selected.kind) + ' · portée ' + selected.scope)"></div>
|
||||
<textarea x-model="selected.description" @change="saveMeta()" :disabled="selected.is_system" rows="2" placeholder="Description…"
|
||||
style="width:100%;padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-size:13px;box-sizing:border-box;"></textarea>
|
||||
|
||||
<div x-show="detailError" x-text="detailError" style="color:#e5484d;font-size:13px;margin-top:8px;"></div>
|
||||
|
||||
<h4 style="margin:16px 0 6px;font-size:13px;">Aperçu</h4>
|
||||
<div style="background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;padding:10px 12px;font-size:13px;">
|
||||
<div style="font-weight:600;margin-bottom:6px;" x-text="detail.title || selected.name"></div>
|
||||
<template x-for="b in (detail.blocks_preview || []).slice(0,6)" :key="b.id">
|
||||
<div style="color:var(--text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" x-text="('• ' + (b.content || ('[' + (b.type||'?') + ']')))"></div>
|
||||
</template>
|
||||
<div x-show="!(detail.blocks_preview || []).length" style="color:var(--text-dim);">Aucun contenu.</div>
|
||||
<div x-show="(detail.warnings || []).length" style="color:#f5a524;font-size:12px;margin-top:6px;" x-text="detail.warnings.join(' · ')"></div>
|
||||
</div>
|
||||
|
||||
<h4 style="margin:16px 0 6px;font-size:13px;">Variables déclarées (JSON)</h4>
|
||||
<textarea x-model="variablesText" rows="4" :disabled="selected.is_system"
|
||||
style="width:100%;font-family:monospace;font-size:12px;padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);box-sizing:border-box;"></textarea>
|
||||
<div style="display:flex;gap:8px;margin-top:6px;">
|
||||
<button class="btn-sm" @click="saveVariables()" x-show="!selected.is_system">Enregistrer</button>
|
||||
</div>
|
||||
|
||||
<div x-show="selected.kind === 'row'">
|
||||
<h4 style="margin:16px 0 6px;font-size:13px;">Propriétés par défaut (JSON, par nom)</h4>
|
||||
<textarea x-model="defaultsText" rows="4" :disabled="selected.is_system"
|
||||
style="width:100%;font-family:monospace;font-size:12px;padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);box-sizing:border-box;"></textarea>
|
||||
<div style="display:flex;gap:8px;margin-top:6px;flex-wrap:wrap;">
|
||||
<button class="btn-sm" @click="saveDefaults()" x-show="!selected.is_system">Enregistrer</button>
|
||||
<button class="btn-sm" @click="toggleDefault()" x-show="!selected.is_system" x-text="selected.is_default ? 'Retirer le défaut' : 'Définir comme défaut'"></button>
|
||||
</div>
|
||||
<h4 style="margin:16px 0 6px;font-size:13px;">Récurrence (RRULE)</h4>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input x-model="rrule" placeholder="FREQ=WEEKLY;BYDAY=MO" :disabled="selected.is_system"
|
||||
style="flex:1;padding:8px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-size:13px;">
|
||||
<button class="btn-sm" @click="saveRecurrence()" x-show="!selected.is_system">OK</button>
|
||||
<button class="btn-sm" @click="clearRecurrence()" x-show="!selected.is_system && selected.recurrence">Arrêter</button>
|
||||
</div>
|
||||
<div x-show="selected.recurrence" style="font-size:12px;color:var(--text-dim);margin-top:4px;"
|
||||
x-text="('Prochain passage : ' + (selected.recurrence.next_run_at || '—'))"></div>
|
||||
<h4 style="margin:16px 0 6px;font-size:13px;">Journal récent</h4>
|
||||
<div style="font-size:12px;color:var(--text-dim);">
|
||||
<template x-for="r in runs" :key="r.id">
|
||||
<div x-text="(r.created_at + ' · ' + r.run_source + ' · ' + r.status + (r.created_id ? ' → ' + r.created_id : ''))"></div>
|
||||
</template>
|
||||
<div x-show="!runs.length">Aucune instanciation journalisée.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 style="margin:16px 0 6px;font-size:13px;">Actions</h4>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;">
|
||||
<button class="btn-sm" @click="useTemplate(selected)">Utiliser</button>
|
||||
<button class="btn-sm" @click="duplicate(selected)" x-text="selected.is_system ? 'Dupliquer pour modifier' : 'Dupliquer'"></button>
|
||||
<button class="btn-sm" @click="exportJson(selected)">Exporter</button>
|
||||
<button class="btn-sm" @click="archive(selected)" x-show="!selected.is_system" x-text="selected.archived_at ? 'Restaurer' : 'Archiver'"></button>
|
||||
<button class="btn-sm btn-sm-danger" @click="remove(selected)" x-show="!selected.is_system">Supprimer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
if (window.Alpine) { Alpine.data('fdTemplatesManager', fdTemplatesManager); }
|
||||
else document.addEventListener('alpine:init', function () { Alpine.data('fdTemplatesManager', fdTemplatesManager); });
|
||||
|
||||
function fdTemplatesManager() {
|
||||
return {
|
||||
items: [], selected: null, detail: { blocks_preview: [], warnings: [] },
|
||||
runs: [], loading: true, error: '', detailError: '',
|
||||
search: '', kindFilter: '', scopeFilter: '', showArchived: false,
|
||||
createOpen: false, form: { name: '', kind: 'page', target_collection_id: '', from_page_id: '' },
|
||||
formError: '', variablesText: '[]', defaultsText: '{}', rrule: '',
|
||||
|
||||
csrf() { return (window.getCsrf && window.getCsrf()) || ''; },
|
||||
headers(json) {
|
||||
const h = { 'X-CSRF-Token': this.csrf() };
|
||||
if (json) h['Content-Type'] = 'application/json';
|
||||
return h;
|
||||
},
|
||||
kindLabel(k) { return { page: 'Page', row: 'Ligne', database: 'Base', blocks: 'Blocs' }[k] || k; },
|
||||
|
||||
async init() { await this.reload(); },
|
||||
async reload() {
|
||||
this.loading = true; this.error = '';
|
||||
try {
|
||||
const p = new URLSearchParams();
|
||||
if (this.kindFilter) p.set('kind', this.kindFilter);
|
||||
if (this.scopeFilter) p.set('scope', this.scopeFilter);
|
||||
if (this.search) p.set('q', this.search);
|
||||
if (this.showArchived) p.set('include_archived', 'true');
|
||||
const r = await fetch('/api/templates?' + p.toString(), { credentials: 'same-origin' });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const d = await r.json();
|
||||
this.items = d.templates || [];
|
||||
} catch (e) { this.error = 'Chargement impossible : ' + e.message; this.items = []; }
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
async select(t) {
|
||||
this.selected = t; this.detailError = ''; this.runs = [];
|
||||
this.variablesText = JSON.stringify(t.variables || [], null, 1);
|
||||
this.defaultsText = JSON.stringify((t.manifest || {}).property_defaults || {}, null, 1);
|
||||
this.rrule = (t.recurrence && t.recurrence.rrule) || '';
|
||||
try {
|
||||
const r = await fetch('/api/templates/' + encodeURIComponent(t.id), { credentials: 'same-origin' });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
const d = await r.json();
|
||||
this.detail = { title: d.name, blocks_preview: d.blocks_preview || [], warnings: [] };
|
||||
const pv = await fetch('/api/templates/' + encodeURIComponent(t.id) + '/preview',
|
||||
{ method: 'POST', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify({}) });
|
||||
if (pv.ok) { const pd = await pv.json(); this.detail = { title: pd.title, blocks_preview: pd.blocks_preview || [], warnings: pd.warnings || [] }; }
|
||||
if (t.kind === 'row') {
|
||||
const rr = await fetch('/api/templates/' + encodeURIComponent(t.id) + '/runs', { credentials: 'same-origin' });
|
||||
if (rr.ok) this.runs = (await rr.json()).runs || [];
|
||||
}
|
||||
} catch (e) { this.detailError = 'Aperçu impossible : ' + e.message; }
|
||||
},
|
||||
|
||||
async create() {
|
||||
this.formError = '';
|
||||
if (!this.form.name.trim()) { this.formError = 'Nom requis.'; return; }
|
||||
if (this.form.kind === 'row' && !this.form.target_collection_id && !this.form.from_page_id) {
|
||||
this.formError = 'Un template de ligne exige une base cible (ID).'; return;
|
||||
}
|
||||
const body = { name: this.form.name.trim(), kind: this.form.kind, scope: 'personal' };
|
||||
if (this.form.target_collection_id) body.target_collection_id = parseInt(this.form.target_collection_id, 10);
|
||||
if (this.form.from_page_id) body.from_page_id = parseInt(this.form.from_page_id, 10);
|
||||
try {
|
||||
const r = await fetch('/api/templates', { method: 'POST', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify(body) });
|
||||
if (!r.ok) { const t = await r.text(); throw new Error('HTTP ' + r.status + ' ' + t.slice(0, 200)); }
|
||||
this.createOpen = false; this.form = { name: '', kind: 'page', target_collection_id: '', from_page_id: '' };
|
||||
await this.reload();
|
||||
window.showToast && window.showToast('Template créé');
|
||||
} catch (e) { this.formError = 'Création impossible : ' + e.message; }
|
||||
},
|
||||
|
||||
async saveMeta() {
|
||||
this.detailError = '';
|
||||
try {
|
||||
const r = await fetch('/api/templates/' + encodeURIComponent(this.selected.id),
|
||||
{ method: 'PATCH', headers: this.headers(true), credentials: 'same-origin',
|
||||
body: JSON.stringify({ name: this.selected.name, description: this.selected.description }) });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
Object.assign(this.selected, await r.json());
|
||||
const i = this.items.findIndex(x => x.id === this.selected.id);
|
||||
if (i >= 0) this.items[i] = this.selected;
|
||||
} catch (e) { this.detailError = 'Enregistrement impossible : ' + e.message; }
|
||||
},
|
||||
async saveVariables() {
|
||||
this.detailError = '';
|
||||
try {
|
||||
const v = JSON.parse(this.variablesText);
|
||||
const r = await fetch('/api/templates/' + encodeURIComponent(this.selected.id) + '/variables',
|
||||
{ method: 'PUT', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify({ variables: v }) });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
Object.assign(this.selected, await r.json());
|
||||
} catch (e) { this.detailError = 'Variables invalides ou enregistrement impossible : ' + e.message; }
|
||||
},
|
||||
async saveDefaults() {
|
||||
this.detailError = '';
|
||||
try {
|
||||
const v = JSON.parse(this.defaultsText);
|
||||
const r = await fetch('/api/templates/' + encodeURIComponent(this.selected.id) + '/property-defaults',
|
||||
{ method: 'PUT', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify(v) });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
Object.assign(this.selected, await r.json());
|
||||
} catch (e) { this.detailError = 'JSON invalide ou enregistrement impossible : ' + e.message; }
|
||||
},
|
||||
async toggleDefault() {
|
||||
const url = '/api/templates/' + encodeURIComponent(this.selected.id) +
|
||||
(this.selected.is_default ? '/unset-default' : '/set-default');
|
||||
const r = await fetch(url, { method: 'POST', headers: this.headers(), credentials: 'same-origin' });
|
||||
if (r.ok) { Object.assign(this.selected, await r.json()); this.reload(); }
|
||||
else this.detailError = 'Défaut : HTTP ' + r.status;
|
||||
},
|
||||
async saveRecurrence() {
|
||||
const r = await fetch('/api/templates/' + encodeURIComponent(this.selected.id) + '/recurrence',
|
||||
{ method: 'PUT', headers: this.headers(true), credentials: 'same-origin',
|
||||
body: JSON.stringify({ rrule: this.rrule }) });
|
||||
if (r.ok) { const d = await r.json(); this.selected.recurrence = d.recurrence; }
|
||||
else this.detailError = 'Récurrence : HTTP ' + r.status;
|
||||
},
|
||||
async clearRecurrence() {
|
||||
const r = await fetch('/api/templates/' + encodeURIComponent(this.selected.id) + '/recurrence',
|
||||
{ method: 'PUT', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify({}) });
|
||||
if (r.ok) { this.selected.recurrence = null; this.rrule = ''; }
|
||||
},
|
||||
|
||||
async useTemplate(t) {
|
||||
try {
|
||||
const r = await fetch('/api/templates/' + encodeURIComponent(t.id) + '/instantiate',
|
||||
{ method: 'POST', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify({}) });
|
||||
if (!r.ok) { const txt = await r.text(); throw new Error('HTTP ' + r.status + ' ' + txt.slice(0, 300)); }
|
||||
const d = await r.json();
|
||||
(d.warnings || []).forEach(w => window.showToast && window.showToast(w, 'error'));
|
||||
window.showToast && window.showToast('Créé depuis « ' + t.name + ' »');
|
||||
if (d.url) { window.fdNavigate ? window.fdNavigate(d.url) : (window.location = d.url); }
|
||||
else this.reload();
|
||||
} catch (e) { this.detailError = 'Application impossible : ' + e.message; window.showToast && window.showToast('Échec : ' + e.message, 'error'); }
|
||||
},
|
||||
async duplicate(t) {
|
||||
const r = await fetch('/api/templates/' + encodeURIComponent(t.id) + '/duplicate',
|
||||
{ method: 'POST', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify({}) });
|
||||
if (r.ok) { await this.reload(); window.showToast && window.showToast('Template dupliqué'); }
|
||||
else this.detailError = 'Duplication : HTTP ' + r.status;
|
||||
},
|
||||
async archive(t) {
|
||||
const action = t.archived_at ? 'restore' : 'archive';
|
||||
const r = await fetch('/api/templates/' + encodeURIComponent(t.id) + '/' + action,
|
||||
{ method: 'POST', headers: this.headers(), credentials: 'same-origin' });
|
||||
if (r.ok) { Object.assign(t, await r.json()); this.reload(); }
|
||||
},
|
||||
async remove(t) {
|
||||
if (!confirm('Supprimer « ' + t.name + ' » ? (corbeille)')) return;
|
||||
const r = await fetch('/api/templates/' + encodeURIComponent(t.id),
|
||||
{ method: 'DELETE', headers: this.headers(), credentials: 'same-origin' });
|
||||
if (r.ok) { this.selected = null; this.reload(); }
|
||||
else this.detailError = 'Suppression : HTTP ' + r.status;
|
||||
},
|
||||
async exportJson(t) {
|
||||
const r = await fetch('/api/templates/' + encodeURIComponent(t.id), { credentials: 'same-origin' });
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
const manifest = {
|
||||
format: 'flowdeck-template', version: 1, kind: d.kind, name: d.name,
|
||||
description: d.description, icon: d.icon, category: d.category,
|
||||
variables: d.variables, manifest: d.manifest, content: { blocks: d.blocks_preview || [] },
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(manifest, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob); a.download = 'template-' + d.name + '.json'; a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
||||
},
|
||||
async importFile(ev) {
|
||||
const f = ev.target.files && ev.target.files[0];
|
||||
if (!f) return;
|
||||
try {
|
||||
const text = await f.text();
|
||||
const m = JSON.parse(text);
|
||||
if (m.format !== 'flowdeck-template') throw new Error('format inconnu');
|
||||
const body = {
|
||||
kind: m.kind, name: m.name, description: m.description || '', icon: m.icon || '📄',
|
||||
category: m.category || '', scope: 'personal', blocks: ((m.content || {}).blocks) || [],
|
||||
manifest: m.manifest || {}, variables: m.variables || [],
|
||||
target_collection_id: ((m.target || {}).collection_id) || undefined,
|
||||
};
|
||||
const r = await fetch('/api/templates', { method: 'POST', headers: this.headers(true), credentials: 'same-origin', body: JSON.stringify(body) });
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
this.createOpen = false; await this.reload();
|
||||
window.showToast && window.showToast('Template importé : ' + m.name);
|
||||
} catch (e) { this.formError = 'Import impossible : ' + e.message; }
|
||||
ev.target.value = '';
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -4,39 +4,96 @@
|
||||
{% block page_icon %}{{ fd_icon("link",18) }}{% endblock %}
|
||||
|
||||
{% block topbar %}
|
||||
{% set breadcrumb_items = [{"label": owner ~ "/" ~ repo, "url": None}] %}
|
||||
{% 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>{% endset %}
|
||||
{% set page_title = workspace_name %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Prism.js syntax highlighting -->
|
||||
<link rel="stylesheet" href="/static/css/prism.css">
|
||||
<script src="/static/js/prism.min.js"></script>
|
||||
<!-- Shared forge layout — identical structure to /local-workspace:
|
||||
ws-split > ws-main, breadcrumb-row + Library-style toolbar
|
||||
(View/Sort/Filter/Search, New file), search bar, filter chips,
|
||||
then tree / list / details / cards / content views bound to the
|
||||
forge tree (filteredTreeItems). File view + editor + commit bar
|
||||
preserved below. -->
|
||||
<link rel="stylesheet" href="/static/css/highlight.css?v={{ asset_version }}">
|
||||
<script data-cfasync="false" src="/static/js/highlight.min.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" src="/static/js/highlight-extra.min.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" src="/static/js/code-highlight.js?v={{ asset_version }}"></script>
|
||||
<style>
|
||||
.gw-main{display:flex;flex-direction:column;height:calc(100vh - 48px);overflow:hidden;}
|
||||
.gw-content{padding:24px;overflow-y:auto;flex:1;}
|
||||
.gw-content h3{font-size:16px;margin-bottom:12px;color:var(--text-dim);}
|
||||
.gw-content pre{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;padding:16px;overflow-x:auto;font-size:13px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:70vh;overflow-y:auto;}
|
||||
.gw-content pre[class*="language-"]{background:transparent;border:none;padding:0;margin:0;white-space:pre;word-break:normal;}
|
||||
.gw-content code[class*="language-"]{font-size:13px;line-height:1.5;tab-size:4;}
|
||||
.gw-content .empty-state{text-align:center;padding:60px 20px;color:var(--text-dim);}
|
||||
.gw-content .empty-state h2{font-size:18px;margin-bottom:8px;color:var(--text);}
|
||||
.gw-content textarea{width:100%;min-height:50vh;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;padding:12px;font-family:monospace;resize:vertical;outline:none;}
|
||||
.gw-content textarea:focus{border-color:var(--accent);}
|
||||
.ws-split{display:flex;max-width:100%;margin:0 auto;padding:0 16px 0;gap:0;min-height:calc(100vh - var(--topbar-height));}
|
||||
.ws-main{flex:1;min-width:0;overflow:auto;}
|
||||
.breadcrumb-row{display:flex;align-items:center;gap:4px;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px 8px 0 0;margin-bottom:0;flex-wrap:wrap;}
|
||||
.breadcrumb-row .nav-arrow{width:22px;height:22px;padding:0;font-size:13px;}
|
||||
.toolbar-row{display:flex;align-items:center;gap:4px;padding:2px 8px;background:var(--bg-secondary);border:1px solid var(--border);border-top:none;border-radius:0 0 8px 8px;margin-bottom:2px;flex-wrap:wrap;}
|
||||
.nav-arrow{width:28px;height:28px;font-size:13px;flex-shrink:0;}
|
||||
.unified-breadcrumb{flex:1;display:flex;align-items:center;gap:2px;min-width:0;font-size:13px;overflow:hidden;}
|
||||
.unified-breadcrumb .bc-root{color:var(--text-secondary);text-decoration:none;white-space:nowrap;font-weight:500;flex-shrink:0;}
|
||||
.unified-breadcrumb .bc-root:hover{color:var(--accent);}
|
||||
.unified-breadcrumb .bc-sep{color:var(--text-tertiary);margin:0 2px;flex-shrink:0;}
|
||||
.unified-breadcrumb a{color:var(--text-secondary);text-decoration:none;white-space:nowrap;}
|
||||
.unified-breadcrumb a:hover{color:var(--accent);}
|
||||
.ws-toolbar{display:flex;align-items:center;gap:2px;flex-shrink:0;margin-left:auto;}
|
||||
.ws-icon-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;background:transparent;border:none;color:var(--text-secondary);cursor:pointer;}
|
||||
.ws-icon-btn:hover,.ws-icon-btn.active{background:var(--bg-hover);color:var(--text-primary);}
|
||||
.ws-icon-btn svg{width:16px;height:16px;}
|
||||
.ws-dropdown{position:absolute;top:calc(100% + 4px);right:0;min-width:210px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:var(--shadow-popover);z-index:200;padding:6px;}
|
||||
.ws-dropdown .dd-label{padding:6px 8px 2px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.3px;}
|
||||
.ws-dropdown .dd-item{padding:6px 8px;font-size:13px;color:var(--text-primary);cursor:pointer;border-radius:4px;display:flex;align-items:center;gap:8px;}
|
||||
.ws-dropdown .dd-item:hover{background:var(--bg-hover);}
|
||||
.ws-dropdown .dd-item.active{color:var(--accent);}
|
||||
.ws-dropdown .dd-item .check{width:14px;flex-shrink:0;color:var(--accent);}
|
||||
.lib-toolbar-wrap{position:relative;}
|
||||
.ws-search-bar{margin-bottom:10px;}
|
||||
.ws-search-bar input{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);color:var(--text-primary);font-size:13px;outline:none;}
|
||||
.ws-search-bar input:focus{border-color:var(--accent);}
|
||||
.filter-row{display:flex;align-items:center;gap:4px;padding:2px 0;margin-bottom:4px;flex-wrap:wrap;}
|
||||
.filter-chip{padding:4px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;font-size:12px;color:var(--text-secondary);cursor:pointer;}
|
||||
.filter-chip:hover{background:var(--bg-hover);color:var(--text);}
|
||||
.filter-chip.active{background:var(--accent);color:#fff;border-color:var(--accent);}
|
||||
.filter-clear{font-size:11px;color:var(--text-tertiary);cursor:pointer;padding:2px 6px;border-radius:4px;}
|
||||
.filter-clear:hover{color:var(--danger);background:var(--bg-hover);}
|
||||
.ws-tree{list-style:none;margin:0;padding:0;}
|
||||
.ws-tree-item{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:6px;cursor:pointer;transition:background 150ms;border:1px solid transparent;margin-bottom:2px;}
|
||||
.ws-tree-item:hover{background:var(--bg-tertiary);border-color:var(--border);}
|
||||
.ws-tree-item .name{flex:1;font-size:14px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.ws-tree-item .name.folder{font-weight:500;}
|
||||
.tree-chevron{width:16px;height:16px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:8px;padding:0;border-radius:3px;}
|
||||
.tree-chevron:hover{color:var(--text);background:var(--bg-hover);}
|
||||
.tree-chevron.open{transform:rotate(90deg);}
|
||||
.ws-table-compact,.ws-table-enhanced{width:100%;border-collapse:collapse;font-size:13px;}
|
||||
.ws-table-compact th,.ws-table-enhanced th{text-align:left;padding:6px 10px;color:var(--text-tertiary);font-weight:500;border-bottom:2px solid var(--border);font-size:11px;text-transform:uppercase;}
|
||||
.ws-table-compact td,.ws-table-enhanced td{padding:5px 10px;border-bottom:1px solid var(--border);}
|
||||
.ws-table-compact tr:hover td,.ws-table-enhanced tr:hover td{background:var(--bg-hover);}
|
||||
.title-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:8px;margin-top:8px;}
|
||||
.title-card{display:flex;flex-direction:column;align-items:center;padding:16px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:10px;cursor:pointer;min-height:100px;justify-content:center;}
|
||||
.title-card:hover{background:var(--bg-hover);border-color:var(--accent);}
|
||||
.title-card-name{font-size:13px;font-weight:500;text-align:center;word-break:break-word;}
|
||||
.title-card-meta{display:flex;gap:8px;margin-top:6px;font-size:11px;color:var(--text-tertiary);}
|
||||
.content-list{display:flex;flex-direction:column;gap:4px;margin-top:8px;}
|
||||
.content-item{padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;cursor:pointer;}
|
||||
.content-item:hover{background:var(--bg-hover);border-color:var(--accent);}
|
||||
.empty-state{text-align:center;padding:80px 20px;color:var(--text-dim);}
|
||||
.empty-state h2{font-size:18px;margin-bottom:8px;color:var(--text);}
|
||||
.gw-file-toolbar{display:flex;align-items:center;gap:8px;padding:8px 24px;background:var(--bg-secondary);border-bottom:1px solid var(--border);font-size:13px;color:var(--text-dim);}
|
||||
.gw-file-toolbar strong{color:var(--text);margin-right:12px;}
|
||||
.gw-content{padding:24px;overflow-y:auto;flex:1;}
|
||||
.gw-content pre{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;padding:16px;overflow-x:auto;font-size:13px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:70vh;overflow-y:auto;}
|
||||
.gw-content textarea{width:100%;min-height:50vh;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;padding:12px;font-family:monospace;resize:vertical;outline:none;}
|
||||
.gw-commit-bar{display:flex;align-items:center;gap:8px;padding:12px 24px;background:var(--bg-secondary);border-top:1px solid var(--border);flex-wrap:wrap;}
|
||||
.gw-commit-bar input{flex:1;min-width:180px;padding:8px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;outline:none;}
|
||||
.gw-commit-bar input:focus{border-color:var(--accent);}
|
||||
|
||||
/* Prism theme tweaks for dark UI */
|
||||
.gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;}
|
||||
.ctx-item{display:flex;align-items:center;gap:8px;padding:7px 14px;font-size:13px;color:var(--text);cursor:pointer;}
|
||||
.ctx-item:hover{background:var(--bg-hover);}
|
||||
.ctx-item.ctx-danger{color:var(--danger);}
|
||||
.btn{padding:10px 20px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;}
|
||||
.btn-primary{background:var(--accent);color:#fff;}
|
||||
.btn-danger{background:rgba(255,80,80,.1);color:var(--danger);}
|
||||
</style>
|
||||
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/gitea_workspace.js?v={{ asset_version }}"></script>
|
||||
<div class="gw-main" x-data="giteaWorkspace()">
|
||||
<div class="ws-split" x-data="giteaWorkspace()">
|
||||
<div class="ws-main">
|
||||
|
||||
<!-- File view -->
|
||||
<div x-show="showFile && !showEditor" x-transition>
|
||||
<div class="gw-file-toolbar">
|
||||
@@ -47,22 +104,10 @@
|
||||
<button class="btn btn-danger" @click="deleteCurrentFile()">{{ fd_icon("trash",14) }} Delete</button>
|
||||
</div>
|
||||
<div class="gw-content">
|
||||
<!-- Skeleton loading -->
|
||||
<div x-show="fileLoading" class="skeleton-file">
|
||||
<div class="skeleton" style="height:15px;width:60%;margin-bottom:12px;"></div>
|
||||
<div class="skeleton" style="height:15px;width:80%;margin-bottom:12px;"></div>
|
||||
<div class="skeleton" style="height:15px;width:40%;margin-bottom:12px;"></div>
|
||||
<div class="skeleton" style="height:15px;width:75%;margin-bottom:12px;"></div>
|
||||
<div class="skeleton" style="height:15px;width:55%;margin-bottom:12px;"></div>
|
||||
<div class="skeleton" style="height:15px;width:90%;margin-bottom:12px;"></div>
|
||||
<div class="skeleton" style="height:15px;width:35%;margin-bottom:12px;"></div>
|
||||
<div class="skeleton" style="height:15px;width:65%;margin-bottom:12px;"></div>
|
||||
<div class="skeleton" style="height:15px;width:45%;margin-bottom:12px;"></div>
|
||||
<div class="skeleton" style="height:15px;width:70%;margin-bottom:12px;"></div>
|
||||
<div class="skeleton" style="height:15px;width:50%;"></div>
|
||||
<div class="gw-code-head" x-show="!fileLoading" style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<span class="gw-code-lang" x-text="fileLanguageLabel" style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-tertiary);"></span>
|
||||
</div>
|
||||
<!-- Syntax-highlighted code -->
|
||||
<pre x-show="!fileLoading"><code :class="'language-'+fileLanguage" x-ref="codeBlock" x-text="fileContent"></code></pre>
|
||||
<pre x-show="!fileLoading"><code class="hljs" :class="'language-'+fileLanguage" :data-hljs-lang="fileLanguage" x-ref="codeBlock" x-text="fileContent"></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -89,24 +134,89 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- File tree browser (like local-workspace) -->
|
||||
<!-- Browser (same chrome as /local-workspace) -->
|
||||
<template x-if="!showFile && !showEditor && !showPrivate">
|
||||
<div class="gw-content">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="gw-breadcrumb" style="display:flex;align-items:center;gap:4px;margin-bottom:16px;font-size:14px;color:var(--text-secondary);">
|
||||
<a href="#" @click.prevent="navigateToRoot()" style="color:var(--text-secondary);text-decoration:none;" x-text="owner + '/' + repo"></a>
|
||||
<template x-for="(b, i) in folderStack" :key="i">
|
||||
<span style="display:contents;">
|
||||
<span style="color:var(--text-tertiary);">/</span>
|
||||
<a href="#" @click.prevent="navigateToFolder(i)" style="color:var(--text-secondary);text-decoration:none;" x-text="b"></a>
|
||||
</span>
|
||||
</template>
|
||||
<span x-show="treeLoading" style="color:var(--text-tertiary);">Loading...</span>
|
||||
<div>
|
||||
<div class="breadcrumb-row">
|
||||
<a href="/workspaces" class="btn-icon nav-arrow" title="All workspaces">{{ fd_icon("home",14) }}</a>
|
||||
<div class="unified-breadcrumb">
|
||||
<a href="#" @click.prevent="navigateToRoot()" class="bc-root" style="cursor:pointer;" x-text="owner + '/' + repo"></a>
|
||||
<template x-for="(b, i) in folderStack" :key="i">
|
||||
<span style="display:contents;">
|
||||
<span class="bc-sep">/</span>
|
||||
<a href="#" @click.prevent="navigateToFolder(i)" x-text="b"></a>
|
||||
</span>
|
||||
</template>
|
||||
<span x-show="treeLoading" style="color:var(--text-tertiary);">Loading...</span>
|
||||
</div>
|
||||
<div class="ws-toolbar">
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<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>
|
||||
</button>
|
||||
<div class="ws-dropdown" x-show="viewMenuOpen" @click.outside="viewMenuOpen=false" x-transition style="right:0;">
|
||||
<div class="dd-label">View</div>
|
||||
<div class="dd-item" :class="{active: viewMode==='tree'}" @click="viewMode='tree', viewMenuOpen=false"><span class="check" x-text="viewMode==='tree'?'✓':''"></span>{{ fd_icon("folder",14) }} Tree</div>
|
||||
<div class="dd-item" :class="{active: viewMode==='list'}" @click="viewMode='list', viewMenuOpen=false"><span class="check" x-text="viewMode==='list'?'✓':''"></span>{{ fd_icon("list",14) }} List</div>
|
||||
<div class="dd-item" :class="{active: viewMode==='details'}" @click="viewMode='details', viewMenuOpen=false"><span class="check" x-text="viewMode==='details'?'✓':''"></span>{{ fd_icon("bar-chart",14) }} Details</div>
|
||||
<div class="dd-item" :class="{active: viewMode==='title'}" @click="viewMode='title', viewMenuOpen=false"><span class="check" x-text="viewMode==='title'?'✓':''"></span>{{ fd_icon("grid",14) }} Cards</div>
|
||||
<div class="dd-item" :class="{active: viewMode==='content'}" @click="viewMode='content', viewMenuOpen=false"><span class="check" x-text="viewMode==='content'?'✓':''"></span>{{ fd_icon("align-left",14) }} Content</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<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"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition style="right:0;">
|
||||
<div class="dd-label">Sort by</div>
|
||||
<div class="dd-item" :class="{active: sortBy===''}" @click="sortBy='', doSort(), sortOpen=false"><span class="check" x-text="sortBy===''?'✓':''"></span> Name A→Z</div>
|
||||
<div class="dd-item" :class="{active: sortBy==='name-desc'}" @click="sortBy='name-desc', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='name-desc'?'✓':''"></span> Name Z→A</div>
|
||||
<div class="dd-item" :class="{active: sortBy==='type'}" @click="sortBy='type', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='type'?'✓':''"></span> Type</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<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>
|
||||
</button>
|
||||
<div class="ws-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition style="right:0;">
|
||||
<div class="dd-label">File type</div>
|
||||
<div class="dd-item" :class="{active: filterType==='folder'}" @click="filterByType('folder'), filterOpen=false"><span class="check" x-text="filterType==='folder'?'✓':''"></span>{{ fd_icon("folder",14) }} Folders</div>
|
||||
<div class="dd-item" :class="{active: filterType==='page'}" @click="filterByType('page'), filterOpen=false"><span class="check" x-text="filterType==='page'?'✓':''"></span>{{ fd_icon("file",14) }} Files</div>
|
||||
<div class="dd-item" :class="{active: filterType==='image'}" @click="filterByType('image'), filterOpen=false"><span class="check" x-text="filterType==='image'?'✓':''"></span>{{ fd_icon("image",14) }} Images</div>
|
||||
<div class="dd-item" :class="{active: filterType==='code'}" @click="filterByType('code'), filterOpen=false"><span class="check" x-text="filterType==='code'?'✓':''"></span>{{ fd_icon("file",14) }} Code</div>
|
||||
<div class="more-sep" x-show="filterType"></div>
|
||||
<div class="dd-item" x-show="filterType" @click="filterType='', doFilter(), filterOpen=false" style="color:var(--text-dim);">{{ fd_icon("x",14) }} Clear filter</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="toggleSearch()">
|
||||
<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 class="ws-icon-btn" @click="refreshTree()" title="Refresh">{{ fd_icon("refresh",16) }}</button>
|
||||
<button class="ws-icon-btn" @click="showNewFile=!showNewFile" title="New file">{{ fd_icon("file",14) }}+</button>
|
||||
<button class="ws-icon-btn" @click="createFolderAtRoot()" title="New folder">{{ fd_icon("folder",14) }}+</button>
|
||||
<button class="ws-icon-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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-search-bar" x-show="searchOpen" x-transition>
|
||||
<input type="text" placeholder="Filter files and folders..." x-model="searchQuery" @input="doFilter()" autocomplete="off">
|
||||
</div>
|
||||
<div class="filter-row" x-show="filterType" style="padding:4px 0;margin-bottom:6px;">
|
||||
<span class="filter-clear" @click="filterType='', doFilter()">clear filter ×</span>
|
||||
</div>
|
||||
|
||||
<!-- Tree items -->
|
||||
<div class="gw-content" style="padding:12px 4px 24px;">
|
||||
<div x-show="!treeLoading">
|
||||
<template x-if="treeItems.length === 0">
|
||||
<template x-if="treeError">
|
||||
<div class="empty-state">
|
||||
<h2>{{ fd_icon("link",16) }} Repository unavailable</h2>
|
||||
<p x-text="treeErrorText || 'Could not load the repository tree.'"></p>
|
||||
<div class="empty-actions">
|
||||
<button class="btn btn-primary" @click="refreshTree()">Retry</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!treeError && (filteredTreeItems || []).length === 0">
|
||||
<div class="empty-state">
|
||||
<h2>{{ fd_icon("folder",16) }} Empty</h2>
|
||||
<p>No files in this folder</p>
|
||||
@@ -114,12 +224,12 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="ws-tree">
|
||||
<template x-for="item in sortedTreeItems" :key="item.path">
|
||||
<!-- Tree view -->
|
||||
<div class="ws-tree" x-show="viewMode==='tree'">
|
||||
<template x-for="item in (filteredTreeItems || [])" :key="item.path">
|
||||
<div class="ws-tree-item"
|
||||
@click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)"
|
||||
@contextmenu.prevent="openGwContext($event, item)"
|
||||
style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;">
|
||||
@contextmenu.prevent="openGwContext($event, item)">
|
||||
<span x-init="bindGwIcon($el, item)" style="font-size:16px;"></span>
|
||||
<span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span>
|
||||
<span x-text="item.type==='folder' ? '' : formatSize(item.size)" style="font-size:12px;color:var(--text-tertiary);"></span>
|
||||
@@ -127,6 +237,59 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- List view -->
|
||||
<table class="ws-table-compact" x-show="viewMode==='list' && (filteredTreeItems || []).length>0">
|
||||
<thead><tr><th>Name</th><th style="width:80px">Type</th><th style="width:80px">Size</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="item in (filteredTreeItems || [])" :key="'l'+item.path">
|
||||
<tr @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)" style="cursor:pointer">
|
||||
<td x-text="item.name"></td>
|
||||
<td style="font-size:12px;color:var(--text-tertiary)" x-text="item.type"></td>
|
||||
<td style="font-size:12px;color:var(--text-secondary)" x-text="formatSize(item.size)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Details view -->
|
||||
<table class="ws-table-enhanced" x-show="viewMode==='details' && (filteredTreeItems || []).length>0">
|
||||
<thead><tr><th>Name</th><th>Path</th><th>Size</th><th>Type</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="item in (filteredTreeItems || [])" :key="'d'+item.path">
|
||||
<tr @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)" style="cursor:pointer">
|
||||
<td x-text="item.name"></td>
|
||||
<td style="font-size:12px;color:var(--text-tertiary);font-family:monospace;" x-text="item.path"></td>
|
||||
<td style="font-size:12px;" x-text="formatSize(item.size)"></td>
|
||||
<td style="font-size:12px;" x-text="item.type"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Cards view -->
|
||||
<div class="title-grid" x-show="viewMode==='title' && (filteredTreeItems || []).length>0">
|
||||
<template x-for="item in (filteredTreeItems || [])" :key="'g'+item.path">
|
||||
<div class="title-card" @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)">
|
||||
<div class="title-card-icon" x-init="bindGwIcon($el, item)"></div>
|
||||
<div class="title-card-name" x-text="item.name"></div>
|
||||
<div class="title-card-meta"><span x-text="formatSize(item.size)"></span></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Content view -->
|
||||
<div class="content-list" x-show="viewMode==='content' && (filteredTreeItems || []).length>0">
|
||||
<template x-for="item in (filteredTreeItems || [])" :key="'c'+item.path">
|
||||
<div class="content-item" @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)">
|
||||
<div class="content-item-header">
|
||||
<span x-init="bindGwIcon($el, item)"></span>
|
||||
<span x-text="item.name"></span>
|
||||
<span style="font-size:11px;color:var(--text-tertiary);margin-left:auto" x-text="formatSize(item.size)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Context menu -->
|
||||
<div class="gw-context-menu"
|
||||
x-show="gwCtx.visible"
|
||||
@@ -145,6 +308,7 @@
|
||||
<input type="text" x-model="newFileMsg" placeholder="Commit message" class="settings-input" style="margin-bottom:8px;">
|
||||
<button class="btn btn-primary" @click="createNewFile()">{{ fd_icon("save",14) }} Create file</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -188,6 +352,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
{% extends "base.html" %}
|
||||
{% block page_title %}{{ workspace_name }}{% endblock %}
|
||||
{% block title_prefix %}{{ workspace_name }}{% endblock %}
|
||||
{% block page_icon %}{{ fd_icon("link",18) }}{% endblock %}
|
||||
|
||||
{% block topbar %}
|
||||
{% set page_icon = "link" %}
|
||||
{% set page_title = workspace_name %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Shared forge layout — identical structure to /local-workspace:
|
||||
ws-split > ws-main, breadcrumb-row + Library-style toolbar
|
||||
(View/Sort/Filter/Search, New file), search bar, filter chips,
|
||||
then tree / list / details / cards / content views bound to the
|
||||
forge tree (filteredTreeItems). File view + editor + commit bar
|
||||
preserved below. -->
|
||||
<link rel="stylesheet" href="/static/css/highlight.css?v={{ asset_version }}">
|
||||
<script data-cfasync="false" src="/static/js/highlight.min.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" src="/static/js/highlight-extra.min.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" src="/static/js/code-highlight.js?v={{ asset_version }}"></script>
|
||||
<style>
|
||||
.ws-split{display:flex;max-width:100%;margin:0 auto;padding:0 16px 0;gap:0;min-height:calc(100vh - var(--topbar-height));}
|
||||
.ws-main{flex:1;min-width:0;overflow:auto;}
|
||||
.breadcrumb-row{display:flex;align-items:center;gap:4px;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px 8px 0 0;margin-bottom:0;flex-wrap:wrap;}
|
||||
.breadcrumb-row .nav-arrow{width:22px;height:22px;padding:0;font-size:13px;}
|
||||
.toolbar-row{display:flex;align-items:center;gap:4px;padding:2px 8px;background:var(--bg-secondary);border:1px solid var(--border);border-top:none;border-radius:0 0 8px 8px;margin-bottom:2px;flex-wrap:wrap;}
|
||||
.nav-arrow{width:28px;height:28px;font-size:13px;flex-shrink:0;}
|
||||
.unified-breadcrumb{flex:1;display:flex;align-items:center;gap:2px;min-width:0;font-size:13px;overflow:hidden;}
|
||||
.unified-breadcrumb .bc-root{color:var(--text-secondary);text-decoration:none;white-space:nowrap;font-weight:500;flex-shrink:0;}
|
||||
.unified-breadcrumb .bc-root:hover{color:var(--accent);}
|
||||
.unified-breadcrumb .bc-sep{color:var(--text-tertiary);margin:0 2px;flex-shrink:0;}
|
||||
.unified-breadcrumb a{color:var(--text-secondary);text-decoration:none;white-space:nowrap;}
|
||||
.unified-breadcrumb a:hover{color:var(--accent);}
|
||||
.ws-toolbar{display:flex;align-items:center;gap:2px;flex-shrink:0;margin-left:auto;}
|
||||
.ws-icon-btn{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;background:transparent;border:none;color:var(--text-secondary);cursor:pointer;}
|
||||
.ws-icon-btn:hover,.ws-icon-btn.active{background:var(--bg-hover);color:var(--text-primary);}
|
||||
.ws-icon-btn svg{width:16px;height:16px;}
|
||||
.ws-dropdown{position:absolute;top:calc(100% + 4px);right:0;min-width:210px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:var(--shadow-popover);z-index:200;padding:6px;}
|
||||
.ws-dropdown .dd-label{padding:6px 8px 2px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.3px;}
|
||||
.ws-dropdown .dd-item{padding:6px 8px;font-size:13px;color:var(--text-primary);cursor:pointer;border-radius:4px;display:flex;align-items:center;gap:8px;}
|
||||
.ws-dropdown .dd-item:hover{background:var(--bg-hover);}
|
||||
.ws-dropdown .dd-item.active{color:var(--accent);}
|
||||
.ws-dropdown .dd-item .check{width:14px;flex-shrink:0;color:var(--accent);}
|
||||
.lib-toolbar-wrap{position:relative;}
|
||||
.ws-search-bar{margin-bottom:10px;}
|
||||
.ws-search-bar input{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-secondary);color:var(--text-primary);font-size:13px;outline:none;}
|
||||
.ws-search-bar input:focus{border-color:var(--accent);}
|
||||
.filter-row{display:flex;align-items:center;gap:4px;padding:2px 0;margin-bottom:4px;flex-wrap:wrap;}
|
||||
.filter-chip{padding:4px 10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:6px;font-size:12px;color:var(--text-secondary);cursor:pointer;}
|
||||
.filter-chip:hover{background:var(--bg-hover);color:var(--text);}
|
||||
.filter-chip.active{background:var(--accent);color:#fff;border-color:var(--accent);}
|
||||
.filter-clear{font-size:11px;color:var(--text-tertiary);cursor:pointer;padding:2px 6px;border-radius:4px;}
|
||||
.filter-clear:hover{color:var(--danger);background:var(--bg-hover);}
|
||||
.ws-tree{list-style:none;margin:0;padding:0;}
|
||||
.ws-tree-item{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:6px;cursor:pointer;transition:background 150ms;border:1px solid transparent;margin-bottom:2px;}
|
||||
.ws-tree-item:hover{background:var(--bg-tertiary);border-color:var(--border);}
|
||||
.ws-tree-item .name{flex:1;font-size:14px;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.ws-tree-item .name.folder{font-weight:500;}
|
||||
.tree-chevron{width:16px;height:16px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:8px;padding:0;border-radius:3px;}
|
||||
.tree-chevron:hover{color:var(--text);background:var(--bg-hover);}
|
||||
.tree-chevron.open{transform:rotate(90deg);}
|
||||
.ws-table-compact,.ws-table-enhanced{width:100%;border-collapse:collapse;font-size:13px;}
|
||||
.ws-table-compact th,.ws-table-enhanced th{text-align:left;padding:6px 10px;color:var(--text-tertiary);font-weight:500;border-bottom:2px solid var(--border);font-size:11px;text-transform:uppercase;}
|
||||
.ws-table-compact td,.ws-table-enhanced td{padding:5px 10px;border-bottom:1px solid var(--border);}
|
||||
.ws-table-compact tr:hover td,.ws-table-enhanced tr:hover td{background:var(--bg-hover);}
|
||||
.title-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:8px;margin-top:8px;}
|
||||
.title-card{display:flex;flex-direction:column;align-items:center;padding:16px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:10px;cursor:pointer;min-height:100px;justify-content:center;}
|
||||
.title-card:hover{background:var(--bg-hover);border-color:var(--accent);}
|
||||
.title-card-name{font-size:13px;font-weight:500;text-align:center;word-break:break-word;}
|
||||
.title-card-meta{display:flex;gap:8px;margin-top:6px;font-size:11px;color:var(--text-tertiary);}
|
||||
.content-list{display:flex;flex-direction:column;gap:4px;margin-top:8px;}
|
||||
.content-item{padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;cursor:pointer;}
|
||||
.content-item:hover{background:var(--bg-hover);border-color:var(--accent);}
|
||||
.empty-state{text-align:center;padding:80px 20px;color:var(--text-dim);}
|
||||
.empty-state h2{font-size:18px;margin-bottom:8px;color:var(--text);}
|
||||
.gw-file-toolbar{display:flex;align-items:center;gap:8px;padding:8px 24px;background:var(--bg-secondary);border-bottom:1px solid var(--border);font-size:13px;color:var(--text-dim);}
|
||||
.gw-file-toolbar strong{color:var(--text);margin-right:12px;}
|
||||
.gw-content{padding:24px;overflow-y:auto;flex:1;}
|
||||
.gw-content pre{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;padding:16px;overflow-x:auto;font-size:13px;line-height:1.5;white-space:pre-wrap;word-break:break-word;max-height:70vh;overflow-y:auto;}
|
||||
.gw-content textarea{width:100%;min-height:50vh;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;padding:12px;font-family:monospace;resize:vertical;outline:none;}
|
||||
.gw-commit-bar{display:flex;align-items:center;gap:8px;padding:12px 24px;background:var(--bg-secondary);border-top:1px solid var(--border);flex-wrap:wrap;}
|
||||
.gw-commit-bar input{flex:1;min-width:180px;padding:8px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;outline:none;}
|
||||
.ctx-item{display:flex;align-items:center;gap:8px;padding:7px 14px;font-size:13px;color:var(--text);cursor:pointer;}
|
||||
.ctx-item:hover{background:var(--bg-hover);}
|
||||
.ctx-item.ctx-danger{color:var(--danger);}
|
||||
.btn{padding:10px 20px;border-radius:8px;font-size:13px;cursor:pointer;border:none;font-weight:500;}
|
||||
.btn-primary{background:var(--accent);color:#fff;}
|
||||
.btn-danger{background:rgba(255,80,80,.1);color:var(--danger);}
|
||||
</style>
|
||||
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/gitea_workspace.js?v={{ asset_version }}"></script>
|
||||
<div class="ws-split" x-data="giteaWorkspace()">
|
||||
<div class="ws-main">
|
||||
|
||||
<!-- File view -->
|
||||
<div x-show="showFile && !showEditor" x-transition>
|
||||
<div class="gw-file-toolbar">
|
||||
<strong x-text="filePath || ''"></strong>
|
||||
<span x-text="formatSize(fileSize)"></span>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" @click="openEditor()">{{ fd_icon("edit",14) }} Edit</button>
|
||||
<button class="btn btn-danger" @click="deleteCurrentFile()">{{ fd_icon("trash",14) }} Delete</button>
|
||||
</div>
|
||||
<div class="gw-content">
|
||||
<div class="gw-code-head" x-show="!fileLoading" style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
|
||||
<span class="gw-code-lang" x-text="fileLanguageLabel" style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--text-tertiary);"></span>
|
||||
</div>
|
||||
<pre x-show="!fileLoading"><code class="hljs" :class="'language-'+fileLanguage" :data-hljs-lang="fileLanguage" x-ref="codeBlock" x-text="fileContent"></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor view -->
|
||||
<template x-if="showEditor">
|
||||
<div>
|
||||
<div class="gw-file-toolbar">
|
||||
<strong>Editing: <span x-text="filePath || 'new file'"></span></strong>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" @click="showEditor=false">Cancel</button>
|
||||
</div>
|
||||
<div class="gw-content">
|
||||
<textarea x-model="editContent"></textarea>
|
||||
</div>
|
||||
<div class="gw-commit-bar">
|
||||
<input type="text" x-model="commitMessage" placeholder="Commit message" list="commit-history">
|
||||
<datalist id="commit-history">
|
||||
<template x-for="msg in commitHistory" :key="msg">
|
||||
<option :value="msg">
|
||||
</template>
|
||||
</datalist>
|
||||
<button class="btn-primary" @click="saveFile()">{{ fd_icon("save",14) }} Commit</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Browser (same chrome as /local-workspace) -->
|
||||
<template x-if="!showFile && !showEditor && !showPrivate">
|
||||
<div>
|
||||
<div class="breadcrumb-row">
|
||||
<a href="/workspaces" class="btn-icon nav-arrow" title="All workspaces">{{ fd_icon("home",14) }}</a>
|
||||
<div class="unified-breadcrumb">
|
||||
<a href="#" @click.prevent="navigateToRoot()" class="bc-root" style="cursor:pointer;" x-text="owner + '/' + repo"></a>
|
||||
<template x-for="(b, i) in folderStack" :key="i">
|
||||
<span style="display:contents;">
|
||||
<span class="bc-sep">/</span>
|
||||
<a href="#" @click.prevent="navigateToFolder(i)" x-text="b"></a>
|
||||
</span>
|
||||
</template>
|
||||
<span x-show="treeLoading" style="color:var(--text-tertiary);">Loading...</span>
|
||||
</div>
|
||||
<div class="ws-toolbar">
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<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>
|
||||
</button>
|
||||
<div class="ws-dropdown" x-show="viewMenuOpen" @click.outside="viewMenuOpen=false" x-transition style="right:0;">
|
||||
<div class="dd-label">View</div>
|
||||
<div class="dd-item" :class="{active: viewMode==='tree'}" @click="viewMode='tree', viewMenuOpen=false"><span class="check" x-text="viewMode==='tree'?'✓':''"></span>{{ fd_icon("folder",14) }} Tree</div>
|
||||
<div class="dd-item" :class="{active: viewMode==='list'}" @click="viewMode='list', viewMenuOpen=false"><span class="check" x-text="viewMode==='list'?'✓':''"></span>{{ fd_icon("list",14) }} List</div>
|
||||
<div class="dd-item" :class="{active: viewMode==='details'}" @click="viewMode='details', viewMenuOpen=false"><span class="check" x-text="viewMode==='details'?'✓':''"></span>{{ fd_icon("bar-chart",14) }} Details</div>
|
||||
<div class="dd-item" :class="{active: viewMode==='title'}" @click="viewMode='title', viewMenuOpen=false"><span class="check" x-text="viewMode==='title'?'✓':''"></span>{{ fd_icon("grid",14) }} Cards</div>
|
||||
<div class="dd-item" :class="{active: viewMode==='content'}" @click="viewMode='content', viewMenuOpen=false"><span class="check" x-text="viewMode==='content'?'✓':''"></span>{{ fd_icon("align-left",14) }} Content</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<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"/></svg>
|
||||
</button>
|
||||
<div class="ws-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition style="right:0;">
|
||||
<div class="dd-label">Sort by</div>
|
||||
<div class="dd-item" :class="{active: sortBy===''}" @click="sortBy='', doSort(), sortOpen=false"><span class="check" x-text="sortBy===''?'✓':''"></span> Name A→Z</div>
|
||||
<div class="dd-item" :class="{active: sortBy==='name-desc'}" @click="sortBy='name-desc', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='name-desc'?'✓':''"></span> Name Z→A</div>
|
||||
<div class="dd-item" :class="{active: sortBy==='type'}" @click="sortBy='type', doSort(), sortOpen=false"><span class="check" x-text="sortBy==='type'?'✓':''"></span> Type</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
<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>
|
||||
</button>
|
||||
<div class="ws-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition style="right:0;">
|
||||
<div class="dd-label">File type</div>
|
||||
<div class="dd-item" :class="{active: filterType==='folder'}" @click="filterByType('folder'), filterOpen=false"><span class="check" x-text="filterType==='folder'?'✓':''"></span>{{ fd_icon("folder",14) }} Folders</div>
|
||||
<div class="dd-item" :class="{active: filterType==='page'}" @click="filterByType('page'), filterOpen=false"><span class="check" x-text="filterType==='page'?'✓':''"></span>{{ fd_icon("file",14) }} Files</div>
|
||||
<div class="dd-item" :class="{active: filterType==='image'}" @click="filterByType('image'), filterOpen=false"><span class="check" x-text="filterType==='image'?'✓':''"></span>{{ fd_icon("image",14) }} Images</div>
|
||||
<div class="dd-item" :class="{active: filterType==='code'}" @click="filterByType('code'), filterOpen=false"><span class="check" x-text="filterType==='code'?'✓':''"></span>{{ fd_icon("file",14) }} Code</div>
|
||||
<div class="more-sep" x-show="filterType"></div>
|
||||
<div class="dd-item" x-show="filterType" @click="filterType='', doFilter(), filterOpen=false" style="color:var(--text-dim);">{{ fd_icon("x",14) }} Clear filter</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="toggleSearch()">
|
||||
<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 class="ws-icon-btn" @click="refreshTree()" title="Refresh">{{ fd_icon("refresh",16) }}</button>
|
||||
<button class="ws-icon-btn" @click="showNewFile=!showNewFile" title="New file">{{ fd_icon("file",14) }}+</button>
|
||||
<button class="ws-icon-btn" @click="createFolderAtRoot()" title="New folder">{{ fd_icon("folder",14) }}+</button>
|
||||
<button class="ws-icon-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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ws-search-bar" x-show="searchOpen" x-transition>
|
||||
<input type="text" placeholder="Filter files and folders..." x-model="searchQuery" @input="doFilter()" autocomplete="off">
|
||||
</div>
|
||||
<div class="filter-row" x-show="filterType" style="padding:4px 0;margin-bottom:6px;">
|
||||
<span class="filter-clear" @click="filterType='', doFilter()">clear filter ×</span>
|
||||
</div>
|
||||
|
||||
<div class="gw-content" style="padding:12px 4px 24px;">
|
||||
<div x-show="!treeLoading">
|
||||
<template x-if="treeError">
|
||||
<div class="empty-state">
|
||||
<h2>{{ fd_icon("link",16) }} Repository unavailable</h2>
|
||||
<p x-text="treeErrorText || 'Could not load the repository tree.'"></p>
|
||||
<div class="empty-actions">
|
||||
<button class="btn btn-primary" @click="refreshTree()">Retry</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!treeError && (filteredTreeItems || []).length === 0">
|
||||
<div class="empty-state">
|
||||
<h2>{{ fd_icon("folder",16) }} Empty</h2>
|
||||
<p>No files in this folder</p>
|
||||
<button class="btn btn-primary" @click="showNewFile=!showNewFile">{{ fd_icon("file",14) }}+ New file</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Tree view -->
|
||||
<div class="ws-tree" x-show="viewMode==='tree'">
|
||||
<template x-for="item in (filteredTreeItems || [])" :key="item.path">
|
||||
<div class="ws-tree-item"
|
||||
@click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)"
|
||||
@contextmenu.prevent="openGwContext($event, item)">
|
||||
<span x-init="bindGwIcon($el, item)" style="font-size:16px;"></span>
|
||||
<span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span>
|
||||
<span x-text="item.type==='folder' ? '' : formatSize(item.size)" style="font-size:12px;color:var(--text-tertiary);"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- List view -->
|
||||
<table class="ws-table-compact" x-show="viewMode==='list' && (filteredTreeItems || []).length>0">
|
||||
<thead><tr><th>Name</th><th style="width:80px">Type</th><th style="width:80px">Size</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="item in (filteredTreeItems || [])" :key="'l'+item.path">
|
||||
<tr @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)" style="cursor:pointer">
|
||||
<td x-text="item.name"></td>
|
||||
<td style="font-size:12px;color:var(--text-tertiary)" x-text="item.type"></td>
|
||||
<td style="font-size:12px;color:var(--text-secondary)" x-text="formatSize(item.size)"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Details view -->
|
||||
<table class="ws-table-enhanced" x-show="viewMode==='details' && (filteredTreeItems || []).length>0">
|
||||
<thead><tr><th>Name</th><th>Path</th><th>Size</th><th>Type</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="item in (filteredTreeItems || [])" :key="'d'+item.path">
|
||||
<tr @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)" style="cursor:pointer">
|
||||
<td x-text="item.name"></td>
|
||||
<td style="font-size:12px;color:var(--text-tertiary);font-family:monospace;" x-text="item.path"></td>
|
||||
<td style="font-size:12px;" x-text="formatSize(item.size)"></td>
|
||||
<td style="font-size:12px;" x-text="item.type"></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Cards view -->
|
||||
<div class="title-grid" x-show="viewMode==='title' && (filteredTreeItems || []).length>0">
|
||||
<template x-for="item in (filteredTreeItems || [])" :key="'g'+item.path">
|
||||
<div class="title-card" @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)">
|
||||
<div class="title-card-icon" x-init="bindGwIcon($el, item)"></div>
|
||||
<div class="title-card-name" x-text="item.name"></div>
|
||||
<div class="title-card-meta"><span x-text="formatSize(item.size)"></span></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Content view -->
|
||||
<div class="content-list" x-show="viewMode==='content' && (filteredTreeItems || []).length>0">
|
||||
<template x-for="item in (filteredTreeItems || [])" :key="'c'+item.path">
|
||||
<div class="content-item" @click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)">
|
||||
<div class="content-item-header">
|
||||
<span x-init="bindGwIcon($el, item)"></span>
|
||||
<span x-text="item.name"></span>
|
||||
<span style="font-size:11px;color:var(--text-tertiary);margin-left:auto" x-text="formatSize(item.size)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Context menu -->
|
||||
<div class="gw-context-menu"
|
||||
x-show="gwCtx.visible"
|
||||
:style="'left:' + gwCtx.x + 'px; top:' + gwCtx.y + 'px'"
|
||||
@click.outside="gwCtx.visible = false"
|
||||
style="display:none;position:fixed;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-md);box-shadow:var(--shadow-popover);z-index:1100;min-width:160px;padding:4px;">
|
||||
<div class="ctx-item" @click="gwRename()">{{ fd_icon("edit",14) }} Rename</div>
|
||||
<div class="ctx-item ctx-danger" @click="gwDelete()">{{ fd_icon("trash",14) }} Delete</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New file form -->
|
||||
<div x-show="showNewFile" style="margin-top:16px;max-width:400px;">
|
||||
<input type="text" x-model="newFilePath" placeholder="path/to/file.md" class="settings-input" style="margin-bottom:8px;">
|
||||
<textarea x-model="newFileContent" placeholder="File content..." class="settings-input" style="height:120px;margin-bottom:8px;"></textarea>
|
||||
<input type="text" x-model="newFileMsg" placeholder="Commit message" class="settings-input" style="margin-bottom:8px;">
|
||||
<button class="btn btn-primary" @click="createNewFile()">{{ fd_icon("save",14) }} Create file</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Private pages view -->
|
||||
<template x-if="showPrivate && !editingPrivate">
|
||||
<div class="gw-content">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
||||
<h3>{{ fd_icon("lock",14) }} Private Pages</h3>
|
||||
<button class="btn btn-primary" onclick="window.FlowDeck.createPage()">+ New Page</button>
|
||||
</div>
|
||||
<template x-if="privatePages.length === 0">
|
||||
<div class="empty-state" style="padding:40px 20px;">
|
||||
<p>No private pages yet. Create one to store notes linked to this project.</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-for="pp in privatePages" :key="pp.id">
|
||||
<div style="background:var(--bg-secondary);border:1px solid var(--border);border-radius:8px;padding:16px;margin-bottom:8px;cursor:pointer;display:flex;align-items:center;justify-content:space-between;"
|
||||
@click="openPrivate(pp.id)">
|
||||
<div>
|
||||
<div style="font-weight:500;" x-text="pp.title"></div>
|
||||
<div style="font-size:12px;color:var(--text-dim);" x-text="fmtGwDate(pp)"></div>
|
||||
</div>
|
||||
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">{{ fd_icon('trash',14) }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Private page editor -->
|
||||
<template x-if="editingPrivate">
|
||||
<div>
|
||||
<div class="gw-file-toolbar">
|
||||
<strong>Editing: <span x-text="editingPrivateTitle || 'Untitled'"></span></strong>
|
||||
<span style="flex:1;"></span>
|
||||
<button class="btn" @click="savePrivate()">{{ fd_icon("save",14) }} Save</button>
|
||||
<button class="btn" @click="closePrivateEdit()">Cancel</button>
|
||||
</div>
|
||||
<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;">
|
||||
<textarea x-model="editingPrivateContent" placeholder="Start writing…" style="min-height:60vh;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,252 @@
|
||||
{% 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>
|
||||
|
||||
<!-- v7.69.3 : positionnement IDENTIQUE à la section Settings — les surcharges
|
||||
inline (position:static / background:none / panneau 1050px) faisaient
|
||||
d'Aide un bloc posé dans la page au lieu de l'overlay centré de Settings. -->
|
||||
<div class="settings-overlay" x-data="helpInit()">
|
||||
<div class="settings-panel" @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 %}
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Importer — FlowDeck</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="icon" href="/static/favicon.ico" sizes="48x48">
|
||||
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
|
||||
<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;}
|
||||
|
||||
@@ -5,12 +5,23 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{% from '_icons.html' import fd_icon %}
|
||||
<title>FlowDeck — All-in-one workspace</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="icon" href="/static/favicon.ico" sizes="48x48">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<meta name="theme-color" content="#191919">
|
||||
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<!-- v7.69 : identité visuelle — la landing est la page la plus partagée -->
|
||||
<meta name="description" content="FlowDeck — espace de travail style Notion auto-hébergé, intégré à Gitea/GitHub. Éditeur de blocs, Kanban, bases de données.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="FlowDeck">
|
||||
<meta property="og:title" content="FlowDeck — votre espace Notion auto-hébergé">
|
||||
<meta property="og:description" content="Éditeur de blocs, Kanban, bases de données et intégration Gitea/GitHub, sur votre propre serveur.">
|
||||
<meta property="og:image" content="{{ app_base_url() }}/static/img/logo-512.jpg">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="FlowDeck — votre espace Notion auto-hébergé">
|
||||
<meta name="twitter:description" content="Éditeur de blocs, Kanban, bases de données et intégration Gitea/GitHub.">
|
||||
<meta name="twitter:image" content="{{ app_base_url() }}/static/img/logo-512.jpg">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #191919;
|
||||
@@ -112,6 +123,22 @@
|
||||
padding:12px 28px;
|
||||
}
|
||||
|
||||
/* v7.69 : identité visuelle */
|
||||
.brand-mark{border-radius:7px;flex:none;}
|
||||
.hero-visual{
|
||||
max-width:1060px;
|
||||
margin:0 auto 72px;
|
||||
padding:0 32px;
|
||||
}
|
||||
.hero-visual img{
|
||||
width:100%;
|
||||
height:auto;
|
||||
display:block;
|
||||
border-radius:14px;
|
||||
border:1px solid var(--border);
|
||||
box-shadow:0 30px 70px -30px rgba(0,0,0,.5);
|
||||
}
|
||||
|
||||
/* Features grid */
|
||||
.features{
|
||||
display:grid;
|
||||
@@ -148,7 +175,7 @@
|
||||
|
||||
<nav class="landing-nav">
|
||||
<a href="/" class="landing-logo">
|
||||
<span>{{ fd_icon("book",20) }}</span> FlowDeck
|
||||
<img class="brand-mark" src="/static/img/logo.webp" alt="" width="26" height="26"> FlowDeck
|
||||
</a>
|
||||
<div class="landing-nav-actions">
|
||||
<a href="/auth/login?provider=local" class="btn btn-secondary">Log in</a>
|
||||
@@ -169,6 +196,14 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- v7.69 : bannière du projet (asset optimisé : WebP 22 KB, JPEG en secours) -->
|
||||
<div class="hero-visual">
|
||||
<picture>
|
||||
<source srcset="/static/img/banner.webp" type="image/webp">
|
||||
<img src="/static/img/banner.jpg" alt="FlowDeck — éditeur de blocs, Kanban et bases de données" width="2400" height="793" loading="lazy" decoding="async">
|
||||
</picture>
|
||||
</div>
|
||||
|
||||
<div class="features">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">{{ fd_icon("edit",24) }}</div>
|
||||
|
||||
+49
-14
@@ -18,7 +18,7 @@
|
||||
|
||||
<style>
|
||||
/* ── 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 h1{font-size:24px;font-weight:700;color:var(--text-primary);margin:0;}
|
||||
.lib-header-actions{display:flex;align-items:center;gap:8px;}
|
||||
@@ -60,20 +60,38 @@
|
||||
/* ── Table (div-based for hover effects) ── */
|
||||
.lib-table{width:100%;}
|
||||
.lib-table-header{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:0;}
|
||||
.lib-table-header .th{display:flex;align-items:center;gap:5px;padding:6px 8px;font-size:12px;font-weight:500;color:var(--text-dim);user-select:none;white-space:nowrap;}
|
||||
.lib-table-header .th{display:flex;align-items:center;gap:5px;padding:6px 8px;font-size:12px;font-weight:500;color:var(--text-dim);user-select:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.lib-table-header .th svg{width:13px;height:13px;flex-shrink:0;}
|
||||
|
||||
/* Column widths — Notion-style */
|
||||
.lib-col-name{flex:1;min-width:0;}
|
||||
.lib-col-author{width:150px;flex-shrink:0;display:none;}
|
||||
.lib-col-source{width:120px;flex-shrink:0;display:none;}
|
||||
.lib-col-author{width:150px;flex-shrink:0;}
|
||||
.lib-col-editedby{width:150px;flex-shrink:0;}
|
||||
.lib-col-source{width:120px;flex-shrink:0;}
|
||||
.lib-col-edited{width:110px;flex-shrink:0;text-align:right;justify-content:flex-end;}
|
||||
.lib-table.show-author .lib-col-author{display:flex;}
|
||||
.lib-table.show-source .lib-col-source{display:flex;}
|
||||
.lib-col-visited{width:130px;flex-shrink:0;text-align:right;justify-content:flex-end;}
|
||||
/* Visibilité des colonnes pilotée par Show columns. Ces règles doivent
|
||||
battre .lib-table-header .th et .lib-row .row-cell (spécificité 0,2,0) :
|
||||
avant, seul :not(.show-editedtime) (0,3,0) gagnait, donc seule Last
|
||||
edited time s'affichait/masquait — les autres options semblaient mortes. */
|
||||
.lib-table div.lib-col-author,
|
||||
.lib-table div.lib-col-editedby,
|
||||
.lib-table div.lib-col-source,
|
||||
.lib-table div.lib-col-visited{display:none;}
|
||||
.lib-table.show-author div.lib-col-author,
|
||||
.lib-table.show-editedby div.lib-col-editedby,
|
||||
.lib-table.show-source div.lib-col-source,
|
||||
.lib-table.show-visited div.lib-col-visited{display:flex;}
|
||||
.lib-table:not(.show-editedtime) div.lib-col-edited{display:none;}
|
||||
.row-source{display:inline-flex;align-items:center;gap:5px;}
|
||||
.row-source svg{width:13px;height:13px;flex-shrink:0;color:var(--text-dim);}
|
||||
|
||||
/* ── Row ── */
|
||||
.lib-row{display:flex;align-items:center;height:34px;transition:background 0.1s;position:relative;}
|
||||
.lib-row:hover{background:var(--bg-hover);}
|
||||
/* ── Date section headers (AI Meeting Notes tab) ── */
|
||||
.lib-section-header{display:flex;align-items:center;font-size:12px;font-weight:600;color:var(--text-secondary);padding:10px 8px 4px;user-select:none;}
|
||||
.lib-section-header:first-child{padding-top:2px;}
|
||||
.lib-row.selected{background:rgba(35,131,226,0.08);}
|
||||
.lib-row.drag-over{outline:2px solid var(--accent);outline-offset:-2px;background:rgba(35,131,226,0.12);}
|
||||
.lib-row.dragging{opacity:0.4;}
|
||||
@@ -150,7 +168,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:hover{background:var(--bg-hover);color:var(--text-primary);}
|
||||
.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-loading{text-align:center;padding:40px;color:var(--text-dim);}
|
||||
|
||||
@@ -189,7 +207,7 @@
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media(max-width:768px){
|
||||
.lib-col-author,.lib-col-source{display:none!important;}
|
||||
.lib-col-author,.lib-col-editedby,.lib-col-source,.lib-col-visited{display:none!important;}
|
||||
.peek-overlay{width:100vw;max-width:100vw;}
|
||||
}
|
||||
</style>
|
||||
@@ -212,6 +230,9 @@
|
||||
<button class="lib-tab" :class="{ active: tab === 'recents' }" @click="switchTab('recents')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 16 14"/></svg> Recents
|
||||
</button>
|
||||
<button class="lib-tab" :class="{ active: tab === 'meetings' }" @click="switchTab('meetings')">
|
||||
<svg 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> AI Meeting Notes
|
||||
</button>
|
||||
<button class="lib-tab" :class="{ active: tab === 'favorites' }" @click="switchTab('favorites')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg> Favorites
|
||||
</button>
|
||||
@@ -265,6 +286,9 @@
|
||||
<div class="lib-dropdown" x-show="viewOpen" @click.outside="viewOpen=false" x-transition>
|
||||
<div class="dd-label">Show columns</div>
|
||||
<div class="dd-item" @click="toggleColumn('author')"><span class="check" x-text="showAuthor ? '✓' : ''"></span> Created by</div>
|
||||
<div class="dd-item" @click="toggleColumn('editedby')"><span class="check" x-text="showEditedBy ? '✓' : ''"></span> Last edited by</div>
|
||||
<div class="dd-item" @click="toggleColumn('editedtime')"><span class="check" x-text="showEditedTime ? '✓' : ''"></span> Last edited time</div>
|
||||
<div class="dd-item" @click="toggleColumn('visited')"><span class="check" x-text="showVisited ? '✓' : ''"></span> Last visited time</div>
|
||||
<div class="dd-item" @click="toggleColumn('source')"><span class="check" x-text="showSource ? '✓' : ''"></span> Source</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -351,7 +375,7 @@
|
||||
</div>
|
||||
|
||||
<!-- ── Table ── -->
|
||||
<div class="lib-table" id="lib-table" :class="{'show-author': showAuthor, 'show-source': showSource}">
|
||||
<div class="lib-table" id="lib-table" :class="{'show-author': showAuthor, 'show-editedby': showEditedBy, 'show-editedtime': showEditedTime, 'show-visited': showVisited, 'show-source': showSource}">
|
||||
<div class="lib-table-header">
|
||||
<div class="th lib-col-name">
|
||||
<input type="checkbox" style="width:14px;height:14px;accent-color:var(--accent);cursor:pointer;margin-left:2px;margin-right:6px;" :checked="allSelected" @click="toggleSelectAll()" title="Select / unselect all">
|
||||
@@ -359,11 +383,16 @@
|
||||
Page name
|
||||
</div>
|
||||
<div class="th lib-col-author">Created by</div>
|
||||
<div class="th lib-col-source">Source</div>
|
||||
<div class="th lib-col-editedby">Last edited by</div>
|
||||
<div class="th lib-col-edited">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 16 14"/></svg>
|
||||
Last edited time
|
||||
</div>
|
||||
<div class="th lib-col-visited">
|
||||
<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>
|
||||
Last visited time
|
||||
</div>
|
||||
<div class="th lib-col-source">Source</div>
|
||||
</div>
|
||||
<div class="lib-table-body" id="lib-table-body">
|
||||
</div>
|
||||
@@ -408,7 +437,7 @@
|
||||
<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>
|
||||
<template x-if="peekItem">
|
||||
<div style="display:flex;flex-direction:column;height:100%;">
|
||||
<div style="flex-shrink:0;">
|
||||
<div class="peek-header">
|
||||
<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>
|
||||
@@ -445,11 +474,17 @@
|
||||
</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>
|
||||
</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><!-- /x-data -->
|
||||
|
||||
@@ -166,9 +166,12 @@
|
||||
.bc-edit-go:hover{opacity:.85;}
|
||||
|
||||
/* ── Preserved styles ── */
|
||||
/* NOTE : scopées à .ws-tree (arbre principal) — la variante globale
|
||||
écrasait aussi les items .ws-tree-item de la sidebar (macro partagée),
|
||||
d'où un espacement différent sur /local-workspace. */
|
||||
.ws-tree{list-style:none;margin:0;padding:0;}
|
||||
.ws-tree-item{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:6px;cursor:pointer;transition:background 150ms;border:1px solid transparent;margin-bottom:2px;}
|
||||
.ws-tree-item:hover{background:var(--bg-tertiary);border-color:var(--border);}
|
||||
.ws-tree .ws-tree-item{display:flex;align-items:center;gap:10px;padding:8px 12px;border-radius:6px;cursor:pointer;transition:background 150ms;border:1px solid transparent;margin-bottom:2px;}
|
||||
.ws-tree .ws-tree-item:hover{background:var(--bg-tertiary);border-color:var(--border);}
|
||||
.ws-tree-item .icon{font-size:16px;width:20px;text-align:center;flex-shrink:0;}
|
||||
.ws-tree-item .name{flex:1;font-size:14px;color:var(--text);}
|
||||
.ws-tree-item .name.folder{font-weight:500;cursor:pointer;}
|
||||
@@ -179,6 +182,8 @@
|
||||
.ws-tree-item .name.file .inline-rename-input{flex:0 1 auto;}
|
||||
.ws-tree-item .name.file .btn-open{flex:0 0 auto;}
|
||||
.ws-shared-badge{flex-shrink:0;margin-right:4px;font-size:13px;line-height:1;opacity:.9;filter:grayscale(1) brightness(1.6);}
|
||||
.ws-fav-badge{flex-shrink:0;margin-right:4px;font-size:13px;line-height:1;color:#f5a623;}
|
||||
.ws-pub-badge{flex-shrink:0;margin-right:4px;font-size:13px;line-height:1;}
|
||||
.ws-tree-item .actions{display:none;gap:2px;}
|
||||
.ws-tree-item:hover .actions{display:flex;}
|
||||
.ws-chevron{width:16px;height:16px;flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;background:none;border:none;color:var(--text-tertiary);cursor:pointer;font-size:8px;padding:0;border-radius:3px;transition:transform 150ms,color 100ms;}
|
||||
@@ -480,7 +485,11 @@
|
||||
.preview-leave-end{transform:translateX(100%);opacity:0;}
|
||||
</style>
|
||||
|
||||
<script type="application/json" id="lw-config" nonce="{{ csp_nonce() }}">{{ {"current_folder_id": current_folder_id, "workspace_id": workspace_id} | tojson }}</script>
|
||||
<script type="application/json" id="lw-config" nonce="{{ csp_nonce() }}">{{ {"current_folder_id": current_folder_id, "workspace_id": workspace_id, "workspace_name": workspace_name} | tojson }}</script>
|
||||
<link rel="stylesheet" href="/static/css/highlight.css?v={{ asset_version }}">
|
||||
<script data-cfasync="false" src="/static/js/highlight.min.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" src="/static/js/highlight-extra.min.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" src="/static/js/code-highlight.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" src="/static/js/local_workspace.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof _wsInitData !== 'undefined');</script>
|
||||
|
||||
@@ -528,7 +537,6 @@
|
||||
<a href="/workspaces" class="btn-icon nav-arrow" title="All workspaces">{{ fd_icon("home",14) }}</a>
|
||||
<button class="btn-icon nav-arrow" @click="goBack()" title="Back" :disabled="!canGoBack">{{ fd_icon("chevron-left",14) }}</button>
|
||||
<button class="btn-icon nav-arrow" @click="goForward()" title="Forward" :disabled="!canGoForward">{{ fd_icon("chevron-right",14) }}</button>
|
||||
{% if breadcrumbs %}
|
||||
<div class="unified-breadcrumb">
|
||||
<span class="bc-icon">{{ fd_icon("folder",13) }}</span>
|
||||
<a href="/local-workspace" @click.prevent="navigateToRoot()" class="bc-root">{{ workspace_name }}</a>
|
||||
@@ -549,7 +557,6 @@
|
||||
</div>
|
||||
<button class="bc-edit-trigger" x-show="!editingPath" @click="startPathEdit()" title="Edit path">{{ fd_icon("edit",11) }}</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="ws-toolbar" style="margin-left:auto;">
|
||||
<!-- View dropdown -->
|
||||
<div class="lib-toolbar-wrap" style="position:relative;">
|
||||
@@ -729,6 +736,8 @@
|
||||
<span class="name folder" @click.stop="navigateToFolder(node.id)"
|
||||
@dblclick.prevent.stop="startInlineRename(node, $event)">
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
|
||||
<span x-show="node.favorited" class="ws-fav-badge" title="Favorite">★</span>
|
||||
<span x-show="node.published" class="ws-pub-badge" title="Published to web">🌐</span>
|
||||
<span x-show="renamingId !== node.id" x-text="node.name"></span>
|
||||
<span class="child-count" x-show="node.child_count > 0 && renamingId !== node.id"
|
||||
x-text="'(' + node.child_count + ')'"></span>
|
||||
@@ -743,6 +752,8 @@
|
||||
@mouseleave="hidePreview()"
|
||||
title="Click to rename, double-click to open">
|
||||
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
|
||||
<span x-show="node.favorited" class="ws-fav-badge" title="Favorite">★</span>
|
||||
<span x-show="node.published" class="ws-pub-badge" title="Published to web">🌐</span>
|
||||
<span x-show="renamingId !== node.id" x-text="node.name"></span>
|
||||
<input x-show="renamingId === node.id" class="inline-rename-input" x-model="renamingName"
|
||||
@keydown.enter="commitRename(node)" @keydown.escape="cancelRename()"
|
||||
@@ -970,11 +981,11 @@
|
||||
<template x-for="t in (node.tags||[])" :key="'ctag'+t.id">
|
||||
<span class="tag-chip" :style="{background: t.color}"><span x-text="t.name"></span></span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Old table view kept for backward compat, hidden -->
|
||||
<table class="ws-table" x-show="false" style="display:none">
|
||||
<thead><tr><th>Name</th><th>Type</th><th>Size</th></tr></thead>
|
||||
|
||||
@@ -5,6 +5,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
{% 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' %}
|
||||
{% endblock %} {% block content %}
|
||||
<h1 class="database-page-title">{{ page.title }}</h1>
|
||||
{% include "_database_table.html" %}
|
||||
{% endblock %} {% block scripts %}
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
|
||||
@@ -8,4 +8,9 @@
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
{% 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 %}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% from '_icons.html' import fd_icon %}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ title }} — FlowDeck</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="icon" href="/static/favicon.ico" sizes="48x48">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #191919;
|
||||
@@ -173,8 +173,17 @@
|
||||
</footer>
|
||||
|
||||
<script src="/static/js/katex.min.js?v=0.16.11"></script>
|
||||
<link rel="stylesheet" href="/static/css/highlight.css?v={{ asset_version }}">
|
||||
<script src="/static/js/highlight.min.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/highlight-extra.min.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/code-highlight.js?v={{ asset_version }}"></script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// hljs — syntax-highlight code blocks (~230 file types via FDHighlight).
|
||||
try {
|
||||
if (window.FDHighlight) FDHighlight.highlightAllIn(document.querySelector('.pub-content') || document);
|
||||
else if (window.hljs) document.querySelectorAll('.pub-content pre code').forEach(function (el) { try { window.hljs.highlightElement(el); } catch (e) {} });
|
||||
} catch (e) {}
|
||||
if (window.katex) {
|
||||
document.querySelectorAll('div[data-katex]').forEach(function (el) {
|
||||
var tex = el.getAttribute('data-katex') || '';
|
||||
|
||||
+15
-21
@@ -11,16 +11,6 @@
|
||||
|
||||
{% block content %}
|
||||
<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 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);}
|
||||
@@ -69,9 +59,7 @@
|
||||
.status-dot.active{background:var(--toast-success-bg);}
|
||||
.status-dot.inactive{background:var(--danger);}
|
||||
|
||||
/* Close button */
|
||||
.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);}
|
||||
/* Close button — shared styles in /static/css/settings.css */
|
||||
|
||||
/* 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;}
|
||||
@@ -125,26 +113,32 @@
|
||||
.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;}
|
||||
@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>
|
||||
|
||||
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="historyBack()">
|
||||
<div class="settings-panel" style="position:relative;">
|
||||
<div class="settings-overlay" x-data="settingsInit()">
|
||||
<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>
|
||||
|
||||
<!-- Left navigation -->
|
||||
<div class="settings-nav">
|
||||
<!-- Left navigation (mobile: side-navigation drawer via hamburger + backdrop) -->
|
||||
<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-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==='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="navTo('sessions')"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="navTo('extensions')"><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-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-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==='automations' }" @click="navTo('automations')">{{ 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>
|
||||
<!-- Admin nav: only visible to admins -->
|
||||
<template x-if="userIsAdmin">
|
||||
@@ -423,7 +417,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Extensions (Web Clipper) -->
|
||||
<div x-show="activeSection==='extensions'">
|
||||
<div x-show="{{ 'true' if plugin_enabled('web-clipper') else 'false' }} && activeSection==='extensions'">
|
||||
<h2>Extensions</h2>
|
||||
<p class="section-desc">FlowDeck Web Clipper — capture web content directly into FlowDeck. Manage connected browsers.</p>
|
||||
<div class="setting-group">
|
||||
@@ -584,7 +578,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Automations -->
|
||||
<div x-show="activeSection==='automations'">
|
||||
<div x-show="{{ 'true' if plugin_enabled('automations') else 'false' }} && activeSection==='automations'">
|
||||
<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>
|
||||
|
||||
|
||||
+72
-13
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-data="trashData()" style="padding: 0 24px; max-width: 800px;">
|
||||
<div x-data="trashData()" class="lib-container">
|
||||
<!-- Toolbar : recherche + tri + emplacement + purge -->
|
||||
<div style="display:flex; align-items:center; gap:8px; margin-bottom:12px;">
|
||||
<div style="position:relative; flex:1;">
|
||||
@@ -106,6 +106,25 @@
|
||||
<span>Once a page has been in Trash for 30 days, it will be automatically deleted</span>
|
||||
<span style="font-size:16px;">ⓘ</span>
|
||||
</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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -127,6 +146,8 @@
|
||||
locOpen: false,
|
||||
selected: [],
|
||||
items: [],
|
||||
// Confirmation thématique (remplace confirm() du navigateur)
|
||||
confirm: { show: false, title: '', message: '', okText: 'Delete', action: null },
|
||||
|
||||
async init() {
|
||||
try {
|
||||
@@ -184,6 +205,27 @@
|
||||
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.
|
||||
@@ -210,13 +252,6 @@
|
||||
else window.showToast('Restore failed', 'error');
|
||||
} catch (e) { window.showToast('Restore failed: ' + e.message, 'error'); }
|
||||
},
|
||||
async deleteForever(id) {
|
||||
if (!confirm('Permanently delete this page? This cannot be undone.')) return;
|
||||
try {
|
||||
if (await this._delete(id)) window.showToast('Page permanently deleted', 'success');
|
||||
else window.showToast('Delete failed', 'error');
|
||||
} catch (e) { window.showToast('Delete failed: ' + e.message, 'error'); }
|
||||
},
|
||||
async restoreSelected() {
|
||||
const ids = this.selected.slice();
|
||||
let ok = 0;
|
||||
@@ -225,18 +260,42 @@
|
||||
}
|
||||
window.showToast(ok + ' of ' + ids.length + ' page(s) restored', ok === ids.length ? 'success' : 'error');
|
||||
},
|
||||
async deleteSelected() {
|
||||
const ids = this.selected.slice();
|
||||
if (!confirm('Permanently delete ' + ids.length + ' page(s)? This cannot be undone.')) return;
|
||||
async _deleteAndToast(ids) {
|
||||
let ok = 0;
|
||||
for (const id of ids) {
|
||||
try { if (await this._delete(id)) ok++; } catch (e) { /* compté en échec */ }
|
||||
}
|
||||
window.showToast(ok + ' of ' + ids.length + ' page(s) deleted', ok === ids.length ? 'success' : 'error');
|
||||
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;
|
||||
if (!confirm('Permanently delete ALL ' + n + ' page(s) in the Trash? This cannot be undone.')) return;
|
||||
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);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bienvenue sur FlowDeck</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="icon" href="/static/favicon.ico" sizes="48x48">
|
||||
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
|
||||
<style>
|
||||
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;}
|
||||
|
||||
+58
-1
@@ -6,10 +6,46 @@ les `|safe` du codebase étaient des no-op.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
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
|
||||
# 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(
|
||||
loader=FileSystemLoader("app/templates"),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
undefined=LoggingUndefined,
|
||||
)
|
||||
|
||||
|
||||
@@ -40,3 +77,23 @@ except OSError: # pragma: no cover
|
||||
ENV.globals["asset_version"] = ASSET_VERSION
|
||||
ENV.globals["csp_nonce"] = lambda: CSP_NONCE.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)
|
||||
# v7.69 : URL de base absolue pour og:image / twitter:image — les crawlers
|
||||
# ignorent les URL relatives. Lecture paresseuse comme _plugin_enabled
|
||||
# (évite l'import circulaire au chargement du module). Même source que les
|
||||
# liens de partage de pages (collaboration.py) : si elle est mal renseignée,
|
||||
# ces liens le sont déjà.
|
||||
ENV.globals["app_base_url"] = lambda: _app_base_url()
|
||||
|
||||
|
||||
def _app_base_url() -> str:
|
||||
from app.config import settings
|
||||
|
||||
return (settings.app_base_url or "").rstrip("/")
|
||||
|
||||
|
||||
def _plugin_enabled(slug: str) -> bool:
|
||||
from app.services.plugins import is_enabled
|
||||
|
||||
return is_enabled(slug)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1,333 @@
|
||||
# FlowDeck — Modèle de données (v7.69.8)
|
||||
|
||||
Document de référence du schéma de persistance, généré à partir du code réel :
|
||||
|
||||
- `app/db.py` — schéma de base (baseline, « version 1 »), créé de façon idempotente par `init_db()` (lignes 14–839) et connexion SQLite via `get_conn()` (lignes 843–858) ;
|
||||
- `app/migrations.py` — migrations versionnées (1–38, table `schema_version`) ;
|
||||
- `app/services/webhook_outbound.py` — table `webhook_subscriptions` (créée au boot, l. 272–286) ;
|
||||
- `app/models/requests.py` / `app/models/responses.py` — couche Pydantic de l'API.
|
||||
|
||||
Total : **105 tables relationnelles + 1 table virtuelle FTS5** (`pages_fts`), réparties entre le baseline (`app/db.py`) et 33 migrations effectuant des créations de tables (`app/migrations.py`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Moteur de base de données
|
||||
|
||||
### 1.1 SQLite uniquement (pas de dual-engine)
|
||||
|
||||
**Correction vis-à-vis de certaines notes d'architecture : FlowDeck ne supporte PAS PostgreSQL.** Le code est exclusivement SQLite :
|
||||
|
||||
- `app/db.py` l.4 : `import sqlite3` (stdlib) — aucune dépendance `psycopg`/`pg8000`/SQLAlchemy nulle part (`requirements.txt`, `pyproject.toml` vérifiés) ;
|
||||
- le module entier est typé `sqlite3.Connection` (y compris `app/migrations.py` l.16) ;
|
||||
- fonctionnalités SQLite spécifiques utilisées : `PRAGMA journal_mode=WAL`, `PRAGMA foreign_keys=ON`, `PRAGMA busy_timeout=5000` (`get_conn`, l.846–850), `INTEGER PRIMARY KEY AUTOINCREMENT`, `ALTER TABLE … ADD COLUMN` (sans `IF NOT EXISTS`, d'où le pattern `try/except sqlite3.OperationalError`), déclencheurs (`TRIGGER`), extension **FTS5**, `DROP TABLE`/`RENAME` pour les reconstructions (`comments`, `tags`) ;
|
||||
- `app/config.py` l.58 : `database_url: str = "sqlite:////data/flowdeck.db"` — seul le préfixe `sqlite:///` est interprété par la propriété `db_path` (l.151–163, avec gestion `:memory:` et des chemins Windows type `C:\…`) ; toute autre valeur retombe sur `/data/flowdeck.db`.
|
||||
|
||||
La « compatibilité » mentionnée dans certains documents est donc en réalité : une **base SQLite unique par instance**, en mode WAL (un writer, multi-readers), avec `PRAGMA busy_timeout=5000` pour supporter la concurrence (tests xdist, scheduler d'automations). Les backups (v5.2.0, `backup_*` dans `app/config.py`) sont des copies du fichier SQLite.
|
||||
|
||||
### 1.2 Stratégie de migrations
|
||||
|
||||
Décrite dans l'en-tête de `app/migrations.py` (l.1–13) :
|
||||
|
||||
| Composant | Rôle |
|
||||
|---|---|
|
||||
| `app/db.py::init_db()` | Schéma **baseline** (« version 1 ») : `CREATE TABLE IF NOT EXISTS` + ALTER garde-fous (`try/except OperationalError`). Réexécuté à chaque boot, idempotent. |
|
||||
| `schema_version` (`version INTEGER PK, name TEXT, applied_at`) | Registre des migrations appliquées ; version maximale lue par `current_version()` (l.66–71). Scellée à `BASELINE_VERSION = 1` au premier démarrage (l.86–101). |
|
||||
| `@register(version, name)` (l.28–38) | Décorateur d'enregistrement dans `MIGRATIONS`, trié par version, doublons refusés. |
|
||||
| `apply_migrations(conn)` (l.86–111) | Appelée en fin de `init_db()` (`app/db.py` l.829–831) ; applique exactement une fois chaque migration `> version courante`. |
|
||||
| `_apply_one()` (l.113–130) | **Une migration = une transaction** (`BEGIN` explicite, `rollback` complet à l'échec, puis `INSERT INTO schema_version`) — DDL tout-ou-rien (audit A31). |
|
||||
| `columns(conn, table)` (l.53–64) | Helper unique `PRAGMA table_info` pour tester l'existence de colonnes avant un ALTER. |
|
||||
| `fts5_available()` (l.74–83) | Détection de FTS5 ; sinon repli sur recherche `LIKE`. |
|
||||
|
||||
Version de schéma actuelle : **38** (`v7.1.1 meeting block`, dernier `@register`). Les migrations 4 et 17 et 23–38 ont été enregistrées hors ordre chronologique de version (versions 4, 17, 23 insérées après 21) — le tri à l'enregistrement garantit l'ordre d'application.
|
||||
|
||||
---
|
||||
|
||||
## 2. Tables par domaine
|
||||
|
||||
Légende : **PK** = clé primaire, **FK** = clé étrangère (avec cascade quand indiquée). Sauf mention, PK = `id INTEGER PK AUTOINCREMENT`.
|
||||
|
||||
### 2.1 Kanban & intégration Gitea (héritage v0.x–v1.x)
|
||||
|
||||
Créées dans `app/db.py` (l.22–140, 430–478).
|
||||
|
||||
| Table | But | Clés / colonnes clés |
|
||||
|---|---|---|
|
||||
| `boards` | Board Kanban par dépôt Gitea | `UNIQUE(project_owner, project_name)` ; `columns_json` (liste des colonnes), `wip_limits_json` |
|
||||
| `cards` | Position d'une issue Gitea dans une colonne | FK `board_id → boards` ; `gitea_issue_id`, `column_name`, `position`, `priority`, `due_date` |
|
||||
| `col_mapping` | Mapping colonne ↔ label Gitea | FK `board_id → boards` ; `UNIQUE(board_id, column_name)` ; `close_issue` |
|
||||
| `notes` | Notes par projet | `UNIQUE(project_owner, project_name, title)` ; `content` |
|
||||
| `checklists` | Checklists par issue | `board_id`, `gitea_issue_id` (pas de FK déclarée), `title`, `position`, `UNIQUE(board_id, gitea_issue_id, title)` |
|
||||
| `checklist_items` | Lignes de checklist | FK `checklist_id → checklists ON DELETE CASCADE` ; `content`, `checked`, `position` |
|
||||
| `project_properties` | Propriétés par projet (ancêtre de `collection_properties`, conservé) | `UNIQUE(project_owner, project_name, name)` ; `prop_type`, `options_json` |
|
||||
| `property_values` | Valeurs de ces propriétés par issue | FK `property_id → project_properties ON DELETE CASCADE` ; `UNIQUE(property_id, gitea_issue_id)` |
|
||||
| `ai_keywords` | Fréquentité des mots-clés détectés par l'IA | `UNIQUE(project_owner, project_name, keyword)` ; `color`, `usage_count` |
|
||||
| `gitea_private_pages` | Pages privées rattachées à un dépôt Gitea (v2.7) | FK `user_id → users` ; `gitea_owner`, `gitea_repo`, `content` |
|
||||
| `user_tokens` | Token Gitea par utilisateur | `gitea_user_id INTEGER UNIQUE`, `gitea_token` |
|
||||
| `projects` | Registre forge-agnostique des projets (gitea/github/builtin), synchronisé par cron (mig. 6, `migrations.py` l.260–331) | `UNIQUE(proj_type, owner, name)` ; `clone_url`, `default_branch`, `last_synced_at` |
|
||||
|
||||
### 2.2 Utilisateurs & authentification
|
||||
|
||||
| Table | But | Clés / colonnes clés | Emplacement |
|
||||
|---|---|---|---|
|
||||
| `users` | Compte local ou fédéré | `login UNIQUE`, `email`, `password_hash`, `is_admin`, `is_active`, `login_attempts`, `locked_until`, `auth_method` (local\|gitea), `avatar_url/color`, `sidebar_config` (JSON), `notification_prefs` (JSON), `timezone`, `totp_secret_enc`, `totp_backup_hashes` (JSON) | db.py l.22 ; ALTER db.py l.328–470, mig. 10/11/28 |
|
||||
| `login_history` | Journal des connexions | FK `user_id → users` ; `ip_address`, `user_agent` | db.py l.40 |
|
||||
| `user_oauth_tokens` | Tokens OAuth par provider (Gitea, Google, M365…) | FK `user_id → users` ; `UNIQUE(user_id, provider)` ; `access_token`, `refresh_token`, `expires_at` | db.py l.55 |
|
||||
| `user_sessions` | Sessions cookie révocables (une ligne = un cookie signé) | **PK `id TEXT`** (payload du cookie) ; FK `user_id → users ON DELETE CASCADE` ; `revoked`, `last_seen_at` | mig. 6 (l.294–316) |
|
||||
| `api_tokens` | Tokens Bearer publics (sha256 stocké) | FK `user_id → users ON DELETE CASCADE` ; `token_hash UNIQUE`, `token_prefix`, `scopes`, `expires_at`, `revoked` | mig. 6 + mig. 20 (l.884–900) |
|
||||
| `webauthn_credentials` | Passkeys / 2FA WebAuthn | FK `user_id → users ON DELETE CASCADE` ; `credential_id UNIQUE`, `public_key`, `sign_count` | mig. 28 (l.1311–1324) |
|
||||
| `sso_config` | Config SSO entreprise (SAML 2.0 ou OIDC), la table prime sur `.env` dès qu'un admin sauvegarde | FK `workspace_id → workspaces` ; secrets chiffrés au repos (`client_secret`, `sp_private_key` — `app/services/sso_provisioning.py`) ; `attribute_mapping`/`groups_mapping` (JSON), `active` | mig. 23 (l.1495–1523) |
|
||||
| `sso_login_history` | Audit de chaque tentative SSO (succès + rejets) | FK `user_id → users` ; `success`, `error_message`, `sso_identifier` | mig. 23 (l.1525–1545) |
|
||||
| `sso_requests` | Store mono-usage anti-replay (AuthnRequest ID, état OIDC, PKCE, relay state) | **PK `id TEXT`** ; `kind`, `code_verifier`, `next_path`, `used` | mig. 23 (l.1547–1555) |
|
||||
| `scim_tokens` | Tokens Bearer de provisioning SCIM (hash unique) | `token_hash UNIQUE`, `revoked`, FK `created_by → users` | mig. 28 (l.1285–1294) |
|
||||
| `domain_claims` | Domaines d'entreprise vérifiés (DNS/txt), auto-jonction + `enforce_sso` | `domain UNIQUE`, `txt_token`, FK `workspace_id → workspaces` | mig. 28 (l.1296–1307) |
|
||||
| `llm_config` | Config LLM globale de l'instance — **ligne unique** `CHECK (id = 1)` | `provider`, `model`, `api_key`, `api_base`, `verified`, `verified_model`, `last_error` ; précédence : ligne DB > `settings.llm_*` | db.py l.769–784 |
|
||||
| `user_llm_keys` | Clés API LLM par utilisateur et provider | FK `user_id → users ON DELETE CASCADE` ; `UNIQUE(user_id, provider)` ; `models_json` (cache des modèles), `default_model`, `verified*` | db.py l.788–812 |
|
||||
|
||||
### 2.3 Workspaces, collaboration & permissions
|
||||
|
||||
| Table | But | Clés / colonnes clés | Emplacement |
|
||||
|---|---|---|---|
|
||||
| `workspaces` | Espace de travail multi-utilisateur | FK `owner_id → users` ; `settings_json` | db.py l.222 |
|
||||
| `workspace_members` | Adhésion + rôle (`owner/admin/editor/viewer/commenter`) | FK `workspace_id → workspaces CASCADE`, `user_id → users` ; `UNIQUE(workspace_id, user_id)` | db.py l.230 |
|
||||
| `teamspaces` | Namespace de pages/bases dans un workspace (v7.3.0) ; `private=1` = invisibilité 404 | FK `workspace_id → workspaces CASCADE` ; `UNIQUE(workspace_id, name)` | mig. 29 (l.1380–1391) |
|
||||
| `teamspace_members` | Membres + rôle d'un teamspace | FK `teamspace_id → teamspaces CASCADE`, `user_id → users` ; `UNIQUE(teamspace_id, user_id)` | mig. 29 (l.1393–1403) |
|
||||
| `user_groups` | Groupes réutilisables par workspace (ACL) | FK `workspace_id → workspaces CASCADE` ; `UNIQUE(workspace_id, name)` | mig. 18 (l.721–730) |
|
||||
| `group_members` | Adhésion à un groupe | FK `group_id → user_groups CASCADE`, `user_id → users CASCADE` ; `UNIQUE(group_id, user_id)` | mig. 18 (l.732–740) |
|
||||
| `page_permissions` | Grant explicite sur une page de l'éditeur | FK `page_id → pages CASCADE` ; `user_id XOR group_id` (`CHECK`) ; `role` (viewer/commenter/editor/owner), `grant_type`, `granted_by` | mig. 18 (l.745–760) |
|
||||
| `collection_permissions` | Idem pour les bases (collections) | FK `collection_id → collections CASCADE` ; mêmes colonnes | mig. 18 (l.766–780) |
|
||||
| `property_permissions` | Grant par propriété de collection (view/edit) | FK `collection_id`, `property_id → collection_properties CASCADE` | mig. 18 (l.782–797) |
|
||||
| `permission_audit_log` | Traînée immuable grant/revoke | `resource_type`, `resource_id`, `action`, `old_role`, `new_role`, `performed_by → users`, `ip_address` | mig. 18 (l.799–815) |
|
||||
| `page_shares` | Partage de page (user, groupe ou email) | FK `page_id → pages CASCADE`, `shared_with_user_id → users`, `shared_with_group_id → user_groups` ; `permission` (view/edit/comment) | db.py l.451–478 |
|
||||
| `guest_shares` | Accès sans compte via `/g/<token>` (v7.3.0) | FK `page_id → pages CASCADE` ; `token UNIQUE`, `role`, `expires_at`, `revoked` | mig. 29 (l.1440–1452) |
|
||||
| `favorites` | Favoris d'un utilisateur (reconstruite en v2.2 pour pointer `pages`) | FK `user_id → users`, `page_id → pages` ; `UNIQUE(user_id, page_id)` | db.py l.260–270 |
|
||||
| `recents` | Pages récemment accédées (vue « Recents ») | FK `user_id → users`, `page_id → pages` ; `source_type` (local/gitea…), `UNIQUE(user_id, page_id)` | db.py l.481–493 |
|
||||
| `notifications` | Notifications in-app (mention/commentaire/page) | FK `user_id → users CASCADE`, `actor_id → users` ; `ntype`, `resource_type/resource_id`, `is_read` ; index `(user_id, is_read)` | db.py l.592–613 |
|
||||
| `comments` | Commentaires (fil, résolus) ancrés sur pages ou lignes de base — **reconstruite** v4.9 : cible polymorphe | FK `user_id → users` ; `target_type` (`page` \| `collection_page`) + `target_id`, `parent_id` (réponses), `anchor_block_id/anchor_start/anchor_end` (ancre inline) | db.py l.241 (v2.0) puis l.620–649 (v4.9) |
|
||||
| `comment_reactions` | Réactions emoji d'un commentaire | FK `comment_id → comments CASCADE`, `user_id → users CASCADE` ; `UNIQUE(comment_id, user_id, emoji)` | mig. 29 (l.1418–1428) |
|
||||
| `text_reactions` | Réactions ancrées sur une plage de texte d'un bloc (façon Notion, v7.64) | FK `page_id → pages CASCADE`, `user_id → users CASCADE` ; `block_id TEXT`, offsets `anchor_start/anchor_end`, `UNIQUE(page_id, block_id, start, end, emoji, user_id)` | mig. 37 (l.1683–1707) |
|
||||
| `page_follows` | Abonnement aux changements d'une page | PK composite `(page_id → pages CASCADE, user_id → users CASCADE)` | mig. 29 (l.1430–1437) |
|
||||
| `page_verifications` | Badge « page vérifiée ✅ » avec expiration | **PK `page_id → pages CASCADE`** (1 par page) ; `verified_by → users`, `expires_at` | mig. 29 (l.1407–1416) |
|
||||
| `tags` | Étiquettes par utilisateur (`user_id=0` = globales) | FK `user_id → users` ; `UNIQUE(name, user_id)` ; `color` | db.py l.282–290 |
|
||||
| `page_tags` | Association page ↔ tag | PK composite `(page_id → pages CASCADE, tag_id → tags CASCADE)` | db.py l.292–299 |
|
||||
| `custom_emojis` | Emojis personnalisés uploadés par workspace | FK `workspace_id` (défaut 1, **non déclaré**) ; `name`, `url` | mig. 8 (l.371–388) |
|
||||
|
||||
### 2.4 Pages & éditeur de blocs
|
||||
|
||||
| Table | But | Clés / colonnes clés | Emplacement |
|
||||
|---|---|---|---|
|
||||
| `pages` | Page de l'éditeur (wiki) — table pivot du produit | FK `workspace_id → workspaces`, `parent_id → pages` (hiérarchie), `collection_id → collections` (base plein écran, v4.6), `collection_row_id → collection_pages CASCADE` (page-ombre de contenu d'une ligne de base, v6.5, mig. 22 l.997–1020), `locked_by → users` ; colonnes notables : `content` (markdown legacy **ou JSON de blocs** selon `content_format`), `content_format`, `parent_section`, `share_mode`, `published`/`is_published`/`publish_slug`/`is_shared`, `deleted_at` (corbeille), `sort_order`, `cover_url`, `page_icon`, `is_locked`, `full_width`, `font_small`, `search_excluded`, `permission_type` (inherit/restricted/private), `teamspace_id` (ALTER, **sans FK déclarée**), `sync_version` (trigger de concurrence optimiste, mig. 17) | db.py l.154–166 + ALTER l.328–470 ; mig. 7/13/18/22/24/25/29 |
|
||||
| `page_versions` | Snapshots annulables de l'éditeur de blocs (undo/restore, une ligne par save) | FK `page_id → pages CASCADE` ; `title`, `blocks_json` (liste complète des blocs), `note`, `user_id → users` | mig. 7 (l.336–365) |
|
||||
| `page_history` | Historique legacy des **lignes de base** (`collection_pages`) | FK `page_id → collection_pages CASCADE` ; `change_type`, `snapshot_json` | db.py l.249 |
|
||||
| `page_global_templates` | Modèles de page globaux créés par les utilisateurs | `blocks_json` (format éditeur), FK `created_by → users SET NULL` | mig. 13 (l.582–597) |
|
||||
| `synced_blocks` | Bloc synchronisé : source de vérité du contenu, réutilisé sur plusieurs pages | `content` (JSON blocs), `created_by → users` (sans FK), `workspace` (TEXT), `synced` via `page_synced_blocks` | mig. 15 (l.626–662) |
|
||||
| `page_synced_blocks` | Référence page ↔ bloc synchronisé (désynchronisable par page) | FK `page_id → pages CASCADE`, `synced_block_id → synced_blocks CASCADE` ; `block_index`, `UNIQUE(page_id, synced_block_id)` | mig. 15 (l.646–662) |
|
||||
| `page_views` | Compteur de vues quotidien par page (même pattern que `site_views`) | FK `page_id → pages CASCADE` ; PK composite `(page_id, day)`, `views` | mig. 29 (l.1454–1461) |
|
||||
| `pages_fts` | **Table virtuelle FTS5** (`title`, `body`) pour la recherche plein texte de la palette de commandes ; synchronisée par 3 triggers `AFTER INSERT/UPDATE/DELETE` sur `pages` ; backfill au boot ; repli `LIKE` si FTS5 absent | rowid = `pages.id` | mig. 3 (l.156–205) |
|
||||
|
||||
### 2.5 Bases de données (collections, style Notion)
|
||||
|
||||
| Table | But | Clés / colonnes clés | Emplacement |
|
||||
|---|---|---|---|
|
||||
| `collections` | Base de données (collection) | `schema_json` (schéma des propriétés), FK `gitea_owner/gitea_repo` (texte, verrouillage sync), `workspace_id → workspaces`, `created_by → users`, `is_locked`, `is_inline` + `parent_page_id` (base inline), `is_task` + `task_assignee_prop/task_status_prop/task_due_prop → collection_properties SET NULL` (mapping My Tasks, mig. 30 l.1558–1585), `permission_type`, `teamspace_id` (sans FK), `form_config_json`, `sync_version` | db.py l.172 + ALTER db.py l.495–520, mig. 18/24/29/30 |
|
||||
| `collection_pages` | **Ligne** d'une base (row) | FK `collection_id → collections CASCADE`, `parent_id → collection_pages` (hiérarchie interne), `property_values_json` (valeurs par propriété), `gitea_issue_id/gitea_issue_number` (sync Kanban), `cover_url`, `external_event_id` (calendrier, mig. 27), `permission_type`, `sync_version` | db.py l.187 ; mig. 10/18/27 |
|
||||
| `collection_views` | Vues sauvegardées par base (table/board/calendar/gallery/list…) | FK `collection_id → collections CASCADE` ; `view_type`, **`config_json`** (filtres/tri/groupement/columns visibles), `position`, `created_by` (NULL = vue partagée, sinon personnelle — mig. 10) | db.py l.210 ; mig. 10 (l.472–478) |
|
||||
| `collection_properties` | Schéma des propriétés d'une base (remplace `project_properties`) | FK `collection_id → collections CASCADE` ; `prop_type`, `options_json` (select/multi-select), `number_format`, relationnels : `related_collection_id → collections SET NULL`, `relation_property_id/target_property_id → collection_properties SET NULL`, `rollup_function`, `formula_expression`, `validation_json` (mig. 4), `group_name` (mig. 10), `button_automation_id → automations SET NULL` (bouton natif, mig. 26) ; `UNIQUE(collection_id, name)` | db.py l.222–241 ; mig. 4/10/26 |
|
||||
| `collection_data_sources` | Sources de données liées (base liée type Notion « Linked database ») | FK `collection_id → collections CASCADE`, `source_collection_id → collections` ; `is_linked`, `UNIQUE(collection_id, source_collection_id)` | db.py l.480–493 |
|
||||
| `collection_dashboards` | Dashboard = combinaison de vues/widgets sur une page | FK `collection_id → collections CASCADE` ; **`layout_json`** (`{"columns":1,"widgets":[]}`) | db.py l.531–542 |
|
||||
| `database_templates` | Modèles de bases prêts à l'emploi (seed idempotent, v5.3.0 : Meeting notes, etc.) | `name`, `icon`, `description`, `schema_json` | db.py l.272 + mig. 4 (l.390–411) ; seed via `app/services/db_templates.py::SEED_TEMPLATES` |
|
||||
| `page_templates` | Modèles de **ligne** par base, éventuellement récurrents | FK `collection_id → collections CASCADE` ; `property_values_json`, `content_json` (blocs), `is_recurring` + `recurrence_rule` | db.py l.294–303 + ALTER l.512–522 |
|
||||
| `page_dependencies` | Dépendances entre lignes (bloque / bloqué par) | FK `page_id`, `dependency_id → collection_pages CASCADE` ; `dependency_type`, `auto_shift` (overlap/…) ; `UNIQUE(page_id, dependency_id, dependency_type)` | db.py l.551–563 |
|
||||
| `sprints` | Sprints Scrum par base de tâches | FK `collection_id → collections CASCADE` ; `start_date/end_date` (TEXT), `status` (planning/active/done), `goal`, `auto_complete` | db.py l.570–582 |
|
||||
| `sprint_pages` | Lignes affectées à un sprint + vélocité | FK `sprint_id → sprints CASCADE`, `page_id → collection_pages CASCADE` ; `velocity_points`, `status_at_start`, `UNIQUE(sprint_id, page_id)` | db.py l.584–594 |
|
||||
| `reminder_log` | Journal anti-doublon des rappels : 1 ligne par (ligne de base, date d'occurrence) — le rappel ne fire qu'une fois même après restart | FK `page_id → collection_pages CASCADE` ; `UNIQUE(page_id, occurrence_date)` ; alimenté par le scan `reminders_enabled` (`app/config.py` l.75–77) — **il n'existe pas de table `reminders`** ; les dates de rappel proviennent des propriétés `due` des lignes | mig. 11 (l.485–509) |
|
||||
|
||||
### 2.6 Automatisation, workers & webhooks
|
||||
|
||||
| Table | But | Clés / colonnes clés | Emplacement |
|
||||
|---|---|---|---|
|
||||
| `automations` | Règles if-this-then-that (moteur v5.1.0) : `trigger_type` event/cron/button, `event`, `cron_expression`, `collection_id` (TEXT sans FK, scope optionnel), **`condition_json`** (clauses), **`actions_json`** (descripteurs), `enabled`, `run_count`, `last_run_at`, `trigger_mode` (`any`\|`all`, fenêtre de 5 min, mig. 26) | | mig. 5 (l.208–257) + mig. 26 (l.1196–1201) |
|
||||
| `automation_steps` | Étapes ordonnées d'une automatisation v2 (trigger/condition/delay/action, v7.0.0) ; l'engine retombe sur `condition_json/actions_json` si aucune step | FK `automation_id → automations CASCADE` ; `kind`, `position`, `config_json` | mig. 26 (l.1156–1172) |
|
||||
| `automation_runs` | Historique d'exécution (audit + UI Settings) | FK `automation_id → automations CASCADE` ; `status` (fired/skipped/error), `trigger_source`, `collection_id`/`page_id` (TEXT/INT sans FK déclarée), `detail` | mig. 5 (l.242–257) |
|
||||
| `workers` | Snippets Python sandboxés (cron ou manuel), partageables (v7.0.0) | `slug UNIQUE`, FK `workspace_id → workspaces CASCADE`, `code_py`, `schedule_cron`, `shared`, `daily_budget_s` (budget CPU quotidien), `created_by → users` (sans FK déclarée) | mig. 26 (l.1174–1192) |
|
||||
| `worker_runs` | Logs d'exécution des workers | FK `worker_id → workers CASCADE` ; `status`, `logs`, `duration_ms` | mig. 26 (l.1194–1210) |
|
||||
| `webhook_subscriptions` | Abonnement sortant URL + event + secret (créée au boot hors migrations, appelée par `init_db` l.834–837) | `url`, `event`, `secret`, `active` | `app/services/webhook_outbound.py` l.272–286 |
|
||||
| `webhook_deliveries` | Journal de livraison (retry ledger) | FK `webhook_id → webhook_subscriptions CASCADE` ; `status` (pending/retrying/success/error/superseded), `http_code`, `attempt`, `event`, `next_retry_at` (epoch, scheduler v6.4), `payload` (JSON), `duration_ms` | mig. 20 (l.913–928) + mig. 21 (l.940–957) |
|
||||
| `idempotency_keys` | Support d'`Idempotency-Key` sur les POST de l'API v2 (réponse rejouée) | **PK `key TEXT`** ; FK `user_id → users CASCADE` ; `response_json`, `status_code` | mig. 20 (l.930–938) |
|
||||
|
||||
### 2.7 IA / Agent FlowDeck
|
||||
|
||||
| Table | But | Clés / colonnes clés | Emplacement |
|
||||
|---|---|---|---|
|
||||
| `agents` | Définition d'agent (personnel ou workspace) | FK `workspace_id → workspaces`, `created_by → users` ; `agent_type`, `system_instructions`, `model`, `scope_json`, `trigger_json`, `approval_mode` (auto/gated), `is_active`, `UNIQUE(workspace_id, name)` | db.py l.660–680 |
|
||||
| `agent_conversations` | Conversation d'un utilisateur avec un agent | FK `agent_id → agents CASCADE`, `user_id → users` ; `status`, `context_json`, override `provider`/`model` (ALTER db.py l.820–827), `memory_enabled` (mig. 31) | db.py l.681–691 |
|
||||
| `agent_messages` | Messages (rôles user/assistant/tool), coût tokens | FK `conversation_id → agent_conversations CASCADE` ; `tool_calls_json`, `model`, `tokens_used` | db.py l.692–702 |
|
||||
| `agent_actions` | Journal des tool calls exécutés (audit + undo) | FK `conversation_id → … CASCADE` ; `tool_name`, `target_type/target_id`, `payload_json`, `result_json`, `status`, `undo_snapshot_json`, `executed_by → users` | db.py l.703–716 |
|
||||
| `agent_skills` | Skills réutilisables (prompt + outils autorisés) | FK `workspace_id → workspaces`, `created_by → users` ; `prompt_template`, `allowed_tools_json`, `UNIQUE(workspace_id, name)` | db.py l.717–729 |
|
||||
| `agent_triggers` | Déclencheurs d'agent (manual/cron/événement) | FK `agent_id → agents CASCADE` ; `trigger_type`, `config_json`, `is_active`, `last_fired_at` | db.py l.730–740 |
|
||||
| `agent_feedback` | 👍/👎 sur les réponses (rating CHECK up/down) | FK `conversation_id`/`message_id`/`user_id` en `SET NULL` ; `snippet`, `comment` | db.py l.742–756 |
|
||||
| `agent_policies` | Gouvernance par workspace (1 ligne/workspace) | FK `workspace_id → workspaces CASCADE`, `UNIQUE(workspace_id)` ; `allowed_tools_json`, `max_steps`, `require_approval` | mig. 28 (l.1326–1338) |
|
||||
| `agent_approvals` | File d'approbation des actions d'écriture gated | `conversation_id` (INT **sans FK déclarée**), `tool`, `args_json`, `status` (pending/…), `requester_id`/`approver_id → users` | mig. 28 (l.1340–1354) |
|
||||
| `agent_memory` | Résumé mémorisé par conversation (upsert 1:1, v7.54.0) | **PK `conversation_id → agent_conversations CASCADE`** ; `kind` (summary), `content` | mig. 31 (l.1661–1681) |
|
||||
| `agent_connectors` | Catalogue de connecteurs d'agent (custom/Discord/Telegram/MCP, v7.55–7.57) | `name`, `url`, `secret_encrypted` (Fernet), `kind`, `auth` (bearer/…), `tools_json` (cache d'outils MCP), `enabled`, `status`, `created_by` (sans FK) | mig. 32 (l.1640–1659) + mig. 34 (l.1613–1624) |
|
||||
| `connector_tokens` | Tokens OAuth par (kind, user) — Google / M365, chiffrés Fernet | **PK composite `(kind, user_id)`** ; `tokens_enc` | mig. 33 (l.1626–1638) |
|
||||
| `plugins` | Registre des modules activables/désactivables de l'instance (web-tools, web-clipper, automations) — seed idempotent | **PK `slug TEXT`** ; `enabled` | mig. 35 (l.1589–1611) |
|
||||
| `semantic_embeddings` | Recherche sémantique hybride + Ask AI (v6.9) : vecteurs « hashed-TF » 256 dims **sans dépendance externe** ; chunks par ressource polymorphe | **PK composite `(resource_type, resource_id, chunk_id)`** (page \| collection…) ; `chunk_text`, `embedding BLOB`, `model` | mig. 25 (l.1096–1133) |
|
||||
| `semantic_index_state` | État d'indexation incrémentale (dernier index par ressource) | **PK composite `(resource_type, resource_id)`** ; `indexed_at` | mig. 25 (l.1122–1133) |
|
||||
|
||||
### 2.8 Sites publics & formulaires
|
||||
|
||||
(mig. 24, `migrations.py` l.1021–1093)
|
||||
|
||||
| Table | But | Clés / colonnes clés |
|
||||
|---|---|---|
|
||||
| `sites` | Mini-site public multi-pages (façon Notion Sites) | `slug UNIQUE`, FK `root_page_id → pages CASCADE`, `custom_domain UNIQUE`, `theme`, `password_hash`, `expires_at`, `noindex`, `analytics_id`, `created_by → users` |
|
||||
| `site_pages` | Arbre public ordonné des pages du site | FK `site_id → sites CASCADE`, `page_id → pages CASCADE` ; `position`, `UNIQUE(site_id, page_id)` |
|
||||
| `site_views` | Compteur de vues jour/site (upsert, **pas d'IP brute** — RGPD) | FK `site_id → sites CASCADE` ; PK composite `(site_id, day)`, `views` |
|
||||
| `form_responses` | Journal anonymisé des soumissions de formulaire public | FK `collection_id → collections CASCADE`, `row_id → collection_pages SET NULL` (ligne créée) ; `ip_hash` (hash tournant quotidien, pas d'IP) ; config du form dans `collections.form_config_json` |
|
||||
|
||||
### 2.9 Calendrier & réunions
|
||||
|
||||
| Table | But | Clés / colonnes clés | Emplacement |
|
||||
|---|---|---|---|
|
||||
| `calendar_links` | Lien par utilisateur entre une collection et un calendrier externe (Google REST ou CalDAV générique) | FK `user_id → users CASCADE`, `collection_id → collections CASCADE` ; `provider`, `tokens_enc` (Fernet), `calendar_id`, `date_property`, `sync_token`, `UNIQUE(user_id, provider, calendar_id)` | mig. 27 (l.1231–1245) |
|
||||
| `meeting_transcripts` | Note de réunion (audio + transcription + résumé IA), machine à états du bloc Notion-like (idle/recording/paused/processing/done/failed) | FK `page_id → pages CASCADE` ; `audio_path`, `transcript`, `summary`, `language` ; v7.1.1 : `status`, `mode`, `channels_json`, `instruction_snapshot`, `consent_json`, `segments_json`, `processing_steps_json`, `summary_json`, `generated_title`, `completeness_class`, `quality_score`, `duration_ms/paused_ms`, **`event_occurrence_id`** (index unique partiel : une occurrence calendrier = au plus une note) | mig. 27 (l.1247–1271) + mig. 38 (l.1730–1800) |
|
||||
| `meeting_consent_log` | Journal append-only du consentement d'enregistrement | FK `transcript_id → meeting_transcripts CASCADE` ; `method` (start_attestation/…), `attested_by → users`, `participants_json` | mig. 38 (l.1767–1777) |
|
||||
| `meeting_distributions` | Journal append-only des diffusions de compte-rendu (copy_link/…) | FK `transcript_id → meeting_transcripts CASCADE` ; `channel`, `actor_id → users`, `recipients_json`, `status` | mig. 38 (l.1779–1788) |
|
||||
|
||||
### 2.10 Import/export & offline (PWA, clipper)
|
||||
|
||||
| Table | But | Clés / colonnes clés | Emplacement |
|
||||
|---|---|---|---|
|
||||
| `import_jobs` | Job d'import asynchrone (polling UI) — Notion/CSV/markdown | **PK `id TEXT`** (uuid) ; `source`, `filename`, `status` (queued/running/done/error), `error`, `report_json` | mig. 9 (l.434–451) |
|
||||
| `import_items` | Dédup : 1 ligne par page importée, clé (workspace, source, external_id) → re-import idempotent | FK `workspace_id`/`page_id` **sans REFERENCES déclaré** ; `UNIQUE(workspace_id, source, external_id)` | mig. 9 (l.418–432) |
|
||||
| `offline_sync_queue` | File serveur des mutations reçues des clients hors-ligne (`/api/v2/sync/batch`), rejouables/auditées par device | FK `user_id → users CASCADE` ; `device_id`, `type` (page_create/update/delete/move, collection_*), `payload` (JSON), `client_timestamp`, `server_version`, `status` (pending/syncing/synced/failed), `retries`, `error` ; détection de conflit édit/édit via `sync_version` (mig. 17, triggers sur `pages`, `collection_pages`, `collections`) | mig. 16 (l.668–700) |
|
||||
| `extension_devices` | Devices de l'extension Web Clipper (token hashé, scopes) | FK `user_id → users CASCADE` ; `device_id`, `token_hash`, `scopes`, `revoked`, `UNIQUE(user_id, extension_name, device_id)` | mig. 19 (l.838–857) |
|
||||
| `extension_clips` | Clippings capturés (page cible + workspace cible) | FK `user_id → users CASCADE`, `target_page_id → pages SET NULL`, `target_workspace_id → workspaces SET NULL` ; `clip_type`, `source_url` | mig. 19 (l.861–877) |
|
||||
|
||||
### 2.11 API & audit
|
||||
|
||||
| Table | But | Clés / colonnes clés | Emplacement |
|
||||
|---|---|---|---|
|
||||
| `api_audit_log` | Traînée immuable des mutations de l'API publique v2 | FK `user_id → users SET NULL`, `token_id → api_tokens SET NULL` ; `action`, `resource_type`, `resource_id`, `ip_address`, `detail` | mig. 20 (l.895–911) |
|
||||
| `schema_version` | Registre des migrations appliquées (meta) | **PK `version INTEGER`** ; `name`, `applied_at` | `app/migrations.py` l.41–51 |
|
||||
|
||||
### 2.12 Divers
|
||||
|
||||
`favorites`, `recents`, `notes`, `ai_keywords`, `login_history` déjà couverts ci-dessus. À noter également : les tables temporaires de reconstruction vues dans le code (`comments_new`, `tags_new`) **ne sont pas** des tables permanentes (pattern recreate → copy → drop → rename).
|
||||
|
||||
---
|
||||
|
||||
## 3. Relations clés (graphe FK simplifié)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
USERS[users]
|
||||
WS[workspaces]
|
||||
PAGES[pages]
|
||||
COLS[collections]
|
||||
CP[collection_pages]
|
||||
CPV[collection_properties]
|
||||
CV[collection_views]
|
||||
|
||||
WS -->|owner_id| USERS
|
||||
WS -->|workspace_members| USERS
|
||||
WS -->|workspace_id| PAGES
|
||||
WS -->|workspace_id| COLS
|
||||
PAGES -->|parent_id self| PAGES
|
||||
PAGES -->|collection_id| COLS
|
||||
PAGES -->|collection_row_id CASCADE| CP
|
||||
CP -->|collection_id CASCADE| COLS
|
||||
CP -->|parent_id self| CP
|
||||
CPV -->|collection_id CASCADE| COLS
|
||||
CPV -->|related_collection_id| COLS
|
||||
CPV -->|relation/target_property_id self| CPV
|
||||
CPV -->|button_automation_id| AUTO[automations]
|
||||
CV -->|collection_id CASCADE| COLS
|
||||
AUTO -->|collection_id texte sans FK| COLS
|
||||
AGENTS[agents] -->|workspace_id| WS
|
||||
CONV[agent_conversations] -->|agent_id CASCADE| AGENTS
|
||||
CONV -->|user_id| USERS
|
||||
MSG[agent_messages + actions] -->|conversation_id CASCADE| CONV
|
||||
GRP[user_groups] -->|workspace_id CASCADE| WS
|
||||
PPERM[page/collection/property_permissions] -->|user_id XOR group_id| USERS
|
||||
PPERM -->|group_id| GRP
|
||||
SITES[sites/site_pages] -->|root/page CASCADE| PAGES
|
||||
TRANS[meeting_transcripts] -->|page_id CASCADE| PAGES
|
||||
CAL[calendar_links] -->|user_id| USERS
|
||||
CAL -->|collection_id CASCADE| COLS
|
||||
CLIPS[extension_clips] -->|target_page_id| PAGES
|
||||
```
|
||||
|
||||
Points de relation à retenir :
|
||||
|
||||
1. **`users` est le hub d'identité** : plus de 30 FK entrantes (sessions, tokens, permissions, audit, contenu). `workspaces` est le hub d'isolation multi-tenant (`owner_id`, `workspace_members` définit le rôle).
|
||||
2. **Hiérarchie `pages ↔ collection_pages`** : deux univers de « pages ». `pages` = éditeur de blocs wiki (FK self `parent_id`, soft-delete `deleted_at`, corbeille) ; `collection_pages` = lignes de base. Le pont v6.5 : chaque ligne peut avoir une page-ombre `pages.collection_row_id` (CASCADE) qui porte son contenu blocs — versions, synced blocks et temps réel opèrent sur elle.
|
||||
3. **Chaîne de cascades principales** : `collections → collection_pages/collection_views/collection_properties → …` et `pages → page_versions/page_shares/page_synced_blocks/page_permissions/guest_shares/text_reactions`. Les suppressions de ressource nettoyent l'éditorial ; les grants d'audit (`*_audit_log`, `api_audit_log`, `permission_audit_log`) privilégient `SET NULL` sur l'acteur pour survivre à la suppression du compte.
|
||||
4. **Permissions en 3 niveaux** (mig. 18) : `pages/collections/collection_pages.permission_type` (`inherit`|`restricted`|`private`) pilote si les tables `*_permissions` (user **XOR** group via `CHECK`) font foi ; les groupes (`user_groups`/`group_members`) sont workspace-scopés ; `page_shares` reste le chemin de partage simple, `guest_shares` le chemin sans compte.
|
||||
5. **Sync sémantique Gitea** : `boards/cards/col_mapping/checklists/property_values` et `collection_pages.gitea_issue_id` + `collections.gitea_owner/repo` relient les entités FlowDeck aux issues Gitea (jointures par ID numérique, **pas de FK** — système externe).
|
||||
6. **Polymorphisme non-FK** : `notifications.resource_type/resource_id`, `semantic_embeddings.resource_type/resource_id`, `permission_audit_log.resource_type/resource_id`, `import_items.page_id`, `agent_approvals.conversation_id`, `automations.collection_id`, `custom_emojis.workspace_id`, `teamspaces`-id sur `pages`/`collections` (ALTER sans FK) — cohérence maintenue applicativement, pas par le moteur.
|
||||
|
||||
---
|
||||
|
||||
## 4. Colonnes JSON structurantes
|
||||
|
||||
Le modèle est fortement « document-oriented » : le JSON TEXT est le format de stockage des structures riches (choix assumé du SQLite sans JSON natif, requêtage via `json_extract` côté applicatif).
|
||||
|
||||
| Domaine | Table.colonne | Contenu |
|
||||
|---|---|---|
|
||||
| Kanban | `boards.columns_json` / `wip_limits_json` | liste ordonnée des colonnes ; limites WIP par colonne |
|
||||
| Kanban/legacy | `project_properties.options_json` | options de select |
|
||||
| Collections | `collections.schema_json` | schéma complet des propriétés d'une base (déclaratif, dupliqué avec `collection_properties`) |
|
||||
| Collections | `collections.form_config_json` | formulaire public (champs, validations) |
|
||||
| Lignes | `collection_pages.property_values_json` | map `{property_name → valeur}` de la ligne |
|
||||
| Vues | `collection_views.config_json` | **config de vue** : filtres, sorts, groupements, colonnes visibles/cache, sizing — cœur du système de vues multiples |
|
||||
| Propriétés | `collection_properties.options_json` / `validation_json` | options select, règles de validation |
|
||||
| Dashboard | `collection_dashboards.layout_json` | colonnes + widgets |
|
||||
| Modèles | `database_templates.schema_json`, `page_templates.property_values_json` / `content_json` | blueprint de base / ligne |
|
||||
| Éditeur | `pages.content` (avec `content_format='blocks'`) | **tableau JSON des blocs** (heading, todo, callout, synced_block, meeting, etc.) — l'unité de contenu fondamentale |
|
||||
| Historique | `page_versions.blocks_json`, `page_history.snapshot_json`, `page_global_templates.blocks_json`, `synced_blocks.content` | snapshots de liste de blocs |
|
||||
| Workspace | `workspaces.settings_json`, `users.sidebar_config`, `users.notification_prefs`, `users.totp_backup_hashes` | préférences |
|
||||
| Agent | `agents.scope_json`/`trigger_json`, `agent_conversations.context_json`, `agent_messages.tool_calls_json`, `agent_actions.payload_json`/`result_json`/`undo_snapshot_json`, `agent_skills.allowed_tools_json`, `agent_triggers.config_json`, `agent_policies.allowed_tools_json`, `agent_approvals.args_json` | orchestration, audit et undo |
|
||||
| LLM | `user_llm_keys.models_json` | cache des modèles distants |
|
||||
| Automatisation | `automations.condition_json`/`actions_json`, `automation_steps.config_json` | DSL des règles (fallback steps ↔ legacy colonnes) |
|
||||
| Sync offline | `offline_sync_queue.payload`, `idempotency_keys.response_json` | mutation client + réponse rejouée |
|
||||
| Webhooks | `webhook_deliveries.payload` | corps livré |
|
||||
| Réunions | `meeting_transcripts.channels_json`/`consent_json`/`segments_json`/`processing_steps_json`/`summary_json`, `meeting_consent_log.participants_json`, `meeting_distributions.recipients_json` | pipeline transcription |
|
||||
| Imports | `import_jobs.report_json` | rapport par fichier |
|
||||
| SSO | `sso_config.attribute_mapping`, `groups_mapping`, `sso_login_history` (mapping) | attributs d'assertion |
|
||||
|
||||
---
|
||||
|
||||
## 5. Couche Pydantic (`app/models/`)
|
||||
|
||||
Les modèles Pydantic ne couvrent **qu'une petite partie** du contrat API — la majorité des routes FastAPI valide en dur dans les handlers :
|
||||
|
||||
- `app/models/requests.py` (75 l.) :
|
||||
- `FileSaveRequest` — sauvegarde de fichier via Gitea (`path`, `content`, `message`, `sha` optionnel pour update) ; `@model_validator(mode="after")` restreint l'extension via `ALLOWED_EXTENSIONS`/`_ext` (`app/middleware/security`) ;
|
||||
- `IssueCreateRequest` / `IssueUpdateRequest` — création/mutation d'issues Gitea (title ≤ 500, `state` pattern `^(open|closed)$`, labels/milestone/assignee en ids virgule) ;
|
||||
- `CardMoveRequest` — déplacement de carte Kanban (owner/repo/issue_id/column) ;
|
||||
- `ColMappingRequest` — mapping colonne ↔ label ;
|
||||
- `UploadValidationResult` — DTO de validation d'upload (filename/size/extension/valid/error).
|
||||
- `app/models/responses.py` (38 l.) : deux enveloppes standardisées, utilisées en `response_model` OpenAPI des routes publiques :
|
||||
- `ErrorResponse {error, detail?}` ;
|
||||
- `SuccessResponse {status="ok", data?: dict}`.
|
||||
|
||||
Aucun modèle ORM (pas de SQLAlchemy) : l'accès aux données est du **SQL brut `sqlite3`** via le context-manager `get_conn()` (`app/db.py` l.843–858, `row_factory = sqlite3.Row`), et les conversions dict↔JSON des colonnes `*_json` sont faites manuellement dans les services.
|
||||
|
||||
---
|
||||
|
||||
## 6. Fiches de synthèse technique
|
||||
|
||||
| Aspect | Valeur réelle (vérifiée dans le code) |
|
||||
|---|---|
|
||||
| Moteur | SQLite unique, fichier `settings.db_path` (`database_url` = `sqlite:///…`), WAL + `busy_timeout=5000`, `foreign_keys=ON` |
|
||||
| ORM | aucun — SQL brut |
|
||||
| Pilote | `sqlite3` stdlib |
|
||||
| Migrations | maison, versionnées : baseline v1 (`init_db` idempotent) + registry `@register` (`app/migrations.py`), table `schema_version`, transaction par migration |
|
||||
| Version de schéma | 38 |
|
||||
| Triggers | FTS5 (`pages_fts_ai/ad/au`), bumps `sync_version` (`*_sync_version_bu`) sur `pages`/`collection_pages`/`collections` |
|
||||
| Full-text | FTS5 optionnel, repli `LIKE` |
|
||||
| Recherche vectorielle | `semantic_embeddings` (hash TF 256-dim en BLOB, PK composite polymorphe, pas d'extension vec0) |
|
||||
| Chiffrement au repos | Fernet sur `calendar_links.tokens_enc`, `connector_tokens.tokens_enc`, secrets SSO ; hash sha256 pour `api_tokens.token_hash`, `scim_tokens.token_hash`, `extension_devices.token_hash` ; `password_hash` (users) |
|
||||
| Sauvegardes | copie planifiée du fichier SQLite (`backup_*`, `app/config.py` l.66–70) |
|
||||
| Concurrence | verrou applicatif d'écriture indirect via WAL + busy_timeout ; migration async du 510 call sites synchrones encore en cours (note A21, `app/db.py` l.848–852) |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user