diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d72c50..cfcd18b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog - FlowDeck +## v7.24.0 (2026-10-01) — Audit : A27 extraction TERMINÉE (bilan -85 %) + +### Changed + +- **A27 (phase 2c)** — `_database_table_scripts.html` : **1 314 L extraites** + vers `static/js/database_table.js`. Le Jinja du bloc était confiné à la + construction de l'objet de config (4 clés + `{% if collection_data %}`) → + config JSON `#db-config` **null-vs-objet** : `new DBInstance(container, + PAGE_COLLECTION_ID, DB_CONFIG)` remplace les 2 branches Jinja +- 2 tests adaptés (lisaient le template source → `database_table.js`) ; + `FlowDeckDB` plus dans le HTML → assert sur le JS + +### Notes + +- **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, eslint **0 erreur / 285 warnings** +- Reste : `base` 1 523 L structurel (`{% block %}`/`{% for %}` — reste + inline par nature, décision assumée), ~500 L de petits blocs hors cibles, + nettoyage des 285 warnings +- Suite complète : **1089/1089** + ## v7.23.0 (2026-10-01) — Audit : A27 phase 2b (+3 801 L) ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index 09f990c..4288009 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1147,7 +1147,7 @@ Quality DB views, Agent IA Palette → Realtime + E ### 🟡 P2 — Moyennes - [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 — PARTIEL 2026-10-01 (4 243 extraites) dans 37 blocs**, ~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). **Reste A27 (structurel)** : `base` 1338 (`{% block %}`/`{% for %}` — reste inline par nature) + `database_table` 1323 (`if/else` sur collection_data — extraction à faire avec sélection de config) ≈ 2 661 L + nettoyage des 279 warnings. Effort : **L**. +- [x] **A27 — 13 900 lignes de JS inline — EXTRACTION TERMINÉE 2026-10-01 (11 874 L, -85 %) — reste : lint + base structurel**, ~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. **Reste A27** : `base` 1 523 L structurel (`{% block %}`/`{% for %}` — **reste inline par nature**, décision assumée) + ~500 L de petits blocs hors cibles (`_header` 124, `welcome` 50, `card_detail` 47, `workspace` 46, `public_page` 45…) + nettoyage des **285 warnings eslint** (0 erreur). Effort : **L**. - [ ] **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**.* - [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**.* @@ -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. · **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) → cumul 10 560 L, reste 3 344 (base structurel + database_table) → suite 1089/1089, version 7.23.0. +→ **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. · **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)** ; reste base 1523 structurel + 500 L de petits blocs + 285 warnings eslint → suite 1089/1089, version 7.24.0. diff --git a/VERSION b/VERSION index 14d6b5d..0783219 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.23.0 +7.24.0 diff --git a/WORKLOAD.md b/WORKLOAD.md index 2d993e4..7ad1a5c 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -1,6 +1,6 @@ # WORKLOAD — FlowDeck Notion Clone -> **Début**: 2026-07-08 | **Version**: v7.23.0 (audit — A27 phase 2b : +3 801 L, cumul 10 560 L) | **Statut**: EN COURS 🔄 +> **Début**: 2026-07-08 | **Version**: v7.24.0 (audit — A27 extraction TERMINÉE : 11 874 L, -85 % inline) | **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 47df909..bd81955 100644 --- a/app/main.py +++ b/app/main.py @@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.23.0", + version="7.24.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/app/templates/_database_table_scripts.html b/app/templates/_database_table_scripts.html index 02be0a6..52619d4 100644 --- a/app/templates/_database_table_scripts.html +++ b/app/templates/_database_table_scripts.html @@ -106,1327 +106,5 @@ .db-list-title{flex:1;min-width:120px} .db-list-cell{color:var(--text-secondary);font-size:12px;min-width:110px} - + + diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index 1c17a0b..3ea86d8 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "7.23.0" + "version": "7.24.0" }, "paths": { "/auth/register": { diff --git a/static/js/database_table.js b/static/js/database_table.js new file mode 100644 index 0000000..f34087a --- /dev/null +++ b/static/js/database_table.js @@ -0,0 +1,1315 @@ +const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=el?JSON.parse(el.textContent):null;return v===undefined?null:v}catch(e){return null}})(); + +(function() { + 'use strict'; + + // ── SVG Outline Icons ── + function propTypeSvg(type) { + const s = 14; + switch(type) { + case 'text': case 'title': + return ''; + case 'number': + return ''; + case 'select': + return ''; + case 'multi_select': + return ''; + case 'status': + return ''; + case 'date': + return ''; + case 'checkbox': + return ''; + case 'url': + return ''; + case 'email': + return ''; + case 'phone': + return ''; + case 'person': + return ''; + case 'files': + return ''; + case 'relation': case 'rollup': case 'formula': + return ''; + case 'created_time': case 'last_edited_time': + return ''; + case 'created_by': case 'last_edited_by': + return ''; + default: + return ''; + } + } + + var AUTO_TYPES = ['created_time','created_by','last_edited_time','last_edited_by']; + var READONLY_TYPES = AUTO_TYPES.concat(['rollup','formula']); + var PALETTE = {gray:'#8b8b8b',brown:'#9f6b53',orange:'#d9730d',yellow:'#cb912f',green:'#448361',blue:'#337ea9',purple:'#9065b0',pink:'#c14c8a',red:'#d44c47'}; + + function escHtml(s) { return String(s==null?'':s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } + function parseJSON(s, d) { try { return typeof s === 'string' ? JSON.parse(s) : (s || d); } catch(e) { return d; } } + function parseOpts(prop) { + var o = parseJSON(prop.options_json, []); + if (!Array.isArray(o)) o = []; + return o.map(function(x){ return typeof x === 'string' ? {name:x,color:'gray'} : {name:x.name||'',color:x.color||'gray'}; }); + } + function optColor(prop, name) { + var found = parseOpts(prop).filter(function(o){ return o.name === name; })[0]; + return (found && PALETTE[found.color]) || '#8b8b8b'; + } + function uid() { return 'dbx' + Math.random().toString(36).slice(2,9); } + function getCsrf() { var m = document.cookie.match(/csrf_token=([^;]+)/); return m ? m[1] : ''; } + function toast(msg, kind) { + if (typeof window.showToast === 'function') window.showToast(msg, kind); + else if (kind === 'error') console.warn(msg); + } + + function pvOf(cp) { return parseJSON(cp.property_values_json, {}) || {}; } + function pageVal(cp, prop) { + if (prop.prop_type === 'title') return cp.title || ''; + var pv = pvOf(cp); + var v = pv[String(prop.id)]; + if (v === undefined) v = pv[prop.name]; + return v; + } + function setPageVal(cp, prop, val) { + var pv = pvOf(cp); + pv[String(prop.id)] = val; + cp.property_values_json = pv; + if (prop.prop_type === 'title') cp.title = val; + } + + // ── v5.8.0: recurrence / reminder meta keys ── + function metaOf(cp, key) { + var m = pvOf(cp)[key]; + return (m && typeof m === 'object' && !Array.isArray(m)) ? m : null; + } + function setMeta(cp, key, propId, val) { + var pv = pvOf(cp); + var m = pv[key]; + if (!m || typeof m !== 'object' || Array.isArray(m)) m = {}; + if (val === null || val === undefined) delete m[String(propId)]; + else m[String(propId)] = val; + if (Object.keys(m).length) pv[key] = m; else delete pv[key]; + cp.property_values_json = pv; + } + function recBadgeHtml(cp) { + var rec = metaOf(cp, '__recurrence__'); + var rem = metaOf(cp, '__reminder__'); + var h = ''; + if (rec) { Object.keys(rec).forEach(function(k){ if (rec[k] && rec[k].freq) h += '\u21bb'; }); } + if (rem) { Object.keys(rem).forEach(function(k){ var r=rem[k]; if (r && r.unit && r.unit!=='none') h += '🔔'; }); } + return h; + } + + // ── Value display (type-aware) ── + function initials(s) { s = (s||'?').trim(); return s ? s.charAt(0).toUpperCase() : '?'; } + function avatarHtml(p, size) { + size = size || 16; + var bg = p.avatar_color || '#5b6b8c'; + if (p.avatar_url) return ''; + return ''+escHtml(initials(p.login||p.full_name))+''; + } + function displayValue(prop, val) { + if (val === undefined || val === null || val === '') return '\u2014'; + var t = prop.prop_type; + if (t === 'checkbox') return ''+(val ? '✓' : '')+''; + if (t === 'person' && Array.isArray(val)) { + if (!val.length) return '\u2014'; + return val.map(function(p){ return ''+avatarHtml(p)+escHtml(p.login||p.full_name||'')+''; }).join(''); + } + if ((t === 'select' || t === 'status')) { + return ''+escHtml(val)+''; + } + if (t === 'multi_select' && Array.isArray(val)) { + return val.map(function(v){ return ''+escHtml(v)+''; }).join('') || '\u2014'; + } + if (t === 'date') { + return ''+escHtml(String(val).slice(0,10))+''; + } + if (t === 'created_time' || t === 'last_edited_time') { + return ''+escHtml(String(val).slice(0,16).replace('T',' '))+''; + } + if (t === 'created_by' || t === 'last_edited_by') { + return ''+avatarHtml(val)+escHtml(val.login||val.full_name||'')+''; + } + if (t === 'url' && val) return ''+escHtml(val)+''; + if (t === 'email' && val) return ''+escHtml(val)+''; + if (t === 'relation' && Array.isArray(val)) return ''+escHtml(val.join(', '))+''; + return ''+escHtml(Array.isArray(val)?val.join(', '):String(val))+''; + } + function textValue(prop, val) { + if (val === undefined || val === null) return ''; + if (Array.isArray(val)) return val.map(function(v){ return (v && (v.login||v.full_name)) || v; }).join(', '); + return String(val); + } + + // ── DB Instance (one per container) ── + function DBInstance(containerEl, collectionId, initialData) { + var state = { + collection: (initialData && initialData.collection) || {}, + properties: (initialData && initialData.properties) || [], + pages: (initialData && initialData.pages) || [], + views: (initialData && initialData.views) || [], + members: null, + activeViewId: null, + viewConfig: {}, + viewType: 'table', + editingCell: null, + colWidths: {}, + collapsedGroups: {}, + calCursor: new Date(), + zones: null + }; + + function propsById() { var m = {}; state.properties.forEach(function(p){ m[String(p.id)] = p; }); return m; } + function findProp(ref) { + if (ref === undefined || ref === null || ref === '') return null; + return state.properties.filter(function(p){ return String(p.id) === String(ref) || p.name === ref; })[0] || null; + } + function propName(ref) { var p = findProp(ref); return p ? p.name : ''; } + function firstPropOfTypes(types) { + return state.properties.filter(function(p){ return types.indexOf(p.prop_type) >= 0; })[0] || null; + } + function pageById(id) { return state.pages.filter(function(p){ return p.id === id; })[0]; } + function groupProp() { + return findProp(state.viewConfig.group_by) || firstPropOfTypes(['status','select']) || state.properties[0]; + } + + function activeView() { + return state.views.filter(function(v){ return v.id === state.activeViewId; })[0] || null; + } + function applyView(v) { + if (!v) { state.activeViewId = null; state.viewType = 'table'; state.viewConfig = {}; return; } + state.activeViewId = v.id; + state.viewType = v.view_type || 'table'; + state.viewConfig = parseJSON(v.config_json, {}) || {}; + } + function ensureView() { + if (state.views.length) { applyView(state.views[0]); return Promise.resolve(); } + return fetch('/db/'+collectionId+'/views/save-as', { + method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, + body: JSON.stringify({name:'Table', view_type:'table', config:{}}) + }).then(function(r){ return r.json(); }).then(function(d){ + state.views = [d]; applyView(d); + }).catch(function(){ applyView(null); }); + } + + function persistConfig(patch, opts) { + opts = opts || {}; + Object.keys(patch).forEach(function(k){ state.viewConfig[k] = patch[k]; }); + var v = activeView(); + if (!v) { + return fetch('/db/'+collectionId+'/views/save-as', { + method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, + body: JSON.stringify({name: state.viewType.charAt(0).toUpperCase()+state.viewType.slice(1), view_type: state.viewType, config: state.viewConfig}) + }).then(function(r){ return r.json(); }).then(function(d){ state.views.push(d); applyView(d); render(); }); + } + if (opts.name) v.name = opts.name; + if (opts.viewType) { v.view_type = opts.viewType; state.viewType = opts.viewType; } + v.config_json = JSON.stringify(state.viewConfig); + if (!opts.silent) render(); + return fetch('/db/views/'+v.id+'/config', { + method:'PUT', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, + body: JSON.stringify(Object.assign({}, patch, opts.name?{name:opts.name}:{}, opts.viewType?{view_type:opts.viewType}:{})) + }).catch(function(){}); + } + + // ── Root render ── + function render() { + state.properties.forEach(function(p){ if (!state.colWidths[p.id]) state.colWidths[p.id] = 200; }); + var h = renderViewBar(); + if (state.viewType === 'board') h += renderBoard(); + else if (state.viewType === 'calendar') h += renderCalendar(); + else if (state.viewType === 'gallery') h += renderGallery(); + else if (state.viewType === 'list') h += renderList(); + else h += renderTable(); + containerEl.innerHTML = h; + bindEvents(); + } + + var VIEW_ICONS = { + table:'', + board:'', + calendar:'', + gallery:'', + list:'' + }; + function viewSvg(t) { + return ''+(VIEW_ICONS[t]||VIEW_ICONS.table)+''; + } + + function renderViewBar() { + var h = '
'; + if (!state.views.length) { + h += ''; + } + state.views.forEach(function(v){ + var active = v.id === state.activeViewId; + h += ''; + }); + h += ''; + h += '
'; + h += '
'; + h += '
'; + return h; + } + + function bindViewBar() { + containerEl.querySelectorAll('[data-view-id]').forEach(function(b){ + b.onclick = function(e){ + if (e.target.closest('[data-view-delete]') || e.target.closest('[data-view-settings]')) return; + var id = this.dataset.viewId; if (!id) return; + var v = state.views.filter(function(x){ return String(x.id) === String(id); })[0]; + if (v) { applyView(v); render(); } + }; + }); + containerEl.querySelectorAll('[data-view-delete]').forEach(function(b){ + b.onclick = function(e){ + e.stopPropagation(); + var id = parseInt(this.dataset.viewDelete); + if (!confirm('Delete this view?')) return; + fetch('/db/views/'+id+'/api', {method:'DELETE', headers:{'X-CSRF-Token':getCsrf()}}).then(function(){ + state.views = state.views.filter(function(v){ return v.id !== id; }); + if (state.activeViewId === id) applyView(state.views[0] || null); + render(); + }); + }; + }); + var settings = containerEl.querySelector('[data-view-settings]'); + if (settings) settings.onclick = function(e){ e.stopPropagation(); openViewSettings(this); }; + var add = containerEl.querySelector('[data-view-add]'); + if (add) add.onclick = function(e){ e.stopPropagation(); openAddViewMenu(this); }; + containerEl.querySelectorAll('.db-add-page-btn').forEach(function(b){ b.onclick = addNewPage; }); + } + + // ── Table view ── + function groupedProperties() { + var groups = []; + state.properties.forEach(function(p){ + var g = p.group_name || ''; + var last = groups[groups.length-1]; + if (!last || last.name !== g) { last = {name:g, props:[]}; groups.push(last); } + last.props.push(p); + }); + return groups; + } + function renderTable() { + var props = state.properties; + var pages = state.pages; + var groups = groupedProperties(); + var hasGroups = groups.some(function(g){ return g.name; }); + var h = '
'; + h += ''; + if (hasGroups) { + h += ''; + h += ''; + groups.forEach(function(g, gi){ + var collapsed = state.collapsedGroups[gi]; + h += ''; + }); + h += ''; + h += ''; + } + h += ''; + h += ''; + props.forEach(function(prop){ + var w = state.colWidths[prop.id] || 200; + var groupIdx = 0; var acc = 0; + groups.forEach(function(g, gi){ if (g.props.indexOf(prop) >= 0) groupIdx = gi; }); + var collapsed = hasGroups && state.collapsedGroups[groupIdx]; + h += ''; + }); + h += ''; + h += ''; + h += ''; + pages.forEach(function(cp){ + h += ''; + h += ''; + props.forEach(function(prop){ + var groupIdx = 0; + groups.forEach(function(g, gi){ if (g.props.indexOf(prop) >= 0) groupIdx = gi; }); + var collapsed = hasGroups && state.collapsedGroups[groupIdx]; + var val = pageVal(cp, prop); + var ro = READONLY_TYPES.indexOf(prop.prop_type) >= 0; + h += ''; + }); + h += ''; + h += ''; + }); + var emptyNeeded = Math.max(0, 4 - pages.length); + for (var i=0;i'; + h += '
'; + if (g.name) { + h += '▾'+escHtml(g.name)+''; + } + h += '
'; + h += '
'; + h += ''+propTypeSvg(prop.prop_type)+''; + h += ''+escHtml(prop.name)+''; + h += '
'; + h += '
'; + h += displayValue(prop, val); + if (prop.prop_type === 'date') h += recBadgeHtml(cp); + if (prop.prop_type === 'title') h += ''; + h += '
'; + return h; + } + + // ── Board view ── + function optionColumns(prop) { + if (!prop) return [{key:'', label:'Items'}]; + if (prop.prop_type === 'person') { + var cols = (state.members||[]).map(function(m){ return {key:m.login, label:m.full_name||m.login, person:m}; }); + cols.push({key:'__none__', label:'No assignee'}); + return cols; + } + var opts = parseOpts(prop).map(function(o){ return {key:o.name, label:o.name, color:o.color}; }); + if (!opts.length) { + var seen = {}; + state.pages.forEach(function(cp){ + var v = pageVal(cp, prop); if (v === undefined || v === null || v === '') return; + (Array.isArray(v)?v:[v]).forEach(function(x){ if (!seen[x]) { seen[x]=1; opts.push({key:x,label:x}); } }); + }); + opts.push({key:'__none__', label:'No value'}); + } else { + opts.push({key:'__none__', label:'No value'}); + } + return opts; + } + function columnKeyFor(prop, cp) { + var v = pageVal(cp, prop); + if (prop.prop_type === 'person') { + if (Array.isArray(v) && v.length) return v[0].login || v[0].full_name || '__none__'; + return '__none__'; + } + if (Array.isArray(v)) return v[0] || '__none__'; + if (v === undefined || v === null || v === '') return '__none__'; + return String(v); + } + function cardHtml(cp, opts) { + opts = opts || {}; + var cover = opts.cover !== false ? coverHtml(cp) : ''; + var size = state.viewConfig.card_size || 'small'; + var cardProps = state.viewConfig.card_properties; + if (!Array.isArray(cardProps) || !cardProps.length) cardProps = defaultCardProps(); + var body = '
'; + body += '
'+(cp.icon&&cp.icon!=='file'?escHtml(cp.icon)+' ':'')+escHtml(cp.title||'Untitled')+'
'; + cardProps.forEach(function(ref){ + var p = findProp(ref); if (!p) return; + var v = pageVal(cp, p); + var txt = textValue(p, v); + if (!txt) return; + body += '
'+escHtml(p.name)+''+escHtml(txt.slice(0,60))+'
'; + }); + body += '
'; + return '
'+cover+body+'
'; + } + function defaultCardProps() { + return state.properties.filter(function(p){ return p.prop_type !== 'title'; }).slice(0,3).map(function(p){ return p.id; }); + } + function coverHtml(cp) { + if (cp.cover_url) return '
'; + var mode = state.viewConfig.cover_mode || 'icon'; + if (mode === 'none') return ''; + if (mode === 'property') { + var p = findProp(state.viewConfig.cover_property); + if (p) { + var v = pageVal(cp, p); + var url = null; + if (Array.isArray(v) && v.length) url = (v[0] && (v[0].url||v[0].src)) || (typeof v[0]==='string'?v[0]:null); + else if (typeof v === 'string' && /^https?:\/\//.test(v)) url = v; + if (url) return '
'; + } + } + if (mode === 'color') { + var g = groupProp(); + var key = g ? columnKeyFor(g, cp) : ''; + var col = g ? optColor(g, key) : '#8b8b8b'; + return '
'; + } + return '
'+escHtml(cp.icon && cp.icon!=='file' ? cp.icon : '📄')+'
'; + } + function renderBoard() { + var g = groupProp(); + if (!g) return '
Add a Select, Status or Person property to group the board.
'; + var sub = findProp(state.viewConfig.sub_group_by); + var cols = optionColumns(g); + var wip = state.viewConfig.wip_limits || {}; + var h = '
'; + if (sub) { + var lanes = optionColumns(sub); + lanes.forEach(function(lane){ + var lanePages = state.pages.filter(function(cp){ return columnKeyFor(sub, cp) === lane.key; }); + if (!lanePages.length && lane.key === '__none__') return; + h += '
'+escHtml(lane.label)+' '+lanePages.length+'
'; + h += boardColumnsHtml(g, cols, lanePages, wip); + h += '
'; + }); + } else { + h += boardColumnsHtml(g, cols, state.pages, wip); + } + h += '
'; + return h; + } + function boardColumnsHtml(g, cols, pages, wip) { + var h = '
'; + cols.forEach(function(col){ + var colPages = pages.filter(function(cp){ return columnKeyFor(g, cp) === col.key; }); + var limit = wip[col.key]; + var exceeded = limit && colPages.length > Number(limit); + var dot = col.person ? avatarHtml(col.person) : (col.color ? '' : ''); + h += '
'; + h += '
'+dot+''+escHtml(col.label)+''+colPages.length+''; + if (limit) h += 'WIP '+colPages.length+'/'+limit+''; + h += '
'; + h += '
'; + colPages.forEach(function(cp){ h += cardHtml(cp); }); + h += ''; + h += '
'; + }); + h += '
'; + return h; + } + + // ── Calendar view (v5.8.0: day/week/month + server-expanded recurrences) ── + function dateProp() { return findProp(state.viewConfig.date_property) || firstPropOfTypes(['date']); } + var CAL_DOW = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; + var MONTH_NAMES = ['January','February','March','April','May','June','July','August','September','October','November','December']; + function isoDay(d) { return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0'); } + function mondayOf(d) { var x = new Date(d); x.setDate(x.getDate() - ((x.getDay()+6)%7)); x.setHours(0,0,0,0); return x; } + function calMode() { var m = state.viewConfig.calendar_mode; return (m==='day'||m==='week') ? m : 'month'; } + + function baseEvents() { + var dp = dateProp(); if (!dp) return {}; + var byDate = {}; + state.pages.forEach(function(cp){ + var v = pageVal(cp, dp); if (!v) return; + var ds = String(v).slice(0,10); + (byDate[ds] = byDate[ds] || []).push({page_id:cp.id, title:cp.title, icon:cp.icon, recurring:false, time:String(v).length>10?String(v).slice(11,16):''}); + }); + return byDate; + } + function eventsForWindow(startIso, endIso) { + // Server expansion covers recurring occurrences; merge over base events. + var dp = dateProp(); + if (!dp) return Promise.resolve({}); + return fetch('/db/'+collectionId+'/calendar/api?start='+startIso+'&end='+endIso+'&date_property='+dp.id, {credentials:'same-origin'}) + .then(function(r){ return r.ok ? r.json() : {events:[]}; }) + .then(function(d){ + var byDate = {}; + (d.events||[]).forEach(function(ev){ (byDate[ev.date] = byDate[ev.date] || []).push(ev); }); + return {byDate: byDate, timezone: d.timezone || ''}; + }) + .catch(function(){ return {byDate: baseEvents(), timezone:''}; }); + } + function evChipHtml(ev) { + var badge = ev.recurring ? '\u21bb' : ''; + return '
' + + (ev.time ? ''+escHtml(ev.time)+'' : '') + + (ev.icon && ev.icon !== 'file' ? escHtml(ev.icon)+' ' : '') + + escHtml(ev.title || 'Untitled') + badge + '
'; + } + function renderCalendar() { + var dp = dateProp(); + if (!dp) return '
Add a Date property to use the calendar view.
'; + var mode = calMode(); + var cur = state.calCursor; + var today = new Date(); + var h = '
'; + h += '
'; + var title; + if (mode === 'month') title = MONTH_NAMES[cur.getMonth()]+' '+cur.getFullYear(); + else if (mode === 'week') { var ws=mondayOf(cur); title = MONTH_NAMES[ws.getMonth()].slice(0,3)+' '+ws.getDate()+' \u2013 '+(function(we){return MONTH_NAMES[we.getMonth()].slice(0,3)+' '+we.getDate();})(new Date(ws.getTime()+6*864e5)); } + else title = MONTH_NAMES[cur.getMonth()]+' '+cur.getDate()+', '+cur.getFullYear(); + h += '
'+title+'
'; + h += ''; + h += '
'+ + ['day','week','month'].map(function(m){ return ''; }).join('')+ + '
'; + h += '
'; + h += '
Loading\u2026
'; + return h; + } + function calWindow() { + var mode = calMode(); var cur = state.calCursor; + if (mode === 'month') { + var first = new Date(cur.getFullYear(), cur.getMonth(), 1); + var startDow = (first.getDay()+6)%7; + var start = new Date(first.getTime() - startDow*864e5); + var daysInMonth = new Date(cur.getFullYear(), cur.getMonth()+1, 0).getDate(); + var totalCells = Math.ceil((startDow + daysInMonth)/7)*7; + return {start: start, days: totalCells, mode: mode, startDow: startDow, daysInMonth: daysInMonth}; + } + if (mode === 'week') return {start: mondayOf(cur), days: 7, mode: mode}; + return {start: new Date(cur.getFullYear(), cur.getMonth(), cur.getDate()), days: 1, mode: mode}; + } + function paintCalendar(win, data) { + var body = containerEl.querySelector('#db-cal-body'); + if (!body) return; + body.removeAttribute('data-cal-loading'); + var today = new Date(); var todayIso = isoDay(today); + var byDate = data.byDate || {}; + var h = ''; + if (win.mode === 'month') { + h += '
'; + CAL_DOW.forEach(function(d){ h += '
'+d+'
'; }); + for (var i=0;i'; + h += '
'+cellDate.getDate()+'
'; + (byDate[iso]||[]).forEach(function(ev){ h += evChipHtml(ev); }); + h += '
'; + } + h += ''; + } else if (win.mode === 'week') { + h += '
'; + CAL_DOW.forEach(function(d){ h += '
'+d+'
'; }); + for (var j=0;j<7;j++) { + var d2 = new Date(win.start.getTime() + j*864e5); + var iso2 = isoDay(d2); + h += '
'; + h += '
'+d2.getDate()+'
'; + (byDate[iso2]||[]).forEach(function(ev){ h += evChipHtml(ev); }); + h += '
'; + } + h += '
'; + } else { + var iso3 = isoDay(win.start); + h += '
'; + h += '
'+CAL_DOW[(win.start.getDay()+6)%7]+' '+MONTH_NAMES[win.start.getMonth()]+' '+win.start.getDate()+(iso3===todayIso?' \u00b7 today':'')+'
'; + (byDate[iso3]||[]).slice().sort(function(a,b){ return (a.time||'99').localeCompare(b.time||'99'); }).forEach(function(ev){ + h += '
' + + ''+(ev.time || '\u2014')+'' + + ''+(ev.icon && ev.icon!=='file' ? escHtml(ev.icon)+' ' : '')+escHtml(ev.title||'Untitled')+'' + + (ev.recurring ? '\u21bb' : '') + + '
'; + }); + if (!(byDate[iso3]||[]).length) h += '
No events
'; + h += '
'; + } + body.innerHTML = h; + bindCalendarCells(); + } + var calSeq = 0; + function refreshCalendar() { + var win = calWindow(); + var startIso = isoDay(win.start); + var endIso = isoDay(new Date(win.start.getTime() + (win.days-1)*864e5)); + var seq = ++calSeq; + eventsForWindow(startIso, endIso).then(function(data){ + if (seq !== calSeq) return; // a newer request won + paintCalendar(win, data); + }); + } + function bindCalendarCells() { + // day cells: drop to reschedule + containerEl.querySelectorAll('[data-cal-date]').forEach(function(day){ + day.addEventListener('dragover', function(e){ e.preventDefault(); this.classList.add('drop-target'); }); + day.addEventListener('dragleave', function(){ this.classList.remove('drop-target'); }); + day.addEventListener('drop', function(e){ + e.preventDefault(); this.classList.remove('drop-target'); + var pid = parseInt(e.dataTransfer.getData('text/plain')); + var cp = pageById(pid); var dp = dateProp(); + if (!cp || !dp) return; + var old = pageVal(cp, dp); + var newDate = this.dataset.calDate; + var time = (old && String(old).length > 10) ? String(old).slice(10) : ''; + setPageVal(cp, dp, newDate + time); render(); + savePage(cp); + }); + }); + // event chips: open row in peek (stop drag click-through) + containerEl.querySelectorAll('.db-cal-ev, .db-cal-agenda-row').forEach(function(ev){ + ev.addEventListener('dragstart', function(e){ + e.dataTransfer.setData('text/plain', this.dataset.cardPage); + this.classList.add('dragging'); + }); + ev.addEventListener('dragend', function(){ this.classList.remove('dragging'); }); + ev.addEventListener('click', function(e){ + e.stopPropagation(); + openPageInSidePeek(parseInt(this.dataset.cardPage)); + }); + ev.addEventListener('dblclick', function(e){ + e.stopPropagation(); + openEventEditor(parseInt(this.dataset.cardPage), this.dataset.evDate, e); + }); + }); + } + + // ── v5.8.0: event editor popover (Repeat + Remind + time) ── + function openEventEditor(pageId, evDate, mouseEvt) { + var cp = pageById(pageId); var dp = dateProp(); + if (!cp || !dp) return; + if (!state.zones) { + fetch('/db/timezones/api', {credentials:'same-origin'}).then(function(r){return r.ok?r.json():{zones:[]};}) + .then(function(d){ state.zones = d.zones || []; }).catch(function(){}); + } + var old = document.querySelector('.db-event-pop'); if (old) old.remove(); + var pid = String(dp.id); + var rec = (metaOf(cp, '__recurrence__') || {})[pid] || null; + var rem = (metaOf(cp, '__reminder__') || {})[pid] || null; + var baseVal = String(pageVal(cp, dp) || ''); + var curTime = baseVal.length >= 16 ? baseVal.slice(11,16) : ''; + var pop = document.createElement('div'); + pop.className = 'db-event-pop'; + var freq = rec ? rec.freq : ''; + var curTz = ((metaOf(cp, '__timezone__') || {})[pid]) || ''; + var h = '
'+escHtml(cp.title||'Untitled')+'
'; + h += '
'; + h += '
'; + h += '
'; + if (freq) { + h += '
'; + h += '
'; + if (rec.count != null) h += '
'; + if (rec.until) h += '
'; + } + h += '
'; + h += '
'; + h += '
'; + pop.innerHTML = h; + document.body.appendChild(pop); + if (mouseEvt) { + pop.style.left = Math.min(mouseEvt.clientX, window.innerWidth-300)+'px'; + pop.style.top = Math.min(mouseEvt.clientY, window.innerHeight-320)+'px'; + } else { pop.style.left='50%'; pop.style.top='120px'; } + var freqSel = pop.querySelector('[data-ev-freq]'); + function collectDraft() { + var draft = rec ? Object.assign({}, rec) : {}; + draft.freq = freqSel.value; + var iv = pop.querySelector('[data-ev-interval]'); + if (iv) draft.interval = parseInt(iv.value)||1; + if (!pop.querySelector('[data-ev-interval]')) draft.interval = draft.interval || 1; + var em = pop.querySelector('[data-ev-endmode]'); + if (em) { + if (em.value === 'count') { var c=pop.querySelector('[data-ev-count]'); draft.count = c ? (parseInt(c.value)||null) : null; draft.until = null; } + else if (em.value === 'until') { var u=pop.querySelector('[data-ev-until]'); draft.until = (u&&u.value) ? u.value : null; draft.count = null; } + else { draft.count = null; draft.until = null; } + } + if (!draft.freq) return null; + return draft; + } + freqSel.onchange = function(){ + // re-open to reveal/hide detail rows, keeping the in-place edits + setMeta(cp, '__recurrence__', dp.id, collectDraft()); + rec = (metaOf(cp, '__recurrence__') || {})[pid] || null; + pop.remove(); + openEventEditor(pageId, evDate, mouseEvt); + }; + var endSel = pop.querySelector('[data-ev-endmode]'); + if (endSel) endSel.onchange = function(){ + setMeta(cp, '__recurrence__', dp.id, collectDraft()); + rec = (metaOf(cp, '__recurrence__') || {})[pid] || null; + pop.remove(); + openEventEditor(pageId, evDate, mouseEvt); + }; + pop.querySelector('[data-ev-save]').onclick = function(){ + var base = (baseVal.slice(0,10)) || evDate || isoDay(new Date()); + var t = pop.querySelector('[data-ev-time]').value; + var recDraft = collectDraft(); + var tzVal = pop.querySelector('[data-ev-tz]').value; + var unit = pop.querySelector('[data-ev-remunit]').value; + var val = parseInt(pop.querySelector('[data-ev-remval]').value)||0; + setPageVal(cp, dp, t && base ? (base+'T'+t) : base); + setMeta(cp, '__recurrence__', dp.id, recDraft); + setMeta(cp, '__timezone__', dp.id, tzVal || null); + setMeta(cp, '__reminder__', dp.id, unit==='none'?null:{value:val||1, unit:unit}); + pop.remove(); render(); savePage(cp); + }; + setTimeout(function(){ + document.addEventListener('click', function onDoc(e){ if (!pop.contains(e.target)) { pop.remove(); document.removeEventListener('click', onDoc); } }, {once:true}); + }, 0); + } + + // ── Gallery view ── + function renderGallery() { + var size = state.viewConfig.card_size || 'medium'; + var h = ''; + return h; + } + + // ── List view ── + function renderList() { + var cols = state.viewConfig.visible_properties; + var props; + if (Array.isArray(cols) && cols.length) props = cols.map(findProp).filter(Boolean); + else props = state.properties.filter(function(p){ return p.prop_type !== 'title'; }).slice(0,3); + var h = '
'; + state.pages.forEach(function(cp){ + h += '
'; + h += ''+escHtml(cp.icon&&cp.icon!=='file'?cp.icon:'📄')+''; + h += ''+escHtml(cp.title||'Untitled')+''; + props.forEach(function(p){ h += ''+escHtml(textValue(p, pageVal(cp,p)).slice(0,40))+''; }); + h += '
'; + }); + h += '
'; + return h; + } + + // ── Event binding ── + function bindEvents() { + bindViewBar(); + containerEl.querySelectorAll('.db-cell-content').forEach(function(el){ + el.onclick = function(e){ + if (state.editingCell || e.target.closest('.db-open-btn') || e.target.closest('a')) return; + startEditCell(parseInt(this.dataset.pageId), parseInt(this.dataset.propId), this); + }; + }); + containerEl.querySelectorAll('.db-open-page-btn').forEach(function(btn){ + btn.onclick = function(e){ e.stopPropagation(); openPageInSidePeek(parseInt(this.dataset.pageId)); }; + }); + containerEl.querySelectorAll('.db-ai-fill-btn').forEach(function(btn){ + btn.onclick = function(e){ e.stopPropagation(); aiFillRow(parseInt(this.dataset.pageId)); }; + }); + containerEl.querySelectorAll('.db-row').forEach(function(row){ + row.onmouseenter = function(){ this.querySelectorAll('.db-hover-only').forEach(function(el){ el.style.visibility='visible'; }); }; + row.onmouseleave = function(){ this.querySelectorAll('.db-hover-only').forEach(function(el){ el.style.visibility='hidden'; }); }; + }); + containerEl.querySelectorAll('.db-th-content').forEach(function(el){ + el.onclick = function(e){ e.stopPropagation(); showColMenu(parseInt(this.dataset.propId), this.dataset.propType, this.dataset.propName, this); }; + }); + containerEl.querySelectorAll('.db-col-resize-handle').forEach(bindResize); + var addProp = containerEl.querySelector('.db-show-prop-modal-btn'); + if (addProp) addProp.onclick = showPropModal; + // property group collapse + containerEl.querySelectorAll('[data-group-toggle]').forEach(function(el){ + el.onclick = function(){ + var gi = parseInt(this.dataset.groupToggle); + state.collapsedGroups[gi] = !state.collapsedGroups[gi]; + render(); + }; + }); + // open cards / rows + containerEl.querySelectorAll('[data-card-page]').forEach(function(el){ + el.addEventListener('click', function(e){ + if (e.target.closest('[data-add-col]')) return; + openPageInSidePeek(parseInt(this.dataset.cardPage)); + }); + el.addEventListener('dragstart', function(e){ + e.dataTransfer.setData('text/plain', this.dataset.cardPage); + this.classList.add('dragging'); + }); + el.addEventListener('dragend', function(){ this.classList.remove('dragging'); }); + }); + // board drop targets + containerEl.querySelectorAll('[data-drop-col]').forEach(function(zone){ + zone.addEventListener('dragover', function(e){ e.preventDefault(); this.classList.add('drop-target'); }); + zone.addEventListener('dragleave', function(){ this.classList.remove('drop-target'); }); + zone.addEventListener('drop', function(e){ + e.preventDefault(); this.classList.remove('drop-target'); + var pid = parseInt(e.dataTransfer.getData('text/plain')); + var cp = pageById(pid); var g = groupProp(); + if (!cp || !g) return; + var key = this.dataset.dropCol; + var val = key === '__none__' ? '' : key; + if (g.prop_type === 'person') { + var m = (state.members||[]).filter(function(x){ return (x.login||x.full_name) === key; })[0]; + val = m ? [m] : []; + } else if (g.prop_type === 'multi_select') { + val = key === '__none__' ? [] : [key]; + } + setPageVal(cp, g, val); render(); + savePage(cp); + }); + }); + // board add card + containerEl.querySelectorAll('[data-add-col]').forEach(function(b){ + b.onclick = function(e){ e.stopPropagation(); addNewPage(this.dataset.addCol); }; + }); + // calendar nav (v5.8.0: mode-aware) + function stepCursor(dir) { + var c = state.calCursor; + if (calMode() === 'month') state.calCursor = new Date(c.getFullYear(), c.getMonth()+dir, 1); + else if (calMode() === 'week') { var w = new Date(c); w.setDate(w.getDate()+7*dir); state.calCursor = w; } + else { var d = new Date(c); d.setDate(d.getDate()+dir); state.calCursor = d; } + render(); + } + var prev = containerEl.querySelector('[data-cal-prev]'); + if (prev) prev.onclick = function(){ stepCursor(-1); }; + var next = containerEl.querySelector('[data-cal-next]'); + if (next) next.onclick = function(){ stepCursor(1); }; + var today = containerEl.querySelector('[data-cal-today]'); + if (today) today.onclick = function(){ state.calCursor = new Date(); render(); }; + containerEl.querySelectorAll('[data-cal-mode]').forEach(function(b){ + b.onclick = function(){ + var m = this.dataset.calMode; + if (m !== calMode()) { persistConfig({calendar_mode: m}, {silent:true}); render(); } + }; + }); + if (state.viewType === 'calendar' && containerEl.querySelector('#db-cal-body')) refreshCalendar(); + var galAdd = containerEl.querySelector('[data-gallery-add]'); + if (galAdd) galAdd.onclick = function(){ addNewPage(); }; + } + + function bindResize(handle) { + handle.onpointerdown = function(e) { + e.preventDefault(); e.stopPropagation(); + var propId = parseInt(this.dataset.propId); + var th = this.parentElement; + var thLeft = th.getBoundingClientRect().left; + function onMove(ev) { + var newW = Math.max(60, ev.clientX - thLeft); + state.colWidths[propId] = newW; + th.style.width = newW+'px'; th.style.minWidth = newW+'px'; + var colIdx = state.properties.findIndex(function(p){ return p.id === propId; }); + if (colIdx >= 0) containerEl.querySelectorAll('.db-row').forEach(function(row){ + var cells = row.querySelectorAll('.db-td-cell'); + if (cells[colIdx]) cells[colIdx].style.minWidth = newW+'px'; + }); + } + function onUp() { document.removeEventListener('pointermove',onMove); document.removeEventListener('pointerup',onUp); document.body.style.cursor=''; document.body.style.userSelect=''; } + document.body.style.cursor='col-resize'; document.body.style.userSelect='none'; + document.addEventListener('pointermove',onMove); document.addEventListener('pointerup',onUp); + }; + } + + // ── Cell editing ── + function startEditCell(pageId, propId, cellEl) { + var cp = pageById(pageId); + var prop = propsById()[String(propId)]; + if (!cp || !prop || READONLY_TYPES.indexOf(prop.prop_type) >= 0) return; + var val = pageVal(cp, prop); + var t = prop.prop_type; + if (t === 'person') { openPersonPicker(cellEl, cp, prop); return; } + var input; + cellEl.innerHTML = ''; cellEl.classList.add('db-cell-editing'); + if (t === 'checkbox') { + setPageVal(cp, prop, !val); render(); savePage(cp); return; + } else if (t === 'select' || t === 'status') { + input = document.createElement('select'); input.className = 'db-cell-input'; + input.innerHTML = '' + parseOpts(prop).map(function(o){ return ''+escHtml(o.name)+''; }).join(''); + } else if (t === 'date') { + input = document.createElement('input'); input.type = 'date'; input.className = 'db-cell-input'; + input.value = val ? String(val).slice(0,10) : ''; + } else if (t === 'number') { + input = document.createElement('input'); input.type = 'number'; input.className = 'db-cell-input'; input.value = val==null?'':val; + } else { + input = document.createElement('input'); input.type = 'text'; input.className = 'db-cell-input'; input.value = textValue(prop, val); + } + cellEl.appendChild(input); input.focus(); + if (input.select) input.select(); + state.editingCell = {pageId:pageId, propId:propId, input:input, cellEl:cellEl, prop:prop}; + input.onkeydown = function(e){ if (e.key === 'Enter') commitEditCell(); else if (e.key === 'Escape') cancelEditCell(); }; + input.onblur = function(){ commitEditCell(); }; + } + + function commitEditCell() { + if (!state.editingCell) return; + var ed = state.editingCell; state.editingCell = null; + var cp = pageById(ed.pageId); var prop = ed.prop; + var val = ed.input.value; + if (prop.prop_type === 'multi_select') val = val ? val.split(',').map(function(s){ return s.trim(); }).filter(Boolean) : []; + if (prop.prop_type === 'number' && val !== '') val = Number(val); + if (prop.prop_type === 'checkbox') val = !!val; + setPageVal(cp, prop, val); + render(); + savePage(cp, ed.cellEl); + } + function cancelEditCell() { state.editingCell = null; render(); } + + function openPersonPicker(cellEl, cp, prop) { + var old = document.querySelector('.db-person-picker'); if (old) old.remove(); + var cur = pageVal(cp, prop); if (!Array.isArray(cur)) cur = []; + var menu = document.createElement('div'); + menu.className = 'db-person-picker'; + menu.innerHTML = (state.members||[]).map(function(m){ + var checked = cur.some(function(x){ return String(x.id) === String(m.id); }); + return '
'+avatarHtml(m)+''+escHtml(m.full_name||m.login)+''+(checked?'✓':'')+'
'; + }).join('') || '
No members
'; + document.body.appendChild(menu); + var r = cellEl.getBoundingClientRect(); + menu.style.left = r.left+'px'; menu.style.top = (r.bottom+4)+'px'; + menu.querySelectorAll('[data-mid]').forEach(function(opt){ + opt.onclick = function(){ + var m = (state.members||[]).filter(function(x){ return String(x.id) === String(this.dataset.mid); }.bind(this))[0]; + var idx = cur.findIndex(function(x){ return String(x.id) === String(m.id); }); + if (idx >= 0) cur.splice(idx,1); else cur.push(m); + setPageVal(cp, prop, cur.slice()); + menu.remove(); render(); savePage(cp); + }; + }); + setTimeout(function(){ + document.addEventListener('click', function onDoc(e){ if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', onDoc); } }, {once:true}); + }, 0); + } + + function savePage(cp, cellEl) { + var pv = pvOf(cp); + return fetch('/db/pages/'+cp.id+'/api', { + method:'PUT', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, + body: JSON.stringify({title: cp.title||'', properties: pv}) + }).then(function(r){ + if (!r.ok) return r.json().then(function(d){ throw {status:r.status, detail:(d&&d.detail)||('Error '+r.status)}; }); + return r.json(); + }).catch(function(err){ + var msg = (err && err.detail) ? String(err.detail) : ((err&&err.message)||'Failed to save'); + if (cellEl && err && err.status === 400) { cellEl.classList.add('db-cell-invalid'); cellEl.title = msg; } + toast('⚠ '+msg, 'error'); + }); + } + + // ── v5.9.0: AI property suggestions ── + function aiFillRow(pageId) { + var cp = pageById(pageId); if (!cp) return; + var props = state.properties.filter(function(p){ return p.prop_type !== 'title' && READONLY_TYPES.indexOf(p.prop_type) < 0; }); + if (!props.length) { toast('Aucune propriété à remplir'); return; } + toast('✨ FlowDeck AI : analyse en cours…'); + fetch('/api/agent/writing/properties', { + method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, + body: JSON.stringify({title: cp.title||'', content: cp.content||'', properties: props.map(function(p){ return {name:p.name, type:p.prop_type}; })}) + }).then(function(r){ return r.json(); }).then(function(d){ + if (!d || !d.ok) { toast((d&&d.error)||'Échec de la génération'); return; } + var filled = 0; + props.forEach(function(p){ + var v = d.suggestions ? d.suggestions[p.name] : undefined; + if (v !== undefined && v !== null && v !== '') { setPageVal(cp, p, v); filled++; } + }); + render(); savePage(cp); + toast(filled ? ('✨ '+filled+' propriété(s) remplie(s)') : 'Aucune suggestion disponible'); + }).catch(function(){ toast('Erreur IA', 'error'); }); + } + + function addNewPage(colKey) { + var body = {title:''}; + var g = groupProp(); + if (colKey !== undefined && g && colKey !== '__none__') { + if (g.prop_type === 'person') { + var m = (state.members||[]).filter(function(x){ return (x.login||x.full_name) === colKey; })[0]; + body.properties = {}; body.properties[String(g.id)] = m ? [m] : []; + } else if (g.prop_type === 'multi_select') { + body.properties = {}; body.properties[String(g.id)] = [colKey]; + } else { + body.properties = {}; body.properties[String(g.id)] = colKey; + } + } + fetch('/api/collections/'+collectionId+'/pages', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify(body)}) + .then(function(r){ return r.json(); }) + .then(function(d){ if (d.id) { state.pages.push({id:d.id,title:'',icon:'file',collection_id:collectionId,property_values_json:d.property_values_json||body.properties||{}}); render(); } }) + .catch(function(e){ console.error('[DB] addNewPage error:', e); }); + } + + // ── Property modal ── + function showPropModal() { + var old = containerEl.querySelector('.db-prop-modal-inline'); if (old) old.remove(); + var modal = document.createElement('div'); + modal.className = 'db-prop-modal-inline'; + modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:1000;display:flex;align-items:center;justify-content:center'; + modal.innerHTML = '
'+ + '

