Compare commits

...
19 Commits
Author SHA1 Message Date
bruno afbc236cc2 fix: navigation partielle Alpine sans course + scripts de page idempotents (v7.45.2)
FlowDeck CI / docker (push) Successful in 1m51s
FlowDeck CI / lint (push) Successful in 1m57s
FlowDeck CI / test (push) Successful in 15m16s
- app.js : x-ignore sur .main-wrapper au swap fdLoad (nœud remplacé
  uniquement), démontage unique quand tous les <script src> ont exécuté
  (load/error + filet 4s) via Alpine.initTree idempotent (_x_marker) —
  fini les cascades « Undefined variable » (éditeur) et « reading 'has' »
  ($store.fdCtx) : la zone s'initialisait avant composants et stores.
- 5 scripts de page gardés contre la ré-exécution (SyntaxError
  « Identifier 'LW' has already been declared » à la 2e visite
  partielle) : local_workspace, board, settings, database_table,
  page_editor_realtime.
- if (window.Alpine) → Alpine.data immédiat sinon listener alpine:init
  (déjà passé sur swap) : 8 scripts + 7 templates inline.
- app.css : purge des 7 blocs @font-face Inter orphelins (fichiers
  inexistants → 302 HTML → « Failed to decode downloaded font »).
- Gates : e2e/regression_editor_mount + e2e/regression_partial_nav
  (7 pages x complet/1er/2e passage) + regression_logout_dnd + smoke
  = 6/6 verts sur l'image rebuildée · pytest 1094 passed · ruff OK ·
  OpenAPI 7.45.2.
2026-10-02 21:29:46 -04:00
bruno c20aeaada1 fix: logout servi « hors ligne » par le SW + drag&drop upload en 403 CSRF (v7.45.1)
FlowDeck CI / docker (push) Successful in 1m51s
FlowDeck CI / lint (push) Successful in 1m56s
FlowDeck CI / test (push) Successful in 15m11s
- SW : le fetch event de navigation arrive en redirect:'manual' → toute 302
  du serveur (logout → /auth/login) se lisait opaqueredirect (status 0) →
  « bad status » → page hors ligne. networkFirst rejoue la requête en
  redirect:'follow' et sert une 302 synthétique vers l'URL finale (Chromium
  refuse une response 'redirected' servie à une navigation → ERR_FAILED).
  timeoutFetch annule désormais réellement (AbortController branchée).
- Local workspace : _doUpload (upload/upload-folder) et toggleFavorite
  n'envoyaient pas X-CSRF-Token → 403 systématique depuis A19 (derniers
  appels mutants du front, balayage complet refait).
- Porte : e2e/regression_logout_dnd.spec.js (2 tests verts, joués sur
  l'image rebuildée) · pytest 1094 passed · ruff OK · OpenAPI 7.45.1.
2026-10-02 17:41:07 -04:00
bruno 6d7af3fb64 feat: éditeur visuel d'automations (pipeline steps) + fix CSP multi-instructions (v7.45.0)
FlowDeck CI / docker (push) Successful in 1m51s
FlowDeck CI / lint (push) Successful in 1m56s
FlowDeck CI / test (push) Successful in 15m9s
Added — Settings → Automations, pipeline visuel (API steps v7.0) :
- Cartes ordinees (trigger/condition/delay/action) : resume + edition
  TYPÉE par kind/type (datalist evenements, 7 ops, 8 types d'action avec
  leurs champs reels), ajout/edition/suppression/haut-bas via
  POST/PUT/DELETE /workspace/automations[/steps]/...
- ✨ Convertir le JSON en pipeline (legacy → steps ordonnes) ; textareas
  JSON masques des qu'un step existe.
- Gate E2E « editeur visuel de steps » : creation → edition → ajout →
  carte « Action · webhook » sous CSP reel.

Fixed (trouve par le gate) — 51 expressions Alpine MULTI-INSTRUCTIONS
(`a=1; b()`) = interdites par le parseur CSP (une seule expression par
directive ; ';' = token inattendu) — INVISIBLE pour le scan par tokens :
- Conversion en methodes dans 11 fichiers : nav settings x8 (navTo),
  menu section base x7 (closeAndSetCount/Move/...), parts+editeur x13
  (setSharePerm, more*, markAndSave...), breadcrumb x5 (hover*/goClose),
  library/local x6 (menus popup), board x3 (pickStatus/...), ctx-menu x2
  (addTagAndClear), agent/card/gitea/workspaces x6.
- Scanner dedie scan_semi (inventaire ';' hors chaines) ajoute au lot.

Verifs : 39 templates Jinja parse OK · scan expressions = 0 incompatible
(4 faux positifs en chaines) · **E2E 8/8** · suite **1094/1094** ·
ruff OK · docs a jour
2026-10-02 16:56:29 -04:00
bruno b0af1bbfb6 feat: palette Ctrl+K — onglets Pages / ✨ Réponses IA (v7.44.0)
FlowDeck CI / lint (push) Successful in 1m57s
FlowDeck CI / test (push) Successful in 15m21s
FlowDeck CI / docker (push) Successful in 1m51s
Added :
- Onglets dans la palette (markup + CSS + wiring IIFE) :
  · Pages — comportement inchange (recherche /api/search + actions).
  · ✨ Réponses IA — POST /api/v2/search/ask (debounce 150 ms, CSRF via
    getCsrf(), gardes staleness onglet+requete) → answer_markdown rendu :
    echappement AVANT injection, [[fdpage:ID]] → lien citation (ids =
    chiffres, type = [a-z]+ — pas d'injection), **gras**, bloc « Sources »
    avec liens /pages/{id} · /db/{id} ; etats hint/chargement/erreur.
- Gate E2E etendu : ouverture → clic onglet IA → reponse non-echappee.
  (probe : ask = 200 en 1,6s, 8 citations, provider actif)

Reporte (backend absent) : onglet Fichiers — /api/search ne renvoie que
pages/collections → endpoint a creer d'abord (ROADMAP, entree mise a jour).

Notes : 401 transitoire sur le 1er ask d'un run E2E observe une fois
(session fraiche), non reproductible ensuite — signale CHANGELOG.

suite **1094/1094** · ruff OK · E2E **7/7** · docs a jour
2026-10-02 16:27:52 -04:00
bruno de751ffe35 feat: A20 TERMINÉ — Alpine en build CSP, unsafe-eval retiré de la CSP (v7.43.0)
FlowDeck CI / test (push) Successful in 15m24s
FlowDeck CI / lint (push) Successful in 2m1s
FlowDeck CI / docker (push) Successful in 1m52s
La bascule A20 phase 3 :
- static/js/alpine.csp.min.js (build officiel @alpinejs/csp, 0
  eval/new Function, parseur maison) servi partout : base.html,
  import.html, welcome.html + entree sw.js (cache bump v8).
- CSP : script-src 'self' 'nonce-…' — unsafe-eval SUPPRIMÉ (ne servait
  plus qu'Alpine standard). htmx allowEval:false deja pose (v7.37).
- Assertion test inversée : assert "'unsafe-eval'" not in script_src.
- Scan statique final sur TOUS les templates : 0 expression incompatible
  (4 residus = faux positifs dans des chaines de texte).

Pré-requis réunis par les lots 1-3 : 12 surfaces migrées + gateées
(csp_preview), registres Alpine.data, x-html → x-init+Alpine.effect,
délégués window.E, partage d'état lexical, bug topbar corrigé.

Verifs : suite **1094/1094** · ruff OK · eslint 0/0 · **E2E 7/7 sous
CSP reel** (script-src sans unsafe-eval verifie sur l'instance).

Hors gate (scan propre, gitea down) : board/table_view/teamload/
card_detail → à vérifier au premier usage avec gitea remonté (noté
ROADMAP/CHANGELOG).
2026-10-02 16:00:12 -04:00
bruno 48b5551b93 fix: BUG TOPBAR — boutons du header servis échappés sur toutes les pages (v7.42.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
Cause racine : {% set right_actions = '…' ~ fd_icon(…) ~ '…' %} —
fd_icon est une macro → Markup, et Markup.__radd__/__add__ ÉCHAPPE ses
arguments str → tous les segments littéraux sortent entité-és (&#34;/&lt;),
et le |safe de _header:141 est no-op sur un Markup déjà échappé.
Régression probable depuis A10 (activation d'autoescape).

Fix (5 templates, forme idiomatique) : conversion en block-set
{% set right_actions %}…{{ fd_icon(…) }}…{% endset %} — source brute,
interpolation Markup brute : gitea_workspace, page_editor,
page_editor_collection, workspace, workspaces. (Piège script : regex
greedy multi-lignes avalait le set suivant → matcher sur UNE ligne.)

Tests :
- NOUVEAU tests/test_topbar_right_actions.py (permanent) : /workspaces
  doit servir class="topbar-btn" parsé et ZÉRIE entité &#34;
- gate éditeur CSP : assertion .star-btn RÉTABLIE (les boutons rendent)
- debug temporaires (DBGCLS/DBGVAL) retirés

suite **1094/1094** (+1 nouveau test) · ruff OK · E2E **7/7** (5
csp_preview + 2 smoke) · docs à jour (CHANGELOG détail, ROADMAP bug →
CORRIGÉ)
2026-10-02 15:39:57 -04:00
bruno e4552c3763 chore: probe Playwright jetable retirée (e2e/_probe_edit.js)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
2026-10-02 15:15:58 -04:00
bruno 6914780f24 feat: A20 phase 3 LOT 3b — gitea + agent + éditeur verts en CSP (v7.41.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
Changed :
- gitea_workspace : x-data="giteaWorkspace" → appel giteaWorkspace(),
  new Date(…) → fmtGwDate(pp), x-html icône arbre → bindGwIcon (x-init +
  Alpine.effect).
- agent_panel : x-html markdown → bindMarkdown($el, m) (effet reactif).
- page_editor : les 12 sites window.E du topbar right_actions →
  délégués appState (edCall('…') x6, edTimeAgo, edCommentCount, edShared,
  bindStar — les 2 branches du ternaire favorited étaient identiques) ;
  + 3 sites dans _page_editor_content (edCall commentOnSelection,
  openBacklink, fmtImportSize, bindIconHtml). Garde Jinja : quotes \' dans
  le set délimité par ' (quote nue = 500).
- Gate éditeur (csp_preview) : création collection → /pages/{id},
  délégués + editorState liés, filet 0-erreur.

Fixed :
- x-html iconHtml() du contenu éditeur = directive INTERDITE sous build
  CSP (attrapé par le filet) → x-init + Alpine.effect.

⚠️ BUG pre-existant identifie (pas introduit ici) : les right_actions du
topbar sont servis ÉCHAPPÉS sur TOUTES les pages (entities &#34;/&lt; —
boutons Share/Star/Settings en texte brut). _header:141 a bien |safe,
ENV standard, rendu local = PARSED ; cause serveur à cerner → suivi
ROADMAP dédié. Le gate éditeur n'asserte donc pas la présence boutons.

suite **1093/1093** · ruff OK · E2E **7/7** (5 csp_preview + 2 smoke)
· docs a jour
2026-10-02 15:15:26 -04:00
bruno d7d9966edf feat: A20 phase 3 LOT 3a — 5 surfaces CSP vertes + fix bug /import (v7.40.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Ajout :
- Gate csp_preview « surfaces simples » : /welcome, /trash, /accounts,
  /workspace, /import — 0 modification necessaire sur les 4 premieres
  (scan statique 0 expression/x-html + registres Alpine.data du lot 1).
  8 surfaces couvertes au total.

Fixed (pre-existant, visible sous les DEUX builds) :
- /import : x-text "'🔗 '+report.relations…" evalue avec report=null
  (le x-show parent ne masque pas, il initialise quand meme) →
  pageerror « Cannot read property ... 'relations' » → garde
  report && report.relations.

Reste ph3 documente dans ROADMAP : page_editor (12 sites window.E dans
right_actions), gitea_workspace (new Date), agent_panel (x-text+x-html
markdown), board/table_view/teamload/card_detail (scan propre, gates lies
au contexte Gitea) → bascule reel ensuite.

suite **1093/1093** · ruff OK · eslint 0/0 · E2E **6/6** (4 csp_preview +
2 smoke) · docs a jour
2026-10-02 13:49:11 -04:00
bruno 3cab76fed5 feat: A20 phase 3 LOT 2 — settings + local workspace verts en CSP preview (v7.39.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Ajout :
- 2 gates csp_preview de plus : settings (composant lie, overlay visible)
  et local workspace (recherche focalisee via Alpine.nextTick, chips
  filtre en SVG via bindSvg, 0 erreur) → 3 surfaces vertes sous build
  CSP : library, settings, local workspace.

Changed :
- settings : window.history.back()/new Date(...) → methodes
  historyBack/fmtLastLogin/fmtAuditDate ; ?. → ternaires.
- local workspace : x-data="_wsInitData" → registre wsInitData() ;
  14 x-html → x-init + Alpine.effect (bindSvg/bindFileIcon/bindNodeIcon/
  bindChildren/bindPreview) ; $nextTick+$refs arrow → toggleSearch() ;
  window.FlowDeck.* → createPageAt/createFolderAt ; ?. → ternaires ;
  @contextmenu="_wsInitData.*" → appel de methode.

Piesges resolus (CHANGELOG en details) :
- snapshot ji du build CSP = valeurs globalThis au boot → l'objet mis sur
  window avant Alpine est banni (« Accessing global variables ») → objet
  porte par une CONST LEXICALE (non propriete globalThis) + factory
  Alpine.data → MEME objet partage, reactivite intacte.
- bloc preview hors div racine (structure pre-existante, masquee par le
  fallback window standard) → composant wsPreview DELEGUANT vers
  _wsInitData via Alpine.reactive (wrapper unique : les magics $nextTick
  ne sont redefinissables qu'une fois).
- .env local : RATE_LIMIT_REQUESTS=600 (rafales E2E vs 60/min par IP ;
  defaut produit inchange).

suite **1093/1093** · ruff OK · eslint 0/0 · E2E **5/5** (3 csp_preview + 2
smoke) · CSP preview ET standard = 0 erreur sur /local-workspace · docs
a jour
2026-10-02 13:24:46 -04:00
bruno 6ff88237fc feat: A20 phase 3 LOT 1 — shell + library migres, harnais csp_preview vert (v7.38.0)
FlowDeck CI / lint (push) Successful in 1m56s
FlowDeck CI / test (push) Successful in 15m32s
FlowDeck CI / docker (push) Successful in 1m51s
Ajout :
- e2e/csp_preview.spec.js — apercu CSP strict SANS deployer : le build
  officiel @alpinejs/csp (e2e/fixtures/alpine.csp.js, 0 eval) est servi a
  la place de alpine.min.js par interception Playwright ; toute expression
  que le parseur maison ne digere pas = pageerror (filet). Premiere
  surface VERTE : library (composant lie, icones SVG via Alpine.effect,
  recherche ouverte + focalisee, 0 erreur).

Changed :
- 16 composants x-data="fn()" enregistres via Alpine.data (registre =
  seule resolution du build CSP, probe « Undefined variable » ;
  scripts classiques executes pendant le parsing => alpine:init toujours
  joint) : appState, libraryPage, workspacesPage, editorState, board x4,
  settings/import/table_view/team_load/trash/workspace/welcome/accounts/
  card_detail.
- base.html (shell) migre : x-effect document.* -> syncSidebarClass(),
  $nextTick(arrow) -> initSidebarSort(), window.FlowDeck.* ->
  fdCreatePage/fdCreateFolder/fdGwRefresh, Object.keys/Math.min/
  window.innerWidth dans x-for et :style -> sidebarSections()/
  sectionMenuPos() — tout = simple appel de methode.
- x-html restants du shell -> x-init + Alpine.effect : icone agent,
  carte projet, library x3 ; recherche library -> toggleSearch()
  (Alpine.nextTick) ; openMoveSelected() pour Object.keys en expression.
- eslint : 70 warnings -> 0/0 (globals getCsrf depuis A38 ph1,
  /* exported openCardDetail */ + /* global owner, repo */, 3 ;; residuels).

suite **1093/1093** · ruff OK · eslint 0/0 · E2E 3/3 (csp_preview + smoke x2)
· docs a jour (ROADMAP ph3 LOT 1, CHANGELOG, WORKLOAD, VERSION)
2026-10-02 12:08:18 -04:00
bruno 840d2b2615 feat: A20 — htmx allowEval off + plan Alpine CSP phase 3 scopé par probes (v7.37.0)
FlowDeck CI / docker (push) Successful in 1m49s
FlowDeck CI / lint (push) Successful in 1m55s
FlowDeck CI / test (push) Successful in 15m4s
Changed :
- htmx `allowEval: false` dans le meta htmx-config (base.html) : plus
  d'évaluation JS côté htmx (hx-on/hx-vars/hx-vals = 0 usage grep → zéro
  régression possible) ; unsafe-eval reste UNIQUEMENT pour Alpine standard.
- Gate E20 renforcée : le smoke vérifie que `Alpine.$data()` lie un vrai
  composant [x-data] de la page (lien composant = cœur de toute bascule CSP).
- sw.js : cache bump flowdeck-v7 (purge + re-precache après Inter).

Probes (non conservés, retirés après mesure) — A20 phase 3 scopée :
- Build `@alpinejs/csp` téléchargé et TESTÉ : 72 Ko, 0 eval/new Function,
  parseur d'expressions maison, tourne sous CSP strict (meta sans
  unsafe-eval) — le lint sélectif fonctionne.
- Mais bloqué sur FlowDeck :
  (a) 13 expressions non parsables par la grammaire restreinte
      (arrows ×2, typeof ×1, new Date ×4, optional-chaining ×6 ;
       base, library, local_workspace, settings, gitea_workspace) —
      le gate E2E a attrapé la première : `CSP Parser Error: Unexpected
      token: PUNCTUATION ")"` ;
  (b) 24 `x-html` réactifs (icônes SVG + markdown agent + preview) =
      INTERDITS par le build CSP (innerHTML) → architecture d'icônes à
      reposer ;
  (c) scope des expressions CSP = données du composant uniquement
      (probe : `Undefined variable: fmtDate` / `document`) → chaque site
      devient une méthode Alpine.data enregistrée.
- Conséquence : build CSP reverté (alpine.min.js ×3 templates + sw),
  unsafe-eval maintenu, fichier alpine.csp.min.js retiré (re-téléchargeable),
  assert test CSP de nouveau `in`. Plan de migration composant par composant
  (library → settings → local_workspace → gitea → base) + gate E2E par
  surface documenté dans ROADMAP (A20 phase 3).

suite **1093/1093** · ruff OK · E2E **2/2** (dont assertion Alpine.$data)
· docs à jour (ROADMAP A20 phase 3, CHANGELOG, WORKLOAD, VERSION)
2026-10-02 11:14:47 -04:00
bruno ab6ac1e84c feat: fondations E2E + 2 bugs trouvés (onglets ?view=, Inter CSP) (v7.36.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 3m26s
Ajout — e2e/smoke.spec.js (2 gates verts contre l'instance de test) :
- gate A39 : bascule de vues d'une collection (clic onglet Calendar →
  ?view_type=calendar, grille .calendar + .cal-header rendue ; collection
  créée puis SUPPRIMÉE = répétable)
- gate A20 : palette Ctrl+K (ouverture Alpine .open, recherche GET rend
  .cmd-palette-item, fermeture Échap)
- filet console : 0 erreur JS/CSP (bruit Failed to load resource 401/403
  filtré)
- Service Workers bloqués : /sw.js sert sa page « hors ligne » sur les
  navigations redirigées (redirect:'manual') — pwa_offline.spec.js couvre
  le SW
- bootstrap autonome : login OU création du compte e2e documenté (jamais
  de mot de passe deviné), workspace si absent
- commande : cd e2e && node node_modules/@playwright/test/cli.js test smoke.spec.js

Fixed — trouvés par les gates :
1. Bascule de vues standalone JAMAIS fonctionnelle : les onglets
   émettaient ?view=… mais la route lit `view_type` (FastAPI) → l'onglet
   restait sur Table quel que soit le clic (bug pré-existant, A28 n'y est
   pour rien). Onglets → ?view_type= ; test_all_view_tabs_present adapté +
   assertion comportementale (GET ?view_type=calendar rend .calendar).
2. Inter bloqué par la CSP depuis v7.27 : app.css importait encore
   Google Fonts (@import raté par le grep de la passe v7.27) → violation
   style-src sur chaque page + police en fallback. Inter auto-hébergé :
   2 faces variables (100-900, latin + latin-ext) dans static/fonts/,
   @import supprimé (8 fichiers dupliqués dédupliqués → 2).

suite **1093/1093** · ruff OK · E2E **2/2** · docs à jour
2026-10-02 10:23:34 -04:00
bruno 3a74ea8bbd fix: A35 TERMINÉ — drift Python 3.12→3.13 aligné, rebuild validé (v7.35.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
Dernier reliquat de l'A35 (docs/périmètre dérivés) :

- Dockerfile : python:3.12-slim → python:3.13-slim (builder + runtime)
- .gitea/workflows/ci.yml : python-version '3.12' → '3.13' (lint + test)
- README.md : « Python 3.12 » et « python:3.12-slim » → 3.13 (×2)
- pyproject.toml : ruff target-version py312 → py313 (0 nouvelle
  remarque ruff)
- zéro référence 3.12 résiduelle ; uv.lock (requires-python >=3.13) et
  le venv (3.13.14) étaient déjà bons

Validation (le point laissé « à faire par un rebuild d'image ») :
- docker build VERT sur python:3.13-slim → image flowdeck:a35-py313
- dans le conteneur : python -V = 3.13.16, `import app.main` OK
  (v7.35.0) → wheels requirements.txt construits + importables sur 3.13

A35 = TERMINÉ (OpenAPI/README/titre dupliqué faits en 7.3.9 + drift).

suite **1093/1093** · ruff OK (target py313) · docs à jour
2026-10-02 09:39:02 -04:00
bruno 13dc8fdaad fix: A38 phase 2 — 0 doublon de fonction globale + garde-fou (v7.34.0)
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
- Inventaire exhaustif des 13 noms `function NAME` définis 2+ fois
  (templates + static/js) avec scan de profondeur de brace (strings,
  comments, backticks gérés) : 12 sont déjà scopés dans des IIFEs
  depuis A27 (escHtml/flush/emit/setMeta/initials/up/esc/show/close…) —
  aucun conflit de page possible.
- Seul doublon GLOBALE = openCardDetail (corps byte-identiques ×2 dans
  board_fragment + detailed_board, fragments de vues mutuellement
  exclusifs) → dédupliquée vers static/js/app.js, 2 copies supprimées ;
  les onclick/@click des deux fragments appellent la même définition
  (owner/repo globaux fournis par board.js au moment du clic).
- test_no_duplicate_global_functions : garde-fou 0-doublon entre
  templates et static/js (scanner naïf, plafond ponytail commenté).

Reste A38 : méthodes jumelles library/local_workspace (~9-21 noms
communs, corps divergents) → fusion workspace-tree.js reportée
(réconciliation sans E2E, même logique que A39/A20).

suite **1093/1093** · ruff OK · node --check vert · docs à jour
2026-10-02 09:18:04 -04:00
bruno 770fdc2b68 fix: A43 TERMINÉ + A38 phase 1 — CSRF rendu côté serveur, helper unique (v7.33.0)
FlowDeck CI / lint (push) Successful in 1m57s
FlowDeck CI / test (push) Successful in 15m20s
FlowDeck CI / docker (push) Canceled after 0s
A43 (les 4 items sont clos) :
- `__CSRF_PLACEHOLDER__` supprimé : ContextVar CSRF_TOKEN posée par
  CSRFMiddleware avant call_next (même mécanique que le nonce CSP),
  global `{{ csrf_token() }}` dans templating, base.html rend
  `{"X-CSRF-Token":{{ csrf_token()|tojson }}}` — vide si cookie absent,
  `htmx:configRequest` re-lit le cookie à chaque appel → plus jamais de
  jeton factice servi.
- Palette : `fetch('/api/search…')` SANS header (GET ∈ SAFE_METHODS →
  CSRF inapplicable) — le JSON.parse du body-attr par frappe disparaît.
- utcnow déprécié = 0 dans app/**.py ; health loggé (A25) + booléens db/
  gitea = raison ; probe réseau = voulu (test de connectivité).

A38 phase 1 (CSRF unifié) :
- `window.getCsrf()` unique dans le <head> de base.html.
- 76 lectures brutes du cookie → getCsrf() dans 13 fichiers (47 formes
  `(…||[])[1]||''`, 25 déclarations `const X = match(…)` avec conversion
  de leurs usages `X?X[1]:''` → `X` (noms collectés PAR FICHIER, jamais
  de règle globale), 4 formes espacées).
- Définitions dupliquées supprimées : card_detail.html (multi-lignes),
  database_table.js (1 ligne dans IIFE → chute sur le global).
- Les 3 variantes de base.html (IIFE getCsrf + 2 getCsrfToken) →
  `return getCsrf()`.
- welcome.html garde sa lecture locale (page autonome sans base) ;
  reste exactement 1 raw dans base.html (la déf head) + 2 dans welcome.

Test : test_csrf_server_rendered_no_placeholder (pas de placeholder +
token du hx-headers == cookie csrf_token).

suite **1092/1092** · ruff OK · node --check vert · docs à jour
2026-10-02 08:45:27 -04:00
bruno 0bc74ad728 refactor: A28 TERMINÉ — board.py (2 101 L) → package 14 fichiers (v7.32.0)
FlowDeck CI / lint (push) Successful in 2m1s
FlowDeck CI / test (push) Successful in 15m17s
FlowDeck CI / docker (push) Canceled after 0s
Lot 4/4 de l'A28 (god files) : l'ancien app/routers/board.py (2 101
lignes, 53 routes) devient le package `app/routers/board/` :

- 12 modules de routes : pages 271 L (7 r.), page_api 229 (5),
  board_views 223 (8), page_ops 176 (3), sharing 175 (10), synced 144 (8),
  page_media 122 (4), import_ 85 (2), wiki 76 (2), library 66 (1),
  embed 64 (2), sync 51 (1)
- _common.py (878 L) : 23 helpers dont 4 async + les 4 constantes
  (STATUS_COLORS, STATUS_LABELS, AI_KEYWORD_COLORS, _REPO_REF_RE)
- __init__.py : __all__ complet — importateurs inchangés (api.py ×4
  top-level, webhooks top-level, dashboard ×5 lazy, tests ×4)

Preuve contractuelle : docs/openapi-v2.json régénéré = IDENTIQUE
byte-à-byte (509 chemins, ordre préservé).

Pièges rattrapés :
- constantes d'état oubliées dans _common à la 1ʳᵉ passe (F821 +
  ImportError au chargement) → ré-insérées avec les valeurs exactes
- docstring du header copié → F404 → slice [1:21]
- helpers `async def` non détectés par `def ` seul

A28 TERMINÉ en 4 lots : api_v2 (7.29.0), dashboard (7.30.0),
collections (7.31.0), board (7.32.0) — 0 changement d'URL sur les 4.

suite **1091/1091** · ruff OK · OpenAPI 509 identique · docs à jour
2026-10-02 08:27:57 -04:00
bruno adf56a2dd8 refactor: A28 lot 3 — collections.py (2 622 L) → package 13 fichiers (v7.31.0)
FlowDeck CI / docker (push) Successful in 1m54s
FlowDeck CI / lint (push) Successful in 1m55s
FlowDeck CI / test (push) Successful in 15m28s
Découpe par concern de l'ancien app/routers/collections.py (2 622 lignes,
53 endpoints / 52 fonctions) en package `app/routers/collections/` :

- 10 modules de routes : crud 337 L (6 r.), properties 322 (8),
  linked 286 (7), structure 267 (8), dashboard_views 214 (3),
  meta 197 (5), views 187 (6), pages 184 (4), data_api 122 (2),
  boards 61 (3)
- _common.py (220 L) : 8 helpers auth/permissions/validation
- _renderers.py (667 L) : 15 rendus HTML des vues + CHART_MAX_GROUPS
- __init__.py : ré-exports connus (_validate_page_properties pour
  automations ; _chart_values/_chart_aggregate/_fmt_number/_render_chart
  pour les tests) + __all__

Preuve contractuelle : docs/openapi-v2.json régénéré = IDENTIQUE
byte-à-byte (509 chemins, ordre préservé).

Pièges rattrapés :
- docstring d'origine conservée dans le header copié → F404
  (from __future__ après un statement) → slice [1:30]
- décorateurs empilés (view_collection ×2) : segment sans def →
  skip du 2e décorateur (53 endpoints = 52 unités)
- CHART_MAX_GROUPS hors détection des helpers (F821) → import ._renderers
- test_csp_no_cdn_and_vendor lisait collections.py → balayage du package

Reste A28 : board.py 2 101 L (lot 4).

suite **1091/1091** · ruff OK · OpenAPI 509 identique · docs à jour
2026-10-02 08:16:03 -04:00
bruno c0925e511b refactor: A28 lot 2 — dashboard.py (2 735 L) → package 10 fichiers (v7.30.0)
FlowDeck CI / lint (push) Successful in 1m56s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 9m39s
Découpe par concern de l'ancien app/routers/dashboard.py (2 735 lignes,
63 routes) en package `app/routers/dashboard/` :

- 8 modules de routes : local_workspace 559 L (15 r.), pages_html 485 (6),
  account_settings 439 (16), workspace 321 (9), pages_api 240 (6),
  workspaces 131 (6), public 78 (1), account_api 77 (4)
- _common.py (774 L) : les 15 helpers top-level INTERCALÉS dans l'ancien
  fichier + état (logger, _VERSION, WORKSPACE_COOKIE)
- __init__.py : ordre d'enregistrement identique à l'origine, re-export
  complet (7 importateurs inchangés : main, board ×3, my_tasks,
  web_clipper, wiki, sites `_dash._render_blocks_public`, tests) + __all__

Preuve contractuelle : docs/openapi-v2.json régénéré = IDENTIQUE
byte-à-byte (509 chemins, ordre préservé).

Pièges rattrapés :
- segment décorateur sans sa fonction → assert `def in seg` + récupération
  git (corps perdus en silence à la 1ʳᵉ exécution)
- collision `settings` (section vs from app.config import settings →
  hasattr du fromlist) → renommée account_settings
- WORKSPACE_COOKIE utilisé sans import dans workspaces.py (F821)
- script __all__ mangeant la fin du fichier → __init__ réécrit

Reste A28 : collections.py 2 622 L, board.py 2 101 L (lot 3).

suite **1091/1091** · ruff OK · OpenAPI 509 identique · docs à jour
2026-10-02 07:47:41 -04:00
107 changed files with 11570 additions and 7762 deletions
+2 -2
View File
@@ -13,7 +13,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
python-version: '3.13'
- name: Install lint tools
run: pip install -r requirements-dev.txt
- name: Ruff (Python)
@@ -31,7 +31,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
python-version: '3.13'
- name: Install system dependencies (WeasyPrint / emoji fonts)
run: |-
SUDO=""
+574
View File
@@ -1,5 +1,579 @@
# Changelog - FlowDeck
## v7.45.2 (2026-10-03) — Navigation partielle : montage Alpine sans course + scripts idempotents
### Fixed
- **Cascades d'erreurs Alpine en navigation partielle** (« Undefined
variable: … » puis « reading 'has' » sur `$store.fdCtx`) : sur un swap
fdLoad, les `<script src>` de page s'exécutent APRÈS le microtask
MutationObserver d'Alpine → toute la zone `.main-wrapper` s'initialisait
avant que composants ET stores (menu contextuel) existent, et le
démontage anticipé dès le premier script chargé perdait la course avec
les autres. Fix : `x-ignore` sur **toute** la zone swapée au
`htmx:afterSwap` (uniquement quand le nœud `.main-wrapper` a été
remplacé — swaps sidebar/vues inchangés), démontage **unique** quand
**tous** les `<script src>` ont exécuté (load/error + filet 4 s) via
`Alpine.initTree` (idempotent grâce au `_x_marker`). Vérifié : 7 pages ×
(complet / 1ʳᵉ / 2ᵉ visite partielle) + éditeur (3 phases) = 0 erreur.
- **`SyntaxError: Identifier 'LW' has already been declared`** : tout
script de page ré-exécuté à la 2ᵉ visite partielle plantait entièrement
(`const` de haut niveau re-déclaré) → panneaux Alpine non montés.
Garde de fichier posé sur `local_workspace.js`, `board.js`,
`settings.js`, `database_table.js`, `page_editor_realtime.js` (pattern
déjà utilisé par `page_editor_scripts.js`).
- **Enregistrement Alpine différé** : `if (window.Alpine) Alpine.data(…
sinon listener 'alpine:init'` (l'événement ne sera plus jamais émis sur
swap) dans 8 scripts de page et 7 templates inline (`accounts`,
`card_detail`, `table_view`, `team_load`, `trash`, `welcome`,
`workspace`).
- **Polices Inter orphelines** : `app.css` référençait 7 fichiers
inexistants (`inter-400-latin.woff2`…) → 302 → HTML → « Failed to
decode downloaded font » sur chaque page. Blocs legacy supprimés (les
faces variable Inter 100-900 restent).
### Added
- **Gates E2E** : `e2e/regression_editor_mount.spec.js` (éditeur en 3
phases : partielle / complet / 2ᵉ partielle) et
`e2e/regression_partial_nav.spec.js` (7 pages × 3 passages + état
fdCtx/appState) —0 erreur Alpine attendue.
## v7.45.1 (2026-10-02) — Fix logout « hors ligne » (SW) + drag & drop upload 403
### Fixed
- **Logout → page « Vous êtes hors ligne »** : le fetch event de navigation
arrive en `redirect:'manual'` → une 302 du serveur se lisait
`opaqueredirect` (status 0) → `bad status` → filet offline. Tout logout
(et toute navigation redirigée) affichait la page hors ligne alors que le
backend répondait en ~10 ms. `networkFirst` rejoue désormais la requête en
`redirect:'follow'` et sert une **302 synthétique vers l'URL finale**
(Chromium refuse une response `redirected` servie à une navigation →
`net::ERR_FAILED`). Bonus : `timeoutFetch` annule réellement la requête
(l'`AbortController` créée dans le `setTimeout` n'était pas branchée sur
`fetch`).
- **Drag & drop de fichiers/dossiers en échec silencieux (403 CSRF)** :
`_doUpload()` (POST `/api/local-workspace/upload[-folder]`) et
`toggleFavorite()` du local workspace n'envoyaient pas `X-CSRF-Token` —
les 2 derniers appels mutants du front oubliés par A19 (exemption CSRF
retirée, balayage complet des `fetch` mutants refait).
- **Gate E2E** : `e2e/regression_logout_dnd.spec.js` enregistre les 2 bugs
(logout sous SW → page login, drop de fichier → 200 + création, avec
nettoyage).
## v7.45.0 (2026-10-01) — Éditeur visuel d'automations + correction CSP (multi-instructions)
### Added
- **Pipeline visuel (steps) dans Settings → Automations** : cartes
ordonnées par étape avec badge + résumé (`📡 Déclencheur · event`,
`⚖ Condition · prop op val`, `⏳ Attente · Xs`, `⚡ Action · type → …`),
**éditeur typé par kind/type** (événements en datalist, 7 ops de
condition, 8 types d'action avec leurs champs réels : url, property/value,
collection/title, message, webhook_url/text, to/subject/body,
owner/repo/labels, agent_id/message), ajout/édition/suppression/**↑↓** via
`POST/PUT/DELETE /workspace/automations[/steps]/…`, conversion legacy
**✨ Convertir le JSON en pipeline** (trigger + conditions + actions
ordonnés) ; les textareas JSON disparaissent dès qu'un step existe.
- **Gate E2E** « éditeur visuel de steps » : création → édition → ajout
d'étape → carte `Action · webhook` visible (sous CSP réel).
### Fixed (trouvé par le gate)
- **51 expressions Alpine multi-instructions** (`activeSection='x';
loadX()` etc.) = **interdites par le parseur CSP** (une seule expression
par directive ; `;` = token inattendu) — invisible pour le scan par
tokens ! Conversion en **méthodes** : settings nav ×8 (`navTo`), menu de
section base ×7 (`closeAndSetCount/…`), parts/éditeur ×13
(`setSharePerm`, `more*`, `markAndSave`…), breadcrumb ×5
(`hoverEllipsis/goClose`), library/local ×6 (menus), board ×3
(`pickStatus/…`), ctx-menu ×2 (`addTagAndClear`), agent/card/gitea/
workspaces ×6. Scanner dédié `scan_semi` (inventaire `;` hors chaînes).
### Notes
- 39 templates Jinja parse OK, scan d'expressions = 0 incompatibilité
(4 faux positifs en chaînes), E2E **8/8**, suite **1094/1094**.
## v7.44.0 (2026-10-01) — Palette Ctrl+K : onglets Pages / ✨ Réponses IA
### Added
- **Palette `Ctrl+K` = 2 onglets** (markup + CSS + wiring dans l'IIFE) :
· **Pages** — comportement inchangé (recherche `/api/search` + actions).
· **✨ Réponses IA** — `POST /api/v2/search/ask` (debounce 150 ms,
CSRF via `getCsrf()`, gardes staleness onglet+requête) → rendu de
`answer_markdown` : **échappement AVANT injection**, `[[fdpage:ID]]` →
lien citation (ids = chiffres, type = `[a-z]+` — pas d'injection),
`**gras**`, bloc « Sources » avec liens `/pages/{id}` · `/db/{id}`.
États : hint / chargement / erreur.
- **Gate E2E étendu** (`gate A20 palette`) : ouverture → clic onglet IA →
réponse non-échaffée affichée (le backend tourne en extractif ou LLM —
probe : 200 en 1,6 s, 8 citations).
- **Reporté (backend absent)** : onglet `Fichiers` — `/api/search` ne
renvoie que `pages`/`collections` → endpoint à créer d'abord (ROADMAP).
### Notes
- Un 401 transitoire sur le 1er ask d'un run E2E a été observé (session
fraîche) — non reproductible ensuite ; à surveiller si ça revient.
## v7.43.0 (2026-10-01) — 🎉 A20 TERMINÉ : Alpine en build CSP, `unsafe-eval` retiré
### Changed (la bascule A20 phase 3)
- **`static/js/alpine.csp.min.js`** (build officiel `@alpinejs/csp`,
0 `eval`/`new Function`, parseur d'expressions maison) servi partout :
`base.html`, `import.html`, `welcome.html` + entrée `sw.js` (cache bump
v8).
- **CSP** : `script-src 'self' 'nonce-…'` — **`unsafe-eval` supprimé** (il
ne servait plus qu'Alpine standard). htmx : `allowEval: false` déjà posé
(v7.37).
- Assertion test inversée : `assert "'unsafe-eval'" not in script_src`.
- Scan statique final sur **tous** les templates : 0 expression incompatible
(4 résidus = faux positifs dans des chaînes de texte).
### Notes
- 12 surfaces migrées + gateées en amont (lots 1-3a/3b) ; **E2E 7/7 sous
CSP réel** (le route-swap du `csp_preview` sert désormais le même
fichier — les gates restent utiles contre une régression du build).
- board/table_view/teamload/card_detail : scan propre mais non gateés
(gitea down) → à vérifier au premier usage avec gitea remonté.
- Risque résiduel assumé : expressions n'ayant jamais tourné en runtime
sur ces 4 surfaces (les gates + le filet 0-erreur les attraperont).
## v7.42.0 (2026-10-01) — 🐛 BUG TOPBAR CORRIGÉ (boutons du header en texte brut)
### Fixed
- **Les `right_actions` du topbar étaient servis ÉCHAPPÉS sur TOUTES les
pages** (entities `&#34;`/`&lt;` → boutons Share/Star/Settings/Login en
texte brut). **Cause racine** : `{% set right_actions = '…' ~ fd_icon(…) ~ '…' %}`
— `fd_icon` est une **macro → `Markup`**, et `Markup.__radd__/__add__`
**échappe ses arguments `str`** → tous les segments littéraux sortent
entité-és ; le `|safe` de `_header:141` est then no-op sur un Markup déjà
échappé. Découvert en cherchant l'échec du gate éditeur CSP (v7.41.0),
reproductible partout (`curl /workspaces`).
- **Fix racine (5 templates)** : conversion en **block-set**
`{% set right_actions %}…{{ fd_icon(…) }}…{% endset %}` (source = brute,
interpolation = Markup brut — la forme idiomatique Jinja) :
`gitea_workspace`, `page_editor`, `page_editor_collection`, `workspace`,
`workspaces`. Piège du script : regex greedy multi-lignes = set avalé →
matcher **une ligne**.
- **Test permanent** `tests/test_topbar_right_actions.py` : `/workspaces`
doit contenir `class="topbar-btn"` parsé et ZÉRIE entité `&#34;`.
- Gate éditeur : l'assertion `.star-btn` **ré-ajoutée** (les boutons
rendent à nouveau).
### Notes
- Probable régression depuis A10 (activation d'autoescape) : les `~`
étaient des no-op avant, Markup.__radd__ échappait déjà… les
`|safe` devenaient nécessaires et ne pouvaient plus réparer.
## v7.41.0 (2026-10-01) — A20 phase 3 LOT 3b : gitea + agent + éditeur verts
### Changed
- **gitea_workspace** : `x-data="giteaWorkspace"` → appel `giteaWorkspace()`,
`new Date(…)` → `fmtGwDate(pp)`, x-html icône d'arbre → `bindGwIcon`
(x-init + `Alpine.effect`).
- **agent_panel** : x-html markdown → `bindMarkdown($el, m)` (effet réactif
sur `m.content`).
- **page_editor (les 12 sites `window.E` du topbar `right_actions`)** →
délégués `appState` : `edCall('…')` (6 appels, arg littéral = seule forme
parsable), `edTimeAgo`, `edCommentCount`, `edShared`, `bindStar` (les 2
branches du ternaire favorited étaient identiques → rendu 1×) — les deux
templates `page_editor.html` + `page_editor_collection.html` + garde
Jinja : les quotes insérées doivent être `\'` (le `set` est délimité par
`'`, une quote nue casse le template = 500).
- **_page_editor_content** : +3 sites (`window.E.commentOnSelection` →
`edCall`, `backlinksOpen…location.href` → `openBacklink(b)`,
`Math.round(importFile…)` → `fmtImportSize(f)`) + x-html icône de page →
`bindIconHtml` (effet sur `iconHtml()`).
- **Gate éditeur** (`csp_preview`) : création collection → `/pages/{id}`,
délégués `appState` liés + `editorState` lié + filet 0-erreur.
### Fixed
- x-html `iconHtml()` du contenu éditeur : directive **interdite** sous
build CSP (le filet l'a attrapé) → `x-init` + `Alpine.effect`.
### ⚠️ Nouveau bug pré-existant identifié (PAS introdui par ce lot)
- **Les `right_actions` du topbar sont servi ÉCHAPPÉS sur TOUTES les
pages** (entities `&#34;`/`&lt;` — les boutons Share/Star/Settings/…
s'affichent en texte brut). Reproductible : `curl /workspaces` →
`&#34;topbar-btn&#34;`. Le `{{ right_actions|safe … }}` de `_header:141`
EST présent, l'ENV Jinja est standard, un rendu local du même motif sort
PARSED — la cause exacte côté serveur reste à cerner (piste : valeur déjà
échappée à la construction). Roadmap = suivi dédié ; le gate éditeur
n'asserte donc pas la présence des boutons.
## v7.40.0 (2026-10-01) — A20 phase 3 LOT 3a : 5 surfaces de plus vertes
### Added
- **Gate `csp_preview` « surfaces simples »** : `/welcome`, `/trash`,
`/accounts`, `/workspace`, `/import` — **0 modification de code
nécessaire** sur les 4 premières (scan statique 0 expression/x-html +
registres Alpine.data posés au lot 1). **8 surfaces couvertes** au total
(base shell, library, settings, local workspace + celles-ci).
### Fixed
- **Bug pré-existant sur `/import` (visible sous les DEUX builds)** :
`x-text="'🔗 '+report.relations…"` évalué alors que `report = null`
(le `x-show` parent ne masque pas, il initialise quand même) → pageerror
« Cannot read property … 'relations' » — garde `report && report.relations`.
(Le probe montrait aussi un 401 console pré-existant sur la page — hors
périmètre, non touché.)
### Notes
- Reste ph3 : page_editor (12 sites `window.E` dans `right_actions`),
gitea_workspace (`new Date`), agent_panel (site `x-text` markdown +
1 x-html), board + table_view/teamload/card_detail (gabarits liés au
contexte Gitea, scan statique propre) → bascule réelle ensuite.
## v7.39.0 (2026-10-01) — A20 phase 3 LOT 2 : settings + local workspace verts
### Added
- **2 gates `csp_preview` de plus** : `settings` (composant lié, overlay
visible, 0 erreur) et `local workspace` (recherche focalisée via
`Alpine.nextTick`, chips filtre en SVG via `bindSvg`, 0 erreur).
**3 surfaces vertes** sous build CSP : library, settings, local workspace.
### Changed
- **settings** : `window.history.back()`/`new Date(…)` → méthodes
`historyBack`/`fmtLastLogin`/`fmtAuditDate` ; `?.` → ternaires.
- **local workspace** : `x-data="_wsInitData"` → registre `wsInitData()`
(le build CSP ne résout que le registre) ; 14 `x-html` → `x-init` +
`Alpine.effect` (`bindSvg`/`bindFileIcon`/`bindNodeIcon`/`bindChildren`/
`bindPreview`) ; `$nextTick`+`$refs` arrow → `toggleSearch()` ;
`window.FlowDeck.*` → `createPageAt`/`createFolderAt` ; `?.` → ternaires ;
`@contextmenu="_wsInitData.…"` → appel de méthode.
- **Partage d'état JS↔Alpine (piège du build CSP)** : `ji` = snapshot des
**valeurs** de toutes les propriétés `globalThis` au boot → l'objet mis
sur `window` avant Alpine est **banni** (« Accessing global variables is
prohibited »). Fix : objet porté par une **const lexicale** (non propriété
`globalThis`) + factory Alpine.data qui le retourne → **même objet**
partagé, réactivité intacte (une copie `Object.assign` aurait coupé les
mises à jour JS : preview, uploads, isDragging).
- **Bloc preview hors div racine** (structure pré-existante : le parseur
referme la racine avant, masquée par le fallback window d'Alpine
standard) → composant `wsPreview` **déléguant** vers `_wsInitData`
(`Alpine.reactive` pour la réactivité ; wrapper = objet unique, les
magics `$nextTick` ne sont redéfinissables qu'une fois — deuxième
montage du même objet = « Cannot redefine property »).
- **`.env` local : `RATE_LIMIT_REQUESTS=600`** — les rafales E2E
Playwright (5 tests × ~25 requêtes) butaient sur le 60/min par IP ;
défaut produit inchangé.
### Notes
- Reste ph3 : page_editor, board, agent_panel, import, gitea_workspace,
welcome/accounts/trash/team_load/workspace/table_view/card_detail →
puis bascule réelle (retrait `unsafe-eval`).
## v7.38.0 (2026-10-01) — A20 phase 3 LOT 1 : shell + library migres
### Added
- **`e2e/csp_preview.spec.js`** — aperçu CSP strict SANS déployer : le
build `@alpinejs/csp` (fichier officiel, `e2e/fixtures/alpine.csp.js`)
est servi **à la place** de `alpine.min.js` par interception Playwright ;
tout échec du parseur maison = `pageerror` (filet). **Première surface
verte : library** (composant lié, icônes SVG rendues via `Alpine.effect`,
recherche ouverte + focalisée, 0 erreur console/page).
### Changed
- **Composants `x-data="fn()"` → registre `Alpine.data(...)`** (15 +
`appState` + `libraryPage`) : le build CSP ne résout que le registre
(probe : globale window → `Undefined variable`) — scripts/classiques
chargés pendant le parsing = `alpine:init` toujours joint à temps.
- **base.html (shell) migré** : `x-effect document.*` → `syncSidebarClass()`,
`$nextTick(arrow)` → `initSidebarSort()`, `window.FlowDeck.*` →
`fdCreatePage/fdCreateFolder/fdGwRefresh`, `Object.keys`/`Math.min`/
`window.innerWidth` dans `x-for`/`:style` → `sidebarSections()`/
`sectionMenuPos()` — toutes les formes = simple appel de méthode.
- **x-html restants du shell** → `x-init` + `Alpine.effect` :
icône agent (`bindAgentIcon`), carte projet (`bindProjectIcon`),
library ×3 (`bindHtmlIcon`/`bindHtmlItem`), recherche library
(`toggleSearch` avec `Alpine.nextTick`), `openMoveSelected` (library).
- **eslint : 70 warnings → 0/0** : `getCsrf` (helper A38 ph1) déclaré dans
les globals du config, `/* exported openCardDetail */` +
`/* global owner, repo */` (app.js), 3 `;;` résiduels de la conversion
A38 supprimés.
### Notes
- Portes A20 ph3 : surfaces restantes = settings, local_workspace,
gitea_workspace, page_editor, board (partiels), agent_panel, import,
welcome, accounts, trash, team_load, workspace, table_view, card_detail ;
**bascule réelle** (retrait `unsafe-eval`) = quand csp_preview est vert
sur toutes les pages principales.
## v7.37.0 (2026-10-01) — A20 : htmx allowEval off + plan Alpine CSP (phase 3)
### Changed
- **htmx `allowEval: false`** dans le `<meta name="htmx-config">` : htmx
ne peut plus évaluer de JS (`hx-on`/`hx-vars`/`hx-vals`) — grep = **0
usage** dans les templates, donc zéro régression possible. `unsafe-eval`
reste **uniquement** pour Alpine standard.
- **Gate E20 renforcé** : le smoke vérifie désormais que `Alpine.$data()`
lie un vrai composant `[x-data]` de la page (le lien composant = le cœur
de tout basculement CSP).
### Notes — A20 phase 3 (unsafe-eval, scopé par probes)
Le build `@alpinejs/csp` a été **testé empiriquement** (fichier 72 Ko,
**0 `eval`/`new Function`**, parseur d'expressions maison) : il tourne sous
CSP strict, mais **bloqué sur FlowDeck** par deux familles d'usages :
- **13 expressions non parsables** par la grammaire restreinte :
arrows (`$nextTick(() => …)` ×2), `typeof` ×1, `new Date(…)` ×4,
optional-chaining `?.` ×6 (base, library, local_workspace, settings,
gitea_workspace) ;
- **24 `x-html` réactifs** (icônes SVG `getSvgIcon`/`_fileIcon`,
markdown agent, preview) — **interdits par le build CSP** (innerHTML) :
nécessitent de reposer les icônes sur des composants `Alpine.data`.
- Portée : 0 variable globale/`document` accessible dans les expressions
du build CSP (scope = données du composant + magics) → chaque site
devient une méthode de composant enregistrée via `Alpine.data`.
**Plan phase 3** : migrer composant par composant (library → settings →
local_workspace → gitea → base) avec gate E2E dédiée, puis retirer
`unsafe-eval`. En attendant : `unsafe-eval` conservé (Alpine standard).
## v7.36.0 (2026-10-01) — Fondations E2E + 2 bugs trouvés au passage
### Added
- **`e2e/smoke.spec.js`** — 2 gates vert contre l'instance de test :
· **gate A39** : bascule de vues d'une collection (clic onglet Calendar →
`?view_type=calendar`, grille `.calendar` + `.cal-header` rendue,
collection créée puis **supprimée** = répétable)
· **gate A20** : palette Ctrl+K (ouverture Alpine `.open`, recherche GET
rend `.cmd-palette-item`, fermeture Échap)
· **filet console** : 0 erreur JS/CSP (les violations atterrissent ici ;
le bruit `Failed to load resource` 401/403 est filtré)
· **Service Workers bloqués** dans le smoke : `/sw.js` sert sa page
« hors ligne » sur les navigations redirigées (`redirect:'manual'`) —
bruit PWA hors sujet, `pwa_offline.spec.js` couvre le SW
· bootstrap autonome : login OU création du compte e2e documenté,
workspace si absent — lecture seule sur les données existantes
- Commande : `cd e2e && node node_modules/@playwright/test/cli.js test smoke.spec.js`
### Fixed (trouvés par les gates)
- **Bascule de vues standalone jamais fonctionnelle** : les onglets
émettaient `?view=…` mais la route lit `view_type` (FastAPI) → l'onglet
restait sur Table quel que soit le clic (bug pré-existant, non introduit
par A28). Onglets → `?view_type=` + assertion comportementale ajoutée à
`test_all_view_tabs_present`
- **Inter bloqué par la CSP depuis v7.27** : `app.css` importait encore
Google Fonts (`@import` raté par le grep de v7.27) → violation
`style-src` sur chaque page + police tombée en fallback. Inter
**auto-hébergé** : 2 faces variables (100-900, latin + latin-ext) dans
`static/fonts/`, `@import` supprimé
### Notes
- Suite complète : **1093/1093** · ruff OK · E2E **2/2**
- Portes : A20 (unsafe-eval) attaquable avec ce filet ; A39 couvre la
bascule collection (pas la bascule htmx board → décision « rien »
maintenue) ; A38 twins reste conditionné à une couverture élargie
## v7.35.0 (2026-10-01) — Audit : A35 TERMINÉ (Python 3.13 aligné + rebuild)
### Changed
- **Drift Python résolu** (dernier reliquat A35) : tout le projet est en
**3.13** — `Dockerfile` (`python:3.13-slim` builder + runtime), CI Gitea
(`python-version: '3.13'` ×2), `README.md` ×2, ruff
`target-version = "py313"` (0 nouvelle remarque ruff). Restait 3.12 aux
6 endroits alors que `uv.lock` = `requires-python >=3.13` et le venv =
3.13.14. Zéro référence 3.12 résiduelle.
### Validation (le point que l'audit laissait « à faire par rebuild »)
- `docker build` **vert** sur `python:3.13-slim` (image `flowdeck:a35-py313`)
- dans le conteneur : `python -V` = **3.13.16**, `import app.main` OK
(version 7.35.0) → wheels `requirements.txt` construits et importables
sur 3.13
- Suite locale complète : **1093/1093** · ruff OK (target py313)
### Notes
- A35 = **TERMINÉ** (OpenAPI/README/titre faits en 7.3.9 + drift aujourd'hui)
## v7.34.0 (2026-10-01) — Audit : A38 phase 2 (0 doublon de fonction globale)
### Changed
- **Inventaire exhaustif de la duplication de fonctions** : scan de
profondeur de brace sur les 13 noms définis 2+ fois (templates +
static/js, strings/comments/backticks gérés) → **12 sont déjà scopés**
dans des IIFEs depuis A27 (aucun conflit de page possible)
- **Seul doublon global = `openCardDetail`** (corps byte-identiques ×2 dans
`board_fragment` + `detailed_board`, fragments de vues mutuellement
exclusifs) → dédupliquée vers `static/js/app.js`, les 2 copies
supprimées (les onclick/@click des deux fragments appellent la même
définition)
### Tests
- `test_no_duplicate_global_functions` : garde-fou — 0 `function NAME`
globale définie 2+ fois entre templates et static/js (scanner naïf,
plafond `ponytail:` commenté : un faux positif se lit au nom signalé)
- Suite complète : **1092/1092** · ruff OK · node --check vert
### Notes
- Reste A38 : méthodes jumelles library/local_workspace (~9-21 noms
communs, corps divergents) → fusion `workspace-tree.js` reportée
(réconciliation sans E2E — même logique que A39/A20)
## v7.33.0 (2026-10-01) — Audit : A43 TERMINÉ + A38 phase 1 (CSRF unifié)
### Fixed
- **A43-1 — plus de `__CSRF_PLACEHOLDER__` servi** : ContextVar
`CSRF_TOKEN` posée par `CSRFMiddleware` **avant** `call_next` (même
mécanique que le nonce CSP), global `{{ csrf_token() }}` ajouté à
templating ; `base.html` rend `{"X-CSRF-Token":{{ csrf_token()|tojson }}}`
— vide si le cookie est absent sur cette 1ʳᵉ requête, mais
`htmx:configRequest` re-lit le cookie à chaque appel → jamais de jeton
factice. Test `test_csrf_server_rendered_no_placeholder` (pas de
placeholder + token == cookie)
- **A43-2 — palette : plus de re-parse par frappe** : `fetch('/api/search…')`
sans header (GET ∈ `SAFE_METHODS` → le CSRF ne s'applique pas) — le
`JSON.parse(document.body.getAttribute('hx-headers'))` par frappe disparaît
### Changed
- **A38 phase 1 — helper CSRF unique** : `window.getCsrf()` défini dans le
`<head>` de `base.html` (le plus tôt possible) ; **76 lectures brutes du
cookie → `getCsrf()`** dans 13 fichiers (47 formes `(…||[])[1]||''`,
25 déclarations `const X = match(…)` + leurs usages `X?X[1]:''` → `X`,
4 formes espacées) ; définitions dupliquées supprimées (`card_detail`,
`database_table`) ; les 3 variantes de `base.html` (IIFE + 2
`getCsrfToken`) unifiées sur `return getCsrf()` ; `welcome.html` garde sa
lecture locale (page autonome documentée)
### Notes
- A43 = **TERMINÉ** (4/4 : utcnow déprécié = 0 dans app/**.py, health
loggé, placeholder, palette)
- A38 reste : 12 fonctions dupliquées entre templates (wrappeur) + 21
méthodes jumelles library/local_workspace
- Suite complète : **1092/1092** · ruff OK · node --check vert
## v7.32.0 (2026-10-01) — Audit : A28 TERMINÉ (board, dernier lot)
### Changed
- **A28 lot 4** : `app/routers/board.py` (**2 101 lignes, 53 routes**)
devient le package `app/routers/board/` :
· 12 modules : `pages` 271 L (7), `page_api` 229 (5), `board_views` 223 (8),
`page_ops` 176 (3), `sharing` 175 (10), `synced` 144 (8),
`page_media` 122 (4), `import_` 85 (2), `wiki` 76 (2), `library` 66 (1),
`embed` 64 (2), `sync` 51 (1)
· `_common.py` (878 L) : les **23 helpers dont 4 async** + les 4
constantes (STATUS_COLORS, STATUS_LABELS, AI_KEYWORD_COLORS,
_REPO_REF_RE)
· `__init__.py` : `__all__` complet — importateurs **inchangés**
(api.py ×4 top-level, webhooks top-level, dashboard ×5 lazy, tests ×4)
- Preuve contractuelle : **`docs/openapi-v2.json` régénéré = identique
byte-à-byte**
### Fixed
- Constantes d'état oubliées dans `_common` à la 1ʳᵉ passe (F821 +
ImportError au chargement) → ré-insérées avec leurs valeurs exactes
(git show)
- Docstring du header copié → F404 (`from __future__` après un statement)
→ slice `[1:21]` ; helpers **async** non détectés par `def ` seul
### Notes
- **A28 TERMINÉ en 4 lots** : api_v2 (7.29.0), dashboard (7.30.0),
collections (7.31.0), board (7.32.0) — **0 changement d'URL** sur les 4
- Suite complète : **1091/1091** · ruff OK
## v7.31.0 (2026-10-01) — Audit : A28 lot 3 (collections → package 13 fichiers)
### Changed
- **A28 lot 3** : `app/routers/collections.py` (**2 622 lignes, 53
endpoints / 52 fonctions**) devient le package `app/routers/collections/` :
· 10 modules de routes : `crud` 337 L (6), `properties` 322 (8),
`linked` 286 (7), `structure` 267 (8), `dashboard_views` 214 (3),
`meta` 197 (5), `views` 187 (6), `pages` 184 (4), `data_api` 122 (2),
`boards` 61 (3)
· `_common.py` (220 L) : 8 helpers auth/permissions/validation
· `_renderers.py` (667 L) : **15 rendus HTML des vues** (_render_chart,
_render_map, …) + `CHART_MAX_GROUPS`
· `__init__.py` : ré-exports connus — `_validate_page_properties`
(automations), `_chart_values`/`_chart_aggregate`/`_fmt_number`/
`_render_chart` (tests)
- Preuve contractuelle : **`docs/openapi-v2.json` régénéré = identique
byte-à-byte**
### Fixed (pièges de la découpe)
- docstring d'origine gardée dans le header copié → **F404** (`from
__future__` après un statement) : slice `[1:30]`
- décorateurs **empilés** (`view_collection` ×2) : segment sans `def` →
skip du 2ᵉ décorateur (53 endpoints = 52 unités)
- `CHART_MAX_GROUPS` hors détection des helpers (F821 dans
`dashboard_views`) → import `._renderers` ajouté
- `test_csp_no_cdn_and_vendor` lisait `collections.py` → balayage du
package
### Notes
- Reste A28 : `board.py` 2 101 L (lot 4)
- Suite complète : **1091/1091** · ruff OK
## v7.30.0 (2026-10-01) — Audit : A28 lot 2 (dashboard → package 10 fichiers)
### Changed
- **A28 lot 2** : `app/routers/dashboard.py` (**2 735 lignes, 63 routes**)
devient le package `app/routers/dashboard/` :
· `local_workspace` 559 L (15 routes), `pages_html` 485 (6),
`account_settings` 439 (16), `workspace` 321 (9), `pages_api` 240 (6),
`workspaces` 131 (6), `public` 78 (1), `account_api` 77 (4)
· `_common.py` (774 L) : les **15 helpers top-level intercalés** dans
l'ancien fichier + état (`logger`, `_VERSION`, `WORKSPACE_COOKIE`)
· `__init__.py` : re-export complet (les 7 importateurs existants —
main, board ×3, my_tasks, web_clipper, wiki, sites, tests — n'ont
rien changé) + `__all__`
- Preuve contractuelle : **`docs/openapi-v2.json` régénéré = identique
byte-à-byte** (ordre d'enregistrement des routes préservé)
### Fixed (pièges de la découpe, rattrapés)
- segment décorateur sans sa fonction (`def`) → corps perdus en silence :
assert `def in seg` ajoutée + récupération depuis git
- collision `settings` : la section `settings.py` entrait en conflit avec
`from app.config import settings` du header (`hasattr` du fromlist de
Python → la section était ignorée) → renommée `account_settings`
- `WORKSPACE_COOKIE` utilisé dans `workspaces.py` sans import (F821) →
import `._common` ajouté
- script `__all__` tronquant la fin du fichier → `__init__.py` réécrit
### Notes
- Reste A28 : `collections.py` 2 622 L, `board.py` 2 101 L
- Suite complète : **1091/1091** · ruff OK
## v7.29.0 (2026-10-01) — Audit : A28 lot 1 (api_v2 → package 14 fichiers)
### Changed
+2 -2
View File
@@ -3,7 +3,7 @@
# Stage 1 "builder": build Python wheels once.
# Stage 2 "runtime": minimal image with WeasyPrint system libs.
# ═══════════════════════════════════════════════════════════
FROM python:3.12-slim AS builder
FROM python:3.13-slim AS builder
WORKDIR /app
@@ -11,7 +11,7 @@ COPY requirements.txt .
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
# ── runtime stage ───────────────────────────────────────────
FROM python:3.12-slim AS runtime
FROM python:3.13-slim AS runtime
WORKDIR /app
+2 -2
View File
@@ -73,9 +73,9 @@ docker compose up -d
| Couche | Techno |
|--------|--------|
| Frontend | Jinja2 + HTMX + Alpine.js + SortableJS + CSS |
| Backend | Python 3.12 + FastAPI + httpx |
| Backend | Python 3.13 + FastAPI + httpx |
| BDD | SQLite (WAL, 21 tables) — `/data/flowdeck.db` |
| Déploiement | Docker (python:3.12-slim), docker-compose |
| Déploiement | Docker (python:3.13-slim), docker-compose |
## Configuration
+32 -8
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
7.29.0
7.45.2
+1 -1
View File
@@ -1,6 +1,6 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Version**: v7.29.0 (audit — A28 lot 1 : api_v2.py 2 110 L → package 14 fichiers) | **Statut**: EN COURS 🔄
> **Début**: 2026-07-08 | **Version**: v7.45.2 (navigation partielle Alpine sans course = x-ignore sur `.main-wrapper` + démontage à tous scripts chargés, **5 scripts de page gardés** contre le SyntaxError de re-exécution, polices Inter orphelines purgées) | **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
+1 -1
View File
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="7.29.0",
version="7.45.2",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
+5
View File
@@ -7,6 +7,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
from app.templating import CSRF_TOKEN
class CSRFMiddleware(BaseHTTPMiddleware):
"""Lightweight CSRF protection for state-changing requests.
@@ -35,6 +37,9 @@ class CSRFMiddleware(BaseHTTPMiddleware):
}
async def dispatch(self, request: Request, call_next):
# A43 : jeton expose aux templates (base.html hx-headers) — posé AVANT
# call_next, comme le nonce CSP (meme mecanisme ContextVar).
CSRF_TOKEN.set(request.cookies.get("csrf_token", ""))
# Webhook receiver, OAuth callback, and internal API are exempt
if any(request.url.path.startswith(p) for p in self.EXCLUDED_PATHS):
return await call_next(request)
+5 -5
View File
@@ -71,15 +71,15 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
# 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).
# A20 TERMINÉ : `unsafe-eval` retiré — Alpine tourne en build CSP
# (static/js/alpine.csp.min.js, 0 eval) ; htmx allowEval=false.
# 15 surfaces en csp_preview vert + scan statique 0 (board/gitea/cards
# = props propres, gitea down empêche un gate dédié).
CSP_VALUE = (
"default-src 'self'; "
# A20 phase 2 : chart.js/leaflet vendorisés dans /static/js/vendor
# (test_csp_no_cdn_and_vendor), plus aucun hôte CDN tiers.
# `unsafe-eval` reste : Alpine (x-data) + htmx (hx-on/hx-vars) en
# ont besoin → retrait avec le build CSP d'Alpine (reste d'A20).
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}'; "
"script-src 'self' 'nonce-{nonce}'; "
"script-src-attr 'unsafe-inline'; "
# ponytail: aucun @font-face Google (grep négatif) → les deux
# hôtes fonts étaient morts, supprimés.
-2101
View File
File diff suppressed because it is too large Load Diff
+108
View File
@@ -0,0 +1,108 @@
"""FlowDeck — Board : Kanban Notion-style + multi-vues.
Découpe A28 : l'ancien `board.py` (2 101 lignes, 53 routes) est
devenu ce package — un module par concern, helpers/constantes dans
`_common`. Ré-exportés (importateurs inchangés) : api.py
(STATUS_COLORS, STATUS_LABELS, _issue_column, _map_issue_to_card),
webhooks (_issue_column), dashboard (_sidebar_data,
_load_workspace_pages, _load_shared_sidebar_pages, _file_icon),
tests (_build_page_tree, _REPO_REF_RE, _unfurl_repo).
"""
from __future__ import annotations
import logging
from fastapi import APIRouter
from . import ( # ordre = ordre d'enregistrement d'origine
board_views,
embed,
import_,
library,
page_api,
page_media,
page_ops,
pages,
sharing,
sync,
synced,
wiki,
)
from ._common import ( # noqa: F401 — ré-exports
_REPO_REF_RE,
AI_KEYWORD_COLORS,
STATUS_COLORS,
STATUS_LABELS,
_apply_filters,
_apply_sorts,
_block_texts,
_build_page_tree,
_create_page_from_markdown,
_ensure_block_ids,
_ensure_page_editable,
_extract_ai_keywords,
_file_icon,
_get_project_properties,
_issue_column,
_load_children,
_load_shared_sidebar_pages,
_load_workspace_pages,
_local_workspaces_for_user,
_map_issue_to_card,
_record_version,
_sidebar_data,
_store_uploaded_file,
_unfurl_repo,
_upload_root,
_ws_id_for,
asyncio_get_labels,
)
logger = logging.getLogger(__name__)
router = APIRouter()
for _mod in (
wiki,
page_api,
library,
sharing,
synced,
board_views,
pages,
page_media,
import_,
embed,
page_ops,
sync,
):
router.include_router(_mod.router)
__all__ = [
"router",
"AI_KEYWORD_COLORS",
"STATUS_COLORS",
"STATUS_LABELS",
"_REPO_REF_RE",
"_apply_filters",
"_apply_sorts",
"_block_texts",
"_build_page_tree",
"_create_page_from_markdown",
"_ensure_block_ids",
"_ensure_page_editable",
"_extract_ai_keywords",
"_file_icon",
"_get_project_properties",
"_issue_column",
"_load_children",
"_load_shared_sidebar_pages",
"_load_workspace_pages",
"_local_workspaces_for_user",
"_map_issue_to_card",
"_record_version",
"_sidebar_data",
"_store_uploaded_file",
"_unfurl_repo",
"_upload_root",
"_ws_id_for",
"asyncio_get_labels",
]
+878
View File
@@ -0,0 +1,878 @@
"""FlowDeck — Board : helpers et constantes partagés (A28).
Les 23 helpers de l'ancien board.py (dont 4 async) + constantes
(STATUS_COLORS/STATUS_LABELS/AI_KEYWORD_COLORS/_REPO_REF_RE) —
ré-exportés : api.py, webhooks, dashboard, tests.
"""
from __future__ import annotations
import json
import logging
import re
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from app.auth.session import SessionManager
from app.config import settings
from app.db import get_conn
from app.routers.dashboard import _get_app_version
from app.routers.sidebar_config import get_sidebar_config_sync
from app.services.gitea_client import gitea
from app.services.http_client import shared_client
logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board")
STATUS_COLORS = {"todo": "var(--gray)", "progress": "var(--blue)", "done": "var(--green)"}
STATUS_LABELS = {"todo": "To-do", "progress": "In progress", "done": "Complete"}
AI_KEYWORD_COLORS = [
"#E03E3E", "#D9730D", "#9B72F0", "#0F7B6C", "#3399CC",
"#E255A1", "#787774", "#6B4E3D", "#6374C4", "#5A9E4B",
]
_REPO_REF_RE = re.compile(r"^(gitea|github):([\w\-\.]+)/([\w\-\.]+)$")
def _ensure_page_editable(conn, page_id: int, user: dict | None) -> None:
"""v5.12.0: raise 423 when the page is locked and the actor may not edit.
Allowed to edit a locked page: admins and the user who locked it
(locked_by). Unauthenticated callers only pass when the page is unlocked.
"""
row = conn.execute("SELECT is_locked, locked_by FROM pages WHERE id=?", (page_id,)).fetchone()
if not row or not row["is_locked"]:
return
uid = (user or {}).get("id")
is_admin = bool((user or {}).get("is_admin"))
if is_admin or (uid and row["locked_by"] == uid):
return
raise HTTPException(423, "Page is locked — only the owner of the lock or an admin can edit")
def _ensure_block_ids(blocks) -> None:
"""Assign unique ids to blocks missing one, recursively.
Built-in page templates ship without ids (the editor used to assign them
client-side only). Without persisted ids, the realtime layer and the editor
disagree on block identity, which duplicated lines / shuffled blocks when
editing a template-created page. We now materialize ids at creation time.
"""
import uuid
if not isinstance(blocks, list):
return
for b in blocks:
if isinstance(b, dict):
if not b.get("id"):
b["id"] = "b" + uuid.uuid4().hex[:12]
if isinstance(b.get("children"), list):
_ensure_block_ids(b["children"])
# ── Core helpers ──
def _issue_column(issue: dict, columns: list[str], board_id: int) -> str:
if issue.get("state") == "closed":
return "Terminé" if "Terminé" in columns else columns[-1]
for lbl in issue.get("labels", []):
with get_conn() as conn:
row = conn.execute(
"SELECT column_name FROM col_mapping WHERE board_id=? AND gitea_label=?",
(board_id, lbl["name"]),
).fetchone()
if row and row["column_name"] in columns:
return row["column_name"]
return columns[0] if columns else "Backlog"
def _map_issue_to_card(issue: dict, owner: str = "", repo: str = "") -> dict:
title = issue.get("title", "Untitled")
status = "todo"
if issue.get("state") == "closed":
status = "done"
labels = issue.get("labels", [])
for lbl in labels:
name = lbl.get("name", "").lower()
if "progress" in name or "doing" in name:
status = "progress"
elif "done" in name or "complete" in name or "terminé" in name:
status = "done"
assignee = issue.get("assignee", {}) or {}
assignee_name = assignee.get("login", "")
tag = labels[0].get("name", "") if labels else ""
tag_color = labels[0].get("color", "#787774") if labels else "#787774"
if tag_color and not tag_color.startswith("#"):
tag_color = f"#{tag_color}"
icon_map = {
"bug": "🐛", "feature": "✨", "enhancement": "⚡", "documentation": "📄",
"design": "🎨", "testing": "🧪", "refactor": "🔧", "security": "🔒",
}
icon = "file"
for lbl in labels:
for kw, emoji in icon_map.items():
if kw in lbl.get("name", "").lower():
icon = emoji
break
# Load custom property values
props = {}
if owner and repo:
with get_conn() as conn:
pvs = conn.execute("""
SELECT pp.name, pp.prop_type, pv.value
FROM property_values pv
JOIN project_properties pp ON pp.id = pv.property_id
WHERE pp.project_owner=? AND pp.project_name=? AND pv.gitea_issue_id=?
""", (owner, repo, issue.get("number", 0))).fetchall()
for pv in pvs:
props[pv["name"]] = {"type": pv["prop_type"], "value": pv["value"]}
# AI keywords from DB
keywords = []
if owner and repo:
with get_conn() as conn:
kw_rows = conn.execute(
"SELECT keyword, color FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC",
(owner, repo),
).fetchall()
# Filter: show keywords matching this issue's labels
label_names = {lbl.get("name", "").lower() for lbl in labels}
for kw in kw_rows:
if kw["keyword"].lower() in label_names or any(
kw["keyword"].lower() in lbl for lbl in label_names
):
keywords.append({"name": kw["keyword"], "color": kw["color"]})
return {
"id": str(issue.get("number", 0)),
"title": title,
"status": status,
"status_color": STATUS_COLORS.get(status, "var(--gray)"),
"status_label": STATUS_LABELS.get(status, "To-do"),
"icon": icon,
"assignee": assignee_name,
"tag": tag if tag else None,
"tag_color": tag_color,
"due_date": issue.get("due_date", ""),
"url": issue.get("html_url", ""),
"keywords": keywords,
"custom_props": props,
}
def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, max_depth: int = 3) -> list[dict]:
"""Build nested page tree recursively. max_depth prevents infinite recursion."""
if depth >= max_depth:
return []
rows = conn.execute(
"SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY sort_order ASC, updated_at DESC",
(ws_key, parent_id),
).fetchall()
items = []
for row in rows:
children = _build_page_tree(conn, row["id"], ws_key, depth + 1, max_depth)
items.append({
"id": f"page/{row['id']}",
"db_id": row["id"],
"name": row["title"] or "New page",
"icon": "📄",
"url": f"/pages/{row['id']}",
"active": False,
"depth": depth,
"has_children": len(children) > 0,
"children": children,
})
return items
def _file_icon(name: str, content_format: str = "") -> str:
"""Map file extension to icon name (SVG-safe)."""
# FlowDeck internal pages (no extension)
if content_format and content_format != 'file':
return 'edit'
n = name.lower()
if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n):
return 'image'
if n.endswith('.pdf'):
return 'file'
if re.search(r'\.(md|markdown)$', n):
return 'edit'
if n.endswith('.py'):
return 'file'
if re.search(r'\.(js|jsx|ts|tsx)$', n):
return 'file'
if re.search(r'\.(html?|xml)$', n):
return 'file'
if n.endswith('.css'):
return 'file'
if n.endswith('.json'):
return 'file'
if n.endswith('.sql'):
return 'file'
if re.search(r'\.(sh|bash|zsh)$', n):
return 'file'
if n.endswith('.ps1'):
return 'file'
if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n):
return 'file'
if re.search(r'\.(txt|log)$', n):
return 'file'
if re.search(r'\.(zip|tar|gz|rar|7z)$', n):
return 'file'
return 'file'
def _load_workspace_pages(ws_cookie: str) -> list:
"""Load top-level pages with children for the active workspace."""
if not ws_cookie:
return []
try:
ws_id = int(ws_cookie)
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, "
"is_shared, share_mode, COALESCE(published,0) AS published "
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY created_at DESC",
(ws_id,),
).fetchall()
items = []
for r in rows:
is_folder = r["parent_section"] == "Workspace"
title = r["title"] or "Untitled"
sub_children = _load_children(r["id"])
is_shared = bool(r["is_shared"] or r["share_mode"] != "private" or r["published"])
items.append({
"db_id": r["id"], "name": title,
"id": f"page/{r['id']}",
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
"is_folder": is_folder,
"is_shared": is_shared,
"child_count": len(sub_children),
"children": sub_children,
})
return items
except (ValueError, Exception):
return []
def _load_children(parent_id: int) -> list:
"""Recursively load children of a page."""
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, "
"is_shared, share_mode, COALESCE(published,0) AS published "
"FROM pages WHERE parent_id=? AND deleted_at IS NULL ORDER BY created_at",
(parent_id,),
).fetchall()
children = []
for r in rows:
is_folder = r["parent_section"] == "Workspace"
title = r["title"] or "Untitled"
sub_children = _load_children(r["id"])
is_shared = bool(r["is_shared"] or r["share_mode"] != "private" or r["published"])
children.append({
"db_id": r["id"], "name": title,
"id": f"page/{r['id']}",
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
"is_folder": is_folder,
"is_shared": is_shared,
"child_count": len(sub_children),
"children": sub_children,
})
return children
def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list[dict]:
"""Return list of local workspaces for a user."""
if not user:
return []
try:
from app.db import get_conn
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name FROM workspaces WHERE owner_id = ? ORDER BY name",
(user["id"],)
).fetchall()
return [{"id": r["id"], "name": r["name"]} for r in rows]
except Exception:
return []
def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]:
"""Shared / received / published pages for the sidebar (reused by dashboard)."""
with get_conn() as conn:
own_ws = (
"SELECT w.id FROM workspaces w WHERE w.owner_id = ? "
"UNION SELECT wm.workspace_id FROM workspace_members wm WHERE wm.user_id = ?"
)
own_ws_names = (
"SELECT w.name FROM workspaces w WHERE w.owner_id = ? "
"UNION SELECT w.name FROM workspaces w "
"JOIN workspace_members wm ON wm.workspace_id = w.id WHERE wm.user_id = ?"
)
# Scope "shared by me"-style lists to pages in the user's own workspaces
# (or legacy pages whose workspace_id is NULL but identify the workspace by text).
scope_cond = (
f"(workspace_id IN ({own_ws}) "
f"OR (workspace_id IS NULL AND lower(workspace) IN "
f"(SELECT lower(name) FROM ({own_ws_names}))) "
f"OR (workspace_id IS NULL AND lower(workspace) = lower("
f"(SELECT login FROM users WHERE id=?))))"
)
scope_params = (user_id, user_id, user_id, user_id, user_id)
made_nominal = conn.execute(
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
"JOIN pages p ON p.id=s.page_id "
"WHERE s.created_by=? AND p.deleted_at IS NULL",
(user_id,),
).fetchall()
made_link = conn.execute(
f"SELECT id, title, workspace, updated_at FROM pages "
f"WHERE share_mode='anyone' AND published=0 AND deleted_at IS NULL "
f"AND {scope_cond}",
scope_params,
).fetchall()
made_flag = conn.execute(
f"SELECT id, title, workspace, updated_at FROM pages "
f"WHERE (is_shared=1 OR share_mode != 'private') AND COALESCE(published,0)=0 AND deleted_at IS NULL "
f"AND {scope_cond}",
scope_params,
).fetchall()
published_rows = conn.execute(
f"SELECT id, title, workspace, updated_at FROM pages "
f"WHERE published=1 AND deleted_at IS NULL AND {scope_cond} "
f"ORDER BY updated_at DESC LIMIT 20",
scope_params,
).fetchall()
try:
received_rows = conn.execute(
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
"JOIN pages p ON p.id=s.page_id "
"LEFT JOIN group_members gm ON gm.group_id = s.shared_with_group_id AND gm.user_id=? "
"WHERE (s.shared_with_user_id=? OR gm.user_id=?) AND p.deleted_at IS NULL",
(user_id, user_id, user_id),
).fetchall()
except Exception:
received_rows = conn.execute(
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
"JOIN pages p ON p.id=s.page_id "
"WHERE s.shared_with_user_id=? AND p.deleted_at IS NULL",
(user_id,),
).fetchall()
def _entry(r, icon):
return {
"id": f"page/{r['id']}",
"db_id": r["id"],
"name": r["title"] or "New page",
"icon": icon,
"url": f"/pages/{r['id']}",
"active": False,
"indent": 0,
"depth": 0,
"has_children": False,
"children": [],
}
made_map = {}
for r in (*made_nominal, *made_link, *made_flag):
made_map.setdefault(r["id"], r)
made_sorted = sorted(made_map.values(), key=lambda r: r["updated_at"] or "", reverse=True)[:20]
shared_made = [_entry(r, "link") for r in made_sorted]
received_sorted = [r for r in received_rows if r["id"] not in made_map]
received_sorted = sorted(received_sorted, key=lambda r: r["updated_at"] or "", reverse=True)[:20]
shared_received = [_entry(r, "users") for r in received_sorted]
published = [_entry(r, "globe") for r in published_rows]
shared_all = [_entry(r, "link") for r in made_sorted]
return shared_made, shared_received, published, shared_all
def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws_name = user.get("login", "Bruno") if user else "Bruno"
ws_key = f"{owner}/{repo}" if owner and repo else ws_name # workspace = project context
# Active workspace name from cookie (for local workspace display)
from app.routers.dashboard import WORKSPACE_COOKIE
ws_cookie = request.cookies.get(WORKSPACE_COOKIE, "")
active_ws_name = "Workspace"
workspace_pages = []
gitea_workspace = False
gitea_owner = ""
gitea_repo = ""
has_active_workspace = False
local_ws_id = 0
if ws_cookie and ws_cookie.startswith("gitea:"):
# Gitea workspace: preserve context across pages. Also load the local
# mirror workspace so it appears in "My Workspaces" in the top section
# of the sidebar, in parallel with the Gitea repository tree.
parts = ws_cookie.split(":", 2)
if len(parts) >= 3:
gitea_owner = parts[1]
gitea_repo = parts[2]
active_ws_name = f"{gitea_owner}/{gitea_repo}"
gitea_workspace = True
has_active_workspace = True
# Get local workspace ID for mirror and load its tree
if user:
try:
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
).fetchone()
if row:
local_ws_id = row["id"]
workspace_pages = _load_workspace_pages(str(local_ws_id))
except Exception:
logger.exception("_sidebar_data")
elif ws_cookie and user:
try:
wsi = int(ws_cookie)
with get_conn() as conn:
row = conn.execute(
"SELECT id, name, owner_id FROM workspaces WHERE id=? AND owner_id=?",
(wsi, user["id"])
).fetchone()
if row:
active_ws_name = row["name"]
workspace_pages = _load_workspace_pages(ws_cookie)
has_active_workspace = True
except (ValueError, Exception):
pass
recent = []
if owner and repo:
view_map = {
"Kanban board": "kanban", "Detailed board": "detailed",
"Table view": "table", "Status overview": "status", "Team Load": "teamload",
}
first = True
for label, view in view_map.items():
indent = 0 if first else 1
active = first
recent.append({
"id": f"{owner}/{repo}/{view}",
"name": label, "icon": "folder" if first else "",
"url": f"/board/{owner}/{repo}?view={view}",
"active": active, "indent": indent,
"depth": indent, "has_children": False, "children": [],
})
first = False
# Load pages as nested tree for this project workspace
with get_conn() as conn:
tree_pages = _build_page_tree(conn, None, ws_key)
for p in tree_pages:
recent.append(p)
# Private pages: same as recent but filtered for page/ items (non-board views)
private_items = [r for r in recent if r.get("active") or r["id"].startswith("page/")]
# Load favorite pages from DB
uid = user["id"] if user and user.get("id") else 1
with get_conn() as conn:
fav_rows = conn.execute(
"SELECT p.id, p.title, p.workspace, p.updated_at FROM favorites f "
"JOIN pages p ON p.id = f.page_id "
"WHERE f.user_id=? ORDER BY f.position", (uid,)
).fetchall()
favorites = []
for r in fav_rows:
favorites.append({
"id": f"page/{r['id']}",
"db_id": r["id"],
"name": r["title"] or "New page",
"icon": "📄",
"url": f"/pages/{r['id']}",
"active": False,
"indent": 0,
"depth": 0,
"has_children": False,
"children": [],
})
# Load shared pages
shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(uid)
# Auth method & OAuth badge data
auth_method = "local"
gitea_linked = False
github_linked = False
if user and user.get("id"):
try:
with get_conn() as conn:
am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone()
if am_row and am_row["auth_method"]:
auth_method = am_row["auth_method"]
tokens = conn.execute(
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
).fetchall()
for t in tokens:
if t["provider"] == "gitea":
gitea_linked = True
elif t["provider"] == "github":
github_linked = True
except Exception:
logger.exception("_sidebar_data")
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
"active_ws_name": active_ws_name,
"workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else ws_key,
"workspace_pages": workspace_pages,
"gitea_workspace": gitea_workspace,
"gitea_owner": gitea_owner,
"gitea_repo": gitea_repo,
"local_ws_id": local_ws_id,
"current_page": repo or "Dashboard", "last_edited": "now",
"recent_pages": recent, "private_pages": private_items,
"favorite_pages": favorites, "shared_pages": shared_pages,
"shared_made_pages": shared_made_pages,
"shared_received_pages": shared_received_pages,
"published_pages": published_pages,
"user": user,
"auth_method": auth_method,
"gitea_linked": gitea_linked,
"github_linked": github_linked,
"has_active_workspace": has_active_workspace,
"app_version": _get_app_version(),
"local_workspaces": _local_workspaces_for_user(user),
"sidebar_config": get_sidebar_config_sync(uid)}
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
"""Extract and persist AI keywords from issue labels and body."""
if not owner or not repo:
return
candidates = set()
for lbl in labels:
name = lbl.get("name", "").strip().lower()
if name and len(name) > 1:
candidates.add(name)
# Simple extraction from body: single words > 3 chars
for word in re.findall(r'\b[a-zA-Z]{4,}\b', body.lower()):
if word not in ("this", "that", "with", "from", "have", "when", "will"):
candidates.add(word)
with get_conn() as conn:
for kw in candidates:
kw = kw[:30]
existing = conn.execute(
"SELECT id, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? AND keyword=?",
(owner, repo, kw),
).fetchone()
if existing:
conn.execute("UPDATE ai_keywords SET usage_count=? WHERE id=?",
(existing["usage_count"] + 1, existing["id"]))
else:
color_idx = len(candidates) % len(AI_KEYWORD_COLORS)
conn.execute(
"INSERT INTO ai_keywords (project_owner, project_name, keyword, color) VALUES (?,?,?,?)",
(owner, repo, kw, AI_KEYWORD_COLORS[color_idx]),
)
conn.commit()
def _get_project_properties(owner: str, repo: str) -> list[dict]:
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM project_properties WHERE project_owner=? AND project_name=? ORDER BY position",
(owner, repo),
).fetchall()
return [dict(r) for r in rows]
async def asyncio_get_labels(owner: str, repo: str):
return await gitea.get_labels(owner, repo)
def _apply_filters(cards: list[dict], status_filter: str, filters: str) -> list[dict]:
if status_filter:
allowed = set(status_filter.split(","))
cards = [c for c in cards if c["status"] in allowed]
if filters:
for f in filters.split(","):
if ":" in f:
prop, val = f.split(":", 1)
val_lower = val.lower()
if prop == "assignee":
cards = [c for c in cards if c.get("assignee", "").lower() == val_lower]
elif prop == "tag":
cards = [c for c in cards if (c.get("tag") or "").lower() == val_lower]
elif prop == "keyword":
cards = [c for c in cards if any(val_lower in kw.get("name", "").lower() for kw in c.get("keywords", []))]
return cards
def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
if not sorts:
return cards
order = {"todo": 0, "progress": 1, "done": 2}
for spec in reversed(sorts.split(",")):
if ":" not in spec:
continue
field, direction = spec.split(":", 1)
rev = direction == "desc"
if field == "name":
cards.sort(key=lambda c: c["title"].lower(), reverse=rev)
elif field == "status":
cards.sort(key=lambda c: order.get(c["status"], 0), reverse=rev)
elif field == "assignee":
cards.sort(key=lambda c: c.get("assignee", "").lower(), reverse=rev)
elif field == "deadline":
cards.sort(key=lambda c: c.get("due_date", ""), reverse=rev)
return cards
# ═══════════ Library page ═══════════
def _record_version(conn, page_id: int, user_id, title: str, blocks_json: str) -> None:
"""Insert a version snapshot unless it is byte-identical to the latest one."""
prev = conn.execute(
"SELECT COALESCE(title, ''), blocks_json FROM page_versions "
"WHERE page_id=? ORDER BY id DESC LIMIT 1",
(page_id,),
).fetchone()
if prev is not None and prev["blocks_json"] == blocks_json:
if prev["title"] != (title or ""):
conn.execute(
"UPDATE page_versions SET title=? WHERE id="
"(SELECT id FROM page_versions WHERE page_id=? ORDER BY id DESC LIMIT 1)",
(title or "", page_id),
)
return
conn.execute(
"INSERT INTO page_versions (page_id, user_id, title, blocks_json, note) VALUES (?,?,?,?,'edited')",
(page_id, user_id, title or "", blocks_json),
)
def _block_texts(b: dict) -> list[str]:
"""Flatten a block (including children) into searchable text chunks."""
out = []
raw = b.get("content")
if isinstance(raw, str) and raw.strip():
out.append(raw)
for child in b.get("children") or []:
out.extend(_block_texts(child))
return out
# ═══════════ v5.5.0: Cover & icon ═══════════
def _upload_root() -> Path:
return Path(settings.data_dir)
def _ws_id_for(request: Request, page_id: int) -> int:
"""The active workspace id for the page (cookie, then page, then fallback 1)."""
cookie = request.cookies.get("flowdeck_workspace", "")
try:
ws_id = int(cookie)
if ws_id > 0:
return ws_id
except (ValueError, TypeError):
pass
with get_conn() as conn:
row = conn.execute(
"SELECT workspace_id FROM pages WHERE id=?", (page_id,)
).fetchone()
if row and row["workspace_id"]:
return int(row["workspace_id"])
return 1
async def _store_uploaded_file(request: Request, ws_id: int) -> dict:
"""Persist an uploaded file under uploads/workspace_{ws_id}/ and return
{file_url, file_path, mime_type, size, file_name}."""
import datetime
import re as _re
form = await request.form()
upload = form.get("file")
if upload is None or not hasattr(upload, "filename"):
raise HTTPException(400, "file field required")
original = (upload.filename or "cover.png").replace("\\", "/").rsplit("/", 1)[-1]
name = _re.sub(r"[^A-Za-z0-9._-]", "_", original)[:120]
ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin"
if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}:
raise HTTPException(400, "Unsupported image format")
stamp = datetime.datetime.now(datetime.UTC).replace(tzinfo=None).strftime("%Y%m%d%H%M%S")
folder = _upload_root() / f"uploads/workspace_{ws_id}"
folder.mkdir(parents=True, exist_ok=True)
final = f"{stamp}_{name}"
(folder / final).write_bytes(await upload.read())
mime = f"image/{'svg+xml' if ext == 'svg' else 'jpeg' if ext == 'jpg' else ext}"
return {
"file_url": f"/api/files/{ws_id}/{final}",
"file_path": f"uploads/workspace_{ws_id}/{final}",
"mime_type": mime,
"size": (folder / final).stat().st_size,
"file_name": name,
}
# ═══════════ v5.4.0: Import (Markdown / .md / Notion .zip) ═══════════
async def _create_page_from_markdown(request: Request, markdown: str, title: str = "") -> int:
"""Convert markdown → blocks (server-side, same mapping as the editor) and
create a page in the caller's workspace."""
from app.services.export import _md_to_blocks
blocks = _md_to_blocks(markdown or "")
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws_key = user.get("login", "Bruno") if user else "Bruno"
file_title = title.strip() or "Import"
fallback = (file_title or "Imported page").replace("/", "-").replace("\\", "-")[:120]
if not blocks:
blocks = [{"type": "paragraph", "content": markdown or ""}]
with get_conn() as conn:
next_order = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL",
(ws_key,),
).fetchone()[0]
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, parent_section, sort_order, workspace_id) "
"VALUES (?,?,?,?,'Private',?,(SELECT id FROM workspaces WHERE name=? LIMIT 1))",
(ws_key, fallback, json.dumps(blocks), "blocks", next_order, ws_key),
)
conn.commit()
return cur.lastrowid
async def _unfurl_repo(forge: str, owner: str, repo: str):
"""Resolve a ``gitea:owner/repo`` / ``github:owner/repo`` ref."""
try:
if forge == "gitea":
from app.services.gitea_client import GiteaClient
info = await GiteaClient().get_repo_info(owner, repo)
site = "Gitea"
else:
from app.config import settings
from app.services.github_adapter import GitHubAdapter
token = getattr(settings, "github_token", None) or ""
if token:
info = await GitHubAdapter(access_token=token).get_repo_info(owner, repo)
else:
async with shared_client(timeout=10) as client:
r = await client.get(
f"https://api.github.com/repos/{owner}/{repo}",
headers={"Accept": "application/vnd.github+json"},
)
r.raise_for_status()
info = r.json()
site = "GitHub"
except Exception as exc: # noqa: BLE001 — forge lookup is best-effort
logging.getLogger(__name__).debug("unfurl %s failed: %s", forge, exc)
return None
branch = info.get("default_branch") or "main"
return {
"url": info.get("html_url") or f"https://{forge}.com/{owner}/{repo}",
"title": info.get("full_name") or f"{owner}/{repo}",
"description": (info.get("description") or f"{site} repository "
f"{owner}/{repo} · default branch: {branch}"),
"image": "",
"site_name": site,
"language": info.get("language") or "",
}
+223
View File
@@ -0,0 +1,223 @@
"""FlowDeck — Board : board_views.
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from app.db import get_conn
from app.services.gitea_client import gitea
from ._common import (
STATUS_COLORS,
STATUS_LABELS,
_apply_filters,
_apply_sorts,
_extract_ai_keywords,
_get_project_properties,
_map_issue_to_card,
_sidebar_data,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board")
# ═══════════ Board page ═══════════
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, owner, repo)
template = env.get_template("board.html")
return template.render(request=request, owner=owner, repo=repo, groups=[],
initial_view=view, **sidebar)
# ═══════════ View fragments ═══════════
# ═══════════ View fragments ═══════════
@router.get("/{owner}/{repo}/view/{view}", response_class=HTMLResponse)
async def board_view(
request: Request, owner: str, repo: str, view: str,
status: str = Query(default=""),
filter: str = Query(default=""),
sort: str = Query(default=""),
):
try:
issues = await gitea.get_issues(owner, repo, state="all")
issues_only = [i for i in issues if not i.get("pull_request")]
cards = [_map_issue_to_card(i, owner, repo) for i in issues_only]
cards = _apply_filters(cards, status, filter)
cards = _apply_sorts(cards, sort)
except Exception as e:
logger.error("Board view error: %s", e)
cards = []
from app.templating import ENV
env = ENV
# Dynamic groups from Gitea labels (fallback to hardcoded)
group_names = ["Design", "Engineering", "No Team"]
groups = []
for gname in group_names:
gid = gname.lower().replace(" ", "-")
gcards = cards
groups.append({
"id": gid, "name": gname,
"counts": {
"todo": len([c for c in gcards if c["status"] == "todo"]),
"progress": len([c for c in gcards if c["status"] == "progress"]),
"done": len([c for c in gcards if c["status"] == "done"]),
},
"cards": gcards,
})
ctx = {"owner": owner, "repo": repo, "groups": groups, "cards": cards}
template_map = {
"table": "table_view.html",
"status": "status_overview.html",
"teamload": "team_load.html",
"detailed": "detailed_board.html",
}
if view == "table":
grouped = {g["name"]: g["cards"] for g in groups}
ctx["grouped_cards"] = grouped
elif view == "status":
counts = {"todo": 0, "progress": 0, "done": 0}
for c in cards:
if c["status"] in counts:
counts[c["status"]] += 1
ctx.update(status_data=counts, status_colors=STATUS_COLORS, status_labels=STATUS_LABELS)
elif view == "teamload":
members = {}
for c in cards:
name = c.get("assignee") or "Unassigned"
if name not in members:
members[name] = {"name": name, "initial": name[0].upper(),
"todo": 0, "progress": 0, "complete": 0, "total": 0}
sk = c["status"] if c["status"] in ("todo", "progress") else "complete"
members[name][sk] += 1
members[name]["total"] += 1
ctx["team_data"] = list(members.values())
elif view == "detailed":
pass
else:
template_map["kanban"] = "board_fragment.html"
template_name = template_map.get(view, "board_fragment.html")
template = env.get_template(template_name)
return template.render(**ctx)
# ═══════════ v0.9.0: Custom Properties API ═══════════
# ═══════════ v0.9.0: Custom Properties API ═══════════
@router.get("/api/properties/{owner}/{repo}")
def get_properties(owner: str, repo: str):
return {"properties": _get_project_properties(owner, repo)}
@router.post("/api/properties/{owner}/{repo}")
def create_property(owner: str, repo: str, name: str = Query(...),
prop_type: str = Query(default="select"),
options: str = Query(default="")):
opts = json.dumps([o.strip() for o in options.split(",") if o.strip()])
with get_conn() as conn:
try:
conn.execute(
"INSERT INTO project_properties (project_owner, project_name, name, prop_type, options_json) VALUES (?,?,?,?,?)",
(owner, repo, name, prop_type, opts),
)
conn.commit()
except Exception as e:
raise HTTPException(409, f"Property already exists: {e}") from e
return {"status": "ok", "name": name, "type": prop_type}
@router.delete("/api/properties/{owner}/{repo}")
def delete_property(owner: str, repo: str, name: str = Query(...)):
with get_conn() as conn:
conn.execute(
"DELETE FROM project_properties WHERE project_owner=? AND project_name=? AND name=?",
(owner, repo, name),
)
conn.commit()
return {"status": "ok"}
@router.post("/api/properties/{owner}/{repo}/values")
def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
name: str = Query(...), value: str = Query(default="")):
with get_conn() as conn:
prop = conn.execute(
"SELECT id FROM project_properties WHERE project_owner=? AND project_name=? AND name=?",
(owner, repo, name),
).fetchone()
if not prop:
raise HTTPException(404, f"Property '{name}' not found")
conn.execute(
"INSERT OR REPLACE INTO property_values (property_id, gitea_issue_id, value) VALUES (?,?,?)",
(prop["id"], issue_id, value),
)
conn.commit()
return {"status": "ok"}
# ═══════════ v0.9.0: AI Keywords API ═══════════
# ═══════════ v0.9.0: AI Keywords API ═══════════
@router.get("/api/ai-keywords/{owner}/{repo}")
def get_ai_keywords(owner: str, repo: str):
with get_conn() as conn:
rows = conn.execute(
"SELECT keyword, color, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC LIMIT 30",
(owner, repo),
).fetchall()
return {"keywords": [dict(r) for r in rows]}
@router.post("/api/ai-keywords/{owner}/{repo}/extract")
async def extract_ai_keywords(owner: str, repo: str):
"""Re-extract keywords from all issues in the repo."""
try:
issues = await gitea.get_issues(owner, repo, state="all")
for issue in issues:
if not issue.get("pull_request"):
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
except Exception as e:
raise HTTPException(500, str(e)) from e
return {"status": "ok", "issues_scanned": len(issues)}
# ═══════════ Pages Markdown ═══════════
+64
View File
@@ -0,0 +1,64 @@
"""FlowDeck — Board : embed.
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Body, HTTPException, Request
from app.config import settings
from ._common import _REPO_REF_RE, _unfurl_repo
logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board")
@router.post("/api/og/metadata")
async def og_metadata(request: Request):
"""v5.5.0: Open Graph metadata for a bookmark card.
v7.3.0: ``gitea:owner/repo`` and ``github:owner/repo`` schemes are
unfurled straight from the forge API (no HTTP fetch of the HTML page).
"""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
url = (body.get("url") or "").strip()
if not url:
raise HTTPException(400, "url required")
m = _REPO_REF_RE.match(url)
if m:
forge, owner, repo = m.group(1).lower(), m.group(2), m.group(3)
data = await _unfurl_repo(forge, owner, repo)
if data:
return {"ok": True, **data}
from app.services.og_fetcher import fetch_og_metadata
try:
data = await fetch_og_metadata(url)
except ValueError as exc:
# A12 : SSRF — URL vers un hôte privé/loopback (ou redirection vers l'un).
raise HTTPException(400, str(exc)) from None
return {"ok": True, **data}
@router.post("/api/embed/resolve")
def resolve_embed(request: Request, body: dict = Body(...)):
"""v5.5.0: rewrite a pasted URL to its provider embed src.
Powers the universal ``/embed`` block (YouTube, Vimeo, Figma, Maps,
Docs, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter…).
"""
url = (body.get("url") or "").strip()
if not url:
raise HTTPException(400, "url required")
from app.services.embeds import resolve_embed as _resolve
data = _resolve(url, parent=settings.app_base_url)
return {"ok": True, "url": url, **data}
+85
View File
@@ -0,0 +1,85 @@
"""FlowDeck — Board : import_.
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from app.services.automations import fire_event
from ._common import _create_page_from_markdown
logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board")
@router.post("/api/pages/import")
async def import_page(request: Request):
"""v5.4.0: import markdown text as a new page (blocks) in the workspace."""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
markdown = body.get("markdown", "")
title = body.get("title", "")
if not markdown and not body.get("csv"):
raise HTTPException(400, "markdown field required")
if not markdown.strip():
raise HTTPException(400, "markdown is empty")
page_id = await _create_page_from_markdown(request, markdown, title)
await fire_event("page.created", {"page_id": page_id, "title": title or "Import",
"workspace": ""})
return {"status": "ok", "id": page_id}
@router.post("/api/pages/import/file")
async def import_file(request: Request):
"""v5.4.0: import an uploaded .md file (or a Notion export .zip containing
markdown pages) into the workspace. Returns the created page ids."""
import io as _io
import zipfile
form = await request.form()
upload = form.get("file")
if upload is None or not hasattr(upload, "filename"):
raise HTTPException(400, "file field required")
filename = (upload.filename or "import.md").replace("\\", "/").rsplit("/", 1)[-1]
data = await upload.read()
created_ids = []
if filename.lower().endswith(".zip"):
try:
zf = zipfile.ZipFile(_io.BytesIO(data))
except zipfile.BadZipFile:
raise HTTPException(400, "Invalid zip archive") from None
md_entries = sorted(
(n for n in zf.namelist() if n.lower().endswith((".md", ".markdown"))),
key=lambda n: (n.count("/"), n.lower()),
)
if not md_entries:
raise HTTPException(400, "No .md files found in archive")
for name in md_entries:
raw = zf.read(name).decode("utf-8", errors="replace")
title = name.replace("\\", "/").rsplit("/", 1)[-1][:-3]
try:
created_ids.append(await _create_page_from_markdown(request, raw, title))
except Exception as exc: # noqa: BLE001 - keep importing the rest
logger.warning("import failed for %s: %s", name, exc)
else:
try:
raw = data.decode("utf-8")
except UnicodeDecodeError:
raise HTTPException(400, "Only text/markdown files are supported") from None
title = filename.replace(".md", "").replace(".markdown", "").replace(".txt", "")
created_ids.append(await _create_page_from_markdown(request, raw, title))
if not created_ids:
raise HTTPException(422, "No pages could be imported")
return {"status": "ok", "ids": created_ids, "count": len(created_ids)}
+66
View File
@@ -0,0 +1,66 @@
"""FlowDeck — Board : library.
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Query, Request
from fastapi.responses import HTMLResponse
from app.db import get_conn
from ._common import _sidebar_data
logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board")
# ═══════════ Library page ═══════════
@router.get("/library", response_class=HTMLResponse)
def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace."""
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, owner, repo)
# Load all pages for the workspace from DB
ws_key = f"{owner}/{repo}" if owner and repo else ""
with get_conn() as conn:
if ws_key:
rows = conn.execute(
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE workspace=? AND collection_row_id IS NULL ORDER BY updated_at DESC",
(ws_key,),
).fetchall()
else:
rows = conn.execute(
"SELECT id, title, workspace, updated_at FROM pages "
"WHERE collection_row_id IS NULL ORDER BY updated_at DESC",
).fetchall()
all_pages = []
for r in rows:
page = dict(r)
all_pages.append({
"id": f"page/{page['id']}",
"name": page["title"] or "Untitled",
"icon": "📄",
"url": f"/pages/{page['id']}",
"created_by": "You",
"source": page.get("workspace") or "Private",
"last_edited": page.get("updated_at", "now"),
"last_visited": page.get("updated_at", "now"),
})
sidebar["recent_pages"] = all_pages
sidebar["favorite_pages"] = []
sidebar["private_pages"] = [p for p in all_pages if p.get("source") == "🔒 Private"]
sidebar["shared_pages"] = []
sidebar["shared_made_pages"] = []
sidebar["shared_received_pages"] = []
template = env.get_template("library.html")
return template.render(**sidebar)
# ═══════════ Favorites API ═══════════
+229
View File
@@ -0,0 +1,229 @@
"""FlowDeck — Board : page_api.
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Body, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import fire_event, run_event_sync
from ._common import _ensure_block_ids
logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board")
@router.post("/api/pages/{page_id}/lock")
def set_page_lock(request: Request, page_id: int, body: dict = Body(default={})):
"""v5.12.0: lock/unlock a page (read-only for everyone except the locker,
admins and the page creator)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
locked = bool(body.get("locked"))
with get_conn() as conn:
row = conn.execute("SELECT id, is_locked, locked_by, workspace FROM pages WHERE id=?",
(page_id,)).fetchone()
if not row:
raise HTTPException(404, "Page not found")
is_admin = 1 if user.get("is_admin") else 0
if row["is_locked"] and not is_admin and row["locked_by"] != user["id"]:
raise HTTPException(403, "Only the person who locked this page (or an admin) can unlock it")
conn.execute("UPDATE pages SET is_locked=?, locked_by=? WHERE id=?",
(1 if locked else 0, user["id"] if locked else None, page_id))
conn.commit()
run_event_sync(fire_event("page.locked" if locked else "page.unlocked",
{"page_id": page_id, "by": user["id"]}))
return {"status": "ok", "is_locked": int(locked)}
@router.post("/api/pages/{page_id}/options")
def set_page_options(request: Request, page_id: int, body: dict = Body(default={})):
"""v5.12.0: page layout options — full-width and compact typography."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
updates = {}
for key in ("full_width", "font_small"):
if key in body:
updates[key] = 1 if body[key] else 0
if not updates:
raise HTTPException(400, "nothing to update")
sets = ", ".join(f"{k}=?" for k in updates)
with get_conn() as conn:
row = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
raise HTTPException(404, "Page not found")
conn.execute(f"UPDATE pages SET {sets}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(*updates.values(), page_id))
conn.commit()
return {"status": "ok", **{k: bool(v) for k, v in updates.items()}}
@router.get("/api/page-templates")
def list_page_templates_api(request: Request):
"""v5.12.0: built-in + user global page templates for the picker."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = (user or {}).get("id")
from app.services.block_templates import template_list
with get_conn() as conn:
rows = conn.execute(
"""SELECT id, name, icon, description, created_by
FROM page_global_templates
WHERE created_by IS NULL OR created_by=?
ORDER BY created_at""",
(uid,),
).fetchall()
mine = [dict(r) for r in rows]
for t in mine:
t["builtin"] = False
return {"templates": template_list() + mine}
@router.post("/api/page-templates")
def create_page_template(request: Request, body: dict = Body(default={})):
"""v5.12.0: save the current page (or a raw block list) as a personal
global template: {name, icon?, description?, page_id? | blocks?}."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(400, "name is required")
blocks = body.get("blocks")
if body.get("page_id"):
with get_conn() as conn:
row = conn.execute("SELECT content, content_format FROM pages WHERE id=?",
(int(body["page_id"]),)).fetchone()
if not row:
raise HTTPException(404, "Page not found")
if row["content_format"] == "blocks" and row["content"]:
try:
blocks = json.loads(row["content"])
except (json.JSONDecodeError, TypeError):
raise HTTPException(400, "Page content is not block JSON") from None
if not isinstance(blocks, list) or not blocks:
raise HTTPException(400, "blocks (or page_id) required")
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO page_global_templates (name, icon, description, blocks_json, created_by)
VALUES (?, ?, ?, ?, ?)""",
(name, body.get("icon") or "📄", body.get("description") or "",
json.dumps(blocks), user["id"]),
)
conn.commit()
tid = cur.lastrowid
return {"status": "ok", "id": tid}
@router.post("/api/page-templates/{template_id}/use")
def use_page_template(request: Request, template_id: int, body: dict = Body(default={})):
"""v5.12.0: instantiate a page from a template (built-in or user).
Body: {key?} for built-ins OR uses the row id for user templates.
Creates 'blocks'-format page in the caller's workspace and returns its id.
"""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
title = (body.get("title") or "").strip()
blocks_json = None
if template_id == 0:
from app.services.block_templates import blocks_json_for
key = body.get("key") or "empty"
blocks_json = blocks_json_for(key)
name = key
if blocks_json is None:
raise HTTPException(404, "Unknown built-in template")
else:
with get_conn() as conn:
uid = (user or {}).get("id")
row = conn.execute(
"SELECT * FROM page_global_templates WHERE id=? AND (created_by IS NULL OR created_by=?)",
(template_id, uid),
).fetchone()
if not row:
raise HTTPException(404, "Template not found")
blocks_json = row["blocks_json"]
name = row["name"]
title = title or row["name"]
# Resolve the target workspace so the new page actually shows up in the
# active local workspace (bugfix: template pages previously got
# workspace_id = NULL and never appeared in the sidebar/tree).
uid = (user or {}).get("id")
ws_id_raw = body.get("workspace_id")
ws_id = None
if ws_id_raw is not None:
try:
ws_id = int(ws_id_raw)
except (TypeError, ValueError):
ws_id = None
ws_key = user.get("login", "Bruno") if user else "Bruno"
if ws_id is not None:
with get_conn() as conn:
ws_row = conn.execute(
"SELECT id, name, owner_id FROM workspaces WHERE id=?", (ws_id,)
).fetchone()
if ws_row and (uid is None or ws_row["owner_id"] == uid):
ws_key = ws_row["name"] or ws_key
else:
ws_id = None
else:
body_ws = (body.get("workspace") or "").strip()
if body_ws:
ws_key = body_ws
# Optional target folder: instantiate the template as a child of it.
parent_id = body.get("parent_id")
try:
parent_id = int(parent_id) if parent_id not in (None, "", 0, "0") else None
except (TypeError, ValueError):
parent_id = None
try:
parsed_blocks = json.loads(blocks_json)
except (json.JSONDecodeError, TypeError):
raise HTTPException(500, "Template content corrupted") from None
_ensure_block_ids(parsed_blocks)
blocks_json = json.dumps(parsed_blocks)
with get_conn() as conn:
if parent_id is not None:
next_order = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE parent_id=?",
(parent_id,),
).fetchone()[0]
elif ws_id is not None:
next_order = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace_id=? AND parent_id IS NULL",
(ws_id,),
).fetchone()[0]
else:
next_order = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL",
(ws_key,),
).fetchone()[0]
cur = conn.execute(
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id, sort_order)
VALUES (?,?,?,?,?, 'Private', ?, ?)""",
(ws_key, ws_id, title or name, blocks_json, "blocks", parent_id, next_order),
)
conn.commit()
page_id = cur.lastrowid
run_event_sync(fire_event("page.created", {"page_id": page_id, "title": title or name,
"workspace": ws_key, "from_template": name}))
return {"status": "ok", "id": page_id, "title": title or name}
# ── Core helpers ──
+122
View File
@@ -0,0 +1,122 @@
"""FlowDeck — Board : page_media.
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Body, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import fire_event, run_event_sync
from ._common import _store_uploaded_file, _ws_id_for
logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board")
# ═══════════ v5.4.0: Page & collection duplication ═══════════
@router.post("/api/pages/{page_id}/duplicate")
def duplicate_page(request: Request, page_id: int):
"""Duplicate a page (block/markdown content included) as a sibling."""
SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)
).fetchone()
if not row:
raise HTTPException(404, "Page not found")
page = dict(row)
def copy_tree(src_id: int, parent_id) -> int:
with get_conn() as conn:
conn.execute("SELECT * FROM pages WHERE id=?", (src_id,)).fetchone()
cur = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, "
"parent_section, parent_id, sort_order, share_mode, published, is_published, "
"publish_slug, is_shared, cover_url, page_icon, created_at, updated_at) "
"SELECT workspace, workspace_id, title || ' copy', content, content_format, "
"parent_section, ?, sort_order, share_mode, 0, is_published, '', is_shared, "
"cover_url, page_icon, created_at, updated_at FROM pages WHERE id=?",
(parent_id, src_id),
)
new_id = cur.lastrowid
conn.commit()
for child in conn.execute(
"SELECT id FROM pages WHERE parent_id=? ", (src_id,)
).fetchall():
copy_tree(child["id"], new_id)
return new_id
new_id = copy_tree(page_id, page.get("parent_id"))
title = (page.get("title") or "Untitled") + " copy"
with get_conn() as conn:
conn.execute("UPDATE pages SET title=? WHERE id=?", (title, new_id))
conn.commit()
run_event_sync(fire_event("page.created", {"page_id": new_id, "title": title,
"workspace": page.get("workspace")}))
return {"status": "ok", "id": new_id, "title": title}
# ═══════════ v5.5.0: Cover & icon ═══════════
@router.post("/api/pages/{page_id}/cover")
async def set_page_cover(request: Request, page_id: int):
"""v5.5.0: upload an image cover for a page.
JSON body {cover_url} accepts an external URL; multipart ``file`` uploads
an image stored in the workspace's uploads directory.
"""
ctype = (request.headers.get("content-type") or "").lower()
if ctype.startswith("application/json"):
body = await request.json()
cover_url = (body.get("cover_url") or "").strip()
if not cover_url:
raise HTTPException(400, "cover_url required")
with get_conn() as conn:
conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (cover_url, page_id))
conn.commit()
return {"status": "ok", "page_id": page_id, "cover_url": cover_url}
ws_id = _ws_id_for(request, page_id)
meta = await _store_uploaded_file(request, ws_id)
with get_conn() as conn:
conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (meta["file_url"], page_id))
conn.commit()
return {"status": "ok", "page_id": page_id, "cover_url": meta["file_url"]}
@router.delete("/api/pages/{page_id}/cover")
def remove_page_cover(request: Request, page_id: int):
with get_conn() as conn:
conn.execute("UPDATE pages SET cover_url='' WHERE id=?", (page_id,))
conn.commit()
return {"status": "ok", "page_id": page_id}
@router.post("/api/pages/{page_id}/icon")
def set_page_icon(request: Request, page_id: int, body: dict = Body(default={})):
"""v5.5.0: set a page emoji/icon label (or a custom-emoji image URL)."""
icon = (body.get("icon") or "").strip()
if len(icon) > 512:
raise HTTPException(400, "icon too long")
with get_conn() as conn:
conn.execute("UPDATE pages SET page_icon=? WHERE id=?", (icon, page_id))
conn.commit()
return {"status": "ok", "page_id": page_id, "icon": icon}
# ═══════════ v5.4.0: Import (Markdown / .md / Notion .zip) ═══════════
+176
View File
@@ -0,0 +1,176 @@
"""FlowDeck — Board : page_ops.
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import fire_event, run_event_sync
from app.services.permission_manager import PermissionManager
from ._common import _sidebar_data
logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board")
@router.put("/api/pages/{page_id}/move")
def move_page(request: Request, page_id: int, body: dict = Body(default={})):
"""Move a page to another workspace or reorder within tree.
Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int }
- workspace_id: move page to a different workspace
- parent_id: change parent (0 = root level)
- new_order: position among siblings (0 = append)
"""
new_ws_id = body.get("workspace_id")
new_parent_id = body.get("parent_id", 0)
new_order = body.get("new_order", 0)
with get_conn() as conn:
row = conn.execute("SELECT id, workspace, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
raise HTTPException(404, "Page not found")
if new_ws_id:
# Move to a different workspace: get the workspace name
ws_row = conn.execute("SELECT name FROM workspaces WHERE id=?", (new_ws_id,)).fetchone()
if not ws_row:
return JSONResponse({"status": "error", "detail": "Workspace not found"}, status_code=404)
conn.execute(
"UPDATE pages SET workspace_id=?, workspace=?, parent_id=NULL, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(new_ws_id, ws_row["name"], page_id),
)
else:
# Reorder within same workspace
conn.execute(
"UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(new_parent_id if new_parent_id > 0 else None, page_id),
)
conn.execute(
"UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(new_order, page_id),
)
conn.commit()
run_event_sync(fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0,
"parent_id": new_parent_id}))
return {"status": "ok", "id": page_id}
@router.delete("/api/pages/{page_id}")
def delete_page(request: Request, page_id: int):
"""Move a page to trash (soft delete)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = (user or {}).get("id")
if not uid:
raise HTTPException(403, "Authentication required")
# v6.0.0: granular page permissions — need at least edit access to trash.
if not PermissionManager(uid).can_edit_page(page_id):
raise HTTPException(403, "You don't have edit access to this page")
with get_conn() as conn:
row = conn.execute("SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)).fetchone()
if not row:
raise HTTPException(404, "Page not found")
import datetime
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,))
conn.commit()
run_event_sync(fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""}))
return {"status": "ok", "deleted": page_id, "title": row["title"]}
@router.get("/pages/{page_id}", response_class=HTMLResponse)
def view_page(request: Request, page_id: int):
"""Render a page as HTML, or a file viewer for uploaded files.
?embed=1 — minimal mode for side peek (editor only, no header)."""
embed = request.query_params.get("embed") == "1"
from app.templating import ENV
env = ENV
# v6.0.0: granular page permissions — hide restricted pages (404).
user_hdr = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if user_hdr and user_hdr.get("id") and not PermissionManager(user_hdr["id"]).can_view_page(page_id):
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
page = dict(row)
# v6.5.0: synced blocks resolve server-side at read time (fresh content
# even when the stored cache is stale).
from app.services.synced_blocks import resolve_content_json
page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format"))
ws = page.get("workspace", "")
parts = ws.split("/") if "/" in ws else ["", ""]
owner, repo = parts[0], parts[1] if len(parts) > 1 else ""
sidebar = _sidebar_data(request, owner, repo)
# Check if page is favorited
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = user["id"] if user and user.get("id") else 1
with get_conn() as conn:
fav = conn.execute(
"SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id)
).fetchone()
# Build page_data, including file metadata for uploaded files
_locked = bool(page.get("is_locked", 0))
_locked_by = page.get("locked_by") if "locked_by" in page else None
_can_edit = (not _locked) or bool(user and user.get("is_admin")) or (uid and _locked_by == uid)
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)), "cover_url": page.get("cover_url", "") or "", "page_icon": page.get("page_icon", "") or "",
"is_locked": _locked,
"locked_by": _locked_by,
"can_edit": _can_edit,
"full_width": bool(page.get("full_width", 0)) if "full_width" in page else False,
"font_small": bool(page.get("font_small", 0)) if "font_small" in page else False}
# For file pages, extract file metadata and add to page_data
if page.get("content_format") == "file":
import json as _json
try:
meta = _json.loads(page.get("content", "{}"))
except _json.JSONDecodeError:
meta = {}
file_path = meta.get("file_path", "").replace("\\", "/")
mime_type = meta.get("mime_type", "application/octet-stream")
file_size = meta.get("size", 0)
# Build workspace_id from file_path
fp_parts = file_path.split("/")
ws_id = ""
for p in fp_parts:
if p.startswith("workspace_"):
ws_id = p.replace("workspace_", "")
break
filename = fp_parts[-1] if fp_parts else page.get("title", "File")
file_url = f"/api/files/{ws_id}/{filename}" if ws_id else ""
page_data["file_url"] = file_url
page_data["file_mime"] = mime_type
page_data["file_size"] = file_size
page_data["file_name"] = filename
from app.routers.dashboard import _nav_breadcrumb
with get_conn() as conn:
nav_crumbs = _nav_breadcrumb(conn, page_id)
ctx = {**sidebar, "page": page, "page_favorited": fav is not None,
"page_share_mode": page.get("share_mode", "private"),
"page_published": bool(page.get("published", 0)),
"page_is_shared": bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)),
"page_data": page_data,
"breadcrumb_items": nav_crumbs,
"nav_workspace_id": page.get("workspace_id") or 0,
"nav_page_id": page_id,
"embed_mode": embed}
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
response = template.render(**ctx)
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
+271
View File
@@ -0,0 +1,271 @@
"""FlowDeck — Board : pages.
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Body, HTTPException, Query, Request
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import fire_event, run_event_sync
from app.services.permission_manager import PermissionManager
from ._common import _block_texts, _ensure_page_editable, _record_version
logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board")
# ═══════════ Pages Markdown ═══════════
@router.post("/api/pages")
def create_page(request: Request, title: str = Query(default=""),
section: str = Query(default="Private"),
project: str = Query(default=""),
parent_id: int = Query(default=0)):
"""Create a new Markdown page, optionally as a sub-page."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
# A7 : la création de page exige une session (route sortue de la liste CSRF).
raise HTTPException(401, "Authentication required")
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
page_title = title.strip() if title else ""
try:
with get_conn() as conn:
# Compute next sort_order for this parent
next_order = 0
parent_val = parent_id if parent_id > 0 else None
row = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS ?",
(ws_key, parent_val),
).fetchone()
if row:
next_order = row[0]
cur = conn.execute(
"INSERT INTO pages (workspace, title, parent_section, parent_id, sort_order) VALUES (?,?,?,?,?)",
(ws_key, page_title, section, parent_val, next_order),
)
conn.commit()
page_id = cur.lastrowid
run_event_sync(fire_event("page.created", {"page_id": page_id, "title": page_title,
"workspace": ws_key, "parent_id": parent_id}))
return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id}
except Exception as e:
logger.error("create_page failed: %s", e)
from fastapi.responses import JSONResponse
return JSONResponse({"error": "Failed to create page", "detail": str(e)}, status_code=500)
@router.get("/api/pages/{page_id}")
def get_page(request: Request, page_id: int):
"""Get a Markdown page."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
# v6.0.0: granular page permissions — 404 (not 403) hides restricted pages.
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
raise HTTPException(404, "Page not found")
with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
raise HTTPException(404, "Page not found")
return dict(row)
@router.put("/api/pages/{page_id}")
def update_page(request: Request, page_id: int, title: str = Query(default=""),
content: str = Query(default=""),
content_format: str = Query(default="")):
"""Update a page's title and/or content. Accepts JSON body for blocks."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(403, "Authentication required")
# v6.0.0: granular page permissions — hidden pages 404 (not 403); a visible
# page the caller cannot edit yields 403.
pm = PermissionManager(user["id"], bool(user.get("is_admin")))
if not pm.can_view_page(page_id):
raise HTTPException(404, "Page not found")
if not pm.can_edit_page(page_id):
raise HTTPException(403, "You don't have edit access to this page")
with get_conn() as conn:
_ensure_page_editable(conn, page_id, user)
if title:
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
# v6.5.0: renaming a database row's content page updates the row.
from app.services.row_pages import sync_page_title_to_row
sync_page_title_to_row(conn, page_id)
if content:
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content, page_id))
if content_format:
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
conn.commit()
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title,
"content_format": content_format or "markdown",
"actor_id": user.get("id")}))
return {"status": "ok"}
@router.post("/api/pages/{page_id}/blocks")
def save_page_blocks(request: Request, page_id: int, body: dict = Body(...)):
"""Save blocks JSON content (Notion-style block editor).
v5.4.0: a version snapshot is recorded (if the block content actually
changed) so the UI can browse the version history and restore any of them.
v5.14.0: synced block references are tracked in page_synced_blocks.
"""
blocks = body.get("blocks", [])
blocks_json = json.dumps(blocks)
title = body.get("title", "")
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = (user or {}).get("id")
# No session → legacy single-user behaviour; otherwise enforce edit rights.
if uid and not PermissionManager(uid).can_edit_page(page_id):
raise HTTPException(403, "You don't have edit access to this page")
# Extract synced block ids from the blocks
def _extract_synced(blocks: list[dict]) -> set[int]:
ids: set[int] = set()
for b in blocks:
if b.get("type") == "synced" and b.get("synced_id"):
ids.add(b["synced_id"])
if isinstance(b.get("children"), list):
ids |= _extract_synced(b["children"])
return ids
synced_ids = _extract_synced(blocks)
with get_conn() as conn:
_ensure_page_editable(conn, page_id, user)
if title:
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
# v6.5.0: renaming a database row's content page updates the row.
from app.services.row_pages import sync_page_title_to_row
sync_page_title_to_row(conn, page_id)
conn.execute(
"UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?",
(blocks_json, page_id),
)
_record_version(conn, page_id, uid, title or "", blocks_json)
# Update synced block references
existing = {r["synced_block_id"] for r in conn.execute(
"SELECT synced_block_id FROM page_synced_blocks WHERE page_id=?", (page_id,)
).fetchall()}
for sid in synced_ids:
if sid not in existing:
conn.execute(
"INSERT OR IGNORE INTO page_synced_blocks (page_id, synced_block_id, block_index) VALUES (?, ?, 0)",
(page_id, sid),
)
for sid in existing - synced_ids:
conn.execute(
"DELETE FROM page_synced_blocks WHERE page_id=? AND synced_block_id=?",
(page_id, sid),
)
conn.commit()
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title or "",
"content_format": "blocks",
"actor_id": uid}))
return {"status": "ok", "id": page_id}
@router.get("/api/pages/{page_id}/backlinks")
def page_backlinks(request: Request, page_id: int):
"""v5.4.0: pages that link to this one ("Lié depuis…").
Scans every non-deleted page's blocks (and raw markdown) for an internal
reference to ``/pages/{page_id}`` or ``#fdblk-…`` inside ``/pages/{page_id}``.
"""
target = f"/pages/{page_id}" if page_id else None
wiki_target = f"[[fdpage:{page_id}]]" if page_id else None
backlinks = []
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title, workspace, content, content_format, updated_at "
"FROM pages WHERE deleted_at IS NULL AND id != ?",
(page_id,),
).fetchall()
for r in rows:
fmt = r["content_format"]
hits = False
if fmt == "blocks" and r["content"]:
try:
blocks = json.loads(r["content"])
for b in blocks if isinstance(blocks, list) else []:
for text in _block_texts(b):
if target and (target in text or (wiki_target and wiki_target in text)):
hits = True
break
if hits:
break
except (json.JSONDecodeError, TypeError):
hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or "")))
elif fmt == "markdown":
hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or "")))
elif r["content"]:
hits = target and (target in json.dumps(r["content"]) or (wiki_target and wiki_target in json.dumps(r["content"])))
if not hits and target:
hits = f"/pages/{page_id}" in (r["content"] or "")
if hits:
backlinks.append({
"id": r["id"],
"title": r["title"] or "Untitled",
"workspace": r["workspace"] or "",
"updated_at": r["updated_at"] or "",
})
backlinks.sort(key=lambda x: x.get("updated_at") or "", reverse=True)
return {"backlinks": backlinks}
@router.get("/api/pages/{page_id}/versions")
def page_versions(request: Request, page_id: int):
"""v5.4.0: version history for a block-editor page."""
with get_conn() as conn:
rows = conn.execute(
"SELECT pv.id, pv.title, pv.note, pv.created_at, "
"COALESCE(u.login, '') AS author "
"FROM page_versions pv LEFT JOIN users u ON u.id=pv.user_id "
"WHERE pv.page_id=? ORDER BY pv.id DESC LIMIT 100",
(page_id,),
).fetchall()
return {"versions": [dict(r) for r in rows]}
@router.post("/api/pages/{page_id}/versions/{version_id}/restore")
def restore_version(request: Request, page_id: int, version_id: int):
"""v5.4.0: restore a page from a version snapshot."""
with get_conn() as conn:
ver = conn.execute(
"SELECT * FROM page_versions WHERE id=? AND page_id=?",
(version_id, page_id),
).fetchone()
if not ver:
raise HTTPException(404, "Version not found")
conn.execute(
"UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(ver["blocks_json"], ver["title"] or "", page_id),
)
conn.commit()
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "",
"content_format": "blocks"}))
return {"status": "ok", "restored": version_id}
# ═══════════ v5.4.0: Page & collection duplication ═══════════
+175
View File
@@ -0,0 +1,175 @@
"""FlowDeck — Board : sharing.
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import HTMLResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import fire_event, run_event_sync
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
from ._common import _sidebar_data
logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board")
# ═══════════ Favorites API ═══════════
@router.get("/api/favorites")
def list_favorites(request: Request):
"""List favorited page IDs for the current user."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = user["id"] if user and user.get("id") else 1
with get_conn() as conn:
rows = conn.execute(
"SELECT page_id FROM favorites WHERE user_id=? ORDER BY position", (uid,)
).fetchall()
return {"favorites": [r["page_id"] for r in rows]}
@router.post("/api/favorites/{page_id:int}")
def add_favorite(request: Request, page_id: int):
"""Add a page to favorites."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = user["id"] if user and user.get("id") else 1
with get_conn() as conn:
existing = conn.execute(
"SELECT id FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id)
).fetchone()
if not existing:
pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM favorites WHERE user_id=?", (uid,)
).fetchone()[0]
conn.execute(
"INSERT INTO favorites (user_id, page_id, position) VALUES (?,?,?)",
(uid, page_id, pos),
)
conn.commit()
try:
run_event_sync(fire_event("favorite.added", {"page_id": page_id, "user_id": uid}))
except Exception:
logger.exception("add_favorite")
return {"status": "added", "page_id": page_id}
@router.delete("/api/favorites/{page_id:int}")
def remove_favorite(request: Request, page_id: int):
"""Remove a page from favorites."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = user["id"] if user and user.get("id") else 1
with get_conn() as conn:
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id))
conn.commit()
try:
run_event_sync(fire_event("favorite.removed", {"page_id": page_id, "user_id": uid}))
except Exception:
logger.exception("remove_favorite")
return {"status": "removed", "page_id": page_id}
# ═══════════ Share API ═══════════
# ═══════════ Share API ═══════════
@router.post("/api/share/{page_id:int}")
def update_share(request: Request, page_id: int, body: dict = Body(default={})):
"""Save share settings for a page."""
mode = body.get("mode", "private")
published = body.get("published", False)
with get_conn() as conn:
conn.execute(
"UPDATE pages SET share_mode=?, published=? WHERE id=?",
(mode, 1 if published else 0, page_id),
)
conn.commit()
return {"status": "ok", "share_mode": mode, "published": published}
@router.post("/api/pages/{page_id:int}/publish")
def publish_page(request: Request, page_id: int):
"""Publish a page to the web (generates publish_slug)."""
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
raise HTTPException(401, "Authentication required")
slug, title = publish(page_id)
run_event_sync(fire_published(page_id, slug))
return {"is_published": True, "publish_slug": slug, "title": title}
@router.delete("/api/pages/{page_id:int}/publish")
def unpublish_page(request: Request, page_id: int):
"""Unpublish a page from the web."""
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
raise HTTPException(401, "Authentication required")
unpublish(page_id)
run_event_sync(fire_unpublished(page_id))
return {"is_published": False}
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
@router.get("/api/trash")
def list_trash(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall()
return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows]
@router.post("/api/trash/{page_id}/restore")
def restore_page(request: Request, page_id: int):
with get_conn() as conn:
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
conn.commit()
try:
run_event_sync(fire_event("page.restored", {"page_id": page_id}))
except Exception:
logger.exception("restore_page")
return {"status": "ok", "restored": page_id}
@router.delete("/api/trash/{page_id}")
def permanent_delete(request: Request, page_id: int):
with get_conn() as conn:
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
conn.commit()
return {"status": "ok", "deleted": page_id}
@router.get("/trash", response_class=HTMLResponse)
def trash_page(request: Request):
from app.templating import ENV
env = ENV
template = env.get_template("trash.html")
return template.render(**_sidebar_data(request))
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
+51
View File
@@ -0,0 +1,51 @@
"""FlowDeck — Board : sync.
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, HTTPException
from app.db import get_conn
from app.services.gitea_client import gitea
from ._common import _extract_ai_keywords, _issue_column
logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board")
@router.post("/api/sync/{owner}/{repo}")
async def sync_project(owner: str, repo: str):
"""Full bidirectional sync: fetch Gitea issues → update local DB."""
try:
issues = await gitea.get_issues(owner, repo, state="all")
issues_only = [i for i in issues if not i.get("pull_request")]
with get_conn() as conn:
board = conn.execute(
"SELECT id, columns_json FROM boards WHERE project_owner=? AND project_name=?",
(owner, repo),
).fetchone()
if board:
board_id = board["id"]
columns = json.loads(board["columns_json"])
# A23 : un seul executemany pour toutes les cards.
conn.executemany(
"INSERT OR REPLACE INTO cards (board_id, gitea_issue_id, column_name) VALUES (?,?,?)",
[
(board_id, issue["number"], _issue_column(issue, columns, board_id))
for issue in issues_only
],
)
for issue in issues_only:
# Extract AI keywords from each issue
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
conn.commit()
return {"status": "ok", "issues_synced": len(issues_only)}
except Exception as e:
raise HTTPException(500, str(e)) from e
+144
View File
@@ -0,0 +1,144 @@
"""FlowDeck — Board : synced.
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Body, HTTPException, Query, Request
from app.auth.session import SessionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board")
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
@router.get("/api/synced-blocks")
def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
"""List synced blocks for a workspace."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
from app.services.synced_blocks import list_synced_blocks
return {"synced_blocks": list_synced_blocks(workspace or user.get("login", ""))}
@router.post("/api/synced-blocks")
def create_synced_block_api(request: Request, body: dict = Body(...)):
"""Create a new synced block."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
from app.services.synced_blocks import create_synced_block
sid = create_synced_block(
workspace=body.get("workspace", ""),
title=body.get("title", "Synced block"),
content=body.get("content", []),
created_by=user.get("id"),
)
return {"status": "ok", "synced_block_id": sid}
@router.put("/api/synced-blocks/{sid}")
async def update_synced_block_api(request: Request, sid: int):
"""Update a synced block's content (propagates to all pages)."""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
from app.services.synced_blocks import (
get_synced_block,
page_ids_for_synced,
sync_synced_blocks_in_page,
update_synced_block,
)
sb = get_synced_block(sid)
if not sb:
raise HTTPException(404, "Synced block not found")
update_synced_block(sid, body.get("title", sb["title"]), body.get("content", []))
# v6.5.0: rewrite every referencing page's stored content first (DB row
# content pages included), THEN push the realtime update so open rooms
# reload the fresh content from the DB.
for pid in page_ids_for_synced(sid):
sync_synced_blocks_in_page(pid)
from app.services.realtime_server import manager
await manager._propagate_synced(sid)
return {"status": "ok"}
@router.delete("/api/synced-blocks/{sid}")
async def delete_synced_block_api(request: Request, sid: int):
"""Delete a synced block."""
from app.services.synced_blocks import (
delete_synced_block,
get_synced_block,
mark_synced_block_deleted,
page_ids_for_synced,
)
sb = get_synced_block(sid)
if not sb:
raise HTTPException(404, "Synced block not found")
# v6.5.0: collect referencing pages BEFORE the FK cascade wipes the
# refs, rewrite their stored content (deleted state), then broadcast.
pids = page_ids_for_synced(sid)
delete_synced_block(sid)
mark_synced_block_deleted(sid, pids)
from app.services.realtime_server import manager
await manager._broadcast_synced_to(pids, sid)
return {"status": "ok"}
@router.get("/api/synced-blocks/{sid}")
def get_synced_block_api(sid: int):
"""Get a synced block by id."""
from app.services.synced_blocks import get_synced_block
sb = get_synced_block(sid)
if not sb:
raise HTTPException(404, "Synced block not found")
return dict(sb)
@router.post("/api/pages/{page_id}/synced")
def add_synced_to_page(request: Request, page_id: int, body: dict = Body(...)):
"""Add a synced block reference to a page."""
from app.services.synced_blocks import add_page_synced, get_synced_block
sid = body.get("synced_block_id")
sb = get_synced_block(sid)
if not sb:
raise HTTPException(404, "Synced block not found")
add_page_synced(page_id, sid, body.get("block_index", 0))
return {"status": "ok", "synced_block_id": sid}
@router.delete("/api/pages/{page_id}/synced/{sid}")
def remove_synced_from_page(request: Request, page_id: int, sid: int):
"""Remove a synced block reference from a page (unsync)."""
from app.services.synced_blocks import remove_page_synced
remove_page_synced(page_id, sid)
return {"status": "ok"}
@router.get("/api/pages/{page_id}/synced")
def get_page_synced_refs(request: Request, page_id: int):
"""Get all synced block references for a page."""
from app.services.synced_blocks import get_page_synced
return {"synced_blocks": get_page_synced(page_id)}
# ═══════════ Board page ═══════════
+76
View File
@@ -0,0 +1,76 @@
"""FlowDeck — Board : wiki.
Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Query, Request
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board")
@router.get("/api/wiki/pages")
def wiki_page_search(request: Request, q: str = Query(default="")):
"""v5.11.0: page picker for [[ wiki-links. Fuzzy-ish title search across
every non-deleted page the current user can see (single source: pages)."""
q = (q or "").strip().lower()
with get_conn() as conn:
rows = conn.execute(
"""SELECT id, title, page_icon, workspace FROM pages
WHERE deleted_at IS NULL
ORDER BY updated_at DESC LIMIT 500"""
).fetchall()
results = []
for r in rows:
title = r["title"] or "Untitled"
if q:
# subsequence match ("mtg" → "Meeting notes") or plain substring.
hay = title.lower()
it = iter(hay)
subseq = all(ch in it for ch in q)
if q not in hay and not subseq:
continue
results.append({
"id": r["id"],
"title": title,
"icon": r["page_icon"] or "",
"workspace": r["workspace"] or "",
})
if len(results) >= 20:
break
return {"pages": results}
@router.get("/api/wiki/titles")
def wiki_titles(request: Request, ids: str = Query(default="")):
"""v5.11.0: resolve page-id lists to current labels (rename propagation)."""
parsed: list[int] = []
for part in (ids or "").split(","):
part = part.strip()
if part.isdigit():
parsed.append(int(part))
parsed = parsed[:200]
out: dict[str, str] = {}
if parsed:
placeholders = ",".join("?" * len(parsed))
with get_conn() as conn:
rows = conn.execute(
f"SELECT id, title, page_icon, deleted_at FROM pages WHERE id IN ({placeholders})",
parsed,
).fetchall()
for r in rows:
if r["deleted_at"]:
out[str(r["id"])] = "Deleted page"
else:
icon = (r["page_icon"] or "")
out[str(r["id"])] = (icon + " " if icon else "") + (r["title"] or "Untitled")
return {"titles": out}
File diff suppressed because it is too large Load Diff
+58
View File
@@ -0,0 +1,58 @@
"""FlowDeck — Collections : bases de données façon Notion.
Découpe A28 : l'ancien `collections.py` (2 622 lignes, 53 routes) est
devenu ce package — un module par concern, helpers dans `_common`
(auth/permissions/validation) et `_renderers` (rendus HTML des vues).
Ré-exports : automations importe `_validate_page_properties`, les
tests importent les helpers de graphes.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter
from . import ( # ordre = ordre d'enregistrement d'origine
boards,
crud,
dashboard_views,
data_api,
linked,
meta,
pages,
properties,
structure,
views,
)
from ._common import _validate_page_properties # noqa: F401
from ._renderers import ( # noqa: F401 — ré-exports tests
_chart_aggregate,
_chart_values,
_fmt_number,
_render_chart,
)
logger = logging.getLogger(__name__)
router = APIRouter()
for _mod in (
crud,
pages,
boards,
meta,
properties,
views,
structure,
linked,
dashboard_views,
data_api,
):
router.include_router(_mod.router)
__all__ = [
"router",
"_chart_aggregate",
"_chart_values",
"_fmt_number",
"_render_chart",
"_validate_page_properties",
]
+220
View File
@@ -0,0 +1,220 @@
"""FlowDeck — Collections : helpers partagés (A28).
Les 8 helpers de tête de l'ancien collections.py (auth, permissions,
validation) — ré-exportés par le package.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from app.auth.session import SessionManager
from app.services.permission_manager import PermissionManager
from app.services.property_types import (
AUTO_TYPES,
validate_property_rule,
)
from app.services.recurrence import (
RECURRENCE_KEY,
is_valid_timezone,
validate_rule,
)
from app.services.reminders import REMINDER_KEY, parse_lead
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collections"], prefix="/db")
def _current_user(request: Request) -> dict:
"""Resolve the session user, falling back to the local admin (single-user)."""
s = request.cookies.get("flowdeck_session", "")
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
def _session_user(request: Request) -> dict | None:
"""Resolve the session user WITHOUT the admin fallback (for ACL checks)."""
s = request.cookies.get("flowdeck_session", "")
user = SessionManager.decode_session(s)
return user if user and user.get("id") else None
def _require_view(collection_id: int, user: dict | None) -> None:
"""Raise 404 when the user may not view the collection (404 hides it).
A6 : plus de session = accès refusé — l'absence de user ne vaut plus
« legacy single-user » ( lecture anonyme de n'importe quelle collection ).
"""
if not user:
raise HTTPException(status_code=404, detail="Collection not found")
pm = PermissionManager(user["id"])
if not pm.can_view_collection(collection_id):
raise HTTPException(status_code=404, detail="Collection not found")
def _require_edit(collection_id: int, user: dict | None) -> None:
"""Raise 401/403 when the user may not edit pages in the collection."""
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
pm = PermissionManager(user["id"])
if not pm.can_edit_collection(collection_id):
raise HTTPException(status_code=403, detail="You don't have edit access to this collection")
def _collection_properties(conn, collection_id: int) -> list[dict]:
return [
dict(r) for r in conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
]
def _apply_template(conn, template_name: str) -> dict | None:
"""Resolve a database template by name (from the seeded/built-in set)."""
if not template_name:
return None
row = conn.execute(
"SELECT id, name, icon, description, schema_json FROM database_templates WHERE name=?",
(template_name,),
).fetchone()
if row:
return dict(row)
return None
def _validate_page_properties(conn, collection_id: int, properties: dict, exclude_page_id: int | None = None) -> None:
"""Validate submitted property values against the collection's schema.
Raises ``HTTPException(400)`` with a user-friendly message on the first
failure (type, required, unique, min/max).
"""
props = conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)
).fetchall()
for prop in props:
ptype = prop["prop_type"]
if ptype == "title" or ptype in AUTO_TYPES:
continue
pid = prop["id"]
# Values may be keyed by property id (FlowDeckDB UI) or by name (agent).
value = properties.get(str(pid))
if value is None:
value = properties.get(prop["name"])
validation = prop["validation_json"] if "validation_json" in prop.keys() else "{}"
existing_values = None
try:
import json as _json
vcfg = _json.loads(validation) if validation else {}
except Exception:
vcfg = {}
if vcfg.get("unique"):
rows = conn.execute(
"SELECT id, property_values_json FROM collection_pages WHERE collection_id=?",
(collection_id,),
).fetchall()
existing_values = []
for r in rows:
if exclude_page_id is not None and r["id"] == exclude_page_id:
continue
try:
pv = _json.loads(r["property_values_json"] or "{}")
except Exception:
pv = {}
existing_values.append(pv.get(str(pid)) or pv.get(prop["name"]))
ok, msg = validate_property_rule(ptype, value, validation, existing_values=existing_values)
if not ok:
raise HTTPException(status_code=400, detail=f"Property '{prop['name']}': {msg}")
def _validate_meta_keys(conn, collection_id: int, properties: dict) -> None:
"""Validate the ``__recurrence__`` / ``__reminder__`` meta keys stored
alongside real property values. Raises HTTPException(400) on bad shape.
Each meta key maps a date-property id to a rule/reminder object. We verify
the target is actually a date property and the payload parses.
"""
date_ids = {
str(r["id"]) for r in conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='date'",
(collection_id,),
).fetchall()
}
rec = properties.get(RECURRENCE_KEY)
if rec not in (None, {}):
if not isinstance(rec, dict):
raise HTTPException(status_code=400, detail="Recurrence must be an object")
for prop_id, rule in rec.items():
if rule is None:
continue
if str(prop_id) not in date_ids:
raise HTTPException(status_code=400, detail="Recurrence target must be a date property")
ok, msg = validate_rule(rule)
if not ok:
raise HTTPException(status_code=400, detail=f"Recurrence: {msg}")
rem = properties.get(REMINDER_KEY)
if rem not in (None, {}):
if not isinstance(rem, dict):
raise HTTPException(status_code=400, detail="Reminder must be an object")
for prop_id, reminder in rem.items():
if reminder is None:
continue
if str(prop_id) not in date_ids:
raise HTTPException(status_code=400, detail="Reminder target must be a date property")
if not isinstance(reminder, dict):
raise HTTPException(status_code=400, detail="Reminder must be an object")
if reminder.get("unit") not in (None, "none", "minutes", "hours", "days"):
raise HTTPException(status_code=400, detail="Reminder unit must be minutes/hours/days/none")
if parse_lead(reminder) is None and reminder.get("unit") != "none":
raise HTTPException(status_code=400, detail="Reminder value must be a positive integer")
from app.services.recurrence import TIMEZONE_KEY
tzmap = properties.get(TIMEZONE_KEY)
if tzmap not in (None, {}):
if not isinstance(tzmap, dict):
raise HTTPException(status_code=400, detail="Timezone map must be an object")
for prop_id, value in tzmap.items():
if str(prop_id) not in date_ids:
raise HTTPException(status_code=400, detail="Timezone target must be a date property")
if value and not is_valid_timezone(str(value)):
raise HTTPException(status_code=400, detail=f"Unknown timezone '{value}'")
# ── API: List & Create (no path params) ──
+667
View File
@@ -0,0 +1,667 @@
"""FlowDeck — Collections : rendus HTML des vues (A28).
Les 15 helpers de rendu de l'ancien collections.py (_render_view,
_render_chart, …) + CHART_MAX_GROUPS — ré-exportés pour les tests.
"""
from __future__ import annotations
import json
from app.db import get_conn
from app.templating import CSP_NONCE
CHART_MAX_GROUPS = 200
# ── View renderers (v1.6.0) ──
def _render_view(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
if view_type == "calendar":
return _render_calendar(view_type, collection, pages, config)
elif view_type == "gallery":
return _render_gallery(view_type, collection, pages, config)
elif view_type == "list":
return _render_list(view_type, collection, pages, config)
elif view_type == "timeline":
return _render_timeline(view_type, collection, pages, config)
elif view_type == "chart":
return _render_chart(view_type, collection, pages, config)
elif view_type == "form":
return _render_form(view_type, collection, pages, config)
elif view_type == "map":
return _render_map(view_type, collection, pages, config)
elif view_type == "feed":
return _render_feed(view_type, collection, pages, config)
elif view_type == "gantt":
return _render_gantt(view_type, collection, pages, config)
else:
return _render_table(view_type, collection, pages, config)
def _base_html(title: str, icon: str, view_type: str, body: str) -> str:
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>{title} — FlowDeck</title>
<style>
body{{font-family:system-ui,sans-serif;background:#191919;color:#fff;margin:0;padding:20px}}
h1{{font-size:24px;margin:0 0 8px}} p.desc{{color:#A0A0A0;margin:0 0 20px}}
.view-tabs{{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid #333;padding-bottom:8px}}
.tab{{padding:6px 14px;border-radius:6px;cursor:pointer;color:#A0A0A0;font-size:13px;background:none;border:none}}
.tab:hover,.tab.active{{background:#333;color:#fff}}
</style></head><body>
<h1>{icon} {title}</h1>
<div class="view-tabs">
<a class="tab{' active' if view_type=='table' else ''}" href="?view_type=table">📊 Table</a>
<a class="tab{' active' if view_type=='board' else ''}" href="?view_type=board">📋 Board</a>
<a class="tab{' active' if view_type=='calendar' else ''}" href="?view_type=calendar">📅 Calendar</a>
<a class="tab{' active' if view_type=='gallery' else ''}" href="?view_type=gallery">🖼️ Gallery</a>
<a class="tab{' active' if view_type=='list' else ''}" href="?view_type=list">📝 List</a>
<a class="tab{' active' if view_type=='timeline' else ''}" href="?view_type=timeline">📈 Timeline</a>
<a class="tab{' active' if view_type=='gantt' else ''}" href="?view_type=gantt">📊 Gantt</a>
<a class="tab{' active' if view_type=='chart' else ''}" href="?view_type=chart">📉 Chart</a>
<a class="tab{' active' if view_type=='form' else ''}" href="?view_type=form">📋 Form</a>
<a class="tab{' active' if view_type=='map' else ''}" href="?view_type=map">🗺️ Map</a>
<a class="tab{' active' if view_type=='feed' else ''}" href="?view_type=feed">📰 Feed</a>
</div>
{body}
</body></html>"""
def _render_calendar(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
from datetime import date as dt_date
from datetime import timedelta
today = dt_date.today()
# Determine month/year from config or current
year = config.get("year", today.year)
month = config.get("month", today.month)
first = dt_date(year, month, 1)
# Start from Monday of first week
start = first - timedelta(days=first.weekday())
days_in_month = []
for i in range(42): # 6 weeks
d = start + timedelta(days=i)
days_in_month.append(d)
# Map pages to dates
date_pages: dict[str, list[dict]] = {}
for p in pages:
props = json.loads(p.get("property_values_json", "{}"))
for v in props.values():
if isinstance(v, str) and v.startswith("20"):
d = v[:10]
date_pages.setdefault(d, []).append(p)
break
cells = ""
for d in days_in_month:
iso = d.isoformat()
items = date_pages.get(iso, [])
other_month = " other-month" if d.month != month else ""
today_class = " today" if d == today else ""
items_html = "".join(
f"<div class='cal-item' title='{p['title']}'>{p.get('icon','📄')} {p['title'][:20]}</div>"
for p in items
)
cells += f"<div class='cal-day{other_month}{today_class}'><span class='cal-num'>{d.day}</span>{items_html}</div>"
prev = first - timedelta(days=1)
next_month = first + timedelta(days=32)
next_month = next_month.replace(day=1)
return _base_html(collection["name"], collection.get("icon", "📅"), view_type, f"""
<style>
.calendar{{display:grid;grid-template-columns:repeat(7,1fr);gap:1px;background:#333;border-radius:8px;overflow:hidden}}
.cal-header{{background:#222;padding:8px;text-align:center;font-size:11px;color:#A0A0A0;text-transform:uppercase}}
.cal-day{{background:#1a1a1a;min-height:80px;padding:4px}}
.cal-day.other-month{{opacity:.35}}
.cal-day.today{{background:#1a2744}}
.cal-num{{font-size:12px;color:#A0A0A0;display:block;margin-bottom:2px}}
.cal-item{{font-size:11px;padding:2px 4px;margin:1px 0;background:#333;border-radius:3px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}}
.cal-nav{{display:flex;gap:8px;align-items:center;margin-bottom:12px}}
.cal-nav a{{color:#3366CC;text-decoration:none;font-size:14px}}
.cal-nav span{{font-size:16px;font-weight:600}}
</style>
<div class="cal-nav">
<a href="?view_type=calendar&year={prev.year}&month={prev.month}">← {prev.strftime('%B')}</a>
<span>{first.strftime('%B %Y')}</span>
<a href="?view_type=calendar&year={next_month.year}&month={next_month.month}">{next_month.strftime('%B')} →</a>
</div>
<div class="calendar">
<div class="cal-header">Mon</div><div class="cal-header">Tue</div><div class="cal-header">Wed</div>
<div class="cal-header">Thu</div><div class="cal-header">Fri</div><div class="cal-header">Sat</div><div class="cal-header">Sun</div>
{cells}
</div>
<p class="desc">{len(pages)} pages in collection</p>
""")
def _render_gallery(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
card_size = config.get("card_size", "medium")
size_css = {"small": "160px", "medium": "220px", "large": "300px"}.get(card_size, "220px")
cards = ""
for p in pages:
props = json.loads(p.get("property_values_json", "{}"))
cover_url = config.get("cover_property")
cover_html = ""
if cover_url:
for _k, v in props.items():
if isinstance(v, list) and len(v) > 0:
url = v[0].get("url", "") if isinstance(v[0], dict) else str(v[0])
if url.startswith("http"):
cover_html = f"<div class='gal-cover' style='background-image:url({url})'></div>"
break
prop_tags = "".join(
f"<span class='gal-prop'>{str(v)[:30]}</span>"
for v in list(props.values())[:3] if v
)
cards += f"""<div class='gal-card'>
{cover_html}
<div class='gal-body'>
<div class='gal-title'>{p.get('icon','📄')} {p['title']}</div>
<div class='gal-props'>{prop_tags}</div>
</div>
</div>"""
return _base_html(collection["name"], collection.get("icon", "🖼️"), view_type, f"""
<style>
.gallery{{display:grid;grid-template-columns:repeat(auto-fill,minmax({size_css},1fr));gap:12px}}
.gal-card{{background:#1a1a1a;border-radius:8px;overflow:hidden;border:1px solid #333}}
.gal-card:hover{{border-color:#555}}
.gal-cover{{height:120px;background:#222;background-size:cover;background-position:center}}
.gal-body{{padding:12px}}
.gal-title{{font-size:14px;font-weight:500;margin-bottom:6px}}
.gal-props{{display:flex;flex-wrap:wrap;gap:4px}}
.gal-prop{{font-size:11px;padding:2px 6px;background:#333;border-radius:4px;color:#A0A0A0}}
</style>
<div class="gallery">{cards}</div>
<p class="desc">{len(pages)} cards</p>
""")
def _render_list(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
items = ""
for p in pages:
props = json.loads(p.get("property_values_json", "{}"))
preview = " · ".join(str(v)[:60] for v in list(props.values())[:3] if v)
items += f"""<div class='list-item'>
<span class='list-icon'>{p.get('icon','📄')}</span>
<div class='list-content'>
<div class='list-title'>{p['title']}</div>
<div class='list-preview'>{preview}</div>
</div>
</div>"""
return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""
<style>
.list-item{{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;background:#1a1a1a;border-radius:6px;margin-bottom:4px;border:1px solid #222}}
.list-item:hover{{border-color:#444}}
.list-icon{{font-size:18px;margin-top:1px}}
.list-content{{flex:1;min-width:0}}
.list-title{{font-size:14px;font-weight:500}}
.list-preview{{font-size:12px;color:#A0A0A0;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
</style>
{items}
<p class="desc">{len(pages)} items</p>
""")
def _render_timeline(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
# Find date range
dates = []
page_dates = []
for p in pages:
props = json.loads(p.get("property_values_json", "{}"))
start_val = end_val = None
for _k, v in props.items():
if isinstance(v, str) and v.startswith("20"):
if "..." in v:
parts = v.split("...")
start_val, end_val = parts[0][:10], parts[1][:10] if len(parts) > 1 else parts[0][:10]
else:
start_val = end_val = v[:10]
break
if start_val:
dates.append(start_val)
if end_val:
dates.append(end_val)
page_dates.append((p, start_val, end_val or start_val))
if not dates:
return _base_html(collection["name"], collection.get("icon", "📈"), view_type,
"<p class='desc'>No date data to display timeline.</p>")
from datetime import date as dt_date
min_date = min(dt_date.fromisoformat(d) for d in dates)
max_date = max(dt_date.fromisoformat(d) for d in dates)
total = (max_date - min_date).days or 1
bars = ""
for p, start, end in page_dates:
sd = dt_date.fromisoformat(start)
ed = dt_date.fromisoformat(end)
left = (sd - min_date).days / total * 100
width = max((ed - sd).days / total * 100, 1)
bars += f"""<div class='tl-row'>
<span class='tl-label'>{p.get('icon','📄')} {p['title']}</span>
<div class='tl-track'>
<div class='tl-bar' style='left:{left:.1f}%;width:{width:.1f}%' title='{start} → {end}'></div>
</div>
</div>"""
return _base_html(collection["name"], collection.get("icon", "📈"), view_type, f"""
<style>
.tl-row{{display:flex;align-items:center;margin-bottom:8px;gap:12px}}
.tl-label{{width:160px;font-size:13px;text-align:right;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
.tl-track{{flex:1;height:28px;background:#222;border-radius:4px;position:relative}}
.tl-bar{{position:absolute;top:4px;height:20px;background:#3366CC;border-radius:4px;min-width:4px}}
</style>
<div class="tl-header" style="display:flex;margin-bottom:16px;padding-left:172px">
<span style="flex:1;font-size:11px;color:#A0A0A0">{min_date}</span>
<span style="font-size:11px;color:#A0A0A0">{max_date}</span>
</div>
{bars}
<p class="desc">{len(pages)} items · {min_date} → {max_date}</p>
""")
# ── v4.3.0: New view types ──
# Multi-collection dashboards and chart aggregations cap the number of
# input rows/groups at 200 (keeps the rendered HTML and export reasonable).
def _chart_values(pages: list[dict], chart_property: str) -> list[float]:
"""Numeric values of ``chart_property`` across ``pages`` (cap 200)."""
values: list[float] = []
for p in pages[:CHART_MAX_GROUPS]:
props = json.loads(p.get("property_values_json", "{}") or "{}")
v = props.get(chart_property)
if v is None or v == "":
continue
try:
values.append(float(v))
except (ValueError, TypeError):
continue
return values
def _chart_aggregate(pages: list[dict], chart_property: str, aggregate: str) -> float:
"""Compute count|sum|avg|min|max over a property (or row count)."""
values = _chart_values(pages, chart_property)
if aggregate == "count":
return float(len(pages[:CHART_MAX_GROUPS]))
if not values:
return 0.0
if aggregate == "sum":
return float(sum(values))
if aggregate == "avg":
return float(sum(values) / len(values))
if aggregate == "min":
return float(min(values))
if aggregate == "max":
return float(max(values))
return 0.0
def _fmt_number(value: float) -> str:
if abs(value) >= 1e9:
return f"{value / 1e9:.2f}B"
if abs(value) >= 1e6:
return f"{value / 1e6:.2f}M"
if abs(value) >= 1e3:
return f"{value / 1e3:.1f}K"
if value == int(value):
return str(int(value))
return f"{value:.2f}"
def _render_chart(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
"""Chart view — bar, line, pie, doughnut, scatter via Chart.js CDN, plus
the v7.3.0 ``number`` KPI widget (count|sum|avg|min|max aggregate)."""
chart_type = config.get("chart_type", "bar")
chart_property = config.get("chart_property", "")
if chart_type == "number":
aggregate = config.get("aggregate", "sum" if chart_property else "count")
if aggregate not in ("count", "sum", "avg", "min", "max"):
aggregate = "sum" if chart_property else "count"
num = _chart_aggregate(pages, chart_property, aggregate)
label = config.get("title") or chart_property or collection["name"]
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
<style>
.kpi{{max-width:420px;margin:60px auto;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.12);border-radius:14px;padding:36px;text-align:center}}
.kpi-label{{font-size:13px;text-transform:uppercase;letter-spacing:1.2px;opacity:.6;margin-bottom:10px}}
.kpi-value{{font-size:64px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums}}
.kpi-agg{{font-size:12px;color:var(--text-dim);margin-top:12px}}
</style>
<div class="kpi">
<div class="kpi-label">{label}</div>
<div class="kpi-value">{_fmt_number(num)}</div>
<div class="kpi-agg">{aggregate} · {len(pages[:CHART_MAX_GROUPS])} row(s)
{' of ' + chart_property if chart_property else ''}</div>
</div>
""")
labels = []
values = []
for p in pages[:CHART_MAX_GROUPS]:
labels.append(str(p.get("title") or "")[:30])
props = json.loads(p.get("property_values_json", "{}") or "{}")
val = 0.0
if chart_property:
v_raw = props.get(chart_property, 0)
try:
val = float(v_raw) if v_raw else 0.0
except (ValueError, TypeError):
val = 0.0
values.append(val)
labels_json = json.dumps(labels)
values_json = json.dumps(values)
subtitle = (f"{len(pages[:CHART_MAX_GROUPS])} entries"
+ (f" (truncated at {CHART_MAX_GROUPS})" if len(pages) > CHART_MAX_GROUPS else ""))
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
<style>
.chart-container{{max-width:800px;margin:0 auto}}
canvas{{max-height:400px}}
</style>
<div class="chart-container"><canvas id="chartCanvas"></canvas></div>
<script src="/static/js/vendor/chart.umd.js"></script>
<script nonce="{CSP_NONCE.get()}">
new Chart(document.getElementById('chartCanvas'), {{
type: '{chart_type}',
data: {{
labels: {labels_json},
datasets: [{{
label: '{config.get("title") or collection["name"]}',
data: {values_json},
backgroundColor: ['#3366CC','#DC3912','#FF9900','#109618','#990099','#0099C6','#DD4477','#66AA00'],
}}]
}},
options: {{ responsive: true }}
}});
</script>
<p class="desc">{subtitle}</p>
""")
def _render_form(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
"""Form view — generates an HTML form that creates new pages in the collection."""
properties = []
with get_conn() as conn:
props = conn.execute(
"SELECT name, prop_type, options_json FROM collection_properties WHERE collection_id=? AND prop_type!='formula' ORDER BY position",
(collection["id"],),
).fetchall()
for p in props:
prop_dict = dict(p)
prop_dict["options"] = json.loads(prop_dict.get("options_json", "[]"))
properties.append(prop_dict)
fields = ""
for prop in properties:
name = prop["name"]
ptype = prop["prop_type"]
if ptype in ("text", "email", "url", "phone", "number"):
fields += f"""<div class='form-field'><label>{name}</label><input type='{"number" if ptype=="number" else "text"}' name='prop_{name}' placeholder='{name}'></div>"""
elif ptype in ("select", "status"):
options = "".join(f"<option value='{o}'>{o}</option>" for o in prop.get("options", []))
fields += f"""<div class='form-field'><label>{name}</label><select name='prop_{name}'>{options}</select></div>"""
elif ptype == "checkbox":
fields += f"""<div class='form-field'><label><input type='checkbox' name='prop_{name}' value='1'> {name}</label></div>"""
elif ptype == "date":
fields += f"""<div class='form-field'><label>{name}</label><input type='date' name='prop_{name}'></div>"""
return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""
<style>
.form-field{{margin-bottom:12px}}
.form-field label{{display:block;font-size:13px;color:#A0A0A0;margin-bottom:4px}}
.form-field input,.form-field select{{width:100%;max-width:400px;padding:8px;background:#333;border:1px solid #555;border-radius:6px;color:#fff;font-size:14px}}
.form-submit{{padding:8px 20px;background:#3366CC;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px;margin-top:8px}}
.form-submit:hover{{background:#254E99}}
</style>
<div class="form-container">
<h3>New entry in {collection['name']}</h3>
<form id="collectionForm" onsubmit="submitForm(event)">
{fields}
<div class='form-field'><label>Title</label><input type='text' name='title' placeholder='Page title' required></div>
<button type='submit' class='form-submit'>Submit</button>
</form>
<div id="formResult" style="margin-top:12px;color:#4CAF50;display:none">✓ Created successfully!</div>
</div>
<script nonce="{CSP_NONCE.get()}">
async function submitForm(e) {{
e.preventDefault();
const form = document.getElementById('collectionForm');
const fd = new FormData(form);
const properties = {{}};
const title = fd.get('title') || 'New entry';
fd.forEach((v,k) => {{ if(k.startsWith('prop_')) properties[k.slice(5)] = v; }});
const resp = await fetch('/db/{collection["id"]}/pages/api', {{
method:'POST', headers:{{'Content-Type':'application/json'}},
body: JSON.stringify({{title, properties}})
}});
if(resp.ok) {{
document.getElementById('formResult').style.display='block';
form.reset();
}}
}}
</script>
""")
def _render_map(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
"""Map view — displays pages with location data on Leaflet map."""
markers = []
for p in pages:
props = json.loads(p.get("property_values_json", "{}"))
lat, lng = None, None
for _k, v in props.items():
if isinstance(v, str) and "," in v:
parts = v.split(",")
try:
lat, lng = float(parts[0].strip()), float(parts[1].strip())
except ValueError:
continue
elif isinstance(v, dict):
lat = v.get("lat")
lng = v.get("lng")
if lat and lng:
markers.append({"title": p["title"], "lat": lat, "lng": lng})
markers_json = json.dumps(markers)
center_lat = markers[0]["lat"] if markers else 45.5
center_lng = markers[0]["lng"] if markers else -73.5
return _base_html(collection["name"], collection.get("icon", "🗺️"), view_type, f"""
<style>
#map{{height:400px;border-radius:8px}}
</style>
<link rel="stylesheet" href="/static/js/vendor/leaflet.css" />
<div id="map"></div>
<script src="/static/js/vendor/leaflet.js"></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};
markers.forEach(m => L.marker([m.lat, m.lng]).addTo(map).bindPopup(m.title));
if(markers.length===0) L.marker([{center_lat},{center_lng}]).addTo(map).bindPopup('Default');
</script>
<p class="desc">{len(markers)} location(s) mapped</p>
""")
def _render_feed(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
"""Feed view — chronological feed of pages, newest first."""
sorted_pages = sorted(pages, key=lambda p: p.get("created_at", ""), reverse=True)
items = ""
for p in sorted_pages:
created = p.get("created_at", "")[:10] if p.get("created_at") else ""
items += f"""<div class='feed-item'>
<div class='feed-meta'>{created}</div>
<div class='feed-title'>{p.get('icon','📄')} {p['title']}</div>
</div>"""
return _base_html(collection["name"], collection.get("icon", "📰"), view_type, f"""
<style>
.feed-item{{padding:12px 16px;border-left:2px solid #333;margin-bottom:8px;background:#1a1a1a;border-radius:0 8px 8px 0}}
.feed-item:hover{{border-left-color:#3366CC}}
.feed-meta{{font-size:11px;color:#A0A0A0;margin-bottom:4px}}
.feed-title{{font-size:14px;font-weight:500}}
</style>
{items}
<p class="desc">{len(sorted_pages)} entries</p>
""")
def _render_gantt(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
"""Gantt view — timeline with dependencies and group_by support."""
group_by = config.get("group_by", "")
gantt_data = []
for p in pages:
props = json.loads(p.get("property_values_json", "{}"))
group = None
start_val = end_val = None
for _k, v in props.items():
if isinstance(v, str) and v.startswith("20"):
if "→" in v:
parts = v.split("→")
start_val, end_val = parts[0].strip()[:10], parts[1].strip()[:10] if len(parts) > 1 else parts[0].strip()[:10]
elif "..." in v:
parts = v.split("...")
start_val, end_val = parts[0][:10], parts[1][:10] if len(parts) > 1 else parts[0][:10]
else:
start_val = end_val = v[:10]
break
if group_by:
group = str(props.get(group_by, props.get("Status", "")))[:20]
if start_val:
gantt_data.append({"title": p["title"], "start": start_val, "end": end_val or start_val, "group": group or ""})
if not gantt_data:
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, "<p class='desc'>No date data for Gantt chart.</p>")
from datetime import date as dt_date_2
all_dates = [d["start"] for d in gantt_data] + [d["end"] for d in gantt_data]
min_date = min(dt_date_2.fromisoformat(d) for d in all_dates)
max_date = max(dt_date_2.fromisoformat(d) for d in all_dates)
total_days = max((max_date - min_date).days, 1)
groups = {}
for d in gantt_data:
groups.setdefault(d["group"], []).append(d)
if not groups or all(k == "" for k in groups):
groups = {"": gantt_data}
rows = ""
for group_name, items in sorted(groups.items()):
if group_name:
rows += f"<div class='gantt-group'>{group_name} ({len(items)})</div>"
for item in items:
sd = dt_date_2.fromisoformat(item["start"])
ed = dt_date_2.fromisoformat(item["end"])
left = max((sd - min_date).days / total_days * 100, 0)
width = max((ed - sd).days / total_days * 100, 1)
rows += f"""<div class='gantt-row'>
<span class='gantt-label'>{item['title'][:30]}</span>
<div class='gantt-track'><div class='gantt-bar' style='left:{left:.1f}%;width:{width:.1f}%' title='{item["start"]} → {item["end"]}'></div></div>
<span class='gantt-dates'>{item['start']} → {item['end']}</span>
</div>"""
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
<style>
.gantt-group{{padding:8px 12px;background:#222;font-size:13px;font-weight:600;margin:8px 0 4px;border-radius:4px}}
.gantt-row{{display:flex;align-items:center;margin-bottom:6px;gap:8px}}
.gantt-label{{width:180px;font-size:12px;text-align:right;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
.gantt-track{{flex:1;height:24px;background:#222;border-radius:4px;position:relative}}
.gantt-bar{{position:absolute;top:3px;height:18px;background:linear-gradient(90deg,#3366CC,#5599EE);border-radius:4px;min-width:4px}}
.gantt-dates{{font-size:10px;color:#A0A0A0;flex-shrink:0;min-width:140px}}
</style>
<div class="gantt-header" style="display:flex;margin-bottom:8px;padding-left:188px">
<span style="flex:1;font-size:10px;color:#A0A0A0">{min_date}</span>
<span style="font-size:10px;color:#A0A0A0">{max_date}</span>
</div>
{rows}
<p class="desc">{len(gantt_data)} items · {min_date} → {max_date}</p>
""")
def _render_table(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
rows = ""
for p in pages:
props = json.loads(p.get("property_values_json", "{}"))
prop_cells = "".join(f"<td>{str(v)[:80]}</td>" for v in list(props.values())[:4])
rows += f"<tr><td>{p.get('icon','📄')}</td><td>{p['title']}</td>{prop_cells}</tr>"
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
<style>
table{{width:100%;border-collapse:collapse}}
th,td{{padding:8px 12px;text-align:left;font-size:13px;border-bottom:1px solid #333}}
th{{color:#A0A0A0;font-weight:500;background:#1a1a1a;position:sticky;top:0}}
tr:hover td{{background:#222}}
</style>
<table><thead><tr><th></th><th>Title</th><th>Properties</th></tr></thead><tbody>{rows}</tbody></table>
<p class="desc">{len(pages)} rows</p>
""")
+61
View File
@@ -0,0 +1,61 @@
"""FlowDeck — Collections : boards.
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collections"], prefix="/db")
# ── Gitea Board Compatibility (BEFORE {collection_id} wildcards) ──
@router.get("/boards/api")
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()
return {"boards": boards}
@router.get("/board/{owner}/{repo}/api")
async def get_board_as_collection(request: Request, owner: str, repo: str):
"""API: get a specific Gitea board as a pseudo-collection."""
from app.services.collection_adapter import GiteaBoardCompat
coll = GiteaBoardCompat.get_board_as_collection(owner, repo)
if not coll:
raise HTTPException(status_code=404, detail="Board not found")
from app.services.gitea_client import gitea
issues = await gitea.get_issues(owner, repo, state="all")
cards = GiteaBoardCompat.get_board_cards(owner, repo, issues)
return {"collection": coll, "pages": cards}
@router.post("/board/{owner}/{repo}/sync")
async def sync_board_to_collection(request: Request, owner: str, repo: str):
"""Sync a Gitea board to a real collection."""
from app.services.collection_adapter import GiteaBoardCompat
from app.services.gitea_client import gitea
issues = await gitea.get_issues(owner, repo, state="all")
coll_id = GiteaBoardCompat.sync_to_collection(owner, repo, issues)
if coll_id is None:
raise HTTPException(status_code=404, detail="Board not found")
return {"collection_id": coll_id, "status": "synced"}
# ── Collection Properties (v1.4.0) ──
+337
View File
@@ -0,0 +1,337 @@
"""FlowDeck — Collections : crud.
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import HTMLResponse
from app.db import get_conn
from app.services.automations import fire_event, run_event_sync
from app.services.db_templates import materialize_properties
from app.services.permission_manager import PermissionManager
from ._common import _apply_template, _require_view, _session_user
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collections"], prefix="/db")
# ── API: List & Create (no path params) ──
@router.get("", response_class=HTMLResponse)
def list_collections(request: Request):
"""Page listing all collections in the workspace."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM collections ORDER BY name"
).fetchall()
collections = [dict(r) for r in rows]
return HTMLResponse(
f"<div class='collections-list'>"
f"<h2>Collections ({len(collections)})</h2>"
f"<pre>{json.dumps(collections, indent=2, default=str)}</pre>"
f"</div>"
)
@router.get("/api")
def list_collections_api(request: Request):
"""API: list all collections."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM collections ORDER BY name"
).fetchall()
return {"collections": [dict(r) for r in rows]}
@router.post("/api")
def create_collection_api(request: Request, body: dict = Body(default={})):
"""API: create a new collection, optionally from a database template."""
name = body.get("name", "").strip()
if not name:
raise HTTPException(status_code=400, detail="name is required")
description = body.get("description", "")
icon = body.get("icon", "📋")
gitea_owner = body.get("gitea_owner")
gitea_repo = body.get("gitea_repo")
schema = body.get("schema", [])
is_locked = body.get("is_locked", False)
with get_conn() as conn:
# Apply a template if requested (provides schema + icon).
tpl = _apply_template(conn, body.get("template"))
if tpl:
if body.get("name"):
name = body["name"].strip()
description = tpl["description"]
icon = tpl.get("icon") or icon
try:
schema = json.loads(tpl["schema_json"])
except (json.JSONDecodeError, TypeError):
schema = []
schema_json = json.dumps(schema)
cur = conn.execute(
"""INSERT INTO collections
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(name, description, icon, schema_json, gitea_owner, gitea_repo, int(is_locked)),
)
collection_id = cur.lastrowid
materialize_properties(conn, collection_id, schema)
conn.execute(
"""INSERT INTO collection_views
(collection_id, name, view_type, config_json)
VALUES (?, ?, ?, ?)""",
(collection_id, "Default View", "table", json.dumps({
"visible_properties": ["Title"],
"sorts": [],
"filters": [],
})),
)
conn.commit()
run_event_sync(fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon}))
return {"id": collection_id, "name": name, "status": "created"}
# ── API: Update & Delete (no path-param conflicts) ──
# ── API: Update & Delete (no path-param conflicts) ──
@router.put("/api/{collection_id}")
def update_collection_api(request: Request, collection_id: int, body: dict = Body(default={})):
"""API: update a collection."""
with get_conn() as conn:
existing = conn.execute(
"SELECT * FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Collection not found")
name = body.get("name", existing["name"])
description = body.get("description", existing["description"])
icon = body.get("icon", existing["icon"])
is_locked = body.get("is_locked", existing["is_locked"])
schema_json = json.dumps(body.get("schema", json.loads(existing["schema_json"])))
gitea_owner = body.get("gitea_owner", existing["gitea_owner"])
gitea_repo = body.get("gitea_repo", existing["gitea_repo"])
conn.execute(
"""UPDATE collections SET name=?, description=?, icon=?, schema_json=?,
is_locked=?, gitea_owner=?, gitea_repo=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?""",
(name, description, icon, schema_json, int(is_locked),
gitea_owner, gitea_repo, collection_id),
)
conn.commit()
run_event_sync(fire_event("collection.updated", {"collection_id": collection_id, "name": name}))
return {"id": collection_id, "status": "updated"}
@router.delete("/api/{collection_id}")
def delete_collection_api(request: Request, collection_id: int):
"""API: delete a collection and its pages (CASCADE)."""
# v6.0.0: granular collection permissions — owner/admin only.
user = _session_user(request)
_require_view(collection_id, user)
if user:
pm = PermissionManager(user["id"])
if not pm.can_manage_collection_permissions(collection_id):
raise HTTPException(status_code=403, detail="Only a collection owner can delete it")
with get_conn() as conn:
existing = conn.execute(
"SELECT * FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Collection not found")
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
conn.commit()
run_event_sync(fire_event("collection.deleted", {"collection_id": collection_id,
"name": existing["name"] if existing else ""}))
return {"id": collection_id, "status": "deleted"}
@router.post("/{collection_id}/duplicate")
def duplicate_collection_api(request: Request, collection_id: int):
"""v5.4.0: deep-duplicate a database (views + properties + pages + data
sources) into a new collection named '<original> (copy)'."""
with get_conn() as conn:
src = conn.execute(
"SELECT * FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not src:
raise HTTPException(status_code=404, detail="Collection not found")
new_name = (src["name"] or "Database") + " copy"
cur = conn.execute(
"""INSERT INTO collections
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked,
is_inline, parent_page_id, workspace_id, created_by, is_task, updated_at)
SELECT ?, description, icon, schema_json, gitea_owner, gitea_repo, is_locked,
is_inline, parent_page_id, workspace_id, created_by, is_task, updated_at
FROM collections WHERE id=?""",
(new_name, collection_id),
)
new_id = cur.lastrowid
# ── Properties (remap ids so relation/rollup refs stay valid) ──
prop_map: dict[int, int] = {}
rows = conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
# A23 : un seul executemany ; les rowid sont contigus (même transaction,
# insertion dans l'ordre de `rows`), donc le mappeur se fait par index.
tuples = [
(new_id, p["name"], p["prop_type"], p["options_json"], p["number_format"],
None, p["reverse_name"], None, None, p["rollup_function"],
p["formula_expression"], p["position"], p["required"],
p["visible_in_views"])
for p in rows
]
if tuples:
ncur = conn.executemany(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, options_json, number_format,
related_collection_id, reverse_name, relation_property_id,
target_property_id, rollup_function, formula_expression,
position, required, visible_in_views)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
tuples,
)
new_ids = [
r["id"]
for r in conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? ORDER BY id",
(new_id,),
).fetchall()
]
assert len(new_ids) == len(tuples), "remap des propriétés : effectif inattendu"
for p, new_pid in zip(rows, new_ids, strict=True):
prop_map[p["id"]] = new_pid
# Fix cross-property references after all rows exist (creates may target
# columns not inserted yet). Related collection remapped to the copy.
for p in rows:
p = dict(p) # convert sqlite3.Row to dict
new_pid = prop_map[p["id"]]
related = p["related_collection_id"]
related_new = new_id if related == collection_id else related
if p["prop_type"] == "relation":
conn.execute(
"UPDATE collection_properties SET related_collection_id=? WHERE id=?",
(related_new, new_pid),
)
if p.get("relation_property_id") and p["relation_property_id"] in prop_map:
conn.execute(
"UPDATE collection_properties SET relation_property_id=? WHERE id=?",
(prop_map[p["relation_property_id"]], new_pid),
)
if p.get("target_property_id") and p["target_property_id"] in prop_map:
conn.execute(
"UPDATE collection_properties SET target_property_id=? WHERE id=?",
(prop_map[p["target_property_id"]], new_pid),
)
# ── Views ──
vrows = conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
for v in vrows:
conn.execute(
"""INSERT INTO collection_views
(collection_id, name, view_type, config_json, position)
VALUES (?,?,?,?,?)""",
(new_id, v["name"], v["view_type"], v["config_json"], v["position"]),
)
# ── Pages (rows) with property ids remapped to the copy's properties ──
prows = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
page_map: dict[int, int] = {}
for p in prows:
try:
pv = json.loads(p["property_values_json"]) if p["property_values_json"] else {}
except (json.JSONDecodeError, TypeError):
pv = {}
pv_new = {}
for k, val in pv.items():
try:
prop_id = int(k)
except (ValueError, TypeError):
prop_id = None
new_key = str(prop_map.get(prop_id, prop_id)) if prop_id is not None else k
pv_new[new_key] = val
ncur = conn.execute(
"""INSERT INTO collection_pages
(collection_id, title, icon, position, parent_id, gitea_issue_id,
gitea_issue_number, property_values_json, created_at, updated_at)
SELECT ?, title, icon, position, NULL, NULL, NULL, ?, created_at, updated_at
FROM collection_pages WHERE id=?""",
(new_id, json.dumps(pv_new), p["id"]),
)
page_map[p["id"]] = ncur.lastrowid
# Re-parent sub-items to the copied rows.
for p in prows:
if p["parent_id"] and p["parent_id"] in page_map:
conn.execute(
"UPDATE collection_pages SET parent_id=? WHERE id=?",
(page_map[p["parent_id"]], page_map[p["id"]]),
)
# ── Data sources (linked DBs) ──
drows = conn.execute(
"SELECT * FROM collection_data_sources WHERE collection_id=?",
(collection_id,),
).fetchall()
for d in drows:
src_coll = d["source_collection_id"]
src_now = new_id if src_coll == collection_id else src_coll
conn.execute(
"""INSERT INTO collection_data_sources
(collection_id, source_collection_id, source_name, is_linked, position)
VALUES (?,?,?,?,?)""",
(new_id, src_now, d["source_name"], d["is_linked"], d["position"]),
)
conn.commit()
run_event_sync(fire_event("collection.created", {"collection_id": new_id, "name": new_name}))
return {"id": new_id, "name": new_name, "status": "duplicated"}
# ── Page CRUD (standalone, BEFORE collection wildcards) ──
+214
View File
@@ -0,0 +1,214 @@
"""FlowDeck — Collections : dashboard_views.
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import html as _htmlmod
import json
import logging
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import HTMLResponse
from app.db import get_conn
from ._common import _require_view, _session_user
from ._renderers import CHART_MAX_GROUPS, _base_html, _render_chart, _render_view
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collections"], prefix="/db")
@router.post("/{collection_id}/pages/{page_id}/auto-shift/api")
def auto_shift_dates(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
"""API: auto-shift dates based on blocking dependencies."""
from datetime import date as dt_date
from datetime import timedelta
skip_weekends = body.get("skip_weekends", False)
with get_conn() as conn:
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(status_code=404, detail="Page not found")
# Get all blocking dependencies
deps = conn.execute(
"SELECT * FROM page_dependencies WHERE page_id=? AND dependency_type='blocks'",
(page_id,),
).fetchall()
shifted = False
new_start = None
for dep in deps:
dep_page = conn.execute(
"SELECT title, property_values_json FROM collection_pages WHERE id=?",
(dep["dependency_id"],),
).fetchone()
if not dep_page:
continue
dep_props = json.loads(dep_page["property_values_json"])
# Find the latest end date among blockers
for v in dep_props.values():
if isinstance(v, str) and v.startswith("20"):
end_date = v.split("...")[-1].split("→")[-1].strip()[:10]
try:
ed = dt_date.fromisoformat(end_date)
if new_start is None or ed >= new_start:
new_start = ed + timedelta(days=1)
shifted = True
except ValueError:
continue
if not shifted:
return {"page_id": page_id, "shifted": False, "message": "No blocking dependencies with dates found"}
# Skip weekends if requested
if skip_weekends and new_start:
while new_start.weekday() >= 5: # 5=Sat, 6=Sun
new_start = new_start + timedelta(days=1)
# Update the page's date properties
props = json.loads(page["property_values_json"])
for k, v in list(props.items()):
if isinstance(v, str) and v.startswith("20"):
old_parts = v.split("...")
old_end = old_parts[-1] if len(old_parts) > 1 else old_parts[0]
try:
old_start_d = dt_date.fromisoformat(old_parts[0][:10])
old_end_d = dt_date.fromisoformat(old_end[:10])
duration = (old_end_d - old_start_d).days
new_end = new_start + timedelta(days=max(duration, 0))
props[k] = f"{new_start.isoformat()}...{new_end.isoformat()}"
except ValueError:
props[k] = new_start.isoformat()
break
conn.execute(
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(props), page_id),
)
conn.commit()
return {"page_id": page_id, "shifted": True, "new_start": new_start.isoformat(), "skip_weekends": skip_weekends}
# ── {collection_id} wildcards (LAST — catches everything else) ──
# ── {collection_id} wildcards (LAST — catches everything else) ──
@router.get("/{collection_id}/dashboards/{dashboard_id}", response_class=HTMLResponse)
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
``{"columns": N, "widgets": [{collection_id?, view_type?, chart_type?,
chart_property?, aggregate?, title?, width?, height?}]}``. Each widget may
point at *any* database (the dashboard's own collection is the default),
which is what "dashboards multi-DB" means.
"""
uid = _session_user(request)
_require_view(collection_id, uid)
with get_conn() as conn:
dash = conn.execute(
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?",
(dashboard_id, collection_id)).fetchone()
if not dash:
raise HTTPException(404, "Dashboard not found")
layout = json.loads(dash["layout_json"] or "{}")
columns = max(1, int(layout.get("columns", 1) or 1))
widgets = layout.get("widgets", []) or []
if not isinstance(widgets, list):
widgets = []
rendered = []
for w in widgets[:40]:
if not isinstance(w, dict):
continue
wc = int(w.get("collection_id") or 0) or collection_id
with get_conn() as conn:
coll = conn.execute("SELECT * FROM collections WHERE id=?", (wc,)).fetchone()
if not coll:
continue
try:
_require_view(wc, uid)
except HTTPException:
continue # restricted database → widget skipped, not rendered
with get_conn() as conn:
wpages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position LIMIT ?",
(wc, CHART_MAX_GROUPS)).fetchall()
wconfig = {k: v for k, v in w.items()
if k in ("chart_type", "chart_property", "aggregate", "title")}
view_type = w.get("view_type") or "chart"
if view_type == "chart":
body = _render_chart(view_type, dict(coll), [dict(p) for p in wpages], wconfig)
else:
body = _render_view(view_type, dict(coll), [dict(p) for p in wpages], wconfig)
width = int(w.get("width") or 0)
span = f"grid-column: span {width};" if width and width > 0 else ""
rendered.append(f'<div class="dash-widget" style="{span}">{body}</div>')
grid_css = f"grid-template-columns: repeat({columns}, minmax(0, 1fr));"
body = f"""
<style>
.dash-grid{{display:grid;{grid_css} gap:16px;max-width:1200px;margin:0 auto;padding:24px}}
.dash-widget{{background:rgba(255,255,255,.02);border:1px solid rgba(255,255,255,.08);border-radius:12px;overflow:hidden}}
.dash-widget .desc{{color:var(--text-dim);font-size:12px;padding:8px 16px 16px}}
</style>
<h1 style="max-width:1200px;margin:24px auto 0;padding:0 24px;font-size:22px;">{_htmlmod.escape(dash['name'])}</h1>
<div class="dash-grid">{''.join(rendered) if rendered else '<p style="color:var(--text-dim);padding:20px;">Empty dashboard — add widgets to <code>layout_json</code>.</p>'}</div>
"""
return HTMLResponse(_base_html(dash["name"], "📊", "dashboard", body))
@router.get("/{collection_id}", response_class=HTMLResponse)
@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
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))
with get_conn() as conn:
collection = conn.execute(
"SELECT * FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
view = conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? AND view_type=? ORDER BY position LIMIT 1",
(collection_id, view_type),
).fetchone()
if not view:
view = conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position LIMIT 1",
(collection_id,),
).fetchone()
pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
collection_dict = dict(collection)
pages_list = [dict(p) for p in pages]
config = json.loads(view["config_json"]) if view else {}
if "year" in request.query_params:
config["year"] = int(request.query_params["year"])
if "month" in request.query_params:
config["month"] = int(request.query_params["month"])
return HTMLResponse(_render_view(view_type, collection_dict, pages_list, config))
# ── View renderers (v1.6.0) ──
+122
View File
@@ -0,0 +1,122 @@
"""FlowDeck — Collections : data_api.
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Body, HTTPException, Request
from app.db import get_conn
from app.services.automations import fire_event, run_event_sync
from app.services.property_types import (
apply_auto_properties,
)
from ._common import (
_collection_properties,
_current_user,
_require_edit,
_require_view,
_session_user,
_validate_meta_keys,
_validate_page_properties,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collections"], prefix="/db")
@router.get("/{collection_id}/api")
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))
with get_conn() as conn:
collection = conn.execute(
"SELECT * FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not collection:
raise HTTPException(status_code=404, detail="Collection not found")
pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
views = conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
return {
"collection": dict(collection),
"pages": [dict(p) for p in pages],
"views": [dict(v) for v in views],
}
@router.post("/{collection_id}/pages/api")
def create_page_api(request: Request, collection_id: int, body: dict = Body(default={})):
"""API: create a page in a collection."""
# v6.0.0: granular collection permissions — viewer/commenter cannot create.
_require_view(collection_id, _session_user(request))
_require_edit(collection_id, _session_user(request))
title = body.get("title", "").strip()
if not title:
raise HTTPException(status_code=400, detail="title is required")
icon = body.get("icon", "📄")
property_values = body.get("properties", {})
cover_url = body.get("cover_url", "")
gitea_issue_id = body.get("gitea_issue_id")
gitea_issue_number = body.get("gitea_issue_number")
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
_validate_page_properties(conn, collection_id, property_values)
_validate_meta_keys(conn, collection_id, property_values)
apply_auto_properties(
_collection_properties(conn, collection_id),
property_values,
_current_user(request),
is_create=True,
)
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
(collection_id,),
).fetchone()[0]
cur = conn.execute(
"""INSERT INTO collection_pages
(collection_id, title, icon, cover_url, position, gitea_issue_id, gitea_issue_number, property_values_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(collection_id, title, icon, cover_url, max_pos, gitea_issue_id, gitea_issue_number,
json.dumps(property_values)),
)
conn.commit()
page_id = cur.lastrowid
run_event_sync(fire_event("page.created", {
"page_id": page_id,
"collection_id": collection_id,
"title": title,
"icon": icon,
"properties": property_values,
}))
run_event_sync(fire_event("collection.page.created", {
"page_id": page_id,
"collection_id": collection_id,
"title": title,
}))
return {"id": page_id, "title": title, "status": "created"}
+286
View File
@@ -0,0 +1,286 @@
"""FlowDeck — Collections : linked.
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import json
import logging
import sqlite3
from fastapi import APIRouter, Body, HTTPException, Request
from app.db import get_conn
from app.services.db_templates import materialize_properties
from ._common import _apply_template
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collections"], prefix="/db")
@router.post("/{collection_id}/linked/api")
def create_linked_database(request: Request, collection_id: int, body: dict = Body(default={})):
"""API: create a linked database view from a source collection.
A linked database copies the structure (views, filters, sorts) of a source
but shares the same pages — edits to pages propagate to the source.
"""
name = body.get("name", "").strip()
body.get("workspace_id")
with get_conn() as conn:
source = conn.execute(
"SELECT * FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not source:
raise HTTPException(status_code=404, detail="Source collection not found")
if not name:
name = f"{source['name']} (linked)"
# Create the linked collection (shallow copy of structure)
# Inherit workspace_id from source for permission inheritance
src_dict = dict(source)
source_workspace_id = src_dict.get("workspace_id")
cur = conn.execute(
"""INSERT INTO collections
(name, description, icon, schema_json, is_locked, is_inline, parent_page_id, workspace_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(
name,
src_dict["description"],
src_dict["icon"],
src_dict["schema_json"],
0, # linked DB is never locked
1, # linked DB starts as inline
src_dict.get("parent_page_id"),
source_workspace_id, # linked DB inherits source workspace permissions
),
)
linked_id = cur.lastrowid
# Copy views from source
views = conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
for v in views:
conn.execute(
"INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)",
(linked_id, v["name"], v["view_type"], v["config_json"], v["position"]),
)
# Add the source as a data source with is_linked=1
conn.execute(
"""INSERT INTO collection_data_sources
(collection_id, source_collection_id, source_name, is_linked, position)
VALUES (?, ?, ?, 1, 0)""",
(linked_id, collection_id, source["name"]),
)
# Copy properties from source
props = conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
for p in props:
conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, options_json, number_format,
related_collection_id, reverse_name, relation_property_id,
target_property_id, rollup_function, formula_expression,
position, required, visible_in_views)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
linked_id, p["name"], p["prop_type"], p["options_json"],
p["number_format"], p["related_collection_id"], p["reverse_name"],
p["relation_property_id"], p["target_property_id"],
p["rollup_function"], p["formula_expression"],
p["position"], p["required"], p["visible_in_views"],
),
)
conn.commit()
return {
"linked_id": linked_id,
"name": name,
"source_collection_id": collection_id,
"status": "created",
}
@router.post("/{collection_id}/toggle-inline/api")
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(
"SELECT id, is_inline FROM collections WHERE id=?",
(collection_id,),
).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
new_inline = 0 if coll["is_inline"] else 1
conn.execute(
"UPDATE collections SET is_inline=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(new_inline, collection_id),
)
conn.commit()
return {
"collection_id": collection_id,
"is_inline": bool(new_inline),
"mode": "inline" if new_inline else "full-page",
}
@router.post("/inline/api")
def create_inline_database(request: Request, body: dict = Body(default={})):
"""API: create an inline database within a parent page (optionally from a template)."""
name = body.get("name", "").strip()
if not name:
raise HTTPException(status_code=400, detail="name is required")
description = body.get("description", "")
icon = body.get("icon", "📋")
parent_page_id = body.get("parent_page_id")
workspace_id = body.get("workspace_id")
schema = body.get("schema", [])
with get_conn() as conn:
tpl = _apply_template(conn, body.get("template"))
if tpl:
if body.get("name"):
name = body["name"].strip()
description = tpl["description"]
icon = tpl.get("icon") or icon
try:
schema = json.loads(tpl["schema_json"])
except (json.JSONDecodeError, TypeError):
schema = []
cur = conn.execute(
"""INSERT INTO collections
(name, description, icon, schema_json, is_inline, parent_page_id, workspace_id)
VALUES (?, ?, ?, ?, 1, ?, ?)""",
(name, description, icon, json.dumps(schema), parent_page_id, workspace_id),
)
collection_id = cur.lastrowid
materialize_properties(conn, collection_id, schema)
# Create default view
conn.execute(
"""INSERT INTO collection_views
(collection_id, name, view_type, config_json)
VALUES (?, ?, ?, ?)""",
(collection_id, "Default View", "table", json.dumps({
"visible_properties": ["Title"],
"sorts": [],
"filters": [],
})),
)
conn.commit()
return {
"id": collection_id,
"name": name,
"icon": icon,
"is_inline": True,
"parent_page_id": parent_page_id,
"status": "created",
}
# ── v4.4.0: Tasks & Dependencies ──
# ── v4.4.0: Tasks & Dependencies ──
@router.put("/{collection_id}/toggle-task/api")
def toggle_task(request: Request, collection_id: int):
"""API: toggle is_task flag on a collection (Turn into Tasks)."""
with get_conn() as conn:
coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
new_val = 0 if coll["is_task"] else 1
conn.execute("UPDATE collections SET is_task=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (new_val, collection_id))
conn.commit()
return {"collection_id": collection_id, "is_task": bool(new_val), "mode": "tasks" if new_val else "standard"}
@router.get("/{collection_id}/pages/{page_id}/dependencies/api")
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(
"SELECT * FROM page_dependencies WHERE page_id=? ORDER BY created_at",
(page_id,),
).fetchall()
deps = []
for r in rows:
d = dict(r)
dep_page = conn.execute(
"SELECT id, title FROM collection_pages WHERE id=?", (r["dependency_id"],)
).fetchone()
if dep_page:
d["dependency_title"] = dep_page["title"]
deps.append(d)
return {"dependencies": deps}
@router.post("/{collection_id}/pages/{page_id}/dependencies/api")
def add_page_dependency(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
"""API: add a dependency (blocks/blocked_by/related) between two pages."""
dependency_id = body.get("dependency_id")
if not dependency_id:
raise HTTPException(status_code=400, detail="dependency_id is required")
dep_type = body.get("dependency_type", "blocks")
auto_shift = body.get("auto_shift", "overlap")
with get_conn() as conn:
for pid in (page_id, dependency_id):
if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (pid,)).fetchone():
raise HTTPException(status_code=404, detail=f"Page {pid} not found")
try:
cur = conn.execute(
"INSERT INTO page_dependencies (page_id, dependency_id, dependency_type, auto_shift) VALUES (?,?,?,?)",
(page_id, dependency_id, dep_type, auto_shift),
)
conn.commit()
except sqlite3.IntegrityError:
raise HTTPException(status_code=409, detail="This dependency already exists") from None
return {"id": cur.lastrowid, "page_id": page_id, "dependency_id": dependency_id, "status": "added"}
@router.delete("/{collection_id}/pages/{page_id}/dependencies/{dep_id}/api")
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(
"SELECT * FROM page_dependencies WHERE id=? AND page_id=?", (dep_id, page_id)
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Dependency not found")
conn.execute("DELETE FROM page_dependencies WHERE id=?", (dep_id,))
conn.commit()
return {"id": dep_id, "status": "removed"}
+197
View File
@@ -0,0 +1,197 @@
"""FlowDeck — Collections : meta.
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, HTTPException, Request
from app.db import get_conn
from app.services.permission_manager import PermissionManager
from app.services.recurrence import (
RECURRENCE_KEY,
expand_rule,
parse_date,
)
from ._common import _collection_properties, _current_user, _require_view, _session_user
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collections"], prefix="/db")
# ── Collection Properties (v1.4.0) ──
@router.get("/property-types/api")
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")
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)
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
rows = conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
props = [dict(r) for r in rows]
# v6.0.0: property-level visibility — owners/editors see everything, other
# users only the properties explicitly granted or left open.
if user:
pm = PermissionManager(user["id"])
visible = pm.get_visible_properties(collection_id)
props = [p for p in props if p["id"] in visible]
return {"properties": props}
@router.get("/{collection_id}/members/api")
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
every active user for standalone databases without a workspace).
"""
with get_conn() as conn:
coll = conn.execute(
"SELECT workspace_id FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
ws_id = coll["workspace_id"] if "workspace_id" in coll.keys() else None
if ws_id:
rows = conn.execute(
"""SELECT u.id, u.login, u.full_name, u.avatar_url, u.avatar_color, wm.role
FROM workspace_members wm JOIN users u ON wm.user_id=u.id
WHERE wm.workspace_id=? AND u.is_active=1 ORDER BY u.full_name, u.login""",
(ws_id,),
).fetchall()
else:
rows = []
if not rows:
rows = conn.execute(
"""SELECT id, login, full_name, avatar_url, avatar_color, '' AS role
FROM users WHERE is_active=1 ORDER BY full_name, login"""
).fetchall()
return {"members": [dict(r) for r in rows]}
@router.get("/{collection_id}/calendar/api")
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].
Returns every occurrence (recurrence-aware, virtual — never persisted)
of the rows in the collection whose ``date_property`` falls inside the
inclusive window. Rows without a rule yield their base date.
"""
user = _current_user(request)
s = parse_date(start)
e = parse_date(end)
if s is None or e is None or s > e:
raise HTTPException(status_code=400, detail="start/end must be YYYY-MM-DD")
if (e - s).days > 370:
raise HTTPException(status_code=400, detail="window too large (max 370 days)")
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
props = _collection_properties(conn, collection_id)
date_props = [p for p in props if p["prop_type"] == "date"]
target = None
if date_property:
target = next((p for p in date_props
if str(p["id"]) == str(date_property) or p["name"] == date_property), None)
if target is None:
raise HTTPException(status_code=400, detail="Unknown date property")
elif date_props:
target = date_props[0]
if target is None:
return {"events": [], "timezone": "", "property": None}
urow = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone()
user_tz = (urow["timezone"] if urow and "timezone" in urow.keys() else "") or ""
rows = conn.execute(
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=?",
(collection_id,),
).fetchall()
pid = str(target["id"])
events: list[dict] = []
for r in rows:
try:
pv = json.loads(r["property_values_json"] or "{}")
except (json.JSONDecodeError, TypeError):
continue
base_value = pv.get(pid)
if base_value is None:
base_value = pv.get(target["name"])
if parse_date(base_value) is None:
continue
rec_all = pv.get(RECURRENCE_KEY) if isinstance(pv.get(RECURRENCE_KEY), dict) else {}
rule = rec_all.get(pid) or rec_all.get(target["name"])
row_tz = user_tz
if isinstance(rule, dict) and rule.get("timezone"):
row_tz = rule["timezone"]
tzmap = pv.get("__timezone__")
if isinstance(tzmap, dict):
ev_tz = tzmap.get(pid) or tzmap.get(target["name"])
if ev_tz:
row_tz = str(ev_tz)
if rule:
dates = expand_rule(base_value, rule, s, e, max_occurrences=500)
else:
d = parse_date(base_value)
dates = [d.isoformat()] if d and s <= d <= e else []
for iso in dates:
events.append({
"date": iso,
"page_id": r["id"],
"title": r["title"],
"icon": r["icon"],
"recurring": bool(rule),
"time": str(base_value)[11:16] if len(str(base_value)) >= 16 else "",
"timezone": row_tz,
})
events.sort(key=lambda ev: (ev["date"], ev["page_id"]))
return {"events": events, "timezone": user_tz, "property": {"id": target["id"], "name": target["name"]}}
@router.get("/timezones/api")
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:
row = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone()
from app.services.recurrence import common_timezones
return {
"timezone": (row["timezone"] if row and "timezone" in row.keys() else "") or "",
"zones": common_timezones(),
}
+184
View File
@@ -0,0 +1,184 @@
"""FlowDeck — Collections : pages.
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Body, HTTPException, Request
from app.db import get_conn
from app.services.automations import fire_event, run_event_sync
from app.services.property_types import (
apply_auto_properties,
)
from ._common import (
_collection_properties,
_current_user,
_require_edit,
_require_view,
_session_user,
_validate_meta_keys,
_validate_page_properties,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collections"], prefix="/db")
# ── Page CRUD (standalone, BEFORE collection wildcards) ──
@router.get("/pages/{page_id}/api")
def get_page_api(request: Request, page_id: int):
"""API: get a single page."""
with get_conn() as conn:
page = conn.execute(
"SELECT * FROM collection_pages WHERE id=?", (page_id,)
).fetchone()
if not page:
raise HTTPException(status_code=404, detail="Page not found")
# v6.0.0: granular collection/page permissions.
_require_view(page["collection_id"], _session_user(request))
return dict(page)
@router.get("/pages/{page_id}/open/api")
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
this endpoint: it returns the shadow ``pages`` id whose full page
editor carries the row's block content (synced blocks included).
"""
with get_conn() as conn:
row = conn.execute(
"SELECT collection_id FROM collection_pages WHERE id=?",
(page_id,),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Page not found")
coll_id = row["collection_id"]
# v6.0.0: granular collection permissions (same gate as the row itself).
_require_view(coll_id, _session_user(request))
from app.services.row_pages import ensure_row_page
try:
content_page_id = ensure_row_page(page_id)
except KeyError:
raise HTTPException(status_code=404, detail="Page not found") from None
return {"page_id": content_page_id, "row_id": page_id}
@router.put("/pages/{page_id}/api")
def update_page_api(request: Request, page_id: int, body: dict = Body(default={})):
"""API: update a page's properties."""
with get_conn() as conn:
existing = conn.execute(
"SELECT * FROM collection_pages WHERE id=?", (page_id,)
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Page not found")
# v6.0.0: granular collection/page permissions.
_require_edit(existing["collection_id"], _session_user(request))
title = body.get("title", existing["title"])
icon = body.get("icon", existing["icon"])
cover_url = body.get("cover_url", existing["cover_url"] if "cover_url" in existing.keys() else "")
position = body.get("position", existing["position"])
parent_id = body.get("parent_id", existing["parent_id"])
try:
stored = json.loads(existing["property_values_json"])
except (json.JSONDecodeError, TypeError):
stored = {}
if "properties" in body:
# Partial PATCH semantics: merge submitted values over stored ones.
props = dict(stored)
props.update(body["properties"])
else:
props = stored
_validate_page_properties(conn, existing["collection_id"], props, exclude_page_id=page_id)
_validate_meta_keys(conn, existing["collection_id"], props)
apply_auto_properties(
_collection_properties(conn, existing["collection_id"]),
props,
_current_user(request),
is_create=False,
)
property_values = json.dumps(props)
conn.execute(
"""UPDATE collection_pages
SET title=?, icon=?, cover_url=?, position=?, parent_id=?, property_values_json=?,
updated_at=CURRENT_TIMESTAMP
WHERE id=?""",
(title, icon, cover_url, position, parent_id, property_values, page_id),
)
# v6.5.0: keep the row's content page title in sync (row → page).
from app.services.row_pages import sync_row_title_to_page
sync_row_title_to_page(conn, page_id)
conn.commit()
run_event_sync(fire_event("page.updated", {
"page_id": page_id,
"collection_id": existing["collection_id"],
"title": title,
"icon": icon,
"properties": props,
}))
run_event_sync(fire_event("collection.page.updated", {
"page_id": page_id,
"collection_id": existing["collection_id"],
"title": title,
}))
# Notify newly assigned people (person properties) — v5.8.0.
from app.services.notifications import notify_assignment
user = _current_user(request)
notify_assignment(existing["collection_id"], page_id, title,
stored, props, user.get("id"))
return {"id": page_id, "status": "updated"}
@router.delete("/pages/{page_id}/api")
def delete_page_api(request: Request, page_id: int):
"""API: delete a page from its collection."""
with get_conn() as conn:
existing = conn.execute(
"SELECT * FROM collection_pages WHERE id=?", (page_id,)
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Page not found")
# v6.0.0: granular collection/page permissions.
_require_edit(existing["collection_id"], _session_user(request))
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
conn.commit()
run_event_sync(fire_event("page.deleted", {
"page_id": page_id,
"collection_id": existing["collection_id"],
"title": existing["title"],
}))
run_event_sync(fire_event("collection.page.deleted", {
"page_id": page_id,
"collection_id": existing["collection_id"],
}))
return {"id": page_id, "status": "deleted"}
# ── Gitea Board Compatibility (BEFORE {collection_id} wildcards) ──
+322
View File
@@ -0,0 +1,322 @@
"""FlowDeck — Collections : properties.
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Body, HTTPException, Request
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collections"], prefix="/db")
@router.post("/{collection_id}/property-groups/api")
def set_property_groups_api(request: Request, collection_id: int, body: dict = Body(default={})):
"""API: (re)assign properties to collapsible groups in the table header.
Body: ``{"groups": [{"name": "Basics", "property_ids": [1, 2]}]}``. Properties
omitted from any group have their group cleared. Empty group names clear.
"""
groups = body.get("groups", [])
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
conn.execute(
"UPDATE collection_properties SET group_name='' WHERE collection_id=?",
(collection_id,),
)
for grp in groups:
gname = (grp.get("name") or "").strip()
if not gname:
continue
for pid in grp.get("property_ids", []) or []:
conn.execute(
"UPDATE collection_properties SET group_name=? WHERE id=? AND collection_id=?",
(gname, pid, collection_id),
)
conn.commit()
return {"status": "updated"}
@router.post("/{collection_id}/properties/api")
def create_property_api(request: Request, collection_id: int, body: dict = Body(default={})):
"""API: create a new property on a collection."""
name = body.get("name", "").strip()
if not name:
raise HTTPException(status_code=400, detail="name is required")
prop_type = body.get("prop_type", "text")
options_json = json.dumps(body.get("options", []))
number_format = body.get("number_format", "number")
required = int(body.get("required", False))
visible = int(body.get("visible_in_views", True))
validation_json = json.dumps(body.get("validation", {}))
group_name = (body.get("group_name") or "").strip()
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
(collection_id,),
).fetchone()[0]
try:
cur = conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, options_json, number_format,
position, required, visible_in_views, validation_json, group_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(collection_id, name, prop_type, options_json, number_format, max_pos,
required, visible, validation_json, group_name),
)
conn.commit()
except Exception:
raise HTTPException(status_code=409, detail=f"Property '{name}' already exists") from None
return {"id": cur.lastrowid, "name": name, "prop_type": prop_type,
"group_name": group_name, "status": "created"}
@router.put("/properties/{prop_id}/api")
def update_property_api(request: Request, prop_id: int, body: dict = Body(default={})):
"""API: update a property."""
with get_conn() as conn:
existing = conn.execute(
"SELECT * FROM collection_properties WHERE id=?", (prop_id,)
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Property not found")
name = body.get("name", existing["name"])
options_json = json.dumps(body.get("options", json.loads(existing["options_json"])))
number_format = body.get("number_format", existing["number_format"])
required = int(body.get("required", existing["required"]))
visible = int(body.get("visible_in_views", existing["visible_in_views"]))
if "validation" in body:
validation_json = json.dumps(body.get("validation", {}))
else:
validation_json = existing["validation_json"] if "validation_json" in existing.keys() else "{}"
group_name = body.get("group_name", existing["group_name"] if "group_name" in existing.keys() else "")
conn.execute(
"""UPDATE collection_properties
SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?,
validation_json=?, group_name=?
WHERE id=?""",
(name, options_json, number_format, required, visible, validation_json,
group_name, prop_id),
)
conn.commit()
return {"id": prop_id, "status": "updated"}
@router.delete("/properties/{prop_id}/api")
def delete_property_api(request: Request, prop_id: int):
"""API: delete a property."""
with get_conn() as conn:
existing = conn.execute(
"SELECT * FROM collection_properties WHERE id=?", (prop_id,)
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Property not found")
conn.execute("DELETE FROM collection_properties WHERE id=?", (prop_id,))
conn.commit()
return {"id": prop_id, "status": "deleted"}
# ── Relations, Rollups, Formulas (v1.5.0) ──
# ── Relations, Rollups, Formulas (v1.5.0) ──
@router.post("/{collection_id}/properties/relation")
def create_relation_property(request: Request, collection_id: int, body: dict = Body(default={})):
"""Create a relation property between two collections."""
name = body.get("name", "").strip()
related_collection_id = body.get("related_collection_id")
reverse_name = body.get("reverse_name", "").strip()
if not name or not related_collection_id:
raise HTTPException(status_code=400, detail="name and related_collection_id are required")
with get_conn() as conn:
# Verify both collections exist
for cid in (collection_id, related_collection_id):
if not conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone():
raise HTTPException(status_code=404, detail=f"Collection {cid} not found")
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
(collection_id,),
).fetchone()[0]
cur = conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, related_collection_id, reverse_name, position)
VALUES (?, ?, 'relation', ?, ?, ?)""",
(collection_id, name, related_collection_id, reverse_name, max_pos),
)
prop_id = cur.lastrowid
# Create reverse relation on the related collection
if reverse_name:
max_pos2 = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?",
(related_collection_id,),
).fetchone()[0]
conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, related_collection_id, reverse_name, position)
VALUES (?, ?, 'relation', ?, ?, ?)""",
(related_collection_id, reverse_name, collection_id, name, max_pos2),
)
conn.commit()
return {"id": prop_id, "name": name, "prop_type": "relation", "status": "created"}
@router.post("/{collection_id}/properties/relation/link")
def link_pages(request: Request, collection_id: int, body: dict = Body(default={})):
"""Link two pages via a relation property."""
property_id = body.get("property_id")
source_page_id = body.get("source_page_id")
target_page_id = body.get("target_page_id")
if not all([property_id, source_page_id, target_page_id]):
raise HTTPException(status_code=400, detail="property_id, source_page_id, target_page_id required")
with get_conn() as conn:
# Get the relation property
prop = conn.execute(
"SELECT * FROM collection_properties WHERE id=? AND prop_type='relation'",
(property_id,),
).fetchone()
if not prop:
raise HTTPException(status_code=404, detail="Relation property not found")
# Update source page's property_values_json
source = conn.execute(
"SELECT property_values_json FROM collection_pages WHERE id=?",
(source_page_id,),
).fetchone()
if not source:
raise HTTPException(status_code=404, detail="Source page not found")
props = json.loads(source["property_values_json"])
current = props.get(str(property_id), [])
if not isinstance(current, list):
current = []
if target_page_id not in current:
current.append(target_page_id)
props[str(property_id)] = current
conn.execute(
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(props), source_page_id),
)
# Update reverse relation if exists
if prop["reverse_name"]:
reverse_prop = conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? AND name=? AND prop_type='relation'",
(prop["related_collection_id"], prop["reverse_name"]),
).fetchone()
if reverse_prop:
target = conn.execute(
"SELECT property_values_json FROM collection_pages WHERE id=?",
(target_page_id,),
).fetchone()
if target:
tprops = json.loads(target["property_values_json"])
tcurrent = tprops.get(str(reverse_prop["id"]), [])
if not isinstance(tcurrent, list):
tcurrent = []
if source_page_id not in tcurrent:
tcurrent.append(source_page_id)
tprops[str(reverse_prop["id"])] = tcurrent
conn.execute(
"UPDATE collection_pages SET property_values_json=? WHERE id=?",
(json.dumps(tprops), target_page_id),
)
conn.commit()
return {"status": "linked", "source": source_page_id, "target": target_page_id}
@router.post("/rollup/compute")
def compute_rollup(request: Request, body: dict = Body(default={})):
"""Compute a rollup aggregation."""
collection_id = body.get("collection_id")
relation_property_id = body.get("relation_property_id")
target_property_id = body.get("target_property_id")
page_id = body.get("page_id")
rollup_function = body.get("function", "count")
if not all([collection_id, relation_property_id, target_property_id, page_id]):
raise HTTPException(status_code=400, detail="collection_id, relation_property_id, target_property_id, page_id required")
from app.services.rollup_engine import RollupEngine
engine = RollupEngine()
result = engine.compute(
collection_id, relation_property_id, target_property_id, page_id, rollup_function,
)
return {"result": result, "function": rollup_function}
@router.post("/formula/evaluate")
def evaluate_formula(request: Request, body: dict = Body(default={})):
"""Evaluate a formula expression."""
expression = body.get("expression", "")
context = body.get("context", {})
if not expression:
raise HTTPException(status_code=400, detail="expression is required")
from app.services.formula_engine import FormulaEngine
engine = FormulaEngine()
result = engine.evaluate(expression, context)
return {"result": result, "expression": expression}
# ── v1.7.0 View Management ──
+267
View File
@@ -0,0 +1,267 @@
"""FlowDeck — Collections : structure.
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import json
import logging
import sqlite3
from fastapi import APIRouter, Body, HTTPException, Request
from app.db import get_conn
from app.services.automations import fire_event, run_event_sync
from app.services.property_types import (
apply_auto_properties,
)
from ._common import _collection_properties, _current_user
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collections"], prefix="/db")
# ── v1.8.0 Sub-items & Dependencies ──
@router.get("/{collection_id}/pages/{page_id}/sub-items")
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(
"SELECT * FROM collection_pages WHERE parent_id=? ORDER BY position",
(page_id,),
).fetchall()
return {"sub_items": [dict(r) for r in rows]}
@router.post("/{collection_id}/pages/{page_id}/sub-items")
def create_sub_item(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
"""API: create a sub-item under a page."""
title = body.get("title", "New sub-item").strip()
if not title:
raise HTTPException(status_code=400, detail="title is required")
with get_conn() as conn:
parent = conn.execute("SELECT id FROM collection_pages WHERE id=? AND collection_id=?", (page_id, collection_id)).fetchone()
if not parent:
raise HTTPException(status_code=404, detail="Parent page not found")
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE parent_id=?",
(page_id,),
).fetchone()[0]
sub_props = body.get("properties", {}) or {}
apply_auto_properties(
_collection_properties(conn, collection_id),
sub_props,
_current_user(request),
is_create=True,
)
cur = conn.execute(
"INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?, ?, ?, ?, ?)",
(collection_id, title, page_id, max_pos, json.dumps(sub_props)),
)
conn.commit()
new_id = cur.lastrowid
run_event_sync(fire_event("page.created", {
"page_id": new_id,
"collection_id": collection_id,
"parent_id": page_id,
"title": title,
"properties": body.get("properties", {}),
}))
run_event_sync(fire_event("collection.page.created", {
"page_id": new_id,
"collection_id": collection_id,
"title": title,
}))
return {"id": new_id, "title": title, "parent_id": page_id, "status": "created"}
@router.get("/{collection_id}/pages/{page_id}/status-aggregate")
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(
"SELECT property_values_json FROM collection_pages WHERE parent_id=?",
(page_id,),
).fetchall()
statuses = []
for c in children:
props = json.loads(c["property_values_json"])
for v in props.values():
if isinstance(v, str) and v:
statuses.append(v)
total = len(statuses)
if total == 0:
return {"total": 0, "done": 0, "all_done": False}
done = sum(1 for s in statuses if s.lower() in ("done", "complete", "completed", "terminé"))
return {"total": total, "done": done, "all_done": done == total}
@router.post("/{collection_id}/pages/{page_id}/dependencies")
def set_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
"""API: set blocking dependencies for a page (stored as 'blocks' property)."""
blocks_ids = body.get("blocks", [])
with get_conn() as conn:
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(status_code=404, detail="Page not found")
props = json.loads(page["property_values_json"])
props["blocks"] = blocks_ids
conn.execute(
"UPDATE collection_pages SET property_values_json=? WHERE id=?",
(json.dumps(props), page_id),
)
conn.commit()
return {"page_id": page_id, "blocks": blocks_ids, "status": "updated"}
@router.post("/{collection_id}/pages/{page_id}/check-deps")
def check_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
"""API: check if a page can transition to a new status."""
body.get("new_status", "Done")
with get_conn() as conn:
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(status_code=404, detail="Page not found")
props = json.loads(page["property_values_json"])
blocks_ids = props.get("blocks", [])
if not blocks_ids:
return {"can_transition": True, "blocked_by": []}
# Check blocked pages status
placeholders = ",".join("?" for _ in blocks_ids)
blocked = conn.execute(
f"SELECT id, title, property_values_json FROM collection_pages WHERE id IN ({placeholders})",
blocks_ids,
).fetchall()
blockers = []
for b in blocked:
bprops = json.loads(b["property_values_json"])
bstatus = None
for v in bprops.values():
if isinstance(v, str) and v:
bstatus = v
break
if bstatus and bstatus.lower() not in ("done", "complete", "completed", "terminé"):
blockers.append({"id": b["id"], "title": b["title"], "status": bstatus})
return {
"can_transition": len(blockers) == 0,
"blocked_by": blockers,
}
# ── v4.1.0: Data Sources & Linked Databases ──
# ── v4.1.0: Data Sources & Linked Databases ──
@router.get("/{collection_id}/sources/api")
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()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
rows = conn.execute(
"SELECT * FROM collection_data_sources WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
return {"sources": [dict(r) for r in rows]}
@router.post("/{collection_id}/sources/api")
def add_data_source(request: Request, collection_id: int, body: dict = Body(default={})):
"""API: add a data source to a collection."""
source_collection_id = body.get("source_collection_id")
if not source_collection_id:
raise HTTPException(status_code=400, detail="source_collection_id is required")
source_name = body.get("source_name", "").strip()
is_linked = body.get("is_linked", False)
with get_conn() as conn:
# Verify both collections exist
for cid in (collection_id, source_collection_id):
if not conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone():
raise HTTPException(status_code=404, detail=f"Collection {cid} not found")
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_data_sources WHERE collection_id=?",
(collection_id,),
).fetchone()[0]
try:
cur = conn.execute(
"""INSERT INTO collection_data_sources
(collection_id, source_collection_id, source_name, is_linked, position)
VALUES (?, ?, ?, ?, ?)""",
(collection_id, source_collection_id, source_name, int(is_linked), max_pos),
)
conn.commit()
except sqlite3.IntegrityError:
raise HTTPException(status_code=409, detail="This data source already exists in this collection") from None
return {
"id": cur.lastrowid,
"collection_id": collection_id,
"source_collection_id": source_collection_id,
"status": "added",
}
@router.delete("/{collection_id}/sources/{source_id}/api")
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(
"SELECT * FROM collection_data_sources WHERE id=? AND collection_id=?",
(source_id, collection_id),
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Data source not found")
conn.execute("DELETE FROM collection_data_sources WHERE id=?", (source_id,))
conn.commit()
return {"id": source_id, "status": "removed"}
+187
View File
@@ -0,0 +1,187 @@
"""FlowDeck — Collections : views.
Découpe A28 de l'ancien app/routers/collections.py (2 622 lignes, 53 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Body, HTTPException, Request
from app.db import get_conn
from app.services.automations import fire_event, run_event_sync
from ._common import _current_user
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collections"], prefix="/db")
# ── v1.7.0 View Management ──
@router.get("/views/{view_id}/api")
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()
if not row:
raise HTTPException(status_code=404, detail="View not found")
return dict(row)
@router.put("/views/{view_id}/config")
def update_view_config(request: Request, view_id: int, body: dict = Body(default={})):
"""API: update view configuration (group_by, card_size, visible_properties, etc.)."""
with get_conn() as conn:
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="View not found")
config = json.loads(existing["config_json"])
for key in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property",
"cover_mode", "card_properties", "visible_properties", "filters", "sorts",
"filter_conjunction", "date_property", "date_range_property",
"property_groups", "view_type"):
if key in body:
config[key] = body[key]
new_type = body.get("view_type") or existing["view_type"]
conn.execute(
"UPDATE collection_views SET config_json=?, name=COALESCE(?, name), view_type=?, "
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(config), body.get("name"), new_type, view_id),
)
conn.commit()
return {"id": view_id, "status": "updated", "config": config, "view_type": new_type}
@router.post("/{collection_id}/views/save-as")
def save_view_as(request: Request, collection_id: int, body: dict = Body(default={})):
"""API: save current view state as a new named view."""
name = body.get("name", "New View")
config = body.get("config", {})
user = _current_user(request)
user_id = user.get("id") if user else None
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?",
(collection_id,),
).fetchone()[0]
view_type = body.get("view_type", "table")
cur = conn.execute(
"""INSERT INTO collection_views
(collection_id, name, view_type, config_json, position, created_by)
VALUES (?, ?, ?, ?, ?, ?)""",
(collection_id, name, view_type, json.dumps(config), max_pos, user_id),
)
conn.commit()
new_view_id = cur.lastrowid
run_event_sync(fire_event("collection.view.created", {
"view_id": new_view_id,
"collection_id": collection_id,
"name": name,
"view_type": view_type,
}))
return {"id": new_view_id, "name": name, "view_type": view_type,
"config_json": json.dumps(config), "created_by": user_id, "status": "saved"}
@router.get("/{collection_id}/views/api")
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;
personal views (``created_by = user``) only to their owner.
"""
user = _current_user(request)
user_id = user.get("id") if user else None
with get_conn() as conn:
if user_id is not None:
rows = conn.execute(
"""SELECT * FROM collection_views
WHERE collection_id=? AND (created_by IS NULL OR created_by=?)
ORDER BY position""",
(collection_id, user_id),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? AND created_by IS NULL ORDER BY position",
(collection_id,),
).fetchall()
return {"views": [dict(r) for r in rows]}
@router.delete("/views/{view_id}/api")
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()
if not existing:
raise HTTPException(status_code=404, detail="View not found")
conn.execute("DELETE FROM collection_views WHERE id=?", (view_id,))
conn.commit()
return {"id": view_id, "status": "deleted"}
@router.post("/views/{view_id}/duplicate")
def duplicate_view_api(request: Request, view_id: int, body: dict = Body(default={})):
"""API: duplicate a view (config + type), owned by the current user."""
with get_conn() as conn:
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="View not found")
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?",
(existing["collection_id"],),
).fetchone()[0]
user = _current_user(request)
user_id = user.get("id") if user else None
name = body.get("name") or (existing["name"] + " copy")
cur = conn.execute(
"""INSERT INTO collection_views
(collection_id, name, view_type, config_json, position, created_by)
VALUES (?, ?, ?, ?, ?, ?)""",
(existing["collection_id"], name, existing["view_type"],
existing["config_json"], max_pos, user_id),
)
conn.commit()
dup_view_id = cur.lastrowid
run_event_sync(fire_event("collection.view.created", {
"view_id": dup_view_id,
"collection_id": existing["collection_id"],
"name": name,
"view_type": existing["view_type"],
}))
return {"id": dup_view_id, "name": name, "view_type": existing["view_type"],
"status": "duplicated"}
# ── v1.8.0 Sub-items & Dependencies ──
File diff suppressed because it is too large Load Diff
+77
View File
@@ -0,0 +1,77 @@
"""FlowDeck — Dashboard (pages HTML + API de l'app).
Découpe A28 : l'ancien `dashboard.py` (2 735 lignes, 63 routes) est
devenu ce package — un module par concern, helpers dans `_common`,
re-export de tout ce que les 7 importateurs existants utilisent
(main, board, my_tasks, web_clipper, wiki, sites, tests).
"""
from __future__ import annotations
import logging
from fastapi import APIRouter
from . import ( # ordre = ordre d'enregistrement d'origine (openapi identique)
account_api,
account_settings,
local_workspace,
pages_api,
pages_html,
public,
workspace,
workspaces,
)
from ._common import ( # noqa: F401 — re-export des helpers
_VERSION,
WORKSPACE_COOKIE,
_build_breadcrumb,
_build_tree_children,
_file_page_disk_path,
_format_size,
_get_active_workspace,
_get_app_version,
_get_user_id,
_get_user_or_redirect,
_local_workspaces_for_user,
_nav_breadcrumb,
_render_blocks_public,
_require_page_view,
_require_user_id,
_sanitize_id,
_sidebar_data,
)
logger = logging.getLogger(__name__)
router = APIRouter()
for _mod in (
pages_html,
account_api,
workspace,
local_workspace,
workspaces,
account_settings,
public,
pages_api,
):
router.include_router(_mod.router)
__all__ = [
"router",
"WORKSPACE_COOKIE",
"_VERSION",
"_build_breadcrumb",
"_build_tree_children",
"_file_page_disk_path",
"_format_size",
"_get_active_workspace",
"_get_app_version",
"_get_user_id",
"_get_user_or_redirect",
"_local_workspaces_for_user",
"_nav_breadcrumb",
"_render_blocks_public",
"_require_page_view",
"_require_user_id",
"_sanitize_id",
"_sidebar_data",
]
+774
View File
@@ -0,0 +1,774 @@
"""FlowDeck — Dashboard : helpers partagés des modules de routes (A28).
Les 15 helpers top-level de l'ancien dashboard.py vivent ici (état :
_VERSION, WORKSPACE_COOKIE) — ré-exportés par le package.
"""
from __future__ import annotations
import logging
from fastapi import HTTPException, Request
from fastapi.responses import RedirectResponse
from app.auth.session import SessionManager
from app.config import settings
from app.db import get_conn
logger = logging.getLogger(__name__)
_VERSION = None
WORKSPACE_COOKIE = "flowdeck_workspace"
def _get_app_version() -> str:
"""Read version from VERSION file with caching."""
global _VERSION
if _VERSION is not None:
return _VERSION
try:
import os
version_path = os.path.join(os.path.dirname(__file__), "..", "..", "VERSION")
if os.path.exists(version_path):
with open(version_path) as f:
_VERSION = f.read().strip()
else:
# Docker fallback
version_path = "/app/VERSION"
if os.path.exists(version_path):
with open(version_path) as f:
_VERSION = f.read().strip()
else:
_VERSION = "0.0.0"
except Exception:
_VERSION = "0.0.0"
return _VERSION
def _get_user_or_redirect(request: Request):
"""Return decoded user or a RedirectResponse to login page.
Skips redirect when DB has no users (fresh install / test env)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
# Allow through if no users exist yet (fresh install / tests)
try:
with get_conn() as conn:
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if count == 0:
return {"id": 1, "login": "admin", "full_name": "Admin", "is_admin": True}
except Exception:
logger.exception("_get_user_or_redirect")
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
return user
def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list[dict]:
"""Return list of local workspaces for a user."""
if not user:
return []
try:
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name FROM workspaces WHERE owner_id = ? ORDER BY name",
(user["id"],)
).fetchall()
return [{"id": r["id"], "name": r["name"]} for r in rows]
except Exception:
return []
def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool = True) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws = user.get("login", "Bruno") if user else "Bruno"
initial = ws[0].upper() if ws else "B"
# Get avatar info from DB
avatar_url = ""
avatar_color = "#3A3A3A"
if user:
try:
with get_conn() as conn:
row = conn.execute("SELECT avatar_url, avatar_color FROM users WHERE id = ?", (user["id"],)).fetchone()
if row:
avatar_url = row["avatar_url"] or ""
avatar_color = row["avatar_color"] or "#3A3A3A"
except Exception:
logger.exception("_sidebar_data")
recent_pages = []
for repo in repos[:10]:
full_name = repo.get("full_name", "")
recent_pages.append({
"id": full_name,
"name": repo.get("name", full_name),
"icon": "folder",
"url": f"/board/{full_name}",
"active": False,
"indent": 0,
"depth": 0,
"has_children": False,
"children": [],
})
# Active workspace from cookie (skip on pages like /workspaces where no
# workspace context should be shown)
from app.routers.board import _load_workspace_pages
ws_cookie = request.cookies.get("flowdeck_workspace", "")
active_ws_name = "Workspace"
workspace_pages = []
gitea_workspace = False
gitea_owner = ""
gitea_repo = ""
has_active_workspace = False
local_ws_id = 0
if ws_cookie and ws_cookie.startswith("gitea:"):
# Gitea workspace: set owner/repo for client-side tree loading
# AND open the local workspace mirror of the same name in the
# sidebar's top "My Workspaces" section, in parallel with the
# Gitea repository tree.
parts = ws_cookie.split(":", 2)
if len(parts) >= 3:
gitea_owner = parts[1]
gitea_repo = parts[2]
active_ws_name = f"{gitea_owner}/{gitea_repo}"
gitea_workspace = True
has_active_workspace = True
# Load the local mirror workspace tree so it appears in "My
# Workspaces" alongside the Gitea repository section.
if user:
try:
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
).fetchone()
if row:
local_ws_id = row["id"]
workspace_pages = _load_workspace_pages(str(local_ws_id))
except (ValueError, Exception):
pass
elif include_workspace and ws_cookie and user:
try:
wsi = int(ws_cookie)
with get_conn() as conn:
# Verify this workspace belongs to the current user
row = conn.execute(
"SELECT id, name, owner_id FROM workspaces WHERE id=? AND owner_id=?",
(wsi, user["id"])
).fetchone()
if row:
active_ws_name = row["name"]
workspace_pages = _load_workspace_pages(ws_cookie)
has_active_workspace = True
# else: stale cookie from another user — ignore
except (ValueError, Exception):
pass
# Auth method & OAuth badge data
auth_method = "local"
gitea_linked = False
github_linked = False
if user and user.get("id"):
try:
with get_conn() as conn:
am_row = conn.execute("SELECT auth_method FROM users WHERE id=?", (user["id"],)).fetchone()
if am_row and am_row["auth_method"]:
auth_method = am_row["auth_method"]
tokens = conn.execute(
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
).fetchall()
for t in tokens:
if t["provider"] == "gitea":
gitea_linked = True
elif t["provider"] == "github":
github_linked = True
except Exception:
logger.exception("_sidebar_data")
# Get local workspace ID for Gitea workspace mirror
local_ws_id = 0
if gitea_workspace and gitea_owner and gitea_repo:
try:
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
).fetchone()
if row:
local_ws_id = row["id"]
except Exception:
logger.exception("_sidebar_data")
# Private pages for mirror workspace (when Gitea remote active)
private_pages = []
if gitea_workspace and local_ws_id:
try:
with get_conn() as conn:
pp_rows = conn.execute(
"SELECT id, title FROM pages WHERE parent_section='Private' AND workspace_id=? AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 20",
(local_ws_id,)
).fetchall()
private_pages = [{"id": r[0], "title": r[1] or "Untitled"} for r in pp_rows]
except Exception:
logger.exception("_sidebar_data")
# Shared / received / published pages for the sidebar (Par moi / Avec moi)
shared_made_pages = []
shared_received_pages = []
published_pages = []
shared_pages = []
if user and user.get("id"):
from app.routers.board import _load_shared_sidebar_pages
shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(user["id"])
sidebar = {
"workspace_name": ws, "workspace_initial": initial,
"active_ws_name": active_ws_name,
"workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else "",
"gitea_workspace": gitea_workspace,
"gitea_owner": gitea_owner,
"gitea_repo": gitea_repo,
"local_ws_id": local_ws_id,
"workspace_pages": workspace_pages,
"current_page": "Dashboard", "last_edited": "now",
"recent_pages": recent_pages,
"private_pages": private_pages,
"favorite_pages": [],
"shared_pages": shared_pages,
"shared_made_pages": shared_made_pages,
"shared_received_pages": shared_received_pages,
"published_pages": published_pages,
"user": user,
"avatar_url": avatar_url,
"avatar_color": avatar_color,
"auth_method": auth_method,
"gitea_linked": gitea_linked,
"github_linked": github_linked,
"has_active_workspace": has_active_workspace,
"app_version": _get_app_version(),
}
sidebar["local_workspaces"] = _local_workspaces_for_user(user)
return sidebar
# ═══════════ User API endpoints ═══════════
def _get_user_id(request: Request) -> int:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
return user["id"] if user and user.get("id") else 1
def _require_user_id(request: Request) -> int:
"""A3/A4 — 401 sans session (les routes qui mutent un compte ne tolèrent
plus le fallback « legacy single-user » → id 1 = l'admin seedé)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
return user["id"]
def _file_page_disk_path(page: dict):
"""Resolve the on-disk file behind a ``content_format == 'file'`` page.
Returns ``(abs_path: Path, filename: str, mime: str, size: int)`` or None
when the row is not a file page, references a non-textual/missing file, or
the path escapes the data root (path-traversal guard).
"""
if (page.get("content_format") or "") != "file":
return None
import json as _json
try:
meta = _json.loads(page.get("content", "{}"))
except (_json.JSONDecodeError, TypeError):
meta = {}
if not isinstance(meta, dict):
return None
rel = (meta.get("file_path") or "").replace("\\", "/").strip()
if not rel or not rel.startswith("uploads/"):
return None
parts = rel.split("/")
if ".." in parts or "." in parts:
return None
from pathlib import Path
root = Path(settings.data_dir).resolve()
full = (root / rel).resolve()
try:
full.relative_to(root)
except ValueError:
return None
if not full.exists() or not full.is_file():
return None
filename = parts[-1] or page.get("title", "file")
mime = meta.get("mime_type") or "application/octet-stream"
size = meta.get("size") or 0
return (full, filename, mime, size)
def _require_page_view(request: Request, page_id: int) -> None:
"""A16 : lecture d'une pièce jointe = session + `can_view_page` (404 sinon)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
from app.services.permission_manager import PermissionManager
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
raise HTTPException(404, "Page not found")
def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | None = None) -> list:
"""Recursively build the tree of children for a node."""
if parent_id is None:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, content, page_icon, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
"created_at, updated_at FROM pages "
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL "
"ORDER BY created_at DESC",
(ws_id,),
).fetchall()
else:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, content, page_icon, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
"created_at, updated_at FROM pages "
"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL "
"ORDER BY created_at DESC",
(parent_id, ws_id),
).fetchall()
# Get workspace owner name for author display
ws_owner = conn.execute(
"SELECT u.full_name, u.login FROM workspaces w JOIN users u ON u.id=w.owner_id WHERE w.id=?",
(ws_id,),
).fetchone()
author = ws_owner["full_name"] or ws_owner["login"] if ws_owner else "—"
# Collect all page IDs to fetch tags in one query
all_ids = [r["id"] for r in rows]
tags_map = {}
favorited_ids = set()
if all_ids:
placeholders = ",".join("?" for _ in all_ids)
tag_rows = conn.execute(
f"SELECT pt.page_id, t.id, t.name, t.color FROM page_tags pt "
f"JOIN tags t ON t.id=pt.tag_id WHERE pt.page_id IN ({placeholders})",
all_ids,
).fetchall()
for tr in tag_rows:
tags_map.setdefault(tr["page_id"], []).append({
"id": tr["id"], "name": tr["name"], "color": tr["color"],
})
if uid is not None:
fav_rows = conn.execute(
f"SELECT page_id FROM favorites WHERE user_id=? AND page_id IN ({placeholders})",
[uid, *all_ids],
).fetchall()
favorited_ids = {fr["page_id"] for fr in fav_rows}
tree = []
for r in rows:
is_folder = r["parent_section"] == "Workspace"
children = _build_tree_children(conn, r["id"], ws_id, uid)
# Compute size
size = 0
if r["content_format"] == "file":
import json as _json
try:
meta = _json.loads(r["content"])
size = meta.get("size", 0)
except Exception:
size = len(r["content"] or "")
else:
size = len(r["content"] or "")
tree.append({
"id": r["id"],
"name": r["title"] or "Untitled",
"type": "folder" if is_folder else "page",
"is_folder": is_folder,
"content_format": r["content_format"] if not is_folder else None,
"page_icon": r["page_icon"] or "",
"children": children,
"has_children": len(children) > 0,
"child_count": len(children),
"size": size,
"size_display": _format_size(size),
"created_at": r["created_at"],
"updated_at": r["updated_at"],
"author": author,
"tags": tags_map.get(r["id"], []),
"is_shared": bool(r["is_shared"]),
"favorited": r["id"] in favorited_ids,
})
return tree
def _format_size(size_bytes: int) -> str:
"""Human-readable file size."""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
elif size_bytes < 1024 * 1024 * 1024:
return f"{size_bytes / (1024 * 1024):.1f} MB"
return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB"
def _build_breadcrumb(conn, folder_id: int) -> list:
"""Build breadcrumb trail from root to folder_id."""
breadcrumb = []
current = folder_id
seen = set()
while current and current not in seen:
seen.add(current)
row = conn.execute(
"SELECT id, title, parent_id, parent_section FROM pages WHERE id=?",
(current,),
).fetchone()
if row:
breadcrumb.insert(0, {
"id": row["id"],
"name": row["title"] or "Untitled",
"is_folder": row["parent_section"] == "Workspace",
})
current = row["parent_id"]
else:
break
return breadcrumb
def _nav_breadcrumb(conn, page_id: int) -> list:
"""Build a Notion-style breadcrumb chain (root -> page) for the header.
Returns a list of dicts: {id, label, url, icon, menu}. The last item is the
current page (url = None). Every item has ``menu: True`` so the header can
open a sibling-navigation dropdown for it.
"""
from app.routers.board import _file_icon
chain = []
current = page_id
seen = set()
while current and current not in seen:
seen.add(current)
row = conn.execute(
"SELECT id, title, parent_id, parent_section, content_format "
"FROM pages WHERE id=? AND deleted_at IS NULL",
(current,),
).fetchone()
if not row:
break
is_folder = row["parent_section"] == "Workspace"
title = row["title"] or "Untitled"
chain.insert(0, {
"id": row["id"],
"label": title,
"url": None,
"icon": "folder" if is_folder else _file_icon(title, row["content_format"]),
"menu": True,
})
current = row["parent_id"]
# All items except the current page are navigable links.
for i, item in enumerate(chain):
if i < len(chain) - 1:
item["url"] = f"/pages/{item['id']}"
return chain
def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
"""Get the active workspace ID from the cookie (verified for current user), or first user workspace, or None."""
ws_id = request.cookies.get(WORKSPACE_COOKIE)
if ws_id:
try:
with get_conn() as conn:
ws = conn.execute("SELECT * FROM workspaces WHERE id=?",
(int(ws_id),)).fetchone()
if ws:
ws_dict = dict(ws)
# Verify ownership — only return if it belongs to the current user
if user_id is None or ws_dict.get("owner_id") == user_id:
return ws_dict
except (ValueError, Exception):
pass
# Fallback: first workspace owned by this user
if user_id:
with get_conn() as conn:
ws = conn.execute(
"SELECT * FROM workspaces WHERE owner_id=? ORDER BY id LIMIT 1",
(user_id,)
).fetchone()
if ws:
return dict(ws)
return None
def _sanitize_id(block_id: str) -> str:
"""Sanitize a block id for use as an HTML anchor (only alnum kept)."""
if not block_id:
return ""
return "".join(ch for ch in str(block_id) if ch.isalnum())
def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
"""Render FlowDeck blocks as plain HTML for public pages.
v5.11.0: ``titles`` (token → label, see app.services.wiki_links) turns
``[[fdpage:ID]]`` / ``[[fddate:...]]`` tokens into chips/links.
"""
html_parts = []
def _wiki(c: str) -> str:
if titles and ("[[fdpage:" in c or "[[fddate:" in c):
from app.services.wiki_links import resolve_tokens_html
return resolve_tokens_html(c, titles)
return c
for b in blocks:
t = b.get("type", "paragraph")
c = _wiki(b.get("content", "") or "")
if t == "heading_1":
html_parts.append(f'<h1 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:2.5rem;font-weight:700;margin:32px 0 8px;">{c}</h1>')
elif t == "heading_2":
html_parts.append(f'<h2 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:1.75rem;font-weight:600;margin:28px 0 6px;">{c}</h2>')
elif t == "heading_3":
html_parts.append(f'<h3 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:1.35rem;font-weight:600;margin:24px 0 4px;">{c}</h3>')
elif t == "heading_4":
html_parts.append(f'<h4 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:1.15rem;font-weight:600;margin:20px 0 4px;">{c}</h4>')
elif t == "bulleted_list":
html_parts.append(f'<li style="margin-left:24px;">{c}</li>')
elif t == "numbered_list":
html_parts.append(f'<li style="margin-left:24px;list-style:decimal;">{c}</li>')
elif t == "to_do":
checked = "checked" if b.get("checked") else ""
todo_style = "text-decoration:line-through;opacity:.5" if b.get("checked") else ""
html_parts.append(
f'<div style="display:flex;align-items:center;gap:8px;margin:4px 0;">'
f'<input type="checkbox" {checked} disabled>'
f'<span style="{todo_style}">{c}</span>'
f'</div>'
)
elif t == "toggle":
children_html = ""
if b.get("children"):
children_html = '<div style="margin-left:22px;padding-left:12px;border-left:1px solid rgba(255,255,255,.1);margin-top:4px;">'
children_html += _render_blocks_public(b["children"], titles)
children_html += "</div>"
html_parts.append(
f'<details style="margin:8px 0;" open><summary style="cursor:pointer;font-weight:500;">{c}</summary>{children_html}</details>'
)
elif t == "quote":
html_parts.append(
f'<blockquote style="border-left:3px solid var(--accent,#4c9aff);margin:12px 0;padding:4px 16px;opacity:.85;">{c}</blockquote>'
)
elif t == "table_of_contents":
toc = [
x for x in blocks
if x.get("type", "").startswith("heading_") and (x.get("content") or "").strip()
]
if toc:
items = []
for h in toc:
lvl = int(h["type"].split("_")[-1])
items.append(
f'<div style="margin-left:{max(0, lvl - 1) * 14}px;padding:5px 8px;font-size:14px;">'
f'<a href="#h-{_sanitize_id(h.get("id",""))}" style="color:inherit;text-decoration:none;display:block;">{h.get("content","")}</a></div>'
)
html_parts.append(
'<div style="border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:16px 20px;margin:4px 0;">'
'<div style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;opacity:.5;margin-bottom:10px;">On this page</div>'
+ "".join(items) + "</div>"
)
elif t == "math":
tex = c.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
html_parts.append(
f'<div data-katex="{tex}" style="margin:12px 0;padding:12px 16px;background:rgba(255,255,255,.04);border-radius:8px;overflow-x:auto;"></div>'
)
elif t == "columns":
cols_html = ""
for child in b.get("children") or []:
cols_html += (
'<div style="flex:1;min-width:0;padding:10px 12px;background:rgba(255,255,255,.05);'
'border-radius:8px;box-sizing:border-box;">'
+ _render_blocks_public([child], titles) + "</div>"
)
html_parts.append(
f'<div style="display:flex;gap:12px;margin:8px 0 16px;align-items:stretch;">{cols_html}</div>'
)
elif t == "callout":
icon = b.get("icon", "💡")
bg = (b.get("style") or {}).get("bgColor", "rgba(76,154,255,.1)")
html_parts.append(
f'<div style="display:flex;gap:10px;padding:14px 18px;margin:12px 0;border-radius:8px;'
f'background:{bg};align-items:flex-start;">'
f'<span style="font-size:20px;flex-shrink:0;">{icon}</span>'
f'<span>{c}</span></div>'
)
elif t == "code":
lang = b.get("language", "")
lang_label = f"<div style='font-size:11px;opacity:.4;margin-bottom:8px;'>{lang}</div>" if lang else ""
html_parts.append(
f'<pre style="background:rgba(255,255,255,.05);padding:16px 20px;border-radius:8px;'
f'overflow-x:auto;font-size:14px;line-height:1.5;margin:12px 0;">'
f'{lang_label}'
f'<code>{c}</code></pre>'
)
elif t == "divider":
html_parts.append('<hr style="border:none;border-top:1px solid rgba(255,255,255,.1);margin:16px 0;">')
elif t == "image":
src = b.get("src", "")
alt = b.get("alt", "")
html_parts.append(
f'<figure style="margin:16px 0;text-align:center;">'
f'<img src="{src}" alt="{alt}" data-full="{src}" style="max-width:100%;border-radius:8px;cursor:zoom-in;">'
f'</figure>'
)
elif t == "video":
src = b.get("src", "")
if src:
html_parts.append(
f'<video controls preload="metadata" style="max-width:100%;border-radius:8px;display:block;margin:12px auto;">'
f'<source src="{src}"></video>'
)
elif t == "audio":
src = b.get("src", "")
if src:
html_parts.append(
f'<audio controls preload="metadata" style="width:100%;margin:8px 0;"><source src="{src}"></audio>'
)
elif t == "bookmark":
url = b.get("url") or b.get("src") or ""
title = b.get("title") or url
desc = b.get("description") or ""
img = b.get("image") or ""
site = b.get("site_name") or ""
img_html = (
f'<img src="{img}" alt="" style="width:120px;height:90px;object-fit:cover;border-radius:8px;flex-shrink:0;">' if img else ""
)
desc_html = f'<div style="font-size:13px;opacity:.75;margin-top:4px;">{desc}</div>' if desc else ""
site_html = f'<div style="font-size:11px;opacity:.5;text-transform:uppercase;letter-spacing:.5px;margin-top:6px;">{site}</div>' if site else ""
html_parts.append(
f'<a href="{url}" target="_blank" rel="noopener noreferrer" style="text-decoration:none;color:inherit;">'
f'<div style="display:flex;gap:14px;align-items:center;border:1px solid rgba(255,255,255,.12);border-radius:10px;'
f'padding:14px 16px;margin:14px 0;background:rgba(255,255,255,.03);">'
f'<div style="flex:1;min-width:0;"><div style="font-weight:600;font-size:15px;">{title}</div>'
f'{desc_html}{site_html}</div>{img_html}</div></a>'
)
elif t == "embed":
url = b.get("src", "")
emb = b.get("embed_type") or ""
if emb in ("inline_dbs", "collection"):
html_parts.append('<div>[Embedded content]</div>')
elif emb == "download":
html_parts.append(
f'<a href="{url}" download style="display:inline-block;margin:12px 0;color:var(--accent,#4c9aff);">⬇ {b.get("file_name") or "Download"}</a>'
)
elif emb == "pdf" and url:
html_parts.append(
f'<iframe src="{url}" style="width:100%;height:70vh;border:none;border-radius:8px;margin:12px 0;"></iframe>'
)
elif url:
from app.services.embeds import embed_src
src = b.get("embed_src") or embed_src(url) or url
height = b.get("height") or 520
try:
height = int(height)
except (ValueError, TypeError):
height = 520
html_parts.append(
f'<div style="position:relative;width:100%;height:{height}px;border-radius:8px;overflow:hidden;'
f'background:#0a0a0a;"><iframe src="{src}" loading="lazy" frameborder="0" '
f'style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div>'
)
elif t == "synced":
# v6.5.0: render synced block instances (resolved server-side).
if b.get("_synced_deleted"):
html_parts.append(
'<div style="margin:8px 0;padding:8px 12px;border-left:3px solid #e05e5e;'
'background:rgba(224,94,94,.08);border-radius:4px;font-size:13px;opacity:.8;">'
'Deleted synced block</div>'
)
else:
inner = b.get("_synced_content")
if not isinstance(inner, list) or not inner:
try:
import json as _sj
parsed = _sj.loads(b.get("content") or "[]")
inner = parsed if isinstance(parsed, list) else []
except (ValueError, TypeError):
inner = []
inner = [{"type": "paragraph", "content": str(x)} if not isinstance(x, dict) else x
for x in inner]
if inner:
html_parts.append(
'<div style="margin:8px 0;padding-left:12px;'
'border-left:3px solid var(--accent,#4c9aff);">'
+ _render_blocks_public(inner, titles) + '</div>'
)
else:
html_parts.append(f'<p style="margin:4px 0;line-height:1.7;">{c}</p>')
return "\n".join(html_parts)
# ═══════════ Library page actions API ═══════════
+77
View File
@@ -0,0 +1,77 @@
"""FlowDeck — Dashboard : account_api.
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Body, HTTPException, Request
from app.db import get_conn
from ._common import _require_user_id
logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"])
@router.put("/api/user/profile")
def update_profile(request: Request, body: dict = Body(default={})):
full_name = body.get("full_name", "").strip()
uid = _require_user_id(request)
with get_conn() as conn:
conn.execute("UPDATE users SET full_name=? WHERE id=?", (full_name, uid))
conn.commit()
return {"status": "ok"}
@router.put("/api/user/password")
def update_password(request: Request, body: dict = Body(default={})):
from app.password_utils import hash_password, verify_password
password = body.get("password", "").strip()
if len(password) < 6:
return {"error": "Password must be at least 6 characters"}
uid = _require_user_id(request)
# A3 : le mot de passe actuel est exigé (session volée ≠ droit de changer le mdp).
current = body.get("current_password", "")
with get_conn() as conn:
row = conn.execute("SELECT password_hash FROM users WHERE id=?", (uid,)).fetchone()
if not row or not verify_password(current, row["password_hash"]):
raise HTTPException(403, "Current password is incorrect")
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(password), uid))
conn.commit()
return {"status": "ok"}
@router.post("/api/user/token")
def generate_token(request: Request):
import secrets
uid = _require_user_id(request)
token = secrets.token_hex(32)
with get_conn() as conn:
conn.execute(
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
(uid, token),
)
conn.commit()
return {"token": f"fd_{token}"}
@router.delete("/api/user/forge/{provider}")
def disconnect_forge(request: Request, provider: str):
uid = _require_user_id(request)
with get_conn() as conn:
conn.execute(
"DELETE FROM user_oauth_tokens WHERE user_id=? AND provider=?", (uid, provider)
)
conn.commit()
return {"status": "ok"}
+439
View File
@@ -0,0 +1,439 @@
"""FlowDeck — Dashboard : settings.
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Body, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.auth.session import SessionManager
from app.db import get_conn
from ._common import _format_size, _get_active_workspace, _get_user_id, _sidebar_data
logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"])
# ═══════════ Settings Page ═══════════
@router.get("/settings", response_class=HTMLResponse)
def app_settings_page(request: Request):
"""Settings & configuration page."""
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302)
ctx = {**sidebar, "user": user}
# Pass active workspace for breadcrumb nav menu
ws = _get_active_workspace(request, user_id=_get_user_id(request))
ctx["nav_workspace_id"] = ws["id"] if ws else 0
template = env.get_template("settings.html")
return template.render(**ctx)
@router.post("/api/settings/avatar")
async def upload_avatar(request: Request):
"""Upload a user avatar image."""
import os
import uuid
from pathlib import Path
form = await request.form()
file = form.get("file")
if not file:
return {"error": "No file"}, 400
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return {"error": "Not authenticated"}, 401
# Save to data/avatars
avatars_dir = Path("/data/avatars")
avatars_dir.mkdir(parents=True, exist_ok=True)
ext = os.path.splitext(file.filename)[1] or ".png"
filename = f"{user['id']}_{uuid.uuid4().hex[:8]}{ext}"
filepath = avatars_dir / filename
content = await file.read()
filepath.write_bytes(content)
# Update user avatar_url
avatar_url = f"/api/settings/avatar/{filename}"
with get_conn() as conn:
conn.execute("UPDATE users SET avatar_url = ? WHERE id = ?", (avatar_url, user["id"]))
conn.commit()
return {"avatar_url": avatar_url}
@router.get("/api/settings/avatar/{filename:path}")
def serve_avatar_file(filename: str):
"""Serve an uploaded avatar image file."""
from pathlib import Path
from fastapi.responses import FileResponse
# A11 : garde path traversal (motif de serve_uploaded_file) — `:path` Starlette
# accepte les `/`, donc `..%2f` ressortirait du dossier avatars.
base_dir = Path("/data/avatars").resolve()
filepath = (base_dir / filename).resolve()
try:
filepath.relative_to(base_dir)
except ValueError:
return JSONResponse({"error": "Path traversal denied"}, status_code=403)
if not filepath.is_file():
return JSONResponse({"error": "Not found"}, status_code=404)
return FileResponse(filepath)
@router.get("/api/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()
if row and row["avatar_url"]:
return RedirectResponse(row["avatar_url"], status_code=302)
return JSONResponse({"error": "No avatar"}, status_code=404)
@router.post("/api/settings/avatar-color")
def set_avatar_color(request: Request, body: dict = Body(default={})):
"""Set the user's avatar background color."""
color = body.get("color", "#3A3A3A")
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return {"error": "Not authenticated"}, 401
with get_conn() as conn:
conn.execute("UPDATE users SET avatar_url = '', avatar_color = ? WHERE id = ?", (color, user["id"]))
conn.commit()
return {"status": "ok", "color": color}
# ═══════════ Tag Management API (per-user) ═══════════
# ═══════════ Tag Management API (per-user) ═══════════
@router.post("/api/settings/tags")
def create_tag_global(request: Request, body: dict = Body(default={})):
"""Create a tag for the current user."""
tag_name = body.get("name", "").strip().lower()
color = body.get("color", "#787774")
uid = _get_user_id(request)
if not tag_name or not uid:
return {"error": "Tag name required"}, 400
with get_conn() as conn:
tag = conn.execute("SELECT id FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone()
if tag:
conn.execute("UPDATE tags SET color = ? WHERE id = ?", (color, tag["id"]))
conn.commit()
return {"tag": {"id": tag["id"], "name": tag_name, "color": color}}
cursor = conn.execute("INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, color, uid))
conn.commit()
return {"tag": {"id": cursor.lastrowid, "name": tag_name, "color": color}}
@router.put("/api/settings/tags/{tag_id:int}")
def update_tag_global(tag_id: int, request: Request, body: dict = Body(default={})):
"""Update a tag (name or color) — only if owned by user."""
uid = _get_user_id(request)
with get_conn() as conn:
if "name" in body:
conn.execute("UPDATE tags SET name = ? WHERE id = ? AND user_id = ?", (body["name"].strip().lower(), tag_id, uid))
if "color" in body:
conn.execute("UPDATE tags SET color = ? WHERE id = ? AND user_id = ?", (body["color"], tag_id, uid))
conn.commit()
return {"status": "ok"}
@router.delete("/api/settings/tags/{tag_id:int}")
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:
conn.execute("DELETE FROM page_tags WHERE tag_id = ?", (tag_id,))
conn.execute("DELETE FROM tags WHERE id = ? AND user_id = ?", (tag_id, uid))
conn.commit()
return {"status": "ok"}
@router.get("/api/settings/tags/all")
def list_all_tags_global(request: Request):
"""List current user's tags with counts."""
uid = _get_user_id(request)
with get_conn() as conn:
rows = conn.execute(
"SELECT t.id, t.name, t.color, COUNT(pt.page_id) as count "
"FROM tags t LEFT JOIN page_tags pt ON pt.tag_id = t.id "
"WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name",
(uid,),
).fetchall()
return JSONResponse(
{"tags": [dict(r) for r in rows]},
headers={"Cache-Control": "no-store"},
)
# ═══════════ Workspace Tags API ═══════════
# ═══════════ Workspace Tags API ═══════════
@router.get("/api/local-workspace/tags")
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
uid = _get_user_id(request)
if not ws_id:
return {"tags": []}
with get_conn() as conn:
rows = conn.execute(
"SELECT t.id, t.name, t.color, "
"(SELECT COUNT(*) FROM page_tags pt "
" JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? "
" WHERE pt.tag_id = t.id) as count "
"FROM tags t WHERE t.user_id = ? ORDER BY t.name",
(ws_id, uid),
).fetchall()
return JSONResponse(
{"tags": [dict(r) for r in rows]},
headers={"Cache-Control": "no-store"},
)
@router.get("/api/local-workspace/items/{item_id:int}/tags")
def get_item_tags(item_id: int):
"""Get tags for a specific item."""
with get_conn() as conn:
rows = conn.execute(
"SELECT t.id, t.name, t.color FROM tags t "
"JOIN page_tags pt ON pt.tag_id = t.id "
"WHERE pt.page_id = ? ORDER BY t.name",
(item_id,),
).fetchall()
return {"tags": [dict(r) for r in rows]}
@router.post("/api/local-workspace/items/{item_id:int}/tags")
def add_item_tag(request: Request, item_id: int, body: dict = Body(default={})):
"""Add a tag to an item (creates tag if new, scoped to user)."""
tag_name = body.get("name", "").strip().lower()
tag_color = body.get("color", "#787774")
uid = _get_user_id(request)
if not tag_name:
return {"error": "Tag name required"}, 400
with get_conn() as conn:
# Get or create tag (per user)
tag = conn.execute("SELECT id, name, color FROM tags WHERE name = ? AND user_id = ?", (tag_name, uid)).fetchone()
if not tag:
cursor = conn.execute(
"INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (tag_name, tag_color, uid)
)
conn.commit()
tag_id = cursor.lastrowid
tag = {"id": tag_id, "name": tag_name, "color": tag_color}
else:
tag_id = tag["id"]
# Link tag to page (ignore duplicate)
try:
conn.execute(
"INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)",
(item_id, tag_id),
)
conn.commit()
except Exception:
logger.exception("add_item_tag")
return {"tag": {"id": tag["id"], "name": tag["name"], "color": tag["color"]}}
@router.delete("/api/local-workspace/items/{item_id:int}/tags/{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(
"DELETE FROM page_tags WHERE page_id = ? AND tag_id = ?",
(item_id, tag_id),
)
conn.commit()
return {"status": "ok"}
@router.get("/api/local-workspace/tags/search")
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
if not ws_id:
return {"items": []}
tag_names = [t.strip().lower() for t in tags.split(",") if t.strip()]
if not tag_names:
return {"items": []}
with get_conn() as conn:
placeholders = ",".join("?" for _ in tag_names)
rows = conn.execute(
f"SELECT DISTINCT p.id, p.title, p.content_format, p.parent_section, "
f"p.content, p.created_at, p.updated_at "
f"FROM pages p "
f"JOIN page_tags pt ON pt.page_id = p.id "
f"JOIN tags t ON t.id = pt.tag_id "
f"WHERE t.name IN ({placeholders}) AND p.workspace_id = ? AND p.deleted_at IS NULL "
f"ORDER BY p.updated_at DESC",
tag_names + [ws_id],
).fetchall()
items = []
for r in rows:
is_folder = r["parent_section"] == "Workspace"
size = len(r["content"] or "")
items.append({
"id": r["id"],
"name": r["title"] or "Untitled",
"is_folder": is_folder,
"content_format": r["content_format"],
"created_at": r["created_at"],
"updated_at": r["updated_at"],
"size": size,
"size_display": _format_size(size),
})
return {"items": items}
# ── Account update ──
# ── Account update ──
@router.put("/api/settings/account")
def update_account(request: Request, body: dict = Body(default={})):
"""Update current user's profile: full_name, login, email, password."""
from app.password_utils import hash_password
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
with get_conn() as conn:
uid = user["id"]
if "full_name" in body:
conn.execute("UPDATE users SET full_name=? WHERE id=?", (body["full_name"].strip(), uid))
if "login" in body:
new_login = body["login"].strip()
if new_login and new_login != user.get("login"):
existing = conn.execute("SELECT id FROM users WHERE login=? AND id!=?", (new_login, uid)).fetchone()
if existing:
return JSONResponse({"error": "Username already taken"}, status_code=409)
conn.execute("UPDATE users SET login=? WHERE id=?", (new_login, uid))
if "email" in body:
conn.execute("UPDATE users SET email=? WHERE id=?", (body["email"].strip(), uid))
if "password" in body and body["password"].strip():
pw = body["password"].strip()
if len(pw) < 6:
return JSONResponse({"error": "Password must be at least 6 characters"}, status_code=400)
conn.execute("UPDATE users SET password_hash=? WHERE id=?", (hash_password(pw), uid))
conn.commit()
row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
user_data = dict(row)
# Refresh session cookie with updated data (keeps the same session id)
cookie = request.cookies.get("flowdeck_session", "")
new_session = SessionManager.refresh_session(cookie, user_data, request)
response = JSONResponse({"status": "ok", "user": {k: user_data[k] for k in ("id","login","full_name","email","is_admin")}})
response.set_cookie("flowdeck_session", new_session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
return response
# ═══════════ Sidebar Refresh API ═══════════
# ═══════════ Sidebar Refresh API ═══════════
@router.get("/api/sidebar/workspace-tree")
def sidebar_workspace_tree(request: Request):
"""Return the sidebar workspace tree as HTML fragment.
Called by appState().refreshSidebarTree() after CRUD operations
in the main content area to keep the sidebar in sync.
"""
from app.routers.board import _load_workspace_pages
from app.templating import ENV
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return HTMLResponse("")
ws_cookie = request.cookies.get("flowdeck_workspace", "")
if not ws_cookie:
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li>')
# Gitea workspace — no server-side tree, loaded client-side
if ws_cookie.startswith("gitea:"):
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">🔗</span><span class="page-name text-dim">Remote workspace</span></li>')
try:
ws_id = int(ws_cookie)
with get_conn() as conn:
# Verify workspace belongs to user
row = conn.execute(
"SELECT id FROM workspaces WHERE id=? AND owner_id=?",
(ws_id, user["id"])
).fetchone()
if not row:
return HTMLResponse(
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
'<span class="page-name text-dim">No pages yet</span></li>'
)
pages = _load_workspace_pages(ws_cookie)
if not pages:
return HTMLResponse(
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
'<span class="page-name text-dim">No pages yet</span></li>'
)
# Render the tree using the extracted macro
env = ENV
template = env.from_string(
"{% from '_workspace_tree_macro.html' import render_workspace_tree %}"
"{{ render_workspace_tree(pages) }}"
)
html = template.render(pages=pages)
return HTMLResponse(html)
except (ValueError, Exception) as e:
logger.error(f"sidebar_workspace_tree failed: {e}", exc_info=True)
return HTMLResponse(
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
'<span class="page-name text-dim">No pages yet</span></li>'
)
# ═══════════ Public Published Page ═══════════
+559
View File
@@ -0,0 +1,559 @@
"""FlowDeck — Dashboard : local_workspace.
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import logging
from datetime import UTC
from fastapi import APIRouter, Body, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.auth.session import SessionManager
from app.config import settings
from app.db import get_conn
from ._common import (
_build_breadcrumb,
_build_tree_children,
_file_page_disk_path,
_get_active_workspace,
_get_user_id,
_require_page_view,
_require_user_id,
_sidebar_data,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"])
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
@router.get("/local-workspace", response_class=HTMLResponse)
def local_workspace_page(request: Request, folder: int = None):
"""Local workspace page with file/folder tree.
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
"""
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302)
ws = _get_active_workspace(request, user_id=user["id"])
ws_id = ws["id"] if ws else None
# If no workspace exists for this user, redirect to workspaces page
if not ws_id:
return RedirectResponse("/workspaces", status_code=302)
# Build breadcrumb if navigating into a folder
breadcrumb = []
current_folder_id = folder
if folder and ws_id:
with get_conn() as conn:
breadcrumb = _build_breadcrumb(conn, folder)
ctx = {
**sidebar,
"user": user,
"workspace_name": sidebar.get("active_ws_name", "My Workspace"),
"current_folder_id": current_folder_id or 0,
"workspace_id": ws_id or 0,
"nav_workspace_id": ws_id or 0,
"breadcrumb": breadcrumb,
"breadcrumbs": breadcrumb,
}
template = env.get_template("local_workspace.html")
return HTMLResponse(
content=template.render(**ctx),
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0",
}
)
@router.get("/api/local-workspace/tree")
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.
Otherwise returns the full recursive tree from root.
"""
ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = ws["id"] if ws else None
if not ws_id:
return {"tree": [], "breadcrumb": []}
uid = _get_user_id(request)
with get_conn() as conn:
if folder:
# Show only this folder's children + build breadcrumb
children = _build_tree_children(conn, folder, ws_id, uid)
breadcrumb = _build_breadcrumb(conn, folder)
return {"tree": children, "breadcrumb": breadcrumb, "current_folder": folder}
else:
# Full tree from root
roots = _build_tree_children(conn, None, ws_id, uid)
return {"tree": roots, "breadcrumb": [], "current_folder": None}
@router.get("/api/local-workspace/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(
"SELECT content, content_format FROM pages WHERE id=?", (page_id,)
).fetchone()
if not row:
return JSONResponse({"error": "Not found"}, status_code=404)
fmt = row["content_format"]
if fmt == "file":
return JSONResponse({"content": "(uploaded file)", "format": fmt})
# v6.5.0: resolve synced blocks server-side (fresh content on read).
from app.services.synced_blocks import resolve_content_json
return {"content": resolve_content_json(row["content"] or "", fmt), "format": fmt}
@router.get("/api/pages/{page_id}/download")
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:
row = conn.execute(
"SELECT id, title, content, content_format FROM pages "
"WHERE id=? AND deleted_at IS NULL",
(page_id,),
).fetchone()
if not row:
return JSONResponse({"error": "Not found"}, status_code=404)
resolved = _file_page_disk_path(dict(row))
if not resolved:
return JSONResponse({"error": "No downloadable file"}, status_code=404)
full, filename, mime, _size = resolved
from fastapi.responses import FileResponse
return FileResponse(
str(full), media_type=mime or "application/octet-stream",
filename=filename, content_disposition_type="attachment",
)
@router.get("/api/pages/{page_id}/file-content")
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
only meaningful for plain-text / code / markdown files.
"""
from app.services.export import _file_text
_require_page_view(request, page_id)
with get_conn() as conn:
row = conn.execute(
"SELECT id, title, content, content_format FROM pages "
"WHERE id=? AND deleted_at IS NULL",
(page_id,),
).fetchone()
if not row:
return JSONResponse({"error": "Not found"}, status_code=404)
page = dict(row)
text = _file_text(page)
if text is None:
return JSONResponse(
{"ok": False, "error": "Not a textual file", "name": page.get("title", "")},
status_code=415,
)
return {"ok": True, "name": page.get("title") or "File", "content": text}
@router.get("/api/local-workspace/breadcrumb")
def local_workspace_breadcrumb(request: Request, folder: int):
"""Return breadcrumb trail for a folder."""
with get_conn() as conn:
breadcrumb = _build_breadcrumb(conn, folder)
return {"breadcrumb": breadcrumb}
@router.get("/api/nav/menu")
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
workspace's root pages. Each item includes ``has_children`` so the frontend
can render an expandable sub-menu.
"""
from app.routers.board import _file_icon
ws_id = workspace_id
if not ws_id:
ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = ws["id"] if ws else None
if not ws_id:
return {"items": []}
with get_conn() as conn:
if parent_id:
rows = conn.execute(
"SELECT id, title, parent_section, content_format FROM pages "
"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL "
"ORDER BY sort_order ASC, created_at DESC",
(parent_id, ws_id),
).fetchall()
else:
rows = conn.execute(
"SELECT id, title, parent_section, content_format FROM pages "
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL "
"ORDER BY sort_order ASC, created_at DESC",
(ws_id,),
).fetchall()
ids = [r["id"] for r in rows]
child_counts = {}
if ids:
placeholders = ",".join("?" for _ in ids)
cc_rows = conn.execute(
f"SELECT parent_id, COUNT(*) AS c FROM pages "
f"WHERE parent_id IN ({placeholders}) AND deleted_at IS NULL "
f"GROUP BY parent_id",
ids,
).fetchall()
for cr in cc_rows:
child_counts[cr["parent_id"]] = cr["c"]
items = []
for r in rows:
is_folder = r["parent_section"] == "Workspace"
title = r["title"] or "Untitled"
items.append({
"id": r["id"],
"name": title,
"icon": "folder" if is_folder else _file_icon(title, r["content_format"]),
"has_children": child_counts.get(r["id"], 0) > 0,
"url": f"/pages/{r['id']}",
})
return {"items": items}
@router.post("/api/local-workspace/items")
def create_local_workspace_item(request: Request, body: dict = Body(default={})):
"""Create a new file in the active workspace."""
name = (body.get("name") or "").strip() or "Untitled"
item_type = body.get("type", "page")
parent_id = body.get("parent_id")
explicit_ws_id = body.get("workspace_id")
ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = explicit_ws_id or (ws["id"] if ws else None)
ws_key = (ws["name"] if ws else "Workspace") if not explicit_ws_id else ""
section = 'Workspace' if item_type == 'folder' else 'Private'
with get_conn() as conn:
cursor = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id) "
"VALUES (?, ?, ?, '', 'blocks', ?, ?)",
(ws_key, ws_id, name, section, parent_id)
)
conn.commit()
pid = cursor.lastrowid
return {"id": pid, "name": name, "type": item_type}
@router.put("/api/local-workspace/items/{item_id:int}")
def rename_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
"""Rename a file."""
name = body.get("name", "Untitled").strip()
with get_conn() as conn:
conn.execute("UPDATE pages SET title=? WHERE id=?", (name, item_id))
conn.commit()
return {"status": "ok"}
@router.delete("/api/local-workspace/items/{item_id:int}")
def delete_local_workspace_item(request: Request, item_id: int):
"""Soft-delete a file/folder (sets deleted_at)."""
from datetime import datetime
with get_conn() as conn:
conn.execute(
"UPDATE pages SET deleted_at=? WHERE id=? AND deleted_at IS NULL",
(datetime.now(UTC).replace(tzinfo=None).isoformat(), item_id),
)
conn.commit()
return {"status": "ok"}
@router.post("/api/local-workspace/items/{item_id:int}/restore")
def restore_local_workspace_item(request: Request, item_id: int):
"""Restore a soft-deleted file/folder."""
with get_conn() as conn:
conn.execute(
"UPDATE pages SET deleted_at=NULL WHERE id=?",
(item_id,),
)
conn.commit()
return {"status": "ok"}
@router.get("/api/files/{ws_id:int}/{filename:path}")
def serve_uploaded_file(ws_id: int, filename: str):
"""Serve an uploaded file from disk."""
import mimetypes
from pathlib import Path
root = Path(settings.data_dir)
base_dir = (root / f"uploads/workspace_{ws_id}").resolve()
fp = (base_dir / filename).resolve()
try:
fp.relative_to(base_dir)
except ValueError:
return JSONResponse({"error": "Path traversal denied"}, status_code=403)
if not fp.exists():
return JSONResponse({"error": "File not found"}, status_code=404)
mime, _ = mimetypes.guess_type(str(fp))
content = fp.read_bytes()
from fastapi.responses import Response
return Response(content=content, media_type=mime or "application/octet-stream")
@router.put("/api/local-workspace/items/{item_id:int}/move")
def move_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
"""Move an item to a new parent (drag & drop)."""
new_parent_id = body.get("parent_id") # None = move to root
with get_conn() as conn:
conn.execute(
"UPDATE pages SET parent_id=? WHERE id=?",
(new_parent_id, item_id),
)
conn.commit()
return {"status": "ok"}
@router.post("/api/local-workspace/upload")
async def upload_local_workspace_file(request: Request):
"""Upload one or more files via drag-and-drop.
Accepts multipart form with 'files' field (one or multiple files).
Optional: 'parent_id' to place files in a specific folder.
Stores files on disk at /data/uploads/workspace_{id}/ and creates DB records.
"""
import json
from pathlib import Path
uid = _require_user_id(request) # A22 : pas d'upload anonyme
ws = _get_active_workspace(request, user_id=uid)
ws_id = ws["id"] if ws else None
if not ws_id:
return JSONResponse({"error": "No active workspace"}, status_code=400)
try:
form = await request.form()
except Exception:
return JSONResponse({"error": "Invalid form data"}, status_code=400)
parent_id_raw = form.get("parent_id")
parent_id = int(parent_id_raw) if parent_id_raw else None
files = form.getlist("files")
if not files:
return JSONResponse({"error": "No files provided"}, status_code=400)
from app.middleware.security import validate_upload
data_root = Path(settings.data_dir)
upload_dir = data_root / f"uploads/workspace_{ws_id}"
upload_dir.mkdir(parents=True, exist_ok=True)
results = []
with get_conn() as conn:
for f in files:
filename = f.filename or "untitled"
# Sanitize filename: only keep basename, prevent path traversal
safe_name = Path(filename).name
if not safe_name:
safe_name = "untitled"
# Unique filename on disk
file_path = upload_dir / safe_name
stem, suffix = file_path.stem, file_path.suffix
counter = 1
while file_path.exists():
file_path = upload_dir / f"{stem} ({counter}){suffix}"
counter += 1
content = await f.read()
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
if err:
results.append({"name": safe_name, "error": err})
continue
file_path.write_bytes(content)
# Determine if this is a folder marker or actual file
rel_path = str(file_path.relative_to(data_root))
size = len(content)
mime = f.content_type or "application/octet-stream"
cursor = conn.execute(
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
VALUES ('', ?, ?, ?, 'file', 'Private', ?)""",
(ws_id, file_path.name,
json.dumps({"file_path": rel_path, "size": size, "mime_type": mime}),
parent_id),
)
results.append({"id": cursor.lastrowid, "name": file_path.name, "type": "page", "size": size})
conn.commit()
return {"status": "ok", "items": results}
@router.post("/api/local-workspace/upload-folder")
async def upload_local_workspace_folder(request: Request):
"""Handle recursive folder upload.
Frontend walks the directory tree with webkitGetAsEntry and sends:
- 'structure': JSON array of {path: str, type: 'folder'|'file'}
- 'files': multipart files (one per file in the structure)
- 'parent_id': target folder (optional)
Creates folders first, then uploads files into their respective folders.
"""
import json
from pathlib import Path
uid = _require_user_id(request) # A22 : pas d'upload anonyme
ws = _get_active_workspace(request, user_id=uid)
ws_id = ws["id"] if ws else None
if not ws_id:
return JSONResponse({"error": "No active workspace"}, status_code=400)
try:
form = await request.form()
except Exception:
return JSONResponse({"error": "Invalid form data"}, status_code=400)
parent_id_raw = form.get("parent_id")
root_parent_id = int(parent_id_raw) if parent_id_raw else None
structure_raw = form.get("structure")
if not structure_raw:
return JSONResponse({"error": "No structure provided"}, status_code=400)
try:
structure = json.loads(structure_raw)
except json.JSONDecodeError:
return JSONResponse({"error": "Invalid structure JSON"}, status_code=400)
from app.middleware.security import validate_upload
data_root = Path(settings.data_dir)
upload_dir = data_root / f"uploads/workspace_{ws_id}"
upload_dir.mkdir(parents=True, exist_ok=True)
results = []
created_folders = {} # relative_path -> db_id
with get_conn() as conn:
# Phase 1: Create all folders
for item in structure:
if item.get("type") != "folder":
continue
path_parts = item["path"].strip("/").split("/")
folder_name = path_parts[-1]
# Determine parent: parent of this folder in the tree
if len(path_parts) == 1:
actual_parent = root_parent_id
else:
parent_path = "/".join(path_parts[:-1])
actual_parent = created_folders.get(parent_path)
cursor = conn.execute(
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
VALUES ('', ?, ?, '', 'blocks', 'Workspace', ?)""",
(ws_id, folder_name, actual_parent),
)
fid = cursor.lastrowid
created_folders[item["path"].strip("/")] = fid
results.append({"id": fid, "name": folder_name, "type": "folder"})
# Phase 2: Upload files into their respective folders
for item in structure:
if item.get("type") != "file":
continue
path_parts = item["path"].strip("/").split("/")
file_name = path_parts[-1]
if len(path_parts) == 1:
file_parent = root_parent_id
else:
parent_path = "/".join(path_parts[:-1])
file_parent = created_folders.get(parent_path)
# Find the matching file in multipart data
matched = None
for f in form.getlist("files"):
if f.filename and (f.filename == item["path"] or f.filename.endswith("/" + file_name)):
matched = f
break
if not matched:
continue
safe_name = Path(file_name).name
file_path = upload_dir / safe_name
stem, suffix = file_path.stem, file_path.suffix
counter = 1
while file_path.exists():
file_path = upload_dir / f"{stem} ({counter}){suffix}"
counter += 1
content = await matched.read()
err = validate_upload(safe_name, len(content)) # A22 : taille + extension
if err:
results.append({"name": safe_name, "error": err})
continue
file_path.write_bytes(content)
rel_path = str(file_path.relative_to(data_root))
cursor = conn.execute(
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id)
VALUES ('', ?, ?, ?, 'file', 'Private', ?)""",
(ws_id, file_path.name,
json.dumps({"file_path": rel_path, "size": len(content), "mime_type": matched.content_type or "application/octet-stream"}),
file_parent),
)
results.append({"id": cursor.lastrowid, "name": file_path.name, "type": "page", "size": len(content)})
conn.commit()
return {"status": "ok", "items": results}
# ═══════════ Workspaces CRUD ═══════════
+240
View File
@@ -0,0 +1,240 @@
"""FlowDeck — Dashboard : pages_api.
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Body, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"])
# ═══════════ Library page actions API ═══════════
@router.get("/api/pages/{page_id:int}/content")
def api_page_content(page_id: int):
"""Get page content for side peek preview."""
with get_conn() as conn:
row = conn.execute(
"SELECT title, content, content_format FROM pages WHERE id=? AND deleted_at IS NULL",
(page_id,),
).fetchone()
if not row:
return JSONResponse({"error": "Not found"}, status_code=404)
# v6.5.0: resolve synced blocks server-side (fresh content on read).
from app.services.synced_blocks import resolve_content_json
return {
"title": row["title"],
"content": resolve_content_json(row["content"], row["content_format"]),
"format": row["content_format"] or "blocks",
}
@router.put("/api/pages/{page_id:int}/rename")
def api_rename_page(page_id: int, request: Request, body: dict = Body(default={})):
"""Inline rename a page title."""
title = (body.get("title") or "").strip()
if not title:
return JSONResponse({"error": "Title required"}, status_code=400)
with get_conn() as conn:
conn.execute(
"UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL",
(title, page_id),
)
# v6.5.0: renaming a database row's content page updates the row.
from app.services.row_pages import sync_page_title_to_row
sync_page_title_to_row(conn, page_id)
conn.commit()
return {"status": "ok", "title": title}
@router.post("/api/pages/{page_id:int}/trash")
def api_trash_page(page_id: int):
"""Soft-delete a page (move to trash)."""
with get_conn() as conn:
conn.execute(
"UPDATE pages SET parent_section='Trash', deleted_at=CURRENT_TIMESTAMP WHERE id=?",
(page_id,),
)
conn.commit()
return {"status": "ok"}
@router.post("/api/pages/{page_id:int}/convert-to-database")
def api_convert_to_database(page_id: int, request: Request, body: dict = Body(default={})):
"""Convert a page into a full-page database (Notion-style).
Creates a collection linked to this page, adds the default 'Name' property,
and sets the page's content_format to 'collection'.
"""
import json as _json
db_name = (body.get("name") or "").strip()
with get_conn() as conn:
page = conn.execute(
"SELECT id, title, workspace_id FROM pages WHERE id=? AND deleted_at IS NULL",
(page_id,),
).fetchone()
if not page:
return JSONResponse({"error": "Page not found"}, status_code=404)
if not db_name:
db_name = page["title"] or "New Database"
# Create the collection
cur = conn.execute(
"""INSERT INTO collections
(name, description, icon, schema_json, is_inline, parent_page_id, workspace_id)
VALUES (?, '', '📋', '[]', 0, ?, ?)""",
(db_name, page_id, page["workspace_id"]),
)
collection_id = cur.lastrowid
# Create default "Name" property (text, position 0)
conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, position, required, visible_in_views)
VALUES (?, 'Name', 'title', 0, 1, 1)""",
(collection_id,),
)
# Create default "Table" view
conn.execute(
"""INSERT INTO collection_views
(collection_id, name, view_type, config_json, position)
VALUES (?, 'Table', 'table', ?, 0)""",
(collection_id, _json.dumps({"visible_properties": ["Name"]})),
)
# Update the page to be a database page
conn.execute(
"UPDATE pages SET content_format='collection', collection_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(collection_id, page_id),
)
conn.commit()
return {
"status": "converted",
"collection_id": collection_id,
"name": db_name,
"view_url": f"/pages/{page_id}",
}
@router.get("/api/collections/{collection_id:int}/table-data")
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(
"SELECT * FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not coll:
return JSONResponse({"error": "Collection not found"}, status_code=404)
properties = [
dict(r) for r in conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
]
pages = [
dict(r) for r in conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
]
views = [
dict(r) for r in conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
]
return {
"collection": dict(coll),
"properties": properties,
"pages": pages,
"views": views,
}
@router.post("/api/collections/{collection_id:int}/pages")
def api_create_collection_page(collection_id: int, request: Request, body: dict = Body(default={})):
"""Create a new page (row) in a collection."""
import json as _json
title = body.get("title", "New page").strip() or "New page"
icon = body.get("icon", "file")
with get_conn() as conn:
coll = conn.execute(
"SELECT id FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not coll:
return JSONResponse({"error": "Collection not found"}, status_code=404)
# Get next position
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
(collection_id,),
).fetchone()[0]
# Load default property values from collection properties
props = [
dict(r) for r in conn.execute(
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
]
default_values = {}
for p in props:
if p["prop_type"] == "title":
default_values[str(p["id"])] = title
# Caller-provided values (e.g. board "add card in column X") win.
incoming = body.get("properties") or {}
if isinstance(incoming, dict):
default_values.update(incoming)
from app.services.property_types import apply_auto_properties
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) or {"login": "admin", "id": 1}
apply_auto_properties(props, default_values, user, is_create=True)
cur = conn.execute(
"""INSERT INTO collection_pages
(collection_id, title, icon, cover_url, position, property_values_json)
VALUES (?, ?, ?, ?, ?, ?)""",
(collection_id, title, icon, body.get("cover_url", ""), max_pos,
_json.dumps(default_values)),
)
page_id = cur.lastrowid
conn.commit()
return {
"id": page_id,
"title": title,
"icon": icon,
"position": max_pos,
"property_values_json": default_values,
"status": "created",
}
+485
View File
@@ -0,0 +1,485 @@
"""FlowDeck — Dashboard : pages_html.
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth.session import SessionManager
from app.db import get_conn
from ._common import _get_active_workspace, _get_user_id, _nav_breadcrumb, _sidebar_data
logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"])
@router.get("/trash", response_class=HTMLResponse)
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
env = ENV
sidebar = board_sidebar(request, owner, repo)
with get_conn() as conn:
ws_key = f"{owner}/{repo}" if owner and repo else ""
# Pages are soft-deleted via parent_section='Trash'
if ws_key:
rows = conn.execute(
"SELECT id, title, workspace FROM pages WHERE parent_section='Trash' AND workspace=? ORDER BY updated_at DESC",
(ws_key,),
).fetchall()
else:
rows = conn.execute(
"SELECT id, title, workspace FROM pages WHERE parent_section='Trash' ORDER BY updated_at DESC",
).fetchall()
sidebar["trash_items"] = [{"id": r["id"], "name": r["title"] or "Untitled", "workspace": r["workspace"]} for r in rows]
# Pass active workspace for breadcrumb nav menu
ws = _get_active_workspace(request, user_id=_get_user_id(request))
sidebar["nav_workspace_id"] = ws["id"] if ws else 0
template = env.get_template("trash.html")
return template.render(**sidebar)
@router.get("/library", response_class=HTMLResponse)
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.
"""
from app.routers.board import _sidebar_data as board_sidebar
from app.templating import ENV
env = ENV
sidebar = board_sidebar(request, owner, repo)
# Pass active workspace for breadcrumb nav menu
ws_key_ws = f"{owner}/{repo}" if owner and repo else ""
if ws_key_ws:
with get_conn() as conn:
ws_row = conn.execute("SELECT id FROM workspaces WHERE name=? AND owner_id=?", (ws_key_ws, _get_user_id(request))).fetchone()
sidebar["nav_workspace_id"] = ws_row["id"] if ws_row else 0
else:
ws = _get_active_workspace(request, user_id=_get_user_id(request))
sidebar["nav_workspace_id"] = ws["id"] if ws else 0
template = env.get_template("library.html")
sidebar["active_workspace_id"] = sidebar.get("nav_workspace_id", 0)
# Gitea workspace context for Repository tab
sidebar["is_gitea_workspace"] = bool(owner and repo)
sidebar["gitea_workspace_owner"] = owner
sidebar["gitea_workspace_repo"] = repo
return template.render(**sidebar)
@router.get("/pages/{page_id}", response_class=HTMLResponse)
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"
from app.routers.board import _sidebar_data as board_sidebar
from app.templating import ENV
env = ENV
with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
return RedirectResponse("/workspaces", status_code=302)
page = dict(row)
# v6.5.0: synced blocks resolve server-side at read time.
from app.services.synced_blocks import resolve_content_json
page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format"))
ws = page.get("workspace", "")
parts = ws.split("/") if "/" in ws else ["", ""]
owner, repo = parts[0], parts[1] if len(parts) > 1 else ""
sidebar = board_sidebar(request, owner, repo)
# Load sub-pages
with get_conn() as conn:
subs = conn.execute(
"SELECT id, title FROM pages WHERE parent_id=? ORDER BY updated_at DESC",
(page_id,),
).fetchall()
fav = conn.execute(
"SELECT id FROM favorites WHERE user_id=? AND page_id=?",
(1, page_id),
).fetchone()
# Build page_data, including file metadata for uploaded files
_locked = bool(page.get("is_locked", 0))
_locked_by = page.get("locked_by") if "locked_by" in page else None
_sess_user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
_uid = _sess_user.get("id") if _sess_user and _sess_user.get("id") else None
_can_edit = (not _locked) or bool(_sess_user and _sess_user.get("is_admin")) or (_locked_by and _uid and _locked_by == _uid)
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)),
"is_locked": _locked,
"locked_by": _locked_by,
"can_edit": _can_edit,
"full_width": bool(page.get("full_width", 0)) if "full_width" in page else False,
"font_small": bool(page.get("font_small", 0)) if "font_small" in page else False}
# For file pages, extract file metadata and add to page_data
if page.get("content_format") == "file":
import json as _json
try:
meta = _json.loads(page.get("content", "{}"))
except _json.JSONDecodeError:
meta = {}
file_path = meta.get("file_path", "").replace("\\", "/")
mime_type = meta.get("mime_type", "application/octet-stream")
file_size = meta.get("size", 0)
fp_parts = file_path.split("/")
ws_id = ""
for p in fp_parts:
if p.startswith("workspace_"):
ws_id = p.replace("workspace_", "")
break
filename = fp_parts[-1] if fp_parts else page.get("title", "File")
from urllib.parse import quote
safe_name = quote(filename, safe='')
file_url = f"/api/files/{ws_id}/{safe_name}" if ws_id else ""
page_data["file_url"] = file_url
page_data["file_mime"] = mime_type
page_data["file_size"] = file_size
page_data["file_name"] = filename
# For collection (database) pages, load collection + properties + pages
collection_data = None
if page.get("content_format") == "collection" and page.get("collection_id"):
with get_conn() as conn:
col = conn.execute(
"SELECT * FROM collections WHERE id=?", (page["collection_id"],)
).fetchone()
if col:
props = [
dict(r) for r in conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(page["collection_id"],),
).fetchall()
]
cpages = [
dict(r) for r in conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
(page["collection_id"],),
).fetchall()
]
cviews = [
dict(r) for r in conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
(page["collection_id"],),
).fetchall()
]
collection_data = {
"collection": dict(col),
"properties": props,
"pages": cpages,
"views": cviews,
}
page_data["collection_id"] = page["collection_id"]
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": 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")
else:
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
response = template.render(**ctx)
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
@router.get("/accounts", response_class=HTMLResponse)
def accounts_page(request: Request):
"""Account management panel."""
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
with get_conn() as conn:
users = conn.execute(
"SELECT id, login, full_name, email, avatar_url, avatar_color, "
"is_admin, is_active, created_at FROM users ORDER BY created_at DESC"
).fetchall()
ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]}
template = env.get_template("accounts.html")
return template.render(**ctx)
@router.get("/help", response_class=HTMLResponse)
def help_page(request: Request):
"""Comprehensive help & documentation page."""
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [])
# Render via a block-based template so content_html lands in {% block content %}
block_tpl = env.from_string(
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
)
return HTMLResponse(block_tpl.render(
**sidebar,
request=request,
page_title="Help",
title_prefix="Help",
page_icon="❓",
content_html="""<style>
.help-page{max-width:900px;margin:0 auto;padding:40px 24px 80px;}
.help-hero{text-align:center;margin-bottom:48px;}
.help-hero h1{font-size:32px;font-weight:800;margin:0 0 8px;}
.help-hero p{font-size:16px;color:var(--text-dim);max-width:500px;margin:0 auto;}
.help-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px;margin-bottom:48px;}
.help-card{background:var(--bg-card);border:1px solid var(--border);border-radius:12px;padding:24px;transition:border-color .15s;}
.help-card:hover{border-color:rgba(255,255,255,.12);}
.help-card h3{font-size:15px;font-weight:600;margin:0 0 4px;display:flex;align-items:center;gap:8px;}
.help-card .icon{font-size:20px;}
.help-card p{font-size:13px;color:var(--text-dim);line-height:1.5;margin:8px 0 0;}
.help-card ul{list-style:none;padding:0;margin:12px 0 0;}
.help-card li{font-size:13px;padding:3px 0;color:var(--text-dim);}
.help-card li::before{content:'• ';color:var(--accent);}
.help-section{margin-bottom:48px;}
.help-section h2{font-size:20px;font-weight:700;margin:0 0 16px;padding-bottom:8px;border-bottom:1px solid var(--border);}
.help-kbd{display:inline-block;padding:2px 8px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:12px;color:var(--text);min-width:16px;text-align:center;}
.help-shortcut-row{display:flex;align-items:center;gap:12px;padding:8px 12px;border-radius:6px;margin-bottom:2px;}
.help-shortcut-row:hover{background:var(--bg-hover);}
.help-shortcut-row .keys{display:flex;gap:4px;min-width:140px;}
.help-shortcut-row .desc{font-size:13px;color:var(--text-dim);}
.help-badge{display:inline-block;padding:2px 10px;border-radius:20px;font-size:11px;font-weight:600;}
.help-badge.local{background:rgba(35,131,226,.15);color:#2C8CEB;}
.help-badge.gitea{background:rgba(0,200,100,.15);color:#00CC66;}
.help-badge.github{background:rgba(130,80,220,.15);color:#A060F0;}
.help-badge.sso{background:rgba(217,115,13,.18);color:#E0952B;}
</style>
<div class="help-page">
<div class="help-hero">
<h1>❓ FlowDeck Help</h1>
<p>Everything you need to know about your Notion-style workspace with Gitea & GitHub integration.</p>
</div>
<div class="help-grid">
<div class="help-card">
<h3><span class="icon">🚀</span>Getting Started</h3>
<p>FlowDeck is your private, self-hosted workspace. Create pages, organize projects, and integrate with your Git forge.</p>
<ul>
<li>Create a workspace from the <b>Workspaces</b> page</li>
<li>Click <b>📄 New Page</b> in the sidebar to start writing</li>
<li>Use <span class="help-kbd">Ctrl+N</span> anywhere to create a page</li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">📝</span>Pages & Editor</h3>
<p>Notion-style block editor with slash commands, markdown shortcuts, and rich formatting.</p>
<ul>
<li>Type <span class="help-kbd">/</span> for the slash command menu</li>
<li>Drag & drop pages in the sidebar to reorganize</li>
<li>Right-click for context menu (duplicate, rename, delete)</li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">{{ fd_icon("folder",16) }}</span>Workspaces</h3>
<p>Organize your work into separate workspaces. Each has its own pages and files.</p>
<ul>
<li><span class="help-badge local">Local</span> Files stored on your server</li>
<li><span class="help-badge gitea">Gitea</span> Connect to browse & edit repos</li>
<li><span class="help-badge github">GitHub</span> Connect via Settings → Integrations</li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">🦎</span>Gitea Integration</h3>
<p>Connect your Gitea account to access repositories directly from FlowDeck.</p>
<ul>
<li>Go to <b>Settings → Integrations</b> to connect</li>
<li>Browse repo file trees in the sidebar</li>
<li>Create & edit files with commit messages</li>
<li>Sync labels as tags</li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">🌐</span>Sharing & Publishing</h3>
<p>Share pages with collaborators or publish them to the web.</p>
<ul>
<li>Click <b>Share</b> in the page editor top-right</li>
<li>Share with specific users or get a public link</li>
<li>Publish to make a page visible at <code>/p/your-slug</code></li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">📚</span>Library, Trash & Tasks</h3>
<p>Find all your content in one place with powerful filtering.</p>
<ul>
<li><b>Library</b> — Tabs for Recents, Favorites, Shared, Published</li>
<li><b>Trash</b> — Soft-deleted pages (30-day retention)</li>
<li><b>My Tasks</b> — Aggregated tasks from all collections</li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">📶</span>Offline & PWA</h3>
<p>Install FlowDeck as an app and keep working without a connection.</p>
<ul>
<li><b>Install</b> — browser menu → <i>Install app</i> / <i>Add to Home Screen</i></li>
<li>Edits made offline are queued locally and synced automatically</li>
<li>A <b>⟳</b> marker shows pages with pending changes</li>
</ul>
</div>
</div>
<div class="help-section">
<h2>⌨️ Keyboard Shortcuts</h2>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">N</span></div><div class="desc">Create new page</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">K</span></div><div class="desc">Quick find / command palette</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">S</span></div><div class="desc">Save current page</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">F2</span></div><div class="desc">Rename selected item</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Delete</span></div><div class="desc">Move selected item to trash</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Escape</span></div><div class="desc">Close modal / cancel editing</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Enter</span></div><div class="desc">Open selected page</div></div>
<div class="help-shortcut-row"><div class="keys"><span class="help-kbd">Ctrl</span>+<span class="help-kbd">O</span></div><div class="desc">New AI chat (in footer)</div></div>
</div>
<div class="help-section">
<h2>🔐 Authentication</h2>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
FlowDeck supports three authentication methods:<br>
<span class="help-badge local">Local</span> Email + password — create an account on the login page.<br>
<span class="help-badge gitea">Gitea OAuth</span> Login with your Gitea account. Your repos appear as workspaces.<br>
<span class="help-badge github">GitHub OAuth</span> Login or link your GitHub account in Settings → Integrations.<br><br>
<b>Tip:</b> You can connect Gitea/GitHub to an existing local account — your identity stays as your local user.
</p>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
<span class="help-badge sso">SSO</span> <b>Enterprise SSO</b> (v6.7.0) — sign in with your organization account.<br>
<i>For administrators:</i> open <b>Settings → Admin → SSO / Enterprise</b> and pick a provider:<br>
• <b>SAML 2.0</b> — paste the IdP <i>Entity ID</i>, <i>SSO URL</i> and signing certificate, then give the IdP this
<code>/auth/saml/metadata</code> link (it contains the SP Entity ID, ACS URL and certificate).<br>
• <b>OpenID Connect</b> — paste the <i>Issuer URL</i>, <i>Client ID</i> and <i>Client Secret</i> (PKCE is used, scopes default to <code>openid profile email</code>).<br>
• <b>Provisioning</b> — accounts are created automatically on first login, groups from the IdP map to workspace roles,
and <i>SSO only</i> disables local login (admins keep their local door). Every attempt is audited in
<b>Settings → Admin → SSO / Enterprise</b> (login history).
</p>
</div>
<div class="help-section">
<h2>📶 Offline mode (PWA)</h2>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
FlowDeck is a Progressive Web App: pages you visited stay available offline and your
edits are saved locally, then synchronised when the connection returns.<br><br>
<b>Install:</b> open your browser menu and choose <i>Install app</i> (Chrome/Edge) or
<i>Add to Home Screen</i> (Safari/iOS). FlowDeck then opens in its own window.<br>
<b>Offline editing:</b> while offline, the editor stores changes in the browser
(IndexedDB) and shows an offline banner with the number of pending changes. A
<b>⟳</b> icon appears next to pages that have unsynced edits.<br>
<b>Reconnection:</b> the queue is replayed automatically (and via Background Sync).
A spinner badge appears while syncing, followed by a confirmation toast.<br>
<b>Conflicts:</b> if a page changed on the server, the latest edit wins and a notice is
shown. If a page was deleted server-side, your offline copy is recreated as an orphan
page. If a page with the same title already exists, the offline copy is renamed
<i>“Title (copie offline)”</i>.
</p>
</div>
<div class="help-section">
<h2>🔌 API publique v2</h2>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
FlowDeck exposes a full REST API under <b>/api/v2</b> for third-party integrations.<br>
<b>Auth:</b> create a token in Settings → API tokens, then send it as
<code>Authorization: Bearer &lt;token&gt;</code>. Tokens carry scopes
<code>read</code>, <code>write</code> or <code>admin</code> (a higher scope implies the lower ones).<br>
<b>Features:</b> CRUD on collections, pages, properties, views, comments, notifications,
favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&amp;offset=</code> +
<code>X-Total-Count</code>), filters (<code>filter[prop]=value</code>), sorting, full-text search
(<code>/api/v2/search</code>), idempotency (<code>Idempotency-Key</code>) and RFC 7807 error bodies.<br>
<b>Reference:</b> interactive OpenAPI docs at <a href="/docs" target="_blank" rel="noopener">/docs</a>
(also <code>/redoc</code>, <code>docs/openapi-v2.json</code>).
</p>
</div>
<div class="help-section">
<h2>💡 Tips</h2>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
• Toggle the sidebar with the <b>«</b> button in the top-left corner.<br>
• Switch between workspaces using the dropdown menu in the sidebar header.<br>
• The <b>Private</b> section appears when a remote workspace is active — files here stay local.<br>
• Hover over any sidebar item to see action buttons (favorite, share, delete).<br>
• Use <b>Ctrl+Click</b> or <b>Shift+Click</b> to multi-select items in the sidebar.
</p>
</div>
</div>"""
))
@router.get("/accounts/settings", response_class=HTMLResponse)
def settings_page(request: Request):
"""User settings page — profile, forges, tokens."""
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
from fastapi.responses import RedirectResponse
return RedirectResponse("/auth/login?provider=local", status_code=302)
# Check forge connections
gitea_connected = False
github_connected = False
if user.get("id"):
with get_conn() as conn:
tokens = conn.execute(
"SELECT provider FROM user_oauth_tokens WHERE user_id=?", (user["id"],)
).fetchall()
for t in tokens:
if t["provider"] == "gitea":
gitea_connected = True
elif t["provider"] == "github":
github_connected = True
ctx = {**sidebar, "user": user, "gitea_connected": gitea_connected, "github_connected": github_connected}
template = env.get_template("settings.html")
response = template.render(**ctx)
return HTMLResponse(content=response)
# ═══════════ User API endpoints ═══════════
+78
View File
@@ -0,0 +1,78 @@
"""FlowDeck — Dashboard : public.
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from app.db import get_conn
from ._common import _render_blocks_public
logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"])
# ═══════════ Public Published Page ═══════════
@router.get("/p/{slug}", response_class=HTMLResponse)
def public_published_page(request: Request, slug: str):
"""Serve a published page at /p/<slug> — no auth required."""
from app.templating import ENV
with get_conn() as conn:
row = conn.execute(
"SELECT id, title, content, content_format, updated_at, created_at, cover_url, page_icon "
"FROM pages WHERE publish_slug=? AND is_published=1",
(slug,),
).fetchone()
if not row:
return HTMLResponse(
"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8">
<title>Not Found — FlowDeck</title>
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;
justify-content:center;height:100vh;margin:0;background:#191919;color:#ccc;}
h1{font-size:3rem;opacity:.3}</style></head><body><h1>404</h1></body></html>""",
status_code=404,
)
page = dict(row)
env = ENV
# Convert blocks to HTML for rendering
content_html = ""
if page.get("content_format") == "blocks" and page.get("content"):
import json as _json
try:
blocks = _json.loads(page["content"])
from app.services.synced_blocks import resolve_synced_block
blocks = resolve_synced_block(blocks)
from app.db import get_conn as _gc
from app.services.wiki_links import token_labels
with _gc() as conn:
wiki_titles_map = token_labels(conn, page["content"])
content_html = _render_blocks_public(blocks, wiki_titles_map)
except (_json.JSONDecodeError, Exception):
content_html = f"<p>{page.get('content', '')}</p>"
elif page.get("content"):
# Plain text / markdown
text = page["content"]
content_html = f"<pre style='white-space:pre-wrap;font-family:system-ui;font-size:16px;line-height:1.6;'>{text}</pre>"
template = env.get_template("public_page.html")
return template.render(
title=page["title"] or "Untitled",
content_html=content_html,
updated_at=page.get("updated_at", ""),
created_at=page.get("created_at", ""),
cover_url=page.get("cover_url", ""),
page_icon=page.get("page_icon", ""),
)
+321
View File
@@ -0,0 +1,321 @@
"""FlowDeck — Dashboard : workspace.
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Body, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.gitea_client import get_user_gitea_client, gitea
from ._common import _get_active_workspace, _get_user_id, _sidebar_data
logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"])
@router.get("/", response_class=HTMLResponse)
async def dashboard(
request: Request,
search: str = Query(default=""),
show_archived: bool = Query(default=False),
):
"""Smart root route: landing for visitors, local workspace for new users, dashboard for Gitea users."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
# ── Not authenticated → show landing page ──
if not user:
# Allow through if DB is empty (fresh install)
try:
with get_conn() as conn:
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if count == 0:
from app.templating import ENV
env = ENV
template = env.get_template("landing.html")
return template.render()
except Exception:
logger.exception("dashboard")
from app.templating import ENV
env = ENV
template = env.get_template("landing.html")
return template.render()
# ── Authenticated ──
user_id = user.get("id", 1)
has_gitea = False
try:
with get_conn() as conn:
tok = conn.execute(
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider='gitea'",
(user_id,),
).fetchone()
has_gitea = bool(tok)
except Exception:
logger.exception("dashboard")
if not has_gitea:
# Check if user has any workspace
try:
with get_conn() as conn:
ws_count = conn.execute(
"SELECT COUNT(*) FROM workspaces WHERE owner_id=?", (user_id,)
).fetchone()[0]
if ws_count == 0:
# v5.2.0: first-launch → onboarding wizard
return RedirectResponse("/welcome", status_code=302)
except Exception:
logger.exception("dashboard")
return RedirectResponse("/local-workspace", status_code=302)
# ── Gitea user → full dashboard ──
try:
repos = await gitea.get_user_repos(page=1, limit=50)
if search:
q = search.lower()
repos = [r for r in repos if q in r.get("full_name", "").lower()
or q in (r.get("description") or "").lower()]
if not show_archived:
repos = [r for r in repos if not r.get("archived", False)]
repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
except Exception as e:
logger.error("Dashboard error: %s", e)
repos = []
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, repos)
template = env.get_template("dashboard.html")
return template.render(request=request, repos=repos, search=search,
show_archived=show_archived, **sidebar)
# ═══════════ Workspace ═══════════
# ═══════════ Workspace ═══════════
@router.get("/workspace", response_class=HTMLResponse)
def workspace_page(request: Request):
"""Unified workspace showing all projects."""
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302)
ctx = {**sidebar, "user": user}
# Pass active workspace for breadcrumb nav menu
ws = _get_active_workspace(request, user_id=_get_user_id(request))
ctx["nav_workspace_id"] = ws["id"] if ws else 0
template = env.get_template("workspace.html")
return template.render(**ctx)
@router.get("/gitea-workspace", response_class=HTMLResponse)
def gitea_workspace_page(request: Request):
"""Gitea workspace — browse repo files."""
import json
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302)
owner = request.query_params.get("owner", "")
repo = request.query_params.get("repo", "")
ws_key = f"{owner}/{repo}" if owner and repo else ""
ws_name = ws_key or "Gitea Workspace"
# Auto-create local workspace mirror for storing local files
local_ws_id = None
if ws_key:
with get_conn() as conn:
existing = conn.execute(
"SELECT id, owner_id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
(user["id"], ws_key, "%gitea_repo%")
).fetchone()
if existing:
local_ws_id = existing["id"]
else:
c = conn.execute(
"INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)",
(ws_key, user["id"], json.dumps({"gitea_repo": ws_key, "gitea_owner": owner}))
)
local_ws_id = c.lastrowid
conn.execute(
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
(local_ws_id, user["id"], "admin")
)
conn.commit()
ctx = {
**sidebar,
"user": user,
"active_ws_name": ws_name,
"workspace_key": ws_key,
"gitea_workspace": True, # always true on this page
"gitea_owner": owner,
"gitea_repo": repo,
"workspace_name": ws_name,
"workspace_initial": repo[0].upper() if repo else "G",
"owner": owner,
"repo": repo,
"nav_workspace_id": local_ws_id or 0, # for breadcrumb nav menu
}
template = env.get_template("gitea_workspace.html")
resp = HTMLResponse(content=template.render(**ctx))
resp.set_cookie("flowdeck_workspace", f"gitea:{owner}:{repo}", path="/", samesite="lax")
return resp
@router.get("/api/workspace/projects")
async def list_workspace_projects(request: Request):
"""List all projects: built-in + Gitea + GitHub.
Uses the user's own Gitea token if connected, not the global admin token."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
builtin = []
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL ORDER BY updated_at DESC LIMIT 20"
).fetchall()
# A23 : une seule agrégation GROUP BY au lieu d'un COUNT par ligne.
counts = {}
if rows:
for c in conn.execute(
"SELECT parent_id, COUNT(*) AS c FROM pages WHERE parent_id IN ({}) GROUP BY parent_id".format(
",".join("?" * len(rows))
),
[r["id"] for r in rows],
).fetchall():
counts[c["parent_id"]] = c["c"]
for r in rows:
builtin.append({"id": str(r["id"]), "name": r["title"] or "Untitled", "pageCount": counts.get(r["id"], 0), "forge": "builtin"})
gitea_repos = []
# Use per-user token if available, otherwise return empty
user_gitea = get_user_gitea_client(request) if user else None
if user_gitea:
try:
repos = await user_gitea.get_user_repos(page=1, limit=50)
for repo in repos:
gitea_repos.append({
"id": str(repo.get("id", "")),
"name": repo.get("name", ""),
"full_name": repo.get("full_name", ""),
"description": repo.get("description", ""),
"html_url": repo.get("html_url", ""),
"language": repo.get("language", ""),
"forge": "gitea",
})
except Exception:
logger.exception("list_workspace_projects")
return {"builtin": builtin, "gitea": gitea_repos, "github": []}
@router.post("/api/workspace/projects")
def create_workspace_project(request: Request, body: dict = Body(default={})):
name = body.get("name", "").strip()
if not name:
return {"error": "Name required"}
with get_conn() as conn:
cursor = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, parent_section) VALUES ('', ?, '', 'blocks', 'Private')",
(name,),
)
conn.commit()
pid = cursor.lastrowid
return {"id": pid, "name": name, "forge": "builtin"}
# ═══════════ Workspace Members API ═══════════
# ═══════════ Workspace Members API ═══════════
@router.get("/api/workspace/{ws_id:int}/members")
def list_members(request: Request, ws_id: int):
"""List all members of a workspace."""
with get_conn() as conn:
rows = conn.execute(
"""SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role, wm.joined_at
FROM workspace_members wm JOIN users u ON u.id = wm.user_id
WHERE wm.workspace_id=? ORDER BY wm.joined_at""", (ws_id,)
).fetchall()
return {"members": [dict(r) for r in rows]}
@router.post("/api/workspace/{ws_id:int}/members")
def invite_member(request: Request, ws_id: int, body: dict = Body(default={})):
"""Invite a user to a workspace by email."""
email = body.get("email", "").strip()
role = body.get("role", "editor")
if role not in ("owner", "admin", "editor", "viewer"):
return {"error": "Invalid role"}, 400
with get_conn() as conn:
user = conn.execute("SELECT id FROM users WHERE login=? OR email=?", (email, email)).fetchone()
if not user:
return {"error": "User not found"}, 404
try:
conn.execute(
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)",
(ws_id, user["id"], role),
)
conn.commit()
except Exception:
return {"error": "Already a member"}, 409
return {"status": "ok", "user_id": user["id"], "role": role}
@router.put("/api/workspace/{ws_id:int}/members/{user_id:int}")
def update_member_role(request: Request, ws_id: int, user_id: int, body: dict = Body(default={})):
"""Change a member's role."""
role = body.get("role", "editor")
if role not in ("owner", "admin", "editor", "viewer"):
return {"error": "Invalid role"}
with get_conn() as conn:
conn.execute(
"UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?",
(role, ws_id, user_id),
)
conn.commit()
return {"status": "ok"}
@router.delete("/api/workspace/{ws_id:int}/members/{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(
"DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?",
(ws_id, user_id),
)
conn.commit()
return {"status": "ok"}
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
+131
View File
@@ -0,0 +1,131 @@
"""FlowDeck — Dashboard : workspaces.
Découpe A28 de l'ancien app/routers/dashboard.py (2 735 lignes, 63 routes) — un module par concern, contrat inchangé.
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Body, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.auth.session import SessionManager
from app.db import get_conn
from ._common import WORKSPACE_COOKIE, _get_active_workspace, _get_user_id, _sidebar_data
logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"])
@router.get("/workspaces", response_class=HTMLResponse)
def workspaces_page(request: Request):
"""Workspaces list page."""
from app.templating import ENV
env = ENV
sidebar = _sidebar_data(request, [], include_workspace=False)
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return RedirectResponse("/auth/login?provider=local&expired=1", status_code=302)
ctx = {**sidebar, "user": user}
# Pass active workspace for breadcrumb nav menu (null on workspaces home)
ws = _get_active_workspace(request, user_id=_get_user_id(request))
ctx["nav_workspace_id"] = ws["id"] if ws else 0
template = env.get_template("workspaces.html")
return template.render(**ctx)
@router.get("/api/workspaces")
def list_workspaces(request: Request):
"""List all workspaces for the current user."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = user["id"] if user and user.get("id") else 1
with get_conn() as conn:
rows = conn.execute(
"SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id AND collection_row_id IS NULL) as page_count "
"FROM workspaces w WHERE w.owner_id=? ORDER BY w.created_at DESC",
(uid,),
).fetchall()
workspaces = []
for r in rows:
d = dict(r)
workspaces.append(d)
active = _get_active_workspace(request, user_id=_get_user_id(request))
return {"workspaces": workspaces, "active_id": active["id"] if active else None}
@router.post("/api/workspaces")
def create_workspace(request: Request, body: dict = Body(default={})):
"""Create a new workspace."""
name = body.get("name", "New Workspace").strip()
if not name:
return {"error": "Name required"}
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = user["id"] if user and user.get("id") else 1
with get_conn() as conn:
# Ensure user exists (FK constraint)
uid_ok = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone()
if not uid_ok:
conn.execute(
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?, ?, ?, 1)",
(uid, user.get("login", "admin") if user else "admin",
user.get("full_name", "Admin") if user else "Admin"),
)
cursor = conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES (?, ?)",
(name, uid),
)
ws_id = cursor.lastrowid
# Add owner as member
conn.execute(
"INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')",
(ws_id, uid),
)
conn.commit()
return {"id": ws_id, "name": name}
@router.put("/api/workspaces/{ws_id:int}")
def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})):
"""Rename a workspace."""
name = body.get("name", "").strip()
if not name:
return {"error": "Name required"}
with get_conn() as conn:
conn.execute("UPDATE workspaces SET name=? WHERE id=?", (name, ws_id))
conn.commit()
return {"status": "ok"}
@router.delete("/api/workspaces/{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,))
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,))
conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,))
conn.commit()
return {"status": "ok"}
@router.post("/api/workspaces/{ws_id:int}/select")
def select_workspace(request: Request, ws_id: int):
"""Set the active workspace via cookie."""
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/")
return response
# ═══════════ Settings Page ═══════════
+2 -2
View File
@@ -163,9 +163,9 @@
</div>
<div style="display:flex;gap:4px;padding:0 4px;">
<input class="ctx-input" x-ref="ctxTagInput" placeholder="Tag name…" @click.stop
@keydown.enter.prevent="$store.fdCtx.addNewTag($event.target.value, $store.fdCtx.newTagColor); $event.target.value = ''"
@keydown.enter.prevent="$store.fdCtx.addTagAndClear($event.target.value, $store.fdCtx.newTagColor, $event.target)"
@keydown.escape.stop="$store.fdCtx.tagAdding = false">
<button type="button" class="ctx-btn" @click.stop="$store.fdCtx.addNewTag($refs.ctxTagInput.value, $store.fdCtx.newTagColor); $refs.ctxTagInput.value = ''">Add</button>
<button type="button" class="ctx-btn" @click.stop="$store.fdCtx.addTagAndClear($refs.ctxTagInput.value, $store.fdCtx.newTagColor, $refs.ctxTagInput)">Add</button>
</div>
</div>
</div>
+8 -5
View File
@@ -60,14 +60,14 @@
{# ── Collapsed "…" segment ── #}
<template x-if="crumb.ellipsis">
<span class="crumb-anchor"
@mouseenter="cancelCloseTimer(); ellipsisOpen = true"
@mouseenter="hoverEllipsis()"
@mouseleave="dragCloseTimer()">
<button type="button" class="breadcrumb-link crumb-ellipsis">&hellip;</button>
<div class="fd-nav-menu" x-show="ellipsisOpen" x-cloak x-transition.opacity
@mouseenter="cancelCloseTimer()"
@mouseleave="dragCloseTimer()">
<template x-for="h in crumb.hidden" :key="h.id">
<div class="fd-nav-item" @click.stop="go(h.url); closeAll()">
<div class="fd-nav-item" @click.stop="goClose(h.url)">
<span class="fd-nav-ico" :title="h.icon || 'file'"></span>
<span class="fd-nav-label" x-text="h.label"></span>
</div>
@@ -91,7 +91,7 @@
{# ── Interactive crumb with navigation dropdown (Notion-style hover) ── #}
<template x-if="!crumb.ellipsis && crumb.menu">
<span class="crumb-anchor"
@mouseenter="cancelCloseTimer(); openMenu(crumb.origIndex)"
@mouseenter="hoverMenu(crumb.origIndex)"
@mouseleave="dragCloseTimer()">
<button type="button" class="breadcrumb-link"
:class="{ 'breadcrumb-current': crumb.origIndex === crumbs.length - 1 }"
@@ -110,7 +110,7 @@
<template x-for="item in activeItems" :key="item.id">
<div class="fd-nav-item"
@mouseenter="openSub(item)"
@click.stop="go(item.url); closeAll()">
@click.stop="goClose(item.url)">
<span class="fd-nav-ico" :title="item.icon || 'file'"></span>
<span class="fd-nav-label" x-text="item.name"></span>
<span class="fd-nav-arrow" x-show="item.has_children">&rsaquo;</span>
@@ -118,7 +118,7 @@
@mouseenter="cancelCloseTimer()"
@mouseleave="dragCloseTimer()">
<template x-for="s in subItems" :key="s.id">
<div class="fd-nav-item" @click.stop="go(s.url); closeAll()">
<div class="fd-nav-item" @click.stop="goClose(s.url)">
<span class="fd-nav-ico" :title="s.icon || 'file'"></span>
<span class="fd-nav-label" x-text="s.name"></span>
<span class="fd-nav-arrow" x-show="s.has_children">&rsaquo;</span>
@@ -152,6 +152,9 @@ document.addEventListener('alpine:init', function () {
wsId: 0,
openIdx: null,
ellipsisOpen: false,
hoverEllipsis(){ this.cancelCloseTimer(); this.ellipsisOpen = true; },
hoverMenu(i){ this.cancelCloseTimer(); this.openMenu(i); },
goClose(u){ this.go(u); this.closeAll(); },
loading: false,
cache: {},
activeItems: [],
+1 -1
View File
@@ -88,7 +88,7 @@ document.addEventListener('alpine:init', function () {
return Math.floor(diff / 86400) + 'd ago';
},
csrf() {
return (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
return getCsrf();
},
refreshCount() {
var self = this;
+17 -17
View File
@@ -50,7 +50,7 @@
@keydown.enter.prevent="inviteEnter()"
@keydown.down.prevent="inviteMove(1)"
@keydown.up.prevent="inviteMove(-1)"
@keydown.escape="inviteSuggestOpen = false; inviteUsers = []"
@keydown.escape="closeInviteSuggest()"
autocomplete="off"
/>
<button class="sd-btn-primary" @click="shareInvite()">
@@ -169,7 +169,7 @@
<div
class="sd-perm-option"
:class="{ active: a.permission === 'edit' }"
@click="updateShare(a.id, 'edit'); a.permOpen=false"
@click="setSharePerm(a, 'edit')"
>
<span>Can edit</span>
<span class="sd-perm-desc"
@@ -179,7 +179,7 @@
<div
class="sd-perm-option"
:class="{ active: a.permission === 'comment' }"
@click="updateShare(a.id, 'comment'); a.permOpen=false"
@click="setSharePerm(a, 'comment')"
>
<span>Can comment</span>
<span class="sd-perm-desc"
@@ -189,7 +189,7 @@
<div
class="sd-perm-option"
:class="{ active: a.permission === 'view' }"
@click="updateShare(a.id, 'view'); a.permOpen=false"
@click="setSharePerm(a, 'view')"
>
<span>Can view</span>
<span class="sd-perm-desc"
@@ -199,7 +199,7 @@
<div class="sd-perm-sep"></div>
<div
class="sd-perm-option danger"
@click="removeShare(a.id); a.permOpen=false"
@click="removeShareAndClose(a)"
>
Remove
</div>
@@ -233,7 +233,7 @@
<div
class="sd-perm-option"
:class="{ active: generalAccess === 'invited' }"
@click="generalAccess='invited'; accessMenuOpen=false"
@click="pickGeneralAccess('invited')"
>
<span>{{ fd_icon('lock',14) }} Only people invited</span>
<span class="sd-perm-desc"
@@ -243,7 +243,7 @@
<div
class="sd-perm-option"
:class="{ active: generalAccess === 'anyone' }"
@click="generalAccess='anyone'; accessMenuOpen=false"
@click="pickGeneralAccess('anyone')"
>
<span>{{ fd_icon('globe',14) }} Anyone with the link</span>
<span class="sd-perm-desc"
@@ -312,7 +312,7 @@
<template x-for="(ic, ici) in ['📄','📝','📋','📊','🗂️','📁','📂','🗒️','🗓️','📌','🔖','⭐','🚀','💡','🎯','🔑','📚','🧪','🌍','💰','📝','🧩','🎨','🔧','⚙️','🗃️','📦','🏷️','📍','🏠','👤']" :key="ici">
<button type="button" class="sd-icon-btn" @click="setIcon(ic)" :class="{ active: iconValue === ic }" style="width:36px;height:36px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);font-size:18px;display:flex;align-items:center;justify-content:center;cursor:pointer;" x-text="ic"></button>
</template>
<input type="text" class="sd-input" x-model="iconValue" placeholder="Custom emoji" style="max-width:80px;" @keydown.enter.prevent="setIcon(iconValue);iconOpen=false;">
<input type="text" class="sd-input" x-model="iconValue" placeholder="Custom emoji" style="max-width:80px;" @keydown.enter.prevent="applyIcon()">
</div>
</div>
</div>
@@ -498,16 +498,16 @@
</div>
<div class="more-menu-item" @click="movePage">↗ Move to</div>
<div class="more-menu-sep"></div>
<div class="more-menu-item" @click="moreOpen=false; toggleLock()">
<div class="more-menu-item" @click="moreToggleLock()">
<span x-text="isLocked ? '🔓 Unlock page' : '🔒 Lock page'"></span>
</div>
<div class="more-menu-item" @click="moreOpen=false; setPageOption('full_width', !fullWidth)">
<div class="more-menu-item" @click="moreFullWidth()">
↔ Full width <span x-show="fullWidth" style="margin-left:auto;font-size:11px">✓</span>
</div>
<div class="more-menu-item" @click="moreOpen=false; setPageOption('font_small', !fontSmall)">
<div class="more-menu-item" @click="moreFontSmall()">
Aa Small text <span x-show="fontSmall" style="margin-left:auto;font-size:11px">✓</span>
</div>
<div class="more-menu-item" @click="moreOpen=false; saveAsTemplate()">
<div class="more-menu-item" @click="moreSaveTemplate()">
📑 Save as template
</div>
<div class="more-menu-sep"></div>
@@ -546,13 +546,13 @@
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,.6) 100%);z-index:1;"></div>
</div>
<div class="page-title-block">
<span class="page-icon-emoji" @click="openIconPicker()" title="Change icon" x-html="iconHtml()"></span>
<span class="page-icon-emoji" @click="openIconPicker()" title="Change icon" x-init="bindIconHtml($el)"></span>
<div
class="page-title-input"
contenteditable="true"
id="_titleEl"
data-placeholder="New Page"
@input="dirty=true;save()"
@input="markAndSave()"
@keydown.enter.prevent="focusBlock()"
@paste.prevent="pastePlain($event)"
spellcheck="false"
@@ -583,7 +583,7 @@
style="display:none;position:fixed;z-index:1100;padding:7px 12px;font-size:13px;font-weight:600;
color:#fff;background:var(--accent,#2383E2);border:none;border-radius:8px;cursor:pointer;
box-shadow:0 4px 16px rgba(0,0,0,.35);" title="Comment on selection"
@click="window.E && window.E.commentOnSelection()">
@click="edCall('commentOnSelection')">
💬 Comment
</button>
@@ -820,7 +820,7 @@
<div style="padding:24px;text-align:center;color:var(--text-dim);">No pages link here</div>
</template>
<template x-for="b in backlinksList" :key="b.id">
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="backlinksOpen=false;window.location.href='/pages/'+b.id">
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="openBacklink(b)">
<div style="font-weight:500;color:var(--text-primary);" x-text="b.title"></div>
<div style="font-size:12px;color:var(--text-dim);margin-top:2px;" x-text="b.workspace || ''"></div>
</a>
@@ -856,7 +856,7 @@
<template x-if="importFile">
<div style="margin-top:8px;display:flex;align-items:center;gap:12px;color:var(--text-dim);font-size:13px;">
<span x-text="importFile.name"></span>
<span x-text="Math.round(importFile.size/1024)+' KB'"></span>
<span x-text="fmtImportSize(importFile)"></span>
</div>
</template>
<div style="display:flex;gap:8px;margin-top:10px;">
+8 -2
View File
@@ -105,6 +105,12 @@
{% block scripts %}
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: accountsData »).
if (window.Alpine) { Alpine.data('accountsData', accountsData); }
else document.addEventListener('alpine:init', function () { Alpine.data('accountsData', accountsData); });
function accountsData() {
return {
profile: { full_name: '', email: '' },
@@ -117,8 +123,8 @@
} catch(e) {}
},
saveProfile() {
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
const token = csrf ? csrf[1] : '';
const csrf = getCsrf();;
const token = csrf;
fetch(`/api/users/me?full_name=${encodeURIComponent(this.profile.full_name)}&email=${encodeURIComponent(this.profile.email)}`, {
method: 'PUT', headers: { 'X-CSRF-Token': token }
}).then(() => {
+2 -2
View File
@@ -231,7 +231,7 @@
<div class="fd-ap-tabs">
<button class="fd-ap-tab" :class="{active: tab==='chat'}" @click="tab='chat'">Conversation</button>
<button class="fd-ap-tab" :class="{active: tab==='history'}" @click="tab='history'; loadConversations()">Historique</button>
<button class="fd-ap-tab" :class="{active: tab==='history'}" @click="showHistory()">Historique</button>
</div>
<div class="fd-agent-body">
@@ -305,7 +305,7 @@
</div>
</div>
<div class="fd-agent-msg-content" x-show="m.generating" x-cloak>Génération…</div>
<div class="fd-agent-msg-content fd-agent-msg-markdown" x-show="!m.generating && m.role==='assistant' && m.content" x-html="renderMarkdown(m.content)"></div>
<div class="fd-agent-msg-content fd-agent-msg-markdown" x-show="!m.generating && m.role==='assistant' && m.content" x-init="bindMarkdown($el, m)"></div>
<div class="fd-agent-msg-content" x-show="!m.generating && m.role!=='assistant' && m.content" x-text="m.content"></div>
<template x-if="m.proposal">
<div class="fd-proposal-bar">
+160 -35
View File
@@ -115,12 +115,21 @@
</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() }}"}'>
<meta name="htmx-config" content='{"inlineScriptNonce": "{{ csp_nonce() }}", "allowEval": false}'>
<script nonce="{{ csp_nonce() }}">
// A38 : helper CSRF unique — défini le plus tôt possible (head) pour
// tous les scripts inline/externes de la page (welcome.html, isolé de
// base, garde sa propre lecture du cookie).
window.getCsrf = function() {
var m = document.cookie.match(/csrf_token=([^;]+)/);
return m ? m[1] : '';
};
</script>
<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/alpine.csp.min.js" defer data-cfasync="false"></script>
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
</head>
<body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'{% if embed_mode %} class="embed-mode"{% endif %}>
<body hx-headers='{"X-CSRF-Token":{{ csrf_token() | tojson }}}'{% if embed_mode %} class="embed-mode"{% endif %}>
<div class="app-layout" x-data="appState()">
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
@@ -179,8 +188,8 @@
:class="{ collapsed: sidebarCollapsed && !sidebarPeek, 'mobile-open': mobileSidebarOpen, peeking: sidebarPeek }"
id="sidebar"
@mouseleave="!sidebarResizing && (sidebarPeek = false)"
x-effect="document.documentElement.classList.toggle('fd-sidebar-collapsed', sidebarCollapsed && !sidebarPeek)"
x-init="$nextTick(() => { if(typeof initTreeSortable==='function') initTreeSortable(); })">
x-effect="syncSidebarClass()"
x-init="initSidebarSort()"
<!-- Header + dropdown wrapper -->
<div style="position:relative;">
<div class="sidebar-workspace-header" @click="workspaceMenuOpen = !workspaceMenuOpen" @mouseenter="wsHeaderHover=true" @mouseleave="wsHeaderHover=false"
@@ -263,8 +272,8 @@
</div>
{% if has_active_workspace %}
<div class="sidebar-section-actions">
<button class="section-action-btn" title="New Page" @click.stop="window.FlowDeck.createPage()">{{ fd_icon("file",16) }}</button>
<button class="section-action-btn" title="New Folder" @click.stop="window.FlowDeck.showCreateFolderModal()">{{ fd_icon("folder",16) }}</button>
<button class="section-action-btn" title="New Page" @click.stop="fdCreatePage()">{{ fd_icon("file",16) }}</button>
<button class="section-action-btn" title="New Folder" @click.stop="fdCreateFolder()">{{ fd_icon("folder",16) }}</button>
<a class="section-action-btn" href="/local-workspace" title="Open workspace page" style="text-decoration:none;">{{ fd_icon("external-link",16) }}</a>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'workspace')" @contextmenu.stop="openSectionMenu($event, 'workspace')">⋮</button>
</div>
@@ -299,7 +308,7 @@
<span class="section-label">Repository (Gitea)</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="Refresh" @click.stop="if(window._gwData)window._gwData.refreshTree()">{{ fd_icon("refresh",16) }}</button>
<button class="section-action-btn" title="Refresh" @click.stop="fdGwRefresh()">{{ fd_icon("refresh",16) }}</button>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'gitea')">⋮</button>
</div>
</div>
@@ -321,7 +330,7 @@
</div>
<div class="sidebar-section-actions">
{% if has_active_workspace %}
<button class="section-action-btn" title="Add" @click.stop="window.FlowDeck.createPage()">+</button>
<button class="section-action-btn" title="Add" @click.stop="fdCreatePage()">+</button>
{% endif %}
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'meetings')">⋮</button>
</div>
@@ -398,7 +407,7 @@
<ul class="sidebar-items" data-section="agents">
<template x-for="a in agentList" :key="a.id">
<li class="sidebar-item" @click="agentOpen(a.id)">
<span class="page-icon page-icon-svg" x-html="a.icon || '🤖'"></span>
<span class="page-icon page-icon-svg" x-init="bindAgentIcon($el, a)"></span>
<span class="page-name" x-text="a.name"></span>
</li>
</template>
@@ -532,12 +541,7 @@
<button class="scp-done" @click="toggleCustomize()">Done</button>
</div>
<div class="scp-list">
<template x-for="(cfg, key) in (Object.keys(sidebarConfig).length ? sidebarConfig : {
workspace:{visible:true,order:0},teamspaces:{visible:true,order:1},
meetings:{visible:true,order:2},recents:{visible:true,order:3},
favorites:{visible:true,order:4},agents:{visible:true,order:5},
shared:{visible:true,order:6},published:{visible:true,order:7}
})" :key="key">
<template x-for="(cfg, key) in sidebarSections()" :key="key">
<div class="scp-item" @click="toggleSectionVisibility(key)">
<div class="scp-item-left">
<span class="scp-item-icon" x-text="getSectionIcon(key)"></span>
@@ -586,40 +590,40 @@
<!-- Section options menu (⋮ dropdown) -->
<div class="section-menu-overlay" x-show="sectionMenu.visible" @click="closeSectionMenu()" @contextmenu.prevent="closeSectionMenu()"></div>
<div class="section-menu" x-show="sectionMenu.visible" x-cloak
:style="{ top: sectionMenu.y + 'px', left: Math.min(sectionMenu.x, window.innerWidth - 220) + 'px' }"
:style="sectionMenuPos()"
@click.outside="closeSectionMenu()">
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 5)">
<div class="section-menu-item" @click="closeAndSetCount(5)">
<span class="smi-label">Show 5 items</span>
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 5">✓</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 10)">
<div class="section-menu-item" @click="closeAndSetCount(10)">
<span class="smi-label">Show 10 items</span>
<span class="smi-check" x-show="!sidebarConfig[sectionMenu.section] || sidebarConfig[sectionMenu.section].show_count === 10 || sidebarConfig[sectionMenu.section].show_count == null">✓</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 15)">
<div class="section-menu-item" @click="closeAndSetCount(15)">
<span class="smi-label">Show 15 items</span>
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 15">✓</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 20)">
<div class="section-menu-item" @click="closeAndSetCount(20)">
<span class="smi-label">Show 20 items</span>
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 20">✓</span>
</div>
<div class="section-menu-sep"></div>
<div class="section-menu-item" @click="closeSectionMenu(); moveSection(sectionMenu.section, 'up')">
<div class="section-menu-item" @click="closeAndMove('up')">
<span class="smi-icon">↑</span>
<span class="smi-label">Move up</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); moveSection(sectionMenu.section, 'down')">
<div class="section-menu-item" @click="closeAndMove('down')">
<span class="smi-icon">↓</span>
<span class="smi-label">Move down</span>
</div>
<div class="section-menu-sep"></div>
<div class="section-menu-item" @click="closeSectionMenu(); toggleSectionVisibility(sectionMenu.section)">
<div class="section-menu-item" @click="closeAndToggleVisibility()">
<span class="smi-icon">👁</span>
<span class="smi-label" x-text="isSectionVisible(sectionMenu.section) ? 'Hide section' : 'Show section'"></span>
</div>
<div class="section-menu-sep"></div>
<div class="section-menu-item section-menu-customize" @click="closeSectionMenu(); toggleCustomize()">
<div class="section-menu-item section-menu-customize" @click="menuCustomize()">
<span class="smi-icon">⚙️</span>
<span class="smi-label">Customize sidebar</span>
</div>
@@ -796,10 +800,6 @@
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// Inject CSRF token into HTMX headers
(function() {
const getCsrf = () => {
const m = document.cookie.match(/csrf_token=([^;]+)/);
return m ? m[1] : '';
};
document.body.setAttribute('hx-headers', JSON.stringify({'X-CSRF-Token': getCsrf()}));
document.addEventListener('htmx:configRequest', function(evt) {
evt.detail.headers['X-CSRF-Token'] = getCsrf();
@@ -853,8 +853,7 @@
// these functions for consistent behaviour.
window.FlowDeck = {
getCsrfToken: function() {
var m = document.cookie.match(/csrf_token=([^;]+)/);
return m ? m[1] : '';
return getCsrf();
},
/** Reflète le rename d'une page/dossier dans toute l'UI : sidebar gauche
@@ -1123,6 +1122,12 @@
}
};
// A20 phase 3 : le build CSP ne résout que le registre Alpine.data
// (probe : globale window → « Undefined variable: appState »).
document.addEventListener('alpine:init', function () {
Alpine.data('appState', appState);
});
function appState() {
return {
init() {
@@ -1458,6 +1463,10 @@
this.sectionMenu = { section: section, visible: true, x: ev.clientX, y: ev.clientY };
},
menuCustomize() { this.closeSectionMenu(); this.toggleCustomize(); },
closeAndSetCount(n) { this.closeSectionMenu(); this.setShowCount(this.sectionMenu.section, n); },
closeAndMove(dir) { this.closeSectionMenu(); this.moveSection(this.sectionMenu.section, dir); },
closeAndToggleVisibility() { this.closeSectionMenu(); this.toggleSectionVisibility(this.sectionMenu.section); },
closeSectionMenu() {
this.sectionMenu.visible = false;
},
@@ -1564,6 +1573,55 @@
else window.location.href = url;
},
// ── A20 phase 3 : expressions → méthode (build CSP : appel seul ;
// document/Math/window/FlowsDeck = JS réel, hors évaluateur) ──
bindProjectIcon(el, p) {
Alpine.effect(() => { el.innerHTML = getSvgIcon(p.icon || 'folder', 20); });
},
// ── A20 ph3 : délégués du topbar éditeur (window.E hors portée CSP,
// scope du topbar = appState) — voir right_actions de page_editor ──
edCall(name) {
if (window.E && typeof window.E[name] === 'function') window.E[name]();
},
edTimeAgo() { return (window.E && window.E.timeAgo) || ''; },
edCommentCount() {
return (window.E && window.E.commentCount > 0) ? window.E.commentCount : '';
},
edShared() { return !!(window.E && window.E.pageIsShared); },
// les 2 branches du ternaire favorited étaient identiques → rendu 1×
bindStar(el) { Alpine.effect(() => { el.innerHTML = getSvgIcon('star', 14); }); },
bindAgentIcon(el, a) {
Alpine.effect(() => { el.innerHTML = a.icon || '🤖'; });
},
syncSidebarClass() {
document.documentElement.classList.toggle(
'fd-sidebar-collapsed', this.sidebarCollapsed && !this.sidebarPeek);
},
initSidebarSort() {
Alpine.nextTick(() => {
if (typeof initTreeSortable === 'function') initTreeSortable();
});
},
fdCreatePage() { window.FlowDeck.createPage(); },
fdCreateFolder() { window.FlowDeck.showCreateFolderModal(); },
fdGwRefresh() { if (window._gwData) window._gwData.refreshTree(); },
sidebarSections() {
if (Object.keys(this.sidebarConfig).length) return this.sidebarConfig;
return {
workspace: { visible: true, order: 0 }, teamspaces: { visible: true, order: 1 },
meetings: { visible: true, order: 2 }, recents: { visible: true, order: 3 },
favorites: { visible: true, order: 4 }, agents: { visible: true, order: 5 },
shared: { visible: true, order: 6 }, published: { visible: true, order: 7 },
};
},
sectionMenuPos() {
return {
top: this.sectionMenu.y + 'px',
left: Math.min(this.sectionMenu.x, window.innerWidth - 220) + 'px',
};
},
toggleSidebar() {
this.sidebarPeek = false;
this.sidebarCollapsed = !this.sidebarCollapsed;
@@ -1733,8 +1791,7 @@
addPage(section) { this.newPage(section); },
addSubPage(id) { this.newSubPage(id); },
getCsrfToken() {
const m = document.cookie.match(/csrf_token=([^;]+)/);
return m ? m[1] : '';
return getCsrf();
},
newPage(section) {
const project = this.workspaceKey || '';
@@ -2181,6 +2238,14 @@
.cmd-palette-overlay.open .cmd-palette{transform:translateY(0)}
.cmd-palette-input{width:100%;box-sizing:border-box;padding:16px 18px;background:transparent;border:none;outline:none;color:var(--text,#fff);font-size:16px}
.cmd-palette-input::placeholder{color:var(--text-dim,rgba(255,255,255,.4))}
.cmd-palette-tabs{display:flex;gap:6px;padding:10px 16px 0}
.cp-tab{background:none;border:1px solid var(--border,rgba(255,255,255,.12));color:var(--text-dim,rgba(255,255,255,.55));border-radius:999px;padding:4px 12px;font-size:12px;cursor:pointer}
.cp-tab.active{background:var(--bg-hover,#2a2a2a);color:var(--text,#eee);border-color:var(--accent,#2383e2)}
.cmd-palette-ai{padding:14px 18px;font-size:13.5px;line-height:1.55;white-space:pre-wrap;color:var(--text,#eee)}
.cmd-palette-ai .cp-cites{margin-top:10px;display:flex;flex-direction:column;gap:4px;font-size:12.5px;white-space:normal}
a.cp-cite{color:var(--accent,#529ffa);text-decoration:none}
a.cp-cite:hover{text-decoration:underline}
.cp-loading{color:var(--text-dim,rgba(255,255,255,.5))}
.cmd-palette-list{overflow-y:auto;border-top:1px solid var(--border,rgba(255,255,255,.06))}
.cmd-palette-group{display:flex;align-items:center;gap:8px;padding:10px 18px 4px;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-dim,rgba(255,255,255,.4))}
.cmd-palette-item{display:flex;align-items:center;gap:10px;padding:9px 18px;cursor:pointer;font-size:14px;color:var(--text,#fff)}
@@ -2202,6 +2267,10 @@
<input id="fd-cp-input" class="cmd-palette-input" type="text"
placeholder="Search pages, databases, or type a command…"
autocomplete="off" spellcheck="false">
<div class="cmd-palette-tabs" id="fd-cp-tabs" role="tablist">
<button type="button" class="cp-tab active" data-tab="pages">Pages</button>
<button type="button" class="cp-tab" data-tab="ai">✨ Réponses IA</button>
</div>
<div id="fd-cp-list" class="cmd-palette-list"></div>
<div class="cmd-palette-footer">
<span class="cpf-kbd"><b>↑</b> / <b>↓</b> navigate</span>
@@ -2222,7 +2291,7 @@
];
var overlay, input, list;
var state = { open:false, items:[], index:0, query:'', timer:null, actions:false };
var state = { open:false, items:[], index:0, query:'', timer:null, actions:false, tab:'pages', ai:null };
function esc(s){ return String(s==null?'':s).replace(/[&<>"']/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]; }); }
function highlight(text, q){
@@ -2246,6 +2315,7 @@
}
function render(){
if(state.tab==='ai'){ renderAi(); return; }
if(!state.open) return;
if(!state.items.length){
list.innerHTML = '<div class="cmd-palette-empty">No results for “'+esc(state.query)+'”</div>';
@@ -2282,6 +2352,7 @@
}
function runSearch(){
if(state.tab==='ai'){ runAi(); return; }
var q = input.value.trim();
state.query = q;
state.index = 0;
@@ -2292,7 +2363,7 @@
return;
}
state.actions = false;
fetch('/api/search?q='+encodeURIComponent(q), {headers:{'X-CSRF-Token': document.body.getAttribute('hx-headers') ? (JSON.parse(document.body.getAttribute('hx-headers'))['X-CSRF-Token']||'') : ''}})
fetch('/api/search?q='+encodeURIComponent(q))
.then(function(r){ return r.json(); })
.then(function(data){
if(input.value.trim()!==q) return; // stale
@@ -2306,6 +2377,50 @@
.catch(function(){ state.items=[]; render(); });
}
function runAi(){
var q = input.value.trim();
state.ai = null;
if(!q){ state.ai = {hint:1}; renderAi(); return; }
state.ai = {loading:1}; renderAi();
fetch('/api/v2/search/ask', {
method:'POST',
headers: {'Content-Type':'application/json', 'X-CSRF-Token': getCsrf()},
body: JSON.stringify({question: q})
}).then(function(r){ if(!r.ok) throw 0; return r.json(); })
.then(function(d){
if(state.tab!=='ai' || input.value.trim()!==q) return;
state.ai = d; renderAi();
})
.catch(function(){
if(state.tab!=='ai') return;
state.ai = {error:1}; renderAi();
});
}
function renderAi(){
var a = state.ai;
if(!a){ list.innerHTML=''; return; }
if(a.loading){ list.innerHTML='<div class="cmd-palette-ai"><span class="cp-loading">Recherche de la réponse…</span></div>'; return; }
if(a.error){ list.innerHTML='<div class="cmd-palette-ai">Demande impossible — réessayez.</div>'; return; }
if(a.hint){ list.innerHTML='<div class="cmd-palette-ai">Posez une question sur votre espace : la réponse cite vos pages.</div>'; return; }
var cits = a.citations || [], byId = {};
cits.forEach(function(c){ byId[String(c.id)] = c; });
// échappement AVANT injection des liens (ids = chiffres, type = [a-z]+)
var md = esc(a.answer_markdown || '')
.replace(/\[\[([a-z]+):(\d+)\]\]/g, function(m, type, id){
var c = byId[id];
var label = esc((c && (c.title || c.name)) || (type + ' #' + id));
var href = type === 'collection' ? '/db/' + id : '/pages/' + id;
return '<a class="cp-cite" href="' + href + '">' + label + '</a>';
})
.replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>');
var cites = cits.map(function(c){
var href = c.type === 'collection' ? '/db/' + c.id : '/pages/' + c.id;
return '<a class="cp-cite" href="' + href + '">' + esc(c.title || c.name || 'page #' + c.id) + '</a>';
}).join('');
list.innerHTML = '<div class="cmd-palette-ai">' + md +
(cites ? '<div class="cp-cites"><b>Sources</b> ' + cites + '</div>' : '') + '</div>';
}
function open(){
if(state.open) return;
state.open=true; overlay.classList.add('open');
@@ -2360,6 +2475,16 @@
list = document.getElementById('fd-cp-list');
if(!overlay) return;
input.addEventListener('input', function(){ clearTimeout(state.timer); state.timer=setTimeout(runSearch, 150); });
var tabs = document.getElementById('fd-cp-tabs');
if(tabs) tabs.addEventListener('click', function(e){
var b = e.target && e.target.closest ? e.target.closest('.cp-tab') : null;
if(!b) return;
state.tab = b.getAttribute('data-tab');
var all = tabs.querySelectorAll('.cp-tab');
for(var i=0;i<all.length;i++) all[i].classList.toggle('active', all[i]===b);
state.index = 0;
runSearch();
});
document.addEventListener('keydown', onKey);
});
})();
+3 -3
View File
@@ -74,7 +74,7 @@
<template x-for="(f, i) in activeFilters" :key="i">
<div class="filter-pill">
<span x-text="f.property + ': ' + f.value"></span>
<button class="remove-filter" @click="removeFilter(i); refreshView()">✕</button>
<button class="remove-filter" @click="removeFilterAndRefresh(i)">✕</button>
</div>
</template>
<div class="filter-pill" style="cursor:pointer; position:relative;" @click="showStatusMenu = !showStatusMenu">
@@ -84,7 +84,7 @@
<div class="dropdown-panel" :class="{ visible: showStatusMenu }" style="top:100%; left:0;"
@click.stop>
<template x-for="s in statusOptions" :key="s.value">
<div class="dropdown-option" @click="toggleStatus(s.value); showStatusMenu = false; refreshView()">
<div class="dropdown-option" @click="pickStatus(s.value)">
<span class="option-checkbox" :class="{ selected: statusFilters.includes(s.value) }">
<span x-show="statusFilters.includes(s.value)">✓</span>
</span>
@@ -95,7 +95,7 @@
</div>
</div>
<button class="filter-add-btn" @click="addFilter()">+ Filter</button>
<button class="filter-reset-btn" @click="resetFilters(); refreshView()" x-show="activeFilters.length > 0">Reset</button>
<button class="filter-reset-btn" @click="resetFiltersAndRefresh()" x-show="activeFilters.length > 0">Reset</button>
<div class="view-toolbar-spacer"></div>
+2 -9
View File
@@ -49,12 +49,5 @@
{% endfor %}
</div>
<script nonce="{{ csp_nonce() }}">
function openCardDetail(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
target: '#card-modal-content', swap: 'innerHTML'
});
document.getElementById('card-modal').style.display = 'flex';
document.getElementById('card-modal-title').textContent = 'Issue #' + id;
}
</script>
<!-- A38 : openCardDetail vit dans static/js/app.js (dedupliquee,
corps identique x2 dans ces deux fragments de vue) -->
+7 -6
View File
@@ -95,17 +95,17 @@
style="width:100%; min-height:60px; background:var(--bg-secondary); border:1px solid var(--border);
border-radius:6px; padding:8px; color:var(--text-primary); font-family:inherit; font-size:13px;
resize:vertical; margin-top:8px;"
@keydown.ctrl.enter="addComment($event.target.value); $event.target.value=''"></textarea>
@keydown.ctrl.enter="addCommentAndClear($event.target)"></textarea>
</div>
</div>
<script nonce="{{ csp_nonce() }}">
// ponytail: CSRF helper
function getCsrf() {
const m = document.cookie.match(/csrf_token=([^;]+)/);
return m ? m[1] : '';
}
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: cardDetail »).
if (window.Alpine) { Alpine.data('cardDetail', cardDetail); }
else document.addEventListener('alpine:init', function () { Alpine.data('cardDetail', cardDetail); });
function cardDetail() {
return {
updateField(field, value) {
@@ -139,6 +139,7 @@
})
.then(() => htmx.ajax('GET', `/api/issues/${owner}/${repo}/${issue_id}?format=html`, { target: '#card-modal-content', swap: 'innerHTML' }));
},
addCommentAndClear(el) { this.addComment(el.value); el.value = ''; },
addComment(body) {
if (!body.trim()) return;
console.log('Add comment:', body);
+2 -9
View File
@@ -58,12 +58,5 @@
{% endfor %}
</div>
<script nonce="{{ csp_nonce() }}">
function openCardDetail(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
target: '#card-modal-content', swap: 'innerHTML'
});
document.getElementById('card-modal').style.display = 'flex';
document.getElementById('card-modal-title').textContent = 'Issue #' + id;
}
</script>
<!-- A38 : openCardDetail vit dans static/js/app.js (dedupliquee,
corps identique x2 dans ces deux fragments de vue) -->
+5 -5
View File
@@ -6,7 +6,7 @@
{% block topbar %}
{% set breadcrumb_items = [{"label": owner ~ "/" ~ repo, "url": None}] %}
{% set page_icon = "link" %}
{% set right_actions = '<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">' ~ fd_icon("file",14) ~ '+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">' ~ fd_icon("upload",14) ~ '</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">' ~ fd_icon("refresh",14) ~ '</button><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
{% set right_actions %}<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">{{ fd_icon("file",14) }}+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">{{ fd_icon("upload",14) }}</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">{{ fd_icon("refresh",14) }}</button><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
{% include '_header.html' %}
{% endblock %}
@@ -36,7 +36,7 @@
</style>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/gitea_workspace.js?v={{ asset_version }}"></script>
<div class="gw-main" x-data="giteaWorkspace">
<div class="gw-main" x-data="giteaWorkspace()">
<!-- File view -->
<div x-show="showFile && !showEditor" x-transition>
<div class="gw-file-toolbar">
@@ -120,7 +120,7 @@
@click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)"
@contextmenu.prevent="openGwContext($event, item)"
style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;">
<span x-html="item.type==='folder' ? getSvgIcon('folder',16) : getSvgIcon('file',16)" style="font-size:16px;"></span>
<span x-init="bindGwIcon($el, item)" style="font-size:16px;"></span>
<span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span>
<span x-text="item.type==='folder' ? '' : formatSize(item.size)" style="font-size:12px;color:var(--text-tertiary);"></span>
</div>
@@ -165,7 +165,7 @@
@click="openPrivate(pp.id)">
<div>
<div style="font-weight:500;" x-text="pp.title"></div>
<div style="font-size:12px;color:var(--text-dim);" x-text="pp.updated_at ? new Date(pp.updated_at+'Z').toLocaleString() : ''"></div>
<div style="font-size:12px;color:var(--text-dim);" x-text="fmtGwDate(pp)"></div>
</div>
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">{{ fd_icon('trash',14) }}</button>
</div>
@@ -180,7 +180,7 @@
<strong>Editing: <span x-text="editingPrivateTitle || 'Untitled'"></span></strong>
<span style="flex:1;"></span>
<button class="btn" @click="savePrivate()">{{ fd_icon("save",14) }} Save</button>
<button class="btn" @click="editingPrivate=null;editingPrivateTitle='';editingPrivateContent='';">Cancel</button>
<button class="btn" @click="closePrivateEdit()">Cancel</button>
</div>
<div class="gw-content">
<input x-model="editingPrivateTitle" placeholder="Page title" style="width:100%;padding:10px 12px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:16px;margin-bottom:12px;outline:none;">
+2 -2
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Importer — FlowDeck</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
<style>
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;--warn:#D9730D;}
*{margin:0;padding:0;box-sizing:border-box;}
@@ -201,7 +201,7 @@ select,input[type=text]{background:var(--bg3);color:var(--text);border:1px solid
<template x-for="(e,i) in (report ? report.errors : [])" :key="i"><div x-text="'✕ '+e.title+' : '+e.error"></div></template>
</div>
<div class="warnings" x-show="report && report.relations && report.relations.relations_resolved">
<div x-text="'🔗 '+report.relations.relations_resolved+' relation(s) reconstruite(s)'"></div>
<div x-text="report && report.relations ? '🔗 '+report.relations.relations_resolved+' relation(s) reconstruite(s)' : ''"></div>
</div>
<div class="actions">
<button class="btn btn-ghost" @click="downloadReport()">Télécharger le rapport (JSON)</button>
+9 -9
View File
@@ -233,7 +233,7 @@
</div>
<div class="lib-toolbar">
<div class="lib-toolbar-wrap" x-show="tab !== 'repository'">
<button class="lib-icon-btn" :class="{active: filterOpen}" title="Filter" @click.stop="filterOpen=!filterOpen; sortOpen=false; viewOpen=false">
<button class="lib-icon-btn" :class="{active: filterOpen}" title="Filter" @click.stop="toggleFilterMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
</button>
<div class="lib-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition>
@@ -245,7 +245,7 @@
</div>
</div>
<div class="lib-toolbar-wrap">
<button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="sortOpen=!sortOpen; filterOpen=false; viewOpen=false">
<button class="lib-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSortMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/><polyline points="17 5 17 19"/><polyline points="13 16 17 20 21 16"/></svg>
</button>
<div class="lib-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition>
@@ -255,11 +255,11 @@
<div class="dd-item" :class="{active: sortBy==='author'}" @click="setSort('author')"><span class="check" x-text="sortBy==='author' ? '✓' : ''"></span> Created by</div>
</div>
</div>
<button class="lib-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="searchOpen=!searchOpen; $nextTick(()=>{ if(searchOpen) document.getElementById('lib-search-input').focus(); })">
<button class="lib-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="toggleSearch()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</button>
<div class="lib-toolbar-wrap">
<button class="lib-icon-btn" :class="{active: viewOpen}" title="View options" @click.stop="viewOpen=!viewOpen; filterOpen=false; sortOpen=false">
<button class="lib-icon-btn" :class="{active: viewOpen}" title="View options" @click.stop="toggleViewMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/><circle cx="9" cy="6" r="1.5" fill="currentColor" stroke="none"/><circle cx="15" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="9" cy="18" r="1.5" fill="currentColor" stroke="none"/></svg>
</button>
<div class="lib-dropdown" x-show="viewOpen" @click.outside="viewOpen=false" x-transition>
@@ -310,7 +310,7 @@
<button @click="copyLinks()" title="Copy links">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
</button>
<button @click="openMovePicker(Object.keys(selected).map(Number))" title="Move to">
<button @click="openMoveSelected()" title="Move to">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
</button>
<div style="position:relative;margin-left:auto;">
@@ -326,7 +326,7 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
Copy links to all
</div>
<div class="menu-item" @click="openMovePicker(Object.keys(selected).map(Number))">
<div class="menu-item" @click="openMoveSelected()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
Move to
</div>
@@ -378,7 +378,7 @@
<!-- ── Empty state ── -->
<div class="lib-empty" id="lib-empty">
<div class="empty-icon" x-html="getSvgIcon(emptyIcon,48)"></div>
<div class="empty-icon" x-init="bindHtmlIcon($el)"></div>
<h3 x-text="emptyTitle"></h3>
<p x-text="emptyText"></p>
</div>
@@ -397,7 +397,7 @@
<div class="move-modal-item" @click="confirmMove(0)">{{ fd_icon("file",14) }} <span>Root (no parent)</span></div>
<template x-for="it in moveCandidates" :key="it.id">
<div class="move-modal-item" @click="confirmMove(it.id)">
<span x-html="_renderIcon(it)"></span> <span x-text="it.title"></span>
<span x-init="bindHtmlItem($el, it)"></span> <span x-text="it.title"></span>
</div>
</template>
</div>
@@ -414,7 +414,7 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<div class="peek-title">
<span x-html="_renderIcon(peekItem)"></span>
<span x-init="bindHtmlItem($el, peekItem)"></span>
<span x-text="peekItem.title"></span>
</div>
<button @click="openItem(peekItem)" title="Open full page">
+30 -27
View File
@@ -11,7 +11,7 @@
{% block content %}
<span id="fd-local-ws-id" hidden>{{ workspace_id }}</span>
<div x-data="_wsInitData"
<div x-data="wsInitData()"
@dragover.prevent="onDragOver($event)"
@dragleave="onDragLeave($event)"
@drop.prevent="onDrop($event)"
@@ -482,10 +482,10 @@
<script type="application/json" id="lw-config" nonce="{{ csp_nonce() }}">{{ {"current_folder_id": current_folder_id, "workspace_id": workspace_id} | tojson }}</script>
<script data-cfasync="false" src="/static/js/local_workspace.js?v={{ asset_version }}"></script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof _wsInitData !== 'undefined');</script>
<div class="ws-split"
@contextmenu.prevent="_wsInitData.onContextMenu($event)"
@contextmenu.prevent="onContextMenu($event)"
@touchstart="onTouchStart($event)"
@touchend="onTouchEnd($event)"
@touchmove="onTouchMove($event)"
@@ -553,7 +553,7 @@
<div class="ws-toolbar" style="margin-left:auto;">
<!-- View dropdown -->
<div class="lib-toolbar-wrap" style="position:relative;">
<button class="ws-icon-btn" :class="{active: viewMenuOpen}" title="View" @click.stop="viewMenuOpen=!viewMenuOpen; sortOpen=false; searchOpen=false">
<button class="ws-icon-btn" :class="{active: viewMenuOpen}" title="View" @click.stop="toggleViewMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>
</button>
<div class="ws-dropdown" x-show="viewMenuOpen" @click.outside="viewMenuOpen=false" x-transition style="right:0;">
@@ -567,7 +567,7 @@
</div>
<!-- Sort -->
<div class="lib-toolbar-wrap" style="position:relative;">
<button class="ws-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="sortOpen=!sortOpen; viewMenuOpen=false">
<button class="ws-icon-btn" :class="{active: sortOpen}" title="Sort" @click.stop="toggleSortMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="9" x2="14" y2="9"/><line x1="4" y1="15" x2="10" y2="15"/><polyline points="17 5 17 19"/><polyline points="13 16 17 20 21 16"/></svg>
</button>
<div class="ws-dropdown" x-show="sortOpen" @click.outside="sortOpen=false" x-transition style="right:0;">
@@ -581,7 +581,7 @@
</div>
<!-- Filter -->
<div class="lib-toolbar-wrap" style="position:relative;">
<button class="ws-icon-btn" :class="{active: filterOpen || filterType}" title="Filter" @click.stop="filterOpen=!filterOpen; viewMenuOpen=false; sortOpen=false">
<button class="ws-icon-btn" :class="{active: filterOpen || filterType}" title="Filter" @click.stop="toggleFilterMenu()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/></svg>
</button>
<div class="ws-dropdown" x-show="filterOpen" @click.outside="filterOpen=false" x-transition style="right:0;">
@@ -597,7 +597,7 @@
</div>
</div>
<!-- Search toggle -->
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="searchOpen=!searchOpen; if(searchOpen) $nextTick(()=>$refs.searchInput?.focus())">
<button class="ws-icon-btn" :class="{active: searchOpen}" title="Search" @click.stop="toggleSearch()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</button>
<!-- Expand/Collapse (tree only) -->
@@ -650,12 +650,12 @@
<!-- Filter chips (visual indicator when filter is active — shown in all views) -->
<div class="filter-row" x-show="filterType" style="padding:4px 0;margin-bottom:6px;">
<button class="filter-chip" :class="{ active: filterType === 'folder' }" @click="filterByType('folder')" title="Folders" x-html="getSvgIcon('folder',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'page' }" @click="filterByType('page')" title="Pages" x-html="getSvgIcon('file',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'pdf' }" @click="filterByType('pdf')" title="PDF" x-html="getSvgIcon('file',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'image' }" @click="filterByType('image')" title="Images" x-html="getSvgIcon('image',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'code' }" @click="filterByType('code')" title="Code" x-html="getSvgIcon('file',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'text' }" @click="filterByType('text')" title="Text" x-html="getSvgIcon('file',14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'folder' }" @click="filterByType('folder')" title="Folders" x-init="bindSvg($el, 'folder', 14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'page' }" @click="filterByType('page')" title="Pages" x-init="bindSvg($el, 'file', 14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'pdf' }" @click="filterByType('pdf')" title="PDF" x-init="bindSvg($el, 'file', 14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'image' }" @click="filterByType('image')" title="Images" x-init="bindSvg($el, 'image', 14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'code' }" @click="filterByType('code')" title="Code" x-init="bindSvg($el, 'file', 14)"></button>
<button class="filter-chip" :class="{ active: filterType === 'text' }" @click="filterByType('text')" title="Text" x-init="bindSvg($el, 'file', 14)"></button>
<span class="filter-clear" @click="filterType='', doFilter()">clear filter</span>
</div>
@@ -723,7 +723,7 @@
<span style="width:16px;flex-shrink:0;"></span>
</template>
<span class="icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
<span class="icon" x-init="bindFileIcon($el, node)"></span>
<template x-if="node.is_folder">
<span class="name folder" @click.stop="navigateToFolder(node.id)"
@@ -754,8 +754,8 @@
<div class="actions">
<template x-if="node.is_folder">
<span style="display:flex;gap:2px">
<button class="ws-act" @click.stop="window.FlowDeck.createPage(node.id)" title="New file">{{ fd_icon("file",14) }}</button>
<button class="ws-act" @click.stop="window.FlowDeck.showCreateFolderModal(node.id)" title="New folder">{{ fd_icon("folder",14) }}</button>
<button class="ws-act" @click.stop="createPageAt(node)" title="New file">{{ fd_icon("file",14) }}</button>
<button class="ws-act" @click.stop="createFolderAt(node)" title="New folder">{{ fd_icon("folder",14) }}</button>
<a class="ws-act" href="/local-workspace" title="Open workspace page" style="text-decoration:none;display:inline-flex;align-items:center;">{{ fd_icon("external-link",14) }}</a>
</span>
</template>
@@ -765,7 +765,7 @@
</div>
<!-- Children -->
<ul class="ws-tree" x-show="expanded[node.id]" x-transition
x-html="renderChildren(node.children, (node.depth||0)+1, tagsVersion)">
x-init="bindChildren($el, node)">
</ul>
</li>
</template>
@@ -807,7 +807,7 @@
<tr :class="{ selected: !!selectedIds[node.id] }">
<td><input type="checkbox" :checked="!!selectedIds[node.id]" @click.stop="toggleSelect(node, $event)"></td>
<td>
<span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
<span class="table-icon" x-init="bindFileIcon($el, node)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span class="col-name" @click="node.is_folder ? navigateToFolder(node.id) : openPage(node.id)" x-text="node.name"></span>
</td>
@@ -864,7 +864,7 @@
</tr>
<template x-for="node in displayTree" :key="'l'+node.id">
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer">
<td><span x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
<td><span x-init="bindFileIcon($el, node)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td>
<td class="col-name" x-text="node.name"></td>
<td style="font-size:12px;color:var(--text-tertiary)" x-text="node.is_folder ? 'Folder' : _fileTypeLabel(node.name, node.content_format)"></td>
@@ -902,7 +902,7 @@
<template x-for="node in displayTree" :key="'d'+node.id">
<tr :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()" style="cursor:pointer"
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
<td><span class="table-icon" x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
<td><span class="table-icon" x-init="bindFileIcon($el, node)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span></td>
<td class="col-name" x-text="node.name"></td>
<td class="col-path" x-text="node._path || '—'"></td>
@@ -933,7 +933,7 @@
<template x-for="node in displayTree" :key="'g'+node.id">
<div class="title-card" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
:class="{ 'selected-card': previewItem && previewItem.id === node.id }">
<div class="title-card-icon" x-html="node.is_folder ? getSvgIcon('folder',24) : _fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></div>
<div class="title-card-icon" x-init="bindNodeIcon($el, node)"></div>
<div class="title-card-name">
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span x-text="node.name"></span>
@@ -960,7 +960,7 @@
<div class="content-item" :data-ws-id="node.id" @click="node.is_folder ? navigateToFolder(node.id) : selectForPreview(node)" @mouseenter="showPreview($event, node)" @mouseleave="hidePreview()"
:class="{ 'selected-row': previewItem && previewItem.id === node.id }">
<div class="content-item-header">
<span x-html="_fileIcon(node.name, node.is_folder, node.content_format, node.page_icon)"></span>
<span x-init="bindFileIcon($el, node)"></span>
<span x-show="node.is_shared" class="ws-shared-badge" title="Partagé" x-text="'👥'"></span>
<span class="col-name" x-text="node.name"></span>
<span style="font-size:11px;color:var(--text-tertiary);margin-left:auto" x-text="_formatDate(node.updated_at)"></span>
@@ -1023,7 +1023,7 @@
<h3><span x-show="createType==='folder'">{{ fd_icon('folder',16) }}</span><span x-show="createType!='folder'">{{ fd_icon('file',16) }}</span> <span x-text="createType==='folder'?'New Folder':'New File'"></span></h3>
<p class="modal-sub">
In <strong>{{ workspace_name }}</strong> <span x-show="folderStack.length>1">/ <span x-text="(folderStack[folderStack.length-1]||{}).name||''"></span></span>
<span x-show="parentFolder"> / <span x-text="parentFolder?.name"></span></span>
<span x-show="parentFolder"> / <span x-text="parentFolder ? parentFolder.name : ''"></span></span>
</p>
<input class="modal-input" x-model="newName" :placeholder="createType==='folder'?'Folder name':'File name'" @keydown.enter="doCreate" x-ref="cinp">
<div class="modal-actions">
@@ -1037,7 +1037,7 @@
<div class="modal-overlay" x-show="showRename" @click.outside="showRename=false" @keydown.escape="showRename=false" style="display:none">
<div class="modal-box">
<h3>{{ fd_icon("edit",14) }} Rename</h3>
<p class="modal-sub">Rename <strong x-text="target?.name"></strong></p>
<p class="modal-sub">Rename <strong x-text="target ? target.name : ''"></strong></p>
<input class="modal-input" x-model="newName" @keydown.enter="doRename" x-ref="rinp">
<div class="modal-actions">
<button class="btn btn-secondary" @click="showRename=false">Cancel</button>
@@ -1050,7 +1050,7 @@
<div class="modal-overlay" x-show="showDelete" @click.outside="showDelete=false" @keydown.escape="showDelete=false" style="display:none">
<div class="modal-box">
<h3>{{ fd_icon("trash",16) }} Delete</h3>
<p class="modal-sub">Delete <strong x-text="target?.name"></strong>?</p>
<p class="modal-sub">Delete <strong x-text="target ? target.name : ''"></strong>?</p>
<div class="delete-confirm">This cannot be undone. Children will also be deleted.</div>
<div class="modal-actions">
<button class="btn btn-secondary" @click="showDelete=false">Cancel</button>
@@ -1065,11 +1065,14 @@
{% include "_ctx_menu.html" %}
<!-- Preview tooltip -->
<div class="file-preview" x-show="previewVisible"
<!-- ponytail: le parseur referme la div racine avant ce bloc (structure
pré-existante, masquée par le fallback window d'Alpine standard) →
wsPreview = wrapper déléguant vers _wsInitData (voir local_workspace.js) -->
<div class="file-preview" x-data="wsPreview()" x-show="previewVisible"
:style="{ left: previewX + 'px', top: previewY + 'px' }"
@mouseenter="previewVisible = true" @mouseleave="previewVisible = false">
<div class="file-preview-header" x-text="previewName"></div>
<div class="file-preview-body" x-html="previewContent"></div>
<div class="file-preview-body" x-init="bindPreview($el)"></div>
</div>
<!-- Preview Panel — identical to Library peek-overlay -->
+1 -1
View File
@@ -3,7 +3,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% set page_icon = "file" if page.content_format != 'file' else "paperclip" %}
{% set page_title = page.title %}
{% set nav_page_id = page.id %}
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn" @click="window.E && window.E.toggleComments()" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="window.E && window.E.commentCount>0 ? window.E.commentCount : \'\'"></span></button><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
{% set right_actions %}<span class="topbar-edited" style="cursor:pointer;" @click="edCall('toggleActivityOpen')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn" @click="edCall('toggleComments')" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="edCommentCount()"></span></button><button class="topbar-btn share-btn" @click="edCall('toggleShareOpen')"><span x-show="!edShared()">{{ fd_icon("lock",14) }} Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall('copyPageLink')" title="Copy link">{{ fd_icon("link",14) }}</button><button class="topbar-btn star-btn" @click="edCall('toggleFavorite')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall('toggleMoreOpen')">⋯</button>{% endset %}
{% include '_header.html' %}
{% endblock %} {% block content %}
{% include "_page_editor_content.html" %}
+1 -1
View File
@@ -2,7 +2,7 @@
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% set page_icon = "file" %}
{% set page_title = page.title %}
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
{% set right_actions %}<span class="topbar-edited" style="cursor:pointer;" @click="edCall('toggleActivityOpen')">Edited <span x-text="edTimeAgo()"></span> ▾</span><button class="topbar-btn share-btn" @click="edCall('toggleShareOpen')"><span x-show="!edShared()">{{ fd_icon("lock",14) }} Share ▾</span><span x-show="edShared()" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="edCall('copyPageLink')" title="Copy link">{{ fd_icon("link",14) }}</button><button class="topbar-btn star-btn" @click="edCall('toggleFavorite')" x-init="bindStar($el)"></button><button class="topbar-btn relative" @click="edCall('toggleMoreOpen')">⋯</button>{% endset %}
{% include '_header.html' %}
{% endblock %} {% block content %}
{% include "_database_table.html" %}
+146 -20
View File
@@ -127,7 +127,7 @@
@media (max-width:760px){.llm-layout{grid-template-columns:1fr;}.llm-list{max-height:220px;}}
</style>
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="window.history.back()">
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="historyBack()">
<div class="settings-panel" style="position:relative;">
<button class="settings-close" @click="closeSettings()" title="Close">×</button>
@@ -136,24 +136,24 @@
<div class="settings-nav-header">Account</div>
<div class="settings-nav-item" :class="{ active: activeSection==='account' }" @click="activeSection='account'"><span class="nav-icon-inline">{{ fd_icon("user",14) }}</span> My account</div>
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'"><span class="nav-icon-inline">{{ fd_icon("bell",14) }}</span> Notifications</div>
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="activeSection='api-tokens'; loadApiTokens()"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="activeSection='sessions'; loadSessions()"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="activeSection='extensions'; loadClipperDevices()"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="navTo('api-tokens')"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="navTo('sessions')"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="navTo('extensions')"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>
<div class="settings-nav-header">Workspace</div>
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">{{ fd_icon("file",14) }} General</div>
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
<div class="settings-nav-header">Features</div>
<div class="settings-nav-item" :class="{ active: activeSection==='features' }" @click="activeSection='features'">{{ fd_icon("link",14) }} Integrations</div>
<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="activeSection='automations'; loadAutomations()">{{ fd_icon("zap",14) }} Automations</div>
<div class="settings-nav-item" :class="{ active: activeSection==='automations' }" @click="navTo('automations')">{{ fd_icon("zap",14) }} Automations</div>
<div class="settings-nav-item" :class="{ active: activeSection==='llm' }" @click="activeSection='llm'">{{ fd_icon("bot",14) }} Agent &amp; IA</div>
<!-- Admin nav: only visible to admins -->
<template x-if="userIsAdmin">
<div>
<div class="settings-nav-header">Admin</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="activeSection='admin-users'; loadAdminUsers()">{{ fd_icon("users",14) }} Users &amp; Roles</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="activeSection='admin-audit'; loadAdminAudit()">{{ fd_icon("file-text",14) }} Audit Log</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-backups' }" @click="activeSection='admin-backups'; loadBackups()">{{ fd_icon("download",14) }} Backups</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-sso' }" @click="activeSection='admin-sso'; loadSsoConfig()">{{ fd_icon("lock",14) }} SSO / Enterprise</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-users' }" @click="navTo('admin-users')">{{ fd_icon("users",14) }} Users &amp; Roles</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-audit' }" @click="navTo('admin-audit')">{{ fd_icon("file-text",14) }} Audit Log</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-backups' }" @click="navTo('admin-backups')">{{ fd_icon("download",14) }} Backups</div>
<div class="settings-nav-item" :class="{ active: activeSection==='admin-sso' }" @click="navTo('admin-sso')">{{ fd_icon("lock",14) }} SSO / Enterprise</div>
</div>
</template>
</div>
@@ -515,9 +515,9 @@
<div class="modal-box" style="max-width:360px;">
<h3 style="margin:0 0 8px;">Delete tag</h3>
<p style="color:var(--text-dim);margin:0 0 16px;">
Are you sure you want to delete the tag "<strong x-text="deletingTag?.name"></strong>"?
<span x-show="deletingTag?.count > 0" style="color:var(--danger);display:block;margin-top:4px;">
{{ fd_icon('alert-triangle',14) }} This tag is used on <span x-text="deletingTag?.count"></span> item(s).
Are you sure you want to delete the tag "<strong x-text="deletingTag ? deletingTag.name : ''"></strong>"?
<span x-show="deletingTag && deletingTag.count > 0" style="color:var(--danger);display:block;margin-top:4px;">
{{ fd_icon('alert-triangle',14) }} This tag is used on <span x-text="deletingTag ? deletingTag.count : 0"></span> item(s).
</span>
</p>
<div style="display:flex;gap:8px;justify-content:flex-end;">
@@ -531,7 +531,7 @@
<div class="modal-box" style="max-width:360px;">
<h3 style="margin:0 0 12px;">Rename tag</h3>
<div style="display:flex;gap:8px;align-items:center;">
<div class="tag-color-dot" :style="{background: renamingTag?.color}" style="width:16px;height:16px;"></div>
<div class="tag-color-dot" :style="{background: renamingTag ? renamingTag.color : ''}" style="width:16px;height:16px;"></div>
<input type="text" class="settings-input" x-model="renameValue"
@keydown.enter="confirmRenameTag()" @keydown.escape="renamingTag=null"
style="flex:1;" autofocus>
@@ -635,16 +635,142 @@
</div>
</div>
</div>
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
<div class="setting-row" x-show="!editSteps.length" style="flex-direction:column;align-items:stretch;gap:6px;">
<div class="setting-label">Conditions (JSON — toutes AND)</div>
<textarea class="settings-input" style="width:100%;min-height:60px;font-family:monospace;font-size:12px;" x-model="af.condition_json" placeholder='[{"property":"Status","op":"eq","value":"Done"}]'></textarea>
<div class="setting-desc">Ops : eq, neq, contains, not_contains, is_empty, is_not_empty, changed</div>
</div>
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;">
<div class="setting-row" x-show="!editSteps.length" style="flex-direction:column;align-items:stretch;gap:6px;">
<div class="setting-label">Actions (JSON)</div>
<textarea class="settings-input" style="width:100%;min-height:80px;font-family:monospace;font-size:12px;" x-model="af.actions_json" placeholder='[{"type":"webhook","url":"https://..."}, {"type":"set_property","property":"Status","value":"Done"}, {"type":"create_page","collection_id":1,"title":"New task"}, {"type":"notify","message":"Automation fired"}]'></textarea>
<div class="setting-desc">Types : webhook, set_property, create_page, notify</div>
</div>
<!-- Pipeline visuel (steps API v7.0) -->
<div class="setting-row" x-show="editAutomationId" style="flex-direction:column;align-items:stretch;gap:6px;">
<div class="setting-label">Pipeline visuel (steps)</div>
<div class="setting-desc" x-show="!editSteps.length">Aucun step : automation en mode legacy (JSON ci-dessus). Ajoutez une étape (ou convertissez le JSON) pour basculer en pipeline.</div>
<template x-for="(s, i) in editSteps" :key="s.id">
<div style="border:1px solid var(--border);border-radius:6px;padding:8px 10px;margin-bottom:6px;">
<div style="display:flex;gap:8px;align-items:center;">
<span class="setting-label" style="font-weight:500;" x-text="(i+1) + '. ' + stepSummary(s)"></span>
<span style="flex:1"></span>
<button class="btn-sm" title="Monter" @click="moveStep(i, -1)">↑</button>
<button class="btn-sm" title="Descendre" @click="moveStep(i, 1)">↓</button>
<button class="btn-sm" @click="stepEdit = stepEdit===i ? -1 : i" x-text="stepEdit===i ? 'Fermer' : 'Modifier'"></button>
<button class="btn-sm" style="color:#e5534b;" title="Supprimer" @click="delStep(i)">✕</button>
</div>
<div x-show="stepEdit===i" style="margin-top:8px;display:flex;flex-direction:column;gap:6px;">
<template x-if="s.kind==='trigger'">
<div>
<div class="setting-label">Événement</div>
<input class="settings-input" style="width:100%;" x-model="s.config.event" list="auto-events">
<datalist id="auto-events">
<option value="page.created"></option><option value="page.updated"></option><option value="page.deleted"></option>
<option value="collection.created"></option><option value="collection.updated"></option><option value="collection.deleted"></option>
<option value="form.submitted"></option><option value="meeting.summarized"></option><option value="site.viewed"></option>
</datalist>
</div>
</template>
<template x-if="s.kind==='condition'">
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<input class="settings-input" placeholder="Propriété" x-model="s.config.property">
<select class="settings-input" x-model="s.config.op">
<option value="eq">=</option><option value="neq">≠</option>
<option value="contains">contient</option><option value="not_contains">ne contient pas</option>
<option value="is_empty">vide</option><option value="is_not_empty">non vide</option>
<option value="changed">changé</option>
</select>
<input class="settings-input" placeholder="Valeur" x-model="s.config.value">
</div>
</template>
<template x-if="s.kind==='delay'">
<div>
<div class="setting-label">Secondes (0-86400 · exécution plafonnée à 300 s)</div>
<input class="settings-input" type="number" min="0" max="86400" x-model.number="s.config.seconds">
</div>
</template>
<template x-if="s.kind==='action'">
<div style="display:flex;flex-direction:column;gap:6px;">
<select class="settings-input" x-model="s.config.type">
<option value="webhook">Webhook</option>
<option value="set_property">Définir une propriété</option>
<option value="create_page">Créer une page</option>
<option value="notify">Notification</option>
<option value="slack">Slack</option>
<option value="email">Email</option>
<option value="forge_issue">Issue Gitea/GitHub</option>
<option value="agent_trigger">Déclencher un agent</option>
</select>
<template x-if="s.config.type==='webhook'">
<input class="settings-input" placeholder="https://…" x-model="s.config.url">
</template>
<template x-if="s.config.type==='set_property'">
<div style="display:flex;gap:8px;">
<input class="settings-input" placeholder="Propriété" x-model="s.config.property">
<input class="settings-input" placeholder="Valeur" x-model="s.config.value">
</div>
</template>
<template x-if="s.config.type==='create_page'">
<div style="display:flex;gap:8px;">
<select class="settings-input" x-model="s.config.collection_id">
<option value="">Base (optionnel)</option>
<template x-for="c in globalCollections" :key="c.id">
<option x-bind:value="c.id" x-text="c.icon + ' ' + c.name"></option>
</template>
</select>
<input class="settings-input" placeholder="Titre" x-model="s.config.title">
</div>
</template>
<template x-if="s.config.type==='notify'">
<input class="settings-input" placeholder="Message" x-model="s.config.message">
</template>
<template x-if="s.config.type==='slack'">
<div style="display:flex;gap:8px;">
<input class="settings-input" placeholder="Webhook URL (chiffrée)" x-model="s.config.webhook_url">
<input class="settings-input" placeholder="Texte" x-model="s.config.text">
</div>
</template>
<template x-if="s.config.type==='email'">
<div style="display:flex;gap:8px;flex-direction:column;">
<input class="settings-input" placeholder="À (to)" x-model="s.config.to">
<input class="settings-input" placeholder="Sujet" x-model="s.config.subject">
<textarea class="settings-input" style="min-height:56px;" placeholder="Corps" x-model="s.config.body"></textarea>
</div>
</template>
<template x-if="s.config.type==='forge_issue'">
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<input class="settings-input" placeholder="owner" x-model="s.config.owner">
<input class="settings-input" placeholder="repo" x-model="s.config.repo">
<input class="settings-input" placeholder="Titre" x-model="s.config.title">
<input class="settings-input" placeholder="Labels (virgules)" x-model="s.config.labels">
</div>
</template>
<template x-if="s.config.type==='agent_trigger'">
<div style="display:flex;gap:8px;">
<input class="settings-input" type="number" placeholder="Agent ID" x-model.number="s.config.agent_id">
<input class="settings-input" placeholder="Message" x-model="s.config.message">
</div>
</template>
</div>
</template>
<div><button class="btn-sm" @click="saveStep(s)">Enregistrer l'étape</button></div>
</div>
</div>
</template>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<select class="settings-input" style="min-width:150px;" x-model="stepNewKind">
<option value="trigger">Déclencheur</option>
<option value="condition">Condition</option>
<option value="delay">Attente</option>
<option value="action">Action</option>
</select>
<button class="btn-sm" @click="addStep()">+ Ajouter l'étape</button>
<button class="btn-sm" style="color:var(--accent,#2383e2);" x-show="!editSteps.length && editAutomationId"
@click="convertToSteps()">✨ Convertir le JSON en pipeline</button>
</div>
</div>
<div style="display:flex;gap:8px;margin-top:8px;">
<button class="btn-sm" @click="saveAutomation()" x-text="editAutomationId ? 'Enregistrer' : 'Créer'"></button>
<button class="btn-sm" style="color:var(--text-secondary);" x-show="editAutomationId" @click="cancelEditAutomation()">Annuler</button>
@@ -770,11 +896,11 @@
<td x-text="u.folder_count" style="text-align:center;"></td>
<td x-text="u.total_mb + ' MB'" style="text-align:right;"></td>
<td>
<span style="font-size:11px;color:var(--text-dim);" x-text="u.last_login ? new Date(u.last_login*1000).toLocaleDateString() : 'Never'"></span>
<span style="font-size:11px;color:var(--text-dim);" x-text="fmtLastLogin(u)"></span>
</td>
<td>
<div style="display:flex;gap:4px;">
<button class="btn-sm" @click="editingUser=u; editUserForm={login:u.login,name:u.full_name,email:u.email,is_admin:u.is_admin,is_active:u.is_active}" title="Edit">{{ fd_icon("edit",14) }}</button>
<button class="btn-sm" @click="editUserRow(u)" title="Edit">{{ fd_icon("edit",14) }}</button>
<button class="btn-sm btn-sm-danger" @click="adminDeleteUser(u.id)" title="Delete" x-show="adminUsers.length > 1">{{ fd_icon("trash",14) }}</button>
</div>
</td>
@@ -813,7 +939,7 @@
<p class="section-desc">Actions API, changements de permissions et connexions SSO (unifiés).</p>
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px;align-items:center;">
<template x-for="s in ['all','api','permissions','sso']" :key="s">
<button class="btn" :class="{ 'btn-primary': auditSource===s }" style="font-size:12px;" @click="auditSource=s; loadAuditLogs()" x-text="s"></button>
<button class="btn" :class="{ 'btn-primary': auditSource===s }" style="font-size:12px;" @click="setAuditSource(s)" x-text="s"></button>
</template>
<input type="text" placeholder="actor (user id)" x-model="auditActor" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
<input type="text" placeholder="action (LIKE)" x-model="auditAction" style="font-size:12px;padding:6px 8px;" @keyup.enter="loadAuditLogs()">
@@ -837,7 +963,7 @@
<tbody>
<template x-for="e in auditLogs" :key="e.at + '-' + e.source + '-' + e.actor">
<tr>
<td><span style="font-size:12px;" x-text="new Date(e.at).toLocaleString()"></span></td>
<td><span style="font-size:12px;" x-text="fmtAuditDate(e)"></span></td>
<td><span style="font-size:11px;" :class="'audit-src audit-src-'+e.source" x-text="e.source"></span></td>
<td><code style="font-size:11px;" x-text="e.actor"></code></td>
<td><code style="font-size:11px;" x-text="e.action"></code></td>
@@ -853,7 +979,7 @@
</table>
</div>
<div style="display:flex;justify-content:center;margin-top:12px;">
<button class="btn" style="font-size:12px;" x-show="auditHasMore" @click="auditOffset+=auditPageSize; loadAuditLogs(false)">Load more</button>
<button class="btn" style="font-size:12px;" x-show="auditHasMore" @click="auditNext()">Load more</button>
</div>
<style>
.audit-src{display:inline-block;padding:1px 7px;border-radius:8px;font-weight:600;}
+6
View File
@@ -100,6 +100,12 @@
</div>
<script nonce="{{ csp_nonce() }}">
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: tableView »).
if (window.Alpine) { Alpine.data('tableView', tableView); }
else document.addEventListener('alpine:init', function () { Alpine.data('tableView', tableView); });
function tableView() {
return {
sortField: '',
+6
View File
@@ -44,6 +44,12 @@
</div>
<script nonce="{{ csp_nonce() }}">
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: teamLoad »).
if (window.Alpine) { Alpine.data('teamLoad', teamLoad); }
else document.addEventListener('alpine:init', function () { Alpine.data('teamLoad', teamLoad); });
function teamLoad() {
return {};
}
+10 -4
View File
@@ -63,13 +63,19 @@
{% block scripts %}
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: trashData »).
if (window.Alpine) { Alpine.data('trashData', trashData); }
else document.addEventListener('alpine:init', function () { Alpine.data('trashData', trashData); });
function trashData() {
return {
search: '',
items: [],
async init() {
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
const token = csrf ? csrf[1] : '';
const csrf = getCsrf();;
const token = csrf;
try {
const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': token } });
this.items = await r.json();
@@ -80,13 +86,13 @@
return this.items.filter(i => !q || i.name.toLowerCase().includes(q) || (i.path||'').toLowerCase().includes(q));
},
async restore(id) {
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
const csrf = getCsrf();;
const r = await fetch(`/board/api/trash/${id}/restore`, { method: 'POST', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
},
async deleteForever(id) {
if (!confirm('Permanently delete this page? This cannot be undone.')) return;
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
const csrf = getCsrf();;
const r = await fetch(`/board/api/trash/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
}
+7 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bienvenue sur FlowDeck</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
<script src="/static/js/alpine.csp.min.js" defer data-cfasync="false"></script>
<style>
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;}
*{margin:0;padding:0;box-sizing:border-box;}
@@ -134,6 +134,12 @@ h1{font-size:22px;font-weight:700;margin-bottom:6px;}
</div>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: onboarding »).
if (window.Alpine) { Alpine.data('onboarding', onboarding); }
else document.addEventListener('alpine:init', function () { Alpine.data('onboarding', onboarding); });
function onboarding() {
return {
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
+9 -3
View File
@@ -6,7 +6,7 @@
{% block topbar %}
{% set page_icon = "home" %}
{% set page_title = "Workspace — Projects" %}
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">' ~ fd_icon("key",16) ~ '</a><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
{% set right_actions %}<a href="/auth/login?provider=local" class="topbar-btn" title="Login">{{ fd_icon("key",16) }}</a><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
{% include '_header.html' %}
{% endblock %}
@@ -62,7 +62,7 @@
<template x-for="p in builtinProjects" :key="p.id">
<div class="project-card" @click="openProject(p)">
<div class="project-card-top">
<span class="project-card-icon" x-html="getSvgIcon(p.icon || 'folder',20)"></span>
<span class="project-card-icon" x-init="bindProjectIcon($el, p)"></span>
<span class="forge-badge builtin">Built-in</span>
</div>
<div class="project-card-name" x-text="p.name"></div>
@@ -141,6 +141,12 @@
</div>
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: workspacePage »).
if (window.Alpine) { Alpine.data('workspacePage', workspacePage); }
else document.addEventListener('alpine:init', function () { Alpine.data('workspacePage', workspacePage); });
function workspacePage() {
return {
builtinProjects: [],
@@ -174,7 +180,7 @@ function workspacePage() {
if (!this.newProjectName.trim()) return;
const r = await fetch('/api/workspace/projects', {
method: 'POST',
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
body: JSON.stringify({name: this.newProjectName.trim()})
});
if (r.ok) {
+3 -3
View File
@@ -6,7 +6,7 @@
{% block topbar %}
{% set breadcrumb_items = [{"label": "Workspaces", "url": None}] %}
{% set page_icon = "home" %}
{% set right_actions = '<a href="/auth/login?provider=local" class="topbar-btn" title="Login">' ~ fd_icon("key",16) ~ '</a><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
{% set right_actions %}<a href="/auth/login?provider=local" class="topbar-btn" title="Login">{{ fd_icon("key",16) }}</a><a href="/accounts/settings" class="topbar-btn" title="Settings">{{ fd_icon("settings",16) }}</a>{% endset %}
{% include '_header.html' %}
{% endblock %}
@@ -159,12 +159,12 @@
</div>
<!-- Create/Rename dialog -->
<div class="dialog-overlay" x-show="showCreate||showRename" @click.outside="showCreate=false;showRename=false">
<div class="dialog-overlay" x-show="showCreate||showRename" @click.outside="closeCreateRename()">
<div class="dialog-box">
<h3 x-text="showRename?'Rename Workspace':'New Workspace'"></h3>
<input class="dialog-input" x-model="wsName" placeholder="Workspace name" @keydown.enter="showRename?doRename():doCreate()">
<div class="dialog-actions">
<button class="btn btn-secondary" @click="showCreate=false;showRename=false">Cancel</button>
<button class="btn btn-secondary" @click="closeCreateRename()">Cancel</button>
<button class="btn btn-primary" @click="showRename?doRename():doCreate()" x-text="showRename?'Rename':'Create'"></button>
</div>
</div>
+7
View File
@@ -16,6 +16,12 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape
# dans ce cas, donc rien n'est bloqué).
CSP_NONCE: ContextVar[str] = ContextVar("csp_nonce", default="")
# A43 : jeton CSRF rendu côté serveur dans `hx-headers` (base.html) — posé
# par le middleware CSRF AVANT call_next, lu via `{{ csrf_token() }}`
# (vide = cookie absent sur cette requête, htmx:configRequest re-lit le
# cookie au moment de l'appel → jamais de « __CSRF_PLACEHOLDER__ » servi).
CSRF_TOKEN: ContextVar[str] = ContextVar("csrf_token", default="")
ENV = Environment(
loader=FileSystemLoader("app/templates"),
autoescape=select_autoescape(["html"]),
@@ -33,3 +39,4 @@ except OSError: # pragma: no cover
ENV.globals["asset_version"] = ASSET_VERSION
ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get()
ENV.globals["csrf_token"] = lambda: CSRF_TOKEN.get()
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "FlowDeck",
"version": "7.29.0"
"version": "7.45.2"
},
"paths": {
"/auth/register": {
+40
View File
@@ -0,0 +1,40 @@
// CSP build : x-data="foo()" — globale window vs Alpine.data, lequel résout ?
const { chromium } = require('playwright-core');
const path = require('path');
const fs = require('fs');
function findChromium() {
const root = path.join(process.env.LOCALAPPDATA, 'ms-playwright');
const dirs = fs.readdirSync(root).filter((d) => d.startsWith('chromium-') && !d.includes('headless'));
dirs.sort();
return path.join(root, dirs[dirs.length - 1], 'chrome-win64', 'chrome.exe');
}
const ALPINE = fs.readFileSync(
'C:/Users/bruno/AppData/Local/hermes/cache/scratch/alpine_csp.js',
'utf-8'
);
const html = `<!DOCTYPE html><html><body>
<div id="a" x-data="composantGlobal()" x-init="init()"><span id="s1" x-text="v"></span></div>
<div id="b" x-data="composantAlpineData()" x-init="init()"><span id="s2" x-text="v"></span></div>
<script>${ALPINE.replace(/<\/script>/g, '<\\/script>')}</script>
<script>
function composantGlobal() { return { v: '?', init() { this.v = 'glok'; } }; }
document.addEventListener('alpine:init', () => {
Alpine.data('composantAlpineData', () => ({ v: '?', init() { this.v = 'adok'; } }));
});
</script></body></html>`;
(async () => {
const browser = await chromium.launch({ headless: true, executablePath: findChromium() });
const page = await browser.newPage();
const errs = [];
page.on('pageerror', (e) => errs.push(e.message.slice(0, 120)));
await page.setContent(html, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(500);
const res = await page.evaluate(() => ({
global: document.querySelector('#s1').textContent,
alpineData: document.querySelector('#s2').textContent,
}));
console.log('RESULTATS:', JSON.stringify(res), 'ERREURS:', errs.length ? errs : 'aucune');
await browser.close();
})();
+237
View File
@@ -0,0 +1,237 @@
const { test, expect } = require('@playwright/test');
/**
* Aperçu CSP strict (A20 phase 3) : charge la page avec le build CSP
* d'Alpine (fichier officiel `@alpinejs/csp`, 0 eval) servi à la place de
* alpine.min.js via interception — SANS déployer. Toute expression que le
* parseur maison ne digère pas = pageerror « CSP Parser Error » (filet) ;
* les x-html restants = directive interdite du build (console error).
* Quand toutes les surfaces passent ici → bascule réelle + retrait
* d'unsafe-eval (ROADMAP A20 phase 3).
*/
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
test.use({ serviceWorkers: 'block' });
const errors = [];
let currentUrl = '';
test.beforeEach(async ({ page }) => {
errors.length = 0;
currentUrl = '';
await page.route('**/static/js/alpine.min.js', (route) =>
route.fulfill({
path: require('path').join(__dirname, 'fixtures', 'alpine.csp.js'),
contentType: 'application/javascript',
})
);
page.on('console', (m) => {
if (m.type() !== 'error') return;
if (/Failed to load resource/.test(m.text())) return;
errors.push(m.text());
});
page.on('pageerror', (e) =>
errors.push('pageerror@' + (currentUrl || '?') + ': ' + e.message)
);
});
test.afterEach(() => expect(errors).toEqual([]));
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page
.waitForURL('**/workspaces', { timeout: 10000 })
.then(() => true)
.catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
data: { email: USER, password: PASS, name: 'E2E' },
});
if (resp.status() === 409) throw new Error('compte e2e existant — FD_USER/FD_PASS incorrects');
if (!resp.ok()) throw new Error(`register ${resp.status()}: ${await resp.text()}`);
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForURL('**/workspaces', { timeout: 15000 });
}
}
async function assertBound(page) {
return page.evaluate(() => {
const el = document.querySelector('[x-data]');
if (!el || !window.Alpine) return 'absent';
try {
const d = window.Alpine.$data(el);
return d && typeof d === 'object' ? 'ok' : 'vide';
} catch (e) {
return 'throw:' + e.message;
}
});
}
test('A20-ph3 : surfaces simples sous build CSP (welcome/trash/accounts/workspace)', async ({ page }) => {
// scan statique = 0 expression/x-html sur ces gabarits → ici on traque
// les échecs RUNTIME (globales, timing de registre, scope de structure)
// /welcome est anonyme (avant login aussi) mais login() ne gêne pas
await login(page);
for (const url of ['/welcome', '/trash', '/accounts', '/workspace', '/import',
'/gitea-workspace']) {
currentUrl = url;
await page.goto(FD_BASE + url, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(500);
expect(await assertBound(page), `x-data non lié sur ${url}`).toBe('ok');
}
// panneau agent (composant de base, x-html markdown migré via bindMarkdown)
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(400);
const agent = await page.evaluate(() => {
const el = document.querySelector('#fd-agent-panel');
if (!el || !window.Alpine) return 'absent';
try {
const d = window.Alpine.$data(el);
return d && typeof d === 'object' ? 'ok' : 'vide';
} catch (e) {
return 'throw:' + e.message;
}
});
expect(agent).toBe('ok');
});
test('A20-ph3 : éditeur de page (right_actions) sous build CSP', async ({ page }) => {
// le topbar vit dans le scope appState : les12 sites window.E ont été
// remplacés par edCall/edTimeAgo/edCommentCount/edShared/bindStar —
// toute expression non parsable = pageerror (filet).
await login(page);
const coll = await page.evaluate(async () => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
const r = await fetch('/db/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ name: 'e2e-csp-editor' }),
});
return r.json();
});
expect(coll.id, JSON.stringify(coll)).toBeTruthy();
try {
await page.goto(`${FD_BASE}/pages/${coll.id}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
// le composant éditeur est lié
const editor = await page.evaluate(() => {
const el = document.querySelector('#page-editor, .page-editor, [x-data]');
const root = document.querySelector('.app-layout');
const d1 = root && window.Alpine ? window.Alpine.$data(root) : null;
const hasEd = d1 && typeof d1.edCall === 'function';
let ed = 'absent';
try {
const cand = Array.from(document.querySelectorAll('[x-data]'))
.map((e) => e.getAttribute('x-data'))
.filter((a) => a && a.startsWith('editorState'));
ed = cand.length ? 'ok' : 'aucun-editorState';
} catch (e) { ed = 'throw'; }
return { ed: ed, delegates: hasEd ? 'ok' : 'absent', el: !!el };
});
expect(editor.delegates).toBe('ok');
expect(editor.ed).toBe('ok');
// les boutons du topbar sont réellement servis (bug A10 « ~ + Markup »
// corrigé : block-set) ET leurs expressions passent le filet CSP
await expect(page.locator('.star-btn').first()).toBeAttached();
} finally {
await page.evaluate(async (id) => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
await fetch(`/db/api/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf } });
}, coll.id);
}
});
test('UI : éditeur visuel de steps automations (API v7.0)', async ({ page }) => {
// crée une automation, ouvre l'édition, ajoute une étape (POST /steps),
// vérifie la carte résumée — sous CSP réel (expressions du nouveau bloc)
await login(page);
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(500);
await page.click('.settings-nav-item:has-text("Automations")');
const name = 'e2e-steps-' + Date.now();
await page.fill('input[placeholder*="Notifier le statut"]', name);
await page.click('button:has-text("Créer")');
await page.waitForTimeout(700);
const row = page.locator('.setting-row', { hasText: name }).last();
await row.locator('button:has-text("✎")').click();
await page.waitForTimeout(700);
await expect(page.locator('text=Pipeline visuel (steps)')).toBeVisible();
await page.click('button:has-text("Ajouter l\'étape")');
await page.waitForFunction(
() => Array.from(document.querySelectorAll('.setting-label'))
.some((e) => (e.textContent || '').includes('Action · webhook')),
null,
{ timeout: 8000 }
);
// nettoyage API
await page.evaluate(async (n) => {
const d = await (await fetch('/workspace/automations')).json();
const a = (d.automations || []).find((x) => x.name === n);
if (a) {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
await fetch('/workspace/automations/' + a.id,
{ method: 'DELETE', headers: { 'X-CSRF-Token': csrf } });
await fetch('/workspace/automations/' + a.id + '/steps', { method: 'DELETE' }).catch(() => {});
}
}, name);
});
test('A20-ph3 : settings sous build Alpine CSP', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/settings`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
expect(await assertBound(page)).toBe('ok');
await expect(page.locator('.settings-overlay')).toBeVisible();
});
test('A20-ph3 : local workspace sous build Alpine CSP', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/local-workspace`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
expect(await assertBound(page)).toBe('ok');
// recherche : toggleSearch() (méthode réelle) + focus Alpine.nextTick
await page.click('button.ws-icon-btn[title="Search"]');
await page.waitForTimeout(400);
const focused = await page.evaluate(
() => document.activeElement && document.activeElement.getAttribute('x-ref') === 'searchInput'
);
expect(focused).toBe(true);
// chips filtre : bindSvg() via x-init (x-html interdit en CSP) —
// x-init tourne même si la rangée est masquée (x-show=filterType)
const svg = await page.evaluate(
() => (document.querySelector('button.filter-chip[title="Folders"]') || {}).innerHTML || ''
);
expect(svg).toContain('<svg');
});
test('A20-ph3 : library sous build Alpine CSP', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/library`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
// le composant est lié par le registre Alpine.data (scope CSP)
expect(await assertBound(page)).toBe('ok');
// icône du empty-state : x-html remplacé par x-init + Alpine.effect
await expect(page.locator('#lib-empty .empty-icon')).toBeVisible({ timeout: 8000 });
const svg = await page.evaluate(
() => document.querySelector('#lib-empty .empty-icon').innerHTML
);
expect(svg).toContain('<svg');
// bouton recherche : @click.stop → toggleSearch() (méthode réelle,
// Alpine.nextTick pour le focus) — l'expression inline arrow n'existe plus
await page.click('.lib-icon-btn[title="Search"]');
await expect(page.locator('#lib-search-input')).toBeVisible();
await page.waitForTimeout(300);
const focused = await page.evaluate(
() => document.activeElement && document.activeElement.id === 'lib-search-input'
);
expect(focused).toBe(true);
});
File diff suppressed because one or more lines are too long
+97
View File
@@ -0,0 +1,97 @@
/** Diag temporaire : navigation partielle vers l'éditeur, erreurs par phase */
const { test } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page.waitForURL('**/workspaces', { timeout: 8000 }).then(() => true).catch(() => false);
if (!ok) {
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForURL('**/workspaces', { timeout: 15000 });
}
}
function alpineErrs(msgs) {
return msgs.filter((m) => m.includes('Alpine Expression Error') || m.includes('PAGEERROR'));
}
async function partialClick(page, pid) {
await page.evaluate((id) => {
const a = document.createElement('a');
a.href = '/pages/' + id;
a.textContent = 'x';
document.body.appendChild(a);
a.click();
}, pid);
}
test('diag phases', async ({ page }) => {
test.setTimeout(90000);
const msgs = [];
page.on('console', (m) => msgs.push(m.type() + ' | ' + m.text().slice(0, 160).replace(/\s+/g, ' ')));
page.on('pageerror', (e) => msgs.push('PAGEERROR | ' + String(e).slice(0, 200)));
await login(page);
const pid = await page.evaluate(async () => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
const r = await fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ name: 'Diag2', type: 'page' }),
});
const d = await r.json();
return d.id || (d.item && d.item.id);
});
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(800);
msgs.length = 0;
// PHASE A : navigation partielle (clic intercepte par fdLoad)
await partialClick(page, pid);
await page.waitForTimeout(4000);
const stackA = await page.evaluate(() => {
const el = document.querySelector('[x-data="editorState()"]');
const st = el && el._x_dataStack;
return { stack: st ? st.map((o) => (o ? Object.keys(o).length : -1)) : 'none', refreshed: !!(el && el.__fdRefreshed) };
});
const trace = await page.evaluate(() => window.__fdTrace || []);
stackA.trace = trace;
console.log('PHASE A (partielle): alpineErrs=', alpineErrs(msgs).length, 'total=', msgs.length, JSON.stringify(stackA));
console.log('TRACE:', JSON.stringify(trace));
alpineErrs(msgs).slice(0, 6).forEach((m) => console.log(' A>', m));
msgs.filter((m) => !m.includes('Alpine Expression')).slice(0, 5).forEach((m) => console.log(' A-other>', m));
// A2 : les erreurs s'arretent-elles apres le montage (fenetre transitoire) ?
const a1 = alpineErrs(msgs).length;
await page.waitForTimeout(3000);
const a2 = alpineErrs(msgs).length;
const ui = await page.evaluate(() => ({
title: !!document.getElementById('_titleEl'),
blocks: document.querySelectorAll('[data-bid]').length,
editorVisible: !!document.querySelector('.page-editor-wrapper, .editor-wrap, [x-data^="editorState"]'),
}));
console.log('PHASE A2: erreurs a t0=', a1, '-> apres +3s=', a2, 'UI=', JSON.stringify(ui));
// PHASE B : rechargement complet de la meme page
msgs.length = 0;
await page.goto(`${FD_BASE}/pages/${pid}`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(3000);
console.log('PHASE B (complet): alpineErrs=', alpineErrs(msgs).length, 'total=', msgs.length);
alpineErrs(msgs).slice(0, 6).forEach((m) => console.log(' B>', m));
// PHASE C : deuxieme navigation partielle vers la meme page
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(600);
msgs.length = 0;
await partialClick(page, pid);
await page.waitForTimeout(3500);
console.log('PHASE C (2e partielle): alpineErrs=', alpineErrs(msgs).length, 'total=', msgs.length);
alpineErrs(msgs).slice(0, 6).forEach((m) => console.log(' C>', m));
});
+108
View File
@@ -0,0 +1,108 @@
/**
* Régression — 2 bugs rapportés (post v7.45.0) :
* 1. Logout : le SW servait sa page « hors ligne » sur TOUTE navigation
* redirigée (fetch event en redirect:'manual' → opaqueredirect → rejet).
* 2. Drag & drop de fichiers : POST /api/local-workspace/upload sans
* X-CSRF-Token → 403 (A19 a retiré l'exemption sans équiper l'appel).
*
* Instance attendue sur FD_BASE_URL (défaut 8080), compte e2e documenté.
*/
const { test, expect } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page
.waitForURL('**/workspaces', { timeout: 8000 })
.then(() => true)
.catch(() => false);
if (!ok) {
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
data: { email: USER, password: PASS, name: 'E2E' },
});
if (!resp.ok() && resp.status() !== 409) {
throw new Error(`register ${resp.status()}: ${await resp.text()}`);
}
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForURL('**/workspaces', { timeout: 15000 });
}
// workspace actif requis par /api/local-workspace/upload (A22)
const ws = await page.evaluate(async () => (await fetch('/api/workspaces')).json());
if (!ws.workspaces || ws.workspaces.length === 0) {
await page.evaluate(async () => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
const r = await fetch('/api/workspaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ name: 'E2E workspace' }),
});
const w = await r.json();
await fetch(`/api/workspaces/${w.id}/select`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrf },
});
});
}
}
test('logout avec SW : atterrit sur la page login, pas hors ligne', async ({ page }) => {
await login(page);
await page.evaluate(async () => {
if ('serviceWorker' in navigator) await navigator.serviceWorker.ready;
});
await page.waitForTimeout(1000);
expect(await page.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true);
await page.goto(`${FD_BASE}/auth/logout`, { waitUntil: 'domcontentloaded' });
await page.waitForURL(/\/auth\/login/, { timeout: 10000 });
const body = await page.evaluate(() => document.body.innerText);
expect(body).not.toContain('hors ligne');
expect(page.url()).toContain('/auth/login');
});
test('drop de fichier : upload accepté (CSRF), 200 et item créé', async ({ page }) => {
test.setTimeout(60000);
await login(page);
await page.goto(`${FD_BASE}/local-workspace`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1000);
const uploadResp = [];
page.on('response', (r) => {
if (r.url().includes('/api/local-workspace/upload')) {
uploadResp.push(
r.text().then((body) => ({ status: r.status(), body })).catch(() => ({ status: r.status(), body: '' }))
);
}
});
await page.evaluate(() => {
const zone = document.querySelector('[x-data*="wsInitData"]') || document.body;
const dt = new DataTransfer();
dt.items.add(new File(['hello world'], 'e2e-drop.txt', { type: 'text/plain' }));
zone.dispatchEvent(new DragEvent('dragover', { dataTransfer: dt, bubbles: true, cancelable: true }));
zone.dispatchEvent(new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true }));
});
await expect.poll(() => uploadResp.length, { timeout: 15000 }).toBeGreaterThan(0);
const resp = await uploadResp[0];
expect(resp.status, resp.body).toBe(200);
// Nettoyage : l'upload crée une page fichier → on la supprime.
const parsed = JSON.parse(resp.body || '{}');
for (const item of parsed.items || []) {
if (!item.id) continue;
await page.evaluate(async (pid) => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
await fetch('/api/local-workspace/items/' + pid, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrf },
});
}, item.id);
}
});
+90
View File
@@ -0,0 +1,90 @@
/** Sweep diag : nav partielle vs complet sur N pages — erreurs + état fdCtx/appState */
const { test } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, { waitUntil: 'domcontentloaded' });
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await page.waitForURL('**/workspaces', { timeout: 8000 }).then(() => true).catch(() => false);
if (!ok) {
await page.request.post(`${FD_BASE}/auth/register`, { data: { email: USER, password: PASS, name: 'E2E' } });
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForURL('**/workspaces', { timeout: 15000 });
}
}
const PAGES = ['/workspaces', '/library', '/local-workspace', '/settings', '/trash', '/accounts', '/import'];
test('sweep', async ({ page }) => {
test.setTimeout(180000);
const msgs = [];
page.on('console', (m) => msgs.push(m.type() + ' | ' + m.text().slice(0, 200).replace(/\s+/g, ' ')));
page.on('pageerror', (e) => msgs.push('PAGEERROR | ' + String(e).slice(0, 160)));
await login(page);
for (const path of PAGES) {
// baseline : chargement complet
msgs.length = 0;
await page.goto(FD_BASE + path, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2200);
const fullErrs = msgs.filter((m) => m.includes('PAGEERROR') || m.includes('Alpine Expression Error') || m.includes('Uncaught'));
const stFull = await page.evaluate(() => ({
fdCtx: (() => { try { return typeof Alpine.store('fdCtx'); } catch (e) { return 'ERR'; } })(),
fdCtxHas: (() => { try { var s = Alpine.store('fdCtx'); return s ? typeof s.has + '/' + typeof s.avail : 'no store'; } catch (e) { return 'ERR'; } })(),
appState: typeof window.appState,
}));
// nav partielle : depuis /workspaces
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(700);
msgs.length = 0;
await page.evaluate((p) => {
const a = document.createElement('a');
a.href = p;
a.textContent = 'x';
document.body.appendChild(a);
a.click();
}, path);
await page.waitForTimeout(2500);
const partErrs = msgs.filter((m) => m.includes('PAGEERROR') || m.includes('Alpine Expression Error') || m.includes('Uncaught'));
// 2e visite partielle dans LE MEME document (declencheur du SyntaxError
// « Identifier ... has already been declared » sur script sans garde)
await page.evaluate(() => {
const a = document.createElement('a');
a.href = '/workspaces';
a.textContent = 'x';
document.body.appendChild(a);
a.click();
});
await page.waitForTimeout(900);
msgs.length = 0;
await page.evaluate((p) => {
const a = document.createElement('a');
a.href = p;
a.textContent = 'x';
document.body.appendChild(a);
a.click();
}, path);
await page.waitForTimeout(2500);
const part2Errs = msgs.filter((m) => m.includes('PAGEERROR') || m.includes('Alpine Expression Error') || m.includes('Uncaught') || m.includes('already been declared'));
const stPart = await page.evaluate(() => ({
fdCtx: (() => { try { return typeof Alpine.store('fdCtx'); } catch (e) { return 'ERR'; } })(),
fdCtxHas: (() => { try { var s = Alpine.store('fdCtx'); return s ? typeof s.has + '/' + typeof s.avail : 'no store'; } catch (e) { return 'ERR'; } })(),
appState: typeof window.appState,
lwGuard: !!window.__fdLocalWorkspaceScriptsLoaded,
}));
console.log(`PAGE ${path}`);
console.log(` complet : errs=${fullErrs.length} ${JSON.stringify(stFull)}`);
fullErrs.slice(0, 2).forEach((m) => console.log(' F>', m));
console.log(` 1ere partiel : errs=${partErrs.length} ${JSON.stringify(stPart)}`);
partErrs.slice(0, 3).forEach((m) => console.log(' P>', m));
console.log(` 2e partiel : errs=${part2Errs.length}`);
part2Errs.slice(0, 3).forEach((m) => console.log(' P2>', m));
}
});
+186
View File
@@ -0,0 +1,186 @@
const { test, expect } = require('@playwright/test');
/**
* Smoke E2E — fondations vérifiant les portes des reports d'audit :
* - A39 : bascule de vues (création d'une vue Board depuis la barre de
* vues d'une collection → rendu de la grille)
* - A20 : Alpine + palette de commandes (Ctrl+K, recherche GET, fermeture)
* - filet : 0 erreur console (les violations CSP atterrissent ici)
*
* READ-ONLY sur les données existantes : crée puis SUPPRIME sa collection
* (répétable). Instance de test attendue sur FD_BASE_URL (défaut 8080).
*/
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
// Service Workers BLOQUÉS : /sw.js sert sa page « hors ligne » quand la
// réponse de navigation est une redirection (redirect:'manual' sur les
// requêtes navigate) — bruit PWA hors sujet ici (pwa_offline.spec.js
// couvre le SW). On interroge le serveur directement.
test.use({ serviceWorkers: 'block' });
const consoleErrors = [];
test.beforeEach(async ({ page }) => {
consoleErrors.length = 0;
page.on('console', (m) => {
if (m.type() !== 'error') return;
// les 401 de ressources (checks de session sur login) sont du bruit
// navigateur, pas une erreur JS/CSP — le reste compte
if (/Failed to load resource/.test(m.text())) return;
consoleErrors.push(m.text());
});
page.on('pageerror', (e) => consoleErrors.push('pageerror: ' + e.message));
});
test.afterEach(() => {
// Aucune erreur JS/CSP pendant le scénario
expect(consoleErrors).toEqual([]);
});
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`, {
waitUntil: 'domcontentloaded',
});
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
const ok = await Promise.race([
page
.waitForURL('**/workspaces', { timeout: 8000 })
.then(() => true)
.catch(() => false),
]);
if (!ok) {
// Compte absent de l'instance de test → création (bootstrap du harness,
// pas un mot de passe deviné : c'est le compte e2e documenté du repo).
const resp = await page.request.post(`${FD_BASE}/auth/register`, {
data: { email: USER, password: PASS, name: 'E2E' },
});
if (resp.status() === 409) {
throw new Error(
'compte e2e existant mais mot de passe refusé — définir FD_USER/FD_PASS'
);
}
if (!resp.ok()) {
throw new Error(`register ${resp.status()}: ${await resp.text()}`);
}
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForURL('**/workspaces', { timeout: 15000 });
}
// workspace requis pour créer une collection (compte neuf = aucun ws)
const ws = await page.evaluate(async () => (await fetch('/api/workspaces')).json());
if (!ws.workspaces || ws.workspaces.length === 0) {
await page.evaluate(async () => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
const r = await fetch('/api/workspaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ name: 'E2E workspace' }),
});
const w = await r.json();
await fetch(`/api/workspaces/${w.id}/select`, { method: 'POST' });
});
}
}
test('gate A39 : bascule de vues (table → Calendar, rendu par onglet)', async ({ page }) => {
await login(page);
// collection jetable (créée puis supprimée = répétable). /db/{id} est une
// page STANDALONE (hors base.html) : les onglets .view-tabs naviguent en
// ?view=… et le corps est rendu côté serveur par _render_view().
const coll = await page.evaluate(async () => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
const r = await fetch('/db/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ name: 'e2e-view-switch' }),
});
return r.json();
});
expect(coll.id, `création collection: ${JSON.stringify(coll)}`).toBeTruthy();
try {
await page.goto(`${FD_BASE}/db/${coll.id}`, { waitUntil: 'domcontentloaded' });
await expect(page.locator('.view-tabs a.tab')).toHaveCount(11);
await expect(page.locator('.calendar')).toHaveCount(0); // vue table par défaut
// bascule réelle : clic sur l'onglet Calendar → navigation ?view=calendar
await page.click('.view-tabs a.tab:has-text("Calendar")');
await page.waitForURL(/view_type=calendar/, { timeout: 10000 });
await expect(page.locator('.view-tabs a.tab.active')).toContainText('Calendar');
// corps Calendar rendu par _render_calendar (grille 6×7)
await expect(page.locator('.calendar')).toHaveCount(1);
expect(await page.locator('.cal-header').count()).toBeGreaterThanOrEqual(7);
} finally {
await page.evaluate(async (id) => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
await fetch(`/db/api/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf } });
}, coll.id);
}
});
test('gate A20 : palette Ctrl+K (Alpine + recherche GET)', async ({ page }) => {
await login(page);
await page.goto(`${FD_BASE}/workspaces`, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(800);
await page.keyboard.press('Control+k');
await page.waitForTimeout(400);
// overlay ouvert (classe .open pilotée par l'IIFE de base.html)
const open = await page.evaluate(() => {
const ov = document.querySelector('#fd-command-palette');
return !!ov && ov.classList.contains('open');
});
expect(open).toBe(true);
// tape une requête → la recherche GET répond et rend des résultats
await page.keyboard.type('a');
await page.waitForTimeout(900);
const items = await page.evaluate(
() => document.querySelectorAll('.cmd-palette-item').length
);
expect(items).toBeGreaterThan(0);
// Alpine doit être lié (build CSP : le x-data + ses expressions évalués
// SANS eval) sur un composant réel de la page
const alpine = await page.evaluate(() => {
const el = document.querySelector('[x-data]');
if (!el || !window.Alpine) return 'absent';
try {
const data = window.Alpine.$data(el);
return data && typeof data === 'object' ? 'ok:' + Object.keys(data).slice(0, 3).join(',') : 'vide';
} catch (e) {
return 'throw:' + e.message;
}
});
expect(alpine).toMatch(/^ok:/);
// Échap ferme la palette
await page.keyboard.press('Escape');
await page.waitForTimeout(300);
const closed = await page.evaluate(() => {
const ov = document.querySelector('#fd-command-palette');
return !ov || !ov.classList.contains('open');
});
expect(closed).toBe(true);
// onglet ✨ Réponses IA : POST /api/v2/search/ask (extractif hors-LM)
await page.keyboard.press('Control+k');
await page.waitForTimeout(300);
await page.click('.cp-tab[data-tab="ai"]');
await page.waitForSelector('.cmd-palette-ai', { timeout: 10000 });
await page.waitForFunction(
() => {
const el = document.querySelector('.cmd-palette-ai');
return el && !el.querySelector('.cp-loading');
},
null,
{ timeout: 15000 }
);
const ai = await page.evaluate(() => {
const el = document.querySelector('.cmd-palette-ai');
return { text: ((el && el.textContent) || '').trim() };
});
expect(ai.text.length).toBeGreaterThan(5);
});
+2
View File
@@ -40,6 +40,8 @@ const browserGlobals = {
Alpine: "readonly", htmx: "readonly", Sortable: "readonly",
// Globals exposed on window by app.js
openModal: "readonly", closeModal: "readonly",
// getCsrf : helper unique posé dans le <head> de base.html (A38 phase 1)
getCsrf: "readonly",
// getSvgIcon : script inline de base.html (nonce) ; Prism : CDN des vues ;
// TextDecoder : API navigateur (ES2015)
getSvgIcon: "readonly", Prism: "readonly", TextDecoder: "readonly",
+1 -1
View File
@@ -7,7 +7,7 @@ pythonpath = ["."]
[tool.ruff]
target-version = "py312"
target-version = "py313"
line-length = 110
exclude = [".venv", ".venv311", ".pytest_cache", "static/js/vendor"]
+22 -1
View File
@@ -3,7 +3,28 @@
Référence: Notion Light Mode (par défaut)
═══════════════════════════════════════════════════════════ */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
/* Inter auto-hébergé — remplace l'@import Google Fonts que la CSP bloque
(style-src sans fonts.googleapis depuis v7.27). Police variable v20 :
une face par sous-ensemble, font-weight 100-900. */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/static/fonts/inter-latin-ext.woff2') format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url('/static/fonts/inter-latin.woff2') format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* ===== LIGHT THEME (default) ===== */
:root {
Binary file not shown.
Binary file not shown.
+4
View File
@@ -21,6 +21,10 @@
window.Alpine.__fdCtxStore = true;
Alpine.store('fdCtx', {
addTagAndClear(value, color, el){
this.addNewTag(value, color);
if (el) el.value = '';
},
open: false, x: 0, y: 0, node: null, page: null,
maxH: 0, _cx: 0, _cy: 0, _ro: null,
handlers: {},
+2 -2
View File
@@ -188,7 +188,7 @@
var fd = new FormData();
fd.append('name', this.customName || 'emoji');
fd.append('file', this.customFile);
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
var csrf = getCsrf();
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) {
@@ -203,7 +203,7 @@
},
async deleteCustom(id) {
try {
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
var csrf = getCsrf();
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 { /* volontaire */ }
+13 -8
View File
@@ -97,7 +97,7 @@
var payload = {prompt: message};
if(context && context.trim()) payload.context = String(context).slice(0, 20000);
return fetch('/api/agent/generate', {
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify(payload)
}).then(function(resp){
return resp.json();
@@ -243,7 +243,7 @@
if(self.llmModel) payload.model = self.llmModel;
fetch('/api/agent/generate', {
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(payload)
method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, body: JSON.stringify(payload)
}).then(function(r){ return r.json(); }).then(function(d){
self.messages = self.messages.filter(function(m){ return m.id !== thinkId; });
if(d && d.ok && d.text){
@@ -265,6 +265,10 @@
});
}).catch(function(){});
},
// ── A20 ph3 : x-html (markdown) → x-init + effet réactif ──
bindMarkdown(el, m){
Alpine.effect(() => { el.innerHTML = this.renderMarkdown(m.content); });
},
approveProposal(m){
if(!m || m.applied || m.offline) return;
var ok = window.fdApplyDocument ? window.fdApplyDocument(m.content, m.mode) : false;
@@ -366,7 +370,7 @@
installGallerySkill(slug, icon, name){
var self = this;
fetch('/api/agent/skills/gallery/' + encodeURIComponent(slug) + '/install', {
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'}, body: '{}'
method: 'POST', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'}, body: '{}'
}).then(function(r){
return r.json().then(function(d){ return {ok: r.ok, d: d}; });
}).then(function(res){
@@ -390,6 +394,7 @@
this._insertToken({token:pin.token, label:pin.label, icon:pin.icon, kind:'skill'});
this.toast('Skill épinglé — décrivez votre demande, il sera appliqué à l\u2019envoi.');
},
showHistory(){ this.tab = 'history'; this.loadConversations(); },
loadConversations(){
var self = this;
fetch('/api/agent/conversations').then(function(r){return r.json()}).then(function(d){
@@ -530,7 +535,7 @@
var body = {title:'Nouvelle conversation'};
if(self.llmProvider) body.provider = self.llmProvider;
if(self.llmModel) body.model = self.llmModel;
fetch('/api/agent/conversations', {method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)})
fetch('/api/agent/conversations', {method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, body: JSON.stringify(body)})
.then(function(r){return r.json()}).then(function(d){
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv;
@@ -547,7 +552,7 @@
if(self.llmProvider) body.provider = self.llmProvider;
if(self.llmModel) body.model = self.llmModel;
self._ensuring = fetch('/api/agent/conversations', {
method:'POST', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)
method:'POST', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, body: JSON.stringify(body)
}).then(function(r){return r.json()}).then(function(d){
var conv = {id:d.id, title:d.title, updated_at:new Date().toISOString()};
self.conversations.unshift(conv); self.currentConv = conv;
@@ -569,7 +574,7 @@
if(self.llmProvider) body.provider = self.llmProvider;
if(self.llmModel) body.model = self.llmModel;
fetch('/api/agent/conversations/'+self.currentConv.id, {
method:'PATCH', headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify(body)
method:'PATCH', headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, body: JSON.stringify(body)
}).catch(function(){});
},
@@ -1370,7 +1375,7 @@
if(m.id && /^\d+$/.test(String(m.id))) payload.message_id = Number(m.id);
fetch('/api/agent/feedback', {
method: 'POST',
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
body: JSON.stringify(payload)
}).then(function(r){ return r.json(); }).then(function(d){
if(d && d.status === 'recorded'){ m.fb = rating; }
@@ -1468,7 +1473,7 @@
return fetch('/api/agent/conversations/'+self.currentConv.id+'/run', {
method:'POST',
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify(payload)
}).then(function(resp){
if(!resp.ok){ return resp.json().then(function(j){ throw new Error(j.detail || ('HTTP '+resp.status)); }); }
+23
View File
File diff suppressed because one or more lines are too long
+74
View File
@@ -1,5 +1,6 @@
// FlowDeck Notion UI — Client-side logic
// Alpine.js + SortableJS + HTMX + Mobile support
/* exported openCardDetail */ /* global owner, repo */
// ── Frontend Error Capture ──────────────────────────────────
// Intercepte TOUTES les erreurs JS et les envoie au backend.
@@ -325,6 +326,67 @@
fdLoad(window.location.href, false);
});
// ── Swap partiel : monter proprement la zone swapée sous Alpine ──
// Lors d'une navigation partielle (fdLoad), les <script src> de la page
// s'exécutent APRÈS le microtask MutationObserver d'Alpine : toute la zone
// .main-wrapper serait initialisée avant que composants ET stores (menu
// contextuel $store.fdCtx, …) existent → « Undefined variable: … » puis
// « reading 'has' » sur les stores. On pose x-ignore sur TOUTE la zone
// pendant le chargement des scripts, puis on démarle quand tous les
// <script src> ont exécuté (load/error) — filet de sécurité 4 s.
// (initTree est idempotent : le _x_marker posé au premier passage évite
// tout double montage.)
var fdLastMw = document.querySelector('.main-wrapper'); // nœud du chargement complet
window.fdRefreshXData = function (name) {
document.querySelectorAll('[x-data="' + name + '()"]').forEach(function (el) {
if (el.__fdRefreshed) return;
var st = el._x_dataStack;
if (!st) return; // sous x-ignore → done() montera
if (st[0] && Object.keys(st[0]).length) return; // déjà monté correctement
el.__fdRefreshed = true; // monté cassé (hors watcher) → on remonte
var fresh = el.cloneNode(true);
el.parentNode.replaceChild(fresh, el);
if (window.htmx) { try { htmx.process(fresh); } catch { /* volontaire */ } }
});
};
document.addEventListener('htmx:afterSwap', function () {
var mw = document.querySelector('.main-wrapper');
// nœud inchangé = swap hors navigation (sidebar, vues) : rien à faire
if (!mw || mw === fdLastMw || mw.__fdSwapWatch) return;
fdLastMw = mw;
mw.__fdSwapWatch = true;
mw.setAttribute('x-ignore', '');
var pending = mw.querySelectorAll('script[src]').length;
var done = function () {
if (!mw.__fdSwapWatch) return;
mw.__fdSwapWatch = false;
document.removeEventListener('load', onScript, true);
document.removeEventListener('error', onScript, true);
if (!mw.isConnected || !mw.hasAttribute('x-ignore')) return;
mw.removeAttribute('x-ignore');
mw._x_ignore = false; // propriété posée par Alpine au checkpoint
try { Alpine.initTree(mw); } catch (e) { console.warn('[FlowDeck] mount', e); }
};
var onScript = function (e) {
if (!e.target || e.target.tagName !== 'SCRIPT') return;
if (--pending <= 0) done();
};
if (!pending) {
// Avant le microtask MutationObserver d'Alpine : retirer x-ignore suffit,
// le MO initialisera au checkpoint (initTree de sécurité = no-op).
mw.removeAttribute('x-ignore');
mw.__fdSwapWatch = false;
setTimeout(function () {
if (mw.isConnected) { try { Alpine.initTree(mw); } catch (e) { console.warn('[FlowDeck] mount', e); } }
}, 0);
return;
}
document.addEventListener('load', onScript, true);
document.addEventListener('error', onScript, true);
setTimeout(done, 4000);
});
// Ctrl/Cmd + \ : toggle the sidebar (Notion-style).
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && (e.key === '\\' || e.code === 'Backslash')) {
@@ -395,3 +457,15 @@
});
})();
// A38 : openCardDetail était définie (corps identique) dans board_fragment
// ET detailed_board — une seule définition globale ici, appelée par les
// onclick/@click des deux fragments (owner/repo globaux fournis par board.js).
function openCardDetail(id) {
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
target: '#card-modal-content',
swap: 'innerHTML'
});
document.getElementById('card-modal').style.display = 'flex';
document.getElementById('card-modal-title').textContent = 'Issue #' + id;
}
+30 -4
View File
@@ -1,3 +1,5 @@
if (!window.__fdBoardScriptsLoaded) {
window.__fdBoardScriptsLoaded = true;
/* exported setActiveTab, kanbanBoard, filterSystem, sortSystem, newIssueForm, showNewIssue -- appeles depuis les attributs HTML des templates */
const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
@@ -18,6 +20,11 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
el.classList.add('active');
}
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: kanbanBoard »).
if (window.Alpine) { Alpine.data('kanbanBoard', kanbanBoard); fdRefreshXData('kanbanBoard'); } else document.addEventListener('alpine:init', function () { Alpine.data('kanbanBoard', kanbanBoard); });
function kanbanBoard() {
return {
collapsedGroups: [],
@@ -33,6 +40,11 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
};
}
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: filterSystem »).
if (window.Alpine) { Alpine.data('filterSystem', filterSystem); fdRefreshXData('filterSystem'); } else document.addEventListener('alpine:init', function () { Alpine.data('filterSystem', filterSystem); });
function filterSystem() {
return {
activeFilters: [],
@@ -60,6 +72,9 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
},
removeFilter(i) { this.activeFilters.splice(i, 1); },
resetFilters() { this.activeFilters = []; this.statusFilters = []; },
removeFilterAndRefresh(i) { this.removeFilter(i); this.refreshView(); },
resetFiltersAndRefresh() { this.resetFilters(); this.refreshView(); },
pickStatus(v) { this.toggleStatus(v); this.showStatusMenu = false; this.refreshView(); },
refreshView() {
const activeTab = document.querySelector('.view-tab.active');
if (activeTab) activeTab.click();
@@ -67,6 +82,11 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
};
}
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: sortSystem »).
if (window.Alpine) { Alpine.data('sortSystem', sortSystem); fdRefreshXData('sortSystem'); } else document.addEventListener('alpine:init', function () { Alpine.data('sortSystem', sortSystem); });
function sortSystem() {
return {
sorts: [],
@@ -89,15 +109,20 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
};
}
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: newIssueForm »).
if (window.Alpine) { Alpine.data('newIssueForm', newIssueForm); fdRefreshXData('newIssueForm'); } else document.addEventListener('alpine:init', function () { Alpine.data('newIssueForm', newIssueForm); });
function newIssueForm() {
return {
title: '', status: 'todo',
create() {
if (!this.title.trim()) return;
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
const csrf = getCsrf();
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
headers: { 'X-CSRF-Token': csrf }
})
.then(r => r.json())
.then(data => {
@@ -131,10 +156,10 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
onEnd: function(evt) {
const cardId = evt.item.dataset.cardId;
const toStatus = evt.to.dataset.status;
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
const csrf = getCsrf();
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
headers: { 'X-CSRF-Token': csrf }
})
.then(() => {
// ponytail: refresh current view after move
@@ -146,3 +171,4 @@ const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.
});
}
});
}
+3 -1
View File
@@ -1,3 +1,5 @@
if (!window.__fdDbTableScriptsLoaded) {
window.__fdDbTableScriptsLoaded = true;
const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=el?JSON.parse(el.textContent):null;return v===undefined?null:v}catch{return null}})();
(function() {
@@ -57,7 +59,6 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=
var found = parseOpts(prop).filter(function(o){ return o.name === name; })[0];
return (found && PALETTE[found.color]) || '#8b8b8b';
}
function getCsrf() { var m = document.cookie.match(/csrf_token=([^;]+)/); return m ? m[1] : ''; }
function toast(msg, kind) {
if (typeof window.showToast === 'function') window.showToast(msg, kind);
else if (kind === 'error') console.warn(msg);
@@ -1310,3 +1311,4 @@ const DB_CONFIG=(()=>{try{const el=document.getElementById('db-config');const v=
.catch(function(){ return rowId; });
}
})();
}
+26 -6
View File
@@ -1,5 +1,5 @@
document.addEventListener('alpine:init', () => {
var _regGiteaWs = () => {
Alpine.data('giteaWorkspace', () => {
const params = new URLSearchParams(window.location.search);
const owner = params.get('owner') || '';
@@ -98,7 +98,7 @@ document.addEventListener('alpine:init', () => {
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' },
headers: {'X-CSRF-Token': getCsrf(), '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){
@@ -113,7 +113,7 @@ document.addEventListener('alpine:init', () => {
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]||''},
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {headers: {'X-CSRF-Token': getCsrf()},
method: 'DELETE'
}).then(function(r){
if (r.ok) { self.refreshTree(); }
@@ -208,7 +208,7 @@ document.addEventListener('alpine:init', () => {
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]||''},
fetch('/api/gitea/projects/' + self.owner + '/' + self.repo + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': getCsrf()},
method: 'DELETE',
}).then(function(r) {
if (r.ok) self.refreshTree();
@@ -350,7 +350,7 @@ document.addEventListener('alpine:init', () => {
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]||''},
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': getCsrf()},
method: 'DELETE',
});
if (r.ok) {
@@ -367,6 +367,20 @@ document.addEventListener('alpine:init', () => {
this.loadSidebarTree();
},
// ── A20 ph3 : new Date / x-html interdits sous build CSP ──
closePrivateEdit() {
this.editingPrivate = null;
this.editingPrivateTitle = '';
this.editingPrivateContent = '';
},
fmtGwDate(pp) {
return pp.updated_at ? new Date(pp.updated_at + 'Z').toLocaleString() : '';
},
bindGwIcon(el, item) {
Alpine.effect(() => {
el.innerHTML = item.type === 'folder' ? getSvgIcon('folder', 16) : getSvgIcon('file', 16);
});
},
formatSize(bytes) {
if (!bytes) return '';
if (bytes < 1024) return bytes + ' B';
@@ -623,4 +637,10 @@ document.addEventListener('alpine:init', () => {
},
};
});
});
};
if (window.Alpine) {
_regGiteaWs();
fdRefreshXData('giteaWorkspace');
} else {
document.addEventListener('alpine:init', _regGiteaWs);
}
+5
View File
@@ -1,5 +1,10 @@
/* exported importWizard -- appeles depuis les attributs HTML des templates */
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
// globales window — probe « Undefined variable: importWizard »).
if (window.Alpine) { Alpine.data('importWizard', importWizard); fdRefreshXData('importWizard'); } else document.addEventListener('alpine:init', function () { Alpine.data('importWizard', importWizard); });
function importWizard() {
return {
sources: [],
+45 -5
View File
@@ -1,5 +1,10 @@
/* exported libraryPage -- appeles depuis les attributs HTML des templates */
// A20 phase 3 : enregistrement Alpine.data — le build CSP ne résout que le
// registre (probe : globale window → « Undefined variable: libraryPage »).
if (window.Alpine) { Alpine.data('libraryPage', libraryPage); fdRefreshXData('libraryPage'); }
else document.addEventListener('alpine:init', function () { Alpine.data('libraryPage', libraryPage); });
function libraryPage() {
return {
tab: 'recents',
@@ -160,7 +165,42 @@ function libraryPage() {
_escHtml(s) { var d=document.createElement('div'); d.textContent = s||''; return d.innerHTML; },
_escAttr(s) { return String(s||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); },
_renderIcon(item) {
openMoveSelected() {
this.openMovePicker(Object.keys(this.selected).map(Number));
},
// ── A20 phase 3 (surface library) : formes compatibles build CSP ──
// $nextTick / x-html ne passent pas par l'évaluateur maison du build CSP
// (arrow inline = parse error ; x-html = interdit) → méthodes JS réelles,
// réactivité via Alpine.effect — valable sous les deux builds.
toggleFilterMenu() {
this.filterOpen = !this.filterOpen;
this.sortOpen = false;
this.viewOpen = false;
},
toggleSortMenu() {
this.sortOpen = !this.sortOpen;
this.filterOpen = false;
this.viewOpen = false;
},
toggleViewMenu() {
this.viewOpen = !this.viewOpen;
this.filterOpen = false;
this.sortOpen = false;
},
toggleSearch() {
this.searchOpen = !this.searchOpen;
if (this.searchOpen) Alpine.nextTick(() => {
var el = document.getElementById('lib-search-input');
if (el) el.focus();
});
},
bindHtmlIcon(el) {
Alpine.effect(() => { el.innerHTML = getSvgIcon(this.emptyIcon, 48); });
},
bindHtmlItem(el, item) {
Alpine.effect(() => { el.innerHTML = this._renderIcon(item); });
},
_renderIcon(item) {
if (!item) return getSvgIcon('file', 14);
// Custom emoji (image URL), icon name, or unicode emoji
if (item.page_icon) return '<span style="font-size:14px;line-height:1;display:inline-flex;align-items:center;">' + (window.fdIconHtml ? window.fdIconHtml(item.page_icon, 14) : this._escHtml(item.page_icon)) + '</span>';
@@ -363,8 +403,8 @@ function libraryPage() {
},
_getCsrf() {
var m = document.cookie.match(/csrf_token=([^;]+)/);
return m ? m[1] : '';
var m = getCsrf();
return m;
},
_syncSidebar() {
@@ -740,7 +780,7 @@ function libraryPage() {
var item = store && store.node;
if (!item) return;
var self = this;
fetch('/api/local-workspace/items/' + item.id + '/tags/' + tagId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' })
fetch('/api/local-workspace/items/' + item.id + '/tags/' + tagId, {headers: {'X-CSRF-Token': getCsrf()}, method: 'DELETE' })
.then(function(r) {
if (!r.ok) return;
item.tags = (item.tags || []).filter(function(t) { return t.id !== tagId; });
@@ -760,7 +800,7 @@ function libraryPage() {
try {
var r = await fetch('/api/local-workspace/items/' + item.id + '/tags', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf()},
body: JSON.stringify({name: tagName, color: color})
});
if (r.ok) {
+107 -24
View File
@@ -1,7 +1,10 @@
if (!window.__fdLocalWorkspaceScriptsLoaded) {
window.__fdLocalWorkspaceScriptsLoaded = true;
const LW=(()=>{try{const el=document.getElementById('lw-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
console.log('FLOWDECK v2.4.6 — IIFE running, about to set _wsInitData');
window._wsInitData = (function() {
const _wsInitData = (function() { // lexical : NON propriété globalThis →
// invisible au snapshot ji du build CSP (valeur bannie sinon)
return {
tree:[],
displayTree:[],
@@ -300,7 +303,7 @@ window._wsInitData = (function() {
var self = this;
// Soft-delete all selected items
for (var i=0; i<ids.length; i++) {
await fetch('/api/local-workspace/items/' + ids[i], {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
await fetch('/api/local-workspace/items/' + ids[i], {headers: {'X-CSRF-Token': getCsrf()}, method: 'DELETE' });
}
this.clearSelection();
this._reloadAfterAction();
@@ -627,7 +630,7 @@ window._wsInitData = (function() {
if (!node) return;
try {
var method = node.favorited ? 'DELETE' : 'POST';
var r = await fetch('/board/api/favorites/' + node.id, { method: method });
var r = await fetch('/board/api/favorites/' + node.id, { method: method, headers: {'X-CSRF-Token': getCsrf()} });
if (r.ok) {
node.favorited = !node.favorited;
if (window.appState && window.appState.refreshFavorites) window.appState.refreshFavorites();
@@ -643,7 +646,7 @@ window._wsInitData = (function() {
var r = await fetch('/board/api/pages/' + node.id + '/icon', {
method: 'POST',
headers: {'Content-Type': 'application/json',
'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || ''},
'X-CSRF-Token': getCsrf()},
body: JSON.stringify({icon: icon})
});
if (!r.ok) throw new Error('icon update failed');
@@ -725,7 +728,7 @@ window._wsInitData = (function() {
color = color || (store && store.newTagColor) || '#787774';
try {
var r = await fetch('/api/local-workspace/items/' + node.id + '/tags', {
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
method: 'POST', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({name: tagName, color: color})
});
if (r.ok) {
@@ -862,7 +865,7 @@ window._wsInitData = (function() {
if (!newName) return;
try {
var r = await fetch('/api/local-workspace/items/' + id, {
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
method: 'PUT', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({name: newName})
});
if (r.ok) {
@@ -1042,7 +1045,7 @@ window._wsInitData = (function() {
this.renamingId = null;
if (!n || n === node.name) return;
var r = await fetch('/api/local-workspace/items/' + node.id, {
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
method: 'PUT', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({name: n})
});
if (r.ok) {
@@ -1096,7 +1099,7 @@ window._wsInitData = (function() {
var targetId = this.currentFolder > 0 ? this.currentFolder : null;
this.clipboard.forEach(function(id) {
fetch('/api/local-workspace/items/' + id + '/move', {
method: 'PUT', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
method: 'PUT', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({parent_id: targetId})
}).then(function(r) { if (r.ok) self._reloadAfterAction(); });
});
@@ -1107,7 +1110,7 @@ window._wsInitData = (function() {
// ── Duplicate ──
async duplicateItem(node) {
var r = await fetch('/api/local-workspace/items', {
method: 'POST', headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
method: 'POST', headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({name: node.name + ' (copy)', type: node.type || 'page',
parent_id: this.currentFolder > 0 ? this.currentFolder : null})
});
@@ -1135,7 +1138,7 @@ window._wsInitData = (function() {
this.undoMessage = 'Deleted "' + (node.name || 'item') + '"';
this.undoVisible = true;
// Delete via API
var r = await fetch('/api/local-workspace/items/' + this.undoItemId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE' });
var r = await fetch('/api/local-workspace/items/' + this.undoItemId, {headers: {'X-CSRF-Token': getCsrf()}, method:'DELETE' });
if (!r.ok) { this.undoVisible = false; return; }
// Reload from API for reliability (sidebar refresh handled by _reloadAfterAction)
this._reloadAfterAction();
@@ -1159,7 +1162,7 @@ window._wsInitData = (function() {
self._reloadAfterAction();
return;
}
fetch('/api/local-workspace/items/' + ids[i] + '/restore', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'POST' })
fetch('/api/local-workspace/items/' + ids[i] + '/restore', {headers: {'X-CSRF-Token': getCsrf()}, method: 'POST' })
.finally(function() { restoreOne(i + 1); });
}
restoreOne(0);
@@ -1455,7 +1458,7 @@ window._wsInitData = (function() {
if (this.parentFolder) body.parent_id = this.parentFolder.db_id;
var r = await fetch('/api/local-workspace/items', {
method:'POST',
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body:JSON.stringify(body)
});
if (r.ok) {
@@ -1539,7 +1542,7 @@ window._wsInitData = (function() {
if (!n||!this.target) return;
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {
method:'PUT',
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body:JSON.stringify({name:n})
});
if (r.ok) { this._reloadAfterAction(); }
@@ -1552,7 +1555,7 @@ window._wsInitData = (function() {
async doDelete() {
if (!this.target) return;
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE' });
var r = await fetch('/api/local-workspace/items/'+this.target.db_id, {headers: {'X-CSRF-Token': getCsrf()}, method:'DELETE' });
if (r.ok) { this._reloadAfterAction(); }
},
@@ -1739,7 +1742,7 @@ window._wsInitData = (function() {
try {
var r = await fetch('/api/local-workspace/items/' + itemId + '/move', {
method: 'PUT',
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json' },
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (r.ok) {
@@ -1762,7 +1765,7 @@ window._wsInitData = (function() {
var url = folderHandled ? '/api/local-workspace/upload-folder' : '/api/local-workspace/upload';
try {
var r = await fetch(url, { method: 'POST', body: formData });
var r = await fetch(url, { method: 'POST', headers: {'X-CSRF-Token': getCsrf()}, body: formData });
this.uploadProgress = 80;
var d = await r.json();
if (r.ok) {
@@ -1834,7 +1837,7 @@ window._wsInitData = (function() {
try {
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags', {
method: 'POST',
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json'},
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type': 'application/json'},
body: JSON.stringify({name: tagName})
});
if (r.ok) {
@@ -1853,7 +1856,7 @@ window._wsInitData = (function() {
async removeTag(itemId, tagId) {
try {
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags/' + tagId, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
var r = await fetch('/api/local-workspace/items/' + itemId + '/tags/' + tagId, {headers: {'X-CSRF-Token': getCsrf()},
method: 'DELETE'
});
if (r.ok) {
@@ -1999,7 +2002,7 @@ window._wsInitData = (function() {
try {
await fetch('/api/local-workspace/items/' + ids[i] + '/move', {
method: 'PUT',
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
body: JSON.stringify({ parent_id: parentId || null })
});
} catch { /* volontaire */ }
@@ -2007,13 +2010,93 @@ window._wsInitData = (function() {
this.clearSelection();
this._reloadAfterAction();
if (window.showToast) window.showToast('Item' + (ids.length > 1 ? 's' : '') + ' moved', 'success');
}
},
};
// ── A20 ph3 : x-html interdit par le build CSP → x-init + Alpine.effect
// (réactivité conservée : lecture des données réactives dans l'effect) ──
toggleViewMenu() {
this.viewMenuOpen = !this.viewMenuOpen;
this.sortOpen = false;
this.searchOpen = false;
},
toggleSortMenu() {
this.sortOpen = !this.sortOpen;
this.viewMenuOpen = false;
},
toggleFilterMenu() {
this.filterOpen = !this.filterOpen;
this.viewMenuOpen = false;
this.sortOpen = false;
},
toggleSearch() {
this.searchOpen = !this.searchOpen;
if (this.searchOpen) Alpine.nextTick(() => {
const el = document.querySelector('[x-ref="searchInput"]');
if (el) el.focus();
});
},
createPageAt(node) { window.FlowDeck.createPage(node.id); },
createFolderAt(node) { window.FlowDeck.showCreateFolderModal(node.id); },
bindSvg(el, name, size) { el.innerHTML = getSvgIcon(name, size); },
bindFileIcon(el, node) {
Alpine.effect(() => {
el.innerHTML = this._fileIcon(node.name, node.is_folder, node.content_format, node.page_icon);
});
},
bindNodeIcon(el, node) {
Alpine.effect(() => {
el.innerHTML = node.is_folder
? getSvgIcon('folder', 24)
: this._fileIcon(node.name, node.is_folder, node.content_format, node.page_icon);
});
},
bindChildren(el, node) {
Alpine.effect(() => {
el.innerHTML = this.renderChildren(node.children, (node.depth || 0) + 1, this.tagsVersion);
});
},
bindPreview(el) { Alpine.effect(() => { el.innerHTML = this.previewContent; }); },
};
})();
// Injecter toutes les props comme globales pour Alpine (résout les 36 erreurs de scope)
var _wsKeys = Object.keys(window._wsInitData);
var _wsKeys = Object.keys(_wsInitData);
for (var _i = 0; _i < _wsKeys.length; _i++) {
try { window[_wsKeys[_i]] = window._wsInitData[_wsKeys[_i]]; } catch { /* volontaire */ }
try { window[_wsKeys[_i]] = _wsInitData[_wsKeys[_i]]; } catch { /* volontaire */ }
}
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(_wsInitData).length);
// A20 ph3 : registre Alpine.data (le build CSP ne résout que le registre ;
// x-data="wsInitData()" au lieu de l'identifiant global _wsInitData).
var _regWs = function () {
// objet partagé JS↔Alpine via closure LEXICALE (const hors window →
// non snapshoté par ji ; probe4 : factory retournant l'objet = accepté).
Alpine.data('wsInitData', function () { return _wsInitData; });
// Bloc preview : le parseur le place HORS de la div racine (structure
// pré-existante) → montage distinct DÉLÉGUANT vers l'objet partagé.
// Wrapper = objet unique (les magics $nextTick… ne sont redéfinissables
// qu'une fois) + lecture/écriture via Alpine.reactive(_wsInitData) pour
// garder la réactivité JS↔Alpine.
Alpine.data('wsPreview', function () {
const R = Alpine.reactive(_wsInitData);
const t = {};
['previewVisible', 'previewX', 'previewY', 'previewName'].forEach(function (k) {
Object.defineProperty(t, k, {
get: function () { return R[k]; },
set: function (v) { R[k] = v; },
configurable: true,
});
});
t.bindPreview = function (el) {
Alpine.effect(function () { el.innerHTML = R.previewContent; });
};
return t;
});
};
if (window.Alpine) {
_regWs();
fdRefreshXData('wsInitData');
fdRefreshXData('wsPreview');
} else {
document.addEventListener('alpine:init', _regWs);
}
}
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length);
+3
View File
@@ -1,3 +1,5 @@
if (!window.__fdRtScriptsLoaded) {
window.__fdRtScriptsLoaded = true;
const RT=(()=>{try{const el=document.getElementById('rt-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
/* eslint-disable */
@@ -530,3 +532,4 @@ window.__fdRT = (function () {
return { start, syncNow };
})();
}

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