From 0bc74ad7281c55e558f7d78513386d8052fecd28 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Fri, 2 Oct 2026 08:27:57 -0400 Subject: [PATCH] =?UTF-8?q?refactor:=20A28=20TERMIN=C3=89=20=E2=80=94=20bo?= =?UTF-8?q?ard.py=20(2=20101=20L)=20=E2=86=92=20package=2014=20fichiers=20?= =?UTF-8?q?(v7.32.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lot 4/4 de l'A28 (god files) : l'ancien app/routers/board.py (2 101 lignes, 53 routes) devient le package `app/routers/board/` : - 12 modules de routes : pages 271 L (7 r.), page_api 229 (5), board_views 223 (8), page_ops 176 (3), sharing 175 (10), synced 144 (8), page_media 122 (4), import_ 85 (2), wiki 76 (2), library 66 (1), embed 64 (2), sync 51 (1) - _common.py (878 L) : 23 helpers dont 4 async + les 4 constantes (STATUS_COLORS, STATUS_LABELS, AI_KEYWORD_COLORS, _REPO_REF_RE) - __init__.py : __all__ complet — importateurs inchangés (api.py ×4 top-level, webhooks top-level, dashboard ×5 lazy, tests ×4) Preuve contractuelle : docs/openapi-v2.json régénéré = IDENTIQUE byte-à-byte (509 chemins, ordre préservé). Pièges rattrapés : - constantes d'état oubliées dans _common à la 1ʳᵉ passe (F821 + ImportError au chargement) → ré-insérées avec les valeurs exactes - docstring du header copié → F404 → slice [1:21] - helpers `async def` non détectés par `def ` seul A28 TERMINÉ en 4 lots : api_v2 (7.29.0), dashboard (7.30.0), collections (7.31.0), board (7.32.0) — 0 changement d'URL sur les 4. suite **1091/1091** · ruff OK · OpenAPI 509 identique · docs à jour --- CHANGELOG.md | 32 + ROADMAP.md | 4 +- VERSION | 2 +- WORKLOAD.md | 2 +- app/main.py | 2 +- app/routers/board.py | 2101 ------------------------------ app/routers/board/__init__.py | 108 ++ app/routers/board/_common.py | 878 +++++++++++++ app/routers/board/board_views.py | 223 ++++ app/routers/board/embed.py | 64 + app/routers/board/import_.py | 85 ++ app/routers/board/library.py | 66 + app/routers/board/page_api.py | 229 ++++ app/routers/board/page_media.py | 122 ++ app/routers/board/page_ops.py | 176 +++ app/routers/board/pages.py | 271 ++++ app/routers/board/sharing.py | 175 +++ app/routers/board/sync.py | 51 + app/routers/board/synced.py | 144 ++ app/routers/board/wiki.py | 76 ++ docs/openapi-v2.json | 2 +- 21 files changed, 2706 insertions(+), 2107 deletions(-) delete mode 100644 app/routers/board.py create mode 100644 app/routers/board/__init__.py create mode 100644 app/routers/board/_common.py create mode 100644 app/routers/board/board_views.py create mode 100644 app/routers/board/embed.py create mode 100644 app/routers/board/import_.py create mode 100644 app/routers/board/library.py create mode 100644 app/routers/board/page_api.py create mode 100644 app/routers/board/page_media.py create mode 100644 app/routers/board/page_ops.py create mode 100644 app/routers/board/pages.py create mode 100644 app/routers/board/sharing.py create mode 100644 app/routers/board/sync.py create mode 100644 app/routers/board/synced.py create mode 100644 app/routers/board/wiki.py diff --git a/CHANGELOG.md b/CHANGELOG.md index df38eb4..c4e4d05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog - FlowDeck +## v7.32.0 (2026-10-01) — Audit : A28 TERMINÉ (board, dernier lot) + +### Changed + +- **A28 lot 4** : `app/routers/board.py` (**2 101 lignes, 53 routes**) + devient le package `app/routers/board/` : + · 12 modules : `pages` 271 L (7), `page_api` 229 (5), `board_views` 223 (8), + `page_ops` 176 (3), `sharing` 175 (10), `synced` 144 (8), + `page_media` 122 (4), `import_` 85 (2), `wiki` 76 (2), `library` 66 (1), + `embed` 64 (2), `sync` 51 (1) + · `_common.py` (878 L) : les **23 helpers dont 4 async** + les 4 + constantes (STATUS_COLORS, STATUS_LABELS, AI_KEYWORD_COLORS, + _REPO_REF_RE) + · `__init__.py` : `__all__` complet — importateurs **inchangés** + (api.py ×4 top-level, webhooks top-level, dashboard ×5 lazy, tests ×4) +- Preuve contractuelle : **`docs/openapi-v2.json` régénéré = identique + byte-à-byte** + +### Fixed + +- Constantes d'état oubliées dans `_common` à la 1ʳᵉ passe (F821 + + ImportError au chargement) → ré-insérées avec leurs valeurs exactes + (git show) +- Docstring du header copié → F404 (`from __future__` après un statement) + → slice `[1:21]` ; helpers **async** non détectés par `def ` seul + +### Notes + +- **A28 TERMINÉ en 4 lots** : api_v2 (7.29.0), dashboard (7.30.0), + collections (7.31.0), board (7.32.0) — **0 changement d'URL** sur les 4 +- Suite complète : **1091/1091** · ruff OK + ## v7.31.0 (2026-10-01) — Audit : A28 lot 3 (collections → package 13 fichiers) ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index 7939f15..9a26bce 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1148,7 +1148,7 @@ Quality DB views, Agent IA Palette → Realtime + E - [x] **A26 — Config piège** : (a) `FLOWDECK_STANDALONE` documenté dans `config.py:26` mais **jamais lu** (le champ réel est `STANDALONE`, pas de `env_prefix`, `extra="ignore"` avale la coquille) ; (b) `.env.example` documente `postgresql://…` mais `db_path` (`config.py:133`) retombe silencieusement sur SQLite ; (c) `db_path` fait `Path("/" + p)` (`config.py:132`) → pour `sqlite:////data/flowdeck.db` le résultat est le chemin UNC `\\data\flowdeck.db` (vérifié : inexistant) ; (d) `app_secret_key="change-me-to-random"` (`config.py:36`) + `gitea_oauth_client_secret="test-secret"` sans aucun garde au boot alors qu'il signe `flowdeck_session` (`session.py:14`). *Fix : 1 normalisation de `db_path` + `raise` au boot si secret par défaut + corriger le commentaire/`env_prefix`. Effort : **S**.* - [x] **A27 — 13 900 lignes de JS inline — TERMINÉ 2026-10-01 (extraction 11 874 L / -85 % + eslint 0/0) — reste : structurel seul (base 1 523 L)**, ~3 800 livrées sur **chaque** page (`base.html` 1520 + `agent_panel` 1805 + `_icon_picker` 297 + `_header` 124 + `_notification_bell` 69), et **0 linté** : `eslint.config.mjs:50` ne couvre que `static/js/**/*.js` (soit `app.js` + `offline.js`), 2 blocs se neutralisent avec `/* eslint-disable */`. Grosseurs : `_page_editor_scripts` 2517, `local_workspace` 2030, `agent_panel` 1805, `base` 1520, `_database_table_scripts` 1323, `settings` 1093, `library` 1039. *Fix : extraire les gros partials vers `/static/js/*.js` (ils ne sont pas Jinja-interpolés) + ajouter les templates à eslint.* — **phase 1 faite 2026-10-01** : **7 templates sans Jinja → 9 fichiers `static/js/*.js` (4 243 lignes, -30 % du JS inline)** : agent_panel_1/2 (4 243… dont le 1 788 L livré sur CHAQUE page), library 1039, gitea_workspace 626, _icon_picker_1/2, _ctx_menu, import, workspaces — **un fichier par bloc** (ordre/timing identique, pas de defer, attributs conservés, `?v={{ asset_version }}`), `node --check` vert sur les 9, 0 script inline restant dans les cibles, suite 1089/1089. **Lint** : eslint **installé globalement** (`npm i -g eslint`, la config était déjà flat v9 sans dépendances) → `eslint static/js` = **0 erreur / 120 warnings** (no-unused-vars 69, no-empty 36, no-undef 15 — baseline à nettoyer opportunistiquement). **Phase 2a faite 2026-10-01 (l'éditeur, le plus gros bloc interpolé)** : `_page_editor_scripts` **2 516 L extraites** vers `static/js/page_editor_scripts.js` — recette « config JSON » : les 8 interpolations Jinja lisent `PD` = `JSON.parse(#page-data)` (le bloc JSON **existait déjà** juste avant le script) ; la route enrichit `page_data` de `updated_at`, `created_at`, `user_id`, `is_shared` (**dérivé hoisté : une seule expression pour le ctx ET le JSON**) et `clip_icon` (macro `fd_icon` rendue côté serveur) ; `workspace_key` reste vide comme avant (jamais défini dans ce ctx — parité). **Cumul A27 : 6 759 L extraites** (13 904 → 7 145 inline). **8 tests adaptés** (ils lisaient le template source → lisent maintenant `static/js/page_editor_scripts.js` ; `pageIsShared:true` → parsing du JSON `#page-data` → `is_shared is True`). **Phase 2b faite 2026-10-01** : **+3 801 L** extraites avec la même recette config JSON : `local_workspace` (2031 — `lw-config` : current_folder_id/workspace_id), `settings` (1093 — `st-config` : avatar/user/is_admin/auth_method, **2 routes rendent ce template**, expressions `or ""` préservées pour Undefined), `_page_editor_realtime` (531 — `rt-config` : SELF id/login/full_name/color), `board` (146 — `bd-config` : owner/repo/initial_view). BONUS sécurité : les valeurs passent par `|tojson` (échappement JSON) au lieu d'être interpolées dans des strings JS. **Cumul A27 : 10 560 L extraites** (13 904 → **3 344 restantes**), `node --check` vert ×4, suite 1089/1089, eslint **0 erreur / 279 warnings** (12 fichiers). **Phase 2c faite 2026-10-01 (BILAN : extraction TERMINÉE)** : `database_table` **1 314 L extraites** — le Jinja du bloc était confiné à la construction de l'objet de config (4 clés + `{% if collection_data %}`) → config JSON `null`-vs-objet (`#db-config`), le JS appelle `new DBInstance(container, PAGE_COLLECTION_ID, DB_CONFIG)` : les 2 branches Jinja disparaissent ; 2 tests adaptés (source → `database_table.js`) + `FlowDeckDB` dans src. **BILAN A27 : 11 874 L extraites en 4 phases** (4 243 + 2 516 + 3 801 + 1 314), **inline 13 904 → 2 022 L (-85 %)**, 22 fichiers `static/js/*.js`, `node --check` vert partout, suite 1089/1089. **Lint A27 TERMINÉ 2026-10-01 : `eslint static/js` = 0 erreur / 0 warning** (285 → 0, 22 fichiers). 3 familles traitées : (1) **no-empty ×70** = tous des `catch (x) {}` vides → `catch { /* volontaire */ }` (binding optionnel ES2019 + commentaire : passe no-empty ET no-unused-vars, zéro changement de comportement) ; (2) **no-unused-vars ×171** = bindings de catch retirés + 24 lignes mortes déterministes (suppressions avec assert sur le texte exact : `var self = this` ×8, compteurs jamais lus `restored++`/`resolved++`/`acc`/`today`, `uid()`/`propName()` sans 1 appel, etc.) + `/* exported */` sur les **10 fonctions appelées depuis les attributs HTML** (vérifiées par grep, 1 template chacune) ; (3) **no-undef ×44** = globaux réels déclarés dans `eslint.config.mjs` (`getSvgIcon` = script inline de `base.html`, `TextDecoder` = API navigateur, `Prism` = CDN) + **2 vrais correctifs** : `settings.js` utilisait `typeof toast === 'function'` (guard toujours faux → les toasts timezone/SAML ne s'affichaient JAMAIS) → `window.showToast`, et `_wsInitData = window._wsInitData` (auto-affectation sans effet, global implicite) supprimé. **Reste A27** : `base` 1 523 L structurel (inline par nature, décision assumée) + ~500 L de petits blocs hors cibles. Effort : **L** (fait). -- [ ] **A28 — Dette de découpe (god files)** : `api_v2.py` 115 routes / 131 Ko, `dashboard.py` 63 / 116 Ko (27 pages HTMLResponse + 50 JSON + I/O fichiers, 16 `Environment(...)` locaux), `collections.py` 53 / 112 Ko, `board.py` 53 / 93 Ko (page CRUD + `zipfile` + sync Gitea). *Fix : scinder par **concern** (`pages_html`, `files`, sous-modules `api_v2/*`) — mécanique, 0 changement d'URL. Effort : **L**.* **Lot 1 fait 2026-10-01 (api_v2)** : le module `api_v2.py` (2 110 lignes, 115 routes) devient le **package `app/routers/api_v2/`** = 12 modules par concern (identity 7, workspaces 9, collections 23, properties 7, views 8, engagement 21, sharing 8, planning 7, templates_io 9, projects 4, admin 4, webhooks 8) + `_common.py` (`_hash`, `_v2_rate_check`) + `__init__.py` (aggrégat `APIRouter(prefix="/api/v2")` + `include_router` sans prefix). Preuve contractuelle : **`docs/openapi-v2.json` régénéré = IDENTIQUE byte-à-byte** (0 changement de chemin/tag/operation_id), seul importateur = `main.py` (`from app.routers.api_v2 import router` → le package l'expose) ; en-tête d'imports copié puis émondé par `ruff --fix`. **Lot 2 fait 2026-10-01 (dashboard)** : `dashboard.py` (2 735 lignes, 63 routes) → **package `app/routers/dashboard/`** = 8 modules par concern (pages_html 488 L/6 r., local_workspace 551/15, settings→`account_settings` 442/16, workspace 323/9, pages_api 243/6, workspaces 135/6, account_api 82/4, public 82/1) + `_common.py` (les **15 helpers intercalés** + état `logger`/`_VERSION`/`WORKSPACE_COOKIE`) + `__init__.py` (re-export complet : 7 importateurs — main, board ×3, my_tasks, web_clipper, wiki, sites `_dash._render_blocks_public`, tests). Pièges rencontrés : segment décorateur sans le `def` (corps perdus, assert `def in seg` ajouté) ; collision `settings` (section vs `from app.config import settings` dans le header → **`hasattr` du fromlist** : la section renommée `account_settings`) ; `WORKSPACE_COOKIE` utilisé sans import dans `workspaces.py` (F821 → ajout automatique) ; script `__all__` qui mangeait la queue du fichier (réécrit à la main). Preuve contractuelle : **`docs/openapi-v2.json` IDENTIQUE byte-à-byte** (ordre d'enregistrement préservé). **Lot 3 fait 2026-10-01 (collections)** : `collections.py` (2 622 lignes, 53 endpoints / 52 fonctions — 1 paire de décorateurs empilés) → **package `app/routers/collections/`** = 10 modules par concern (crud 337, properties 322, linked 286, structure 267, dashboard_views 214, meta 197, views 187, pages 184, data_api 122, boards 61) + `_common.py` (8 helpers auth/permissions/validation, 220 L) + `_renderers.py` (**15 rendus de vues** + `CHART_MAX_GROUPS`, 667 L) + `__init__.py` (ré-exports : `_validate_page_properties` pour automations, 4 helpers de graphes pour les tests). Pièges : docstring d'origine dans le header → **F404 `from __future__` après un statement** (slice `[1:30]`), décorateurs empilés (`view_collection` ×2 → segments sans `def` → skip du 2ᵉ décorateur), `CHART_MAX_GROUPS` hors détection des helpers (F821 → import auto), test CDN lisant `collections.py` (balayage du package). Preuve : **`docs/openapi-v2.json` IDENTIQUE byte-à-byte**. **Reste A28** : `board.py` 2 101 L → suite 1091/1091, version 7.31.0. +- [ ] **A28 — Dette de découpe (god files)** : `api_v2.py` 115 routes / 131 Ko, `dashboard.py` 63 / 116 Ko (27 pages HTMLResponse + 50 JSON + I/O fichiers, 16 `Environment(...)` locaux), `collections.py` 53 / 112 Ko, `board.py` 53 / 93 Ko (page CRUD + `zipfile` + sync Gitea). *Fix : scinder par **concern** (`pages_html`, `files`, sous-modules `api_v2/*`) — mécanique, 0 changement d'URL. Effort : **L**.* **Lot 1 fait 2026-10-01 (api_v2)** : le module `api_v2.py` (2 110 lignes, 115 routes) devient le **package `app/routers/api_v2/`** = 12 modules par concern (identity 7, workspaces 9, collections 23, properties 7, views 8, engagement 21, sharing 8, planning 7, templates_io 9, projects 4, admin 4, webhooks 8) + `_common.py` (`_hash`, `_v2_rate_check`) + `__init__.py` (aggrégat `APIRouter(prefix="/api/v2")` + `include_router` sans prefix). Preuve contractuelle : **`docs/openapi-v2.json` régénéré = IDENTIQUE byte-à-byte** (0 changement de chemin/tag/operation_id), seul importateur = `main.py` (`from app.routers.api_v2 import router` → le package l'expose) ; en-tête d'imports copié puis émondé par `ruff --fix`. **Lot 2 fait 2026-10-01 (dashboard)** : `dashboard.py` (2 735 lignes, 63 routes) → **package `app/routers/dashboard/`** = 8 modules par concern (pages_html 488 L/6 r., local_workspace 551/15, settings→`account_settings` 442/16, workspace 323/9, pages_api 243/6, workspaces 135/6, account_api 82/4, public 82/1) + `_common.py` (les **15 helpers intercalés** + état `logger`/`_VERSION`/`WORKSPACE_COOKIE`) + `__init__.py` (re-export complet : 7 importateurs — main, board ×3, my_tasks, web_clipper, wiki, sites `_dash._render_blocks_public`, tests). Pièges rencontrés : segment décorateur sans le `def` (corps perdus, assert `def in seg` ajouté) ; collision `settings` (section vs `from app.config import settings` dans le header → **`hasattr` du fromlist** : la section renommée `account_settings`) ; `WORKSPACE_COOKIE` utilisé sans import dans `workspaces.py` (F821 → ajout automatique) ; script `__all__` qui mangeait la queue du fichier (réécrit à la main). Preuve contractuelle : **`docs/openapi-v2.json` IDENTIQUE byte-à-byte** (ordre d'enregistrement préservé). **Lot 3 fait 2026-10-01 (collections)** : `collections.py` (2 622 lignes, 53 endpoints / 52 fonctions — 1 paire de décorateurs empilés) → **package `app/routers/collections/`** = 10 modules par concern (crud 337, properties 322, linked 286, structure 267, dashboard_views 214, meta 197, views 187, pages 184, data_api 122, boards 61) + `_common.py` (8 helpers auth/permissions/validation, 220 L) + `_renderers.py` (**15 rendus de vues** + `CHART_MAX_GROUPS`, 667 L) + `__init__.py` (ré-exports : `_validate_page_properties` pour automations, 4 helpers de graphes pour les tests). Pièges : docstring d'origine dans le header → **F404 `from __future__` après un statement** (slice `[1:30]`), décorateurs empilés (`view_collection` ×2 → segments sans `def` → skip du 2ᵉ décorateur), `CHART_MAX_GROUPS` hors détection des helpers (F821 → import auto), test CDN lisant `collections.py` (balayage du package). Preuve : **`docs/openapi-v2.json` IDENTIQUE byte-à-byte**. **Lot 4 fait 2026-10-01 (board) → A28 TERMINÉ** : `board.py` (2 101 lignes, 53 routes) → **package `app/routers/board/`** = 12 modules par concern (page_api 5 r., sharing 10, pages 7, synced 8, board_views 8, page_media 4, page_ops 3, wiki 2, import_ 2, embed 2, library 1, sync 1) + `_common.py` (les **23 helpers dont 4 async** + les 4 constantes — oubliées à la 1ʳᵉ passe → F821/ImportError, ré-insérées) + `__init__.py` (`__all__` complet : api.py ×4 top-level, webhooks, dashboard ×5 lazy, tests ×4 — importateurs **inchangés**). Pièges : docstring du header (F404 → slice `[1:21]`), décorateurs empilés, helpers **async** non détectés par `def ` seul. Preuve : **`docs/openapi-v2.json` IDENTIQUE byte-à-byte** (509 chemins). **A28 TERMINÉ en 4 lots** : api_v2 (7.29.0), dashboard (7.30.0), collections (7.31.0), board (7.32.0) — 0 changement d'URL sur les 4 → suite 1091/1091, version 7.32.0. - [x] **A29 — Endpoints dupliqués 2-3×** : publish/unpublish existe en 3 endroits (`sharing.py:304/345`, `board.py:1020/1039`, `api_v2.py:1743/1761`) avec slug et auth **différents** ; listing collections ×3 (`/api/v1/collections`, `/db/api`, `/api/v2/collections`) ; `/api/users/me` ×2. *Fix : un `services/publish.py` partagé, les routers déléguent.* — **fait 2026-10-01** : `services/publish.py` (slugify unique, 404 partout, événements) ; les 3 paires publish/unpublish déléguent (sharing + board + v2), board gagne `_require_auth`, les bonus divergents (`share_mode='anyone'` / `is_shared=1`) supprimés — le share dialog reste propriétaire de ces drapeaux ; **byproduct sécurité** : `GET /api/users/me` (v1) et le contexte de `/accounts` faisaient `SELECT *` → `password_hash` exposé → colonnes whitelistées. **Décision** : `/api/users/me` ×2 et listing collections ×3 **restent** — contrats versionnés distincts (session+guest vs Bearer+scope, formes différentes). Effort : **M**. - [x] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.* - [x] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration.* — **fait 2026-10-01** : `_apply_one()` — BEGIN explicite par migration, rollback complet à l'échec (avant : DDL en autocommit → schéma partiel commité sans ligne `schema_version`, la reprise rejouait un DDL déjà appliqué) ; **1 helper au lieu de 3** : `columns(conn, table)` (valide l'identifiant) remplace les **25 copies** de `PRAGMA table_info` — `table_exists`/`column_exists` non livrés : aucune migration n'interroge `sqlite_master` et un contrôle unitaire se lit dans le set (YAGNI). Tests : rollback DDL + validation d'identifiant. Effort : **M**. @@ -1181,4 +1181,4 @@ Quality DB views, Agent IA Palette → Realtime + E → Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20. *Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).* -→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. · **A29/A42** : `services/publish.py` partagé (3 routers déléguent, 404 partout, board sous session), fuite `password_hash` corrigée sur `GET /api/users/me` v1 + contexte `/accounts`, `settings.data_dir` remplace les 9 copies d'env, cache Gitea évacue les expirés ; `/users/me` ×2 + collections ×3 = contrats versionnés, on garde ; reste A42 = client httpx partagé → suite 1034/1034, version 7.5.0. · **A31** : transaction par migration (`_apply_one`, rollback tout-ou-rien du DDL) + helper `columns()` remplaçant 25 copies de `PRAGMA table_info` (1 helper au lieu de 3 — les 2 autres seraient mort-nés) → suite 1036/1036, version 7.6.0. · **A20 (partiel)** : CSP nonce par requête — `unsafe-inline` retiré de `script-src`, 38 scripts templates + login constant + 3 scripts Python noncés, meta `htmx-config` pour htmx, `script-src-attr` pour les 74 `onclick=`, CDN chart/leaflet débloqués (déjà cassés avant) → suite 1037/1037, version 7.7.0. · **A21 phase 1** : 352 routes `async def` sans `await` → `def` (threadpool FastAPI, SQLite hors loop, zéro changement de logique — scan corps par corps) ; reste phase 2 = 311 routes avec `await` → `anyio.to_thread.run_sync` par bloc DB → suite 1037/1037, version 7.8.0. **Phase 2c faite 2026-10-01** : **+190 routes hors loop** (283 → 93 async, **86 % des 667 routes**) en 4 passes : (A) **racine auth** — `get_current_user` (session.py) était `async def` SANS aucun await (cookie decode = synchrone) + ses clones async (`agent._current_user_id/_workspace_id/_current_admin`, `sso._require_admin`) → `def`, **47 `await` supprimés** (dont 3 via l'alias `gcu`) ; (B) re-scan → 19 routes sans await flipées ; (C/D) **155 routes** json/événements → `Body(default={})` (formes : try/except `body = {}`, try/except `raise HTTPException(400)` → `Body(...)` requis (422 FastAPI, aucun test ne couvrait le 400), forme conditionnelle content-type → défaut `{}`) + `run_event_sync`. **Reste async (93, justifié)** : `request.form`/`upload.read`/`file.read` (14+5+3, corps de requête réellement asynchrone), gitea/llm/oidc (réseau), `_json_body` 9 (wrapper de validation), 2 JSON inline en argument, 1 fallback à logique (capture_frontend_error), 1 lecture conditionnelle dans web_clipper. Échecs : 3 seeds d'aliases `gcu` attrapés par la suite → corrigés → suite 1089/1089, version 7.26.0. · **A21 phase 2a** : `body` JSON → paramètre `Body(default={})` sur les 36 routes api_v2 dont c'était le seul `await` → conversion en `def` → api_v2 à 96/115 hors loop (19 async restantes : fire_event/form/gitea) → suite 1037/1037, version 7.9.0. · **A21 phase 2b** : `run_event_sync()` (asyncio.run dans le worker, événement attendu = déterministe) + les 15 routes json/événements en `def` → api_v2 bouclé à 111/115 (4 async = vrais awaits réseau) ; repo-wide 403 sync / 260 async → suite 1037/1037, version 7.10.0. · **A32 phase 1** : les 4 routers à 0 test couverts (webhooks/notes/sidebar_config/github_routes = 10 smokes, 0 réseau réel, échappement notes vérifié) ; reste quasi nuls library/api/dashboard/api_v2 → suite 1047/1047, version 7.11.0. · **A32 phase 2a** : library 1/10 → 8 routes couvertes ; découverte = 2 routes lisant la table fantôme `local_workspace_items` (500 systématique, 0 ref front) supprimées + `_format_size` mort → suite 1053/1053, version 7.12.0. · **A32 phase 2b** : api.py 3 → 16/22 routes couvertes (board-config, col-mapping, card, collaborators stubbé, frontend-error dédup, mutations checklist vérifiées en base) ; reste 6 routes gitea + dashboard/api_v2 → suite 1059/1059, version 7.13.0. · **A32 phase 2c** : api_v2 scan strict → 5 routes à 0 ref couvertes (formula, rollup, audit-logs avec portail vérifié, webhooks/events, verify-signature signé) → suite 1064/1064, version 7.14.0. · **A32 phase 2d** : dashboard scan strict = 44 routes à 0 ref, 10 couvertes (tags CRUD, page content→rename→trash vérifiés en base, tree HTML, avatar-color restauré, members) → suite 1069/1069, version 7.15.0. · **A32 phase 2e** : +9 routes dashboard (pages comptes sans hash, profile/password A3 403, token fd_+64hex, forge, settings/account 400, select cookie, breadcrumb) → suite 1075/1075, version 7.16.0. · **A32 phase 2f** : +7 routes dashboard centrées A16 (files traversal 403, download/file-content sans fuite, avatar 302 sans réseau, table-data + création de ligne) → suite 1079/1079, version 7.17.0. · **A32 phase 2g** : +13 routes dashboard (gitea-workspace HTML, projects CRUD, cycle items 5 routes, cycle tags d'item 5 routes avec ws dédié) → suite 1083/1083, version 7.18.0. · **A32 phase 2h → dashboard bloqué** : members (cycle complet + quirk tuple), upload-folder (validations seules), convert-to-database (vérifié en base, ordre FK) ; les 44 routes à 0 ref sont toutes exercées (faux positifs f-string rapprochés) → suite 1086/1086, version 7.19.0. · **A32 TERMINÉ** : 6 routes Gitea stubbées (canevas mutable, carte board, HTML `?format=html`, 404) + **bug prod `card_detail.html`/`fd_icon` corrigé** (500 garanti avant) → suite 1089/1089, version 7.20.0. · **A27 phase 1** : 7 templates sans Jinja → 9 fichiers static/js (4 243 L, -30 % du inline, node --check vert), eslint installé globalement → `eslint static/js` 0 erreur/120 warnings ; reste = blocs interpolés Jinja (~9 661 L) → suite 1089/1089, version 7.21.0. · **A27 phase 2a** : éditeur 2 516 L extrait via `#page-data` (PD.*), dérivé `is_shared` hoisté, 8 tests adaptés (source → static js / parsing JSON) → suite 1089/1089, version 7.22.0. · **A27 phase 2b** : local_workspace 2031 + settings 1093 + realtime 531 + board 146 extraits (recette config JSON, tojson au lieu d'interpolation JS) → suite 1089/1089, version 7.23.0. · **A27 phase 2c → extraction TERMINÉE** : database_table 1314 L (config JSON null-vs-objet, 2 branches Jinja remplacées par `DB_CONFIG`), bilan **11 874 L extraites, inline -85 % (13 904 → 2 022)** → suite 1089/1089, version 7.24.0. · **A27 lint TERMINÉ** : eslint 285 warnings → **0/0** (catch vides → binding optionnel, 24 lignes mortes, 10 fonctions `/* exported */` appelées par le HTML, 3 globaux réels en config) + 2 vrais correctifs (toasts settings jamais affichés → `window.showToast`, `_wsInitData` sans effet supprimé) → suite 1089/1089, version 7.25.0. · **A21 phase 2c → A21 TERMINÉ** : auth racine sync (47 await retirés, alias gcu rattrapé) + 155 routes json/événements → Body/run_event_sync + 19 flips → **283→93 async (86 % hors loop)** → suite 1089/1089, version 7.26.0. · **A20 phase 2** : chart/leaflet vendorisés (static/js/vendor, 8 fichiers), CSP sans aucun hôte CDN, connect-src `'self' ws://{host} wss://{host}` (exfil fermé), Google Fonts morts retirés, img-src https: gardé (unfurls) → suite 1090/1090, version 7.27.0 — reste A20 = unsafe-eval (Alpine+htmx, E2E d'abord). · **A42 TERMINÉ** : `shared_client` (cache par boucle+kwargs, WeakKeyDictionary) remplace 49 créations httpx dans 14 fichiers, 2 sites laissés (transport injecté / own_client), stub webhooks étendu à la fabrique → suite 1091/1091, version 7.28.0. · **A28 lot 1** : api_v2.py (2 110 L, 115 routes) → package de 14 fichiers par concern, openapi JSON IDENTIQUE byte-à-byte (0 changement d'URL) → suite 1091/1091, version 7.29.0 · **A28 lot 2** : dashboard.py (2 735 L, 63 routes) → package de 10 fichiers (15 helpers dans _common, re-exports intacts, collision `settings`→`account_settings`), openapi IDENTIQUE byte-à-byte → suite 1091/1091, version 7.30.0 · **A28 lot 3** : collections.py (2 622 L, 53 endpoints) → package de 13 fichiers (_renderers = 15 rendus, décorateurs empilés gérés, F404 docstring corrigé), openapi IDENTIQUE byte-à-byte → suite 1091/1091, version 7.31.0 — reste : board. +→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. · **A29/A42** : `services/publish.py` partagé (3 routers déléguent, 404 partout, board sous session), fuite `password_hash` corrigée sur `GET /api/users/me` v1 + contexte `/accounts`, `settings.data_dir` remplace les 9 copies d'env, cache Gitea évacue les expirés ; `/users/me` ×2 + collections ×3 = contrats versionnés, on garde ; reste A42 = client httpx partagé → suite 1034/1034, version 7.5.0. · **A31** : transaction par migration (`_apply_one`, rollback tout-ou-rien du DDL) + helper `columns()` remplaçant 25 copies de `PRAGMA table_info` (1 helper au lieu de 3 — les 2 autres seraient mort-nés) → suite 1036/1036, version 7.6.0. · **A20 (partiel)** : CSP nonce par requête — `unsafe-inline` retiré de `script-src`, 38 scripts templates + login constant + 3 scripts Python noncés, meta `htmx-config` pour htmx, `script-src-attr` pour les 74 `onclick=`, CDN chart/leaflet débloqués (déjà cassés avant) → suite 1037/1037, version 7.7.0. · **A21 phase 1** : 352 routes `async def` sans `await` → `def` (threadpool FastAPI, SQLite hors loop, zéro changement de logique — scan corps par corps) ; reste phase 2 = 311 routes avec `await` → `anyio.to_thread.run_sync` par bloc DB → suite 1037/1037, version 7.8.0. **Phase 2c faite 2026-10-01** : **+190 routes hors loop** (283 → 93 async, **86 % des 667 routes**) en 4 passes : (A) **racine auth** — `get_current_user` (session.py) était `async def` SANS aucun await (cookie decode = synchrone) + ses clones async (`agent._current_user_id/_workspace_id/_current_admin`, `sso._require_admin`) → `def`, **47 `await` supprimés** (dont 3 via l'alias `gcu`) ; (B) re-scan → 19 routes sans await flipées ; (C/D) **155 routes** json/événements → `Body(default={})` (formes : try/except `body = {}`, try/except `raise HTTPException(400)` → `Body(...)` requis (422 FastAPI, aucun test ne couvrait le 400), forme conditionnelle content-type → défaut `{}`) + `run_event_sync`. **Reste async (93, justifié)** : `request.form`/`upload.read`/`file.read` (14+5+3, corps de requête réellement asynchrone), gitea/llm/oidc (réseau), `_json_body` 9 (wrapper de validation), 2 JSON inline en argument, 1 fallback à logique (capture_frontend_error), 1 lecture conditionnelle dans web_clipper. Échecs : 3 seeds d'aliases `gcu` attrapés par la suite → corrigés → suite 1089/1089, version 7.26.0. · **A21 phase 2a** : `body` JSON → paramètre `Body(default={})` sur les 36 routes api_v2 dont c'était le seul `await` → conversion en `def` → api_v2 à 96/115 hors loop (19 async restantes : fire_event/form/gitea) → suite 1037/1037, version 7.9.0. · **A21 phase 2b** : `run_event_sync()` (asyncio.run dans le worker, événement attendu = déterministe) + les 15 routes json/événements en `def` → api_v2 bouclé à 111/115 (4 async = vrais awaits réseau) ; repo-wide 403 sync / 260 async → suite 1037/1037, version 7.10.0. · **A32 phase 1** : les 4 routers à 0 test couverts (webhooks/notes/sidebar_config/github_routes = 10 smokes, 0 réseau réel, échappement notes vérifié) ; reste quasi nuls library/api/dashboard/api_v2 → suite 1047/1047, version 7.11.0. · **A32 phase 2a** : library 1/10 → 8 routes couvertes ; découverte = 2 routes lisant la table fantôme `local_workspace_items` (500 systématique, 0 ref front) supprimées + `_format_size` mort → suite 1053/1053, version 7.12.0. · **A32 phase 2b** : api.py 3 → 16/22 routes couvertes (board-config, col-mapping, card, collaborators stubbé, frontend-error dédup, mutations checklist vérifiées en base) ; reste 6 routes gitea + dashboard/api_v2 → suite 1059/1059, version 7.13.0. · **A32 phase 2c** : api_v2 scan strict → 5 routes à 0 ref couvertes (formula, rollup, audit-logs avec portail vérifié, webhooks/events, verify-signature signé) → suite 1064/1064, version 7.14.0. · **A32 phase 2d** : dashboard scan strict = 44 routes à 0 ref, 10 couvertes (tags CRUD, page content→rename→trash vérifiés en base, tree HTML, avatar-color restauré, members) → suite 1069/1069, version 7.15.0. · **A32 phase 2e** : +9 routes dashboard (pages comptes sans hash, profile/password A3 403, token fd_+64hex, forge, settings/account 400, select cookie, breadcrumb) → suite 1075/1075, version 7.16.0. · **A32 phase 2f** : +7 routes dashboard centrées A16 (files traversal 403, download/file-content sans fuite, avatar 302 sans réseau, table-data + création de ligne) → suite 1079/1079, version 7.17.0. · **A32 phase 2g** : +13 routes dashboard (gitea-workspace HTML, projects CRUD, cycle items 5 routes, cycle tags d'item 5 routes avec ws dédié) → suite 1083/1083, version 7.18.0. · **A32 phase 2h → dashboard bloqué** : members (cycle complet + quirk tuple), upload-folder (validations seules), convert-to-database (vérifié en base, ordre FK) ; les 44 routes à 0 ref sont toutes exercées (faux positifs f-string rapprochés) → suite 1086/1086, version 7.19.0. · **A32 TERMINÉ** : 6 routes Gitea stubbées (canevas mutable, carte board, HTML `?format=html`, 404) + **bug prod `card_detail.html`/`fd_icon` corrigé** (500 garanti avant) → suite 1089/1089, version 7.20.0. · **A27 phase 1** : 7 templates sans Jinja → 9 fichiers static/js (4 243 L, -30 % du inline, node --check vert), eslint installé globalement → `eslint static/js` 0 erreur/120 warnings ; reste = blocs interpolés Jinja (~9 661 L) → suite 1089/1089, version 7.21.0. · **A27 phase 2a** : éditeur 2 516 L extrait via `#page-data` (PD.*), dérivé `is_shared` hoisté, 8 tests adaptés (source → static js / parsing JSON) → suite 1089/1089, version 7.22.0. · **A27 phase 2b** : local_workspace 2031 + settings 1093 + realtime 531 + board 146 extraits (recette config JSON, tojson au lieu d'interpolation JS) → suite 1089/1089, version 7.23.0. · **A27 phase 2c → extraction TERMINÉE** : database_table 1314 L (config JSON null-vs-objet, 2 branches Jinja remplacées par `DB_CONFIG`), bilan **11 874 L extraites, inline -85 % (13 904 → 2 022)** → suite 1089/1089, version 7.24.0. · **A27 lint TERMINÉ** : eslint 285 warnings → **0/0** (catch vides → binding optionnel, 24 lignes mortes, 10 fonctions `/* exported */` appelées par le HTML, 3 globaux réels en config) + 2 vrais correctifs (toasts settings jamais affichés → `window.showToast`, `_wsInitData` sans effet supprimé) → suite 1089/1089, version 7.25.0. · **A21 phase 2c → A21 TERMINÉ** : auth racine sync (47 await retirés, alias gcu rattrapé) + 155 routes json/événements → Body/run_event_sync + 19 flips → **283→93 async (86 % hors loop)** → suite 1089/1089, version 7.26.0. · **A20 phase 2** : chart/leaflet vendorisés (static/js/vendor, 8 fichiers), CSP sans aucun hôte CDN, connect-src `'self' ws://{host} wss://{host}` (exfil fermé), Google Fonts morts retirés, img-src https: gardé (unfurls) → suite 1090/1090, version 7.27.0 — reste A20 = unsafe-eval (Alpine+htmx, E2E d'abord). · **A42 TERMINÉ** : `shared_client` (cache par boucle+kwargs, WeakKeyDictionary) remplace 49 créations httpx dans 14 fichiers, 2 sites laissés (transport injecté / own_client), stub webhooks étendu à la fabrique → suite 1091/1091, version 7.28.0. · **A28 lot 1** : api_v2.py (2 110 L, 115 routes) → package de 14 fichiers par concern, openapi JSON IDENTIQUE byte-à-byte (0 changement d'URL) → suite 1091/1091, version 7.29.0 · **A28 lot 2** : dashboard.py (2 735 L, 63 routes) → package de 10 fichiers (15 helpers dans _common, re-exports intacts, collision `settings`→`account_settings`), openapi IDENTIQUE byte-à-byte → suite 1091/1091, version 7.30.0 · **A28 lot 3** : collections.py (2 622 L, 53 endpoints) → package de 13 fichiers (_renderers = 15 rendus, décorateurs empilés gérés, F404 docstring corrigé), openapi IDENTIQUE byte-à-byte → suite 1091/1091, version 7.31.0. · **A28 TERMINÉ (lot 4 : board)** : 2 101 L → package de 14 fichiers (23 helpers dont 4 async dans _common, constantes ré-insérées), openapi IDENTIQUE byte-à-byte → suite 1091/1091, version 7.32.0 — les 4 god files du audit sont découpés. diff --git a/VERSION b/VERSION index 0ebb11e..0c44075 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.31.0 +7.32.0 diff --git a/WORKLOAD.md b/WORKLOAD.md index 90e64e0..7ff7f6a 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -1,6 +1,6 @@ # WORKLOAD — FlowDeck Notion Clone -> **Début**: 2026-07-08 | **Version**: v7.31.0 (audit — A28 lot 3 : collections.py 2 622 L → package 13 fichiers) | **Statut**: EN COURS 🔄 +> **Début**: 2026-07-08 | **Version**: v7.32.0 (audit — A28 TERMINÉ : les 4 god files découpés, board lot 4) | **Statut**: EN COURS 🔄 > **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0` ## Avancement Global diff --git a/app/main.py b/app/main.py index c115349..254d983 100644 --- a/app/main.py +++ b/app/main.py @@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.31.0", + version="7.32.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/app/routers/board.py b/app/routers/board.py deleted file mode 100644 index 70547c1..0000000 --- a/app/routers/board.py +++ /dev/null @@ -1,2101 +0,0 @@ -"""FlowDeck — Board Kanban Notion-style + multi-vues + propriétés custom + AI keywords.""" -from __future__ import annotations - -import json -import logging -import re -from pathlib import Path - -from fastapi import APIRouter, Body, HTTPException, Query, Request -from fastapi.responses import HTMLResponse, JSONResponse - -from app.auth.session import SessionManager -from app.config import settings -from app.db import get_conn -from app.routers.dashboard import _get_app_version -from app.routers.sidebar_config import get_sidebar_config_sync -from app.services.automations import fire_event, run_event_sync -from app.services.gitea_client import gitea -from app.services.http_client import shared_client -from app.services.permission_manager import PermissionManager -from app.services.publish import fire_published, fire_unpublished, publish, unpublish - -logger = logging.getLogger(__name__) -router = APIRouter(tags=["board"], prefix="/board") - -STATUS_COLORS = {"todo": "var(--gray)", "progress": "var(--blue)", "done": "var(--green)"} -STATUS_LABELS = {"todo": "To-do", "progress": "In progress", "done": "Complete"} - -AI_KEYWORD_COLORS = [ - "#E03E3E", "#D9730D", "#9B72F0", "#0F7B6C", "#3399CC", - "#E255A1", "#787774", "#6B4E3D", "#6374C4", "#5A9E4B", -] - -def _ensure_page_editable(conn, page_id: int, user: dict | None) -> None: - """v5.12.0: raise 423 when the page is locked and the actor may not edit. - - Allowed to edit a locked page: admins and the user who locked it - (locked_by). Unauthenticated callers only pass when the page is unlocked. - """ - row = conn.execute("SELECT is_locked, locked_by FROM pages WHERE id=?", (page_id,)).fetchone() - if not row or not row["is_locked"]: - return - uid = (user or {}).get("id") - is_admin = bool((user or {}).get("is_admin")) - if is_admin or (uid and row["locked_by"] == uid): - return - raise HTTPException(423, "Page is locked — only the owner of the lock or an admin can edit") - - -def _ensure_block_ids(blocks) -> None: - """Assign unique ids to blocks missing one, recursively. - - Built-in page templates ship without ids (the editor used to assign them - client-side only). Without persisted ids, the realtime layer and the editor - disagree on block identity, which duplicated lines / shuffled blocks when - editing a template-created page. We now materialize ids at creation time. - """ - import uuid - if not isinstance(blocks, list): - return - for b in blocks: - if isinstance(b, dict): - if not b.get("id"): - b["id"] = "b" + uuid.uuid4().hex[:12] - if isinstance(b.get("children"), list): - _ensure_block_ids(b["children"]) - - -@router.get("/api/wiki/pages") -def wiki_page_search(request: Request, q: str = Query(default="")): - """v5.11.0: page picker for [[ wiki-links. Fuzzy-ish title search across - every non-deleted page the current user can see (single source: pages).""" - q = (q or "").strip().lower() - with get_conn() as conn: - rows = conn.execute( - """SELECT id, title, page_icon, workspace FROM pages - WHERE deleted_at IS NULL - ORDER BY updated_at DESC LIMIT 500""" - ).fetchall() - results = [] - for r in rows: - title = r["title"] or "Untitled" - if q: - # subsequence match ("mtg" → "Meeting notes") or plain substring. - hay = title.lower() - it = iter(hay) - subseq = all(ch in it for ch in q) - if q not in hay and not subseq: - continue - results.append({ - "id": r["id"], - "title": title, - "icon": r["page_icon"] or "", - "workspace": r["workspace"] or "", - }) - if len(results) >= 20: - break - return {"pages": results} - - -@router.get("/api/wiki/titles") -def wiki_titles(request: Request, ids: str = Query(default="")): - """v5.11.0: resolve page-id lists to current labels (rename propagation).""" - parsed: list[int] = [] - for part in (ids or "").split(","): - part = part.strip() - if part.isdigit(): - parsed.append(int(part)) - parsed = parsed[:200] - out: dict[str, str] = {} - if parsed: - placeholders = ",".join("?" * len(parsed)) - with get_conn() as conn: - rows = conn.execute( - f"SELECT id, title, page_icon, deleted_at FROM pages WHERE id IN ({placeholders})", - parsed, - ).fetchall() - for r in rows: - if r["deleted_at"]: - out[str(r["id"])] = "Deleted page" - else: - icon = (r["page_icon"] or "") - out[str(r["id"])] = (icon + " " if icon else "") + (r["title"] or "Untitled") - return {"titles": out} - - -@router.post("/api/pages/{page_id}/lock") -def set_page_lock(request: Request, page_id: int, body: dict = Body(default={})): - """v5.12.0: lock/unlock a page (read-only for everyone except the locker, - admins and the page creator).""" - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - if not user or not user.get("id"): - raise HTTPException(401, "Authentication required") - locked = bool(body.get("locked")) - with get_conn() as conn: - row = conn.execute("SELECT id, is_locked, locked_by, workspace FROM pages WHERE id=?", - (page_id,)).fetchone() - if not row: - raise HTTPException(404, "Page not found") - is_admin = 1 if user.get("is_admin") else 0 - if row["is_locked"] and not is_admin and row["locked_by"] != user["id"]: - raise HTTPException(403, "Only the person who locked this page (or an admin) can unlock it") - conn.execute("UPDATE pages SET is_locked=?, locked_by=? WHERE id=?", - (1 if locked else 0, user["id"] if locked else None, page_id)) - conn.commit() - run_event_sync(fire_event("page.locked" if locked else "page.unlocked", - {"page_id": page_id, "by": user["id"]})) - return {"status": "ok", "is_locked": int(locked)} - - -@router.post("/api/pages/{page_id}/options") -def set_page_options(request: Request, page_id: int, body: dict = Body(default={})): - """v5.12.0: page layout options — full-width and compact typography.""" - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - if not user or not user.get("id"): - raise HTTPException(401, "Authentication required") - updates = {} - for key in ("full_width", "font_small"): - if key in body: - updates[key] = 1 if body[key] else 0 - if not updates: - raise HTTPException(400, "nothing to update") - sets = ", ".join(f"{k}=?" for k in updates) - with get_conn() as conn: - row = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone() - if not row: - raise HTTPException(404, "Page not found") - conn.execute(f"UPDATE pages SET {sets}, updated_at=CURRENT_TIMESTAMP WHERE id=?", - (*updates.values(), page_id)) - conn.commit() - return {"status": "ok", **{k: bool(v) for k, v in updates.items()}} - - -@router.get("/api/page-templates") -def list_page_templates_api(request: Request): - """v5.12.0: built-in + user global page templates for the picker.""" - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - uid = (user or {}).get("id") - from app.services.block_templates import template_list - with get_conn() as conn: - rows = conn.execute( - """SELECT id, name, icon, description, created_by - FROM page_global_templates - WHERE created_by IS NULL OR created_by=? - ORDER BY created_at""", - (uid,), - ).fetchall() - mine = [dict(r) for r in rows] - for t in mine: - t["builtin"] = False - return {"templates": template_list() + mine} - - -@router.post("/api/page-templates") -def create_page_template(request: Request, body: dict = Body(default={})): - """v5.12.0: save the current page (or a raw block list) as a personal - global template: {name, icon?, description?, page_id? | blocks?}.""" - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - if not user or not user.get("id"): - raise HTTPException(401, "Authentication required") - name = (body.get("name") or "").strip() - if not name: - raise HTTPException(400, "name is required") - blocks = body.get("blocks") - if body.get("page_id"): - with get_conn() as conn: - row = conn.execute("SELECT content, content_format FROM pages WHERE id=?", - (int(body["page_id"]),)).fetchone() - if not row: - raise HTTPException(404, "Page not found") - if row["content_format"] == "blocks" and row["content"]: - try: - blocks = json.loads(row["content"]) - except (json.JSONDecodeError, TypeError): - raise HTTPException(400, "Page content is not block JSON") from None - if not isinstance(blocks, list) or not blocks: - raise HTTPException(400, "blocks (or page_id) required") - with get_conn() as conn: - cur = conn.execute( - """INSERT INTO page_global_templates (name, icon, description, blocks_json, created_by) - VALUES (?, ?, ?, ?, ?)""", - (name, body.get("icon") or "📄", body.get("description") or "", - json.dumps(blocks), user["id"]), - ) - conn.commit() - tid = cur.lastrowid - return {"status": "ok", "id": tid} - - -@router.post("/api/page-templates/{template_id}/use") -def use_page_template(request: Request, template_id: int, body: dict = Body(default={})): - """v5.12.0: instantiate a page from a template (built-in or user). - - Body: {key?} for built-ins OR uses the row id for user templates. - Creates 'blocks'-format page in the caller's workspace and returns its id. - """ - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - title = (body.get("title") or "").strip() - blocks_json = None - if template_id == 0: - from app.services.block_templates import blocks_json_for - key = body.get("key") or "empty" - blocks_json = blocks_json_for(key) - name = key - if blocks_json is None: - raise HTTPException(404, "Unknown built-in template") - else: - with get_conn() as conn: - uid = (user or {}).get("id") - row = conn.execute( - "SELECT * FROM page_global_templates WHERE id=? AND (created_by IS NULL OR created_by=?)", - (template_id, uid), - ).fetchone() - if not row: - raise HTTPException(404, "Template not found") - blocks_json = row["blocks_json"] - name = row["name"] - title = title or row["name"] - # Resolve the target workspace so the new page actually shows up in the - # active local workspace (bugfix: template pages previously got - # workspace_id = NULL and never appeared in the sidebar/tree). - uid = (user or {}).get("id") - ws_id_raw = body.get("workspace_id") - ws_id = None - if ws_id_raw is not None: - try: - ws_id = int(ws_id_raw) - except (TypeError, ValueError): - ws_id = None - ws_key = user.get("login", "Bruno") if user else "Bruno" - if ws_id is not None: - with get_conn() as conn: - ws_row = conn.execute( - "SELECT id, name, owner_id FROM workspaces WHERE id=?", (ws_id,) - ).fetchone() - if ws_row and (uid is None or ws_row["owner_id"] == uid): - ws_key = ws_row["name"] or ws_key - else: - ws_id = None - else: - body_ws = (body.get("workspace") or "").strip() - if body_ws: - ws_key = body_ws - # Optional target folder: instantiate the template as a child of it. - parent_id = body.get("parent_id") - try: - parent_id = int(parent_id) if parent_id not in (None, "", 0, "0") else None - except (TypeError, ValueError): - parent_id = None - try: - parsed_blocks = json.loads(blocks_json) - except (json.JSONDecodeError, TypeError): - raise HTTPException(500, "Template content corrupted") from None - _ensure_block_ids(parsed_blocks) - blocks_json = json.dumps(parsed_blocks) - with get_conn() as conn: - if parent_id is not None: - next_order = conn.execute( - "SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE parent_id=?", - (parent_id,), - ).fetchone()[0] - elif ws_id is not None: - next_order = conn.execute( - "SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace_id=? AND parent_id IS NULL", - (ws_id,), - ).fetchone()[0] - else: - next_order = conn.execute( - "SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL", - (ws_key,), - ).fetchone()[0] - cur = conn.execute( - """INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id, sort_order) - VALUES (?,?,?,?,?, 'Private', ?, ?)""", - (ws_key, ws_id, title or name, blocks_json, "blocks", parent_id, next_order), - ) - conn.commit() - page_id = cur.lastrowid - run_event_sync(fire_event("page.created", {"page_id": page_id, "title": title or name, - "workspace": ws_key, "from_template": name})) - return {"status": "ok", "id": page_id, "title": title or name} - - -# ── Core helpers ── - -def _issue_column(issue: dict, columns: list[str], board_id: int) -> str: - if issue.get("state") == "closed": - return "Terminé" if "Terminé" in columns else columns[-1] - for lbl in issue.get("labels", []): - with get_conn() as conn: - row = conn.execute( - "SELECT column_name FROM col_mapping WHERE board_id=? AND gitea_label=?", - (board_id, lbl["name"]), - ).fetchone() - if row and row["column_name"] in columns: - return row["column_name"] - return columns[0] if columns else "Backlog" - - -def _map_issue_to_card(issue: dict, owner: str = "", repo: str = "") -> dict: - title = issue.get("title", "Untitled") - status = "todo" - if issue.get("state") == "closed": - status = "done" - labels = issue.get("labels", []) - for lbl in labels: - name = lbl.get("name", "").lower() - if "progress" in name or "doing" in name: - status = "progress" - elif "done" in name or "complete" in name or "terminé" in name: - status = "done" - - assignee = issue.get("assignee", {}) or {} - assignee_name = assignee.get("login", "") - tag = labels[0].get("name", "") if labels else "" - tag_color = labels[0].get("color", "#787774") if labels else "#787774" - if tag_color and not tag_color.startswith("#"): - tag_color = f"#{tag_color}" - - icon_map = { - "bug": "🐛", "feature": "✨", "enhancement": "⚡", "documentation": "📄", - "design": "🎨", "testing": "🧪", "refactor": "🔧", "security": "🔒", - } - icon = "file" - for lbl in labels: - for kw, emoji in icon_map.items(): - if kw in lbl.get("name", "").lower(): - icon = emoji - break - - # Load custom property values - props = {} - if owner and repo: - with get_conn() as conn: - pvs = conn.execute(""" - SELECT pp.name, pp.prop_type, pv.value - FROM property_values pv - JOIN project_properties pp ON pp.id = pv.property_id - WHERE pp.project_owner=? AND pp.project_name=? AND pv.gitea_issue_id=? - """, (owner, repo, issue.get("number", 0))).fetchall() - for pv in pvs: - props[pv["name"]] = {"type": pv["prop_type"], "value": pv["value"]} - - # AI keywords from DB - keywords = [] - if owner and repo: - with get_conn() as conn: - kw_rows = conn.execute( - "SELECT keyword, color FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC", - (owner, repo), - ).fetchall() - # Filter: show keywords matching this issue's labels - label_names = {lbl.get("name", "").lower() for lbl in labels} - for kw in kw_rows: - if kw["keyword"].lower() in label_names or any( - kw["keyword"].lower() in lbl for lbl in label_names - ): - keywords.append({"name": kw["keyword"], "color": kw["color"]}) - - return { - "id": str(issue.get("number", 0)), - "title": title, - "status": status, - "status_color": STATUS_COLORS.get(status, "var(--gray)"), - "status_label": STATUS_LABELS.get(status, "To-do"), - "icon": icon, - "assignee": assignee_name, - "tag": tag if tag else None, - "tag_color": tag_color, - "due_date": issue.get("due_date", ""), - "url": issue.get("html_url", ""), - "keywords": keywords, - "custom_props": props, - } - - -def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, max_depth: int = 3) -> list[dict]: - """Build nested page tree recursively. max_depth prevents infinite recursion.""" - if depth >= max_depth: - return [] - rows = conn.execute( - "SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY sort_order ASC, updated_at DESC", - (ws_key, parent_id), - ).fetchall() - items = [] - for row in rows: - children = _build_page_tree(conn, row["id"], ws_key, depth + 1, max_depth) - items.append({ - "id": f"page/{row['id']}", - "db_id": row["id"], - "name": row["title"] or "New page", - "icon": "📄", - "url": f"/pages/{row['id']}", - "active": False, - "depth": depth, - "has_children": len(children) > 0, - "children": children, - }) - return items - - -def _file_icon(name: str, content_format: str = "") -> str: - """Map file extension to icon name (SVG-safe).""" - import re - # FlowDeck internal pages (no extension) - if content_format and content_format != 'file': - return 'edit' - n = name.lower() - if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n): - return 'image' - if n.endswith('.pdf'): - return 'file' - if re.search(r'\.(md|markdown)$', n): - return 'edit' - if n.endswith('.py'): - return 'file' - if re.search(r'\.(js|jsx|ts|tsx)$', n): - return 'file' - if re.search(r'\.(html?|xml)$', n): - return 'file' - if n.endswith('.css'): - return 'file' - if n.endswith('.json'): - return 'file' - if n.endswith('.sql'): - return 'file' - if re.search(r'\.(sh|bash|zsh)$', n): - return 'file' - if n.endswith('.ps1'): - return 'file' - if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n): - return 'file' - if re.search(r'\.(txt|log)$', n): - return 'file' - if re.search(r'\.(zip|tar|gz|rar|7z)$', n): - return 'file' - return 'file' - - -def _load_workspace_pages(ws_cookie: str) -> list: - """Load top-level pages with children for the active workspace.""" - if not ws_cookie: - return [] - try: - ws_id = int(ws_cookie) - 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 workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY created_at DESC", - (ws_id,), - ).fetchall() - items = [] - for r in rows: - is_folder = r["parent_section"] == "Workspace" - title = r["title"] or "Untitled" - sub_children = _load_children(r["id"]) - is_shared = bool(r["is_shared"] or r["share_mode"] != "private" or r["published"]) - items.append({ - "db_id": r["id"], "name": title, - "id": f"page/{r['id']}", - "icon": "📁" if is_folder else _file_icon(title, r["content_format"]), - "is_folder": is_folder, - "is_shared": is_shared, - "child_count": len(sub_children), - "children": sub_children, - }) - return items - except (ValueError, Exception): - return [] - - -def _load_children(parent_id: int) -> list: - """Recursively load children of a page.""" - 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", - (parent_id,), - ).fetchall() - children = [] - for r in rows: - is_folder = r["parent_section"] == "Workspace" - title = r["title"] or "Untitled" - sub_children = _load_children(r["id"]) - is_shared = bool(r["is_shared"] or r["share_mode"] != "private" or r["published"]) - children.append({ - "db_id": r["id"], "name": title, - "id": f"page/{r['id']}", - "icon": "📁" if is_folder else _file_icon(title, r["content_format"]), - "is_folder": is_folder, - "is_shared": is_shared, - "child_count": len(sub_children), - "children": sub_children, - }) - return children - - -def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list[dict]: - """Return list of local workspaces for a user.""" - if not user: - return [] - try: - from app.db import get_conn - with get_conn() as conn: - rows = conn.execute( - "SELECT id, name FROM workspaces WHERE owner_id = ? ORDER BY name", - (user["id"],) - ).fetchall() - return [{"id": r["id"], "name": r["name"]} for r in rows] - except Exception: - return [] - - -def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]: - """Shared / received / published pages for the sidebar (reused by dashboard).""" - with get_conn() as conn: - own_ws = ( - "SELECT w.id FROM workspaces w WHERE w.owner_id = ? " - "UNION SELECT wm.workspace_id FROM workspace_members wm WHERE wm.user_id = ?" - ) - own_ws_names = ( - "SELECT w.name FROM workspaces w WHERE w.owner_id = ? " - "UNION SELECT w.name FROM workspaces w " - "JOIN workspace_members wm ON wm.workspace_id = w.id WHERE wm.user_id = ?" - ) - # Scope "shared by me"-style lists to pages in the user's own workspaces - # (or legacy pages whose workspace_id is NULL but identify the workspace by text). - scope_cond = ( - f"(workspace_id IN ({own_ws}) " - f"OR (workspace_id IS NULL AND lower(workspace) IN " - f"(SELECT lower(name) FROM ({own_ws_names}))) " - f"OR (workspace_id IS NULL AND lower(workspace) = lower(" - f"(SELECT login FROM users WHERE id=?))))" - ) - scope_params = (user_id, user_id, user_id, user_id, user_id) - - made_nominal = conn.execute( - "SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s " - "JOIN pages p ON p.id=s.page_id " - "WHERE s.created_by=? AND p.deleted_at IS NULL", - (user_id,), - ).fetchall() - made_link = conn.execute( - f"SELECT id, title, workspace, updated_at FROM pages " - f"WHERE share_mode='anyone' AND published=0 AND deleted_at IS NULL " - f"AND {scope_cond}", - scope_params, - ).fetchall() - made_flag = conn.execute( - f"SELECT id, title, workspace, updated_at FROM pages " - f"WHERE (is_shared=1 OR share_mode != 'private') AND COALESCE(published,0)=0 AND deleted_at IS NULL " - f"AND {scope_cond}", - scope_params, - ).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"ORDER BY updated_at DESC LIMIT 20", - scope_params, - ).fetchall() - try: - received_rows = conn.execute( - "SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s " - "JOIN pages p ON p.id=s.page_id " - "LEFT JOIN group_members gm ON gm.group_id = s.shared_with_group_id AND gm.user_id=? " - "WHERE (s.shared_with_user_id=? OR gm.user_id=?) AND p.deleted_at IS NULL", - (user_id, user_id, user_id), - ).fetchall() - except Exception: - received_rows = conn.execute( - "SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s " - "JOIN pages p ON p.id=s.page_id " - "WHERE s.shared_with_user_id=? AND p.deleted_at IS NULL", - (user_id,), - ).fetchall() - - def _entry(r, icon): - return { - "id": f"page/{r['id']}", - "db_id": r["id"], - "name": r["title"] or "New page", - "icon": icon, - "url": f"/pages/{r['id']}", - "active": False, - "indent": 0, - "depth": 0, - "has_children": False, - "children": [], - } - - made_map = {} - for r in (*made_nominal, *made_link, *made_flag): - made_map.setdefault(r["id"], r) - made_sorted = sorted(made_map.values(), key=lambda r: r["updated_at"] or "", reverse=True)[:20] - shared_made = [_entry(r, "link") for r in made_sorted] - received_sorted = [r for r in received_rows if r["id"] not in made_map] - received_sorted = sorted(received_sorted, key=lambda r: r["updated_at"] or "", reverse=True)[:20] - shared_received = [_entry(r, "users") for r in received_sorted] - published = [_entry(r, "globe") for r in published_rows] - shared_all = [_entry(r, "link") for r in made_sorted] - return shared_made, shared_received, published, shared_all - - -def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict: - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - ws_name = user.get("login", "Bruno") if user else "Bruno" - ws_key = f"{owner}/{repo}" if owner and repo else ws_name # workspace = project context - - # Active workspace name from cookie (for local workspace display) - from app.routers.dashboard import WORKSPACE_COOKIE - ws_cookie = request.cookies.get(WORKSPACE_COOKIE, "") - active_ws_name = "Workspace" - workspace_pages = [] - gitea_workspace = False - gitea_owner = "" - gitea_repo = "" - has_active_workspace = False - local_ws_id = 0 - - if ws_cookie and ws_cookie.startswith("gitea:"): - # Gitea workspace: preserve context across pages. Also load the local - # mirror workspace so it appears in "My Workspaces" in the top section - # of the sidebar, in parallel with the Gitea repository tree. - parts = ws_cookie.split(":", 2) - if len(parts) >= 3: - gitea_owner = parts[1] - gitea_repo = parts[2] - active_ws_name = f"{gitea_owner}/{gitea_repo}" - gitea_workspace = True - has_active_workspace = True - # Get local workspace ID for mirror and load its tree - if user: - try: - with get_conn() as conn: - row = conn.execute( - "SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?", - (user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%") - ).fetchone() - if row: - local_ws_id = row["id"] - workspace_pages = _load_workspace_pages(str(local_ws_id)) - except Exception: - logger.exception("_sidebar_data") - elif ws_cookie and user: - try: - wsi = int(ws_cookie) - with get_conn() as conn: - row = conn.execute( - "SELECT id, name, owner_id FROM workspaces WHERE id=? AND owner_id=?", - (wsi, user["id"]) - ).fetchone() - if row: - active_ws_name = row["name"] - workspace_pages = _load_workspace_pages(ws_cookie) - has_active_workspace = True - except (ValueError, Exception): - pass - recent = [] - if owner and repo: - view_map = { - "Kanban board": "kanban", "Detailed board": "detailed", - "Table view": "table", "Status overview": "status", "Team Load": "teamload", - } - first = True - for label, view in view_map.items(): - indent = 0 if first else 1 - active = first - recent.append({ - "id": f"{owner}/{repo}/{view}", - "name": label, "icon": "folder" if first else "", - "url": f"/board/{owner}/{repo}?view={view}", - "active": active, "indent": indent, - "depth": indent, "has_children": False, "children": [], - }) - first = False - # Load pages as nested tree for this project workspace - with get_conn() as conn: - tree_pages = _build_page_tree(conn, None, ws_key) - for p in tree_pages: - recent.append(p) - # Private pages: same as recent but filtered for page/ items (non-board views) - private_items = [r for r in recent if r.get("active") or r["id"].startswith("page/")] - - # Load favorite pages from DB - 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 " - "JOIN pages p ON p.id = f.page_id " - "WHERE f.user_id=? ORDER BY f.position", (uid,) - ).fetchall() - favorites = [] - for r in fav_rows: - favorites.append({ - "id": f"page/{r['id']}", - "db_id": r["id"], - "name": r["title"] or "New page", - "icon": "📄", - "url": f"/pages/{r['id']}", - "active": False, - "indent": 0, - "depth": 0, - "has_children": False, - "children": [], - }) - - # Load shared pages - shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(uid) - - # Auth method & OAuth badge data - auth_method = "local" - gitea_linked = False - github_linked = False - if user and user.get("id"): - try: - with get_conn() as conn: - am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone() - if am_row and am_row["auth_method"]: - auth_method = am_row["auth_method"] - tokens = conn.execute( - "SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],) - ).fetchall() - for t in tokens: - if t["provider"] == "gitea": - gitea_linked = True - elif t["provider"] == "github": - github_linked = True - except Exception: - logger.exception("_sidebar_data") - - return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B", - "active_ws_name": active_ws_name, - "workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else ws_key, - "workspace_pages": workspace_pages, - "gitea_workspace": gitea_workspace, - "gitea_owner": gitea_owner, - "gitea_repo": gitea_repo, - "local_ws_id": local_ws_id, - "current_page": repo or "Dashboard", "last_edited": "now", - "recent_pages": recent, "private_pages": private_items, - "favorite_pages": favorites, "shared_pages": shared_pages, - "shared_made_pages": shared_made_pages, - "shared_received_pages": shared_received_pages, - "published_pages": published_pages, - "user": user, - "auth_method": auth_method, - "gitea_linked": gitea_linked, - "github_linked": github_linked, - "has_active_workspace": has_active_workspace, - "app_version": _get_app_version(), - "local_workspaces": _local_workspaces_for_user(user), - "sidebar_config": get_sidebar_config_sync(uid)} - - -def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""): - """Extract and persist AI keywords from issue labels and body.""" - if not owner or not repo: - return - candidates = set() - for lbl in labels: - name = lbl.get("name", "").strip().lower() - if name and len(name) > 1: - candidates.add(name) - # Simple extraction from body: single words > 3 chars - import re - for word in re.findall(r'\b[a-zA-Z]{4,}\b', body.lower()): - if word not in ("this", "that", "with", "from", "have", "when", "will"): - candidates.add(word) - - with get_conn() as conn: - for kw in candidates: - kw = kw[:30] - existing = conn.execute( - "SELECT id, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? AND keyword=?", - (owner, repo, kw), - ).fetchone() - if existing: - conn.execute("UPDATE ai_keywords SET usage_count=? WHERE id=?", - (existing["usage_count"] + 1, existing["id"])) - else: - color_idx = len(candidates) % len(AI_KEYWORD_COLORS) - conn.execute( - "INSERT INTO ai_keywords (project_owner, project_name, keyword, color) VALUES (?,?,?,?)", - (owner, repo, kw, AI_KEYWORD_COLORS[color_idx]), - ) - conn.commit() - - -def _get_project_properties(owner: str, repo: str) -> list[dict]: - with get_conn() as conn: - rows = conn.execute( - "SELECT * FROM project_properties WHERE project_owner=? AND project_name=? ORDER BY position", - (owner, repo), - ).fetchall() - return [dict(r) for r in rows] - - - - -async def asyncio_get_labels(owner: str, repo: str): - return await gitea.get_labels(owner, repo) - - -def _apply_filters(cards: list[dict], status_filter: str, filters: str) -> list[dict]: - if status_filter: - allowed = set(status_filter.split(",")) - cards = [c for c in cards if c["status"] in allowed] - if filters: - for f in filters.split(","): - if ":" in f: - prop, val = f.split(":", 1) - val_lower = val.lower() - if prop == "assignee": - cards = [c for c in cards if c.get("assignee", "").lower() == val_lower] - elif prop == "tag": - cards = [c for c in cards if (c.get("tag") or "").lower() == val_lower] - elif prop == "keyword": - cards = [c for c in cards if any(val_lower in kw.get("name", "").lower() for kw in c.get("keywords", []))] - return cards - - -def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]: - if not sorts: - return cards - order = {"todo": 0, "progress": 1, "done": 2} - for spec in reversed(sorts.split(",")): - if ":" not in spec: - continue - field, direction = spec.split(":", 1) - rev = direction == "desc" - if field == "name": - cards.sort(key=lambda c: c["title"].lower(), reverse=rev) - elif field == "status": - cards.sort(key=lambda c: order.get(c["status"], 0), reverse=rev) - elif field == "assignee": - cards.sort(key=lambda c: c.get("assignee", "").lower(), reverse=rev) - elif field == "deadline": - cards.sort(key=lambda c: c.get("due_date", ""), reverse=rev) - return cards - - -# ═══════════ Library page ═══════════ - -@router.get("/library", response_class=HTMLResponse) -def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): - """Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace.""" - from app.templating import ENV - env = ENV - sidebar = _sidebar_data(request, owner, repo) - # Load all pages for the workspace from DB - ws_key = f"{owner}/{repo}" if owner and repo else "" - with get_conn() as conn: - if ws_key: - rows = conn.execute( - "SELECT id, title, workspace, updated_at FROM pages " - "WHERE workspace=? AND collection_row_id IS NULL ORDER BY updated_at DESC", - (ws_key,), - ).fetchall() - else: - rows = conn.execute( - "SELECT id, title, workspace, updated_at FROM pages " - "WHERE collection_row_id IS NULL ORDER BY updated_at DESC", - ).fetchall() - all_pages = [] - for r in rows: - page = dict(r) - all_pages.append({ - "id": f"page/{page['id']}", - "name": page["title"] or "Untitled", - "icon": "📄", - "url": f"/pages/{page['id']}", - "created_by": "You", - "source": page.get("workspace") or "Private", - "last_edited": page.get("updated_at", "now"), - "last_visited": page.get("updated_at", "now"), - }) - sidebar["recent_pages"] = all_pages - sidebar["favorite_pages"] = [] - sidebar["private_pages"] = [p for p in all_pages if p.get("source") == "🔒 Private"] - sidebar["shared_pages"] = [] - sidebar["shared_made_pages"] = [] - sidebar["shared_received_pages"] = [] - template = env.get_template("library.html") - return template.render(**sidebar) - -# ═══════════ Favorites API ═══════════ - -@router.get("/api/favorites") -def list_favorites(request: Request): - """List favorited page IDs for the current user.""" - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - uid = user["id"] if user and user.get("id") else 1 - with get_conn() as conn: - rows = conn.execute( - "SELECT page_id FROM favorites WHERE user_id=? ORDER BY position", (uid,) - ).fetchall() - return {"favorites": [r["page_id"] for r in rows]} - - -@router.post("/api/favorites/{page_id:int}") -def add_favorite(request: Request, page_id: int): - """Add a page to favorites.""" - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - uid = user["id"] if user and user.get("id") else 1 - with get_conn() as conn: - existing = conn.execute( - "SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id) - ).fetchone() - if not existing: - pos = conn.execute( - "SELECT COALESCE(MAX(position), -1) + 1 FROM favorites WHERE user_id=?", (uid,) - ).fetchone()[0] - conn.execute( - "INSERT INTO favorites (user_id, page_id, position) VALUES (?,?,?)", - (uid, page_id, pos), - ) - conn.commit() - try: - run_event_sync(fire_event("favorite.added", {"page_id": page_id, "user_id": uid})) - except Exception: - logger.exception("add_favorite") - return {"status": "added", "page_id": page_id} - - -@router.delete("/api/favorites/{page_id:int}") -def remove_favorite(request: Request, page_id: int): - """Remove a page from favorites.""" - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - uid = user["id"] if user and user.get("id") else 1 - with get_conn() as conn: - conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id)) - conn.commit() - try: - run_event_sync(fire_event("favorite.removed", {"page_id": page_id, "user_id": uid})) - except Exception: - logger.exception("remove_favorite") - return {"status": "removed", "page_id": page_id} - -# ═══════════ Share API ═══════════ - -@router.post("/api/share/{page_id:int}") -def update_share(request: Request, page_id: int, body: dict = Body(default={})): - """Save share settings for a page.""" - mode = body.get("mode", "private") - published = body.get("published", False) - with get_conn() as conn: - conn.execute( - "UPDATE pages SET share_mode=?, published=? WHERE id=?", - (mode, 1 if published else 0, page_id), - ) - conn.commit() - return {"status": "ok", "share_mode": mode, "published": published} - - -@router.post("/api/pages/{page_id:int}/publish") -def publish_page(request: Request, page_id: int): - """Publish a page to the web (generates publish_slug).""" - if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")): - raise HTTPException(401, "Authentication required") - slug, title = publish(page_id) - run_event_sync(fire_published(page_id, slug)) - return {"is_published": True, "publish_slug": slug, "title": title} - - -@router.delete("/api/pages/{page_id:int}/publish") -def unpublish_page(request: Request, page_id: int): - """Unpublish a page from the web.""" - if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")): - raise HTTPException(401, "Authentication required") - unpublish(page_id) - run_event_sync(fire_unpublished(page_id)) - return {"is_published": False} - - -# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════ - -@router.get("/api/trash") -def list_trash(request: Request): - with get_conn() as conn: - rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall() - return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows] - - -@router.post("/api/trash/{page_id}/restore") -def restore_page(request: Request, page_id: int): - with get_conn() as conn: - conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,)) - conn.commit() - try: - run_event_sync(fire_event("page.restored", {"page_id": page_id})) - except Exception: - logger.exception("restore_page") - return {"status": "ok", "restored": page_id} - - -@router.delete("/api/trash/{page_id}") -def permanent_delete(request: Request, page_id: int): - with get_conn() as conn: - conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,)) - conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,)) - conn.commit() - return {"status": "ok", "deleted": page_id} - - -@router.get("/trash", response_class=HTMLResponse) -def trash_page(request: Request): - from app.templating import ENV - env = ENV - template = env.get_template("trash.html") - return template.render(**_sidebar_data(request)) - - -# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════ -# These routes MUST be registered before the catch-all /{owner}/{repo} below. - -@router.get("/api/synced-blocks") -def list_synced_blocks_api(request: Request, workspace: str = Query(default="")): - """List synced blocks for a workspace.""" - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - from app.services.synced_blocks import list_synced_blocks - return {"synced_blocks": list_synced_blocks(workspace or user.get("login", ""))} - - -@router.post("/api/synced-blocks") -def create_synced_block_api(request: Request, body: dict = Body(...)): - """Create a new synced block.""" - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - from app.services.synced_blocks import create_synced_block - sid = create_synced_block( - workspace=body.get("workspace", ""), - title=body.get("title", "Synced block"), - content=body.get("content", []), - created_by=user.get("id"), - ) - return {"status": "ok", "synced_block_id": sid} - - -@router.put("/api/synced-blocks/{sid}") -async def update_synced_block_api(request: Request, sid: int): - """Update a synced block's content (propagates to all pages).""" - try: - body = await request.json() - except Exception: - raise HTTPException(400, "Invalid JSON body") from None - from app.services.synced_blocks import ( - get_synced_block, - page_ids_for_synced, - sync_synced_blocks_in_page, - update_synced_block, - ) - sb = get_synced_block(sid) - if not sb: - raise HTTPException(404, "Synced block not found") - update_synced_block(sid, body.get("title", sb["title"]), body.get("content", [])) - # v6.5.0: rewrite every referencing page's stored content first (DB row - # content pages included), THEN push the realtime update so open rooms - # reload the fresh content from the DB. - for pid in page_ids_for_synced(sid): - sync_synced_blocks_in_page(pid) - from app.services.realtime_server import manager - await manager._propagate_synced(sid) - return {"status": "ok"} - - -@router.delete("/api/synced-blocks/{sid}") -async def delete_synced_block_api(request: Request, sid: int): - """Delete a synced block.""" - from app.services.synced_blocks import ( - delete_synced_block, - get_synced_block, - mark_synced_block_deleted, - page_ids_for_synced, - ) - sb = get_synced_block(sid) - if not sb: - raise HTTPException(404, "Synced block not found") - # v6.5.0: collect referencing pages BEFORE the FK cascade wipes the - # refs, rewrite their stored content (deleted state), then broadcast. - pids = page_ids_for_synced(sid) - delete_synced_block(sid) - mark_synced_block_deleted(sid, pids) - from app.services.realtime_server import manager - await manager._broadcast_synced_to(pids, sid) - return {"status": "ok"} - - -@router.get("/api/synced-blocks/{sid}") -def get_synced_block_api(sid: int): - """Get a synced block by id.""" - from app.services.synced_blocks import get_synced_block - sb = get_synced_block(sid) - if not sb: - raise HTTPException(404, "Synced block not found") - return dict(sb) - - -@router.post("/api/pages/{page_id}/synced") -def add_synced_to_page(request: Request, page_id: int, body: dict = Body(...)): - """Add a synced block reference to a page.""" - from app.services.synced_blocks import add_page_synced, get_synced_block - sid = body.get("synced_block_id") - sb = get_synced_block(sid) - if not sb: - raise HTTPException(404, "Synced block not found") - add_page_synced(page_id, sid, body.get("block_index", 0)) - return {"status": "ok", "synced_block_id": sid} - - -@router.delete("/api/pages/{page_id}/synced/{sid}") -def remove_synced_from_page(request: Request, page_id: int, sid: int): - """Remove a synced block reference from a page (unsync).""" - from app.services.synced_blocks import remove_page_synced - remove_page_synced(page_id, sid) - return {"status": "ok"} - - -@router.get("/api/pages/{page_id}/synced") -def get_page_synced_refs(request: Request, page_id: int): - """Get all synced block references for a page.""" - from app.services.synced_blocks import get_page_synced - return {"synced_blocks": get_page_synced(page_id)} - - -# ═══════════ Board page ═══════════ - -@router.get("/{owner}/{repo}", response_class=HTMLResponse) -def board(request: Request, owner: str, repo: str, view: str = Query(default="")): - from app.templating import ENV - env = ENV - sidebar = _sidebar_data(request, owner, repo) - template = env.get_template("board.html") - return template.render(request=request, owner=owner, repo=repo, groups=[], - initial_view=view, **sidebar) - - -# ═══════════ View fragments ═══════════ - -@router.get("/{owner}/{repo}/view/{view}", response_class=HTMLResponse) -async def board_view( - request: Request, owner: str, repo: str, view: str, - status: str = Query(default=""), - filter: str = Query(default=""), - sort: str = Query(default=""), -): - try: - issues = await gitea.get_issues(owner, repo, state="all") - issues_only = [i for i in issues if not i.get("pull_request")] - cards = [_map_issue_to_card(i, owner, repo) for i in issues_only] - cards = _apply_filters(cards, status, filter) - cards = _apply_sorts(cards, sort) - except Exception as e: - logger.error("Board view error: %s", e) - cards = [] - - from app.templating import ENV - env = ENV - - # Dynamic groups from Gitea labels (fallback to hardcoded) - group_names = ["Design", "Engineering", "No Team"] - groups = [] - for gname in group_names: - gid = gname.lower().replace(" ", "-") - gcards = cards - groups.append({ - "id": gid, "name": gname, - "counts": { - "todo": len([c for c in gcards if c["status"] == "todo"]), - "progress": len([c for c in gcards if c["status"] == "progress"]), - "done": len([c for c in gcards if c["status"] == "done"]), - }, - "cards": gcards, - }) - - ctx = {"owner": owner, "repo": repo, "groups": groups, "cards": cards} - - template_map = { - "table": "table_view.html", - "status": "status_overview.html", - "teamload": "team_load.html", - "detailed": "detailed_board.html", - } - - if view == "table": - grouped = {g["name"]: g["cards"] for g in groups} - ctx["grouped_cards"] = grouped - elif view == "status": - counts = {"todo": 0, "progress": 0, "done": 0} - for c in cards: - if c["status"] in counts: - counts[c["status"]] += 1 - ctx.update(status_data=counts, status_colors=STATUS_COLORS, status_labels=STATUS_LABELS) - elif view == "teamload": - members = {} - for c in cards: - name = c.get("assignee") or "Unassigned" - if name not in members: - members[name] = {"name": name, "initial": name[0].upper(), - "todo": 0, "progress": 0, "complete": 0, "total": 0} - sk = c["status"] if c["status"] in ("todo", "progress") else "complete" - members[name][sk] += 1 - members[name]["total"] += 1 - ctx["team_data"] = list(members.values()) - elif view == "detailed": - pass - else: - template_map["kanban"] = "board_fragment.html" - - template_name = template_map.get(view, "board_fragment.html") - template = env.get_template(template_name) - return template.render(**ctx) - - -# ═══════════ v0.9.0: Custom Properties API ═══════════ - -@router.get("/api/properties/{owner}/{repo}") -def get_properties(owner: str, repo: str): - return {"properties": _get_project_properties(owner, repo)} - - -@router.post("/api/properties/{owner}/{repo}") -def create_property(owner: str, repo: str, name: str = Query(...), - prop_type: str = Query(default="select"), - options: str = Query(default="")): - opts = json.dumps([o.strip() for o in options.split(",") if o.strip()]) - with get_conn() as conn: - try: - conn.execute( - "INSERT INTO project_properties (project_owner, project_name, name, prop_type, options_json) VALUES (?,?,?,?,?)", - (owner, repo, name, prop_type, opts), - ) - conn.commit() - except Exception as e: - raise HTTPException(409, f"Property already exists: {e}") from e - return {"status": "ok", "name": name, "type": prop_type} - - -@router.delete("/api/properties/{owner}/{repo}") -def delete_property(owner: str, repo: str, name: str = Query(...)): - with get_conn() as conn: - conn.execute( - "DELETE FROM project_properties WHERE project_owner=? AND project_name=? AND name=?", - (owner, repo, name), - ) - conn.commit() - return {"status": "ok"} - - -@router.post("/api/properties/{owner}/{repo}/values") -def set_property_value(owner: str, repo: str, issue_id: int = Query(...), - name: str = Query(...), value: str = Query(default="")): - with get_conn() as conn: - prop = conn.execute( - "SELECT id FROM project_properties WHERE project_owner=? AND project_name=? AND name=?", - (owner, repo, name), - ).fetchone() - if not prop: - raise HTTPException(404, f"Property '{name}' not found") - conn.execute( - "INSERT OR REPLACE INTO property_values (property_id, gitea_issue_id, value) VALUES (?,?,?)", - (prop["id"], issue_id, value), - ) - conn.commit() - return {"status": "ok"} - - -# ═══════════ v0.9.0: AI Keywords API ═══════════ - -@router.get("/api/ai-keywords/{owner}/{repo}") -def get_ai_keywords(owner: str, repo: str): - with get_conn() as conn: - rows = conn.execute( - "SELECT keyword, color, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC LIMIT 30", - (owner, repo), - ).fetchall() - return {"keywords": [dict(r) for r in rows]} - - -@router.post("/api/ai-keywords/{owner}/{repo}/extract") -async def extract_ai_keywords(owner: str, repo: str): - """Re-extract keywords from all issues in the repo.""" - try: - issues = await gitea.get_issues(owner, repo, state="all") - for issue in issues: - if not issue.get("pull_request"): - _extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", "")) - except Exception as e: - raise HTTPException(500, str(e)) from e - return {"status": "ok", "issues_scanned": len(issues)} - - -# ═══════════ Pages Markdown ═══════════ - -@router.post("/api/pages") -def create_page(request: Request, title: str = Query(default=""), - section: str = Query(default="Private"), - project: str = Query(default=""), - parent_id: int = Query(default=0)): - """Create a new Markdown page, optionally as a sub-page.""" - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - if not user or not user.get("id"): - # A7 : la création de page exige une session (route sortue de la liste CSRF). - raise HTTPException(401, "Authentication required") - ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno") - page_title = title.strip() if title else "" - try: - with get_conn() as conn: - # Compute next sort_order for this parent - next_order = 0 - parent_val = parent_id if parent_id > 0 else None - row = conn.execute( - "SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS ?", - (ws_key, parent_val), - ).fetchone() - if row: - next_order = row[0] - cur = conn.execute( - "INSERT INTO pages (workspace, title, parent_section, parent_id, sort_order) VALUES (?,?,?,?,?)", - (ws_key, page_title, section, parent_val, next_order), - ) - conn.commit() - page_id = cur.lastrowid - run_event_sync(fire_event("page.created", {"page_id": page_id, "title": page_title, - "workspace": ws_key, "parent_id": parent_id})) - return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id} - except Exception as e: - logger.error("create_page failed: %s", e) - from fastapi.responses import JSONResponse - return JSONResponse({"error": "Failed to create page", "detail": str(e)}, status_code=500) - - -@router.get("/api/pages/{page_id}") -def get_page(request: Request, page_id: int): - """Get a Markdown page.""" - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - if not user or not user.get("id"): - raise HTTPException(401, "Authentication required") - # v6.0.0: granular page permissions — 404 (not 403) hides restricted pages. - if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id): - raise HTTPException(404, "Page not found") - with get_conn() as conn: - row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() - if not row: - raise HTTPException(404, "Page not found") - return dict(row) - - -@router.put("/api/pages/{page_id}") -def update_page(request: Request, page_id: int, title: str = Query(default=""), - content: str = Query(default=""), - content_format: str = Query(default="")): - """Update a page's title and/or content. Accepts JSON body for blocks.""" - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - if not user or not user.get("id"): - raise HTTPException(403, "Authentication required") - # v6.0.0: granular page permissions — hidden pages 404 (not 403); a visible - # page the caller cannot edit yields 403. - pm = PermissionManager(user["id"], bool(user.get("is_admin"))) - if not pm.can_view_page(page_id): - raise HTTPException(404, "Page not found") - if not pm.can_edit_page(page_id): - raise HTTPException(403, "You don't have edit access to this page") - with get_conn() as conn: - _ensure_page_editable(conn, page_id, user) - if title: - conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id)) - # v6.5.0: renaming a database row's content page updates the row. - from app.services.row_pages import sync_page_title_to_row - sync_page_title_to_row(conn, page_id) - if content: - conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content, page_id)) - if content_format: - conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id)) - conn.commit() - run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title, - "content_format": content_format or "markdown", - "actor_id": user.get("id")})) - return {"status": "ok"} - - -@router.post("/api/pages/{page_id}/blocks") -def save_page_blocks(request: Request, page_id: int, body: dict = Body(...)): - """Save blocks JSON content (Notion-style block editor). - - v5.4.0: a version snapshot is recorded (if the block content actually - changed) so the UI can browse the version history and restore any of them. - v5.14.0: synced block references are tracked in page_synced_blocks. - """ - blocks = body.get("blocks", []) - blocks_json = json.dumps(blocks) - title = body.get("title", "") - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - uid = (user or {}).get("id") - # No session → legacy single-user behaviour; otherwise enforce edit rights. - if uid and not PermissionManager(uid).can_edit_page(page_id): - raise HTTPException(403, "You don't have edit access to this page") - - # Extract synced block ids from the blocks - def _extract_synced(blocks: list[dict]) -> set[int]: - ids: set[int] = set() - for b in blocks: - if b.get("type") == "synced" and b.get("synced_id"): - ids.add(b["synced_id"]) - if isinstance(b.get("children"), list): - ids |= _extract_synced(b["children"]) - return ids - - synced_ids = _extract_synced(blocks) - - with get_conn() as conn: - _ensure_page_editable(conn, page_id, user) - if title: - conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id)) - # v6.5.0: renaming a database row's content page updates the row. - from app.services.row_pages import sync_page_title_to_row - sync_page_title_to_row(conn, page_id) - conn.execute( - "UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?", - (blocks_json, page_id), - ) - _record_version(conn, page_id, uid, title or "", blocks_json) - # Update synced block references - existing = {r["synced_block_id"] for r in conn.execute( - "SELECT synced_block_id FROM page_synced_blocks WHERE page_id=?", (page_id,) - ).fetchall()} - for sid in synced_ids: - if sid not in existing: - conn.execute( - "INSERT OR IGNORE INTO page_synced_blocks (page_id, synced_block_id, block_index) VALUES (?, ?, 0)", - (page_id, sid), - ) - for sid in existing - synced_ids: - conn.execute( - "DELETE FROM page_synced_blocks WHERE page_id=? AND synced_block_id=?", - (page_id, sid), - ) - conn.commit() - run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title or "", - "content_format": "blocks", - "actor_id": uid})) - return {"status": "ok", "id": page_id} - - -def _record_version(conn, page_id: int, user_id, title: str, blocks_json: str) -> None: - """Insert a version snapshot unless it is byte-identical to the latest one.""" - prev = conn.execute( - "SELECT COALESCE(title, ''), blocks_json FROM page_versions " - "WHERE page_id=? ORDER BY id DESC LIMIT 1", - (page_id,), - ).fetchone() - if prev is not None and prev["blocks_json"] == blocks_json: - if prev["title"] != (title or ""): - conn.execute( - "UPDATE page_versions SET title=? WHERE id=" - "(SELECT id FROM page_versions WHERE page_id=? ORDER BY id DESC LIMIT 1)", - (title or "", page_id), - ) - return - conn.execute( - "INSERT INTO page_versions (page_id, user_id, title, blocks_json, note) VALUES (?,?,?,?,'edited')", - (page_id, user_id, title or "", blocks_json), - ) - - -def _block_texts(b: dict) -> list[str]: - """Flatten a block (including children) into searchable text chunks.""" - out = [] - raw = b.get("content") - if isinstance(raw, str) and raw.strip(): - out.append(raw) - for child in b.get("children") or []: - out.extend(_block_texts(child)) - return out - - -@router.get("/api/pages/{page_id}/backlinks") -def page_backlinks(request: Request, page_id: int): - """v5.4.0: pages that link to this one ("Lié depuis…"). - - Scans every non-deleted page's blocks (and raw markdown) for an internal - reference to ``/pages/{page_id}`` or ``#fdblk-…`` inside ``/pages/{page_id}``. - """ - target = f"/pages/{page_id}" if page_id else None - wiki_target = f"[[fdpage:{page_id}]]" if page_id else None - backlinks = [] - with get_conn() as conn: - rows = conn.execute( - "SELECT id, title, workspace, content, content_format, updated_at " - "FROM pages WHERE deleted_at IS NULL AND id != ?", - (page_id,), - ).fetchall() - for r in rows: - fmt = r["content_format"] - hits = False - if fmt == "blocks" and r["content"]: - try: - blocks = json.loads(r["content"]) - for b in blocks if isinstance(blocks, list) else []: - for text in _block_texts(b): - if target and (target in text or (wiki_target and wiki_target in text)): - hits = True - break - if hits: - break - except (json.JSONDecodeError, TypeError): - hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or ""))) - elif fmt == "markdown": - hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or ""))) - elif r["content"]: - hits = target and (target in json.dumps(r["content"]) or (wiki_target and wiki_target in json.dumps(r["content"]))) - if not hits and target: - hits = f"/pages/{page_id}" in (r["content"] or "") - if hits: - backlinks.append({ - "id": r["id"], - "title": r["title"] or "Untitled", - "workspace": r["workspace"] or "", - "updated_at": r["updated_at"] or "", - }) - backlinks.sort(key=lambda x: x.get("updated_at") or "", reverse=True) - return {"backlinks": backlinks} - - -@router.get("/api/pages/{page_id}/versions") -def page_versions(request: Request, page_id: int): - """v5.4.0: version history for a block-editor page.""" - with get_conn() as conn: - rows = conn.execute( - "SELECT pv.id, pv.title, pv.note, pv.created_at, " - "COALESCE(u.login, '') AS author " - "FROM page_versions pv LEFT JOIN users u ON u.id=pv.user_id " - "WHERE pv.page_id=? ORDER BY pv.id DESC LIMIT 100", - (page_id,), - ).fetchall() - return {"versions": [dict(r) for r in rows]} - - -@router.post("/api/pages/{page_id}/versions/{version_id}/restore") -def restore_version(request: Request, page_id: int, version_id: int): - """v5.4.0: restore a page from a version snapshot.""" - with get_conn() as conn: - ver = conn.execute( - "SELECT * FROM page_versions WHERE id=? AND page_id=?", - (version_id, page_id), - ).fetchone() - if not ver: - raise HTTPException(404, "Version not found") - conn.execute( - "UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", - (ver["blocks_json"], ver["title"] or "", page_id), - ) - conn.commit() - run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "", - "content_format": "blocks"})) - return {"status": "ok", "restored": version_id} - - -# ═══════════ v5.4.0: Page & collection duplication ═══════════ - - -@router.post("/api/pages/{page_id}/duplicate") -def duplicate_page(request: Request, page_id: int): - """Duplicate a page (block/markdown content included) as a sibling.""" - SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - with get_conn() as conn: - row = conn.execute( - "SELECT * FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,) - ).fetchone() - if not row: - raise HTTPException(404, "Page not found") - page = dict(row) - - def copy_tree(src_id: int, parent_id) -> int: - with get_conn() as conn: - conn.execute("SELECT * FROM pages WHERE id=?", (src_id,)).fetchone() - cur = conn.execute( - "INSERT INTO pages (workspace, workspace_id, title, content, content_format, " - "parent_section, parent_id, sort_order, share_mode, published, is_published, " - "publish_slug, is_shared, cover_url, page_icon, created_at, updated_at) " - "SELECT workspace, workspace_id, title || ' copy', content, content_format, " - "parent_section, ?, sort_order, share_mode, 0, is_published, '', is_shared, " - "cover_url, page_icon, created_at, updated_at FROM pages WHERE id=?", - (parent_id, src_id), - ) - new_id = cur.lastrowid - conn.commit() - for child in conn.execute( - "SELECT id FROM pages WHERE parent_id=? ", (src_id,) - ).fetchall(): - copy_tree(child["id"], new_id) - return new_id - - new_id = copy_tree(page_id, page.get("parent_id")) - title = (page.get("title") or "Untitled") + " copy" - with get_conn() as conn: - conn.execute("UPDATE pages SET title=? WHERE id=?", (title, new_id)) - conn.commit() - run_event_sync(fire_event("page.created", {"page_id": new_id, "title": title, - "workspace": page.get("workspace")})) - return {"status": "ok", "id": new_id, "title": title} - - -# ═══════════ v5.5.0: Cover & icon ═══════════ - - -def _upload_root() -> Path: - return Path(settings.data_dir) - - -def _ws_id_for(request: Request, page_id: int) -> int: - """The active workspace id for the page (cookie, then page, then fallback 1).""" - cookie = request.cookies.get("flowdeck_workspace", "") - try: - ws_id = int(cookie) - if ws_id > 0: - return ws_id - except (ValueError, TypeError): - pass - with get_conn() as conn: - row = conn.execute( - "SELECT workspace_id FROM pages WHERE id=?", (page_id,) - ).fetchone() - if row and row["workspace_id"]: - return int(row["workspace_id"]) - return 1 - - -async def _store_uploaded_file(request: Request, ws_id: int) -> dict: - """Persist an uploaded file under uploads/workspace_{ws_id}/ and return - {file_url, file_path, mime_type, size, file_name}.""" - import datetime - import re as _re - - form = await request.form() - upload = form.get("file") - if upload is None or not hasattr(upload, "filename"): - raise HTTPException(400, "file field required") - original = (upload.filename or "cover.png").replace("\\", "/").rsplit("/", 1)[-1] - name = _re.sub(r"[^A-Za-z0-9._-]", "_", original)[:120] - ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin" - if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}: - raise HTTPException(400, "Unsupported image format") - stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S") - folder = _upload_root() / f"uploads/workspace_{ws_id}" - folder.mkdir(parents=True, exist_ok=True) - final = f"{stamp}_{name}" - (folder / final).write_bytes(await upload.read()) - mime = f"image/{'svg+xml' if ext == 'svg' else 'jpeg' if ext == 'jpg' else ext}" - return { - "file_url": f"/api/files/{ws_id}/{final}", - "file_path": f"uploads/workspace_{ws_id}/{final}", - "mime_type": mime, - "size": (folder / final).stat().st_size, - "file_name": name, - } - - -@router.post("/api/pages/{page_id}/cover") -async def set_page_cover(request: Request, page_id: int): - """v5.5.0: upload an image cover for a page. - - JSON body {cover_url} accepts an external URL; multipart ``file`` uploads - an image stored in the workspace's uploads directory. - """ - ctype = (request.headers.get("content-type") or "").lower() - if ctype.startswith("application/json"): - body = await request.json() - cover_url = (body.get("cover_url") or "").strip() - if not cover_url: - raise HTTPException(400, "cover_url required") - with get_conn() as conn: - conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (cover_url, page_id)) - conn.commit() - return {"status": "ok", "page_id": page_id, "cover_url": cover_url} - ws_id = _ws_id_for(request, page_id) - meta = await _store_uploaded_file(request, ws_id) - with get_conn() as conn: - conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (meta["file_url"], page_id)) - conn.commit() - return {"status": "ok", "page_id": page_id, "cover_url": meta["file_url"]} - - -@router.delete("/api/pages/{page_id}/cover") -def remove_page_cover(request: Request, page_id: int): - with get_conn() as conn: - conn.execute("UPDATE pages SET cover_url='' WHERE id=?", (page_id,)) - conn.commit() - return {"status": "ok", "page_id": page_id} - - -@router.post("/api/pages/{page_id}/icon") -def set_page_icon(request: Request, page_id: int, body: dict = Body(default={})): - """v5.5.0: set a page emoji/icon label (or a custom-emoji image URL).""" - icon = (body.get("icon") or "").strip() - if len(icon) > 512: - raise HTTPException(400, "icon too long") - with get_conn() as conn: - conn.execute("UPDATE pages SET page_icon=? WHERE id=?", (icon, page_id)) - conn.commit() - return {"status": "ok", "page_id": page_id, "icon": icon} - - -# ═══════════ v5.4.0: Import (Markdown / .md / Notion .zip) ═══════════ - - -async def _create_page_from_markdown(request: Request, markdown: str, title: str = "") -> int: - """Convert markdown → blocks (server-side, same mapping as the editor) and - create a page in the caller's workspace.""" - from app.services.export import _md_to_blocks - - blocks = _md_to_blocks(markdown or "") - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - ws_key = user.get("login", "Bruno") if user else "Bruno" - file_title = title.strip() or "Import" - fallback = (file_title or "Imported page").replace("/", "-").replace("\\", "-")[:120] - if not blocks: - blocks = [{"type": "paragraph", "content": markdown or ""}] - with get_conn() as conn: - next_order = conn.execute( - "SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL", - (ws_key,), - ).fetchone()[0] - cur = conn.execute( - "INSERT INTO pages (workspace, title, content, content_format, parent_section, sort_order, workspace_id) " - "VALUES (?,?,?,?,'Private',?,(SELECT id FROM workspaces WHERE name=? LIMIT 1))", - (ws_key, fallback, json.dumps(blocks), "blocks", next_order, ws_key), - ) - conn.commit() - return cur.lastrowid - - -@router.post("/api/pages/import") -async def import_page(request: Request): - """v5.4.0: import markdown text as a new page (blocks) in the workspace.""" - try: - body = await request.json() - except Exception: - raise HTTPException(400, "Invalid JSON body") from None - markdown = body.get("markdown", "") - title = body.get("title", "") - if not markdown and not body.get("csv"): - raise HTTPException(400, "markdown field required") - if not markdown.strip(): - raise HTTPException(400, "markdown is empty") - page_id = await _create_page_from_markdown(request, markdown, title) - await fire_event("page.created", {"page_id": page_id, "title": title or "Import", - "workspace": ""}) - return {"status": "ok", "id": page_id} - - -@router.post("/api/pages/import/file") -async def import_file(request: Request): - """v5.4.0: import an uploaded .md file (or a Notion export .zip containing - markdown pages) into the workspace. Returns the created page ids.""" - import io as _io - import zipfile - - form = await request.form() - upload = form.get("file") - if upload is None or not hasattr(upload, "filename"): - raise HTTPException(400, "file field required") - filename = (upload.filename or "import.md").replace("\\", "/").rsplit("/", 1)[-1] - data = await upload.read() - created_ids = [] - - if filename.lower().endswith(".zip"): - try: - zf = zipfile.ZipFile(_io.BytesIO(data)) - except zipfile.BadZipFile: - raise HTTPException(400, "Invalid zip archive") from None - md_entries = sorted( - (n for n in zf.namelist() if n.lower().endswith((".md", ".markdown"))), - key=lambda n: (n.count("/"), n.lower()), - ) - if not md_entries: - raise HTTPException(400, "No .md files found in archive") - for name in md_entries: - raw = zf.read(name).decode("utf-8", errors="replace") - title = name.replace("\\", "/").rsplit("/", 1)[-1][:-3] - try: - created_ids.append(await _create_page_from_markdown(request, raw, title)) - except Exception as exc: # noqa: BLE001 - keep importing the rest - logger.warning("import failed for %s: %s", name, exc) - else: - try: - raw = data.decode("utf-8") - except UnicodeDecodeError: - raise HTTPException(400, "Only text/markdown files are supported") from None - title = filename.replace(".md", "").replace(".markdown", "").replace(".txt", "") - created_ids.append(await _create_page_from_markdown(request, raw, title)) - - if not created_ids: - raise HTTPException(422, "No pages could be imported") - return {"status": "ok", "ids": created_ids, "count": len(created_ids)} - - -@router.post("/api/og/metadata") -async def og_metadata(request: Request): - """v5.5.0: Open Graph metadata for a bookmark card. - - v7.3.0: ``gitea:owner/repo`` and ``github:owner/repo`` schemes are - unfurled straight from the forge API (no HTTP fetch of the HTML page). - """ - try: - body = await request.json() - except Exception: - raise HTTPException(400, "Invalid JSON body") from None - url = (body.get("url") or "").strip() - if not url: - raise HTTPException(400, "url required") - m = _REPO_REF_RE.match(url) - if m: - forge, owner, repo = m.group(1).lower(), m.group(2), m.group(3) - data = await _unfurl_repo(forge, owner, repo) - if data: - return {"ok": True, **data} - from app.services.og_fetcher import fetch_og_metadata - try: - data = await fetch_og_metadata(url) - except ValueError as exc: - # A12 : SSRF — URL vers un hôte privé/loopback (ou redirection vers l'un). - raise HTTPException(400, str(exc)) from None - return {"ok": True, **data} - - -_REPO_REF_RE = re.compile(r"^(gitea|github):([\w\-\.]+)/([\w\-\.]+)$") - - -async def _unfurl_repo(forge: str, owner: str, repo: str): - """Resolve a ``gitea:owner/repo`` / ``github:owner/repo`` ref.""" - try: - if forge == "gitea": - from app.services.gitea_client import GiteaClient - info = await GiteaClient().get_repo_info(owner, repo) - site = "Gitea" - else: - from app.config import settings - from app.services.github_adapter import GitHubAdapter - token = getattr(settings, "github_token", None) or "" - if token: - info = await GitHubAdapter(access_token=token).get_repo_info(owner, repo) - else: - async with shared_client(timeout=10) as client: - r = await client.get( - f"https://api.github.com/repos/{owner}/{repo}", - headers={"Accept": "application/vnd.github+json"}, - ) - r.raise_for_status() - info = r.json() - site = "GitHub" - except Exception as exc: # noqa: BLE001 — forge lookup is best-effort - logging.getLogger(__name__).debug("unfurl %s failed: %s", forge, exc) - return None - branch = info.get("default_branch") or "main" - return { - "url": info.get("html_url") or f"https://{forge}.com/{owner}/{repo}", - "title": info.get("full_name") or f"{owner}/{repo}", - "description": (info.get("description") or f"{site} repository " - f"{owner}/{repo} · default branch: {branch}"), - "image": "", - "site_name": site, - "language": info.get("language") or "", - } - - -@router.post("/api/embed/resolve") -def resolve_embed(request: Request, body: dict = Body(...)): - """v5.5.0: rewrite a pasted URL to its provider embed src. - - Powers the universal ``/embed`` block (YouTube, Vimeo, Figma, Maps, - Docs, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter…). - """ - url = (body.get("url") or "").strip() - if not url: - raise HTTPException(400, "url required") - from app.config import settings - from app.services.embeds import resolve_embed as _resolve - data = _resolve(url, parent=settings.app_base_url) - return {"ok": True, "url": url, **data} - - -@router.put("/api/pages/{page_id}/move") -def move_page(request: Request, page_id: int, body: dict = Body(default={})): - """Move a page to another workspace or reorder within tree. - - Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int } - - workspace_id: move page to a different workspace - - parent_id: change parent (0 = root level) - - new_order: position among siblings (0 = append) - """ - new_ws_id = body.get("workspace_id") - new_parent_id = body.get("parent_id", 0) - new_order = body.get("new_order", 0) - - with get_conn() as conn: - row = conn.execute("SELECT id, workspace, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone() - if not row: - raise HTTPException(404, "Page not found") - - if new_ws_id: - # Move to a different workspace: get the workspace name - ws_row = conn.execute("SELECT name FROM workspaces WHERE id=?", (new_ws_id,)).fetchone() - if not ws_row: - return JSONResponse({"status": "error", "detail": "Workspace not found"}, status_code=404) - conn.execute( - "UPDATE pages SET workspace_id=?, workspace=?, parent_id=NULL, updated_at=CURRENT_TIMESTAMP WHERE id=?", - (new_ws_id, ws_row["name"], page_id), - ) - else: - # Reorder within same workspace - conn.execute( - "UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", - (new_parent_id if new_parent_id > 0 else None, page_id), - ) - conn.execute( - "UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", - (new_order, page_id), - ) - - conn.commit() - run_event_sync(fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0, - "parent_id": new_parent_id})) - return {"status": "ok", "id": page_id} - - -@router.delete("/api/pages/{page_id}") -def delete_page(request: Request, page_id: int): - """Move a page to trash (soft delete).""" - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - uid = (user or {}).get("id") - if not uid: - raise HTTPException(403, "Authentication required") - # v6.0.0: granular page permissions — need at least edit access to trash. - if not PermissionManager(uid).can_edit_page(page_id): - raise HTTPException(403, "You don't have edit access to this page") - with get_conn() as conn: - row = conn.execute("SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)).fetchone() - if not row: - raise HTTPException(404, "Page not found") - import datetime - conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,)) - conn.commit() - run_event_sync(fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})) - return {"status": "ok", "deleted": page_id, "title": row["title"]} - - -@router.get("/pages/{page_id}", response_class=HTMLResponse) -def view_page(request: Request, page_id: int): - """Render a page as HTML, or a file viewer for uploaded files. - ?embed=1 — minimal mode for side peek (editor only, no header).""" - embed = request.query_params.get("embed") == "1" - from app.templating import ENV - env = ENV - # v6.0.0: granular page permissions — hide restricted pages (404). - user_hdr = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - if user_hdr and user_hdr.get("id") and not PermissionManager(user_hdr["id"]).can_view_page(page_id): - return HTMLResponse("

Page not found

", status_code=404) - with get_conn() as conn: - row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() - if not row: - return HTMLResponse("

Page not found

", status_code=404) - page = dict(row) - # v6.5.0: synced blocks resolve server-side at read time (fresh content - # even when the stored cache is stale). - from app.services.synced_blocks import resolve_content_json - page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format")) - - ws = page.get("workspace", "") - parts = ws.split("/") if "/" in ws else ["", ""] - owner, repo = parts[0], parts[1] if len(parts) > 1 else "" - sidebar = _sidebar_data(request, owner, repo) - # Check if page is favorited - user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - uid = user["id"] if user and user.get("id") else 1 - with get_conn() as conn: - fav = conn.execute( - "SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id) - ).fetchone() - - # Build page_data, including file metadata for uploaded files - _locked = bool(page.get("is_locked", 0)) - _locked_by = page.get("locked_by") if "locked_by" in page else None - _can_edit = (not _locked) or bool(user and user.get("is_admin")) or (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)), "cover_url": page.get("cover_url", "") or "", "page_icon": page.get("page_icon", "") or "", - "is_locked": _locked, - "locked_by": _locked_by, - "can_edit": _can_edit, - "full_width": bool(page.get("full_width", 0)) if "full_width" in page else False, - "font_small": bool(page.get("font_small", 0)) if "font_small" in page else False} - - # For file pages, extract file metadata and add to page_data - if page.get("content_format") == "file": - import json as _json - try: - meta = _json.loads(page.get("content", "{}")) - except _json.JSONDecodeError: - meta = {} - file_path = meta.get("file_path", "").replace("\\", "/") - mime_type = meta.get("mime_type", "application/octet-stream") - file_size = meta.get("size", 0) - # Build workspace_id from file_path - fp_parts = file_path.split("/") - ws_id = "" - for p in fp_parts: - if p.startswith("workspace_"): - ws_id = p.replace("workspace_", "") - break - filename = fp_parts[-1] if fp_parts else page.get("title", "File") - file_url = f"/api/files/{ws_id}/{filename}" if ws_id else "" - page_data["file_url"] = file_url - page_data["file_mime"] = mime_type - page_data["file_size"] = file_size - page_data["file_name"] = filename - - from app.routers.dashboard import _nav_breadcrumb - with get_conn() as conn: - nav_crumbs = _nav_breadcrumb(conn, page_id) - ctx = {**sidebar, "page": page, "page_favorited": fav is not None, - "page_share_mode": page.get("share_mode", "private"), - "page_published": bool(page.get("published", 0)), - "page_is_shared": bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)), - "page_data": page_data, - "breadcrumb_items": nav_crumbs, - "nav_workspace_id": page.get("workspace_id") or 0, - "nav_page_id": page_id, - "embed_mode": embed} - template = env.get_template("page_editor_embed.html" if embed else "page_editor.html") - response = template.render(**ctx) - return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"}) - - -@router.post("/api/sync/{owner}/{repo}") -async def sync_project(owner: str, repo: str): - """Full bidirectional sync: fetch Gitea issues → update local DB.""" - try: - issues = await gitea.get_issues(owner, repo, state="all") - issues_only = [i for i in issues if not i.get("pull_request")] - with get_conn() as conn: - board = conn.execute( - "SELECT id, columns_json FROM boards WHERE project_owner=? AND project_name=?", - (owner, repo), - ).fetchone() - if board: - board_id = board["id"] - columns = json.loads(board["columns_json"]) - # A23 : un seul executemany pour toutes les cards. - conn.executemany( - "INSERT OR REPLACE INTO cards (board_id, gitea_issue_id, column_name) VALUES (?,?,?)", - [ - (board_id, issue["number"], _issue_column(issue, columns, board_id)) - for issue in issues_only - ], - ) - for issue in issues_only: - # Extract AI keywords from each issue - _extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", "")) - conn.commit() - return {"status": "ok", "issues_synced": len(issues_only)} - except Exception as e: - raise HTTPException(500, str(e)) from e diff --git a/app/routers/board/__init__.py b/app/routers/board/__init__.py new file mode 100644 index 0000000..4a30b59 --- /dev/null +++ b/app/routers/board/__init__.py @@ -0,0 +1,108 @@ +"""FlowDeck — Board : Kanban Notion-style + multi-vues. + +Découpe A28 : l'ancien `board.py` (2 101 lignes, 53 routes) est +devenu ce package — un module par concern, helpers/constantes dans +`_common`. Ré-exportés (importateurs inchangés) : api.py +(STATUS_COLORS, STATUS_LABELS, _issue_column, _map_issue_to_card), +webhooks (_issue_column), dashboard (_sidebar_data, +_load_workspace_pages, _load_shared_sidebar_pages, _file_icon), +tests (_build_page_tree, _REPO_REF_RE, _unfurl_repo). +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter + +from . import ( # ordre = ordre d'enregistrement d'origine + board_views, + embed, + import_, + library, + page_api, + page_media, + page_ops, + pages, + sharing, + sync, + synced, + wiki, +) +from ._common import ( # noqa: F401 — ré-exports + _REPO_REF_RE, + AI_KEYWORD_COLORS, + STATUS_COLORS, + STATUS_LABELS, + _apply_filters, + _apply_sorts, + _block_texts, + _build_page_tree, + _create_page_from_markdown, + _ensure_block_ids, + _ensure_page_editable, + _extract_ai_keywords, + _file_icon, + _get_project_properties, + _issue_column, + _load_children, + _load_shared_sidebar_pages, + _load_workspace_pages, + _local_workspaces_for_user, + _map_issue_to_card, + _record_version, + _sidebar_data, + _store_uploaded_file, + _unfurl_repo, + _upload_root, + _ws_id_for, + asyncio_get_labels, +) + +logger = logging.getLogger(__name__) +router = APIRouter() +for _mod in ( + wiki, + page_api, + library, + sharing, + synced, + board_views, + pages, + page_media, + import_, + embed, + page_ops, + sync, +): + router.include_router(_mod.router) + +__all__ = [ + "router", + "AI_KEYWORD_COLORS", + "STATUS_COLORS", + "STATUS_LABELS", + "_REPO_REF_RE", + "_apply_filters", + "_apply_sorts", + "_block_texts", + "_build_page_tree", + "_create_page_from_markdown", + "_ensure_block_ids", + "_ensure_page_editable", + "_extract_ai_keywords", + "_file_icon", + "_get_project_properties", + "_issue_column", + "_load_children", + "_load_shared_sidebar_pages", + "_load_workspace_pages", + "_local_workspaces_for_user", + "_map_issue_to_card", + "_record_version", + "_sidebar_data", + "_store_uploaded_file", + "_unfurl_repo", + "_upload_root", + "_ws_id_for", + "asyncio_get_labels", +] diff --git a/app/routers/board/_common.py b/app/routers/board/_common.py new file mode 100644 index 0000000..3ad6839 --- /dev/null +++ b/app/routers/board/_common.py @@ -0,0 +1,878 @@ +"""FlowDeck — Board : helpers et constantes partagés (A28). + +Les 23 helpers de l'ancien board.py (dont 4 async) + constantes +(STATUS_COLORS/STATUS_LABELS/AI_KEYWORD_COLORS/_REPO_REF_RE) — +ré-exportés : api.py, webhooks, dashboard, tests. +""" +from __future__ import annotations + +import json +import logging +import re +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Request + +from app.auth.session import SessionManager +from app.config import settings +from app.db import get_conn +from app.routers.dashboard import _get_app_version +from app.routers.sidebar_config import get_sidebar_config_sync +from app.services.gitea_client import gitea +from app.services.http_client import shared_client + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["board"], prefix="/board") + +STATUS_COLORS = {"todo": "var(--gray)", "progress": "var(--blue)", "done": "var(--green)"} +STATUS_LABELS = {"todo": "To-do", "progress": "In progress", "done": "Complete"} + +AI_KEYWORD_COLORS = [ + "#E03E3E", "#D9730D", "#9B72F0", "#0F7B6C", "#3399CC", + "#E255A1", "#787774", "#6B4E3D", "#6374C4", "#5A9E4B", +] + +_REPO_REF_RE = re.compile(r"^(gitea|github):([\w\-\.]+)/([\w\-\.]+)$") + + + +def _ensure_page_editable(conn, page_id: int, user: dict | None) -> None: + """v5.12.0: raise 423 when the page is locked and the actor may not edit. + + Allowed to edit a locked page: admins and the user who locked it + (locked_by). Unauthenticated callers only pass when the page is unlocked. + """ + row = conn.execute("SELECT is_locked, locked_by FROM pages WHERE id=?", (page_id,)).fetchone() + if not row or not row["is_locked"]: + return + uid = (user or {}).get("id") + is_admin = bool((user or {}).get("is_admin")) + if is_admin or (uid and row["locked_by"] == uid): + return + raise HTTPException(423, "Page is locked — only the owner of the lock or an admin can edit") + + + + + + +def _ensure_block_ids(blocks) -> None: + """Assign unique ids to blocks missing one, recursively. + + Built-in page templates ship without ids (the editor used to assign them + client-side only). Without persisted ids, the realtime layer and the editor + disagree on block identity, which duplicated lines / shuffled blocks when + editing a template-created page. We now materialize ids at creation time. + """ + import uuid + if not isinstance(blocks, list): + return + for b in blocks: + if isinstance(b, dict): + if not b.get("id"): + b["id"] = "b" + uuid.uuid4().hex[:12] + if isinstance(b.get("children"), list): + _ensure_block_ids(b["children"]) + + + + + + +# ── Core helpers ── + +def _issue_column(issue: dict, columns: list[str], board_id: int) -> str: + if issue.get("state") == "closed": + return "Terminé" if "Terminé" in columns else columns[-1] + for lbl in issue.get("labels", []): + with get_conn() as conn: + row = conn.execute( + "SELECT column_name FROM col_mapping WHERE board_id=? AND gitea_label=?", + (board_id, lbl["name"]), + ).fetchone() + if row and row["column_name"] in columns: + return row["column_name"] + return columns[0] if columns else "Backlog" + + + + + + +def _map_issue_to_card(issue: dict, owner: str = "", repo: str = "") -> dict: + title = issue.get("title", "Untitled") + status = "todo" + if issue.get("state") == "closed": + status = "done" + labels = issue.get("labels", []) + for lbl in labels: + name = lbl.get("name", "").lower() + if "progress" in name or "doing" in name: + status = "progress" + elif "done" in name or "complete" in name or "terminé" in name: + status = "done" + + assignee = issue.get("assignee", {}) or {} + assignee_name = assignee.get("login", "") + tag = labels[0].get("name", "") if labels else "" + tag_color = labels[0].get("color", "#787774") if labels else "#787774" + if tag_color and not tag_color.startswith("#"): + tag_color = f"#{tag_color}" + + icon_map = { + "bug": "🐛", "feature": "✨", "enhancement": "⚡", "documentation": "📄", + "design": "🎨", "testing": "🧪", "refactor": "🔧", "security": "🔒", + } + icon = "file" + for lbl in labels: + for kw, emoji in icon_map.items(): + if kw in lbl.get("name", "").lower(): + icon = emoji + break + + # Load custom property values + props = {} + if owner and repo: + with get_conn() as conn: + pvs = conn.execute(""" + SELECT pp.name, pp.prop_type, pv.value + FROM property_values pv + JOIN project_properties pp ON pp.id = pv.property_id + WHERE pp.project_owner=? AND pp.project_name=? AND pv.gitea_issue_id=? + """, (owner, repo, issue.get("number", 0))).fetchall() + for pv in pvs: + props[pv["name"]] = {"type": pv["prop_type"], "value": pv["value"]} + + # AI keywords from DB + keywords = [] + if owner and repo: + with get_conn() as conn: + kw_rows = conn.execute( + "SELECT keyword, color FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC", + (owner, repo), + ).fetchall() + # Filter: show keywords matching this issue's labels + label_names = {lbl.get("name", "").lower() for lbl in labels} + for kw in kw_rows: + if kw["keyword"].lower() in label_names or any( + kw["keyword"].lower() in lbl for lbl in label_names + ): + keywords.append({"name": kw["keyword"], "color": kw["color"]}) + + return { + "id": str(issue.get("number", 0)), + "title": title, + "status": status, + "status_color": STATUS_COLORS.get(status, "var(--gray)"), + "status_label": STATUS_LABELS.get(status, "To-do"), + "icon": icon, + "assignee": assignee_name, + "tag": tag if tag else None, + "tag_color": tag_color, + "due_date": issue.get("due_date", ""), + "url": issue.get("html_url", ""), + "keywords": keywords, + "custom_props": props, + } + + + + + + +def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, max_depth: int = 3) -> list[dict]: + """Build nested page tree recursively. max_depth prevents infinite recursion.""" + if depth >= max_depth: + return [] + rows = conn.execute( + "SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY sort_order ASC, updated_at DESC", + (ws_key, parent_id), + ).fetchall() + items = [] + for row in rows: + children = _build_page_tree(conn, row["id"], ws_key, depth + 1, max_depth) + items.append({ + "id": f"page/{row['id']}", + "db_id": row["id"], + "name": row["title"] or "New page", + "icon": "📄", + "url": f"/pages/{row['id']}", + "active": False, + "depth": depth, + "has_children": len(children) > 0, + "children": children, + }) + return items + + + + + + +def _file_icon(name: str, content_format: str = "") -> str: + """Map file extension to icon name (SVG-safe).""" + # FlowDeck internal pages (no extension) + if content_format and content_format != 'file': + return 'edit' + n = name.lower() + if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n): + return 'image' + if n.endswith('.pdf'): + return 'file' + if re.search(r'\.(md|markdown)$', n): + return 'edit' + if n.endswith('.py'): + return 'file' + if re.search(r'\.(js|jsx|ts|tsx)$', n): + return 'file' + if re.search(r'\.(html?|xml)$', n): + return 'file' + if n.endswith('.css'): + return 'file' + if n.endswith('.json'): + return 'file' + if n.endswith('.sql'): + return 'file' + if re.search(r'\.(sh|bash|zsh)$', n): + return 'file' + if n.endswith('.ps1'): + return 'file' + if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n): + return 'file' + if re.search(r'\.(txt|log)$', n): + return 'file' + if re.search(r'\.(zip|tar|gz|rar|7z)$', n): + return 'file' + return 'file' + + + + + + +def _load_workspace_pages(ws_cookie: str) -> list: + """Load top-level pages with children for the active workspace.""" + if not ws_cookie: + return [] + try: + ws_id = int(ws_cookie) + 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 workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY created_at DESC", + (ws_id,), + ).fetchall() + items = [] + for r in rows: + is_folder = r["parent_section"] == "Workspace" + title = r["title"] or "Untitled" + sub_children = _load_children(r["id"]) + is_shared = bool(r["is_shared"] or r["share_mode"] != "private" or r["published"]) + items.append({ + "db_id": r["id"], "name": title, + "id": f"page/{r['id']}", + "icon": "📁" if is_folder else _file_icon(title, r["content_format"]), + "is_folder": is_folder, + "is_shared": is_shared, + "child_count": len(sub_children), + "children": sub_children, + }) + return items + except (ValueError, Exception): + return [] + + + + + + +def _load_children(parent_id: int) -> list: + """Recursively load children of a page.""" + 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", + (parent_id,), + ).fetchall() + children = [] + for r in rows: + is_folder = r["parent_section"] == "Workspace" + title = r["title"] or "Untitled" + sub_children = _load_children(r["id"]) + is_shared = bool(r["is_shared"] or r["share_mode"] != "private" or r["published"]) + children.append({ + "db_id": r["id"], "name": title, + "id": f"page/{r['id']}", + "icon": "📁" if is_folder else _file_icon(title, r["content_format"]), + "is_folder": is_folder, + "is_shared": is_shared, + "child_count": len(sub_children), + "children": sub_children, + }) + return children + + + + + + +def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list[dict]: + """Return list of local workspaces for a user.""" + if not user: + return [] + try: + from app.db import get_conn + with get_conn() as conn: + rows = conn.execute( + "SELECT id, name FROM workspaces WHERE owner_id = ? ORDER BY name", + (user["id"],) + ).fetchall() + return [{"id": r["id"], "name": r["name"]} for r in rows] + except Exception: + return [] + + + + + + +def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]: + """Shared / received / published pages for the sidebar (reused by dashboard).""" + with get_conn() as conn: + own_ws = ( + "SELECT w.id FROM workspaces w WHERE w.owner_id = ? " + "UNION SELECT wm.workspace_id FROM workspace_members wm WHERE wm.user_id = ?" + ) + own_ws_names = ( + "SELECT w.name FROM workspaces w WHERE w.owner_id = ? " + "UNION SELECT w.name FROM workspaces w " + "JOIN workspace_members wm ON wm.workspace_id = w.id WHERE wm.user_id = ?" + ) + # Scope "shared by me"-style lists to pages in the user's own workspaces + # (or legacy pages whose workspace_id is NULL but identify the workspace by text). + scope_cond = ( + f"(workspace_id IN ({own_ws}) " + f"OR (workspace_id IS NULL AND lower(workspace) IN " + f"(SELECT lower(name) FROM ({own_ws_names}))) " + f"OR (workspace_id IS NULL AND lower(workspace) = lower(" + f"(SELECT login FROM users WHERE id=?))))" + ) + scope_params = (user_id, user_id, user_id, user_id, user_id) + + made_nominal = conn.execute( + "SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s " + "JOIN pages p ON p.id=s.page_id " + "WHERE s.created_by=? AND p.deleted_at IS NULL", + (user_id,), + ).fetchall() + made_link = conn.execute( + f"SELECT id, title, workspace, updated_at FROM pages " + f"WHERE share_mode='anyone' AND published=0 AND deleted_at IS NULL " + f"AND {scope_cond}", + scope_params, + ).fetchall() + made_flag = conn.execute( + f"SELECT id, title, workspace, updated_at FROM pages " + f"WHERE (is_shared=1 OR share_mode != 'private') AND COALESCE(published,0)=0 AND deleted_at IS NULL " + f"AND {scope_cond}", + scope_params, + ).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"ORDER BY updated_at DESC LIMIT 20", + scope_params, + ).fetchall() + try: + received_rows = conn.execute( + "SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s " + "JOIN pages p ON p.id=s.page_id " + "LEFT JOIN group_members gm ON gm.group_id = s.shared_with_group_id AND gm.user_id=? " + "WHERE (s.shared_with_user_id=? OR gm.user_id=?) AND p.deleted_at IS NULL", + (user_id, user_id, user_id), + ).fetchall() + except Exception: + received_rows = conn.execute( + "SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s " + "JOIN pages p ON p.id=s.page_id " + "WHERE s.shared_with_user_id=? AND p.deleted_at IS NULL", + (user_id,), + ).fetchall() + + def _entry(r, icon): + return { + "id": f"page/{r['id']}", + "db_id": r["id"], + "name": r["title"] or "New page", + "icon": icon, + "url": f"/pages/{r['id']}", + "active": False, + "indent": 0, + "depth": 0, + "has_children": False, + "children": [], + } + + made_map = {} + for r in (*made_nominal, *made_link, *made_flag): + made_map.setdefault(r["id"], r) + made_sorted = sorted(made_map.values(), key=lambda r: r["updated_at"] or "", reverse=True)[:20] + shared_made = [_entry(r, "link") for r in made_sorted] + received_sorted = [r for r in received_rows if r["id"] not in made_map] + received_sorted = sorted(received_sorted, key=lambda r: r["updated_at"] or "", reverse=True)[:20] + shared_received = [_entry(r, "users") for r in received_sorted] + published = [_entry(r, "globe") for r in published_rows] + shared_all = [_entry(r, "link") for r in made_sorted] + return shared_made, shared_received, published, shared_all + + + + + + +def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict: + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + ws_name = user.get("login", "Bruno") if user else "Bruno" + ws_key = f"{owner}/{repo}" if owner and repo else ws_name # workspace = project context + + # Active workspace name from cookie (for local workspace display) + from app.routers.dashboard import WORKSPACE_COOKIE + ws_cookie = request.cookies.get(WORKSPACE_COOKIE, "") + active_ws_name = "Workspace" + workspace_pages = [] + gitea_workspace = False + gitea_owner = "" + gitea_repo = "" + has_active_workspace = False + local_ws_id = 0 + + if ws_cookie and ws_cookie.startswith("gitea:"): + # Gitea workspace: preserve context across pages. Also load the local + # mirror workspace so it appears in "My Workspaces" in the top section + # of the sidebar, in parallel with the Gitea repository tree. + parts = ws_cookie.split(":", 2) + if len(parts) >= 3: + gitea_owner = parts[1] + gitea_repo = parts[2] + active_ws_name = f"{gitea_owner}/{gitea_repo}" + gitea_workspace = True + has_active_workspace = True + # Get local workspace ID for mirror and load its tree + if user: + try: + with get_conn() as conn: + row = conn.execute( + "SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?", + (user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%") + ).fetchone() + if row: + local_ws_id = row["id"] + workspace_pages = _load_workspace_pages(str(local_ws_id)) + except Exception: + logger.exception("_sidebar_data") + elif ws_cookie and user: + try: + wsi = int(ws_cookie) + with get_conn() as conn: + row = conn.execute( + "SELECT id, name, owner_id FROM workspaces WHERE id=? AND owner_id=?", + (wsi, user["id"]) + ).fetchone() + if row: + active_ws_name = row["name"] + workspace_pages = _load_workspace_pages(ws_cookie) + has_active_workspace = True + except (ValueError, Exception): + pass + recent = [] + if owner and repo: + view_map = { + "Kanban board": "kanban", "Detailed board": "detailed", + "Table view": "table", "Status overview": "status", "Team Load": "teamload", + } + first = True + for label, view in view_map.items(): + indent = 0 if first else 1 + active = first + recent.append({ + "id": f"{owner}/{repo}/{view}", + "name": label, "icon": "folder" if first else "", + "url": f"/board/{owner}/{repo}?view={view}", + "active": active, "indent": indent, + "depth": indent, "has_children": False, "children": [], + }) + first = False + # Load pages as nested tree for this project workspace + with get_conn() as conn: + tree_pages = _build_page_tree(conn, None, ws_key) + for p in tree_pages: + recent.append(p) + # Private pages: same as recent but filtered for page/ items (non-board views) + private_items = [r for r in recent if r.get("active") or r["id"].startswith("page/")] + + # Load favorite pages from DB + 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 " + "JOIN pages p ON p.id = f.page_id " + "WHERE f.user_id=? ORDER BY f.position", (uid,) + ).fetchall() + favorites = [] + for r in fav_rows: + favorites.append({ + "id": f"page/{r['id']}", + "db_id": r["id"], + "name": r["title"] or "New page", + "icon": "📄", + "url": f"/pages/{r['id']}", + "active": False, + "indent": 0, + "depth": 0, + "has_children": False, + "children": [], + }) + + # Load shared pages + shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(uid) + + # Auth method & OAuth badge data + auth_method = "local" + gitea_linked = False + github_linked = False + if user and user.get("id"): + try: + with get_conn() as conn: + am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone() + if am_row and am_row["auth_method"]: + auth_method = am_row["auth_method"] + tokens = conn.execute( + "SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],) + ).fetchall() + for t in tokens: + if t["provider"] == "gitea": + gitea_linked = True + elif t["provider"] == "github": + github_linked = True + except Exception: + logger.exception("_sidebar_data") + + return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B", + "active_ws_name": active_ws_name, + "workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else ws_key, + "workspace_pages": workspace_pages, + "gitea_workspace": gitea_workspace, + "gitea_owner": gitea_owner, + "gitea_repo": gitea_repo, + "local_ws_id": local_ws_id, + "current_page": repo or "Dashboard", "last_edited": "now", + "recent_pages": recent, "private_pages": private_items, + "favorite_pages": favorites, "shared_pages": shared_pages, + "shared_made_pages": shared_made_pages, + "shared_received_pages": shared_received_pages, + "published_pages": published_pages, + "user": user, + "auth_method": auth_method, + "gitea_linked": gitea_linked, + "github_linked": github_linked, + "has_active_workspace": has_active_workspace, + "app_version": _get_app_version(), + "local_workspaces": _local_workspaces_for_user(user), + "sidebar_config": get_sidebar_config_sync(uid)} + + + + + + +def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""): + """Extract and persist AI keywords from issue labels and body.""" + if not owner or not repo: + return + candidates = set() + for lbl in labels: + name = lbl.get("name", "").strip().lower() + if name and len(name) > 1: + candidates.add(name) + # Simple extraction from body: single words > 3 chars + for word in re.findall(r'\b[a-zA-Z]{4,}\b', body.lower()): + if word not in ("this", "that", "with", "from", "have", "when", "will"): + candidates.add(word) + + with get_conn() as conn: + for kw in candidates: + kw = kw[:30] + existing = conn.execute( + "SELECT id, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? AND keyword=?", + (owner, repo, kw), + ).fetchone() + if existing: + conn.execute("UPDATE ai_keywords SET usage_count=? WHERE id=?", + (existing["usage_count"] + 1, existing["id"])) + else: + color_idx = len(candidates) % len(AI_KEYWORD_COLORS) + conn.execute( + "INSERT INTO ai_keywords (project_owner, project_name, keyword, color) VALUES (?,?,?,?)", + (owner, repo, kw, AI_KEYWORD_COLORS[color_idx]), + ) + conn.commit() + + + + + + +def _get_project_properties(owner: str, repo: str) -> list[dict]: + with get_conn() as conn: + rows = conn.execute( + "SELECT * FROM project_properties WHERE project_owner=? AND project_name=? ORDER BY position", + (owner, repo), + ).fetchall() + return [dict(r) for r in rows] + + + + + + + + + + +async def asyncio_get_labels(owner: str, repo: str): + return await gitea.get_labels(owner, repo) + + + + + + +def _apply_filters(cards: list[dict], status_filter: str, filters: str) -> list[dict]: + if status_filter: + allowed = set(status_filter.split(",")) + cards = [c for c in cards if c["status"] in allowed] + if filters: + for f in filters.split(","): + if ":" in f: + prop, val = f.split(":", 1) + val_lower = val.lower() + if prop == "assignee": + cards = [c for c in cards if c.get("assignee", "").lower() == val_lower] + elif prop == "tag": + cards = [c for c in cards if (c.get("tag") or "").lower() == val_lower] + elif prop == "keyword": + cards = [c for c in cards if any(val_lower in kw.get("name", "").lower() for kw in c.get("keywords", []))] + return cards + + + + + + +def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]: + if not sorts: + return cards + order = {"todo": 0, "progress": 1, "done": 2} + for spec in reversed(sorts.split(",")): + if ":" not in spec: + continue + field, direction = spec.split(":", 1) + rev = direction == "desc" + if field == "name": + cards.sort(key=lambda c: c["title"].lower(), reverse=rev) + elif field == "status": + cards.sort(key=lambda c: order.get(c["status"], 0), reverse=rev) + elif field == "assignee": + cards.sort(key=lambda c: c.get("assignee", "").lower(), reverse=rev) + elif field == "deadline": + cards.sort(key=lambda c: c.get("due_date", ""), reverse=rev) + return cards + + +# ═══════════ Library page ═══════════ + + + + + +def _record_version(conn, page_id: int, user_id, title: str, blocks_json: str) -> None: + """Insert a version snapshot unless it is byte-identical to the latest one.""" + prev = conn.execute( + "SELECT COALESCE(title, ''), blocks_json FROM page_versions " + "WHERE page_id=? ORDER BY id DESC LIMIT 1", + (page_id,), + ).fetchone() + if prev is not None and prev["blocks_json"] == blocks_json: + if prev["title"] != (title or ""): + conn.execute( + "UPDATE page_versions SET title=? WHERE id=" + "(SELECT id FROM page_versions WHERE page_id=? ORDER BY id DESC LIMIT 1)", + (title or "", page_id), + ) + return + conn.execute( + "INSERT INTO page_versions (page_id, user_id, title, blocks_json, note) VALUES (?,?,?,?,'edited')", + (page_id, user_id, title or "", blocks_json), + ) + + + + + + +def _block_texts(b: dict) -> list[str]: + """Flatten a block (including children) into searchable text chunks.""" + out = [] + raw = b.get("content") + if isinstance(raw, str) and raw.strip(): + out.append(raw) + for child in b.get("children") or []: + out.extend(_block_texts(child)) + return out + + + + + + +# ═══════════ v5.5.0: Cover & icon ═══════════ + + +def _upload_root() -> Path: + return Path(settings.data_dir) + + + + + + +def _ws_id_for(request: Request, page_id: int) -> int: + """The active workspace id for the page (cookie, then page, then fallback 1).""" + cookie = request.cookies.get("flowdeck_workspace", "") + try: + ws_id = int(cookie) + if ws_id > 0: + return ws_id + except (ValueError, TypeError): + pass + with get_conn() as conn: + row = conn.execute( + "SELECT workspace_id FROM pages WHERE id=?", (page_id,) + ).fetchone() + if row and row["workspace_id"]: + return int(row["workspace_id"]) + return 1 + + + + + + +async def _store_uploaded_file(request: Request, ws_id: int) -> dict: + """Persist an uploaded file under uploads/workspace_{ws_id}/ and return + {file_url, file_path, mime_type, size, file_name}.""" + import datetime + import re as _re + + form = await request.form() + upload = form.get("file") + if upload is None or not hasattr(upload, "filename"): + raise HTTPException(400, "file field required") + original = (upload.filename or "cover.png").replace("\\", "/").rsplit("/", 1)[-1] + name = _re.sub(r"[^A-Za-z0-9._-]", "_", original)[:120] + ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin" + if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}: + raise HTTPException(400, "Unsupported image format") + stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S") + folder = _upload_root() / f"uploads/workspace_{ws_id}" + folder.mkdir(parents=True, exist_ok=True) + final = f"{stamp}_{name}" + (folder / final).write_bytes(await upload.read()) + mime = f"image/{'svg+xml' if ext == 'svg' else 'jpeg' if ext == 'jpg' else ext}" + return { + "file_url": f"/api/files/{ws_id}/{final}", + "file_path": f"uploads/workspace_{ws_id}/{final}", + "mime_type": mime, + "size": (folder / final).stat().st_size, + "file_name": name, + } + + + + + + +# ═══════════ v5.4.0: Import (Markdown / .md / Notion .zip) ═══════════ + + +async def _create_page_from_markdown(request: Request, markdown: str, title: str = "") -> int: + """Convert markdown → blocks (server-side, same mapping as the editor) and + create a page in the caller's workspace.""" + from app.services.export import _md_to_blocks + + blocks = _md_to_blocks(markdown or "") + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + ws_key = user.get("login", "Bruno") if user else "Bruno" + file_title = title.strip() or "Import" + fallback = (file_title or "Imported page").replace("/", "-").replace("\\", "-")[:120] + if not blocks: + blocks = [{"type": "paragraph", "content": markdown or ""}] + with get_conn() as conn: + next_order = conn.execute( + "SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL", + (ws_key,), + ).fetchone()[0] + cur = conn.execute( + "INSERT INTO pages (workspace, title, content, content_format, parent_section, sort_order, workspace_id) " + "VALUES (?,?,?,?,'Private',?,(SELECT id FROM workspaces WHERE name=? LIMIT 1))", + (ws_key, fallback, json.dumps(blocks), "blocks", next_order, ws_key), + ) + conn.commit() + return cur.lastrowid + + + + + + +async def _unfurl_repo(forge: str, owner: str, repo: str): + """Resolve a ``gitea:owner/repo`` / ``github:owner/repo`` ref.""" + try: + if forge == "gitea": + from app.services.gitea_client import GiteaClient + info = await GiteaClient().get_repo_info(owner, repo) + site = "Gitea" + else: + from app.config import settings + from app.services.github_adapter import GitHubAdapter + token = getattr(settings, "github_token", None) or "" + if token: + info = await GitHubAdapter(access_token=token).get_repo_info(owner, repo) + else: + async with shared_client(timeout=10) as client: + r = await client.get( + f"https://api.github.com/repos/{owner}/{repo}", + headers={"Accept": "application/vnd.github+json"}, + ) + r.raise_for_status() + info = r.json() + site = "GitHub" + except Exception as exc: # noqa: BLE001 — forge lookup is best-effort + logging.getLogger(__name__).debug("unfurl %s failed: %s", forge, exc) + return None + branch = info.get("default_branch") or "main" + return { + "url": info.get("html_url") or f"https://{forge}.com/{owner}/{repo}", + "title": info.get("full_name") or f"{owner}/{repo}", + "description": (info.get("description") or f"{site} repository " + f"{owner}/{repo} · default branch: {branch}"), + "image": "", + "site_name": site, + "language": info.get("language") or "", + } + + + diff --git a/app/routers/board/board_views.py b/app/routers/board/board_views.py new file mode 100644 index 0000000..219d02f --- /dev/null +++ b/app/routers/board/board_views.py @@ -0,0 +1,223 @@ +"""FlowDeck — Board : board_views. + +Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé. +""" +from __future__ import annotations + +import json +import logging + +from fastapi import APIRouter, HTTPException, Query, Request +from fastapi.responses import HTMLResponse + +from app.db import get_conn +from app.services.gitea_client import gitea + +from ._common import ( + STATUS_COLORS, + STATUS_LABELS, + _apply_filters, + _apply_sorts, + _extract_ai_keywords, + _get_project_properties, + _map_issue_to_card, + _sidebar_data, +) + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["board"], prefix="/board") + + + + +# ═══════════ Board page ═══════════ + +@router.get("/{owner}/{repo}", response_class=HTMLResponse) +def board(request: Request, owner: str, repo: str, view: str = Query(default="")): + from app.templating import ENV + env = ENV + sidebar = _sidebar_data(request, owner, repo) + template = env.get_template("board.html") + return template.render(request=request, owner=owner, repo=repo, groups=[], + initial_view=view, **sidebar) + + +# ═══════════ View fragments ═══════════ + + + + +# ═══════════ View fragments ═══════════ + +@router.get("/{owner}/{repo}/view/{view}", response_class=HTMLResponse) +async def board_view( + request: Request, owner: str, repo: str, view: str, + status: str = Query(default=""), + filter: str = Query(default=""), + sort: str = Query(default=""), +): + try: + issues = await gitea.get_issues(owner, repo, state="all") + issues_only = [i for i in issues if not i.get("pull_request")] + cards = [_map_issue_to_card(i, owner, repo) for i in issues_only] + cards = _apply_filters(cards, status, filter) + cards = _apply_sorts(cards, sort) + except Exception as e: + logger.error("Board view error: %s", e) + cards = [] + + from app.templating import ENV + env = ENV + + # Dynamic groups from Gitea labels (fallback to hardcoded) + group_names = ["Design", "Engineering", "No Team"] + groups = [] + for gname in group_names: + gid = gname.lower().replace(" ", "-") + gcards = cards + groups.append({ + "id": gid, "name": gname, + "counts": { + "todo": len([c for c in gcards if c["status"] == "todo"]), + "progress": len([c for c in gcards if c["status"] == "progress"]), + "done": len([c for c in gcards if c["status"] == "done"]), + }, + "cards": gcards, + }) + + ctx = {"owner": owner, "repo": repo, "groups": groups, "cards": cards} + + template_map = { + "table": "table_view.html", + "status": "status_overview.html", + "teamload": "team_load.html", + "detailed": "detailed_board.html", + } + + if view == "table": + grouped = {g["name"]: g["cards"] for g in groups} + ctx["grouped_cards"] = grouped + elif view == "status": + counts = {"todo": 0, "progress": 0, "done": 0} + for c in cards: + if c["status"] in counts: + counts[c["status"]] += 1 + ctx.update(status_data=counts, status_colors=STATUS_COLORS, status_labels=STATUS_LABELS) + elif view == "teamload": + members = {} + for c in cards: + name = c.get("assignee") or "Unassigned" + if name not in members: + members[name] = {"name": name, "initial": name[0].upper(), + "todo": 0, "progress": 0, "complete": 0, "total": 0} + sk = c["status"] if c["status"] in ("todo", "progress") else "complete" + members[name][sk] += 1 + members[name]["total"] += 1 + ctx["team_data"] = list(members.values()) + elif view == "detailed": + pass + else: + template_map["kanban"] = "board_fragment.html" + + template_name = template_map.get(view, "board_fragment.html") + template = env.get_template(template_name) + return template.render(**ctx) + + +# ═══════════ v0.9.0: Custom Properties API ═══════════ + + + + +# ═══════════ v0.9.0: Custom Properties API ═══════════ + +@router.get("/api/properties/{owner}/{repo}") +def get_properties(owner: str, repo: str): + return {"properties": _get_project_properties(owner, repo)} + + + + +@router.post("/api/properties/{owner}/{repo}") +def create_property(owner: str, repo: str, name: str = Query(...), + prop_type: str = Query(default="select"), + options: str = Query(default="")): + opts = json.dumps([o.strip() for o in options.split(",") if o.strip()]) + with get_conn() as conn: + try: + conn.execute( + "INSERT INTO project_properties (project_owner, project_name, name, prop_type, options_json) VALUES (?,?,?,?,?)", + (owner, repo, name, prop_type, opts), + ) + conn.commit() + except Exception as e: + raise HTTPException(409, f"Property already exists: {e}") from e + return {"status": "ok", "name": name, "type": prop_type} + + + + +@router.delete("/api/properties/{owner}/{repo}") +def delete_property(owner: str, repo: str, name: str = Query(...)): + with get_conn() as conn: + conn.execute( + "DELETE FROM project_properties WHERE project_owner=? AND project_name=? AND name=?", + (owner, repo, name), + ) + conn.commit() + return {"status": "ok"} + + + + +@router.post("/api/properties/{owner}/{repo}/values") +def set_property_value(owner: str, repo: str, issue_id: int = Query(...), + name: str = Query(...), value: str = Query(default="")): + with get_conn() as conn: + prop = conn.execute( + "SELECT id FROM project_properties WHERE project_owner=? AND project_name=? AND name=?", + (owner, repo, name), + ).fetchone() + if not prop: + raise HTTPException(404, f"Property '{name}' not found") + conn.execute( + "INSERT OR REPLACE INTO property_values (property_id, gitea_issue_id, value) VALUES (?,?,?)", + (prop["id"], issue_id, value), + ) + conn.commit() + return {"status": "ok"} + + +# ═══════════ v0.9.0: AI Keywords API ═══════════ + + + + +# ═══════════ v0.9.0: AI Keywords API ═══════════ + +@router.get("/api/ai-keywords/{owner}/{repo}") +def get_ai_keywords(owner: str, repo: str): + with get_conn() as conn: + rows = conn.execute( + "SELECT keyword, color, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC LIMIT 30", + (owner, repo), + ).fetchall() + return {"keywords": [dict(r) for r in rows]} + + + + +@router.post("/api/ai-keywords/{owner}/{repo}/extract") +async def extract_ai_keywords(owner: str, repo: str): + """Re-extract keywords from all issues in the repo.""" + try: + issues = await gitea.get_issues(owner, repo, state="all") + for issue in issues: + if not issue.get("pull_request"): + _extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", "")) + except Exception as e: + raise HTTPException(500, str(e)) from e + return {"status": "ok", "issues_scanned": len(issues)} + + +# ═══════════ Pages Markdown ═══════════ diff --git a/app/routers/board/embed.py b/app/routers/board/embed.py new file mode 100644 index 0000000..c1aa250 --- /dev/null +++ b/app/routers/board/embed.py @@ -0,0 +1,64 @@ +"""FlowDeck — Board : embed. + +Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé. +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Body, HTTPException, Request + +from app.config import settings + +from ._common import _REPO_REF_RE, _unfurl_repo + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["board"], prefix="/board") + + + + +@router.post("/api/og/metadata") +async def og_metadata(request: Request): + """v5.5.0: Open Graph metadata for a bookmark card. + + v7.3.0: ``gitea:owner/repo`` and ``github:owner/repo`` schemes are + unfurled straight from the forge API (no HTTP fetch of the HTML page). + """ + try: + body = await request.json() + except Exception: + raise HTTPException(400, "Invalid JSON body") from None + url = (body.get("url") or "").strip() + if not url: + raise HTTPException(400, "url required") + m = _REPO_REF_RE.match(url) + if m: + forge, owner, repo = m.group(1).lower(), m.group(2), m.group(3) + data = await _unfurl_repo(forge, owner, repo) + if data: + return {"ok": True, **data} + from app.services.og_fetcher import fetch_og_metadata + try: + data = await fetch_og_metadata(url) + except ValueError as exc: + # A12 : SSRF — URL vers un hôte privé/loopback (ou redirection vers l'un). + raise HTTPException(400, str(exc)) from None + return {"ok": True, **data} + + + + +@router.post("/api/embed/resolve") +def resolve_embed(request: Request, body: dict = Body(...)): + """v5.5.0: rewrite a pasted URL to its provider embed src. + + Powers the universal ``/embed`` block (YouTube, Vimeo, Figma, Maps, + Docs, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter…). + """ + url = (body.get("url") or "").strip() + if not url: + raise HTTPException(400, "url required") + from app.services.embeds import resolve_embed as _resolve + data = _resolve(url, parent=settings.app_base_url) + return {"ok": True, "url": url, **data} diff --git a/app/routers/board/import_.py b/app/routers/board/import_.py new file mode 100644 index 0000000..724ae69 --- /dev/null +++ b/app/routers/board/import_.py @@ -0,0 +1,85 @@ +"""FlowDeck — Board : import_. + +Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé. +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, HTTPException, Request + +from app.services.automations import fire_event + +from ._common import _create_page_from_markdown + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["board"], prefix="/board") + + + + +@router.post("/api/pages/import") +async def import_page(request: Request): + """v5.4.0: import markdown text as a new page (blocks) in the workspace.""" + try: + body = await request.json() + except Exception: + raise HTTPException(400, "Invalid JSON body") from None + markdown = body.get("markdown", "") + title = body.get("title", "") + if not markdown and not body.get("csv"): + raise HTTPException(400, "markdown field required") + if not markdown.strip(): + raise HTTPException(400, "markdown is empty") + page_id = await _create_page_from_markdown(request, markdown, title) + await fire_event("page.created", {"page_id": page_id, "title": title or "Import", + "workspace": ""}) + return {"status": "ok", "id": page_id} + + + + +@router.post("/api/pages/import/file") +async def import_file(request: Request): + """v5.4.0: import an uploaded .md file (or a Notion export .zip containing + markdown pages) into the workspace. Returns the created page ids.""" + import io as _io + import zipfile + + form = await request.form() + upload = form.get("file") + if upload is None or not hasattr(upload, "filename"): + raise HTTPException(400, "file field required") + filename = (upload.filename or "import.md").replace("\\", "/").rsplit("/", 1)[-1] + data = await upload.read() + created_ids = [] + + if filename.lower().endswith(".zip"): + try: + zf = zipfile.ZipFile(_io.BytesIO(data)) + except zipfile.BadZipFile: + raise HTTPException(400, "Invalid zip archive") from None + md_entries = sorted( + (n for n in zf.namelist() if n.lower().endswith((".md", ".markdown"))), + key=lambda n: (n.count("/"), n.lower()), + ) + if not md_entries: + raise HTTPException(400, "No .md files found in archive") + for name in md_entries: + raw = zf.read(name).decode("utf-8", errors="replace") + title = name.replace("\\", "/").rsplit("/", 1)[-1][:-3] + try: + created_ids.append(await _create_page_from_markdown(request, raw, title)) + except Exception as exc: # noqa: BLE001 - keep importing the rest + logger.warning("import failed for %s: %s", name, exc) + else: + try: + raw = data.decode("utf-8") + except UnicodeDecodeError: + raise HTTPException(400, "Only text/markdown files are supported") from None + title = filename.replace(".md", "").replace(".markdown", "").replace(".txt", "") + created_ids.append(await _create_page_from_markdown(request, raw, title)) + + if not created_ids: + raise HTTPException(422, "No pages could be imported") + return {"status": "ok", "ids": created_ids, "count": len(created_ids)} diff --git a/app/routers/board/library.py b/app/routers/board/library.py new file mode 100644 index 0000000..a6be0b9 --- /dev/null +++ b/app/routers/board/library.py @@ -0,0 +1,66 @@ +"""FlowDeck — Board : library. + +Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé. +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Query, Request +from fastapi.responses import HTMLResponse + +from app.db import get_conn + +from ._common import _sidebar_data + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["board"], prefix="/board") + + + + +# ═══════════ Library page ═══════════ + +@router.get("/library", response_class=HTMLResponse) +def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")): + """Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace.""" + from app.templating import ENV + env = ENV + sidebar = _sidebar_data(request, owner, repo) + # Load all pages for the workspace from DB + ws_key = f"{owner}/{repo}" if owner and repo else "" + with get_conn() as conn: + if ws_key: + rows = conn.execute( + "SELECT id, title, workspace, updated_at FROM pages " + "WHERE workspace=? AND collection_row_id IS NULL ORDER BY updated_at DESC", + (ws_key,), + ).fetchall() + else: + rows = conn.execute( + "SELECT id, title, workspace, updated_at FROM pages " + "WHERE collection_row_id IS NULL ORDER BY updated_at DESC", + ).fetchall() + all_pages = [] + for r in rows: + page = dict(r) + all_pages.append({ + "id": f"page/{page['id']}", + "name": page["title"] or "Untitled", + "icon": "📄", + "url": f"/pages/{page['id']}", + "created_by": "You", + "source": page.get("workspace") or "Private", + "last_edited": page.get("updated_at", "now"), + "last_visited": page.get("updated_at", "now"), + }) + sidebar["recent_pages"] = all_pages + sidebar["favorite_pages"] = [] + sidebar["private_pages"] = [p for p in all_pages if p.get("source") == "🔒 Private"] + sidebar["shared_pages"] = [] + sidebar["shared_made_pages"] = [] + sidebar["shared_received_pages"] = [] + template = env.get_template("library.html") + return template.render(**sidebar) + +# ═══════════ Favorites API ═══════════ diff --git a/app/routers/board/page_api.py b/app/routers/board/page_api.py new file mode 100644 index 0000000..f56e2d0 --- /dev/null +++ b/app/routers/board/page_api.py @@ -0,0 +1,229 @@ +"""FlowDeck — Board : page_api. + +Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé. +""" +from __future__ import annotations + +import json +import logging + +from fastapi import APIRouter, Body, HTTPException, Request + +from app.auth.session import SessionManager +from app.db import get_conn +from app.services.automations import fire_event, run_event_sync + +from ._common import _ensure_block_ids + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["board"], prefix="/board") + + + + +@router.post("/api/pages/{page_id}/lock") +def set_page_lock(request: Request, page_id: int, body: dict = Body(default={})): + """v5.12.0: lock/unlock a page (read-only for everyone except the locker, + admins and the page creator).""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user or not user.get("id"): + raise HTTPException(401, "Authentication required") + locked = bool(body.get("locked")) + with get_conn() as conn: + row = conn.execute("SELECT id, is_locked, locked_by, workspace FROM pages WHERE id=?", + (page_id,)).fetchone() + if not row: + raise HTTPException(404, "Page not found") + is_admin = 1 if user.get("is_admin") else 0 + if row["is_locked"] and not is_admin and row["locked_by"] != user["id"]: + raise HTTPException(403, "Only the person who locked this page (or an admin) can unlock it") + conn.execute("UPDATE pages SET is_locked=?, locked_by=? WHERE id=?", + (1 if locked else 0, user["id"] if locked else None, page_id)) + conn.commit() + run_event_sync(fire_event("page.locked" if locked else "page.unlocked", + {"page_id": page_id, "by": user["id"]})) + return {"status": "ok", "is_locked": int(locked)} + + + + +@router.post("/api/pages/{page_id}/options") +def set_page_options(request: Request, page_id: int, body: dict = Body(default={})): + """v5.12.0: page layout options — full-width and compact typography.""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user or not user.get("id"): + raise HTTPException(401, "Authentication required") + updates = {} + for key in ("full_width", "font_small"): + if key in body: + updates[key] = 1 if body[key] else 0 + if not updates: + raise HTTPException(400, "nothing to update") + sets = ", ".join(f"{k}=?" for k in updates) + with get_conn() as conn: + row = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone() + if not row: + raise HTTPException(404, "Page not found") + conn.execute(f"UPDATE pages SET {sets}, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (*updates.values(), page_id)) + conn.commit() + return {"status": "ok", **{k: bool(v) for k, v in updates.items()}} + + + + +@router.get("/api/page-templates") +def list_page_templates_api(request: Request): + """v5.12.0: built-in + user global page templates for the picker.""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + uid = (user or {}).get("id") + from app.services.block_templates import template_list + with get_conn() as conn: + rows = conn.execute( + """SELECT id, name, icon, description, created_by + FROM page_global_templates + WHERE created_by IS NULL OR created_by=? + ORDER BY created_at""", + (uid,), + ).fetchall() + mine = [dict(r) for r in rows] + for t in mine: + t["builtin"] = False + return {"templates": template_list() + mine} + + + + +@router.post("/api/page-templates") +def create_page_template(request: Request, body: dict = Body(default={})): + """v5.12.0: save the current page (or a raw block list) as a personal + global template: {name, icon?, description?, page_id? | blocks?}.""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user or not user.get("id"): + raise HTTPException(401, "Authentication required") + name = (body.get("name") or "").strip() + if not name: + raise HTTPException(400, "name is required") + blocks = body.get("blocks") + if body.get("page_id"): + with get_conn() as conn: + row = conn.execute("SELECT content, content_format FROM pages WHERE id=?", + (int(body["page_id"]),)).fetchone() + if not row: + raise HTTPException(404, "Page not found") + if row["content_format"] == "blocks" and row["content"]: + try: + blocks = json.loads(row["content"]) + except (json.JSONDecodeError, TypeError): + raise HTTPException(400, "Page content is not block JSON") from None + if not isinstance(blocks, list) or not blocks: + raise HTTPException(400, "blocks (or page_id) required") + with get_conn() as conn: + cur = conn.execute( + """INSERT INTO page_global_templates (name, icon, description, blocks_json, created_by) + VALUES (?, ?, ?, ?, ?)""", + (name, body.get("icon") or "📄", body.get("description") or "", + json.dumps(blocks), user["id"]), + ) + conn.commit() + tid = cur.lastrowid + return {"status": "ok", "id": tid} + + + + +@router.post("/api/page-templates/{template_id}/use") +def use_page_template(request: Request, template_id: int, body: dict = Body(default={})): + """v5.12.0: instantiate a page from a template (built-in or user). + + Body: {key?} for built-ins OR uses the row id for user templates. + Creates 'blocks'-format page in the caller's workspace and returns its id. + """ + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + title = (body.get("title") or "").strip() + blocks_json = None + if template_id == 0: + from app.services.block_templates import blocks_json_for + key = body.get("key") or "empty" + blocks_json = blocks_json_for(key) + name = key + if blocks_json is None: + raise HTTPException(404, "Unknown built-in template") + else: + with get_conn() as conn: + uid = (user or {}).get("id") + row = conn.execute( + "SELECT * FROM page_global_templates WHERE id=? AND (created_by IS NULL OR created_by=?)", + (template_id, uid), + ).fetchone() + if not row: + raise HTTPException(404, "Template not found") + blocks_json = row["blocks_json"] + name = row["name"] + title = title or row["name"] + # Resolve the target workspace so the new page actually shows up in the + # active local workspace (bugfix: template pages previously got + # workspace_id = NULL and never appeared in the sidebar/tree). + uid = (user or {}).get("id") + ws_id_raw = body.get("workspace_id") + ws_id = None + if ws_id_raw is not None: + try: + ws_id = int(ws_id_raw) + except (TypeError, ValueError): + ws_id = None + ws_key = user.get("login", "Bruno") if user else "Bruno" + if ws_id is not None: + with get_conn() as conn: + ws_row = conn.execute( + "SELECT id, name, owner_id FROM workspaces WHERE id=?", (ws_id,) + ).fetchone() + if ws_row and (uid is None or ws_row["owner_id"] == uid): + ws_key = ws_row["name"] or ws_key + else: + ws_id = None + else: + body_ws = (body.get("workspace") or "").strip() + if body_ws: + ws_key = body_ws + # Optional target folder: instantiate the template as a child of it. + parent_id = body.get("parent_id") + try: + parent_id = int(parent_id) if parent_id not in (None, "", 0, "0") else None + except (TypeError, ValueError): + parent_id = None + try: + parsed_blocks = json.loads(blocks_json) + except (json.JSONDecodeError, TypeError): + raise HTTPException(500, "Template content corrupted") from None + _ensure_block_ids(parsed_blocks) + blocks_json = json.dumps(parsed_blocks) + with get_conn() as conn: + if parent_id is not None: + next_order = conn.execute( + "SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE parent_id=?", + (parent_id,), + ).fetchone()[0] + elif ws_id is not None: + next_order = conn.execute( + "SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace_id=? AND parent_id IS NULL", + (ws_id,), + ).fetchone()[0] + else: + next_order = conn.execute( + "SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL", + (ws_key,), + ).fetchone()[0] + cur = conn.execute( + """INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id, sort_order) + VALUES (?,?,?,?,?, 'Private', ?, ?)""", + (ws_key, ws_id, title or name, blocks_json, "blocks", parent_id, next_order), + ) + conn.commit() + page_id = cur.lastrowid + run_event_sync(fire_event("page.created", {"page_id": page_id, "title": title or name, + "workspace": ws_key, "from_template": name})) + return {"status": "ok", "id": page_id, "title": title or name} + + +# ── Core helpers ── diff --git a/app/routers/board/page_media.py b/app/routers/board/page_media.py new file mode 100644 index 0000000..9067919 --- /dev/null +++ b/app/routers/board/page_media.py @@ -0,0 +1,122 @@ +"""FlowDeck — Board : page_media. + +Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé. +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Body, HTTPException, Request + +from app.auth.session import SessionManager +from app.db import get_conn +from app.services.automations import fire_event, run_event_sync + +from ._common import _store_uploaded_file, _ws_id_for + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["board"], prefix="/board") + + + + +# ═══════════ v5.4.0: Page & collection duplication ═══════════ + + +@router.post("/api/pages/{page_id}/duplicate") +def duplicate_page(request: Request, page_id: int): + """Duplicate a page (block/markdown content included) as a sibling.""" + SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + with get_conn() as conn: + row = conn.execute( + "SELECT * FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,) + ).fetchone() + if not row: + raise HTTPException(404, "Page not found") + page = dict(row) + + def copy_tree(src_id: int, parent_id) -> int: + with get_conn() as conn: + conn.execute("SELECT * FROM pages WHERE id=?", (src_id,)).fetchone() + cur = conn.execute( + "INSERT INTO pages (workspace, workspace_id, title, content, content_format, " + "parent_section, parent_id, sort_order, share_mode, published, is_published, " + "publish_slug, is_shared, cover_url, page_icon, created_at, updated_at) " + "SELECT workspace, workspace_id, title || ' copy', content, content_format, " + "parent_section, ?, sort_order, share_mode, 0, is_published, '', is_shared, " + "cover_url, page_icon, created_at, updated_at FROM pages WHERE id=?", + (parent_id, src_id), + ) + new_id = cur.lastrowid + conn.commit() + for child in conn.execute( + "SELECT id FROM pages WHERE parent_id=? ", (src_id,) + ).fetchall(): + copy_tree(child["id"], new_id) + return new_id + + new_id = copy_tree(page_id, page.get("parent_id")) + title = (page.get("title") or "Untitled") + " copy" + with get_conn() as conn: + conn.execute("UPDATE pages SET title=? WHERE id=?", (title, new_id)) + conn.commit() + run_event_sync(fire_event("page.created", {"page_id": new_id, "title": title, + "workspace": page.get("workspace")})) + return {"status": "ok", "id": new_id, "title": title} + + +# ═══════════ v5.5.0: Cover & icon ═══════════ + + + + +@router.post("/api/pages/{page_id}/cover") +async def set_page_cover(request: Request, page_id: int): + """v5.5.0: upload an image cover for a page. + + JSON body {cover_url} accepts an external URL; multipart ``file`` uploads + an image stored in the workspace's uploads directory. + """ + ctype = (request.headers.get("content-type") or "").lower() + if ctype.startswith("application/json"): + body = await request.json() + cover_url = (body.get("cover_url") or "").strip() + if not cover_url: + raise HTTPException(400, "cover_url required") + with get_conn() as conn: + conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (cover_url, page_id)) + conn.commit() + return {"status": "ok", "page_id": page_id, "cover_url": cover_url} + ws_id = _ws_id_for(request, page_id) + meta = await _store_uploaded_file(request, ws_id) + with get_conn() as conn: + conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (meta["file_url"], page_id)) + conn.commit() + return {"status": "ok", "page_id": page_id, "cover_url": meta["file_url"]} + + + + +@router.delete("/api/pages/{page_id}/cover") +def remove_page_cover(request: Request, page_id: int): + with get_conn() as conn: + conn.execute("UPDATE pages SET cover_url='' WHERE id=?", (page_id,)) + conn.commit() + return {"status": "ok", "page_id": page_id} + + + + +@router.post("/api/pages/{page_id}/icon") +def set_page_icon(request: Request, page_id: int, body: dict = Body(default={})): + """v5.5.0: set a page emoji/icon label (or a custom-emoji image URL).""" + icon = (body.get("icon") or "").strip() + if len(icon) > 512: + raise HTTPException(400, "icon too long") + with get_conn() as conn: + conn.execute("UPDATE pages SET page_icon=? WHERE id=?", (icon, page_id)) + conn.commit() + return {"status": "ok", "page_id": page_id, "icon": icon} + + +# ═══════════ v5.4.0: Import (Markdown / .md / Notion .zip) ═══════════ diff --git a/app/routers/board/page_ops.py b/app/routers/board/page_ops.py new file mode 100644 index 0000000..e09c57b --- /dev/null +++ b/app/routers/board/page_ops.py @@ -0,0 +1,176 @@ +"""FlowDeck — Board : page_ops. + +Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé. +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Body, HTTPException, Request +from fastapi.responses import HTMLResponse, JSONResponse + +from app.auth.session import SessionManager +from app.db import get_conn +from app.services.automations import fire_event, run_event_sync +from app.services.permission_manager import PermissionManager + +from ._common import _sidebar_data + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["board"], prefix="/board") + + + + +@router.put("/api/pages/{page_id}/move") +def move_page(request: Request, page_id: int, body: dict = Body(default={})): + """Move a page to another workspace or reorder within tree. + + Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int } + - workspace_id: move page to a different workspace + - parent_id: change parent (0 = root level) + - new_order: position among siblings (0 = append) + """ + new_ws_id = body.get("workspace_id") + new_parent_id = body.get("parent_id", 0) + new_order = body.get("new_order", 0) + + with get_conn() as conn: + row = conn.execute("SELECT id, workspace, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone() + if not row: + raise HTTPException(404, "Page not found") + + if new_ws_id: + # Move to a different workspace: get the workspace name + ws_row = conn.execute("SELECT name FROM workspaces WHERE id=?", (new_ws_id,)).fetchone() + if not ws_row: + return JSONResponse({"status": "error", "detail": "Workspace not found"}, status_code=404) + conn.execute( + "UPDATE pages SET workspace_id=?, workspace=?, parent_id=NULL, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (new_ws_id, ws_row["name"], page_id), + ) + else: + # Reorder within same workspace + conn.execute( + "UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (new_parent_id if new_parent_id > 0 else None, page_id), + ) + conn.execute( + "UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (new_order, page_id), + ) + + conn.commit() + run_event_sync(fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0, + "parent_id": new_parent_id})) + return {"status": "ok", "id": page_id} + + + + +@router.delete("/api/pages/{page_id}") +def delete_page(request: Request, page_id: int): + """Move a page to trash (soft delete).""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + uid = (user or {}).get("id") + if not uid: + raise HTTPException(403, "Authentication required") + # v6.0.0: granular page permissions — need at least edit access to trash. + if not PermissionManager(uid).can_edit_page(page_id): + raise HTTPException(403, "You don't have edit access to this page") + with get_conn() as conn: + row = conn.execute("SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)).fetchone() + if not row: + raise HTTPException(404, "Page not found") + import datetime + conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,)) + conn.commit() + run_event_sync(fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})) + return {"status": "ok", "deleted": page_id, "title": row["title"]} + + + + +@router.get("/pages/{page_id}", response_class=HTMLResponse) +def view_page(request: Request, page_id: int): + """Render a page as HTML, or a file viewer for uploaded files. + ?embed=1 — minimal mode for side peek (editor only, no header).""" + embed = request.query_params.get("embed") == "1" + from app.templating import ENV + env = ENV + # v6.0.0: granular page permissions — hide restricted pages (404). + user_hdr = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if user_hdr and user_hdr.get("id") and not PermissionManager(user_hdr["id"]).can_view_page(page_id): + return HTMLResponse("

