Compare commits

...
18 Commits
Author SHA1 Message Date
bruno 45917c194d fix: A27 phase 2b — +3 801 L extraits (recette config JSON) (v7.23.0)
FlowDeck CI / lint (push) Successful in 1m55s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 37m14s
4 blocs interpolés extraits avec la recette de la 2a (config JSON inline +
JS statique, substitutions sur le CORPS du bloc) :
- local_workspace.html → local_workspace.js (2 031 L, lw-config :
  current_folder_id, workspace_id)
- settings.html → settings.js (1 093 L, st-config : avatar, user
  full_name/login/email, is_admin (bool), auth_method — 2 routes rendent ce
  template, expressions « or "" » préservées pour les valeurs Undefined)
- _page_editor_realtime.html → page_editor_realtime.js (531 L, rt-config :
  SELF id/login/full_name/color)
- board.html → board.js (146 L, bd-config : owner/repo/initial_view)

BONUS sécurité : les valeurs passent par |tojson (échappement JSON explicite)
au lieu d'être interpolées dans des strings JS. Tags : config JSON (nonce
conservé) + <script src> ?v={{ asset_version }} ; loaders JSON.parse en tête
(try/catch → {}). Correctif sur le loader (accolade try en trop, caught par
node --check avant tout commit).

Cumul A27 : 10 560 L extraites (13 904 → 3 344 restantes, -76 %).
Reste structurel : base 1 338 ({% block %}/{% for %}) + database_table 1 323
(if/else) + 279 warnings eslint (12 fichiers, 0 erreur).

suite **1089/1089** · ruff OK · node --check ×4 vert · docs à jour
2026-10-01 20:37:07 -04:00
bruno ee1d46e965 fix: A27 phase 2a — éditeur 2 516 L extrait via page-data JSON (v7.22.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- `_page_editor_scripts.html` : le gros bloc interpolé (2 516 L) part vers
  `static/js/page_editor_scripts.js` — recette « config JSON » : les 8
  interpolations Jinja lisent `PD = JSON.parse(#page-data)`, bloc JSON qui
  EXISTAIT DÉJÀ juste avant le script (même ordre d'exécution), garde
  `__fdEditorScriptsLoaded` préservée, node --check vert.
- Route `view_page_root` : page_data enrichi de updated_at, created_at,
  user_id, is_shared (dérivé HOISTÉ : une seule expression sert 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é stricte).

8 tests adaptés à l'extraction (ils lisaient le template SOURCE) :
- test_ai_writing ×2 (+ helper _read_js), test_pwa_offline,
  test_v511 front_end_wired, test_v55 ×3 → lisent le JS extrait
- test_page_editor_renders_page_is_shared → parsing du JSON #page-data
  (`is_shared is True`) — la valeur sert toujours à la page

Cumul A27 : 6 759 L extraites (13 904 → 7 145 inline). Reste : local_workspace
2 031, base 1 523 (structurel {% for %}/{% block %}), database_table 1 323,
settings 1 093, realtime 531, board 146 ≈ 6 653 L + 120 warnings eslint.

suite **1089/1089** · ruff OK · docs à jour
2026-10-01 16:12:26 -04:00
bruno 587ec8d61b fix: A27 phase 1 — 4 243 L de JS inline extraites + eslint actif (v7.21.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Extraction des 7 templates dont le JS n'est PAS interpolé Jinja → 9 fichiers
static/js/*.js (4 243 lignes, -30 % du JS inline : 13 904 → 9 661) :
- agent_panel_1/_2 (bloc de 1 788 L livré sur CHAQUE page), library (1 039),
  gitea_workspace (626), _icon_picker_1/_2, _ctx_menu, import, workspaces
- UN fichier par bloc : ordre/timing identiques (pas de defer, attributs
  conservés dont data-cfasync), cache-busting via ?v={{ asset_version }}
  (source unique A40), scripts externes = 'self' en CSP (pas de nonce requis)
- garde-fou : le script refuse tout bloc contenant {{ ou {%
- vérifs : node --check vert sur les 9, 0 script inline restant dans les
  cibles, suite complète 1089/1089

Lint (la moitié « ajouter les templates à eslint » de l'audit) :
- eslint.config.mjs existait (flat v9, sans dépendances npm) mais AUCUN
  binaire eslint n'était installé → npm i -g eslint
- `eslint static/js` → 0 erreur, 120 warnings (no-unused-vars 69,
  no-empty 36, no-undef 15) sur 8 fichiers = baseline à nettoyer
- les extraits sont couverts d'office par la config (static/js/**/*.js)

Reste A27 : blocs interpolés Jinja (page_editor 2 517, local_workspace 2 031,
base 1 523, database_table 1 323, settings 1 093, realtime 531 ≈ 9 661 L)
→ extraction en 2 temps (config JSON injectée + script statique).

suite **1089/1089** · ruff OK · docs à jour
2026-10-01 15:55:09 -04:00
bruno 7a38ddd0f6 test: A32 TERMINÉ — 6 routes Gitea stubbées + bug prod fd_icon (v7.20.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Les 6 dernières routes d'A32 (api.py, gitea) avec stub de transport — zéro
réseau réel :

- _stub_gitea() : stubs manuels sur gitea_client.gitea (create_issue,
  update_issue, update_issue_labels, get_issue, get_issue_comments) avec
  ÉTAT MUTABLE PARTAGÉ — le handler PATCH re-fetch l'issue via get_issue,
  un canevas figé aurait masqué la mise à jour.
- POST /issues : carte INSÉRÉE sur le board (board seedé par endpoint) ;
  PATCH : colonne recalculée sans perdre la carte.
- GET /issues JSON + HTML : ?format=html requis (le segment /html ne fixe pas
  le paramètre, le handler le lit dans la query) ; stub qui lève → 404.
- POST /checklists + POST /checklist-items : lignes vérifiées en base,
  404 sans board ; cleanup (items → checklists).

BUG PROD corrigé (trouvé par le smoke HTML) : card_detail.html utilisait la
macro fd_icon SANS l'importer → UndefinedError → 500 systématique sur
GET /api/issues/...?format=html (seul rendu du template dans le code).
Fix : {% from '_icons.html' import fd_icon %}.

A32 COMPLET : plus aucun router « 0 test » (webhooks, notes, sidebar_config,
github_routes, library, api, dashboard, api_v2 tous couverts).

test_smoke_uncovered.py : 52 tests. suite **1089/1089** · ruff OK · docs à jour
2026-10-01 15:39:21 -04:00
bruno 113374e499 test: A32 phase 2h — dashboard bloqué : 44/44 routes à 0 ref (v7.19.0)
FlowDeck CI / test (push) Failing after 3h12m50s
FlowDeck CI / lint (push) Successful in 1m54s
FlowDeck CI / docker (push) Skipped
+5 routes dashboard (fichier test_smoke_uncovered.py à 49 tests) :

- Members POST/PUT/DELETE : invitation de soi-même dans un workspace dédié
  (_own_workspace), rôle admin relu en base, membre supprimé (COUNT=0).
  Quirk documenté : les retours tuple des routes (`{"error": ...}, 400`)
  sont sérialisés FastAPI en tableau + 200 → assert sur `[0]["error"]`.
- upload-folder : validations SEULES (structure absente → 400 « No
  structure provided », JSON cassé → 400 « Invalid structure JSON ») —
  zéro fichier écrit, workspace dédié nettoyé.
- convert-to-database : collection + propriété title + vue table + page en
  content_format='collection' VÉRIFIÉS en base, 404 page inconnue,
  cleanup dans l'ordre FK (pages avant collections — IntegrityError corrigée).

Recoupement final : scan des 44 routes strictement à 0 ref de dashboard.py →
TOUTES exercées. Les 19 résidus du scan sont des faux positifs (paths en
f-string dans les tests : /api/workspace/1/…, f"/api/pages/{id}/…", …)
rapprochés manuellement un par un.

Reste A32 : les 6 routes Gitea d'api.py (issues ×4, créations checklists)
→ stub de transport httpx (effort S).

suite **1086/1086** · `ruff check app tests` OK · docs à jour
2026-10-01 15:24:38 -04:00
bruno 0cb476e336 test: A32 phase 2g — dashboard +13 routes, cycles items/tags (v7.18.0)
FlowDeck CI / lint (push) Successful in 1m58s
FlowDeck CI / test (push) Successful in 14m55s
FlowDeck CI / docker (push) Canceled after 0s
Cumul dashboard : 43 → 56 des 63 routes. 4 nouveaux tests (fichier à 46) :

- GET /gitea-workspace : page HTML (200 ou redirection propre)
- workspace/projects GET+POST : shape {builtin, gitea, github} avec
  github == [] ; projet créé RETROUVÉ dans builtin ; quirk « error » sans nom
  ; nettoyage (DELETE page)
- Cycle items local-workspace (5 routes) : POST création (titre relu),
  PUT rename (relu en base), PUT move, DELETE soft-delete (deleted_at relu),
  POST restore (deleted_at NULL relu) — nettoyage finally
- Cycle tags d'item (5 routes) : POST (urgenta32 lowercasé), tags de l'item,
  liste workspace, search (shape), suppression vérifiée. Utilisateur DÉDIÉ +
  workspace créé dans le test (le endpoint /api/local-workspace/tags exige un
  workspace actif : fallback « premier workspace du user » — on n'attache pas
  ce workspace à l'utilisateur fixture partagé), tout est nettoyé.

Reste A32 : dashboard 7 routes (members invite/role/unsubscribe,
upload-folder, convert-to-database) + 6 routes Gitea d'api.py (stub httpx).

suite **1083/1083** · `ruff check app tests` OK · docs à jour
2026-10-01 15:11:45 -04:00
bruno 2339fa2586 test: A32 phase 2f — dashboard +7 routes, garde-fous A16 (v7.17.0)
FlowDeck CI / lint (push) Successful in 1m52s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 5m54s
Cumul dashboard : 36 → 43 des 63 routes. 4 nouveaux tests (fichier à 42),
centrés sur les garde-fous A16 :

- GET /api/files/{ws}/{path} : traversal encodé %2e%2e%2f → 403
  « Path traversal denied » (décodé par Starlette puis bloqué par resolve) ;
  inexistant → 404 ; vrai fichier écrit dans le data_dir de test →
  200 + octets exacts, nettoyé en finally
- GET /api/pages/{id}/download : page markdown → 404 « No downloadable file »
  (pas de 500) ; page « file » avec chemin ../ sortant de la racine →
  jamais 200 ; file-content → 404/415 sans fuite
- GET /api/local-workspace/page-content/{id} : contenu + format relus,
  404 sur id inconnu
- GET /api/avatar/{id} : 302 + Location avec follow_redirects=False
  (AUCUNE requête réelle vers l'URL externe), 404 sans avatar
- GET/POST /api/collections/{id}/table-data|pages : 404 inconnu, shape,
  ligne créée retrouvée dans table-data, nettoyage finally

Reste A32 : dashboard 20 routes (upload/local-workspace items/members/
projects/HTML gitea) + 6 routes Gitea d'api.py (stub transport httpx).

suite **1079/1079** · `ruff check app tests` OK · docs à jour
2026-10-01 14:40:06 -04:00
bruno 8b48dbdd4b test: A32 phase 2e — dashboard +9 routes, comptes A2/A3 (v7.16.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Cumul dashboard : 27 → 36 des 63 routes. 6 nouveaux tests (fichier à 38) :

- /accounts + /accounts/settings : 200 HTML et « password_hash » ABSENT du
  rendu (whitelist A2 vérifiée côté page)
- PUT /api/user/profile : persistance relue en base, restauration finally
- PUT /api/user/password : 403 « current password is incorrect » (A3 — la
  session seule ne change pas le mdp) + quirk assumé documenté : la longueur
  est validée AVANT l'auth et répond 200 + message
- POST /api/user/token : format fd_ + 64 hex ; ligne user_tokens nettoyée
- DELETE /api/user/forge/{provider} : {"status": "ok"}
- PUT /api/settings/account : full_name/email persistés + 400 sur mdp court,
  restauration finally
- POST /api/workspaces/1/select : Set-Cookie flowdeck_workspace vérifié ;
  GET /api/local-workspace/breadcrumb : shape liste

Reste A32 : dashboard 27 routes (fichiers/avatars/local-workspace/collections)
+ 6 routes Gitea d'api.py (stub transport httpx).

suite **1075/1075** · `ruff check app tests` OK · docs à jour
2026-10-01 13:59:00 -04:00
bruno 360c705fd4 test: A32 phase 2d — dashboard +10 routes couvertes (v7.15.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Scan strict dashboard.py : 44 routes à 0 référence stricte sur 63. 10
couvertes cette passe (test_smoke_uncovered.py → 32 tests) :

- Tags CRUD complet : POST (nom lowercasé `SmokeTag` → `smoketag`), GET all
  (présent), PUT couleur (relue), DELETE (absente de la liste ensuite)
- Vie d'une page : GET /api/pages/{id}/content (contenu seedé relu) →
  PUT rename (ok + **400 titre vide** + titre relu en base) →
  POST trash (parent_section='Trash' + deleted_at RELUS en base) ;
  nettoyage en finally
- GET /api/sidebar/workspace-tree : 200 HTML, fragment « No pages yet »
  (pas de cookie workspace)
- POST /api/settings/avatar-color : couleur relue SUR L'UTILISATEUR DE LA
  SESSION (pas LIMIT 1), avatar_color/avatar_url d'origine restaurés
- GET /api/workspace/1/members : shape {"members": [...]}

Helper _seed_page : les colonnes par défaut sont surchargeables (content=)
pour les seeds à contenu.

Reste A32 : dashboard 34 routes à 0 ref (fichiers/avatars/imports…) +
6 routes Gitea d'api.py (stub transport httpx).

suite **1069/1069** · `ruff check app tests` OK · docs à jour
2026-10-01 13:36:56 -04:00
bruno 0698645dbd test: A32 phase 2c — api_v2 : les 5 routes à 0 ref couvertes (v7.14.0)
FlowDeck CI / lint (push) Successful in 1m52s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 34m59s
Scan strict des 115 routes api_v2.py contre tous les tests (chaîne de chemin
littérale) → 5 routes sans AUCUNE référence, toutes couvertes maintenant :

- POST /properties/evaluate-formula : 200 + shape, 400 sans expression.
  Le moteur renvoie « 1 + 2 » tel quel aujourd'hui → le smoke valide le câble
  (bearer, Body param, parse), pas le moteur (réalm de ses propres tests).
- POST /properties/compute-rollup : 400 « collection_id required »,
  401 sans bearer.
- GET /admin/audit-logs : portail admin VÉRIFIÉ — l'attendu est calculé
  depuis /users/me (le tout premier utilisateur d'un worker est admin :
  état non contrôlable depuis un test isolé), + token scope admin → 200 + logs.
- GET /webhooks/events : catalogue non vide + wildcards * / page.*.
- POST /webhooks/verify-signature : valid=True avec sign_payload() (le même
  helper que le serveur), False avec signature bidon.

test_smoke_uncovered.py : 27 tests. Reste A32 : dashboard 17/63 + 6 routes
gitea d'api.py (stub transport).

suite **1064/1064** · `ruff check app tests` OK · docs à jour
2026-10-01 13:09:32 -04:00
bruno b2e38aece7 test: A32 phase 2b — api.py 3 → 16/22 routes couvertes (v7.13.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
+6 smokes dans test_smoke_uncovered.py (22 tests au total dans le fichier) :
- board-config GET/POST : défauts 5 colonnes sans board, création puis
  relecture du roundtrip (seed via l'endpoint lui-même, pas de SQL brut)
- col-mapping POST/DELETE : 404 sans board, upsert label vérifié, suppression
- card POST : 404 sans board, ok avec
- collaborators GET : gitea.get_collaborators STUBBÉ (zéro accès réseau réel)
- frontend-error(s) : capture, JSON invalide → ignored, DÉDUP d'une erreur
  répétée (count=2), lecture qui purge (cleared=true puis 0)
- checklist mutations : PATCH item (checked/content relus EN BASE), DELETE
  item, DELETE checklist (COUNT=0) — seed + cleanup en finally

Reste api.py : 6 routes Gitea (issues ×4 + créations checklists owner/repo) →
stub de transport httpx (phase suivante). Reste global : dashboard 17/63,
api_v2 50/115.

suite **1059/1059** (236 s) · `ruff check app tests` OK · docs à jour
2026-10-01 12:35:22 -04:00
bruno df9a269d76 test: A32 phase 2a — library 10/10 + 2 routes fantômes supprimées (v7.12.0)
FlowDeck CI / lint (push) Successful in 1m53s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 8m41s
- tests/test_smoke_uncovered.py : +6 tests pour library.py (1/10 → 8 routes
  couvertes) : les 5 listes en boucle (recents/favorites/published/private/
  workspace → 200 + items), /private avec page seedée retrouvée, /children/{id}
  avec parent/enfant seedés, /repository vide ET clé (string, aucun réseau),
  non-régression 404 sur les routes supprimées.
- DÉCOUVERTE (les smokes l'ont prouvé) : `/api/library/local-workspace-children`
  renvoyait un 500 systématique (test vert → ASGI double response.start) et
  `/api/library/local-workspace` 500 dès qu'un workspace existe — les deux
  lisaient `local_workspace_items`, table AUCUNEMENT créée dans le codebase
  (0 CREATE TABLE) et sans 1 seule référence front. Supprimés avec
  `library._format_size` devenu mort (la copie de dashboard.py est inchangée).
  `local_workspace_items` : 0 occurrence restante dans app/.
- helper `_seed_page` minimal (workspace NOT NULL inclus) + nettoyage en
  finally (pas de pollution des autres tests).

suite **1053/1053** (229 s) · `ruff check app tests` OK · docs à jour
restent phase 2b : api.py 3/23, dashboard.py 17/63, api_v2.py 50/115
2026-10-01 12:11:12 -04:00
bruno da7326ffde test: A32 phase 1 — 4 routers à 0 test couverts (10 smokes) (v7.11.0)
FlowDeck CI / lint (push) Successful in 1m50s
FlowDeck CI / test (push) Successful in 13m55s
FlowDeck CI / docker (push) Canceled after 0s
tests/test_smoke_uncovered.py — un smoke par route des 4 routers qui n'avaient
AUCUN test :
- webhooks.py 3/3 : réception sans secret → {"status":"ok"} ; HMAC faux → 401
  (secret piloté par monkeypatch, déterministe quel que soit le .env) ;
  register sans secret → 400 AVANT tout appel réseau ; status avec
  gitea.list_webhooks stubbé → {"registered": False} (zéro réseau réel)
- notes.py 2/2 : GET HTML + roundtrip POST→GET (upsert persisté en base,
  échappement HTML vérifié : &lt;b&gt; et non <b>)
- sidebar_config.py 2/2 : GET défauts ; PUT persisté puis RELU depuis
  users.sidebar_config ; 400 sans config ; remise en état en fin de test
- github_routes.py 2/2 : status {"linked": False} ; disconnect {"status": "ok"}

Reste (A32 phase 2) : quasi nuls — library 1/10, api 3/23, dashboard 17/63,
api_v2 50/115 → même recette, fixture client existante.

suite **1047/1047** · `ruff check app tests` OK · docs à jour
2026-10-01 11:54:43 -04:00
bruno 3a1276596c fix: A21 phase 2b — run_event_sync + 15 routes api_v2 en def (v7.10.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- `run_event_sync(coro)` (app/services/automations.py) : exécute une coroutine
  d'événement depuis un handler synchrone — `asyncio.run` sur une boucle
  dédiée dans le worker threadpool : le worker est bloqué, JAMAIS la boucle
  d'event, et la réponse n'est produite qu'une fois l'événement terminé
  (déterministe, équivalent sémantique de l'await). ponytail: les clients
  httpx sont créés à chaque appel partout → aucun lien de boucle ; sinon
  run_coroutine_threadsafe + boucle du lifespan.
- 15 routes api_v2 dont les SEULS awaits étaient `request.json`,
  `_fire_event`, `fire_published`, `fire_unpublished` →
  `Body(default={})` + `run_event_sync(...)` + conversion en `def` (script
  : wrapping par appariement de parenthèses chaîne-aware, assert de flip
  « plus aucun await »).
- api_v2 : **111/115 routes hors loop**. Les 4 restantes ont de vrais awaits
  réseau et restent async volontairement : import_csv_v2 (multipart),
  project_tree_v2 (gitea), test_webhook_v2 (delivery), retry_webhook_deliveries.
- Repo-wide : 403 routes sync (hors loop) / 260 async (phase 2c).

tests : ciblé public_api_v2 + v65 + webhooks_v2 + audit = 90/90 (les webhooks
prouvent la détermination de run_event_sync) ; suite complète **1037/1037**
(228 s) · `ruff check app tests` OK · docs à jour
2026-10-01 11:35:43 -04:00
bruno 07904f05e5 fix: A21 phase 2a — api_v2 : body JSON en paramètre, 36 routes hors loop (v7.9.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- Les 36 routes d'api_v2 dont le SEUL `await` était
  `body = await request.json()` (bloc try/except uniforme) → paramètre FastAPI
  `body: dict = Body(default={})` + conversion en `def` → threadpool :
  toute leur séquence SQLite quitte l'event loop.
- Équivalences vérifiées avant engament (probe FastAPI) :
  · corps absent → `{}` (identique à l'ancien try/except)
  · JSON invalide → 422 (avant : avalé comme `{}` — 422 est plus juste)
  · zéro `body[...]=` / setdefault / update dans api_v2 → défaut partagé
    jamais muté
- verify_webhook_signature (signature multi-ligne) traitée à la main.
- Piège courant évité : première version du script supprimait 5 lignes au
  lieu de 4 (slice m-1:m+4) → fichier restauré depuis git, slice corrigée,
  0 ligne perdue (diff logique +39/-183).

api_v2 : 96/115 routes hors loop (60 phase 1 + 36 ici) ; 19 async restantes
(fire_event, request.form, gitea/webhooks) = phase 2b.

suite **1037/1037** (242 s) · `ruff check app tests` OK · docs à jour
2026-10-01 11:19:58 -04:00
bruno 224bda74d5 fix: A21 phase 1 — 352 routes async sans await → threadpool (v7.8.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- Conversion `async def` → `def` de TOUTES les routes dont le corps ne contient
  ni `await`, ni `async with`, ni `async for`, ni `asyncio` (scan automatique
  corps par corps sur app/ : 352 converties, 0 dangereuses, vérifié
  `asyncio`/`run_coroutine`/`.result()` absents). FastAPI exécute ces handlers
  dans son threadpool → tout leur SQLite (`get_conn()` + `conn.execute`) quitte
  l'event loop, sans changer une ligne de logique.
- Répartition : api_v2 60, dashboard 40, collections 25, board 23,
  workspace 19, wiki 17, permissions 14, api 14, main.py 6, + 35 fichiers.
- Les 4 routers prioritaires de l'audit sont couverts par ce lot :
  api_v2 60 + dashboard 40 + collections 25 + board 23 = 148 conversions
  (le reste de leurs routes attend la phase 2 : elles ont de vrais `await`).
- Reste (phase 2) : les 311 routes avec de vrais `await` → enrouler les blocs
  DB dans `await anyio.to_thread.run_sync(...)` ; pas de wrapper partagé livré
  (rien ne l'appellerait — YAGNI jusqu'au premier usage).

suite **1037/1037** (233 s) · `ruff check app tests` OK · docs à jour
2026-10-01 10:53:26 -04:00
bruno c718fe06de fix: A20 (partiel) — CSP nonce par requête, unsafe-inline sort de script-src (v7.7.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- ContentSecurityPolicyMiddleware : nonce aléatoire par requête dans la
  ContextVar `CSP_NONCE` (posée avant `call_next` → visible des templates),
  `script-src 'self' 'unsafe-eval' 'nonce-…'` — plus aucun script inline
  sans nonce ne tourne (fin des XSS injectés en JS)
- 38 tags `<script>` des templates : `nonce="{{ csp_nonce() }}"` (passage
  scripté, vérifié : 0 restant) ; `LOCAL_LOGIN_HTML` (constante de module) :
  helper `_with_nonce()` au rendu ; collections.py : 3 scripts Python
  (chart/form/map) noncés
- `<meta name="htmx-config" content='{"inlineScriptNonce": …}'>` dans base.html
  : htmx ré-injecte les <script> des réponses boostées avec le bon nonce
- `script-src-attr 'unsafe-inline'` : les 74 handlers `onclick=` inline
  restent couverts (le nonce les aurait désactivés aussi)
- chart.js (cdn.jsdelivr.net) et leaflet (unpkg) ajoutés à script-src/style-src
  : vues chart/map déjà BLOQUÉES par la CSP depuis toujours
  (commentaire ponytail: upgrade = vendoriser puis retirer les hôtes)
- reste d'A20 : unsafe-eval (Alpine x-data → @alpinejs/csp), externalisation
  JS (A27), resserrer img-src/connect-src

test : test_csp_nonce_per_request (page base.html + page hors template,
nonce unique par requête)

suite **1037/1037** · `ruff check app tests` OK · docs à jour
2026-10-01 10:41:39 -04:00
bruno f706424f90 fix: A31 — transaction par migration + helper columns() (v7.6.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- `_apply_one()` : BEGIN explicite → `fn(conn)` → marque `schema_version` →
  commit ; rollback complet à l'échec. Avant le DDL sortait en autocommit
  (isolation_level legacy) : un échec au milieu laissait un schéma partiel
  commité SANS ligne de version, et la reprise rejouait un DDL déjà appliqué.
  Si une transaction englobante subsiste (init_db commit juste avant), on la
  vide d'abord plutôt que de l'englober.
- Helper unique `columns(conn, table)` (valide l'identifiant, ValueError sinon)
  : 25 copies de `{r[1] for r in conn.execute("PRAGMA table_info(...)")}`
  éliminées dans migrations.py (21 littéraux + 3 f-string + 1 variante row).
  `table_exists`/`column_exists` préconisés par l'audit NON livrés : aucune
  migration n'interroge sqlite_master, un contrôle unitaire se lit dans le set.
- Smoke : DB fraîche → 28 migrations → version 29, ré-apply idempotent.

tests : test_migration_transaction_rolls_back (DDL partiel annulé + zéro marque
de version), test_columns_helper_validates_table_name

suite **1036/1036** · `ruff check app tests` OK · OpenAPI 511 chemins / 7.6.0
docs (ROADMAP/CHANGELOG/WORKLOAD/VERSION) à jour
2026-10-01 10:13:23 -04:00
102 changed files with 13383 additions and 11495 deletions
+427
View File
@@ -1,5 +1,432 @@
# Changelog - FlowDeck
## v7.23.0 (2026-10-01) — Audit : A27 phase 2b (+3 801 L)
### Changed
- **A27 (phase 2b)** — 4 blocs interpolés extraits avec la recette config
JSON (identique à la 2a) :
· `local_workspace.html` → `local_workspace.js` (2 031 L, `lw-config` :
current_folder_id, workspace_id)
· `settings.html` → `settings.js` (1 093 L, `st-config` : avatar_url,
avatar_color, user full_name/login/email, is_admin, auth_method —
**2 routes rendent ce template**, les expressions `or ""` sont préservées
pour les valeurs Undefined, `is_admin` reste un booléen)
· `_page_editor_realtime.html` → `page_editor_realtime.js` (531 L,
`rt-config` : SELF id/login/full_name/color)
· `board.html` → `board.js` (146 L, `bd-config` : owner/repo/initial_view)
- BONUS sécurité : les valeurs passent par `|tojson` (échappement JSON
explicite) au lieu d'être interpolées dans des strings JS
- Tags : config JSON inline (nonce conservé) + `<script src>` avec
`?v={{ asset_version }}` ; loaders `XX = JSON.parse(#xx-config)` en tête
des fichiers extraits (try/catch → `{}`)
### Notes
- **Cumul A27 : 10 560 L extraites** (13 904 → **3 344 restantes**, -76 %)
- `node --check` vert sur les 4 fichiers ; eslint : **0 erreur, 279 warnings**
(12 fichiers, baseline mise à jour)
- Reste structurel : `base` 1 338 (`{% block %}`/`{% for %}` — reste inline
par nature) + `database_table` 1 323 (`if/else` sur collection_data)
- Suite complète : **1089/1089**
## v7.22.0 (2026-10-01) — Audit : A27 phase 2a (l'éditeur, 2 516 L)
### Changed
- **A27 (phase 2a)** — `_page_editor_scripts.html` : les **2 516 lignes** du
gros bloc interpolé partent vers `static/js/page_editor_scripts.js`
- Recette « config JSON » : les **8 interpolations Jinja** lisent maintenant
`PD = JSON.parse(document.getElementById('page-data'))` — le bloc JSON
`#page-data` **existait déjà** juste avant le script, même ordre
d'exécution, garde `__fdEditorScriptsLoaded` préservée
- Côté route (`view_page_root`) : `page_data` enrichi de `updated_at`,
`created_at`, `user_id`, `is_shared` (le dérivé est **hoisté** — une seule
expression sert 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 contexte → parité stricte)
- `node --check` vert ; template : 2 tags restants (JSON config + src)
### Fixed
- **8 tests adaptés** à l'extraction (ils lisaient le template source) :
`test_ai_writing` ×2 (+ helper `_read_js`), `test_pwa_offline`,
`test_v511` front_end_wired, `test_v55` ×3 (lightbox, previews, endpoints)
→ lisent `static/js/page_editor_scripts.js` ; `test_page_editor_renders_
page_is_shared` → **parsing du JSON `#page-data`** (`is_shared is True`,
la valeur sert toujours à la page)
### Notes
- Cumul A27 : **6 759 L extraites** (13 904 → 7 145 inline)
- Reste : local_workspace 2 031, base 1 523 (structurel {% for %}/{% block %}),
database_table 1 323 (if/else), settings 1 093, realtime 531, board 146
≈ 6 653 L + 120 warnings eslint à nettoyer
- Suite complète : **1089/1089**
## v7.21.0 (2026-10-01) — Audit : A27 phase 1 (4 243 L de JS extraites)
### Changed
- **A27 (phase 1)** — les 7 templates dont le JS **n'est pas interpolé Jinja**
sont extraits vers `static/js/*.js` : agent_panel_1/_2 (dont un bloc de
**1 788 lignes livré sur chaque page**), library (1 039), gitea_workspace
(626), _icon_picker_1/_2, _ctx_menu, import, workspaces →
**4 243 lignes, -30 % de JS inline** (13 904 → 9 661)
- Extraction **un fichier par bloc** : ordre et timing d'exécution identiques
(pas de defer/async, attributs d'origine conservés dont `data-cfasync`),
cache-busting `?v={{ asset_version }}` (source unique A40), CSP : les
scripts externes relèvent de `'self'` (pas de nonce requis)
### Lint
- ESLint **installé globalement** (`npm i -g eslint`) — `eslint.config.mjs`
existait déjà en flat config « sans dépendances » mais **aucun binaire**
n'était installé (d'où « 0 linté »)
- `eslint static/js` : **0 erreur, 120 warnings** sur 8 fichiers
(no-unused-vars 69, no-empty 36, no-undef 15) → baseline à nettoyer
- `node --check` vert sur les 9 fichiers extraits ; `eslint.config.mjs` couvre
déjà `static/js/**/*.js` donc les extraits sont lintés d'office
### Notes
- Reste A27 : les blocs interpolés Jinja (page_editor 2 517, local_workspace
2 031, base 1 523, database_table 1 323, settings 1 093… ≈ 9 661 L) →
extraction en 2 temps (config JSON injectée + script statique)
- Suite complète : **1089/1089**
## v7.20.0 (2026-10-01) — Audit : A32 TERMINÉ (routes Gitea + bug fd_icon)
### Fixed
- **Bug prod trouvé par les smokes** : `card_detail.html` appelait la macro
`fd_icon` **sans l'importer** → `UndefinedError` → **500 systématique** sur
`GET /api/issues/{o}/{r}/{id}?format=html` (seul rendu du template dans le
code) → `{% from '_icons.html' import fd_icon %}` ajouté
### Tests
- Les **6 dernières routes d'A32** (Gitea, stub de transport, zéro réseau) :
· stubs manuels sur `gitea_client.gitea` avec **état mutable partagé**
(le handler PATCH re-fetch l'issue via `get_issue` — un canevas figé
aurait masqué la mise à jour)
· `POST /issues` : carte **insérée sur le board** ; `PATCH` : colonne
recalculée **sans perdre la carte**
· `GET /issues` JSON + **HTML** (`?format=html` — le segment `/html` ne fixe
pas le paramètre `format`, il est lu dans la query) ; stub qui lève → 404
· `POST /checklists` + `POST /checklist-items` : lignes **vérifiées en
base**, 404 sans board
- `test_smoke_uncovered.py` : **52 tests** ; suite complète **1089/1089**
- **A32 complet** : plus aucun router « 0 test » (webhooks, notes,
sidebar_config, github_routes, library, api, dashboard, api_v2)
## v7.19.0 (2026-10-01) — Audit : A32 : dashboard bloqué (44/44)
### Tests
- +5 routes `dashboard.py` — `test_smoke_uncovered.py` : 49 tests :
· **Members** (POST/PUT/DELETE) : invitation de soi-même dans un workspace
dédié, rôle relu en base, membre supprimé (`COUNT=0`) ; **quirk documenté**
: les retours `(..., 400)` de ces routes sont sérialisés FastAPI en
tableau + 200 (`[{"error": "Invalid role"}, 400]`)
· `upload-folder` : **validations seules** (structure absente → 400,
JSON cassé → 400) — zéro fichier écrit sur disque, workspace dédié nettoyé
· `convert-to-database` : collection + propriété `title` + vue `table` +
page en `content_format='collection'` **vérifiés en base**, 404 page
inconnue, cleanup **dans l'ordre FK** (page avant collection)
- **Recoupement final** : scan des 44 routes strictement à 0 ref de
`dashboard.py` → **toutes exercées** (19 faux positifs résiduels =
paths en f-string dans les tests, rapprochés manuellement)
- Suite complète : **1086/1086**
## v7.18.0 (2026-10-01) — Audit : A32 phase 2g (dashboard +13)
### Tests
- +13 routes `dashboard.py` (cumul **43→56 sur 63**) —
`test_smoke_uncovered.py` : 46 tests :
· `/gitea-workspace` : page HTML (200 ou redirection propre)
· `workspace/projects` GET+POST : shape `{builtin, gitea, github}` avec
`github == []`, projet créé **retrouvé dans builtin**, quirk `error` sans
nom, nettoyage
· **Cycle items local-workspace** (5 routes) : création → renommage **relu en
base** → move → soft-delete (`deleted_at` **relu**) → restore
(`deleted_at IS NULL` **relu**), nettoyage
· **Cycle tags d'item** (5 routes) : POST (nom lowercasé), tags de l'item,
liste workspace, search (shape), suppression vérifiée. Utilisateur +
workspace **créés dans le test** : `/api/local-workspace/tags` a besoin
d'un workspace actif (fallback « premier workspace du user ») — on ne le
fait pas dépendre de l'utilisateur fixture partagé, tout est nettoyé
- Suite complète : **1083/1083**
## v7.17.0 (2026-10-01) — Audit : A32 phase 2f (dashboard +7, garde-fous A16)
### Tests
- +7 routes `dashboard.py` (cumul **36→43 sur 63**), centrées sur les
garde-fous A16 — `test_smoke_uncovered.py` : 42 tests :
· `GET /api/files/{ws}/{path}` : traversal encodé `%2e%2e%2f` →
**403 « Path traversal denied »** ; inexistant → 404 ; vrai fichier écrit
dans le data_dir de test → **200 + octets exacts** (nettoyé)
· `GET /api/pages/{id}/download` : page markdown → 404 « downloadable »
(pas de 500) ; page « file » avec chemin `../` qui sort de la racine →
**jamais 200** (404), et `file-content` → 404/415
· `GET /api/local-workspace/page-content/{id}` : contenu + format relus,
404 sur id inconnu
· `GET /api/avatar/{id}` : **302 + Location** avec `follow_redirects=False`
(AUCUNE requête réelle vers l'URL externe — règle « 0 réseau »), 404 sans
avatar
· `GET/POST /api/collections/{id}/table-data|pages` : 404 inconnu, shape,
ligne créée **retrouvée dans table-data**, nettoyage finally
- Suite complète : **1079/1079**
## v7.16.0 (2026-10-01) — Audit : A32 phase 2e (dashboard +9, comptes)
### Tests
- +9 routes `dashboard.py` (cumul **27→36 sur 63**) :
· `/accounts` + `/accounts/settings` : 200 HTML, **`password_hash` absent**
du rendu (whitelist A2 vérifiée côté page)
· `PUT /api/user/profile` : persistance **relue en base**, valeur d'origine
restaurée en `finally`
· `PUT /api/user/password` : **403 « current password is incorrect »** (A3 —
la session seule ne change pas le mdp) + quirk assumé et documenté :
longueur validée AVANT auth → 200 + message d'erreur
· `POST /api/user/token` : format `fd_` + 64 hex, ligne `user_tokens`
nettoyée en `finally`
· `DELETE /api/user/forge/{provider}` : `{"status": "ok"}`
· `PUT /api/settings/account` : full_name/email persistés + **400 sur mdp
court** (validateur), restauration en `finally`
· `POST /api/workspaces/{id}/select` : `Set-Cookie flowdeck_workspace`
vérifié ; `GET /api/local-workspace/breadcrumb` : shape liste
- `test_smoke_uncovered.py` : 38 tests ; suite complète **1075/1075**
## v7.15.0 (2026-10-01) — Audit : A32 phase 2d (dashboard +10 routes)
### Tests
- Scan strict `dashboard.py` : **44 routes à 0 référence** (sur 63) — 10
couvertes cette passe dans `test_smoke_uncovered.py` (32 tests au fichier) :
· **Tags CRUD** : création (`SmokeTag` → `smoketag` lowercasé), présence
dans la liste, changement de couleur relu, suppression puis absence
· **Vie d'une page** : GET `content` (contenu seedé relu) → PUT `rename`
(ok + **400 sur titre vide** + titre relu en base) → POST `trash`
(`parent_section='Trash'` + `deleted_at` **relus en base**)
· `sidebar/workspace-tree` : 200 HTML, fragment « No pages yet » sans cookie
· `settings/avatar-color` : couleur relue **sur l'utilisateur de la
session** (pas `LIMIT 1`), valeurs d'origine restaurées en `finally`
· `workspace/{id}/members` : shape `{"members": [...]}`
- Helper `_seed_page` : surcharge des colonnes par défaut (`content=`, …)
- Suite complète : **1069/1069**
## v7.14.0 (2026-10-01) — Audit : A32 phase 2c (api_v2 +5 routes)
### Tests
- Scan strict des 115 routes `api_v2.py` contre tous les tests (chaîne de
chemin littérale) → **5 routes à 0 référence**, toutes couvertes :
· `POST /properties/evaluate-formula` : 200 + shape, 400 sans `expression`
(le moteur renvoie `1 + 2` tel quel aujourd'hui — le smoke valide le câble
route/auth/parse, pas le moteur)
· `POST /properties/compute-rollup` : 400 `collection_id required`,
401 sans bearer
· `GET /admin/audit-logs` : portail admin vérifié — attendu **calculé depuis
`/users/me`** (le tout premier utilisateur d'un worker est admin, état non
contrôlable depuis le test), + token scope `admin` → 200 + `logs` liste
· `GET /webhooks/events` : catalogue non vide + wildcards `*`/`page.*`
· `POST /webhooks/verify-signature` : **valid=True** avec
`sign_payload(secret, payload)` (même helper que le serveur), False avec
une signature bidon
- `test_smoke_uncovered.py` : 27 tests au total
- Suite complète : **1064/1064**
## v7.13.0 (2026-10-01) — Audit : A32 phase 2b (api.py 16/22)
### Tests
- `api.py` passe de **3 à 16 routes couvertes** (22 `@router` au total) :
· `board-config` GET/POST : défauts à 5 colonnes sans board, création puis
relecture du roundtrip
· `col-mapping` POST/DELETE : 404 sans board, upsert `label` vérifié,
suppression vérifiée
· `card` POST : 404 sans board, `{"status": "ok"}` avec
· `collaborators` GET : **`gitea.get_collaborators` stubbé** (zéro réseau réel)
· `frontend-error(s)` : capture, JSON invalide → `ignored`, **dédup** d'une
erreur répétée (`count=2`), lecture qui purge (`cleared=true` puis 0)
· checklist mutations : PATCH item (checked/content relus EN BASE),
DELETE item, DELETE checklist (réapparition `COUNT=0`) — seed + cleanup
- Reste `api.py` : 6 routes Gitea (issues ×4 + créations checklists) →
stub de transport httpx. Reste global : `dashboard.py` 17/63,
`api_v2.py` 50/115
### Tests
- Suite complète : **1059/1059** (236 s) ; `test_smoke_uncovered.py` : 22 tests
## v7.12.0 (2026-10-01) — Audit : A32 phase 2a (library 10/10)
### Tests
- `library.py` passe de **1/10 à 8 routes couvertes** : les 5 listes
(recents/favorites/published/private/workspace) en un test de boucle,
`/private` avec une page seedée et retrouvée, `/children/{id}` avec un
parent/enfant seedés (titre retrouvé), `/repository` vide et clé
(aucun appel réseau — la clé n'est qu'une string de workspace)
- Test de non-régression 404 sur les 2 routes supprimées
### Removed
- **2 routes cassées supprimées** (découverte des smokes) :
`/api/library/local-workspace-children/{id}` renvoyait un 500 systématique
et `/api/library/local-workspace` un 500 dès qu'un workspace existait —
les deux lisaient `local_workspace_items`, **une table qui n'est créée nulle
part** dans le codebase (grep : 0 `CREATE TABLE`), avec **0 référence front**.
`library._format_size` devenu mort : supprimé aussi (une version vit dans
`dashboard.py`, inchangée)
- `local_workspace_items` : plus aucune occurrence dans `app/`
### Tests
- Suite complète : **1053/1053** (229 s) ; `test_smoke_uncovered.py` : 16 tests
## v7.11.0 (2026-10-01) — Audit : A32 phase 1 (routers à 0 test)
### Tests
- `tests/test_smoke_uncovered.py` — **10 smoke tests**, un par route des 4
routers qui n'avaient AUCUN test :
· `webhooks.py` (3/3) : réception sans secret → `{"status":"ok"}` ;
HMAC faux → 401 (secret piloté par monkeypatch) ; register sans secret →
400 **avant** tout appel réseau ; status avec `gitea.list_webhooks` stubbé
(zéro accès réseau réel)
· `notes.py` (2/2) : GET HTML + roundtrip POST→GET (upsert persisté,
échappement HTML vérifié `&lt;b&gt;`)
· `sidebar_config.py` (2/2) : GET défauts, PUT persisté relu depuis
`users.sidebar_config`, 400 sans `config`, remise en état en fin de test
· `github_routes.py` (2/2) : status `{"linked": False}`, disconnect ok
- Reste (phase 2) : quasi nuls — `library.py` 1/10, `api.py` 3/23,
`dashboard.py` 17/63, `api_v2.py` 50/115
- Suite complète : **1047/1047**
## v7.10.0 (2026-10-01) — Audit : A21 phase 2b (api_v2 bouclé)
### Changed
- **Helper** `run_event_sync(coro)` (`app/services/automations.py`) : exécute
une coroutine d'événement depuis un handler synchrone — `asyncio.run` sur une
boucle dédiée dans le **worker threadpool** : bloqué = le worker, jamais la
boucle d'event, et la réponse n'est envoyée qu'une fois l'événement terminé
(déterministe, équivalent sémantique de l'`await` d'avant). Note
`ponytail:` : clients httpx créés à chaque appel partout → aucun lien de
boucle ; sinon `run_coroutine_threadsafe` + boucle du lifespan
- **A21 (phase 2b)** — 15 routes `api_v2` dont les seuls awaits étaient
`request.json` / `_fire_event` / `fire_published` / `fire_unpublished` →
paramètre `Body(default={})` + `run_event_sync(...)` + conversion en `def`
- **`api_v2` : 111/115 routes hors event loop** — il ne reste que 4 routes
async, toutes avec de vrais awaits réseau : `import_csv_v2` (multipart),
`project_tree_v2` (gitea), `test_webhook_v2`, `retry_webhook_deliveries`
- Repo-wide : **403 routes synchrones (hors loop) / 260 async** (phase 2c)
### Tests
- Ciblé (public_api_v2 + v65 + webhooks_v2 + audit) : 90/90 — les webhooks
prouvent la détermination de `run_event_sync` ; suite complète **1037/1037**
en 228 s
## v7.9.0 (2026-10-01) — Audit : A21 phase 2a (api_v2 hors loop)
### Changed
- **A21 (phase 2a)** — dans `api_v2`, les 36 routes dont le **seul** `await`
était `body = await request.json()` passent à un paramètre FastAPI
`body: dict = Body(default={})` (parsing async fait par FastAPI avant
l'appel) puis sont converties en `def` → threadpool. Équivalences vérifiées
avant-engagement : corps absent → `{}` (identique au `try/except` d'avant),
JSON invalide → **422** (avant : traité silencieusement comme `{}`),
zéro `body[...] = ` dans le fichier (le défaut partagé n'est jamais muté)
- `api_v2` : **96/115 routes hors event loop** (60 en phase 1 + 36 ici) ;
il ne reste que **19 routes async** dans ce router (`fire_event`,
`request.form`, appels gitea/webhooks — phase 2b)
- Bug de transformation évité en cours de route : première version du script
supprimait 5 lignes au lieu de 4 (`slice` fermant d'un cran trop loin) —
fichier restauré depuis git puis script corrigé, 0 ligne perdue (diff
logique : +39/-183 = 36 signatures + import, 4 lignes de try/except × 35)
### Tests
- Suite complète **1037/1037** (242 s) ; ciblée sur `test_public_api_v2` +
`test_v65` + audit : 62/62 verts avant la passe complète
## v7.8.0 (2026-10-01) — Audit : A21 phase 1 (SQLite hors event loop)
### Changed
- **A21 (phase 1)** — **352 routes** `async def` sans aucun `await` converties
en `def` : FastAPI les exécute alors dans son threadpool — tout leur travail
SQLite (`get_conn()` + `conn.execute`) quitte l'event loop, **sans changer une
ligne de logique** (la conversion est sémantiquement neutre : vérifié corps
par corps — aucun `await`/`async with`/`async for`/`asyncio` dans les
fonctions converties). Répartition : api_v2 60, dashboard 40, collections 25,
board 23, workspace 19, wiki 17, permissions 14, api 14, + 35 autres fichiers
- **Reste (phase 2)** — les 311 routes qui ont de vrais `await`
(`request.json()`, `fire_event`, httpx) : enrouler les blocs DB dans
`await anyio.to_thread.run_sync(...)` ; aucun wrapper partagé livré pour
l'instant (rien ne l'appellerait)
### Perf
- Dernière suite : 233 s (écarts précédents mesurés : 235-359 s) — les
handlers SQLite ne saturent plus la boucle pendant les tests
## v7.7.0 (2026-10-01) — Audit : A20 (CSP — nonce, partie 1)
### Security
- **A20** — `script-src` : `'unsafe-inline'` remplacé par `'nonce-<aléatoire par
requête>'`. Le middleware CSP génère le nonce dans une `ContextVar` avant
`call_next` (visible des templates via `{{ csp_nonce() }}`) ; **38 tags
`<script>` inline** des templates, la constante de module `LOCAL_LOGIN_HTML`
(helper `_with_nonce()` au rendu) et **3 scripts Python** dans `collections.py`
le portent ; htmx reçoit le même nonce via `<meta name="htmx-config">`
(`inlineScriptNonce` — les scripts des réponses boostées restent valides)
- Les 74 handlers `onclick=` inline restent fonctionnels via
`script-src-attr 'unsafe-inline'` (détaché de `script-src` : le nonce les
aurait désactivés aussi)
- `https://cdn.jsdelivr.net` / `https://unpkg.com` ajoutés à `script-src` et
`style-src` : les vues chart/map de `collections` les utilisent et étaient
**bloquées par la CSP depuis toujours** (commentaire `ponytail:` → upgrade :
vendoriser ces libs puis retirer les hôtes)
- Reste d'A20 : `unsafe-eval` (Alpine `x-data` en string → build
`@alpinejs/csp`), externalisation du JS inline (A27), resserrer
`img-src`/`connect-src`
### Tests
- `test_csp_nonce_per_request` : page `base.html` (meta htmx-config + nonce du
header identique sur tous les scripts inline, nonce différent d'une requête à
l'autre) et page hors template (`/auth/login?provider=local`)
## v7.6.0 (2026-10-01) — Audit : A31 (dette migrations)
### Fixed
- **A31** — transaction par migration : `_apply_one()` fait `BEGIN` → `fn(conn)`
→ marque `schema_version` → `commit`, rollback complet à l'échec. Avant, le
DDL sortait en autocommit (isolation_level legacy) : un échec au milieu
laissait un schéma partiel commité SANS ligne de version, et la reprise
rejouait un DDL déjà appliqué
- **A31** — helper unique `columns(conn, table)` (valide l'identifiant,
`ValueError` sinon) : **25 copies** de
`{r[1] for r in conn.execute("PRAGMA table_info(...)")}` éliminées dans
`migrations.py`. `table_exists`/`column_exists` préconisés par l'audit non
livrés : aucune migration n'interroge `sqlite_master`, un contrôle unitaire
se lit dans le set
### Tests
- `test_migration_transaction_rolls_back` (DDL partiel annulé + pas de marque
de version, chemin nominal enregistré), `test_columns_helper_validates_table_name`
## v7.5.0 (2026-10-01) — Audit : A29, A42 (partiel)
### Changed
+6 -6
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
7.5.0
7.23.0
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.5.0 (audit — A29/A42 partiel) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.23.0 (audit — A27 phase 2b : +3 801 L, cumul 10 560 L) | **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
+7 -7
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.5.0",
version="7.23.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
@@ -265,14 +265,14 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/manifest.json")
async def pwa_manifest():
def pwa_manifest():
"""Serve the static web manifest from disk (same URL as before v6.0.0)."""
from fastapi.responses import FileResponse
return FileResponse("static/manifest.json", media_type="application/manifest+json")
@app.get("/sw.js")
async def service_worker():
def service_worker():
"""Serve the PWA service worker at top-level scope (/)."""
from fastapi.responses import FileResponse
return FileResponse("static/sw.js", media_type="application/javascript")
@@ -282,7 +282,7 @@ async def service_worker():
@app.get("/api/csrf-token")
async def csrf_token_endpoint(request: Request):
def csrf_token_endpoint(request: Request):
"""Return a fresh CSRF token. Used by the frontend to auto-recover from 403."""
import secrets
@@ -297,7 +297,7 @@ async def csrf_token_endpoint(request: Request):
@app.get("/api/pages")
async def api_pages_alias(request: Request):
def api_pages_alias(request: Request):
"""Alias /api/pages → /board/api/pages for API path consistency."""
from fastapi.responses import RedirectResponse
qs = str(request.url.query)
@@ -306,7 +306,7 @@ async def api_pages_alias(request: Request):
@app.post("/api/pages")
async def api_pages_post_alias(request: Request):
def api_pages_post_alias(request: Request):
"""Alias POST /api/pages → /board/api/pages for API path consistency."""
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/board/api/pages", status_code=307)
@@ -341,7 +341,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
@app.exception_handler(_StarHTTPException)
async def http_exception_handler(request: Request, exc: _StarHTTPException):
def http_exception_handler(request: Request, exc: _StarHTTPException):
"""Unified handler: RFC7807 for /api/v2, JSON for other /api, redirect for HTML.
Registered on Starlette's HTTPException (the base class) so it catches both
+22 -3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import ipaddress
import secrets
import time
from collections import defaultdict
@@ -9,6 +10,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
from app.templating import CSP_NONCE
# ── Constants ────────────────────────────────────────────────
# Allowed extensions for file uploads
@@ -63,10 +66,21 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
"""
CSP_HEADER = "Content-Security-Policy"
# A20 : `unsafe-inline` sort de script-src (remplacé par un nonce par
# requête — un XSS injecté dans une page ne tourne plus). Les 74 handlers
# inline `onclick=` restent couverts par `script-src-attr`, explicitement
# détaché de script-src (sinon le nonce les désactiverait aussi).
# `unsafe-eval` reste : Alpine (x-data en string) et htmx (hx-vars) en ont
# besoin → à retirer avec le build CSP d'Alpine (voir reste d'A20).
CSP_VALUE = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
# ponytail: chart.js et leaflet sont chargés depuis CDN par les vues
# chart/map de collections — l'upgrade est de les vendoriser dans
# /static/js puis de retirer ces deux hôtes.
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}' "
"https://cdn.jsdelivr.net https://unpkg.com; "
"script-src-attr 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://unpkg.com; "
"img-src 'self' data: blob: https:; "
"font-src 'self' data: https://fonts.gstatic.com; "
"connect-src 'self' https: wss: ws:; "
@@ -78,11 +92,16 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
)
async def dispatch(self, request: Request, call_next):
nonce = secrets.token_urlsafe(16)
# Posé AVANT call_next : BaseHTTPMiddleware exécute le aval dans une
# tâche créée maintenant → le contexte (donc le nonce) y est copié,
# exactement ce que les templates liront via `csp_nonce()`.
CSP_NONCE.set(nonce)
response = await call_next(request)
# Only set CSP on HTML responses
content_type = response.headers.get("content-type", "")
if "text/html" in content_type:
response.headers[self.CSP_HEADER] = self.CSP_VALUE
response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(nonce=nonce)
return response
+61 -28
View File
@@ -50,6 +50,19 @@ def _ensure_table(conn: sqlite3.Connection) -> None:
)
def columns(conn: sqlite3.Connection, table: str) -> set[str]:
"""Colonnes d'une table — A31 : l'unique helper qui remplace les 24 copies
de `{r[1] for r in conn.execute("PRAGMA table_info(...)")}`.
``table_exists``/``column_exists`` (préconisés par l'audit) ne sont pas
livrés : aucune migration n'interroge ``sqlite_master``, et un contrôle
unitaire se lit déjà dans le set.
"""
if not table.replace("_", "").isalnum():
raise ValueError(f"nom de table invalide: {table!r}")
return {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
def current_version(conn: sqlite3.Connection) -> int:
_ensure_table(conn)
row = conn.execute(
@@ -90,16 +103,36 @@ def apply_migrations(conn: sqlite3.Connection) -> int:
for version, name, fn in MIGRATIONS:
if version <= applied:
continue
_apply_one(conn, version, name, fn)
applied = version
logger.info("Applied migration %d: %s", version, name)
return applied
def _apply_one(conn: sqlite3.Connection, version: int, name: str, fn: Callable) -> None:
"""A31 : une migration = une transaction (DDL tout-ou-rien).
Avant : le DDL sortait en autocommit (isolation_level legacy) — un échec au
milieu laissait un schéma partiel commité ET pas de ligne schema_version :
la reprise rejouait un DDL déjà appliqué. Maintenant : BEGIN explicite,
rollback complet à l'échec, donc la prochaine exécution retente proprement.
"""
if conn.in_transaction:
# transaction résiduelle du caller (init_db commit juste avant) — on
# part d'un état propre plutôt que d'englober son travail.
conn.commit()
conn.execute("BEGIN")
try:
fn(conn)
conn.execute(
"INSERT INTO schema_version (version, name) VALUES (?, ?)",
(version, name),
)
conn.commit()
applied = version
logger.info("Applied migration %d: %s", version, name)
return applied
except BaseException:
conn.rollback()
raise
# ═══════════════════════════════════════════════════════════════════════════
@@ -236,7 +269,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
``projects`` — normalized project list across forges (builtin/gitea/
github) + last sync timestamp for the periodic cron.
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
_pcols = columns(conn, "api_tokens")
if "id" not in _pcols:
conn.execute(
"""
@@ -256,7 +289,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id, revoked)"
)
_scols = {r[1] for r in conn.execute("PRAGMA table_info(user_sessions)").fetchall()}
_scols = columns(conn, "user_sessions")
if "id" not in _scols:
conn.execute(
"""
@@ -275,7 +308,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id, revoked)"
)
_projcols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()}
_projcols = columns(conn, "projects")
if "id" not in _projcols:
conn.execute(
"""
@@ -328,7 +361,7 @@ def _migration_v54_page_versions_cover(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_page_versions_page ON page_versions(page_id, created_at)"
)
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
_pcols = columns(conn, "pages")
if "cover_url" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN cover_url TEXT DEFAULT ''")
if "page_icon" not in _pcols:
@@ -358,11 +391,11 @@ def _migration_custom_emojis(conn: sqlite3.Connection) -> None:
def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
"""v5.3.0: database templates get an icon, properties a validation config,
and the built-in database templates are seeded (idempotently)."""
_cols = {r[1] for r in conn.execute("PRAGMA table_info(database_templates)").fetchall()}
_cols = columns(conn, "database_templates")
if "icon" not in _cols:
conn.execute("ALTER TABLE database_templates ADD COLUMN icon TEXT NOT NULL DEFAULT '📋'")
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
_pcols = columns(conn, "collection_properties")
if "validation_json" not in _pcols:
conn.execute("ALTER TABLE collection_properties ADD COLUMN validation_json TEXT NOT NULL DEFAULT '{}'")
@@ -432,19 +465,19 @@ def _migration_v57_db_advanced(conn: sqlite3.Connection) -> None:
``collection_pages.cover_url`` — per-row cover image (gallery/board
cards), independent from the block-page ``pages.cover_url``.
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
_pcols = columns(conn, "collection_properties")
if "group_name" not in _pcols:
conn.execute(
"ALTER TABLE collection_properties ADD COLUMN group_name TEXT NOT NULL DEFAULT ''"
)
_vcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_views)").fetchall()}
_vcols = columns(conn, "collection_views")
if "created_by" not in _vcols:
conn.execute("ALTER TABLE collection_views ADD COLUMN created_by INTEGER")
if "updated_at" not in _vcols:
conn.execute("ALTER TABLE collection_views ADD COLUMN updated_at TIMESTAMP")
_cpcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
_cpcols = columns(conn, "collection_pages")
if "cover_url" not in _cpcols:
conn.execute("ALTER TABLE collection_pages ADD COLUMN cover_url TEXT DEFAULT ''")
@@ -471,7 +504,7 @@ def _migration_v58_calendar_reminders(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_remlog_page ON reminder_log(page_id)"
)
_ucols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
_ucols = columns(conn, "users")
if "timezone" not in _ucols:
conn.execute("ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT ''")
@@ -522,7 +555,7 @@ def _migration_v511_wiki_v512_templates(conn: sqlite3.Connection) -> None:
``page_global_templates`` — user-created global page templates
(blocks_json = same format as the block editor saves).
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
_pcols = columns(conn, "pages")
if "is_locked" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0")
if "locked_by" not in _pcols:
@@ -777,12 +810,12 @@ def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
)
for table in ("pages", "collection_pages"):
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
cols = columns(conn, table)
if "permission_type" not in cols:
conn.execute(
f"ALTER TABLE {table} ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
)
_ccols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
_ccols = columns(conn, "collections")
if "permission_type" not in _ccols:
conn.execute(
"ALTER TABLE collections ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
@@ -791,7 +824,7 @@ def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
"""Add ``sync_version`` to ``table`` if it is not already present."""
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
cols = columns(conn, table)
if "sync_version" not in cols:
conn.execute(f"ALTER TABLE {table} ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1")
@@ -853,7 +886,7 @@ def _migration_v630_api_v2(conn: sqlite3.Connection) -> None:
``idempotency_keys`` — Idempotency-Key support for POST creations.
"""
# api_tokens extra columns
_cols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
_cols = columns(conn, "api_tokens")
if "scopes" not in _cols:
conn.execute("ALTER TABLE api_tokens ADD COLUMN scopes TEXT NOT NULL DEFAULT 'read,write'")
if "expires_at" not in _cols:
@@ -913,7 +946,7 @@ def _migration_v640_webhooks_prod(conn: sqlite3.Connection) -> None:
New statuses: ``retrying`` (a later attempt is scheduled) and
``superseded`` (a retry row replaced this attempt).
"""
_cols = {r[1] for r in conn.execute("PRAGMA table_info(webhook_deliveries)").fetchall()}
_cols = columns(conn, "webhook_deliveries")
if "event" not in _cols:
conn.execute("ALTER TABLE webhook_deliveries ADD COLUMN event TEXT NOT NULL DEFAULT ''")
if "next_retry_at" not in _cols:
@@ -973,7 +1006,7 @@ def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
``ON DELETE CASCADE``: deleting a database row deletes its content
page (and ``page_synced_blocks`` cascades from ``pages``).
"""
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
cols = columns(conn, "pages")
if "collection_row_id" not in cols:
conn.execute(
"ALTER TABLE pages ADD COLUMN collection_row_id INTEGER "
@@ -1053,7 +1086,7 @@ def _migration_sites_forms(conn: sqlite3.Connection) -> None:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_form_responses_col ON form_responses(collection_id, created_at)"
)
cols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
cols = columns(conn, "collections")
if "form_config_json" not in cols:
conn.execute(
"ALTER TABLE collections ADD COLUMN form_config_json TEXT NOT NULL DEFAULT '{}'"
@@ -1100,7 +1133,7 @@ def _migration_semantic_search(conn: sqlite3.Connection) -> None:
)
"""
)
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
cols = columns(conn, "pages")
if "search_excluded" not in cols:
conn.execute(
"ALTER TABLE pages ADD COLUMN search_excluded INTEGER NOT NULL DEFAULT 0"
@@ -1169,12 +1202,12 @@ def _migration_automations_v2_workers(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_worker_runs_worker "
"ON worker_runs(worker_id, created_at)"
)
auto_cols = {r[1] for r in conn.execute("PRAGMA table_info(automations)").fetchall()}
auto_cols = columns(conn, "automations")
if "trigger_mode" not in auto_cols:
conn.execute(
"ALTER TABLE automations ADD COLUMN trigger_mode TEXT NOT NULL DEFAULT 'any'"
)
prop_cols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
prop_cols = columns(conn, "collection_properties")
if "button_automation_id" not in prop_cols:
conn.execute(
"ALTER TABLE collection_properties ADD COLUMN button_automation_id "
@@ -1226,7 +1259,7 @@ def _migration_calendar_meetings(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_meeting_transcripts_page "
"ON meeting_transcripts(page_id)"
)
cols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
cols = columns(conn, "collection_pages")
if "external_event_id" not in cols:
conn.execute(
"ALTER TABLE collection_pages ADD COLUMN external_event_id TEXT DEFAULT ''"
@@ -1322,7 +1355,7 @@ def _migration_enterprise_admin(conn: sqlite3.Connection) -> None:
"CREATE INDEX IF NOT EXISTS idx_agent_approvals_status "
"ON agent_approvals(status, created_at)"
)
user_cols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
user_cols = columns(conn, "users")
if "totp_secret_enc" not in user_cols:
conn.execute("ALTER TABLE users ADD COLUMN totp_secret_enc TEXT DEFAULT ''")
if "totp_backup_hashes" not in user_cols:
@@ -1433,7 +1466,7 @@ def _migration_wiki_teamspaces(conn: sqlite3.Connection) -> None:
"""
)
for table in ("pages", "collections"):
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
cols = columns(conn, table)
if "teamspace_id" not in cols:
conn.execute(f"ALTER TABLE {table} ADD COLUMN teamspace_id INTEGER")
+4 -4
View File
@@ -23,7 +23,7 @@ async def admin_required(request: Request):
# ── Users ──
@router.get("/users")
async def list_users(_admin=Depends(admin_required)):
def list_users(_admin=Depends(admin_required)):
"""List all users with workspace/file/folder counts and storage usage."""
from app.db import get_conn
with get_conn() as conn:
@@ -109,7 +109,7 @@ async def update_user(user_id: int, request: Request, _admin=Depends(admin_requi
@router.delete("/users/{user_id:int}")
async def delete_user(user_id: int, _admin=Depends(admin_required)):
def delete_user(user_id: int, _admin=Depends(admin_required)):
"""Delete a user and cascade their data."""
from app.db import get_conn
with get_conn() as conn:
@@ -139,7 +139,7 @@ async def delete_user(user_id: int, _admin=Depends(admin_required)):
# ── Stats ──
@router.get("/stats")
async def user_stats(_admin=Depends(admin_required)):
def user_stats(_admin=Depends(admin_required)):
"""Aggregate stats: total users, workspaces, files, storage."""
from app.db import get_conn
with get_conn() as conn:
@@ -159,7 +159,7 @@ async def user_stats(_admin=Depends(admin_required)):
# ── Audit ──
@router.get("/audit")
async def audit_log(limit: int = 100, _admin=Depends(admin_required)):
def audit_log(limit: int = 100, _admin=Depends(admin_required)):
"""Recent login history."""
from app.db import get_conn
with get_conn() as conn:
+10 -10
View File
@@ -213,7 +213,7 @@ async def create_conversation(request: Request):
@router.get("/conversations/{conversation_id}")
async def get_conversation(request: Request, conversation_id: int):
def get_conversation(request: Request, conversation_id: int):
with get_conn() as conn:
conv = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone()
if not conv:
@@ -226,7 +226,7 @@ async def get_conversation(request: Request, conversation_id: int):
@router.delete("/conversations/{conversation_id}")
async def delete_conversation(request: Request, conversation_id: int):
def delete_conversation(request: Request, conversation_id: int):
with get_conn() as conn:
if not conn.execute("SELECT id FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone():
raise HTTPException(status_code=404, detail="Conversation introuvable")
@@ -450,7 +450,7 @@ async def agent_writing_properties(request: Request):
@router.get("/conversations/{conversation_id}/actions")
async def list_actions(request: Request, conversation_id: int):
def list_actions(request: Request, conversation_id: int):
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM agent_actions WHERE conversation_id=? ORDER BY created_at, id",
@@ -460,7 +460,7 @@ async def list_actions(request: Request, conversation_id: int):
@router.post("/actions/{action_id}/undo")
async def undo(request: Request, action_id: int):
def undo(request: Request, action_id: int):
try:
undo_action(action_id)
except ValueError as exc:
@@ -528,7 +528,7 @@ async def apply_skill(request: Request, skill_id: int):
@router.get("/skills/gallery")
async def skills_gallery(request: Request):
def skills_gallery(request: Request):
presets = skill_gallery.list_gallery()
return {"gallery": presets, "total": len(presets),
"install": "POST /api/agent/skills/gallery/{slug}/install"}
@@ -577,7 +577,7 @@ async def import_skill(request: Request):
@router.get("/skills/{skill_id}/export")
async def export_skill(request: Request, skill_id: int):
def export_skill(request: Request, skill_id: int):
"""Document JSON portable — à rejouer sur /api/agent/skills/import."""
with get_conn() as conn:
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
@@ -587,7 +587,7 @@ async def export_skill(request: Request, skill_id: int):
@router.delete("/skills/{skill_id}")
async def delete_skill(request: Request, skill_id: int):
def delete_skill(request: Request, skill_id: int):
with get_conn() as conn:
row = conn.execute("SELECT name FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
if not row:
@@ -794,7 +794,7 @@ async def trigger_agent(request: Request, agent_id: int):
@router.get("/tools")
async def list_tools(request: Request):
def list_tools(request: Request):
registry = ToolRegistry()
tools = registry.schema()
return {"tools": tools}
@@ -1081,7 +1081,7 @@ async def test_provider_config(request: Request):
@router.get("/{agent_id}")
async def get_agent(request: Request, agent_id: int):
def get_agent(request: Request, agent_id: int):
with get_conn() as conn:
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
if not row:
@@ -1116,7 +1116,7 @@ async def update_agent(request: Request, agent_id: int):
@router.delete("/{agent_id}")
async def delete_agent(request: Request, agent_id: int):
def delete_agent(request: Request, agent_id: int):
with get_conn() as conn:
existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
if not existing:
+14 -14
View File
@@ -83,7 +83,7 @@ async def health(request: Request):
@router.get("/stats")
async def stats():
def stats():
"""Global stats for dashboard."""
with get_conn() as conn:
board_count = conn.execute("SELECT COUNT(*) as c FROM boards").fetchone()["c"]
@@ -179,7 +179,7 @@ async def _get_status_labels(owner: str, repo: str, board_id: int) -> list[str]:
@router.post("/col-mapping")
async def set_col_mapping(
def set_col_mapping(
owner: str = Query(...),
repo: str = Query(...),
column: str = Query(...),
@@ -209,7 +209,7 @@ async def set_col_mapping(
@router.delete("/col-mapping")
async def delete_col_mapping(
def delete_col_mapping(
owner: str = Query(...),
repo: str = Query(...),
column: str = Query(...),
@@ -234,7 +234,7 @@ async def delete_col_mapping(
@router.get("/board-config/{owner}/{repo}")
async def get_board_config(owner: str, repo: str):
def get_board_config(owner: str, repo: str):
with get_conn() as conn:
board = conn.execute(
"SELECT * FROM boards WHERE project_owner=? AND project_name=?",
@@ -255,7 +255,7 @@ async def get_board_config(owner: str, repo: str):
@router.post("/board-config/{owner}/{repo}")
async def update_board_config(
def update_board_config(
owner: str,
repo: str,
columns: str = Query(default=""),
@@ -460,7 +460,7 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Q
# ── v0.5.0: Checklists ──
@router.post("/checklists/{owner}/{repo}/{issue_id}")
async def create_checklist(
def create_checklist(
owner: str,
repo: str,
issue_id: int,
@@ -484,7 +484,7 @@ async def create_checklist(
@router.post("/checklist-items/{owner}/{repo}/{issue_id}/{checklist_id}")
async def add_checklist_item(
def add_checklist_item(
owner: str,
repo: str,
issue_id: int,
@@ -502,7 +502,7 @@ async def add_checklist_item(
@router.patch("/checklist-items/{item_id}")
async def toggle_checklist_item(
def toggle_checklist_item(
item_id: int,
checked: bool = Query(default=False),
content: str = Query(default=""),
@@ -524,7 +524,7 @@ async def toggle_checklist_item(
@router.delete("/checklist-items/{item_id}")
async def delete_checklist_item(item_id: int):
def delete_checklist_item(item_id: int):
"""Delete a checklist item."""
with get_conn() as conn:
conn.execute("DELETE FROM checklist_items WHERE id=?", (item_id,))
@@ -533,7 +533,7 @@ async def delete_checklist_item(item_id: int):
@router.delete("/checklists/{checklist_id}")
async def delete_checklist(checklist_id: int):
def delete_checklist(checklist_id: int):
"""Delete a checklist and all its items."""
with get_conn() as conn:
conn.execute("DELETE FROM checklist_items WHERE checklist_id=?", (checklist_id,))
@@ -545,7 +545,7 @@ async def delete_checklist(checklist_id: int):
# ── v1.0.0: User management ──
@router.get("/users/me")
async def get_my_profile(request: Request):
def get_my_profile(request: Request):
"""Get current user profile."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
@@ -565,7 +565,7 @@ async def get_my_profile(request: Request):
@router.put("/users/me")
async def update_my_profile(request: Request, full_name: str = Query(default=""),
def update_my_profile(request: Request, full_name: str = Query(default=""),
email: str = Query(default="")):
"""Update current user's local profile."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
@@ -584,7 +584,7 @@ async def update_my_profile(request: Request, full_name: str = Query(default="")
# ── v0.5.0: Card priority & due date ──
@router.post("/card/{owner}/{repo}/{issue_id}")
async def update_card(
def update_card(
owner: str,
repo: str,
issue_id: int,
@@ -673,7 +673,7 @@ async def capture_frontend_error(request: Request):
@router.get("/frontend-errors")
async def get_frontend_errors(request: Request, clear: bool = True):
def get_frontend_errors(request: Request, clear: bool = True):
"""Retourne les erreurs frontend récentes. Hermes appelle cette route."""
errors = list(_frontend_errors)
if clear:
+130 -317
View File
File diff suppressed because it is too large Load Diff
+13 -13
View File
@@ -103,7 +103,7 @@ def _engine_for(user_id: int, workspace_id: int | None, provider: str | None) ->
# ── Agents ─────────────────────────────────────────────────────────────────
@router.get("/agents")
async def list_agents_v2(request: Request, authorization: str | None = Header(default=None)):
def list_agents_v2(request: Request, authorization: str | None = Header(default=None)):
user = _guard(request, authorization)
limit, offset = parse_pagination(request)
ws = _workspace_of(request)
@@ -157,7 +157,7 @@ async def create_agent_v2(request: Request, authorization: str | None = Header(d
@router.get("/agents/{agent_id}")
async def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
_guard(request, authorization)
with get_conn() as conn:
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
@@ -195,7 +195,7 @@ async def update_agent_v2(agent_id: int, request: Request, authorization: str |
@router.delete("/agents/{agent_id}")
async def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
user = _guard(request, authorization, write=True)
with get_conn() as conn:
if not conn.execute("SELECT id FROM agents WHERE id=?", (agent_id,)).fetchone():
@@ -209,7 +209,7 @@ async def delete_agent_v2(agent_id: int, request: Request, authorization: str |
# ── Conversations (static paths declared before /agents/{agent_id}) ────────
@router.get("/agents/conversations")
async def list_conversations_v2(request: Request, authorization: str | None = Header(default=None)):
def list_conversations_v2(request: Request, authorization: str | None = Header(default=None)):
user = _guard(request, authorization)
limit, offset = parse_pagination(request)
with get_conn() as conn:
@@ -264,7 +264,7 @@ async def create_conversation_v2(request: Request, authorization: str | None = H
@router.get("/agents/conversations/{conversation_id}")
async def get_conversation_v2(conversation_id: int, request: Request,
def get_conversation_v2(conversation_id: int, request: Request,
authorization: str | None = Header(default=None)):
user = _guard(request, authorization)
with get_conn() as conn:
@@ -279,7 +279,7 @@ async def get_conversation_v2(conversation_id: int, request: Request,
@router.delete("/agents/conversations/{conversation_id}")
async def delete_conversation_v2(conversation_id: int, request: Request,
def delete_conversation_v2(conversation_id: int, request: Request,
authorization: str | None = Header(default=None)):
user = _guard(request, authorization, write=True)
with get_conn() as conn:
@@ -292,7 +292,7 @@ async def delete_conversation_v2(conversation_id: int, request: Request,
@router.get("/agents/conversations/{conversation_id}/actions")
async def list_actions_v2(conversation_id: int, request: Request,
def list_actions_v2(conversation_id: int, request: Request,
authorization: str | None = Header(default=None)):
user = _guard(request, authorization)
with get_conn() as conn:
@@ -306,7 +306,7 @@ async def list_actions_v2(conversation_id: int, request: Request,
@router.post("/agents/actions/{action_id}/undo")
async def undo_action_v2(action_id: int, request: Request,
def undo_action_v2(action_id: int, request: Request,
authorization: str | None = Header(default=None)):
user = _guard(request, authorization, write=True)
with get_conn() as conn:
@@ -480,7 +480,7 @@ async def trigger_agent_v2(agent_id: int, request: Request,
# ── Skill marketplace ──────────────────────────────────────────────────────
@router.get("/skills")
async def list_skills_v2(request: Request, authorization: str | None = Header(default=None)):
def list_skills_v2(request: Request, authorization: str | None = Header(default=None)):
_guard(request, authorization)
limit, offset = parse_pagination(request)
ws = _workspace_of(request)
@@ -535,7 +535,7 @@ async def create_skill_v2(request: Request, authorization: str | None = Header(d
# Gallery & import are static segments: declared before /skills/{skill_id} so
# FastAPI never tries to coerce "gallery" into an int path parameter.
@router.get("/skills/gallery")
async def skills_gallery_v2(request: Request, authorization: str | None = Header(default=None)):
def skills_gallery_v2(request: Request, authorization: str | None = Header(default=None)):
_guard(request, authorization)
presets = skill_gallery.list_gallery()
return {"gallery": presets, "total": len(presets),
@@ -596,7 +596,7 @@ async def import_skill_v2(request: Request, authorization: str | None = Header(d
@router.get("/skills/{skill_id}")
async def get_skill_v2(skill_id: int, request: Request, authorization: str | None = Header(default=None)):
def get_skill_v2(skill_id: int, request: Request, authorization: str | None = Header(default=None)):
_guard(request, authorization)
with get_conn() as conn:
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
@@ -606,7 +606,7 @@ async def get_skill_v2(skill_id: int, request: Request, authorization: str | Non
@router.get("/skills/{skill_id}/export")
async def export_skill_v2(skill_id: int, request: Request,
def export_skill_v2(skill_id: int, request: Request,
authorization: str | None = Header(default=None)):
"""Portable JSON document — POST it to /api/v2/skills/import elsewhere."""
_guard(request, authorization)
@@ -618,7 +618,7 @@ async def export_skill_v2(skill_id: int, request: Request,
@router.delete("/skills/{skill_id}")
async def delete_skill_v2(skill_id: int, request: Request,
def delete_skill_v2(skill_id: int, request: Request,
authorization: str | None = Header(default=None)):
user = _guard(request, authorization, write=True)
with get_conn() as conn:
+1 -1
View File
@@ -99,7 +99,7 @@ def _query(source: str, actor: str, action: str, limit: int, offset: int):
@router.get("/api/v2/audit/logs")
async def audit_logs(request: Request):
def audit_logs(request: Request):
_admin_user(request)
qp = request.query_params
source = (qp.get("source") or "all").lower()
+19 -9
View File
@@ -9,6 +9,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth.session import SessionManager
from app.config import settings
from app.templating import CSP_NONCE
logger = logging.getLogger(__name__)
router = APIRouter(tags=["auth"], prefix="/auth")
@@ -31,6 +32,15 @@ def get_redirect_uri(request: Request) -> str:
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080")
return f"{scheme}://{host}/auth/callback"
def _with_nonce(html: str) -> str:
"""A20 : injecte le nonce CSP au moment du rendu.
`LOCAL_LOGIN_HTML` est une constante de module — le nonce, lui, est par
requête, donc il ne peut être figé qu'ici.
"""
return html.replace("<script>", f'<script nonce="{CSP_NONCE.get()}">', 1)
LOCAL_LOGIN_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
@@ -146,9 +156,9 @@ async function handleLogin(e){e.preventDefault();const email=document.getElement
@router.get("/register")
async def register_page(request: Request):
def register_page(request: Request):
"""Show the registration page (local login page with register tab active)."""
return HTMLResponse(LOCAL_LOGIN_HTML.replace(
return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML.replace(
'class="tab active" onclick="switchTab(\'login\')"',
'class="tab" onclick="switchTab(\'login\')"'
).replace(
@@ -163,16 +173,16 @@ async def register_page(request: Request):
).replace(
'id="submit-btn">Login<',
'id="submit-btn">Register<'
), status_code=200)
)), status_code=200)
@router.get("/login")
async def login(request: Request, provider: str = Query("gitea")):
def login(request: Request, provider: str = Query("gitea")):
"""Redirect to OAuth2 authorize page or show local login page."""
# Local login page (POST handled by /auth/local-login)
from fastapi.responses import HTMLResponse
if provider == "local":
return HTMLResponse(LOCAL_LOGIN_HTML, status_code=200)
return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML), status_code=200)
# OAuth flow — check if provider is configured
from app.auth.providers import get_provider
@@ -450,7 +460,7 @@ async def callback(
@router.get("/logout")
async def logout(request: Request):
def logout(request: Request):
"""Clear session and redirect to login page.
SAML sessions additionally hand over to the IdP's Single Logout when one
@@ -519,7 +529,7 @@ def _session_user_or_401(request: Request) -> dict:
@router.get("/2fa/status")
async def twofa_status(request: Request):
def twofa_status(request: Request):
from app.services import two_factor as _2fa
user = _session_user_or_401(request)
return {"enabled": _2fa.is_enabled(user["id"]),
@@ -527,7 +537,7 @@ async def twofa_status(request: Request):
@router.post("/2fa/setup")
async def twofa_setup(request: Request):
def twofa_setup(request: Request):
from app.services import two_factor as _2fa
user = _session_user_or_401(request)
return _2fa.setup_secret(user["id"])
@@ -553,7 +563,7 @@ async def twofa_activate(request: Request):
@router.post("/2fa/disable")
async def twofa_disable(request: Request):
def twofa_disable(request: Request):
from app.services import two_factor as _2fa
user = _session_user_or_401(request)
_2fa.disable(user["id"])
+6 -6
View File
@@ -71,7 +71,7 @@ def _validate_payload(body: dict) -> None:
@router.get("/workspace/automations")
async def list_automations(request: Request):
def list_automations(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT * FROM automations ORDER BY created_at DESC").fetchall()
items = [dict(r) for r in rows]
@@ -109,7 +109,7 @@ async def create_automation(request: Request):
@router.get("/workspace/automations/{auto_id}")
async def get_automation(request: Request, auto_id: int):
def get_automation(request: Request, auto_id: int):
with get_conn() as conn:
row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone()
if not row:
@@ -147,7 +147,7 @@ async def update_automation(request: Request, auto_id: int):
@router.delete("/workspace/automations/{auto_id}")
async def delete_automation(request: Request, auto_id: int):
def delete_automation(request: Request, auto_id: int):
with get_conn() as conn:
conn.execute("DELETE FROM automations WHERE id=?", (auto_id,))
conn.commit()
@@ -178,7 +178,7 @@ async def run_automation_button(request: Request, auto_id: int):
@router.get("/workspace/automations/{auto_id}/runs")
async def automation_runs_history(request: Request, auto_id: int, limit: int = 50):
def automation_runs_history(request: Request, auto_id: int, limit: int = 50):
with get_conn() as conn:
rows = conn.execute(
"""SELECT * FROM automation_runs WHERE automation_id=?
@@ -226,7 +226,7 @@ def _encrypt_step_config(config: dict) -> dict:
@router.get("/workspace/automations/{auto_id}/steps")
async def list_steps(request: Request, auto_id: int):
def list_steps(request: Request, auto_id: int):
if _get_auto(auto_id) is None:
return _auto_404()
return {"automation_id": auto_id, "steps": get_steps(auto_id)}
@@ -279,7 +279,7 @@ async def update_step(request: Request, step_id: int):
@router.delete("/workspace/automations/steps/{step_id}")
async def delete_step(request: Request, step_id: int):
def delete_step(request: Request, step_id: int):
_require_session(request)
with get_conn() as conn:
conn.execute("DELETE FROM automation_steps WHERE id=?", (step_id,))
+23 -23
View File
@@ -66,7 +66,7 @@ def _ensure_block_ids(blocks) -> None:
@router.get("/api/wiki/pages")
async def wiki_page_search(request: Request, q: str = Query(default="")):
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()
@@ -98,7 +98,7 @@ async def wiki_page_search(request: Request, q: str = Query(default="")):
@router.get("/api/wiki/titles")
async def wiki_titles(request: Request, ids: str = Query(default="")):
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(","):
@@ -179,7 +179,7 @@ async def set_page_options(request: Request, page_id: int):
@router.get("/api/page-templates")
async def list_page_templates_api(request: Request):
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")
@@ -899,7 +899,7 @@ def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
# ═══════════ Library page ═══════════
@router.get("/library", response_class=HTMLResponse)
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
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
@@ -943,7 +943,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
# ═══════════ Favorites API ═══════════
@router.get("/api/favorites")
async def list_favorites(request: Request):
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
@@ -1033,7 +1033,7 @@ async def unpublish_page(request: Request, page_id: int):
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
@router.get("/api/trash")
async def list_trash(request: Request):
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]
@@ -1052,7 +1052,7 @@ async def restore_page(request: Request, page_id: int):
@router.delete("/api/trash/{page_id}")
async def permanent_delete(request: Request, page_id: int):
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,))
@@ -1061,7 +1061,7 @@ async def permanent_delete(request: Request, page_id: int):
@router.get("/trash", response_class=HTMLResponse)
async def trash_page(request: Request):
def trash_page(request: Request):
from app.templating import ENV
env = ENV
template = env.get_template("trash.html")
@@ -1072,7 +1072,7 @@ async def trash_page(request: Request):
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
@router.get("/api/synced-blocks")
async def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
"""List synced blocks for a workspace."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
from app.services.synced_blocks import list_synced_blocks
@@ -1147,7 +1147,7 @@ async def delete_synced_block_api(request: Request, sid: int):
@router.get("/api/synced-blocks/{sid}")
async def get_synced_block_api(sid: int):
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)
@@ -1173,7 +1173,7 @@ async def add_synced_to_page(request: Request, page_id: int):
@router.delete("/api/pages/{page_id}/synced/{sid}")
async def remove_synced_from_page(request: Request, page_id: int, sid: int):
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)
@@ -1181,7 +1181,7 @@ async def remove_synced_from_page(request: Request, page_id: int, sid: int):
@router.get("/api/pages/{page_id}/synced")
async def get_page_synced_refs(request: Request, page_id: int):
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)}
@@ -1190,7 +1190,7 @@ async def get_page_synced_refs(request: Request, page_id: int):
# ═══════════ Board page ═══════════
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
async def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
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)
@@ -1279,12 +1279,12 @@ async def board_view(
# ═══════════ v0.9.0: Custom Properties API ═══════════
@router.get("/api/properties/{owner}/{repo}")
async def get_properties(owner: str, repo: str):
def get_properties(owner: str, repo: str):
return {"properties": _get_project_properties(owner, repo)}
@router.post("/api/properties/{owner}/{repo}")
async def create_property(owner: str, repo: str, name: str = Query(...),
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()])
@@ -1301,7 +1301,7 @@ async def create_property(owner: str, repo: str, name: str = Query(...),
@router.delete("/api/properties/{owner}/{repo}")
async def delete_property(owner: str, repo: str, name: str = Query(...)):
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=?",
@@ -1312,7 +1312,7 @@ async def delete_property(owner: str, repo: str, name: str = Query(...)):
@router.post("/api/properties/{owner}/{repo}/values")
async def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
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(
@@ -1332,7 +1332,7 @@ async def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
# ═══════════ v0.9.0: AI Keywords API ═══════════
@router.get("/api/ai-keywords/{owner}/{repo}")
async def get_ai_keywords(owner: str, repo: str):
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",
@@ -1395,7 +1395,7 @@ async def create_page(request: Request, title: str = Query(default=""),
@router.get("/api/pages/{page_id}")
async def get_page(request: Request, page_id: int):
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"):
@@ -1543,7 +1543,7 @@ def _block_texts(b: dict) -> list[str]:
@router.get("/api/pages/{page_id}/backlinks")
async def page_backlinks(request: Request, page_id: int):
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
@@ -1591,7 +1591,7 @@ async def page_backlinks(request: Request, page_id: int):
@router.get("/api/pages/{page_id}/versions")
async def page_versions(request: Request, page_id: int):
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(
@@ -1750,7 +1750,7 @@ async def set_page_cover(request: Request, page_id: int):
@router.delete("/api/pages/{page_id}/cover")
async def remove_page_cover(request: Request, page_id: int):
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()
@@ -2024,7 +2024,7 @@ async def delete_page(request: Request, page_id: int):
@router.get("/pages/{page_id}", response_class=HTMLResponse)
async def view_page(request: Request, page_id: int):
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"
+2 -2
View File
@@ -49,7 +49,7 @@ def _serialize(rows):
@router.get("/pages/{page_id}/comments")
async def list_comments(request: Request, page_id: int):
def list_comments(request: Request, page_id: int):
"""List page-level and inline comments for a FlowDeck page."""
_current_user(request)
with get_conn() as conn:
@@ -195,7 +195,7 @@ async def update_comment(request: Request, comment_id: int):
@router.delete("/comments/{comment_id}")
async def delete_comment(request: Request, comment_id: int):
def delete_comment(request: Request, comment_id: int):
"""Delete a comment and its replies."""
user = _current_user(request)
with get_conn() as conn:
+29 -28
View File
@@ -27,6 +27,7 @@ from app.services.recurrence import (
validate_rule,
)
from app.services.reminders import REMINDER_KEY, parse_lead
from app.templating import CSP_NONCE
def _current_user(request: Request) -> dict:
@@ -195,7 +196,7 @@ def _validate_meta_keys(conn, collection_id: int, properties: dict) -> None:
@router.get("", response_class=HTMLResponse)
async def list_collections(request: Request):
def list_collections(request: Request):
"""Page listing all collections in the workspace."""
with get_conn() as conn:
rows = conn.execute(
@@ -211,7 +212,7 @@ async def list_collections(request: Request):
@router.get("/api")
async def list_collections_api(request: Request):
def list_collections_api(request: Request):
"""API: list all collections."""
with get_conn() as conn:
rows = conn.execute(
@@ -501,7 +502,7 @@ async def duplicate_collection_api(request: Request, collection_id: int):
@router.get("/pages/{page_id}/api")
async def get_page_api(request: Request, page_id: int):
def get_page_api(request: Request, page_id: int):
"""API: get a single page."""
with get_conn() as conn:
page = conn.execute(
@@ -515,7 +516,7 @@ async def get_page_api(request: Request, page_id: int):
@router.get("/pages/{page_id}/open/api")
async def open_row_page_api(request: Request, page_id: int):
def open_row_page_api(request: Request, page_id: int):
"""v6.5.0 — content page of a database row (lazy-created).
Any DB view (table/board/gallery/list/calendar) opens a row through
@@ -649,7 +650,7 @@ async def delete_page_api(request: Request, page_id: int):
@router.get("/boards/api")
async def list_boards_as_collections(request: Request):
def list_boards_as_collections(request: Request):
"""API: list all Gitea boards as pseudo-collections."""
from app.services.collection_adapter import GiteaBoardCompat
boards = GiteaBoardCompat.list_boards_as_collections()
@@ -689,14 +690,14 @@ async def sync_board_to_collection(request: Request, owner: str, repo: str):
@router.get("/property-types/api")
async def list_property_types_api(request: Request):
def list_property_types_api(request: Request):
"""API: list all available property types."""
from app.services.property_types import PROPERTY_TYPES
return {"types": PROPERTY_TYPES}
@router.get("/{collection_id}/properties/api")
async def list_properties_api(request: Request, collection_id: int):
def list_properties_api(request: Request, collection_id: int):
"""API: list all properties visible to the current user."""
user = _session_user(request)
_require_view(collection_id, user)
@@ -719,7 +720,7 @@ async def list_properties_api(request: Request, collection_id: int):
@router.get("/{collection_id}/members/api")
async def list_collection_members_api(request: Request, collection_id: int):
def list_collection_members_api(request: Request, collection_id: int):
"""API: list workspace members available for a ``person`` property.
Resolves the collection's workspace and returns its members (falling back to
@@ -752,7 +753,7 @@ async def list_collection_members_api(request: Request, collection_id: int):
@router.get("/{collection_id}/calendar/api")
async def collection_calendar_api(request: Request, collection_id: int,
def collection_calendar_api(request: Request, collection_id: int,
start: str = "", end: str = "",
date_property: str = ""):
"""API (v5.8.0): expanded calendar events for a window [start, end].
@@ -837,7 +838,7 @@ async def collection_calendar_api(request: Request, collection_id: int,
@router.get("/timezones/api")
async def timezones_api(request: Request):
def timezones_api(request: Request):
"""API (v5.8.0): the user's timezone plus a picker-friendly zone list."""
user = _current_user(request)
with get_conn() as conn:
@@ -972,7 +973,7 @@ async def update_property_api(request: Request, prop_id: int):
@router.delete("/properties/{prop_id}/api")
async def delete_property_api(request: Request, prop_id: int):
def delete_property_api(request: Request, prop_id: int):
"""API: delete a property."""
with get_conn() as conn:
existing = conn.execute(
@@ -1166,7 +1167,7 @@ async def evaluate_formula(request: Request):
@router.get("/views/{view_id}/api")
async def get_view_api(request: Request, view_id: int):
def get_view_api(request: Request, view_id: int):
"""API: get a single view config."""
with get_conn() as conn:
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
@@ -1251,7 +1252,7 @@ async def save_view_as(request: Request, collection_id: int):
@router.get("/{collection_id}/views/api")
async def list_views_api(request: Request, collection_id: int):
def list_views_api(request: Request, collection_id: int):
"""API: list views for a collection visible to the current user.
Shared/legacy views (``created_by IS NULL``) are visible to everyone;
@@ -1276,7 +1277,7 @@ async def list_views_api(request: Request, collection_id: int):
@router.delete("/views/{view_id}/api")
async def delete_view_api(request: Request, view_id: int):
def delete_view_api(request: Request, view_id: int):
"""API: delete a saved view."""
with get_conn() as conn:
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
@@ -1332,7 +1333,7 @@ async def duplicate_view_api(request: Request, view_id: int):
@router.get("/{collection_id}/pages/{page_id}/sub-items")
async def list_sub_items(request: Request, collection_id: int, page_id: int):
def list_sub_items(request: Request, collection_id: int, page_id: int):
"""API: list sub-items of a page."""
with get_conn() as conn:
rows = conn.execute(
@@ -1395,7 +1396,7 @@ async def create_sub_item(request: Request, collection_id: int, page_id: int):
@router.get("/{collection_id}/pages/{page_id}/status-aggregate")
async def aggregate_child_status(request: Request, collection_id: int, page_id: int):
def aggregate_child_status(request: Request, collection_id: int, page_id: int):
"""API: compute aggregate status from children."""
with get_conn() as conn:
children = conn.execute(
@@ -1494,7 +1495,7 @@ async def check_dependencies(request: Request, collection_id: int, page_id: int)
@router.get("/{collection_id}/sources/api")
async def list_data_sources(request: Request, collection_id: int):
def list_data_sources(request: Request, collection_id: int):
"""API: list all data sources for a collection."""
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
@@ -1554,7 +1555,7 @@ async def add_data_source(request: Request, collection_id: int):
@router.delete("/{collection_id}/sources/{source_id}/api")
async def remove_data_source(request: Request, collection_id: int, source_id: int):
def remove_data_source(request: Request, collection_id: int, source_id: int):
"""API: remove a data source from a collection."""
with get_conn() as conn:
existing = conn.execute(
@@ -1667,7 +1668,7 @@ async def create_linked_database(request: Request, collection_id: int):
@router.post("/{collection_id}/toggle-inline/api")
async def toggle_inline(request: Request, collection_id: int):
def toggle_inline(request: Request, collection_id: int):
"""API: toggle a collection between full-page and inline mode."""
with get_conn() as conn:
coll = conn.execute(
@@ -1758,7 +1759,7 @@ async def create_inline_database(request: Request):
@router.put("/{collection_id}/toggle-task/api")
async def toggle_task(request: Request, collection_id: int):
def toggle_task(request: Request, collection_id: int):
"""API: toggle is_task flag on a collection (Turn into Tasks)."""
with get_conn() as conn:
coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
@@ -1771,7 +1772,7 @@ async def toggle_task(request: Request, collection_id: int):
@router.get("/{collection_id}/pages/{page_id}/dependencies/api")
async def list_page_dependencies(request: Request, collection_id: int, page_id: int):
def list_page_dependencies(request: Request, collection_id: int, page_id: int):
"""API: list dependencies for a page (blocks, blocked_by, related)."""
with get_conn() as conn:
rows = conn.execute(
@@ -1819,7 +1820,7 @@ async def add_page_dependency(request: Request, collection_id: int, page_id: int
@router.delete("/{collection_id}/pages/{page_id}/dependencies/{dep_id}/api")
async def remove_page_dependency(request: Request, collection_id: int, page_id: int, dep_id: int):
def remove_page_dependency(request: Request, collection_id: int, page_id: int, dep_id: int):
"""API: remove a dependency."""
with get_conn() as conn:
existing = conn.execute(
@@ -1914,7 +1915,7 @@ async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
@router.get("/{collection_id}/dashboards/{dashboard_id}", response_class=HTMLResponse)
async def view_dashboard(request: Request, collection_id: int, dashboard_id: int):
def view_dashboard(request: Request, collection_id: int, dashboard_id: int):
"""v7.3.0: render a collection dashboard grid (multi-DB widgets).
Widgets live in ``collection_dashboards.layout_json`` as
@@ -1980,7 +1981,7 @@ async def view_dashboard(request: Request, collection_id: int, dashboard_id: int
@router.get("/{collection_id}", response_class=HTMLResponse)
@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
async def view_collection(request: Request, collection_id: int, view_type: str = "table"):
def view_collection(request: Request, collection_id: int, view_type: str = "table"):
"""Main view — renders collection in the requested view type."""
# v6.0.0: granular collection permissions — hide restricted collections.
_require_view(collection_id, _session_user(request))
@@ -2376,7 +2377,7 @@ canvas{{max-height:400px}}
</style>
<div class="chart-container"><canvas id="chartCanvas"></canvas></div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script>
<script nonce="{CSP_NONCE.get()}">
new Chart(document.getElementById('chartCanvas'), {{
type: '{chart_type}',
data: {{
@@ -2439,7 +2440,7 @@ def _render_form(view_type: str, collection: dict, pages: list[dict], config: di
</form>
<div id="formResult" style="margin-top:12px;color:#4CAF50;display:none">✓ Created successfully!</div>
</div>
<script>
<script nonce="{CSP_NONCE.get()}">
async function submitForm(e) {{
e.preventDefault();
const form = document.getElementById('collectionForm');
@@ -2490,7 +2491,7 @@ def _render_map(view_type: str, collection: dict, pages: list[dict], config: dic
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
<div id="map"></div>
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
<script>
<script nonce="{CSP_NONCE.get()}">
const map = L.map('map').setView([{center_lat}, {center_lng}], 6);
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map);
const markers = {markers_json};
@@ -2617,7 +2618,7 @@ tr:hover td{{background:#222}}
@router.get("/{collection_id}/api")
async def get_collection_api(request: Request, collection_id: int):
def get_collection_api(request: Request, collection_id: int):
"""API: get a single collection with its pages."""
# v6.0.0: granular collection permissions — hide restricted collections.
_require_view(collection_id, _session_user(request))
+60 -41
View File
@@ -252,7 +252,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
@router.get("/trash", response_class=HTMLResponse)
async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Trash page — scoped to workspace if owner/repo provided."""
from app.routers.board import _sidebar_data as board_sidebar
from app.templating import ENV
@@ -279,7 +279,7 @@ async def trash_page(request: Request, owner: str = Query(default=""), repo: str
@router.get("/library", response_class=HTMLResponse)
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Library page — tabbed view (recents, favorites, shared, published, private, workspace).
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
@@ -308,7 +308,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
@router.get("/pages/{page_id}", response_class=HTMLResponse)
async def view_page_root(request: Request, page_id: int):
def view_page_root(request: Request, page_id: int):
"""Render a Markdown page at root level with workspace context — or file viewer.
?embed=1 — minimal mode for side peek (editor only, no header)."""
embed = request.query_params.get("embed") == "1"
@@ -413,17 +413,36 @@ async def view_page_root(request: Request, page_id: int):
with get_conn() as conn:
nav_crumbs = _nav_breadcrumb(conn, page_id)
# dérivé calculé UNE fois : ctx ET page_data (JSON) l'utilisent (A27)
page_is_shared = (
bool(page.get("is_shared", 0))
or page.get("share_mode", "private") != "private"
or bool(page.get("published", 0))
)
ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs],
"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_is_shared": page_is_shared,
"page_data": page_data,
"collection_data": collection_data,
"breadcrumb_items": nav_crumbs,
"nav_workspace_id": page.get("workspace_id") or 0,
"nav_page_id": page_id,
"embed_mode": embed}
# A27 phase 2 : le JS de l'éditeur lit ces valeurs dans page-data (JSON)
# au lieu des interpolations Jinja — une seule source, même calculs que le
# ctx ci-dessus.
from app.templating import ENV as _ENV27
page_data.update(
updated_at=page.get("updated_at", ""),
created_at=page.get("created_at", ""),
user_id=_uid or 0,
is_shared=page_is_shared,
clip_icon=_ENV27.from_string(
"{% from '_icons.html' import fd_icon %}{{ fd_icon('paperclip', 14) }}"
).render(),
)
# Select template: collection pages use database table view
if page.get("content_format") == "collection" and not embed:
template = env.get_template("page_editor_collection.html")
@@ -434,7 +453,7 @@ async def view_page_root(request: Request, page_id: int):
@router.get("/accounts", response_class=HTMLResponse)
async def accounts_page(request: Request):
def accounts_page(request: Request):
"""Account management panel."""
from app.templating import ENV
env = ENV
@@ -451,7 +470,7 @@ async def accounts_page(request: Request):
@router.get("/help", response_class=HTMLResponse)
async def help_page(request: Request):
def help_page(request: Request):
"""Comprehensive help & documentation page."""
from app.templating import ENV
env = ENV
@@ -656,7 +675,7 @@ favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&amp;o
@router.get("/accounts/settings", response_class=HTMLResponse)
async def settings_page(request: Request):
def settings_page(request: Request):
"""User settings page — profile, forges, tokens."""
from app.templating import ENV
env = ENV
@@ -731,7 +750,7 @@ async def update_password(request: Request):
@router.post("/api/user/token")
async def generate_token(request: Request):
def generate_token(request: Request):
import secrets
uid = _require_user_id(request)
token = secrets.token_hex(32)
@@ -745,7 +764,7 @@ async def generate_token(request: Request):
@router.delete("/api/user/forge/{provider}")
async def disconnect_forge(request: Request, provider: str):
def disconnect_forge(request: Request, provider: str):
uid = _require_user_id(request)
with get_conn() as conn:
conn.execute(
@@ -834,7 +853,7 @@ async def dashboard(
# ═══════════ Workspace ═══════════
@router.get("/workspace", response_class=HTMLResponse)
async def workspace_page(request: Request):
def workspace_page(request: Request):
"""Unified workspace showing all projects."""
from app.templating import ENV
env = ENV
@@ -851,7 +870,7 @@ async def workspace_page(request: Request):
@router.get("/gitea-workspace", response_class=HTMLResponse)
async def gitea_workspace_page(request: Request):
def gitea_workspace_page(request: Request):
"""Gitea workspace — browse repo files."""
import json
@@ -971,7 +990,7 @@ async def create_workspace_project(request: Request):
# ═══════════ Workspace Members API ═══════════
@router.get("/api/workspace/{ws_id:int}/members")
async def list_members(request: Request, ws_id: int):
def list_members(request: Request, ws_id: int):
"""List all members of a workspace."""
with get_conn() as conn:
rows = conn.execute(
@@ -1022,7 +1041,7 @@ async def update_member_role(request: Request, ws_id: int, user_id: int):
@router.delete("/api/workspace/{ws_id:int}/members/{user_id:int}")
async def remove_member(request: Request, ws_id: int, user_id: int):
def remove_member(request: Request, ws_id: int, user_id: int):
"""Remove a member from a workspace."""
with get_conn() as conn:
conn.execute(
@@ -1036,7 +1055,7 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
@router.get("/local-workspace", response_class=HTMLResponse)
async def local_workspace_page(request: Request, folder: int = None):
def local_workspace_page(request: Request, folder: int = None):
"""Local workspace page with file/folder tree.
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
@@ -1084,7 +1103,7 @@ async def local_workspace_page(request: Request, folder: int = None):
@router.get("/api/local-workspace/tree")
async def local_workspace_tree(request: Request, folder: int = None):
def local_workspace_tree(request: Request, folder: int = None):
"""Return the file/folder tree filtered by active workspace.
If ?folder=ID is provided, returns only that folder's children.
@@ -1109,7 +1128,7 @@ async def local_workspace_tree(request: Request, folder: int = None):
@router.get("/api/local-workspace/page-content/{page_id:int}")
async def get_page_content(page_id: int):
def get_page_content(page_id: int):
"""Return the raw content of a page (for preview)."""
with get_conn() as conn:
row = conn.execute(
@@ -1174,7 +1193,7 @@ def _require_page_view(request: Request, page_id: int) -> None:
@router.get("/api/pages/{page_id}/download")
async def download_page_file(request: Request, page_id: int):
def download_page_file(request: Request, page_id: int):
"""Download the original uploaded file of a ``file`` page (attachment)."""
_require_page_view(request, page_id)
with get_conn() as conn:
@@ -1197,7 +1216,7 @@ async def download_page_file(request: Request, page_id: int):
@router.get("/api/pages/{page_id}/file-content")
async def page_file_content(request: Request, page_id: int):
def page_file_content(request: Request, page_id: int):
"""Return the textual content of a ``file`` page (for copy to clipboard).
Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is
@@ -1225,7 +1244,7 @@ async def page_file_content(request: Request, page_id: int):
@router.get("/api/local-workspace/breadcrumb")
async def local_workspace_breadcrumb(request: Request, folder: int):
def local_workspace_breadcrumb(request: Request, folder: int):
"""Return breadcrumb trail for a folder."""
with get_conn() as conn:
breadcrumb = _build_breadcrumb(conn, folder)
@@ -1393,7 +1412,7 @@ def _nav_breadcrumb(conn, page_id: int) -> list:
@router.get("/api/nav/menu")
async def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
"""Return the pages at one level for the header breadcrumb navigation menu.
If ``parent_id`` is given, returns that page's children; otherwise the
@@ -1483,7 +1502,7 @@ async def rename_local_workspace_item(request: Request, item_id: int):
@router.delete("/api/local-workspace/items/{item_id:int}")
async def delete_local_workspace_item(request: Request, item_id: int):
def delete_local_workspace_item(request: Request, item_id: int):
"""Soft-delete a file/folder (sets deleted_at)."""
from datetime import datetime
with get_conn() as conn:
@@ -1496,7 +1515,7 @@ async def delete_local_workspace_item(request: Request, item_id: int):
@router.post("/api/local-workspace/items/{item_id:int}/restore")
async def restore_local_workspace_item(request: Request, item_id: int):
def restore_local_workspace_item(request: Request, item_id: int):
"""Restore a soft-deleted file/folder."""
with get_conn() as conn:
conn.execute(
@@ -1508,7 +1527,7 @@ async def restore_local_workspace_item(request: Request, item_id: int):
@router.get("/api/files/{ws_id:int}/{filename:path}")
async def serve_uploaded_file(ws_id: int, filename: str):
def serve_uploaded_file(ws_id: int, filename: str):
"""Serve an uploaded file from disk."""
import mimetypes
from pathlib import Path
@@ -1774,7 +1793,7 @@ def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
@router.get("/workspaces", response_class=HTMLResponse)
async def workspaces_page(request: Request):
def workspaces_page(request: Request):
"""Workspaces list page."""
from app.templating import ENV
env = ENV
@@ -1791,7 +1810,7 @@ async def workspaces_page(request: Request):
@router.get("/api/workspaces")
async def list_workspaces(request: Request):
def list_workspaces(request: Request):
"""List all workspaces for the current user."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = user["id"] if user and user.get("id") else 1
@@ -1855,7 +1874,7 @@ async def rename_workspace(request: Request, ws_id: int):
@router.delete("/api/workspaces/{ws_id:int}")
async def delete_workspace(request: Request, ws_id: int):
def delete_workspace(request: Request, ws_id: int):
"""Delete a workspace and all its pages."""
with get_conn() as conn:
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,))
@@ -1866,7 +1885,7 @@ async def delete_workspace(request: Request, ws_id: int):
@router.post("/api/workspaces/{ws_id:int}/select")
async def select_workspace(request: Request, ws_id: int):
def select_workspace(request: Request, ws_id: int):
"""Set the active workspace via cookie."""
from fastapi.responses import JSONResponse
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
@@ -1877,7 +1896,7 @@ async def select_workspace(request: Request, ws_id: int):
# ═══════════ Settings Page ═══════════
@router.get("/settings", response_class=HTMLResponse)
async def app_settings_page(request: Request):
def app_settings_page(request: Request):
"""Settings & configuration page."""
from app.templating import ENV
env = ENV
@@ -1923,7 +1942,7 @@ async def upload_avatar(request: Request):
@router.get("/api/settings/avatar/{filename:path}")
async def serve_avatar_file(filename: str):
def serve_avatar_file(filename: str):
"""Serve an uploaded avatar image file."""
from pathlib import Path
@@ -1942,7 +1961,7 @@ async def serve_avatar_file(filename: str):
@router.get("/api/avatar/{user_id:int}")
async def get_avatar(user_id: int):
def get_avatar(user_id: int):
"""Redirect to the user's avatar."""
with get_conn() as conn:
row = conn.execute("SELECT avatar_url FROM users WHERE id = ?", (user_id,)).fetchone()
@@ -2002,7 +2021,7 @@ async def update_tag_global(tag_id: int, request: Request):
@router.delete("/api/settings/tags/{tag_id:int}")
async def delete_tag_global(tag_id: int, request: Request):
def delete_tag_global(tag_id: int, request: Request):
"""Delete a tag — only if owned by user."""
uid = _get_user_id(request)
with get_conn() as conn:
@@ -2013,7 +2032,7 @@ async def delete_tag_global(tag_id: int, request: Request):
@router.get("/api/settings/tags/all")
async def list_all_tags_global(request: Request):
def list_all_tags_global(request: Request):
"""List current user's tags with counts."""
uid = _get_user_id(request)
with get_conn() as conn:
@@ -2032,7 +2051,7 @@ async def list_all_tags_global(request: Request):
# ═══════════ Workspace Tags API ═══════════
@router.get("/api/local-workspace/tags")
async def list_tags(request: Request):
def list_tags(request: Request):
"""List ALL user tags with counts scoped to the active workspace."""
ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = ws["id"] if ws else None
@@ -2055,7 +2074,7 @@ async def list_tags(request: Request):
@router.get("/api/local-workspace/items/{item_id:int}/tags")
async def get_item_tags(item_id: int):
def get_item_tags(item_id: int):
"""Get tags for a specific item."""
with get_conn() as conn:
rows = conn.execute(
@@ -2104,7 +2123,7 @@ async def add_item_tag(request: Request, item_id: int):
@router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}")
async def remove_item_tag(item_id: int, tag_id: int):
def remove_item_tag(item_id: int, tag_id: int):
"""Remove a tag from an item."""
with get_conn() as conn:
conn.execute(
@@ -2116,7 +2135,7 @@ async def remove_item_tag(item_id: int, tag_id: int):
@router.get("/api/local-workspace/tags/search")
async def search_by_tags(request: Request, tags: str = ""):
def search_by_tags(request: Request, tags: str = ""):
"""Search items by tags (comma-separated)."""
ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = ws["id"] if ws else None
@@ -2198,7 +2217,7 @@ async def update_account(request: Request):
# ═══════════ Sidebar Refresh API ═══════════
@router.get("/api/sidebar/workspace-tree")
async def sidebar_workspace_tree(request: Request):
def sidebar_workspace_tree(request: Request):
"""Return the sidebar workspace tree as HTML fragment.
Called by appState().refreshSidebarTree() after CRUD operations
@@ -2260,7 +2279,7 @@ async def sidebar_workspace_tree(request: Request):
@router.get("/p/{slug}", response_class=HTMLResponse)
async def public_published_page(request: Request, slug: str):
def public_published_page(request: Request, slug: str):
"""Serve a published page at /p/<slug> — no auth required."""
from app.templating import ENV
@@ -2525,7 +2544,7 @@ def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
# ═══════════ Library page actions API ═══════════
@router.get("/api/pages/{page_id:int}/content")
async def api_page_content(page_id: int):
def api_page_content(page_id: int):
"""Get page content for side peek preview."""
with get_conn() as conn:
row = conn.execute(
@@ -2563,7 +2582,7 @@ async def api_rename_page(page_id: int, request: Request):
@router.post("/api/pages/{page_id:int}/trash")
async def api_trash_page(page_id: int):
def api_trash_page(page_id: int):
"""Soft-delete a page (move to trash)."""
with get_conn() as conn:
conn.execute(
@@ -2641,7 +2660,7 @@ async def api_convert_to_database(page_id: int, request: Request):
@router.get("/api/collections/{collection_id:int}/table-data")
async def api_collection_table_data(collection_id: int):
def api_collection_table_data(collection_id: int):
"""Get collection properties + pages for rendering the table view."""
with get_conn() as conn:
coll = conn.execute(
+2 -2
View File
@@ -37,7 +37,7 @@ def _active_ws(request: Request) -> int:
@router.get("/api/custom-emojis")
async def list_custom_emojis(request: Request):
def list_custom_emojis(request: Request):
"""List the current workspace's custom emojis."""
ws_id = _active_ws(request)
with get_conn() as conn:
@@ -79,7 +79,7 @@ async def create_custom_emoji(request: Request):
@router.delete("/api/custom-emojis/{emoji_id}")
async def delete_custom_emoji(request: Request, emoji_id: int):
def delete_custom_emoji(request: Request, emoji_id: int):
"""Delete a custom emoji (and its stored file)."""
ws_id = _active_ws(request)
with get_conn() as conn:
+4 -4
View File
@@ -61,7 +61,7 @@ def _safe_filename(page: dict, ext: str) -> str:
@router.get("/markdown/{page_id}")
async def export_markdown(page_id: int, request: Request):
def export_markdown(page_id: int, request: Request):
page = _load_page_or_404(request, page_id)
md = page_to_markdown(page)
filename = _safe_filename(page, "md")
@@ -70,7 +70,7 @@ async def export_markdown(page_id: int, request: Request):
@router.get("/html/{page_id}")
async def export_html(page_id: int, request: Request):
def export_html(page_id: int, request: Request):
page = _load_page_or_404(request, page_id)
html = page_to_standalone_html(page)
filename = _safe_filename(page, "html")
@@ -79,7 +79,7 @@ async def export_html(page_id: int, request: Request):
@router.get("/pdf/{page_id}")
async def export_pdf(page_id: int, request: Request):
def export_pdf(page_id: int, request: Request):
page = _load_page_or_404(request, page_id)
try:
pdf_bytes = page_to_pdf_bytes(page)
@@ -94,7 +94,7 @@ async def export_pdf(page_id: int, request: Request):
@router.get("/site/{page_id}")
async def export_site(page_id: int, request: Request):
def export_site(page_id: int, request: Request):
page = _load_page_or_404(request, page_id)
site_bytes = build_static_site_bytes(page)
title = _safe_filename(page, "site").replace(".site", "") or "flowdeck-site"
+5 -5
View File
@@ -162,7 +162,7 @@ async def get_labels(request: Request, owner: str, repo: str):
# ── Account linking ──
@router.get("/status")
async def gitea_status(request: Request):
def gitea_status(request: Request):
"""Check if the current user has Gitea linked."""
from app.services.gitea_client import get_user_gitea_client
client = get_user_gitea_client(request)
@@ -170,7 +170,7 @@ async def gitea_status(request: Request):
@router.delete("/disconnect")
async def disconnect_gitea(request: Request):
def disconnect_gitea(request: Request):
"""Remove all Gitea OAuth tokens for the current user."""
from app.auth.session import SessionManager
from app.db import get_conn
@@ -186,7 +186,7 @@ async def disconnect_gitea(request: Request):
# ── Private Pages (local FlowDeck pages linked to Gitea project) ──
@router.get("/projects/{owner}/{repo}/private-pages")
async def list_private_pages(owner: str, repo: str, request: Request):
def list_private_pages(owner: str, repo: str, request: Request):
"""List private pages for this Gitea project."""
from app.auth.session import SessionManager
from app.db import get_conn
@@ -225,7 +225,7 @@ async def create_private_page(owner: str, repo: str, request: Request):
@router.get("/projects/{owner}/{repo}/private-pages/{page_id}")
async def get_private_page(owner: str, repo: str, page_id: int, request: Request):
def get_private_page(owner: str, repo: str, page_id: int, request: Request):
"""Get a single private page."""
from app.auth.session import SessionManager
from app.db import get_conn
@@ -272,7 +272,7 @@ async def update_private_page(owner: str, repo: str, page_id: int, request: Requ
@router.delete("/projects/{owner}/{repo}/private-pages/{page_id}")
async def delete_private_page(owner: str, repo: str, page_id: int, request: Request):
def delete_private_page(owner: str, repo: str, page_id: int, request: Request):
"""Delete a private page."""
from app.auth.session import SessionManager
from app.db import get_conn
+2 -2
View File
@@ -6,7 +6,7 @@ router = APIRouter(tags=["github"], prefix="/api/github")
@router.get("/status")
async def github_status(request: Request):
def github_status(request: Request):
"""Check if the current user has GitHub linked."""
from app.auth.session import SessionManager
from app.db import get_conn
@@ -22,7 +22,7 @@ async def github_status(request: Request):
@router.delete("/disconnect")
async def disconnect_github(request: Request):
def disconnect_github(request: Request):
"""Remove all GitHub OAuth tokens for the current user."""
from app.auth.session import SessionManager
from app.db import get_conn
+2 -2
View File
@@ -37,7 +37,7 @@ def _owner_or_admin(request: Request) -> dict:
@router.get("/api/v2/agent-policies")
async def list_policies(request: Request):
def list_policies(request: Request):
_owner_or_admin(request)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM agent_policies ORDER BY workspace_id").fetchall()
@@ -74,7 +74,7 @@ async def upsert_policy(request: Request):
@router.get("/api/v2/agent-approvals")
async def list_approvals(request: Request):
def list_approvals(request: Request):
_owner_or_admin(request)
status = request.query_params.get("status", "pending")
with get_conn() as conn:
+5 -5
View File
@@ -79,7 +79,7 @@ async def _read_upload(request: Request) -> tuple[str, bytes, str | None]:
@router.get("/sources")
async def import_sources(request: Request):
def import_sources(request: Request):
"""List every available importer for the UI source picker."""
return {"sources": list_sources()}
@@ -289,7 +289,7 @@ async def import_run_batch(request: Request):
@router.post("/relations/resolve")
async def import_resolve_relations(request: Request):
def import_resolve_relations(request: Request):
"""Convert text columns referencing another collection into relation props."""
ws_id, _ = _workspace(request)
with get_conn() as conn:
@@ -297,12 +297,12 @@ async def import_resolve_relations(request: Request):
@router.get("/jobs")
async def import_jobs(request: Request):
def import_jobs(request: Request):
return {"jobs": list_jobs()}
@router.get("/jobs/{job_id}")
async def import_job(job_id: str):
def import_job(job_id: str):
job = get_job(job_id)
if not job:
raise HTTPException(404, "Job not found")
@@ -310,7 +310,7 @@ async def import_job(job_id: str):
@router.get("/jobs/{job_id}/report")
async def import_job_report(job_id: str):
def import_job_report(job_id: str):
"""Download a job's import report as JSON."""
job = get_job(job_id)
if not job:
+8 -161
View File
@@ -157,7 +157,7 @@ BASE_SELECT = (
@router.get("/recents")
async def library_recents(
def library_recents(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
@@ -191,7 +191,7 @@ async def library_recents(
@router.get("/favorites")
async def library_favorites(
def library_favorites(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
@@ -221,7 +221,7 @@ async def library_favorites(
@router.get("/shared")
async def library_shared(
def library_shared(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
@@ -292,7 +292,7 @@ async def library_shared(
@router.get("/published")
async def library_published(
def library_published(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
@@ -317,7 +317,7 @@ async def library_published(
@router.get("/private")
async def library_private(
def library_private(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
@@ -341,76 +341,8 @@ async def library_private(
return {"items": items}
@router.get("/local-workspace-children/{item_id:int}")
async def library_local_workspace_children(item_id: int, request: Request):
"""Return children of a local workspace item for tree expansion."""
_get_user_id(request)
with get_conn() as conn:
# Get the item to find its workspace
item = conn.execute(
"SELECT workspace_id FROM local_workspace_items WHERE id=? AND deleted_at IS NULL",
[item_id],
).fetchone()
if not item:
return {"items": []}
rows = conn.execute(
"SELECT id, name, is_folder, parent_id, content_format, size, "
"COALESCE(updated_at, created_at) as updated_at "
"FROM local_workspace_items "
"WHERE parent_id = ? AND deleted_at IS NULL "
"ORDER BY is_folder DESC, name COLLATE NOCASE",
[item_id],
).fetchall()
items = []
for r in rows:
name = r["name"] or "Untitled"
is_folder = bool(r["is_folder"])
icon = "📁" if is_folder else "📄"
fn = name.lower()
if not is_folder:
if fn.endswith(".pdf"):
icon = "📕"
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "🖼️"
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
icon = "📜"
with get_conn() as conn:
child_count = conn.execute(
"SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL",
[r["id"]],
).fetchone()[0]
items.append({
"id": r["id"],
"title": name,
"icon": icon,
"is_folder": is_folder,
"source_type": "local-ws",
"source_label": "",
"workspace": "",
"workspace_name": "",
"author": "",
"author_initial": "?",
"updated_at": r["updated_at"] or "",
"visited_at": "",
"has_children": child_count > 0,
"children": [],
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
"content_format": r["content_format"] or "file",
"favorited": False,
"page_icon": "",
"tags": [],
"size_display": _format_size(r["size"]) if r["size"] else "",
})
return {"items": items}
@router.get("/children/{page_id:int}")
async def library_children(page_id: int, request: Request):
def library_children(page_id: int, request: Request):
"""Return child pages for a given parent page (for tree expansion in Library)."""
uid = _get_user_id(request)
with get_conn() as conn:
@@ -426,7 +358,7 @@ async def library_children(page_id: int, request: Request):
@router.get("/repository")
async def library_repository(
def library_repository(
request: Request,
gitea_owner: str = Query(default=""),
gitea_repo: str = Query(default=""),
@@ -449,93 +381,8 @@ async def library_repository(
return {"items": items}
@router.get("/local-workspace")
async def library_local_workspace(
request: Request,
workspace_id: int = Query(default=0),
):
"""Return local workspace items (files/folders) formatted for Library display."""
from app.routers.dashboard import _get_active_workspace
uid = _get_user_id(request)
# Get the active workspace
ws = _get_active_workspace(request, user_id=uid)
if not ws:
return {"items": []}
ws_id = workspace_id or ws["id"]
# Query local workspace tree
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, is_folder, parent_id, content_format, size, "
"COALESCE(updated_at, created_at) as updated_at "
"FROM local_workspace_items "
"WHERE workspace_id = ? AND deleted_at IS NULL "
"ORDER BY is_folder DESC, name COLLATE NOCASE",
[ws_id],
).fetchall()
items = []
for r in rows:
name = r["name"] or "Untitled"
is_folder = bool(r["is_folder"])
icon = "📁" if is_folder else "📄"
fn = name.lower()
if not is_folder:
if fn.endswith(".pdf"):
icon = "📕"
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "🖼️"
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
icon = "📜"
# Check for children
child_count = conn.execute(
"SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL",
[r["id"]],
).fetchone()[0]
items.append({
"id": r["id"],
"title": name,
"icon": icon,
"is_folder": is_folder,
"source_type": "local-ws",
"source_label": ws.get("name", "Workspace"),
"workspace": ws.get("name", ""),
"workspace_name": ws.get("name", ""),
"author": "",
"author_initial": "?",
"updated_at": r["updated_at"] or "",
"visited_at": "",
"has_children": child_count > 0,
"children": [],
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
"content_format": r["content_format"] or "file",
"favorited": False,
"page_icon": "",
"tags": [],
"size_display": _format_size(r["size"]) if r["size"] else "",
})
return {"items": items}
def _format_size(size_bytes):
if not size_bytes:
return ""
if size_bytes < 1024:
return f"{size_bytes} B"
if size_bytes < 1048576:
return f"{size_bytes/1024:.1f} KB"
if size_bytes < 1073741824:
return f"{size_bytes/1048576:.1f} MB"
return f"{size_bytes/1073741824:.1f} GB"
@router.get("/workspace")
async def library_workspace(
def library_workspace(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
+3 -3
View File
@@ -75,13 +75,13 @@ async def create_link(request: Request):
@router.get("/api/v2/calendar-links")
async def get_links(request: Request):
def get_links(request: Request):
user = _auth_user(request)
return {"links": cal.list_links(user["id"])}
@router.delete("/api/v2/calendar-links/{link_id}")
async def remove_link(link_id: int, request: Request):
def remove_link(link_id: int, request: Request):
user = _auth_user(request, require_write=True)
if not cal.delete_link(user["id"], link_id):
raise HTTPException(404, "Link not found")
@@ -108,7 +108,7 @@ async def sync_now(link_id: int, request: Request):
# ── free/busy ──────────────────────────────────────────────────────────────
@router.get("/db/{collection_id}/calendar/freebusy")
async def freebusy(collection_id: int, request: Request):
def freebusy(collection_id: int, request: Request):
_auth_user(request)
qp = request.query_params
try:
+2 -2
View File
@@ -21,7 +21,7 @@ def _get_current_user(request: Request) -> dict | None:
@router.get("", response_class=HTMLResponse)
async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
"""My Tasks — aggregates all pages assigned to the current user across all collections."""
user = _get_current_user(request)
user_login = user.get("login", "admin") if user else "admin"
@@ -118,7 +118,7 @@ async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7)
@router.get("/api")
async def my_tasks_api(request: Request, view: str = "all", days: int = 7):
def my_tasks_api(request: Request, view: str = "all", days: int = 7):
"""API: return my tasks as JSON."""
user = _get_current_user(request)
user.get("login", "admin") if user else "admin"
+1 -1
View File
@@ -13,7 +13,7 @@ router = APIRouter(tags=["notes"], prefix="/notes")
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
async def get_notes(request: Request, owner: str, repo: str):
def get_notes(request: Request, owner: str, repo: str):
with get_conn() as conn:
row = conn.execute(
"SELECT content FROM notes WHERE project_owner=? AND project_name=? AND title='Notes'",
+5 -5
View File
@@ -20,7 +20,7 @@ def _current_user(request: Request) -> dict:
@router.get("")
async def list_notifications(request: Request, limit: int = 50):
def list_notifications(request: Request, limit: int = 50):
"""List the current user's notifications, newest first."""
user = _current_user(request)
with get_conn() as conn:
@@ -44,7 +44,7 @@ async def list_notifications(request: Request, limit: int = 50):
@router.get("/unread-count")
async def unread_count(request: Request):
def unread_count(request: Request):
"""Unread count for the topbar badge."""
user = _current_user(request)
with get_conn() as conn:
@@ -83,7 +83,7 @@ async def mark_all_read(request: Request):
@router.get("/prefs")
async def get_prefs(request: Request):
def get_prefs(request: Request):
"""Return the current user's notification email preferences."""
user = _current_user(request)
from app.services import notifications as notif
@@ -105,7 +105,7 @@ async def set_prefs(request: Request):
@router.get("/timezone")
async def get_timezone(request: Request):
def get_timezone(request: Request):
"""Return the current user's IANA timezone ('' = UTC)."""
user = _current_user(request)
with get_conn() as conn:
@@ -131,7 +131,7 @@ async def set_timezone(request: Request):
@router.get("/users/search")
async def search_users(request: Request, q: str = ""):
def search_users(request: Request, q: str = ""):
"""User autocomplete for @mentions."""
_current_user(request)
q = (q or "").strip()
+1 -1
View File
@@ -28,7 +28,7 @@ def _require_user(request: Request) -> dict:
@router.get("/welcome", response_class=HTMLResponse)
async def onboarding_page(request: Request):
def onboarding_page(request: Request):
"""Onboarding wizard. Redirects logged-out users to login and users who
already have a workspace straight to the app."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
+14 -14
View File
@@ -158,7 +158,7 @@ def _set_permission_type(request: Request, pm: PermissionManager, resource_type:
@router.get("/pages/{page_id}/permissions")
async def list_page_permissions(page_id: int, request: Request):
def list_page_permissions(page_id: int, request: Request):
"""List explicit page grants + the caller's effective role."""
pm = _pm(request)
if not pm.can_view_page(page_id):
@@ -174,7 +174,7 @@ async def list_page_permissions(page_id: int, request: Request):
@router.get("/pages/{page_id}/permissions/mine")
async def my_page_permission(page_id: int, request: Request):
def my_page_permission(page_id: int, request: Request):
"""Effective role of the current user on a page (UI gating)."""
pm = _pm(request)
if not pm.can_view_page(page_id):
@@ -224,7 +224,7 @@ async def batch_page_permissions(page_id: int, request: Request):
@router.delete("/pages/{page_id}/permissions/{perm_id}")
async def revoke_page_permission(page_id: int, perm_id: int, request: Request):
def revoke_page_permission(page_id: int, perm_id: int, request: Request):
pm = _pm(request)
if not pm.can_manage_page_permissions(page_id):
raise HTTPException(403, "Only a page owner can manage its permissions")
@@ -243,7 +243,7 @@ async def set_page_permission_type(page_id: int, request: Request):
@router.get("/collections/{collection_id}/permissions")
async def list_collection_permissions(collection_id: int, request: Request):
def list_collection_permissions(collection_id: int, request: Request):
pm = _pm(request)
if not pm.can_view_collection(collection_id):
raise HTTPException(404, "Collection not found")
@@ -276,7 +276,7 @@ async def grant_collection_permission(collection_id: int, request: Request):
@router.delete("/collections/{collection_id}/permissions/{perm_id}")
async def revoke_collection_permission(collection_id: int, perm_id: int, request: Request):
def revoke_collection_permission(collection_id: int, perm_id: int, request: Request):
pm = _pm(request)
if not pm.can_manage_collection_permissions(collection_id):
raise HTTPException(403, "Only a collection owner can manage its permissions")
@@ -294,7 +294,7 @@ async def set_collection_permission_type(collection_id: int, request: Request):
@router.get("/collections/{collection_id}/properties/visible")
async def visible_properties(collection_id: int, request: Request):
def visible_properties(collection_id: int, request: Request):
"""Split property ids into visible / hidden for the current user."""
pm = _pm(request)
if not pm.can_view_collection(collection_id):
@@ -316,7 +316,7 @@ async def visible_properties(collection_id: int, request: Request):
@router.get("/collections/{collection_id}/properties/{property_id}/permissions")
async def list_property_permissions(collection_id: int, property_id: int, request: Request):
def list_property_permissions(collection_id: int, property_id: int, request: Request):
pm = _pm(request)
if not pm.can_view_collection(collection_id):
raise HTTPException(404, "Collection not found")
@@ -349,7 +349,7 @@ async def grant_property_permission(collection_id: int, property_id: int, reques
@router.delete("/collections/{collection_id}/properties/{property_id}/permissions/{perm_id}")
async def revoke_property_permission(collection_id: int, property_id: int,
def revoke_property_permission(collection_id: int, property_id: int,
perm_id: int, request: Request):
pm = _pm(request)
if not pm.can_manage_collection_permissions(collection_id):
@@ -362,7 +362,7 @@ async def revoke_property_permission(collection_id: int, property_id: int,
@router.get("/groups")
async def list_groups(request: Request, workspace_id: int | None = None):
def list_groups(request: Request, workspace_id: int | None = None):
user = _require_user(request)
pm = PermissionManager(user["id"])
return {"groups": pm.get_groups_for_workspace(workspace_id)}
@@ -405,7 +405,7 @@ async def update_group(group_id: int, request: Request):
@router.delete("/groups/{group_id}")
async def delete_group(group_id: int, request: Request):
def delete_group(group_id: int, request: Request):
pm = _pm(request)
with get_conn() as conn:
row = conn.execute(
@@ -422,7 +422,7 @@ async def delete_group(group_id: int, request: Request):
@router.get("/groups/{group_id}/members")
async def list_group_members(group_id: int, request: Request):
def list_group_members(group_id: int, request: Request):
user = _require_user(request)
return {"members": PermissionManager(user["id"]).get_group_members(group_id)}
@@ -451,7 +451,7 @@ async def add_group_member(group_id: int, request: Request):
@router.delete("/groups/{group_id}/members/{user_id}")
async def remove_group_member(group_id: int, user_id: int, request: Request):
def remove_group_member(group_id: int, user_id: int, request: Request):
pm = _pm(request)
with get_conn() as conn:
row = conn.execute(
@@ -473,7 +473,7 @@ async def remove_group_member(group_id: int, user_id: int, request: Request):
@router.get("/users")
async def list_users(request: Request, workspace_id: int | None = None, q: str = ""):
def list_users(request: Request, workspace_id: int | None = None, q: str = ""):
"""Workspace members (+ admins) for the grant pickers."""
_require_user(request)
q = (q or "").strip().lower()
@@ -503,7 +503,7 @@ async def list_users(request: Request, workspace_id: int | None = None, q: str =
@router.get("/audit/permissions")
async def permission_audit(request: Request, limit: int = 100):
def permission_audit(request: Request, limit: int = 100):
"""Full permission change history — workspace owner/admin only."""
user = _require_user(request)
uid = user["id"]
+1 -1
View File
@@ -22,7 +22,7 @@ def _require_admin(request: Request) -> dict:
@router.get("")
async def list_projects(request: Request):
def list_projects(request: Request):
"""List all synced projects (optionally filtered by type)."""
proj_type = request.query_params.get("type") or None
return {"projects": projects_svc.list_projects(proj_type)}
+6 -6
View File
@@ -55,7 +55,7 @@ def verify_token(authorization: str | None = Header(None)):
@router.post("/token")
async def generate_token(request: Request):
def generate_token(request: Request):
"""Generate a public API access token (A4 : session obligatoire — plus de
« legacy shared token » `user_id=0` créable par un anonymous)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
@@ -72,14 +72,14 @@ async def generate_token(request: Request):
@router.get("/collections", dependencies=[Depends(verify_token)])
async def public_list_collections(request: Request):
def public_list_collections(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT id, name, description, icon, created_at FROM collections ORDER BY name").fetchall()
return {"collections": [dict(r) for r in rows]}
@router.get("/collections/{collection_id}", dependencies=[Depends(verify_token)])
async def public_get_collection(request: Request, collection_id: int):
def public_get_collection(request: Request, collection_id: int):
with get_conn() as conn:
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
@@ -92,7 +92,7 @@ async def public_get_collection(request: Request, collection_id: int):
@router.get("/collections/{collection_id}/pages", dependencies=[Depends(verify_token)])
async def public_list_pages(request: Request, collection_id: int):
def public_list_pages(request: Request, collection_id: int):
with get_conn() as conn:
pages = conn.execute(
"SELECT id, title, icon, position, property_values_json, created_at FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
@@ -102,7 +102,7 @@ async def public_list_pages(request: Request, collection_id: int):
@router.get("/pages/{page_id}", dependencies=[Depends(verify_token)])
async def public_get_page(request: Request, page_id: int):
def public_get_page(request: Request, page_id: int):
with get_conn() as conn:
p = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not p:
@@ -111,7 +111,7 @@ async def public_get_page(request: Request, page_id: int):
@router.get("/my-tasks", dependencies=[Depends(verify_token)])
async def public_my_tasks(request: Request):
def public_my_tasks(request: Request):
"""Public API: list tasks (requires valid token)."""
with get_conn() as conn:
pages = conn.execute(
+1 -1
View File
@@ -18,7 +18,7 @@ router = APIRouter(tags=["realtime"])
@router.get("/api/realtime/stats")
async def realtime_stats(request: Request):
def realtime_stats(request: Request):
"""Observabilité realtime v6.4.0 : rooms, connexions, ops, merges, conflits.
Réservé aux utilisateurs authentifiés (données d'activité internes).
+7 -7
View File
@@ -60,7 +60,7 @@ def _scim_user(row) -> dict:
# ── SCIM resources ─────────────────────────────────────────────────────────
@router.get("/scim/v2/Users")
async def scim_list(request: Request):
def scim_list(request: Request):
_scim_guard(request)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM users ORDER BY id LIMIT 100").fetchall()
@@ -99,7 +99,7 @@ async def scim_create(request: Request):
@router.get("/scim/v2/Users/{user_id}")
async def scim_get(user_id: str, request: Request):
def scim_get(user_id: str, request: Request):
_scim_guard(request)
with get_conn() as conn:
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
@@ -164,7 +164,7 @@ async def scim_patch(user_id: str, request: Request):
@router.delete("/scim/v2/Users/{user_id}")
async def scim_delete(user_id: str, request: Request):
def scim_delete(user_id: str, request: Request):
_scim_guard(request)
with get_conn() as conn:
row = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
@@ -200,7 +200,7 @@ async def create_scim_token(request: Request):
@router.get("/api/v2/scim/tokens")
async def list_scim_tokens(request: Request):
def list_scim_tokens(request: Request):
_admin_session(request)
with get_conn() as conn:
rows = conn.execute("SELECT id, name, created_by, revoked, created_at"
@@ -209,7 +209,7 @@ async def list_scim_tokens(request: Request):
@router.delete("/api/v2/scim/tokens/{token_id}")
async def revoke_scim_token(token_id: int, request: Request):
def revoke_scim_token(token_id: int, request: Request):
admin = _admin_session(request)
with get_conn() as conn:
conn.execute("UPDATE scim_tokens SET revoked=1 WHERE id=?", (token_id,))
@@ -221,7 +221,7 @@ async def revoke_scim_token(token_id: int, request: Request):
# ── domain claims ──────────────────────────────────────────────────────────
@router.get("/api/v2/domain-claims")
async def list_domains(request: Request):
def list_domains(request: Request):
_admin_session(request)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM domain_claims ORDER BY domain").fetchall()
@@ -288,7 +288,7 @@ async def verify_domain(domain_id: int, request: Request):
@router.delete("/api/v2/domain-claims/{domain_id}")
async def delete_domain(domain_id: int, request: Request):
def delete_domain(domain_id: int, request: Request):
admin = _admin_session(request)
with get_conn() as conn:
conn.execute("DELETE FROM domain_claims WHERE id=?", (domain_id,))
+1 -1
View File
@@ -14,7 +14,7 @@ router = APIRouter(tags=["search"])
@router.get("/api/search")
async def search(request: Request, q: str = Query(default="")):
def search(request: Request, q: str = Query(default="")):
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
user_id = user.get("id") if user and user.get("id") else None
+2 -2
View File
@@ -43,7 +43,7 @@ def _auth_user(request: Request) -> dict:
@router.get("/api/v2/search/hybrid")
async def hybrid(request: Request):
def hybrid(request: Request):
user = _auth_user(request)
q = (request.query_params.get("q") or request.query_params.get("query") or "").strip()
if not q:
@@ -81,7 +81,7 @@ async def ask_ai(request: Request):
@router.get("/api/v2/search/index-status")
async def index_status(request: Request):
def index_status(request: Request):
"""How many resources are indexed vs pending (owner/admin visibility)."""
user = _auth_user(request)
with get_conn() as conn:
+4 -4
View File
@@ -32,7 +32,7 @@ def _current_user_id(request: Request) -> int:
@router.get("/tokens")
async def list_tokens(request: Request):
def list_tokens(request: Request):
"""List the current user's API tokens (prefix only, no secrets)."""
uid = _current_user_id(request)
with get_conn() as conn:
@@ -63,7 +63,7 @@ async def create_token(request: Request):
@router.delete("/tokens/{token_id:int}")
async def revoke_token(token_id: int, request: Request):
def revoke_token(token_id: int, request: Request):
"""Revoke an API token (soft delete)."""
uid = _current_user_id(request)
with get_conn() as conn:
@@ -81,7 +81,7 @@ async def revoke_token(token_id: int, request: Request):
@router.get("/sessions")
async def list_sessions(request: Request):
def list_sessions(request: Request):
"""List the current user's active sessions with their devices."""
uid = _current_user_id(request)
current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", ""))
@@ -101,7 +101,7 @@ async def list_sessions(request: Request):
@router.post("/sessions/{sid}/revoke")
async def revoke_session(sid: str, request: Request):
def revoke_session(sid: str, request: Request):
"""Revoke an active session. If it's the current one, the user is logged out."""
uid = _current_user_id(request)
with get_conn() as conn:
+2 -2
View File
@@ -204,7 +204,7 @@ async def update_share_permission(page_id: int, share_id: int, request: Request)
@router.delete("/pages/{page_id}/share/{share_id}")
async def remove_share(page_id: int, share_id: int, request: Request):
def remove_share(page_id: int, share_id: int, request: Request):
"""Remove a share invitation."""
_require_auth(request)
@@ -238,7 +238,7 @@ async def remove_share(page_id: int, share_id: int, request: Request):
@router.get("/pages/{page_id}/shares")
async def list_shares(page_id: int, request: Request):
def list_shares(page_id: int, request: Request):
"""Get all shares for a page."""
_require_auth(request)
+1 -1
View File
@@ -35,7 +35,7 @@ DEFAULT_CONFIG = {
@router.get("/config")
async def get_sidebar_config(request: Request):
def get_sidebar_config(request: Request):
"""Get the current user's sidebar customization config."""
user = _get_user(request)
with get_conn() as conn:
+11 -11
View File
@@ -315,7 +315,7 @@ async def create_site(request: Request):
@router.get("/api/v2/sites")
async def list_sites(request: Request):
def list_sites(request: Request):
user = _auth_user(request)
limit, offset = parse_pagination(request)
with get_conn() as conn:
@@ -333,7 +333,7 @@ async def list_sites(request: Request):
@router.get("/api/v2/sites/{site_id}")
async def get_site(site_id: int, request: Request):
def get_site(site_id: int, request: Request):
user = _auth_user(request)
with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
@@ -407,7 +407,7 @@ async def update_site(site_id: int, request: Request):
@router.delete("/api/v2/sites/{site_id}")
async def delete_site(site_id: int, request: Request):
def delete_site(site_id: int, request: Request):
user = _auth_user(request, require_write=True)
with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
@@ -422,7 +422,7 @@ async def delete_site(site_id: int, request: Request):
@router.get("/api/v2/sites/{site_id}/pages")
async def list_site_pages(site_id: int, request: Request):
def list_site_pages(site_id: int, request: Request):
user = _auth_user(request)
with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
@@ -461,7 +461,7 @@ async def add_site_page(site_id: int, request: Request):
@router.delete("/api/v2/sites/{site_id}/pages/{page_id}")
async def remove_site_page(site_id: int, page_id: int, request: Request):
def remove_site_page(site_id: int, page_id: int, request: Request):
user = _auth_user(request, require_write=True)
with get_conn() as conn:
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
@@ -478,7 +478,7 @@ async def remove_site_page(site_id: int, page_id: int, request: Request):
@router.get("/api/v2/sites/{site_id}/stats")
async def site_stats(site_id: int, request: Request, days: int = 30):
def site_stats(site_id: int, request: Request, days: int = 30):
user = _auth_user(request)
days = max(1, min(int(days or 30), 365))
with get_conn() as conn:
@@ -516,7 +516,7 @@ def _public_guard(site: dict, request: Request):
@router.get("/s/{slug}", response_class=HTMLResponse)
async def public_site_home(request: Request, slug: str):
def public_site_home(request: Request, slug: str):
with get_conn() as conn:
site = _resolve_site(conn, slug=slug,
host=request.headers.get("host", ""))
@@ -537,7 +537,7 @@ async def public_site_home(request: Request, slug: str):
@router.get("/s/{slug}/sitemap.xml", response_class=PlainTextResponse)
async def site_sitemap(request: Request, slug: str):
def site_sitemap(request: Request, slug: str):
with get_conn() as conn:
site = _resolve_site(conn, slug=slug)
if not site or _site_expired(site) or site.get("password_hash"):
@@ -556,7 +556,7 @@ async def site_sitemap(request: Request, slug: str):
@router.get("/s/{slug}/{page_ref}", response_class=HTMLResponse)
async def public_site_page(request: Request, slug: str, page_ref: str):
def public_site_page(request: Request, slug: str, page_ref: str):
with get_conn() as conn:
site = _resolve_site(conn, slug=slug, host=request.headers.get("host", ""))
if not site:
@@ -614,7 +614,7 @@ async def public_site_auth(request: Request, slug: str):
# ── Public Forms ───────────────────────────────────────────────────────────
@router.get("/api/v2/collections/{collection_id}/form")
async def get_form_config(collection_id: int, request: Request):
def get_form_config(collection_id: int, request: Request):
_auth_user(request)
with get_conn() as conn:
info = _form_config(conn, collection_id)
@@ -662,7 +662,7 @@ def _collection_props(conn, collection_id: int) -> list[dict]:
@router.get("/f/{token}", response_class=HTMLResponse)
async def public_form(request: Request, token: str):
def public_form(request: Request, token: str):
embed = request.query_params.get("embed") == "1"
with get_conn() as conn:
row = conn.execute("SELECT * FROM collections").fetchone()
+3 -3
View File
@@ -106,7 +106,7 @@ def _login_error(message: str, *, cfg: dict | None, identifier: str = "", reques
@router.get("/auth/saml/login")
async def saml_login(request: Request, next: str = DEFAULT_NEXT):
def saml_login(request: Request, next: str = DEFAULT_NEXT):
"""SP-initiated SSO: issue an AuthnRequest and redirect to the IdP."""
if not _rate_ok(request, "saml"):
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
@@ -218,7 +218,7 @@ async def saml_callback(request: Request):
@router.get("/auth/saml/metadata")
async def saml_metadata(request: Request):
def saml_metadata(request: Request):
"""SP metadata XML — paste into the IdP (Azure AD / Okta / Keycloak…)."""
cfg = _sso_config_or_error()
if not cfg or cfg["provider_type"] != "saml":
@@ -536,7 +536,7 @@ async def _require_admin(request: Request, *, write: bool) -> dict:
@router.get("/api/v2/sso/providers")
async def sso_providers(request: Request):
def sso_providers(request: Request):
"""Public: what the login page should show (button list + sso_only flag)."""
cfg = _sso_config_or_error()
if not cfg:
+1 -1
View File
@@ -82,7 +82,7 @@ async def sync_batch(request: Request, authorization: str | None = Header(defaul
@router.get("/status")
async def sync_status(request: Request, workspace_id: int = Query(default=None),
def sync_status(request: Request, workspace_id: int = Query(default=None),
authorization: str | None = Header(default=None)):
"""Synchronization status for the workspace (pending server queue, last sync)."""
user = _user(request, authorization, required_scope="read")
+1 -1
View File
@@ -227,7 +227,7 @@ async def revoke_extension_device(device_id: int, request: Request):
# ── HTML: /extensions download page ──
@router.get("/extensions", response_class=HTMLResponse)
async def extensions_page(request: Request):
def extensions_page(request: Request):
from app.routers.dashboard import _sidebar_data
from app.templating import ENV
+3 -3
View File
@@ -56,7 +56,7 @@ def _session_user(request: Request) -> dict:
@router.post("/register/begin")
async def register_begin(request: Request):
def register_begin(request: Request):
if not _require_lib():
raise HTTPException(501, "WebAuthn library not installed")
from webauthn import generate_registration_options, options_to_json
@@ -193,7 +193,7 @@ async def login_finish(request: Request):
@router.get("/keys")
async def list_keys(request: Request):
def list_keys(request: Request):
user = _session_user(request)
with get_conn() as conn:
rows = conn.execute("SELECT id, name, sign_count, created_at FROM webauthn_credentials"
@@ -202,7 +202,7 @@ async def list_keys(request: Request):
@router.delete("/keys/{key_id}")
async def delete_key(key_id: int, request: Request):
def delete_key(key_id: int, request: Request):
user = _session_user(request)
with get_conn() as conn:
cur = conn.execute("DELETE FROM webauthn_credentials WHERE id=? AND user_id=?",
+17 -17
View File
@@ -98,7 +98,7 @@ def _can_verify(user: dict, page: dict) -> bool:
# ── teamspaces ─────────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/teamspaces")
async def list_teamspaces(request: Request):
def list_teamspaces(request: Request):
user = _user(request)
wid = request.query_params.get("workspace_id")
if wid:
@@ -112,7 +112,7 @@ async def list_teamspaces(request: Request):
@router.get("/wiki/teamspaces/{teamspace_id}", response_class=HTMLResponse)
async def teamspace_page(teamspace_id: int, request: Request):
def teamspace_page(teamspace_id: int, request: Request):
"""Teamspace detail HTML page — sidebar entry point."""
user = _user(request)
ts = _teamspace_or_404(teamspace_id, user["id"])
@@ -204,7 +204,7 @@ async def create_teamspace(request: Request):
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}")
async def get_teamspace(teamspace_id: int, request: Request):
def get_teamspace(teamspace_id: int, request: Request):
user = _user(request)
ts = _teamspace_or_404(teamspace_id, user["id"])
ts["role"] = wiki.get_teamspace_role(user["id"], teamspace_id)
@@ -213,7 +213,7 @@ async def get_teamspace(teamspace_id: int, request: Request):
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}/members")
async def list_members(teamspace_id: int, request: Request):
def list_members(teamspace_id: int, request: Request):
user = _user(request)
_teamspace_or_404(teamspace_id, user["id"])
with get_conn() as conn:
@@ -251,7 +251,7 @@ async def set_member(teamspace_id: int, member_id: int, request: Request):
@router.delete("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}")
async def remove_member(teamspace_id: int, member_id: int, request: Request):
def remove_member(teamspace_id: int, member_id: int, request: Request):
user = _user(request)
_teamspace_or_404(teamspace_id, user["id"])
if not wiki.can_write_teamspace(user["id"], teamspace_id):
@@ -268,7 +268,7 @@ async def remove_member(teamspace_id: int, member_id: int, request: Request):
# ── verified pages ─────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/pages/{page_id}/verification")
async def get_verification(page_id: int, request: Request):
def get_verification(page_id: int, request: Request):
_user(request)
_page_or_404(page_id)
return {"verification": wiki.verification(page_id)}
@@ -292,7 +292,7 @@ async def verify_page(page_id: int, request: Request):
@router.delete("/api/v2/wiki/pages/{page_id}/verify")
async def unverify_page(page_id: int, request: Request):
def unverify_page(page_id: int, request: Request):
user = _user(request)
_page_or_404(page_id)
if not wiki.unverify_page(page_id):
@@ -302,7 +302,7 @@ async def unverify_page(page_id: int, request: Request):
@router.get("/api/v2/wiki/verified")
async def list_verified(request: Request):
def list_verified(request: Request):
"""Verified (non-expired) pages of a workspace — the ✅ wiki index."""
user = _user(request)
wid = _workspace_id(request)
@@ -331,7 +331,7 @@ async def list_verified(request: Request):
# ── follows ────────────────────────────────────────────────────────────────
@router.post("/api/v2/wiki/pages/{page_id}/follow")
async def follow_page(page_id: int, request: Request):
def follow_page(page_id: int, request: Request):
user = _user(request)
_page_or_404(page_id)
now = wiki.toggle_follow(page_id, user["id"])
@@ -339,7 +339,7 @@ async def follow_page(page_id: int, request: Request):
@router.get("/api/v2/wiki/pages/{page_id}/followers")
async def list_followers(page_id: int, request: Request):
def list_followers(page_id: int, request: Request):
_user(request)
_page_or_404(page_id)
ids = wiki.followers(page_id)
@@ -372,7 +372,7 @@ async def react(comment_id: int, request: Request):
@router.get("/api/v2/wiki/comments/{comment_id}/reactions")
async def list_reactions(comment_id: int, request: Request):
def list_reactions(comment_id: int, request: Request):
_user(request)
return {"comment_id": comment_id, "reactions": wiki.reactions(comment_id)}
@@ -402,7 +402,7 @@ async def create_guest(page_id: int, request: Request):
@router.get("/api/v2/wiki/pages/{page_id}/guests")
async def list_guests(page_id: int, request: Request):
def list_guests(page_id: int, request: Request):
_user(request)
_page_or_404(page_id)
with get_conn() as conn:
@@ -413,7 +413,7 @@ async def list_guests(page_id: int, request: Request):
@router.delete("/api/v2/wiki/guests/{share_id}")
async def revoke_guest(share_id: int, request: Request):
def revoke_guest(share_id: int, request: Request):
user = _user(request)
with get_conn() as conn:
if not conn.execute("SELECT 1 FROM guest_shares WHERE id=?", (share_id,)).fetchone():
@@ -436,7 +436,7 @@ Ask the person who shared it with you for a new link.</p></body></html>"""
@router.get("/g/{token}", response_class=HTMLResponse)
async def guest_page(token: str, request: Request):
def guest_page(token: str, request: Request):
"""Account-less page access (read-only or commenter). 404 if inactive."""
share = wiki.resolve_guest_share(token)
if not share:
@@ -465,7 +465,7 @@ Editing is disabled.</div>
# ── page views ─────────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/pages/{page_id}/views")
async def page_views(page_id: int, request: Request):
def page_views(page_id: int, request: Request):
user = _user(request)
page = _page_or_404(page_id)
if page.get("teamspace_id") and not wiki.can_write_teamspace(user["id"], page["teamspace_id"]):
@@ -476,7 +476,7 @@ async def page_views(page_id: int, request: Request):
# ── wiki home ──────────────────────────────────────────────────────────────
@router.get("/api/v2/wiki/home")
async def wiki_home(request: Request):
def wiki_home(request: Request):
"""Aggregated knowledge home: verified pages + recents + teamspaces."""
user = _user(request)
wid = _workspace_id(request)
@@ -500,7 +500,7 @@ async def wiki_home(request: Request):
@router.post("/api/v2/wiki/verify-expiry-sweep")
async def sweep_expiry(request: Request):
def sweep_expiry(request: Request):
"""Notify verifiers whose ✅ expires within 7 days (idempotent-ish job)."""
user = _user(request)
with get_conn() as conn:
+6 -6
View File
@@ -72,7 +72,7 @@ async def create_worker(request: Request):
@router.get("/api/v2/workers")
async def list_workers(request: Request):
def list_workers(request: Request):
_auth_user(request)
limit, offset = parse_pagination(request)
with get_conn() as conn:
@@ -87,7 +87,7 @@ async def list_workers(request: Request):
@router.get("/api/v2/workers/{worker_id}")
async def get_worker(worker_id: int, request: Request):
def get_worker(worker_id: int, request: Request):
user = _auth_user(request)
with get_conn() as conn:
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
@@ -146,7 +146,7 @@ async def update_worker(worker_id: int, request: Request):
@router.delete("/api/v2/workers/{worker_id}")
async def delete_worker(worker_id: int, request: Request):
def delete_worker(worker_id: int, request: Request):
user = _auth_user(request, require_write=True)
with get_conn() as conn:
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
@@ -186,7 +186,7 @@ async def run_worker_endpoint(worker_id: int, request: Request):
@router.get("/api/v2/workers/{worker_id}/runs")
async def worker_runs(worker_id: int, request: Request):
def worker_runs(worker_id: int, request: Request):
_auth_user(request)
limit, _offset = parse_pagination(request, default_limit=20)
with get_conn() as conn:
@@ -199,7 +199,7 @@ async def worker_runs(worker_id: int, request: Request):
@router.post("/api/v2/workers/{worker_id}/fork")
async def fork_worker_endpoint(worker_id: int, request: Request):
def fork_worker_endpoint(worker_id: int, request: Request):
user = _auth_user(request, require_write=True)
out = worker_service.fork_worker(worker_id, user["id"])
audit_log(user, "worker.fork", "worker", worker_id, "", request)
@@ -207,7 +207,7 @@ async def fork_worker_endpoint(worker_id: int, request: Request):
@router.get("/api/v2/workers-usage")
async def workers_usage(request: Request):
def workers_usage(request: Request):
user = _auth_user(request)
ws_raw = request.query_params.get("workspace_id")
wid = int(ws_raw) if ws_raw and str(ws_raw).isdigit() else None
+19 -19
View File
@@ -79,7 +79,7 @@ async def create_workspace(request: Request):
# ── Members ──
@router.get("/{ws_id}/members")
async def list_members(request: Request, ws_id: int):
def list_members(request: Request, ws_id: int):
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
raise HTTPException(401, "Authentication required")
with get_conn() as conn:
@@ -122,7 +122,7 @@ async def update_member_role(request: Request, ws_id: int, user_id: int):
@router.delete("/{ws_id}/members/{user_id}")
async def remove_member(request: Request, ws_id: int, user_id: int):
def remove_member(request: Request, ws_id: int, user_id: int):
_require_ws_admin(request, ws_id)
with get_conn() as conn:
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id))
@@ -133,7 +133,7 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
# ── Comments ──
@router.get("/pages/{page_id}/comments")
async def list_comments(request: Request, page_id: int):
def list_comments(request: Request, page_id: int):
with get_conn() as conn:
rows = conn.execute(
"SELECT c.*, u.login, u.avatar_url FROM comments c JOIN users u ON c.user_id=u.id WHERE c.page_id=? ORDER BY c.created_at",
@@ -187,7 +187,7 @@ async def update_comment(request: Request, comment_id: int):
# ── Page History ──
@router.get("/pages/{page_id}/history")
async def page_history(request: Request, page_id: int):
def page_history(request: Request, page_id: int):
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM page_history WHERE page_id=? ORDER BY created_at DESC LIMIT 50",
@@ -214,7 +214,7 @@ async def record_history(request: Request, page_id: int):
# ── Favorites ──
@router.get("/favorites")
async def list_favorites(request: Request):
def list_favorites(request: Request):
user = _current_user(request)
uid = user.get("id", 1)
with get_conn() as conn:
@@ -251,7 +251,7 @@ async def add_favorite(request: Request):
@router.delete("/favorites/{fav_id}")
async def remove_favorite(request: Request, fav_id: int):
def remove_favorite(request: Request, fav_id: int):
with get_conn() as conn:
conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,))
conn.commit()
@@ -261,7 +261,7 @@ async def remove_favorite(request: Request, fav_id: int):
# ── Templates ──
@router.get("/templates/database")
async def list_db_templates(request: Request):
def list_db_templates(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall()
return {"templates": [dict(r) for r in rows]}
@@ -296,7 +296,7 @@ async def apply_db_template(request: Request, tid: int):
@router.get("/collections/{collection_id}/templates/page")
async def list_page_templates(request: Request, collection_id: int):
def list_page_templates(request: Request, collection_id: int):
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM page_templates WHERE collection_id=? ORDER BY name", (collection_id,)
@@ -369,7 +369,7 @@ async def update_page_template(request: Request, collection_id: int, tid: int):
@router.delete("/collections/{collection_id}/templates/page/{tid}")
async def delete_page_template(request: Request, collection_id: int, tid: int):
def delete_page_template(request: Request, collection_id: int, tid: int):
"""Delete a page template."""
with get_conn() as conn:
tmpl = conn.execute(
@@ -385,7 +385,7 @@ async def delete_page_template(request: Request, collection_id: int, tid: int):
# ── v4.2.0: Dashboards ──
@router.get("/collections/{collection_id}/dashboards")
async def list_dashboards(request: Request, collection_id: int):
def list_dashboards(request: Request, collection_id: int):
"""List all dashboards for a collection."""
with get_conn() as conn:
rows = conn.execute(
@@ -436,7 +436,7 @@ async def update_dashboard(request: Request, collection_id: int, did: int):
@router.delete("/collections/{collection_id}/dashboards/{did}")
async def delete_dashboard(request: Request, collection_id: int, did: int):
def delete_dashboard(request: Request, collection_id: int, did: int):
"""Delete a dashboard."""
with get_conn() as conn:
dash = conn.execute(
@@ -452,7 +452,7 @@ async def delete_dashboard(request: Request, collection_id: int, did: int):
# ── v4.5.0: Sprints ──
@router.get("/collections/{collection_id}/sprints")
async def list_sprints(request: Request, collection_id: int):
def list_sprints(request: Request, collection_id: int):
"""List all sprints for a collection."""
with get_conn() as conn:
rows = conn.execute(
@@ -528,7 +528,7 @@ async def update_sprint(request: Request, collection_id: int, sid: int):
@router.delete("/collections/{collection_id}/sprints/{sid}")
async def delete_sprint(request: Request, collection_id: int, sid: int):
def delete_sprint(request: Request, collection_id: int, sid: int):
"""Delete a sprint."""
with get_conn() as conn:
sprint = conn.execute(
@@ -572,7 +572,7 @@ async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
@router.delete("/collections/{collection_id}/sprints/{sid}/assign/{page_id}")
async def remove_page_from_sprint(request: Request, collection_id: int, sid: int, page_id: int):
def remove_page_from_sprint(request: Request, collection_id: int, sid: int, page_id: int):
"""Remove a page from a sprint."""
with get_conn() as conn:
existing = conn.execute(
@@ -586,7 +586,7 @@ async def remove_page_from_sprint(request: Request, collection_id: int, sid: int
@router.get("/collections/{collection_id}/sprints/burndown/{sid}")
async def sprint_burndown(request: Request, collection_id: int, sid: int):
def sprint_burndown(request: Request, collection_id: int, sid: int):
"""Calculate burndown data for a sprint."""
with get_conn() as conn:
sprint = conn.execute(
@@ -660,7 +660,7 @@ async def import_csv(request: Request, collection_id: int):
@router.get("/collections/{collection_id}/export/csv")
async def export_csv(request: Request, collection_id: int):
def export_csv(request: Request, collection_id: int):
with get_conn() as conn:
pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)
@@ -690,7 +690,7 @@ async def export_csv(request: Request, collection_id: int):
# ── Webhooks Outbound Management ──
@router.get("/webhooks")
async def list_webhooks(request: Request):
def list_webhooks(request: Request):
_require_admin(request)
with get_conn() as conn:
rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall()
@@ -723,7 +723,7 @@ async def create_webhook(request: Request):
@router.delete("/webhooks/{wh_id}")
async def delete_webhook(request: Request, wh_id: int):
def delete_webhook(request: Request, wh_id: int):
_require_admin(request)
with get_conn() as conn:
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (wh_id,))
@@ -734,7 +734,7 @@ async def delete_webhook(request: Request, wh_id: int):
# ── Public Sharing ──
@router.get("/public/{collection_id}")
async def public_view(request: Request, collection_id: int):
def public_view(request: Request, collection_id: int):
"""Simple public read-only view — no auth required.
A18 : les bases ``restricted``/``private`` (``permission_type``) restent
+13
View File
@@ -355,6 +355,19 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict)
return {"status": "error", "detail": str(exc)}
def run_event_sync(coro, timeout: float = 60.0):
"""A21 phase 2b : exécute une coroutine d'événement depuis un handler synchrone.
Bloque le worker threadpool (jamais la boucle d'event) et attend la fin —
déterministe, exactement ce que faisait l'await avant la conversion des
routes en `def`.
ponytail: les clients httpx des services sont créés à chaque appel (aucun
lien de boucle) ; si un jour un client/queue est lié à la boucle de l'app,
passer à `asyncio.run_coroutine_threadsafe` + boucle capturée au lifespan.
"""
return asyncio.run(asyncio.wait_for(coro, timeout))
async def fire_event(event: str, payload: dict):
"""Dispatch an event to outbound webhooks and matching automations."""
# v7.3.0: page.updated → in-app notification to followers (throttled).
+1 -166
View File
@@ -15,172 +15,7 @@
open, openTab, peek, folder, rename, setIcon, duplicate, link, download,
copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove
############################################################################}
<script data-cfasync="false">
/* ═════════════════════════════════════════════════════════════════════
fdCtx — store Alpine UNIQUE du menu contextuel partagé.
Enregistré dans 'alpine:init' avec garde d'exécution : que la page
inclue ce partial dans base.html ou directement, le js ne s'exécute
qu'une seule fois. Chaque page, à l'ouverture du menu, positionne :
fdCtx.openMenu(evt, node, pageHash, handlers)
─────────────────────────────────────────────────────────────────── */
(function () {
if (window.__fdCtxMenuRegistered) return;
window.__fdCtxMenuRegistered = true;
// Sur un chargement complet de page, ce script inline s'exécute AVANT
// alpine.min.js (defer) → Alpine n'existe pas encore : on attend 'alpine:init'.
// Sur une navigation partielle (fdNavigate → htmx), Alpine est déjà démarré et
// 'alpine:init' ne sera plus jamais émis → on enregistre le store tout de suite,
// sinon le menu contextuel reste mort jusqu'au prochain chargement complet.
function registerFdCtx() {
if (!window.Alpine) return;
if (window.Alpine.__fdCtxStore) return;
window.Alpine.__fdCtxStore = true;
Alpine.store('fdCtx', {
open: false, x: 0, y: 0, node: null, page: null,
maxH: 0, _cx: 0, _cy: 0, _ro: null,
handlers: {},
/* Tags (workspace) */
availTags: [], tagAdding: false, tagExistingOpen: false,
newTagColor: '#787774',
tagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
/* Icon picker */
iconOpen: false,
iconChoices: ['📄','📝','📕','📁','⭐','🔖','📌','✅','💡','🔥','🚀','🎯','📊','🗓️','🔗','🧩','📎','🎨','🐛','⚙️','❤️','👍','✨','🏷️','🗒️','📚'],
/* Positionne le menu à l'écran et expose les handlers de la page.
⚠️ Nom : openMenu (et PAS open) — « open » est réservé au booléen
d'état x-show. Une collision ici rendrait le menu PERMANENT. */
openMenu(ev, node, pageHash, handlers) {
handlers = handlers || {};
this.node = node;
this.page = pageHash;
this.handlers = handlers;
this.tagAdding = false;
this.tagExistingOpen = false;
this.iconOpen = false;
this.newTagColor = (node && node.tags && node.tags[0] && node.tags[0].color) || '#787774';
var cx = (ev && ev.clientX) || 0;
var cy = (ev && ev.clientY) || 0;
this._cx = cx;
this._cy = cy;
this.x = Math.max(8, Math.min(cx, window.innerWidth - 240));
this.y = Math.max(8, Math.min(cy, window.innerHeight - 48));
this.open = true;
var self = this;
/* Mesure la taille réelle du menu (une fois rendu) puis le replace
pour qu'il reste TOUJOURS entièrement visible dans le viewport.
Un ResizeObserver relance le placement à chaque fois que le menu
change de taille (ouverture d'un sous-menu « tag », icônes, …),
sinon il grandissait vers le bas et sortait de l'écran. */
var after = function () {
self._place();
var el = document.querySelector('.fd-ctx-menu');
if (el && window.ResizeObserver) {
if (self._ro) { try { self._ro.disconnect(); } catch (e) {} }
self._ro = new ResizeObserver(function () { self._place(); });
self._ro.observe(el);
}
};
if (window.Alpine && window.Alpine.nextTick) window.Alpine.nextTick(after);
else setTimeout(after, 0);
},
/* Reclasse le menu dans le viewport et limite sa hauteur à l'espace
disponible sous son ancre (le contenu déborde en scroll interne). */
_place() {
var el = document.querySelector('.fd-ctx-menu');
if (!el) return;
var prev = el.style.maxHeight;
el.style.maxHeight = 'none';
var w = el.offsetWidth || 240;
var h = el.offsetHeight || 320;
el.style.maxHeight = prev || '';
var vw = window.innerWidth, vh = window.innerHeight;
var cx = (this._cx == null ? this.x : this._cx);
var cy = (this._cy == null ? this.y : this._cy);
var nx = cx;
if (nx + w > vw - 8) nx = vw - w - 8;
nx = Math.max(8, nx);
var ny = cy;
if (ny + h > vh - 8) {
if (cy - h >= 8) ny = cy - h;
else ny = Math.max(8, vh - h - 8);
}
this.x = nx;
this.y = ny;
this.maxH = Math.max(120, vh - ny - 8);
},
close() {
if (this._ro) { try { this._ro.disconnect(); } catch (e) {} this._ro = null; }
this.open = false;
this.node = null;
this.handlers = {};
this.tagAdding = false;
this.tagExistingOpen = false;
this.iconOpen = false;
},
/* Un item est-il disponible pour CETTE page ? (union vs capacités) */
has(key) { return typeof this.handlers[key] === 'function'; },
/* Exécute l'action → handler fourni par la page courante. */
run(key) {
if (!this.node) { this.close(); return; }
var fn = this.handlers[key];
if (fn) try { fn(this.node); } catch (e) { console.error('fdCtx.run', e); }
this.close();
},
/* ── Tags ── */
avail() { return this.availTags || []; },
setAvail(a) { this.availTags = a || []; },
removeTag(id) {
var fn = this.handlers.tagRemove;
if (fn) try { fn(id); } catch (e) { console.error('fdCtx.removeTag', e); }
},
addExistingTag(t) {
var fn = this.handlers.tagExisting;
if (fn) try { fn(t); } catch (e) { console.error('fdCtx.addExistingTag', e); }
this.tagExistingOpen = false;
},
addNewTag(name, color) {
name = (name || '').trim();
if (!name) return;
var fn = this.handlers.tagAdd;
if (fn) try { fn(name, color || this.newTagColor); } catch (e) { console.error('fdCtx.addNewTag', e); }
this.tagAdding = false;
},
/* ── Icon picker ── */
setIcon(icon) {
icon = (icon || '').trim();
var fn = this.handlers.setIcon;
if (fn) try { fn(icon); } catch (e) { console.error('fdCtx.setIcon', e); }
this.close();
},
openIconPicker() {
var fn = this.handlers.setIcon;
if (!fn) return;
if (!window.FDIconPicker) return;
window.FDIconPicker.openFor({
x: this.x,
y: this.y,
onPick: fn,
onRemove: function () { fn(''); }
});
this.close();
},
});
}
if (window.Alpine) {
registerFdCtx();
} else {
document.addEventListener('alpine:init', registerFdCtx);
}
})();
</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/_ctx_menu.js?v={{ asset_version }}"></script>
<style>
.fd-ctx-menu{position:fixed !important;top:0;left:0;min-width:230px;max-width:280px;max-height:calc(100vh - 16px);overflow-y:auto;z-index:2000;padding:4px;}
+1 -1
View File
@@ -106,7 +106,7 @@
.db-list-title{flex:1;min-width:120px}
.db-list-cell{color:var(--text-secondary);font-size:12px;min-width:110px}
</style>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function() {
'use strict';
+2 -2
View File
@@ -47,7 +47,7 @@
</button>
{% endif %}
<script type="application/json" id="fd-breadcrumb-data">{{ {"workspace_id": nav_workspace_id|default(0), "crumbs": ns.items}|tojson }}</script>
<script type="application/json" id="fd-breadcrumb-data" nonce="{{ csp_nonce() }}">{{ {"workspace_id": nav_workspace_id|default(0), "crumbs": ns.items}|tojson }}</script>
<div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()"
@mouseleave="dragCloseTimer()">
@@ -142,7 +142,7 @@
</div>
</header>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdBreadcrumbRegistered) return;
if (window.Alpine) window.Alpine.__fdBreadcrumbRegistered = true;
+2 -299
View File
@@ -10,306 +10,9 @@
############################################################################}
{% set picker_icons = ['folder','file','calendar','clock','star','bot','users','globe','lock','book','check-square','trash','help-circle','settings','refresh','log-out','message-square','home','search','link','plus','bell','image','download','list','bar-chart','grid','align-left','corner-down-right','copy','key','inbox','edit','eye','share','x','paperclip','external-link','sparkles','lightbulb','tag','file-text','save','upload','trending-up','zap','alert-triangle','user'] %}
<script data-cfasync="false">
/* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */
(function () {
var FD_ICONS = {
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
'file': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
'bot': '<rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v3"/><line x1="12" y1="20" x2="12" y2="24"/><circle cx="8" cy="13" r="1"/><circle cx="16" cy="13" r="1"/>',
'users': '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
'book': '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>',
'check-square': '<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
'help-circle': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
'refresh': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
'message-square': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
'bell': '<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
'image': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
'chevron-left': '<polyline points="15 18 9 12 15 6"/>',
'chevron-right': '<polyline points="9 18 15 12 9 6"/>',
'list': '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
'bar-chart': '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
'grid': '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
'align-left': '<line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="17" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/>',
'corner-down-right': '<polyline points="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/>',
'copy': '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
'key': '<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
'inbox': '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
'edit': '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>',
'eye-off': '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/>',
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
'share': '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>',
'more-horizontal': '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
'paperclip': '<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
'sparkles': '<path d="M12 3l1.9 5.8 6.1.5-4.9 3.6 1.7 5.8-4.8-3.5-4.8 3.5 1.7-5.8-4.9-3.6 6.1-.5z"/>',
'lightbulb': '<path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/>',
'tag': '<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>',
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
'save': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
'trending-up': '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'user': '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>'
};
window.FD_ICONS = FD_ICONS;
window.fd_icon = function (name, size) {
size = size || 18;
var inner = FD_ICONS[name];
if (inner) return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + inner + '</svg>';
return (window.getSvgIcon ? getSvgIcon(name, size) : '');
};
/* Render a page_icon value: image URL, known icon name, or emoji text. */
window.fdIconHtml = function (value, size) {
size = size || 16;
var v = (value == null ? '' : String(value)).trim();
if (!v) return '';
if (v.charAt(0) === '/' || v.slice(0, 4) === 'http') {
return '<img src="' + v.replace(/"/g, '&quot;') + '" alt="" style="width:' + size + 'px;height:' + size + 'px;object-fit:contain;vertical-align:middle;display:inline-block;">';
}
if (FD_ICONS[v] || (window.getSvgIcon && getSvgIcon(v, size))) return window.fd_icon(v, size);
return v;
};
})();
</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/_icon_picker_1.js?v={{ asset_version }}"></script>
<script data-cfasync="false">
(function () {
if (window.__fdIconPickerRegistered) return;
window.__fdIconPickerRegistered = true;
var TONES = ['', '\u{1F3FB}', '\u{1F3FC}', '\u{1F3FD}', '\u{1F3FE}', '\u{1F3FF}'];
var TONEABLE = {};
['👋','🤚','🖐️','✋','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘','🤙','👈','👉','👆','👇','☝️','👍','👎','✊','👊','🤛','🤜','👏','🙌','🫶','👐','🤲','🤝','🙏','💪','🦵','🦶','👂','👃','👶','🧒','👦','👧','🧑','👨','👩','🧓','👴','👵','🙍','🙎','🙅','🙆','💁','🙋','🧏','🙇','🤦','🤷','👮','🕵️','💂','👷','🤴','👸','👳','👲','🧕','🤵','👰','🤰','🤱','👼','🎅','🤶','🦸','🦹','🧙','🧚','🧛','🧜','🧝','💆','💇','🚶','🧍','🧎','🏃','💃','🕺','👯','🧖','🧗','🏇','⛷️','🏂','🏌️','🏄','🚣','🏊','⛹️','🏋️','🚴','🚵','🤸','🤼','🤽','🤾','🤹','🧘','🛀','🛌'].forEach(function (e) { TONEABLE[e] = true; });
var CATS = [
{ id: 'people', label: 'People', items: [
['😀','grinning face smile happy'],['😃','smiley happy'],['😄','smile laugh happy'],['😁','grin happy'],['😆','laughing happy'],['😅','sweat smile'],['🤣','rofl rolling laugh'],['😂','joy tears laugh'],['🙂','slight smile'],['🙃','upside down'],['😉','wink'],['😊','blush smile happy'],['😇','innocent halo angel'],['🥰','love hearts'],['😍','heart eyes love'],['🤩','star struck wow'],['😘','kiss love'],['😗','kissing'],['😚','kissing'],['😙','kissing'],['🥲','tear smile happy'],['😋','yum tasty'],['😛','tongue'],['😜','wink tongue'],['🤪','crazy zany'],['😝','tongue'],['🤑','money rich'],['🤗','hug'],['🤭','giggle'],['🤫','shush quiet'],['🤔','thinking'],['🤐','zip quiet'],['🤨','raised eyebrow'],['😐','neutral'],['😑','expressionless'],['😶','no mouth'],['😏','smirk'],['😒','unamused'],['🙄','roll eyes'],['😬','grimace'],['🤥','lying'],['😌','relieved'],['😔','pensive sad'],['😪','sleepy'],['🤤','drool'],['😴','sleeping'],['😷','mask sick'],['🤒','sick thermometer'],['🤕','hurt bandage'],['🤢','nauseated'],['🤮','vomit'],['🤧','sneeze'],['🥵','hot'],['🥶','cold'],['🥴','woozy'],['😵','dizzy'],['🤯','mind blown'],['🤠','cowboy'],['🥳','party'],['🥺','pleading'],['😎','cool sunglasses'],['🤓','nerd'],['🧐','monocle'],['😕','confused'],['😟','worried'],['🙁','frown'],['☹️','frown sad'],['😮','surprised'],['😯','hushed'],['😲','astonished'],['😳','flushed'],['😨','fearful'],['😰','anxious'],['😥','sad'],['😢','cry'],['😭','sob cry'],['😱','scream fear'],['😖','confounded'],['😣','persevere'],['😞','disappointed'],['😓','sweat'],['😩','weary'],['😫','tired'],['🥱','yawn'],['😤','triumph'],['😡','angry'],['😠','rage'],['🤬','cursing'],['😈','devil'],['👿','imp'],['💀','skull'],['💩','poop'],['🤡','clown'],['👻','ghost'],['👽','alien'],['🤖','robot'],['😺','cat'],['🙈','monkey see'],['🙉','monkey hear'],['🙊','monkey speak'],['👋','wave hand'],['🤚','raised hand'],['🖐️','hand'],['✋','raised hand'],['🖖','vulcan'],['👌','ok'],['🤌','pinched'],['🤏','pinch'],['✌️','peace'],['🤞','cross fingers luck'],['🤟','love you'],['🤘','rock'],['🤙','call me'],['👈','point left'],['👉','point right'],['👆','point up'],['👇','point down'],['☝️','point up'],['👍','thumbs up like'],['👎','thumbs down dislike'],['✊','fist'],['👊','fist bump'],['🤛','fist'],['🤜','fist'],['👏','clap'],['🙌','raise hands celebrate'],['🫶','heart hands'],['👐','open hands'],['🤲','palms'],['🤝','handshake'],['🙏','pray thanks'],['💪','muscle strong'],['👂','ear'],['👃','nose'],['👀','eyes look'],['👁️','eye'],['🧠','brain'],['👶','baby'],['🧒','child'],['👦','boy'],['👧','girl'],['🧑','person'],['👨','man'],['👩','woman'],['🧓','older person'],['👴','old man'],['👵','old woman'],['👮','police'],['🕵️','detective'],['💂','guard'],['👷','worker'],['🤴','prince'],['👸','princess'],['👳','turban'],['🧕','hijab'],['🤵','tuxedo'],['👰','bride'],['🤰','pregnant'],['🤱','breastfeeding'],['👼','angel'],['🎅','santa'],['🤶','mrs claus'],['🦸','superhero'],['🦹','supervillain'],['🧙','mage wizard'],['🧚','fairy'],['🧛','vampire'],['🧜','mermaid'],['🧝','elf'],['💆','massage'],['💇','haircut'],['🚶','walk'],['🏃','run'],['💃','dance'],['🕺','dance'],['👯','people dancing'],['🧗','climb'],['🏇','horse race'],['🏂','snowboard'],['🏄','surf'],['🚣','row'],['🏊','swim'],['🚴','bike'],['🚵','mountain bike'],['🤸','cartwheel'],['🤼','wrestle'],['🤽','water polo'],['🤾','handball'],['🤹','juggle'],['🧘','meditate yoga'],['🛌','sleeping bed'],['💋','kiss mark'],['💌','love letter'],['❤️','heart love red'],['🧡','orange heart'],['💛','yellow heart'],['💚','green heart'],['💙','blue heart'],['💜','purple heart'],['🖤','black heart'],['🤍','white heart'],['🤎','brown heart'],['💔','broken heart'],['❣️','heart exclamation'],['💕','two hearts'],['💞','revolving hearts'],['💓','beating heart'],['💗','growing heart'],['💖','sparkling heart'],['💘','cupid heart'],['💝','heart gift'],['✨','sparkles'],['⭐','star'],['🌟','glowing star'],['💫','dizzy star'],['💥','boom'],['💯','hundred perfect']
]},
{ id: 'nature', label: 'Nature', items: [
['🐶','dog'],['🐱','cat'],['🐭','mouse'],['🐹','hamster'],['🐰','rabbit'],['🦊','fox'],['🐻','bear'],['🐼','panda'],['🐨','koala'],['🐯','tiger'],['🦁','lion'],['🐮','cow'],['🐷','pig'],['🐸','frog'],['🐵','monkey'],['🐔','chicken'],['🐧','penguin'],['🐦','bird'],['🐤','chick'],['🦆','duck'],['🦅','eagle'],['🦉','owl'],['🦇','bat'],['🐺','wolf'],['🐗','boar'],['🐴','horse'],['🦄','unicorn'],['🐝','bee'],['🐛','bug'],['🦋','butterfly'],['🐌','snail'],['🐞','ladybug'],['🐜','ant'],['🦗','cricket'],['🕷️','spider'],['🦂','scorpion'],['🐢','turtle'],['🐍','snake'],['🦎','lizard'],['🦖','dinosaur'],['🐙','octopus'],['🦑','squid'],['🦐','shrimp'],['🦀','crab'],['🐡','fish'],['🐠','fish'],['🐟','fish'],['🐬','dolphin'],['🐳','whale'],['🦈','shark'],['🐊','crocodile'],['🌵','cactus'],['🎄','tree christmas'],['🌲','tree evergreen'],['🌳','tree'],['🌴','palm tree'],['🌱','seedling plant'],['🌿','herb leaf'],['☘️','shamrock'],['🍀','clover luck'],['🎍','bamboo'],['🌾','wheat'],['🌷','tulip flower'],['🌹','rose flower'],['🌺','hibiscus flower'],['🌸','cherry blossom'],['🌼','flower'],['🌻','sunflower'],['🌞','sun'],['🌝','moon'],['🌚','moon'],['🌙','moon crescent'],['⭐','star'],['🌟','star'],['☀️','sun sunny'],['⛅','cloud sun'],['☁️','cloud'],['🌧️','rain'],['⛈️','storm'],['🌩️','lightning'],['❄️','snowflake'],['☃️','snowman'],['⛄','snowman'],['🔥','fire'],['💧','droplet water'],['🌊','wave water'],['🌈','rainbow'],['🌍','earth globe'],['🌎','earth globe'],['🌏','earth globe']
]},
{ id: 'food', label: 'Food', items: [
['🍏','apple green'],['🍎','apple red'],['🍐','pear'],['🍊','orange tangerine'],['🍋','lemon'],['🍌','banana'],['🍉','watermelon'],['🍇','grapes'],['🍓','strawberry'],['🫐','blueberry'],['🍈','melon'],['🍒','cherry'],['🍑','peach'],['🥭','mango'],['🍍','pineapple'],['🥥','coconut'],['🥝','kiwi'],['🍅','tomato'],['🍆','eggplant'],['🥑','avocado'],['🥦','broccoli'],['🥬','lettuce'],['🥒','cucumber'],['🌶️','pepper hot'],['🌽','corn'],['🥕','carrot'],['🧄','garlic'],['🧅','onion'],['🥔','potato'],['🍠','sweet potato'],['🥐','croissant'],['🥯','bagel'],['🍞','bread'],['🥖','baguette'],['🧀','cheese'],['🥚','egg'],['🍳','cooking egg'],['🥓','bacon'],['🥩','meat steak'],['🍗','chicken leg'],['🍖','meat'],['🌭','hot dog'],['🍔','burger'],['🍟','fries'],['🍕','pizza'],['🥪','sandwich'],['🥙','pita'],['🌮','taco'],['🌯','burrito'],['🥗','salad'],['🍝','pasta spaghetti'],['🍜','ramen noodles'],['🍲','stew'],['🍛','curry rice'],['🍣','sushi'],['🍱','bento'],['🥟','dumpling'],['🍤','shrimp fried'],['🍙','rice ball'],['🍚','rice'],['🍘','rice cracker'],['🍥','fish cake'],['🥠','fortune cookie'],['🍢','oden'],['🍡','dango'],['🍧','shaved ice'],['🍨','ice cream'],['🍦','ice cream'],['🥧','pie'],['🧁','cupcake'],['🍰','cake'],['🎂','birthday cake'],['🍮','custard'],['🍭','lollipop'],['🍬','candy'],['🍫','chocolate'],['🍿','popcorn'],['🍩','donut'],['🍪','cookie'],['☕','coffee'],['🍵','tea'],['🧃','juice'],['🥤','cup drink'],['🍺','beer'],['🍻','beers cheers'],['🥂','champagne'],['🍷','wine'],['🥃','whiskey'],['🍸','cocktail'],['🍹','tropical drink'],['🧉','mate'],['🍾','champagne bottle']
]},
{ id: 'activity', label: 'Activity', items: [
['⚽','soccer football'],['🏀','basketball'],['🏈','football'],['⚾','baseball'],['🥎','softball'],['🎾','tennis'],['🏐','volleyball'],['🏉','rugby'],['🥏','frisbee'],['🎱','pool billiards'],['🪀','yo-yo'],['🏓','ping pong'],['🏸','badminton'],['🏒','hockey'],['🏑','field hockey'],['🥍','lacrosse'],['🏏','cricket'],['🥅','goal'],['⛳','golf'],['🏹','archery'],['🎣','fishing'],['🥊','boxing'],['🥋','martial arts'],['🎽','running shirt'],['🛹','skateboard'],['🛼','roller skate'],['🛷','sled'],['⛸️','ice skate'],['🥌','curling'],['🎿','ski'],['⛷️','ski'],['🏂','snowboard'],['🏋️','weight lift'],['🤼','wrestle'],['🤸','cartwheel'],['⛹️','basketball'],['🤺','fencing'],['🤾','handball'],['🏌️','golf'],['🏇','horse race'],['🧘','yoga meditate'],['🏄','surf'],['🏊','swim'],['🤽','water polo'],['🚣','row'],['🧗','climb'],['🚴','bike'],['🚵','mountain bike'],['🎪','circus'],['🎭','theater masks'],['🎨','art palette'],['🎬','clapper film'],['🎤','microphone'],['🎧','headphones'],['🎼','music score'],['🎹','piano'],['🥁','drum'],['🎷','saxophone'],['🎺','trumpet'],['🎸','guitar'],['🪕','banjo'],['🎻','violin'],['🎲','dice random game'],['♟️','chess'],['🎯','target dart'],['🎳','bowling'],['🎮','game controller'],['🎰','slot machine'],['🧩','puzzle'],['🏆','trophy win'],['🥇','gold medal first'],['🥈','silver medal'],['🥉','bronze medal'],['🏅','medal'],['🎖️','military medal'],['🎗️','reminder ribbon'],['🎫','ticket'],['🎟️','tickets'],['🎁','gift present'],['🎉','party popper celebrate'],['🎊','confetti'],['🎈','balloon'],['🎂','cake birthday'],['🎃','pumpkin halloween'],['🎄','christmas tree'],['🎆','fireworks'],['🎇','fireworks'],['🧨','firecracker'],['✨','sparkles'],['🎓','graduation cap']
]},
{ id: 'travel', label: 'Travel', items: [
['🚗','car'],['🚕','taxi'],['🚙','car suv'],['🚌','bus'],['🚎','trolley bus'],['🏎️','race car'],['🚓','police car'],['🚑','ambulance'],['🚒','fire truck'],['🚐','van'],['🛻','pickup truck'],['🚚','truck'],['🚛','truck'],['🚜','tractor'],['🏍️','motorcycle'],['🛵','scooter'],['🚲','bicycle'],['🛴','kick scooter'],['🚨','police light'],['🚔','police car'],['🚍','bus'],['🚝','monorail'],['🚄','train'],['🚅','train bullet'],['🚈','train'],['🚂','locomotive train'],['🚆','train'],['🚇','metro subway'],['🚊','tram'],['🚉','station'],['✈️','airplane flight'],['🛫','airplane takeoff'],['🛬','airplane landing'],['🛩️','plane'],['💺','seat'],['🚁','helicopter'],['🛸','ufo'],['🚀','rocket launch'],['🛰️','satellite'],['🚢','ship'],['⛵','sailboat'],['🛥️','motor boat'],['🚤','speedboat'],['⛴️','ferry'],['🛳️','cruise ship'],['⚓','anchor'],['🚧','construction'],['⛽','fuel gas'],['🚏','bus stop'],['🗺️','map world'],['🗿','moai'],['🗽','statue liberty'],['🗼','tokyo tower'],['🏰','castle'],['🏯','castle japanese'],['🏟️','stadium'],['🎡','ferris wheel'],['🎢','roller coaster'],['🎠','carousel'],['⛲','fountain'],['⛱️','beach umbrella'],['🏖️','beach'],['🏝️','island'],['🏜️','desert'],['🌋','volcano'],['⛰️','mountain'],['🏔️','snow mountain'],['🗻','mount fuji'],['🏕️','camping'],['🏠','house home'],['🏡','house garden'],['🏢','office building'],['🏥','hospital'],['🏦','bank'],['🏨','hotel'],['🏫','school'],['🏭','factory'],['🏛️','classical building'],['⛪','church'],['🕌','mosque'],['🕍','synagogue'],['🛕','temple'],['🗼','tower'],['🌆','city sunset'],['🌃','night city'],['🌉','bridge night'],['🌌','milky way'],['🌠','shooting star'],['🌅','sunrise'],['🌄','sunrise mountain'],['🌇','sunset city']
]},
{ id: 'objects', label: 'Objects', items: [
['⌚','watch'],['📱','phone mobile'],['💻','laptop computer'],['⌨️','keyboard'],['🖥️','desktop computer'],['🖨️','printer'],['🖱️','mouse computer'],['💽','minidisc'],['💾','floppy disk save'],['💿','cd disk'],['📀','dvd'],['🧮','abacus'],['🎥','movie camera'],['🎞️','film frames'],['📽️','projector'],['📺','tv television'],['📷','camera'],['📸','camera flash'],['📹','video camera'],['📼','videocassette'],['🔍','magnifying search'],['🔎','magnifying search'],['🕯️','candle'],['💡','bulb idea'],['🔦','flashlight'],['🏮','lantern'],['🪔','lamp diya'],['📔','notebook'],['📕','book closed'],['📖','book open'],['📗','book green'],['📘','book blue'],['📙','book orange'],['📚','books library'],['📓','notebook'],['📒','ledger'],['📃','page'],['📜','scroll'],['📄','page document'],['📰','newspaper'],['🗞️','newspaper'],['📑','bookmark tabs'],['🔖','bookmark'],['🏷️','label tag'],['💰','money bag'],['🪙','coin'],['💴','yen'],['💵','dollar'],['💶','euro'],['💷','pound'],['💸','money wings'],['💳','credit card'],['🧾','receipt'],['✉️','envelope mail'],['📧','email'],['📨','envelope'],['📩','envelope'],['📤','outbox'],['📥','inbox'],['📦','package box'],['📫','mailbox'],['📪','mailbox'],['📬','mailbox'],['📭','mailbox'],['📮','postbox'],['🗳️','ballot box'],['✏️','pencil'],['✒️','pen nib'],['🖋️','pen'],['🖊️','pen'],['🖌️','paintbrush'],['🖍️','crayon'],['📝','memo note write'],['💼','briefcase work'],['📁','folder'],['📂','folder open'],['🗂️','card index'],['📅','calendar date'],['📆','calendar'],['🗒️','notepad'],['🗓️','calendar'],['📇','card index'],['📈','chart up trending'],['📉','chart down'],['📊','bar chart stats'],['📋','clipboard'],['📌','pushpin'],['📍','pin location'],['📎','paperclip attach'],['🖇️','paperclips'],['📏','ruler'],['📐','triangle ruler'],['✂️','scissors cut'],['🗃️','file box'],['🗄️','file cabinet'],['🗑️','trash waste'],['🔒','lock locked'],['🔓','lock open'],['🔑','key'],['🗝️','old key'],['🔨','hammer'],['🪓','axe'],['⛏️','pick'],['⚒️','tools'],['🛠️','tools'],['🔧','wrench'],['🔩','bolt nut'],['⚙️','gear settings'],['🧰','toolbox'],['🧲','magnet'],['🔫','water gun'],['💣','bomb'],['🧪','test tube science'],['🧫','petri dish'],['🧬','dna'],['🔬','microscope'],['🔭','telescope'],['📡','satellite antenna'],['💉','syringe'],['💊','pill medicine'],['🩹','bandage'],['🩺','stethoscope'],['🚪','door'],['🛏️','bed'],['🛋️','couch'],['🪑','chair'],['🚽','toilet'],['🚿','shower'],['🛁','bathtub'],['🧴','lotion'],['🧷','safety pin'],['🧹','broom'],['🧺','basket'],['🧻','toilet paper'],['🧼','soap'],['🪒','razor'],['🧽','sponge'],['🧯','extinguisher'],['🛒','cart shopping'],['🚬','cigarette'],['⚰️','coffin'],['🪦','headstone'],['⚱️','urn']
]},
{ id: 'symbols', label: 'Symbols', items: [
['❤️','heart love'],['🧡','orange heart'],['💛','yellow heart'],['💚','green heart'],['💙','blue heart'],['💜','purple heart'],['🖤','black heart'],['🤍','white heart'],['🤎','brown heart'],['💔','broken heart'],['❣️','heart'],['💕','hearts'],['💞','hearts'],['💓','heartbeat'],['💗','heart'],['💖','heart'],['💘','heart arrow'],['💝','heart gift'],['💟','heart decoration'],['☮️','peace'],['✝️','cross'],['☪️','star crescent'],['🕉️','om'],['☸️','dharma'],['✡️','star david'],['🔯','star'],['🕎','menorah'],['☯️','yin yang'],['☦️','orthodox cross'],['🛐','worship'],['⛎','ophiuchus'],['♈','aries'],['♉','taurus'],['♊','gemini'],['♋','cancer'],['♌','leo'],['♍','virgo'],['♎','libra'],['♏','scorpio'],['♐','sagittarius'],['♑','capricorn'],['♒','aquarius'],['♓','pisces'],['🆔','id'],['⚛️','atom'],['🉑','accept'],['☢️','radioactive'],['☣️','biohazard'],['📴','phone off'],['📳','vibrate'],['📵','no phone'],['🚭','no smoking'],['❗','exclamation'],['❕','exclamation'],['❓','question'],['❔','question'],['‼️','double exclamation'],['⁉️','exclamation question'],['🔅','dim'],['🔆','bright'],['〽️','part alternation'],['⚠️','warning'],['🚸','children crossing'],['🔱','trident'],['⚜️','fleur de lis'],['🔰','beginner'],['♻️','recycle'],['✅','check done'],['🈯','reserved'],['💹','chart yen'],['❇️','sparkle'],['✳️','asterisk'],['❎','cross mark'],['🌐','globe'],['💠','diamond'],['Ⓜ️','m'],['🌀','cyclone'],['💤','zzz sleep'],['🏧','atm'],['🚾','wc'],['♿','wheelchair'],['🅿️','parking'],['🈳','vacancy'],['🈂️','sa'],['🛂','passport control'],['🛃','customs'],['🛄','baggage'],['🛅','left luggage'],['🚹','men'],['🚺','women'],['🚼','baby'],['🚻','restroom'],['🚮','litter'],['🎦','cinema'],['📶','signal'],['🈁','here'],['🔣','symbols'],['ℹ️','info'],['🔤','abc'],['🔡','abcd'],['🔠','abcd'],['🆖','ng'],['🆗','ok'],['🆙','up'],['🆒','cool'],['🆕','new'],['🆓','free'],['0️⃣','zero'],['1️⃣','one'],['2️⃣','two'],['3️⃣','three'],['4️⃣','four'],['5️⃣','five'],['6️⃣','six'],['7️⃣','seven'],['8️⃣','eight'],['9️⃣','nine'],['🔟','ten'],['🔢','numbers'],['#️⃣','hash'],['*️⃣','asterisk'],['⏏️','eject'],['▶️','play'],['⏸️','pause'],['⏹️','stop'],['⏺️','record'],['⏭️','next'],['⏮️','previous'],['⏩','fast forward'],['⏪','rewind'],['⏫','up'],['⏬','down'],['◀️','left'],['🔼','up'],['🔽','down'],['➡️','right'],['⬅️','left'],['⬆️','up'],['⬇️','down'],['↗️','up right'],['↘️','down right'],['↙️','down left'],['↖️','up left'],['↕️','up down'],['↔️','left right'],['↩️','return'],['↪️','redo'],['⤴️','up'],['⤵️','down'],['🔀','shuffle'],['🔁','repeat'],['🔂','repeat one'],['🔄','refresh'],['🔃','refresh'],['🎵','music note'],['🎶','music notes'],['➕','plus'],['➖','minus'],['➗','divide'],['✖️','multiply'],['♾️','infinity'],['💲','dollar'],['💱','currency'],['™️','tm'],['©️','copyright'],['®️','registered'],['〰️','wavy'],['➰','curly loop'],['➿','double loop'],['🔚','end'],['🔙','back'],['🔛','on'],['🔝','top'],['🔜','soon'],['✔️','check'],['☑️','checkbox'],['🔘','radio'],['🔴','red circle'],['🟠','orange circle'],['🟡','yellow circle'],['🟢','green circle'],['🔵','blue circle'],['🟣','purple circle'],['⚫','black circle'],['⚪','white circle'],['🟤','brown circle'],['🔺','red triangle'],['🔻','red triangle'],['🔸','orange diamond'],['🔹','blue diamond'],['🔶','orange diamond'],['🔷','blue diamond'],['🔳','white square'],['🔲','black square'],['▪️','black square'],['▫️','white square'],['◾','black square'],['◽','white square'],['◼️','black square'],['◻️','white square'],['🟥','red square'],['🟧','orange square'],['🟨','yellow square'],['🟩','green square'],['🟦','blue square'],['🟪','purple square'],['⬛','black square'],['⬜','white square'],['🟫','brown square'],['🔈','speaker'],['🔇','mute'],['🔉','speaker'],['🔊','speaker loud'],['🔔','bell'],['🔕','bell off'],['📣','megaphone'],['📢','loudspeaker'],['💬','speech bubble'],['💭','thought bubble'],['🗯️','anger bubble'],['♠️','spade'],['♣️','club'],['♥️','heart suit'],['♦️','diamond suit'],['🃏','joker'],['🎴','flower cards'],['🀄','mahjong']
]},
{ id: 'flags', label: 'Flags', items: [
['🏁','chequered flag finish'],['🚩','triangular flag'],['🎌','crossed flags'],['🏴','black flag'],['🏳️','white flag'],['🏳️‍🌈','rainbow flag pride'],['🏴‍☠️','pirate flag'],['🇺🇸','usa united states'],['🇬🇧','uk united kingdom'],['🇫🇷','france french'],['🇩🇪','germany german'],['🇪🇸','spain spanish'],['🇮🇹','italy italian'],['🇵🇹','portugal'],['🇳🇱','netherlands'],['🇧🇪','belgium'],['🇨🇭','switzerland'],['🇦🇹','austria'],['🇸🇪','sweden'],['🇳🇴','norway'],['🇩🇰','denmark'],['🇫🇮','finland'],['🇮🇪','ireland'],['🇵🇱','poland'],['🇬🇷','greece'],['🇷🇺','russia'],['🇺🇦','ukraine'],['🇹🇷','turkey'],['🇨🇦','canada'],['🇲🇽','mexico'],['🇧🇷','brazil'],['🇦🇷','argentina'],['🇨🇱','chile'],['🇨🇴','colombia'],['🇨🇳','china chinese'],['🇯🇵','japan japanese'],['🇰🇷','korea south'],['🇮🇳','india'],['🇦🇺','australia'],['🇳🇿','new zealand'],['🇿🇦','south africa'],['🇪🇬','egypt'],['🇲🇦','morocco'],['🇳🇬','nigeria'],['🇰🇪','kenya'],['🇸🇦','saudi arabia'],['🇦🇪','uae emirates'],['🇮🇱','israel'],['🇸🇬','singapore'],['🇹🇭','thailand'],['🇻🇳','vietnam'],['🇮🇩','indonesia'],['🇵🇭','philippines'],['🇲🇾','malaysia'],['🇵🇰','pakistan'],['🇧🇩','bangladesh'],['🇺🇳','united nations']
]}
];
document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdIconPicker) return;
if (window.Alpine) window.Alpine.__fdIconPicker = true;
Alpine.store('fdIconPicker', {
open: false, x: 0, y: 0, tab: 'emoji', query: '', category: 'people',
skinTone: 0, toneOpen: false, recent: [], custom: [], customLoaded: false,
customModal: false, customName: '', customPreview: '', customFile: null, customBusy: false,
onPick: null, onRemove: null, _cx: 0, _cy: 0,
cats: CATS,
openFor(opts) {
opts = opts || {};
this.onPick = opts.onPick || null;
this.onRemove = opts.onRemove || null;
this.query = '';
this.toneOpen = false;
this.customModal = false;
this._cx = (opts.x != null) ? opts.x : 240;
this._cy = (opts.y != null) ? opts.y : 200;
this.x = this._cx;
this.y = this._cy;
this.open = true;
var self = this;
var place = function () {
var el = document.querySelector('.fd-icon-picker');
if (!el) return;
var w = el.offsetWidth || 344, h = el.offsetHeight || 440;
var vw = window.innerWidth, vh = window.innerHeight;
var nx = self._cx, ny = self._cy;
if (nx + w > vw - 8) nx = vw - w - 8;
if (ny + h > vh - 8) ny = vh - h - 8;
self.x = Math.max(8, nx);
self.y = Math.max(8, ny);
};
if (window.Alpine && Alpine.nextTick) Alpine.nextTick(place);
else setTimeout(place, 0);
this.loadRecent();
this.loadCustom();
},
close() {
this.open = false;
this.customModal = false;
this.toneOpen = false;
this.onPick = null;
this.onRemove = null;
},
matches(name) {
var q = (this.query || '').trim().toLowerCase();
if (!q) return true;
return name.toLowerCase().indexOf(q) >= 0;
},
items() {
var q = (this.query || '').trim().toLowerCase();
if (q) {
var out = [];
this.cats.forEach(function (c) {
c.items.forEach(function (it) {
if ((it[1] || '').toLowerCase().indexOf(q) >= 0 || it[0].indexOf(q) >= 0) out.push(it[0]);
});
});
return out;
}
if (this.category === 'recent') return this.recent;
var found = [];
for (var i = 0; i < this.cats.length; i++) {
if (this.cats[i].id === this.category) { found = this.cats[i].items.map(function (it) { return it[0]; }); break; }
}
return found;
},
withTone(e) {
if (!this.skinTone) return e;
if (TONEABLE[e]) return e + TONES[this.skinTone];
return e;
},
pick(v) {
v = (v || '').trim();
if (!v) return;
this.pushRecent(v);
var fn = this.onPick;
if (fn) { try { fn(v); } catch (e) { console.error('fdIconPicker.pick', e); } }
this.close();
},
remove() {
var fn = this.onRemove || this.onPick;
if (fn) { try { fn(''); } catch (e) {} }
this.close();
},
random() {
var pool = [];
this.cats.forEach(function (c) { c.items.forEach(function (it) { pool.push(it[0]); }); });
if (!pool.length) return;
this.pick(pool[Math.floor(Math.random() * pool.length)]);
},
setTone(i) { this.skinTone = i; this.toneOpen = false; },
setCategory(id) { this.category = id; this.tab = 'emoji'; this.query = ''; },
setTab(t) { this.tab = t; this.query = ''; if (t === 'upload') this.loadCustom(); },
loadRecent() {
try { this.recent = JSON.parse(localStorage.getItem('fd_icon_recent') || '[]'); }
catch (e) { this.recent = []; }
},
pushRecent(e) {
try {
var r = this.recent.filter(function (x) { return x !== e; });
r.unshift(e);
this.recent = r.slice(0, 32);
localStorage.setItem('fd_icon_recent', JSON.stringify(this.recent));
} catch (err) {}
},
async loadCustom() {
try {
var r = await fetch('/api/custom-emojis', { credentials: 'same-origin' });
var d = await r.json();
this.custom = (d && d.emojis) || [];
this.customLoaded = true;
} catch (e) { this.custom = []; }
},
openCustomModal() {
this.customModal = true;
this.customName = '';
this.customPreview = '';
this.customFile = null;
this.tab = 'upload';
},
closeCustomModal() { this.customModal = false; },
onCustomFile(ev) {
var f = ev.target.files && ev.target.files[0];
if (!f) return;
this.customFile = f;
var self = this;
var fr = new FileReader();
fr.onload = function () { self.customPreview = fr.result; };
fr.readAsDataURL(f);
if (!this.customName) this.customName = (f.name || '').replace(/\.[^.]+$/, '').slice(0, 40);
},
async saveCustom() {
if (!this.customFile || this.customBusy) return;
this.customBusy = true;
try {
var fd = new FormData();
fd.append('name', this.customName || 'emoji');
fd.append('file', this.customFile);
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
var r = await fetch('/api/custom-emojis', { method: 'POST', headers: { 'X-CSRF-Token': csrf }, body: fd, credentials: 'same-origin' });
var d = await r.json();
if (d && d.emoji) {
this.custom.unshift(d.emoji);
this.customModal = false;
this.pick(d.emoji.url);
}
} catch (e) {
if (window.showToast) window.showToast('Emoji upload failed', 'error');
}
this.customBusy = false;
},
async deleteCustom(id) {
try {
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
await fetch('/api/custom-emojis/' + id, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf }, credentials: 'same-origin' });
this.custom = this.custom.filter(function (e) { return e.id !== id; });
} catch (e) {}
}
});
});
window.FDIconPicker = {
openFor: function (opts) {
var s = window.Alpine && Alpine.store('fdIconPicker');
if (s) s.openFor(opts);
}
};
})();
</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/_icon_picker_2.js?v={{ asset_version }}"></script>
<style>
.fd-icon-picker{position:fixed;top:0;left:0;width:344px;background:var(--bg-modal);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.28);z-index:2200;display:flex;flex-direction:column;max-height:440px;overflow:hidden;padding:0;}
+1 -1
View File
@@ -61,7 +61,7 @@
</div>
</span>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdNotificationsRegistered) return;
if (window.Alpine) window.Alpine.__fdNotificationsRegistered = true;
+2 -532
View File
@@ -9,535 +9,5 @@
.rt-cursor .rt-cursor-name{position:absolute;top:-14px;left:4px;white-space:nowrap;font-size:9px;line-height:12px;padding:0 4px;border-radius:4px;color:#fff;font-weight:600;letter-spacing:.2px;}
.rt-offline{font-size:11px;color:var(--text-tertiary,#999);margin-right:6px;display:none;}
</style>
<script data-cfasync="false">
/* eslint-disable */
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
window.__fdRT = (function () {
const SELF = {
id: {{ (user.get('id') if user else 0) | tojson }},
login: {{ (user.get('login','') if user else '') | tojson }},
full_name: {{ (user.get('full_name','') if user else '') | tojson }},
color: {{ (user.get('avatar_color','') or '' if user else '') | tojson }},
};
const COLORS = [
"#2383E2", "#46A758", "#E5484D", "#F76B15", "#8E4EC6", "#12A594",
"#FFC53D", "#D6409F", "#0091FF", "#3E63DD", "#30A46C", "#FF3333",
];
let E = null; // editorState (window.E)
let ws = null;
let mode = 'off'; // 'ws' | 'poll'
let open = false;
let base = []; // dernier snapshot serveur des blocs
let version = 0;
let pendingOps = 0;
let needSync = false;
let peers = []; // liste des présents (hors moi)
let remoteCursors = {}; // userId -> {peer, block, offset}
let myCursor = null; // {block, offset}
let emitT = null, selT = null, titleT = null, drawT = null, retryT = null, pollT = null;
let _pid = 0;
// Detach global listeners from a previous editor instance so that
// partial (HTMX) navigation doesn't accumulate stale handlers.
function unbindGlobal() {
const g = window.__fdRTGlobal;
if (!g) return;
try {
document.removeEventListener('selectionchange', g.onSel, true);
window.removeEventListener('scroll', g.onScroll, true);
window.removeEventListener('resize', g.onResize);
} catch (e) { /* noop */ }
window.__fdRTGlobal = null;
}
const clone = (o) => JSON.parse(JSON.stringify(o));
function colorOf(uid) { return COLORS[Math.abs(uid || 0) % COLORS.length]; }
function initials(p) {
const n = (p && (p.full_name || p.login)) || '';
const parts = String(n).trim().split(/\s+/);
if (!parts[0]) return '?';
return ((parts[0][0] || '') + (parts.length > 1 ? (parts[1][0] || '') : '')).toUpperCase().slice(0, 2);
}
function send(obj) {
if (ws && ws.readyState === WebSocket.OPEN) {
try { ws.send(JSON.stringify(obj)); } catch (e) { /* noop */ }
}
}
/* ── ops miroir du serveur (apply_op/merge) ── */
function applyOpJS(blocks, op) {
const t = op && op.type;
if (t === 'insert') {
const blk = op.block || {};
const id = blk.id || ('rb' + Date.now().toString(36));
blk.id = id;
if (typeof ensureBlockIds === 'function') ensureBlockIds([blk]);
let idx = op.index != null ? op.index : blocks.length;
idx = Math.max(0, Math.min(idx, blocks.length));
return blocks.slice(0, idx).concat([blk]).concat(blocks.slice(idx));
}
if (t === 'update') {
const nb = op.block || {};
if (!nb.id) return blocks;
return blocks.map(b => (b.id === nb.id ? nb : b));
}
if (t === 'delete') {
const bid = op.id;
return blocks.filter(b => b.id !== bid);
}
if (t === 'move') {
const bid = op.id, idx = op.index || 0;
const out = blocks.filter(b => b.id !== bid);
const moved = blocks.find(b => b.id === bid);
if (!moved) return blocks;
const i2 = Math.max(0, Math.min(idx, out.length));
out.splice(i2, 0, moved);
return out;
}
return blocks;
}
/* Diff base → courants : update/delete/move/insert (ordre pour le serveur). */
function diffOps(cur) {
if (!base.length && !cur.length) return [];
const ops = [];
const baseById = {}, curById = {};
base.forEach(b => { baseById[b.id] = b; });
cur.forEach(b => { curById[b.id] = b; });
cur.forEach(b => {
if (baseById[b.id] !== undefined && JSON.stringify(baseById[b.id]) !== JSON.stringify(b)) {
// v6.4.0 : on embarque la `base` dont dérive la saisie → le serveur
// fait un merge 3-voix au lieu d'écraser le bloc (LWW).
ops.push({ type: 'update', block: clone(b), base: clone(baseById[b.id]) });
}
});
base.forEach(b => {
if (curById[b.id] === undefined) ops.push({ type: 'delete', id: b.id });
});
// structure : simule l'état serveur (base − supprimés) pour indices valides
let sim = base.filter(b => curById[b.id] !== undefined).map(b => clone(b));
const simById = {}; sim.forEach(b => { simById[b.id] = b; });
const finalOrder = cur.map(b => b.id);
let si = 0;
finalOrder.forEach(id => {
if (simById[id] !== undefined) {
const curPos = sim.findIndex(b => b.id === id);
if (curPos !== si) ops.push({ type: 'move', id, index: si });
const [mv] = sim.splice(curPos, 1);
sim.splice(si, 0, mv);
si++;
} else {
ops.push({ type: 'insert', index: si, block: clone(curById[id]) });
sim.splice(si, 0, curById[id]);
simById[id] = curById[id];
si++;
}
});
return ops;
}
/* ── rendu + présence ── */
function activeBlockId() {
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
return a ? a.bid : null;
}
function refocus(fid) {
if (!fid) return;
setTimeout(() => {
const el = E.getEl(fid);
if (el) { el.focus(); try { (typeof ce === 'function') && ce(el); } catch (e) { /* noop */ } }
}, 30);
}
function renderPresence() {
let host = document.querySelector('.topbar-right.header-actions');
if (!host) return;
let box = document.getElementById('rtPresence');
if (!box) {
box = document.createElement('span');
box.id = 'rtPresence';
box.className = 'rt-presence';
host.insertBefore(box, host.firstChild);
}
const shown = peers.filter(p => p.id !== (SELF.id || 0));
if (!shown.length) { box.style.display = 'none'; return; }
box.style.display = 'inline-flex';
box.innerHTML = '';
shown.forEach(p => {
const c = document.createElement('span');
c.className = 'rt-avatar';
c.title = (p.full_name || p.login) + ' est en train d\u2019éditer' + (mode === 'poll' ? ' (polling)' : '');
c.textContent = initials(p);
c.style.background = p.color || colorOf(p.id);
box.appendChild(c);
});
if (mode === 'poll') {
let off = document.getElementById('rtOffline');
if (!off) {
off = document.createElement('span');
off.id = 'rtOffline';
off.className = 'rt-offline';
off.textContent = '●';
off.title = 'Realtime indisponible — rafraîchissement toutes les 10 s';
host.insertBefore(off, box.nextSibling || null);
}
off.style.display = 'inline';
}
}
/* ── curseurs ── */
function rangeFromOffset(el, offset) {
try {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let n = walker.nextNode(), count = 0;
while (n) {
const len = (n.nodeValue || '').length;
if (count + len >= offset) {
const r = document.createRange();
r.setStart(n, Math.min(offset - count, len));
r.collapse(true);
return r;
}
count += len;
n = walker.nextNode();
}
const r = document.createRange();
r.selectNodeContents(el);
r.collapse(false);
return r;
} catch (e) { return null; }
}
function drawCursors() {
const layer = document.getElementById('rtCursors');
if (!layer) return;
layer.innerHTML = '';
const ids = Object.keys(remoteCursors);
if (!ids.length) return;
ids.forEach(uid => {
const c = remoteCursors[uid];
if (!c || !c.block) return;
const el = E.getEl(c.block);
if (!el) return;
const r = rangeFromOffset(el, c.offset || 0);
let x, y, h;
if (r) {
const rc = r.getBoundingClientRect();
if (!rc.width && !rc.height) return; // hors viewport positionné
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
} else {
const rc = el.getBoundingClientRect();
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
}
const m = document.createElement('div');
m.className = 'rt-cursor';
m.style.left = (x - 1) + 'px';
m.style.top = (y - 1) + 'px';
m.style.height = h + 'px';
m.style.background = c.peer.color || colorOf(c.peer.id);
const nm = document.createElement('span');
nm.className = 'rt-cursor-name';
nm.textContent = initials(c.peer);
nm.style.background = m.style.background;
m.appendChild(nm);
layer.appendChild(m);
});
}
function emitCursor() {
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
let block = null, offset = 0;
if (a && a.el && a.bid) {
block = a.bid;
try { offset = (typeof cp === 'function') ? cp(a.el) : 0; } catch (e) { offset = 0; }
}
const changed = !myCursor || myCursor.block !== block || myCursor.offset !== offset;
myCursor = { block, offset };
if (!changed) return;
send({ t: 'sel', block, offset });
}
function scheduleDraw() {
clearTimeout(drawT);
drawT = setTimeout(drawCursors, 40);
}
/* ── application des changements distants ── */
function applySync(blocks, title) {
if (!E || !E.blocks) return;
// v5.13.1: guarantee ids before comparing/merging. Server rooms may still
// carry legacy id-less blocks; without this they collide on
// data-bid="undefined" and the merge below duplicated every line.
blocks = (blocks || []).slice();
if (typeof ensureBlockIds === 'function') ensureBlockIds(blocks);
const curIds = JSON.stringify((E.blocks || []).map(b => b.id));
const srvIds = JSON.stringify(blocks.map(b => b.id));
if (curIds === srvIds) {
base = clone(E.blocks);
E.dirty = false;
return;
}
const fid = activeBlockId();
const curById = {};
(E.blocks || []).forEach(b => { curById[b.id] = b; });
let out;
try {
if (!blocks.length) {
// serveur vide : ne pas effacer le contenu local (page neuve).
out = (E.blocks || []).slice();
} else {
out = blocks.map(b => {
const cb = curById[b.id];
if (cb && b.id === fid) return cb; // garde la frappe locale en cours
return b;
});
}
} catch (e) { return; }
E.blocks = out;
const tEl = document.getElementById('_titleEl');
if (title && title !== E.pageTitle && document.activeElement !== tEl) {
tEl.textContent = title;
E.pageTitle = title;
}
E.dirty = true;
base = clone(E.blocks);
E.render();
refocus(fid);
scheduleDraw();
}
function applyRemoteOp(op) {
const fid = activeBlockId();
if (op.type === 'update') {
const nb = op.block || {};
if (nb.id === fid) {
// bloc en cours d'édition : on garde la valeur locale, le serveur a déjà
// l'op ; la prochaine frappe locale repartira (LWW).
base = applyOpJS(base, op);
return;
}
E.blocks = applyOpJS(E.blocks, op);
base = applyOpJS(base, op);
E.dirty = true;
E.render();
refocus(fid);
} else {
E.blocks = applyOpJS(E.blocks, op);
base = applyOpJS(base, op);
E.dirty = true;
E.render();
refocus(fid);
}
scheduleDraw();
}
/* ── diffusion des modifications locales ── */
function emit() {
if (mode !== 'ws' || !open || !E || !E.blocks) return;
if (needSync) { send({ t: 'sync_req' }); return; }
const cur = E.blocks.filter(b => b && b.id);
const ops = diffOps(cur);
if (!ops.length) return;
ops.forEach(op => { send({ t: 'op', op, v: version }); pendingOps++; });
base = clone(cur);
}
function onMsg(m) {
if (!m || !m.t) return;
if (m.t === 'sync') {
version = m.version || 0;
base = clone(m.blocks || []);
needSync = false;
pendingOps = 0;
if (typeof m.blocks === 'undefined') return;
applySync(m.blocks || [], m.title || '');
} else if (m.t === 'ack') {
version = m.v || 0;
if (pendingOps > 0) pendingOps--;
// v6.4.0 : le serveur renvoie le bloc fusionné (merge 3-voix). On
// l'adopte comme nouvelle base ; s'il diffère de notre saisie locale
// c'est qu'un autre utilisateur avait modifié le même bloc.
if (m.merged) {
const mid = m.merged.id;
const localBlock = (E && E.blocks || []).find(b => b.id === mid);
const differs = localBlock && JSON.stringify(localBlock) !== JSON.stringify(m.merged);
base = applyOpJS(base, { type: 'update', block: m.merged });
if (differs && E) {
const fid = activeBlockId();
if (fid !== mid) {
E.blocks = applyOpJS(E.blocks, { type: 'update', block: m.merged });
E.dirty = true;
E.render();
refocus(fid);
}
if (m.conflict && window.showToast) {
window.showToast('Editing conflict merged on a block', 'info');
}
}
}
if (m.stale || needSync) { needSync = true; send({ t: 'sync_req' }); }
} else if (m.t === 'op') {
if (m.v) version = m.v;
if (m.from === (SELF.id || 0)) { base = applyOpJS(base, m.op); return; }
applyRemoteOp(m.op);
} else if (m.t === 'title') {
const tEl = document.getElementById('_titleEl');
if (m.from !== (SELF.id || 0) && tEl && document.activeElement !== tEl && E) {
tEl.textContent = m.title || '';
E.pageTitle = m.title || '';
}
if (m.v) version = m.v;
scheduleDraw();
} else if (m.t === 'sel') {
if (m.from === (SELF.id || 0)) return;
if (!m.block) { delete remoteCursors[m.from]; scheduleDraw(); return; }
remoteCursors[m.from] = { peer: m.peer || { id: m.from }, block: m.block, offset: m.offset || 0 };
scheduleDraw();
} else if (m.t === 'welcome') {
peers = (m.peers || []).filter(p => p.id !== (SELF.id || 0));
renderPresence();
} else if (m.t === 'peer_join') {
if (m.peer && m.peer.id !== (SELF.id || 0)) {
peers = peers.filter(p => p.id !== m.peer.id).concat([m.peer]);
renderPresence();
}
} else if (m.t === 'peer_leave') {
peers = peers.filter(p => p.id !== m.id);
delete remoteCursors[m.id];
renderPresence();
scheduleDraw();
} else if (m.t === 'synced_update') {
// A synced block was updated — re-sync the whole page
if (m.synced_id) {
needSync = true;
send({ t: 'sync_req' });
}
}
}
/* ── connexion WS + fallback polling ── */
function startPolling() {
if (pollT) return;
mode = 'poll';
renderPresence();
scheduleDraw();
pollT = setInterval(poll, 10000);
}
function stopPolling() {
if (pollT) { clearInterval(pollT); pollT = null; }
const off = document.getElementById('rtOffline');
if (off) off.style.display = 'none';
}
async function poll() {
if (!E || !_pid) return;
try {
const r = await fetch('/board/api/pages/' + _pid, { credentials: 'same-origin' });
if (!r.ok) return;
const d = await r.json();
if ((d.content_format || 'blocks') !== 'blocks' || !d.content) return;
const serverBlocks = JSON.parse(d.content);
// en cas de modifs locales non persistées : on garde local (LWW au prochain save)
if (E.dirty) return;
const cur = JSON.stringify((E.blocks || []).map(b => b.id));
const srv = JSON.stringify(serverBlocks.map(b => b.id));
if (cur === srv) return;
version = version; // pas de version via polling — on adopte l'état serveur
applySync(serverBlocks, d.title || E.pageTitle);
} catch (e) { /* noop */ }
}
function connect() {
if (!E || !_pid || ws) return;
const proto = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
try {
ws = new WebSocket(proto + window.location.host + '/ws/pages/' + _pid);
} catch (e) {
startPolling();
return;
}
ws.onopen = () => {
open = true;
mode = 'ws';
stopPolling();
send({ t: 'hello' });
};
ws.onmessage = (ev) => {
let m;
try { m = JSON.parse(ev.data); } catch (e) { return; }
onMsg(m);
};
ws.onclose = () => {
open = false;
ws = null;
if (retryT) clearTimeout(retryT);
retryT = setTimeout(connect, 15000);
startPolling();
};
ws.onerror = () => { try { ws.close(); } catch (e) { /* noop */ } };
setTimeout(() => {
if (!open) { try { ws && ws.close(); } catch (e) { /* noop */ } }
}, 5000);
}
function titleInput() {
const tEl = document.getElementById('_titleEl');
if (!tEl) return;
clearTimeout(titleT);
titleT = setTimeout(() => {
send({ t: 'title', title: (tEl.textContent || '').trim() });
}, 500);
}
function wire() {
const ct = document.getElementById('_blocksCt');
if (ct) {
ct.addEventListener('input', () => {
clearTimeout(emitT);
emitT = setTimeout(emit, 350);
setTimeout(emitCursor, 80);
}, true);
ct.addEventListener('keyup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
ct.addEventListener('click', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
ct.addEventListener('mouseup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
}
const tEl = document.getElementById('_titleEl');
if (tEl) tEl.addEventListener('input', titleInput);
unbindGlobal();
const onSel = () => { clearTimeout(selT); selT = setTimeout(emitCursor, 150); };
const onScroll = () => scheduleDraw();
const onResize = () => scheduleDraw();
document.addEventListener('selectionchange', onSel, true);
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onResize);
window.__fdRTGlobal = { onSel, onScroll, onResize };
}
function start(ed) {
if (!ed) return;
if ((ed.contentFormat || 'blocks') !== 'blocks') return;
E = ed;
_pid = ed.pid || 0;
if (!_pid) return;
base = clone(ed.blocks || []);
wire();
connect();
}
// poussée immédiate des ops après une mutation programmatique (v5.10.0)
function syncNow() {
if (mode !== 'ws' || !open || !E || !E.blocks) return;
clearTimeout(emitT);
emit();
}
return { start, syncNow };
})();
</script>
<script type="application/json" id="rt-config" nonce="{{ csp_nonce() }}">{{ {"id": user.get("id") if user else 0, "login": user.get("login", "") if user else "", "full_name": user.get("full_name", "") if user else "", "color": (user.get("avatar_color", "") or "") if user else ""} | tojson }}</script>
<script data-cfasync="false" src="/static/js/page_editor_realtime.js?v={{ asset_version }}"></script>
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -104,7 +104,7 @@
{% endblock %}
{% block scripts %}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function accountsData() {
return {
profile: { full_name: '', email: '' },
File diff suppressed because it is too large Load Diff
+7 -4
View File
@@ -113,6 +113,9 @@
body.embed-mode .page-editor-wrapper { padding: 8px 16px !important; max-width: 100% !important; }
body.embed-mode .page-cover-area { padding-top: 0 !important; }
</style>
{# A20 : htmx copie les <script nonce="{{ csp_nonce() }}"> des réponses boostées — il remet leur nonce
depuis cette config (le nonce de la réponse courante, pas celui du fetch). #}
<meta name="htmx-config" content='{"inlineScriptNonce": "{{ csp_nonce() }}"}'>
<script src="/static/js/htmx.min.js" data-cfasync="false"></script>
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
@@ -790,7 +793,7 @@
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// Inject CSRF token into HTMX headers
(function() {
const getCsrf = () => {
@@ -2207,7 +2210,7 @@
</div>
</div>
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function(){
var PALETTE_ACTIONS = [
{ id:'new-page', icon:'📄', title:'New page', sub:'Create a new page in the current workspace', key:'Ctrl N', run:function(){ return window.FlowDeck && window.FlowDeck.createPage ? (window.FlowDeck.createPage(), true) : false; } },
@@ -2378,7 +2381,7 @@
{# ─── PWA: offline client module + service worker registration (v6.0.0) ─── #}
<script src="/static/js/offline.js?v={{ asset_version }}" defer data-cfasync="false"></script>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function() {
if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function() {
@@ -2392,7 +2395,7 @@
</script>
{# ─── PWA: sync badge + toasts wiring (v6.0.0) ─── #}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('DOMContentLoaded', function() {
if (!window.FlowOffline) return;
window.FlowOffline.onChange(function(s) {
+2 -147
View File
@@ -151,151 +151,6 @@
{% endblock %}
{% block scripts %}
<script data-cfasync="false">
var owner = '{{ owner }}';
var repo = '{{ repo }}';
var initialView = '{{ initial_view }}';
// Auto-switch to view from URL param
document.addEventListener('DOMContentLoaded', function() {
if (initialView && initialView !== 'kanban') {
const tab = document.querySelector(`.view-tab[data-view="${initialView}"]`);
if (tab) tab.click();
}
});
function setActiveTab(el) {
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
el.classList.add('active');
}
function kanbanBoard() {
return {
collapsedGroups: [],
toggleGroup(id) {
const idx = this.collapsedGroups.indexOf(id);
idx >= 0 ? this.collapsedGroups.splice(idx, 1) : this.collapsedGroups.push(id);
},
newCard(groupId, status) {
document.getElementById('new-issue-form').style.display = 'block';
document.querySelector('#new-issue-form').__x.$data.status = status;
},
newGroup() { console.log('New group'); }
};
}
function filterSystem() {
return {
activeFilters: [],
statusFilters: [],
showStatusMenu: false,
statusOptions: [
{ value: 'todo', label: 'To-do', color: 'var(--gray)' },
{ value: 'progress', label: 'In progress', color: 'var(--blue)' },
{ value: 'done', label: 'Complete', color: 'var(--green)' },
],
get statusFilterLabel() {
return this.statusFilters.length ? this.statusFilters.join(', ') : 'All';
},
toggleStatus(val) {
const idx = this.statusFilters.indexOf(val);
idx >= 0 ? this.statusFilters.splice(idx, 1) : this.statusFilters.push(val);
},
addFilter() {
const prop = prompt('Filter by property (status, assignee, label):');
if (!prop) return;
const val = prompt('Value:');
if (!val) return;
this.activeFilters.push({ property: prop, value: val });
this.refreshView();
},
removeFilter(i) { this.activeFilters.splice(i, 1); },
resetFilters() { this.activeFilters = []; this.statusFilters = []; },
refreshView() {
const activeTab = document.querySelector('.view-tab.active');
if (activeTab) activeTab.click();
}
};
}
function sortSystem() {
return {
sorts: [],
showSortPanel: false,
addSort() {
const field = prompt('Sort by (name, status, assignee, deadline):');
if (!field) return;
this.sorts.push({ field, dir: 'asc' });
this.applySorts();
},
removeSort(i) { this.sorts.splice(i, 1); this.applySorts(); },
toggleDir(i) { this.sorts[i].dir = this.sorts[i].dir === 'asc' ? 'desc' : 'asc'; this.applySorts(); },
clearSorts() { this.sorts = []; this.applySorts(); },
applySorts() {
const activeTab = document.querySelector('.view-tab.active');
const url = new URL(activeTab.getAttribute('hx-get'), window.location.origin);
this.sorts.forEach(s => url.searchParams.append('sort', s.field + ':' + s.dir));
htmx.ajax('GET', url.pathname + url.search, { target: '#view-content', swap: 'innerHTML' });
}
};
}
function newIssueForm() {
return {
title: '', status: 'todo',
create() {
if (!this.title.trim()) return;
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
})
.then(r => r.json())
.then(data => {
this.title = '';
this.hide();
// Refresh current view
const activeTab = document.querySelector('.view-tab.active');
if (activeTab) activeTab.click();
});
},
hide() { document.getElementById('new-issue-form').style.display = 'none'; },
show() { document.getElementById('new-issue-form').style.display = 'block'; }
};
}
function showNewIssue() {
const form = document.getElementById('new-issue-form');
form.style.display = form.style.display === 'none' ? 'block' : 'none';
}
// Init SortableJS after HTMX swaps
document.addEventListener('htmx:afterSwap', function(evt) {
if (evt.target.id === 'view-content') {
document.querySelectorAll('.kanban-cards').forEach(el => {
if (el._sortable) el._sortable.destroy();
el._sortable = new Sortable(el, {
group: 'kanban',
animation: 200,
ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
onEnd: function(evt) {
const cardId = evt.item.dataset.cardId;
const toStatus = evt.to.dataset.status;
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
})
.then(() => {
// ponytail: refresh current view after move
const activeTab = document.querySelector('.view-tab.active');
if (activeTab) activeTab.click();
});
}
});
});
}
});
</script>
<script type="application/json" id="bd-config" nonce="{{ csp_nonce() }}">{{ {"owner": owner, "repo": repo, "initial_view": initial_view} | tojson }}</script>
<script data-cfasync="false" src="/static/js/board.js?v={{ asset_version }}"></script>
{% endblock %}
+1 -1
View File
@@ -49,7 +49,7 @@
{% endfor %}
</div>
<script>
<script nonce="{{ csp_nonce() }}">
function openCardDetail(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
target: '#card-modal-content', swap: 'innerHTML'
+2 -1
View File
@@ -1,4 +1,5 @@
{# Card detail modal content — full issue info + comments #}
{% from '_icons.html' import fd_icon %}
<div class="card-detail" x-data="cardDetail()">
<!-- Title -->
<div style="display:flex; align-items:flex-start; gap:12px; margin-bottom:16px;">
@@ -98,7 +99,7 @@
</div>
</div>
<script>
<script nonce="{{ csp_nonce() }}">
// ponytail: CSRF helper
function getCsrf() {
const m = document.cookie.match(/csrf_token=([^;]+)/);
+1 -1
View File
@@ -58,7 +58,7 @@
{% endfor %}
</div>
<script>
<script nonce="{{ csp_nonce() }}">
function openCardDetail(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
target: '#card-modal-content', swap: 'innerHTML'
+1 -627
View File
@@ -35,633 +35,7 @@
.gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;}
</style>
<script data-cfasync="false">
document.addEventListener('alpine:init', () => {
Alpine.data('giteaWorkspace', () => {
const params = new URLSearchParams(window.location.search);
const owner = params.get('owner') || '';
const repo = params.get('repo') || '';
return {
owner, repo,
showFile: false,
showEditor: false,
showPrivate: false,
privatePages: [],
editingPrivate: null,
editingPrivateTitle: '',
editingPrivateContent: '',
filePath: '',
fileContent: '',
fileSize: 0,
fileSha: '',
fileLoading: false,
fileLanguage: 'text',
editContent: '',
commitMessage: 'Update via FlowDeck',
commitHistory: JSON.parse(localStorage.getItem('fd_commit_msgs') || '[]'),
// File tree browser
treeItems: [],
sortedTreeItems: [],
treeLoading: false,
folderStack: [],
currentPath: '',
// Context menu
gwCtx: { visible: false, x: 0, y: 0, item: null },
_sortTree() {
this.sortedTreeItems = [...this.treeItems].sort(function(a, b) {
if (a.type === b.type) return a.name.localeCompare(b.name);
return a.type === 'folder' ? -1 : 1;
});
},
init() {
// Expose globally for header to access
window._gwData = this;
// Set cookie for sidebar
document.cookie = 'flowdeck_workspace=gitea:' + this.owner + ':' + this.repo + ';path=/;SameSite=Lax';
// Load sidebar tree (into gitea section)
this.loadSidebarTree();
// Load main content tree
this.loadMainTree('');
// Listen for sidebar clicks on Gitea items
this.setupSidebarClicks();
},
async loadMainTree(path) {
this.treeLoading = true;
this.currentPath = path;
try {
var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree';
if (path) url += '?path=' + encodeURIComponent(path);
var r = await fetch(url);
if (!r.ok) { this.treeItems = []; this.sortedTreeItems = []; return; }
var d = await r.json();
this.treeItems = d.tree || [];
this._sortTree();
} catch(e) { this.treeItems = []; this.sortedTreeItems = []; }
finally { this.treeLoading = false; }
},
drillDown(path) {
this.folderStack.push(path.split('/').pop());
this.loadMainTree(path);
},
navigateToFolder(idx) {
// Truncate stack and rebuild path
this.folderStack = this.folderStack.slice(0, idx + 1);
var path = this.folderStack.join('/');
this.loadMainTree(path);
},
navigateToRoot() {
this.folderStack = [];
this.loadMainTree('');
},
openGwContext(ev, item) {
this.gwCtx = { visible: true, x: ev.clientX, y: ev.clientY, item: item };
},
gwRename() {
this.gwCtx.visible = false;
var item = this.gwCtx.item;
if (!item) return;
var newName = prompt('Rename:', item.name);
if (!newName || !newName.trim() || newName.trim() === item.name) return;
var self = this;
var oldPath = item.path;
var newPath = oldPath.replace(/[^/]+$/, newName.trim());
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
method: 'PUT',
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json' },
body: JSON.stringify({ path: oldPath, new_path: newPath, message: 'Rename ' + oldPath + ' to ' + newPath })
}).then(function(r){ return r.json(); })
.then(function(d){
if (d.status === 'ok') { self.refreshTree(); }
else { window.showToast('Rename failed', 'error'); }
})
.catch(function(){ window.showToast('Rename failed', 'error'); });
},
gwDelete() {
this.gwCtx.visible = false;
var item = this.gwCtx.item;
if (!item) return;
if (!confirm('Delete ' + item.name + '?')) return;
var self = this;
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
method: 'DELETE'
}).then(function(r){
if (r.ok) { self.refreshTree(); }
else { window.showToast('Delete failed', 'error'); }
}).catch(function(){ window.showToast('Delete failed', 'error'); });
},
async loadSidebarTree() {
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree');
if (!r.ok) {
// If API fails (e.g. no Gitea token), set a message
var container = document.getElementById('sidebar-gitea-items');
if (container) container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon">'+getSvgIcon('link',14)+'</span><span class="page-name text-dim">Connect Gitea to browse files</span></li>';
return;
}
var d = await r.json();
var items = d.tree || [];
var container = document.getElementById('sidebar-gitea-items');
if (!container) return;
// Ensure gitea section is visible
if (window.appState && window.appState.sectionsOpen && !window.appState.sectionsOpen.gitea) {
window.appState.sectionsOpen.gitea = true;
}
container.innerHTML = '';
// Build tree HTML as string and set once (more performant)
var html = '';
for (var i = 0; i < items.length; i++) {
var item = items[i];
var icon = item.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
html += '<li class="sidebar-item" data-gitea-path="' + item.path + '" data-gitea-type="' + (item.type === 'folder' ? 'folder' : 'file') + '" data-gitea-sha="' + (item.sha || '') + '" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
html += '<span class="page-icon">' + icon + '</span>';
html += '<span class="page-name">' + item.name + '</span>';
html += '</li>';
}
// Add Private Pages link
html += '<li style="border-top:1px solid var(--border);margin:8px 0;"></li>';
html += '<li class="sidebar-item" id="gitea-private-link" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
html += '<span class="page-icon">'+getSvgIcon('lock',14)+'</span><span class="page-name">Private Pages</span></li>';
container.innerHTML = html;
// Re-attach event listeners
this.setupSidebarClicks();
} catch(e) {
console.error('Gitea sidebar tree load failed:', e);
}
},
setupSidebarClicks() {
var self = this;
document.addEventListener('click', function(e) {
var el = e.target.closest('.sidebar-item[data-gitea-path]');
if (!el) return;
var path = el.getAttribute('data-gitea-path');
var type = el.getAttribute('data-gitea-type');
// If clicking the checkbox, let its own handler deal with selection
if (e.target.classList.contains('gitea-checkbox')) return;
// If clicking the inline rename input, don't navigate
if (e.target.classList.contains('inline-rename-input')) return;
// If Shift or Ctrl/Meta is pressed, use multi-selection
if (e.shiftKey || e.ctrlKey || e.metaKey) {
e.preventDefault();
e.stopPropagation();
self.selectItem(path, el, e);
return;
}
// Normal click: navigate (open file/folder)
e.preventDefault();
e.stopPropagation();
// Update visual "active" state
document.querySelectorAll('.sidebar-item.active').forEach(function(si) {
si.classList.remove('active');
});
el.classList.add('active');
if (type === 'folder') {
self.loadSubdir(path, el);
} else {
self.openFile(path, el.getAttribute('data-gitea-sha'));
}
});
// Listen for custom delete event on gitea sidebar items
document.addEventListener('gitea-delete', function(e) {
var el = e.target;
var path = el.getAttribute('data-gitea-path');
if (!path) return;
if (!confirm('Delete ' + path + ' from ' + self.owner + '/' + self.repo + '?')) return;
var sha = el.getAttribute('data-gitea-sha') || '';
fetch('/api/gitea/projects/' + self.owner + '/' + self.repo + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
method: 'DELETE',
}).then(function(r) {
if (r.ok) self.refreshTree();
}).catch(function() {});
});
},
async loadSubdir(path, el) {
var self = this;
// Check if already loaded
var ul = el.querySelector('ul');
if (ul) {
ul.style.display = ul.style.display === 'none' ? '' : 'none';
return;
}
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree?path=' + encodeURIComponent(path));
if (!r.ok) return;
var d = await r.json();
var children = d.tree || [];
ul = document.createElement('ul');
ul.className = 'sidebar-items';
ul.style.paddingLeft = '20px';
children.forEach(function(child) {
var icon = child.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
var li = document.createElement('li');
li.className = 'sidebar-item';
li.setAttribute('data-gitea-path', child.path);
li.setAttribute('data-gitea-type', child.type === 'folder' ? 'folder' : 'file');
li.setAttribute('data-gitea-sha', child.sha || '');
li.style.cssText = 'cursor:pointer;display:flex;align-items:center;gap:4px;';
// Checkbox
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'gitea-checkbox';
cb.style.cssText = 'flex-shrink:0;margin:0;display:none;';
cb.addEventListener('click', function(ev) {
ev.stopPropagation();
self.selectItem(child.path, li, ev);
});
li.appendChild(cb);
// Icon
var iconSpan = document.createElement('span');
iconSpan.className = 'sidebar-icon';
iconSpan.innerHTML = icon; // icon = HTML SVG from getSvgIcon(), NOT text
li.appendChild(iconSpan);
// Name
var nameSpan = document.createElement('span');
nameSpan.textContent = child.name;
nameSpan.addEventListener('dblclick', function(ev) {
ev.preventDefault();
ev.stopPropagation();
self.startInlineRename(nameSpan, child.path, li);
});
li.appendChild(nameSpan);
ul.appendChild(li);
});
el.appendChild(ul);
} catch(e) {}
},
async openFile(path, sha) {
this.filePath = path;
this.fileSha = sha || '';
this.showEditor = false;
this.showFile = true;
this.fileLoading = true;
this.fileLanguage = this.getLanguage(path);
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(path));
if (r.ok) {
var d = await r.json();
this.fileContent = d.content || '';
this.fileSize = d.content ? d.content.length : 0;
} else {
this.fileContent = '[Error loading file]';
}
} catch(e) {
this.fileContent = '[Error loading file]';
}
this.fileLoading = false;
// Highlight after Alpine renders
var self = this;
this.$nextTick(function() {
var block = self.$refs.codeBlock;
if (block && block.textContent && typeof Prism !== 'undefined') {
Prism.highlightElement(block);
}
});
},
getLanguage(path) {
var ext = (path || '').split('.').pop().toLowerCase();
var map = {
js:'javascript', jsx:'javascript', ts:'typescript', tsx:'typescript',
py:'python', rb:'ruby', rs:'rust', go:'go', java:'java', kt:'kotlin',
c:'c', cpp:'c', h:'c', hpp:'c', cs:'csharp',
html:'markup', htm:'markup', xml:'markup', svg:'markup', css:'css', scss:'css',
json:'json', yaml:'yaml', yml:'yaml', toml:'toml', ini:'ini',
md:'markdown', sql:'sql', sh:'bash', bash:'bash', zsh:'bash',
php:'php', swift:'swift', r:'r', lua:'lua', dart:'dart',
dockerfile:'docker', makefile:'makefile', cmake:'cmake',
};
return map[ext] || 'text';
},
openEditor() {
this.editContent = this.fileContent;
this.showEditor = true;
},
async saveFile() {
if (!this.filePath) return;
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
method: 'PUT',
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken()},
body: JSON.stringify({
path: this.filePath, content: this.editContent,
message: this.commitMessage, sha: this.fileSha,
})
});
if (r.ok) {
this.fileContent = this.editContent;
this.showEditor = false;
if (!this.commitHistory.includes(this.commitMessage)) {
this.commitHistory.unshift(this.commitMessage);
if (this.commitHistory.length > 10) this.commitHistory.pop();
localStorage.setItem('fd_commit_msgs', JSON.stringify(this.commitHistory));
}
} else {
var d = await r.json();
window.showToast('Failed to save: ' + (d.error || 'Unknown error'), 'error');
}
} catch(e) { window.showToast('Network error', 'error'); }
},
async deleteCurrentFile() {
if (!this.filePath) return;
if (!confirm('Delete ' + this.filePath + ' from ' + this.owner + '/' + this.repo + '?')) return;
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(this.filePath) + '&sha=' + encodeURIComponent(this.fileSha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
method: 'DELETE',
});
if (r.ok) {
this.showFile = false;
this.filePath = '';
await this.refreshTree();
}
} catch(e) {}
},
async refreshTree() {
// Reload main tree and sidebar tree without full page reload
this.loadMainTree(this.currentPath);
this.loadSidebarTree();
},
formatSize(bytes) {
if (!bytes) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB';
return (bytes/1048576).toFixed(1) + ' MB';
},
// ── Private Pages ──
async loadPrivatePages() {
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages');
if (r.ok) { var d = await r.json(); this.privatePages = d.pages || []; }
} catch(e) {}
},
newPrivate() {
this.editingPrivate = 'new';
this.editingPrivateTitle = '';
this.editingPrivateContent = '';
},
async openPrivate(id) {
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id);
if (r.ok) {
var d = await r.json();
this.editingPrivate = id;
this.editingPrivateTitle = d.page.title;
this.editingPrivateContent = d.page.content;
}
} catch(e) {}
},
async savePrivate() {
var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages';
var method = 'POST';
if (this.editingPrivate !== 'new') {
url += '/' + this.editingPrivate;
method = 'PUT';
}
try {
var r = await fetch(url, {
method, headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''},
body: JSON.stringify({title: this.editingPrivateTitle, content: this.editingPrivateContent})
});
if (r.ok) {
this.editingPrivate = null;
this.editingPrivateTitle = '';
this.editingPrivateContent = '';
await this.loadPrivatePages();
}
} catch(e) {}
},
// Create new file
showNewFile: false,
newFilePath: '',
newFileContent: '',
newFileMsg: 'Create via FlowDeck',
async createNewFile() {
if (!this.newFilePath.trim()) return;
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
method: 'PUT',
headers: {'Content-Type':'application/json', 'X-CSRF-Token': this.getCsrfToken()},
body: JSON.stringify({path: this.newFilePath.trim(), content: this.newFileContent, message: this.newFileMsg || 'Create via FlowDeck'})
});
if (r.ok) {
this.showNewFile = false;
this.newFilePath = '';
this.newFileContent = '';
this.newFileMsg = 'Create via FlowDeck';
await this.loadSidebarTree();
} else {
var d = await r.json();
window.showToast('Failed: ' + (d.error || d.detail || 'Unknown error'), 'error');
}
} catch(e) { window.showToast('Error: ' + e.message, 'error'); }
},
// Upload files
triggerUpload() { document.getElementById('gitea-upload-input').click(); },
async doUpload(ev) {
var files = ev.target.files;
if (!files.length) return;
for (var i = 0; i < files.length; i++) {
var form = new FormData();
form.append('file', files[i]);
form.append('message', 'Upload ' + files[i].name + ' via FlowDeck');
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/upload', {
method: 'POST',
headers: {'X-CSRF-Token': this.getCsrfToken()},
body: form
});
if (!r.ok) { var d = await r.json(); console.error('Upload failed:', d.error); }
} catch(e) { console.error('Upload error:', e); }
}
await this.loadSidebarTree();
ev.target.value = '';
},
async deletePrivate(id) {
if (!confirm('Delete this private page?')) return;
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id, {
method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''}
});
if (r.ok) await this.loadPrivatePages();
} catch(e) {}
},
getCsrfToken() {
return document.cookie.split('; ').find(function(c) { return c.startsWith('csrf_token='); })?.split('=')[1] || '';
},
// ── Multi-selection ──
multiSelect: false,
selectedPaths: [],
lastClickedPath: null,
toggleMultiSelect() {
this.multiSelect = !this.multiSelect;
var cbs = document.querySelectorAll('.gitea-checkbox');
var self = this;
cbs.forEach(function(cb) { cb.style.display = self.multiSelect ? '' : 'none'; });
if (!this.multiSelect) {
// Clear selection when leaving multi-select mode
cbs.forEach(function(cb) { cb.checked = false; });
this.selectedPaths = [];
this.lastClickedPath = null;
// Remove selected class
document.querySelectorAll('.sidebar-item.gitea-selected').forEach(function(el) {
el.classList.remove('gitea-selected');
});
}
},
selectItem(path, li, ev) {
if (ev.shiftKey && this.lastClickedPath) {
// Range select
var all = Array.from(document.querySelectorAll('.sidebar-item[data-gitea-path]'));
var startIdx = all.findIndex(function(el) { return el.getAttribute('data-gitea-path') === this.lastClickedPath; }.bind(this));
var endIdx = all.findIndex(function(el) { return el.getAttribute('data-gitea-path') === path; }.bind(this));
if (startIdx >= 0 && endIdx >= 0) {
var lo = Math.min(startIdx, endIdx);
var hi = Math.max(startIdx, endIdx);
this.selectedPaths = [];
for (var i = lo; i <= hi; i++) {
this.selectedPaths.push(all[i].getAttribute('data-gitea-path'));
all[i].classList.add('gitea-selected');
var cb = all[i].querySelector('.gitea-checkbox');
if (cb) cb.checked = true;
}
}
this.multiSelect = true;
this.toggleMultiSelect(); // ensure checkboxes are visible
} else if (ev.ctrlKey || ev.metaKey) {
// Toggle single
var idx = this.selectedPaths.indexOf(path);
if (idx >= 0) {
this.selectedPaths.splice(idx, 1);
li.classList.remove('gitea-selected');
var cb = li.querySelector('.gitea-checkbox');
if (cb) cb.checked = false;
} else {
this.selectedPaths.push(path);
li.classList.add('gitea-selected');
var cb = li.querySelector('.gitea-checkbox');
if (cb) cb.checked = true;
}
this.multiSelect = this.selectedPaths.length > 0;
this.toggleMultiSelect();
} else {
// Normal select — clear multi-selection
this.selectedPaths = [path];
this.lastClickedPath = path;
document.querySelectorAll('.sidebar-item.gitea-selected').forEach(function(el) {
el.classList.remove('gitea-selected');
});
li.classList.add('gitea-selected', 'active');
var cb = li.querySelector('.gitea-checkbox');
if (cb) cb.checked = true;
}
},
isSelected(path) {
return this.selectedPaths.indexOf(path) >= 0;
},
// ── Inline rename ──
startInlineRename(nameSpan, path, li) {
var originalName = nameSpan.textContent;
var input = document.createElement('input');
input.type = 'text';
input.value = originalName;
input.className = 'inline-rename-input';
input.style.cssText = 'flex:1;min-width:0;padding:2px 6px;background:var(--bg-tertiary);border:1px solid var(--accent);border-radius:4px;color:var(--text);font-size:13px;outline:none;';
nameSpan.replaceWith(input);
input.focus();
input.select();
var self = this;
var finish = function() { self.finishInlineRename(input, originalName, path, li); };
var cancel = function() {
var span = document.createElement('span');
span.className = 'page-name';
span.textContent = originalName;
input.replaceWith(span);
};
input.addEventListener('blur', finish);
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') finish();
if (e.key === 'Escape') { e.stopPropagation(); cancel(); }
});
},
finishInlineRename(input, originalName, path, li) {
if (input._renaming) return; // guard against double-fire
input._renaming = true;
var newName = input.value.trim();
if (!newName || newName === originalName) {
var span = document.createElement('span');
span.className = 'page-name';
span.textContent = originalName;
input.replaceWith(span);
return;
}
var self = this;
// Construct new path by replacing the last segment
var parts = path.split('/');
parts.pop();
var newPath = (parts.length > 0 ? parts.join('/') + '/' : '') + newName;
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/rename', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken()},
body: JSON.stringify({old_path: path, new_path: newPath, new_name: newName})
}).then(function(r) {
if (r.ok) {
self.refreshTree();
} else {
var span = document.createElement('span');
span.className = 'page-name';
span.textContent = originalName;
input.replaceWith(span);
window.showToast('Rename failed', 'error');
}
}).catch(function() {
var span = document.createElement('span');
span.className = 'page-name';
span.textContent = originalName;
input.replaceWith(span);
});
},
};
});
});
</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/gitea_workspace.js?v={{ asset_version }}"></script>
<div class="gw-main" x-data="giteaWorkspace">
<!-- File view -->
<div x-show="showFile && !showEditor" x-transition>
+1 -159
View File
@@ -210,164 +210,6 @@ select,input[type=text]{background:var(--bg3);color:var(--text);border:1px solid
</div>
</div>
<script data-cfasync="false">
function importWizard() {
return {
sources: [],
sourceId: '',
files: [],
dragOver: false,
mode: 'skip',
busy: false,
progress: 0,
error: '',
preview: null,
report: null,
mapping: {},
csrf: '',
urlValue: '',
urlError: '',
forgeProvider: 'gitea',
forgeOwner: '',
forgeRepo: '',
forgeState: 'all',
forgeError: '',
types: ['text','number','date','checkbox','email','url','phone','select','multi_select','status'],
async init() {
try {
const r = await fetch('/api/import/sources');
this.sources = (await r.json()).sources || [];
} catch(e) {}
try {
const c = await fetch('/api/csrf-token');
this.csrf = (await c.json()).csrf_token;
} catch(e) {}
},
humanSize(n) { return n > 1048576 ? (n/1048576).toFixed(1)+' Mo' : Math.max(1, Math.round(n/1024))+' Ko'; },
addFiles(list) {
for (const f of list) this.files.push({file:f, name:f.name, size:f.size, status:'queued'});
this.preview = null; this.report = null; this.error = '';
},
onFiles(e) { this.addFiles(e.target.files); e.target.value=''; },
onDrop(e) { this.dragOver=false; this.addFiles(e.dataTransfer.files); },
buildForm(f) {
const fd = new FormData();
fd.append('file', f.file);
if (this.sourceId) fd.append('source', this.sourceId);
fd.append('mode', this.mode);
return fd;
},
async doPreview() {
this.busy = true; this.error=''; this.report=null; this.progress=10;
try {
let merged = null;
for (let i=0;i<this.files.length;i++) {
const r = await fetch('/api/import/preview', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:this.buildForm(this.files[i])});
const d = await r.json();
if (!r.ok) { this.error = (this.files[i].name+': '+(d.detail||'Erreur')); continue; }
if (!merged) merged = {source_label:d.source_label, pages:[], warnings:[], stats:{}};
merged.pages.push(...(d.pages||[]));
merged.warnings.push(...(d.warnings||[]));
this.progress = Math.round(((i+1)/this.files.length)*100);
}
this.preview = merged;
this.mapping = {};
(merged ? merged.pages : []).forEach(p => (p.schema||[]).forEach(c => { this.mapping[c.name]=c.type; }));
} catch(e) { this.error = 'Erreur réseau'; }
finally { this.busy = false; setTimeout(()=>{this.progress=0;},800); }
},
async importOne(f) {
f.status = 'running';
const fd = this.buildForm(f);
if (Object.keys(this.mapping).length) fd.append('mapping', JSON.stringify(this.mapping));
const big = f.size > 5*1024*1024;
try {
if (big) {
fd.append('async','true');
const r = await fetch('/api/import/run', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:fd});
const d = await r.json();
if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; }
return await this.pollJob(d.job_id, f);
}
const r = await fetch('/api/import/run', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:fd});
const d = await r.json();
if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; }
f.status = (d.status==='partial' ? 'partial' : 'done');
return d;
} catch(e) { f.status='error'; f.error='Erreur réseau'; return null; }
},
async doImport() {
this.busy = true; this.error=''; this.report=null; this.progress=0;
const aggregate = {pages_created:0,pages_updated:0,collections_created:0,rows_created:0,
attachments:0,skipped:0,warnings:[],errors:[],per_file:[]};
for (let i=0;i<this.files.length;i++) {
const d = await this.importOne(this.files[i]);
if (d) {
for (const k of ['pages_created','pages_updated','collections_created','rows_created','attachments','skipped'])
aggregate[k] += (d[k]||0);
aggregate.warnings.push(...(d.warnings||[]));
aggregate.errors.push(...(d.errors||[]));
aggregate.per_file.push({filename:this.files[i].name, report:d});
} else {
aggregate.errors.push({title:this.files[i].name, error:this.files[i].error||'Erreur'});
aggregate.per_file.push({filename:this.files[i].name, report:{status:'error'}});
}
this.progress = Math.round(((i+1)/this.files.length)*100);
}
this.report = aggregate;
this.busy = false;
},
async doUrl() {
this.busy = true; this.urlError = ''; this.report = null; this.progress = 40;
try {
const r = await fetch('/api/import/url', {
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
body: JSON.stringify({url:this.urlValue.trim()})
});
const d = await r.json();
if (!r.ok) { this.urlError = d.detail || 'Erreur'; return; }
this.report = d; this.progress = 100;
} catch(e) { this.urlError = 'Erreur réseau'; }
finally { this.busy = false; }
},
async doForge(kind) {
this.busy = true; this.forgeError = ''; this.report = null; this.progress = 30;
const endpoint = kind === 'repo' ? '/api/import/forge-repo' : '/api/import/forge';
try {
const r = await fetch(endpoint, {
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
body: JSON.stringify({
provider:this.forgeProvider, owner:this.forgeOwner.trim(),
repo:this.forgeRepo.trim(), state:this.forgeState
})
});
const d = await r.json();
if (!r.ok) { this.forgeError = d.detail || 'Erreur'; return; }
this.report = d; this.progress = 100;
} catch(e) { this.forgeError = 'Erreur réseau'; }
finally { this.busy = false; }
},
async pollJob(jobId, f) {
for (let i=0;i<600;i++) {
await new Promise(res => setTimeout(res, 700));
const r = await fetch('/api/import/jobs/'+jobId);
const j = await r.json();
if (j.status === 'done') { if (f) f.status='done'; return j.report; }
if (j.status === 'error') { if (f) { f.status='error'; f.error=j.error; } return null; }
}
if (f) { f.status='error'; f.error='Délai dépassé'; }
return null;
},
downloadReport() {
const blob = new Blob([JSON.stringify(this.report, null, 2)], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'flowdeck-import-report.json';
a.click();
URL.revokeObjectURL(a.href);
}
};
}
</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/import.js?v={{ asset_version }}"></script>
</body>
</html>
+1 -1
View File
@@ -206,7 +206,7 @@
FlowDeck v4.0.1 — Open source · Self-hosted · Notion-compatible
</footer>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
(function() {
if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function() {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -7,7 +7,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% endblock %} {% block content %}
{% include "_database_table.html" %}
{% endblock %} {% block scripts %}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// Initialize database table from server-rendered data
window.__DB_PAGE_ID = {{ page.id }};
window.__DB_COLLECTION_ID = {{ page.collection_id or 0 }};
+1 -1
View File
@@ -4,7 +4,7 @@
{% block topbar %}{% endblock %}
{% block content %}
{% include '_page_editor_content.html' %}
<script data-cfasync="false">document.body.classList.add('embed-mode');</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">document.body.classList.add('embed-mode');</script>
{% endblock %}
{% block scripts %}
{% include '_page_editor_scripts.html' %}
+1 -1
View File
@@ -173,7 +173,7 @@
</footer>
<script src="/static/js/katex.min.js?v=0.16.11"></script>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
document.addEventListener('DOMContentLoaded', function () {
if (window.katex) {
document.querySelectorAll('div[data-katex]').forEach(function (el) {
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -99,7 +99,7 @@
</div>
</div>
<script>
<script nonce="{{ csp_nonce() }}">
function tableView() {
return {
sortField: '',
+1 -1
View File
@@ -43,7 +43,7 @@
<div class="legend-item"><span class="legend-dot" style="background:var(--green);"></span> Complete</div>
</div>
<script>
<script nonce="{{ csp_nonce() }}">
function teamLoad() {
return {};
}
+1 -1
View File
@@ -62,7 +62,7 @@
{% endblock %}
{% block scripts %}
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function trashData() {
return {
search: '',
+1 -1
View File
@@ -133,7 +133,7 @@ h1{font-size:22px;font-weight:700;margin-bottom:6px;}
</section>
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function onboarding() {
return {
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
+1 -1
View File
@@ -140,7 +140,7 @@
</div>
<script data-cfasync="false">
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
function workspacePage() {
return {
builtinProjects: [],
+1 -154
View File
@@ -172,158 +172,5 @@
</div>
<script data-cfasync="false">
function workspacesPage() {
return {
workspaces: [],
activeId: null,
showCreate: false,
showRename: false,
wsName: '',
renameTarget: null,
// Gitea
giteaStatus: 'loading', // loading|ok|not_linked|error
giteaGroups: [],
giteaTotal: 0,
activeOrg: 'all',
giteaSearch: '',
get filteredGroups() {
var self = this;
var q = (this.giteaSearch || '').toLowerCase();
var groups = this.activeOrg === 'all' ? this.giteaGroups : this.giteaGroups.filter(function(g) { return g.org === self.activeOrg; });
if (!q) return groups;
return groups.map(function(g) {
return {org: g.org, avatar: g.avatar, projects: g.projects.filter(function(p) {
return p.name.toLowerCase().includes(q) || (p.description||'').toLowerCase().includes(q);
})};
}).filter(function(g) { return g.projects.length > 0; });
},
async init() {
await this.load();
await this.loadGitea();
},
async load() {
const r = await fetch('/api/workspaces');
const d = await r.json();
this.workspaces = d.workspaces || [];
this.activeId = d.active_id;
},
async selectLocal(ws) {
await fetch(`/api/workspaces/${ws.id}/select`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'POST'});
window.location = '/local-workspace';
},
async doCreate() {
if (!this.wsName.trim()) return;
await fetch('/api/workspaces', {
method:'POST',
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body:JSON.stringify({name:this.wsName.trim()})
});
this.wsName = '';
this.showCreate = false;
await this.load();
},
renameWs(ws) {
this.renameTarget = ws;
this.wsName = ws.name;
this.showRename = true;
},
async doRename() {
if (!this.wsName.trim()||!this.renameTarget) return;
await fetch(`/api/workspaces/${this.renameTarget.id}`, {
method:'PUT',
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body:JSON.stringify({name:this.wsName.trim()})
});
this.wsName = '';
this.showRename = false;
this.renameTarget = null;
await this.load();
},
async deleteWs(ws) {
if (!confirm(`Delete workspace "${ws.name}" and all its pages?`)) return;
await fetch(`/api/workspaces/${ws.id}`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE'});
await this.load();
},
// ── Gitea ──
async loadGitea() {
this.giteaStatus = 'loading';
try {
// Get orgs
const orgsRes = await fetch('/api/gitea/orgs');
if (orgsRes.status === 401) { this.giteaStatus = 'not_linked'; return; }
if (!orgsRes.ok) { this.giteaStatus = 'error'; return; }
const orgsData = await orgsRes.json();
const orgs = orgsData.orgs || [];
// Fetch repos: user repos + org repos
const groups = [];
// Get username for the "personal" tab
let myLogin = 'Me';
try {
const meRes = await fetch('/auth/user');
if (meRes.ok) {
const meData = await meRes.json();
myLogin = meData.user?.login || 'Me';
if (myLogin.includes('_')) myLogin = myLogin.split('_').pop(); // strip gitea_ prefix if present
}
} catch(e) {}
// User repos (no org filter)
try {
const userRes = await fetch('/api/gitea/projects');
if (userRes.ok) {
const d = await userRes.json();
const repos = d.projects || [];
if (repos.length) {
groups.push({org: myLogin, avatar: '', projects: repos.map(r => ({...r, owner: r.owner || {login: myLogin}}))});
}
}
} catch(e) {}
// Org repos
for (const org of orgs) {
try {
const res = await fetch('/api/gitea/projects?org=' + encodeURIComponent(org.username || org.login || org.name));
if (res.ok) {
const d = await res.json();
if (d.projects && d.projects.length) {
groups.push({org: org.username || org.login || org.name, avatar: org.avatar_url || '', projects: d.projects});
}
}
} catch(e) {}
}
this.giteaGroups = groups;
this.giteaTotal = groups.reduce((sum, g) => sum + g.projects.length, 0);
this.giteaStatus = 'ok';
} catch(e) {
this.giteaStatus = 'error';
}
},
openGitea(proj) {
const owner = proj.owner?.login || proj.owner?.username || proj.full_name?.split('/')[0] || '';
const repo = proj.name;
if (owner && repo) {
// Set workspace cookie so sidebar shows tree
document.cookie = 'flowdeck_workspace=gitea:' + owner + ':' + repo + ';path=/;SameSite=Lax';
window.location = `/gitea-workspace?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
}
}
};
}
</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/workspaces.js?v={{ asset_version }}"></script>
{% endblock %}
+7
View File
@@ -7,9 +7,15 @@ les `|safe` du codebase étaient des no-op.
from __future__ import annotations
import pathlib
from contextvars import ContextVar
from jinja2 import Environment, FileSystemLoader, select_autoescape
# A20 : nonce de script par requête, posé par le middleware CSP, lu par les
# templates via `{{ csp_nonce() }}` (vide hors requête — pas de header CSP
# dans ce cas, donc rien n'est bloqué).
CSP_NONCE: ContextVar[str] = ContextVar("csp_nonce", default="")
ENV = Environment(
loader=FileSystemLoader("app/templates"),
autoescape=select_autoescape(["html"]),
@@ -26,3 +32,4 @@ except OSError: # pragma: no cover
ASSET_VERSION = "dev"
ENV.globals["asset_version"] = ASSET_VERSION
ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get()
+518 -84
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.5.0"
"version": "7.23.0"
},
"paths": {
"/auth/register": {
@@ -10765,47 +10765,6 @@
}
}
},
"/api/library/local-workspace-children/{item_id}": {
"get": {
"tags": [
"library"
],
"summary": "Library Local Workspace Children",
"description": "Return children of a local workspace item for tree expansion.",
"operationId": "library_local_workspace_children_api_library_local_workspace_children__item_id__get",
"parameters": [
{
"name": "item_id",
"in": "path",
"required": true,
"schema": {
"type": "integer",
"title": "Item Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/library/children/{page_id}": {
"get": {
"tags": [
@@ -10899,48 +10858,6 @@
}
}
},
"/api/library/local-workspace": {
"get": {
"tags": [
"library"
],
"summary": "Library Local Workspace",
"description": "Return local workspace items (files/folders) formatted for Library display.",
"operationId": "library_local_workspace_api_library_local_workspace_get",
"parameters": [
{
"name": "workspace_id",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 0,
"title": "Workspace Id"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/library/workspace": {
"get": {
"tags": [
@@ -16725,6 +16642,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -16974,6 +16902,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17123,6 +17062,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17233,6 +17183,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17397,6 +17358,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17462,6 +17434,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17617,6 +17600,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17727,6 +17721,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -17837,6 +17842,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18003,6 +18019,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18178,6 +18205,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18288,6 +18326,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18454,6 +18503,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18564,6 +18624,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18674,6 +18745,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18849,6 +18931,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -18905,6 +18998,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19015,6 +19119,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19062,6 +19177,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19109,6 +19235,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19219,6 +19356,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19275,6 +19423,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19385,6 +19544,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19495,6 +19665,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19551,6 +19732,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19715,6 +19907,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19771,6 +19974,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -19881,6 +20095,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20125,6 +20350,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20217,6 +20453,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20365,6 +20612,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20421,6 +20679,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20531,6 +20800,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20753,6 +21033,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20809,6 +21100,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -20919,6 +21221,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21085,6 +21398,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21195,6 +21519,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21251,6 +21586,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21361,6 +21707,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21592,6 +21949,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21648,6 +22016,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -21861,6 +22240,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -22367,6 +22757,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -22618,6 +23019,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -22674,6 +23086,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
@@ -22961,6 +23384,17 @@
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"default": {},
"title": "Body"
}
}
}
},
"responses": {
"200": {
"description": "Successful Response",
+165
View File
@@ -0,0 +1,165 @@
/* ═════════════════════════════════════════════════════════════════════
fdCtx — store Alpine UNIQUE du menu contextuel partagé.
Enregistré dans 'alpine:init' avec garde d'exécution : que la page
inclue ce partial dans base.html ou directement, le js ne s'exécute
qu'une seule fois. Chaque page, à l'ouverture du menu, positionne :
fdCtx.openMenu(evt, node, pageHash, handlers)
─────────────────────────────────────────────────────────────────── */
(function () {
if (window.__fdCtxMenuRegistered) return;
window.__fdCtxMenuRegistered = true;
// Sur un chargement complet de page, ce script inline s'exécute AVANT
// alpine.min.js (defer) → Alpine n'existe pas encore : on attend 'alpine:init'.
// Sur une navigation partielle (fdNavigate → htmx), Alpine est déjà démarré et
// 'alpine:init' ne sera plus jamais émis → on enregistre le store tout de suite,
// sinon le menu contextuel reste mort jusqu'au prochain chargement complet.
function registerFdCtx() {
if (!window.Alpine) return;
if (window.Alpine.__fdCtxStore) return;
window.Alpine.__fdCtxStore = true;
Alpine.store('fdCtx', {
open: false, x: 0, y: 0, node: null, page: null,
maxH: 0, _cx: 0, _cy: 0, _ro: null,
handlers: {},
/* Tags (workspace) */
availTags: [], tagAdding: false, tagExistingOpen: false,
newTagColor: '#787774',
tagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
/* Icon picker */
iconOpen: false,
iconChoices: ['📄','📝','📕','📁','⭐','🔖','📌','✅','💡','🔥','🚀','🎯','📊','🗓️','🔗','🧩','📎','🎨','🐛','⚙️','❤️','👍','✨','🏷️','🗒️','📚'],
/* Positionne le menu à l'écran et expose les handlers de la page.
⚠️ Nom : openMenu (et PAS open) — « open » est réservé au booléen
d'état x-show. Une collision ici rendrait le menu PERMANENT. */
openMenu(ev, node, pageHash, handlers) {
handlers = handlers || {};
this.node = node;
this.page = pageHash;
this.handlers = handlers;
this.tagAdding = false;
this.tagExistingOpen = false;
this.iconOpen = false;
this.newTagColor = (node && node.tags && node.tags[0] && node.tags[0].color) || '#787774';
var cx = (ev && ev.clientX) || 0;
var cy = (ev && ev.clientY) || 0;
this._cx = cx;
this._cy = cy;
this.x = Math.max(8, Math.min(cx, window.innerWidth - 240));
this.y = Math.max(8, Math.min(cy, window.innerHeight - 48));
this.open = true;
var self = this;
/* Mesure la taille réelle du menu (une fois rendu) puis le replace
pour qu'il reste TOUJOURS entièrement visible dans le viewport.
Un ResizeObserver relance le placement à chaque fois que le menu
change de taille (ouverture d'un sous-menu « tag », icônes, …),
sinon il grandissait vers le bas et sortait de l'écran. */
var after = function () {
self._place();
var el = document.querySelector('.fd-ctx-menu');
if (el && window.ResizeObserver) {
if (self._ro) { try { self._ro.disconnect(); } catch (e) {} }
self._ro = new ResizeObserver(function () { self._place(); });
self._ro.observe(el);
}
};
if (window.Alpine && window.Alpine.nextTick) window.Alpine.nextTick(after);
else setTimeout(after, 0);
},
/* Reclasse le menu dans le viewport et limite sa hauteur à l'espace
disponible sous son ancre (le contenu déborde en scroll interne). */
_place() {
var el = document.querySelector('.fd-ctx-menu');
if (!el) return;
var prev = el.style.maxHeight;
el.style.maxHeight = 'none';
var w = el.offsetWidth || 240;
var h = el.offsetHeight || 320;
el.style.maxHeight = prev || '';
var vw = window.innerWidth, vh = window.innerHeight;
var cx = (this._cx == null ? this.x : this._cx);
var cy = (this._cy == null ? this.y : this._cy);
var nx = cx;
if (nx + w > vw - 8) nx = vw - w - 8;
nx = Math.max(8, nx);
var ny = cy;
if (ny + h > vh - 8) {
if (cy - h >= 8) ny = cy - h;
else ny = Math.max(8, vh - h - 8);
}
this.x = nx;
this.y = ny;
this.maxH = Math.max(120, vh - ny - 8);
},
close() {
if (this._ro) { try { this._ro.disconnect(); } catch (e) {} this._ro = null; }
this.open = false;
this.node = null;
this.handlers = {};
this.tagAdding = false;
this.tagExistingOpen = false;
this.iconOpen = false;
},
/* Un item est-il disponible pour CETTE page ? (union vs capacités) */
has(key) { return typeof this.handlers[key] === 'function'; },
/* Exécute l'action → handler fourni par la page courante. */
run(key) {
if (!this.node) { this.close(); return; }
var fn = this.handlers[key];
if (fn) try { fn(this.node); } catch (e) { console.error('fdCtx.run', e); }
this.close();
},
/* ── Tags ── */
avail() { return this.availTags || []; },
setAvail(a) { this.availTags = a || []; },
removeTag(id) {
var fn = this.handlers.tagRemove;
if (fn) try { fn(id); } catch (e) { console.error('fdCtx.removeTag', e); }
},
addExistingTag(t) {
var fn = this.handlers.tagExisting;
if (fn) try { fn(t); } catch (e) { console.error('fdCtx.addExistingTag', e); }
this.tagExistingOpen = false;
},
addNewTag(name, color) {
name = (name || '').trim();
if (!name) return;
var fn = this.handlers.tagAdd;
if (fn) try { fn(name, color || this.newTagColor); } catch (e) { console.error('fdCtx.addNewTag', e); }
this.tagAdding = false;
},
/* ── Icon picker ── */
setIcon(icon) {
icon = (icon || '').trim();
var fn = this.handlers.setIcon;
if (fn) try { fn(icon); } catch (e) { console.error('fdCtx.setIcon', e); }
this.close();
},
openIconPicker() {
var fn = this.handlers.setIcon;
if (!fn) return;
if (!window.FDIconPicker) return;
window.FDIconPicker.openFor({
x: this.x,
y: this.y,
onPick: fn,
onRemove: function () { fn(''); }
});
this.close();
},
});
}
if (window.Alpine) {
registerFdCtx();
} else {
document.addEventListener('alpine:init', registerFdCtx);
}
})();
+77
View File
@@ -0,0 +1,77 @@
/* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */
(function () {
var FD_ICONS = {
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
'file': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
'bot': '<rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v3"/><line x1="12" y1="20" x2="12" y2="24"/><circle cx="8" cy="13" r="1"/><circle cx="16" cy="13" r="1"/>',
'users': '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
'book': '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>',
'check-square': '<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
'help-circle': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
'refresh': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
'message-square': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
'bell': '<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
'image': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
'chevron-left': '<polyline points="15 18 9 12 15 6"/>',
'chevron-right': '<polyline points="9 18 15 12 9 6"/>',
'list': '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
'bar-chart': '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
'grid': '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
'align-left': '<line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="17" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/>',
'corner-down-right': '<polyline points="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/>',
'copy': '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
'key': '<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
'inbox': '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
'edit': '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>',
'eye-off': '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/>',
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
'share': '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>',
'more-horizontal': '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
'paperclip': '<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
'sparkles': '<path d="M12 3l1.9 5.8 6.1.5-4.9 3.6 1.7 5.8-4.8-3.5-4.8 3.5 1.7-5.8-4.9-3.6 6.1-.5z"/>',
'lightbulb': '<path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/>',
'tag': '<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>',
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
'save': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
'trending-up': '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'user': '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>'
};
window.FD_ICONS = FD_ICONS;
window.fd_icon = function (name, size) {
size = size || 18;
var inner = FD_ICONS[name];
if (inner) return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + inner + '</svg>';
return (window.getSvgIcon ? getSvgIcon(name, size) : '');
};
/* Render a page_icon value: image URL, known icon name, or emoji text. */
window.fdIconHtml = function (value, size) {
size = size || 16;
var v = (value == null ? '' : String(value)).trim();
if (!v) return '';
if (v.charAt(0) === '/' || v.slice(0, 4) === 'http') {
return '<img src="' + v.replace(/"/g, '&quot;') + '" alt="" style="width:' + size + 'px;height:' + size + 'px;object-fit:contain;vertical-align:middle;display:inline-block;">';
}
if (FD_ICONS[v] || (window.getSvgIcon && getSvgIcon(v, size))) return window.fd_icon(v, size);
return v;
};
})();
+220
View File
@@ -0,0 +1,220 @@
(function () {
if (window.__fdIconPickerRegistered) return;
window.__fdIconPickerRegistered = true;
var TONES = ['', '\u{1F3FB}', '\u{1F3FC}', '\u{1F3FD}', '\u{1F3FE}', '\u{1F3FF}'];
var TONEABLE = {};
['👋','🤚','🖐️','✋','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘','🤙','👈','👉','👆','👇','☝️','👍','👎','✊','👊','🤛','🤜','👏','🙌','🫶','👐','🤲','🤝','🙏','💪','🦵','🦶','👂','👃','👶','🧒','👦','👧','🧑','👨','👩','🧓','👴','👵','🙍','🙎','🙅','🙆','💁','🙋','🧏','🙇','🤦','🤷','👮','🕵️','💂','👷','🤴','👸','👳','👲','🧕','🤵','👰','🤰','🤱','👼','🎅','🤶','🦸','🦹','🧙','🧚','🧛','🧜','🧝','💆','💇','🚶','🧍','🧎','🏃','💃','🕺','👯','🧖','🧗','🏇','⛷️','🏂','🏌️','🏄','🚣','🏊','⛹️','🏋️','🚴','🚵','🤸','🤼','🤽','🤾','🤹','🧘','🛀','🛌'].forEach(function (e) { TONEABLE[e] = true; });
var CATS = [
{ id: 'people', label: 'People', items: [
['😀','grinning face smile happy'],['😃','smiley happy'],['😄','smile laugh happy'],['😁','grin happy'],['😆','laughing happy'],['😅','sweat smile'],['🤣','rofl rolling laugh'],['😂','joy tears laugh'],['🙂','slight smile'],['🙃','upside down'],['😉','wink'],['😊','blush smile happy'],['😇','innocent halo angel'],['🥰','love hearts'],['😍','heart eyes love'],['🤩','star struck wow'],['😘','kiss love'],['😗','kissing'],['😚','kissing'],['😙','kissing'],['🥲','tear smile happy'],['😋','yum tasty'],['😛','tongue'],['😜','wink tongue'],['🤪','crazy zany'],['😝','tongue'],['🤑','money rich'],['🤗','hug'],['🤭','giggle'],['🤫','shush quiet'],['🤔','thinking'],['🤐','zip quiet'],['🤨','raised eyebrow'],['😐','neutral'],['😑','expressionless'],['😶','no mouth'],['😏','smirk'],['😒','unamused'],['🙄','roll eyes'],['😬','grimace'],['🤥','lying'],['😌','relieved'],['😔','pensive sad'],['😪','sleepy'],['🤤','drool'],['😴','sleeping'],['😷','mask sick'],['🤒','sick thermometer'],['🤕','hurt bandage'],['🤢','nauseated'],['🤮','vomit'],['🤧','sneeze'],['🥵','hot'],['🥶','cold'],['🥴','woozy'],['😵','dizzy'],['🤯','mind blown'],['🤠','cowboy'],['🥳','party'],['🥺','pleading'],['😎','cool sunglasses'],['🤓','nerd'],['🧐','monocle'],['😕','confused'],['😟','worried'],['🙁','frown'],['☹️','frown sad'],['😮','surprised'],['😯','hushed'],['😲','astonished'],['😳','flushed'],['😨','fearful'],['😰','anxious'],['😥','sad'],['😢','cry'],['😭','sob cry'],['😱','scream fear'],['😖','confounded'],['😣','persevere'],['😞','disappointed'],['😓','sweat'],['😩','weary'],['😫','tired'],['🥱','yawn'],['😤','triumph'],['😡','angry'],['😠','rage'],['🤬','cursing'],['😈','devil'],['👿','imp'],['💀','skull'],['💩','poop'],['🤡','clown'],['👻','ghost'],['👽','alien'],['🤖','robot'],['😺','cat'],['🙈','monkey see'],['🙉','monkey hear'],['🙊','monkey speak'],['👋','wave hand'],['🤚','raised hand'],['🖐️','hand'],['✋','raised hand'],['🖖','vulcan'],['👌','ok'],['🤌','pinched'],['🤏','pinch'],['✌️','peace'],['🤞','cross fingers luck'],['🤟','love you'],['🤘','rock'],['🤙','call me'],['👈','point left'],['👉','point right'],['👆','point up'],['👇','point down'],['☝️','point up'],['👍','thumbs up like'],['👎','thumbs down dislike'],['✊','fist'],['👊','fist bump'],['🤛','fist'],['🤜','fist'],['👏','clap'],['🙌','raise hands celebrate'],['🫶','heart hands'],['👐','open hands'],['🤲','palms'],['🤝','handshake'],['🙏','pray thanks'],['💪','muscle strong'],['👂','ear'],['👃','nose'],['👀','eyes look'],['👁️','eye'],['🧠','brain'],['👶','baby'],['🧒','child'],['👦','boy'],['👧','girl'],['🧑','person'],['👨','man'],['👩','woman'],['🧓','older person'],['👴','old man'],['👵','old woman'],['👮','police'],['🕵️','detective'],['💂','guard'],['👷','worker'],['🤴','prince'],['👸','princess'],['👳','turban'],['🧕','hijab'],['🤵','tuxedo'],['👰','bride'],['🤰','pregnant'],['🤱','breastfeeding'],['👼','angel'],['🎅','santa'],['🤶','mrs claus'],['🦸','superhero'],['🦹','supervillain'],['🧙','mage wizard'],['🧚','fairy'],['🧛','vampire'],['🧜','mermaid'],['🧝','elf'],['💆','massage'],['💇','haircut'],['🚶','walk'],['🏃','run'],['💃','dance'],['🕺','dance'],['👯','people dancing'],['🧗','climb'],['🏇','horse race'],['🏂','snowboard'],['🏄','surf'],['🚣','row'],['🏊','swim'],['🚴','bike'],['🚵','mountain bike'],['🤸','cartwheel'],['🤼','wrestle'],['🤽','water polo'],['🤾','handball'],['🤹','juggle'],['🧘','meditate yoga'],['🛌','sleeping bed'],['💋','kiss mark'],['💌','love letter'],['❤️','heart love red'],['🧡','orange heart'],['💛','yellow heart'],['💚','green heart'],['💙','blue heart'],['💜','purple heart'],['🖤','black heart'],['🤍','white heart'],['🤎','brown heart'],['💔','broken heart'],['❣️','heart exclamation'],['💕','two hearts'],['💞','revolving hearts'],['💓','beating heart'],['💗','growing heart'],['💖','sparkling heart'],['💘','cupid heart'],['💝','heart gift'],['✨','sparkles'],['⭐','star'],['🌟','glowing star'],['💫','dizzy star'],['💥','boom'],['💯','hundred perfect']
]},
{ id: 'nature', label: 'Nature', items: [
['🐶','dog'],['🐱','cat'],['🐭','mouse'],['🐹','hamster'],['🐰','rabbit'],['🦊','fox'],['🐻','bear'],['🐼','panda'],['🐨','koala'],['🐯','tiger'],['🦁','lion'],['🐮','cow'],['🐷','pig'],['🐸','frog'],['🐵','monkey'],['🐔','chicken'],['🐧','penguin'],['🐦','bird'],['🐤','chick'],['🦆','duck'],['🦅','eagle'],['🦉','owl'],['🦇','bat'],['🐺','wolf'],['🐗','boar'],['🐴','horse'],['🦄','unicorn'],['🐝','bee'],['🐛','bug'],['🦋','butterfly'],['🐌','snail'],['🐞','ladybug'],['🐜','ant'],['🦗','cricket'],['🕷️','spider'],['🦂','scorpion'],['🐢','turtle'],['🐍','snake'],['🦎','lizard'],['🦖','dinosaur'],['🐙','octopus'],['🦑','squid'],['🦐','shrimp'],['🦀','crab'],['🐡','fish'],['🐠','fish'],['🐟','fish'],['🐬','dolphin'],['🐳','whale'],['🦈','shark'],['🐊','crocodile'],['🌵','cactus'],['🎄','tree christmas'],['🌲','tree evergreen'],['🌳','tree'],['🌴','palm tree'],['🌱','seedling plant'],['🌿','herb leaf'],['☘️','shamrock'],['🍀','clover luck'],['🎍','bamboo'],['🌾','wheat'],['🌷','tulip flower'],['🌹','rose flower'],['🌺','hibiscus flower'],['🌸','cherry blossom'],['🌼','flower'],['🌻','sunflower'],['🌞','sun'],['🌝','moon'],['🌚','moon'],['🌙','moon crescent'],['⭐','star'],['🌟','star'],['☀️','sun sunny'],['⛅','cloud sun'],['☁️','cloud'],['🌧️','rain'],['⛈️','storm'],['🌩️','lightning'],['❄️','snowflake'],['☃️','snowman'],['⛄','snowman'],['🔥','fire'],['💧','droplet water'],['🌊','wave water'],['🌈','rainbow'],['🌍','earth globe'],['🌎','earth globe'],['🌏','earth globe']
]},
{ id: 'food', label: 'Food', items: [
['🍏','apple green'],['🍎','apple red'],['🍐','pear'],['🍊','orange tangerine'],['🍋','lemon'],['🍌','banana'],['🍉','watermelon'],['🍇','grapes'],['🍓','strawberry'],['🫐','blueberry'],['🍈','melon'],['🍒','cherry'],['🍑','peach'],['🥭','mango'],['🍍','pineapple'],['🥥','coconut'],['🥝','kiwi'],['🍅','tomato'],['🍆','eggplant'],['🥑','avocado'],['🥦','broccoli'],['🥬','lettuce'],['🥒','cucumber'],['🌶️','pepper hot'],['🌽','corn'],['🥕','carrot'],['🧄','garlic'],['🧅','onion'],['🥔','potato'],['🍠','sweet potato'],['🥐','croissant'],['🥯','bagel'],['🍞','bread'],['🥖','baguette'],['🧀','cheese'],['🥚','egg'],['🍳','cooking egg'],['🥓','bacon'],['🥩','meat steak'],['🍗','chicken leg'],['🍖','meat'],['🌭','hot dog'],['🍔','burger'],['🍟','fries'],['🍕','pizza'],['🥪','sandwich'],['🥙','pita'],['🌮','taco'],['🌯','burrito'],['🥗','salad'],['🍝','pasta spaghetti'],['🍜','ramen noodles'],['🍲','stew'],['🍛','curry rice'],['🍣','sushi'],['🍱','bento'],['🥟','dumpling'],['🍤','shrimp fried'],['🍙','rice ball'],['🍚','rice'],['🍘','rice cracker'],['🍥','fish cake'],['🥠','fortune cookie'],['🍢','oden'],['🍡','dango'],['🍧','shaved ice'],['🍨','ice cream'],['🍦','ice cream'],['🥧','pie'],['🧁','cupcake'],['🍰','cake'],['🎂','birthday cake'],['🍮','custard'],['🍭','lollipop'],['🍬','candy'],['🍫','chocolate'],['🍿','popcorn'],['🍩','donut'],['🍪','cookie'],['☕','coffee'],['🍵','tea'],['🧃','juice'],['🥤','cup drink'],['🍺','beer'],['🍻','beers cheers'],['🥂','champagne'],['🍷','wine'],['🥃','whiskey'],['🍸','cocktail'],['🍹','tropical drink'],['🧉','mate'],['🍾','champagne bottle']
]},
{ id: 'activity', label: 'Activity', items: [
['⚽','soccer football'],['🏀','basketball'],['🏈','football'],['⚾','baseball'],['🥎','softball'],['🎾','tennis'],['🏐','volleyball'],['🏉','rugby'],['🥏','frisbee'],['🎱','pool billiards'],['🪀','yo-yo'],['🏓','ping pong'],['🏸','badminton'],['🏒','hockey'],['🏑','field hockey'],['🥍','lacrosse'],['🏏','cricket'],['🥅','goal'],['⛳','golf'],['🏹','archery'],['🎣','fishing'],['🥊','boxing'],['🥋','martial arts'],['🎽','running shirt'],['🛹','skateboard'],['🛼','roller skate'],['🛷','sled'],['⛸️','ice skate'],['🥌','curling'],['🎿','ski'],['⛷️','ski'],['🏂','snowboard'],['🏋️','weight lift'],['🤼','wrestle'],['🤸','cartwheel'],['⛹️','basketball'],['🤺','fencing'],['🤾','handball'],['🏌️','golf'],['🏇','horse race'],['🧘','yoga meditate'],['🏄','surf'],['🏊','swim'],['🤽','water polo'],['🚣','row'],['🧗','climb'],['🚴','bike'],['🚵','mountain bike'],['🎪','circus'],['🎭','theater masks'],['🎨','art palette'],['🎬','clapper film'],['🎤','microphone'],['🎧','headphones'],['🎼','music score'],['🎹','piano'],['🥁','drum'],['🎷','saxophone'],['🎺','trumpet'],['🎸','guitar'],['🪕','banjo'],['🎻','violin'],['🎲','dice random game'],['♟️','chess'],['🎯','target dart'],['🎳','bowling'],['🎮','game controller'],['🎰','slot machine'],['🧩','puzzle'],['🏆','trophy win'],['🥇','gold medal first'],['🥈','silver medal'],['🥉','bronze medal'],['🏅','medal'],['🎖️','military medal'],['🎗️','reminder ribbon'],['🎫','ticket'],['🎟️','tickets'],['🎁','gift present'],['🎉','party popper celebrate'],['🎊','confetti'],['🎈','balloon'],['🎂','cake birthday'],['🎃','pumpkin halloween'],['🎄','christmas tree'],['🎆','fireworks'],['🎇','fireworks'],['🧨','firecracker'],['✨','sparkles'],['🎓','graduation cap']
]},
{ id: 'travel', label: 'Travel', items: [
['🚗','car'],['🚕','taxi'],['🚙','car suv'],['🚌','bus'],['🚎','trolley bus'],['🏎️','race car'],['🚓','police car'],['🚑','ambulance'],['🚒','fire truck'],['🚐','van'],['🛻','pickup truck'],['🚚','truck'],['🚛','truck'],['🚜','tractor'],['🏍️','motorcycle'],['🛵','scooter'],['🚲','bicycle'],['🛴','kick scooter'],['🚨','police light'],['🚔','police car'],['🚍','bus'],['🚝','monorail'],['🚄','train'],['🚅','train bullet'],['🚈','train'],['🚂','locomotive train'],['🚆','train'],['🚇','metro subway'],['🚊','tram'],['🚉','station'],['✈️','airplane flight'],['🛫','airplane takeoff'],['🛬','airplane landing'],['🛩️','plane'],['💺','seat'],['🚁','helicopter'],['🛸','ufo'],['🚀','rocket launch'],['🛰️','satellite'],['🚢','ship'],['⛵','sailboat'],['🛥️','motor boat'],['🚤','speedboat'],['⛴️','ferry'],['🛳️','cruise ship'],['⚓','anchor'],['🚧','construction'],['⛽','fuel gas'],['🚏','bus stop'],['🗺️','map world'],['🗿','moai'],['🗽','statue liberty'],['🗼','tokyo tower'],['🏰','castle'],['🏯','castle japanese'],['🏟️','stadium'],['🎡','ferris wheel'],['🎢','roller coaster'],['🎠','carousel'],['⛲','fountain'],['⛱️','beach umbrella'],['🏖️','beach'],['🏝️','island'],['🏜️','desert'],['🌋','volcano'],['⛰️','mountain'],['🏔️','snow mountain'],['🗻','mount fuji'],['🏕️','camping'],['🏠','house home'],['🏡','house garden'],['🏢','office building'],['🏥','hospital'],['🏦','bank'],['🏨','hotel'],['🏫','school'],['🏭','factory'],['🏛️','classical building'],['⛪','church'],['🕌','mosque'],['🕍','synagogue'],['🛕','temple'],['🗼','tower'],['🌆','city sunset'],['🌃','night city'],['🌉','bridge night'],['🌌','milky way'],['🌠','shooting star'],['🌅','sunrise'],['🌄','sunrise mountain'],['🌇','sunset city']
]},
{ id: 'objects', label: 'Objects', items: [
['⌚','watch'],['📱','phone mobile'],['💻','laptop computer'],['⌨️','keyboard'],['🖥️','desktop computer'],['🖨️','printer'],['🖱️','mouse computer'],['💽','minidisc'],['💾','floppy disk save'],['💿','cd disk'],['📀','dvd'],['🧮','abacus'],['🎥','movie camera'],['🎞️','film frames'],['📽️','projector'],['📺','tv television'],['📷','camera'],['📸','camera flash'],['📹','video camera'],['📼','videocassette'],['🔍','magnifying search'],['🔎','magnifying search'],['🕯️','candle'],['💡','bulb idea'],['🔦','flashlight'],['🏮','lantern'],['🪔','lamp diya'],['📔','notebook'],['📕','book closed'],['📖','book open'],['📗','book green'],['📘','book blue'],['📙','book orange'],['📚','books library'],['📓','notebook'],['📒','ledger'],['📃','page'],['📜','scroll'],['📄','page document'],['📰','newspaper'],['🗞️','newspaper'],['📑','bookmark tabs'],['🔖','bookmark'],['🏷️','label tag'],['💰','money bag'],['🪙','coin'],['💴','yen'],['💵','dollar'],['💶','euro'],['💷','pound'],['💸','money wings'],['💳','credit card'],['🧾','receipt'],['✉️','envelope mail'],['📧','email'],['📨','envelope'],['📩','envelope'],['📤','outbox'],['📥','inbox'],['📦','package box'],['📫','mailbox'],['📪','mailbox'],['📬','mailbox'],['📭','mailbox'],['📮','postbox'],['🗳️','ballot box'],['✏️','pencil'],['✒️','pen nib'],['🖋️','pen'],['🖊️','pen'],['🖌️','paintbrush'],['🖍️','crayon'],['📝','memo note write'],['💼','briefcase work'],['📁','folder'],['📂','folder open'],['🗂️','card index'],['📅','calendar date'],['📆','calendar'],['🗒️','notepad'],['🗓️','calendar'],['📇','card index'],['📈','chart up trending'],['📉','chart down'],['📊','bar chart stats'],['📋','clipboard'],['📌','pushpin'],['📍','pin location'],['📎','paperclip attach'],['🖇️','paperclips'],['📏','ruler'],['📐','triangle ruler'],['✂️','scissors cut'],['🗃️','file box'],['🗄️','file cabinet'],['🗑️','trash waste'],['🔒','lock locked'],['🔓','lock open'],['🔑','key'],['🗝️','old key'],['🔨','hammer'],['🪓','axe'],['⛏️','pick'],['⚒️','tools'],['🛠️','tools'],['🔧','wrench'],['🔩','bolt nut'],['⚙️','gear settings'],['🧰','toolbox'],['🧲','magnet'],['🔫','water gun'],['💣','bomb'],['🧪','test tube science'],['🧫','petri dish'],['🧬','dna'],['🔬','microscope'],['🔭','telescope'],['📡','satellite antenna'],['💉','syringe'],['💊','pill medicine'],['🩹','bandage'],['🩺','stethoscope'],['🚪','door'],['🛏️','bed'],['🛋️','couch'],['🪑','chair'],['🚽','toilet'],['🚿','shower'],['🛁','bathtub'],['🧴','lotion'],['🧷','safety pin'],['🧹','broom'],['🧺','basket'],['🧻','toilet paper'],['🧼','soap'],['🪒','razor'],['🧽','sponge'],['🧯','extinguisher'],['🛒','cart shopping'],['🚬','cigarette'],['⚰️','coffin'],['🪦','headstone'],['⚱️','urn']
]},
{ id: 'symbols', label: 'Symbols', items: [
['❤️','heart love'],['🧡','orange heart'],['💛','yellow heart'],['💚','green heart'],['💙','blue heart'],['💜','purple heart'],['🖤','black heart'],['🤍','white heart'],['🤎','brown heart'],['💔','broken heart'],['❣️','heart'],['💕','hearts'],['💞','hearts'],['💓','heartbeat'],['💗','heart'],['💖','heart'],['💘','heart arrow'],['💝','heart gift'],['💟','heart decoration'],['☮️','peace'],['✝️','cross'],['☪️','star crescent'],['🕉️','om'],['☸️','dharma'],['✡️','star david'],['🔯','star'],['🕎','menorah'],['☯️','yin yang'],['☦️','orthodox cross'],['🛐','worship'],['⛎','ophiuchus'],['♈','aries'],['♉','taurus'],['♊','gemini'],['♋','cancer'],['♌','leo'],['♍','virgo'],['♎','libra'],['♏','scorpio'],['♐','sagittarius'],['♑','capricorn'],['♒','aquarius'],['♓','pisces'],['🆔','id'],['⚛️','atom'],['🉑','accept'],['☢️','radioactive'],['☣️','biohazard'],['📴','phone off'],['📳','vibrate'],['📵','no phone'],['🚭','no smoking'],['❗','exclamation'],['❕','exclamation'],['❓','question'],['❔','question'],['‼️','double exclamation'],['⁉️','exclamation question'],['🔅','dim'],['🔆','bright'],['〽️','part alternation'],['⚠️','warning'],['🚸','children crossing'],['🔱','trident'],['⚜️','fleur de lis'],['🔰','beginner'],['♻️','recycle'],['✅','check done'],['🈯','reserved'],['💹','chart yen'],['❇️','sparkle'],['✳️','asterisk'],['❎','cross mark'],['🌐','globe'],['💠','diamond'],['Ⓜ️','m'],['🌀','cyclone'],['💤','zzz sleep'],['🏧','atm'],['🚾','wc'],['♿','wheelchair'],['🅿️','parking'],['🈳','vacancy'],['🈂️','sa'],['🛂','passport control'],['🛃','customs'],['🛄','baggage'],['🛅','left luggage'],['🚹','men'],['🚺','women'],['🚼','baby'],['🚻','restroom'],['🚮','litter'],['🎦','cinema'],['📶','signal'],['🈁','here'],['🔣','symbols'],['ℹ️','info'],['🔤','abc'],['🔡','abcd'],['🔠','abcd'],['🆖','ng'],['🆗','ok'],['🆙','up'],['🆒','cool'],['🆕','new'],['🆓','free'],['0️⃣','zero'],['1️⃣','one'],['2️⃣','two'],['3️⃣','three'],['4️⃣','four'],['5️⃣','five'],['6️⃣','six'],['7️⃣','seven'],['8️⃣','eight'],['9️⃣','nine'],['🔟','ten'],['🔢','numbers'],['#️⃣','hash'],['*️⃣','asterisk'],['⏏️','eject'],['▶️','play'],['⏸️','pause'],['⏹️','stop'],['⏺️','record'],['⏭️','next'],['⏮️','previous'],['⏩','fast forward'],['⏪','rewind'],['⏫','up'],['⏬','down'],['◀️','left'],['🔼','up'],['🔽','down'],['➡️','right'],['⬅️','left'],['⬆️','up'],['⬇️','down'],['↗️','up right'],['↘️','down right'],['↙️','down left'],['↖️','up left'],['↕️','up down'],['↔️','left right'],['↩️','return'],['↪️','redo'],['⤴️','up'],['⤵️','down'],['🔀','shuffle'],['🔁','repeat'],['🔂','repeat one'],['🔄','refresh'],['🔃','refresh'],['🎵','music note'],['🎶','music notes'],['➕','plus'],['➖','minus'],['➗','divide'],['✖️','multiply'],['♾️','infinity'],['💲','dollar'],['💱','currency'],['™️','tm'],['©️','copyright'],['®️','registered'],['〰️','wavy'],['➰','curly loop'],['➿','double loop'],['🔚','end'],['🔙','back'],['🔛','on'],['🔝','top'],['🔜','soon'],['✔️','check'],['☑️','checkbox'],['🔘','radio'],['🔴','red circle'],['🟠','orange circle'],['🟡','yellow circle'],['🟢','green circle'],['🔵','blue circle'],['🟣','purple circle'],['⚫','black circle'],['⚪','white circle'],['🟤','brown circle'],['🔺','red triangle'],['🔻','red triangle'],['🔸','orange diamond'],['🔹','blue diamond'],['🔶','orange diamond'],['🔷','blue diamond'],['🔳','white square'],['🔲','black square'],['▪️','black square'],['▫️','white square'],['◾','black square'],['◽','white square'],['◼️','black square'],['◻️','white square'],['🟥','red square'],['🟧','orange square'],['🟨','yellow square'],['🟩','green square'],['🟦','blue square'],['🟪','purple square'],['⬛','black square'],['⬜','white square'],['🟫','brown square'],['🔈','speaker'],['🔇','mute'],['🔉','speaker'],['🔊','speaker loud'],['🔔','bell'],['🔕','bell off'],['📣','megaphone'],['📢','loudspeaker'],['💬','speech bubble'],['💭','thought bubble'],['🗯️','anger bubble'],['♠️','spade'],['♣️','club'],['♥️','heart suit'],['♦️','diamond suit'],['🃏','joker'],['🎴','flower cards'],['🀄','mahjong']
]},
{ id: 'flags', label: 'Flags', items: [
['🏁','chequered flag finish'],['🚩','triangular flag'],['🎌','crossed flags'],['🏴','black flag'],['🏳️','white flag'],['🏳️‍🌈','rainbow flag pride'],['🏴‍☠️','pirate flag'],['🇺🇸','usa united states'],['🇬🇧','uk united kingdom'],['🇫🇷','france french'],['🇩🇪','germany german'],['🇪🇸','spain spanish'],['🇮🇹','italy italian'],['🇵🇹','portugal'],['🇳🇱','netherlands'],['🇧🇪','belgium'],['🇨🇭','switzerland'],['🇦🇹','austria'],['🇸🇪','sweden'],['🇳🇴','norway'],['🇩🇰','denmark'],['🇫🇮','finland'],['🇮🇪','ireland'],['🇵🇱','poland'],['🇬🇷','greece'],['🇷🇺','russia'],['🇺🇦','ukraine'],['🇹🇷','turkey'],['🇨🇦','canada'],['🇲🇽','mexico'],['🇧🇷','brazil'],['🇦🇷','argentina'],['🇨🇱','chile'],['🇨🇴','colombia'],['🇨🇳','china chinese'],['🇯🇵','japan japanese'],['🇰🇷','korea south'],['🇮🇳','india'],['🇦🇺','australia'],['🇳🇿','new zealand'],['🇿🇦','south africa'],['🇪🇬','egypt'],['🇲🇦','morocco'],['🇳🇬','nigeria'],['🇰🇪','kenya'],['🇸🇦','saudi arabia'],['🇦🇪','uae emirates'],['🇮🇱','israel'],['🇸🇬','singapore'],['🇹🇭','thailand'],['🇻🇳','vietnam'],['🇮🇩','indonesia'],['🇵🇭','philippines'],['🇲🇾','malaysia'],['🇵🇰','pakistan'],['🇧🇩','bangladesh'],['🇺🇳','united nations']
]}
];
document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdIconPicker) return;
if (window.Alpine) window.Alpine.__fdIconPicker = true;
Alpine.store('fdIconPicker', {
open: false, x: 0, y: 0, tab: 'emoji', query: '', category: 'people',
skinTone: 0, toneOpen: false, recent: [], custom: [], customLoaded: false,
customModal: false, customName: '', customPreview: '', customFile: null, customBusy: false,
onPick: null, onRemove: null, _cx: 0, _cy: 0,
cats: CATS,
openFor(opts) {
opts = opts || {};
this.onPick = opts.onPick || null;
this.onRemove = opts.onRemove || null;
this.query = '';
this.toneOpen = false;
this.customModal = false;
this._cx = (opts.x != null) ? opts.x : 240;
this._cy = (opts.y != null) ? opts.y : 200;
this.x = this._cx;
this.y = this._cy;
this.open = true;
var self = this;
var place = function () {
var el = document.querySelector('.fd-icon-picker');
if (!el) return;
var w = el.offsetWidth || 344, h = el.offsetHeight || 440;
var vw = window.innerWidth, vh = window.innerHeight;
var nx = self._cx, ny = self._cy;
if (nx + w > vw - 8) nx = vw - w - 8;
if (ny + h > vh - 8) ny = vh - h - 8;
self.x = Math.max(8, nx);
self.y = Math.max(8, ny);
};
if (window.Alpine && Alpine.nextTick) Alpine.nextTick(place);
else setTimeout(place, 0);
this.loadRecent();
this.loadCustom();
},
close() {
this.open = false;
this.customModal = false;
this.toneOpen = false;
this.onPick = null;
this.onRemove = null;
},
matches(name) {
var q = (this.query || '').trim().toLowerCase();
if (!q) return true;
return name.toLowerCase().indexOf(q) >= 0;
},
items() {
var q = (this.query || '').trim().toLowerCase();
if (q) {
var out = [];
this.cats.forEach(function (c) {
c.items.forEach(function (it) {
if ((it[1] || '').toLowerCase().indexOf(q) >= 0 || it[0].indexOf(q) >= 0) out.push(it[0]);
});
});
return out;
}
if (this.category === 'recent') return this.recent;
var found = [];
for (var i = 0; i < this.cats.length; i++) {
if (this.cats[i].id === this.category) { found = this.cats[i].items.map(function (it) { return it[0]; }); break; }
}
return found;
},
withTone(e) {
if (!this.skinTone) return e;
if (TONEABLE[e]) return e + TONES[this.skinTone];
return e;
},
pick(v) {
v = (v || '').trim();
if (!v) return;
this.pushRecent(v);
var fn = this.onPick;
if (fn) { try { fn(v); } catch (e) { console.error('fdIconPicker.pick', e); } }
this.close();
},
remove() {
var fn = this.onRemove || this.onPick;
if (fn) { try { fn(''); } catch (e) {} }
this.close();
},
random() {
var pool = [];
this.cats.forEach(function (c) { c.items.forEach(function (it) { pool.push(it[0]); }); });
if (!pool.length) return;
this.pick(pool[Math.floor(Math.random() * pool.length)]);
},
setTone(i) { this.skinTone = i; this.toneOpen = false; },
setCategory(id) { this.category = id; this.tab = 'emoji'; this.query = ''; },
setTab(t) { this.tab = t; this.query = ''; if (t === 'upload') this.loadCustom(); },
loadRecent() {
try { this.recent = JSON.parse(localStorage.getItem('fd_icon_recent') || '[]'); }
catch (e) { this.recent = []; }
},
pushRecent(e) {
try {
var r = this.recent.filter(function (x) { return x !== e; });
r.unshift(e);
this.recent = r.slice(0, 32);
localStorage.setItem('fd_icon_recent', JSON.stringify(this.recent));
} catch (err) {}
},
async loadCustom() {
try {
var r = await fetch('/api/custom-emojis', { credentials: 'same-origin' });
var d = await r.json();
this.custom = (d && d.emojis) || [];
this.customLoaded = true;
} catch (e) { this.custom = []; }
},
openCustomModal() {
this.customModal = true;
this.customName = '';
this.customPreview = '';
this.customFile = null;
this.tab = 'upload';
},
closeCustomModal() { this.customModal = false; },
onCustomFile(ev) {
var f = ev.target.files && ev.target.files[0];
if (!f) return;
this.customFile = f;
var self = this;
var fr = new FileReader();
fr.onload = function () { self.customPreview = fr.result; };
fr.readAsDataURL(f);
if (!this.customName) this.customName = (f.name || '').replace(/\.[^.]+$/, '').slice(0, 40);
},
async saveCustom() {
if (!this.customFile || this.customBusy) return;
this.customBusy = true;
try {
var fd = new FormData();
fd.append('name', this.customName || 'emoji');
fd.append('file', this.customFile);
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
var r = await fetch('/api/custom-emojis', { method: 'POST', headers: { 'X-CSRF-Token': csrf }, body: fd, credentials: 'same-origin' });
var d = await r.json();
if (d && d.emoji) {
this.custom.unshift(d.emoji);
this.customModal = false;
this.pick(d.emoji.url);
}
} catch (e) {
if (window.showToast) window.showToast('Emoji upload failed', 'error');
}
this.customBusy = false;
},
async deleteCustom(id) {
try {
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
await fetch('/api/custom-emojis/' + id, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf }, credentials: 'same-origin' });
this.custom = this.custom.filter(function (e) { return e.id !== id; });
} catch (e) {}
}
});
});
window.FDIconPicker = {
openFor: function (opts) {
var s = window.Alpine && Alpine.store('fdIconPicker');
if (s) s.openFor(opts);
}
};
})();
+17
View File
@@ -0,0 +1,17 @@
(function(){
var defaultAPI = {
open: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle')); },
close: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle', { detail: { close: true } })); },
toggle: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle')); },
ask: function(){}, generate: function(){ return Promise.resolve(''); }
};
window.fdAgent = window.fdAgent || defaultAPI;
// Ctrl/Cmd+J — open / close the Agent panel from anywhere.
document.addEventListener('keydown', function(e){
if((e.ctrlKey || e.metaKey) && (e.key === 'j' || e.key === 'J')){
e.preventDefault();
if(window.fdAgent && window.fdAgent.toggle) window.fdAgent.toggle();
}
});
})();
File diff suppressed because it is too large Load Diff
+147
View File
@@ -0,0 +1,147 @@
const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.parse(el.textContent):{}}catch(e){return {}}})();
var owner = BD.owner;
var repo = BD.repo;
var initialView = BD.initial_view;
// Auto-switch to view from URL param
document.addEventListener('DOMContentLoaded', function() {
if (initialView && initialView !== 'kanban') {
const tab = document.querySelector(`.view-tab[data-view="${initialView}"]`);
if (tab) tab.click();
}
});
function setActiveTab(el) {
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
el.classList.add('active');
}
function kanbanBoard() {
return {
collapsedGroups: [],
toggleGroup(id) {
const idx = this.collapsedGroups.indexOf(id);
idx >= 0 ? this.collapsedGroups.splice(idx, 1) : this.collapsedGroups.push(id);
},
newCard(groupId, status) {
document.getElementById('new-issue-form').style.display = 'block';
document.querySelector('#new-issue-form').__x.$data.status = status;
},
newGroup() { console.log('New group'); }
};
}
function filterSystem() {
return {
activeFilters: [],
statusFilters: [],
showStatusMenu: false,
statusOptions: [
{ value: 'todo', label: 'To-do', color: 'var(--gray)' },
{ value: 'progress', label: 'In progress', color: 'var(--blue)' },
{ value: 'done', label: 'Complete', color: 'var(--green)' },
],
get statusFilterLabel() {
return this.statusFilters.length ? this.statusFilters.join(', ') : 'All';
},
toggleStatus(val) {
const idx = this.statusFilters.indexOf(val);
idx >= 0 ? this.statusFilters.splice(idx, 1) : this.statusFilters.push(val);
},
addFilter() {
const prop = prompt('Filter by property (status, assignee, label):');
if (!prop) return;
const val = prompt('Value:');
if (!val) return;
this.activeFilters.push({ property: prop, value: val });
this.refreshView();
},
removeFilter(i) { this.activeFilters.splice(i, 1); },
resetFilters() { this.activeFilters = []; this.statusFilters = []; },
refreshView() {
const activeTab = document.querySelector('.view-tab.active');
if (activeTab) activeTab.click();
}
};
}
function sortSystem() {
return {
sorts: [],
showSortPanel: false,
addSort() {
const field = prompt('Sort by (name, status, assignee, deadline):');
if (!field) return;
this.sorts.push({ field, dir: 'asc' });
this.applySorts();
},
removeSort(i) { this.sorts.splice(i, 1); this.applySorts(); },
toggleDir(i) { this.sorts[i].dir = this.sorts[i].dir === 'asc' ? 'desc' : 'asc'; this.applySorts(); },
clearSorts() { this.sorts = []; this.applySorts(); },
applySorts() {
const activeTab = document.querySelector('.view-tab.active');
const url = new URL(activeTab.getAttribute('hx-get'), window.location.origin);
this.sorts.forEach(s => url.searchParams.append('sort', s.field + ':' + s.dir));
htmx.ajax('GET', url.pathname + url.search, { target: '#view-content', swap: 'innerHTML' });
}
};
}
function newIssueForm() {
return {
title: '', status: 'todo',
create() {
if (!this.title.trim()) return;
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
})
.then(r => r.json())
.then(data => {
this.title = '';
this.hide();
// Refresh current view
const activeTab = document.querySelector('.view-tab.active');
if (activeTab) activeTab.click();
});
},
hide() { document.getElementById('new-issue-form').style.display = 'none'; },
show() { document.getElementById('new-issue-form').style.display = 'block'; }
};
}
function showNewIssue() {
const form = document.getElementById('new-issue-form');
form.style.display = form.style.display === 'none' ? 'block' : 'none';
}
// Init SortableJS after HTMX swaps
document.addEventListener('htmx:afterSwap', function(evt) {
if (evt.target.id === 'view-content') {
document.querySelectorAll('.kanban-cards').forEach(el => {
if (el._sortable) el._sortable.destroy();
el._sortable = new Sortable(el, {
group: 'kanban',
animation: 200,
ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
onEnd: function(evt) {
const cardId = evt.item.dataset.cardId;
const toStatus = evt.to.dataset.status;
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
})
.then(() => {
// ponytail: refresh current view after move
const activeTab = document.querySelector('.view-tab.active');
if (activeTab) activeTab.click();
});
}
});
});
}
});
+626
View File
@@ -0,0 +1,626 @@
document.addEventListener('alpine:init', () => {
Alpine.data('giteaWorkspace', () => {
const params = new URLSearchParams(window.location.search);
const owner = params.get('owner') || '';
const repo = params.get('repo') || '';
return {
owner, repo,
showFile: false,
showEditor: false,
showPrivate: false,
privatePages: [],
editingPrivate: null,
editingPrivateTitle: '',
editingPrivateContent: '',
filePath: '',
fileContent: '',
fileSize: 0,
fileSha: '',
fileLoading: false,
fileLanguage: 'text',
editContent: '',
commitMessage: 'Update via FlowDeck',
commitHistory: JSON.parse(localStorage.getItem('fd_commit_msgs') || '[]'),
// File tree browser
treeItems: [],
sortedTreeItems: [],
treeLoading: false,
folderStack: [],
currentPath: '',
// Context menu
gwCtx: { visible: false, x: 0, y: 0, item: null },
_sortTree() {
this.sortedTreeItems = [...this.treeItems].sort(function(a, b) {
if (a.type === b.type) return a.name.localeCompare(b.name);
return a.type === 'folder' ? -1 : 1;
});
},
init() {
// Expose globally for header to access
window._gwData = this;
// Set cookie for sidebar
document.cookie = 'flowdeck_workspace=gitea:' + this.owner + ':' + this.repo + ';path=/;SameSite=Lax';
// Load sidebar tree (into gitea section)
this.loadSidebarTree();
// Load main content tree
this.loadMainTree('');
// Listen for sidebar clicks on Gitea items
this.setupSidebarClicks();
},
async loadMainTree(path) {
this.treeLoading = true;
this.currentPath = path;
try {
var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree';
if (path) url += '?path=' + encodeURIComponent(path);
var r = await fetch(url);
if (!r.ok) { this.treeItems = []; this.sortedTreeItems = []; return; }
var d = await r.json();
this.treeItems = d.tree || [];
this._sortTree();
} catch(e) { this.treeItems = []; this.sortedTreeItems = []; }
finally { this.treeLoading = false; }
},
drillDown(path) {
this.folderStack.push(path.split('/').pop());
this.loadMainTree(path);
},
navigateToFolder(idx) {
// Truncate stack and rebuild path
this.folderStack = this.folderStack.slice(0, idx + 1);
var path = this.folderStack.join('/');
this.loadMainTree(path);
},
navigateToRoot() {
this.folderStack = [];
this.loadMainTree('');
},
openGwContext(ev, item) {
this.gwCtx = { visible: true, x: ev.clientX, y: ev.clientY, item: item };
},
gwRename() {
this.gwCtx.visible = false;
var item = this.gwCtx.item;
if (!item) return;
var newName = prompt('Rename:', item.name);
if (!newName || !newName.trim() || newName.trim() === item.name) return;
var self = this;
var oldPath = item.path;
var newPath = oldPath.replace(/[^/]+$/, newName.trim());
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
method: 'PUT',
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json' },
body: JSON.stringify({ path: oldPath, new_path: newPath, message: 'Rename ' + oldPath + ' to ' + newPath })
}).then(function(r){ return r.json(); })
.then(function(d){
if (d.status === 'ok') { self.refreshTree(); }
else { window.showToast('Rename failed', 'error'); }
})
.catch(function(){ window.showToast('Rename failed', 'error'); });
},
gwDelete() {
this.gwCtx.visible = false;
var item = this.gwCtx.item;
if (!item) return;
if (!confirm('Delete ' + item.name + '?')) return;
var self = this;
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
method: 'DELETE'
}).then(function(r){
if (r.ok) { self.refreshTree(); }
else { window.showToast('Delete failed', 'error'); }
}).catch(function(){ window.showToast('Delete failed', 'error'); });
},
async loadSidebarTree() {
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree');
if (!r.ok) {
// If API fails (e.g. no Gitea token), set a message
var container = document.getElementById('sidebar-gitea-items');
if (container) container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon">'+getSvgIcon('link',14)+'</span><span class="page-name text-dim">Connect Gitea to browse files</span></li>';
return;
}
var d = await r.json();
var items = d.tree || [];
var container = document.getElementById('sidebar-gitea-items');
if (!container) return;
// Ensure gitea section is visible
if (window.appState && window.appState.sectionsOpen && !window.appState.sectionsOpen.gitea) {
window.appState.sectionsOpen.gitea = true;
}
container.innerHTML = '';
// Build tree HTML as string and set once (more performant)
var html = '';
for (var i = 0; i < items.length; i++) {
var item = items[i];
var icon = item.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
html += '<li class="sidebar-item" data-gitea-path="' + item.path + '" data-gitea-type="' + (item.type === 'folder' ? 'folder' : 'file') + '" data-gitea-sha="' + (item.sha || '') + '" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
html += '<span class="page-icon">' + icon + '</span>';
html += '<span class="page-name">' + item.name + '</span>';
html += '</li>';
}
// Add Private Pages link
html += '<li style="border-top:1px solid var(--border);margin:8px 0;"></li>';
html += '<li class="sidebar-item" id="gitea-private-link" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
html += '<span class="page-icon">'+getSvgIcon('lock',14)+'</span><span class="page-name">Private Pages</span></li>';
container.innerHTML = html;
// Re-attach event listeners
this.setupSidebarClicks();
} catch(e) {
console.error('Gitea sidebar tree load failed:', e);
}
},
setupSidebarClicks() {
var self = this;
document.addEventListener('click', function(e) {
var el = e.target.closest('.sidebar-item[data-gitea-path]');
if (!el) return;
var path = el.getAttribute('data-gitea-path');
var type = el.getAttribute('data-gitea-type');
// If clicking the checkbox, let its own handler deal with selection
if (e.target.classList.contains('gitea-checkbox')) return;
// If clicking the inline rename input, don't navigate
if (e.target.classList.contains('inline-rename-input')) return;
// If Shift or Ctrl/Meta is pressed, use multi-selection
if (e.shiftKey || e.ctrlKey || e.metaKey) {
e.preventDefault();
e.stopPropagation();
self.selectItem(path, el, e);
return;
}
// Normal click: navigate (open file/folder)
e.preventDefault();
e.stopPropagation();
// Update visual "active" state
document.querySelectorAll('.sidebar-item.active').forEach(function(si) {
si.classList.remove('active');
});
el.classList.add('active');
if (type === 'folder') {
self.loadSubdir(path, el);
} else {
self.openFile(path, el.getAttribute('data-gitea-sha'));
}
});
// Listen for custom delete event on gitea sidebar items
document.addEventListener('gitea-delete', function(e) {
var el = e.target;
var path = el.getAttribute('data-gitea-path');
if (!path) return;
if (!confirm('Delete ' + path + ' from ' + self.owner + '/' + self.repo + '?')) return;
var sha = el.getAttribute('data-gitea-sha') || '';
fetch('/api/gitea/projects/' + self.owner + '/' + self.repo + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
method: 'DELETE',
}).then(function(r) {
if (r.ok) self.refreshTree();
}).catch(function() {});
});
},
async loadSubdir(path, el) {
var self = this;
// Check if already loaded
var ul = el.querySelector('ul');
if (ul) {
ul.style.display = ul.style.display === 'none' ? '' : 'none';
return;
}
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree?path=' + encodeURIComponent(path));
if (!r.ok) return;
var d = await r.json();
var children = d.tree || [];
ul = document.createElement('ul');
ul.className = 'sidebar-items';
ul.style.paddingLeft = '20px';
children.forEach(function(child) {
var icon = child.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
var li = document.createElement('li');
li.className = 'sidebar-item';
li.setAttribute('data-gitea-path', child.path);
li.setAttribute('data-gitea-type', child.type === 'folder' ? 'folder' : 'file');
li.setAttribute('data-gitea-sha', child.sha || '');
li.style.cssText = 'cursor:pointer;display:flex;align-items:center;gap:4px;';
// Checkbox
var cb = document.createElement('input');
cb.type = 'checkbox';
cb.className = 'gitea-checkbox';
cb.style.cssText = 'flex-shrink:0;margin:0;display:none;';
cb.addEventListener('click', function(ev) {
ev.stopPropagation();
self.selectItem(child.path, li, ev);
});
li.appendChild(cb);
// Icon
var iconSpan = document.createElement('span');
iconSpan.className = 'sidebar-icon';
iconSpan.innerHTML = icon; // icon = HTML SVG from getSvgIcon(), NOT text
li.appendChild(iconSpan);
// Name
var nameSpan = document.createElement('span');
nameSpan.textContent = child.name;
nameSpan.addEventListener('dblclick', function(ev) {
ev.preventDefault();
ev.stopPropagation();
self.startInlineRename(nameSpan, child.path, li);
});
li.appendChild(nameSpan);
ul.appendChild(li);
});
el.appendChild(ul);
} catch(e) {}
},
async openFile(path, sha) {
this.filePath = path;
this.fileSha = sha || '';
this.showEditor = false;
this.showFile = true;
this.fileLoading = true;
this.fileLanguage = this.getLanguage(path);
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(path));
if (r.ok) {
var d = await r.json();
this.fileContent = d.content || '';
this.fileSize = d.content ? d.content.length : 0;
} else {
this.fileContent = '[Error loading file]';
}
} catch(e) {
this.fileContent = '[Error loading file]';
}
this.fileLoading = false;
// Highlight after Alpine renders
var self = this;
this.$nextTick(function() {
var block = self.$refs.codeBlock;
if (block && block.textContent && typeof Prism !== 'undefined') {
Prism.highlightElement(block);
}
});
},
getLanguage(path) {
var ext = (path || '').split('.').pop().toLowerCase();
var map = {
js:'javascript', jsx:'javascript', ts:'typescript', tsx:'typescript',
py:'python', rb:'ruby', rs:'rust', go:'go', java:'java', kt:'kotlin',
c:'c', cpp:'c', h:'c', hpp:'c', cs:'csharp',
html:'markup', htm:'markup', xml:'markup', svg:'markup', css:'css', scss:'css',
json:'json', yaml:'yaml', yml:'yaml', toml:'toml', ini:'ini',
md:'markdown', sql:'sql', sh:'bash', bash:'bash', zsh:'bash',
php:'php', swift:'swift', r:'r', lua:'lua', dart:'dart',
dockerfile:'docker', makefile:'makefile', cmake:'cmake',
};
return map[ext] || 'text';
},
openEditor() {
this.editContent = this.fileContent;
this.showEditor = true;
},
async saveFile() {
if (!this.filePath) return;
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
method: 'PUT',
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken()},
body: JSON.stringify({
path: this.filePath, content: this.editContent,
message: this.commitMessage, sha: this.fileSha,
})
});
if (r.ok) {
this.fileContent = this.editContent;
this.showEditor = false;
if (!this.commitHistory.includes(this.commitMessage)) {
this.commitHistory.unshift(this.commitMessage);
if (this.commitHistory.length > 10) this.commitHistory.pop();
localStorage.setItem('fd_commit_msgs', JSON.stringify(this.commitHistory));
}
} else {
var d = await r.json();
window.showToast('Failed to save: ' + (d.error || 'Unknown error'), 'error');
}
} catch(e) { window.showToast('Network error', 'error'); }
},
async deleteCurrentFile() {
if (!this.filePath) return;
if (!confirm('Delete ' + this.filePath + ' from ' + this.owner + '/' + this.repo + '?')) return;
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(this.filePath) + '&sha=' + encodeURIComponent(this.fileSha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
method: 'DELETE',
});
if (r.ok) {
this.showFile = false;
this.filePath = '';
await this.refreshTree();
}
} catch(e) {}
},
async refreshTree() {
// Reload main tree and sidebar tree without full page reload
this.loadMainTree(this.currentPath);
this.loadSidebarTree();
},
formatSize(bytes) {
if (!bytes) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB';
return (bytes/1048576).toFixed(1) + ' MB';
},
// ── Private Pages ──
async loadPrivatePages() {
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages');
if (r.ok) { var d = await r.json(); this.privatePages = d.pages || []; }
} catch(e) {}
},
newPrivate() {
this.editingPrivate = 'new';
this.editingPrivateTitle = '';
this.editingPrivateContent = '';
},
async openPrivate(id) {
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id);
if (r.ok) {
var d = await r.json();
this.editingPrivate = id;
this.editingPrivateTitle = d.page.title;
this.editingPrivateContent = d.page.content;
}
} catch(e) {}
},
async savePrivate() {
var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages';
var method = 'POST';
if (this.editingPrivate !== 'new') {
url += '/' + this.editingPrivate;
method = 'PUT';
}
try {
var r = await fetch(url, {
method, headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''},
body: JSON.stringify({title: this.editingPrivateTitle, content: this.editingPrivateContent})
});
if (r.ok) {
this.editingPrivate = null;
this.editingPrivateTitle = '';
this.editingPrivateContent = '';
await this.loadPrivatePages();
}
} catch(e) {}
},
// Create new file
showNewFile: false,
newFilePath: '',
newFileContent: '',
newFileMsg: 'Create via FlowDeck',
async createNewFile() {
if (!this.newFilePath.trim()) return;
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
method: 'PUT',
headers: {'Content-Type':'application/json', 'X-CSRF-Token': this.getCsrfToken()},
body: JSON.stringify({path: this.newFilePath.trim(), content: this.newFileContent, message: this.newFileMsg || 'Create via FlowDeck'})
});
if (r.ok) {
this.showNewFile = false;
this.newFilePath = '';
this.newFileContent = '';
this.newFileMsg = 'Create via FlowDeck';
await this.loadSidebarTree();
} else {
var d = await r.json();
window.showToast('Failed: ' + (d.error || d.detail || 'Unknown error'), 'error');
}
} catch(e) { window.showToast('Error: ' + e.message, 'error'); }
},
// Upload files
triggerUpload() { document.getElementById('gitea-upload-input').click(); },
async doUpload(ev) {
var files = ev.target.files;
if (!files.length) return;
for (var i = 0; i < files.length; i++) {
var form = new FormData();
form.append('file', files[i]);
form.append('message', 'Upload ' + files[i].name + ' via FlowDeck');
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/upload', {
method: 'POST',
headers: {'X-CSRF-Token': this.getCsrfToken()},
body: form
});
if (!r.ok) { var d = await r.json(); console.error('Upload failed:', d.error); }
} catch(e) { console.error('Upload error:', e); }
}
await this.loadSidebarTree();
ev.target.value = '';
},
async deletePrivate(id) {
if (!confirm('Delete this private page?')) return;
try {
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id, {
method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''}
});
if (r.ok) await this.loadPrivatePages();
} catch(e) {}
},
getCsrfToken() {
return document.cookie.split('; ').find(function(c) { return c.startsWith('csrf_token='); })?.split('=')[1] || '';
},
// ── Multi-selection ──
multiSelect: false,
selectedPaths: [],
lastClickedPath: null,
toggleMultiSelect() {
this.multiSelect = !this.multiSelect;
var cbs = document.querySelectorAll('.gitea-checkbox');
var self = this;
cbs.forEach(function(cb) { cb.style.display = self.multiSelect ? '' : 'none'; });
if (!this.multiSelect) {
// Clear selection when leaving multi-select mode
cbs.forEach(function(cb) { cb.checked = false; });
this.selectedPaths = [];
this.lastClickedPath = null;
// Remove selected class
document.querySelectorAll('.sidebar-item.gitea-selected').forEach(function(el) {
el.classList.remove('gitea-selected');
});
}
},
selectItem(path, li, ev) {
if (ev.shiftKey && this.lastClickedPath) {
// Range select
var all = Array.from(document.querySelectorAll('.sidebar-item[data-gitea-path]'));
var startIdx = all.findIndex(function(el) { return el.getAttribute('data-gitea-path') === this.lastClickedPath; }.bind(this));
var endIdx = all.findIndex(function(el) { return el.getAttribute('data-gitea-path') === path; }.bind(this));
if (startIdx >= 0 && endIdx >= 0) {
var lo = Math.min(startIdx, endIdx);
var hi = Math.max(startIdx, endIdx);
this.selectedPaths = [];
for (var i = lo; i <= hi; i++) {
this.selectedPaths.push(all[i].getAttribute('data-gitea-path'));
all[i].classList.add('gitea-selected');
var cb = all[i].querySelector('.gitea-checkbox');
if (cb) cb.checked = true;
}
}
this.multiSelect = true;
this.toggleMultiSelect(); // ensure checkboxes are visible
} else if (ev.ctrlKey || ev.metaKey) {
// Toggle single
var idx = this.selectedPaths.indexOf(path);
if (idx >= 0) {
this.selectedPaths.splice(idx, 1);
li.classList.remove('gitea-selected');
var cb = li.querySelector('.gitea-checkbox');
if (cb) cb.checked = false;
} else {
this.selectedPaths.push(path);
li.classList.add('gitea-selected');
var cb = li.querySelector('.gitea-checkbox');
if (cb) cb.checked = true;
}
this.multiSelect = this.selectedPaths.length > 0;
this.toggleMultiSelect();
} else {
// Normal select — clear multi-selection
this.selectedPaths = [path];
this.lastClickedPath = path;
document.querySelectorAll('.sidebar-item.gitea-selected').forEach(function(el) {
el.classList.remove('gitea-selected');
});
li.classList.add('gitea-selected', 'active');
var cb = li.querySelector('.gitea-checkbox');
if (cb) cb.checked = true;
}
},
isSelected(path) {
return this.selectedPaths.indexOf(path) >= 0;
},
// ── Inline rename ──
startInlineRename(nameSpan, path, li) {
var originalName = nameSpan.textContent;
var input = document.createElement('input');
input.type = 'text';
input.value = originalName;
input.className = 'inline-rename-input';
input.style.cssText = 'flex:1;min-width:0;padding:2px 6px;background:var(--bg-tertiary);border:1px solid var(--accent);border-radius:4px;color:var(--text);font-size:13px;outline:none;';
nameSpan.replaceWith(input);
input.focus();
input.select();
var self = this;
var finish = function() { self.finishInlineRename(input, originalName, path, li); };
var cancel = function() {
var span = document.createElement('span');
span.className = 'page-name';
span.textContent = originalName;
input.replaceWith(span);
};
input.addEventListener('blur', finish);
input.addEventListener('keydown', function(e) {
if (e.key === 'Enter') finish();
if (e.key === 'Escape') { e.stopPropagation(); cancel(); }
});
},
finishInlineRename(input, originalName, path, li) {
if (input._renaming) return; // guard against double-fire
input._renaming = true;
var newName = input.value.trim();
if (!newName || newName === originalName) {
var span = document.createElement('span');
span.className = 'page-name';
span.textContent = originalName;
input.replaceWith(span);
return;
}
var self = this;
// Construct new path by replacing the last segment
var parts = path.split('/');
parts.pop();
var newPath = (parts.length > 0 ? parts.join('/') + '/' : '') + newName;
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/rename', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken()},
body: JSON.stringify({old_path: path, new_path: newPath, new_name: newName})
}).then(function(r) {
if (r.ok) {
self.refreshTree();
} else {
var span = document.createElement('span');
span.className = 'page-name';
span.textContent = originalName;
input.replaceWith(span);
window.showToast('Rename failed', 'error');
}
}).catch(function() {
var span = document.createElement('span');
span.className = 'page-name';
span.textContent = originalName;
input.replaceWith(span);
});
},
};
});
});
+158
View File
@@ -0,0 +1,158 @@
function importWizard() {
return {
sources: [],
sourceId: '',
files: [],
dragOver: false,
mode: 'skip',
busy: false,
progress: 0,
error: '',
preview: null,
report: null,
mapping: {},
csrf: '',
urlValue: '',
urlError: '',
forgeProvider: 'gitea',
forgeOwner: '',
forgeRepo: '',
forgeState: 'all',
forgeError: '',
types: ['text','number','date','checkbox','email','url','phone','select','multi_select','status'],
async init() {
try {
const r = await fetch('/api/import/sources');
this.sources = (await r.json()).sources || [];
} catch(e) {}
try {
const c = await fetch('/api/csrf-token');
this.csrf = (await c.json()).csrf_token;
} catch(e) {}
},
humanSize(n) { return n > 1048576 ? (n/1048576).toFixed(1)+' Mo' : Math.max(1, Math.round(n/1024))+' Ko'; },
addFiles(list) {
for (const f of list) this.files.push({file:f, name:f.name, size:f.size, status:'queued'});
this.preview = null; this.report = null; this.error = '';
},
onFiles(e) { this.addFiles(e.target.files); e.target.value=''; },
onDrop(e) { this.dragOver=false; this.addFiles(e.dataTransfer.files); },
buildForm(f) {
const fd = new FormData();
fd.append('file', f.file);
if (this.sourceId) fd.append('source', this.sourceId);
fd.append('mode', this.mode);
return fd;
},
async doPreview() {
this.busy = true; this.error=''; this.report=null; this.progress=10;
try {
let merged = null;
for (let i=0;i<this.files.length;i++) {
const r = await fetch('/api/import/preview', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:this.buildForm(this.files[i])});
const d = await r.json();
if (!r.ok) { this.error = (this.files[i].name+': '+(d.detail||'Erreur')); continue; }
if (!merged) merged = {source_label:d.source_label, pages:[], warnings:[], stats:{}};
merged.pages.push(...(d.pages||[]));
merged.warnings.push(...(d.warnings||[]));
this.progress = Math.round(((i+1)/this.files.length)*100);
}
this.preview = merged;
this.mapping = {};
(merged ? merged.pages : []).forEach(p => (p.schema||[]).forEach(c => { this.mapping[c.name]=c.type; }));
} catch(e) { this.error = 'Erreur réseau'; }
finally { this.busy = false; setTimeout(()=>{this.progress=0;},800); }
},
async importOne(f) {
f.status = 'running';
const fd = this.buildForm(f);
if (Object.keys(this.mapping).length) fd.append('mapping', JSON.stringify(this.mapping));
const big = f.size > 5*1024*1024;
try {
if (big) {
fd.append('async','true');
const r = await fetch('/api/import/run', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:fd});
const d = await r.json();
if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; }
return await this.pollJob(d.job_id, f);
}
const r = await fetch('/api/import/run', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:fd});
const d = await r.json();
if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; }
f.status = (d.status==='partial' ? 'partial' : 'done');
return d;
} catch(e) { f.status='error'; f.error='Erreur réseau'; return null; }
},
async doImport() {
this.busy = true; this.error=''; this.report=null; this.progress=0;
const aggregate = {pages_created:0,pages_updated:0,collections_created:0,rows_created:0,
attachments:0,skipped:0,warnings:[],errors:[],per_file:[]};
for (let i=0;i<this.files.length;i++) {
const d = await this.importOne(this.files[i]);
if (d) {
for (const k of ['pages_created','pages_updated','collections_created','rows_created','attachments','skipped'])
aggregate[k] += (d[k]||0);
aggregate.warnings.push(...(d.warnings||[]));
aggregate.errors.push(...(d.errors||[]));
aggregate.per_file.push({filename:this.files[i].name, report:d});
} else {
aggregate.errors.push({title:this.files[i].name, error:this.files[i].error||'Erreur'});
aggregate.per_file.push({filename:this.files[i].name, report:{status:'error'}});
}
this.progress = Math.round(((i+1)/this.files.length)*100);
}
this.report = aggregate;
this.busy = false;
},
async doUrl() {
this.busy = true; this.urlError = ''; this.report = null; this.progress = 40;
try {
const r = await fetch('/api/import/url', {
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
body: JSON.stringify({url:this.urlValue.trim()})
});
const d = await r.json();
if (!r.ok) { this.urlError = d.detail || 'Erreur'; return; }
this.report = d; this.progress = 100;
} catch(e) { this.urlError = 'Erreur réseau'; }
finally { this.busy = false; }
},
async doForge(kind) {
this.busy = true; this.forgeError = ''; this.report = null; this.progress = 30;
const endpoint = kind === 'repo' ? '/api/import/forge-repo' : '/api/import/forge';
try {
const r = await fetch(endpoint, {
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
body: JSON.stringify({
provider:this.forgeProvider, owner:this.forgeOwner.trim(),
repo:this.forgeRepo.trim(), state:this.forgeState
})
});
const d = await r.json();
if (!r.ok) { this.forgeError = d.detail || 'Erreur'; return; }
this.report = d; this.progress = 100;
} catch(e) { this.forgeError = 'Erreur réseau'; }
finally { this.busy = false; }
},
async pollJob(jobId, f) {
for (let i=0;i<600;i++) {
await new Promise(res => setTimeout(res, 700));
const r = await fetch('/api/import/jobs/'+jobId);
const j = await r.json();
if (j.status === 'done') { if (f) f.status='done'; return j.report; }
if (j.status === 'error') { if (f) { f.status='error'; f.error=j.error; } return null; }
}
if (f) { f.status='error'; f.error='Délai dépassé'; }
return null;
},
downloadReport() {
const blob = new Blob([JSON.stringify(this.report, null, 2)], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'flowdeck-import-report.json';
a.click();
URL.revokeObjectURL(a.href);
}
};
}
+1039
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+532
View File
@@ -0,0 +1,532 @@
const RT=(()=>{try{const el=document.getElementById('rt-config');return el?JSON.parse(el.textContent):{}}catch(e){return {}}})();
/* eslint-disable */
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
window.__fdRT = (function () {
const SELF = {
id: RT.id,
login: RT.login,
full_name: RT.full_name,
color: RT.color,
};
const COLORS = [
"#2383E2", "#46A758", "#E5484D", "#F76B15", "#8E4EC6", "#12A594",
"#FFC53D", "#D6409F", "#0091FF", "#3E63DD", "#30A46C", "#FF3333",
];
let E = null; // editorState (window.E)
let ws = null;
let mode = 'off'; // 'ws' | 'poll'
let open = false;
let base = []; // dernier snapshot serveur des blocs
let version = 0;
let pendingOps = 0;
let needSync = false;
let peers = []; // liste des présents (hors moi)
let remoteCursors = {}; // userId -> {peer, block, offset}
let myCursor = null; // {block, offset}
let emitT = null, selT = null, titleT = null, drawT = null, retryT = null, pollT = null;
let _pid = 0;
// Detach global listeners from a previous editor instance so that
// partial (HTMX) navigation doesn't accumulate stale handlers.
function unbindGlobal() {
const g = window.__fdRTGlobal;
if (!g) return;
try {
document.removeEventListener('selectionchange', g.onSel, true);
window.removeEventListener('scroll', g.onScroll, true);
window.removeEventListener('resize', g.onResize);
} catch (e) { /* noop */ }
window.__fdRTGlobal = null;
}
const clone = (o) => JSON.parse(JSON.stringify(o));
function colorOf(uid) { return COLORS[Math.abs(uid || 0) % COLORS.length]; }
function initials(p) {
const n = (p && (p.full_name || p.login)) || '';
const parts = String(n).trim().split(/\s+/);
if (!parts[0]) return '?';
return ((parts[0][0] || '') + (parts.length > 1 ? (parts[1][0] || '') : '')).toUpperCase().slice(0, 2);
}
function send(obj) {
if (ws && ws.readyState === WebSocket.OPEN) {
try { ws.send(JSON.stringify(obj)); } catch (e) { /* noop */ }
}
}
/* ── ops miroir du serveur (apply_op/merge) ── */
function applyOpJS(blocks, op) {
const t = op && op.type;
if (t === 'insert') {
const blk = op.block || {};
const id = blk.id || ('rb' + Date.now().toString(36));
blk.id = id;
if (typeof ensureBlockIds === 'function') ensureBlockIds([blk]);
let idx = op.index != null ? op.index : blocks.length;
idx = Math.max(0, Math.min(idx, blocks.length));
return blocks.slice(0, idx).concat([blk]).concat(blocks.slice(idx));
}
if (t === 'update') {
const nb = op.block || {};
if (!nb.id) return blocks;
return blocks.map(b => (b.id === nb.id ? nb : b));
}
if (t === 'delete') {
const bid = op.id;
return blocks.filter(b => b.id !== bid);
}
if (t === 'move') {
const bid = op.id, idx = op.index || 0;
const out = blocks.filter(b => b.id !== bid);
const moved = blocks.find(b => b.id === bid);
if (!moved) return blocks;
const i2 = Math.max(0, Math.min(idx, out.length));
out.splice(i2, 0, moved);
return out;
}
return blocks;
}
/* Diff base → courants : update/delete/move/insert (ordre pour le serveur). */
function diffOps(cur) {
if (!base.length && !cur.length) return [];
const ops = [];
const baseById = {}, curById = {};
base.forEach(b => { baseById[b.id] = b; });
cur.forEach(b => { curById[b.id] = b; });
cur.forEach(b => {
if (baseById[b.id] !== undefined && JSON.stringify(baseById[b.id]) !== JSON.stringify(b)) {
// v6.4.0 : on embarque la `base` dont dérive la saisie → le serveur
// fait un merge 3-voix au lieu d'écraser le bloc (LWW).
ops.push({ type: 'update', block: clone(b), base: clone(baseById[b.id]) });
}
});
base.forEach(b => {
if (curById[b.id] === undefined) ops.push({ type: 'delete', id: b.id });
});
// structure : simule l'état serveur (base − supprimés) pour indices valides
let sim = base.filter(b => curById[b.id] !== undefined).map(b => clone(b));
const simById = {}; sim.forEach(b => { simById[b.id] = b; });
const finalOrder = cur.map(b => b.id);
let si = 0;
finalOrder.forEach(id => {
if (simById[id] !== undefined) {
const curPos = sim.findIndex(b => b.id === id);
if (curPos !== si) ops.push({ type: 'move', id, index: si });
const [mv] = sim.splice(curPos, 1);
sim.splice(si, 0, mv);
si++;
} else {
ops.push({ type: 'insert', index: si, block: clone(curById[id]) });
sim.splice(si, 0, curById[id]);
simById[id] = curById[id];
si++;
}
});
return ops;
}
/* ── rendu + présence ── */
function activeBlockId() {
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
return a ? a.bid : null;
}
function refocus(fid) {
if (!fid) return;
setTimeout(() => {
const el = E.getEl(fid);
if (el) { el.focus(); try { (typeof ce === 'function') && ce(el); } catch (e) { /* noop */ } }
}, 30);
}
function renderPresence() {
let host = document.querySelector('.topbar-right.header-actions');
if (!host) return;
let box = document.getElementById('rtPresence');
if (!box) {
box = document.createElement('span');
box.id = 'rtPresence';
box.className = 'rt-presence';
host.insertBefore(box, host.firstChild);
}
const shown = peers.filter(p => p.id !== (SELF.id || 0));
if (!shown.length) { box.style.display = 'none'; return; }
box.style.display = 'inline-flex';
box.innerHTML = '';
shown.forEach(p => {
const c = document.createElement('span');
c.className = 'rt-avatar';
c.title = (p.full_name || p.login) + ' est en train d\u2019éditer' + (mode === 'poll' ? ' (polling)' : '');
c.textContent = initials(p);
c.style.background = p.color || colorOf(p.id);
box.appendChild(c);
});
if (mode === 'poll') {
let off = document.getElementById('rtOffline');
if (!off) {
off = document.createElement('span');
off.id = 'rtOffline';
off.className = 'rt-offline';
off.textContent = '●';
off.title = 'Realtime indisponible — rafraîchissement toutes les 10 s';
host.insertBefore(off, box.nextSibling || null);
}
off.style.display = 'inline';
}
}
/* ── curseurs ── */
function rangeFromOffset(el, offset) {
try {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let n = walker.nextNode(), count = 0;
while (n) {
const len = (n.nodeValue || '').length;
if (count + len >= offset) {
const r = document.createRange();
r.setStart(n, Math.min(offset - count, len));
r.collapse(true);
return r;
}
count += len;
n = walker.nextNode();
}
const r = document.createRange();
r.selectNodeContents(el);
r.collapse(false);
return r;
} catch (e) { return null; }
}
function drawCursors() {
const layer = document.getElementById('rtCursors');
if (!layer) return;
layer.innerHTML = '';
const ids = Object.keys(remoteCursors);
if (!ids.length) return;
ids.forEach(uid => {
const c = remoteCursors[uid];
if (!c || !c.block) return;
const el = E.getEl(c.block);
if (!el) return;
const r = rangeFromOffset(el, c.offset || 0);
let x, y, h;
if (r) {
const rc = r.getBoundingClientRect();
if (!rc.width && !rc.height) return; // hors viewport positionné
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
} else {
const rc = el.getBoundingClientRect();
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
}
const m = document.createElement('div');
m.className = 'rt-cursor';
m.style.left = (x - 1) + 'px';
m.style.top = (y - 1) + 'px';
m.style.height = h + 'px';
m.style.background = c.peer.color || colorOf(c.peer.id);
const nm = document.createElement('span');
nm.className = 'rt-cursor-name';
nm.textContent = initials(c.peer);
nm.style.background = m.style.background;
m.appendChild(nm);
layer.appendChild(m);
});
}
function emitCursor() {
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
let block = null, offset = 0;
if (a && a.el && a.bid) {
block = a.bid;
try { offset = (typeof cp === 'function') ? cp(a.el) : 0; } catch (e) { offset = 0; }
}
const changed = !myCursor || myCursor.block !== block || myCursor.offset !== offset;
myCursor = { block, offset };
if (!changed) return;
send({ t: 'sel', block, offset });
}
function scheduleDraw() {
clearTimeout(drawT);
drawT = setTimeout(drawCursors, 40);
}
/* ── application des changements distants ── */
function applySync(blocks, title) {
if (!E || !E.blocks) return;
// v5.13.1: guarantee ids before comparing/merging. Server rooms may still
// carry legacy id-less blocks; without this they collide on
// data-bid="undefined" and the merge below duplicated every line.
blocks = (blocks || []).slice();
if (typeof ensureBlockIds === 'function') ensureBlockIds(blocks);
const curIds = JSON.stringify((E.blocks || []).map(b => b.id));
const srvIds = JSON.stringify(blocks.map(b => b.id));
if (curIds === srvIds) {
base = clone(E.blocks);
E.dirty = false;
return;
}
const fid = activeBlockId();
const curById = {};
(E.blocks || []).forEach(b => { curById[b.id] = b; });
let out;
try {
if (!blocks.length) {
// serveur vide : ne pas effacer le contenu local (page neuve).
out = (E.blocks || []).slice();
} else {
out = blocks.map(b => {
const cb = curById[b.id];
if (cb && b.id === fid) return cb; // garde la frappe locale en cours
return b;
});
}
} catch (e) { return; }
E.blocks = out;
const tEl = document.getElementById('_titleEl');
if (title && title !== E.pageTitle && document.activeElement !== tEl) {
tEl.textContent = title;
E.pageTitle = title;
}
E.dirty = true;
base = clone(E.blocks);
E.render();
refocus(fid);
scheduleDraw();
}
function applyRemoteOp(op) {
const fid = activeBlockId();
if (op.type === 'update') {
const nb = op.block || {};
if (nb.id === fid) {
// bloc en cours d'édition : on garde la valeur locale, le serveur a déjà
// l'op ; la prochaine frappe locale repartira (LWW).
base = applyOpJS(base, op);
return;
}
E.blocks = applyOpJS(E.blocks, op);
base = applyOpJS(base, op);
E.dirty = true;
E.render();
refocus(fid);
} else {
E.blocks = applyOpJS(E.blocks, op);
base = applyOpJS(base, op);
E.dirty = true;
E.render();
refocus(fid);
}
scheduleDraw();
}
/* ── diffusion des modifications locales ── */
function emit() {
if (mode !== 'ws' || !open || !E || !E.blocks) return;
if (needSync) { send({ t: 'sync_req' }); return; }
const cur = E.blocks.filter(b => b && b.id);
const ops = diffOps(cur);
if (!ops.length) return;
ops.forEach(op => { send({ t: 'op', op, v: version }); pendingOps++; });
base = clone(cur);
}
function onMsg(m) {
if (!m || !m.t) return;
if (m.t === 'sync') {
version = m.version || 0;
base = clone(m.blocks || []);
needSync = false;
pendingOps = 0;
if (typeof m.blocks === 'undefined') return;
applySync(m.blocks || [], m.title || '');
} else if (m.t === 'ack') {
version = m.v || 0;
if (pendingOps > 0) pendingOps--;
// v6.4.0 : le serveur renvoie le bloc fusionné (merge 3-voix). On
// l'adopte comme nouvelle base ; s'il diffère de notre saisie locale
// c'est qu'un autre utilisateur avait modifié le même bloc.
if (m.merged) {
const mid = m.merged.id;
const localBlock = (E && E.blocks || []).find(b => b.id === mid);
const differs = localBlock && JSON.stringify(localBlock) !== JSON.stringify(m.merged);
base = applyOpJS(base, { type: 'update', block: m.merged });
if (differs && E) {
const fid = activeBlockId();
if (fid !== mid) {
E.blocks = applyOpJS(E.blocks, { type: 'update', block: m.merged });
E.dirty = true;
E.render();
refocus(fid);
}
if (m.conflict && window.showToast) {
window.showToast('Editing conflict merged on a block', 'info');
}
}
}
if (m.stale || needSync) { needSync = true; send({ t: 'sync_req' }); }
} else if (m.t === 'op') {
if (m.v) version = m.v;
if (m.from === (SELF.id || 0)) { base = applyOpJS(base, m.op); return; }
applyRemoteOp(m.op);
} else if (m.t === 'title') {
const tEl = document.getElementById('_titleEl');
if (m.from !== (SELF.id || 0) && tEl && document.activeElement !== tEl && E) {
tEl.textContent = m.title || '';
E.pageTitle = m.title || '';
}
if (m.v) version = m.v;
scheduleDraw();
} else if (m.t === 'sel') {
if (m.from === (SELF.id || 0)) return;
if (!m.block) { delete remoteCursors[m.from]; scheduleDraw(); return; }
remoteCursors[m.from] = { peer: m.peer || { id: m.from }, block: m.block, offset: m.offset || 0 };
scheduleDraw();
} else if (m.t === 'welcome') {
peers = (m.peers || []).filter(p => p.id !== (SELF.id || 0));
renderPresence();
} else if (m.t === 'peer_join') {
if (m.peer && m.peer.id !== (SELF.id || 0)) {
peers = peers.filter(p => p.id !== m.peer.id).concat([m.peer]);
renderPresence();
}
} else if (m.t === 'peer_leave') {
peers = peers.filter(p => p.id !== m.id);
delete remoteCursors[m.id];
renderPresence();
scheduleDraw();
} else if (m.t === 'synced_update') {
// A synced block was updated — re-sync the whole page
if (m.synced_id) {
needSync = true;
send({ t: 'sync_req' });
}
}
}
/* ── connexion WS + fallback polling ── */
function startPolling() {
if (pollT) return;
mode = 'poll';
renderPresence();
scheduleDraw();
pollT = setInterval(poll, 10000);
}
function stopPolling() {
if (pollT) { clearInterval(pollT); pollT = null; }
const off = document.getElementById('rtOffline');
if (off) off.style.display = 'none';
}
async function poll() {
if (!E || !_pid) return;
try {
const r = await fetch('/board/api/pages/' + _pid, { credentials: 'same-origin' });
if (!r.ok) return;
const d = await r.json();
if ((d.content_format || 'blocks') !== 'blocks' || !d.content) return;
const serverBlocks = JSON.parse(d.content);
// en cas de modifs locales non persistées : on garde local (LWW au prochain save)
if (E.dirty) return;
const cur = JSON.stringify((E.blocks || []).map(b => b.id));
const srv = JSON.stringify(serverBlocks.map(b => b.id));
if (cur === srv) return;
version = version; // pas de version via polling — on adopte l'état serveur
applySync(serverBlocks, d.title || E.pageTitle);
} catch (e) { /* noop */ }
}
function connect() {
if (!E || !_pid || ws) return;
const proto = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
try {
ws = new WebSocket(proto + window.location.host + '/ws/pages/' + _pid);
} catch (e) {
startPolling();
return;
}
ws.onopen = () => {
open = true;
mode = 'ws';
stopPolling();
send({ t: 'hello' });
};
ws.onmessage = (ev) => {
let m;
try { m = JSON.parse(ev.data); } catch (e) { return; }
onMsg(m);
};
ws.onclose = () => {
open = false;
ws = null;
if (retryT) clearTimeout(retryT);
retryT = setTimeout(connect, 15000);
startPolling();
};
ws.onerror = () => { try { ws.close(); } catch (e) { /* noop */ } };
setTimeout(() => {
if (!open) { try { ws && ws.close(); } catch (e) { /* noop */ } }
}, 5000);
}
function titleInput() {
const tEl = document.getElementById('_titleEl');
if (!tEl) return;
clearTimeout(titleT);
titleT = setTimeout(() => {
send({ t: 'title', title: (tEl.textContent || '').trim() });
}, 500);
}
function wire() {
const ct = document.getElementById('_blocksCt');
if (ct) {
ct.addEventListener('input', () => {
clearTimeout(emitT);
emitT = setTimeout(emit, 350);
setTimeout(emitCursor, 80);
}, true);
ct.addEventListener('keyup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
ct.addEventListener('click', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
ct.addEventListener('mouseup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
}
const tEl = document.getElementById('_titleEl');
if (tEl) tEl.addEventListener('input', titleInput);
unbindGlobal();
const onSel = () => { clearTimeout(selT); selT = setTimeout(emitCursor, 150); };
const onScroll = () => scheduleDraw();
const onResize = () => scheduleDraw();
document.addEventListener('selectionchange', onSel, true);
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onResize);
window.__fdRTGlobal = { onSel, onScroll, onResize };
}
function start(ed) {
if (!ed) return;
if ((ed.contentFormat || 'blocks') !== 'blocks') return;
E = ed;
_pid = ed.pid || 0;
if (!_pid) return;
base = clone(ed.blocks || []);
wire();
connect();
}
// poussée immédiate des ops après une mutation programmatique (v5.10.0)
function syncNow() {
if (mode !== 'ws' || !open || !E || !E.blocks) return;
clearTimeout(emitT);
emit();
}
return { start, syncNow };
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+153
View File
@@ -0,0 +1,153 @@
function workspacesPage() {
return {
workspaces: [],
activeId: null,
showCreate: false,
showRename: false,
wsName: '',
renameTarget: null,
// Gitea
giteaStatus: 'loading', // loading|ok|not_linked|error
giteaGroups: [],
giteaTotal: 0,
activeOrg: 'all',
giteaSearch: '',
get filteredGroups() {
var self = this;
var q = (this.giteaSearch || '').toLowerCase();
var groups = this.activeOrg === 'all' ? this.giteaGroups : this.giteaGroups.filter(function(g) { return g.org === self.activeOrg; });
if (!q) return groups;
return groups.map(function(g) {
return {org: g.org, avatar: g.avatar, projects: g.projects.filter(function(p) {
return p.name.toLowerCase().includes(q) || (p.description||'').toLowerCase().includes(q);
})};
}).filter(function(g) { return g.projects.length > 0; });
},
async init() {
await this.load();
await this.loadGitea();
},
async load() {
const r = await fetch('/api/workspaces');
const d = await r.json();
this.workspaces = d.workspaces || [];
this.activeId = d.active_id;
},
async selectLocal(ws) {
await fetch(`/api/workspaces/${ws.id}/select`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'POST'});
window.location = '/local-workspace';
},
async doCreate() {
if (!this.wsName.trim()) return;
await fetch('/api/workspaces', {
method:'POST',
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body:JSON.stringify({name:this.wsName.trim()})
});
this.wsName = '';
this.showCreate = false;
await this.load();
},
renameWs(ws) {
this.renameTarget = ws;
this.wsName = ws.name;
this.showRename = true;
},
async doRename() {
if (!this.wsName.trim()||!this.renameTarget) return;
await fetch(`/api/workspaces/${this.renameTarget.id}`, {
method:'PUT',
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
body:JSON.stringify({name:this.wsName.trim()})
});
this.wsName = '';
this.showRename = false;
this.renameTarget = null;
await this.load();
},
async deleteWs(ws) {
if (!confirm(`Delete workspace "${ws.name}" and all its pages?`)) return;
await fetch(`/api/workspaces/${ws.id}`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE'});
await this.load();
},
// ── Gitea ──
async loadGitea() {
this.giteaStatus = 'loading';
try {
// Get orgs
const orgsRes = await fetch('/api/gitea/orgs');
if (orgsRes.status === 401) { this.giteaStatus = 'not_linked'; return; }
if (!orgsRes.ok) { this.giteaStatus = 'error'; return; }
const orgsData = await orgsRes.json();
const orgs = orgsData.orgs || [];
// Fetch repos: user repos + org repos
const groups = [];
// Get username for the "personal" tab
let myLogin = 'Me';
try {
const meRes = await fetch('/auth/user');
if (meRes.ok) {
const meData = await meRes.json();
myLogin = meData.user?.login || 'Me';
if (myLogin.includes('_')) myLogin = myLogin.split('_').pop(); // strip gitea_ prefix if present
}
} catch(e) {}
// User repos (no org filter)
try {
const userRes = await fetch('/api/gitea/projects');
if (userRes.ok) {
const d = await userRes.json();
const repos = d.projects || [];
if (repos.length) {
groups.push({org: myLogin, avatar: '', projects: repos.map(r => ({...r, owner: r.owner || {login: myLogin}}))});
}
}
} catch(e) {}
// Org repos
for (const org of orgs) {
try {
const res = await fetch('/api/gitea/projects?org=' + encodeURIComponent(org.username || org.login || org.name));
if (res.ok) {
const d = await res.json();
if (d.projects && d.projects.length) {
groups.push({org: org.username || org.login || org.name, avatar: org.avatar_url || '', projects: d.projects});
}
}
} catch(e) {}
}
this.giteaGroups = groups;
this.giteaTotal = groups.reduce((sum, g) => sum + g.projects.length, 0);
this.giteaStatus = 'ok';
} catch(e) {
this.giteaStatus = 'error';
}
},
openGitea(proj) {
const owner = proj.owner?.login || proj.owner?.username || proj.full_name?.split('/')[0] || '';
const repo = proj.name;
if (owner && repo) {
// Set workspace cookie so sidebar shows tree
document.cookie = 'flowdeck_workspace=gitea:' + owner + ':' + repo + ';path=/;SameSite=Lax';
window.location = `/gitea-workspace?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
}
}
};
}
+9 -2
View File
@@ -270,8 +270,15 @@ def _read_template(name: str) -> str:
return fh.read()
def _read_js(name: str) -> str:
"""A27 : le JS de l'éditeur vit dans static/js depuis l'extraction."""
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
with open(os.path.join(base, "static", "js", name), encoding="utf-8") as fh:
return fh.read()
def test_editor_has_ai_slash_commands(client):
src = _read_template("_page_editor_scripts.html")
src = _read_js("page_editor_scripts.js")
for cmd in ("ai_write", "ai_summarize", "ai_translate", "ai_continue"):
assert cmd in src
assert "AI writing" in src
@@ -280,7 +287,7 @@ def test_editor_has_ai_slash_commands(client):
def test_editor_has_autocomplete(client):
src = _read_template("_page_editor_scripts.html")
src = _read_js("page_editor_scripts.js")
assert "AIAC" in src
assert "autocomplete" in src
assert "AIAC.schedule" in src
+107
View File
@@ -163,6 +163,113 @@ def test_gitea_cache_evicts_expired():
assert "k" not in c._cache and c._cache["k2"][1] == "v2"
def test_migration_transaction_rolls_back():
"""A31 : un échec au milieu d'une migration ne laisse ni DDL partiel, ni
ligne dans schema_version → la reprise rejoue proprement."""
import sqlite3 as _sqlite3
import pytest as _pytest
from app.migrations import _apply_one, _ensure_table
conn = _sqlite3.connect(":memory:")
_ensure_table(conn)
def boom(c):
c.execute("CREATE TABLE partial_x (id INTEGER)")
raise RuntimeError("boom")
with _pytest.raises(RuntimeError, match="boom"):
_apply_one(conn, 9999, "boom", boom)
assert (
conn.execute("SELECT name FROM sqlite_master WHERE name='partial_x'").fetchone()
is None
), "DDL partiel non annulé"
assert (
conn.execute("SELECT COUNT(*) FROM schema_version WHERE version=9999").fetchone()[0]
== 0
)
# chemin nominal : DDL + marque de version dans la même transaction
_apply_one(conn, 9998, "ok", lambda c: c.execute("CREATE TABLE ok_x (id INTEGER)"))
assert (
conn.execute("SELECT COUNT(*) FROM schema_version WHERE version=9998").fetchone()[0]
== 1
)
conn.close()
def test_columns_helper_validates_table_name():
"""A31 : `columns()` remplace les 24 copies de PRAGMA table_info + valide l'identifiant."""
import sqlite3 as _sqlite3
from app.migrations import columns
conn = _sqlite3.connect(":memory:")
conn.execute("CREATE TABLE t1 (id INTEGER, nom TEXT)")
assert columns(conn, "t1") == {"id", "nom"}
try:
columns(conn, "t1; DROP TABLE users")
raise AssertionError("identifiant non validé")
except ValueError:
pass
conn.close()
def _assert_nonce(csp: str, html: str) -> str:
"""Header CSP : script-src sans unsafe-inline + TOUS les scripts inline noncés."""
import re as _re
m = _re.search(r"script-src ([^;]*);", csp)
assert m, csp
script_src = m.group(1)
nm = _re.search(r"'nonce-([^']+)'", script_src)
assert nm, script_src
nonce = nm.group(1)
assert "'unsafe-inline'" not in script_src, script_src
assert "'unsafe-eval'" in script_src # Alpine/htmx — reste d'A20
assert "script-src-attr 'unsafe-inline'" in csp
tags = [
mm.group(0)
for mm in _re.finditer(r"<script[^>]*>", html)
if "src=" not in mm.group(0)
]
assert tags, "aucun script inline"
missing = [t for t in tags if f'nonce="{nonce}"' not in t]
assert missing == [], missing[:3]
return nonce
def test_csp_nonce_per_request(client):
"""A20 : nonce par requête — page base.html (avec meta htmx-config) et page
hors template (LOCAL_LOGIN_HTML, constante de module → nonce au rendu)."""
# 1) une page qui étend base.html (la meta htmx-config y est)
base = None
for url in ("/", "/dashboard", "/board", "/notes", "/settings", "/workspaces"):
cand = client.get(url)
if cand.status_code == 200 and "htmx-config" in cand.text:
base = cand
break
assert base is not None, "aucune page base.html atteignable"
nonce = _assert_nonce(base.headers.get("content-security-policy", ""), base.text)
assert f'"inlineScriptNonce": "{nonce}"' in base.text
# deux requêtes = deux nonces différents
other = client.get("/dashboard")
if other.status_code == 200 and "htmx-config" in other.text:
other_nonce = _re_search_nonce(other.headers.get("content-security-policy", ""))
assert other_nonce != nonce
# 2) la page de login hors template (script injecté par _with_nonce)
r = client.get("/auth/login?provider=local")
assert r.status_code == 200, r.status_code
_assert_nonce(r.headers.get("content-security-policy", ""), r.text)
def _re_search_nonce(csp: str) -> str:
import re as _re
return _re.search(r"'nonce-([^']+)'", _re.search(r"script-src ([^;]*);", csp).group(1)).group(1)
def test_no_duplicate_routes():
"""A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre."""
from app.main import app
+1 -1
View File
@@ -98,7 +98,7 @@ def test_workspace_offline_banner_shows_pending(client):
def test_editor_save_has_offline_hook():
src = (ROOT / "app" / "templates" / "_page_editor_scripts.html").read_text(encoding="utf-8")
src = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
assert "FlowOffline" in src
assert "savePageOffline" in src
assert "navigator.onLine" in src
+8 -1
View File
@@ -295,7 +295,14 @@ def test_page_editor_renders_page_is_shared(client):
r = client.get(f"/pages/{a}")
assert r.status_code == 200
assert "pageIsShared:true" in r.text
# A27 : la valeur n'est plus interpolée dans le JS inline mais exposée
# dans le JSON #page-data (le JS lit PD.is_shared)
import json as _json
import re as _re
m = _re.search(r'id="page-data"[^>]*>(.*?)</script>', r.text, _re.S)
assert m, "bloc #page-data absent de la page"
assert _json.loads(m.group(1))["is_shared"] is True
def test_tree_is_shared_includes_link_shared_pages(client):
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More