Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0af1bbfb6 | ||
|
|
de751ffe35 | ||
|
|
48b5551b93 | ||
|
|
e4552c3763 | ||
|
|
6914780f24 | ||
|
|
d7d9966edf | ||
|
|
3cab76fed5 | ||
|
|
6ff88237fc | ||
|
|
840d2b2615 | ||
|
|
ab6ac1e84c | ||
|
|
3a74ea8bbd | ||
|
|
13dc8fdaad | ||
|
|
770fdc2b68 |
@@ -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=""
|
||||
|
||||
+378
@@ -1,5 +1,383 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## 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 `"`/`<` → 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é `"`.
|
||||
- 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 `"`/`<` — les boutons Share/Star/Settings/…
|
||||
s'affichent en texte brut). Reproductible : `curl /workspaces` →
|
||||
`"topbar-btn"`. 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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+28
-6
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,6 +1,6 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v7.32.0 (audit — A28 TERMINÉ : les 4 god files découpés, board lot 4) | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.44.0 (UI palette : onglets Pages / ✨ Réponses IA — POST /api/v2/search/ask rendu + sources cité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
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.32.0",
|
||||
version="7.44.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -55,17 +55,17 @@ h1{{font-size:24px;margin:0 0 8px}} p.desc{{color:#A0A0A0;margin:0 0 20px}}
|
||||
</style></head><body>
|
||||
<h1>{icon} {title}</h1>
|
||||
<div class="view-tabs">
|
||||
<a class="tab{' active' if view_type=='table' else ''}" href="?view=table">📊 Table</a>
|
||||
<a class="tab{' active' if view_type=='board' else ''}" href="?view=board">📋 Board</a>
|
||||
<a class="tab{' active' if view_type=='calendar' else ''}" href="?view=calendar">📅 Calendar</a>
|
||||
<a class="tab{' active' if view_type=='gallery' else ''}" href="?view=gallery">🖼️ Gallery</a>
|
||||
<a class="tab{' active' if view_type=='list' else ''}" href="?view=list">📝 List</a>
|
||||
<a class="tab{' active' if view_type=='timeline' else ''}" href="?view=timeline">📈 Timeline</a>
|
||||
<a class="tab{' active' if view_type=='gantt' else ''}" href="?view=gantt">📊 Gantt</a>
|
||||
<a class="tab{' active' if view_type=='chart' else ''}" href="?view=chart">📉 Chart</a>
|
||||
<a class="tab{' active' if view_type=='form' else ''}" href="?view=form">📋 Form</a>
|
||||
<a class="tab{' active' if view_type=='map' else ''}" href="?view=map">🗺️ Map</a>
|
||||
<a class="tab{' active' if view_type=='feed' else ''}" href="?view=feed">📰 Feed</a>
|
||||
<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>"""
|
||||
@@ -130,9 +130,9 @@ def _render_calendar(view_type: str, collection: dict, pages: list[dict], config
|
||||
.cal-nav span{{font-size:16px;font-weight:600}}
|
||||
</style>
|
||||
<div class="cal-nav">
|
||||
<a href="?view=calendar&year={prev.year}&month={prev.month}">← {prev.strftime('%B')}</a>
|
||||
<a href="?view_type=calendar&year={prev.year}&month={prev.month}">← {prev.strftime('%B')}</a>
|
||||
<span>{first.strftime('%B %Y')}</span>
|
||||
<a href="?view=calendar&year={next_month.year}&month={next_month.month}">{next_month.strftime('%B')} →</a>
|
||||
<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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -546,7 +546,7 @@
|
||||
<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"
|
||||
@@ -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;">
|
||||
|
||||
@@ -105,6 +105,11 @@
|
||||
|
||||
{% 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 »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('accountsData', accountsData); });
|
||||
function accountsData() {
|
||||
return {
|
||||
profile: { full_name: '', email: '' },
|
||||
@@ -117,8 +122,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(() => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
+148
-27
@@ -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,7 +590,7 @@
|
||||
<!-- 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)">
|
||||
<span class="smi-label">Show 5 items</span>
|
||||
@@ -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() {
|
||||
@@ -1564,6 +1569,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 +1787,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 +2234,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 +2263,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 +2287,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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); }
|
||||
function highlight(text, q){
|
||||
@@ -2246,6 +2311,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 +2348,7 @@
|
||||
}
|
||||
|
||||
function runSearch(){
|
||||
if(state.tab==='ai'){ runAi(); return; }
|
||||
var q = input.value.trim();
|
||||
state.query = q;
|
||||
state.index = 0;
|
||||
@@ -2292,7 +2359,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 +2373,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 +2471,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);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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) -->
|
||||
|
||||
@@ -100,12 +100,11 @@
|
||||
</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 »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('cardDetail', cardDetail); });
|
||||
function cardDetail() {
|
||||
return {
|
||||
updateField(field, value) {
|
||||
|
||||
@@ -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) -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -255,7 +255,7 @@
|
||||
<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">
|
||||
@@ -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">
|
||||
|
||||
@@ -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)"
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -770,7 +770,7 @@
|
||||
<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;">
|
||||
@@ -837,7 +837,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>
|
||||
|
||||
@@ -100,6 +100,11 @@
|
||||
</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 »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('tableView', tableView); });
|
||||
function tableView() {
|
||||
return {
|
||||
sortField: '',
|
||||
|
||||
@@ -44,6 +44,11 @@
|
||||
</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 »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('teamLoad', teamLoad); });
|
||||
function teamLoad() {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -63,13 +63,18 @@
|
||||
|
||||
{% 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 »).
|
||||
|
||||
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 +85,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); }
|
||||
}
|
||||
|
||||
@@ -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,11 @@ 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 »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('onboarding', onboarding); });
|
||||
function onboarding() {
|
||||
return {
|
||||
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
|
||||
|
||||
@@ -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,11 @@
|
||||
</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 »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('workspacePage', workspacePage); });
|
||||
function workspacePage() {
|
||||
return {
|
||||
builtinProjects: [],
|
||||
@@ -174,7 +179,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) {
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.32.0"
|
||||
"version": "7.44.0"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -0,0 +1,202 @@
|
||||
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('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
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -7,7 +7,7 @@ pythonpath = ["."]
|
||||
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
target-version = "py313"
|
||||
line-length = 110
|
||||
exclude = [".venv", ".venv311", ".pytest_cache", "static/js/vendor"]
|
||||
|
||||
|
||||
+83
-1
@@ -3,7 +3,89 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-400-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;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-500-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: 500;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-500-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;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-600-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: 600;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-600-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;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-700-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: 700;
|
||||
font-display: swap;
|
||||
src: url('/static/fonts/inter-700-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.
@@ -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 */ }
|
||||
|
||||
@@ -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){
|
||||
@@ -530,7 +534,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 +551,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 +573,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 +1374,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 +1472,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)); }); }
|
||||
|
||||
Vendored
+23
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
@@ -395,3 +396,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;
|
||||
}
|
||||
|
||||
|
||||
+24
-4
@@ -18,6 +18,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 »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('kanbanBoard', kanbanBoard); });
|
||||
function kanbanBoard() {
|
||||
return {
|
||||
collapsedGroups: [],
|
||||
@@ -33,6 +38,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 »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('filterSystem', filterSystem); });
|
||||
function filterSystem() {
|
||||
return {
|
||||
activeFilters: [],
|
||||
@@ -67,6 +77,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 »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('sortSystem', sortSystem); });
|
||||
function sortSystem() {
|
||||
return {
|
||||
sorts: [],
|
||||
@@ -89,15 +104,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 »).
|
||||
|
||||
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 +151,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
|
||||
|
||||
@@ -57,7 +57,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);
|
||||
|
||||
@@ -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,15 @@ document.addEventListener('alpine:init', () => {
|
||||
this.loadSidebarTree();
|
||||
},
|
||||
|
||||
// ── A20 ph3 : new Date / x-html interdits sous build CSP ──
|
||||
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';
|
||||
|
||||
@@ -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 »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('importWizard', importWizard); });
|
||||
function importWizard() {
|
||||
return {
|
||||
sources: [],
|
||||
|
||||
+31
-5
@@ -1,5 +1,11 @@
|
||||
/* 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 »).
|
||||
document.addEventListener('alpine:init', function () {
|
||||
Alpine.data('libraryPage', libraryPage);
|
||||
});
|
||||
|
||||
function libraryPage() {
|
||||
return {
|
||||
tab: 'recents',
|
||||
@@ -160,7 +166,27 @@ function libraryPage() {
|
||||
|
||||
_escHtml(s) { var d=document.createElement('div'); d.textContent = s||''; return d.innerHTML; },
|
||||
_escAttr(s) { return String(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); },
|
||||
_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.
|
||||
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 +389,8 @@ function libraryPage() {
|
||||
},
|
||||
|
||||
_getCsrf() {
|
||||
var m = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
var m = getCsrf();
|
||||
return m;
|
||||
},
|
||||
|
||||
_syncSidebar() {
|
||||
@@ -740,7 +766,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 +786,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) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 +301,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();
|
||||
@@ -643,7 +644,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 +726,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 +863,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 +1043,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 +1097,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 +1108,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 +1136,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 +1160,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 +1456,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 +1540,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 +1553,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 +1740,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) {
|
||||
@@ -1834,7 +1835,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 +1854,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 +2000,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 +2008,71 @@ 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) ──
|
||||
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(window._wsInitData).length);
|
||||
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).
|
||||
document.addEventListener('alpine:init', 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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1421,6 +1421,15 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
}catch(e){this.showToast('Remove failed','error');}
|
||||
},
|
||||
toggleIcon(){this.iconOpen=!this.iconOpen;},
|
||||
openBacklink(b){
|
||||
this.backlinksOpen = false;
|
||||
window.location.href = '/pages/' + b.id;
|
||||
},
|
||||
fmtImportSize(f){ return Math.round(f.size / 1024) + ' KB'; },
|
||||
// A20 ph3 : x-html interdit sous build CSP → effet réactif
|
||||
bindIconHtml(el){
|
||||
Alpine.effect(() => { el.innerHTML = this.iconHtml(); });
|
||||
},
|
||||
iconHtml(){
|
||||
var v=this.iconValue;
|
||||
if(!v)v=(this.contentFormat==='file')?'paperclip':'file';
|
||||
@@ -1499,7 +1508,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
cutSelectedBlocks(){const indices=_selIndicesSorted();if(!indices.length)return;this.sync();this.pushHistory();const selected=indices.map(i=>this.blocks[i]).filter(Boolean);if(!selected.length)return;const md=this._blocksToMarkdown(selected);if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md).then(()=>{});}this.blocks=this.blocks.filter((b,i)=>indices.indexOf(i)<0);if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();this.showToast('Blocs coupés');},
|
||||
async pasteBlocks(){const idx=this._focusedIdx();if(idx<0)return;try{const text=await navigator.clipboard.readText();if(!text||!text.trim())return;const parsed=this.md2b(text);if(!parsed.length)return;this.sync();this.pushHistory();this.blocks.splice(idx+1,0,...parsed);this.dirty=true;this.autoSave();this.render();_rtSync();this._focusBid(parsed[parsed.length-1].id);_selClear();}catch(e){if(e.name==='NotAllowedError'){this.showToast('Permission presse-papier refusée');}else{this.showToast('Coller impossible','error');}}},
|
||||
_focusedIdx(){const a=this.getActiveBlock();return a?a.idx:-1;},
|
||||
async moveBlockToPage(idx,pageId){if(idx<0||idx>=this.blocks.length||pageId===this.pid)return;this.sync();this.pushHistory();const block=this.blocks[idx];const self=this;const csrf=document.cookie.match(/csrf_token=([^;]+)/);this.showToast('Déplacement du bloc…');try{const r=await fetch(`/board/api/pages/${pageId}`,{credentials:'same-origin'});const d=await r.json();let tblocks=[];if(d.content_format==='blocks'&&d.content){try{tblocks=JSON.parse(d.content);}catch(e){tblocks=[];}}tblocks.push(block);await fetch(`/board/api/pages/${pageId}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:d.title||'',blocks:tblocks})});
|
||||
async moveBlockToPage(idx,pageId){if(idx<0||idx>=this.blocks.length||pageId===this.pid)return;this.sync();this.pushHistory();const block=this.blocks[idx];const self=this;const csrf = getCsrf();;this.showToast('Déplacement du bloc…');try{const r=await fetch(`/board/api/pages/${pageId}`,{credentials:'same-origin'});const d=await r.json();let tblocks=[];if(d.content_format==='blocks'&&d.content){try{tblocks=JSON.parse(d.content);}catch(e){tblocks=[];}}tblocks.push(block);await fetch(`/board/api/pages/${pageId}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify({title:d.title||'',blocks:tblocks})});
|
||||
if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();this.showToast('Bloc déplacé');
|
||||
}catch(e){this.showToast('Échec du déplacement','error');}},
|
||||
|
||||
@@ -1585,9 +1594,9 @@ applyAIBlocks(text){
|
||||
},
|
||||
|
||||
toggleFavorite(){
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const csrf = getCsrf();;
|
||||
const m=this.favorited?'DELETE':'POST';
|
||||
fetch(`/board/api/favorites/${this.pid}`,{method:m,headers:{'X-CSRF-Token':csrf?csrf[1]:''}})
|
||||
fetch(`/board/api/favorites/${this.pid}`,{method:m,headers:{'X-CSRF-Token':csrf}})
|
||||
.then(r=>r.json()).then(()=>{this.favorited=!this.favorited;this.showToast(this.favorited?'Added to favorites':'Removed from favorites');
|
||||
if(window.appState&&window.appState.refreshFavorites)window.appState.refreshFavorites();
|
||||
}).catch(()=>{this.showToast('Failed to toggle favorite');});
|
||||
@@ -1608,8 +1617,8 @@ applyAIBlocks(text){
|
||||
_syncIsShared(){this.pageIsShared=!!(this.pagePublished||this.generalAccess==='anyone'||(this.accessList||[]).length>0);},
|
||||
togglePublish(){this.pagePublished=!this.pagePublished;this._syncIsShared();this.saveShare();},
|
||||
saveShare(){
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/board/api/share/${this.pid}`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({mode:this.generalAccess,published:this.pagePublished})}).then(()=>this._syncIsShared()).catch(()=>{});
|
||||
const csrf = getCsrf();;
|
||||
fetch(`/board/api/share/${this.pid}`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify({mode:this.generalAccess,published:this.pagePublished})}).then(()=>this._syncIsShared()).catch(()=>{});
|
||||
},
|
||||
async copyPageLink(){
|
||||
console.log('copyPageLink invoked');
|
||||
@@ -1632,10 +1641,10 @@ applyAIBlocks(text){
|
||||
},
|
||||
publishPage() {
|
||||
var self = this;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
var csrf = getCsrf();;
|
||||
fetch('/api/pages/' + this.pid + '/publish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
if (d.is_published) {
|
||||
self.pagePublished = true;
|
||||
@@ -1648,10 +1657,10 @@ applyAIBlocks(text){
|
||||
},
|
||||
unpublishPage() {
|
||||
var self = this;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
var csrf = getCsrf();;
|
||||
fetch('/api/pages/' + this.pid + '/publish', {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
headers: { 'X-CSRF-Token': csrf }
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
if (!d.is_published) {
|
||||
self.pagePublished = false;
|
||||
@@ -1665,10 +1674,10 @@ applyAIBlocks(text){
|
||||
shareInvite() {
|
||||
var self = this;
|
||||
if (this.inviteGroupId) {
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
var csrf = getCsrf();;
|
||||
fetch('/api/pages/' + this.pid + '/share', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ group_id: this.inviteGroupId, permission: this.invitePermission })
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
if (d.status === 'shared') {
|
||||
@@ -1680,10 +1689,10 @@ applyAIBlocks(text){
|
||||
return;
|
||||
}
|
||||
if (!this.inviteEmail.trim()) return;
|
||||
var csrf2 = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
var csrf2 = getCsrf();;
|
||||
fetch('/api/pages/' + this.pid + '/share', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf2 ? csrf2[1] : '' },
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf2 },
|
||||
body: JSON.stringify({ user_id: this.inviteUserId, email: this.inviteEmail.trim(), permission: this.invitePermission })
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
if (d.status === 'shared') {
|
||||
@@ -1760,10 +1769,10 @@ applyAIBlocks(text){
|
||||
},
|
||||
updateShare(sid, perm) {
|
||||
var self = this;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
var csrf = getCsrf();;
|
||||
fetch('/api/pages/' + this.pid + '/share/' + sid, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ permission: perm })
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
self.showToast(d.status === 'updated' ? 'Permission updated' : (d.detail || 'Update failed'));
|
||||
@@ -1779,10 +1788,10 @@ applyAIBlocks(text){
|
||||
},
|
||||
removeShare(sid) {
|
||||
var self = this;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
var csrf = getCsrf();;
|
||||
fetch('/api/pages/' + this.pid + '/share/' + sid, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
headers: { 'X-CSRF-Token': csrf }
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
if (d.status === 'removed') { self.showToast('Share removed'); self.loadShares(); }
|
||||
else { self.showToast(d.detail || 'Remove failed'); }
|
||||
@@ -1794,7 +1803,7 @@ applyAIBlocks(text){
|
||||
this.moreOpen=false;
|
||||
this.exportOpen=false;
|
||||
// Ensure latest blocks are persisted before exporting any format
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
var csrf = getCsrf();;
|
||||
var self = this;
|
||||
var doDownload = function(){
|
||||
var title = encodeURIComponent((self.pageTitle || 'Untitled').trim() || 'Untitled');
|
||||
@@ -1813,7 +1822,7 @@ applyAIBlocks(text){
|
||||
doDownload();
|
||||
}
|
||||
},
|
||||
duplicatePage(){this.moreOpen=false;const csrf=document.cookie.match(/csrf_token=([^;]+)/);const t=(this.pageTitle||'').trim()||'New Page';fetch(`/board/api/pages?title=${encodeURIComponent(t+' copy')}§ion=Private&project=${encodeURIComponent(PD.workspace_key||'')}`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(r=>r.json()).then(d=>{window.location.href=`/pages/${d.id}`;}).catch(()=>{window.showToast('Duplicate failed','error');});},
|
||||
duplicatePage(){this.moreOpen=false;const csrf = getCsrf();;const t=(this.pageTitle||'').trim()||'New Page';fetch(`/board/api/pages?title=${encodeURIComponent(t+' copy')}§ion=Private&project=${encodeURIComponent(PD.workspace_key||'')}`,{method:'POST',headers:{'X-CSRF-Token':csrf}}).then(r=>r.json()).then(d=>{window.location.href=`/pages/${d.id}`;}).catch(()=>{window.showToast('Duplicate failed','error');});},
|
||||
movePage(){
|
||||
this.moreOpen=false;
|
||||
this.moveOpen = true;
|
||||
@@ -1829,11 +1838,11 @@ applyAIBlocks(text){
|
||||
},
|
||||
doMove(wsId) {
|
||||
this.moveOpen = false;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
var csrf = getCsrf();;
|
||||
var self = this;
|
||||
fetch('/board/api/pages/' + this.pid + '/move', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
|
||||
body: JSON.stringify({ workspace_id: wsId })
|
||||
}).then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
@@ -1846,7 +1855,7 @@ applyAIBlocks(text){
|
||||
})
|
||||
.catch(function(){ self.showToast('Move failed'); });
|
||||
},
|
||||
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});},
|
||||
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf = getCsrf();;fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});},
|
||||
downloadFile(){this.moreOpen=false;if(!this.fileUrl)return;var a=document.createElement('a');a.href=this.fileUrl;a.download='';document.body.appendChild(a);a.click();document.body.removeChild(a);this.showToast('Download started','success');},
|
||||
async copyFileContent(){this.moreOpen=false;if(!this.fileUrl)return;try{var r=await fetch('/api/pages/'+this.pid+'/file-content');var d=await r.json();if(d.ok&&d.content!==undefined){await navigator.clipboard.writeText(d.content);this.showToast('Content copied to clipboard','success');}else{this.showToast('Not a text file','error');}}catch(e){this.showToast('Copy failed','error');}},
|
||||
|
||||
@@ -1871,12 +1880,12 @@ applyAIBlocks(text){
|
||||
async createDbFromTemplate(tplName){
|
||||
const self=this;
|
||||
var name = tplName ? (tplName+' — '+new Date().toLocaleDateString('fr-FR')) : 'Database';
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const csrf = getCsrf();;
|
||||
try{
|
||||
const body={name:name, parent_page_id:this.pid};
|
||||
if(tplName) body.template=tplName;
|
||||
const r=await fetch('/db/inline/api',{
|
||||
method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},
|
||||
method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},
|
||||
body:JSON.stringify(body)
|
||||
});
|
||||
const d=await r.json();
|
||||
@@ -1896,10 +1905,10 @@ applyAIBlocks(text){
|
||||
async createForm(){
|
||||
const name=prompt('Form name:','New Form');
|
||||
if(!name||!name.trim())return;
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const csrf = getCsrf();;
|
||||
try{
|
||||
const r=await fetch('/db/inline/api',{
|
||||
method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},
|
||||
method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},
|
||||
body:JSON.stringify({name:name.trim(),parent_page_id:this.pid})
|
||||
});
|
||||
const d=await r.json();
|
||||
@@ -2106,7 +2115,7 @@ applyAIBlocks(text){
|
||||
toggleComments(){this.commentsOpen=!this.commentsOpen;if(this.commentsOpen)this.loadComments();},
|
||||
async loadComments(){try{const r=await fetch('/api/pages/'+this.pid+'/comments',{credentials:'same-origin'});const d=await r.json();this.comments=d.comments||[];this.commentCount=this.comments.length;}catch(e){this.comments=[];}},
|
||||
fmtTime(s){if(!s)return '';const t=new Date((String(s).includes('T')||String(s).includes('Z'))?s:(s+'Z'));if(isNaN(t.getTime()))t=new Date(s);const diff=Math.floor((Date.now()-t.getTime())/1000);if(diff<60)return 'just now';if(diff<3600)return Math.floor(diff/60)+'m ago';if(diff<86400)return Math.floor(diff/3600)+'h ago';return Math.floor(diff/86400)+'d ago';},
|
||||
csrfTok(){return (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'';},
|
||||
csrfTok(){return getCsrf();},
|
||||
async addPageComment(){
|
||||
const text=(this.commentDraft||'').trim();if(!text)return;
|
||||
const self=this;const sel=this._commentSel;
|
||||
@@ -2378,8 +2387,8 @@ applyAIBlocks(text){
|
||||
this._fileTitleT=null;
|
||||
const title=(this.pageTitle||'').trim();
|
||||
if(!title)return;
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/board/api/pages/${this.pid}?title=${encodeURIComponent(title)}`,{method:'PUT',headers:{'X-CSRF-Token':csrf?csrf[1]:''}})
|
||||
const csrf = getCsrf();;
|
||||
fetch(`/board/api/pages/${this.pid}?title=${encodeURIComponent(title)}`,{method:'PUT',headers:{'X-CSRF-Token':csrf}})
|
||||
.then(()=>{this.dirty=false;})
|
||||
.catch(()=>{});
|
||||
},600);
|
||||
@@ -2400,8 +2409,8 @@ applyAIBlocks(text){
|
||||
.catch(()=>{this.saving=false;});
|
||||
};
|
||||
if(!navigator.onLine){queueOffline();return;}
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:blocksArr})})
|
||||
const csrf = getCsrf();;
|
||||
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify({title:this.pageTitle,blocks:blocksArr})})
|
||||
.then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();
|
||||
this._syncTitleUI();
|
||||
if(cb)cb();
|
||||
@@ -2411,11 +2420,11 @@ applyAIBlocks(text){
|
||||
const idx=this.getIdx(bid);if(idx<0)return;
|
||||
const b=this.blocks[idx];
|
||||
if(!b.automation_id){this.pickAutomation(bid);return;}
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
const csrf = getCsrf();;
|
||||
const self=this;
|
||||
this.showToast('⚡ '+ (b.automation_name||'Automation') + '…');
|
||||
fetch('/api/automations/'+b.automation_id+'/run',{method:'POST',
|
||||
headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},
|
||||
headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},
|
||||
body:JSON.stringify({page_id:this.pid})})
|
||||
.then(r=>r.json())
|
||||
.then(d=>{
|
||||
@@ -2512,5 +2521,10 @@ applyAIBlocks(text){
|
||||
default: return c;
|
||||
}
|
||||
}
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: editorState »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('editorState', editorState); });
|
||||
window.editorState = editorState;
|
||||
}
|
||||
|
||||
+24
-12
@@ -1,8 +1,20 @@
|
||||
/* exported settingsInit -- appeles depuis les attributs HTML des templates */
|
||||
const ST=(()=>{try{const el=document.getElementById('st-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
|
||||
|
||||
// A20 phase 3 : registre Alpine.data (le build CSP ne résout pas les
|
||||
|
||||
// globales window — probe « Undefined variable: settingsInit »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('settingsInit', settingsInit); });
|
||||
function settingsInit() {
|
||||
return {
|
||||
// ── A20 ph3 : expressions hostiles au parseur CSP (window/Date) ──
|
||||
historyBack() { window.history.back(); },
|
||||
fmtLastLogin(u) {
|
||||
return u.last_login ? new Date(u.last_login * 1000).toLocaleDateString() : 'Never';
|
||||
},
|
||||
fmtAuditDate(e) { return new Date(e.at).toLocaleString(); },
|
||||
|
||||
activeSection: 'account',
|
||||
allTags: [],
|
||||
newTagName: '',
|
||||
@@ -146,7 +158,7 @@ function settingsInit() {
|
||||
var n = this.newTagName.trim();
|
||||
if (!n) return;
|
||||
var r = await fetch('/api/settings/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: n, color: this.newTagColor})
|
||||
});
|
||||
if (r.ok) { this.newTagName = ''; this.newTagColor = '#787774'; await this.loadTags(); }
|
||||
@@ -154,7 +166,7 @@ function settingsInit() {
|
||||
|
||||
async updateTagColor(id, color) {
|
||||
await fetch('/api/settings/tags/' + 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({color: color})
|
||||
});
|
||||
await this.loadTags();
|
||||
@@ -162,7 +174,7 @@ function settingsInit() {
|
||||
|
||||
async deleteTag(id) {
|
||||
if (!confirm('Delete this tag?')) return;
|
||||
await fetch('/api/settings/tags/' + id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
|
||||
await fetch('/api/settings/tags/' + id, {headers: {'X-CSRF-Token': getCsrf()}, method: 'DELETE' });
|
||||
await this.loadTags();
|
||||
},
|
||||
|
||||
@@ -183,7 +195,7 @@ function settingsInit() {
|
||||
this.renamingTag = null; return;
|
||||
}
|
||||
await fetch('/api/settings/tags/' + tag.id, {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json', 'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
|
||||
method: 'PUT', headers: {'Content-Type':'application/json', 'X-CSRF-Token': getCsrf()},
|
||||
body: JSON.stringify({name: newName})
|
||||
});
|
||||
this.renamingTag = null;
|
||||
@@ -456,7 +468,7 @@ function settingsInit() {
|
||||
try {
|
||||
var r = await fetch('/api/agent/keys/' + id + '/models', {
|
||||
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({api_key: f.api_key, api_base: f.api_base})
|
||||
});
|
||||
var d = await r.json();
|
||||
@@ -485,7 +497,7 @@ function settingsInit() {
|
||||
if (f.models && f.models.length) body.models = f.models;
|
||||
var r = await fetch('/api/agent/keys/' + 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(body)
|
||||
});
|
||||
var d = await r.json();
|
||||
@@ -521,7 +533,7 @@ function settingsInit() {
|
||||
if (f.api_key) body.api_key = f.api_key;
|
||||
var r = await fetch('/api/agent/keys/' + id + '/test', {
|
||||
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)
|
||||
});
|
||||
var d = await r.json();
|
||||
@@ -547,7 +559,7 @@ function settingsInit() {
|
||||
var f = this.keyForm(id);
|
||||
f.deleting = true; f.msg = ''; f.ok = false;
|
||||
try {
|
||||
var r = await fetch('/api/agent/keys/' + id, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'DELETE' });
|
||||
var r = await fetch('/api/agent/keys/' + id, {headers: {'X-CSRF-Token': getCsrf()}, method: 'DELETE' });
|
||||
var d = await r.json();
|
||||
if (r.ok) {
|
||||
f.has_key = false; f.api_key = ''; f.models = []; f.model = ''; f.last_error = '';
|
||||
@@ -707,7 +719,7 @@ function settingsInit() {
|
||||
if (!file) return;
|
||||
var form = new FormData();
|
||||
form.append('file', file);
|
||||
var r = await fetch('/api/settings/avatar', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method: 'POST', body: form });
|
||||
var r = await fetch('/api/settings/avatar', {headers: {'X-CSRF-Token': getCsrf()}, method: 'POST', body: form });
|
||||
if (r.ok) {
|
||||
var d = await r.json();
|
||||
this.avatarUrl = d.avatar_url + '?t=' + Date.now();
|
||||
@@ -718,7 +730,7 @@ function settingsInit() {
|
||||
async selectAvatarColor(color) {
|
||||
this.avatarColor = color;
|
||||
var r = await fetch('/api/settings/avatar-color', {
|
||||
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({color: color})
|
||||
});
|
||||
if (r.ok) { this.avatarUrl = ''; }
|
||||
@@ -863,7 +875,7 @@ function settingsInit() {
|
||||
// ── v5.2.0 API tokens ──
|
||||
async loadApiTokens() {
|
||||
try {
|
||||
var r = await fetch('/api/settings/tokens', {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, credentials:'same-origin'});
|
||||
var r = await fetch('/api/settings/tokens', {headers: {'X-CSRF-Token': getCsrf()}, credentials:'same-origin'});
|
||||
var d = await r.json();
|
||||
this.apiTokens = d.tokens || [];
|
||||
} catch { this.apiTokens = []; }
|
||||
@@ -873,7 +885,7 @@ function settingsInit() {
|
||||
if (!name) return;
|
||||
try {
|
||||
var r = await fetch('/api/settings/tokens', {method:'POST', credentials:'same-origin',
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'}, body: JSON.stringify({name: name})});
|
||||
headers: {'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'}, body: JSON.stringify({name: name})});
|
||||
var d = await r.json();
|
||||
if (!r.ok) { window.showToast && window.showToast(d.detail || 'Erreur', 'error'); return; }
|
||||
this.newToken = d;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
/* exported workspacesPage -- 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: workspacesPage »).
|
||||
|
||||
document.addEventListener('alpine:init', function () { Alpine.data('workspacesPage', workspacesPage); });
|
||||
function workspacesPage() {
|
||||
return {
|
||||
workspaces: [],
|
||||
@@ -41,7 +46,7 @@ function workspacesPage() {
|
||||
},
|
||||
|
||||
async selectLocal(ws) {
|
||||
await fetch(`/api/workspaces/${ws.id}/select`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'POST'});
|
||||
await fetch(`/api/workspaces/${ws.id}/select`, {headers: {'X-CSRF-Token': getCsrf()}, method:'POST'});
|
||||
window.location = '/local-workspace';
|
||||
},
|
||||
|
||||
@@ -49,7 +54,7 @@ function workspacesPage() {
|
||||
if (!this.wsName.trim()) return;
|
||||
await fetch('/api/workspaces', {
|
||||
method:'POST',
|
||||
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
|
||||
body:JSON.stringify({name:this.wsName.trim()})
|
||||
});
|
||||
this.wsName = '';
|
||||
@@ -67,7 +72,7 @@ function workspacesPage() {
|
||||
if (!this.wsName.trim()||!this.renameTarget) return;
|
||||
await fetch(`/api/workspaces/${this.renameTarget.id}`, {
|
||||
method:'PUT',
|
||||
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
headers:{'X-CSRF-Token': getCsrf(), 'Content-Type':'application/json'},
|
||||
body:JSON.stringify({name:this.wsName.trim()})
|
||||
});
|
||||
this.wsName = '';
|
||||
@@ -78,7 +83,7 @@ function workspacesPage() {
|
||||
|
||||
async deleteWs(ws) {
|
||||
if (!confirm(`Delete workspace "${ws.name}" and all its pages?`)) return;
|
||||
await fetch(`/api/workspaces/${ws.id}`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE'});
|
||||
await fetch(`/api/workspaces/${ws.id}`, {headers: {'X-CSRF-Token': getCsrf()}, method:'DELETE'});
|
||||
await this.load();
|
||||
},
|
||||
|
||||
|
||||
+3
-3
@@ -8,8 +8,8 @@
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
'use strict';
|
||||
|
||||
const CACHE_NAME = 'flowdeck-v6';
|
||||
const DATA_CACHE = 'flowdeck-data-v6';
|
||||
const CACHE_NAME = 'flowdeck-v8'; // v8 : Alpine CSP build (A20 fini)
|
||||
const DATA_CACHE = 'flowdeck-data-v7';
|
||||
|
||||
// App shell (assets versionnés comme référencés dans les templates).
|
||||
const PRECACHE_URLS = [
|
||||
@@ -23,7 +23,7 @@ const PRECACHE_URLS = [
|
||||
'/static/css/prism.css',
|
||||
// JS
|
||||
'/static/js/htmx.min.js',
|
||||
'/static/js/alpine.min.js',
|
||||
'/static/js/alpine.csp.min.js',
|
||||
'/static/js/sortable.min.js',
|
||||
'/static/js/app.js?v=2.4.8',
|
||||
'/static/js/offline.js?v=6.0.0',
|
||||
|
||||
+6
-1
@@ -2554,7 +2554,12 @@ def test_all_view_tabs_present(client):
|
||||
resp = client.get(f"/db/{coll_id}/view/table")
|
||||
assert resp.status_code == 200
|
||||
for vt in ["table", "board", "calendar", "gallery", "list", "timeline", "gantt", "chart", "form", "map", "feed"]:
|
||||
assert f"?view={vt}" in resp.text, f"Missing view tab: {vt}"
|
||||
# A39/E2E : le nom du paramètre doit être `view_type` (celui de la
|
||||
# route) — `?view=` était ignoré et l'onglet restait sur Table.
|
||||
assert f"?view_type={vt}" in resp.text, f"Missing view tab: {vt}"
|
||||
# et la query commute réellement la vue rendue
|
||||
resp = client.get(f"/db/{coll_id}?view_type=calendar")
|
||||
assert 'class="calendar"' in resp.text
|
||||
|
||||
|
||||
def test_view_unknown_falls_back_to_table(client):
|
||||
|
||||
@@ -226,7 +226,8 @@ def _assert_nonce(csp: str, html: str) -> str:
|
||||
assert nm, script_src
|
||||
nonce = nm.group(1)
|
||||
assert "'unsafe-inline'" not in script_src, script_src
|
||||
assert "'unsafe-eval'" in script_src # Alpine/htmx — reste d'A20
|
||||
# A20 TERMINÉ : Alpine en build CSP (alpine.csp.min.js) + htmx allowEval=false
|
||||
assert "'unsafe-eval'" not in script_src
|
||||
assert "script-src-attr 'unsafe-inline'" in csp
|
||||
tags = [
|
||||
mm.group(0)
|
||||
@@ -338,6 +339,101 @@ def test_http_client_shared_and_loop_scoped():
|
||||
assert second is not first
|
||||
|
||||
|
||||
def test_csrf_server_rendered_no_placeholder(client):
|
||||
"""A43-1 : `hx-headers` est rendu côté serveur avec le vrai jeton (plus
|
||||
de `__CSRF_PLACEHOLDER__` servi — la fenêtre de course JS disparaît),
|
||||
et la valeur vaut le cookie `csrf_token` de la session."""
|
||||
import json as _json
|
||||
|
||||
page = None
|
||||
for url in ("/", "/dashboard", "/board", "/notes", "/settings", "/workspaces"):
|
||||
cand = client.get(url)
|
||||
if cand.status_code == 200 and "htmx-config" in cand.text:
|
||||
page = cand
|
||||
break
|
||||
assert page is not None, "aucune page base.html atteignable"
|
||||
# 1ʳᵉ visite : cookie créé dans la response → on refait un aller-retour
|
||||
r = client.get(page.url if hasattr(page, "url") else "/dashboard")
|
||||
if "htmx-config" not in r.text:
|
||||
r = page
|
||||
assert "__CSRF_PLACEHOLDER__" not in r.text, "placeholder servi au navigateur"
|
||||
import re as _re
|
||||
|
||||
m = _re.search(r"hx-headers=\'([^\']*)\'", r.text)
|
||||
assert m, "attribut hx-headers absent"
|
||||
token = _json.loads(m.group(1).replace(""", '"'))["X-CSRF-Token"]
|
||||
cookie = client.cookies.get("csrf_token", "")
|
||||
assert cookie, "cookie csrf_token absent"
|
||||
assert token == cookie, (token[:8], cookie[:8])
|
||||
|
||||
|
||||
def test_no_duplicate_global_functions():
|
||||
"""A38 : aucune fonction `function NAME` GLOBALE (profondeur 0) définie
|
||||
2+ fois entre les templates et static/js — les paires à risque d'ombre
|
||||
silencieuse (onDoc, escHtml, openCardDetail…) vivent dans des IIFEs ou
|
||||
sont dédupliquées (openCardDetail → app.js)."""
|
||||
import pathlib as _pathlib
|
||||
import re as _re
|
||||
|
||||
files = list(_pathlib.Path("app/templates").glob("*.html")) + list(
|
||||
_pathlib.Path("static/js").glob("*.js")
|
||||
)
|
||||
found: dict[str, list[str]] = {}
|
||||
for p in files:
|
||||
s = p.read_text(encoding="utf-8", errors="ignore")
|
||||
depth = 0
|
||||
line = 1
|
||||
i = 0
|
||||
state = None
|
||||
n = len(s)
|
||||
# ponytail: scanner naïve (strings/comments/backticks) — un faux
|
||||
# positif se voit immédiatement à la lecture du nom signalé
|
||||
while i < n:
|
||||
c = s[i]
|
||||
if c == "\n":
|
||||
line += 1
|
||||
if state is None:
|
||||
if c in ('"', "'"):
|
||||
state = c
|
||||
i += 1
|
||||
continue
|
||||
if c == "`":
|
||||
state = c
|
||||
i += 1
|
||||
continue
|
||||
if c == "/" and i + 1 < n and s[i + 1] == "/":
|
||||
state = "//"
|
||||
i += 2
|
||||
continue
|
||||
if c == "/" and i + 1 < n and s[i + 1] == "*":
|
||||
state = "/*"
|
||||
i += 2
|
||||
continue
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
else:
|
||||
if c == "\\":
|
||||
i += 2
|
||||
continue
|
||||
if (state in ('"', "'") and c == state) or (state == "`" and c == state):
|
||||
state = None
|
||||
elif state == "//" and c == "\n":
|
||||
state = None
|
||||
elif state == "/*" and c == "*" and i + 1 < n and s[i + 1] == "/":
|
||||
state = None
|
||||
i += 2
|
||||
continue
|
||||
i += 1
|
||||
if state is None and depth == 0 and s.startswith("function ", i):
|
||||
m = _re.match(r"function\s+([A-Za-z_]\w*)", s[i : i + 60])
|
||||
if m:
|
||||
found.setdefault(m.group(1), []).append(f"{p.name}:{line}")
|
||||
dups = {k: v for k, v in found.items() if len(v) >= 2}
|
||||
assert dups == {}, dups
|
||||
|
||||
|
||||
def test_no_duplicate_routes():
|
||||
"""A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre."""
|
||||
from app.main import app
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Les `right_actions` du topbar doivent être servis PARSED (A10).
|
||||
|
||||
Regresssion historique : `'literal' ~ fd_icon(...)` — fd_icon = macro →
|
||||
Markup, et `Markup.__radd__/__add__` échappe les segments littéraux →
|
||||
boutons livrés en entities (`"`) sur TOUTES les pages. Fix : block-set
|
||||
`{% set x %}…{{ fd_icon() }}…{% endset %}` (5 templates).
|
||||
"""
|
||||
from tests.conftest import login_test_client
|
||||
|
||||
|
||||
def test_right_actions_parsed(client):
|
||||
login_test_client(client)
|
||||
r = client.get("/workspaces")
|
||||
assert r.status_code == 200
|
||||
body = r.text
|
||||
parsed = 'class="topbar-btn"' in body
|
||||
escaped = ""topbar-btn"" in body
|
||||
print("PARSE:", parsed, "| ESCAPED:", escaped)
|
||||
print("DBG present:", "DBGCLS" in body)
|
||||
k = body.find("DBGCLS=")
|
||||
print("CTX:", body[max(0, k - 80) : k + 320].replace("\n", " ") if k >= 0 else "pas de DBG")
|
||||
assert parsed and not escaped, "right_actions servi échappé"
|
||||
Reference in New Issue
Block a user