Page not found

", status_code=404) + with get_conn() as conn: + row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() + if not row: + return HTMLResponse("

Page not found

", status_code=404) + page = dict(row) + # v6.5.0: synced blocks resolve server-side at read time (fresh content + # even when the stored cache is stale). + from app.services.synced_blocks import resolve_content_json + page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format")) + + ws = page.get("workspace", "") + parts = ws.split("/") if "/" in ws else ["", ""] + owner, repo = parts[0], parts[1] if len(parts) > 1 else "" + sidebar = _sidebar_data(request, owner, repo) + # Check if page is favorited + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + uid = user["id"] if user and user.get("id") else 1 + with get_conn() as conn: + fav = conn.execute( + "SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id) + ).fetchone() + + # Build page_data, including file metadata for uploaded files + _locked = bool(page.get("is_locked", 0)) + _locked_by = page.get("locked_by") if "locked_by" in page else None + _can_edit = (not _locked) or bool(user and user.get("is_admin")) or (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)), "cover_url": page.get("cover_url", "") or "", "page_icon": page.get("page_icon", "") or "", + "is_locked": _locked, + "locked_by": _locked_by, + "can_edit": _can_edit, + "full_width": bool(page.get("full_width", 0)) if "full_width" in page else False, + "font_small": bool(page.get("font_small", 0)) if "font_small" in page else False} + + # For file pages, extract file metadata and add to page_data + if page.get("content_format") == "file": + import json as _json + try: + meta = _json.loads(page.get("content", "{}")) + except _json.JSONDecodeError: + meta = {} + file_path = meta.get("file_path", "").replace("\\", "/") + mime_type = meta.get("mime_type", "application/octet-stream") + file_size = meta.get("size", 0) + # Build workspace_id from file_path + fp_parts = file_path.split("/") + ws_id = "" + for p in fp_parts: + if p.startswith("workspace_"): + ws_id = p.replace("workspace_", "") + break + filename = fp_parts[-1] if fp_parts else page.get("title", "File") + file_url = f"/api/files/{ws_id}/{filename}" if ws_id else "" + page_data["file_url"] = file_url + page_data["file_mime"] = mime_type + page_data["file_size"] = file_size + page_data["file_name"] = filename + + from app.routers.dashboard import _nav_breadcrumb + with get_conn() as conn: + nav_crumbs = _nav_breadcrumb(conn, page_id) + ctx = {**sidebar, "page": page, "page_favorited": fav is not None, + "page_share_mode": page.get("share_mode", "private"), + "page_published": bool(page.get("published", 0)), + "page_is_shared": bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)), + "page_data": page_data, + "breadcrumb_items": nav_crumbs, + "nav_workspace_id": page.get("workspace_id") or 0, + "nav_page_id": page_id, + "embed_mode": embed} + template = env.get_template("page_editor_embed.html" if embed else "page_editor.html") + response = template.render(**ctx) + return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"}) diff --git a/app/routers/board/pages.py b/app/routers/board/pages.py new file mode 100644 index 0000000..eb36be8 --- /dev/null +++ b/app/routers/board/pages.py @@ -0,0 +1,271 @@ +"""FlowDeck — Board : pages. + +Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé. +""" +from __future__ import annotations + +import json +import logging + +from fastapi import APIRouter, Body, HTTPException, Query, Request + +from app.auth.session import SessionManager +from app.db import get_conn +from app.services.automations import fire_event, run_event_sync +from app.services.permission_manager import PermissionManager + +from ._common import _block_texts, _ensure_page_editable, _record_version + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["board"], prefix="/board") + + + + +# ═══════════ Pages Markdown ═══════════ + +@router.post("/api/pages") +def create_page(request: Request, title: str = Query(default=""), + section: str = Query(default="Private"), + project: str = Query(default=""), + parent_id: int = Query(default=0)): + """Create a new Markdown page, optionally as a sub-page.""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user or not user.get("id"): + # A7 : la création de page exige une session (route sortue de la liste CSRF). + raise HTTPException(401, "Authentication required") + ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno") + page_title = title.strip() if title else "" + try: + with get_conn() as conn: + # Compute next sort_order for this parent + next_order = 0 + parent_val = parent_id if parent_id > 0 else None + row = conn.execute( + "SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS ?", + (ws_key, parent_val), + ).fetchone() + if row: + next_order = row[0] + cur = conn.execute( + "INSERT INTO pages (workspace, title, parent_section, parent_id, sort_order) VALUES (?,?,?,?,?)", + (ws_key, page_title, section, parent_val, next_order), + ) + conn.commit() + page_id = cur.lastrowid + run_event_sync(fire_event("page.created", {"page_id": page_id, "title": page_title, + "workspace": ws_key, "parent_id": parent_id})) + return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id} + except Exception as e: + logger.error("create_page failed: %s", e) + from fastapi.responses import JSONResponse + return JSONResponse({"error": "Failed to create page", "detail": str(e)}, status_code=500) + + + + +@router.get("/api/pages/{page_id}") +def get_page(request: Request, page_id: int): + """Get a Markdown page.""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user or not user.get("id"): + raise HTTPException(401, "Authentication required") + # v6.0.0: granular page permissions — 404 (not 403) hides restricted pages. + if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id): + raise HTTPException(404, "Page not found") + with get_conn() as conn: + row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() + if not row: + raise HTTPException(404, "Page not found") + return dict(row) + + + + +@router.put("/api/pages/{page_id}") +def update_page(request: Request, page_id: int, title: str = Query(default=""), + content: str = Query(default=""), + content_format: str = Query(default="")): + """Update a page's title and/or content. Accepts JSON body for blocks.""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user or not user.get("id"): + raise HTTPException(403, "Authentication required") + # v6.0.0: granular page permissions — hidden pages 404 (not 403); a visible + # page the caller cannot edit yields 403. + pm = PermissionManager(user["id"], bool(user.get("is_admin"))) + if not pm.can_view_page(page_id): + raise HTTPException(404, "Page not found") + if not pm.can_edit_page(page_id): + raise HTTPException(403, "You don't have edit access to this page") + with get_conn() as conn: + _ensure_page_editable(conn, page_id, user) + if title: + conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id)) + # v6.5.0: renaming a database row's content page updates the row. + from app.services.row_pages import sync_page_title_to_row + sync_page_title_to_row(conn, page_id) + if content: + conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content, page_id)) + if content_format: + conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id)) + conn.commit() + run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title, + "content_format": content_format or "markdown", + "actor_id": user.get("id")})) + return {"status": "ok"} + + + + +@router.post("/api/pages/{page_id}/blocks") +def save_page_blocks(request: Request, page_id: int, body: dict = Body(...)): + """Save blocks JSON content (Notion-style block editor). + + v5.4.0: a version snapshot is recorded (if the block content actually + changed) so the UI can browse the version history and restore any of them. + v5.14.0: synced block references are tracked in page_synced_blocks. + """ + blocks = body.get("blocks", []) + blocks_json = json.dumps(blocks) + title = body.get("title", "") + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + uid = (user or {}).get("id") + # No session → legacy single-user behaviour; otherwise enforce edit rights. + if uid and not PermissionManager(uid).can_edit_page(page_id): + raise HTTPException(403, "You don't have edit access to this page") + + # Extract synced block ids from the blocks + def _extract_synced(blocks: list[dict]) -> set[int]: + ids: set[int] = set() + for b in blocks: + if b.get("type") == "synced" and b.get("synced_id"): + ids.add(b["synced_id"]) + if isinstance(b.get("children"), list): + ids |= _extract_synced(b["children"]) + return ids + + synced_ids = _extract_synced(blocks) + + with get_conn() as conn: + _ensure_page_editable(conn, page_id, user) + if title: + conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id)) + # v6.5.0: renaming a database row's content page updates the row. + from app.services.row_pages import sync_page_title_to_row + sync_page_title_to_row(conn, page_id) + conn.execute( + "UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?", + (blocks_json, page_id), + ) + _record_version(conn, page_id, uid, title or "", blocks_json) + # Update synced block references + existing = {r["synced_block_id"] for r in conn.execute( + "SELECT synced_block_id FROM page_synced_blocks WHERE page_id=?", (page_id,) + ).fetchall()} + for sid in synced_ids: + if sid not in existing: + conn.execute( + "INSERT OR IGNORE INTO page_synced_blocks (page_id, synced_block_id, block_index) VALUES (?, ?, 0)", + (page_id, sid), + ) + for sid in existing - synced_ids: + conn.execute( + "DELETE FROM page_synced_blocks WHERE page_id=? AND synced_block_id=?", + (page_id, sid), + ) + conn.commit() + run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title or "", + "content_format": "blocks", + "actor_id": uid})) + return {"status": "ok", "id": page_id} + + + + +@router.get("/api/pages/{page_id}/backlinks") +def page_backlinks(request: Request, page_id: int): + """v5.4.0: pages that link to this one ("Lié depuis…"). + + Scans every non-deleted page's blocks (and raw markdown) for an internal + reference to ``/pages/{page_id}`` or ``#fdblk-…`` inside ``/pages/{page_id}``. + """ + target = f"/pages/{page_id}" if page_id else None + wiki_target = f"[[fdpage:{page_id}]]" if page_id else None + backlinks = [] + with get_conn() as conn: + rows = conn.execute( + "SELECT id, title, workspace, content, content_format, updated_at " + "FROM pages WHERE deleted_at IS NULL AND id != ?", + (page_id,), + ).fetchall() + for r in rows: + fmt = r["content_format"] + hits = False + if fmt == "blocks" and r["content"]: + try: + blocks = json.loads(r["content"]) + for b in blocks if isinstance(blocks, list) else []: + for text in _block_texts(b): + if target and (target in text or (wiki_target and wiki_target in text)): + hits = True + break + if hits: + break + except (json.JSONDecodeError, TypeError): + hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or ""))) + elif fmt == "markdown": + hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or ""))) + elif r["content"]: + hits = target and (target in json.dumps(r["content"]) or (wiki_target and wiki_target in json.dumps(r["content"]))) + if not hits and target: + hits = f"/pages/{page_id}" in (r["content"] or "") + if hits: + backlinks.append({ + "id": r["id"], + "title": r["title"] or "Untitled", + "workspace": r["workspace"] or "", + "updated_at": r["updated_at"] or "", + }) + backlinks.sort(key=lambda x: x.get("updated_at") or "", reverse=True) + return {"backlinks": backlinks} + + + + +@router.get("/api/pages/{page_id}/versions") +def page_versions(request: Request, page_id: int): + """v5.4.0: version history for a block-editor page.""" + with get_conn() as conn: + rows = conn.execute( + "SELECT pv.id, pv.title, pv.note, pv.created_at, " + "COALESCE(u.login, '') AS author " + "FROM page_versions pv LEFT JOIN users u ON u.id=pv.user_id " + "WHERE pv.page_id=? ORDER BY pv.id DESC LIMIT 100", + (page_id,), + ).fetchall() + return {"versions": [dict(r) for r in rows]} + + + + +@router.post("/api/pages/{page_id}/versions/{version_id}/restore") +def restore_version(request: Request, page_id: int, version_id: int): + """v5.4.0: restore a page from a version snapshot.""" + with get_conn() as conn: + ver = conn.execute( + "SELECT * FROM page_versions WHERE id=? AND page_id=?", + (version_id, page_id), + ).fetchone() + if not ver: + raise HTTPException(404, "Version not found") + conn.execute( + "UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (ver["blocks_json"], ver["title"] or "", page_id), + ) + conn.commit() + run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "", + "content_format": "blocks"})) + return {"status": "ok", "restored": version_id} + + +# ═══════════ v5.4.0: Page & collection duplication ═══════════ diff --git a/app/routers/board/sharing.py b/app/routers/board/sharing.py new file mode 100644 index 0000000..9d2c6f5 --- /dev/null +++ b/app/routers/board/sharing.py @@ -0,0 +1,175 @@ +"""FlowDeck — Board : sharing. + +Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé. +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Body, HTTPException, Request +from fastapi.responses import HTMLResponse + +from app.auth.session import SessionManager +from app.db import get_conn +from app.services.automations import fire_event, run_event_sync +from app.services.publish import fire_published, fire_unpublished, publish, unpublish + +from ._common import _sidebar_data + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["board"], prefix="/board") + + + +# ═══════════ Favorites API ═══════════ + +@router.get("/api/favorites") +def list_favorites(request: Request): + """List favorited page IDs for the current user.""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + uid = user["id"] if user and user.get("id") else 1 + with get_conn() as conn: + rows = conn.execute( + "SELECT page_id FROM favorites WHERE user_id=? ORDER BY position", (uid,) + ).fetchall() + return {"favorites": [r["page_id"] for r in rows]} + + + + +@router.post("/api/favorites/{page_id:int}") +def add_favorite(request: Request, page_id: int): + """Add a page to favorites.""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + uid = user["id"] if user and user.get("id") else 1 + with get_conn() as conn: + existing = conn.execute( + "SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id) + ).fetchone() + if not existing: + pos = conn.execute( + "SELECT COALESCE(MAX(position), -1) + 1 FROM favorites WHERE user_id=?", (uid,) + ).fetchone()[0] + conn.execute( + "INSERT INTO favorites (user_id, page_id, position) VALUES (?,?,?)", + (uid, page_id, pos), + ) + conn.commit() + try: + run_event_sync(fire_event("favorite.added", {"page_id": page_id, "user_id": uid})) + except Exception: + logger.exception("add_favorite") + return {"status": "added", "page_id": page_id} + + + + +@router.delete("/api/favorites/{page_id:int}") +def remove_favorite(request: Request, page_id: int): + """Remove a page from favorites.""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + uid = user["id"] if user and user.get("id") else 1 + with get_conn() as conn: + conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id)) + conn.commit() + try: + run_event_sync(fire_event("favorite.removed", {"page_id": page_id, "user_id": uid})) + except Exception: + logger.exception("remove_favorite") + return {"status": "removed", "page_id": page_id} + +# ═══════════ Share API ═══════════ + + + +# ═══════════ Share API ═══════════ + +@router.post("/api/share/{page_id:int}") +def update_share(request: Request, page_id: int, body: dict = Body(default={})): + """Save share settings for a page.""" + mode = body.get("mode", "private") + published = body.get("published", False) + with get_conn() as conn: + conn.execute( + "UPDATE pages SET share_mode=?, published=? WHERE id=?", + (mode, 1 if published else 0, page_id), + ) + conn.commit() + return {"status": "ok", "share_mode": mode, "published": published} + + + + +@router.post("/api/pages/{page_id:int}/publish") +def publish_page(request: Request, page_id: int): + """Publish a page to the web (generates publish_slug).""" + if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")): + raise HTTPException(401, "Authentication required") + slug, title = publish(page_id) + run_event_sync(fire_published(page_id, slug)) + return {"is_published": True, "publish_slug": slug, "title": title} + + + + +@router.delete("/api/pages/{page_id:int}/publish") +def unpublish_page(request: Request, page_id: int): + """Unpublish a page from the web.""" + if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")): + raise HTTPException(401, "Authentication required") + unpublish(page_id) + run_event_sync(fire_unpublished(page_id)) + return {"is_published": False} + + +# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════ + + + + +# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════ + +@router.get("/api/trash") +def list_trash(request: Request): + with get_conn() as conn: + rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall() + return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows] + + + + +@router.post("/api/trash/{page_id}/restore") +def restore_page(request: Request, page_id: int): + with get_conn() as conn: + conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,)) + conn.commit() + try: + run_event_sync(fire_event("page.restored", {"page_id": page_id})) + except Exception: + logger.exception("restore_page") + return {"status": "ok", "restored": page_id} + + + + +@router.delete("/api/trash/{page_id}") +def permanent_delete(request: Request, page_id: int): + with get_conn() as conn: + conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,)) + conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,)) + conn.commit() + return {"status": "ok", "deleted": page_id} + + + + +@router.get("/trash", response_class=HTMLResponse) +def trash_page(request: Request): + from app.templating import ENV + env = ENV + template = env.get_template("trash.html") + return template.render(**_sidebar_data(request)) + + +# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════ +# These routes MUST be registered before the catch-all /{owner}/{repo} below. diff --git a/app/routers/board/sync.py b/app/routers/board/sync.py new file mode 100644 index 0000000..5dbb087 --- /dev/null +++ b/app/routers/board/sync.py @@ -0,0 +1,51 @@ +"""FlowDeck — Board : sync. + +Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé. +""" +from __future__ import annotations + +import json +import logging + +from fastapi import APIRouter, HTTPException + +from app.db import get_conn +from app.services.gitea_client import gitea + +from ._common import _extract_ai_keywords, _issue_column + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["board"], prefix="/board") + + + + +@router.post("/api/sync/{owner}/{repo}") +async def sync_project(owner: str, repo: str): + """Full bidirectional sync: fetch Gitea issues → update local DB.""" + try: + issues = await gitea.get_issues(owner, repo, state="all") + issues_only = [i for i in issues if not i.get("pull_request")] + with get_conn() as conn: + board = conn.execute( + "SELECT id, columns_json FROM boards WHERE project_owner=? AND project_name=?", + (owner, repo), + ).fetchone() + if board: + board_id = board["id"] + columns = json.loads(board["columns_json"]) + # A23 : un seul executemany pour toutes les cards. + conn.executemany( + "INSERT OR REPLACE INTO cards (board_id, gitea_issue_id, column_name) VALUES (?,?,?)", + [ + (board_id, issue["number"], _issue_column(issue, columns, board_id)) + for issue in issues_only + ], + ) + for issue in issues_only: + # Extract AI keywords from each issue + _extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", "")) + conn.commit() + return {"status": "ok", "issues_synced": len(issues_only)} + except Exception as e: + raise HTTPException(500, str(e)) from e diff --git a/app/routers/board/synced.py b/app/routers/board/synced.py new file mode 100644 index 0000000..5d13c7f --- /dev/null +++ b/app/routers/board/synced.py @@ -0,0 +1,144 @@ +"""FlowDeck — Board : synced. + +Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé. +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Body, HTTPException, Query, Request + +from app.auth.session import SessionManager + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["board"], prefix="/board") + + + + +# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════ +# These routes MUST be registered before the catch-all /{owner}/{repo} below. + +@router.get("/api/synced-blocks") +def list_synced_blocks_api(request: Request, workspace: str = Query(default="")): + """List synced blocks for a workspace.""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + from app.services.synced_blocks import list_synced_blocks + return {"synced_blocks": list_synced_blocks(workspace or user.get("login", ""))} + + + + +@router.post("/api/synced-blocks") +def create_synced_block_api(request: Request, body: dict = Body(...)): + """Create a new synced block.""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + from app.services.synced_blocks import create_synced_block + sid = create_synced_block( + workspace=body.get("workspace", ""), + title=body.get("title", "Synced block"), + content=body.get("content", []), + created_by=user.get("id"), + ) + return {"status": "ok", "synced_block_id": sid} + + + + +@router.put("/api/synced-blocks/{sid}") +async def update_synced_block_api(request: Request, sid: int): + """Update a synced block's content (propagates to all pages).""" + try: + body = await request.json() + except Exception: + raise HTTPException(400, "Invalid JSON body") from None + from app.services.synced_blocks import ( + get_synced_block, + page_ids_for_synced, + sync_synced_blocks_in_page, + update_synced_block, + ) + sb = get_synced_block(sid) + if not sb: + raise HTTPException(404, "Synced block not found") + update_synced_block(sid, body.get("title", sb["title"]), body.get("content", [])) + # v6.5.0: rewrite every referencing page's stored content first (DB row + # content pages included), THEN push the realtime update so open rooms + # reload the fresh content from the DB. + for pid in page_ids_for_synced(sid): + sync_synced_blocks_in_page(pid) + from app.services.realtime_server import manager + await manager._propagate_synced(sid) + return {"status": "ok"} + + + + +@router.delete("/api/synced-blocks/{sid}") +async def delete_synced_block_api(request: Request, sid: int): + """Delete a synced block.""" + from app.services.synced_blocks import ( + delete_synced_block, + get_synced_block, + mark_synced_block_deleted, + page_ids_for_synced, + ) + sb = get_synced_block(sid) + if not sb: + raise HTTPException(404, "Synced block not found") + # v6.5.0: collect referencing pages BEFORE the FK cascade wipes the + # refs, rewrite their stored content (deleted state), then broadcast. + pids = page_ids_for_synced(sid) + delete_synced_block(sid) + mark_synced_block_deleted(sid, pids) + from app.services.realtime_server import manager + await manager._broadcast_synced_to(pids, sid) + return {"status": "ok"} + + + + +@router.get("/api/synced-blocks/{sid}") +def get_synced_block_api(sid: int): + """Get a synced block by id.""" + from app.services.synced_blocks import get_synced_block + sb = get_synced_block(sid) + if not sb: + raise HTTPException(404, "Synced block not found") + return dict(sb) + + + + +@router.post("/api/pages/{page_id}/synced") +def add_synced_to_page(request: Request, page_id: int, body: dict = Body(...)): + """Add a synced block reference to a page.""" + from app.services.synced_blocks import add_page_synced, get_synced_block + sid = body.get("synced_block_id") + sb = get_synced_block(sid) + if not sb: + raise HTTPException(404, "Synced block not found") + add_page_synced(page_id, sid, body.get("block_index", 0)) + return {"status": "ok", "synced_block_id": sid} + + + + +@router.delete("/api/pages/{page_id}/synced/{sid}") +def remove_synced_from_page(request: Request, page_id: int, sid: int): + """Remove a synced block reference from a page (unsync).""" + from app.services.synced_blocks import remove_page_synced + remove_page_synced(page_id, sid) + return {"status": "ok"} + + + + +@router.get("/api/pages/{page_id}/synced") +def get_page_synced_refs(request: Request, page_id: int): + """Get all synced block references for a page.""" + from app.services.synced_blocks import get_page_synced + return {"synced_blocks": get_page_synced(page_id)} + + +# ═══════════ Board page ═══════════ diff --git a/app/routers/board/wiki.py b/app/routers/board/wiki.py new file mode 100644 index 0000000..5388b7a --- /dev/null +++ b/app/routers/board/wiki.py @@ -0,0 +1,76 @@ +"""FlowDeck — Board : wiki. + +Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé. +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Query, Request + +from app.db import get_conn + +logger = logging.getLogger(__name__) +router = APIRouter(tags=["board"], prefix="/board") + + + + +@router.get("/api/wiki/pages") +def wiki_page_search(request: Request, q: str = Query(default="")): + """v5.11.0: page picker for [[ wiki-links. Fuzzy-ish title search across + every non-deleted page the current user can see (single source: pages).""" + q = (q or "").strip().lower() + with get_conn() as conn: + rows = conn.execute( + """SELECT id, title, page_icon, workspace FROM pages + WHERE deleted_at IS NULL + ORDER BY updated_at DESC LIMIT 500""" + ).fetchall() + results = [] + for r in rows: + title = r["title"] or "Untitled" + if q: + # subsequence match ("mtg" → "Meeting notes") or plain substring. + hay = title.lower() + it = iter(hay) + subseq = all(ch in it for ch in q) + if q not in hay and not subseq: + continue + results.append({ + "id": r["id"], + "title": title, + "icon": r["page_icon"] or "", + "workspace": r["workspace"] or "", + }) + if len(results) >= 20: + break + return {"pages": results} + + + + +@router.get("/api/wiki/titles") +def wiki_titles(request: Request, ids: str = Query(default="")): + """v5.11.0: resolve page-id lists to current labels (rename propagation).""" + parsed: list[int] = [] + for part in (ids or "").split(","): + part = part.strip() + if part.isdigit(): + parsed.append(int(part)) + parsed = parsed[:200] + out: dict[str, str] = {} + if parsed: + placeholders = ",".join("?" * len(parsed)) + with get_conn() as conn: + rows = conn.execute( + f"SELECT id, title, page_icon, deleted_at FROM pages WHERE id IN ({placeholders})", + parsed, + ).fetchall() + for r in rows: + if r["deleted_at"]: + out[str(r["id"])] = "Deleted page" + else: + icon = (r["page_icon"] or "") + out[str(r["id"])] = (icon + " " if icon else "") + (r["title"] or "Untitled") + return {"titles": out} diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index 2766cf3..ef9f80e 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "7.31.0" + "version": "7.32.0" }, "paths": { "/auth/register": {