Add a property

'+ + '
'+ + '
'+ + '
'+ + '
'+ + '
'+ + '
'+ + '
'+ + '
'; + document.body.appendChild(modal); + var nameInput = modal.querySelector('.db-prop-name-inline'); + nameInput.focus(); + modal.querySelector('.db-prop-modal-close-btn').onclick = function(){ modal.remove(); }; + modal.querySelector('.db-prop-cancel-inline').onclick = function(){ modal.remove(); }; + modal.onclick = function(e){ if (e.target === modal) modal.remove(); }; + modal.querySelector('.db-prop-create-inline').onclick = function(){ + var name = nameInput.value.trim(); if (!name) return; + var type = modal.querySelector('.db-prop-type-inline').value; + var group = modal.querySelector('.db-prop-group-inline').value.trim(); + var validation = {}; + if (modal.querySelector('.db-prop-required-inline').checked) validation.required = true; + if (modal.querySelector('.db-prop-unique-inline').checked) validation.unique = true; + var min = modal.querySelector('.db-prop-min-inline').value; + var max = modal.querySelector('.db-prop-max-inline').value; + if (min !== '') validation.min = Number(min); + if (max !== '') validation.max = Number(max); + fetch('/db/'+collectionId+'/properties/api', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({name:name, prop_type:type, group_name:group, validation:validation})}) + .then(function(r){ return r.json(); }) + .then(function(d){ if (d.id) { d.options_json = d.options_json || '[]'; d.group_name = group; state.properties.push(d); render(); modal.remove(); } }) + .catch(function(e){ console.error('[DB] createProperty error:', e); }); + }; + nameInput.onkeydown = function(e){ if (e.key === 'Enter') modal.querySelector('.db-prop-create-inline').click(); }; + } + + // ── Column context menu ── + function showColMenu(propId, propType, propName, anchor) { + var old = document.querySelector('.db-col-menu-inline'); if (old) old.remove(); + var menu = document.createElement('div'); + menu.className = 'db-col-menu-inline'; + menu.style.cssText = 'position:fixed;min-width:240px;max-width:320px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.14);z-index:800;padding:4px 0;font-size:13px;color:var(--text-primary)'; + var rect = anchor.getBoundingClientRect(); + menu.style.top = (rect.bottom+4)+'px'; menu.style.left = rect.left+'px'; + menu.innerHTML = '
'+propTypeSvg(propType)+''+escHtml(propName)+'ⓘ
'+ + '
'+ + '
≡ Filter
'+ + '
↕ Sort
'+ + '
▦ Group by this
'+ + '
🗂 Move to group…
'+ + '
'+ + '
├← Insert left
'+ + '
→┤ Insert right
'; + document.body.appendChild(menu); + menu.querySelectorAll('[data-cm]').forEach(function(item){ + item.onclick = function(){ + var act = this.dataset.cm; menu.remove(); + if (act === 'group') { persistConfig({group_by: propId}); toast('Grouped by '+propName); } + else if (act === 'groupname') { + var g = prompt('Group name for "'+propName+'" (empty to ungroup):', (findProp(propId)||{}).group_name || ''); + if (g !== null) { + fetch('/db/properties/'+propId+'/api', {method:'PUT', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({group_name:g.trim()})}) + .then(function(){ var p = findProp(propId); if (p) p.group_name = g.trim(); render(); }); + } + } + }; + }); + setTimeout(function(){ + document.addEventListener('click', function onDoc(e){ if (!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click', onDoc); } }, {once:true}); + }, 0); + } + + // ── View settings popover ── + function closePop() { var p = document.querySelector('.db-view-pop'); if (p) p.remove(); } + function openPop(anchor, html) { + closePop(); + var pop = document.createElement('div'); + pop.className = 'db-view-pop'; + pop.innerHTML = html; + document.body.appendChild(pop); + var r = anchor.getBoundingClientRect(); + pop.style.left = Math.min(r.left, window.innerWidth - 380) + 'px'; + pop.style.top = (r.bottom + 6) + 'px'; + setTimeout(function(){ + document.addEventListener('click', function onDoc(e){ if (!pop.contains(e.target) && !anchor.contains(e.target)) { pop.remove(); document.removeEventListener('click', onDoc); } }, {once:true}); + }, 0); + return pop; + } + function propSelect(name, selected, filterFn) { + var opts = state.properties.filter(filterFn || function(){ return true; }).map(function(p){ + return ''; + }).join(''); + return ''; + } + function openViewSettings(anchor) { + var v = activeView(); + var t = state.viewType; + var h = '
'+viewSvg(t)+' View settings
'; + h += '
'; + if (t === 'board') { + var g = groupProp(); + h += '
'+propSelect('group_by', state.viewConfig.group_by || (g?g.id:''), function(p){ return ['select','status','person','multi_select','text'].indexOf(p.prop_type)>=0; })+'
'; + h += '
'+propSelect('sub_group_by', state.viewConfig.sub_group_by, function(p){ return ['select','status','person'].indexOf(p.prop_type)>=0; })+'
'; + h += '
'; + h += '
'; + if (state.viewConfig.cover_mode === 'property') h += '
'+propSelect('cover_property', state.viewConfig.cover_property, function(p){ return ['files','url'].indexOf(p.prop_type)>=0; })+'
'; + h += '
WIP limits (leave empty for none)
'; + optionColumns(g).forEach(function(col){ + if (col.key === '__none__') return; + var lim = (state.viewConfig.wip_limits||{})[col.key] || ''; + h += '
'; + }); + h += '
Card properties
'; + state.properties.filter(function(p){ return p.prop_type!=='title'; }).forEach(function(p){ + var on = (state.viewConfig.card_properties||defaultCardProps()).some(function(x){ return String(x)===String(p.id); }); + h += ''+escHtml(p.name)+''; + }); + h += '
'; + } else if (t === 'calendar') { + h += '
'+propSelect('date_property', state.viewConfig.date_property, function(p){ return p.prop_type==='date'; })+'
'; + } else if (t === 'gallery') { + h += '
'; + h += '
'; + if (state.viewConfig.cover_mode === 'property') h += '
'+propSelect('cover_property', state.viewConfig.cover_property, function(p){ return ['files','url'].indexOf(p.prop_type)>=0; })+'
'; + h += '
Card properties
'; + state.properties.filter(function(p){ return p.prop_type!=='title'; }).forEach(function(p){ + var on = (state.viewConfig.card_properties||defaultCardProps()).some(function(x){ return String(x)===String(p.id); }); + h += ''+escHtml(p.name)+''; + }); + h += '
'; + } else if (t === 'table') { + h += '
Assign properties to collapsible groups. Group headers appear above the table.
'; + state.properties.forEach(function(p){ + h += '
'; + }); + } else { + h += '
List uses the first properties. Configure card properties in a Board or Gallery view.
'; + } + h += '
'; + h += '
⧉ Duplicate view
'; + h += '
🗑 Delete view
'; + var pop = openPop(anchor, h); + // Wire name + var nameInput = pop.querySelector('[data-pop-name]'); + if (nameInput) nameInput.onchange = function(){ if (v) { persistConfig({}, {name:this.value, silent:true}); render(); } }; + // Wire selects / inputs + pop.querySelectorAll('[data-pop-field]').forEach(function(el){ + el.onchange = function(){ + var patch = {}; patch[this.dataset.popField] = this.value; persistConfig(patch); + }; + }); + pop.querySelectorAll('[data-wip]').forEach(function(el){ + el.onchange = function(){ + var limits = Object.assign({}, state.viewConfig.wip_limits || {}); + if (this.value === '') delete limits[this.dataset.wip]; else limits[this.dataset.wip] = Number(this.value); + persistConfig({wip_limits: limits}); + }; + }); + pop.querySelectorAll('[data-cardprop]').forEach(function(el){ + el.onclick = function(){ + var arr = (state.viewConfig.card_properties || defaultCardProps()).slice(); + var id = Number(this.dataset.cardprop); + var idx = arr.findIndex(function(x){ return String(x)===String(id); }); + if (idx >= 0) arr.splice(idx,1); else arr.push(id); + persistConfig({card_properties: arr}); + }; + }); + pop.querySelectorAll('[data-groupassign]').forEach(function(el){ + el.onchange = function(){ + fetch('/db/properties/'+this.dataset.groupassign+'/api', {method:'PUT', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({group_name:this.value.trim()})}) + .then(function(){ var p = findProp(this.dataset.groupassign); if (p) p.group_name = this.value.trim(); }.bind(this)) + .then(function(){ render(); }); + }; + }); + pop.querySelectorAll('[data-pop-action]').forEach(function(el){ + el.onclick = function(){ + var act = this.dataset.popAction; pop.remove(); + if (act === 'duplicate' && v) { + fetch('/db/views/'+v.id+'/duplicate', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body:'{}'}) + .then(function(r){ return r.json(); }) + .then(function(d){ if (d.id) { state.views.push(d); applyView(d); render(); } }); + } else if (act === 'delete' && v) { + if (!confirm('Delete this view?')) return; + fetch('/db/views/'+v.id+'/api', {method:'DELETE', headers:{'X-CSRF-Token':getCsrf()}}).then(function(){ + state.views = state.views.filter(function(x){ return x.id !== v.id; }); + applyView(state.views[0] || null); render(); + }); + } + }; + }); + } + + function openAddViewMenu(anchor) { + var h = '
Add a view
'; + ['table','board','calendar','gallery','list'].forEach(function(t){ + h += '
'+viewSvg(t)+' '+t.charAt(0).toUpperCase()+t.slice(1)+'
'; + }); + var pop = openPop(anchor, h); + pop.querySelectorAll('[data-addview]').forEach(function(el){ + el.onclick = function(){ + var t = this.dataset.addview; pop.remove(); + fetch('/db/'+collectionId+'/views/save-as', {method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()}, body: JSON.stringify({name:t.charAt(0).toUpperCase()+t.slice(1), view_type:t, config:{}})}) + .then(function(r){ return r.json(); }) + .then(function(d){ if (d.id) { state.views.push(d); applyView(d); render(); } }); + }; + }); + } + + // ── Init ── + function loadMembers() { + if (state.members) return Promise.resolve(state.members); + return fetch('/db/'+collectionId+'/members/api').then(function(r){ return r.json(); }).then(function(d){ state.members = (d && d.members) || []; return state.members; }).catch(function(){ state.members = []; }); + } + + if (!initialData) { + fetch('/api/collections/'+collectionId+'/table-data').then(function(r){ return r.json(); }).then(function(d){ + state.collection = d.collection || {}; state.properties = d.properties || []; state.pages = d.pages || []; state.views = d.views || []; + ensureView().then(loadMembers).then(render); + }).catch(function(e){ console.error('[DB] load error:', e); }); + } else { + ensureView().then(loadMembers).then(render); + } + } + + // ── Global API ── + window.FlowDeckDB = { + /** Render a database table into a container element. */ + renderInto: function(containerEl, collectionId, initialData) { + new DBInstance(containerEl, collectionId, initialData); + } + }; + + // ── Full-page init (when page_editor_collection.html is used) ── + var PAGE_COLLECTION_ID = window.__DB_COLLECTION_ID || 0; + if (PAGE_COLLECTION_ID > 0) { + var container = document.getElementById('db-table-container'); + if (container) { + new DBInstance(container, PAGE_COLLECTION_ID, DB_CONFIG); + } + } + + // ── Global Side Peek for Database Pages (auto-creates panel) ── + function openPageInSidePeek(pageId) { + var panel = document.getElementById('db-side-peek'); + if (!panel) { + panel = document.createElement('div'); + panel.id = 'db-side-peek'; + panel.style.cssText = 'position:fixed;top:0;right:0;width:560px;height:100vh;background:var(--bg-primary);box-shadow:-4px 0 20px rgba(0,0,0,0.12);z-index:900;display:flex;flex-direction:column'; + panel.innerHTML = '
'+ + '
'+ + ''+ + ''+ + '
'+ + ''; + document.body.appendChild(panel); + document.getElementById('db-side-peek-close').onclick = function(){ panel.style.display='none'; document.getElementById('db-side-peek-iframe').src=''; }; + document.getElementById('db-side-peek-full').onclick = function(){ + var ifr = document.getElementById('db-side-peek-iframe'); + if (ifr && ifr.src) window.open(ifr.src.replace('?embed=1',''),'_blank'); + }; + var rh = document.getElementById('db-side-peek-resize'); + rh.onpointerdown = function(e) { + e.preventDefault(); e.stopPropagation(); + var sx = e.clientX, sw = panel.offsetWidth; + function mv(ev) { var w = Math.max(300, Math.min(window.innerWidth*0.9, sw-(ev.clientX-sx))); panel.style.width = w+'px'; } + function up() { document.removeEventListener('pointermove',mv); document.removeEventListener('pointerup',up); document.body.style.cursor=''; } + document.body.style.cursor='col-resize'; + document.addEventListener('pointermove',mv); document.addEventListener('pointerup',up); + }; + } + var iframe = document.getElementById('db-side-peek-iframe'); + // v6.5.0: a database row id is NOT a pages id (raw /pages/{rowId} + // opened an unrelated page with the same id) — resolve the row's + // content page first (lazy-created), then load the real editor. + resolveRowContentPage(pageId).then(function(pid){ + var fr = document.getElementById('db-side-peek-iframe'); + if (fr) { fr.src = '/pages/'+pid+'?embed=1'; panel.style.display = 'flex'; } + }); + if (iframe) panel.style.display = 'flex'; + } + + function resolveRowContentPage(rowId){ + return fetch('/db/pages/'+rowId+'/open/api') + .then(function(r){ return r.ok ? r.json() : null; }) + .then(function(d){ return (d && d.page_id) ? d.page_id : rowId; }) + .catch(function(){ return rowId; }); + } +})(); diff --git a/tests/test_ai_writing.py b/tests/test_ai_writing.py index 650e144..43f4fee 100644 --- a/tests/test_ai_writing.py +++ b/tests/test_ai_writing.py @@ -294,7 +294,7 @@ def test_editor_has_autocomplete(client): def test_database_has_ai_properties(client): - src = _read_template("_database_table_scripts.html") + src = _read_js("database_table.js") assert "db-ai-fill-btn" in src assert "aiFillRow" in src assert "/api/agent/writing/properties" in src diff --git a/tests/test_v57_db_advanced.py b/tests/test_v57_db_advanced.py index 6e1d072..e64d054 100644 --- a/tests/test_v57_db_advanced.py +++ b/tests/test_v57_db_advanced.py @@ -8,6 +8,7 @@ from __future__ import annotations import json import os +import pathlib import secrets import tempfile @@ -273,8 +274,10 @@ def test_collection_page_renders_multi_view(client): resp = client.get(f"/pages/{page}", cookies={"flowdeck_session": cookie}) assert resp.status_code == 200 assert "db-view-bar" in resp.text - assert "FlowDeckDB" in resp.text # New view types + settings are available client-side. - assert "db-board" in resp.text - assert "db-cal-grid" in resp.text - assert "db-gallery" in resp.text + # (A27 : le JS de la DB est extrait dans static/js/database_table.js.) + src = pathlib.Path("static/js/database_table.js").read_text(encoding="utf-8") + assert "FlowDeckDB" in src + assert "db-board" in src + assert "db-cal-grid" in src + assert "db-gallery" in src diff --git a/tests/test_v58_calendar_reminders.py b/tests/test_v58_calendar_reminders.py index aedc4a2..fa57f5d 100644 --- a/tests/test_v58_calendar_reminders.py +++ b/tests/test_v58_calendar_reminders.py @@ -417,7 +417,7 @@ def test_notification_prefs_extended_keys(client): def test_calendar_front_end_wired(client): import pathlib - src = pathlib.Path("app/templates/_database_table_scripts.html").read_text(encoding="utf-8") + src = pathlib.Path("static/js/database_table.js").read_text(encoding="utf-8") assert "calendar/api" in src assert "data-cal-mode" in src and "refreshCalendar" in src assert "openEventEditor" in src and "__recurrence__" in src and "__reminder__" in src