From 0218d8f5e5594ecf0aba14b244b8d9fc004b71bd Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Fri, 9 Oct 2026 16:58:04 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20/local-workspace=20=E2=80=94=20chemin=20?= =?UTF-8?q?du=20header=20+=20clic=20sur=20un=20dossier=20du=20sidebar=20(v?= =?UTF-8?q?7.69.9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrige deux régressions de /local-workspace : - le chemin du header restait bloqué sur « Home / » quel que soit le dossier affiché : la route rend désormais breadcrumb_items (Home / / / , niveaux cliquables, collapse « … » au-delà de 4) et la navigation sans rechargement recalcule le chemin via l'event flowdeck:breadcrumb-changed ; - le clic sur un dossier du sidebar affichait TOUS les composants à la fois : Alpine.data('wsInitData') retournait le même objet singleton, le 2e montage (navigation partielle) levait « Cannot redefine property: \ » et initTree abandonnait, laissant tout le contenu au state brut. La factory retourne désormais une enveloppe fraîche par montage qui délègue à l'état réactif partagé. #lw-config est aussi relu à chaque exécution (le 2e montage gardait le folder_id du 1er chargement). Inclus également le travail en cours de l'arbre : Library (colonnes Last visited/Source, ordre d'en-tête, favoris à icônes Workspace), Meeting Notes (bloc, CSS, routes, docs), coloration de code hljs, badges favori/publié dans l'arbre local-workspace, docs (DATA_MODEL, architectures) et tests associés. --- ARCHITECTURE.md | 2384 ++++++----------- CHANGELOG.md | 168 +- VERSION | 2 +- app/main.py | 2 +- app/middleware/security.py | 54 +- app/migrations.py | 77 + app/routers/board/_common.py | 27 +- app/routers/board/sharing.py | 17 +- app/routers/dashboard/_common.py | 65 +- app/routers/dashboard/local_workspace.py | 23 + app/routers/dashboard/pages_html.py | 20 +- app/routers/library.py | 139 +- app/routers/meetings.py | 331 ++- app/services/export.py | 247 +- app/services/importers/forge_repo.py | 18 +- app/services/meetings.py | 717 ++++- app/services/publish.py | 20 +- app/templates/_header.html | 10 + app/templates/_page_editor_scripts.html | 6 + app/templates/base.html | 246 +- app/templates/gitea_workspace.html | 15 +- app/templates/library.html | 45 +- app/templates/local_workspace.html | 21 +- app/templates/public_page.html | 9 + docs/DATA_MODEL.md | 333 +++ docs/V71_Calendar_Meetings.md | 15 +- ...hitecture-agents-skills-notion-flowdeck.md | 1650 ++++++++++++ docs/architecture-meeting-notion-flowdeck.md | 1805 +++++++++++++ ...ression_local_workspace_breadcrumb.spec.js | 121 + scripts/vendor_hljs.py | 113 + static/css/app.css | 136 +- static/css/highlight.css | 23 + static/css/meeting.css | 180 ++ static/js/code-highlight.js | 348 +++ static/js/gitea_workspace.js | 17 +- static/js/highlight-extra.min.js | 776 ++++++ static/js/highlight.min.js | 1213 +++++++++ static/js/library.js | 76 +- static/js/local_workspace.js | 123 +- static/js/meeting_block.js | 1566 +++++++++++ static/js/page_editor_scripts.js | 57 +- static/sw.js | 6 +- tests/test_v696_sidebar_library_publish.py | 240 ++ tests/test_v711_meeting_notion.py | 328 +++ 44 files changed, 12060 insertions(+), 1729 deletions(-) create mode 100644 docs/DATA_MODEL.md create mode 100644 docs/architecture-agents-skills-notion-flowdeck.md create mode 100644 docs/architecture-meeting-notion-flowdeck.md create mode 100644 e2e/regression_local_workspace_breadcrumb.spec.js create mode 100644 scripts/vendor_hljs.py create mode 100644 static/css/highlight.css create mode 100644 static/css/meeting.css create mode 100644 static/js/code-highlight.js create mode 100644 static/js/highlight-extra.min.js create mode 100644 static/js/highlight.min.js create mode 100644 static/js/meeting_block.js create mode 100644 tests/test_v696_sidebar_library_publish.py create mode 100644 tests/test_v711_meeting_notion.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0b2bc27..e6d1c1b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,1344 +1,553 @@ -# Architecture FlowDeck — Document Complet v4.0 +# Architecture FlowDeck — Document Complet v7.69.8 -> **Version:** 4.0 · **Date:** 2026-07-20 · **Auteur:** Hermes-Deepin -> **Cible:** Clone Notion intégré à Gitea/GitHub — multi-comptes, multi-intégrations -> **Version déployée:** v4.0.x (production : https://flowdeck.dracodev.net) - ---- +> **FlowDeck** = clone complet de **Notion** intégré nativement à **Gitea**, servi par une application **FastAPI** monolithique (SSR Jinja2 + htmx + Alpine.js), avec **API REST publique v2**, **agent IA conversationnel**, **automatisations**, **SSO entreprise** et **édition collaborative temps réel**. +> +> Document d'architecture à jour de la version **7.69.8** (`VERSION`). Historique complet : voir §17 et `CHANGELOG.md`. Modèle de données détaillé table par table : `docs/DATA_MODEL.md`. ## Table des matières 1. [Vue d'ensemble](#1-vue-densemble) 2. [Architecture système](#2-architecture-système) -3. [Modèle de données](#3-modèle-de-données) +3. [Structure du backend](#3-structure-du-backend) 4. [Routage & API](#4-routage--api) -5. [Frontend](#5-frontend) -6. [Système d'authentification](#6-système-dauthentification) -7. [Intégration Gitea](#7-intégration-gitea) -8. [Système de propriétés](#8-système-de-propriétés) -9. [Système de vues](#9-système-de-vues) -10. [Sub-items & Dépendances](#10-sub-items--dépendances) -11. [My Tasks & Dashboard unifié](#11-my-tasks--dashboard-unifié) -12. [Éditeur de blocs](#12-éditeur-de-blocs) -13. [Multi-utilisateurs & Collaboratif](#13-multi-utilisateurs--collaboratif) -14. [Déploiement](#14-déploiement) -15. [Références](#15-références) +5. [Modèle de données](#5-modèle-de-données) +6. [Authentification & comptes](#6-authentification--comptes) +7. [SSO entreprise : SAML, OIDC, SCIM, 2FA, WebAuthn](#7-sso-entreprise-saml-oidc-scim-2fa-webauthn) +8. [Sécurité applicative](#8-sécurité-applicative) +9. [Autorisation : rôles, permissions granulaires, gouvernance](#9-autorisation-rôles-permissions-granulaires-gouvernance) +10. [Workspaces & collaboration multi-utilisateurs](#10-workspaces--collaboration-multi-utilisateurs) +11. [Databases : propriétés, formules, rollups, dépendances](#11-databases-propriétés-formules-rollups-dépendances) +12. [Système de vues](#12-système-de-vues) +13. [Pages, éditeur de blocs & temps réel](#13-pages-éditeur-de-blocs--temps-réel) +14. [Wiki, liens, blocs synchronisés & teamspaces](#14-wiki-liens-blocs-synchronisés--teamspaces) +15. [Partage, publication, Library, My Tasks & Trash](#15-partage-publication-library-my-tasks--trash) +16. [IA : agent, compétences, connecteurs & écriture](#16-ia-agent-compétences-connecteurs--écriture) +17. [Recherche : FTS5, sémantique hybride & Ask AI](#17-recherche-fts5-sémantique-hybride--ask-ai) +18. [AI Meeting Notes & calendrier](#18-ai-meeting-notes--calendrier) +19. [Automatisations, workers, rappels, notifications & webhooks](#19-automatisations-workers-rappels-notifications--webhooks) +20. [Intégration Gitea / GitHub](#20-intégration-gitea--github) +21. [Import & Export](#21-import--export) +22. [Frontend : rendu, CSP, PWA & Web Clipper](#22-frontend-rendu-csp-pwa--web-clipper) +23. [Déploiement & exploitation](#23-déploiement--exploitation) +24. [Outils de développement, tests & CI](#24-outils-de-développement-tests--ci) +25. [Historique des versions v1.0 → v7.69.8](#25-historique-des-versions-v10--v7698) +26. [Chantiers ouverts & suites](#26-chantiers-ouverts--suites) +27. [Références](#27-références) --- ## 1. Vue d'ensemble -FlowDeck est un **clone de Notion** intégré à Gitea. Il recrée l'expérience Notion — databases, propriétés typées, vues multiples, sub-items, dépendances, éditeur de blocs — tout en restant connecté aux repositories et issues Gitea. +FlowDeck est un **produit unique en un serveur** : une application FastAPI (Python 3.13) qui rend des pages HTML complètes (Jinja2), interagit via htmx et Alpine.js, persiste tout dans **une base SQLite unique** (mode WAL), et se branche sur un **Gitea externe** (OAuth2, API, webhooks) comme forge optionnel — tout en restant utilisable seul (comptes locaux, workspaces locaux, GitHub en option). ### Principes architecturaux -- **Serveur-side rendering** — Jinja2 + HTMX, pas de SPA lourde -- **SQLite-first** — un fichier, zéro maintenance, migration PostgreSQL triviale si scaling futur -- **Gitea-native** — chaque database peut être liée à un repo Gitea, les issues deviennent des pages -- **Progressive enhancement** — fonctionnel sans JS, enrichi avec Alpine.js + SortableJS -- **Propriétés en JSON** — flexibilité maximale du schéma sans migration de table +| Principe | Application | +|---|---| +| **Monolithe lisible** | Un seul processus uvicorn : routage, rendu, services, scheduler d'arrière-plan dans le `lifespan` FastAPI. Pas de worker Celery, pas de file externe. | +| **SSR d'abord** | Les pages sont rendues serveur ; JS = hydratation (Alpine) + interactions ciblées (htmx). Navigation partielle maison (`static/js/app.js`) sans rechargement du shell. | +| **SQLite + JSON document-oriented** | Pas d'ORM : SQL brut via `get_conn()`, ~35 colonnes `*_json` portent les structures riches (blocs, configs de vues, schémas de propriétés, tool calls). | +| **Zéro bundler front** | Aucun pipeline JS : les libs (Alpine build CSP, htmx, KaTeX, highlight.js, Chart.js, Leaflet, SortableJS) sont **vendorisées** dans `static/js/` ; CSP stricte par nonce, `unsafe-eval` banni (audit A20, achevé v7.43). | +| **Sécurité vérifiée par audit** | 43 items d'audit (A1–A43, `ROADMAP.md`) traités v7.3→v7.35 : 401 partout, CSRF unifié, CSP sans eval, SSRF, autoescape, rate limiting, SQLite hors event loop. | +| **Multi-tenant par workspace** | Isolation des données par `workspace_id`, rôles par workspace + ACL granulaires page/collection/propriété, moindre privilège par défaut. | +| **IA au service du produit** | Agent ReAct, AI Writing, Ask AI, Meeting Notes : tous branchés sur un client LLM unique à 23 fournisseurs + mode offline déterministe ; l'agent n'a **jamais plus de droits que l'utilisateur appelant**. | +| **Compat Gitea historique** | Boards Kanban legacy ↔ Collections Notion-like via un adaptateur (`GiteaBoardCompat`) ; le miroir dépôt/page privée coexiste avec le workspace local. | + +### Stack technique + +- **Backend** : Python 3.13, FastAPI 0.115, uvicorn[standard] 0.34, Jinja2, httpx, pydantic v2 + pydantic-settings, itsdangerous (sessions signées), cryptography (Fernet), python3-saml, authlib, pyotp, webauthn, WeasyPrint/xhtml2pdf (PDF), openpyxl/python-docx/pypdf/BeautifulSoup/PyYAML (imports). +- **Frontend** : htmx 2.0.4, `@alpinejs/csp` 3.17.4, SortableJS, Chart.js, Leaflet, KaTeX, highlight.js — tous self-hosted. +- **Persistance** : SQLite (WAL, FTS5), migrations versionnées maison (baseline v1 + 38 migrations). +- **Empaquetage** : Docker multi-stage Python (pas de stage node), verrouillage des dépendances avec **uv** (`uv.lock`). --- ## 2. Architecture système +```mermaid +graph TD + B[Navigateur / PWA + service worker] -->|HTML, htmx, fetch nonce-CSP| W[uvicorn :8080 FastAPI] + E[Extension Web Clipper MV3] -->|Bearer fd_ token| A2 + X[Clients tiers / agents externes] -->|Bearer + scopes| A2[API publique /api/v2 + OpenAPI /docs] + W --> SSO[SSO : SAML / OIDC / SCIM] + W --> AG[Agent IA / LLM client] + AG --> P23[23 fournisseurs LLM + mode offline] + W --> GT[Gitea externe OAuth2, API, webhooks] + W --> GH[GitHub API] + W --> DB[(SQLite WAL /data/flowdeck.db + FTS5)] + W --> FS[/data uploads avatars backups] + W --> WS[WebSocket /ws/pages/id temps réel] + W --> OUT[Webhooks sortants HMAC] + SCH[lifespan : 9 schedulers asyncio] --> DB ``` -┌──────────────────────────────────────────────────────────────────┐ -│ CLIENT (Browser) │ -│ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ Templates Jinja2 (SSR) │ │ -│ │ ├─ base.html — Layout Notion commun (sidebar) │ │ -│ │ ├─ _header.html — Topbar + breadcrumbs │ │ -│ │ ├─ _workspace_tree_macro.html — Macro arbre sidebar │ │ -│ │ ├─ landing.html — Landing page non-auth │ │ -│ │ ├─ workspaces.html — Page Workspaces (locale/Gitea) │ │ -│ │ ├─ library.html — Bibliothèque multi-onglets │ │ -│ │ ├─ local_workspace.html — Workspace local (explorer) │ │ -│ │ ├─ page_editor.html — Éditeur de blocs │ │ -│ │ ├─ trash.html — Corbeille (local only) │ │ -│ │ ├─ settings.html — Settings / My Account / Admin │ │ -│ │ ├─ accounts.html — Gestion comptes admin │ │ -│ │ ├─ board.html — Kanban (wrapper) │ │ -│ │ ├─ board_fragment.html — Cartes Kanban │ │ -│ │ ├─ table_view.html — Vue table │ │ -│ │ ├─ login.html — Login/Register (local + OAuth) │ │ -│ │ ├─ public_page.html — Page publiée (/p/slug) │ │ -│ │ └─ (legacy: dashboard, detailed_board, card_detail, etc.) │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ JavaScript (servi localement, pas de CDN externe) │ │ -│ │ ├─ Alpine.js 3.14 — Interactivité légère │ │ -│ │ ├─ HTMX 1.9 — AJAX sans JS (legacy) │ │ -│ │ ├─ SortableJS 1.15 — Drag & drop │ │ -│ │ └─ Prism.js — Syntax highlighting │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ CSS custom — Design system Notion, dark theme, 31+ KB │ │ -│ └─────────────────────────────────────────────────────────┘ │ -└──────────────────────────┬───────────────────────────────────────┘ - │ HTTP (HTML fragments / JSON) -┌──────────────────────────▼───────────────────────────────────────┐ -│ FASTAPI (Python 3.11+) │ -│ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ MIDDLEWARE │ │ -│ │ ├─ SessionMiddleware — Sessions utilisateur │ │ -│ │ ├─ CSRFMiddleware — Protection CSRF │ │ -│ │ ├─ CORSMiddleware — CORS (* pour dev local) │ │ -│ │ └─ RateLimiter — Rate limiting par IP │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ ROUTERS │ │ -│ │ ├─ main.py — FastAPI app, lifespan, CORS, 404 │ │ -│ │ ├─ dashboard.py — /, /workspaces, /library, /help │ │ -│ │ │ — /trash, /settings, /accounts │ │ -│ │ │ — /p/{slug} (pages publiques) │ │ -│ │ │ — _sidebar_data() — données sidebar │ │ -│ │ ├─ board.py — /board/{o}/{r} Board + vues │ │ -│ │ │ — /board/api/pages CRUD pages │ │ -│ │ │ — /board/api/trash Trash │ │ -│ │ ├─ api.py — /api/... CRUD + sync │ │ -│ │ ├─ auth.py — /auth/... OAuth2 + local │ │ -│ │ ├─ local_workspace.py— /api/local-workspace CRUD + upload │ │ -│ │ ├─ my_tasks.py — /my-tasks Dashboard │ │ -│ │ ├─ library.py — /api/library/* API bibliothèque│ │ -│ │ ├─ pages.py — /pages/... Pages CRUD │ │ -│ │ ├─ collections.py — /db/... Collections │ │ -│ │ ├─ editor.py — /api/editor/... Block editor │ │ -│ │ ├─ public_api.py — /api/v1 Public API v1 │ │ -│ │ ├─ api_v2.py — /api/v2 Public API v2 │ │ -│ │ │ — Bearer + scopes, CRUD complet │ │ -│ │ ├─ web_clipper.py — /api/v2/web-clipper Web Clipper │ │ -│ │ ├─ permissions.py — /api/v2 (ACL) Permissions │ │ -│ │ ├─ workspace.py — Workspaces API + Gitea projets │ │ -│ │ ├─ webhooks.py — /webhooks/... Gitea hooks │ │ -│ │ └─ admin.py — /api/admin/* Admin users │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ SERVICES │ │ -│ │ ├─ GiteaClient (httpx async) — API Gitea │ │ -│ │ ├─ FormulaEngine — Moteur de formules │ │ -│ │ ├─ RollupEngine — Agrégations cross-DB │ │ -│ │ ├─ CollectionSync — Sync Gitea ↔ collections │ │ -│ │ ├─ DependencyChecker — Contraintes dépendances │ │ -│ │ ├─ PermissionManager — ACL multi-user │ │ -│ │ └─ SearchEngine — FTS5 recherche full-text │ │ -│ └─────────────────────────────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ DATA LAYER │ │ -│ │ ┌─────────────────────────────────────────────────┐ │ │ -│ │ │ SQLite (WAL mode, foreign keys ON) │ │ │ -│ │ │ │ │ │ -│ │ │ ┌─ collections (v1.3) │ │ │ -│ │ │ ├─ collection_pages (v1.3) │ │ │ -│ │ │ ├─ collection_views (v1.3) │ │ │ -│ │ │ ├─ collection_properties (v1.4) │ │ │ -│ │ │ ├─ boards (legacy) │ │ │ -│ │ │ ├─ cards (legacy) │ │ │ -│ │ │ ├─ col_mapping (legacy) │ │ │ -│ │ │ ├─ notes │ │ │ -│ │ │ ├─ checklists │ │ │ -│ │ │ ├─ checklist_items │ │ │ -│ │ │ ├─ project_properties (legacy → collection_properties) │ -│ │ │ ├─ property_values (legacy → collection_pages) │ -│ │ │ ├─ ai_keywords │ │ │ -│ │ │ ├─ pages │ │ │ -│ │ │ ├─ page_shares (v4.0) │ │ │ -│ │ │ ├─ recents (v4.0) │ │ │ -│ │ │ ├─ tags (v4.0) │ │ │ -│ │ │ ├─ page_tags (v4.0) │ │ │ -│ │ │ ├─ gitea_private_pages (v4.0) │ │ │ -│ │ │ ├─ users │ │ │ -│ │ │ ├─ user_tokens │ │ │ -│ │ │ ├─ workspaces (v2.0) │ │ │ -│ │ │ ├─ workspace_members (v2.0) │ │ │ -│ │ │ ├─ page_history (v2.0) │ │ │ -│ │ │ ├─ comments (v2.0) │ │ │ -│ │ │ ├─ favorites (v2.0) │ │ │ -│ │ │ └─ database_templates (v2.0) │ │ │ -│ │ └─────────────────────────────────────────────────┘ │ │ -│ └─────────────────────────────────────────────────────────┘ │ -└──────────────────────────┬───────────────────────────────────────┘ - │ HTTPS -┌──────────────────────────▼───────────────────────────────────────┐ -│ GITEA API REST v1 │ -│ git.dracodev.net │ -│ ├─ /api/v1/user/repos — Liste projets │ -│ ├─ /api/v1/repos/{o}/{r}/issues — Issues │ -│ ├─ /api/v1/repos/{o}/{r}/labels — Labels │ -│ ├─ /api/v1/repos/{o}/{r}/milestones — Milestones │ -│ ├─ /api/v1/user — Infos utilisateur │ -│ └─ /api/v1/orgs/{org}/repos — Repos d'organisation │ -└──────────────────────────────────────────────────────────────────┘ + +### 2.1 Cycle de vie applicatif (`app/main.py`) + +Le `lifespan` FastAPI orchestre, au démarrage : + +1. `init_db()` (`app/db.py`) — baseline idempotent + `apply_migrations()` (`app/migrations.py`, version courante **38**) ; +2. démarrage des **schedulers asyncio** lancés via `_spawn(name, coro)` — un wrapper `A34` qui journalise toute exception et redémarre le scheduler au bout de 10 s : + +| Scheduler | Service | Rôle | +|---|---|---| +| `agent_scheduler` | `app/routers/agent.py` | déclencheurs `schedule` des agents (60 s) | +| `automation_scheduler` | `app/services/automations.py` | crons + retries des automatisations, mutualisé avec `run_due_workers()` | +| `backup_scheduler` | `app/services/backup.py` | snapshot quotidien SQLite (checkpoint WAL avant copie) | +| `project_sync_scheduler` | `app/services/projects.py` | synchronisation du registre `projects` avec la forge | +| `trash_purge_scheduler` | `app/services/trash.py` | purge de la corbeille | +| `reminder_scheduler` | `app/services/reminders.py` | scan des rappels (60 s) | +| `semantic_index_scheduler` | `app/services/semantic_search.py` | indexation incrémentale des embeddings (300 s) | +| `calendar_sync_scheduler` | `app/services/calendar_sync.py` | resync bidirectionnelle Google/CalDAV | +| `webhook_retry_scheduler` | `app/services/webhook_outbound.py` | ledger de retry des webhooks sortants (condé par `WEBHOOK_RETRY_ENABLED`) | + +3. arrêt gracieux de ces tâches au shutdown. + +### 2.2 Chaîne de middleware (ordre d'exécution, requête entrante) + +Ajoutées dans `app/main.py` dans l'ordre inverse de l'exécution (Starlette : la dernière ajoutée tourne en premier) : + ``` +Requête → CORS (A37, origines fermées + regex localhost/extension) + → RateLimitMiddleware (fenêtre glissante par IP, config à chaud A33) + → ContentSecurityPolicyMiddleware (nonce par requête, script-src fermé) + → CSRFMiddleware (double soumission cookie→header, exemptions machine-to-machine) + → SessionMiddleware (1 h, stockage transitoire état OAuth `state` uniquement) + → route +``` + +La **vraie session applicative** n'est pas la SessionMiddleware : c'est la cookie signée `flowdeck_session` résolue par `app/auth/session.py` (§6.4). + +### 2.3 Conventions de rendu + +- Deux modes de réponse selon la surface : **HTML SSR** (pages, fragments htmx) ou **JSON** (API internes `/api/…` cookie-auth et API publiques `/api/v1`, `/api/v2` Bearer). +- Les erreurs API v2 suivent **RFC 7807** ; réponses standardisées `SuccessResponse {status:"ok", data}` / `ErrorResponse` (`app/models/responses.py`). +- `app/templating.py` : Jinja2 avec **`select_autoescape(["html"])`** (audit A10), globals `fd_icon`, `csrf_token`, `csp_nonce()`, `app_base_url()`. --- -## 3. Modèle de données - -### 3.1 Tables Core (existantes v1.0) - -```sql --- Authentification -CREATE TABLE users ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - login TEXT NOT NULL UNIQUE, - full_name TEXT NOT NULL DEFAULT '', - email TEXT NOT NULL DEFAULT '', - avatar_url TEXT NOT NULL DEFAULT '', - is_admin BOOLEAN NOT NULL DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE user_tokens ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - gitea_user_id INTEGER NOT NULL UNIQUE, - gitea_token TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Boards (legacy — sera absorbé par collections en v1.3) -CREATE TABLE boards ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - project_owner TEXT NOT NULL, - project_name TEXT NOT NULL, - columns_json TEXT NOT NULL DEFAULT '["Backlog","À faire","En cours","Révision","Terminé"]', - wip_limits_json TEXT DEFAULT '{}', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(project_owner, project_name) -); - -CREATE TABLE cards ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - board_id INTEGER NOT NULL REFERENCES boards(id), - gitea_issue_id INTEGER, - column_name TEXT NOT NULL DEFAULT 'Backlog', - position INTEGER NOT NULL DEFAULT 0, - priority TEXT DEFAULT 'Medium', - due_date TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE col_mapping ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - board_id INTEGER NOT NULL REFERENCES boards(id), - column_name TEXT NOT NULL, - gitea_label TEXT NOT NULL, - close_issue BOOLEAN NOT NULL DEFAULT 0, - UNIQUE(board_id, column_name) -); - --- Notes & Checklists -CREATE TABLE notes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - project_owner TEXT NOT NULL, - project_name TEXT NOT NULL, - title TEXT NOT NULL DEFAULT 'Notes', - content TEXT NOT NULL DEFAULT '', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(project_owner, project_name, title) -); - -CREATE TABLE checklists ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - board_id INTEGER NOT NULL, - gitea_issue_id INTEGER NOT NULL, - title TEXT NOT NULL DEFAULT 'Checklist', - position INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(board_id, gitea_issue_id, title) -); - -CREATE TABLE checklist_items ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - checklist_id INTEGER NOT NULL REFERENCES checklists(id) ON DELETE CASCADE, - content TEXT NOT NULL DEFAULT '', - checked BOOLEAN NOT NULL DEFAULT 0, - position INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- Propriétés custom (legacy) -CREATE TABLE project_properties ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - project_owner TEXT NOT NULL, - project_name TEXT NOT NULL, - name TEXT NOT NULL, - prop_type TEXT NOT NULL DEFAULT 'select', - options_json TEXT DEFAULT '[]', - position INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(project_owner, project_name, name) -); - -CREATE TABLE property_values ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - property_id INTEGER NOT NULL REFERENCES project_properties(id) ON DELETE CASCADE, - gitea_issue_id INTEGER NOT NULL, - value TEXT NOT NULL DEFAULT '', - UNIQUE(property_id, gitea_issue_id) -); - -CREATE TABLE ai_keywords ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - project_owner TEXT NOT NULL, - project_name TEXT NOT NULL, - keyword TEXT NOT NULL, - color TEXT NOT NULL DEFAULT '#787774', - usage_count INTEGER NOT NULL DEFAULT 1, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(project_owner, project_name, keyword) -); - --- Pages -CREATE TABLE pages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - workspace TEXT NOT NULL, - title TEXT NOT NULL DEFAULT 'New page', - content TEXT NOT NULL DEFAULT '', - content_format TEXT NOT NULL DEFAULT 'markdown', - parent_section TEXT DEFAULT 'Private', - parent_id INTEGER REFERENCES pages(id), - sort_order INTEGER NOT NULL DEFAULT 0, - deleted_at TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); -``` - -### 3.2 Tables Database Concept (v1.3) - -```sql --- Collections = Databases Notion -CREATE TABLE collections ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - workspace_id INTEGER REFERENCES workspaces(id), - name TEXT NOT NULL, - description TEXT DEFAULT '', - icon TEXT DEFAULT '📋', - schema_json TEXT NOT NULL DEFAULT '[]', - -- [{name,type,options,position}, ...] - - -- Lien Gitea optionnel - gitea_owner TEXT, - gitea_repo TEXT, - - is_locked BOOLEAN NOT NULL DEFAULT 0, - is_inline BOOLEAN NOT NULL DEFAULT 0, - parent_page_id INTEGER REFERENCES collection_pages(id), - - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - created_by INTEGER REFERENCES users(id), - UNIQUE(workspace_id, name) -); - --- Pages dans une collection -CREATE TABLE collection_pages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, - - title TEXT NOT NULL DEFAULT '', - icon TEXT DEFAULT '📄', - position INTEGER NOT NULL DEFAULT 0, - - -- Sub-items - parent_id INTEGER REFERENCES collection_pages(id), - - -- Gitea link - gitea_issue_id INTEGER, - gitea_issue_number INTEGER, - - -- Toutes les valeurs de propriétés en JSON - property_values_json TEXT NOT NULL DEFAULT '{}', - - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - created_by INTEGER REFERENCES users(id), - updated_by INTEGER REFERENCES users(id) -); - -CREATE INDEX idx_cp_collection ON collection_pages(collection_id, position); -CREATE INDEX idx_cp_parent ON collection_pages(parent_id); -CREATE INDEX idx_cp_gitea ON collection_pages(gitea_issue_id); - --- Vues d'une collection -CREATE TABLE collection_views ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, - - name TEXT NOT NULL DEFAULT 'Default View', - view_type TEXT NOT NULL DEFAULT 'table', - -- table | board | calendar | timeline | gallery | list - - config_json TEXT NOT NULL DEFAULT '{}', - -- { - -- group_by: "Status", - -- filters: [{property,operator,value}, ...], - -- filter_conjunction: "and" | "or", - -- sorts: [{property,direction}, ...], - -- visible_properties: ["Title","Status"], - -- card_size: "medium", - -- cover_property: "Files", - -- date_property: "DueDate", - -- date_range_property: "EndDate" - -- } - - position INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); -``` - -### 3.3 Tables Propriétés Avancées (v1.4) - -```sql -CREATE TABLE collection_properties ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, - - name TEXT NOT NULL, - prop_type TEXT NOT NULL DEFAULT 'text', - -- title, text, number, select, multi_select, status, - -- date, person, checkbox, url, email, phone, - -- relation, rollup, formula, - -- files, unique_id, - -- created_time, created_by, last_edited_time, last_edited_by - - -- Options pour select/multi_select/status - options_json TEXT DEFAULT '[]', - -- [{name:"Todo",color:"gray"}, {name:"Done",color:"green"}] - - -- Format pour number - number_format TEXT DEFAULT 'number', - -- number, percent, dollar, euro, pound, yen - - -- Pour relation - related_collection_id INTEGER REFERENCES collections(id), - reverse_name TEXT, - - -- Pour rollup - relation_property_id INTEGER REFERENCES collection_properties(id), - target_property_id INTEGER REFERENCES collection_properties(id), - rollup_function TEXT, - -- count, sum, avg, min, max, range, unique - - -- Pour formula - formula_expression TEXT, - - position INTEGER NOT NULL DEFAULT 0, - required BOOLEAN NOT NULL DEFAULT 0, - visible_in_views BOOLEAN NOT NULL DEFAULT 1, - - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(collection_id, name) -); -``` - -### 3.4 Tables Multi-Utilisateurs (v2.0) - -```sql -CREATE TABLE workspaces ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - owner_id INTEGER NOT NULL REFERENCES users(id), - settings_json TEXT NOT NULL DEFAULT '{}', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE workspace_members ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id), - role TEXT NOT NULL DEFAULT 'editor', - -- admin | editor | commenter | viewer - joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(workspace_id, user_id) -); - -CREATE TABLE page_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE, - user_id INTEGER REFERENCES users(id), - change_type TEXT NOT NULL, - -- created | updated | moved | deleted | restored - snapshot_json TEXT NOT NULL DEFAULT '{}', - -- Snapshot des valeurs avant modification - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE comments ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE, - user_id INTEGER NOT NULL REFERENCES users(id), - body TEXT NOT NULL DEFAULT '', - parent_id INTEGER REFERENCES comments(id), - resolved BOOLEAN NOT NULL DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - -CREATE TABLE favorites ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id), - page_id INTEGER REFERENCES collection_pages(id), - collection_id INTEGER REFERENCES collections(id), - -- L'un des deux est NOT NULL - position INTEGER NOT NULL DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(user_id, page_id, collection_id) -); - -CREATE TABLE database_templates ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - workspace_id INTEGER REFERENCES workspaces(id), - name TEXT NOT NULL, - description TEXT DEFAULT '', - schema_json TEXT NOT NULL DEFAULT '[]', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - created_by INTEGER REFERENCES users(id) -); - -CREATE TABLE page_templates ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, - name TEXT NOT NULL DEFAULT 'Default', - property_values_json TEXT NOT NULL DEFAULT '{}', - content_json TEXT DEFAULT '[]', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); -``` - -### 3.5 Diagramme des relations +## 3. Structure du backend ``` -┌──────────────┐ ┌──────────────────┐ ┌───────────────────┐ -│ workspaces │──1:N──│ workspace_members│──N:1──│ users │ -│ │ │ - role │ │ - login │ -│ - name │ │ - joined_at │ │ - email │ -│ - owner_id │ └──────────────────┘ │ - avatar_url │ -└──────┬───────┘ │ - is_admin │ - │ 1:N └──────┬────┬────────┘ - ▼ │ │ -┌──────────────┐ ┌──────────────────┐ │ │ (author) -│ collections │──1:N──│ collection_pages │◄─N:1───────┘ │ -│ │ │ │ │ -│ - name │ │ - title │ │ -│ - schema_json│ │ - parent_id ────┼─── self-ref │ -│ - icon │ │ - gitea_issue_id│ │ -│ - gitea_* │ │ - property_values│ │ -│ - is_locked │ │ _json │ │ -└──────┬───────┘ └────────┬─────────┘ │ - │ 1:N │ 1:N │ - ▼ ▼ │ -┌──────────────────┐ ┌──────────────────┐ │ -│collection_views │ │ comments │ │ -│ - view_type │ │ - body │ │ -│ - config_json │ │ - resolved │ │ -└──────────────────┘ │ - parent_id │ │ - └────────┬─────────┘ │ - │ │ -┌──────────────────┐ │ │ -│collection_properties│ │ │ -│ - name │ │ │ -│ - prop_type │◄─────────┘ │ -│ - options_json │ │ -│ - related_collection_id ──┐ │ -│ - relation_property_id │ self-ref │ -│ - target_property_id │ │ -│ - formula_expression │ │ -└────────────────────────────┘ │ - │ -┌──────────────────┐ ┌──────────────────┐ │ -│ page_history │ │ favorites │ │ -│ - change_type │ │ - position │ │ -│ - snapshot_json │ └──────────────────┘ │ -│ - user_id ──────┼────────────────────────────────────────┘ -└──────────────────┘ +app/ +├── main.py # FastAPI, middlewares, include_router ×40, lifespan/schedulers +├── config.py # pydantic-settings (.env), db_path, secrets, feature flags +├── db.py # baseline schema (105 tables au total avec migrations), get_conn() +├── migrations.py # @register(version) + schema_version, 1 migration = 1 transaction +├── templating.py # Jinja2 autoescape + globals +├── password_utils.py # hash SHA-256 + sel, validation force +├── auth/ # oauth.py, session.py, providers/ (oidc, saml) +├── middleware/ # csrf.py, security.py (CSP, rate limit, uploads) +├── models/ # requests.py / responses.py (Pydantic, partiel) +├── schemas/ # schémas API +├── templates/ # ~35 gabarits + fragments _*.html (§22) +├── routers/ # surface HTTP (§4) — découplés en packages par l'audit A28 +│ ├── api_v2/ # API publique v2 : collections, properties, views, projects, +│ │ # planning, engagement, sharing, identity, workspaces, +│ │ # templates_io, webhooks, admin +│ ├── board/ # Kanban Gitea + pages legacy + wiki + synced + embed + import +│ ├── collections/ # Databases Notion-like (/db) : crud, properties, views, +│ │ # data_api, pages, linked, meta, boards, task_db, dashboards +│ ├── dashboard/ # Pages : local_workspace, workspaces, account_*, pages_*, +│ │ # public, settings +│ └── *.py # auth, sso, scim, webauthn, security, permissions, governance, +│ # audit, admin, agent, api_v2_agent, meetings, automations, +│ # workers, notifications, realtime, search, search_ai, sites, +│ # library, my_tasks, notes, projects, gitea, github_routes, +│ # workspace, sharing, sidebar_config, export, imports, +│ # web_clipper, sync, emoji, onboarding, wiki, collaboration, +│ # public_api, api, webhooks +└── services/ # logique métier (~80 modules, §11–21) ``` +**Pattern** : les *routers* font l'authentification, la validation d'entrée et le rendu ; les *services* portent la logique (accès données, moteur de règles, intégrations). Les quatre plus gros domaines de routes ont été restructurés en **packages** (`api_v2`, `board`, `collections`, `dashboard`) par l'audit **A28** ; chaque package a un `_common.py` (dépendances partagées) et un `__init__.py` qui agrége ses sous-routeurs par scan de modules. + --- ## 4. Routage & API -### 4.1 Routes existantes (v1.2) +Carte des surfaces HTTP par préfixe (tous les routeurs sont déclarés dans `app/main.py`) : -``` -GET / → Dashboard -GET /board/{owner}/{repo} → Board Kanban -GET /board/{owner}/{repo}/view/{view} → Fragment vue (kanban|table|status|teamload|detailed) -GET /board/{owner}/{repo}?view=&status=&filter=&sort= → Avec paramètres +| Préfixe | Fichiers | Nature | Contenu | +|---|---|---|---| +| `/` | `dashboard/` (pas de préfixe) | SSR | Pages : accueil/dashboard, `/library`, `/workspace…`, `/settings`, `/trash`, `/help`, `/welcome`, `/onboarding`, `accounts`, page éditeur `/pages/{id}` (+ variantes collection/embed), workspaces Gitea | +| `/board` | `board/` (13 modules) | SSR + API | Kanban Gitea (`/board/{owner}/{repo}/view/{kanban|detailed|table|status|teamload}`), pages legacy, médias, partage, sync, wiki, embed, import, `page-templates` | +| `/db` | `collections/` (13 modules) | SSR + API | CRUD bases, propriétés, vues, data API des lignes, pages de ligne, linked databases, meta, boards synchronisés, dashboards, conversion `task-db` | +| `/my-tasks` | `my_tasks.py` | SSR + `/my-tasks/api` | Dashboard transverses des tâches assignées (3 vues) | +| `/notes` | `notes.py` | SSR | Notes markdown par projet Gitea | +| `/workspace` | `workspace.py` | SSR + JSON | Workspaces locaux, arborescence, upload ; `public_router` exempté d'auth pour le partage | +| `/api` | `api.py`, `collaboration.py`, `sharing.py`, `security.py`, `sidebar_config.py`, `admin.py`, `agent.py`, `search.py`, `notifications.py`, `projects.py`, `gitea.py`, `github_routes.py`, `library.py`, `import.py`, `export.py`, `emoji.py`, `realtime.py`, `onboarding.py` | JSON cookie-auth | API internes du front : `/api/search`, `/api/settings/*` (tokens, sessions), `/api/agent/*` (panneau IA), `/api/nav/menu`, `/api/pages/{id}/comments|reactions|mentions|backlinks`, `/api/export/*`, `/api/health` | +| `/api/v1` | `public_api.py` | JSON Bearer | API publique v1 en **lecture seule** (compat) | +| `/api/v2` | `api_v2/` (14 modules), `permissions.py`, `api_v2_agent.py`, `sync.py`, `web_clipper.py` | JSON Bearer | **API publique v2 complète** : CRUD tous domaines + scopes, `/api/v2/agents/*` (run synchrone, rollback), `/api/v2/skills/*`, `/api/v2/search/{ask,hybrid}`, `/api/v2/sync/{batch,delta}`, `/api/v2/web-clipper/clip`, `/api/v2/sso/*`, `/api/v2/scim/tokens`, `/api/v2/audit/logs`, `/api/v2/groups`, `/api/v2/users`, OpenAPI `/docs` + `docs/openapi-v2.json` | +| `/api/webhook` | `webhooks.py` | JSON | **Webhooks entrants** Gitea (secret partagé, exemption CSRF) | +| `/auth` | `auth.py`, `webauthn.py`, `sso.py`, `scim.py` (en partie) | HTML/JSON | Login local/OAuth (`/auth/login`, `/auth/callback`), `/auth/2fa/*`, `/auth/webauthn/*`, `/auth/saml/*`, `/auth/oidc/*`, `/auth/logout` | +| `/scim/v2` | `scim.py` | JSON Bearer | Provisioning SCIM 2.0 (`/scim/v2/Users` GET/POST/PUT/PATCH/DELETE) | +| `/s/` | `sites.py` | HTML | **Sites publics** multi-pages (mot de passe, expiration, stats) | +| `/f/` | `sites.py` | HTML/POST | **Formulaires publics** anonymes → collection | +| `/g/` | `wiki.py` | HTML | Accès **guest** à une page sans compte | +| `/wiki` | `wiki.py` | JSON/SSR | Teamspaces, vérification, follows | +| `/api/v2/meetings` | `meetings.py` | JSON | AI Meeting Notes (upload audio, statut, résumé) | +| `/api/v2/workers` | `workers.py` | JSON | Workers Python sandboxés (cron/partage/fork) | +| `/workspace/automations` | `automations.py` | SSR/JSON | Éditeur visuel d'automations (steps) | +| `/static` | `StaticFiles("static")` | fichiers | JS/CSS vendorisés, manifest.json, sw.js, icônes | -GET /board/api/properties/{o}/{r} → Liste propriétés -POST /board/api/properties/{o}/{r} → Créer propriété -DEL /board/api/properties/{o}/{r} → Supprimer propriété -POST /board/api/properties/{o}/{r}/values → Set valeur propriété -GET /board/api/ai-keywords/{o}/{r} → Liste keywords -POST /board/api/ai-keywords/{o}/{r}/extract → Extraire keywords -POST /board/api/sync/{o}/{r} → Sync complète +### 4.1 API publique v2 — contrat -GET /api/health → Health check -GET /api/stats → Statistiques -GET /api/projects → Projets JSON -POST /api/move → Déplacer carte -GET /api/issues/{o}/{r}/{id} → Issue detail (JSON) -GET /api/issues/{o}/{r}/{id}?format=html → Issue detail (HTML) -POST /api/issues/{o}/{r} → Créer issue -PATCH /api/issues/{o}/{r}/{id} → Update issue +- **Auth** : Bearer `fd_` (scopes hiérarchiques `read < write < admin`) **ou** session admin pour les routes sensibles (CSRF alors contrôlé *dans* le routeur, `/api/v2` étant exempté par la middleware). +- **Pagination / filtres / tri** systématiques, **erreurs RFC 7807**, **idempotence** par en-tête `Idempotency-Key` (table `idempotency_keys`, réponse rejouée), **rate limiting par jeton** (`API_V2_RATE_LIMIT_PER_TOKEN`), **audit** systématique (`api_audit_log`). +- **Ouverture** : OpenAPI servi sur `/docs` ; schéma exporté dans `docs/openapi-v2.json` ; guide complet `docs/API_GUIDE_V6.md`. +- **Sync offline** : `POST /api/v2/sync/batch` (rejeu des mutations PWA) + `GET /api/v2/sync/delta` (pull par `sync_version`) — backend `app/routers/sync.py`. -POST /api/checklists/{o}/{r}/{id} → Créer checklist -POST /api/checklist-items/{o}/{r}/{id}/{cl_id} → Ajouter item -PATCH /api/checklist-items/{id} → Toggle item +### 4.2 Plugins à effet réel sur le routage -GET /notes/{o}/{r} → Lire notes -POST /notes/{o}/{r} → Sauvegarder notes +Le registre `plugins` (`app/services/plugins.py`, v7.58) coupe **de vraies routes** quand un module est désactivé : `include_router(..., dependencies=[Depends(plugin_required("automations"))])` ou `("web-clipper")` dans `app/main.py` → toutes les routes du router renvoient 404, et le scheduler associé est arrêté. Les outils `web-tools` éteints retirent leurs outils du registre de l'agent (`PLUGIN_TOOLS`). -GET /auth/login → Login OAuth2 Gitea -GET /auth/callback → Callback OAuth2 -GET /auth/logout → Déconnexion - -POST /webhooks/gitea → Webhook Gitea - -GET /pages/{page_id} → Voir page -POST /board/api/pages → Créer page -GET /board/api/pages/{id} → Get page JSON -PUT /board/api/pages/{id} → Update page -PUT /board/api/pages/{id}/move → Déplacer page (drag & drop) -POST /board/api/pages/{id}/blocks → Save block editor -DEL /board/api/pages/{id} → Soft delete - -GET /board/trash → Page corbeille -GET /board/api/trash → Liste trash -POST /board/api/trash/{id}/restore → Restaurer -DEL /board/api/trash/{id} → Suppression définitive - -GET /library → Bibliothèque -GET /accounts → Gestion comptes -``` - -### 4.2 Routes Database (v1.3-v1.5) - -``` -GET /db → Liste collections du workspace -POST /db → Créer collection -GET /db/{collection_id} → Vue par défaut de la collection -GET /db/{collection_id}/view/{view_id} → Vue spécifique -GET /db/{collection_id}/fragment/{view_type} → Fragment HTMX - -POST /db/{collection_id}/pages → Créer page -GET /db/{collection_id}/pages/{page_id} → Détail page -PUT /db/{collection_id}/pages/{page_id} → Update page -DELETE /db/{collection_id}/pages/{page_id} → Soft delete page - -GET /db/{collection_id}/properties → Liste propriétés -POST /db/{collection_id}/properties → Créer propriété -PUT /db/{collection_id}/properties/{prop_id} → Update propriété -DELETE /db/{collection_id}/properties/{prop_id} → Supprimer propriété - -GET /db/{collection_id}/views → Liste vues -POST /db/{collection_id}/views → Créer vue -PUT /db/{collection_id}/views/{view_id} → Update config vue -DELETE /db/{collection_id}/views/{view_id} → Supprimer vue - -POST /db/{collection_id}/views/duplicate/{id} → Dupliquer vue -POST /db/{collection_id}/views/save-as → Sauvegarder état courant - -GET /db/{collection_id}/templates → Page templates -POST /db/{collection_id}/templates → Créer template -POST /db/{collection_id}/templates/{tid}/apply → Appliquer template - -POST /db/{collection_id}/import/csv → Import CSV -GET /db/{collection_id}/export/csv → Export CSV -``` - -### 4.3 Routes Sub-items & Dépendances (v1.8) - -``` -GET /db/{collection_id}/pages/{page_id}/sub-items → Liste sub-items -POST /db/{collection_id}/pages/{page_id}/sub-items → Créer sub-item - -GET /db/{collection_id}/pages/{page_id}/dependencies → Bloque/Bloqué par -POST /db/{collection_id}/pages/{page_id}/dependencies → Ajouter dépendance -DELETE /db/{collection_id}/pages/{page_id}/dependencies/{dep_id} → Supprimer - -POST /db/{collection_id}/pages/{page_id}/check-deps → Vérifier contraintes -``` - -### 4.4 Routes My Tasks (v1.9) - -``` -GET /my-tasks → Dashboard unifié -GET /my-tasks?view=calendar → Vue calendrier -GET /my-tasks?view=overdue → Tâches en retard -GET /my-tasks?view=upcoming&days=7 → 7 prochains jours -GET /my-tasks?filter=collection:3 → Filtrer par collection -``` - -### 4.5 Routes Multi-Utilisateurs (v2.0) - -``` -GET /workspace/{ws_id} → Workspace -POST /workspace → Créer workspace -PUT /workspace/{ws_id}/settings → Settings -POST /workspace/{ws_id}/members → Inviter membre -PUT /workspace/{ws_id}/members/{user_id}/role → Changer rôle -DELETE /workspace/{ws_id}/members/{user_id} → Retirer membre - -GET /db/{collection_id}/pages/{page_id}/history → Historique -GET /db/{collection_id}/pages/{page_id}/comments→ Commentaires -POST /db/{collection_id}/pages/{page_id}/comments→ Ajouter commentaire - -GET /favorites → Liste favoris -POST /favorites → Ajouter favori -DELETE /favorites/{id} → Retirer favori -``` - -### 4.6 Routes Sidebar & Library (v4.0) - -``` -GET /library → Bibliothèque dynamique -GET /api/library/recents → Pages récentes -GET /api/library/favorites → Favoris -GET /api/library/shared → Pages partagées -GET /api/library/published → Pages publiées -GET /api/library/private → Pages privées -GET /api/library/workspace → Pages du workspace - -GET /api/local-workspace/items → Arborescence workspace -POST /api/local-workspace/items → Créer page/dossier -PUT /api/local-workspace/items/{id} → Renommer -DELETE /api/local-workspace/items/{id} → Soft delete -POST /api/local-workspace/upload → Upload fichier -GET /api/files/{ws_id}/{filename} → Servir fichier uploadé - -GET /help → Page d'aide complète -GET /api/public/pages/{slug} → Page publique -``` - -### 4.7 Routes Share & Publish (v4.0) - -``` -POST /api/pages/{id}/share → Partager une page -DELETE /api/pages/{id}/share/{share_id} → Retirer partage -GET /api/pages/{id}/shares → Liste partages -POST /api/pages/{id}/publish → Publier page -DELETE /api/pages/{id}/publish → Dépublier -``` --- -## 5. Système d'authentification (v4.0) +## 5. Modèle de données -FlowDeck supporte **3 méthodes d'authentification** et la **connexion d'intégrations multiples** à un même compte local. +> Référence exhaustive table par table (colonnes, FK, fichiers, numéros de ligne) : **`docs/DATA_MODEL.md`**. Cette section en donne la synthèse architecturale. -### 5.1 Types de comptes +### 5.1 Moteur : SQLite uniquement -| Type | Auth | Workspaces | Intégrations | -|------|------|------------|--------------| -| **Local pur** | email + password | Locaux uniquement | Aucune | -| **Local + intégrations** | email + password | Locaux + distants | Gitea et/ou GitHub | -| **OAuth pur** | Gitea ou GitHub | Distants uniquement | Non-déconnectable | +Contrairement à certaines notes anciennes, **FlowDeck ne supporte pas PostgreSQL**. `app/db.py` est du `sqlite3` stdlib typé `sqlite3.Connection`, sans ORM ni SQLAlchemy : -### 5.2 Comportement du sidebar selon le type de compte +- une base par instance : `settings.db_path` (par défaut `sqlite:///…/flowdeck.db` sous `/data` en Docker) ; +- `PRAGMA journal_mode=WAL` (un writer, multi-readers), `foreign_keys=ON`, `busy_timeout=5000` pour la concurrence (tests xdist, schedulers) ; +- `get_conn()` = context-manager avec `row_factory = sqlite3.Row` ; les conversions dict↔JSON des colonnes `*_json` sont faites à la main dans les services ; +- `DATABASE_URL` n'est interprété que pour le préfixe `sqlite:///` (gestion des chemins Windows, bug UNC corrigé par l'audit A26) ; +- l'audit **A21** a sorti les accès SQLite bloquants de l'event loop (migration des ~510 call sites, achevée pour api_v2 et les routes principales v7.9–v7.26). -``` -LOCAL PUR: - ├─ 📁 [nom workspace local] - ├─ 📅 Meetings - ├─ 🕒 Recents - ├─ ⭐ Favorites - ├─ 🤖 Agents - ├─ 👥 Shared ← visible si pages partagées - ├─ 🌐 Published ← visible si pages publiées - ├─ (Private caché) - └─ 📚 Library / ✅ My Tasks / 🗑️ Trash / ❓ Help +### 5.2 Migrations versionnées (`app/migrations.py`) -LOCAL + GITEA/GITHUB: - ├─ 🔗 [owner/repo] ← workspace distant actif - ├─ 📅 Meetings - ├─ 🕒 Recents - ├─ ⭐ Favorites - ├─ 🤖 Agents - ├─ 👥 Shared - ├─ 🌐 Published - ├─ 🔒 Private ← visible (fichiers locaux liés au projet distant) - └─ 📚 / ✅ / 🗑️ / ❓ +Alternative assumée à Alembic : -OAUTH PUR (GITEA/GITHUB): - ├─ 🔗 [owner/repo] ← identité = compte Gitea/GitHub - ├─ 🕒 Recents - ├─ ⭐ Favorites - ├─ 👥 Shared - ├─ 🌐 Published - ├─ (Private caché) - └─ 📚 / ✅ / ❓ (Trash caché — pas de workspace local) +| Composant | Rôle | +|---|---| +| `init_db()` (`app/db.py`) | baseline « version 1 » : `CREATE TABLE IF NOT EXISTS` + ALTER `try/except OperationalError`, réexécutée à chaque boot, idempotente | +| `schema_version` | registre `(version, name, applied_at)` ; version max = version courante (actuellement **38**) | +| `@register(version, name)` | décorateur d'enregistrement, tri par version, doublons refusés | +| `apply_migrations()` | appelée en fin de `init_db()` ; applique exactement une fois chaque migration > version courante | +| `_apply_one()` | **1 migration = 1 transaction** (DDL tout-ou-rien, audit A31) | +| `fts5_available()` | détection FTS5, repli recherche `LIKE` sinon | + +### 5.3 Inventaire : 105 tables + 1 table virtuelle, par domaine + +| Domaine | Tables principales | Points notables | +|---|---|---| +| **Kanban & forge** (v0.x–v1.x) | `boards`, `cards`, `col_mapping`, `notes`, `checklists(_items)`, `project_properties`, `property_values`, `ai_keywords`, `gitea_private_pages`, `user_tokens`, `projects` | `projects` = registre forge-agnostique (gitea/github/builtin) synchronisé par cron | +| **Auth & identités** | `users`, `login_history`, `user_oauth_tokens`, `user_sessions`, `api_tokens`, `webauthn_credentials`, `scim_tokens`, `domain_claims`, `sso_config`, `sso_login_history`, `sso_requests` | `users.auth_method` : local / gitea / github / saml / oidc ; secrets chiffrés Fernet | +| **LLM** | `llm_config` (ligne unique `CHECK(id=1)`), `user_llm_keys` | clés API stockées serveur, jamais exposées en clair | +| **Workspaces & permissions** | `workspaces`, `workspace_members`, `teamspaces(_members)`, `user_groups`, `group_members`, `page/collection/property_permissions`, `permission_audit_log`, `page_shares`, `guest_shares` | ACL : grant sur user **XOR** group (`CHECK`) | +| **Collaboration** | `favorites`, `recents`, `notifications`, `comments`, `comment_reactions`, `text_reactions`, `page_follows`, `page_verifications`, `tags`, `page_tags`, `custom_emojis` | `comments` cible polymorphe (`page` ou `collection_page`) + ancre texte `anchor_start/end` (v7.64) | +| **Pages & éditeur** | `pages`, `page_versions`, `page_history`, `page_global_templates`, `synced_blocks`, `page_synced_blocks`, `page_views`, **`pages_fts`** (virtuelle FTS5) | `pages.content` = markdown legacy **ou** JSON de blocs selon `content_format` ; page-ombre de ligne via `pages.collection_row_id` (v6.5) ; trigger `sync_version` | +| **Collections (bases)** | `collections`, `collection_pages` (lignes), `collection_views`, `collection_properties`, `collection_data_sources`, `collection_dashboards`, `database_templates`, `page_templates`, `page_dependencies`, `sprints`, `sprint_pages`, `reminder_log` | `collections.schema_json` + `collection_pages.property_values_json` + `collection_views.config_json` = le trio documentaire du système de bases | +| **Automatisation** | `automations`, `automation_steps`, `automation_runs`, `workers`, `worker_runs`, `webhook_subscriptions` (créée hors migrations par `webhook_outbound.py`), `webhook_deliveries`, `idempotency_keys` | steps = chaîne trigger/condition/delay/action (v7.0) | +| **Agent IA** | `agents`, `agent_conversations`, `agent_messages`, `agent_actions`, `agent_skills`, `agent_triggers`, `agent_feedback`, `agent_policies`, `agent_approvals`, `agent_memory`, `agent_connectors`, `connector_tokens`, `plugins`, `semantic_embeddings`, `semantic_index_state` | `agent_actions.undo_snapshot_json` = journal annulable ; embeddings = vecteurs hashed-TF 256-dims en BLOB | +| **Sites & formulaires** (v6.8) | `sites`, `site_pages`, `site_views`, `form_responses` | RGPD : compteurs sans IP brute, `ip_hash` tournant | +| **Calendrier & réunions** (v7.1) | `calendar_links`, `meeting_transcripts`, `meeting_consent_log`, `meeting_distributions` | tokens OAuth calendrier chiffrés Fernet | +| **Import / offline / clipper** | `import_jobs`, `import_items`, `offline_sync_queue`, `extension_devices`, `extension_clips` | re-import idempotent par `(workspace, source, external_id)` | +| **API & audit** | `api_audit_log` | écriture « best-effort » : un échec d'audit ne casse jamais le chemin métier | + +### 5.4 Relations clés + +```mermaid +graph TD + USERS[users] + WS[workspaces] --> USERS + PAGES[pages] --> WS + PAGES -->|parent_id self| PAGES + COLS[collections] --> WS + CP[collection_pages lignes] --> COLS + CP -->|parent_id sub-items| CP + PAGES -->|collection_row_id page-ombre| CP + CV[collection_views] --> COLS + CPV[collection_properties] --> COLS + CPV -->|relation| COLS + CPV -->|button_automation_id| AUTO[automations] + AG[agents] --> WS + CONV[agent_conversations] --> AG + MSG[agent_messages + actions] --> CONV + GRP[user_groups] --> WS + ACL[page/collection/property_permissions] --> USERS + ACL --> GRP + SITES[sites] --> PAGES + TRANS[meeting_transcripts] --> PAGES + CAL[calendar_links] --> COLS ``` -### 5.3 Règles d'affichage +À retenir : -- **Section Private** : visible UNIQUEMENT si compte local + workspace distant actif -- **Trash** : visible si `auth_method == 'local'` ou si des pages locales existent -- **Boutons New File/New Folder** : cachés si `has_active_workspace == False` -- **Message "No workspace open"** : affiché dans la section Workspace si aucun workspace actif -- **Header sidebar** : toujours le compte local si auth_method=local, même avec intégrations +1. **`users` = hub d'identité** (30+ FK entrantes), **`workspaces` = hub d'isolation multi-tenant**. +2. **Deux univers de « pages »** : `pages` (éditeur wiki, soft-delete `deleted_at`, corbeille) et `collection_pages` (lignes de base). Le pont v6.5 : chaque ligne peut porter une **page-ombre** (`pages.collection_row_id`, CASCADE) qui héberge son contenu blocs — versions, synced blocks et temps réel opèrent sur elle. +3. **Chaînes de cascades** nettoyant l'éditorial ; les tables d'audit privilégient `SET NULL` sur l'acteur pour survivre à la suppression d'un compte. +4. **Sync Gitea par ID numérique** (`gitea_issue_id`, `gitea_owner/repo`) : jointures sans FK, système externe. +5. **Polymorphisme applicatif** : `notifications.resource_type/id`, `semantic_embeddings.resource_type/id`, `automations.collection_id` (TEXT) — cohérence maintenue par le code, pas par le moteur. -### 5.4 cookie `flowdeck_workspace` +### 5.5 Couche Pydantic -- `""` → aucun workspace actif -- `"42"` → workspace local ID 42 -- `"gitea:owner:repo"` → workspace distant Gitea -- Nettoyage automatique si cookie invalide (workspace supprimé ou autre user) +`app/models/requests.py` / `responses.py` ne couvrent qu'une petite partie du contrat (FileSaveRequest, Issue{Create,Update}Request, CardMoveRequest, ColMappingRequest, enveloppes `SuccessResponse`/`ErrorResponse`) ; la majorité des handlers valide en dur. --- -## 6. Frontend +## 6. Authentification & comptes -### 5.1 Layout +### 6.1 Types de comptes -```ascii -┌──────────────────────────────────────────────────────────────┐ -│ TOPBAR (unified-header) │ -│ ┌──────┬───────────────────────────────────────┬───────────┐ │ -│ │ «» │ Breadcrumbs: 🏠 Workspaces │ Share ⚙ │ │ -│ └──────┴───────────────────────────────────────┴───────────┘ │ -├────────────┬─────────────────────────────────────────────────┤ -│ SIDEBAR │ CONTENU PRINCIPAL │ -│ (240px) │ │ -│ │ ┌─ Help Page ────────────────────────────────┐│ -│ ┌────────┐ │ │ ┌──────────────┐ ┌──────────────┐ ││ -│ │ Header │ │ │ │ 🚀 Getting │ │ 📝 Pages │ (...) ││ -│ │ [D] │ │ │ │ Started │ │ & Editor │ ││ -│ │ Draco │ │ │ └──────────────┘ └──────────────┘ ││ -│ └────────┘ │ └─────────────────────────────────────────────┘│ -│ │ │ -│ 🏠 💬 📨 🔍│ ┌─ Library ─────────────────────────────────┐│ -│ │ │ [Recents|Favorites|Shared|Published|...] ││ -│ 📁 Worksp. │ │ ┌──────┬──────────┬──────────┬───────────┐ ││ -│ 📄 New Pg │ │ │Name │Created by│Source │Last edited│ ││ -│ 📁 New Fl │ │ ├──────┼──────────┼──────────┼───────────┤ ││ -│ 📄 page 1 │ │ │doc A │Draco │Local │10 min ago │ ││ -│ 📄 page 2 │ │ │doc B │bruno │Gitea │2 hours ago│ ││ -│ │ │ └──────┴──────────┴──────────┴───────────┘ ││ -│ 📅 Meeting │ └─────────────────────────────────────────────┘│ -│ 🕒 Recents │ │ -│ ⭐ Favoris │ ┌─ Workspaces ───────────────────────────────┐│ -│ 🤖 Agents │ │ ┌──────────┐ ┌──────────┐ ┌───┐ ┌───┐ ││ -│ 👥 Shared │ │ │Project A │ │Project B │ │ + │ │ + │ ││ -│ 🌐 Publ. │ │ │3 pages │ │12 pages │ │ │ │ │ ││ -│ │ │ └──────────┘ └──────────┘ └───┘ └───┘ ││ -│ ══════════ │ └─────────────────────────────────────────────┘│ -│ 📚 Library │ │ -│ ✅ My Task │ ┌─ Settings ─────────────────────────────────┐│ -│ 🗑️ Trash │ │ My Account | Admin | Integrations ││ -│ ❓ Help │ │ [Upload photo] [Full Name] [Email] [...] ││ -│ │ └─────────────────────────────────────────────┘│ -│ 💬 [+📄] │ │ -└────────────┴─────────────────────────────────────────────────┘ -``` +Tous les comptes vivent dans la table unique `users` (colonne `auth_method`, défaut `local`) : -### 5.2 Vues supportées +| Type | `auth_method` | Particularités | +|---|---|---| +| **Local** | `local` | `password_hash` (SHA-256 + sel, `app/password_utils.py`), min. 6 caractères ; le **premier inscrit local devient admin** | +| **OAuth forge** | `gitea` / `github` | login préfixé `{provider}_{login}` (ex. `gitea_alice`), upsert à chaque callback, token stocké dans `user_oauth_tokens` | +| **SSO provisioned** | `saml` / `oidc` | créé par `handle_sso_login()` si `auto_provision=1` ; **fusion par email** si le compte existe déjà | +| **SCIM** | `saml` | provisionné par `POST /scim/v2/Users` ; désactivation = `is_active=0` + révocation des sessions (jamais de suppression destructrice) | +| **Admin** | tout type | `is_admin=1` ; sur instance `sso_only`, seuls les admins conservent l'accès local | +| **Agent** | — | pas de compte de service dédié : l'agent agit **au plus avec les permissions de l'utilisateur appelant** ; la gouvernance passe par `agent_policies`/`agent_approvals` (§9.3) | -| Vue | Icône | Template | Description | -|-----|-------|----------|-------------| -| Table | 📊 | `table_view.html` | Colonnes = propriétés, lignes = pages | -| Board (Kanban) | 📋 | `board_fragment.html` | Groupé par propriété Select | -| Calendar | 📅 | `calendar_view.html` | Groupé par propriété Date | -| Timeline (Gantt) | 📈 | `timeline_view.html` | Barres horizontales sur axe Date | -| Gallery | 🖼️ | `gallery_view.html` | Cartes visuelles avec cover | -| List | 📝 | `list_view.html` | Compact, 1 colonne + preview | +Un utilisateur sans rôle explicite retombe sur le rôle implicite **`viewer`** (`role_in_workspace()` dans `app/routers/dashboard/_common.py`). -### 5.3 Composants réutilisables +### 6.2 Flux OAuth2 Gitea (+ GitHub) -- **Property editor** — éditeur inline par type (select dropdown, date picker, text input...) -- **Filter builder** — UI de construction de filtres (propriété + opérateur + valeur) -- **Sort builder** — UI de tri multi-critères -- **Group selector** — dropdown pour choisir la propriété de groupement -- **View tabs** — onglets pour switcher entre vues -- **Page modal** — détail d'une page avec propriétés, checklists, commentaires -- **Slash menu** — menu contextuel pour l'éditeur de blocs +- Providers déclarés dans `app/auth/providers/__init__.py` (`GiteaProvider`, `GitHubProvider`, registre `get_provider()`) + singleton legacy `GiteaOAuth` (`app/auth/oauth.py`). Un provider n'est « configuré » que si ses identifiants ne sont pas des placeholders (durcissement d'audit). +- `GET /auth/login?provider=gitea|github|local` → `{forge}/login/oauth/authorize?client_id&redirect_uri&response_type=code&state` ; `state` = `secrets.token_hex(32)` stocké dans la SessionMiddleware (1 h), avec le mode `link` (rattacher OAuth à un compte local depuis Réglages → Intégrations) encodé dans l'état. +- `redirect_uri` : override `OAUTH_REDIRECT_URI` sinon dérivé de la requête (`X-Forwarded-Proto/Host`), réutilisé tel quel au callback. +- `GET /auth/callback?code&state` : échange du code, profil via `GET {forge}/api/v1/user`, upsert `users`, stockage du token, émission de la cookie `flowdeck_session`, redirection `/workspaces`. + +### 6.3 Comportement du sidebar selon le compte + +La cookie `flowdeck_workspace` porte soit un id numérique (workspace local), soit `gitea:/` — auquel cas la sidebar affiche **l'arbre du dépôt Gitea *et* le miroir local du même nom en parallèle** (comportement conservé depuis v4.0, sections Home : Workspace / Repository / Meetings / Recents / Favorites / Agents / Teamspaces / Shared / Published / Private — voir §22.1). + +### 6.4 Sessions (`app/auth/session.py`, v5.2.0) + +- Cookie `flowdeck_session` : payload `{user, created_at, sid}` **signé** (`itsdangerous.URLSafeTimedSerializer`, `APP_SECRET_KEY`), non chiffré → `public_user()` strippe `password_hash` avant toute sortie ; +- `sid` = une ligne `user_sessions` (ip, user-agent, `revoked`, `last_seen_at`) : **révocation immédiate par session**, visible et gérable dans Réglages (`GET /api/settings/sessions`, `POST …/revoke`) ; +- expiration **7 jours** (cookie + signature cohérentes) ; contrôle `revoked` à chaque décodage, **fail-open** si la table est inaccessible (compat vieilles installations) ; +- verrouillage : 5 échecs → 15 min (`locked_until`, HTTP 423) ; `is_active=0` → 403 ; +- historique dans `login_history` ; déconnexion SSO → relais SLO si `_sso_name_id` présent. + +### 6.5 Jetons API Bearer + +- `POST /api/settings/tokens` (`app/routers/security.py`) crée `fd_`, affiché **une seule fois**, stocké **hashé SHA-256** (`api_tokens.token_hash`, `token_prefix` pour l'affichage) ; +- **scopes hiérarchiques** `read < write < admin` (`SCOPE_RANK`, `app/services/api_v2_helpers.py`) ; factory `require_scope()` (audit A30) ; +- `resolve_bearer_token()` accepte 3 sources : `api_tokens`, `extension_devices` (Web Clipper), `user_tokens` legacy (token Gitea en clair, portée implicite read+write) ; +- fallback dev `fd-public-key` uniquement si `PUBLIC_API_INSECURE_OK=true`. --- -## 6. Système d'authentification +## 7. SSO entreprise : SAML, OIDC, SCIM, 2FA, WebAuthn -### 6.1 Flux OAuth2 Gitea +(v6.7.0 SSO ; v7.2.0 SCIM/2FA/passkeys — doc `docs/V6_SSO_SAML_Enterprise_Auth.md`, `docs/V72_Enterprise_SCIM_2FA.md`) -``` -User ──→ GET /auth/login ──→ Redirect Gitea OAuth - │ - Gitea authorize page - │ -User authorize ──→ GET /auth/callback?code=xxx - │ - POST /api/v1/login/oauth/access_token - │ - ┌─────▼──────┐ - │ access_token│ - └─────┬──────┘ - │ - GET /api/v1/user - │ - ┌─────▼──────────────────┐ - │ Create/Update: │ - │ - users (login, email) │ - │ - user_tokens (token) │ - │ - session cookie │ - └─────────────────────────┘ -``` +### 7.1 SAML 2.0 (`app/auth/providers/saml_provider.py`, `app/routers/sso.py`) -### 6.2 Multi-utilisateurs (v2.0) +SP basé `python3-saml` (OneLogin), mode strict : -``` -┌──────────────────────────────────────────────────────────┐ -│ PERMISSION MODEL │ -│ │ -│ Workspace │ -│ ├─ Owner (créateur) — tout peut faire │ -│ ├─ Admin — gérer membres, config │ -│ ├─ Editor — CRUD pages, propriétés │ -│ ├─ Commenter — commentaires seulement │ -│ └─ Viewer — lecture seule │ -│ │ -│ Collection (hérite du workspace par défaut) │ -│ ├─ Peut être overridé par collection │ -│ └─ Database lock → lecture seule pour tout le monde │ -│ │ -│ Page │ -│ ├─ Propriétaire = created_by │ -│ ├─ Mention @user → notifie l'utilisateur │ -│ └─ Assignee (property type Person) │ -└──────────────────────────────────────────────────────────┘ -``` +- `GET /auth/saml/login?next=…` (AuthnRequest HTTP-Redirect, `RelayState = .`) ; `POST /auth/saml/callback` (ACS, exempté CSRF car POST cross-site mais protégé par le jeton single-use) ; `GET /auth/saml/metadata` (XML à coller dans l'IdP) ; `GET|POST /auth/saml/logout` (SLO). +- Validations : signature d'assertion (`wantAssertionsSigned`, RSA-SHA256), schéma XML, Conditions/Audience/Destination/Issuer/Status, `InResponseTo`, rejet des algorithmes dépréciés. +- Clé privée SP générée à la demande et **chiffrée au repos (Fernet)** dérivée de `APP_SECRET_KEY`. + +### 7.2 OpenID Connect (`app/auth/providers/oidc_provider.py`) + +Flow **authorization code + PKCE (S256)**, client confidentiel : découverte `.well-known/openid-configuration` (cache 1 h), `GET /auth/oidc/login` → `GET|POST /auth/oidc/callback` (form_post supporté). Vérification ID token contre la JWKS (sélection par `kid`) + contrôles explicites `iss/aud/exp/iat` (dérive 300 s), `nonce`, `sub`. Enrichissement best-effort via `userinfo_endpoint` (source habituelle des groupes). Logout : révocation locale + `end_session_endpoint` si présent. + +### 7.3 Provisioning & config (`app/services/sso_provisioning.py`) + +- Table `sso_config` (une ligne active) : type, mapping d'attributs, `groups_mapping`, `auto_provision`, `sso_only`, `default_workspace_id` ; la table prime sur les variables d'env `SSO_*` (fallback bootstrap) ; secrets chiffrés Fernet, jamais rendus par `GET /api/v2/sso/config` ; +- `handle_sso_login()` : résolution par **email (fusion)** puis **login** ; création si `auto_provision`, sinon rejet ; +- `sync_sso_groups()` : mapping groupe IdP → rôle `workspace_members` (owner/admin/editor/viewer), réappliqué à chaque login ; `POST /api/v2/sso/sync` force le re-sync global ; +- **anti-replay** : table `sso_requests` (TTL 600 s, consommation atomique) ; anti open-redirect : `safe_next_path()` n'accepte que `/chemin` ; +- `sso_only` : refuse login/enregistrement local pour les non-admins ; les **domaines vérifiés** `enforce_sso=1` (`domain_claims`) imposent le SSO aux comptes de ce domaine email ; +- chaque login/refus écrit `sso_login_history` ; rate limit dédiée 5 tentatives/min/IP ; API admin `GET /api/v2/sso/providers` (boutons de la page de login). + +### 7.4 SCIM 2.0 (`app/routers/scim.py`) + +`/scim/v2/Users` (GET liste, POST create, GET/PUT/PATCH/DELETE). Auth Bearer contre `scim_tokens.token_hash` (SHA-256, `revoked=0`). `active=false` ou DELETE = **suspension** (`is_active=0`) + révocation des sessions — contenu et audit préservés. Gestion des jetons : `POST|GET/DELETE /api/v2/scim/tokens` (préfixe `scim_`, valeur affichée une seule fois). + +### 7.5 2FA TOTP (`app/services/two_factor.py`) + +`POST /auth/2fa/setup` → secret `pyotp` + `otpauth://` (inactif tant que non vérifié) ; `/2fa/activate` (fenêtre ±1 pas) → `users.totp_secret_enc` chiffré Fernet + **10 backup codes** à usage unique (hashés) ; `/2fa/status`, `/2fa/disable`. Parcours login : `/auth/local-login` renvoie `{status:"2fa_required", pending}` (jeton signé 5 min, salt `totp-pending`), puis `/auth/local-verify` échange pending + code contre la cookie de session — **aucune session avant la seconde facteur**. + +### 7.6 WebAuthn / passkeys (`app/routers/webauthn.py`) + +`/auth/webauthn/register/begin|finish` (session requise, `excludeCredentials`), `/auth/webauthn/login/begin|finish` (**passwordless**), `GET /auth/webauthn/keys`, `DELETE …/keys/{id}`. Challenges en mémoire (TTL 300 s — déploiement mono-processus assumé, comme pour les rooms WebSocket) ; `rp_id` dérivé de l'hôte de la requête ; table `webauthn_credentials`, `sign_count` entretenu. --- -## 6.5 Modèle de comptes & intégrations (v4.0) +## 8. Sécurité applicative -### 6.5.1 Types de comptes +Récapitulatif des garde-fous (la grande majorité provient de l'audit sécurité v7.3→v7.35, A1–A43). -FlowDeck supporte 3 types de comptes, déterminés par `auth_method` dans la table `users` : +### 8.1 CSRF (`app/middleware/csrf.py`) -``` -┌──────────────┬──────────────────┬─────────────────────────────────────┐ -│ auth_method │ Login via │ Identité dans le sidebar │ -├──────────────┼──────────────────┼─────────────────────────────────────┤ -│ local │ email + password │ Nom local (ex: "Draco") │ -│ gitea │ OAuth2 Gitea │ Nom Gitea + badge 🦎 Gitea │ -│ github │ OAuth2 GitHub │ Nom GitHub + badge 🐙 GitHub │ -└──────────────┴──────────────────┴─────────────────────────────────────┘ -``` +Double soumission : cookie `csrf_token` (JS-readable, samesite=lax, 1 j) validée par `secrets.compare_digest` contre l'en-tête `X-CSRF-Token` sur POST/PUT/PATCH/DELETE. **A19 (v7.33)** : plus aucun préfixe cookie-auth exempté (46 sites du front équipés en v7.3.6) ; restent exemptés uniquement le machine-to-machine (`/api/webhook`, `/api/v1`, `/api/v2`, `/scim/v2`), les callbacks d'authentification (`/auth/*`), le public (`/s/`, `/f/`) et l'infrastructure (`/api/csrf-token`, `/api/frontend-error`). Compensations : jeton single-use `sso_requests`, validation complète assertion/ID token, et **contrôle CSRF dans le routeur** pour les routes `/api/v2` sensibles acceptant une session (mix session+Bearer). Le front porte le jeton via `` (htmx) et `fetch` manuel. -**Règle fondamentale** : Un compte local avec intégration(s) reste un compte LOCAL. -Le badge OAuth (🦎/🐙) n'apparaît QUE pour les comptes dont `auth_method` est le provider. +### 8.2 CSP (`app/middleware/security.py`) — audit A20 -``` -Compte LOCAL avec intégrations : -┌──────────────────────────────────┐ -│ [D] Draco │ ← Nom local, PAS de badge -│ bruno.charest@gmail.com │ -└──────────────────────────────────┘ +`script-src 'self' 'nonce-…'` — **ni `unsafe-inline` ni `unsafe-eval`** (achevé v7.43) : -Compte GITEA (auth_method=gitea) : -┌──────────────────────────────────┐ -│ [B] bruno [🦎 Gitea] │ ← Badge visible -│ bruno@dracodev.net │ -└──────────────────────────────────┘ -``` +- nonce par requête exposé aux templates via la ContextVar `CSP_NONCE` **avant** `call_next` (A43), global Jinja `csp_nonce()` ; +- `script-src-attr 'unsafe-inline'` **détaché** pour couvrir les ~74 handlers `onclick=` Alpine ; +- htmx neutralisé : `` (v7.37) ; +- Alpine servi en **build officiel `@alpinejs/csp`** (0 `eval`/`new Function`) ; +- `connect-src 'self' ws://… wss://…` fermé (A20 phase 2 : CDN retiré, vendors localisés — test `test_csp_no_cdn_and_vendor`), `object-src 'none'`, `base-uri 'self'`, `form-action 'self'`. -### 6.5.2 Matrice des intégrations +### 8.3 Rate limiting & CORS -``` -Compte principal Peut ajouter Déconnexion possible -───────────────────────────────────────────────────────── -local Gitea ✅ Oui -local GitHub ✅ Oui -local Gitea + GitHub ✅ Les deux -───────────────────────────────────────────────────────── -gitea GitHub ✅ GitHub ❌ Gitea -github Gitea ✅ Gitea ❌ GitHub -``` +- fenêtre glissante in-memory par IP sur `/api/`, `/board/api/`, `/auth/`, `/scim/v2/`, `/workspace/`, `/db/` ; `RATE_LIMIT_REQUESTS` lu à chaud, purge du store à 5000 clés, `X-Forwarded-For` honoré uniquement derrière proxy privé (A33) ; pages publiques `/s/`, `/f/` : seuls les non-GET plafonnés ; +- CORS A37 : origines fermées issues de `APP_BASE_URL` + regex localhost/extensions navigateur, `allow_credentials=True`, jamais `*`+credentials ; +- rate limit par jeton API v2 (`API_V2_RATE_LIMIT_PER_TOKEN` req/min), 300 req/min pour le run agent synchrone, 30 req/min pour Ask AI. -**Settings → Integrations** : Affiche toutes les intégrations avec bouton -Disconnect. Le provider principal (auth_method) n'a PAS de bouton Disconnect. +### 8.4 SSRF, uploads, XSS -### 6.5.3 Scénarios de workspace +- garde-fou SSRF aux points de sortie HTTP (pas en middleware) : `_is_public_host()` (`app/services/importers/url_fetch.py`) rejette les hôtes `private/loopback/link-local/reserved/multicast` ; `app/services/og_fetcher.py` re-vérifie l'hôte **à chaque redirection** (A12) ; `app/services/connectors.py` impose la garde sur tout fetch ; +- uploads : liste `ALLOWED_EXTENSIONS` + 10 Mo max (`validate_upload`) ; +- XSS : autoescape Jinja2 global (A10, 326 interpolations crues corrigées) ; sérialisation markdown échappée de l'éditeur (`gtMd`/`mdEsc`). -``` -SCÉNARIO A : Compte local pur -───────────────────────────── -· Login email+password -· Workspaces locaux uniquement -· Section Private : CACHÉE (tout est déjà local) -· Trash : ✅ (restauration locale) +### 8.5 Chiffrement au repos -SCÉNARIO B : Compte local + intégration(s) -────────────────────────────────────────── -· Login email+password -· Workspaces : locaux + Gitea + GitHub -· Section Private : VISIBLE si workspace distant actif -· Private : stockage DB locale, associé user+repo -· Trash : ✅ (local uniquement) +Fernet (dérivé de `APP_SECRET_KEY`) sur : secrets SSO (`client_secret`, clé SP), `totp_secret_enc`, `calendar_links.tokens_enc`, `connector_tokens.tokens_enc`, `agent_connectors.secret_encrypted`. Hash SHA-256 pour : `api_tokens`, `scim_tokens`, `extension_devices`, backup codes. Note factuelle : le **mot de passe local est SHA-256 + sel** (rapide, pas bcrypt/argon2 — choix « SQLite simplicity » assumé en commentaire de `app/password_utils.py`). -SCÉNARIO C : Compte OAuth pur (gitea ou github) -──────────────────────────────────────────────── -· Login via OAuth -· Workspaces : distants du provider principal -· Section Private : VISIBLE (toujours) -· Trash : ❌ (pas de contenu local) -· Intégration secondaire : possible -· Déconnexion provider principal : impossible -``` +### 8.6 Audit de sécurité A36 & exceptions muettes -### 6.5.4 Sidebar — règles d'affichage par section +Les `except: pass` silencieux ont été loggés (v7.3.9), l'API `/api/admin/*` est réservée aux admins, les logs d'exceptions sont tracés, et `scripts/audit_functional.py` rejoue un audit bout-en-bout sur base SQLite isolée (v7.46). -``` -Section Scénario A Scénario B Scénario C -───────────── ────────────── ────────────────────── ────────────── -Recents ✅ (local) ✅ (tout) ✅ (tout) -Favorites ✅ (local) ✅ (tout) ✅ (tout) -Shared ✅ (local) ✅ (tout) ✅ (tout) -Published ✅ (local) ✅ (tout) ✅ (tout) -Agents ✅ ✅ ✅ -Private ❌ CACHÉ ✅ si ws distant actif ✅ -Workspace ✅ ✅ ✅ -Trash ✅ (local) ✅ (local) ❌ -``` - -Chaque section a un bouton `→ Library` qui ouvre : -``` -/library?tab=recents|favorites|shared|published|private|workspace -``` - -### 6.5.5 Système Share & Publish - -Le bouton [Share] dans la topbar d'une page ouvre une modale à 2 tabs : - -``` -┌──────────────────────────────────────────────────────┐ -│ [ Share ] [ Publish ] │ -├──────────────────────────────────────────────────────┤ -│ Share tab : │ -│ · Invite people : [email] [Invite] │ -│ · General access : [🔒 Restricted] Copy link │ -│ · Liste des personnes/groupes avec accès │ -│ → Document listé dans sidebar "Shared" │ -├──────────────────────────────────────────────────────┤ -│ Publish tab : │ -│ · [Publish to web] → URL publique générée │ -│ · URL : https://flowdeck.draco.dev/p/ │ -│ · [Unpublish] │ -│ → Document listé dans sidebar "Published" │ -└──────────────────────────────────────────────────────┘ -``` - -### 6.5.6 Stockage des fichiers Private - -Quand un workspace distant est actif, les fichiers créés dans la section -Private sont stockés dans la **base de données locale** (table `pages` avec -`parent_section='Private'` et `workspace=gitea:/`). - -Cela permet de : -- Prendre des notes personnelles liées à un projet sans les committer -- Garder des brouillons avant de les pousser -- Avoir un espace de travail privé même sur un repo partagé - -### 6.5.7 Trash — règles - -``` -Contenu Trash ? Comportement -─────────────────────── ──────── ────────────────────────── -Workspace local ✅ Supprimé → restaurable -Dossier/fichier local ✅ Supprimé → restaurable -Fichier Gitea/GitHub ❌ Commit "delete" → pas trash -Page distante ❌ Commit "delete" → pas trash -``` - -### 6.5.8 Édition de fichiers — UI unifiée - -``` -Fichier LOCAL Fichier DISTANT (Gitea/GitHub) -┌─────────────────────────┐ ┌─────────────────────────────┐ -│ Même UI d'édition │ │ Même UI d'édition │ -│ Même rendu visuel │ │ Même rendu visuel │ -│ Sauvegarde auto DB │ │ + Bouton [Commit] │ -│ Pas de commit │ │ + Champ message commit │ -└─────────────────────────┘ └─────────────────────────────┘ -``` - -### 6.5.9 Page Library — `/library?tab=` - -``` -┌──────────────────────────────────────────────────────────────┐ -│ Library │ -│ [Recents] [Favorites] [Shared] [Published] │ -│ [Private] [Workspace] │ -├──────────────────────────────────────────────────────────────┤ -│ Filtres : [All] [Local] [Gitea] [GitHub] │ -│ │ -│ ┌─────────────────────────────────────────────────────────┐ │ -│ │ 📄 Document X Local · modifié il y a 2h │ │ -│ │ 📄 README.md Gitea · bruno/flowdeck │ │ -│ │ 📄 Notes Local · Workspace Projet A │ │ -│ └─────────────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────────┘ -``` - -### 6.5.10 Schéma DB — mises à jour pour v4.0 - -```sql --- users : ajout auth_method -ALTER TABLE users ADD COLUMN auth_method TEXT NOT NULL DEFAULT 'local'; --- 'local' | 'gitea' | 'github' - --- users : colonnes OAuth existantes --- gitea_id INTEGER (NULL si pas Gitea) --- github_id INTEGER (NULL si pas GitHub) --- gitea_token TEXT (token intégration, NULL si pas connecté) --- github_token TEXT (token intégration, NULL si pas connecté) - --- pages : ajout published -ALTER TABLE pages ADD COLUMN is_published BOOLEAN NOT NULL DEFAULT 0; -ALTER TABLE pages ADD COLUMN publish_slug TEXT; -ALTER TABLE pages ADD COLUMN is_shared BOOLEAN NOT NULL DEFAULT 0; -ALTER TABLE pages ADD COLUMN share_mode TEXT DEFAULT 'restricted'; --- 'restricted' | 'link' | 'public' - --- page_shares : qui a accès à quel document -CREATE TABLE page_shares ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE, - shared_with_user_id INTEGER REFERENCES users(id), - shared_with_email TEXT, - permission TEXT NOT NULL DEFAULT 'view', - -- 'view' | 'comment' | 'edit' - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - created_by INTEGER REFERENCES users(id) -); - --- recents : historique de consultation -CREATE TABLE recents ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id), - page_id INTEGER NOT NULL REFERENCES pages(id), - workspace TEXT NOT NULL, - source_type TEXT NOT NULL DEFAULT 'local', - -- 'local' | 'gitea' | 'github' - accessed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - UNIQUE(user_id, page_id) -); -``` - -### 6.5.11 Plan de migration - -``` -Phase 1 — DB & Modèles - 1. Ajouter auth_method aux users existants (auto-détecter) - 2. Ajouter is_published, is_shared, share_mode aux pages - 3. Créer table page_shares - 4. Créer table recents - -Phase 2 — Sidebar - 1. Badge OAuth (🦎/🐙) basé sur auth_method - 2. Afficher/cacher Private selon type de compte + workspace actif - 3. Boutons Library par section - 4. Trash filtré local-only - -Phase 3 — Share & Publish - 1. Modale Share à 2 tabs fonctionnelle - 2. Share via email/user → table page_shares - 3. Share via lien → share_mode='link' - 4. Publish → is_published + publish_slug - 5. Page publique servable à /p/ - -Phase 4 — Library - 1. Page /library avec tabs - 2. Filtrage par source (local/gitea/github) - 3. Recents alimentés automatiquement - -Phase 5 — Intégrations - 1. GitHub OAuth déjà fait (providers.py) - 2. Matrice de déconnexion respectée - 3. Compte local peut connecter/déconnecter Gitea+GitHub -``` --- -## 7. Intégration Gitea +## 9. Autorisation : rôles, permissions granulaires, gouvernance -### 7.1 Flux de données +### 9.1 Couche 1 — rôles de workspace -``` -Création Collection Sync bidirectionnelle -────────────────── ────────────────────── +`workspace_members.role` : **owner > admin > editor > commenter > viewer** (`app/services/permission_manager.py`). Ensembles sémantiques : `READ_ROLES`, `WRITE_ROLES = {editor, admin, owner}`, `DESTRUCTIVE_ROLES = {admin, owner}`. Le propriétaire d'un workspace a le rôle implicite owner ; tout utilisateur sans ligne explicite retombe sur **viewer**. -1. Collection vide 5. POST /board/api/sync/{o}/{r} - (pas de lien Gitea) │ - ├─ GET issues Gitea -2. Collection liée ├─ Map issues → collection_pages - (gitea_owner + gitea_repo) │ (via _issue_column) - ├─ Extract AI keywords -3. Pull initial └─ Upsert cards in DB - GET /api/v1/repos/{o}/{r}/issues - 6. Webhook Gitea -4. Mapping colonnes POST /webhooks/gitea - col_mapping: │ - column_name → gitea_label ├─ issue.opened → create page - ├─ issue.closed → archive page - └─ issue.labeled → move column -``` +### 9.2 Couche 2 — ACL granulaires (v6.0/v6.1) -### 7.2 Adaptateur de compatibilité +Trois ressources protégées par grant explicite, sur un **user OU un groupe** (`user_groups`/`group_members`, workspace-scopés, `CHECK user_id XOR group_id`) : -```python -class GiteaBoardCompat: - """Adaptateur: board Gitea legacy → Collection.""" +| Ressource | Rôles | Table | +|---|---|---| +| Page (éditeur) | viewer / commenter / editor / owner | `page_permissions` | +| Collection (base) | viewer / commenter / editor / owner | `collection_permissions` | +| Propriété d'une base | viewer / editor | `property_permissions` | - @staticmethod - def from_board(board_row) -> dict: - return { - "collection_id": f"gitea:{board_row.id}", - "name": f"{board_row.project_owner}/{board_row.project_name}", - "gitea_owner": board_row.project_owner, - "gitea_repo": board_row.project_name, - "schema": [ - {"name": "Title", "type": "title"}, - {"name": "Status", "type": "select", - "options": json.loads(board_row.columns_json)}, - {"name": "Priority", "type": "select", - "options": ["P1","P2","P3","P4"]}, - ], - "is_gitea_linked": True, - } +Chaque ressource porte un `permission_type` : `inherit` (suit la chaîne page → collection → workspace), `restricted`, `private` (seuls les grants explicites font foi). **Règle de résolution : moindre privilège** — un grant explicite surcharge la chaîne héritée, le meilleur rang gagne (`_explicit_grant_role`). Cache de résolution mémoïsé 60 s, invalidé à chaque mutation. Les rôles SSO se matérialisent par la ligne `workspace_members` du mapping groupe → rôle (`get_sso_roles`). - @staticmethod - def from_collection_page(page_row) -> dict: - """Convertit collection_page → card (pour templates legacy).""" - return { - "id": str(page_row.get("gitea_issue_number", page_row["id"])), - "title": page_row["title"], - "status": _derive_status(page_row), - ... - } -``` +API (`app/routers/permissions.py`, sous `/api/v2`) : `GET|POST /pages/{id}/permissions` (+ `permissions/batch`, `DELETE …/{perm_id}`, `POST …/permission-type`), homologue pour `/collections/{id}/permissions` + `…/properties/{pid}/permissions` + `/properties/visible` (visibilité de propriétés) ; CRUD groupes `/api/v2/groups[/{id}[/members[/{user_id}]]]` (réservé admin/owner de workspace) ; sélecteur d'utilisateurs `/api/v2/users`. Chaque grant/revoke/type_change/group_*/member_* écrit une ligne **immuable** `permission_audit_log` (lecture `GET /api/v2/audit/permissions`, owner/admin, limit ≤ 500). + +### 9.3 Gouvernance de l'agent (v7.2) + +`app/services/agent_policies.py` + `app/routers/governance.py` : table `agent_policies` (ligne globale `workspace_id IS NULL` ou override par workspace) avec `allowed_tools_json` (liste blanche), `max_steps ≤ 50`, `require_approval`. `check_tool()` est consulté **avant** `assert_can()` par l'AgentEngine. En mode `require_approval`, un appel d'écriture crée une demande dans `agent_approvals` (pending/approved/rejected), **suspend l'action** et émet le webhook `agent.run.approval_requested` ; `decide_approval()` approuve/rejette. Le gate d'outil exige editor+ pour les `WRITE_TOOLS`, admin/owner **et mode confirm** (HTTP 428) pour les `DESTRUCTIVE_TOOLS`. API : `GET|POST /api/v2/agent-policies`, `GET /api/v2/agent-approvals`, `POST /api/v2/agent-approvals/{id}/decide` (owner du workspace ou admin). + +### 9.4 Journal d'audit unifié (v7.2) + +Quatre sources, toutes en écriture « best-effort » (un échec d'audit ne casse jamais le chemin métier) : + +| Table | Écrit par | Contenu | +|---|---|---| +| `api_audit_log` | `audit_log()` (`api_v2_helpers.py`) | user_id, token_id, action, resource, ip, détail ≤ 1000 car. | +| `permission_audit_log` | `PermissionManager.log_permission_change()` | mutations d'ACL (§9.2) | +| `sso_login_history` | `sso_provisioning.log_sso_login()` | chaque tentative SSO réussie **ou rejetée** | +| `login_history` | `auth._log_login` | login local (ip + user-agent) | + +API unifiée **`GET /api/v2/audit/logs`** (`app/routers/audit.py`, admin only — session admin ou Bearer scope `admin`) : fusion par `source=all|api|permissions|sso`, filtres `actor`/`action`, `limit ≤ 500`, **export CSV** `?format=csv` (10 000 lignes, rétention visée 365 j). Vues annexes : `GET /api/v2/sso/history`, `GET /api/v2/audit/permissions`, `GET /api/admin/audit`. --- -## 8. Système de propriétés +## 10. Workspaces & collaboration multi-utilisateurs -### 8.1 Architecture +### 10.1 Modèle de workspace -``` -┌─────────────────────────────────────────────────────────────┐ -│ PROPERTY SYSTEM │ -│ │ -│ collection_properties (schéma) property_values_json │ -│ ┌──────────────────────────┐ ┌──────────────────────┐ │ -│ │ id: 1 │ │ Dans collection_pages │ │ -│ │ name: "Status" │ │ { │ │ -│ │ prop_type: "status" │ │ "1": "Done", │ │ -│ │ options_json: [ │ │ "2": "P1", │ │ -│ │ {name:"Todo", │ │ "3": "2026-07-15", │ │ -│ │ color:"gray"}, │ │ "4": ["bruno"], │ │ -│ │ {name:"Done", │ │ "5": ["page_42"], │ │ -│ │ color:"green"} │ │ } │ │ -│ │ ] │ └──────────────────────┘ │ -│ └──────────────────────────┘ │ -│ │ -│ Avantages du JSON: │ -│ - Pas de migration de table pour ajouter une propriété │ -│ - Requêtes via json_extract() en SQLite │ -│ - Facilement requêtable avec index si nécessaire │ -└─────────────────────────────────────────────────────────────┘ -``` +- **`workspaces`** : espace de travail multi-utilisateur (`owner_id → users`, `settings_json`) ; la table `workspace_members` définit le rôle de chacun (§9.1). +- Deux familles exposées dans l'UI : **workspaces locaux** (explorateur `local_workspace.html` — arbre, fil d'Ariane de dossiers, sélection multi-fichiers Maj/Ctrl, uploads avec progression/vitesse, vues table/liste/détails/cartes — routes `/workspace…` de `app/routers/dashboard/local_workspace.py`) et **espaces Gitea** (un dépôt = pseudo-workspace `gitea:/`, navigateur `gitea_workspace.html`, pages privées miroir `gitea_private_pages`). +- **Teamspaces** (v7.3) : namespaces de pages + bases **au sein d'un workspace**, avec leurs propres membres/rôles (`teamspace_members`) et confidentialité (`private=1` → 404 pour les non-membres) — §14.4. +- Le workspace courant est mémorisé par la cookie `flowdeck_workspace` ; les listes de ressources résolvent le rattachement via `effective_workspace_sql`. -### 8.2 Types de propriétés +### 10.2 Collaboration -| Type | Stockage | Exemple | -|------|----------|---------| -| `title` | String | `"Ma tâche"` | -| `text` | String | `"Description longue..."` | -| `number` | Float | `42` / `12.5` | -| `select` | String | `"En cours"` | -| `multi_select` | JSON array | `["Frontend","Backend"]` | -| `status` | String + color | `"Done"` (green) | -| `date` | ISO 8601 string | `"2026-07-15"` | -| `person` | JSON array | `[{"id":1,"login":"bruno"}]` | -| `checkbox` | Boolean | `true` | -| `url` | String | `"https://..."` | -| `email` | String | `"a@b.com"` | -| `phone` | String | `"+1..."` | -| `relation` | JSON array IDs | `[42, 57]` | -| `rollup` | Computé | `3` (COUNT) | -| `formula` | Computé | `"Résultat"` | -| `files` | JSON array URLs | `[{"url":"...","name":"img.png"}]` | -| `unique_id` | Integer | `42` | -| `created_time` | Auto | `"2026-07-10T..."` | -| `created_by` | Auto | `{"id":1,"login":"bruno"}` | -| `last_edited_time` | Auto | `"2026-07-10T..."` | -| `last_edited_by` | Auto | `{"id":1,"login":"bruno"}` | +| Fonctionnalité | Implémentation | +|---|---| +| Commentaires threadés | `comments` (cible polymorphe `page`/`collection_page`, réponses par `parent_id`, ancre inline `anchor_block_id` + `anchor_start/end`) — endpoints `/pages/{id}/comments` dans `app/routers/collaboration.py` | +| Réactions | `comment_reactions` (emoji par commentaire) + `text_reactions` (plage de texte d'un bloc, v7.64) | +| Mentions & suivis | notifications `@`/commentaires/assignations ; abonnements `page_follows` → notification `page.updated` | +| Favoris / Récents | `favorites`, `recents` (`source_type` local/gitea/… — « Recents réels » sidebar v7.69.6) | +| Historique de page | `page_versions` (snapshot complet `blocks_json` par save, undo/restore) pour l'éditeur ; `page_history` (legacy) pour les lignes de base | +| Templates | `database_templates` (bases prêtes, seed `db_templates.py`), `page_templates` (lignes, éventuellement récurrentes), `page_global_templates` (blocs) | +| Tags | `tags` (`user_id=0` = globales) + `page_tags` | +| Icônes | `custom_emojis` uploadés par workspace (`app/routers/emoji.py`) | +| Verrouillage | `pages.is_locked`/`locked_by` + mode « Suggest edits » (v5.12) | +| Vues analytiques | `page_views` (compteur quotidien par page, v7.3) | -### 8.3 Formula Engine +### 10.3 Gestion des conflits d'édition -```python -# Moteur d'expressions JavaScript-like -FUNCTIONS = { - "prop": lambda ctx, name: ctx["values"].get(name), - "now": lambda ctx: datetime.now().isoformat(), - "today": lambda ctx: date.today().isoformat(), - "if": lambda ctx, cond, a, b: a if cond else b, - "concat": lambda ctx, *args: "".join(str(a) for a in args), - "round": lambda ctx, n, d=0: round(n, d), - "contains": lambda ctx, s, sub: sub in str(s), - "length": lambda ctx, s: len(str(s)), - "toNumber": lambda ctx, s: float(s) if s else 0, - "formatDate": lambda ctx, d, fmt: format_date(d, fmt), - "dateAdd": lambda ctx, d, n, unit: add_to_date(d, n, unit), - "dateSubtract": lambda ctx, d, n, unit: subtract_from_date(d, n, unit), - "replace": lambda ctx, s, old, new: str(s).replace(old, new), - "replaceAll": lambda ctx, s, old, new: str(s).replace(old, new), # all by default - "join": lambda ctx, sep, *args: sep.join(str(a) for a in args), - "empty": lambda ctx, v: v is None or v == "" or v == [], - "and": lambda ctx, *args: all(args), - "or": lambda ctx, *args: any(args), - "not": lambda ctx, v: not v, -} -``` +Trois mécaniques selon la surface : -### 8.4 Rollup Engine - -```python -ROLLUP_FUNCTIONS = { - "count": lambda values: len(values), - "count_values": lambda values: len([v for v in values if v]), - "empty": lambda values: len([v for v in values if not v]), - "not_empty": lambda values: len([v for v in values if v]), - "sum": lambda values: sum(float(v) for v in values if v), - "average": lambda values: sum(vals:=...) / len(vals) if vals else 0, - "median": lambda values: statistics.median(vals), - "min": lambda values: min(vals), - "max": lambda values: max(vals), - "range": lambda values: max(vals) - min(vals), - "unique": lambda values: list(set(values)), -} -``` +1. **Temps réel éditeur** : merge 3-voies versionné (§13.4) avec drapeau de conflit champ-par-champ ; +2. **Hors temps réel** : colonne `sync_version` entretenue par triggers SQLite (`*_sync_version_bu` sur `pages`/`collection_pages`/`collections`) = verrou optimiste ; l'API offline s'appuie dessus (`GET /api/v2/sync/delta`) et `POST /api/v2/sync/batch` rejoue les mutations en file avec résolution de conflits ; +3. **Lignes de base hors-ligne** : last-write-wins par cellule ; calendrier synchronisé : last-write-wins + notification `calendar.conflict`. --- -## 9. Système de vues +## 11. Databases : propriétés, formules, rollups, dépendances -### 9.1 Config JSON d'une vue +Le moteur « base de données Notion-like » vit sous `/db` (`app/routers/collections/`, 13 modules), avec la logique dans `app/services/property_types.py`, `formula_engine.py`, `rollup_engine.py`, `collection_adapter.py`, `collection_lifecycle.py`, `row_pages.py`, `task_databases.py`. + +### 11.1 Architecture : schéma déclaratif + valeurs JSON + +`collection_properties` porte la **définition** (type, options, validation, format, cible de relation, expression, agrégat) ; chaque ligne (`collection_pages`) stocke ses **valeurs** dans `property_values_json` (`{property_name → valeur}`). Conséquences : ajouter une propriété ne demande **aucune migration de table** ; le requêtage se fait via `json_extract()` SQLite ; `collections.schema_json` garde une copie déclarative du schéma complet. + +``` +collection_properties (schéma) collection_pages.property_values_json +┌──────────────────────────┐ ┌──────────────────────────────┐ +│ name: "Status" │ │ { "Status": "Done", │ +│ prop_type: "status" │ ↔ │ "Priority": "P1", │ +│ options_json: [Todo, │ │ "DueDate": "2026-07-15", │ +│ Done] │ │ "Assignee": ["bruno"], │ +│ │ │ "Tags": ["page_42"] } │ +└──────────────────────────┘ └──────────────────────────────┘ +``` + +### 11.2 21 types de propriétés + +| Catégorie | Types | Stockage exemple | +|---|---|---| +| Simple | `title`, `text`, `number`, `select`, `multi_select`, `status` (+ couleur), `date` (ISO 8601), `person`, `checkbox`, `url`, `email`, `phone`, `files` | `"En cours"` ; `["Frontend","Backend"]` ; `[{"id":1,"login":"bruno"}]` ; `[{"url":"…","name":"img.png"}]` | +| Avancés | `unique_id` (auto-incrément) ; `relation` (bidirectionnel inter-collections, `related_collection_id` + `relation_property_id/target_property_id`) ; `rollup` (agrégat via relation, `rollup_function`) ; `formula` (`formula_expression`) ; `button` (déclenche une automatisation, `button_automation_id`) | `[42, 57]` ; valeurs calculées | +| Auto (calculées serveur) | `created_time`, `created_by`, `last_edited_time`, `last_edited_by` | horodatages/acteurs | + +Vérification : `validate_property_value()` par type + contraintes `validation_json` (required / unique / min / max, mig. 4). Regroupement UI par `group_name`. Sous-ensemble `SIMPLE_TYPES` pour l'import/export CSV. + +### 11.3 Formula Engine (`app/services/formula_engine.py`) + +Moteur d'expressions JavaScript-like, **19 fonctions** : `prop`, `now`, `today`, `if`, `concat`, `round`, `contains`, `length`, `toNumber`, `formatDate`, `dateAdd`, `dateSubtract`, `replace`, `replaceAll`, `join`, `empty`, `and`, `or`, `not`. L'expression est stockée dans `collection_properties.formula_expression` et évaluée à la lecture. + +### 11.4 Rollup Engine (`app/services/rollup_engine.py`) + +Agrégats calculés au travers d'une propriété `relation` vers la collection cible : **12 fonctions** — `count`, `count_values`, `empty`, `not_empty`, `sum`, `average`, `median`, `min`, `max`, `range`, `unique`, plus les concaténations de listes. Le bloc `progress` de l'éditeur réutilise le RollupEngine pour sa barre. + +### 11.5 Sub-items & dépendances (v1.8) + +- **Sub-items** : `collection_pages.parent_id` (auto-référence, hiérarchie illimitée) ; agrégation de statut — le parent passe `Done` quand tous ses enfants le sont. +- **Dépendances** : `page_dependencies(page_id, dependency_id, dependency_type)` — Blocking/Blocked by, **contrainte à la transition de statut** (impossible de passer Done une tâche qui en bloque une ouverte) et `auto_shift` (décalage des dates en cas d'overlap). + +### 11.6 Sprints & bases de tâches + +- **Sprints** : `sprints` + `sprint_pages` (points de vélocité, statut au démarrage, `auto_complete`) par base de tâches. +- **Task databases** (v7.47) : une collection marquée `is_task` avec mapping explicite `task_assignee_prop` / `task_status_prop` / `task_due_prop` → colonnes existantes — c'est la clé de My Tasks (§15.3) : le front envoie des **champs logiques** (`status`, `due`, `title`), jamais d'identifiant de colonne ; le serveur résout. +- **Linked databases** (v4.1) : `collection_data_sources` — une page peut monter une vue liée d'une collection source (`is_linked`), schéma partagé. + +--- + +## 12. Système de vues + +### 12.1 Config JSON d'une vue + +`collection_views` (`view_type` + **`config_json`**, `position`, `created_by` NULL = vue partagée sinon personnelle — mig. 10). Persistance via `PUT /db/views/{id}/config` et `POST /db/{id}/views/save-as`. Exemple : ```json { "group_by": "Status", + "sub_group_by": "Assignee", "filters": [ {"property": "Status", "operator": "is_not", "value": "Archivé"}, {"property": "DueDate", "operator": "is_after", "value": "2026-01-01"} @@ -1356,360 +565,509 @@ ROLLUP_FUNCTIONS = { } ``` -### 9.2 Chaîne de rendu +### 12.2 Catalogue des vues (trois surfaces) + +1. **Vues interactives client** (`static/js/database_table.js`, composant `DBInstance` — pages `/db/{id}`, `page_editor_collection.html`, blocs `database` du plan d'un document) : **Table** (par défaut), **Board/Kanban** (group par select/status/person/multi_select/text, **swimlanes** `sub_group_by`, WIP limits, taille et couvertures de carte), **Calendar** (grille mensuelle, drag & drop d'événements), **Gallery** (card size S/M/L, cover icon/color/property, chips de propriétés), **List** (compacte avec preview). +2. **Vues rendues serveur** (`app/routers/collections/_renderers.py`, dispatch `_render_view` appelé par `dashboard_views.py`, route `/db/{id}?view_type=…`) : les mêmes + **Timeline** (barres Gantt horizontales), **Gantt**, **Chart** (bar/line/pie/doughnut/scatter via Chart.js vendorisé + widgets **KPI** count/sum/avg/min/max, plafonné `CHART_MAX_GROUPS`), **Form** (formulaire générateur de lignes — alimente aussi les formulaires publics `/f/`), **Map** (Leaflet vendorisé, géocodage `lat,lng` depuis les propriétés), **Feed** (flux chronologique). +3. **Board Gitea legacy** (`app/routers/board/board_views.py`, `board.html`, SSR/htmx `GET /board/{owner}/{repo}/view/{view}`) : **kanban** (`board_fragment.html`), **detailed**, **table** (`table_view.html`), **status overview** (`status_overview.html` — donut SVG), **team load** (`team_load.html` — barres empilées), rendu depuis les issues du dépôt. + +### 12.3 Chaîne de rendu ``` -Requête: GET /db/3/view/7 - │ - ▼ - 1. Charger collection_views.id=7 - → view_type="board", config_json={...} - │ - ▼ - 2. Charger collection_pages WHERE collection_id=3 - → [page1, page2, page3, ...] - │ - ▼ - 3. Appliquer config_json.filters - → pages filtrées - │ - ▼ - 4. Appliquer config_json.sorts - → pages triées - │ - ▼ - 5. Grouper par config_json.group_by - → { "Todo": [p1, p2], "Doing": [p3], "Done": [p4] } - │ - ▼ - 6. Ne garder que config_json.visible_properties - → Alléger le payload - │ - ▼ - 7. Render template correspondant à view_type - → board_fragment.html - │ - ▼ - 8. Retourner HTML fragment (HTMX) +GET /db/{id} (vue sauvegardée) + → charger collection_views (view_type + config_json) + → charger collection_pages WHERE collection_id + → appliquer config_json.filters (conjunction and/or) + → appliquer config_json.sorts + → grouper par group_by (sous-groupes sub_group_by pour les swimlanes) + → projeter visible_properties (alléger le payload) + → rendu : fragment client (database_table.js) ou SSR selon la surface ``` +Les **dashboards** (`collection_dashboards.layout_json`) combinent plusieurs vues/widgets en colonnes sur une page ; les **vues de site** publiées (`site_views`) sont une projection en lecture des vues de collection. + + --- -## 10. Sub-items & Dépendances +## 13. Pages, éditeur de blocs & temps réel -### 10.1 Sub-items +### 13.1 Le modèle `pages` -``` -┌──────────────────────────────────────┐ -│ collection_pages (même collection) │ -│ │ -│ id │ title │ parent_id │ -│ ───┼────────────────┼───────────────│ -│ 1 │ Refonte UI │ NULL │ ← Parent -│ 2 │ Header │ 1 │ ← Sub-item de 1 -│ 3 │ Dark mode │ 1 │ ← Sub-item de 1 -│ 4 │ Tests header │ 2 │ ← Sub-sub-item -│ │ -│ Activation: │ -│ 1. enable_sub_items(collection_id) │ -│ 2. Crée 2 propriétés relation auto: │ -│ - "Parent item" → même collection│ -│ - "Sub-item" (inverse) │ -│ 3. Board affiche sub-items indentés │ -└──────────────────────────────────────┘ -``` +`pages` est la table pivot du produit : hiérarchie (`parent_id` auto-référence, `sort_order`),rattachement (`workspace_id`, `collection_id` pour une base plein écran, `collection_row_id` pour la page-ombre d'une ligne de base, `teamspace_id`), contenu (`content` = markdown legacy **ou** JSON de blocs selon `content_format`), publication (`published`/`is_published`/`publish_slug`/`is_shared`), aspect (`cover_url`, `page_icon`, `full_width`, `font_small`), cycle de vie (`deleted_at` = corbeille), contrôle (`is_locked`/`locked_by`, `permission_type`, `search_excluded`, `sync_version`). -### 10.2 Dépendances +### 13.2 Types de blocs -``` -┌───────────────────────────────────────────┐ -│ Tâche A ──blocks──► Tâche B │ -│ Tâche B ──blocked_by──► Tâche A │ -│ │ -│ Contraintes: │ -│ - B ne peut pas être "Done" si A ≠ "Done"│ -│ - Timeline affiche les flèches │ -│ │ -│ check_dependency_constraint(): │ -│ if new_status == "Done": │ -│ blocked = get_blocked_pages(page_id) │ -│ if any(not done for blocked): │ -│ raise DependencyError(...) │ -└───────────────────────────────────────────┘ -``` +Le menu slash et le « Turn into » de l'éditeur (`static/js/page_editor_scripts.js`, rendu serveur `app/services/wiki_blocks.py`) : + +| Famille | Blocs | +|---|---| +| Texte | `paragraph`, `heading_1…4`, `bulleted_list`, `numbered_list`, `to_do`, `toggle` (enfants `children`, `expanded`), `quote`, `callout` (icône + couleurs), `divider` | +| Technique | `code` (sélecteur de langue, Prism/hljs), `table_of_contents` (rendu client), `math` (bloc KaTeX), `equation_inline` (`$$…$$`), `mermaid` (SVG via `mmdc` si installé, sinon rendu navigateur) | +| Données | `table` (grille éditable `.ftable-editor`, GFM sans pipe de tête optionnel), `database` (base inline), `button` (Bloc bouton → automatisation, `automation_id`), `progress` (barre calculée par le RollupEngine) | +| Médias | `image`, `embed` (upload/fichier, `embed_type:download`), `bookmark` (unfurl OG), `video`, `audio` | +| Structure | `columns` (2/3/4/5 colonnes, `.block-column-wrapper` ×N), `synced` (bloc synchronisé), `meeting` (AI Meeting Notes) | + +### 13.3 Mécanique d'édition + +- **Markdown en saisie continue** : `checkMd` (`# `, `- `, `1. `, `[] `, `> `, `---`, ``` `` ```…), Enter prolonge les listes, Backspace/Delete partagent la garde « bloc vide → supprimer le bloc » (jamais le dernier) ; +- **Sérialisation DOM→markdown `gtMd()`** : gras/ital/souligné/barré/code/surlignage `==…==`/liens ; les chips wiki gardent leurs tokens (`gtTok`) ; +- **Handle ⋮⋮ + réordonnancement SortableJS** ; **bouton « + »** à gauche du handle (insert below, v7.60) ; **Maj-clic = sélection multi-blocs** ; +- **Menu contextuel de bloc façon Notion** (v7.61) : recherche d'action + 10 actions (Turn into ›, Color ›, Copy link to block `Alt+⇧+L`, Duplicate, Move to, Delete, Comment, Suggest edits, Ask AI, Skills ›), pied « Last edited by… / N words » ; sous-menu Color mémorise le dernier usage (`localStorage`) ; +- **Toolbar de sélection sur texte surligné** (v7.62–7.67) : 4 lignes (Turn into ; A/B/I/U/T/Tx ; lien/code/équation ; 💬 Comment + réaction) + Skills + « Edit with AI » ; +- **Presse-papiers multi-blocs** (v7.68–7.69) : copier/couper/coller de plages partielles → blocs entiers → plage partielle, Ctrl+C/X intercepté au `keydown` capture (le navigateur ne sérialise pas une sélection traversant deux `contenteditable`) ; +- **Coller intelligent** : `handleSmartPaste` → `md2b`/`paste2b` (markdown/GFM → blocs structurés) ; +- **Undo/redo** par snapshots `page_versions` (`pushHistory`, une ligne par save) + **autoSave debouncé** ; +- **Menus mobiles** (v7.63, ≤768 px) : barre horizontale flottante au `/` (15 boutons) + feuille plein écran « Insert block » ; +- **IA dans l'éditeur** : slash commands AI, autocomplétion inline (`AIAC`), panneau « Notion AI » Ctrl+J, remplissage de propriétés (`POST /api/agent/writing*`, §16.4). + +### 13.4 Temps réel (`app/services/realtime_server.py`, `realtime_merge.py`, `app/routers/realtime.py`) + +Passerelle WebSocket **`/ws/pages/{page_id}`**, auth par cookie `flowdeck_session` (fermeture `4401` sinon). Rooms **en mémoire par page** (uvicorn mono-worker), persistance `page.content` avec debounce, file sortante + tâche writer par connexion (broadcast non-bloquant, curseurs coalescés 1/flush, éjection propre du client trop lent code `4413`, anti-flood 400 ops/10 s). + +Protocole JSON (champ `t`) : client→serveur `hello`, `sync_req`, `ping`, `op` (insert|update|delete|move + `base` + `v`), `title`, `sel` ; serveur→client `sync`, `ack` (`v`, `stale`, `merged`, `conflict`), `op` broadcast (**le bloc fusionné est diffusé**, jamais la proposition brute), `sel`, `welcome`, `peer_join/leave`, `synced_update`. + +**Merge « CRDT-lite » 3 voies** (`realtime_merge.py`) : au-delà du LWW, chaque `update` porte la version `base` dont il dérive ; règle champ-par-champ — inchangé d'un côté ⇒ l'autre gagne ; identiques ⇒ sans conflit ; régions disjointes ⇒ `merge_text_3way` garde **les deux saisies** (diff3-lite par trim préfixe/suffixe commun) ; chevauchement ⇒ LWW par champ **avec drapeau de conflit** renvoyé au client. Le client (`page_editor_realtime.js`) gère présence (avatars déterministes), curseurs live (offsets restaurés), resynchro complète si `stale`, et **repli polling 10 s** si le WS tombe. --- -## 11. My Tasks & Dashboard unifié +## 14. Wiki, liens, blocs synchronisés & teamspaces -``` -┌──────────────────────────────────────────────────────┐ -│ MY TASKS │ -│ │ -│ ┌──────────────────────────────────────────────┐ │ -│ │ 📋 Projet Alpha [3 tâches]│ │ -│ │ ├─ Design maquette 🟡 En cours 15 juil │ │ -│ │ ├─ API endpoint 🔴 Bloqué 20 juil │ │ -│ │ └─ Tests UI ⚪ Backlog — │ │ -│ │ │ │ -│ │ 📋 Daily Tasks [2] │ │ -│ │ ├─ Réunion standup 🟢 Done 10 juil │ │ -│ │ └─ Revue de code 🟡 En cours 12 juil │ │ -│ └──────────────────────────────────────────────┘ │ -│ │ -│ Algorithme: │ -│ 1. Scanner toutes les collections du workspace │ -│ 2. Filtrer pages où: │ -│ - Une propriété "Person" = current_user │ -│ - Status ≠ "Done" / "Archived" │ -│ 3. Grouper par collection │ -│ 4. Trier par Due Date ASC │ -└──────────────────────────────────────────────────────┘ -``` +### 14.1 Liens wiki & mentions + +Les références inline vivent dans le texte brut des blocs sous forme de **tokens** `[[fdpage:ID]]` et `[[fddate:YYYY-MM-DD[Thh:mm]]]` ; le label est résolu **au rendu** — renommer une page la met donc à jour partout. Le sélecteur de mentions (`WM`, `/api/wiki/pages`) remplace « [[ » tapé par une chip non éditable qui re-sérialise en token. + +### 14.2 Backlinks + +`GET /api/pages/{page_id}/backlinks` (`app/routers/board/pages.py`) : « Lié depuis… » en scannant blocs et markdown brut de toutes les pages non supprimées, tri par fraîcheur. + +### 14.3 Blocs synchronisés (transclusion, v5.14 → prod v6.5) + +`synced_blocks` = **source de vérité** du contenu ; `page_synced_blocks` = références (page, index). L'édition de la source se propage : rooms temps réel via le message `synced_update`, ou re-résolution à la lecture (`resolve_content_json`, garantie de fraîcheur). Source supprimée ⇒ état explicite `_synced_deleted` (plus de « Loading… » éternel). + +### 14.4 Teamspaces & wiki d'équipe (v7.3) + +`app/services/wiki.py` + `app/routers/wiki.py` (doc `docs/V73_Wiki_Teamspaces_Polish.md`) : namespaces de pages/bases partagées avec rôles et confidentialité (`private=1` → 404), **badges de vérification ✅ avec expiration 90 j** (`POST /api/v2/wiki/pages/{id}/verify`), **abonnements** (`page_follows` → notification `page.updated`), **réactions** (`comment_reactions`, `text_reactions`), **partages invités sans compte** `/g/` (`guest_shares` : token, rôle, expiration, révocation), compteurs de vues journaliers (`page_views`). --- -## 12. Éditeur de blocs +## 15. Partage, publication, Library, My Tasks & Trash -### 12.1 Types de blocs +### 15.1 Sharing & Publish + +- **Partage de page** (`page_shares`) : vers un user, un groupe ou un email, permission view/edit/comment ; sections sidebar « Shared → Par moi / Avec moi » et onglet Library correspondant ; +- **Publication** : drapeaux unifiés `published` + `is_published` (+ `publish_slug` garanti, v7.69.6) → page publique rendue par `public_page.html` (thème sombre autonome, OG tags, `og:image` en URL absolue via le global `app_base_url()` v7.69.1) ; +- **Sites publics** (`app/routers/sites.py`, v6.8, doc `docs/V68_Sites_Forms.md`) : mini-sites multi-pages `/s/` (navigation, verrou **mot de passe + expiration**, SEO, `noindex`, analytics id, stats de consultation sans IP brute `site_views`, domaine personnalisé `custom_domain`) ; +- **Formulaires publics** `/f/` : champs générés depuis `collection_properties` (hors formules), validation, **rate limiting**, soumissions anonymes vers la collection (`form_responses` + `collections.form_config_json`), notification aux membres, événement `form.submitted` pour les automations ; +- **Partage public de base** (`/workspace` public_router, `share_mode`) et API de partage `app/routers/sharing.py` + `api_v2/sharing.py`. + +### 15.2 Library + +`app/templates/library.html` + `static/js/library.js` (API `/api/library`, `app/routers/library.py`) — page « toutes mes ressources » à **onglets** : Recents, **AI Meeting Notes** (regroupées Today/Yesterday/This week/Last week/This month/Older, v7.69.6), Favorites, Shared (sous-filtre direction Tous/Par moi/Avec moi), Private, Published, Workspace, Repository (Gitea). Tableau à **colonnes pilotées par « Show columns »** (Created by, Last edited by/time, Last visited time, Source avec icône — Page name verrouillée, ordre des en-têtes corrigé v7.69.8) ; recherche, sélection multiple, publication unifiée ; ouverture en **side peek**. + +### 15.3 My Tasks (v1.9 → refonte v7.47) + +Dashboard transverse **multi-workspaces** des tâches assignées (`app/routers/my_tasks.py`, `static/js/my_tasks.js`) : **3 vues façon Notion** — tableau dense (statut/échéance éditables au clic), kanban (colonnes = options de statut de la base d'origine, drag = écriture du statut), calendrier (mois courant sur les échéances). Sources opt-in, mapping explicite via les collections `is_task` (§11.6) : le front envoie un champ logique, jamais un id de colonne. Conversion d'une base en base de tâches par la modale `task_db_link.js` (`POST /db/{id}/task-db/api`, max 10 bases). + +### 15.4 Trash + +Corbeille unifiée (`trash.html`, `app/services/trash.py`) : soft-delete `deleted_at`, restauration, **purge** (immédiate ou planifiée par `trash_purge_scheduler`), actions groupées, tri/filtres réels, fenêtre de confirmation thématisée (v7.45.5). + +### 15.5 Sidebar & navigation (v7.46) + +Sidebar façon Notion dans `base.html` : en-tête workspace + badge d'auth, **4 onglets compressibles Home / Chat / Meeting / Inbox** (badges non-lus ; le panneau actif garde son label, les autres se replient sur l'icône), sections du panneau Home : Workspace (arbre), Repository, Meetings, Recents, Favorites, Agents, Teamspaces, Shared, Published, Private — chacune avec menu contextuel ; panneau « Customize sidebar » (`/api/sidebar`, `users.sidebar_config` JSON) ; **zone peek de sidebar** (survol quand repliée, épinglable, redimensionnable) ; **side peek** global (Alt+Click / menu contextuel) : panneau latéral ouvrant le document **nu, sans sidebar ni barre** (`page_editor_embed.html` + `?embed=1`, redimensionnable, `postMessage fd-page-renamed` pour synchroniser les titres). + + +--- + +## 16. IA : agent, compétences, connecteurs & écriture + +### 16.1 Fournisseurs LLM & configuration + +**Client unifié** `app/services/llm_client.py` : abstraction `LLMClient` sur **23 fournisseurs + un mode `offline`**, tous en protocole OpenAI-compatible chat-completions (Anthropic, Google `/v1beta/openai`, Cohere `/compatibility/v1` exposent une surface compatible). `PROVIDERS` porte (base_url, modèle par défaut) ; `PROVIDER_MODELS` les presets curatés exposés par `GET /api/agent/providers`. Ollama local (`http://localhost:11434/v1`, sans clé). Le mode **`offline`** est un *mock planner* déterministe — l'agent et les tests restent fonctionnels sans appel externe. + +**Précédence de configuration en 4 niveaux** (`app/services/llm_config.py`) : + +1. `provider`/`model` passés explicitement à l'endpoint de run ; +2. clé de l'utilisateur pour ce provider (`user_llm_keys` : api_key, api_base, default_model, `models_json`, flag `verified`) ; +3. ligne globale `llm_config` (id=1) gérée par l'UI admin ; +4. `settings.llm_*` (`.env`). + +Les clés sont **stockées en base serveur**, jamais exposées (masquées) ; le changement de clé réinitialise `verified` ; les providers à catalogue sur-vendu (NVIDIA, Mistral) sont **validés par sonde chat** avec filtrage des modèles non-chat (embeddings, TTS, génération d'images…). Routes : `/api/agent/keys[/{provider}]`, `/test`, `/models`, `/api/agent/providers`. + +### 16.2 Agent conversationnel — moteur ReAct (`app/services/agent_engine.py`) + +Boucle `objectif → compréhension → contexte → raisonnement ↔ action → résultat` ; `MAX_ITERATIONS = 12` (plafonnée par la politique workspace et `AGENT_MAX_ITERATIONS`), budget tokens, timeout par passe. Le LLM n'émet que des **intentions d'outils** (function calls) ; chaque appel passe par `AgentPolicies.check_tool()` puis `PermissionManager.assert_can()` avant exécution par le `ToolRegistry`. Chaque action est **journalisée dans `agent_actions` avec snapshot d'annulation** (`undo_snapshot_json`) → rollback unitaire (`POST /api/agent/actions/{id}/undo`). Le run émet un **flux SSE** (`reasoning`, `action`, `notice`, `final`, `error`) et déclenche les webhooks `agent.run.started|finished|failed`. + +**Panneau & conversations** (`app/routers/agent.py`, `/api/agent/*` ; UI `agent_panel.html` + `static/js/agent_panel_*.js`) : CRUD agents personnalisés (`agents` : instructions système, `scope_json` d'outils autorisés, `approval_mode`, modèle), conversations (provider/model persistés, toggle `memory_enabled`), feedback 👍/👎 (`agent_feedback`), mentions `@`/`+`, run SSE, **trigger externe** (`POST /api/agent/{id}/trigger` manuel SSE ; pendant synchrone JSON `POST /api/v2/agents/{id}/trigger` Bearer+scope write), **agents planifiés** (`agent_triggers`, scheduler 60 s). + +**API publique agent** (`app/routers/api_v2_agent.py`, v6.6) : mince wrapper Bearer sur le même moteur — `POST /api/v2/agents/conversations/{id}/run` **tamponne le SSE et renvoie un JSON unique** (status, final, reasoning, actions, events, durée), rate limit 300/min, idempotency-key, audit. Une seule implémentation, jamais re-développée. + +### 16.3 Contexte, outils, mémoire, skills + +- **Context builder** (`context_builder.py`) : snapshot Markdown **filtré par permissions** — collections (+schéma, verrou), pages récentes, documents, espaces, mentions résolues (`@document:`, `@collection:`, `@page:`, `@folder:`), fichiers épinglés, plus un guide in-app `APP_GUIDE` pour les questions « comment faire ». +- **Outils** (`tool_registry.py`) : **26 outils statiques** — fins wrappers des opérations des routers humains, chaque mutateur renvoyant un snapshot d'undo. Lecture : `search_workspace`, `read_collection/page/workspaces/document` ; écriture : `create_collection/view/page/document`, `add_property/relation/sub_item/dependency`, `update_page`, `write_blocks`, `apply_template`, `delete_*` ; Gitea : `read_gitea_issues`, `sync_gitea`, `create_gitea_issue` ; **web** (v7.46) : `web_search` (provider Exa, repli DuckDuckGo sans clé), `fetch_url` ; **GitHub** : `search_code` ; **connecteurs** : `connector_fetch`. S'y ajoutent les **outils MCP dynamiques** : fusion à chaque run du cache `agent_connectors.tools_json` (noms `mcp__`) ; retrait des outils des plugins éteints. +- **Mémoire** (`agent_memory.py`, v7.54) : par conversation, une ligne résumé upsert dans `agent_memory` (20 échanges max, injection plafonnée 4 000 car.), ré-injectée au run suivant ; toggle `memory_enabled` = aucune lecture/écriture ; ne fait jamais échouer un run. +- **Skills** (`skill_gallery.py`, v6.6) : prompts paramétrés + liste d'outils autorisés (`agent_skills`) ; CRUD/apply/édition (`/api/agent/skills`, `PATCH` v7.52) ; **galerie de 17 presets** installables (rapport-hebdo, compte-rendu-reunion, base-crm, okr, sprint-review, veille-techno, triage-incident, briefing-quotidien…) ; **format portable `flowdeck-skill` v1** (export/import JSON rejouable) ; doubles routes cookie et `/api/v2/skills/*`. + +### 16.4 Hub « Menu + » de l'assistant (v7.51→v7.58, 8 phases livrées) + +Design `docs/V74_Agent_Plus_Menu.md`. Le bouton + du panneau devient un menu à sections à deux niveaux : + +| Phase | Version | Section | Réalisation | +|---|---|---|---| +| 1 | v7.51 | Fichiers/répertoires (hub de contexte) | parcours d'arborescence `GET /api/nav/menu?parent_id=`, jeton `folder:` | +| 2 | v7.52 | Compétences | « Gérer » (CRUD) + « Parcourir » (galerie) | +| 3 | v7.53 | Design System – Canevas | canevas `design_system` (`block_templates.py`), insertion de blocs | +| 4 | v7.54 | Mémoire | `agent_memory` + toggle persisté | +| 5 | v7.55 | Connecteurs (socle) | `connectors.py` : catalogue natifs (gitea, github, web, google, ms365) + connecteurs personnels `agent_connectors` (kind custom|discord|telegram|mcp), fetch gardé SSRF, secrets Fernet | +| 6 | v7.56 | Google + Microsoft 365 | `oauth_connectors.py` : OAuth PKCE S256, scopes **lecture seule** (Drive/Gmail/Calendar ; Graph Files/Mail/Calendars), tokens chiffrés, refresh auto | +| 7 | v7.57 | Discord, Telegram, Teams, MCP | presets d'auth ; client MCP complet `mcp_client.py` (JSON-RPC 2.0 `initialize → tools/list → tools/call`, réponse JSON ou SSE, cache en base) | +| 8 | v7.58 | Add plugins | `plugins.py` : catalogue on/off **à effet réel** (§4.2) | + +> ⚠️ `docs/FLOWDECK_MCP_SERVER_GUIDE.md` est une conception pour exposer FlowDeck **comme serveur MCP** externe (parité `mcp.notion.com`) ; le code livré côté FlowDeck est le **client** MCP. Un serveur externe s'appuierait sur l'API v2 + les tokens existants. + +### 16.5 AI Writing (`app/services/ai_writing.py`, v5.9) + +Service **headless sans outils**, 6 actions : `write`, `summarize`, `translate`, `continue`, `autocomplete`, `properties`. Consommé par l'éditeur (slash commands, autocomplétion inline) et par les bases (remplissage de propriétés par suggestions structurées, défauts offline déterministes). Entrées HTTP : `POST /api/agent/writing`, `/writing/properties`, `/agent/generate` (génération sur document). + +--- + +## 17. Recherche : FTS5, sémantique hybride & Ask AI + +- **Palette Ctrl+K / Ctrl+P** (`#fd-command-palette` dans `base.html`, v5.0, doc `docs/V6…`) : recherche **FTS5** (`pages_fts`, table virtuelle synchronisée par triggers `AFTER INSERT/UPDATE/DELETE` sur `pages`, backfill au boot, repli `LIKE` si FTS5 absent) portée aux espaces accessibles (`app/services/search.py`, `/api/search`) ; **onglet « ✨ Réponses IA »** depuis v7.44. +- **Recherche sémantique + Ask AI** (v6.9, `app/services/semantic_search.py`, doc `docs/V69_Search_Ask_AI.md`) : retrieval **hybride** = lexical + similarité cosinus vectorielle, fusion **RRF (k=60)**, filtrage ACL `PermissionManager` (un chunk non autorisé n'entre jamais dans un prompt). Vecteurs : encodeur **TF hashé maison** (`hash-256`, 256 dims, zéro dépendance pip) ; chunking 1 200 car. / overlap 150 / 50 chunks max par ressource ; tables `semantic_embeddings` + `semantic_index_state`, scheduler d'indexation incrémental 300 s. +- **Ask AI** : `POST /api/v2/search/ask` — RAG (top 8 chunks autorisés) avec **citations cliquables** `[[fdpage:ID]]`, cache 10 min, rate limit 30/min, réponse extractive offline sinon ; `GET /api/v2/search/hybrid`. +- `flag pages.search_excluded` pour sortir une page de l'index. + +--- + +## 18. AI Meeting Notes & calendrier + +### 18.1 AI Meeting Notes (v7.1 → v7.69, bloc `meeting` de l'éditeur) + +`app/services/meetings.py` + `app/routers/meetings.py` (`/api/v2/meetings/*`) ; spec détaillée `docs/architecture-meeting-notion-flowdeck.md`, doc `docs/V71_Calendar_Meetings.md`. + +- **Machine à états** du bloc : `idle / recording / paused / processing / done / failed` ; modes de capture `mic_only / tab_plus_mic / import` ; audio (mp3/wav/m4a/ogg/flac/aac, 100 Mo max) dans `data/uploads/meetings/`. +- **Transcription** : backend externe par variable `STT_COMMAND` (ex. Whisper local, timeout 10 min) ou transcript collé ; **segments groupés par locuteur**, ré-attribution des speakers. +- **Consentement bloquant journalisé** (`meeting_consent_log`, 6 méthodes : attestation de démarrage, verbal, chat, audio, add-on Meet, force workspace). +- **Résumé façon Notion** : pipeline `run_processing()` en 8 étapes *Thinking* persistées (lecture → analyse → références personnes → compréhension → **classification de complétude** → rédaction → **titre généré** avec diagnostic honnête si réunion brève → **validation des citations obligatoires**) ; presets d'instructions (auto, standup, team, sales, 1:1, interview) ; résumé structuré JSON via `AIWritingService` (overview / décisions / action items), repli offline déterministe ; rendu Markdown → blocs sur la page. +- **Partage tracé** (`meeting_distributions` : copy_link/email/slack) ; événement **`meeting.summarized`** → déclenche les automations ; **index unique partiel `event_occurrence_id`** : une occurrence calendrier = au plus une note. +- UI : onglet Library dédié (§15.2), section Meeting de la sidebar, setup visible avec test micro et retry sans perte (v7.69.5). + +### 18.2 Sync calendrier (`app/services/calendar_sync.py`, v7.1) + +Synchronisation **bidirectionnelle** collection ↔ **Google Calendar** (REST) ou **CalDAV** générique (REPORT/PUT bruts, zéro dépendance). Liens par utilisateur dans `calendar_links` (tokens chiffrés Fernet, `date_property` de la collection), matching des événements par `collection_pages.external_event_id`, conflit = last-write-wins + notification `calendar.conflict`, `GET /db/{id}/calendar/freebusy`, resync périodique par scheduler. + +--- + +## 19. Automatisations, workers, rappels, notifications & webhooks + +### 19.1 Moteur d'automatisations (`app/services/automations.py`, v5.1 → v2 multi-step v7.0) + +*If-this-then-that* : triggers **`event` / `cron` / `button`** (tables `automations`, `automation_runs`), conditions AND sur propriétés (`eq, neq, contains, not_contains, is_empty, is_not_empty, changed`), actions séquentielles. + +**v7.0 — steps chaînés** (`automation_steps`) : kinds `trigger|condition|delay|action` ; multi-trigger mode `any/all` (fenêtre 300 s) ; delay plafonné 300 s ; **8 types d'actions** : `webhook`, `set_property`, `create_page`, `notify`, `slack`, `email`, `forge_issue` (issue Gitea/GitHub) et **`agent_trigger`** (lance un agent FlowDeck dans sa propre conversation journalisée). Bouton natif de base (`press_button()` sur une propriété `button`, `button_automation_id`). `fire_event()` est le point d'entrée des événements `page.*`, `collection.*`, `form.submitted`, `meeting.summarized`… ; scheduler cron 60 s mutualisé avec les workers. **Éditeur visuel v7.45** : pipeline de cartes ordonnées dans Settings → Automations (`/workspace/automations/{id}/steps`), config typée par kind, réordonnancement, « ✨ Convertir le JSON en pipeline ». + +### 19.2 Workers (`app/services/workers.py`, v7.0) + +Extraits **Python custom exécutés sur l'infrastructure FlowDeck** (parité Notion Workers). Exécution manuelle, **cron**, ou **partagée en équipe avec fork**. Sandbox best-effort single-process : lint **AST blacklist** (`import os/sys/subprocess/socket…`, `open/exec/eval/compile/__import__`, attributs dunder), pas de réseau ni filesystem, builtins restreints, API limitée à `log()`, `ctx`, `result` ; **timeout 30 s** et **budget journalier secondes/workspace** (`daily_budget_s`). Tables `workers`/`worker_runs`, routes `/api/v2/workers*`. + +### 19.3 Récurrence & rappels (`recurrence.py`, `reminders.py`, v5.8) + +- **Récurrence** : sous-ensemble RRULE (`daily/weekly/monthly`, interval, COUNT, UNTIL, BYDAY, fuseau `zoneinfo`) stocké dans `property_values_json` sous la clé `__recurrence__` ; **occurrences virtuelles calculées à la volée**, jamais persistées. +- **Rappels** : règle `__reminder__` (lead minutes/heures/jours par propriété date) ; `scan_and_fire()` (60 s) calcule la prochaine occurrence (timezone-aware), envoie notification in-app + email si opt-in ; dédupliqué via `reminder_log` par (page, occurrence) — **il n'existe pas de table `reminders`**, les dates vivent dans les propriétés `due`. + +### 19.4 Notifications (`app/services/notifications.py`, v4.9) + +Table `notifications` (mentions `@`, commentaires, changements de page, assignations `notify_assignment()`, `page.updated`, `calendar.conflict`…) ; préférences utilisateur + fuseau (`/api/notifications/prefs`) ; livraison email via `app/services/mailer.py` (SMTP optionnel) ; UI cloche `_notification_bell.html` + onglet Inbox de la sidebar. + +### 19.5 Webhooks sortants (`app/services/webhook_outbound.py`, v2.1 → prod v6.4) + +**~50 événements** au catalogue (pages, commentaires, collections, sprints, partages, `automation.fired/failed/retrying`, `agent.run.*` y compris `approval_requested`, workspaces, fichiers, imports, `ping`). Signature **HMAC-SHA256** (`X-FlowDeck-Signature`), **retry 2 s/10 s/60 s** par ledger `webhook_deliveries`, abonnements avec wildcards (`page.*`, `*`) dans `webhook_subscriptions`, CRUD `/api/v2/webhooks` + endpoint entrant de test. Gestion admin `app/routers/webhooks.py` (webhooks **entrants** Gitea : `POST /api/webhook/gitea` avec secret partagé). + + +--- + +## 20. Intégration Gitea / GitHub + +### 20.1 Flux de données Gitea ``` -┌──────────────────────────────────────────────────┐ -│ BLOCK TYPES │ -│ │ -│ Texte: │ -│ ├─ text (paragraph) │ -│ ├─ heading_1, heading_2, heading_3, heading_4 │ -│ ├─ bulleted_list, numbered_list │ -│ ├─ to_do (checkbox) │ -│ ├─ toggle (repliable) │ -│ └─ quote │ -│ │ -│ Média: │ -│ ├─ image (resizable, alignable) │ -│ ├─ video (embed) │ -│ ├─ file (attachment) │ -│ ├─ bookmark (link preview) │ -│ └─ code (syntax highlighting) │ -│ │ -│ Layout: │ -│ ├─ divider │ -│ ├─ callout (icône + fond coloré) │ -│ └─ column (2, 3, 4, 5 colonnes) │ -│ │ -│ Data: │ -│ ├─ inline_database (collection embarquée) │ -│ └─ linked_database (vue d'une collection) │ -└──────────────────────────────────────────────────┘ +Création Collection Sync bidirectionnelle +────────────────── ────────────────────── +1. Collection vide 5. POST /board/api/sync/{o}/{r} + (pas de lien Gitea) ├─ GET issues Gitea + ├─ Map issues → collection_pages +2. Collection liée │ (via _issue_column) + (gitea_owner + gitea_repo) ├─ Extract AI keywords + └─ Upsert cards en DB +3. Pull initial + GET /api/v1/repos/{o}/{r}/issues + 6. Webhook Gitea entrant +4. Mapping colonnes POST /api/webhook (secret partagé) + col_mapping: ├─ issue.opened → create page + column_name → gitea_label ├─ issue.closed → archive page + └─ issue.labeled → move column ``` -### 12.2 Format de stockage +- `app/services/gitea_client.py` : wrapper API REST (token admin `GITEA_TOKEN` ou token OAuth de l'utilisateur) ; +- Le dépôt est un **pseudo-workspace** (`gitea:/`) avec son navigateur `gitea_workspace.html`, ses pages privées miroir (`gitea_private_pages`) et son kanban legacy (`boards`/`cards`/`col_mapping`) ; +- L'agent peut lire/créer des issues (`read_gitea_issues`, `create_gitea_issue`, `sync_gitea`). -```json -{ - "blocks": [ - { - "id": "b1", - "type": "heading_2", - "content": "Architecture", - "children": [] - }, - { - "id": "b2", - "type": "text", - "content": "FlowDeck utilise FastAPI...", - "children": [] - }, - { - "id": "b3", - "type": "toggle", - "content": "Détails techniques", - "children": [ - { - "id": "b3a", - "type": "bulleted_list", - "content": "SQLite WAL mode", - "children": [] - }, - { - "id": "b3b", - "type": "bulleted_list", - "content": "HTMX + Alpine.js", - "children": [] +### 20.2 Adaptateur de compatibilité + +```python +class GiteaBoardCompat: + """Adaptateur : board Gitea legacy → Collection.""" + + @staticmethod + def from_board(board_row) -> dict: + return { + "collection_id": f"gitea:{board_row.id}", + "name": f"{board_row.project_owner}/{board_row.project_name}", + "gitea_owner": board_row.project_owner, + "gitea_repo": board_row.project_name, + "schema": [ + {"name": "Title", "type": "title"}, + {"name": "Status", "type": "select", + "options": json.loads(board_row.columns_json)}, + {"name": "Priority", "type": "select", + "options": ["P1", "P2", "P3", "P4"]}, + ], + "is_gitea_linked": True, + } + + @staticmethod + def from_collection_page(page_row) -> dict: + """Convertit collection_page → card (pour templates legacy).""" + return { + "id": str(page_row.get("gitea_issue_number", page_row["id"])), + "title": page_row["title"], + "status": _derive_status(page_row), + ... } - ] - }, - { - "id": "b4", - "type": "code", - "content": "print('Hello')", - "language": "python", - "children": [] - }, - { - "id": "b5", - "type": "callout", - "content": "Note importante", - "icon": "💡", - "color": "blue", - "children": [] - }, - { - "id": "b6", - "type": "divider", - "content": "", - "children": [] - } - ] -} ``` +(`app/services/collection_adapter.py`, `app/routers/board/import_.py` pour l'import de boards legacy.) + +### 20.3 GitHub + +Support forge secondaire : `app/services/github_adapter.py`, `app/routers/github_routes.py` (`/api/github`), OAuth de connexion (`GITHUB_OAUTH_CLIENT_ID/SECRET`), registre unifié `projects` (`proj_type` gitea|github|builtin, `app/services/projects.py` + cron de sync), outil agent `search_code` (API REST), `forge_issue` en action d'automatisation, import de dépôts (`importers/forge_repo.py`). + --- -## 13. Multi-utilisateurs & Collaboratif +## 21. Import & Export -### 13.1 Modèle de workspace +### 21.1 Import (`app/services/importers/`, `app/routers/imports.py`, `/api/import` + page `import.html`) -``` -┌─────────────────────────────────────────────────────────┐ -│ WORKSPACE "Foxy Dev Team" │ -│ │ -│ Members: │ -│ ├─ bruno (owner) — tout │ -│ ├─ marie (admin) — gérer membres, config │ -│ ├─ paul (editor) — CRUD pages │ -│ └─ julie (viewer) — lecture seule │ -│ │ -│ Collections: │ -│ ├─ Tasks ─┬─ permissions: workspace │ -│ │ └─ visibilité: tous les membres │ -│ ├─ Roadmap─ permissions: admin+editor │ -│ │ └─ visibilité: bruno, marie, paul │ -│ └─ HR ──── permissions: owner only │ -│ └─ visibilité: bruno uniquement │ -│ │ -│ Collaboration features: │ -│ ├─ Présence en temps réel (qui est en ligne) │ -│ ├─ Verrouillage optimiste (dernier qui sauve gagne) │ -│ ├─ Historique des modifications par page │ -│ ├─ Commentaires avec @mentions │ -│ └─ Notifications (changement de statut, assignation) │ -└─────────────────────────────────────────────────────────┘ -``` +Pipeline unifié en 6 phases (v5.6) : socle (`base.py`, `pipeline.py`, `jobs.py` avec `import_jobs`/`import_items` pour statut, rapport et **dédup idempotent** par `(workspace, source, external_id)` → re-import incrémental), puis formats : -### 13.2 Gestion des conflits +| Importer | Source | +|---|---| +| `notion.py` | export ZIP Notion (pages + databases) | +| `markdown.py`, `html_notes.py`, `standard_notes.py`, `apple_notes? (via html)` | notes et dossiers de fichiers | +| `obsidian.py`, `outline.py` | vaults Obsidian, espaces Outline | +| `docx.py`, `pdf.py` | documents bureautiques (python-docx, pypdf) | +| `tabular.py` | CSV/TSV/XLSX (openpyxl) → collections | +| `bookmarks.py`, `opml.py` | signets | +| `calendar.py` | ICS | +| `url_fetch.py` | import par URL (gardienné SSRF) | +| `forge.py`, `forge_repo.py` | données de forge (issues, dépôts) | -``` -Stratégie: "Last write wins" avec historique +### 21.2 Export (`app/services/export.py`, `/api/export`, v4.7) -1. Alice ouvre la page → charge version v=42 -2. Bob ouvre la page → charge version v=42 -3. Alice sauvegarde → v=43 (succès) -4. Bob sauvegarde → v=43 (succès, écrase Alice) - → L'historique conserve le snapshot v=42→v=43 d'Alice - -Alternative future: Operational Transformation (OT) -ou CRDT pour du vrai temps réel (complexité ++) -``` +Export **Markdown** (avec images), **PDF** (WeasyPrint + xhtml2pdf), **HTML** standalone, **site statique `.zip`** ; CSV des bases (sous-ensemble `SIMPLE_TYPES` des propriétés) ; export/import **skill portable** `flowdeck-skill` v1 (§16.3). --- -## 14. Déploiement +## 22. Frontend : rendu, CSP, PWA & Web Clipper -### 14.1 Docker Compose (production) +### 22.1 Gabarits (`app/templates/`) + +Répertoire **plat** — la distinction page/fragment est portée par la convention du préfixe `_` : + +- **`base.html`** (~3 000 l.) = shell de toutes les pages authentifiées : `` PWA/OG/CSS/JS vendors, `` (CSRF porté par htmx), **sidebar Notion à 4 onglets** (§15.5), topbar `{% block topbar %}{% include '_header.html' %}`, `#main-content` swappé par la navigation partielle, menu contextuel global, **palette Ctrl+K**, enregistrements `Alpine.data` obligatoires (build CSP), inclusion de `agent_panel.html` + service worker + `offline.js`. +- **`_header.html`** : topbar unifiée avec fil d'Ariane piloté par JSON embarqué (`#fd-breadcrumb-data`) + composant Alpine `fdBreadcrumb()`. +- Fragments : `_page_editor_content/scripts/realtime.html`, `_database_table(+_scripts).html`, `_ctx_menu.html`, `_icons.html`, `_icon_picker.html`, `_notification_bell.html`, `_workspace_tree_macro.html`. +- Pages : `dashboard`, `library`, `local_workspace`, `gitea_workspace`, `page_editor(_collection|_embed)`, `workspace(s)`, `notes`, `board`, `settings`, `help` (géométrie `settings-overlay` partagée avec Settings, v7.69.3), `trash`, `accounts`, `import`, `agent_message`. +- Autonomes (hors `base.html`) : `landing` (page de présentation pré-login), `welcome` (onboarding), `public_page` (rendu public thème sombre), `card(_detail)`, fragments SSR échangés par htmx (`board_fragment`, `detailed_board`, `table_view`, `status_overview`, `team_load`). + +### 22.2 Modules JS (`static/js/`) + +| Module | Rôle | +|---|---| +| `page_editor_scripts.js` (~3 500 l.) | cœur de l'éditeur : blocs, sérialisation `gtMd`, menu slash, toolbars, tableaux, colonnes, synced blocks, undo, autoSave, uploads, collage intelligent, presse-papiers multi-blocs, mentions `WM`, complétion IA `AIAC` | +| `page_editor_realtime.js` | client WebSocket présence/curseurs/ops (§13.4) | +| `database_table.js` | composant `DBInstance` : vues, cellules, filtres/tris, config persistée | +| `library.js`, `local_workspace.js`, `my_tasks.js` | pages §15 | +| `app.js` | **navigation partielle maison** : interception des liens « clic gauche simple », fetch + swap de `.main-wrapper` seul + `history.pushState` + remontée explicite d'Alpine (`Alpine.initTree`) ; `window.fdNavigate(url)` point d'entrée programmatif ; tooltips, helpers peek | +| `agent_panel_1/2.js` | panneau agent, Menu + hub (§16.4) | +| `meeting_block.js` | UI du bloc AI Meeting Notes | +| `settings.js`, `workspaces.js`, `board.js`, `gitea_workspace.js`, `import.js`, `help.js`, `task_db_link.js`, `offline.js`, `code-highlight.js`, `_ctx_menu.js`, `_icon_picker_*.js` | modules de page | +| vendors | `htmx.min.js` (2.0.4), **`alpine.csp.min.js`** (build officiel CSP 3.17.4), `sortable.min.js`, `katex`, `highlight(.extra)/prism`, `vendor/chart.umd.js`, `vendor/leaflet/` | + +Gardes d'idempotence (`window.__fdEditorScriptsLoaded`, v7.45.2) pour survivre aux re-jeux de scripts lors des swaps ; anti-FOUC au chargement (v7.45.3-4). **Aucun bundler** : pas de `package.json` racine ni d'esbuild — les libs sont vendorisées telles quelles (`scripts/vendor_hljs.py` pour highlight.js) ; cache-busting par `?v=VERSION`. + +### 22.3 PWA offline + +- `static/manifest.json` : `standalone`, icônes 72→512 régénérées depuis le logo (`scripts/generate_pwa_icons.py`), `lang: fr` ; +- `static/sw.js` : deux caches — statique `flowdeck-v*` (pré-cache CSS/JS/vendors/icônes, bump à chaque release) et données `flowdeck-data-v*` ; stratégies : **mutations → always network** (la file est côté client), HTML → network-first (timeout 4 s, repli cache puis **page `/offline.html` fabriquée**), GET API → network-first cache données, statique → cache-first ; Background Sync si disponible ; +- `static/js/offline.js` (`window.FlowOffline`) : IndexedDB `flowdeck-offline` (`pages_offline`, `collections_offline`, `sync_queue`, `sync_meta`) ; écritures locales optimistes hors ligne, **file de mutations rejouée sur reconnexion via `POST /api/v2/sync/batch`** (lots ≤ 100, rétention 30 j), pull via `GET /api/v2/sync/delta`, id d'appareil `fd_device_id`. + +### 22.4 Web Clipper (`extension/`, v6.2) + +Extension navigateur **Manifest V3** (« FlowDeck Web Clipper », Chrome/Edge/Firefox) : menus contextuels + **bouton flottant draggable** (sélection/capture), sélecteur Readability ; captures **article / sélection / bookmark / screenshot** envoyées à `POST /api/v2/web-clipper/clip` (Bearer token d'appareil `extension_devices`, device id `dev_…` persisté) ; popup de configuration serveur/token + vérification de connexion ; notification des onglets FlowDeck ouverts après un clip. Coupée par le plugin `web-clipper` (§4.2). + +--- + +## 23. Déploiement & exploitation + +### 23.1 Docker ```yaml +# docker-compose.yml — un seul service ; Gitea reste externe services: flowdeck: build: . container_name: flowdeck ports: - - "8080:8080" - environment: - - GITEA_URL=https://git.dracodev.net - - GITEA_TOKEN=${GITEA_TOKEN} - - GITEA_OAUTH_CLIENT_ID=${GITEA_OAUTH_CLIENT_ID} - - GITEA_OAUTH_CLIENT_SECRET=${GITEA_OAUTH_CLIENT_SECRET} - - APP_SECRET_KEY=${APP_SECRET_KEY} - - DATABASE_URL=sqlite:////data/flowdeck.db - - WEBHOOK_BASE_URL=${WEBHOOK_BASE_URL:-http://localhost:8080} + - "${APP_PORT:-8080}:8080" volumes: - flowdeck_data:/data + env_file: + - .env restart: unless-stopped - - # Optionnel: PostgreSQL pour le futur - # postgres: - # image: postgres:16-alpine - # environment: - # POSTGRES_USER: flowdeck - # POSTGRES_PASSWORD: ${DB_PASSWORD} - # POSTGRES_DB: flowdeck - # volumes: - # - pg_data:/var/lib/postgresql/data - volumes: flowdeck_data: - # pg_data: ``` -### 14.2 Migration SQLite → PostgreSQL +- `Dockerfile` : **2 stages Python** (`builder` : `pip wheel` → `/wheels` ; `runtime` : `python:3.13-slim` + libs système WeasyPrint pango/harfbuzz/gdk-pixbuf, fonts dejavu + noto-color-emoji, curl) ; **aucun stage node** (front non compilé) ; `EXPOSE 8080` ; `HEALTHCHECK` sur `/api/health` (30 s) ; CMD `uvicorn app.main:app --host 0.0.0.0 --port 8080 --proxy-headers --forwarded-allow-ips '*'`. +- Démarrage local : `cp .env.example .env` (token Gitea) puis `docker compose up -d` → http://localhost:8080 ; ou venv + `uvicorn app.main:app --reload --port 8082` (CONTRIBUTING). -```bash -# Étape 1: Exporter le schéma SQLite vers PostgreSQL -pgloader /data/flowdeck.db postgresql://flowdeck:***@localhost/flowdeck +### 23.2 Variables d'environnement (`app/config.py`, pydantic-settings, `extra="ignore"`) -# Étape 2: Changer la config -# DATABASE_URL=postgresql://flowdeck:***@localhost/flowdeck +| Groupe | Variables | +|---|---| +| App | `APP_SECRET_KEY`, `APP_HOST`, `APP_PORT`, `APP_BASE_URL`, `LOG_LEVEL`, `DEFAULT_LANG`, `FLOWDECK_DATA_DIR` | +| Forge/OAuth | `GITEA_URL`, `GITEA_TOKEN`, `GITEA_OAUTH_CLIENT_ID/SECRET`, `GITEA_WEBHOOK_SECRET`, `GITHUB_OAUTH_CLIENT_ID/SECRET`, `OAUTH_REDIRECT_URI`, `WEBHOOK_BASE_URL` | +| Base / sync | `DATABASE_URL` (sqlite uniquement), `SYNC_INTERVAL`, `GITEA_CACHE_TTL` | +| Rate/API | `RATE_LIMIT_ENABLED`, `RATE_LIMIT_REQUESTS`, `PUBLIC_API_INSECURE_OK`, `API_V2_RATE_LIMIT_PER_TOKEN` | +| Sauvegardes | `BACKUP_ENABLED`, `BACKUP_DIR`, `BACKUP_INTERVAL_HOURS`, `BACKUP_KEEP` | +| Fonds | `PROJECT_SYNC_ENABLED`, `PROJECT_SYNC_INTERVAL_HOURS`, `REMINDERS_ENABLED`, `REMINDER_SCAN_INTERVAL_SECONDS`, `WEBHOOK_RETRY_ENABLED`, `WEBHOOK_RETRY_INTERVAL_SECONDS` | +| SMTP | `SMTP_HOST/PORT/USER/PASSWORD/FROM/USE_TLS` | +| SSO | `SSO_PROVIDER`, `SSO_NAME`, `SSO_ENTITY_ID`, `SSO_SSO_URL`, `SSO_SLO_URL`, `SSO_X509_CERTIFICATE`, `SSO_ISSUER_URL`, `SSO_CLIENT_ID/SECRET`, `SSO_SCOPE`, `SSO_ATTRIBUTE_MAPPING`, `SSO_GROUPS_MAPPING`, `SSO_AUTO_PROVISION`, `SSO_ONLY`, `SSO_SIGN_REQUESTS`, `SSO_DEFAULT_WORKSPACE_ID` | +| Agent/IA | `AGENT_ENABLED`, `LLM_PROVIDER/MODEL/API_KEY/API_BASE`, `AGENT_MAX_ITERATIONS`, `AGENT_MAX_TOKENS_BUDGET`, `AGENT_RUN_TIMEOUT_SECONDS`, `AGENT_MEMORY_DEFAULT`, `GOOGLE_CLIENT_ID/SECRET`, `MS_CLIENT_ID/SECRET`, `WEB_SEARCH_PROVIDER`, `EXA_API_KEY`, `GITHUB_TOKEN`, `WEB_FETCH_MAX_CHARS`, `STT_COMMAND` (transcription réunions) | + +### 23.3 Données & sauvegardes + +Volume nommé `flowdeck_data` monté sur `/data` : base SQLite, uploads (dont `data/uploads/meetings/`), avatars, backups. **Service de backup interne** (`app/services/backup.py`, v5.2) : snapshot quotidien du fichier (checkpoint WAL avant copie), nommage `flowdeck-.db`, rétention `BACKUP_KEEP`. `docker-backups/` à la racine = copies hôtes manuelles de checkpoints avant opérations risquées (convention d'équipe, pas automatisée). -# Étape 3: Redémarrer -docker compose restart flowdeck -``` --- -## 15. Références +## 24. Outils de développement, tests & CI -- [Notion API — Property Object](https://developers.notion.com/reference/property-object) -- [Notion Help — Databases](https://www.notion.com/help/intro-to-databases) -- [Notion Help — Tasks & Dependencies](https://www.notion.com/help/tasks-and-dependencies) -- [Notion Help — Relations & Rollups](https://www.notion.com/help/relations-and-rollups) -- [FlowDeck — NOTION_DATABASE_TASKS_GUIDE.md](docs/NOTION_DATABASE_TASKS_GUIDE.md) -- [FlowDeck — ROADMAP.md](ROADMAP.md) +### 24.1 Environnement + +- **Python 3.13** aligné partout (Docker, `pyproject.toml` target, CI — audit A35, v7.35) ; dépendances verrouillées avec **uv** (`uv.lock`, `requirements*.txt`) ; +- Lint : **Ruff** (`pyproject.toml` : line-length 110, règles E/F/I/UP/B/W, isort first-party `app`/`tests`) + **black** ; **ESLint v9** flat config (`eslint.config.mjs`, 4 règles souples en warn sur `static/js`, ignores `*.min.js`/`vendor/`, via `npx --yes eslint static/js`) ; +- Conventions (`CONTRIBUTING.md`) : Conventional Commits ; **code et commentaires en anglais, docs en français**. + +### 24.2 Modèle de branches (`BRANCHING.md`) + +Git-flow simplifié : `main` (prod, tags `vX.Y.Z`, Docker auto sur :8080) ← `develop` (intégration) ← `feat/` / `fix/` ; **jamais de push direct** sur main/develop (PR only), CI verte avant merge, kebab-case, branche supprimée après merge ; hotfix depuis main puis resync `main → develop`. (Note : `CONTRIBUTING.md` dit encore `feature/xxx → main` — divergence à harmoniser.) + +### 24.3 Pyramide de tests + +| Niveau | Outil | Volume | +|---|---|---| +| Unitaires / intégration | **pytest** (+ pytest-xdist `-n auto`, pytest-asyncio) dans `tests/` — TestClient FastAPI sur SQLite temporaire, isolation verrouillée par `conftest.py` (audit A1) | **~1 394 tests verts** (v7.68) ; couverture des routers portée de 0 à l'identifiable par l'audit **A32** | +| E2E navigateur | **Playwright** (`e2e/`, `@playwright/test ^1.63`) ; specs nommées par version (`v768_columns_clipboard.spec.js`, `pwa_offline.spec.js`, `mobile_regression.spec.js`…) ; exigence de **vrais gestes** (Ctrl+C, drag) et **contrôles négatifs** (même spec contre ancien build) | fondations posées v7.36 | +| Audit fonctionnel | `scripts/audit_functional.py` — bout-en-bout sur base isolée ; `scripts/_routescan.py` — inventaire des routes (677 routes auditées en v7.46) | récurrent | +| Grille visuelle manuelle | `TESTING.md` (61 cas ; document daté v2.1, à rafraîchir) | — | + +### 24.4 CI (`/.gitea/workflows/ci.yml`) + +Gitea Actions, 3 jobs sur push + PR → main/develop : `lint` (ruff + eslint), `test` (pytest `-n auto` + coverage, deps système WeasyPrint), `docker` (build + smoke `from app.main import app`). + +### 24.5 Versionnage + +`scripts/bump_version.py` réécrit `version=` dans `app/main.py`, bump `VERSION` et crée le tag git. Le `CHANGELOG.md` est la source d'évolution de référence (les bannières README/WORKLOAD sont figées plus tôt — voir §26). --- -## 16. Versions à venir +## 25. Historique des versions v1.0 → v7.69.8 -### v4.1.0 — Content Blocks enrichis (priorité standard) +Version courante : **7.69.8** (`VERSION`). Bandes thématiques vérifiées contre les titres réels du `CHANGELOG.md` (⚠️ le CHANGELOG saute v2.8→v4.5, documentées uniquement dans `ROADMAP.md` ; plusieurs versions portent un titre « bande roadmap » décalé — ex. `## v5.11.7 — v5.8.0 Calendrier & Rappels` — et trois 7.46.0 distinctes coexistent). -- **Callout boxes** — Blocs d'alerte (info, warning, tip, danger) -- **Table of Contents** — Auto-généré depuis les headings -- **LaTeX / KaTeX** — Formules mathématiques -- **Toggle lists** — Listes pliables/dépliables -- **Multi-columns** — Mise en page 2-3 colonnes +### Socle v1.0 – v2.x (résumé, inchangé du doc v4.0) -### v4.2.0 — Export (priorité standard) +- **v1.0–v1.2** — clone de board Kanban Gitea (boards, cartes, colonnes, notes) ; UI Notion-style. +- **v1.3–v1.5** — Collections indépendantes, 21 types de propriétés, relations, rollups (12 agrégations), formules (19 fonctions), GiteaBoardCompat. +- **v1.6–v1.7** — vues multiples (Table/Board/Calendar/Gallery/List/Timeline/Status Overview/Team Load). +- **v1.8** — sub-items + dépendances Blocking/Blocked by. **v1.9** — My Tasks. +- **v2.0** — workspaces + rôles, commentaires, historique de page, favoris, templates, import/export CSV, partage public. **v2.1–v2.7** — polish, pages privées Gitea, workspaces multiples. -- Export **Markdown** avec images -- Export **PDF** (via WeasyPrint ou headless Chrome) -- Export **HTML** standalone (page auto-suffisante) +### v4.x — Plateforme comptes & partage -### v4.3.0 — Collaboration (priorité basse) +- **v4.0.0** — Accounts, Integrations & Sharing (MVP) : types de comptes, matrice d'intégrations, Library, share/publish, trash (§6, §15). +- **v4.0.1–v4.0.2** — Onboarding & polish ; qualité/robustesse. **v4.1–v4.2** — data sources & linked databases ; templates & dashboards. **v4.3** — Database Views complètes (10 types). **v4.4–v4.5** — tasks/sub-items/dependencies ; sprints & My Tasks. +- **v4.6–v4.9** — callouts, TOC, KaTeX self-hosté, multi-colonnes, toggles ; **export MD/PDF/HTML/site-zip** ; bloc tableau ; commentaires inline + @mentions + notifications (+ SMTP). +- **v4.10–v4.15** — **naissance de l'agent** : moteur ReAct natif (§16.2), config LLM dans l'UI, clés par utilisateur + multi-fournisseurs dynamiques, panneau « Notion AI » Ctrl+J, contexte universel, AI Meeting Note (ébauche), mentions @ et skills `/` dans le panneau. -- **Commentaires inline** sur les pages -- **@mentions** pour notifier des utilisateurs -- **Notifications email** (changements, mentions) -- **Permissions par workspace** (viewer, editor, admin) +### v5.x — Socle moderne -### v5.0.0 — Plateforme avancée (futur) +- **v5.0–v5.2** — palette Ctrl+K + **FTS5** ; **migrations versionnées** `app/migrations.py` ; collage intelligent, inline databases, templates de bases, validation de propriétés ; moteur **d'automatisations if-this-then-that** ; sessions révocables + tokens API ; backup interne. +- **v5.3–v5.9** — **temps réel WebSocket** (v5.3, ébauche LWW + polling) ; interactions de bloc (undo/redo, menu ⋮, drag multi-sélection) ; partage réorganisé ; **database avancée** (personnes, vues sauvegardées, swimlanes + WIP limits, calendar drag & drop, gallery) ; **calendrier & rappels** (récurrences RRULE, fuseaux) ; **import 6 phases** ; **AI Writing** dans l'éditeur. +- **v5.11–v5.15** — wiki-links `[[` + chips, mentions page/date ; templates et verrouillage de page, pleine largeur ; **synced blocks** ; webhooks v2. -- **AI Assistants** — Génération de contenu, résumés, suggestions -- **Embeds** — Vidéos, PDF, Figma, Google Docs -- **Automatisations** — Règles déclenchées sur événements (Notion-style) -- **Base de données avancée** — Relations inter-collections, rollups -- **Kanban flexible** — Colonnes custom, WIP limits -- **API publique REST v2** — `/api/v2` (v6.3.0) : Bearer + scopes `read/write/admin`, CRUD complet, pagination, RFC 7807, idempotence, audit, OpenAPI (`/docs`, `docs/openapi-v2.json`) ; `/api/v1` lecture seule (compat) -- **API agent publique** — `/api/v2/agents/*` + `/api/v2/skills/*` (v6.6.0, agent phase 5) : wrappers Bearer sur `AgentEngine` (run synchrone JSON, journal + rollback, trigger externe) et marketplace de skills (export/import portable, galerie de presets) — `app/routers/api_v2_agent.py`, `app/services/skill_gallery.py` -- **SSO / SAML + OIDC entreprise** — v6.7.0 (Enterprise Auth, dernière feature v6.0.0) : SP SAML (`python3-saml`) + OIDC PKCE (`authlib`), auto-provisioning + mapping groupes IdP → rôles workspace, mode « SSO only », onglet admin « SSO / Enterprise », migration 23 (`sso_config`, `sso_login_history`, `sso_requests`), `/help` section SSO — `app/routers/sso.py`, `app/services/sso_provisioning.py`, `app/auth/providers/{saml,oidc}_provider.py` -- **Volume Docker persistant** — `/data` monté pour survie des données -- **PostgreSQL** — Migration optionnelle pour scaling +### v6.x — Plateforme ouverte & entreprise + +- **v6.0–v6.2** — **PWA offline** (SW + IndexedDB + sync) ; **permissions granulaires** (ACL page/collection/propriété + groupes + audit) ; **Web Clipper MV3**. +- **v6.3–v6.6** — **API publique REST `/api/v2`** complète (Bearer + scopes, RFC 7807, idempotence, OpenAPI) ; **temps réel en production** (merge 3-voies) ; synced blocks en prod (page-ombre de ligne) ; **API agent publique** (run synchrone JSON, rollback, trigger externe) + **marketplace de skills**. +- **v6.7–v6.9** — **SSO entreprise SAML + OIDC** (provisioning, mapping groupes, SSO only) ; **Sites & Forms** publics ; **recherche hybride + Ask AI** (embeddings maison, RAG citations). + +### v7.x — Automatiser, gouverner, polir + +- **v7.0–v7.3** — **Automations v2** (steps chaînés) + **Workers** sandboxés ; **Calendar sync** Google/CalDAV + **AI Meeting Notes** complètes ; **enterprise admin** (SCIM 2.0, 2FA TOTP, passkeys WebAuthn, domain claims, audit unifié, **gouvernance agent**) ; **Wiki/Teamspaces** (badges vérifiés, follows, guests, réactions, analytics). +- **v7.3.1–v7.35** — **cycle d'audit** (43 items A1–A43, `ROADMAP.md`) : sécurité P0→P2 (401 partout, CSRF A19/A38, SSRF A12, autoescape A10, logs A36, CSP A20/A43), A21 SQLite hors event loop, A32 tests routers, A27 extraction JS (−85 %), A28 routers → packages, A35 Python 3.13, A42 httpx partagé. +- **v7.36–v7.50** — fondations **E2E Playwright** ; **A20 terminé : Alpine build CSP, `unsafe-eval` retiré** (v7.43) ; éditeur visuel d'automations (v7.45) ; palette avec onglet Réponses IA ; navigation partielle anti-FOUC ; **web tools de l'agent** + 11 skills (v7.46) ; **sidebar à onglets** Home/Chat/Meeting/Inbox ; **My Tasks 3 vues transverse** ; **side peek** plein écran. +- **v7.51–v7.58** — **Menu + de l'assistant** en 8 phases : hub de contexte, skills gérer/parcourir, canevas Design System, mémoire d'agent, connecteurs (socle → Google/M365 OAuth lecture seule → Discord/Telegram/Teams/**MCP**), plugins catalogue on/off à effet réel. +- **v7.59–v7.69.8** — éditeur **façon Notion** : mobile (drawer réglable, menus au `/`), bouton + d'insertion, menus contextuels de bloc et de sélection refondus, **commentaires ancrés sur la sélection de texte** (surlignage jaune, tiroir refondu), **réactions sur texte + tiroir**, **copier/couper/coller multi-blocs**, colonnes réellement rendues, tables GFM, identité visuelle (logo/bannière/icônes PWA/`og:image`), **Library** (onglet AI Meeting Notes groupé par date, Recents réels, Publish unifié, colonnes visited/source). + +--- + +## 26. Chantiers ouverts & suites + +Extraits de `ROADMAP.md` (audit du 2026-09-30) et `WORKLOAD.md` : + +- **Cycle v6 et cycle v7 (v6.8→v7.3) livrés intégralement** ; `WORKLOAD.md` : 52/52 features, tableau de bord **figé à v7.3.0** (les ~36 versions suivantes ne sont suivies que par le CHANGELOG). +- **Audit A1–A43 quasi clos**. Restes ouverts / décisions assumées : + - **A38 phase 3** : fusion des `workspace-tree.js` Library / local_workspace **reportée** (pas de couverture E2E suffisante pour risquer la refactorisation) ; + - **A39** : htmx **conservé** (décision explicite — pas de remplacement) ; + - migration complète des call sites SQLite sync → async (A21) encore partielle hors chemins chauds ; + - mono-processus assumé : rooms temps réel, challenges WebAuthn, store SSO env → multi-worker non supporté sans refonte. +- **Faiblesses documentaires connues** : bannières `README.md` (cite 7.3.9 / 764 tests) et `TESTING.md` (v2.1) à rafraîchir ; divergence BRANCHING ↔ CONTRIBUTING sur la cible de merge des features. +- **Pistes naturelles de la suite du produit** (non planifiées formellement) : serveur MCP officiel (`docs/FLOWDECK_MCP_SERVER_GUIDE.md`), export de masse enrichi, collaboration avancée (suggestions d'édition étendues), PostgreSQL si un jour un dual-engine est décidé (aujourd'hui **non supporté**, §5.1). + +--- + +## 27. Références + +| Document | Contenu | +|---|---| +| `docs/DATA_MODEL.md` | **modèle de données exhaustif** (105 tables, colonnes, FK, JSON, relations) | +| `docs/API_GUIDE_V6.md` + `docs/openapi-v2.json` + `/docs` | API publique v2 (guide + spécification OpenAPI vivante) | +| `docs/Guide_Complet_Notion_database.md`, `NOTION_DATABASE_TASKS_GUIDE.md` | systèmes de bases et de tâches | +| `docs/Guide_Complet_Notion_AI.md`, `Guide_Complet_Notion_Agent_2026.md`, `Flowdeck_Agent_integration.md` | benchmark IA, design agent | +| `docs/Guide_Complet_Notion_sharing_collaborartion.md` | partage & collaboration | +| `docs/V6_SSO_SAML_Enterprise_Auth.md`, `V6_Granular_Permissions.md`, `V6_PWA_Progressive_Web_App.md`, `V6_Web_Clipper.md` | features v6.x | +| `docs/V68_Sites_Forms.md`, `V69_Search_Ask_AI.md`, `V70_Automations_Workers.md`, `V71_Calendar_Meetings.md`, `V72_Enterprise_SCIM_2FA.md`, `V73_Wiki_Teamspaces_Polish.md`, `V74_Agent_Plus_Menu.md` | features v6.8–v7.4 | +| `docs/architecture-meeting-notion-flowdeck.md`, `docs/FLOWDECK_MCP_SERVER_GUIDE.md` | spec Meeting Notes ; conception serveur MCP (non implémentée) | +| `CHANGELOG.md` | journal détaillé v7.36→v7.69.8 (et au-delà, désordonné — voir §25) | +| `ROADMAP.md` | plans v2.8–v4.5 + **audit A1–A43** + suivi des cycles v6/v7 | +| `WORKLOAD.md`, `TESTING.md`, `CONTRIBUTING.md`, `BRANCHING.md` | pilotage, grille visuelle, conventions, branches | + +--- + +*Ce document décrit l'état du projet à la version 7.69.8 (2026-10-09). Les affirmations marquées « factuel » ou « ⚠️ » signalent des écarts connus entre le code et les documents de pilotage plus anciens.* diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aae79f..149d6de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,172 @@ # Changelog - FlowDeck -## v7.69.4 (2026-10-08) — Le close de Settings ferme vraiment +## v7.69.9 (2026-10-09) — /local-workspace : chemin du header + navigation dans les dossiers réparée + +### Fixed + +- **/local-workspace : le chemin du header suit la navigation** — le header + restait bloqué sur « Home / » quel que soit le dossier + affiché. La route rend désormais `breadcrumb_items` + (`_render_local_workspace`) au format des pages + (`_nav_breadcrumb`) : **Home / / / / + …**, chaque niveau cliquable (`/local-workspace?folder=`) et le + dernier niveau courant ; au-delà de 4 niveaux, collapse « … » identique + à l'éditeur de page. La navigation **sans rechargement** (clic sur un + dossier dans la vue, breadcrumb interne, retour/avant) recalcule le + chemin via le nouvel event `flowdeck:breadcrumb-changed`, écouté par + `fdBreadcrumb` (`_header.html`) — les renommages continuent de suivre + via `flowdeck:page-renamed`. +- **/local-workspace : clic sur un dossier du sidebar = tous les composants + affichés en même temps** — après une navigation partielle + (`/local-workspace` → `/local-workspace?folder=`), Alpine échouait + à remonter la page : `Alpine.data('wsInitData')` retournait **le même + objet singleton** et Alpine redéfinit ses magics (`$nextTick`…) avec + `Object.defineProperty` non-configurable → + « Cannot redefine property: $nextTick », `initTree` abandonnait et + **tout le contenu restait au state brut** (tree/list/détails/cartes, + dropdowns, toast Undo, barre d'upload… visibles simultanément). + La factory retourne désormais une enveloppe fraîche à chaque montage, + qui délègue lecture/écriture au proxy réactif partagé (état et + `wsPreview` inchangés). +- **/local-workspace : le dossier de l'URL était perdu en navigation + partielle** — `LW` (config `#lw-config`) n'était lu qu'à la première + exécution du script (verrouillé par `__fdLocalWorkspaceScriptsLoaded`) : + le 2e montage gardait le `folder_id`/`workspace_id` du premier + chargement. La config est relue à chaque exécution et l'état transitoire + (recherche, filtre, panneaux) est remis à zéro au montage. +- **/local-workspace : breadcrumb interne toujours rendu** — il n'apparaît + que si la page était chargée avec `?folder=` ; il est désormais présent + dès la racine et suit la pile de dossiers. + +### Tests + +- `e2e/regression_local_workspace_breadcrumb.spec.js` : parcours complet + (sidebar → dossiers/sous-dossiers, clic dans la vue, breadcrumb + interne, crumb workspace du header) avec vérification du chemin header + et absence de composants peints à l'état brut. + +## v7.69.8 (2026-10-09) — Library : ordre des colonnes visited/source + +### Fixed + +- **Combinaison « Last visited time » + Source illisible** — les cellules + de données rendaient Source avant Edited/Visited alors que l'en-tête + l'attend en dernier : les valeurs tombaient sous les mauvais intitulés. + L'ordre des cellules suit désormais l'en-tête (Name, Created by, Last + edited by, Last edited time, Last visited time, Source), et les intitulés + d'en-tête tronquent en ellipsis au lieu de déborder sur la colonne + suivante (reproduit et mesuré au navigateur : alignement header/lignes + vérifié colonne par colonne). + +### Fixed + +- **Sidebar Favorites : icônes Workspace** — la liste utilisait des emoji + bruts (voire rien) via `item.icon` ; `_build_item` renvoie désormais les + noms SVG (`folder`/`file`/`edit`/`image`), idem pour les favoris board. +- **/local-workspace : indicateurs de statut** — ★ favori (ambre), 👥 + partagé (existant), 🌐 publié ; exposés par `_build_tree_children` + (`favorited`, `published`, `is_shared`) et rendus dans l'arbre Alpine + comme dans le rendu JS des enfants. +- **Library « Created by » vide** — aucune source auteur n'était + sélectionnée. Nouvelle provenance groupée : created by (première + version, sinon propriétaire du workspace), last edited by (dernière + version), last visited (table `recents`, tracée à chaque lecture de + page). Nouvelles colonnes **Last edited by**, **Last edited time**, + **Last visited time** ; le menu **Show columns** pilote chaque colonne, + seul Page name est verrouillé. +- **Colonne Source avec icône** — `file` (local), `link` (Gitea), + `external-link` (GitHub), mêmes SVG que le reste de Flowdeck. +- **Show columns réellement fonctionnel** — les règles de visibilité + (spécificité 0,1,0) perdaient contre `.lib-table-header .th` et + `.lib-row .row-cell` (0,2,0) : seule Last edited time s'affichait et + masquait, les autres options semblaient mortes (reproduit et corrigé + avec vérification en direct : les 5 toggles on/off + colonne Source + avec icône + libellé, 0 erreur console). + +### Tests + +- `tests/test_v696_sidebar_library_publish.py` (+3) : provenance et visite + tracée, icônes favoris, flags de l'arbre local-workspace. + +## v7.69.6 (2026-10-09) — Library AI Meeting Notes, Recents réels, Publish unifié + +### Added + +- **Onglet Library « AI Meeting Notes »** (`GET /api/library/meetings`) : + notes de réunion de l'utilisateur regroupées par date — Today, + Yesterday, This week, Last week, This month, Older — avec icône + calendrier, statut (Summary ready / Ready to record) en colonne Source, + recherche et sélection réutilisées. Les notes sont exclues des onglets + Recents / Workspace / Private (ainsi que de l'arbre Workspace et du + navigateur local). + +### Fixed + +- **Sidebar Recents vide** — elle ne listait que les dépôts Gitea. Elle + liste désormais les fichiers réellement récents (`updated_at`, 8 + derniers, dépôts conservés ensuite), avec les mêmes icônes SVG que le + Workspace et les notes de réunion exclues (→ Meetings). +- **Customize sidebar inutilisable** — l'overlay plein écran (z-index 998) + recouvrait aussi le panneau d'options : chaque clic refermait le mode. + Le panneau passe au-dessus (z-index 999). +- **Publish qui ne « tenait » pas** — le share dialog écrivait `published`, + les routes `/publish` écrivaient `is_published`, et chaque lecteur ne + regardait qu'une colonne. Toutes les écritures maintiennent désormais + les deux drapeaux (+ slug garanti), tous les lecteurs acceptent l'un ou + l'autre : la page publiée reste publiée dans le dialogue, apparaît dans + la section Published de la sidebar et dans l'onglet Published de la + Library. + +### Tests + +- `tests/test_v696_sidebar_library_publish.py` (6) : onglet meetings et + exclusions, Recents sidebar, publish/unpublish des deux chemins, + visibilité sidebar+library+dialogue, z-index du panneau customize. + +## v7.69.5 (2026-10-09) — Meeting Notes : setup visible, retry qui ne perd plus rien + +### Fixed + +- **Pas de « Setup check » visible avant l'enregistrement** — il était caché + dans le menu ▾ du bouton Start, que personne n'ouvre. Le panneau idle + affiche désormais une bande de vérification : état de la permission micro + (via Permissions API), transcription live dispo ou repli manuel, mode + onglet supporté ou non, et bouton **Test microphone** (4 s de niveau réel, + qui fait aussi accorder l'autorisation avant le Start). +- **Erreur 400 persistante au Stop/Retry après un collage de transcript** — + `retry()`/`regenerate()` appelaient le serveur sans y verser d'abord le + contenu local (segments + manuel collé) : le serveur restait vide et + répondait 400 en boucle. Ils drainent désormais le tampon local (attente + bornée) avant tout appel, et `retry()` détecte une session encore en vie + côté serveur pour terminer par `/stop` plutôt que `/processing:retry`. +- **Stop appelé alors que l'upload des segments échoue** — condamnait à un + 400 (« transcript is empty ») malgré des segments en local. Le client + n'appelle plus `/stop` dans ce cas : état `failed` avec message « envoi + impossible, Retry », session serveur conservée. +- **`/summarize` accepte `allow_empty`** (diagnostic `fragmentary` honnête + au lieu de 400), comme `/stop` et `/processing:retry`. +- **Cache-busting** : le bump 7.69.4 → 7.69.5 change `?v=` de tous les + assets — les navigateurs ayant gardé l'ancien `meeting_block.js` (sans + garde-fou ni diagnostic) reçoivent le nouveau code. C'était la cause la + plus probable du « toujours l'erreur 400 ». +- **Menu contextuel des sections Meetings et Workspace** : les notes de la + section Meetings ont désormais le même clic droit que le Workspace + (Open / Rename / Delete, via `showWsContext`) ; l'action **Open** est + ajoutée au menu des deux sections (page → `/pages/{id}`, dossier → + navigation dossier) ; rename/delete rafraîchissent la section Meetings. +- **Sidebar sur /local-workspace** : la règle CSS globale `.ws-tree-item` + de la page écrasait les items de la sidebar (espacements différents) — + scopée à `.ws-tree` (arbre principal) ; et `refreshSidebarTree()` + remonte désormais le fragment via `Alpine.initTree`, sans quoi l'arbre + perdait menu contextuel, toggles et drag&drop après chaque refresh + (d'où le menu « mort » sur cette page qui rafraîchit en continu). + +### Tests + +- `tests/test_v711_meeting_notion.py` (+5) : `summarize` vide → 400 sans + flag / diagnostic avec flag, retry intelligent quand la session serveur + est encore live, `meeting_notes` + propriété, exclusion de l'arbre + Workspace. ### Fixed diff --git a/VERSION b/VERSION index 39e69d2..c28b81d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.69.4 +7.69.9 diff --git a/app/main.py b/app/main.py index a011390..17dd595 100644 --- a/app/main.py +++ b/app/main.py @@ -186,7 +186,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.69.4", + version="7.69.9", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/app/middleware/security.py b/app/middleware/security.py index 499f105..47b3098 100644 --- a/app/middleware/security.py +++ b/app/middleware/security.py @@ -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 diff --git a/app/migrations.py b/app/migrations.py index 6d9361a..8f81530 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -1725,3 +1725,80 @@ def _migration_mistral_default_model(conn: sqlite3.Connection) -> None: "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 <> ''" + ) diff --git a/app/routers/board/_common.py b/app/routers/board/_common.py index 9e3d26c..9b3b6ec 100644 --- a/app/routers/board/_common.py +++ b/app/routers/board/_common.py @@ -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() @@ -521,17 +528,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, diff --git a/app/routers/board/sharing.py b/app/routers/board/sharing.py index 269066e..369bcfd 100644 --- a/app/routers/board/sharing.py +++ b/app/routers/board/sharing.py @@ -95,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} diff --git a/app/routers/dashboard/_common.py b/app/routers/dashboard/_common.py index 907989d..cb3b3d0 100644 --- a/app/routers/dashboard/_common.py +++ b/app/routers/dashboard/_common.py @@ -90,6 +90,50 @@ def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list +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) -> dict: user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) ws = user.get("login", "Bruno") if user else "Bruno" @@ -108,7 +152,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({ @@ -359,21 +403,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() @@ -441,6 +490,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 @@ -671,12 +721,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"
{lang}
" if lang else "" html_parts.append( f'
'
                 f'{lang_label}'
-                f'{c}
' + f'{c}' ) elif t == "divider": html_parts.append('
') diff --git a/app/routers/dashboard/local_workspace.py b/app/routers/dashboard/local_workspace.py index 5c82908..4c1a6a1 100644 --- a/app/routers/dashboard/local_workspace.py +++ b/app/routers/dashboard/local_workspace.py @@ -100,6 +100,28 @@ def _render_local_workspace(env, sidebar: dict, user: dict, ws_row: dict, with get_conn() as conn: breadcrumb = _build_breadcrumb(conn, folder) + # En-tête Notion-style (même mécanique que les pages) : + # Home / / / / … + # Chaque niveau cliquable renvoie à /local-workspace?folder= — sans + # ça le header restait bloqué sur « Home / » 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, @@ -111,6 +133,7 @@ def _render_local_workspace(env, sidebar: dict, user: dict, ws_row: dict, "nav_workspace_id": ws_id or 0, "breadcrumb": breadcrumb, "breadcrumbs": breadcrumb, + "breadcrumb_items": nav_crumbs, } template = env.get_template("local_workspace.html") return HTMLResponse( diff --git a/app/routers/dashboard/pages_html.py b/app/routers/dashboard/pages_html.py index 3215b2d..8997c8f 100644 --- a/app/routers/dashboard/pages_html.py +++ b/app/routers/dashboard/pages_html.py @@ -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, diff --git a/app/routers/library.py b/app/routers/library.py index ddd9a36..332801e 100644 --- a/app/routers/library.py +++ b/app/routers/library.py @@ -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: diff --git a/app/routers/meetings.py b/app/routers/meetings.py index 551bd73..54ecb56 100644 --- a/app/routers/meetings.py +++ b/app/routers/meetings.py @@ -185,6 +185,41 @@ def upcoming_meetings(request: Request, days: int = 30): # ── 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) @@ -254,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} diff --git a/app/services/export.py b/app/services/export.py index 21135de..77d3985 100644 --- a/app/services/export.py +++ b/app/services/export.py @@ -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("
") elif t == "code": lang = b.get("language") or "" + hljs_lang = normalize_code_lang(lang) label = f'
{_text(lang)}
' if lang else "" - parts.append(f"
{label}{c}
") + parts.append( + f'
{label}{c}
' + ) elif t == "math": parts.append(f'
\\[{c}\\]
') elif t == "table_of_contents": diff --git a/app/services/importers/forge_repo.py b/app/services/importers/forge_repo.py index 7514570..f5315e6 100644 --- a/app/services/importers/forge_repo.py +++ b/app/services/importers/forge_repo.py @@ -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 diff --git a/app/services/meetings.py b/app/services/meetings.py index 9d7fe89..a934182 100644 --- a/app/services/meetings.py +++ b/app/services/meetings.py @@ -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} diff --git a/app/services/publish.py b/app/services/publish.py index 4c42cd7..64177f7 100644 --- a/app/services/publish.py +++ b/app/services/publish.py @@ -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() diff --git a/app/templates/_header.html b/app/templates/_header.html index 6cf325f..6226b4c 100644 --- a/app/templates/_header.html +++ b/app/templates/_header.html @@ -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() { diff --git a/app/templates/_page_editor_scripts.html b/app/templates/_page_editor_scripts.html index 63d2cdf..3a53bc1 100644 --- a/app/templates/_page_editor_scripts.html +++ b/app/templates/_page_editor_scripts.html @@ -1,4 +1,10 @@ + + + + + + diff --git a/app/templates/base.html b/app/templates/base.html index 2b90b9e..380b24b 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -361,13 +361,28 @@ @@ -662,6 +677,10 @@
+ +
+
+
@@ -671,15 +690,18 @@