Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45917c194d | ||
|
|
ee1d46e965 | ||
|
|
587ec8d61b | ||
|
|
7a38ddd0f6 | ||
|
|
113374e499 | ||
|
|
0cb476e336 | ||
|
|
2339fa2586 | ||
|
|
8b48dbdd4b | ||
|
|
360c705fd4 | ||
|
|
0698645dbd | ||
|
|
b2e38aece7 | ||
|
|
df9a269d76 | ||
|
|
da7326ffde | ||
|
|
3a1276596c | ||
|
|
07904f05e5 | ||
|
|
224bda74d5 | ||
|
|
c718fe06de | ||
|
|
f706424f90 |
+427
@@ -1,5 +1,432 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.23.0 (2026-10-01) — Audit : A27 phase 2b (+3 801 L)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A27 (phase 2b)** — 4 blocs interpolés extraits avec la recette config
|
||||
JSON (identique à la 2a) :
|
||||
· `local_workspace.html` → `local_workspace.js` (2 031 L, `lw-config` :
|
||||
current_folder_id, workspace_id)
|
||||
· `settings.html` → `settings.js` (1 093 L, `st-config` : avatar_url,
|
||||
avatar_color, user full_name/login/email, is_admin, auth_method —
|
||||
**2 routes rendent ce template**, les expressions `or ""` sont préservées
|
||||
pour les valeurs Undefined, `is_admin` reste un booléen)
|
||||
· `_page_editor_realtime.html` → `page_editor_realtime.js` (531 L,
|
||||
`rt-config` : SELF id/login/full_name/color)
|
||||
· `board.html` → `board.js` (146 L, `bd-config` : owner/repo/initial_view)
|
||||
- BONUS sécurité : les valeurs passent par `|tojson` (échappement JSON
|
||||
explicite) au lieu d'être interpolées dans des strings JS
|
||||
- Tags : config JSON inline (nonce conservé) + `<script src>` avec
|
||||
`?v={{ asset_version }}` ; loaders `XX = JSON.parse(#xx-config)` en tête
|
||||
des fichiers extraits (try/catch → `{}`)
|
||||
|
||||
### Notes
|
||||
|
||||
- **Cumul A27 : 10 560 L extraites** (13 904 → **3 344 restantes**, -76 %)
|
||||
- `node --check` vert sur les 4 fichiers ; eslint : **0 erreur, 279 warnings**
|
||||
(12 fichiers, baseline mise à jour)
|
||||
- Reste structurel : `base` 1 338 (`{% block %}`/`{% for %}` — reste inline
|
||||
par nature) + `database_table` 1 323 (`if/else` sur collection_data)
|
||||
- Suite complète : **1089/1089**
|
||||
|
||||
## v7.22.0 (2026-10-01) — Audit : A27 phase 2a (l'éditeur, 2 516 L)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A27 (phase 2a)** — `_page_editor_scripts.html` : les **2 516 lignes** du
|
||||
gros bloc interpolé partent vers `static/js/page_editor_scripts.js`
|
||||
- Recette « config JSON » : les **8 interpolations Jinja** lisent maintenant
|
||||
`PD = JSON.parse(document.getElementById('page-data'))` — le bloc JSON
|
||||
`#page-data` **existait déjà** juste avant le script, même ordre
|
||||
d'exécution, garde `__fdEditorScriptsLoaded` préservée
|
||||
- Côté route (`view_page_root`) : `page_data` enrichi de `updated_at`,
|
||||
`created_at`, `user_id`, `is_shared` (le dérivé est **hoisté** — une seule
|
||||
expression sert le ctx ET le JSON) et `clip_icon` (macro `fd_icon` rendue
|
||||
côté serveur). `workspace_key` reste vide comme avant (jamais défini dans
|
||||
ce contexte → parité stricte)
|
||||
- `node --check` vert ; template : 2 tags restants (JSON config + src)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **8 tests adaptés** à l'extraction (ils lisaient le template source) :
|
||||
`test_ai_writing` ×2 (+ helper `_read_js`), `test_pwa_offline`,
|
||||
`test_v511` front_end_wired, `test_v55` ×3 (lightbox, previews, endpoints)
|
||||
→ lisent `static/js/page_editor_scripts.js` ; `test_page_editor_renders_
|
||||
page_is_shared` → **parsing du JSON `#page-data`** (`is_shared is True`,
|
||||
la valeur sert toujours à la page)
|
||||
|
||||
### Notes
|
||||
|
||||
- Cumul A27 : **6 759 L extraites** (13 904 → 7 145 inline)
|
||||
- Reste : local_workspace 2 031, base 1 523 (structurel {% for %}/{% block %}),
|
||||
database_table 1 323 (if/else), settings 1 093, realtime 531, board 146
|
||||
≈ 6 653 L + 120 warnings eslint à nettoyer
|
||||
- Suite complète : **1089/1089**
|
||||
|
||||
## v7.21.0 (2026-10-01) — Audit : A27 phase 1 (4 243 L de JS extraites)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A27 (phase 1)** — les 7 templates dont le JS **n'est pas interpolé Jinja**
|
||||
sont extraits vers `static/js/*.js` : agent_panel_1/_2 (dont un bloc de
|
||||
**1 788 lignes livré sur chaque page**), library (1 039), gitea_workspace
|
||||
(626), _icon_picker_1/_2, _ctx_menu, import, workspaces →
|
||||
**4 243 lignes, -30 % de JS inline** (13 904 → 9 661)
|
||||
- Extraction **un fichier par bloc** : ordre et timing d'exécution identiques
|
||||
(pas de defer/async, attributs d'origine conservés dont `data-cfasync`),
|
||||
cache-busting `?v={{ asset_version }}` (source unique A40), CSP : les
|
||||
scripts externes relèvent de `'self'` (pas de nonce requis)
|
||||
|
||||
### Lint
|
||||
|
||||
- ESLint **installé globalement** (`npm i -g eslint`) — `eslint.config.mjs`
|
||||
existait déjà en flat config « sans dépendances » mais **aucun binaire**
|
||||
n'était installé (d'où « 0 linté »)
|
||||
- `eslint static/js` : **0 erreur, 120 warnings** sur 8 fichiers
|
||||
(no-unused-vars 69, no-empty 36, no-undef 15) → baseline à nettoyer
|
||||
- `node --check` vert sur les 9 fichiers extraits ; `eslint.config.mjs` couvre
|
||||
déjà `static/js/**/*.js` donc les extraits sont lintés d'office
|
||||
|
||||
### Notes
|
||||
|
||||
- Reste A27 : les blocs interpolés Jinja (page_editor 2 517, local_workspace
|
||||
2 031, base 1 523, database_table 1 323, settings 1 093… ≈ 9 661 L) →
|
||||
extraction en 2 temps (config JSON injectée + script statique)
|
||||
- Suite complète : **1089/1089**
|
||||
|
||||
## v7.20.0 (2026-10-01) — Audit : A32 TERMINÉ (routes Gitea + bug fd_icon)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Bug prod trouvé par les smokes** : `card_detail.html` appelait la macro
|
||||
`fd_icon` **sans l'importer** → `UndefinedError` → **500 systématique** sur
|
||||
`GET /api/issues/{o}/{r}/{id}?format=html` (seul rendu du template dans le
|
||||
code) → `{% from '_icons.html' import fd_icon %}` ajouté
|
||||
|
||||
### Tests
|
||||
|
||||
- Les **6 dernières routes d'A32** (Gitea, stub de transport, zéro réseau) :
|
||||
· stubs manuels sur `gitea_client.gitea` avec **état mutable partagé**
|
||||
(le handler PATCH re-fetch l'issue via `get_issue` — un canevas figé
|
||||
aurait masqué la mise à jour)
|
||||
· `POST /issues` : carte **insérée sur le board** ; `PATCH` : colonne
|
||||
recalculée **sans perdre la carte**
|
||||
· `GET /issues` JSON + **HTML** (`?format=html` — le segment `/html` ne fixe
|
||||
pas le paramètre `format`, il est lu dans la query) ; stub qui lève → 404
|
||||
· `POST /checklists` + `POST /checklist-items` : lignes **vérifiées en
|
||||
base**, 404 sans board
|
||||
- `test_smoke_uncovered.py` : **52 tests** ; suite complète **1089/1089**
|
||||
- **A32 complet** : plus aucun router « 0 test » (webhooks, notes,
|
||||
sidebar_config, github_routes, library, api, dashboard, api_v2)
|
||||
|
||||
## v7.19.0 (2026-10-01) — Audit : A32 : dashboard bloqué (44/44)
|
||||
|
||||
### Tests
|
||||
|
||||
- +5 routes `dashboard.py` — `test_smoke_uncovered.py` : 49 tests :
|
||||
· **Members** (POST/PUT/DELETE) : invitation de soi-même dans un workspace
|
||||
dédié, rôle relu en base, membre supprimé (`COUNT=0`) ; **quirk documenté**
|
||||
: les retours `(..., 400)` de ces routes sont sérialisés FastAPI en
|
||||
tableau + 200 (`[{"error": "Invalid role"}, 400]`)
|
||||
· `upload-folder` : **validations seules** (structure absente → 400,
|
||||
JSON cassé → 400) — zéro fichier écrit sur disque, workspace dédié nettoyé
|
||||
· `convert-to-database` : collection + propriété `title` + vue `table` +
|
||||
page en `content_format='collection'` **vérifiés en base**, 404 page
|
||||
inconnue, cleanup **dans l'ordre FK** (page avant collection)
|
||||
- **Recoupement final** : scan des 44 routes strictement à 0 ref de
|
||||
`dashboard.py` → **toutes exercées** (19 faux positifs résiduels =
|
||||
paths en f-string dans les tests, rapprochés manuellement)
|
||||
- Suite complète : **1086/1086**
|
||||
|
||||
## v7.18.0 (2026-10-01) — Audit : A32 phase 2g (dashboard +13)
|
||||
|
||||
### Tests
|
||||
|
||||
- +13 routes `dashboard.py` (cumul **43→56 sur 63**) —
|
||||
`test_smoke_uncovered.py` : 46 tests :
|
||||
· `/gitea-workspace` : page HTML (200 ou redirection propre)
|
||||
· `workspace/projects` GET+POST : shape `{builtin, gitea, github}` avec
|
||||
`github == []`, projet créé **retrouvé dans builtin**, quirk `error` sans
|
||||
nom, nettoyage
|
||||
· **Cycle items local-workspace** (5 routes) : création → renommage **relu en
|
||||
base** → move → soft-delete (`deleted_at` **relu**) → restore
|
||||
(`deleted_at IS NULL` **relu**), nettoyage
|
||||
· **Cycle tags d'item** (5 routes) : POST (nom lowercasé), tags de l'item,
|
||||
liste workspace, search (shape), suppression vérifiée. Utilisateur +
|
||||
workspace **créés dans le test** : `/api/local-workspace/tags` a besoin
|
||||
d'un workspace actif (fallback « premier workspace du user ») — on ne le
|
||||
fait pas dépendre de l'utilisateur fixture partagé, tout est nettoyé
|
||||
- Suite complète : **1083/1083**
|
||||
|
||||
## v7.17.0 (2026-10-01) — Audit : A32 phase 2f (dashboard +7, garde-fous A16)
|
||||
|
||||
### Tests
|
||||
|
||||
- +7 routes `dashboard.py` (cumul **36→43 sur 63**), centrées sur les
|
||||
garde-fous A16 — `test_smoke_uncovered.py` : 42 tests :
|
||||
· `GET /api/files/{ws}/{path}` : traversal encodé `%2e%2e%2f` →
|
||||
**403 « Path traversal denied »** ; inexistant → 404 ; vrai fichier écrit
|
||||
dans le data_dir de test → **200 + octets exacts** (nettoyé)
|
||||
· `GET /api/pages/{id}/download` : page markdown → 404 « downloadable »
|
||||
(pas de 500) ; page « file » avec chemin `../` qui sort de la racine →
|
||||
**jamais 200** (404), et `file-content` → 404/415
|
||||
· `GET /api/local-workspace/page-content/{id}` : contenu + format relus,
|
||||
404 sur id inconnu
|
||||
· `GET /api/avatar/{id}` : **302 + Location** avec `follow_redirects=False`
|
||||
(AUCUNE requête réelle vers l'URL externe — règle « 0 réseau »), 404 sans
|
||||
avatar
|
||||
· `GET/POST /api/collections/{id}/table-data|pages` : 404 inconnu, shape,
|
||||
ligne créée **retrouvée dans table-data**, nettoyage finally
|
||||
- Suite complète : **1079/1079**
|
||||
|
||||
## v7.16.0 (2026-10-01) — Audit : A32 phase 2e (dashboard +9, comptes)
|
||||
|
||||
### Tests
|
||||
|
||||
- +9 routes `dashboard.py` (cumul **27→36 sur 63**) :
|
||||
· `/accounts` + `/accounts/settings` : 200 HTML, **`password_hash` absent**
|
||||
du rendu (whitelist A2 vérifiée côté page)
|
||||
· `PUT /api/user/profile` : persistance **relue en base**, valeur d'origine
|
||||
restaurée en `finally`
|
||||
· `PUT /api/user/password` : **403 « current password is incorrect »** (A3 —
|
||||
la session seule ne change pas le mdp) + quirk assumé et documenté :
|
||||
longueur validée AVANT auth → 200 + message d'erreur
|
||||
· `POST /api/user/token` : format `fd_` + 64 hex, ligne `user_tokens`
|
||||
nettoyée en `finally`
|
||||
· `DELETE /api/user/forge/{provider}` : `{"status": "ok"}`
|
||||
· `PUT /api/settings/account` : full_name/email persistés + **400 sur mdp
|
||||
court** (validateur), restauration en `finally`
|
||||
· `POST /api/workspaces/{id}/select` : `Set-Cookie flowdeck_workspace`
|
||||
vérifié ; `GET /api/local-workspace/breadcrumb` : shape liste
|
||||
- `test_smoke_uncovered.py` : 38 tests ; suite complète **1075/1075**
|
||||
|
||||
## v7.15.0 (2026-10-01) — Audit : A32 phase 2d (dashboard +10 routes)
|
||||
|
||||
### Tests
|
||||
|
||||
- Scan strict `dashboard.py` : **44 routes à 0 référence** (sur 63) — 10
|
||||
couvertes cette passe dans `test_smoke_uncovered.py` (32 tests au fichier) :
|
||||
· **Tags CRUD** : création (`SmokeTag` → `smoketag` lowercasé), présence
|
||||
dans la liste, changement de couleur relu, suppression puis absence
|
||||
· **Vie d'une page** : GET `content` (contenu seedé relu) → PUT `rename`
|
||||
(ok + **400 sur titre vide** + titre relu en base) → POST `trash`
|
||||
(`parent_section='Trash'` + `deleted_at` **relus en base**)
|
||||
· `sidebar/workspace-tree` : 200 HTML, fragment « No pages yet » sans cookie
|
||||
· `settings/avatar-color` : couleur relue **sur l'utilisateur de la
|
||||
session** (pas `LIMIT 1`), valeurs d'origine restaurées en `finally`
|
||||
· `workspace/{id}/members` : shape `{"members": [...]}`
|
||||
- Helper `_seed_page` : surcharge des colonnes par défaut (`content=`, …)
|
||||
- Suite complète : **1069/1069**
|
||||
|
||||
## v7.14.0 (2026-10-01) — Audit : A32 phase 2c (api_v2 +5 routes)
|
||||
|
||||
### Tests
|
||||
|
||||
- Scan strict des 115 routes `api_v2.py` contre tous les tests (chaîne de
|
||||
chemin littérale) → **5 routes à 0 référence**, toutes couvertes :
|
||||
· `POST /properties/evaluate-formula` : 200 + shape, 400 sans `expression`
|
||||
(le moteur renvoie `1 + 2` tel quel aujourd'hui — le smoke valide le câble
|
||||
route/auth/parse, pas le moteur)
|
||||
· `POST /properties/compute-rollup` : 400 `collection_id required`,
|
||||
401 sans bearer
|
||||
· `GET /admin/audit-logs` : portail admin vérifié — attendu **calculé depuis
|
||||
`/users/me`** (le tout premier utilisateur d'un worker est admin, état non
|
||||
contrôlable depuis le test), + token scope `admin` → 200 + `logs` liste
|
||||
· `GET /webhooks/events` : catalogue non vide + wildcards `*`/`page.*`
|
||||
· `POST /webhooks/verify-signature` : **valid=True** avec
|
||||
`sign_payload(secret, payload)` (même helper que le serveur), False avec
|
||||
une signature bidon
|
||||
- `test_smoke_uncovered.py` : 27 tests au total
|
||||
- Suite complète : **1064/1064**
|
||||
|
||||
## v7.13.0 (2026-10-01) — Audit : A32 phase 2b (api.py 16/22)
|
||||
|
||||
### Tests
|
||||
|
||||
- `api.py` passe de **3 à 16 routes couvertes** (22 `@router` au total) :
|
||||
· `board-config` GET/POST : défauts à 5 colonnes sans board, création puis
|
||||
relecture du roundtrip
|
||||
· `col-mapping` POST/DELETE : 404 sans board, upsert `label` vérifié,
|
||||
suppression vérifiée
|
||||
· `card` POST : 404 sans board, `{"status": "ok"}` avec
|
||||
· `collaborators` GET : **`gitea.get_collaborators` stubbé** (zéro réseau réel)
|
||||
· `frontend-error(s)` : capture, JSON invalide → `ignored`, **dédup** d'une
|
||||
erreur répétée (`count=2`), lecture qui purge (`cleared=true` puis 0)
|
||||
· checklist mutations : PATCH item (checked/content relus EN BASE),
|
||||
DELETE item, DELETE checklist (réapparition `COUNT=0`) — seed + cleanup
|
||||
- Reste `api.py` : 6 routes Gitea (issues ×4 + créations checklists) →
|
||||
stub de transport httpx. Reste global : `dashboard.py` 17/63,
|
||||
`api_v2.py` 50/115
|
||||
|
||||
### Tests
|
||||
|
||||
- Suite complète : **1059/1059** (236 s) ; `test_smoke_uncovered.py` : 22 tests
|
||||
|
||||
## v7.12.0 (2026-10-01) — Audit : A32 phase 2a (library 10/10)
|
||||
|
||||
### Tests
|
||||
|
||||
- `library.py` passe de **1/10 à 8 routes couvertes** : les 5 listes
|
||||
(recents/favorites/published/private/workspace) en un test de boucle,
|
||||
`/private` avec une page seedée et retrouvée, `/children/{id}` avec un
|
||||
parent/enfant seedés (titre retrouvé), `/repository` vide et clé
|
||||
(aucun appel réseau — la clé n'est qu'une string de workspace)
|
||||
- Test de non-régression 404 sur les 2 routes supprimées
|
||||
|
||||
### Removed
|
||||
|
||||
- **2 routes cassées supprimées** (découverte des smokes) :
|
||||
`/api/library/local-workspace-children/{id}` renvoyait un 500 systématique
|
||||
et `/api/library/local-workspace` un 500 dès qu'un workspace existait —
|
||||
les deux lisaient `local_workspace_items`, **une table qui n'est créée nulle
|
||||
part** dans le codebase (grep : 0 `CREATE TABLE`), avec **0 référence front**.
|
||||
`library._format_size` devenu mort : supprimé aussi (une version vit dans
|
||||
`dashboard.py`, inchangée)
|
||||
- `local_workspace_items` : plus aucune occurrence dans `app/`
|
||||
|
||||
### Tests
|
||||
|
||||
- Suite complète : **1053/1053** (229 s) ; `test_smoke_uncovered.py` : 16 tests
|
||||
|
||||
## v7.11.0 (2026-10-01) — Audit : A32 phase 1 (routers à 0 test)
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_smoke_uncovered.py` — **10 smoke tests**, un par route des 4
|
||||
routers qui n'avaient AUCUN test :
|
||||
· `webhooks.py` (3/3) : réception sans secret → `{"status":"ok"}` ;
|
||||
HMAC faux → 401 (secret piloté par monkeypatch) ; register sans secret →
|
||||
400 **avant** tout appel réseau ; status avec `gitea.list_webhooks` stubbé
|
||||
(zéro accès réseau réel)
|
||||
· `notes.py` (2/2) : GET HTML + roundtrip POST→GET (upsert persisté,
|
||||
échappement HTML vérifié `<b>`)
|
||||
· `sidebar_config.py` (2/2) : GET défauts, PUT persisté relu depuis
|
||||
`users.sidebar_config`, 400 sans `config`, remise en état en fin de test
|
||||
· `github_routes.py` (2/2) : status `{"linked": False}`, disconnect ok
|
||||
- Reste (phase 2) : quasi nuls — `library.py` 1/10, `api.py` 3/23,
|
||||
`dashboard.py` 17/63, `api_v2.py` 50/115
|
||||
- Suite complète : **1047/1047**
|
||||
|
||||
## v7.10.0 (2026-10-01) — Audit : A21 phase 2b (api_v2 bouclé)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Helper** `run_event_sync(coro)` (`app/services/automations.py`) : exécute
|
||||
une coroutine d'événement depuis un handler synchrone — `asyncio.run` sur une
|
||||
boucle dédiée dans le **worker threadpool** : bloqué = le worker, jamais la
|
||||
boucle d'event, et la réponse n'est envoyée qu'une fois l'événement terminé
|
||||
(déterministe, équivalent sémantique de l'`await` d'avant). Note
|
||||
`ponytail:` : clients httpx créés à chaque appel partout → aucun lien de
|
||||
boucle ; sinon `run_coroutine_threadsafe` + boucle du lifespan
|
||||
- **A21 (phase 2b)** — 15 routes `api_v2` dont les seuls awaits étaient
|
||||
`request.json` / `_fire_event` / `fire_published` / `fire_unpublished` →
|
||||
paramètre `Body(default={})` + `run_event_sync(...)` + conversion en `def`
|
||||
- **`api_v2` : 111/115 routes hors event loop** — il ne reste que 4 routes
|
||||
async, toutes avec de vrais awaits réseau : `import_csv_v2` (multipart),
|
||||
`project_tree_v2` (gitea), `test_webhook_v2`, `retry_webhook_deliveries`
|
||||
- Repo-wide : **403 routes synchrones (hors loop) / 260 async** (phase 2c)
|
||||
|
||||
### Tests
|
||||
|
||||
- Ciblé (public_api_v2 + v65 + webhooks_v2 + audit) : 90/90 — les webhooks
|
||||
prouvent la détermination de `run_event_sync` ; suite complète **1037/1037**
|
||||
en 228 s
|
||||
|
||||
## v7.9.0 (2026-10-01) — Audit : A21 phase 2a (api_v2 hors loop)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A21 (phase 2a)** — dans `api_v2`, les 36 routes dont le **seul** `await`
|
||||
était `body = await request.json()` passent à un paramètre FastAPI
|
||||
`body: dict = Body(default={})` (parsing async fait par FastAPI avant
|
||||
l'appel) puis sont converties en `def` → threadpool. Équivalences vérifiées
|
||||
avant-engagement : corps absent → `{}` (identique au `try/except` d'avant),
|
||||
JSON invalide → **422** (avant : traité silencieusement comme `{}`),
|
||||
zéro `body[...] = ` dans le fichier (le défaut partagé n'est jamais muté)
|
||||
- `api_v2` : **96/115 routes hors event loop** (60 en phase 1 + 36 ici) ;
|
||||
il ne reste que **19 routes async** dans ce router (`fire_event`,
|
||||
`request.form`, appels gitea/webhooks — phase 2b)
|
||||
- Bug de transformation évité en cours de route : première version du script
|
||||
supprimait 5 lignes au lieu de 4 (`slice` fermant d'un cran trop loin) —
|
||||
fichier restauré depuis git puis script corrigé, 0 ligne perdue (diff
|
||||
logique : +39/-183 = 36 signatures + import, 4 lignes de try/except × 35)
|
||||
|
||||
### Tests
|
||||
|
||||
- Suite complète **1037/1037** (242 s) ; ciblée sur `test_public_api_v2` +
|
||||
`test_v65` + audit : 62/62 verts avant la passe complète
|
||||
|
||||
## v7.8.0 (2026-10-01) — Audit : A21 phase 1 (SQLite hors event loop)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A21 (phase 1)** — **352 routes** `async def` sans aucun `await` converties
|
||||
en `def` : FastAPI les exécute alors dans son threadpool — tout leur travail
|
||||
SQLite (`get_conn()` + `conn.execute`) quitte l'event loop, **sans changer une
|
||||
ligne de logique** (la conversion est sémantiquement neutre : vérifié corps
|
||||
par corps — aucun `await`/`async with`/`async for`/`asyncio` dans les
|
||||
fonctions converties). Répartition : api_v2 60, dashboard 40, collections 25,
|
||||
board 23, workspace 19, wiki 17, permissions 14, api 14, + 35 autres fichiers
|
||||
- **Reste (phase 2)** — les 311 routes qui ont de vrais `await`
|
||||
(`request.json()`, `fire_event`, httpx) : enrouler les blocs DB dans
|
||||
`await anyio.to_thread.run_sync(...)` ; aucun wrapper partagé livré pour
|
||||
l'instant (rien ne l'appellerait)
|
||||
|
||||
### Perf
|
||||
|
||||
- Dernière suite : 233 s (écarts précédents mesurés : 235-359 s) — les
|
||||
handlers SQLite ne saturent plus la boucle pendant les tests
|
||||
|
||||
## v7.7.0 (2026-10-01) — Audit : A20 (CSP — nonce, partie 1)
|
||||
|
||||
### Security
|
||||
|
||||
- **A20** — `script-src` : `'unsafe-inline'` remplacé par `'nonce-<aléatoire par
|
||||
requête>'`. Le middleware CSP génère le nonce dans une `ContextVar` avant
|
||||
`call_next` (visible des templates via `{{ csp_nonce() }}`) ; **38 tags
|
||||
`<script>` inline** des templates, la constante de module `LOCAL_LOGIN_HTML`
|
||||
(helper `_with_nonce()` au rendu) et **3 scripts Python** dans `collections.py`
|
||||
le portent ; htmx reçoit le même nonce via `<meta name="htmx-config">`
|
||||
(`inlineScriptNonce` — les scripts des réponses boostées restent valides)
|
||||
- Les 74 handlers `onclick=` inline restent fonctionnels via
|
||||
`script-src-attr 'unsafe-inline'` (détaché de `script-src` : le nonce les
|
||||
aurait désactivés aussi)
|
||||
- `https://cdn.jsdelivr.net` / `https://unpkg.com` ajoutés à `script-src` et
|
||||
`style-src` : les vues chart/map de `collections` les utilisent et étaient
|
||||
**bloquées par la CSP depuis toujours** (commentaire `ponytail:` → upgrade :
|
||||
vendoriser ces libs puis retirer les hôtes)
|
||||
- Reste d'A20 : `unsafe-eval` (Alpine `x-data` en string → build
|
||||
`@alpinejs/csp`), externalisation du JS inline (A27), resserrer
|
||||
`img-src`/`connect-src`
|
||||
|
||||
### Tests
|
||||
|
||||
- `test_csp_nonce_per_request` : page `base.html` (meta htmx-config + nonce du
|
||||
header identique sur tous les scripts inline, nonce différent d'une requête à
|
||||
l'autre) et page hors template (`/auth/login?provider=local`)
|
||||
|
||||
## v7.6.0 (2026-10-01) — Audit : A31 (dette migrations)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A31** — transaction par migration : `_apply_one()` fait `BEGIN` → `fn(conn)`
|
||||
→ marque `schema_version` → `commit`, rollback complet à l'échec. Avant, le
|
||||
DDL sortait en autocommit (isolation_level legacy) : un échec au milieu
|
||||
laissait un schéma partiel commité SANS ligne de version, et la reprise
|
||||
rejouait un DDL déjà appliqué
|
||||
- **A31** — helper unique `columns(conn, table)` (valide l'identifiant,
|
||||
`ValueError` sinon) : **25 copies** de
|
||||
`{r[1] for r in conn.execute("PRAGMA table_info(...)")}` éliminées dans
|
||||
`migrations.py`. `table_exists`/`column_exists` préconisés par l'audit non
|
||||
livrés : aucune migration n'interroge `sqlite_master`, un contrôle unitaire
|
||||
se lit dans le set
|
||||
|
||||
### Tests
|
||||
|
||||
- `test_migration_transaction_rolls_back` (DDL partiel annulé + pas de marque
|
||||
de version, chemin nominal enregistré), `test_columns_helper_validates_table_name`
|
||||
|
||||
## v7.5.0 (2026-10-01) — Audit : A29, A42 (partiel)
|
||||
|
||||
### Changed
|
||||
|
||||
+6
-6
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.5.0 (audit — A29/A42 partiel) | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.23.0 (audit — A27 phase 2b : +3 801 L, cumul 10 560 L) | **Statut**: EN COURS 🔄
|
||||
> **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0`
|
||||
|
||||
## Avancement Global
|
||||
|
||||
+7
-7
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.5.0",
|
||||
version="7.23.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
@@ -265,14 +265,14 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
@app.get("/manifest.json")
|
||||
async def pwa_manifest():
|
||||
def pwa_manifest():
|
||||
"""Serve the static web manifest from disk (same URL as before v6.0.0)."""
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse("static/manifest.json", media_type="application/manifest+json")
|
||||
|
||||
|
||||
@app.get("/sw.js")
|
||||
async def service_worker():
|
||||
def service_worker():
|
||||
"""Serve the PWA service worker at top-level scope (/)."""
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse("static/sw.js", media_type="application/javascript")
|
||||
@@ -282,7 +282,7 @@ async def service_worker():
|
||||
|
||||
|
||||
@app.get("/api/csrf-token")
|
||||
async def csrf_token_endpoint(request: Request):
|
||||
def csrf_token_endpoint(request: Request):
|
||||
"""Return a fresh CSRF token. Used by the frontend to auto-recover from 403."""
|
||||
import secrets
|
||||
|
||||
@@ -297,7 +297,7 @@ async def csrf_token_endpoint(request: Request):
|
||||
|
||||
|
||||
@app.get("/api/pages")
|
||||
async def api_pages_alias(request: Request):
|
||||
def api_pages_alias(request: Request):
|
||||
"""Alias /api/pages → /board/api/pages for API path consistency."""
|
||||
from fastapi.responses import RedirectResponse
|
||||
qs = str(request.url.query)
|
||||
@@ -306,7 +306,7 @@ async def api_pages_alias(request: Request):
|
||||
|
||||
|
||||
@app.post("/api/pages")
|
||||
async def api_pages_post_alias(request: Request):
|
||||
def api_pages_post_alias(request: Request):
|
||||
"""Alias POST /api/pages → /board/api/pages for API path consistency."""
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url="/board/api/pages", status_code=307)
|
||||
@@ -341,7 +341,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
|
||||
|
||||
|
||||
@app.exception_handler(_StarHTTPException)
|
||||
async def http_exception_handler(request: Request, exc: _StarHTTPException):
|
||||
def http_exception_handler(request: Request, exc: _StarHTTPException):
|
||||
"""Unified handler: RFC7807 for /api/v2, JSON for other /api, redirect for HTML.
|
||||
|
||||
Registered on Starlette's HTTPException (the base class) so it catches both
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import secrets
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
@@ -9,6 +10,8 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from app.templating import CSP_NONCE
|
||||
|
||||
# ── Constants ────────────────────────────────────────────────
|
||||
|
||||
# Allowed extensions for file uploads
|
||||
@@ -63,10 +66,21 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
|
||||
CSP_HEADER = "Content-Security-Policy"
|
||||
# A20 : `unsafe-inline` sort de script-src (remplacé par un nonce par
|
||||
# requête — un XSS injecté dans une page ne tourne plus). Les 74 handlers
|
||||
# inline `onclick=` restent couverts par `script-src-attr`, explicitement
|
||||
# détaché de script-src (sinon le nonce les désactiverait aussi).
|
||||
# `unsafe-eval` reste : Alpine (x-data en string) et htmx (hx-vars) en ont
|
||||
# besoin → à retirer avec le build CSP d'Alpine (voir reste d'A20).
|
||||
CSP_VALUE = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
|
||||
# ponytail: chart.js et leaflet sont chargés depuis CDN par les vues
|
||||
# chart/map de collections — l'upgrade est de les vendoriser dans
|
||||
# /static/js puis de retirer ces deux hôtes.
|
||||
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}' "
|
||||
"https://cdn.jsdelivr.net https://unpkg.com; "
|
||||
"script-src-attr 'unsafe-inline'; "
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://unpkg.com; "
|
||||
"img-src 'self' data: blob: https:; "
|
||||
"font-src 'self' data: https://fonts.gstatic.com; "
|
||||
"connect-src 'self' https: wss: ws:; "
|
||||
@@ -78,11 +92,16 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
)
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
nonce = secrets.token_urlsafe(16)
|
||||
# Posé AVANT call_next : BaseHTTPMiddleware exécute le aval dans une
|
||||
# tâche créée maintenant → le contexte (donc le nonce) y est copié,
|
||||
# exactement ce que les templates liront via `csp_nonce()`.
|
||||
CSP_NONCE.set(nonce)
|
||||
response = await call_next(request)
|
||||
# Only set CSP on HTML responses
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if "text/html" in content_type:
|
||||
response.headers[self.CSP_HEADER] = self.CSP_VALUE
|
||||
response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(nonce=nonce)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
+61
-28
@@ -50,6 +50,19 @@ def _ensure_table(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
|
||||
def columns(conn: sqlite3.Connection, table: str) -> set[str]:
|
||||
"""Colonnes d'une table — A31 : l'unique helper qui remplace les 24 copies
|
||||
de `{r[1] for r in conn.execute("PRAGMA table_info(...)")}`.
|
||||
|
||||
``table_exists``/``column_exists`` (préconisés par l'audit) ne sont pas
|
||||
livrés : aucune migration n'interroge ``sqlite_master``, et un contrôle
|
||||
unitaire se lit déjà dans le set.
|
||||
"""
|
||||
if not table.replace("_", "").isalnum():
|
||||
raise ValueError(f"nom de table invalide: {table!r}")
|
||||
return {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
|
||||
|
||||
def current_version(conn: sqlite3.Connection) -> int:
|
||||
_ensure_table(conn)
|
||||
row = conn.execute(
|
||||
@@ -90,16 +103,36 @@ def apply_migrations(conn: sqlite3.Connection) -> int:
|
||||
for version, name, fn in MIGRATIONS:
|
||||
if version <= applied:
|
||||
continue
|
||||
_apply_one(conn, version, name, fn)
|
||||
applied = version
|
||||
logger.info("Applied migration %d: %s", version, name)
|
||||
|
||||
return applied
|
||||
|
||||
|
||||
def _apply_one(conn: sqlite3.Connection, version: int, name: str, fn: Callable) -> None:
|
||||
"""A31 : une migration = une transaction (DDL tout-ou-rien).
|
||||
|
||||
Avant : le DDL sortait en autocommit (isolation_level legacy) — un échec au
|
||||
milieu laissait un schéma partiel commité ET pas de ligne schema_version :
|
||||
la reprise rejouait un DDL déjà appliqué. Maintenant : BEGIN explicite,
|
||||
rollback complet à l'échec, donc la prochaine exécution retente proprement.
|
||||
"""
|
||||
if conn.in_transaction:
|
||||
# transaction résiduelle du caller (init_db commit juste avant) — on
|
||||
# part d'un état propre plutôt que d'englober son travail.
|
||||
conn.commit()
|
||||
conn.execute("BEGIN")
|
||||
try:
|
||||
fn(conn)
|
||||
conn.execute(
|
||||
"INSERT INTO schema_version (version, name) VALUES (?, ?)",
|
||||
(version, name),
|
||||
)
|
||||
conn.commit()
|
||||
applied = version
|
||||
logger.info("Applied migration %d: %s", version, name)
|
||||
|
||||
return applied
|
||||
except BaseException:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -236,7 +269,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
|
||||
``projects`` — normalized project list across forges (builtin/gitea/
|
||||
github) + last sync timestamp for the periodic cron.
|
||||
"""
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
|
||||
_pcols = columns(conn, "api_tokens")
|
||||
if "id" not in _pcols:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -256,7 +289,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id, revoked)"
|
||||
)
|
||||
|
||||
_scols = {r[1] for r in conn.execute("PRAGMA table_info(user_sessions)").fetchall()}
|
||||
_scols = columns(conn, "user_sessions")
|
||||
if "id" not in _scols:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -275,7 +308,7 @@ def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id, revoked)"
|
||||
)
|
||||
|
||||
_projcols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()}
|
||||
_projcols = columns(conn, "projects")
|
||||
if "id" not in _projcols:
|
||||
conn.execute(
|
||||
"""
|
||||
@@ -328,7 +361,7 @@ def _migration_v54_page_versions_cover(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_page_versions_page ON page_versions(page_id, created_at)"
|
||||
)
|
||||
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
_pcols = columns(conn, "pages")
|
||||
if "cover_url" not in _pcols:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN cover_url TEXT DEFAULT ''")
|
||||
if "page_icon" not in _pcols:
|
||||
@@ -358,11 +391,11 @@ def _migration_custom_emojis(conn: sqlite3.Connection) -> None:
|
||||
def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
|
||||
"""v5.3.0: database templates get an icon, properties a validation config,
|
||||
and the built-in database templates are seeded (idempotently)."""
|
||||
_cols = {r[1] for r in conn.execute("PRAGMA table_info(database_templates)").fetchall()}
|
||||
_cols = columns(conn, "database_templates")
|
||||
if "icon" not in _cols:
|
||||
conn.execute("ALTER TABLE database_templates ADD COLUMN icon TEXT NOT NULL DEFAULT '📋'")
|
||||
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
|
||||
_pcols = columns(conn, "collection_properties")
|
||||
if "validation_json" not in _pcols:
|
||||
conn.execute("ALTER TABLE collection_properties ADD COLUMN validation_json TEXT NOT NULL DEFAULT '{}'")
|
||||
|
||||
@@ -432,19 +465,19 @@ def _migration_v57_db_advanced(conn: sqlite3.Connection) -> None:
|
||||
``collection_pages.cover_url`` — per-row cover image (gallery/board
|
||||
cards), independent from the block-page ``pages.cover_url``.
|
||||
"""
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
|
||||
_pcols = columns(conn, "collection_properties")
|
||||
if "group_name" not in _pcols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collection_properties ADD COLUMN group_name TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
|
||||
_vcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_views)").fetchall()}
|
||||
_vcols = columns(conn, "collection_views")
|
||||
if "created_by" not in _vcols:
|
||||
conn.execute("ALTER TABLE collection_views ADD COLUMN created_by INTEGER")
|
||||
if "updated_at" not in _vcols:
|
||||
conn.execute("ALTER TABLE collection_views ADD COLUMN updated_at TIMESTAMP")
|
||||
|
||||
_cpcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
|
||||
_cpcols = columns(conn, "collection_pages")
|
||||
if "cover_url" not in _cpcols:
|
||||
conn.execute("ALTER TABLE collection_pages ADD COLUMN cover_url TEXT DEFAULT ''")
|
||||
|
||||
@@ -471,7 +504,7 @@ def _migration_v58_calendar_reminders(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_remlog_page ON reminder_log(page_id)"
|
||||
)
|
||||
|
||||
_ucols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
|
||||
_ucols = columns(conn, "users")
|
||||
if "timezone" not in _ucols:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT ''")
|
||||
|
||||
@@ -522,7 +555,7 @@ def _migration_v511_wiki_v512_templates(conn: sqlite3.Connection) -> None:
|
||||
``page_global_templates`` — user-created global page templates
|
||||
(blocks_json = same format as the block editor saves).
|
||||
"""
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
_pcols = columns(conn, "pages")
|
||||
if "is_locked" not in _pcols:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0")
|
||||
if "locked_by" not in _pcols:
|
||||
@@ -777,12 +810,12 @@ def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
|
||||
for table in ("pages", "collection_pages"):
|
||||
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
cols = columns(conn, table)
|
||||
if "permission_type" not in cols:
|
||||
conn.execute(
|
||||
f"ALTER TABLE {table} ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
|
||||
)
|
||||
_ccols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
|
||||
_ccols = columns(conn, "collections")
|
||||
if "permission_type" not in _ccols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collections ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
|
||||
@@ -791,7 +824,7 @@ def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
|
||||
|
||||
def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
|
||||
"""Add ``sync_version`` to ``table`` if it is not already present."""
|
||||
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
cols = columns(conn, table)
|
||||
if "sync_version" not in cols:
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1")
|
||||
|
||||
@@ -853,7 +886,7 @@ def _migration_v630_api_v2(conn: sqlite3.Connection) -> None:
|
||||
``idempotency_keys`` — Idempotency-Key support for POST creations.
|
||||
"""
|
||||
# api_tokens extra columns
|
||||
_cols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
|
||||
_cols = columns(conn, "api_tokens")
|
||||
if "scopes" not in _cols:
|
||||
conn.execute("ALTER TABLE api_tokens ADD COLUMN scopes TEXT NOT NULL DEFAULT 'read,write'")
|
||||
if "expires_at" not in _cols:
|
||||
@@ -913,7 +946,7 @@ def _migration_v640_webhooks_prod(conn: sqlite3.Connection) -> None:
|
||||
New statuses: ``retrying`` (a later attempt is scheduled) and
|
||||
``superseded`` (a retry row replaced this attempt).
|
||||
"""
|
||||
_cols = {r[1] for r in conn.execute("PRAGMA table_info(webhook_deliveries)").fetchall()}
|
||||
_cols = columns(conn, "webhook_deliveries")
|
||||
if "event" not in _cols:
|
||||
conn.execute("ALTER TABLE webhook_deliveries ADD COLUMN event TEXT NOT NULL DEFAULT ''")
|
||||
if "next_retry_at" not in _cols:
|
||||
@@ -973,7 +1006,7 @@ def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
|
||||
``ON DELETE CASCADE``: deleting a database row deletes its content
|
||||
page (and ``page_synced_blocks`` cascades from ``pages``).
|
||||
"""
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
cols = columns(conn, "pages")
|
||||
if "collection_row_id" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE pages ADD COLUMN collection_row_id INTEGER "
|
||||
@@ -1053,7 +1086,7 @@ def _migration_sites_forms(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_form_responses_col ON form_responses(collection_id, created_at)"
|
||||
)
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
|
||||
cols = columns(conn, "collections")
|
||||
if "form_config_json" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collections ADD COLUMN form_config_json TEXT NOT NULL DEFAULT '{}'"
|
||||
@@ -1100,7 +1133,7 @@ def _migration_semantic_search(conn: sqlite3.Connection) -> None:
|
||||
)
|
||||
"""
|
||||
)
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
cols = columns(conn, "pages")
|
||||
if "search_excluded" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE pages ADD COLUMN search_excluded INTEGER NOT NULL DEFAULT 0"
|
||||
@@ -1169,12 +1202,12 @@ def _migration_automations_v2_workers(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_worker_runs_worker "
|
||||
"ON worker_runs(worker_id, created_at)"
|
||||
)
|
||||
auto_cols = {r[1] for r in conn.execute("PRAGMA table_info(automations)").fetchall()}
|
||||
auto_cols = columns(conn, "automations")
|
||||
if "trigger_mode" not in auto_cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE automations ADD COLUMN trigger_mode TEXT NOT NULL DEFAULT 'any'"
|
||||
)
|
||||
prop_cols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
|
||||
prop_cols = columns(conn, "collection_properties")
|
||||
if "button_automation_id" not in prop_cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collection_properties ADD COLUMN button_automation_id "
|
||||
@@ -1226,7 +1259,7 @@ def _migration_calendar_meetings(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_meeting_transcripts_page "
|
||||
"ON meeting_transcripts(page_id)"
|
||||
)
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
|
||||
cols = columns(conn, "collection_pages")
|
||||
if "external_event_id" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collection_pages ADD COLUMN external_event_id TEXT DEFAULT ''"
|
||||
@@ -1322,7 +1355,7 @@ def _migration_enterprise_admin(conn: sqlite3.Connection) -> None:
|
||||
"CREATE INDEX IF NOT EXISTS idx_agent_approvals_status "
|
||||
"ON agent_approvals(status, created_at)"
|
||||
)
|
||||
user_cols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
|
||||
user_cols = columns(conn, "users")
|
||||
if "totp_secret_enc" not in user_cols:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN totp_secret_enc TEXT DEFAULT ''")
|
||||
if "totp_backup_hashes" not in user_cols:
|
||||
@@ -1433,7 +1466,7 @@ def _migration_wiki_teamspaces(conn: sqlite3.Connection) -> None:
|
||||
"""
|
||||
)
|
||||
for table in ("pages", "collections"):
|
||||
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
cols = columns(conn, table)
|
||||
if "teamspace_id" not in cols:
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN teamspace_id INTEGER")
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ async def admin_required(request: Request):
|
||||
|
||||
# ── Users ──
|
||||
@router.get("/users")
|
||||
async def list_users(_admin=Depends(admin_required)):
|
||||
def list_users(_admin=Depends(admin_required)):
|
||||
"""List all users with workspace/file/folder counts and storage usage."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
@@ -109,7 +109,7 @@ async def update_user(user_id: int, request: Request, _admin=Depends(admin_requi
|
||||
|
||||
|
||||
@router.delete("/users/{user_id:int}")
|
||||
async def delete_user(user_id: int, _admin=Depends(admin_required)):
|
||||
def delete_user(user_id: int, _admin=Depends(admin_required)):
|
||||
"""Delete a user and cascade their data."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
@@ -139,7 +139,7 @@ async def delete_user(user_id: int, _admin=Depends(admin_required)):
|
||||
|
||||
# ── Stats ──
|
||||
@router.get("/stats")
|
||||
async def user_stats(_admin=Depends(admin_required)):
|
||||
def user_stats(_admin=Depends(admin_required)):
|
||||
"""Aggregate stats: total users, workspaces, files, storage."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
@@ -159,7 +159,7 @@ async def user_stats(_admin=Depends(admin_required)):
|
||||
|
||||
# ── Audit ──
|
||||
@router.get("/audit")
|
||||
async def audit_log(limit: int = 100, _admin=Depends(admin_required)):
|
||||
def audit_log(limit: int = 100, _admin=Depends(admin_required)):
|
||||
"""Recent login history."""
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
|
||||
+10
-10
@@ -213,7 +213,7 @@ async def create_conversation(request: Request):
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}")
|
||||
async def get_conversation(request: Request, conversation_id: int):
|
||||
def get_conversation(request: Request, conversation_id: int):
|
||||
with get_conn() as conn:
|
||||
conv = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone()
|
||||
if not conv:
|
||||
@@ -226,7 +226,7 @@ async def get_conversation(request: Request, conversation_id: int):
|
||||
|
||||
|
||||
@router.delete("/conversations/{conversation_id}")
|
||||
async def delete_conversation(request: Request, conversation_id: int):
|
||||
def delete_conversation(request: Request, conversation_id: int):
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM agent_conversations WHERE id=?", (conversation_id,)).fetchone():
|
||||
raise HTTPException(status_code=404, detail="Conversation introuvable")
|
||||
@@ -450,7 +450,7 @@ async def agent_writing_properties(request: Request):
|
||||
|
||||
|
||||
@router.get("/conversations/{conversation_id}/actions")
|
||||
async def list_actions(request: Request, conversation_id: int):
|
||||
def list_actions(request: Request, conversation_id: int):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM agent_actions WHERE conversation_id=? ORDER BY created_at, id",
|
||||
@@ -460,7 +460,7 @@ async def list_actions(request: Request, conversation_id: int):
|
||||
|
||||
|
||||
@router.post("/actions/{action_id}/undo")
|
||||
async def undo(request: Request, action_id: int):
|
||||
def undo(request: Request, action_id: int):
|
||||
try:
|
||||
undo_action(action_id)
|
||||
except ValueError as exc:
|
||||
@@ -528,7 +528,7 @@ async def apply_skill(request: Request, skill_id: int):
|
||||
|
||||
|
||||
@router.get("/skills/gallery")
|
||||
async def skills_gallery(request: Request):
|
||||
def skills_gallery(request: Request):
|
||||
presets = skill_gallery.list_gallery()
|
||||
return {"gallery": presets, "total": len(presets),
|
||||
"install": "POST /api/agent/skills/gallery/{slug}/install"}
|
||||
@@ -577,7 +577,7 @@ async def import_skill(request: Request):
|
||||
|
||||
|
||||
@router.get("/skills/{skill_id}/export")
|
||||
async def export_skill(request: Request, skill_id: int):
|
||||
def export_skill(request: Request, skill_id: int):
|
||||
"""Document JSON portable — à rejouer sur /api/agent/skills/import."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
@@ -587,7 +587,7 @@ async def export_skill(request: Request, skill_id: int):
|
||||
|
||||
|
||||
@router.delete("/skills/{skill_id}")
|
||||
async def delete_skill(request: Request, skill_id: int):
|
||||
def delete_skill(request: Request, skill_id: int):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT name FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -794,7 +794,7 @@ async def trigger_agent(request: Request, agent_id: int):
|
||||
|
||||
|
||||
@router.get("/tools")
|
||||
async def list_tools(request: Request):
|
||||
def list_tools(request: Request):
|
||||
registry = ToolRegistry()
|
||||
tools = registry.schema()
|
||||
return {"tools": tools}
|
||||
@@ -1081,7 +1081,7 @@ async def test_provider_config(request: Request):
|
||||
|
||||
|
||||
@router.get("/{agent_id}")
|
||||
async def get_agent(request: Request, agent_id: int):
|
||||
def get_agent(request: Request, agent_id: int):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -1116,7 +1116,7 @@ async def update_agent(request: Request, agent_id: int):
|
||||
|
||||
|
||||
@router.delete("/{agent_id}")
|
||||
async def delete_agent(request: Request, agent_id: int):
|
||||
def delete_agent(request: Request, agent_id: int):
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not existing:
|
||||
|
||||
+14
-14
@@ -83,7 +83,7 @@ async def health(request: Request):
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def stats():
|
||||
def stats():
|
||||
"""Global stats for dashboard."""
|
||||
with get_conn() as conn:
|
||||
board_count = conn.execute("SELECT COUNT(*) as c FROM boards").fetchone()["c"]
|
||||
@@ -179,7 +179,7 @@ async def _get_status_labels(owner: str, repo: str, board_id: int) -> list[str]:
|
||||
|
||||
|
||||
@router.post("/col-mapping")
|
||||
async def set_col_mapping(
|
||||
def set_col_mapping(
|
||||
owner: str = Query(...),
|
||||
repo: str = Query(...),
|
||||
column: str = Query(...),
|
||||
@@ -209,7 +209,7 @@ async def set_col_mapping(
|
||||
|
||||
|
||||
@router.delete("/col-mapping")
|
||||
async def delete_col_mapping(
|
||||
def delete_col_mapping(
|
||||
owner: str = Query(...),
|
||||
repo: str = Query(...),
|
||||
column: str = Query(...),
|
||||
@@ -234,7 +234,7 @@ async def delete_col_mapping(
|
||||
|
||||
|
||||
@router.get("/board-config/{owner}/{repo}")
|
||||
async def get_board_config(owner: str, repo: str):
|
||||
def get_board_config(owner: str, repo: str):
|
||||
with get_conn() as conn:
|
||||
board = conn.execute(
|
||||
"SELECT * FROM boards WHERE project_owner=? AND project_name=?",
|
||||
@@ -255,7 +255,7 @@ async def get_board_config(owner: str, repo: str):
|
||||
|
||||
|
||||
@router.post("/board-config/{owner}/{repo}")
|
||||
async def update_board_config(
|
||||
def update_board_config(
|
||||
owner: str,
|
||||
repo: str,
|
||||
columns: str = Query(default=""),
|
||||
@@ -460,7 +460,7 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Q
|
||||
# ── v0.5.0: Checklists ──
|
||||
|
||||
@router.post("/checklists/{owner}/{repo}/{issue_id}")
|
||||
async def create_checklist(
|
||||
def create_checklist(
|
||||
owner: str,
|
||||
repo: str,
|
||||
issue_id: int,
|
||||
@@ -484,7 +484,7 @@ async def create_checklist(
|
||||
|
||||
|
||||
@router.post("/checklist-items/{owner}/{repo}/{issue_id}/{checklist_id}")
|
||||
async def add_checklist_item(
|
||||
def add_checklist_item(
|
||||
owner: str,
|
||||
repo: str,
|
||||
issue_id: int,
|
||||
@@ -502,7 +502,7 @@ async def add_checklist_item(
|
||||
|
||||
|
||||
@router.patch("/checklist-items/{item_id}")
|
||||
async def toggle_checklist_item(
|
||||
def toggle_checklist_item(
|
||||
item_id: int,
|
||||
checked: bool = Query(default=False),
|
||||
content: str = Query(default=""),
|
||||
@@ -524,7 +524,7 @@ async def toggle_checklist_item(
|
||||
|
||||
|
||||
@router.delete("/checklist-items/{item_id}")
|
||||
async def delete_checklist_item(item_id: int):
|
||||
def delete_checklist_item(item_id: int):
|
||||
"""Delete a checklist item."""
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM checklist_items WHERE id=?", (item_id,))
|
||||
@@ -533,7 +533,7 @@ async def delete_checklist_item(item_id: int):
|
||||
|
||||
|
||||
@router.delete("/checklists/{checklist_id}")
|
||||
async def delete_checklist(checklist_id: int):
|
||||
def delete_checklist(checklist_id: int):
|
||||
"""Delete a checklist and all its items."""
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM checklist_items WHERE checklist_id=?", (checklist_id,))
|
||||
@@ -545,7 +545,7 @@ async def delete_checklist(checklist_id: int):
|
||||
# ── v1.0.0: User management ──
|
||||
|
||||
@router.get("/users/me")
|
||||
async def get_my_profile(request: Request):
|
||||
def get_my_profile(request: Request):
|
||||
"""Get current user profile."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -565,7 +565,7 @@ async def get_my_profile(request: Request):
|
||||
|
||||
|
||||
@router.put("/users/me")
|
||||
async def update_my_profile(request: Request, full_name: str = Query(default=""),
|
||||
def update_my_profile(request: Request, full_name: str = Query(default=""),
|
||||
email: str = Query(default="")):
|
||||
"""Update current user's local profile."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
@@ -584,7 +584,7 @@ async def update_my_profile(request: Request, full_name: str = Query(default="")
|
||||
# ── v0.5.0: Card priority & due date ──
|
||||
|
||||
@router.post("/card/{owner}/{repo}/{issue_id}")
|
||||
async def update_card(
|
||||
def update_card(
|
||||
owner: str,
|
||||
repo: str,
|
||||
issue_id: int,
|
||||
@@ -673,7 +673,7 @@ async def capture_frontend_error(request: Request):
|
||||
|
||||
|
||||
@router.get("/frontend-errors")
|
||||
async def get_frontend_errors(request: Request, clear: bool = True):
|
||||
def get_frontend_errors(request: Request, clear: bool = True):
|
||||
"""Retourne les erreurs frontend récentes. Hermes appelle cette route."""
|
||||
errors = list(_frontend_errors)
|
||||
if clear:
|
||||
|
||||
+130
-317
File diff suppressed because it is too large
Load Diff
+13
-13
@@ -103,7 +103,7 @@ def _engine_for(user_id: int, workspace_id: int | None, provider: str | None) ->
|
||||
# ── Agents ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/agents")
|
||||
async def list_agents_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
def list_agents_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
limit, offset = parse_pagination(request)
|
||||
ws = _workspace_of(request)
|
||||
@@ -157,7 +157,7 @@ async def create_agent_v2(request: Request, authorization: str | None = Header(d
|
||||
|
||||
|
||||
@router.get("/agents/{agent_id}")
|
||||
async def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
@@ -195,7 +195,7 @@ async def update_agent_v2(agent_id: int, request: Request, authorization: str |
|
||||
|
||||
|
||||
@router.delete("/agents/{agent_id}")
|
||||
async def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM agents WHERE id=?", (agent_id,)).fetchone():
|
||||
@@ -209,7 +209,7 @@ async def delete_agent_v2(agent_id: int, request: Request, authorization: str |
|
||||
# ── Conversations (static paths declared before /agents/{agent_id}) ────────
|
||||
|
||||
@router.get("/agents/conversations")
|
||||
async def list_conversations_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
def list_conversations_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
@@ -264,7 +264,7 @@ async def create_conversation_v2(request: Request, authorization: str | None = H
|
||||
|
||||
|
||||
@router.get("/agents/conversations/{conversation_id}")
|
||||
async def get_conversation_v2(conversation_id: int, request: Request,
|
||||
def get_conversation_v2(conversation_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
@@ -279,7 +279,7 @@ async def get_conversation_v2(conversation_id: int, request: Request,
|
||||
|
||||
|
||||
@router.delete("/agents/conversations/{conversation_id}")
|
||||
async def delete_conversation_v2(conversation_id: int, request: Request,
|
||||
def delete_conversation_v2(conversation_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
@@ -292,7 +292,7 @@ async def delete_conversation_v2(conversation_id: int, request: Request,
|
||||
|
||||
|
||||
@router.get("/agents/conversations/{conversation_id}/actions")
|
||||
async def list_actions_v2(conversation_id: int, request: Request,
|
||||
def list_actions_v2(conversation_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
@@ -306,7 +306,7 @@ async def list_actions_v2(conversation_id: int, request: Request,
|
||||
|
||||
|
||||
@router.post("/agents/actions/{action_id}/undo")
|
||||
async def undo_action_v2(action_id: int, request: Request,
|
||||
def undo_action_v2(action_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
@@ -480,7 +480,7 @@ async def trigger_agent_v2(agent_id: int, request: Request,
|
||||
# ── Skill marketplace ──────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/skills")
|
||||
async def list_skills_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
def list_skills_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
limit, offset = parse_pagination(request)
|
||||
ws = _workspace_of(request)
|
||||
@@ -535,7 +535,7 @@ async def create_skill_v2(request: Request, authorization: str | None = Header(d
|
||||
# Gallery & import are static segments: declared before /skills/{skill_id} so
|
||||
# FastAPI never tries to coerce "gallery" into an int path parameter.
|
||||
@router.get("/skills/gallery")
|
||||
async def skills_gallery_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
def skills_gallery_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
presets = skill_gallery.list_gallery()
|
||||
return {"gallery": presets, "total": len(presets),
|
||||
@@ -596,7 +596,7 @@ async def import_skill_v2(request: Request, authorization: str | None = Header(d
|
||||
|
||||
|
||||
@router.get("/skills/{skill_id}")
|
||||
async def get_skill_v2(skill_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
def get_skill_v2(skill_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
@@ -606,7 +606,7 @@ async def get_skill_v2(skill_id: int, request: Request, authorization: str | Non
|
||||
|
||||
|
||||
@router.get("/skills/{skill_id}/export")
|
||||
async def export_skill_v2(skill_id: int, request: Request,
|
||||
def export_skill_v2(skill_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Portable JSON document — POST it to /api/v2/skills/import elsewhere."""
|
||||
_guard(request, authorization)
|
||||
@@ -618,7 +618,7 @@ async def export_skill_v2(skill_id: int, request: Request,
|
||||
|
||||
|
||||
@router.delete("/skills/{skill_id}")
|
||||
async def delete_skill_v2(skill_id: int, request: Request,
|
||||
def delete_skill_v2(skill_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
|
||||
@@ -99,7 +99,7 @@ def _query(source: str, actor: str, action: str, limit: int, offset: int):
|
||||
|
||||
|
||||
@router.get("/api/v2/audit/logs")
|
||||
async def audit_logs(request: Request):
|
||||
def audit_logs(request: Request):
|
||||
_admin_user(request)
|
||||
qp = request.query_params
|
||||
source = (qp.get("source") or "all").lower()
|
||||
|
||||
+19
-9
@@ -9,6 +9,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.config import settings
|
||||
from app.templating import CSP_NONCE
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["auth"], prefix="/auth")
|
||||
@@ -31,6 +32,15 @@ def get_redirect_uri(request: Request) -> str:
|
||||
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080")
|
||||
return f"{scheme}://{host}/auth/callback"
|
||||
|
||||
def _with_nonce(html: str) -> str:
|
||||
"""A20 : injecte le nonce CSP au moment du rendu.
|
||||
|
||||
`LOCAL_LOGIN_HTML` est une constante de module — le nonce, lui, est par
|
||||
requête, donc il ne peut être figé qu'ici.
|
||||
"""
|
||||
return html.replace("<script>", f'<script nonce="{CSP_NONCE.get()}">', 1)
|
||||
|
||||
|
||||
LOCAL_LOGIN_HTML = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -146,9 +156,9 @@ async function handleLogin(e){e.preventDefault();const email=document.getElement
|
||||
|
||||
|
||||
@router.get("/register")
|
||||
async def register_page(request: Request):
|
||||
def register_page(request: Request):
|
||||
"""Show the registration page (local login page with register tab active)."""
|
||||
return HTMLResponse(LOCAL_LOGIN_HTML.replace(
|
||||
return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML.replace(
|
||||
'class="tab active" onclick="switchTab(\'login\')"',
|
||||
'class="tab" onclick="switchTab(\'login\')"'
|
||||
).replace(
|
||||
@@ -163,16 +173,16 @@ async def register_page(request: Request):
|
||||
).replace(
|
||||
'id="submit-btn">Login<',
|
||||
'id="submit-btn">Register<'
|
||||
), status_code=200)
|
||||
)), status_code=200)
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login(request: Request, provider: str = Query("gitea")):
|
||||
def login(request: Request, provider: str = Query("gitea")):
|
||||
"""Redirect to OAuth2 authorize page or show local login page."""
|
||||
# Local login page (POST handled by /auth/local-login)
|
||||
from fastapi.responses import HTMLResponse
|
||||
if provider == "local":
|
||||
return HTMLResponse(LOCAL_LOGIN_HTML, status_code=200)
|
||||
return HTMLResponse(_with_nonce(LOCAL_LOGIN_HTML), status_code=200)
|
||||
|
||||
# OAuth flow — check if provider is configured
|
||||
from app.auth.providers import get_provider
|
||||
@@ -450,7 +460,7 @@ async def callback(
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout(request: Request):
|
||||
def logout(request: Request):
|
||||
"""Clear session and redirect to login page.
|
||||
|
||||
SAML sessions additionally hand over to the IdP's Single Logout when one
|
||||
@@ -519,7 +529,7 @@ def _session_user_or_401(request: Request) -> dict:
|
||||
|
||||
|
||||
@router.get("/2fa/status")
|
||||
async def twofa_status(request: Request):
|
||||
def twofa_status(request: Request):
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
return {"enabled": _2fa.is_enabled(user["id"]),
|
||||
@@ -527,7 +537,7 @@ async def twofa_status(request: Request):
|
||||
|
||||
|
||||
@router.post("/2fa/setup")
|
||||
async def twofa_setup(request: Request):
|
||||
def twofa_setup(request: Request):
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
return _2fa.setup_secret(user["id"])
|
||||
@@ -553,7 +563,7 @@ async def twofa_activate(request: Request):
|
||||
|
||||
|
||||
@router.post("/2fa/disable")
|
||||
async def twofa_disable(request: Request):
|
||||
def twofa_disable(request: Request):
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
_2fa.disable(user["id"])
|
||||
|
||||
@@ -71,7 +71,7 @@ def _validate_payload(body: dict) -> None:
|
||||
|
||||
|
||||
@router.get("/workspace/automations")
|
||||
async def list_automations(request: Request):
|
||||
def list_automations(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM automations ORDER BY created_at DESC").fetchall()
|
||||
items = [dict(r) for r in rows]
|
||||
@@ -109,7 +109,7 @@ async def create_automation(request: Request):
|
||||
|
||||
|
||||
@router.get("/workspace/automations/{auto_id}")
|
||||
async def get_automation(request: Request, auto_id: int):
|
||||
def get_automation(request: Request, auto_id: int):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -147,7 +147,7 @@ async def update_automation(request: Request, auto_id: int):
|
||||
|
||||
|
||||
@router.delete("/workspace/automations/{auto_id}")
|
||||
async def delete_automation(request: Request, auto_id: int):
|
||||
def delete_automation(request: Request, auto_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM automations WHERE id=?", (auto_id,))
|
||||
conn.commit()
|
||||
@@ -178,7 +178,7 @@ async def run_automation_button(request: Request, auto_id: int):
|
||||
|
||||
|
||||
@router.get("/workspace/automations/{auto_id}/runs")
|
||||
async def automation_runs_history(request: Request, auto_id: int, limit: int = 50):
|
||||
def automation_runs_history(request: Request, auto_id: int, limit: int = 50):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM automation_runs WHERE automation_id=?
|
||||
@@ -226,7 +226,7 @@ def _encrypt_step_config(config: dict) -> dict:
|
||||
|
||||
|
||||
@router.get("/workspace/automations/{auto_id}/steps")
|
||||
async def list_steps(request: Request, auto_id: int):
|
||||
def list_steps(request: Request, auto_id: int):
|
||||
if _get_auto(auto_id) is None:
|
||||
return _auto_404()
|
||||
return {"automation_id": auto_id, "steps": get_steps(auto_id)}
|
||||
@@ -279,7 +279,7 @@ async def update_step(request: Request, step_id: int):
|
||||
|
||||
|
||||
@router.delete("/workspace/automations/steps/{step_id}")
|
||||
async def delete_step(request: Request, step_id: int):
|
||||
def delete_step(request: Request, step_id: int):
|
||||
_require_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM automation_steps WHERE id=?", (step_id,))
|
||||
|
||||
+23
-23
@@ -66,7 +66,7 @@ def _ensure_block_ids(blocks) -> None:
|
||||
|
||||
|
||||
@router.get("/api/wiki/pages")
|
||||
async def wiki_page_search(request: Request, q: str = Query(default="")):
|
||||
def wiki_page_search(request: Request, q: str = Query(default="")):
|
||||
"""v5.11.0: page picker for [[ wiki-links. Fuzzy-ish title search across
|
||||
every non-deleted page the current user can see (single source: pages)."""
|
||||
q = (q or "").strip().lower()
|
||||
@@ -98,7 +98,7 @@ async def wiki_page_search(request: Request, q: str = Query(default="")):
|
||||
|
||||
|
||||
@router.get("/api/wiki/titles")
|
||||
async def wiki_titles(request: Request, ids: str = Query(default="")):
|
||||
def wiki_titles(request: Request, ids: str = Query(default="")):
|
||||
"""v5.11.0: resolve page-id lists to current labels (rename propagation)."""
|
||||
parsed: list[int] = []
|
||||
for part in (ids or "").split(","):
|
||||
@@ -179,7 +179,7 @@ async def set_page_options(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/api/page-templates")
|
||||
async def list_page_templates_api(request: Request):
|
||||
def list_page_templates_api(request: Request):
|
||||
"""v5.12.0: built-in + user global page templates for the picker."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
@@ -899,7 +899,7 @@ def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
|
||||
# ═══════════ Library page ═══════════
|
||||
|
||||
@router.get("/library", response_class=HTMLResponse)
|
||||
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Library page avec onglets Recents, Favorites, Shared, Private — scopé au workspace."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
@@ -943,7 +943,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
|
||||
# ═══════════ Favorites API ═══════════
|
||||
|
||||
@router.get("/api/favorites")
|
||||
async def list_favorites(request: Request):
|
||||
def list_favorites(request: Request):
|
||||
"""List favorited page IDs for the current user."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
@@ -1033,7 +1033,7 @@ async def unpublish_page(request: Request, page_id: int):
|
||||
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
|
||||
|
||||
@router.get("/api/trash")
|
||||
async def list_trash(request: Request):
|
||||
def list_trash(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall()
|
||||
return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows]
|
||||
@@ -1052,7 +1052,7 @@ async def restore_page(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.delete("/api/trash/{page_id}")
|
||||
async def permanent_delete(request: Request, page_id: int):
|
||||
def permanent_delete(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
|
||||
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
|
||||
@@ -1061,7 +1061,7 @@ async def permanent_delete(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
async def trash_page(request: Request):
|
||||
def trash_page(request: Request):
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
template = env.get_template("trash.html")
|
||||
@@ -1072,7 +1072,7 @@ async def trash_page(request: Request):
|
||||
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
|
||||
|
||||
@router.get("/api/synced-blocks")
|
||||
async def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
|
||||
def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
|
||||
"""List synced blocks for a workspace."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
from app.services.synced_blocks import list_synced_blocks
|
||||
@@ -1147,7 +1147,7 @@ async def delete_synced_block_api(request: Request, sid: int):
|
||||
|
||||
|
||||
@router.get("/api/synced-blocks/{sid}")
|
||||
async def get_synced_block_api(sid: int):
|
||||
def get_synced_block_api(sid: int):
|
||||
"""Get a synced block by id."""
|
||||
from app.services.synced_blocks import get_synced_block
|
||||
sb = get_synced_block(sid)
|
||||
@@ -1173,7 +1173,7 @@ async def add_synced_to_page(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}/synced/{sid}")
|
||||
async def remove_synced_from_page(request: Request, page_id: int, sid: int):
|
||||
def remove_synced_from_page(request: Request, page_id: int, sid: int):
|
||||
"""Remove a synced block reference from a page (unsync)."""
|
||||
from app.services.synced_blocks import remove_page_synced
|
||||
remove_page_synced(page_id, sid)
|
||||
@@ -1181,7 +1181,7 @@ async def remove_synced_from_page(request: Request, page_id: int, sid: int):
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/synced")
|
||||
async def get_page_synced_refs(request: Request, page_id: int):
|
||||
def get_page_synced_refs(request: Request, page_id: int):
|
||||
"""Get all synced block references for a page."""
|
||||
from app.services.synced_blocks import get_page_synced
|
||||
return {"synced_blocks": get_page_synced(page_id)}
|
||||
@@ -1190,7 +1190,7 @@ async def get_page_synced_refs(request: Request, page_id: int):
|
||||
# ═══════════ Board page ═══════════
|
||||
|
||||
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
||||
async def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
|
||||
def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
sidebar = _sidebar_data(request, owner, repo)
|
||||
@@ -1279,12 +1279,12 @@ async def board_view(
|
||||
# ═══════════ v0.9.0: Custom Properties API ═══════════
|
||||
|
||||
@router.get("/api/properties/{owner}/{repo}")
|
||||
async def get_properties(owner: str, repo: str):
|
||||
def get_properties(owner: str, repo: str):
|
||||
return {"properties": _get_project_properties(owner, repo)}
|
||||
|
||||
|
||||
@router.post("/api/properties/{owner}/{repo}")
|
||||
async def create_property(owner: str, repo: str, name: str = Query(...),
|
||||
def create_property(owner: str, repo: str, name: str = Query(...),
|
||||
prop_type: str = Query(default="select"),
|
||||
options: str = Query(default="")):
|
||||
opts = json.dumps([o.strip() for o in options.split(",") if o.strip()])
|
||||
@@ -1301,7 +1301,7 @@ async def create_property(owner: str, repo: str, name: str = Query(...),
|
||||
|
||||
|
||||
@router.delete("/api/properties/{owner}/{repo}")
|
||||
async def delete_property(owner: str, repo: str, name: str = Query(...)):
|
||||
def delete_property(owner: str, repo: str, name: str = Query(...)):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM project_properties WHERE project_owner=? AND project_name=? AND name=?",
|
||||
@@ -1312,7 +1312,7 @@ async def delete_property(owner: str, repo: str, name: str = Query(...)):
|
||||
|
||||
|
||||
@router.post("/api/properties/{owner}/{repo}/values")
|
||||
async def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
|
||||
def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
|
||||
name: str = Query(...), value: str = Query(default="")):
|
||||
with get_conn() as conn:
|
||||
prop = conn.execute(
|
||||
@@ -1332,7 +1332,7 @@ async def set_property_value(owner: str, repo: str, issue_id: int = Query(...),
|
||||
# ═══════════ v0.9.0: AI Keywords API ═══════════
|
||||
|
||||
@router.get("/api/ai-keywords/{owner}/{repo}")
|
||||
async def get_ai_keywords(owner: str, repo: str):
|
||||
def get_ai_keywords(owner: str, repo: str):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT keyword, color, usage_count FROM ai_keywords WHERE project_owner=? AND project_name=? ORDER BY usage_count DESC LIMIT 30",
|
||||
@@ -1395,7 +1395,7 @@ async def create_page(request: Request, title: str = Query(default=""),
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}")
|
||||
async def get_page(request: Request, page_id: int):
|
||||
def get_page(request: Request, page_id: int):
|
||||
"""Get a Markdown page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
@@ -1543,7 +1543,7 @@ def _block_texts(b: dict) -> list[str]:
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/backlinks")
|
||||
async def page_backlinks(request: Request, page_id: int):
|
||||
def page_backlinks(request: Request, page_id: int):
|
||||
"""v5.4.0: pages that link to this one ("Lié depuis…").
|
||||
|
||||
Scans every non-deleted page's blocks (and raw markdown) for an internal
|
||||
@@ -1591,7 +1591,7 @@ async def page_backlinks(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/versions")
|
||||
async def page_versions(request: Request, page_id: int):
|
||||
def page_versions(request: Request, page_id: int):
|
||||
"""v5.4.0: version history for a block-editor page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -1750,7 +1750,7 @@ async def set_page_cover(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}/cover")
|
||||
async def remove_page_cover(request: Request, page_id: int):
|
||||
def remove_page_cover(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET cover_url='' WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
@@ -2024,7 +2024,7 @@ async def delete_page(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
||||
async def view_page(request: Request, page_id: int):
|
||||
def view_page(request: Request, page_id: int):
|
||||
"""Render a page as HTML, or a file viewer for uploaded files.
|
||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
|
||||
@@ -49,7 +49,7 @@ def _serialize(rows):
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/comments")
|
||||
async def list_comments(request: Request, page_id: int):
|
||||
def list_comments(request: Request, page_id: int):
|
||||
"""List page-level and inline comments for a FlowDeck page."""
|
||||
_current_user(request)
|
||||
with get_conn() as conn:
|
||||
@@ -195,7 +195,7 @@ async def update_comment(request: Request, comment_id: int):
|
||||
|
||||
|
||||
@router.delete("/comments/{comment_id}")
|
||||
async def delete_comment(request: Request, comment_id: int):
|
||||
def delete_comment(request: Request, comment_id: int):
|
||||
"""Delete a comment and its replies."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
|
||||
+29
-28
@@ -27,6 +27,7 @@ from app.services.recurrence import (
|
||||
validate_rule,
|
||||
)
|
||||
from app.services.reminders import REMINDER_KEY, parse_lead
|
||||
from app.templating import CSP_NONCE
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
@@ -195,7 +196,7 @@ def _validate_meta_keys(conn, collection_id: int, properties: dict) -> None:
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def list_collections(request: Request):
|
||||
def list_collections(request: Request):
|
||||
"""Page listing all collections in the workspace."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -211,7 +212,7 @@ async def list_collections(request: Request):
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
async def list_collections_api(request: Request):
|
||||
def list_collections_api(request: Request):
|
||||
"""API: list all collections."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -501,7 +502,7 @@ async def duplicate_collection_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/api")
|
||||
async def get_page_api(request: Request, page_id: int):
|
||||
def get_page_api(request: Request, page_id: int):
|
||||
"""API: get a single page."""
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
@@ -515,7 +516,7 @@ async def get_page_api(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/open/api")
|
||||
async def open_row_page_api(request: Request, page_id: int):
|
||||
def open_row_page_api(request: Request, page_id: int):
|
||||
"""v6.5.0 — content page of a database row (lazy-created).
|
||||
|
||||
Any DB view (table/board/gallery/list/calendar) opens a row through
|
||||
@@ -649,7 +650,7 @@ async def delete_page_api(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/boards/api")
|
||||
async def list_boards_as_collections(request: Request):
|
||||
def list_boards_as_collections(request: Request):
|
||||
"""API: list all Gitea boards as pseudo-collections."""
|
||||
from app.services.collection_adapter import GiteaBoardCompat
|
||||
boards = GiteaBoardCompat.list_boards_as_collections()
|
||||
@@ -689,14 +690,14 @@ async def sync_board_to_collection(request: Request, owner: str, repo: str):
|
||||
|
||||
|
||||
@router.get("/property-types/api")
|
||||
async def list_property_types_api(request: Request):
|
||||
def list_property_types_api(request: Request):
|
||||
"""API: list all available property types."""
|
||||
from app.services.property_types import PROPERTY_TYPES
|
||||
return {"types": PROPERTY_TYPES}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/properties/api")
|
||||
async def list_properties_api(request: Request, collection_id: int):
|
||||
def list_properties_api(request: Request, collection_id: int):
|
||||
"""API: list all properties visible to the current user."""
|
||||
user = _session_user(request)
|
||||
_require_view(collection_id, user)
|
||||
@@ -719,7 +720,7 @@ async def list_properties_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.get("/{collection_id}/members/api")
|
||||
async def list_collection_members_api(request: Request, collection_id: int):
|
||||
def list_collection_members_api(request: Request, collection_id: int):
|
||||
"""API: list workspace members available for a ``person`` property.
|
||||
|
||||
Resolves the collection's workspace and returns its members (falling back to
|
||||
@@ -752,7 +753,7 @@ async def list_collection_members_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.get("/{collection_id}/calendar/api")
|
||||
async def collection_calendar_api(request: Request, collection_id: int,
|
||||
def collection_calendar_api(request: Request, collection_id: int,
|
||||
start: str = "", end: str = "",
|
||||
date_property: str = ""):
|
||||
"""API (v5.8.0): expanded calendar events for a window [start, end].
|
||||
@@ -837,7 +838,7 @@ async def collection_calendar_api(request: Request, collection_id: int,
|
||||
|
||||
|
||||
@router.get("/timezones/api")
|
||||
async def timezones_api(request: Request):
|
||||
def timezones_api(request: Request):
|
||||
"""API (v5.8.0): the user's timezone plus a picker-friendly zone list."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
@@ -972,7 +973,7 @@ async def update_property_api(request: Request, prop_id: int):
|
||||
|
||||
|
||||
@router.delete("/properties/{prop_id}/api")
|
||||
async def delete_property_api(request: Request, prop_id: int):
|
||||
def delete_property_api(request: Request, prop_id: int):
|
||||
"""API: delete a property."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -1166,7 +1167,7 @@ async def evaluate_formula(request: Request):
|
||||
|
||||
|
||||
@router.get("/views/{view_id}/api")
|
||||
async def get_view_api(request: Request, view_id: int):
|
||||
def get_view_api(request: Request, view_id: int):
|
||||
"""API: get a single view config."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
@@ -1251,7 +1252,7 @@ async def save_view_as(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.get("/{collection_id}/views/api")
|
||||
async def list_views_api(request: Request, collection_id: int):
|
||||
def list_views_api(request: Request, collection_id: int):
|
||||
"""API: list views for a collection visible to the current user.
|
||||
|
||||
Shared/legacy views (``created_by IS NULL``) are visible to everyone;
|
||||
@@ -1276,7 +1277,7 @@ async def list_views_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.delete("/views/{view_id}/api")
|
||||
async def delete_view_api(request: Request, view_id: int):
|
||||
def delete_view_api(request: Request, view_id: int):
|
||||
"""API: delete a saved view."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
@@ -1332,7 +1333,7 @@ async def duplicate_view_api(request: Request, view_id: int):
|
||||
|
||||
|
||||
@router.get("/{collection_id}/pages/{page_id}/sub-items")
|
||||
async def list_sub_items(request: Request, collection_id: int, page_id: int):
|
||||
def list_sub_items(request: Request, collection_id: int, page_id: int):
|
||||
"""API: list sub-items of a page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -1395,7 +1396,7 @@ async def create_sub_item(request: Request, collection_id: int, page_id: int):
|
||||
|
||||
|
||||
@router.get("/{collection_id}/pages/{page_id}/status-aggregate")
|
||||
async def aggregate_child_status(request: Request, collection_id: int, page_id: int):
|
||||
def aggregate_child_status(request: Request, collection_id: int, page_id: int):
|
||||
"""API: compute aggregate status from children."""
|
||||
with get_conn() as conn:
|
||||
children = conn.execute(
|
||||
@@ -1494,7 +1495,7 @@ async def check_dependencies(request: Request, collection_id: int, page_id: int)
|
||||
|
||||
|
||||
@router.get("/{collection_id}/sources/api")
|
||||
async def list_data_sources(request: Request, collection_id: int):
|
||||
def list_data_sources(request: Request, collection_id: int):
|
||||
"""API: list all data sources for a collection."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
@@ -1554,7 +1555,7 @@ async def add_data_source(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.delete("/{collection_id}/sources/{source_id}/api")
|
||||
async def remove_data_source(request: Request, collection_id: int, source_id: int):
|
||||
def remove_data_source(request: Request, collection_id: int, source_id: int):
|
||||
"""API: remove a data source from a collection."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -1667,7 +1668,7 @@ async def create_linked_database(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/toggle-inline/api")
|
||||
async def toggle_inline(request: Request, collection_id: int):
|
||||
def toggle_inline(request: Request, collection_id: int):
|
||||
"""API: toggle a collection between full-page and inline mode."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute(
|
||||
@@ -1758,7 +1759,7 @@ async def create_inline_database(request: Request):
|
||||
|
||||
|
||||
@router.put("/{collection_id}/toggle-task/api")
|
||||
async def toggle_task(request: Request, collection_id: int):
|
||||
def toggle_task(request: Request, collection_id: int):
|
||||
"""API: toggle is_task flag on a collection (Turn into Tasks)."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
@@ -1771,7 +1772,7 @@ async def toggle_task(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.get("/{collection_id}/pages/{page_id}/dependencies/api")
|
||||
async def list_page_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
def list_page_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
"""API: list dependencies for a page (blocks, blocked_by, related)."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -1819,7 +1820,7 @@ async def add_page_dependency(request: Request, collection_id: int, page_id: int
|
||||
|
||||
|
||||
@router.delete("/{collection_id}/pages/{page_id}/dependencies/{dep_id}/api")
|
||||
async def remove_page_dependency(request: Request, collection_id: int, page_id: int, dep_id: int):
|
||||
def remove_page_dependency(request: Request, collection_id: int, page_id: int, dep_id: int):
|
||||
"""API: remove a dependency."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -1914,7 +1915,7 @@ async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
|
||||
|
||||
|
||||
@router.get("/{collection_id}/dashboards/{dashboard_id}", response_class=HTMLResponse)
|
||||
async def view_dashboard(request: Request, collection_id: int, dashboard_id: int):
|
||||
def view_dashboard(request: Request, collection_id: int, dashboard_id: int):
|
||||
"""v7.3.0: render a collection dashboard grid (multi-DB widgets).
|
||||
|
||||
Widgets live in ``collection_dashboards.layout_json`` as
|
||||
@@ -1980,7 +1981,7 @@ async def view_dashboard(request: Request, collection_id: int, dashboard_id: int
|
||||
|
||||
@router.get("/{collection_id}", response_class=HTMLResponse)
|
||||
@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
|
||||
async def view_collection(request: Request, collection_id: int, view_type: str = "table"):
|
||||
def view_collection(request: Request, collection_id: int, view_type: str = "table"):
|
||||
"""Main view — renders collection in the requested view type."""
|
||||
# v6.0.0: granular collection permissions — hide restricted collections.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
@@ -2376,7 +2377,7 @@ canvas{{max-height:400px}}
|
||||
</style>
|
||||
<div class="chart-container"><canvas id="chartCanvas"></canvas></div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||
<script>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
new Chart(document.getElementById('chartCanvas'), {{
|
||||
type: '{chart_type}',
|
||||
data: {{
|
||||
@@ -2439,7 +2440,7 @@ def _render_form(view_type: str, collection: dict, pages: list[dict], config: di
|
||||
</form>
|
||||
<div id="formResult" style="margin-top:12px;color:#4CAF50;display:none">✓ Created successfully!</div>
|
||||
</div>
|
||||
<script>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
async function submitForm(e) {{
|
||||
e.preventDefault();
|
||||
const form = document.getElementById('collectionForm');
|
||||
@@ -2490,7 +2491,7 @@ def _render_map(view_type: str, collection: dict, pages: list[dict], config: dic
|
||||
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
|
||||
<div id="map"></div>
|
||||
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
|
||||
<script>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
const map = L.map('map').setView([{center_lat}, {center_lng}], 6);
|
||||
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map);
|
||||
const markers = {markers_json};
|
||||
@@ -2617,7 +2618,7 @@ tr:hover td{{background:#222}}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/api")
|
||||
async def get_collection_api(request: Request, collection_id: int):
|
||||
def get_collection_api(request: Request, collection_id: int):
|
||||
"""API: get a single collection with its pages."""
|
||||
# v6.0.0: granular collection permissions — hide restricted collections.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
|
||||
+60
-41
@@ -252,7 +252,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
|
||||
|
||||
|
||||
@router.get("/trash", response_class=HTMLResponse)
|
||||
async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Trash page — scoped to workspace if owner/repo provided."""
|
||||
from app.routers.board import _sidebar_data as board_sidebar
|
||||
from app.templating import ENV
|
||||
@@ -279,7 +279,7 @@ async def trash_page(request: Request, owner: str = Query(default=""), repo: str
|
||||
|
||||
|
||||
@router.get("/library", response_class=HTMLResponse)
|
||||
async def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
def library_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
|
||||
"""Library page — tabbed view (recents, favorites, shared, published, private, workspace).
|
||||
|
||||
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
|
||||
@@ -308,7 +308,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}", response_class=HTMLResponse)
|
||||
async def view_page_root(request: Request, page_id: int):
|
||||
def view_page_root(request: Request, page_id: int):
|
||||
"""Render a Markdown page at root level with workspace context — or file viewer.
|
||||
?embed=1 — minimal mode for side peek (editor only, no header)."""
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
@@ -413,17 +413,36 @@ async def view_page_root(request: Request, page_id: int):
|
||||
|
||||
with get_conn() as conn:
|
||||
nav_crumbs = _nav_breadcrumb(conn, page_id)
|
||||
# dérivé calculé UNE fois : ctx ET page_data (JSON) l'utilisent (A27)
|
||||
page_is_shared = (
|
||||
bool(page.get("is_shared", 0))
|
||||
or page.get("share_mode", "private") != "private"
|
||||
or bool(page.get("published", 0))
|
||||
)
|
||||
ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs],
|
||||
"page_favorited": fav is not None,
|
||||
"page_share_mode": page.get("share_mode", "private"),
|
||||
"page_published": bool(page.get("published", 0)),
|
||||
"page_is_shared": bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)),
|
||||
"page_is_shared": page_is_shared,
|
||||
"page_data": page_data,
|
||||
"collection_data": collection_data,
|
||||
"breadcrumb_items": nav_crumbs,
|
||||
"nav_workspace_id": page.get("workspace_id") or 0,
|
||||
"nav_page_id": page_id,
|
||||
"embed_mode": embed}
|
||||
# A27 phase 2 : le JS de l'éditeur lit ces valeurs dans page-data (JSON)
|
||||
# au lieu des interpolations Jinja — une seule source, même calculs que le
|
||||
# ctx ci-dessus.
|
||||
from app.templating import ENV as _ENV27
|
||||
page_data.update(
|
||||
updated_at=page.get("updated_at", ""),
|
||||
created_at=page.get("created_at", ""),
|
||||
user_id=_uid or 0,
|
||||
is_shared=page_is_shared,
|
||||
clip_icon=_ENV27.from_string(
|
||||
"{% from '_icons.html' import fd_icon %}{{ fd_icon('paperclip', 14) }}"
|
||||
).render(),
|
||||
)
|
||||
# Select template: collection pages use database table view
|
||||
if page.get("content_format") == "collection" and not embed:
|
||||
template = env.get_template("page_editor_collection.html")
|
||||
@@ -434,7 +453,7 @@ async def view_page_root(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/accounts", response_class=HTMLResponse)
|
||||
async def accounts_page(request: Request):
|
||||
def accounts_page(request: Request):
|
||||
"""Account management panel."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
@@ -451,7 +470,7 @@ async def accounts_page(request: Request):
|
||||
|
||||
|
||||
@router.get("/help", response_class=HTMLResponse)
|
||||
async def help_page(request: Request):
|
||||
def help_page(request: Request):
|
||||
"""Comprehensive help & documentation page."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
@@ -656,7 +675,7 @@ favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&o
|
||||
|
||||
|
||||
@router.get("/accounts/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request):
|
||||
def settings_page(request: Request):
|
||||
"""User settings page — profile, forges, tokens."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
@@ -731,7 +750,7 @@ async def update_password(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/user/token")
|
||||
async def generate_token(request: Request):
|
||||
def generate_token(request: Request):
|
||||
import secrets
|
||||
uid = _require_user_id(request)
|
||||
token = secrets.token_hex(32)
|
||||
@@ -745,7 +764,7 @@ async def generate_token(request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/user/forge/{provider}")
|
||||
async def disconnect_forge(request: Request, provider: str):
|
||||
def disconnect_forge(request: Request, provider: str):
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -834,7 +853,7 @@ async def dashboard(
|
||||
# ═══════════ Workspace ═══════════
|
||||
|
||||
@router.get("/workspace", response_class=HTMLResponse)
|
||||
async def workspace_page(request: Request):
|
||||
def workspace_page(request: Request):
|
||||
"""Unified workspace showing all projects."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
@@ -851,7 +870,7 @@ async def workspace_page(request: Request):
|
||||
|
||||
|
||||
@router.get("/gitea-workspace", response_class=HTMLResponse)
|
||||
async def gitea_workspace_page(request: Request):
|
||||
def gitea_workspace_page(request: Request):
|
||||
"""Gitea workspace — browse repo files."""
|
||||
import json
|
||||
|
||||
@@ -971,7 +990,7 @@ async def create_workspace_project(request: Request):
|
||||
# ═══════════ Workspace Members API ═══════════
|
||||
|
||||
@router.get("/api/workspace/{ws_id:int}/members")
|
||||
async def list_members(request: Request, ws_id: int):
|
||||
def list_members(request: Request, ws_id: int):
|
||||
"""List all members of a workspace."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -1022,7 +1041,7 @@ async def update_member_role(request: Request, ws_id: int, user_id: int):
|
||||
|
||||
|
||||
@router.delete("/api/workspace/{ws_id:int}/members/{user_id:int}")
|
||||
async def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
"""Remove a member from a workspace."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -1036,7 +1055,7 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
# ═══════════ Local Workspace (file/folder CRUD) ═══════════
|
||||
|
||||
@router.get("/local-workspace", response_class=HTMLResponse)
|
||||
async def local_workspace_page(request: Request, folder: int = None):
|
||||
def local_workspace_page(request: Request, folder: int = None):
|
||||
"""Local workspace page with file/folder tree.
|
||||
|
||||
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
|
||||
@@ -1084,7 +1103,7 @@ async def local_workspace_page(request: Request, folder: int = None):
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/tree")
|
||||
async def local_workspace_tree(request: Request, folder: int = None):
|
||||
def local_workspace_tree(request: Request, folder: int = None):
|
||||
"""Return the file/folder tree filtered by active workspace.
|
||||
|
||||
If ?folder=ID is provided, returns only that folder's children.
|
||||
@@ -1109,7 +1128,7 @@ async def local_workspace_tree(request: Request, folder: int = None):
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/page-content/{page_id:int}")
|
||||
async def get_page_content(page_id: int):
|
||||
def get_page_content(page_id: int):
|
||||
"""Return the raw content of a page (for preview)."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
@@ -1174,7 +1193,7 @@ def _require_page_view(request: Request, page_id: int) -> None:
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/download")
|
||||
async def download_page_file(request: Request, page_id: int):
|
||||
def download_page_file(request: Request, page_id: int):
|
||||
"""Download the original uploaded file of a ``file`` page (attachment)."""
|
||||
_require_page_view(request, page_id)
|
||||
with get_conn() as conn:
|
||||
@@ -1197,7 +1216,7 @@ async def download_page_file(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/file-content")
|
||||
async def page_file_content(request: Request, page_id: int):
|
||||
def page_file_content(request: Request, page_id: int):
|
||||
"""Return the textual content of a ``file`` page (for copy to clipboard).
|
||||
|
||||
Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is
|
||||
@@ -1225,7 +1244,7 @@ async def page_file_content(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/breadcrumb")
|
||||
async def local_workspace_breadcrumb(request: Request, folder: int):
|
||||
def local_workspace_breadcrumb(request: Request, folder: int):
|
||||
"""Return breadcrumb trail for a folder."""
|
||||
with get_conn() as conn:
|
||||
breadcrumb = _build_breadcrumb(conn, folder)
|
||||
@@ -1393,7 +1412,7 @@ def _nav_breadcrumb(conn, page_id: int) -> list:
|
||||
|
||||
|
||||
@router.get("/api/nav/menu")
|
||||
async def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
|
||||
def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
|
||||
"""Return the pages at one level for the header breadcrumb navigation menu.
|
||||
|
||||
If ``parent_id`` is given, returns that page's children; otherwise the
|
||||
@@ -1483,7 +1502,7 @@ async def rename_local_workspace_item(request: Request, item_id: int):
|
||||
|
||||
|
||||
@router.delete("/api/local-workspace/items/{item_id:int}")
|
||||
async def delete_local_workspace_item(request: Request, item_id: int):
|
||||
def delete_local_workspace_item(request: Request, item_id: int):
|
||||
"""Soft-delete a file/folder (sets deleted_at)."""
|
||||
from datetime import datetime
|
||||
with get_conn() as conn:
|
||||
@@ -1496,7 +1515,7 @@ async def delete_local_workspace_item(request: Request, item_id: int):
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/items/{item_id:int}/restore")
|
||||
async def restore_local_workspace_item(request: Request, item_id: int):
|
||||
def restore_local_workspace_item(request: Request, item_id: int):
|
||||
"""Restore a soft-deleted file/folder."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -1508,7 +1527,7 @@ async def restore_local_workspace_item(request: Request, item_id: int):
|
||||
|
||||
|
||||
@router.get("/api/files/{ws_id:int}/{filename:path}")
|
||||
async def serve_uploaded_file(ws_id: int, filename: str):
|
||||
def serve_uploaded_file(ws_id: int, filename: str):
|
||||
"""Serve an uploaded file from disk."""
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
@@ -1774,7 +1793,7 @@ def _get_active_workspace(request: Request, user_id: int = None) -> dict | None:
|
||||
|
||||
|
||||
@router.get("/workspaces", response_class=HTMLResponse)
|
||||
async def workspaces_page(request: Request):
|
||||
def workspaces_page(request: Request):
|
||||
"""Workspaces list page."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
@@ -1791,7 +1810,7 @@ async def workspaces_page(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/workspaces")
|
||||
async def list_workspaces(request: Request):
|
||||
def list_workspaces(request: Request):
|
||||
"""List all workspaces for the current user."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
@@ -1855,7 +1874,7 @@ async def rename_workspace(request: Request, ws_id: int):
|
||||
|
||||
|
||||
@router.delete("/api/workspaces/{ws_id:int}")
|
||||
async def delete_workspace(request: Request, ws_id: int):
|
||||
def delete_workspace(request: Request, ws_id: int):
|
||||
"""Delete a workspace and all its pages."""
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM pages WHERE workspace_id=?", (ws_id,))
|
||||
@@ -1866,7 +1885,7 @@ async def delete_workspace(request: Request, ws_id: int):
|
||||
|
||||
|
||||
@router.post("/api/workspaces/{ws_id:int}/select")
|
||||
async def select_workspace(request: Request, ws_id: int):
|
||||
def select_workspace(request: Request, ws_id: int):
|
||||
"""Set the active workspace via cookie."""
|
||||
from fastapi.responses import JSONResponse
|
||||
response = JSONResponse({"status": "ok", "workspace_id": ws_id})
|
||||
@@ -1877,7 +1896,7 @@ async def select_workspace(request: Request, ws_id: int):
|
||||
# ═══════════ Settings Page ═══════════
|
||||
|
||||
@router.get("/settings", response_class=HTMLResponse)
|
||||
async def app_settings_page(request: Request):
|
||||
def app_settings_page(request: Request):
|
||||
"""Settings & configuration page."""
|
||||
from app.templating import ENV
|
||||
env = ENV
|
||||
@@ -1923,7 +1942,7 @@ async def upload_avatar(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/settings/avatar/{filename:path}")
|
||||
async def serve_avatar_file(filename: str):
|
||||
def serve_avatar_file(filename: str):
|
||||
"""Serve an uploaded avatar image file."""
|
||||
from pathlib import Path
|
||||
|
||||
@@ -1942,7 +1961,7 @@ async def serve_avatar_file(filename: str):
|
||||
|
||||
|
||||
@router.get("/api/avatar/{user_id:int}")
|
||||
async def get_avatar(user_id: int):
|
||||
def get_avatar(user_id: int):
|
||||
"""Redirect to the user's avatar."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT avatar_url FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
@@ -2002,7 +2021,7 @@ async def update_tag_global(tag_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/settings/tags/{tag_id:int}")
|
||||
async def delete_tag_global(tag_id: int, request: Request):
|
||||
def delete_tag_global(tag_id: int, request: Request):
|
||||
"""Delete a tag — only if owned by user."""
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
@@ -2013,7 +2032,7 @@ async def delete_tag_global(tag_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/settings/tags/all")
|
||||
async def list_all_tags_global(request: Request):
|
||||
def list_all_tags_global(request: Request):
|
||||
"""List current user's tags with counts."""
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
@@ -2032,7 +2051,7 @@ async def list_all_tags_global(request: Request):
|
||||
# ═══════════ Workspace Tags API ═══════════
|
||||
|
||||
@router.get("/api/local-workspace/tags")
|
||||
async def list_tags(request: Request):
|
||||
def list_tags(request: Request):
|
||||
"""List ALL user tags with counts scoped to the active workspace."""
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ws_id = ws["id"] if ws else None
|
||||
@@ -2055,7 +2074,7 @@ async def list_tags(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/items/{item_id:int}/tags")
|
||||
async def get_item_tags(item_id: int):
|
||||
def get_item_tags(item_id: int):
|
||||
"""Get tags for a specific item."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -2104,7 +2123,7 @@ async def add_item_tag(request: Request, item_id: int):
|
||||
|
||||
|
||||
@router.delete("/api/local-workspace/items/{item_id:int}/tags/{tag_id:int}")
|
||||
async def remove_item_tag(item_id: int, tag_id: int):
|
||||
def remove_item_tag(item_id: int, tag_id: int):
|
||||
"""Remove a tag from an item."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -2116,7 +2135,7 @@ async def remove_item_tag(item_id: int, tag_id: int):
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/tags/search")
|
||||
async def search_by_tags(request: Request, tags: str = ""):
|
||||
def search_by_tags(request: Request, tags: str = ""):
|
||||
"""Search items by tags (comma-separated)."""
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ws_id = ws["id"] if ws else None
|
||||
@@ -2198,7 +2217,7 @@ async def update_account(request: Request):
|
||||
# ═══════════ Sidebar Refresh API ═══════════
|
||||
|
||||
@router.get("/api/sidebar/workspace-tree")
|
||||
async def sidebar_workspace_tree(request: Request):
|
||||
def sidebar_workspace_tree(request: Request):
|
||||
"""Return the sidebar workspace tree as HTML fragment.
|
||||
|
||||
Called by appState().refreshSidebarTree() after CRUD operations
|
||||
@@ -2260,7 +2279,7 @@ async def sidebar_workspace_tree(request: Request):
|
||||
|
||||
|
||||
@router.get("/p/{slug}", response_class=HTMLResponse)
|
||||
async def public_published_page(request: Request, slug: str):
|
||||
def public_published_page(request: Request, slug: str):
|
||||
"""Serve a published page at /p/<slug> — no auth required."""
|
||||
from app.templating import ENV
|
||||
|
||||
@@ -2525,7 +2544,7 @@ def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
|
||||
# ═══════════ Library page actions API ═══════════
|
||||
|
||||
@router.get("/api/pages/{page_id:int}/content")
|
||||
async def api_page_content(page_id: int):
|
||||
def api_page_content(page_id: int):
|
||||
"""Get page content for side peek preview."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
@@ -2563,7 +2582,7 @@ async def api_rename_page(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/trash")
|
||||
async def api_trash_page(page_id: int):
|
||||
def api_trash_page(page_id: int):
|
||||
"""Soft-delete a page (move to trash)."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -2641,7 +2660,7 @@ async def api_convert_to_database(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/collections/{collection_id:int}/table-data")
|
||||
async def api_collection_table_data(collection_id: int):
|
||||
def api_collection_table_data(collection_id: int):
|
||||
"""Get collection properties + pages for rendering the table view."""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute(
|
||||
|
||||
@@ -37,7 +37,7 @@ def _active_ws(request: Request) -> int:
|
||||
|
||||
|
||||
@router.get("/api/custom-emojis")
|
||||
async def list_custom_emojis(request: Request):
|
||||
def list_custom_emojis(request: Request):
|
||||
"""List the current workspace's custom emojis."""
|
||||
ws_id = _active_ws(request)
|
||||
with get_conn() as conn:
|
||||
@@ -79,7 +79,7 @@ async def create_custom_emoji(request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/custom-emojis/{emoji_id}")
|
||||
async def delete_custom_emoji(request: Request, emoji_id: int):
|
||||
def delete_custom_emoji(request: Request, emoji_id: int):
|
||||
"""Delete a custom emoji (and its stored file)."""
|
||||
ws_id = _active_ws(request)
|
||||
with get_conn() as conn:
|
||||
|
||||
@@ -61,7 +61,7 @@ def _safe_filename(page: dict, ext: str) -> str:
|
||||
|
||||
|
||||
@router.get("/markdown/{page_id}")
|
||||
async def export_markdown(page_id: int, request: Request):
|
||||
def export_markdown(page_id: int, request: Request):
|
||||
page = _load_page_or_404(request, page_id)
|
||||
md = page_to_markdown(page)
|
||||
filename = _safe_filename(page, "md")
|
||||
@@ -70,7 +70,7 @@ async def export_markdown(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/html/{page_id}")
|
||||
async def export_html(page_id: int, request: Request):
|
||||
def export_html(page_id: int, request: Request):
|
||||
page = _load_page_or_404(request, page_id)
|
||||
html = page_to_standalone_html(page)
|
||||
filename = _safe_filename(page, "html")
|
||||
@@ -79,7 +79,7 @@ async def export_html(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/pdf/{page_id}")
|
||||
async def export_pdf(page_id: int, request: Request):
|
||||
def export_pdf(page_id: int, request: Request):
|
||||
page = _load_page_or_404(request, page_id)
|
||||
try:
|
||||
pdf_bytes = page_to_pdf_bytes(page)
|
||||
@@ -94,7 +94,7 @@ async def export_pdf(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/site/{page_id}")
|
||||
async def export_site(page_id: int, request: Request):
|
||||
def export_site(page_id: int, request: Request):
|
||||
page = _load_page_or_404(request, page_id)
|
||||
site_bytes = build_static_site_bytes(page)
|
||||
title = _safe_filename(page, "site").replace(".site", "") or "flowdeck-site"
|
||||
|
||||
@@ -162,7 +162,7 @@ async def get_labels(request: Request, owner: str, repo: str):
|
||||
# ── Account linking ──
|
||||
|
||||
@router.get("/status")
|
||||
async def gitea_status(request: Request):
|
||||
def gitea_status(request: Request):
|
||||
"""Check if the current user has Gitea linked."""
|
||||
from app.services.gitea_client import get_user_gitea_client
|
||||
client = get_user_gitea_client(request)
|
||||
@@ -170,7 +170,7 @@ async def gitea_status(request: Request):
|
||||
|
||||
|
||||
@router.delete("/disconnect")
|
||||
async def disconnect_gitea(request: Request):
|
||||
def disconnect_gitea(request: Request):
|
||||
"""Remove all Gitea OAuth tokens for the current user."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -186,7 +186,7 @@ async def disconnect_gitea(request: Request):
|
||||
# ── Private Pages (local FlowDeck pages linked to Gitea project) ──
|
||||
|
||||
@router.get("/projects/{owner}/{repo}/private-pages")
|
||||
async def list_private_pages(owner: str, repo: str, request: Request):
|
||||
def list_private_pages(owner: str, repo: str, request: Request):
|
||||
"""List private pages for this Gitea project."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -225,7 +225,7 @@ async def create_private_page(owner: str, repo: str, request: Request):
|
||||
|
||||
|
||||
@router.get("/projects/{owner}/{repo}/private-pages/{page_id}")
|
||||
async def get_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
def get_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
"""Get a single private page."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -272,7 +272,7 @@ async def update_private_page(owner: str, repo: str, page_id: int, request: Requ
|
||||
|
||||
|
||||
@router.delete("/projects/{owner}/{repo}/private-pages/{page_id}")
|
||||
async def delete_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
def delete_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
"""Delete a private page."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
@@ -6,7 +6,7 @@ router = APIRouter(tags=["github"], prefix="/api/github")
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def github_status(request: Request):
|
||||
def github_status(request: Request):
|
||||
"""Check if the current user has GitHub linked."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -22,7 +22,7 @@ async def github_status(request: Request):
|
||||
|
||||
|
||||
@router.delete("/disconnect")
|
||||
async def disconnect_github(request: Request):
|
||||
def disconnect_github(request: Request):
|
||||
"""Remove all GitHub OAuth tokens for the current user."""
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
|
||||
@@ -37,7 +37,7 @@ def _owner_or_admin(request: Request) -> dict:
|
||||
|
||||
|
||||
@router.get("/api/v2/agent-policies")
|
||||
async def list_policies(request: Request):
|
||||
def list_policies(request: Request):
|
||||
_owner_or_admin(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM agent_policies ORDER BY workspace_id").fetchall()
|
||||
@@ -74,7 +74,7 @@ async def upsert_policy(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/agent-approvals")
|
||||
async def list_approvals(request: Request):
|
||||
def list_approvals(request: Request):
|
||||
_owner_or_admin(request)
|
||||
status = request.query_params.get("status", "pending")
|
||||
with get_conn() as conn:
|
||||
|
||||
@@ -79,7 +79,7 @@ async def _read_upload(request: Request) -> tuple[str, bytes, str | None]:
|
||||
|
||||
|
||||
@router.get("/sources")
|
||||
async def import_sources(request: Request):
|
||||
def import_sources(request: Request):
|
||||
"""List every available importer for the UI source picker."""
|
||||
return {"sources": list_sources()}
|
||||
|
||||
@@ -289,7 +289,7 @@ async def import_run_batch(request: Request):
|
||||
|
||||
|
||||
@router.post("/relations/resolve")
|
||||
async def import_resolve_relations(request: Request):
|
||||
def import_resolve_relations(request: Request):
|
||||
"""Convert text columns referencing another collection into relation props."""
|
||||
ws_id, _ = _workspace(request)
|
||||
with get_conn() as conn:
|
||||
@@ -297,12 +297,12 @@ async def import_resolve_relations(request: Request):
|
||||
|
||||
|
||||
@router.get("/jobs")
|
||||
async def import_jobs(request: Request):
|
||||
def import_jobs(request: Request):
|
||||
return {"jobs": list_jobs()}
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}")
|
||||
async def import_job(job_id: str):
|
||||
def import_job(job_id: str):
|
||||
job = get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
@@ -310,7 +310,7 @@ async def import_job(job_id: str):
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}/report")
|
||||
async def import_job_report(job_id: str):
|
||||
def import_job_report(job_id: str):
|
||||
"""Download a job's import report as JSON."""
|
||||
job = get_job(job_id)
|
||||
if not job:
|
||||
|
||||
+8
-161
@@ -157,7 +157,7 @@ BASE_SELECT = (
|
||||
|
||||
|
||||
@router.get("/recents")
|
||||
async def library_recents(
|
||||
def library_recents(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
@@ -191,7 +191,7 @@ async def library_recents(
|
||||
|
||||
|
||||
@router.get("/favorites")
|
||||
async def library_favorites(
|
||||
def library_favorites(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
@@ -221,7 +221,7 @@ async def library_favorites(
|
||||
|
||||
|
||||
@router.get("/shared")
|
||||
async def library_shared(
|
||||
def library_shared(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
@@ -292,7 +292,7 @@ async def library_shared(
|
||||
|
||||
|
||||
@router.get("/published")
|
||||
async def library_published(
|
||||
def library_published(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
@@ -317,7 +317,7 @@ async def library_published(
|
||||
|
||||
|
||||
@router.get("/private")
|
||||
async def library_private(
|
||||
def library_private(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
@@ -341,76 +341,8 @@ async def library_private(
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.get("/local-workspace-children/{item_id:int}")
|
||||
async def library_local_workspace_children(item_id: int, request: Request):
|
||||
"""Return children of a local workspace item for tree expansion."""
|
||||
_get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
# Get the item to find its workspace
|
||||
item = conn.execute(
|
||||
"SELECT workspace_id FROM local_workspace_items WHERE id=? AND deleted_at IS NULL",
|
||||
[item_id],
|
||||
).fetchone()
|
||||
if not item:
|
||||
return {"items": []}
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, is_folder, parent_id, content_format, size, "
|
||||
"COALESCE(updated_at, created_at) as updated_at "
|
||||
"FROM local_workspace_items "
|
||||
"WHERE parent_id = ? AND deleted_at IS NULL "
|
||||
"ORDER BY is_folder DESC, name COLLATE NOCASE",
|
||||
[item_id],
|
||||
).fetchall()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
name = r["name"] or "Untitled"
|
||||
is_folder = bool(r["is_folder"])
|
||||
icon = "📁" if is_folder else "📄"
|
||||
fn = name.lower()
|
||||
if not is_folder:
|
||||
if fn.endswith(".pdf"):
|
||||
icon = "📕"
|
||||
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
|
||||
icon = "🖼️"
|
||||
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
|
||||
icon = "📜"
|
||||
|
||||
with get_conn() as conn:
|
||||
child_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL",
|
||||
[r["id"]],
|
||||
).fetchone()[0]
|
||||
|
||||
items.append({
|
||||
"id": r["id"],
|
||||
"title": name,
|
||||
"icon": icon,
|
||||
"is_folder": is_folder,
|
||||
"source_type": "local-ws",
|
||||
"source_label": "",
|
||||
"workspace": "",
|
||||
"workspace_name": "",
|
||||
"author": "",
|
||||
"author_initial": "?",
|
||||
"updated_at": r["updated_at"] or "",
|
||||
"visited_at": "",
|
||||
"has_children": child_count > 0,
|
||||
"children": [],
|
||||
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
|
||||
"content_format": r["content_format"] or "file",
|
||||
"favorited": False,
|
||||
"page_icon": "",
|
||||
"tags": [],
|
||||
"size_display": _format_size(r["size"]) if r["size"] else "",
|
||||
})
|
||||
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.get("/children/{page_id:int}")
|
||||
async def library_children(page_id: int, request: Request):
|
||||
def library_children(page_id: int, request: Request):
|
||||
"""Return child pages for a given parent page (for tree expansion in Library)."""
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
@@ -426,7 +358,7 @@ async def library_children(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/repository")
|
||||
async def library_repository(
|
||||
def library_repository(
|
||||
request: Request,
|
||||
gitea_owner: str = Query(default=""),
|
||||
gitea_repo: str = Query(default=""),
|
||||
@@ -449,93 +381,8 @@ async def library_repository(
|
||||
return {"items": items}
|
||||
|
||||
|
||||
@router.get("/local-workspace")
|
||||
async def library_local_workspace(
|
||||
request: Request,
|
||||
workspace_id: int = Query(default=0),
|
||||
):
|
||||
"""Return local workspace items (files/folders) formatted for Library display."""
|
||||
from app.routers.dashboard import _get_active_workspace
|
||||
uid = _get_user_id(request)
|
||||
|
||||
# Get the active workspace
|
||||
ws = _get_active_workspace(request, user_id=uid)
|
||||
if not ws:
|
||||
return {"items": []}
|
||||
|
||||
ws_id = workspace_id or ws["id"]
|
||||
|
||||
# Query local workspace tree
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, is_folder, parent_id, content_format, size, "
|
||||
"COALESCE(updated_at, created_at) as updated_at "
|
||||
"FROM local_workspace_items "
|
||||
"WHERE workspace_id = ? AND deleted_at IS NULL "
|
||||
"ORDER BY is_folder DESC, name COLLATE NOCASE",
|
||||
[ws_id],
|
||||
).fetchall()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
name = r["name"] or "Untitled"
|
||||
is_folder = bool(r["is_folder"])
|
||||
icon = "📁" if is_folder else "📄"
|
||||
fn = name.lower()
|
||||
if not is_folder:
|
||||
if fn.endswith(".pdf"):
|
||||
icon = "📕"
|
||||
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
|
||||
icon = "🖼️"
|
||||
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
|
||||
icon = "📜"
|
||||
|
||||
# Check for children
|
||||
child_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM local_workspace_items WHERE parent_id=? AND deleted_at IS NULL",
|
||||
[r["id"]],
|
||||
).fetchone()[0]
|
||||
|
||||
items.append({
|
||||
"id": r["id"],
|
||||
"title": name,
|
||||
"icon": icon,
|
||||
"is_folder": is_folder,
|
||||
"source_type": "local-ws",
|
||||
"source_label": ws.get("name", "Workspace"),
|
||||
"workspace": ws.get("name", ""),
|
||||
"workspace_name": ws.get("name", ""),
|
||||
"author": "",
|
||||
"author_initial": "?",
|
||||
"updated_at": r["updated_at"] or "",
|
||||
"visited_at": "",
|
||||
"has_children": child_count > 0,
|
||||
"children": [],
|
||||
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
|
||||
"content_format": r["content_format"] or "file",
|
||||
"favorited": False,
|
||||
"page_icon": "",
|
||||
"tags": [],
|
||||
"size_display": _format_size(r["size"]) if r["size"] else "",
|
||||
})
|
||||
|
||||
return {"items": items}
|
||||
|
||||
|
||||
def _format_size(size_bytes):
|
||||
if not size_bytes:
|
||||
return ""
|
||||
if size_bytes < 1024:
|
||||
return f"{size_bytes} B"
|
||||
if size_bytes < 1048576:
|
||||
return f"{size_bytes/1024:.1f} KB"
|
||||
if size_bytes < 1073741824:
|
||||
return f"{size_bytes/1048576:.1f} MB"
|
||||
return f"{size_bytes/1073741824:.1f} GB"
|
||||
|
||||
|
||||
@router.get("/workspace")
|
||||
async def library_workspace(
|
||||
def library_workspace(
|
||||
request: Request,
|
||||
source_type: str = Query(default="all"),
|
||||
tree: int = Query(default=0),
|
||||
|
||||
@@ -75,13 +75,13 @@ async def create_link(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/calendar-links")
|
||||
async def get_links(request: Request):
|
||||
def get_links(request: Request):
|
||||
user = _auth_user(request)
|
||||
return {"links": cal.list_links(user["id"])}
|
||||
|
||||
|
||||
@router.delete("/api/v2/calendar-links/{link_id}")
|
||||
async def remove_link(link_id: int, request: Request):
|
||||
def remove_link(link_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
if not cal.delete_link(user["id"], link_id):
|
||||
raise HTTPException(404, "Link not found")
|
||||
@@ -108,7 +108,7 @@ async def sync_now(link_id: int, request: Request):
|
||||
# ── free/busy ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/db/{collection_id}/calendar/freebusy")
|
||||
async def freebusy(collection_id: int, request: Request):
|
||||
def freebusy(collection_id: int, request: Request):
|
||||
_auth_user(request)
|
||||
qp = request.query_params
|
||||
try:
|
||||
|
||||
@@ -21,7 +21,7 @@ def _get_current_user(request: Request) -> dict | None:
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
|
||||
def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
|
||||
"""My Tasks — aggregates all pages assigned to the current user across all collections."""
|
||||
user = _get_current_user(request)
|
||||
user_login = user.get("login", "admin") if user else "admin"
|
||||
@@ -118,7 +118,7 @@ async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7)
|
||||
|
||||
|
||||
@router.get("/api")
|
||||
async def my_tasks_api(request: Request, view: str = "all", days: int = 7):
|
||||
def my_tasks_api(request: Request, view: str = "all", days: int = 7):
|
||||
"""API: return my tasks as JSON."""
|
||||
user = _get_current_user(request)
|
||||
user.get("login", "admin") if user else "admin"
|
||||
|
||||
@@ -13,7 +13,7 @@ router = APIRouter(tags=["notes"], prefix="/notes")
|
||||
|
||||
|
||||
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
||||
async def get_notes(request: Request, owner: str, repo: str):
|
||||
def get_notes(request: Request, owner: str, repo: str):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT content FROM notes WHERE project_owner=? AND project_name=? AND title='Notes'",
|
||||
|
||||
@@ -20,7 +20,7 @@ def _current_user(request: Request) -> dict:
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_notifications(request: Request, limit: int = 50):
|
||||
def list_notifications(request: Request, limit: int = 50):
|
||||
"""List the current user's notifications, newest first."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
@@ -44,7 +44,7 @@ async def list_notifications(request: Request, limit: int = 50):
|
||||
|
||||
|
||||
@router.get("/unread-count")
|
||||
async def unread_count(request: Request):
|
||||
def unread_count(request: Request):
|
||||
"""Unread count for the topbar badge."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
@@ -83,7 +83,7 @@ async def mark_all_read(request: Request):
|
||||
|
||||
|
||||
@router.get("/prefs")
|
||||
async def get_prefs(request: Request):
|
||||
def get_prefs(request: Request):
|
||||
"""Return the current user's notification email preferences."""
|
||||
user = _current_user(request)
|
||||
from app.services import notifications as notif
|
||||
@@ -105,7 +105,7 @@ async def set_prefs(request: Request):
|
||||
|
||||
|
||||
@router.get("/timezone")
|
||||
async def get_timezone(request: Request):
|
||||
def get_timezone(request: Request):
|
||||
"""Return the current user's IANA timezone ('' = UTC)."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
@@ -131,7 +131,7 @@ async def set_timezone(request: Request):
|
||||
|
||||
|
||||
@router.get("/users/search")
|
||||
async def search_users(request: Request, q: str = ""):
|
||||
def search_users(request: Request, q: str = ""):
|
||||
"""User autocomplete for @mentions."""
|
||||
_current_user(request)
|
||||
q = (q or "").strip()
|
||||
|
||||
@@ -28,7 +28,7 @@ def _require_user(request: Request) -> dict:
|
||||
|
||||
|
||||
@router.get("/welcome", response_class=HTMLResponse)
|
||||
async def onboarding_page(request: Request):
|
||||
def onboarding_page(request: Request):
|
||||
"""Onboarding wizard. Redirects logged-out users to login and users who
|
||||
already have a workspace straight to the app."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
|
||||
+14
-14
@@ -158,7 +158,7 @@ def _set_permission_type(request: Request, pm: PermissionManager, resource_type:
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/permissions")
|
||||
async def list_page_permissions(page_id: int, request: Request):
|
||||
def list_page_permissions(page_id: int, request: Request):
|
||||
"""List explicit page grants + the caller's effective role."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_page(page_id):
|
||||
@@ -174,7 +174,7 @@ async def list_page_permissions(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/permissions/mine")
|
||||
async def my_page_permission(page_id: int, request: Request):
|
||||
def my_page_permission(page_id: int, request: Request):
|
||||
"""Effective role of the current user on a page (UI gating)."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_page(page_id):
|
||||
@@ -224,7 +224,7 @@ async def batch_page_permissions(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/permissions/{perm_id}")
|
||||
async def revoke_page_permission(page_id: int, perm_id: int, request: Request):
|
||||
def revoke_page_permission(page_id: int, perm_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_page_permissions(page_id):
|
||||
raise HTTPException(403, "Only a page owner can manage its permissions")
|
||||
@@ -243,7 +243,7 @@ async def set_page_permission_type(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/permissions")
|
||||
async def list_collection_permissions(collection_id: int, request: Request):
|
||||
def list_collection_permissions(collection_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(404, "Collection not found")
|
||||
@@ -276,7 +276,7 @@ async def grant_collection_permission(collection_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/permissions/{perm_id}")
|
||||
async def revoke_collection_permission(collection_id: int, perm_id: int, request: Request):
|
||||
def revoke_collection_permission(collection_id: int, perm_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage its permissions")
|
||||
@@ -294,7 +294,7 @@ async def set_collection_permission_type(collection_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/properties/visible")
|
||||
async def visible_properties(collection_id: int, request: Request):
|
||||
def visible_properties(collection_id: int, request: Request):
|
||||
"""Split property ids into visible / hidden for the current user."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_collection(collection_id):
|
||||
@@ -316,7 +316,7 @@ async def visible_properties(collection_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/properties/{property_id}/permissions")
|
||||
async def list_property_permissions(collection_id: int, property_id: int, request: Request):
|
||||
def list_property_permissions(collection_id: int, property_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(404, "Collection not found")
|
||||
@@ -349,7 +349,7 @@ async def grant_property_permission(collection_id: int, property_id: int, reques
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/properties/{property_id}/permissions/{perm_id}")
|
||||
async def revoke_property_permission(collection_id: int, property_id: int,
|
||||
def revoke_property_permission(collection_id: int, property_id: int,
|
||||
perm_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
@@ -362,7 +362,7 @@ async def revoke_property_permission(collection_id: int, property_id: int,
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
async def list_groups(request: Request, workspace_id: int | None = None):
|
||||
def list_groups(request: Request, workspace_id: int | None = None):
|
||||
user = _require_user(request)
|
||||
pm = PermissionManager(user["id"])
|
||||
return {"groups": pm.get_groups_for_workspace(workspace_id)}
|
||||
@@ -405,7 +405,7 @@ async def update_group(group_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}")
|
||||
async def delete_group(group_id: int, request: Request):
|
||||
def delete_group(group_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
@@ -422,7 +422,7 @@ async def delete_group(group_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/members")
|
||||
async def list_group_members(group_id: int, request: Request):
|
||||
def list_group_members(group_id: int, request: Request):
|
||||
user = _require_user(request)
|
||||
return {"members": PermissionManager(user["id"]).get_group_members(group_id)}
|
||||
|
||||
@@ -451,7 +451,7 @@ async def add_group_member(group_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}/members/{user_id}")
|
||||
async def remove_group_member(group_id: int, user_id: int, request: Request):
|
||||
def remove_group_member(group_id: int, user_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
@@ -473,7 +473,7 @@ async def remove_group_member(group_id: int, user_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(request: Request, workspace_id: int | None = None, q: str = ""):
|
||||
def list_users(request: Request, workspace_id: int | None = None, q: str = ""):
|
||||
"""Workspace members (+ admins) for the grant pickers."""
|
||||
_require_user(request)
|
||||
q = (q or "").strip().lower()
|
||||
@@ -503,7 +503,7 @@ async def list_users(request: Request, workspace_id: int | None = None, q: str =
|
||||
|
||||
|
||||
@router.get("/audit/permissions")
|
||||
async def permission_audit(request: Request, limit: int = 100):
|
||||
def permission_audit(request: Request, limit: int = 100):
|
||||
"""Full permission change history — workspace owner/admin only."""
|
||||
user = _require_user(request)
|
||||
uid = user["id"]
|
||||
|
||||
@@ -22,7 +22,7 @@ def _require_admin(request: Request) -> dict:
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_projects(request: Request):
|
||||
def list_projects(request: Request):
|
||||
"""List all synced projects (optionally filtered by type)."""
|
||||
proj_type = request.query_params.get("type") or None
|
||||
return {"projects": projects_svc.list_projects(proj_type)}
|
||||
|
||||
@@ -55,7 +55,7 @@ def verify_token(authorization: str | None = Header(None)):
|
||||
|
||||
|
||||
@router.post("/token")
|
||||
async def generate_token(request: Request):
|
||||
def generate_token(request: Request):
|
||||
"""Generate a public API access token (A4 : session obligatoire — plus de
|
||||
« legacy shared token » `user_id=0` créable par un anonymous)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
@@ -72,14 +72,14 @@ async def generate_token(request: Request):
|
||||
|
||||
|
||||
@router.get("/collections", dependencies=[Depends(verify_token)])
|
||||
async def public_list_collections(request: Request):
|
||||
def public_list_collections(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, name, description, icon, created_at FROM collections ORDER BY name").fetchall()
|
||||
return {"collections": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}", dependencies=[Depends(verify_token)])
|
||||
async def public_get_collection(request: Request, collection_id: int):
|
||||
def public_get_collection(request: Request, collection_id: int):
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
@@ -92,7 +92,7 @@ async def public_get_collection(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/pages", dependencies=[Depends(verify_token)])
|
||||
async def public_list_pages(request: Request, collection_id: int):
|
||||
def public_list_pages(request: Request, collection_id: int):
|
||||
with get_conn() as conn:
|
||||
pages = conn.execute(
|
||||
"SELECT id, title, icon, position, property_values_json, created_at FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
|
||||
@@ -102,7 +102,7 @@ async def public_list_pages(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}", dependencies=[Depends(verify_token)])
|
||||
async def public_get_page(request: Request, page_id: int):
|
||||
def public_get_page(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
p = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not p:
|
||||
@@ -111,7 +111,7 @@ async def public_get_page(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.get("/my-tasks", dependencies=[Depends(verify_token)])
|
||||
async def public_my_tasks(request: Request):
|
||||
def public_my_tasks(request: Request):
|
||||
"""Public API: list tasks (requires valid token)."""
|
||||
with get_conn() as conn:
|
||||
pages = conn.execute(
|
||||
|
||||
@@ -18,7 +18,7 @@ router = APIRouter(tags=["realtime"])
|
||||
|
||||
|
||||
@router.get("/api/realtime/stats")
|
||||
async def realtime_stats(request: Request):
|
||||
def realtime_stats(request: Request):
|
||||
"""Observabilité realtime v6.4.0 : rooms, connexions, ops, merges, conflits.
|
||||
|
||||
Réservé aux utilisateurs authentifiés (données d'activité internes).
|
||||
|
||||
+7
-7
@@ -60,7 +60,7 @@ def _scim_user(row) -> dict:
|
||||
# ── SCIM resources ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/scim/v2/Users")
|
||||
async def scim_list(request: Request):
|
||||
def scim_list(request: Request):
|
||||
_scim_guard(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM users ORDER BY id LIMIT 100").fetchall()
|
||||
@@ -99,7 +99,7 @@ async def scim_create(request: Request):
|
||||
|
||||
|
||||
@router.get("/scim/v2/Users/{user_id}")
|
||||
async def scim_get(user_id: str, request: Request):
|
||||
def scim_get(user_id: str, request: Request):
|
||||
_scim_guard(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
@@ -164,7 +164,7 @@ async def scim_patch(user_id: str, request: Request):
|
||||
|
||||
|
||||
@router.delete("/scim/v2/Users/{user_id}")
|
||||
async def scim_delete(user_id: str, request: Request):
|
||||
def scim_delete(user_id: str, request: Request):
|
||||
_scim_guard(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
@@ -200,7 +200,7 @@ async def create_scim_token(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/scim/tokens")
|
||||
async def list_scim_tokens(request: Request):
|
||||
def list_scim_tokens(request: Request):
|
||||
_admin_session(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, name, created_by, revoked, created_at"
|
||||
@@ -209,7 +209,7 @@ async def list_scim_tokens(request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/v2/scim/tokens/{token_id}")
|
||||
async def revoke_scim_token(token_id: int, request: Request):
|
||||
def revoke_scim_token(token_id: int, request: Request):
|
||||
admin = _admin_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE scim_tokens SET revoked=1 WHERE id=?", (token_id,))
|
||||
@@ -221,7 +221,7 @@ async def revoke_scim_token(token_id: int, request: Request):
|
||||
# ── domain claims ──────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/domain-claims")
|
||||
async def list_domains(request: Request):
|
||||
def list_domains(request: Request):
|
||||
_admin_session(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM domain_claims ORDER BY domain").fetchall()
|
||||
@@ -288,7 +288,7 @@ async def verify_domain(domain_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/v2/domain-claims/{domain_id}")
|
||||
async def delete_domain(domain_id: int, request: Request):
|
||||
def delete_domain(domain_id: int, request: Request):
|
||||
admin = _admin_session(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM domain_claims WHERE id=?", (domain_id,))
|
||||
|
||||
@@ -14,7 +14,7 @@ router = APIRouter(tags=["search"])
|
||||
|
||||
|
||||
@router.get("/api/search")
|
||||
async def search(request: Request, q: str = Query(default="")):
|
||||
def search(request: Request, q: str = Query(default="")):
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
user_id = user.get("id") if user and user.get("id") else None
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ def _auth_user(request: Request) -> dict:
|
||||
|
||||
|
||||
@router.get("/api/v2/search/hybrid")
|
||||
async def hybrid(request: Request):
|
||||
def hybrid(request: Request):
|
||||
user = _auth_user(request)
|
||||
q = (request.query_params.get("q") or request.query_params.get("query") or "").strip()
|
||||
if not q:
|
||||
@@ -81,7 +81,7 @@ async def ask_ai(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/search/index-status")
|
||||
async def index_status(request: Request):
|
||||
def index_status(request: Request):
|
||||
"""How many resources are indexed vs pending (owner/admin visibility)."""
|
||||
user = _auth_user(request)
|
||||
with get_conn() as conn:
|
||||
|
||||
@@ -32,7 +32,7 @@ def _current_user_id(request: Request) -> int:
|
||||
|
||||
|
||||
@router.get("/tokens")
|
||||
async def list_tokens(request: Request):
|
||||
def list_tokens(request: Request):
|
||||
"""List the current user's API tokens (prefix only, no secrets)."""
|
||||
uid = _current_user_id(request)
|
||||
with get_conn() as conn:
|
||||
@@ -63,7 +63,7 @@ async def create_token(request: Request):
|
||||
|
||||
|
||||
@router.delete("/tokens/{token_id:int}")
|
||||
async def revoke_token(token_id: int, request: Request):
|
||||
def revoke_token(token_id: int, request: Request):
|
||||
"""Revoke an API token (soft delete)."""
|
||||
uid = _current_user_id(request)
|
||||
with get_conn() as conn:
|
||||
@@ -81,7 +81,7 @@ async def revoke_token(token_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/sessions")
|
||||
async def list_sessions(request: Request):
|
||||
def list_sessions(request: Request):
|
||||
"""List the current user's active sessions with their devices."""
|
||||
uid = _current_user_id(request)
|
||||
current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", ""))
|
||||
@@ -101,7 +101,7 @@ async def list_sessions(request: Request):
|
||||
|
||||
|
||||
@router.post("/sessions/{sid}/revoke")
|
||||
async def revoke_session(sid: str, request: Request):
|
||||
def revoke_session(sid: str, request: Request):
|
||||
"""Revoke an active session. If it's the current one, the user is logged out."""
|
||||
uid = _current_user_id(request)
|
||||
with get_conn() as conn:
|
||||
|
||||
@@ -204,7 +204,7 @@ async def update_share_permission(page_id: int, share_id: int, request: Request)
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/share/{share_id}")
|
||||
async def remove_share(page_id: int, share_id: int, request: Request):
|
||||
def remove_share(page_id: int, share_id: int, request: Request):
|
||||
"""Remove a share invitation."""
|
||||
_require_auth(request)
|
||||
|
||||
@@ -238,7 +238,7 @@ async def remove_share(page_id: int, share_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/shares")
|
||||
async def list_shares(page_id: int, request: Request):
|
||||
def list_shares(page_id: int, request: Request):
|
||||
"""Get all shares for a page."""
|
||||
_require_auth(request)
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ DEFAULT_CONFIG = {
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_sidebar_config(request: Request):
|
||||
def get_sidebar_config(request: Request):
|
||||
"""Get the current user's sidebar customization config."""
|
||||
user = _get_user(request)
|
||||
with get_conn() as conn:
|
||||
|
||||
+11
-11
@@ -315,7 +315,7 @@ async def create_site(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/sites")
|
||||
async def list_sites(request: Request):
|
||||
def list_sites(request: Request):
|
||||
user = _auth_user(request)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
@@ -333,7 +333,7 @@ async def list_sites(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/sites/{site_id}")
|
||||
async def get_site(site_id: int, request: Request):
|
||||
def get_site(site_id: int, request: Request):
|
||||
user = _auth_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
@@ -407,7 +407,7 @@ async def update_site(site_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/v2/sites/{site_id}")
|
||||
async def delete_site(site_id: int, request: Request):
|
||||
def delete_site(site_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
@@ -422,7 +422,7 @@ async def delete_site(site_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/sites/{site_id}/pages")
|
||||
async def list_site_pages(site_id: int, request: Request):
|
||||
def list_site_pages(site_id: int, request: Request):
|
||||
user = _auth_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
@@ -461,7 +461,7 @@ async def add_site_page(site_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/v2/sites/{site_id}/pages/{page_id}")
|
||||
async def remove_site_page(site_id: int, page_id: int, request: Request):
|
||||
def remove_site_page(site_id: int, page_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
@@ -478,7 +478,7 @@ async def remove_site_page(site_id: int, page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/sites/{site_id}/stats")
|
||||
async def site_stats(site_id: int, request: Request, days: int = 30):
|
||||
def site_stats(site_id: int, request: Request, days: int = 30):
|
||||
user = _auth_user(request)
|
||||
days = max(1, min(int(days or 30), 365))
|
||||
with get_conn() as conn:
|
||||
@@ -516,7 +516,7 @@ def _public_guard(site: dict, request: Request):
|
||||
|
||||
|
||||
@router.get("/s/{slug}", response_class=HTMLResponse)
|
||||
async def public_site_home(request: Request, slug: str):
|
||||
def public_site_home(request: Request, slug: str):
|
||||
with get_conn() as conn:
|
||||
site = _resolve_site(conn, slug=slug,
|
||||
host=request.headers.get("host", ""))
|
||||
@@ -537,7 +537,7 @@ async def public_site_home(request: Request, slug: str):
|
||||
|
||||
|
||||
@router.get("/s/{slug}/sitemap.xml", response_class=PlainTextResponse)
|
||||
async def site_sitemap(request: Request, slug: str):
|
||||
def site_sitemap(request: Request, slug: str):
|
||||
with get_conn() as conn:
|
||||
site = _resolve_site(conn, slug=slug)
|
||||
if not site or _site_expired(site) or site.get("password_hash"):
|
||||
@@ -556,7 +556,7 @@ async def site_sitemap(request: Request, slug: str):
|
||||
|
||||
|
||||
@router.get("/s/{slug}/{page_ref}", response_class=HTMLResponse)
|
||||
async def public_site_page(request: Request, slug: str, page_ref: str):
|
||||
def public_site_page(request: Request, slug: str, page_ref: str):
|
||||
with get_conn() as conn:
|
||||
site = _resolve_site(conn, slug=slug, host=request.headers.get("host", ""))
|
||||
if not site:
|
||||
@@ -614,7 +614,7 @@ async def public_site_auth(request: Request, slug: str):
|
||||
# ── Public Forms ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/collections/{collection_id}/form")
|
||||
async def get_form_config(collection_id: int, request: Request):
|
||||
def get_form_config(collection_id: int, request: Request):
|
||||
_auth_user(request)
|
||||
with get_conn() as conn:
|
||||
info = _form_config(conn, collection_id)
|
||||
@@ -662,7 +662,7 @@ def _collection_props(conn, collection_id: int) -> list[dict]:
|
||||
|
||||
|
||||
@router.get("/f/{token}", response_class=HTMLResponse)
|
||||
async def public_form(request: Request, token: str):
|
||||
def public_form(request: Request, token: str):
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collections").fetchone()
|
||||
|
||||
+3
-3
@@ -106,7 +106,7 @@ def _login_error(message: str, *, cfg: dict | None, identifier: str = "", reques
|
||||
|
||||
|
||||
@router.get("/auth/saml/login")
|
||||
async def saml_login(request: Request, next: str = DEFAULT_NEXT):
|
||||
def saml_login(request: Request, next: str = DEFAULT_NEXT):
|
||||
"""SP-initiated SSO: issue an AuthnRequest and redirect to the IdP."""
|
||||
if not _rate_ok(request, "saml"):
|
||||
return _page("Too many attempts", "<p>Please wait a minute and try again.</p>", status=429)
|
||||
@@ -218,7 +218,7 @@ async def saml_callback(request: Request):
|
||||
|
||||
|
||||
@router.get("/auth/saml/metadata")
|
||||
async def saml_metadata(request: Request):
|
||||
def saml_metadata(request: Request):
|
||||
"""SP metadata XML — paste into the IdP (Azure AD / Okta / Keycloak…)."""
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg or cfg["provider_type"] != "saml":
|
||||
@@ -536,7 +536,7 @@ async def _require_admin(request: Request, *, write: bool) -> dict:
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/providers")
|
||||
async def sso_providers(request: Request):
|
||||
def sso_providers(request: Request):
|
||||
"""Public: what the login page should show (button list + sso_only flag)."""
|
||||
cfg = _sso_config_or_error()
|
||||
if not cfg:
|
||||
|
||||
+1
-1
@@ -82,7 +82,7 @@ async def sync_batch(request: Request, authorization: str | None = Header(defaul
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def sync_status(request: Request, workspace_id: int = Query(default=None),
|
||||
def sync_status(request: Request, workspace_id: int = Query(default=None),
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Synchronization status for the workspace (pending server queue, last sync)."""
|
||||
user = _user(request, authorization, required_scope="read")
|
||||
|
||||
@@ -227,7 +227,7 @@ async def revoke_extension_device(device_id: int, request: Request):
|
||||
# ── HTML: /extensions download page ──
|
||||
|
||||
@router.get("/extensions", response_class=HTMLResponse)
|
||||
async def extensions_page(request: Request):
|
||||
def extensions_page(request: Request):
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
from app.templating import ENV
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ def _session_user(request: Request) -> dict:
|
||||
|
||||
|
||||
@router.post("/register/begin")
|
||||
async def register_begin(request: Request):
|
||||
def register_begin(request: Request):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import generate_registration_options, options_to_json
|
||||
@@ -193,7 +193,7 @@ async def login_finish(request: Request):
|
||||
|
||||
|
||||
@router.get("/keys")
|
||||
async def list_keys(request: Request):
|
||||
def list_keys(request: Request):
|
||||
user = _session_user(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, name, sign_count, created_at FROM webauthn_credentials"
|
||||
@@ -202,7 +202,7 @@ async def list_keys(request: Request):
|
||||
|
||||
|
||||
@router.delete("/keys/{key_id}")
|
||||
async def delete_key(key_id: int, request: Request):
|
||||
def delete_key(key_id: int, request: Request):
|
||||
user = _session_user(request)
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("DELETE FROM webauthn_credentials WHERE id=? AND user_id=?",
|
||||
|
||||
+17
-17
@@ -98,7 +98,7 @@ def _can_verify(user: dict, page: dict) -> bool:
|
||||
# ── teamspaces ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/wiki/teamspaces")
|
||||
async def list_teamspaces(request: Request):
|
||||
def list_teamspaces(request: Request):
|
||||
user = _user(request)
|
||||
wid = request.query_params.get("workspace_id")
|
||||
if wid:
|
||||
@@ -112,7 +112,7 @@ async def list_teamspaces(request: Request):
|
||||
|
||||
|
||||
@router.get("/wiki/teamspaces/{teamspace_id}", response_class=HTMLResponse)
|
||||
async def teamspace_page(teamspace_id: int, request: Request):
|
||||
def teamspace_page(teamspace_id: int, request: Request):
|
||||
"""Teamspace detail HTML page — sidebar entry point."""
|
||||
user = _user(request)
|
||||
ts = _teamspace_or_404(teamspace_id, user["id"])
|
||||
@@ -204,7 +204,7 @@ async def create_teamspace(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}")
|
||||
async def get_teamspace(teamspace_id: int, request: Request):
|
||||
def get_teamspace(teamspace_id: int, request: Request):
|
||||
user = _user(request)
|
||||
ts = _teamspace_or_404(teamspace_id, user["id"])
|
||||
ts["role"] = wiki.get_teamspace_role(user["id"], teamspace_id)
|
||||
@@ -213,7 +213,7 @@ async def get_teamspace(teamspace_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/teamspaces/{teamspace_id}/members")
|
||||
async def list_members(teamspace_id: int, request: Request):
|
||||
def list_members(teamspace_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_teamspace_or_404(teamspace_id, user["id"])
|
||||
with get_conn() as conn:
|
||||
@@ -251,7 +251,7 @@ async def set_member(teamspace_id: int, member_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}")
|
||||
async def remove_member(teamspace_id: int, member_id: int, request: Request):
|
||||
def remove_member(teamspace_id: int, member_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_teamspace_or_404(teamspace_id, user["id"])
|
||||
if not wiki.can_write_teamspace(user["id"], teamspace_id):
|
||||
@@ -268,7 +268,7 @@ async def remove_member(teamspace_id: int, member_id: int, request: Request):
|
||||
# ── verified pages ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/wiki/pages/{page_id}/verification")
|
||||
async def get_verification(page_id: int, request: Request):
|
||||
def get_verification(page_id: int, request: Request):
|
||||
_user(request)
|
||||
_page_or_404(page_id)
|
||||
return {"verification": wiki.verification(page_id)}
|
||||
@@ -292,7 +292,7 @@ async def verify_page(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/v2/wiki/pages/{page_id}/verify")
|
||||
async def unverify_page(page_id: int, request: Request):
|
||||
def unverify_page(page_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_page_or_404(page_id)
|
||||
if not wiki.unverify_page(page_id):
|
||||
@@ -302,7 +302,7 @@ async def unverify_page(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/verified")
|
||||
async def list_verified(request: Request):
|
||||
def list_verified(request: Request):
|
||||
"""Verified (non-expired) pages of a workspace — the ✅ wiki index."""
|
||||
user = _user(request)
|
||||
wid = _workspace_id(request)
|
||||
@@ -331,7 +331,7 @@ async def list_verified(request: Request):
|
||||
# ── follows ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/wiki/pages/{page_id}/follow")
|
||||
async def follow_page(page_id: int, request: Request):
|
||||
def follow_page(page_id: int, request: Request):
|
||||
user = _user(request)
|
||||
_page_or_404(page_id)
|
||||
now = wiki.toggle_follow(page_id, user["id"])
|
||||
@@ -339,7 +339,7 @@ async def follow_page(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/pages/{page_id}/followers")
|
||||
async def list_followers(page_id: int, request: Request):
|
||||
def list_followers(page_id: int, request: Request):
|
||||
_user(request)
|
||||
_page_or_404(page_id)
|
||||
ids = wiki.followers(page_id)
|
||||
@@ -372,7 +372,7 @@ async def react(comment_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/comments/{comment_id}/reactions")
|
||||
async def list_reactions(comment_id: int, request: Request):
|
||||
def list_reactions(comment_id: int, request: Request):
|
||||
_user(request)
|
||||
return {"comment_id": comment_id, "reactions": wiki.reactions(comment_id)}
|
||||
|
||||
@@ -402,7 +402,7 @@ async def create_guest(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/wiki/pages/{page_id}/guests")
|
||||
async def list_guests(page_id: int, request: Request):
|
||||
def list_guests(page_id: int, request: Request):
|
||||
_user(request)
|
||||
_page_or_404(page_id)
|
||||
with get_conn() as conn:
|
||||
@@ -413,7 +413,7 @@ async def list_guests(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/v2/wiki/guests/{share_id}")
|
||||
async def revoke_guest(share_id: int, request: Request):
|
||||
def revoke_guest(share_id: int, request: Request):
|
||||
user = _user(request)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT 1 FROM guest_shares WHERE id=?", (share_id,)).fetchone():
|
||||
@@ -436,7 +436,7 @@ Ask the person who shared it with you for a new link.</p></body></html>"""
|
||||
|
||||
|
||||
@router.get("/g/{token}", response_class=HTMLResponse)
|
||||
async def guest_page(token: str, request: Request):
|
||||
def guest_page(token: str, request: Request):
|
||||
"""Account-less page access (read-only or commenter). 404 if inactive."""
|
||||
share = wiki.resolve_guest_share(token)
|
||||
if not share:
|
||||
@@ -465,7 +465,7 @@ Editing is disabled.</div>
|
||||
# ── page views ─────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/wiki/pages/{page_id}/views")
|
||||
async def page_views(page_id: int, request: Request):
|
||||
def page_views(page_id: int, request: Request):
|
||||
user = _user(request)
|
||||
page = _page_or_404(page_id)
|
||||
if page.get("teamspace_id") and not wiki.can_write_teamspace(user["id"], page["teamspace_id"]):
|
||||
@@ -476,7 +476,7 @@ async def page_views(page_id: int, request: Request):
|
||||
# ── wiki home ──────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/v2/wiki/home")
|
||||
async def wiki_home(request: Request):
|
||||
def wiki_home(request: Request):
|
||||
"""Aggregated knowledge home: verified pages + recents + teamspaces."""
|
||||
user = _user(request)
|
||||
wid = _workspace_id(request)
|
||||
@@ -500,7 +500,7 @@ async def wiki_home(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/wiki/verify-expiry-sweep")
|
||||
async def sweep_expiry(request: Request):
|
||||
def sweep_expiry(request: Request):
|
||||
"""Notify verifiers whose ✅ expires within 7 days (idempotent-ish job)."""
|
||||
user = _user(request)
|
||||
with get_conn() as conn:
|
||||
|
||||
@@ -72,7 +72,7 @@ async def create_worker(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/workers")
|
||||
async def list_workers(request: Request):
|
||||
def list_workers(request: Request):
|
||||
_auth_user(request)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
@@ -87,7 +87,7 @@ async def list_workers(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/workers/{worker_id}")
|
||||
async def get_worker(worker_id: int, request: Request):
|
||||
def get_worker(worker_id: int, request: Request):
|
||||
user = _auth_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
@@ -146,7 +146,7 @@ async def update_worker(worker_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/v2/workers/{worker_id}")
|
||||
async def delete_worker(worker_id: int, request: Request):
|
||||
def delete_worker(worker_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
@@ -186,7 +186,7 @@ async def run_worker_endpoint(worker_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/workers/{worker_id}/runs")
|
||||
async def worker_runs(worker_id: int, request: Request):
|
||||
def worker_runs(worker_id: int, request: Request):
|
||||
_auth_user(request)
|
||||
limit, _offset = parse_pagination(request, default_limit=20)
|
||||
with get_conn() as conn:
|
||||
@@ -199,7 +199,7 @@ async def worker_runs(worker_id: int, request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/workers/{worker_id}/fork")
|
||||
async def fork_worker_endpoint(worker_id: int, request: Request):
|
||||
def fork_worker_endpoint(worker_id: int, request: Request):
|
||||
user = _auth_user(request, require_write=True)
|
||||
out = worker_service.fork_worker(worker_id, user["id"])
|
||||
audit_log(user, "worker.fork", "worker", worker_id, "", request)
|
||||
@@ -207,7 +207,7 @@ async def fork_worker_endpoint(worker_id: int, request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/workers-usage")
|
||||
async def workers_usage(request: Request):
|
||||
def workers_usage(request: Request):
|
||||
user = _auth_user(request)
|
||||
ws_raw = request.query_params.get("workspace_id")
|
||||
wid = int(ws_raw) if ws_raw and str(ws_raw).isdigit() else None
|
||||
|
||||
+19
-19
@@ -79,7 +79,7 @@ async def create_workspace(request: Request):
|
||||
# ── Members ──
|
||||
|
||||
@router.get("/{ws_id}/members")
|
||||
async def list_members(request: Request, ws_id: int):
|
||||
def list_members(request: Request, ws_id: int):
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
with get_conn() as conn:
|
||||
@@ -122,7 +122,7 @@ async def update_member_role(request: Request, ws_id: int, user_id: int):
|
||||
|
||||
|
||||
@router.delete("/{ws_id}/members/{user_id}")
|
||||
async def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
_require_ws_admin(request, ws_id)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id))
|
||||
@@ -133,7 +133,7 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
|
||||
# ── Comments ──
|
||||
|
||||
@router.get("/pages/{page_id}/comments")
|
||||
async def list_comments(request: Request, page_id: int):
|
||||
def list_comments(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT c.*, u.login, u.avatar_url FROM comments c JOIN users u ON c.user_id=u.id WHERE c.page_id=? ORDER BY c.created_at",
|
||||
@@ -187,7 +187,7 @@ async def update_comment(request: Request, comment_id: int):
|
||||
# ── Page History ──
|
||||
|
||||
@router.get("/pages/{page_id}/history")
|
||||
async def page_history(request: Request, page_id: int):
|
||||
def page_history(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM page_history WHERE page_id=? ORDER BY created_at DESC LIMIT 50",
|
||||
@@ -214,7 +214,7 @@ async def record_history(request: Request, page_id: int):
|
||||
# ── Favorites ──
|
||||
|
||||
@router.get("/favorites")
|
||||
async def list_favorites(request: Request):
|
||||
def list_favorites(request: Request):
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
with get_conn() as conn:
|
||||
@@ -251,7 +251,7 @@ async def add_favorite(request: Request):
|
||||
|
||||
|
||||
@router.delete("/favorites/{fav_id}")
|
||||
async def remove_favorite(request: Request, fav_id: int):
|
||||
def remove_favorite(request: Request, fav_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,))
|
||||
conn.commit()
|
||||
@@ -261,7 +261,7 @@ async def remove_favorite(request: Request, fav_id: int):
|
||||
# ── Templates ──
|
||||
|
||||
@router.get("/templates/database")
|
||||
async def list_db_templates(request: Request):
|
||||
def list_db_templates(request: Request):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall()
|
||||
return {"templates": [dict(r) for r in rows]}
|
||||
@@ -296,7 +296,7 @@ async def apply_db_template(request: Request, tid: int):
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/templates/page")
|
||||
async def list_page_templates(request: Request, collection_id: int):
|
||||
def list_page_templates(request: Request, collection_id: int):
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM page_templates WHERE collection_id=? ORDER BY name", (collection_id,)
|
||||
@@ -369,7 +369,7 @@ async def update_page_template(request: Request, collection_id: int, tid: int):
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/templates/page/{tid}")
|
||||
async def delete_page_template(request: Request, collection_id: int, tid: int):
|
||||
def delete_page_template(request: Request, collection_id: int, tid: int):
|
||||
"""Delete a page template."""
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute(
|
||||
@@ -385,7 +385,7 @@ async def delete_page_template(request: Request, collection_id: int, tid: int):
|
||||
# ── v4.2.0: Dashboards ──
|
||||
|
||||
@router.get("/collections/{collection_id}/dashboards")
|
||||
async def list_dashboards(request: Request, collection_id: int):
|
||||
def list_dashboards(request: Request, collection_id: int):
|
||||
"""List all dashboards for a collection."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -436,7 +436,7 @@ async def update_dashboard(request: Request, collection_id: int, did: int):
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/dashboards/{did}")
|
||||
async def delete_dashboard(request: Request, collection_id: int, did: int):
|
||||
def delete_dashboard(request: Request, collection_id: int, did: int):
|
||||
"""Delete a dashboard."""
|
||||
with get_conn() as conn:
|
||||
dash = conn.execute(
|
||||
@@ -452,7 +452,7 @@ async def delete_dashboard(request: Request, collection_id: int, did: int):
|
||||
# ── v4.5.0: Sprints ──
|
||||
|
||||
@router.get("/collections/{collection_id}/sprints")
|
||||
async def list_sprints(request: Request, collection_id: int):
|
||||
def list_sprints(request: Request, collection_id: int):
|
||||
"""List all sprints for a collection."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -528,7 +528,7 @@ async def update_sprint(request: Request, collection_id: int, sid: int):
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/sprints/{sid}")
|
||||
async def delete_sprint(request: Request, collection_id: int, sid: int):
|
||||
def delete_sprint(request: Request, collection_id: int, sid: int):
|
||||
"""Delete a sprint."""
|
||||
with get_conn() as conn:
|
||||
sprint = conn.execute(
|
||||
@@ -572,7 +572,7 @@ async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/sprints/{sid}/assign/{page_id}")
|
||||
async def remove_page_from_sprint(request: Request, collection_id: int, sid: int, page_id: int):
|
||||
def remove_page_from_sprint(request: Request, collection_id: int, sid: int, page_id: int):
|
||||
"""Remove a page from a sprint."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -586,7 +586,7 @@ async def remove_page_from_sprint(request: Request, collection_id: int, sid: int
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/sprints/burndown/{sid}")
|
||||
async def sprint_burndown(request: Request, collection_id: int, sid: int):
|
||||
def sprint_burndown(request: Request, collection_id: int, sid: int):
|
||||
"""Calculate burndown data for a sprint."""
|
||||
with get_conn() as conn:
|
||||
sprint = conn.execute(
|
||||
@@ -660,7 +660,7 @@ async def import_csv(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/export/csv")
|
||||
async def export_csv(request: Request, collection_id: int):
|
||||
def export_csv(request: Request, collection_id: int):
|
||||
with get_conn() as conn:
|
||||
pages = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)
|
||||
@@ -690,7 +690,7 @@ async def export_csv(request: Request, collection_id: int):
|
||||
# ── Webhooks Outbound Management ──
|
||||
|
||||
@router.get("/webhooks")
|
||||
async def list_webhooks(request: Request):
|
||||
def list_webhooks(request: Request):
|
||||
_require_admin(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall()
|
||||
@@ -723,7 +723,7 @@ async def create_webhook(request: Request):
|
||||
|
||||
|
||||
@router.delete("/webhooks/{wh_id}")
|
||||
async def delete_webhook(request: Request, wh_id: int):
|
||||
def delete_webhook(request: Request, wh_id: int):
|
||||
_require_admin(request)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (wh_id,))
|
||||
@@ -734,7 +734,7 @@ async def delete_webhook(request: Request, wh_id: int):
|
||||
# ── Public Sharing ──
|
||||
|
||||
@router.get("/public/{collection_id}")
|
||||
async def public_view(request: Request, collection_id: int):
|
||||
def public_view(request: Request, collection_id: int):
|
||||
"""Simple public read-only view — no auth required.
|
||||
|
||||
A18 : les bases ``restricted``/``private`` (``permission_type``) restent
|
||||
|
||||
@@ -355,6 +355,19 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict)
|
||||
return {"status": "error", "detail": str(exc)}
|
||||
|
||||
|
||||
def run_event_sync(coro, timeout: float = 60.0):
|
||||
"""A21 phase 2b : exécute une coroutine d'événement depuis un handler synchrone.
|
||||
|
||||
Bloque le worker threadpool (jamais la boucle d'event) et attend la fin —
|
||||
déterministe, exactement ce que faisait l'await avant la conversion des
|
||||
routes en `def`.
|
||||
ponytail: les clients httpx des services sont créés à chaque appel (aucun
|
||||
lien de boucle) ; si un jour un client/queue est lié à la boucle de l'app,
|
||||
passer à `asyncio.run_coroutine_threadsafe` + boucle capturée au lifespan.
|
||||
"""
|
||||
return asyncio.run(asyncio.wait_for(coro, timeout))
|
||||
|
||||
|
||||
async def fire_event(event: str, payload: dict):
|
||||
"""Dispatch an event to outbound webhooks and matching automations."""
|
||||
# v7.3.0: page.updated → in-app notification to followers (throttled).
|
||||
|
||||
@@ -15,172 +15,7 @@
|
||||
open, openTab, peek, folder, rename, setIcon, duplicate, link, download,
|
||||
copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove
|
||||
############################################################################}
|
||||
<script data-cfasync="false">
|
||||
/* ═════════════════════════════════════════════════════════════════════
|
||||
fdCtx — store Alpine UNIQUE du menu contextuel partagé.
|
||||
Enregistré dans 'alpine:init' avec garde d'exécution : que la page
|
||||
inclue ce partial dans base.html ou directement, le js ne s'exécute
|
||||
qu'une seule fois. Chaque page, à l'ouverture du menu, positionne :
|
||||
fdCtx.openMenu(evt, node, pageHash, handlers)
|
||||
─────────────────────────────────────────────────────────────────── */
|
||||
(function () {
|
||||
if (window.__fdCtxMenuRegistered) return;
|
||||
window.__fdCtxMenuRegistered = true;
|
||||
|
||||
// Sur un chargement complet de page, ce script inline s'exécute AVANT
|
||||
// alpine.min.js (defer) → Alpine n'existe pas encore : on attend 'alpine:init'.
|
||||
// Sur une navigation partielle (fdNavigate → htmx), Alpine est déjà démarré et
|
||||
// 'alpine:init' ne sera plus jamais émis → on enregistre le store tout de suite,
|
||||
// sinon le menu contextuel reste mort jusqu'au prochain chargement complet.
|
||||
function registerFdCtx() {
|
||||
if (!window.Alpine) return;
|
||||
if (window.Alpine.__fdCtxStore) return;
|
||||
window.Alpine.__fdCtxStore = true;
|
||||
|
||||
Alpine.store('fdCtx', {
|
||||
open: false, x: 0, y: 0, node: null, page: null,
|
||||
maxH: 0, _cx: 0, _cy: 0, _ro: null,
|
||||
handlers: {},
|
||||
/* Tags (workspace) */
|
||||
availTags: [], tagAdding: false, tagExistingOpen: false,
|
||||
newTagColor: '#787774',
|
||||
tagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
|
||||
/* Icon picker */
|
||||
iconOpen: false,
|
||||
iconChoices: ['📄','📝','📕','📁','⭐','🔖','📌','✅','💡','🔥','🚀','🎯','📊','🗓️','🔗','🧩','📎','🎨','🐛','⚙️','❤️','👍','✨','🏷️','🗒️','📚'],
|
||||
|
||||
/* Positionne le menu à l'écran et expose les handlers de la page.
|
||||
⚠️ Nom : openMenu (et PAS open) — « open » est réservé au booléen
|
||||
d'état x-show. Une collision ici rendrait le menu PERMANENT. */
|
||||
openMenu(ev, node, pageHash, handlers) {
|
||||
handlers = handlers || {};
|
||||
this.node = node;
|
||||
this.page = pageHash;
|
||||
this.handlers = handlers;
|
||||
this.tagAdding = false;
|
||||
this.tagExistingOpen = false;
|
||||
this.iconOpen = false;
|
||||
this.newTagColor = (node && node.tags && node.tags[0] && node.tags[0].color) || '#787774';
|
||||
var cx = (ev && ev.clientX) || 0;
|
||||
var cy = (ev && ev.clientY) || 0;
|
||||
this._cx = cx;
|
||||
this._cy = cy;
|
||||
this.x = Math.max(8, Math.min(cx, window.innerWidth - 240));
|
||||
this.y = Math.max(8, Math.min(cy, window.innerHeight - 48));
|
||||
this.open = true;
|
||||
var self = this;
|
||||
/* Mesure la taille réelle du menu (une fois rendu) puis le replace
|
||||
pour qu'il reste TOUJOURS entièrement visible dans le viewport.
|
||||
Un ResizeObserver relance le placement à chaque fois que le menu
|
||||
change de taille (ouverture d'un sous-menu « tag », icônes, …),
|
||||
sinon il grandissait vers le bas et sortait de l'écran. */
|
||||
var after = function () {
|
||||
self._place();
|
||||
var el = document.querySelector('.fd-ctx-menu');
|
||||
if (el && window.ResizeObserver) {
|
||||
if (self._ro) { try { self._ro.disconnect(); } catch (e) {} }
|
||||
self._ro = new ResizeObserver(function () { self._place(); });
|
||||
self._ro.observe(el);
|
||||
}
|
||||
};
|
||||
if (window.Alpine && window.Alpine.nextTick) window.Alpine.nextTick(after);
|
||||
else setTimeout(after, 0);
|
||||
},
|
||||
/* Reclasse le menu dans le viewport et limite sa hauteur à l'espace
|
||||
disponible sous son ancre (le contenu déborde en scroll interne). */
|
||||
_place() {
|
||||
var el = document.querySelector('.fd-ctx-menu');
|
||||
if (!el) return;
|
||||
var prev = el.style.maxHeight;
|
||||
el.style.maxHeight = 'none';
|
||||
var w = el.offsetWidth || 240;
|
||||
var h = el.offsetHeight || 320;
|
||||
el.style.maxHeight = prev || '';
|
||||
var vw = window.innerWidth, vh = window.innerHeight;
|
||||
var cx = (this._cx == null ? this.x : this._cx);
|
||||
var cy = (this._cy == null ? this.y : this._cy);
|
||||
var nx = cx;
|
||||
if (nx + w > vw - 8) nx = vw - w - 8;
|
||||
nx = Math.max(8, nx);
|
||||
var ny = cy;
|
||||
if (ny + h > vh - 8) {
|
||||
if (cy - h >= 8) ny = cy - h;
|
||||
else ny = Math.max(8, vh - h - 8);
|
||||
}
|
||||
this.x = nx;
|
||||
this.y = ny;
|
||||
this.maxH = Math.max(120, vh - ny - 8);
|
||||
},
|
||||
close() {
|
||||
if (this._ro) { try { this._ro.disconnect(); } catch (e) {} this._ro = null; }
|
||||
this.open = false;
|
||||
this.node = null;
|
||||
this.handlers = {};
|
||||
this.tagAdding = false;
|
||||
this.tagExistingOpen = false;
|
||||
this.iconOpen = false;
|
||||
},
|
||||
|
||||
/* Un item est-il disponible pour CETTE page ? (union vs capacités) */
|
||||
has(key) { return typeof this.handlers[key] === 'function'; },
|
||||
|
||||
/* Exécute l'action → handler fourni par la page courante. */
|
||||
run(key) {
|
||||
if (!this.node) { this.close(); return; }
|
||||
var fn = this.handlers[key];
|
||||
if (fn) try { fn(this.node); } catch (e) { console.error('fdCtx.run', e); }
|
||||
this.close();
|
||||
},
|
||||
|
||||
/* ── Tags ── */
|
||||
avail() { return this.availTags || []; },
|
||||
setAvail(a) { this.availTags = a || []; },
|
||||
removeTag(id) {
|
||||
var fn = this.handlers.tagRemove;
|
||||
if (fn) try { fn(id); } catch (e) { console.error('fdCtx.removeTag', e); }
|
||||
},
|
||||
addExistingTag(t) {
|
||||
var fn = this.handlers.tagExisting;
|
||||
if (fn) try { fn(t); } catch (e) { console.error('fdCtx.addExistingTag', e); }
|
||||
this.tagExistingOpen = false;
|
||||
},
|
||||
addNewTag(name, color) {
|
||||
name = (name || '').trim();
|
||||
if (!name) return;
|
||||
var fn = this.handlers.tagAdd;
|
||||
if (fn) try { fn(name, color || this.newTagColor); } catch (e) { console.error('fdCtx.addNewTag', e); }
|
||||
this.tagAdding = false;
|
||||
},
|
||||
|
||||
/* ── Icon picker ── */
|
||||
setIcon(icon) {
|
||||
icon = (icon || '').trim();
|
||||
var fn = this.handlers.setIcon;
|
||||
if (fn) try { fn(icon); } catch (e) { console.error('fdCtx.setIcon', e); }
|
||||
this.close();
|
||||
},
|
||||
openIconPicker() {
|
||||
var fn = this.handlers.setIcon;
|
||||
if (!fn) return;
|
||||
if (!window.FDIconPicker) return;
|
||||
window.FDIconPicker.openFor({
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
onPick: fn,
|
||||
onRemove: function () { fn(''); }
|
||||
});
|
||||
this.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (window.Alpine) {
|
||||
registerFdCtx();
|
||||
} else {
|
||||
document.addEventListener('alpine:init', registerFdCtx);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/_ctx_menu.js?v={{ asset_version }}"></script>
|
||||
|
||||
<style>
|
||||
.fd-ctx-menu{position:fixed !important;top:0;left:0;min-width:230px;max-width:280px;max-height:calc(100vh - 16px);overflow-y:auto;z-index:2000;padding:4px;}
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
.db-list-title{flex:1;min-width:120px}
|
||||
.db-list-cell{color:var(--text-secondary);font-size:12px;min-width:110px}
|
||||
</style>
|
||||
<script data-cfasync="false">
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
<script type="application/json" id="fd-breadcrumb-data">{{ {"workspace_id": nav_workspace_id|default(0), "crumbs": ns.items}|tojson }}</script>
|
||||
<script type="application/json" id="fd-breadcrumb-data" nonce="{{ csp_nonce() }}">{{ {"workspace_id": nav_workspace_id|default(0), "crumbs": ns.items}|tojson }}</script>
|
||||
|
||||
<div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()"
|
||||
@mouseleave="dragCloseTimer()">
|
||||
@@ -142,7 +142,7 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script data-cfasync="false">
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
document.addEventListener('alpine:init', function () {
|
||||
if (window.Alpine && window.Alpine.__fdBreadcrumbRegistered) return;
|
||||
if (window.Alpine) window.Alpine.__fdBreadcrumbRegistered = true;
|
||||
|
||||
@@ -10,306 +10,9 @@
|
||||
############################################################################}
|
||||
{% set picker_icons = ['folder','file','calendar','clock','star','bot','users','globe','lock','book','check-square','trash','help-circle','settings','refresh','log-out','message-square','home','search','link','plus','bell','image','download','list','bar-chart','grid','align-left','corner-down-right','copy','key','inbox','edit','eye','share','x','paperclip','external-link','sparkles','lightbulb','tag','file-text','save','upload','trending-up','zap','alert-triangle','user'] %}
|
||||
|
||||
<script data-cfasync="false">
|
||||
/* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */
|
||||
(function () {
|
||||
var FD_ICONS = {
|
||||
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
|
||||
'file': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',
|
||||
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
|
||||
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
|
||||
'bot': '<rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v3"/><line x1="12" y1="20" x2="12" y2="24"/><circle cx="8" cy="13" r="1"/><circle cx="16" cy="13" r="1"/>',
|
||||
'users': '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
|
||||
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
'book': '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>',
|
||||
'check-square': '<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
|
||||
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
|
||||
'help-circle': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
|
||||
'refresh': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
|
||||
'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
|
||||
'message-square': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
||||
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
||||
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
|
||||
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||
'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
|
||||
'bell': '<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
|
||||
'image': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
|
||||
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
|
||||
'chevron-left': '<polyline points="15 18 9 12 15 6"/>',
|
||||
'chevron-right': '<polyline points="9 18 15 12 9 6"/>',
|
||||
'list': '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
|
||||
'bar-chart': '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
|
||||
'grid': '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
|
||||
'align-left': '<line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="17" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/>',
|
||||
'corner-down-right': '<polyline points="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/>',
|
||||
'copy': '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
||||
'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
|
||||
'key': '<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
|
||||
'inbox': '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
|
||||
'edit': '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>',
|
||||
'eye-off': '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/>',
|
||||
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
|
||||
'share': '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>',
|
||||
'more-horizontal': '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
|
||||
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
||||
'paperclip': '<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
|
||||
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
|
||||
'sparkles': '<path d="M12 3l1.9 5.8 6.1.5-4.9 3.6 1.7 5.8-4.8-3.5-4.8 3.5 1.7-5.8-4.9-3.6 6.1-.5z"/>',
|
||||
'lightbulb': '<path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/>',
|
||||
'tag': '<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>',
|
||||
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
|
||||
'save': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
|
||||
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
|
||||
'trending-up': '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
|
||||
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
||||
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||
'user': '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>'
|
||||
};
|
||||
window.FD_ICONS = FD_ICONS;
|
||||
window.fd_icon = function (name, size) {
|
||||
size = size || 18;
|
||||
var inner = FD_ICONS[name];
|
||||
if (inner) return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + inner + '</svg>';
|
||||
return (window.getSvgIcon ? getSvgIcon(name, size) : '');
|
||||
};
|
||||
/* Render a page_icon value: image URL, known icon name, or emoji text. */
|
||||
window.fdIconHtml = function (value, size) {
|
||||
size = size || 16;
|
||||
var v = (value == null ? '' : String(value)).trim();
|
||||
if (!v) return '';
|
||||
if (v.charAt(0) === '/' || v.slice(0, 4) === 'http') {
|
||||
return '<img src="' + v.replace(/"/g, '"') + '" alt="" style="width:' + size + 'px;height:' + size + 'px;object-fit:contain;vertical-align:middle;display:inline-block;">';
|
||||
}
|
||||
if (FD_ICONS[v] || (window.getSvgIcon && getSvgIcon(v, size))) return window.fd_icon(v, size);
|
||||
return v;
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/_icon_picker_1.js?v={{ asset_version }}"></script>
|
||||
|
||||
<script data-cfasync="false">
|
||||
(function () {
|
||||
if (window.__fdIconPickerRegistered) return;
|
||||
window.__fdIconPickerRegistered = true;
|
||||
|
||||
var TONES = ['', '\u{1F3FB}', '\u{1F3FC}', '\u{1F3FD}', '\u{1F3FE}', '\u{1F3FF}'];
|
||||
|
||||
var TONEABLE = {};
|
||||
['👋','🤚','🖐️','✋','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘','🤙','👈','👉','👆','👇','☝️','👍','👎','✊','👊','🤛','🤜','👏','🙌','🫶','👐','🤲','🤝','🙏','💪','🦵','🦶','👂','👃','👶','🧒','👦','👧','🧑','👨','👩','🧓','👴','👵','🙍','🙎','🙅','🙆','💁','🙋','🧏','🙇','🤦','🤷','👮','🕵️','💂','👷','🤴','👸','👳','👲','🧕','🤵','👰','🤰','🤱','👼','🎅','🤶','🦸','🦹','🧙','🧚','🧛','🧜','🧝','💆','💇','🚶','🧍','🧎','🏃','💃','🕺','👯','🧖','🧗','🏇','⛷️','🏂','🏌️','🏄','🚣','🏊','⛹️','🏋️','🚴','🚵','🤸','🤼','🤽','🤾','🤹','🧘','🛀','🛌'].forEach(function (e) { TONEABLE[e] = true; });
|
||||
|
||||
var CATS = [
|
||||
{ id: 'people', label: 'People', items: [
|
||||
['😀','grinning face smile happy'],['😃','smiley happy'],['😄','smile laugh happy'],['😁','grin happy'],['😆','laughing happy'],['😅','sweat smile'],['🤣','rofl rolling laugh'],['😂','joy tears laugh'],['🙂','slight smile'],['🙃','upside down'],['😉','wink'],['😊','blush smile happy'],['😇','innocent halo angel'],['🥰','love hearts'],['😍','heart eyes love'],['🤩','star struck wow'],['😘','kiss love'],['😗','kissing'],['😚','kissing'],['😙','kissing'],['🥲','tear smile happy'],['😋','yum tasty'],['😛','tongue'],['😜','wink tongue'],['🤪','crazy zany'],['😝','tongue'],['🤑','money rich'],['🤗','hug'],['🤭','giggle'],['🤫','shush quiet'],['🤔','thinking'],['🤐','zip quiet'],['🤨','raised eyebrow'],['😐','neutral'],['😑','expressionless'],['😶','no mouth'],['😏','smirk'],['😒','unamused'],['🙄','roll eyes'],['😬','grimace'],['🤥','lying'],['😌','relieved'],['😔','pensive sad'],['😪','sleepy'],['🤤','drool'],['😴','sleeping'],['😷','mask sick'],['🤒','sick thermometer'],['🤕','hurt bandage'],['🤢','nauseated'],['🤮','vomit'],['🤧','sneeze'],['🥵','hot'],['🥶','cold'],['🥴','woozy'],['😵','dizzy'],['🤯','mind blown'],['🤠','cowboy'],['🥳','party'],['🥺','pleading'],['😎','cool sunglasses'],['🤓','nerd'],['🧐','monocle'],['😕','confused'],['😟','worried'],['🙁','frown'],['☹️','frown sad'],['😮','surprised'],['😯','hushed'],['😲','astonished'],['😳','flushed'],['😨','fearful'],['😰','anxious'],['😥','sad'],['😢','cry'],['😭','sob cry'],['😱','scream fear'],['😖','confounded'],['😣','persevere'],['😞','disappointed'],['😓','sweat'],['😩','weary'],['😫','tired'],['🥱','yawn'],['😤','triumph'],['😡','angry'],['😠','rage'],['🤬','cursing'],['😈','devil'],['👿','imp'],['💀','skull'],['💩','poop'],['🤡','clown'],['👻','ghost'],['👽','alien'],['🤖','robot'],['😺','cat'],['🙈','monkey see'],['🙉','monkey hear'],['🙊','monkey speak'],['👋','wave hand'],['🤚','raised hand'],['🖐️','hand'],['✋','raised hand'],['🖖','vulcan'],['👌','ok'],['🤌','pinched'],['🤏','pinch'],['✌️','peace'],['🤞','cross fingers luck'],['🤟','love you'],['🤘','rock'],['🤙','call me'],['👈','point left'],['👉','point right'],['👆','point up'],['👇','point down'],['☝️','point up'],['👍','thumbs up like'],['👎','thumbs down dislike'],['✊','fist'],['👊','fist bump'],['🤛','fist'],['🤜','fist'],['👏','clap'],['🙌','raise hands celebrate'],['🫶','heart hands'],['👐','open hands'],['🤲','palms'],['🤝','handshake'],['🙏','pray thanks'],['💪','muscle strong'],['👂','ear'],['👃','nose'],['👀','eyes look'],['👁️','eye'],['🧠','brain'],['👶','baby'],['🧒','child'],['👦','boy'],['👧','girl'],['🧑','person'],['👨','man'],['👩','woman'],['🧓','older person'],['👴','old man'],['👵','old woman'],['👮','police'],['🕵️','detective'],['💂','guard'],['👷','worker'],['🤴','prince'],['👸','princess'],['👳','turban'],['🧕','hijab'],['🤵','tuxedo'],['👰','bride'],['🤰','pregnant'],['🤱','breastfeeding'],['👼','angel'],['🎅','santa'],['🤶','mrs claus'],['🦸','superhero'],['🦹','supervillain'],['🧙','mage wizard'],['🧚','fairy'],['🧛','vampire'],['🧜','mermaid'],['🧝','elf'],['💆','massage'],['💇','haircut'],['🚶','walk'],['🏃','run'],['💃','dance'],['🕺','dance'],['👯','people dancing'],['🧗','climb'],['🏇','horse race'],['🏂','snowboard'],['🏄','surf'],['🚣','row'],['🏊','swim'],['🚴','bike'],['🚵','mountain bike'],['🤸','cartwheel'],['🤼','wrestle'],['🤽','water polo'],['🤾','handball'],['🤹','juggle'],['🧘','meditate yoga'],['🛌','sleeping bed'],['💋','kiss mark'],['💌','love letter'],['❤️','heart love red'],['🧡','orange heart'],['💛','yellow heart'],['💚','green heart'],['💙','blue heart'],['💜','purple heart'],['🖤','black heart'],['🤍','white heart'],['🤎','brown heart'],['💔','broken heart'],['❣️','heart exclamation'],['💕','two hearts'],['💞','revolving hearts'],['💓','beating heart'],['💗','growing heart'],['💖','sparkling heart'],['💘','cupid heart'],['💝','heart gift'],['✨','sparkles'],['⭐','star'],['🌟','glowing star'],['💫','dizzy star'],['💥','boom'],['💯','hundred perfect']
|
||||
]},
|
||||
{ id: 'nature', label: 'Nature', items: [
|
||||
['🐶','dog'],['🐱','cat'],['🐭','mouse'],['🐹','hamster'],['🐰','rabbit'],['🦊','fox'],['🐻','bear'],['🐼','panda'],['🐨','koala'],['🐯','tiger'],['🦁','lion'],['🐮','cow'],['🐷','pig'],['🐸','frog'],['🐵','monkey'],['🐔','chicken'],['🐧','penguin'],['🐦','bird'],['🐤','chick'],['🦆','duck'],['🦅','eagle'],['🦉','owl'],['🦇','bat'],['🐺','wolf'],['🐗','boar'],['🐴','horse'],['🦄','unicorn'],['🐝','bee'],['🐛','bug'],['🦋','butterfly'],['🐌','snail'],['🐞','ladybug'],['🐜','ant'],['🦗','cricket'],['🕷️','spider'],['🦂','scorpion'],['🐢','turtle'],['🐍','snake'],['🦎','lizard'],['🦖','dinosaur'],['🐙','octopus'],['🦑','squid'],['🦐','shrimp'],['🦀','crab'],['🐡','fish'],['🐠','fish'],['🐟','fish'],['🐬','dolphin'],['🐳','whale'],['🦈','shark'],['🐊','crocodile'],['🌵','cactus'],['🎄','tree christmas'],['🌲','tree evergreen'],['🌳','tree'],['🌴','palm tree'],['🌱','seedling plant'],['🌿','herb leaf'],['☘️','shamrock'],['🍀','clover luck'],['🎍','bamboo'],['🌾','wheat'],['🌷','tulip flower'],['🌹','rose flower'],['🌺','hibiscus flower'],['🌸','cherry blossom'],['🌼','flower'],['🌻','sunflower'],['🌞','sun'],['🌝','moon'],['🌚','moon'],['🌙','moon crescent'],['⭐','star'],['🌟','star'],['☀️','sun sunny'],['⛅','cloud sun'],['☁️','cloud'],['🌧️','rain'],['⛈️','storm'],['🌩️','lightning'],['❄️','snowflake'],['☃️','snowman'],['⛄','snowman'],['🔥','fire'],['💧','droplet water'],['🌊','wave water'],['🌈','rainbow'],['🌍','earth globe'],['🌎','earth globe'],['🌏','earth globe']
|
||||
]},
|
||||
{ id: 'food', label: 'Food', items: [
|
||||
['🍏','apple green'],['🍎','apple red'],['🍐','pear'],['🍊','orange tangerine'],['🍋','lemon'],['🍌','banana'],['🍉','watermelon'],['🍇','grapes'],['🍓','strawberry'],['🫐','blueberry'],['🍈','melon'],['🍒','cherry'],['🍑','peach'],['🥭','mango'],['🍍','pineapple'],['🥥','coconut'],['🥝','kiwi'],['🍅','tomato'],['🍆','eggplant'],['🥑','avocado'],['🥦','broccoli'],['🥬','lettuce'],['🥒','cucumber'],['🌶️','pepper hot'],['🌽','corn'],['🥕','carrot'],['🧄','garlic'],['🧅','onion'],['🥔','potato'],['🍠','sweet potato'],['🥐','croissant'],['🥯','bagel'],['🍞','bread'],['🥖','baguette'],['🧀','cheese'],['🥚','egg'],['🍳','cooking egg'],['🥓','bacon'],['🥩','meat steak'],['🍗','chicken leg'],['🍖','meat'],['🌭','hot dog'],['🍔','burger'],['🍟','fries'],['🍕','pizza'],['🥪','sandwich'],['🥙','pita'],['🌮','taco'],['🌯','burrito'],['🥗','salad'],['🍝','pasta spaghetti'],['🍜','ramen noodles'],['🍲','stew'],['🍛','curry rice'],['🍣','sushi'],['🍱','bento'],['🥟','dumpling'],['🍤','shrimp fried'],['🍙','rice ball'],['🍚','rice'],['🍘','rice cracker'],['🍥','fish cake'],['🥠','fortune cookie'],['🍢','oden'],['🍡','dango'],['🍧','shaved ice'],['🍨','ice cream'],['🍦','ice cream'],['🥧','pie'],['🧁','cupcake'],['🍰','cake'],['🎂','birthday cake'],['🍮','custard'],['🍭','lollipop'],['🍬','candy'],['🍫','chocolate'],['🍿','popcorn'],['🍩','donut'],['🍪','cookie'],['☕','coffee'],['🍵','tea'],['🧃','juice'],['🥤','cup drink'],['🍺','beer'],['🍻','beers cheers'],['🥂','champagne'],['🍷','wine'],['🥃','whiskey'],['🍸','cocktail'],['🍹','tropical drink'],['🧉','mate'],['🍾','champagne bottle']
|
||||
]},
|
||||
{ id: 'activity', label: 'Activity', items: [
|
||||
['⚽','soccer football'],['🏀','basketball'],['🏈','football'],['⚾','baseball'],['🥎','softball'],['🎾','tennis'],['🏐','volleyball'],['🏉','rugby'],['🥏','frisbee'],['🎱','pool billiards'],['🪀','yo-yo'],['🏓','ping pong'],['🏸','badminton'],['🏒','hockey'],['🏑','field hockey'],['🥍','lacrosse'],['🏏','cricket'],['🥅','goal'],['⛳','golf'],['🏹','archery'],['🎣','fishing'],['🥊','boxing'],['🥋','martial arts'],['🎽','running shirt'],['🛹','skateboard'],['🛼','roller skate'],['🛷','sled'],['⛸️','ice skate'],['🥌','curling'],['🎿','ski'],['⛷️','ski'],['🏂','snowboard'],['🏋️','weight lift'],['🤼','wrestle'],['🤸','cartwheel'],['⛹️','basketball'],['🤺','fencing'],['🤾','handball'],['🏌️','golf'],['🏇','horse race'],['🧘','yoga meditate'],['🏄','surf'],['🏊','swim'],['🤽','water polo'],['🚣','row'],['🧗','climb'],['🚴','bike'],['🚵','mountain bike'],['🎪','circus'],['🎭','theater masks'],['🎨','art palette'],['🎬','clapper film'],['🎤','microphone'],['🎧','headphones'],['🎼','music score'],['🎹','piano'],['🥁','drum'],['🎷','saxophone'],['🎺','trumpet'],['🎸','guitar'],['🪕','banjo'],['🎻','violin'],['🎲','dice random game'],['♟️','chess'],['🎯','target dart'],['🎳','bowling'],['🎮','game controller'],['🎰','slot machine'],['🧩','puzzle'],['🏆','trophy win'],['🥇','gold medal first'],['🥈','silver medal'],['🥉','bronze medal'],['🏅','medal'],['🎖️','military medal'],['🎗️','reminder ribbon'],['🎫','ticket'],['🎟️','tickets'],['🎁','gift present'],['🎉','party popper celebrate'],['🎊','confetti'],['🎈','balloon'],['🎂','cake birthday'],['🎃','pumpkin halloween'],['🎄','christmas tree'],['🎆','fireworks'],['🎇','fireworks'],['🧨','firecracker'],['✨','sparkles'],['🎓','graduation cap']
|
||||
]},
|
||||
{ id: 'travel', label: 'Travel', items: [
|
||||
['🚗','car'],['🚕','taxi'],['🚙','car suv'],['🚌','bus'],['🚎','trolley bus'],['🏎️','race car'],['🚓','police car'],['🚑','ambulance'],['🚒','fire truck'],['🚐','van'],['🛻','pickup truck'],['🚚','truck'],['🚛','truck'],['🚜','tractor'],['🏍️','motorcycle'],['🛵','scooter'],['🚲','bicycle'],['🛴','kick scooter'],['🚨','police light'],['🚔','police car'],['🚍','bus'],['🚝','monorail'],['🚄','train'],['🚅','train bullet'],['🚈','train'],['🚂','locomotive train'],['🚆','train'],['🚇','metro subway'],['🚊','tram'],['🚉','station'],['✈️','airplane flight'],['🛫','airplane takeoff'],['🛬','airplane landing'],['🛩️','plane'],['💺','seat'],['🚁','helicopter'],['🛸','ufo'],['🚀','rocket launch'],['🛰️','satellite'],['🚢','ship'],['⛵','sailboat'],['🛥️','motor boat'],['🚤','speedboat'],['⛴️','ferry'],['🛳️','cruise ship'],['⚓','anchor'],['🚧','construction'],['⛽','fuel gas'],['🚏','bus stop'],['🗺️','map world'],['🗿','moai'],['🗽','statue liberty'],['🗼','tokyo tower'],['🏰','castle'],['🏯','castle japanese'],['🏟️','stadium'],['🎡','ferris wheel'],['🎢','roller coaster'],['🎠','carousel'],['⛲','fountain'],['⛱️','beach umbrella'],['🏖️','beach'],['🏝️','island'],['🏜️','desert'],['🌋','volcano'],['⛰️','mountain'],['🏔️','snow mountain'],['🗻','mount fuji'],['🏕️','camping'],['🏠','house home'],['🏡','house garden'],['🏢','office building'],['🏥','hospital'],['🏦','bank'],['🏨','hotel'],['🏫','school'],['🏭','factory'],['🏛️','classical building'],['⛪','church'],['🕌','mosque'],['🕍','synagogue'],['🛕','temple'],['🗼','tower'],['🌆','city sunset'],['🌃','night city'],['🌉','bridge night'],['🌌','milky way'],['🌠','shooting star'],['🌅','sunrise'],['🌄','sunrise mountain'],['🌇','sunset city']
|
||||
]},
|
||||
{ id: 'objects', label: 'Objects', items: [
|
||||
['⌚','watch'],['📱','phone mobile'],['💻','laptop computer'],['⌨️','keyboard'],['🖥️','desktop computer'],['🖨️','printer'],['🖱️','mouse computer'],['💽','minidisc'],['💾','floppy disk save'],['💿','cd disk'],['📀','dvd'],['🧮','abacus'],['🎥','movie camera'],['🎞️','film frames'],['📽️','projector'],['📺','tv television'],['📷','camera'],['📸','camera flash'],['📹','video camera'],['📼','videocassette'],['🔍','magnifying search'],['🔎','magnifying search'],['🕯️','candle'],['💡','bulb idea'],['🔦','flashlight'],['🏮','lantern'],['🪔','lamp diya'],['📔','notebook'],['📕','book closed'],['📖','book open'],['📗','book green'],['📘','book blue'],['📙','book orange'],['📚','books library'],['📓','notebook'],['📒','ledger'],['📃','page'],['📜','scroll'],['📄','page document'],['📰','newspaper'],['🗞️','newspaper'],['📑','bookmark tabs'],['🔖','bookmark'],['🏷️','label tag'],['💰','money bag'],['🪙','coin'],['💴','yen'],['💵','dollar'],['💶','euro'],['💷','pound'],['💸','money wings'],['💳','credit card'],['🧾','receipt'],['✉️','envelope mail'],['📧','email'],['📨','envelope'],['📩','envelope'],['📤','outbox'],['📥','inbox'],['📦','package box'],['📫','mailbox'],['📪','mailbox'],['📬','mailbox'],['📭','mailbox'],['📮','postbox'],['🗳️','ballot box'],['✏️','pencil'],['✒️','pen nib'],['🖋️','pen'],['🖊️','pen'],['🖌️','paintbrush'],['🖍️','crayon'],['📝','memo note write'],['💼','briefcase work'],['📁','folder'],['📂','folder open'],['🗂️','card index'],['📅','calendar date'],['📆','calendar'],['🗒️','notepad'],['🗓️','calendar'],['📇','card index'],['📈','chart up trending'],['📉','chart down'],['📊','bar chart stats'],['📋','clipboard'],['📌','pushpin'],['📍','pin location'],['📎','paperclip attach'],['🖇️','paperclips'],['📏','ruler'],['📐','triangle ruler'],['✂️','scissors cut'],['🗃️','file box'],['🗄️','file cabinet'],['🗑️','trash waste'],['🔒','lock locked'],['🔓','lock open'],['🔑','key'],['🗝️','old key'],['🔨','hammer'],['🪓','axe'],['⛏️','pick'],['⚒️','tools'],['🛠️','tools'],['🔧','wrench'],['🔩','bolt nut'],['⚙️','gear settings'],['🧰','toolbox'],['🧲','magnet'],['🔫','water gun'],['💣','bomb'],['🧪','test tube science'],['🧫','petri dish'],['🧬','dna'],['🔬','microscope'],['🔭','telescope'],['📡','satellite antenna'],['💉','syringe'],['💊','pill medicine'],['🩹','bandage'],['🩺','stethoscope'],['🚪','door'],['🛏️','bed'],['🛋️','couch'],['🪑','chair'],['🚽','toilet'],['🚿','shower'],['🛁','bathtub'],['🧴','lotion'],['🧷','safety pin'],['🧹','broom'],['🧺','basket'],['🧻','toilet paper'],['🧼','soap'],['🪒','razor'],['🧽','sponge'],['🧯','extinguisher'],['🛒','cart shopping'],['🚬','cigarette'],['⚰️','coffin'],['🪦','headstone'],['⚱️','urn']
|
||||
]},
|
||||
{ id: 'symbols', label: 'Symbols', items: [
|
||||
['❤️','heart love'],['🧡','orange heart'],['💛','yellow heart'],['💚','green heart'],['💙','blue heart'],['💜','purple heart'],['🖤','black heart'],['🤍','white heart'],['🤎','brown heart'],['💔','broken heart'],['❣️','heart'],['💕','hearts'],['💞','hearts'],['💓','heartbeat'],['💗','heart'],['💖','heart'],['💘','heart arrow'],['💝','heart gift'],['💟','heart decoration'],['☮️','peace'],['✝️','cross'],['☪️','star crescent'],['🕉️','om'],['☸️','dharma'],['✡️','star david'],['🔯','star'],['🕎','menorah'],['☯️','yin yang'],['☦️','orthodox cross'],['🛐','worship'],['⛎','ophiuchus'],['♈','aries'],['♉','taurus'],['♊','gemini'],['♋','cancer'],['♌','leo'],['♍','virgo'],['♎','libra'],['♏','scorpio'],['♐','sagittarius'],['♑','capricorn'],['♒','aquarius'],['♓','pisces'],['🆔','id'],['⚛️','atom'],['🉑','accept'],['☢️','radioactive'],['☣️','biohazard'],['📴','phone off'],['📳','vibrate'],['📵','no phone'],['🚭','no smoking'],['❗','exclamation'],['❕','exclamation'],['❓','question'],['❔','question'],['‼️','double exclamation'],['⁉️','exclamation question'],['🔅','dim'],['🔆','bright'],['〽️','part alternation'],['⚠️','warning'],['🚸','children crossing'],['🔱','trident'],['⚜️','fleur de lis'],['🔰','beginner'],['♻️','recycle'],['✅','check done'],['🈯','reserved'],['💹','chart yen'],['❇️','sparkle'],['✳️','asterisk'],['❎','cross mark'],['🌐','globe'],['💠','diamond'],['Ⓜ️','m'],['🌀','cyclone'],['💤','zzz sleep'],['🏧','atm'],['🚾','wc'],['♿','wheelchair'],['🅿️','parking'],['🈳','vacancy'],['🈂️','sa'],['🛂','passport control'],['🛃','customs'],['🛄','baggage'],['🛅','left luggage'],['🚹','men'],['🚺','women'],['🚼','baby'],['🚻','restroom'],['🚮','litter'],['🎦','cinema'],['📶','signal'],['🈁','here'],['🔣','symbols'],['ℹ️','info'],['🔤','abc'],['🔡','abcd'],['🔠','abcd'],['🆖','ng'],['🆗','ok'],['🆙','up'],['🆒','cool'],['🆕','new'],['🆓','free'],['0️⃣','zero'],['1️⃣','one'],['2️⃣','two'],['3️⃣','three'],['4️⃣','four'],['5️⃣','five'],['6️⃣','six'],['7️⃣','seven'],['8️⃣','eight'],['9️⃣','nine'],['🔟','ten'],['🔢','numbers'],['#️⃣','hash'],['*️⃣','asterisk'],['⏏️','eject'],['▶️','play'],['⏸️','pause'],['⏹️','stop'],['⏺️','record'],['⏭️','next'],['⏮️','previous'],['⏩','fast forward'],['⏪','rewind'],['⏫','up'],['⏬','down'],['◀️','left'],['🔼','up'],['🔽','down'],['➡️','right'],['⬅️','left'],['⬆️','up'],['⬇️','down'],['↗️','up right'],['↘️','down right'],['↙️','down left'],['↖️','up left'],['↕️','up down'],['↔️','left right'],['↩️','return'],['↪️','redo'],['⤴️','up'],['⤵️','down'],['🔀','shuffle'],['🔁','repeat'],['🔂','repeat one'],['🔄','refresh'],['🔃','refresh'],['🎵','music note'],['🎶','music notes'],['➕','plus'],['➖','minus'],['➗','divide'],['✖️','multiply'],['♾️','infinity'],['💲','dollar'],['💱','currency'],['™️','tm'],['©️','copyright'],['®️','registered'],['〰️','wavy'],['➰','curly loop'],['➿','double loop'],['🔚','end'],['🔙','back'],['🔛','on'],['🔝','top'],['🔜','soon'],['✔️','check'],['☑️','checkbox'],['🔘','radio'],['🔴','red circle'],['🟠','orange circle'],['🟡','yellow circle'],['🟢','green circle'],['🔵','blue circle'],['🟣','purple circle'],['⚫','black circle'],['⚪','white circle'],['🟤','brown circle'],['🔺','red triangle'],['🔻','red triangle'],['🔸','orange diamond'],['🔹','blue diamond'],['🔶','orange diamond'],['🔷','blue diamond'],['🔳','white square'],['🔲','black square'],['▪️','black square'],['▫️','white square'],['◾','black square'],['◽','white square'],['◼️','black square'],['◻️','white square'],['🟥','red square'],['🟧','orange square'],['🟨','yellow square'],['🟩','green square'],['🟦','blue square'],['🟪','purple square'],['⬛','black square'],['⬜','white square'],['🟫','brown square'],['🔈','speaker'],['🔇','mute'],['🔉','speaker'],['🔊','speaker loud'],['🔔','bell'],['🔕','bell off'],['📣','megaphone'],['📢','loudspeaker'],['💬','speech bubble'],['💭','thought bubble'],['🗯️','anger bubble'],['♠️','spade'],['♣️','club'],['♥️','heart suit'],['♦️','diamond suit'],['🃏','joker'],['🎴','flower cards'],['🀄','mahjong']
|
||||
]},
|
||||
{ id: 'flags', label: 'Flags', items: [
|
||||
['🏁','chequered flag finish'],['🚩','triangular flag'],['🎌','crossed flags'],['🏴','black flag'],['🏳️','white flag'],['🏳️🌈','rainbow flag pride'],['🏴☠️','pirate flag'],['🇺🇸','usa united states'],['🇬🇧','uk united kingdom'],['🇫🇷','france french'],['🇩🇪','germany german'],['🇪🇸','spain spanish'],['🇮🇹','italy italian'],['🇵🇹','portugal'],['🇳🇱','netherlands'],['🇧🇪','belgium'],['🇨🇭','switzerland'],['🇦🇹','austria'],['🇸🇪','sweden'],['🇳🇴','norway'],['🇩🇰','denmark'],['🇫🇮','finland'],['🇮🇪','ireland'],['🇵🇱','poland'],['🇬🇷','greece'],['🇷🇺','russia'],['🇺🇦','ukraine'],['🇹🇷','turkey'],['🇨🇦','canada'],['🇲🇽','mexico'],['🇧🇷','brazil'],['🇦🇷','argentina'],['🇨🇱','chile'],['🇨🇴','colombia'],['🇨🇳','china chinese'],['🇯🇵','japan japanese'],['🇰🇷','korea south'],['🇮🇳','india'],['🇦🇺','australia'],['🇳🇿','new zealand'],['🇿🇦','south africa'],['🇪🇬','egypt'],['🇲🇦','morocco'],['🇳🇬','nigeria'],['🇰🇪','kenya'],['🇸🇦','saudi arabia'],['🇦🇪','uae emirates'],['🇮🇱','israel'],['🇸🇬','singapore'],['🇹🇭','thailand'],['🇻🇳','vietnam'],['🇮🇩','indonesia'],['🇵🇭','philippines'],['🇲🇾','malaysia'],['🇵🇰','pakistan'],['🇧🇩','bangladesh'],['🇺🇳','united nations']
|
||||
]}
|
||||
];
|
||||
|
||||
document.addEventListener('alpine:init', function () {
|
||||
if (window.Alpine && window.Alpine.__fdIconPicker) return;
|
||||
if (window.Alpine) window.Alpine.__fdIconPicker = true;
|
||||
|
||||
Alpine.store('fdIconPicker', {
|
||||
open: false, x: 0, y: 0, tab: 'emoji', query: '', category: 'people',
|
||||
skinTone: 0, toneOpen: false, recent: [], custom: [], customLoaded: false,
|
||||
customModal: false, customName: '', customPreview: '', customFile: null, customBusy: false,
|
||||
onPick: null, onRemove: null, _cx: 0, _cy: 0,
|
||||
cats: CATS,
|
||||
|
||||
openFor(opts) {
|
||||
opts = opts || {};
|
||||
this.onPick = opts.onPick || null;
|
||||
this.onRemove = opts.onRemove || null;
|
||||
this.query = '';
|
||||
this.toneOpen = false;
|
||||
this.customModal = false;
|
||||
this._cx = (opts.x != null) ? opts.x : 240;
|
||||
this._cy = (opts.y != null) ? opts.y : 200;
|
||||
this.x = this._cx;
|
||||
this.y = this._cy;
|
||||
this.open = true;
|
||||
var self = this;
|
||||
var place = function () {
|
||||
var el = document.querySelector('.fd-icon-picker');
|
||||
if (!el) return;
|
||||
var w = el.offsetWidth || 344, h = el.offsetHeight || 440;
|
||||
var vw = window.innerWidth, vh = window.innerHeight;
|
||||
var nx = self._cx, ny = self._cy;
|
||||
if (nx + w > vw - 8) nx = vw - w - 8;
|
||||
if (ny + h > vh - 8) ny = vh - h - 8;
|
||||
self.x = Math.max(8, nx);
|
||||
self.y = Math.max(8, ny);
|
||||
};
|
||||
if (window.Alpine && Alpine.nextTick) Alpine.nextTick(place);
|
||||
else setTimeout(place, 0);
|
||||
this.loadRecent();
|
||||
this.loadCustom();
|
||||
},
|
||||
|
||||
close() {
|
||||
this.open = false;
|
||||
this.customModal = false;
|
||||
this.toneOpen = false;
|
||||
this.onPick = null;
|
||||
this.onRemove = null;
|
||||
},
|
||||
|
||||
matches(name) {
|
||||
var q = (this.query || '').trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return name.toLowerCase().indexOf(q) >= 0;
|
||||
},
|
||||
|
||||
items() {
|
||||
var q = (this.query || '').trim().toLowerCase();
|
||||
if (q) {
|
||||
var out = [];
|
||||
this.cats.forEach(function (c) {
|
||||
c.items.forEach(function (it) {
|
||||
if ((it[1] || '').toLowerCase().indexOf(q) >= 0 || it[0].indexOf(q) >= 0) out.push(it[0]);
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
if (this.category === 'recent') return this.recent;
|
||||
var found = [];
|
||||
for (var i = 0; i < this.cats.length; i++) {
|
||||
if (this.cats[i].id === this.category) { found = this.cats[i].items.map(function (it) { return it[0]; }); break; }
|
||||
}
|
||||
return found;
|
||||
},
|
||||
|
||||
withTone(e) {
|
||||
if (!this.skinTone) return e;
|
||||
if (TONEABLE[e]) return e + TONES[this.skinTone];
|
||||
return e;
|
||||
},
|
||||
|
||||
pick(v) {
|
||||
v = (v || '').trim();
|
||||
if (!v) return;
|
||||
this.pushRecent(v);
|
||||
var fn = this.onPick;
|
||||
if (fn) { try { fn(v); } catch (e) { console.error('fdIconPicker.pick', e); } }
|
||||
this.close();
|
||||
},
|
||||
|
||||
remove() {
|
||||
var fn = this.onRemove || this.onPick;
|
||||
if (fn) { try { fn(''); } catch (e) {} }
|
||||
this.close();
|
||||
},
|
||||
|
||||
random() {
|
||||
var pool = [];
|
||||
this.cats.forEach(function (c) { c.items.forEach(function (it) { pool.push(it[0]); }); });
|
||||
if (!pool.length) return;
|
||||
this.pick(pool[Math.floor(Math.random() * pool.length)]);
|
||||
},
|
||||
|
||||
setTone(i) { this.skinTone = i; this.toneOpen = false; },
|
||||
setCategory(id) { this.category = id; this.tab = 'emoji'; this.query = ''; },
|
||||
setTab(t) { this.tab = t; this.query = ''; if (t === 'upload') this.loadCustom(); },
|
||||
|
||||
loadRecent() {
|
||||
try { this.recent = JSON.parse(localStorage.getItem('fd_icon_recent') || '[]'); }
|
||||
catch (e) { this.recent = []; }
|
||||
},
|
||||
pushRecent(e) {
|
||||
try {
|
||||
var r = this.recent.filter(function (x) { return x !== e; });
|
||||
r.unshift(e);
|
||||
this.recent = r.slice(0, 32);
|
||||
localStorage.setItem('fd_icon_recent', JSON.stringify(this.recent));
|
||||
} catch (err) {}
|
||||
},
|
||||
|
||||
async loadCustom() {
|
||||
try {
|
||||
var r = await fetch('/api/custom-emojis', { credentials: 'same-origin' });
|
||||
var d = await r.json();
|
||||
this.custom = (d && d.emojis) || [];
|
||||
this.customLoaded = true;
|
||||
} catch (e) { this.custom = []; }
|
||||
},
|
||||
|
||||
openCustomModal() {
|
||||
this.customModal = true;
|
||||
this.customName = '';
|
||||
this.customPreview = '';
|
||||
this.customFile = null;
|
||||
this.tab = 'upload';
|
||||
},
|
||||
closeCustomModal() { this.customModal = false; },
|
||||
onCustomFile(ev) {
|
||||
var f = ev.target.files && ev.target.files[0];
|
||||
if (!f) return;
|
||||
this.customFile = f;
|
||||
var self = this;
|
||||
var fr = new FileReader();
|
||||
fr.onload = function () { self.customPreview = fr.result; };
|
||||
fr.readAsDataURL(f);
|
||||
if (!this.customName) this.customName = (f.name || '').replace(/\.[^.]+$/, '').slice(0, 40);
|
||||
},
|
||||
async saveCustom() {
|
||||
if (!this.customFile || this.customBusy) return;
|
||||
this.customBusy = true;
|
||||
try {
|
||||
var fd = new FormData();
|
||||
fd.append('name', this.customName || 'emoji');
|
||||
fd.append('file', this.customFile);
|
||||
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
var r = await fetch('/api/custom-emojis', { method: 'POST', headers: { 'X-CSRF-Token': csrf }, body: fd, credentials: 'same-origin' });
|
||||
var d = await r.json();
|
||||
if (d && d.emoji) {
|
||||
this.custom.unshift(d.emoji);
|
||||
this.customModal = false;
|
||||
this.pick(d.emoji.url);
|
||||
}
|
||||
} catch (e) {
|
||||
if (window.showToast) window.showToast('Emoji upload failed', 'error');
|
||||
}
|
||||
this.customBusy = false;
|
||||
},
|
||||
async deleteCustom(id) {
|
||||
try {
|
||||
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
await fetch('/api/custom-emojis/' + id, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf }, credentials: 'same-origin' });
|
||||
this.custom = this.custom.filter(function (e) { return e.id !== id; });
|
||||
} catch (e) {}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
window.FDIconPicker = {
|
||||
openFor: function (opts) {
|
||||
var s = window.Alpine && Alpine.store('fdIconPicker');
|
||||
if (s) s.openFor(opts);
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/_icon_picker_2.js?v={{ asset_version }}"></script>
|
||||
|
||||
<style>
|
||||
.fd-icon-picker{position:fixed;top:0;left:0;width:344px;background:var(--bg-modal);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.28);z-index:2200;display:flex;flex-direction:column;max-height:440px;overflow:hidden;padding:0;}
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<script data-cfasync="false">
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
document.addEventListener('alpine:init', function () {
|
||||
if (window.Alpine && window.Alpine.__fdNotificationsRegistered) return;
|
||||
if (window.Alpine) window.Alpine.__fdNotificationsRegistered = true;
|
||||
|
||||
@@ -9,535 +9,5 @@
|
||||
.rt-cursor .rt-cursor-name{position:absolute;top:-14px;left:4px;white-space:nowrap;font-size:9px;line-height:12px;padding:0 4px;border-radius:4px;color:#fff;font-weight:600;letter-spacing:.2px;}
|
||||
.rt-offline{font-size:11px;color:var(--text-tertiary,#999);margin-right:6px;display:none;}
|
||||
</style>
|
||||
<script data-cfasync="false">
|
||||
/* eslint-disable */
|
||||
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
|
||||
window.__fdRT = (function () {
|
||||
const SELF = {
|
||||
id: {{ (user.get('id') if user else 0) | tojson }},
|
||||
login: {{ (user.get('login','') if user else '') | tojson }},
|
||||
full_name: {{ (user.get('full_name','') if user else '') | tojson }},
|
||||
color: {{ (user.get('avatar_color','') or '' if user else '') | tojson }},
|
||||
};
|
||||
const COLORS = [
|
||||
"#2383E2", "#46A758", "#E5484D", "#F76B15", "#8E4EC6", "#12A594",
|
||||
"#FFC53D", "#D6409F", "#0091FF", "#3E63DD", "#30A46C", "#FF3333",
|
||||
];
|
||||
let E = null; // editorState (window.E)
|
||||
let ws = null;
|
||||
let mode = 'off'; // 'ws' | 'poll'
|
||||
let open = false;
|
||||
let base = []; // dernier snapshot serveur des blocs
|
||||
let version = 0;
|
||||
let pendingOps = 0;
|
||||
let needSync = false;
|
||||
let peers = []; // liste des présents (hors moi)
|
||||
let remoteCursors = {}; // userId -> {peer, block, offset}
|
||||
let myCursor = null; // {block, offset}
|
||||
let emitT = null, selT = null, titleT = null, drawT = null, retryT = null, pollT = null;
|
||||
let _pid = 0;
|
||||
|
||||
// Detach global listeners from a previous editor instance so that
|
||||
// partial (HTMX) navigation doesn't accumulate stale handlers.
|
||||
function unbindGlobal() {
|
||||
const g = window.__fdRTGlobal;
|
||||
if (!g) return;
|
||||
try {
|
||||
document.removeEventListener('selectionchange', g.onSel, true);
|
||||
window.removeEventListener('scroll', g.onScroll, true);
|
||||
window.removeEventListener('resize', g.onResize);
|
||||
} catch (e) { /* noop */ }
|
||||
window.__fdRTGlobal = null;
|
||||
}
|
||||
|
||||
const clone = (o) => JSON.parse(JSON.stringify(o));
|
||||
|
||||
function colorOf(uid) { return COLORS[Math.abs(uid || 0) % COLORS.length]; }
|
||||
|
||||
function initials(p) {
|
||||
const n = (p && (p.full_name || p.login)) || '';
|
||||
const parts = String(n).trim().split(/\s+/);
|
||||
if (!parts[0]) return '?';
|
||||
return ((parts[0][0] || '') + (parts.length > 1 ? (parts[1][0] || '') : '')).toUpperCase().slice(0, 2);
|
||||
}
|
||||
|
||||
function send(obj) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try { ws.send(JSON.stringify(obj)); } catch (e) { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── ops miroir du serveur (apply_op/merge) ── */
|
||||
function applyOpJS(blocks, op) {
|
||||
const t = op && op.type;
|
||||
if (t === 'insert') {
|
||||
const blk = op.block || {};
|
||||
const id = blk.id || ('rb' + Date.now().toString(36));
|
||||
blk.id = id;
|
||||
if (typeof ensureBlockIds === 'function') ensureBlockIds([blk]);
|
||||
let idx = op.index != null ? op.index : blocks.length;
|
||||
idx = Math.max(0, Math.min(idx, blocks.length));
|
||||
return blocks.slice(0, idx).concat([blk]).concat(blocks.slice(idx));
|
||||
}
|
||||
if (t === 'update') {
|
||||
const nb = op.block || {};
|
||||
if (!nb.id) return blocks;
|
||||
return blocks.map(b => (b.id === nb.id ? nb : b));
|
||||
}
|
||||
if (t === 'delete') {
|
||||
const bid = op.id;
|
||||
return blocks.filter(b => b.id !== bid);
|
||||
}
|
||||
if (t === 'move') {
|
||||
const bid = op.id, idx = op.index || 0;
|
||||
const out = blocks.filter(b => b.id !== bid);
|
||||
const moved = blocks.find(b => b.id === bid);
|
||||
if (!moved) return blocks;
|
||||
const i2 = Math.max(0, Math.min(idx, out.length));
|
||||
out.splice(i2, 0, moved);
|
||||
return out;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/* Diff base → courants : update/delete/move/insert (ordre pour le serveur). */
|
||||
function diffOps(cur) {
|
||||
if (!base.length && !cur.length) return [];
|
||||
const ops = [];
|
||||
const baseById = {}, curById = {};
|
||||
base.forEach(b => { baseById[b.id] = b; });
|
||||
cur.forEach(b => { curById[b.id] = b; });
|
||||
|
||||
cur.forEach(b => {
|
||||
if (baseById[b.id] !== undefined && JSON.stringify(baseById[b.id]) !== JSON.stringify(b)) {
|
||||
// v6.4.0 : on embarque la `base` dont dérive la saisie → le serveur
|
||||
// fait un merge 3-voix au lieu d'écraser le bloc (LWW).
|
||||
ops.push({ type: 'update', block: clone(b), base: clone(baseById[b.id]) });
|
||||
}
|
||||
});
|
||||
base.forEach(b => {
|
||||
if (curById[b.id] === undefined) ops.push({ type: 'delete', id: b.id });
|
||||
});
|
||||
|
||||
// structure : simule l'état serveur (base − supprimés) pour indices valides
|
||||
let sim = base.filter(b => curById[b.id] !== undefined).map(b => clone(b));
|
||||
const simById = {}; sim.forEach(b => { simById[b.id] = b; });
|
||||
const finalOrder = cur.map(b => b.id);
|
||||
let si = 0;
|
||||
finalOrder.forEach(id => {
|
||||
if (simById[id] !== undefined) {
|
||||
const curPos = sim.findIndex(b => b.id === id);
|
||||
if (curPos !== si) ops.push({ type: 'move', id, index: si });
|
||||
const [mv] = sim.splice(curPos, 1);
|
||||
sim.splice(si, 0, mv);
|
||||
si++;
|
||||
} else {
|
||||
ops.push({ type: 'insert', index: si, block: clone(curById[id]) });
|
||||
sim.splice(si, 0, curById[id]);
|
||||
simById[id] = curById[id];
|
||||
si++;
|
||||
}
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
/* ── rendu + présence ── */
|
||||
function activeBlockId() {
|
||||
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
|
||||
return a ? a.bid : null;
|
||||
}
|
||||
|
||||
function refocus(fid) {
|
||||
if (!fid) return;
|
||||
setTimeout(() => {
|
||||
const el = E.getEl(fid);
|
||||
if (el) { el.focus(); try { (typeof ce === 'function') && ce(el); } catch (e) { /* noop */ } }
|
||||
}, 30);
|
||||
}
|
||||
|
||||
function renderPresence() {
|
||||
let host = document.querySelector('.topbar-right.header-actions');
|
||||
if (!host) return;
|
||||
let box = document.getElementById('rtPresence');
|
||||
if (!box) {
|
||||
box = document.createElement('span');
|
||||
box.id = 'rtPresence';
|
||||
box.className = 'rt-presence';
|
||||
host.insertBefore(box, host.firstChild);
|
||||
}
|
||||
const shown = peers.filter(p => p.id !== (SELF.id || 0));
|
||||
if (!shown.length) { box.style.display = 'none'; return; }
|
||||
box.style.display = 'inline-flex';
|
||||
box.innerHTML = '';
|
||||
shown.forEach(p => {
|
||||
const c = document.createElement('span');
|
||||
c.className = 'rt-avatar';
|
||||
c.title = (p.full_name || p.login) + ' est en train d\u2019éditer' + (mode === 'poll' ? ' (polling)' : '');
|
||||
c.textContent = initials(p);
|
||||
c.style.background = p.color || colorOf(p.id);
|
||||
box.appendChild(c);
|
||||
});
|
||||
if (mode === 'poll') {
|
||||
let off = document.getElementById('rtOffline');
|
||||
if (!off) {
|
||||
off = document.createElement('span');
|
||||
off.id = 'rtOffline';
|
||||
off.className = 'rt-offline';
|
||||
off.textContent = '●';
|
||||
off.title = 'Realtime indisponible — rafraîchissement toutes les 10 s';
|
||||
host.insertBefore(off, box.nextSibling || null);
|
||||
}
|
||||
off.style.display = 'inline';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── curseurs ── */
|
||||
function rangeFromOffset(el, offset) {
|
||||
try {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
let n = walker.nextNode(), count = 0;
|
||||
while (n) {
|
||||
const len = (n.nodeValue || '').length;
|
||||
if (count + len >= offset) {
|
||||
const r = document.createRange();
|
||||
r.setStart(n, Math.min(offset - count, len));
|
||||
r.collapse(true);
|
||||
return r;
|
||||
}
|
||||
count += len;
|
||||
n = walker.nextNode();
|
||||
}
|
||||
const r = document.createRange();
|
||||
r.selectNodeContents(el);
|
||||
r.collapse(false);
|
||||
return r;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
function drawCursors() {
|
||||
const layer = document.getElementById('rtCursors');
|
||||
if (!layer) return;
|
||||
layer.innerHTML = '';
|
||||
const ids = Object.keys(remoteCursors);
|
||||
if (!ids.length) return;
|
||||
ids.forEach(uid => {
|
||||
const c = remoteCursors[uid];
|
||||
if (!c || !c.block) return;
|
||||
const el = E.getEl(c.block);
|
||||
if (!el) return;
|
||||
const r = rangeFromOffset(el, c.offset || 0);
|
||||
let x, y, h;
|
||||
if (r) {
|
||||
const rc = r.getBoundingClientRect();
|
||||
if (!rc.width && !rc.height) return; // hors viewport positionné
|
||||
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
|
||||
} else {
|
||||
const rc = el.getBoundingClientRect();
|
||||
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
|
||||
}
|
||||
const m = document.createElement('div');
|
||||
m.className = 'rt-cursor';
|
||||
m.style.left = (x - 1) + 'px';
|
||||
m.style.top = (y - 1) + 'px';
|
||||
m.style.height = h + 'px';
|
||||
m.style.background = c.peer.color || colorOf(c.peer.id);
|
||||
const nm = document.createElement('span');
|
||||
nm.className = 'rt-cursor-name';
|
||||
nm.textContent = initials(c.peer);
|
||||
nm.style.background = m.style.background;
|
||||
m.appendChild(nm);
|
||||
layer.appendChild(m);
|
||||
});
|
||||
}
|
||||
|
||||
function emitCursor() {
|
||||
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
|
||||
let block = null, offset = 0;
|
||||
if (a && a.el && a.bid) {
|
||||
block = a.bid;
|
||||
try { offset = (typeof cp === 'function') ? cp(a.el) : 0; } catch (e) { offset = 0; }
|
||||
}
|
||||
const changed = !myCursor || myCursor.block !== block || myCursor.offset !== offset;
|
||||
myCursor = { block, offset };
|
||||
if (!changed) return;
|
||||
send({ t: 'sel', block, offset });
|
||||
}
|
||||
|
||||
function scheduleDraw() {
|
||||
clearTimeout(drawT);
|
||||
drawT = setTimeout(drawCursors, 40);
|
||||
}
|
||||
|
||||
/* ── application des changements distants ── */
|
||||
function applySync(blocks, title) {
|
||||
if (!E || !E.blocks) return;
|
||||
// v5.13.1: guarantee ids before comparing/merging. Server rooms may still
|
||||
// carry legacy id-less blocks; without this they collide on
|
||||
// data-bid="undefined" and the merge below duplicated every line.
|
||||
blocks = (blocks || []).slice();
|
||||
if (typeof ensureBlockIds === 'function') ensureBlockIds(blocks);
|
||||
const curIds = JSON.stringify((E.blocks || []).map(b => b.id));
|
||||
const srvIds = JSON.stringify(blocks.map(b => b.id));
|
||||
if (curIds === srvIds) {
|
||||
base = clone(E.blocks);
|
||||
E.dirty = false;
|
||||
return;
|
||||
}
|
||||
const fid = activeBlockId();
|
||||
const curById = {};
|
||||
(E.blocks || []).forEach(b => { curById[b.id] = b; });
|
||||
let out;
|
||||
try {
|
||||
if (!blocks.length) {
|
||||
// serveur vide : ne pas effacer le contenu local (page neuve).
|
||||
out = (E.blocks || []).slice();
|
||||
} else {
|
||||
out = blocks.map(b => {
|
||||
const cb = curById[b.id];
|
||||
if (cb && b.id === fid) return cb; // garde la frappe locale en cours
|
||||
return b;
|
||||
});
|
||||
}
|
||||
} catch (e) { return; }
|
||||
E.blocks = out;
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (title && title !== E.pageTitle && document.activeElement !== tEl) {
|
||||
tEl.textContent = title;
|
||||
E.pageTitle = title;
|
||||
}
|
||||
E.dirty = true;
|
||||
base = clone(E.blocks);
|
||||
E.render();
|
||||
refocus(fid);
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
function applyRemoteOp(op) {
|
||||
const fid = activeBlockId();
|
||||
if (op.type === 'update') {
|
||||
const nb = op.block || {};
|
||||
if (nb.id === fid) {
|
||||
// bloc en cours d'édition : on garde la valeur locale, le serveur a déjà
|
||||
// l'op ; la prochaine frappe locale repartira (LWW).
|
||||
base = applyOpJS(base, op);
|
||||
return;
|
||||
}
|
||||
E.blocks = applyOpJS(E.blocks, op);
|
||||
base = applyOpJS(base, op);
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
} else {
|
||||
E.blocks = applyOpJS(E.blocks, op);
|
||||
base = applyOpJS(base, op);
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
}
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
/* ── diffusion des modifications locales ── */
|
||||
function emit() {
|
||||
if (mode !== 'ws' || !open || !E || !E.blocks) return;
|
||||
if (needSync) { send({ t: 'sync_req' }); return; }
|
||||
const cur = E.blocks.filter(b => b && b.id);
|
||||
const ops = diffOps(cur);
|
||||
if (!ops.length) return;
|
||||
ops.forEach(op => { send({ t: 'op', op, v: version }); pendingOps++; });
|
||||
base = clone(cur);
|
||||
}
|
||||
|
||||
function onMsg(m) {
|
||||
if (!m || !m.t) return;
|
||||
if (m.t === 'sync') {
|
||||
version = m.version || 0;
|
||||
base = clone(m.blocks || []);
|
||||
needSync = false;
|
||||
pendingOps = 0;
|
||||
if (typeof m.blocks === 'undefined') return;
|
||||
applySync(m.blocks || [], m.title || '');
|
||||
} else if (m.t === 'ack') {
|
||||
version = m.v || 0;
|
||||
if (pendingOps > 0) pendingOps--;
|
||||
// v6.4.0 : le serveur renvoie le bloc fusionné (merge 3-voix). On
|
||||
// l'adopte comme nouvelle base ; s'il diffère de notre saisie locale
|
||||
// c'est qu'un autre utilisateur avait modifié le même bloc.
|
||||
if (m.merged) {
|
||||
const mid = m.merged.id;
|
||||
const localBlock = (E && E.blocks || []).find(b => b.id === mid);
|
||||
const differs = localBlock && JSON.stringify(localBlock) !== JSON.stringify(m.merged);
|
||||
base = applyOpJS(base, { type: 'update', block: m.merged });
|
||||
if (differs && E) {
|
||||
const fid = activeBlockId();
|
||||
if (fid !== mid) {
|
||||
E.blocks = applyOpJS(E.blocks, { type: 'update', block: m.merged });
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
}
|
||||
if (m.conflict && window.showToast) {
|
||||
window.showToast('Editing conflict merged on a block', 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (m.stale || needSync) { needSync = true; send({ t: 'sync_req' }); }
|
||||
} else if (m.t === 'op') {
|
||||
if (m.v) version = m.v;
|
||||
if (m.from === (SELF.id || 0)) { base = applyOpJS(base, m.op); return; }
|
||||
applyRemoteOp(m.op);
|
||||
} else if (m.t === 'title') {
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (m.from !== (SELF.id || 0) && tEl && document.activeElement !== tEl && E) {
|
||||
tEl.textContent = m.title || '';
|
||||
E.pageTitle = m.title || '';
|
||||
}
|
||||
if (m.v) version = m.v;
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'sel') {
|
||||
if (m.from === (SELF.id || 0)) return;
|
||||
if (!m.block) { delete remoteCursors[m.from]; scheduleDraw(); return; }
|
||||
remoteCursors[m.from] = { peer: m.peer || { id: m.from }, block: m.block, offset: m.offset || 0 };
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'welcome') {
|
||||
peers = (m.peers || []).filter(p => p.id !== (SELF.id || 0));
|
||||
renderPresence();
|
||||
} else if (m.t === 'peer_join') {
|
||||
if (m.peer && m.peer.id !== (SELF.id || 0)) {
|
||||
peers = peers.filter(p => p.id !== m.peer.id).concat([m.peer]);
|
||||
renderPresence();
|
||||
}
|
||||
} else if (m.t === 'peer_leave') {
|
||||
peers = peers.filter(p => p.id !== m.id);
|
||||
delete remoteCursors[m.id];
|
||||
renderPresence();
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'synced_update') {
|
||||
// A synced block was updated — re-sync the whole page
|
||||
if (m.synced_id) {
|
||||
needSync = true;
|
||||
send({ t: 'sync_req' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── connexion WS + fallback polling ── */
|
||||
function startPolling() {
|
||||
if (pollT) return;
|
||||
mode = 'poll';
|
||||
renderPresence();
|
||||
scheduleDraw();
|
||||
pollT = setInterval(poll, 10000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollT) { clearInterval(pollT); pollT = null; }
|
||||
const off = document.getElementById('rtOffline');
|
||||
if (off) off.style.display = 'none';
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (!E || !_pid) return;
|
||||
try {
|
||||
const r = await fetch('/board/api/pages/' + _pid, { credentials: 'same-origin' });
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
if ((d.content_format || 'blocks') !== 'blocks' || !d.content) return;
|
||||
const serverBlocks = JSON.parse(d.content);
|
||||
// en cas de modifs locales non persistées : on garde local (LWW au prochain save)
|
||||
if (E.dirty) return;
|
||||
const cur = JSON.stringify((E.blocks || []).map(b => b.id));
|
||||
const srv = JSON.stringify(serverBlocks.map(b => b.id));
|
||||
if (cur === srv) return;
|
||||
version = version; // pas de version via polling — on adopte l'état serveur
|
||||
applySync(serverBlocks, d.title || E.pageTitle);
|
||||
} catch (e) { /* noop */ }
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (!E || !_pid || ws) return;
|
||||
const proto = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
try {
|
||||
ws = new WebSocket(proto + window.location.host + '/ws/pages/' + _pid);
|
||||
} catch (e) {
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
ws.onopen = () => {
|
||||
open = true;
|
||||
mode = 'ws';
|
||||
stopPolling();
|
||||
send({ t: 'hello' });
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
let m;
|
||||
try { m = JSON.parse(ev.data); } catch (e) { return; }
|
||||
onMsg(m);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
open = false;
|
||||
ws = null;
|
||||
if (retryT) clearTimeout(retryT);
|
||||
retryT = setTimeout(connect, 15000);
|
||||
startPolling();
|
||||
};
|
||||
ws.onerror = () => { try { ws.close(); } catch (e) { /* noop */ } };
|
||||
setTimeout(() => {
|
||||
if (!open) { try { ws && ws.close(); } catch (e) { /* noop */ } }
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function titleInput() {
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (!tEl) return;
|
||||
clearTimeout(titleT);
|
||||
titleT = setTimeout(() => {
|
||||
send({ t: 'title', title: (tEl.textContent || '').trim() });
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function wire() {
|
||||
const ct = document.getElementById('_blocksCt');
|
||||
if (ct) {
|
||||
ct.addEventListener('input', () => {
|
||||
clearTimeout(emitT);
|
||||
emitT = setTimeout(emit, 350);
|
||||
setTimeout(emitCursor, 80);
|
||||
}, true);
|
||||
ct.addEventListener('keyup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
ct.addEventListener('click', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
ct.addEventListener('mouseup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
}
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (tEl) tEl.addEventListener('input', titleInput);
|
||||
unbindGlobal();
|
||||
const onSel = () => { clearTimeout(selT); selT = setTimeout(emitCursor, 150); };
|
||||
const onScroll = () => scheduleDraw();
|
||||
const onResize = () => scheduleDraw();
|
||||
document.addEventListener('selectionchange', onSel, true);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', onResize);
|
||||
window.__fdRTGlobal = { onSel, onScroll, onResize };
|
||||
}
|
||||
|
||||
function start(ed) {
|
||||
if (!ed) return;
|
||||
if ((ed.contentFormat || 'blocks') !== 'blocks') return;
|
||||
E = ed;
|
||||
_pid = ed.pid || 0;
|
||||
if (!_pid) return;
|
||||
base = clone(ed.blocks || []);
|
||||
wire();
|
||||
connect();
|
||||
}
|
||||
|
||||
// poussée immédiate des ops après une mutation programmatique (v5.10.0)
|
||||
function syncNow() {
|
||||
if (mode !== 'ws' || !open || !E || !E.blocks) return;
|
||||
clearTimeout(emitT);
|
||||
emit();
|
||||
}
|
||||
|
||||
return { start, syncNow };
|
||||
})();
|
||||
</script>
|
||||
<script type="application/json" id="rt-config" nonce="{{ csp_nonce() }}">{{ {"id": user.get("id") if user else 0, "login": user.get("login", "") if user else "", "full_name": user.get("full_name", "") if user else "", "color": (user.get("avatar_color", "") or "") if user else ""} | tojson }}</script>
|
||||
<script data-cfasync="false" src="/static/js/page_editor_realtime.js?v={{ asset_version }}"></script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -104,7 +104,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script data-cfasync="false">
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
function accountsData() {
|
||||
return {
|
||||
profile: { full_name: '', email: '' },
|
||||
|
||||
+2
-1807
File diff suppressed because it is too large
Load Diff
@@ -113,6 +113,9 @@
|
||||
body.embed-mode .page-editor-wrapper { padding: 8px 16px !important; max-width: 100% !important; }
|
||||
body.embed-mode .page-cover-area { padding-top: 0 !important; }
|
||||
</style>
|
||||
{# A20 : htmx copie les <script nonce="{{ csp_nonce() }}"> des réponses boostées — il remet leur nonce
|
||||
depuis cette config (le nonce de la réponse courante, pas celui du fetch). #}
|
||||
<meta name="htmx-config" content='{"inlineScriptNonce": "{{ csp_nonce() }}"}'>
|
||||
<script src="/static/js/htmx.min.js" data-cfasync="false"></script>
|
||||
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
|
||||
@@ -790,7 +793,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script data-cfasync="false">
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
// Inject CSRF token into HTMX headers
|
||||
(function() {
|
||||
const getCsrf = () => {
|
||||
@@ -2207,7 +2210,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script data-cfasync="false">
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
(function(){
|
||||
var PALETTE_ACTIONS = [
|
||||
{ id:'new-page', icon:'📄', title:'New page', sub:'Create a new page in the current workspace', key:'Ctrl N', run:function(){ return window.FlowDeck && window.FlowDeck.createPage ? (window.FlowDeck.createPage(), true) : false; } },
|
||||
@@ -2378,7 +2381,7 @@
|
||||
|
||||
{# ─── PWA: offline client module + service worker registration (v6.0.0) ─── #}
|
||||
<script src="/static/js/offline.js?v={{ asset_version }}" defer data-cfasync="false"></script>
|
||||
<script data-cfasync="false">
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
(function() {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
window.addEventListener('load', function() {
|
||||
@@ -2392,7 +2395,7 @@
|
||||
</script>
|
||||
|
||||
{# ─── PWA: sync badge + toasts wiring (v6.0.0) ─── #}
|
||||
<script data-cfasync="false">
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (!window.FlowOffline) return;
|
||||
window.FlowOffline.onChange(function(s) {
|
||||
|
||||
+2
-147
@@ -151,151 +151,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script data-cfasync="false">
|
||||
var owner = '{{ owner }}';
|
||||
var repo = '{{ repo }}';
|
||||
var initialView = '{{ initial_view }}';
|
||||
|
||||
// Auto-switch to view from URL param
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (initialView && initialView !== 'kanban') {
|
||||
const tab = document.querySelector(`.view-tab[data-view="${initialView}"]`);
|
||||
if (tab) tab.click();
|
||||
}
|
||||
});
|
||||
|
||||
function setActiveTab(el) {
|
||||
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
}
|
||||
|
||||
function kanbanBoard() {
|
||||
return {
|
||||
collapsedGroups: [],
|
||||
toggleGroup(id) {
|
||||
const idx = this.collapsedGroups.indexOf(id);
|
||||
idx >= 0 ? this.collapsedGroups.splice(idx, 1) : this.collapsedGroups.push(id);
|
||||
},
|
||||
newCard(groupId, status) {
|
||||
document.getElementById('new-issue-form').style.display = 'block';
|
||||
document.querySelector('#new-issue-form').__x.$data.status = status;
|
||||
},
|
||||
newGroup() { console.log('New group'); }
|
||||
};
|
||||
}
|
||||
|
||||
function filterSystem() {
|
||||
return {
|
||||
activeFilters: [],
|
||||
statusFilters: [],
|
||||
showStatusMenu: false,
|
||||
statusOptions: [
|
||||
{ value: 'todo', label: 'To-do', color: 'var(--gray)' },
|
||||
{ value: 'progress', label: 'In progress', color: 'var(--blue)' },
|
||||
{ value: 'done', label: 'Complete', color: 'var(--green)' },
|
||||
],
|
||||
get statusFilterLabel() {
|
||||
return this.statusFilters.length ? this.statusFilters.join(', ') : 'All';
|
||||
},
|
||||
toggleStatus(val) {
|
||||
const idx = this.statusFilters.indexOf(val);
|
||||
idx >= 0 ? this.statusFilters.splice(idx, 1) : this.statusFilters.push(val);
|
||||
},
|
||||
addFilter() {
|
||||
const prop = prompt('Filter by property (status, assignee, label):');
|
||||
if (!prop) return;
|
||||
const val = prompt('Value:');
|
||||
if (!val) return;
|
||||
this.activeFilters.push({ property: prop, value: val });
|
||||
this.refreshView();
|
||||
},
|
||||
removeFilter(i) { this.activeFilters.splice(i, 1); },
|
||||
resetFilters() { this.activeFilters = []; this.statusFilters = []; },
|
||||
refreshView() {
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function sortSystem() {
|
||||
return {
|
||||
sorts: [],
|
||||
showSortPanel: false,
|
||||
addSort() {
|
||||
const field = prompt('Sort by (name, status, assignee, deadline):');
|
||||
if (!field) return;
|
||||
this.sorts.push({ field, dir: 'asc' });
|
||||
this.applySorts();
|
||||
},
|
||||
removeSort(i) { this.sorts.splice(i, 1); this.applySorts(); },
|
||||
toggleDir(i) { this.sorts[i].dir = this.sorts[i].dir === 'asc' ? 'desc' : 'asc'; this.applySorts(); },
|
||||
clearSorts() { this.sorts = []; this.applySorts(); },
|
||||
applySorts() {
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
const url = new URL(activeTab.getAttribute('hx-get'), window.location.origin);
|
||||
this.sorts.forEach(s => url.searchParams.append('sort', s.field + ':' + s.dir));
|
||||
htmx.ajax('GET', url.pathname + url.search, { target: '#view-content', swap: 'innerHTML' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function newIssueForm() {
|
||||
return {
|
||||
title: '', status: 'todo',
|
||||
create() {
|
||||
if (!this.title.trim()) return;
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
this.title = '';
|
||||
this.hide();
|
||||
// Refresh current view
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
});
|
||||
},
|
||||
hide() { document.getElementById('new-issue-form').style.display = 'none'; },
|
||||
show() { document.getElementById('new-issue-form').style.display = 'block'; }
|
||||
};
|
||||
}
|
||||
|
||||
function showNewIssue() {
|
||||
const form = document.getElementById('new-issue-form');
|
||||
form.style.display = form.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Init SortableJS after HTMX swaps
|
||||
document.addEventListener('htmx:afterSwap', function(evt) {
|
||||
if (evt.target.id === 'view-content') {
|
||||
document.querySelectorAll('.kanban-cards').forEach(el => {
|
||||
if (el._sortable) el._sortable.destroy();
|
||||
el._sortable = new Sortable(el, {
|
||||
group: 'kanban',
|
||||
animation: 200,
|
||||
ghostClass: 'sortable-ghost',
|
||||
dragClass: 'sortable-drag',
|
||||
onEnd: function(evt) {
|
||||
const cardId = evt.item.dataset.cardId;
|
||||
const toStatus = evt.to.dataset.status;
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(() => {
|
||||
// ponytail: refresh current view after move
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script type="application/json" id="bd-config" nonce="{{ csp_nonce() }}">{{ {"owner": owner, "repo": repo, "initial_view": initial_view} | tojson }}</script>
|
||||
<script data-cfasync="false" src="/static/js/board.js?v={{ asset_version }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
function openCardDetail(id) {
|
||||
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
|
||||
target: '#card-modal-content', swap: 'innerHTML'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{# Card detail modal content — full issue info + comments #}
|
||||
{% from '_icons.html' import fd_icon %}
|
||||
<div class="card-detail" x-data="cardDetail()">
|
||||
<!-- Title -->
|
||||
<div style="display:flex; align-items:flex-start; gap:12px; margin-bottom:16px;">
|
||||
@@ -98,7 +99,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
// ponytail: CSRF helper
|
||||
function getCsrf() {
|
||||
const m = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
function openCardDetail(id) {
|
||||
htmx.ajax('GET', `/api/issues/${owner}/${repo}/${id}?format=html`, {
|
||||
target: '#card-modal-content', swap: 'innerHTML'
|
||||
|
||||
@@ -35,633 +35,7 @@
|
||||
.gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;}
|
||||
</style>
|
||||
|
||||
<script data-cfasync="false">
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('giteaWorkspace', () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const owner = params.get('owner') || '';
|
||||
const repo = params.get('repo') || '';
|
||||
|
||||
return {
|
||||
owner, repo,
|
||||
showFile: false,
|
||||
showEditor: false,
|
||||
showPrivate: false,
|
||||
privatePages: [],
|
||||
editingPrivate: null,
|
||||
editingPrivateTitle: '',
|
||||
editingPrivateContent: '',
|
||||
filePath: '',
|
||||
fileContent: '',
|
||||
fileSize: 0,
|
||||
fileSha: '',
|
||||
fileLoading: false,
|
||||
fileLanguage: 'text',
|
||||
editContent: '',
|
||||
commitMessage: 'Update via FlowDeck',
|
||||
commitHistory: JSON.parse(localStorage.getItem('fd_commit_msgs') || '[]'),
|
||||
// File tree browser
|
||||
treeItems: [],
|
||||
sortedTreeItems: [],
|
||||
treeLoading: false,
|
||||
folderStack: [],
|
||||
currentPath: '',
|
||||
// Context menu
|
||||
gwCtx: { visible: false, x: 0, y: 0, item: null },
|
||||
|
||||
_sortTree() {
|
||||
this.sortedTreeItems = [...this.treeItems].sort(function(a, b) {
|
||||
if (a.type === b.type) return a.name.localeCompare(b.name);
|
||||
return a.type === 'folder' ? -1 : 1;
|
||||
});
|
||||
},
|
||||
|
||||
init() {
|
||||
// Expose globally for header to access
|
||||
window._gwData = this;
|
||||
// Set cookie for sidebar
|
||||
document.cookie = 'flowdeck_workspace=gitea:' + this.owner + ':' + this.repo + ';path=/;SameSite=Lax';
|
||||
// Load sidebar tree (into gitea section)
|
||||
this.loadSidebarTree();
|
||||
// Load main content tree
|
||||
this.loadMainTree('');
|
||||
// Listen for sidebar clicks on Gitea items
|
||||
this.setupSidebarClicks();
|
||||
},
|
||||
|
||||
async loadMainTree(path) {
|
||||
this.treeLoading = true;
|
||||
this.currentPath = path;
|
||||
try {
|
||||
var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree';
|
||||
if (path) url += '?path=' + encodeURIComponent(path);
|
||||
var r = await fetch(url);
|
||||
if (!r.ok) { this.treeItems = []; this.sortedTreeItems = []; return; }
|
||||
var d = await r.json();
|
||||
this.treeItems = d.tree || [];
|
||||
this._sortTree();
|
||||
} catch(e) { this.treeItems = []; this.sortedTreeItems = []; }
|
||||
finally { this.treeLoading = false; }
|
||||
},
|
||||
|
||||
drillDown(path) {
|
||||
this.folderStack.push(path.split('/').pop());
|
||||
this.loadMainTree(path);
|
||||
},
|
||||
|
||||
navigateToFolder(idx) {
|
||||
// Truncate stack and rebuild path
|
||||
this.folderStack = this.folderStack.slice(0, idx + 1);
|
||||
var path = this.folderStack.join('/');
|
||||
this.loadMainTree(path);
|
||||
},
|
||||
|
||||
navigateToRoot() {
|
||||
this.folderStack = [];
|
||||
this.loadMainTree('');
|
||||
},
|
||||
|
||||
openGwContext(ev, item) {
|
||||
this.gwCtx = { visible: true, x: ev.clientX, y: ev.clientY, item: item };
|
||||
},
|
||||
gwRename() {
|
||||
this.gwCtx.visible = false;
|
||||
var item = this.gwCtx.item;
|
||||
if (!item) return;
|
||||
var newName = prompt('Rename:', item.name);
|
||||
if (!newName || !newName.trim() || newName.trim() === item.name) return;
|
||||
var self = this;
|
||||
var oldPath = item.path;
|
||||
var newPath = oldPath.replace(/[^/]+$/, newName.trim());
|
||||
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
|
||||
method: 'PUT',
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: oldPath, new_path: newPath, message: 'Rename ' + oldPath + ' to ' + newPath })
|
||||
}).then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
if (d.status === 'ok') { self.refreshTree(); }
|
||||
else { window.showToast('Rename failed', 'error'); }
|
||||
})
|
||||
.catch(function(){ window.showToast('Rename failed', 'error'); });
|
||||
},
|
||||
gwDelete() {
|
||||
this.gwCtx.visible = false;
|
||||
var item = this.gwCtx.item;
|
||||
if (!item) return;
|
||||
if (!confirm('Delete ' + item.name + '?')) return;
|
||||
var self = this;
|
||||
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
|
||||
method: 'DELETE'
|
||||
}).then(function(r){
|
||||
if (r.ok) { self.refreshTree(); }
|
||||
else { window.showToast('Delete failed', 'error'); }
|
||||
}).catch(function(){ window.showToast('Delete failed', 'error'); });
|
||||
},
|
||||
|
||||
async loadSidebarTree() {
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree');
|
||||
if (!r.ok) {
|
||||
// If API fails (e.g. no Gitea token), set a message
|
||||
var container = document.getElementById('sidebar-gitea-items');
|
||||
if (container) container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon">'+getSvgIcon('link',14)+'</span><span class="page-name text-dim">Connect Gitea to browse files</span></li>';
|
||||
return;
|
||||
}
|
||||
var d = await r.json();
|
||||
var items = d.tree || [];
|
||||
var container = document.getElementById('sidebar-gitea-items');
|
||||
if (!container) return;
|
||||
// Ensure gitea section is visible
|
||||
if (window.appState && window.appState.sectionsOpen && !window.appState.sectionsOpen.gitea) {
|
||||
window.appState.sectionsOpen.gitea = true;
|
||||
}
|
||||
container.innerHTML = '';
|
||||
// Build tree HTML as string and set once (more performant)
|
||||
var html = '';
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var item = items[i];
|
||||
var icon = item.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
|
||||
html += '<li class="sidebar-item" data-gitea-path="' + item.path + '" data-gitea-type="' + (item.type === 'folder' ? 'folder' : 'file') + '" data-gitea-sha="' + (item.sha || '') + '" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
|
||||
html += '<span class="page-icon">' + icon + '</span>';
|
||||
html += '<span class="page-name">' + item.name + '</span>';
|
||||
html += '</li>';
|
||||
}
|
||||
// Add Private Pages link
|
||||
html += '<li style="border-top:1px solid var(--border);margin:8px 0;"></li>';
|
||||
html += '<li class="sidebar-item" id="gitea-private-link" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
|
||||
html += '<span class="page-icon">'+getSvgIcon('lock',14)+'</span><span class="page-name">Private Pages</span></li>';
|
||||
container.innerHTML = html;
|
||||
// Re-attach event listeners
|
||||
this.setupSidebarClicks();
|
||||
} catch(e) {
|
||||
console.error('Gitea sidebar tree load failed:', e);
|
||||
}
|
||||
},
|
||||
|
||||
setupSidebarClicks() {
|
||||
var self = this;
|
||||
document.addEventListener('click', function(e) {
|
||||
var el = e.target.closest('.sidebar-item[data-gitea-path]');
|
||||
if (!el) return;
|
||||
|
||||
var path = el.getAttribute('data-gitea-path');
|
||||
var type = el.getAttribute('data-gitea-type');
|
||||
|
||||
// If clicking the checkbox, let its own handler deal with selection
|
||||
if (e.target.classList.contains('gitea-checkbox')) return;
|
||||
|
||||
// If clicking the inline rename input, don't navigate
|
||||
if (e.target.classList.contains('inline-rename-input')) return;
|
||||
|
||||
// If Shift or Ctrl/Meta is pressed, use multi-selection
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
self.selectItem(path, el, e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal click: navigate (open file/folder)
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Update visual "active" state
|
||||
document.querySelectorAll('.sidebar-item.active').forEach(function(si) {
|
||||
si.classList.remove('active');
|
||||
});
|
||||
el.classList.add('active');
|
||||
|
||||
if (type === 'folder') {
|
||||
self.loadSubdir(path, el);
|
||||
} else {
|
||||
self.openFile(path, el.getAttribute('data-gitea-sha'));
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for custom delete event on gitea sidebar items
|
||||
document.addEventListener('gitea-delete', function(e) {
|
||||
var el = e.target;
|
||||
var path = el.getAttribute('data-gitea-path');
|
||||
if (!path) return;
|
||||
if (!confirm('Delete ' + path + ' from ' + self.owner + '/' + self.repo + '?')) return;
|
||||
var sha = el.getAttribute('data-gitea-sha') || '';
|
||||
fetch('/api/gitea/projects/' + self.owner + '/' + self.repo + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
|
||||
method: 'DELETE',
|
||||
}).then(function(r) {
|
||||
if (r.ok) self.refreshTree();
|
||||
}).catch(function() {});
|
||||
});
|
||||
},
|
||||
|
||||
async loadSubdir(path, el) {
|
||||
var self = this;
|
||||
// Check if already loaded
|
||||
var ul = el.querySelector('ul');
|
||||
if (ul) {
|
||||
ul.style.display = ul.style.display === 'none' ? '' : 'none';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree?path=' + encodeURIComponent(path));
|
||||
if (!r.ok) return;
|
||||
var d = await r.json();
|
||||
var children = d.tree || [];
|
||||
ul = document.createElement('ul');
|
||||
ul.className = 'sidebar-items';
|
||||
ul.style.paddingLeft = '20px';
|
||||
children.forEach(function(child) {
|
||||
var icon = child.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
|
||||
var li = document.createElement('li');
|
||||
li.className = 'sidebar-item';
|
||||
li.setAttribute('data-gitea-path', child.path);
|
||||
li.setAttribute('data-gitea-type', child.type === 'folder' ? 'folder' : 'file');
|
||||
li.setAttribute('data-gitea-sha', child.sha || '');
|
||||
li.style.cssText = 'cursor:pointer;display:flex;align-items:center;gap:4px;';
|
||||
// Checkbox
|
||||
var cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.className = 'gitea-checkbox';
|
||||
cb.style.cssText = 'flex-shrink:0;margin:0;display:none;';
|
||||
cb.addEventListener('click', function(ev) {
|
||||
ev.stopPropagation();
|
||||
self.selectItem(child.path, li, ev);
|
||||
});
|
||||
li.appendChild(cb);
|
||||
// Icon
|
||||
var iconSpan = document.createElement('span');
|
||||
iconSpan.className = 'sidebar-icon';
|
||||
iconSpan.innerHTML = icon; // icon = HTML SVG from getSvgIcon(), NOT text
|
||||
li.appendChild(iconSpan);
|
||||
// Name
|
||||
var nameSpan = document.createElement('span');
|
||||
nameSpan.textContent = child.name;
|
||||
nameSpan.addEventListener('dblclick', function(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
self.startInlineRename(nameSpan, child.path, li);
|
||||
});
|
||||
li.appendChild(nameSpan);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
el.appendChild(ul);
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
async openFile(path, sha) {
|
||||
this.filePath = path;
|
||||
this.fileSha = sha || '';
|
||||
this.showEditor = false;
|
||||
this.showFile = true;
|
||||
this.fileLoading = true;
|
||||
this.fileLanguage = this.getLanguage(path);
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(path));
|
||||
if (r.ok) {
|
||||
var d = await r.json();
|
||||
this.fileContent = d.content || '';
|
||||
this.fileSize = d.content ? d.content.length : 0;
|
||||
} else {
|
||||
this.fileContent = '[Error loading file]';
|
||||
}
|
||||
} catch(e) {
|
||||
this.fileContent = '[Error loading file]';
|
||||
}
|
||||
this.fileLoading = false;
|
||||
// Highlight after Alpine renders
|
||||
var self = this;
|
||||
this.$nextTick(function() {
|
||||
var block = self.$refs.codeBlock;
|
||||
if (block && block.textContent && typeof Prism !== 'undefined') {
|
||||
Prism.highlightElement(block);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getLanguage(path) {
|
||||
var ext = (path || '').split('.').pop().toLowerCase();
|
||||
var map = {
|
||||
js:'javascript', jsx:'javascript', ts:'typescript', tsx:'typescript',
|
||||
py:'python', rb:'ruby', rs:'rust', go:'go', java:'java', kt:'kotlin',
|
||||
c:'c', cpp:'c', h:'c', hpp:'c', cs:'csharp',
|
||||
html:'markup', htm:'markup', xml:'markup', svg:'markup', css:'css', scss:'css',
|
||||
json:'json', yaml:'yaml', yml:'yaml', toml:'toml', ini:'ini',
|
||||
md:'markdown', sql:'sql', sh:'bash', bash:'bash', zsh:'bash',
|
||||
php:'php', swift:'swift', r:'r', lua:'lua', dart:'dart',
|
||||
dockerfile:'docker', makefile:'makefile', cmake:'cmake',
|
||||
};
|
||||
return map[ext] || 'text';
|
||||
},
|
||||
|
||||
openEditor() {
|
||||
this.editContent = this.fileContent;
|
||||
this.showEditor = true;
|
||||
},
|
||||
|
||||
async saveFile() {
|
||||
if (!this.filePath) return;
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken()},
|
||||
body: JSON.stringify({
|
||||
path: this.filePath, content: this.editContent,
|
||||
message: this.commitMessage, sha: this.fileSha,
|
||||
})
|
||||
});
|
||||
if (r.ok) {
|
||||
this.fileContent = this.editContent;
|
||||
this.showEditor = false;
|
||||
if (!this.commitHistory.includes(this.commitMessage)) {
|
||||
this.commitHistory.unshift(this.commitMessage);
|
||||
if (this.commitHistory.length > 10) this.commitHistory.pop();
|
||||
localStorage.setItem('fd_commit_msgs', JSON.stringify(this.commitHistory));
|
||||
}
|
||||
} else {
|
||||
var d = await r.json();
|
||||
window.showToast('Failed to save: ' + (d.error || 'Unknown error'), 'error');
|
||||
}
|
||||
} catch(e) { window.showToast('Network error', 'error'); }
|
||||
},
|
||||
|
||||
async deleteCurrentFile() {
|
||||
if (!this.filePath) return;
|
||||
if (!confirm('Delete ' + this.filePath + ' from ' + this.owner + '/' + this.repo + '?')) return;
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(this.filePath) + '&sha=' + encodeURIComponent(this.fileSha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (r.ok) {
|
||||
this.showFile = false;
|
||||
this.filePath = '';
|
||||
await this.refreshTree();
|
||||
}
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
async refreshTree() {
|
||||
// Reload main tree and sidebar tree without full page reload
|
||||
this.loadMainTree(this.currentPath);
|
||||
this.loadSidebarTree();
|
||||
},
|
||||
|
||||
formatSize(bytes) {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB';
|
||||
return (bytes/1048576).toFixed(1) + ' MB';
|
||||
},
|
||||
|
||||
// ── Private Pages ──
|
||||
|
||||
async loadPrivatePages() {
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages');
|
||||
if (r.ok) { var d = await r.json(); this.privatePages = d.pages || []; }
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
newPrivate() {
|
||||
this.editingPrivate = 'new';
|
||||
this.editingPrivateTitle = '';
|
||||
this.editingPrivateContent = '';
|
||||
},
|
||||
|
||||
async openPrivate(id) {
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id);
|
||||
if (r.ok) {
|
||||
var d = await r.json();
|
||||
this.editingPrivate = id;
|
||||
this.editingPrivateTitle = d.page.title;
|
||||
this.editingPrivateContent = d.page.content;
|
||||
}
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
async savePrivate() {
|
||||
var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages';
|
||||
var method = 'POST';
|
||||
if (this.editingPrivate !== 'new') {
|
||||
url += '/' + this.editingPrivate;
|
||||
method = 'PUT';
|
||||
}
|
||||
try {
|
||||
var r = await fetch(url, {
|
||||
method, headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''},
|
||||
body: JSON.stringify({title: this.editingPrivateTitle, content: this.editingPrivateContent})
|
||||
});
|
||||
if (r.ok) {
|
||||
this.editingPrivate = null;
|
||||
this.editingPrivateTitle = '';
|
||||
this.editingPrivateContent = '';
|
||||
await this.loadPrivatePages();
|
||||
}
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
// Create new file
|
||||
showNewFile: false,
|
||||
newFilePath: '',
|
||||
newFileContent: '',
|
||||
newFileMsg: 'Create via FlowDeck',
|
||||
async createNewFile() {
|
||||
if (!this.newFilePath.trim()) return;
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type':'application/json', 'X-CSRF-Token': this.getCsrfToken()},
|
||||
body: JSON.stringify({path: this.newFilePath.trim(), content: this.newFileContent, message: this.newFileMsg || 'Create via FlowDeck'})
|
||||
});
|
||||
if (r.ok) {
|
||||
this.showNewFile = false;
|
||||
this.newFilePath = '';
|
||||
this.newFileContent = '';
|
||||
this.newFileMsg = 'Create via FlowDeck';
|
||||
await this.loadSidebarTree();
|
||||
} else {
|
||||
var d = await r.json();
|
||||
window.showToast('Failed: ' + (d.error || d.detail || 'Unknown error'), 'error');
|
||||
}
|
||||
} catch(e) { window.showToast('Error: ' + e.message, 'error'); }
|
||||
},
|
||||
|
||||
// Upload files
|
||||
triggerUpload() { document.getElementById('gitea-upload-input').click(); },
|
||||
async doUpload(ev) {
|
||||
var files = ev.target.files;
|
||||
if (!files.length) return;
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
var form = new FormData();
|
||||
form.append('file', files[i]);
|
||||
form.append('message', 'Upload ' + files[i].name + ' via FlowDeck');
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/upload', {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRF-Token': this.getCsrfToken()},
|
||||
body: form
|
||||
});
|
||||
if (!r.ok) { var d = await r.json(); console.error('Upload failed:', d.error); }
|
||||
} catch(e) { console.error('Upload error:', e); }
|
||||
}
|
||||
await this.loadSidebarTree();
|
||||
ev.target.value = '';
|
||||
},
|
||||
|
||||
async deletePrivate(id) {
|
||||
if (!confirm('Delete this private page?')) return;
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id, {
|
||||
method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''}
|
||||
});
|
||||
if (r.ok) await this.loadPrivatePages();
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
getCsrfToken() {
|
||||
return document.cookie.split('; ').find(function(c) { return c.startsWith('csrf_token='); })?.split('=')[1] || '';
|
||||
},
|
||||
|
||||
// ── Multi-selection ──
|
||||
multiSelect: false,
|
||||
selectedPaths: [],
|
||||
lastClickedPath: null,
|
||||
|
||||
toggleMultiSelect() {
|
||||
this.multiSelect = !this.multiSelect;
|
||||
var cbs = document.querySelectorAll('.gitea-checkbox');
|
||||
var self = this;
|
||||
cbs.forEach(function(cb) { cb.style.display = self.multiSelect ? '' : 'none'; });
|
||||
if (!this.multiSelect) {
|
||||
// Clear selection when leaving multi-select mode
|
||||
cbs.forEach(function(cb) { cb.checked = false; });
|
||||
this.selectedPaths = [];
|
||||
this.lastClickedPath = null;
|
||||
// Remove selected class
|
||||
document.querySelectorAll('.sidebar-item.gitea-selected').forEach(function(el) {
|
||||
el.classList.remove('gitea-selected');
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
selectItem(path, li, ev) {
|
||||
if (ev.shiftKey && this.lastClickedPath) {
|
||||
// Range select
|
||||
var all = Array.from(document.querySelectorAll('.sidebar-item[data-gitea-path]'));
|
||||
var startIdx = all.findIndex(function(el) { return el.getAttribute('data-gitea-path') === this.lastClickedPath; }.bind(this));
|
||||
var endIdx = all.findIndex(function(el) { return el.getAttribute('data-gitea-path') === path; }.bind(this));
|
||||
if (startIdx >= 0 && endIdx >= 0) {
|
||||
var lo = Math.min(startIdx, endIdx);
|
||||
var hi = Math.max(startIdx, endIdx);
|
||||
this.selectedPaths = [];
|
||||
for (var i = lo; i <= hi; i++) {
|
||||
this.selectedPaths.push(all[i].getAttribute('data-gitea-path'));
|
||||
all[i].classList.add('gitea-selected');
|
||||
var cb = all[i].querySelector('.gitea-checkbox');
|
||||
if (cb) cb.checked = true;
|
||||
}
|
||||
}
|
||||
this.multiSelect = true;
|
||||
this.toggleMultiSelect(); // ensure checkboxes are visible
|
||||
} else if (ev.ctrlKey || ev.metaKey) {
|
||||
// Toggle single
|
||||
var idx = this.selectedPaths.indexOf(path);
|
||||
if (idx >= 0) {
|
||||
this.selectedPaths.splice(idx, 1);
|
||||
li.classList.remove('gitea-selected');
|
||||
var cb = li.querySelector('.gitea-checkbox');
|
||||
if (cb) cb.checked = false;
|
||||
} else {
|
||||
this.selectedPaths.push(path);
|
||||
li.classList.add('gitea-selected');
|
||||
var cb = li.querySelector('.gitea-checkbox');
|
||||
if (cb) cb.checked = true;
|
||||
}
|
||||
this.multiSelect = this.selectedPaths.length > 0;
|
||||
this.toggleMultiSelect();
|
||||
} else {
|
||||
// Normal select — clear multi-selection
|
||||
this.selectedPaths = [path];
|
||||
this.lastClickedPath = path;
|
||||
document.querySelectorAll('.sidebar-item.gitea-selected').forEach(function(el) {
|
||||
el.classList.remove('gitea-selected');
|
||||
});
|
||||
li.classList.add('gitea-selected', 'active');
|
||||
var cb = li.querySelector('.gitea-checkbox');
|
||||
if (cb) cb.checked = true;
|
||||
}
|
||||
},
|
||||
|
||||
isSelected(path) {
|
||||
return this.selectedPaths.indexOf(path) >= 0;
|
||||
},
|
||||
|
||||
// ── Inline rename ──
|
||||
startInlineRename(nameSpan, path, li) {
|
||||
var originalName = nameSpan.textContent;
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = originalName;
|
||||
input.className = 'inline-rename-input';
|
||||
input.style.cssText = 'flex:1;min-width:0;padding:2px 6px;background:var(--bg-tertiary);border:1px solid var(--accent);border-radius:4px;color:var(--text);font-size:13px;outline:none;';
|
||||
nameSpan.replaceWith(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
var self = this;
|
||||
var finish = function() { self.finishInlineRename(input, originalName, path, li); };
|
||||
var cancel = function() {
|
||||
var span = document.createElement('span');
|
||||
span.className = 'page-name';
|
||||
span.textContent = originalName;
|
||||
input.replaceWith(span);
|
||||
};
|
||||
input.addEventListener('blur', finish);
|
||||
input.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') finish();
|
||||
if (e.key === 'Escape') { e.stopPropagation(); cancel(); }
|
||||
});
|
||||
},
|
||||
|
||||
finishInlineRename(input, originalName, path, li) {
|
||||
if (input._renaming) return; // guard against double-fire
|
||||
input._renaming = true;
|
||||
var newName = input.value.trim();
|
||||
if (!newName || newName === originalName) {
|
||||
var span = document.createElement('span');
|
||||
span.className = 'page-name';
|
||||
span.textContent = originalName;
|
||||
input.replaceWith(span);
|
||||
return;
|
||||
}
|
||||
var self = this;
|
||||
// Construct new path by replacing the last segment
|
||||
var parts = path.split('/');
|
||||
parts.pop();
|
||||
var newPath = (parts.length > 0 ? parts.join('/') + '/' : '') + newName;
|
||||
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/rename', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken()},
|
||||
body: JSON.stringify({old_path: path, new_path: newPath, new_name: newName})
|
||||
}).then(function(r) {
|
||||
if (r.ok) {
|
||||
self.refreshTree();
|
||||
} else {
|
||||
var span = document.createElement('span');
|
||||
span.className = 'page-name';
|
||||
span.textContent = originalName;
|
||||
input.replaceWith(span);
|
||||
window.showToast('Rename failed', 'error');
|
||||
}
|
||||
}).catch(function() {
|
||||
var span = document.createElement('span');
|
||||
span.className = 'page-name';
|
||||
span.textContent = originalName;
|
||||
input.replaceWith(span);
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/gitea_workspace.js?v={{ asset_version }}"></script>
|
||||
<div class="gw-main" x-data="giteaWorkspace">
|
||||
<!-- File view -->
|
||||
<div x-show="showFile && !showEditor" x-transition>
|
||||
|
||||
+1
-159
@@ -210,164 +210,6 @@ select,input[type=text]{background:var(--bg3);color:var(--text);border:1px solid
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script data-cfasync="false">
|
||||
function importWizard() {
|
||||
return {
|
||||
sources: [],
|
||||
sourceId: '',
|
||||
files: [],
|
||||
dragOver: false,
|
||||
mode: 'skip',
|
||||
busy: false,
|
||||
progress: 0,
|
||||
error: '',
|
||||
preview: null,
|
||||
report: null,
|
||||
mapping: {},
|
||||
csrf: '',
|
||||
urlValue: '',
|
||||
urlError: '',
|
||||
forgeProvider: 'gitea',
|
||||
forgeOwner: '',
|
||||
forgeRepo: '',
|
||||
forgeState: 'all',
|
||||
forgeError: '',
|
||||
types: ['text','number','date','checkbox','email','url','phone','select','multi_select','status'],
|
||||
async init() {
|
||||
try {
|
||||
const r = await fetch('/api/import/sources');
|
||||
this.sources = (await r.json()).sources || [];
|
||||
} catch(e) {}
|
||||
try {
|
||||
const c = await fetch('/api/csrf-token');
|
||||
this.csrf = (await c.json()).csrf_token;
|
||||
} catch(e) {}
|
||||
},
|
||||
humanSize(n) { return n > 1048576 ? (n/1048576).toFixed(1)+' Mo' : Math.max(1, Math.round(n/1024))+' Ko'; },
|
||||
addFiles(list) {
|
||||
for (const f of list) this.files.push({file:f, name:f.name, size:f.size, status:'queued'});
|
||||
this.preview = null; this.report = null; this.error = '';
|
||||
},
|
||||
onFiles(e) { this.addFiles(e.target.files); e.target.value=''; },
|
||||
onDrop(e) { this.dragOver=false; this.addFiles(e.dataTransfer.files); },
|
||||
buildForm(f) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f.file);
|
||||
if (this.sourceId) fd.append('source', this.sourceId);
|
||||
fd.append('mode', this.mode);
|
||||
return fd;
|
||||
},
|
||||
async doPreview() {
|
||||
this.busy = true; this.error=''; this.report=null; this.progress=10;
|
||||
try {
|
||||
let merged = null;
|
||||
for (let i=0;i<this.files.length;i++) {
|
||||
const r = await fetch('/api/import/preview', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:this.buildForm(this.files[i])});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { this.error = (this.files[i].name+': '+(d.detail||'Erreur')); continue; }
|
||||
if (!merged) merged = {source_label:d.source_label, pages:[], warnings:[], stats:{}};
|
||||
merged.pages.push(...(d.pages||[]));
|
||||
merged.warnings.push(...(d.warnings||[]));
|
||||
this.progress = Math.round(((i+1)/this.files.length)*100);
|
||||
}
|
||||
this.preview = merged;
|
||||
this.mapping = {};
|
||||
(merged ? merged.pages : []).forEach(p => (p.schema||[]).forEach(c => { this.mapping[c.name]=c.type; }));
|
||||
} catch(e) { this.error = 'Erreur réseau'; }
|
||||
finally { this.busy = false; setTimeout(()=>{this.progress=0;},800); }
|
||||
},
|
||||
async importOne(f) {
|
||||
f.status = 'running';
|
||||
const fd = this.buildForm(f);
|
||||
if (Object.keys(this.mapping).length) fd.append('mapping', JSON.stringify(this.mapping));
|
||||
const big = f.size > 5*1024*1024;
|
||||
try {
|
||||
if (big) {
|
||||
fd.append('async','true');
|
||||
const r = await fetch('/api/import/run', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:fd});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; }
|
||||
return await this.pollJob(d.job_id, f);
|
||||
}
|
||||
const r = await fetch('/api/import/run', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:fd});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; }
|
||||
f.status = (d.status==='partial' ? 'partial' : 'done');
|
||||
return d;
|
||||
} catch(e) { f.status='error'; f.error='Erreur réseau'; return null; }
|
||||
},
|
||||
async doImport() {
|
||||
this.busy = true; this.error=''; this.report=null; this.progress=0;
|
||||
const aggregate = {pages_created:0,pages_updated:0,collections_created:0,rows_created:0,
|
||||
attachments:0,skipped:0,warnings:[],errors:[],per_file:[]};
|
||||
for (let i=0;i<this.files.length;i++) {
|
||||
const d = await this.importOne(this.files[i]);
|
||||
if (d) {
|
||||
for (const k of ['pages_created','pages_updated','collections_created','rows_created','attachments','skipped'])
|
||||
aggregate[k] += (d[k]||0);
|
||||
aggregate.warnings.push(...(d.warnings||[]));
|
||||
aggregate.errors.push(...(d.errors||[]));
|
||||
aggregate.per_file.push({filename:this.files[i].name, report:d});
|
||||
} else {
|
||||
aggregate.errors.push({title:this.files[i].name, error:this.files[i].error||'Erreur'});
|
||||
aggregate.per_file.push({filename:this.files[i].name, report:{status:'error'}});
|
||||
}
|
||||
this.progress = Math.round(((i+1)/this.files.length)*100);
|
||||
}
|
||||
this.report = aggregate;
|
||||
this.busy = false;
|
||||
},
|
||||
async doUrl() {
|
||||
this.busy = true; this.urlError = ''; this.report = null; this.progress = 40;
|
||||
try {
|
||||
const r = await fetch('/api/import/url', {
|
||||
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
|
||||
body: JSON.stringify({url:this.urlValue.trim()})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { this.urlError = d.detail || 'Erreur'; return; }
|
||||
this.report = d; this.progress = 100;
|
||||
} catch(e) { this.urlError = 'Erreur réseau'; }
|
||||
finally { this.busy = false; }
|
||||
},
|
||||
async doForge(kind) {
|
||||
this.busy = true; this.forgeError = ''; this.report = null; this.progress = 30;
|
||||
const endpoint = kind === 'repo' ? '/api/import/forge-repo' : '/api/import/forge';
|
||||
try {
|
||||
const r = await fetch(endpoint, {
|
||||
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
|
||||
body: JSON.stringify({
|
||||
provider:this.forgeProvider, owner:this.forgeOwner.trim(),
|
||||
repo:this.forgeRepo.trim(), state:this.forgeState
|
||||
})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { this.forgeError = d.detail || 'Erreur'; return; }
|
||||
this.report = d; this.progress = 100;
|
||||
} catch(e) { this.forgeError = 'Erreur réseau'; }
|
||||
finally { this.busy = false; }
|
||||
},
|
||||
async pollJob(jobId, f) {
|
||||
for (let i=0;i<600;i++) {
|
||||
await new Promise(res => setTimeout(res, 700));
|
||||
const r = await fetch('/api/import/jobs/'+jobId);
|
||||
const j = await r.json();
|
||||
if (j.status === 'done') { if (f) f.status='done'; return j.report; }
|
||||
if (j.status === 'error') { if (f) { f.status='error'; f.error=j.error; } return null; }
|
||||
}
|
||||
if (f) { f.status='error'; f.error='Délai dépassé'; }
|
||||
return null;
|
||||
},
|
||||
downloadReport() {
|
||||
const blob = new Blob([JSON.stringify(this.report, null, 2)], {type:'application/json'});
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'flowdeck-import-report.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/import.js?v={{ asset_version }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -206,7 +206,7 @@
|
||||
FlowDeck v4.0.1 — Open source · Self-hosted · Notion-compatible
|
||||
</footer>
|
||||
|
||||
<script data-cfasync="false">
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
(function() {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
window.addEventListener('load', function() {
|
||||
|
||||
+1
-1040
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
{% endblock %} {% block content %}
|
||||
{% include "_database_table.html" %}
|
||||
{% endblock %} {% block scripts %}
|
||||
<script data-cfasync="false">
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
// Initialize database table from server-rendered data
|
||||
window.__DB_PAGE_ID = {{ page.id }};
|
||||
window.__DB_COLLECTION_ID = {{ page.collection_id or 0 }};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% block topbar %}{% endblock %}
|
||||
{% block content %}
|
||||
{% include '_page_editor_content.html' %}
|
||||
<script data-cfasync="false">document.body.classList.add('embed-mode');</script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">document.body.classList.add('embed-mode');</script>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
{% include '_page_editor_scripts.html' %}
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
</footer>
|
||||
|
||||
<script src="/static/js/katex.min.js?v=0.16.11"></script>
|
||||
<script data-cfasync="false">
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (window.katex) {
|
||||
document.querySelectorAll('div[data-katex]').forEach(function (el) {
|
||||
|
||||
+2
-1094
File diff suppressed because it is too large
Load Diff
@@ -99,7 +99,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
function tableView() {
|
||||
return {
|
||||
sortField: '',
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
<div class="legend-item"><span class="legend-dot" style="background:var(--green);"></span> Complete</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script nonce="{{ csp_nonce() }}">
|
||||
function teamLoad() {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script data-cfasync="false">
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
function trashData() {
|
||||
return {
|
||||
search: '',
|
||||
|
||||
@@ -133,7 +133,7 @@ h1{font-size:22px;font-weight:700;margin-bottom:6px;}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script data-cfasync="false">
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
function onboarding() {
|
||||
return {
|
||||
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script data-cfasync="false">
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
function workspacePage() {
|
||||
return {
|
||||
builtinProjects: [],
|
||||
|
||||
@@ -172,158 +172,5 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script data-cfasync="false">
|
||||
function workspacesPage() {
|
||||
return {
|
||||
workspaces: [],
|
||||
activeId: null,
|
||||
showCreate: false,
|
||||
showRename: false,
|
||||
wsName: '',
|
||||
renameTarget: null,
|
||||
|
||||
// Gitea
|
||||
giteaStatus: 'loading', // loading|ok|not_linked|error
|
||||
giteaGroups: [],
|
||||
giteaTotal: 0,
|
||||
activeOrg: 'all',
|
||||
giteaSearch: '',
|
||||
|
||||
get filteredGroups() {
|
||||
var self = this;
|
||||
var q = (this.giteaSearch || '').toLowerCase();
|
||||
var groups = this.activeOrg === 'all' ? this.giteaGroups : this.giteaGroups.filter(function(g) { return g.org === self.activeOrg; });
|
||||
if (!q) return groups;
|
||||
return groups.map(function(g) {
|
||||
return {org: g.org, avatar: g.avatar, projects: g.projects.filter(function(p) {
|
||||
return p.name.toLowerCase().includes(q) || (p.description||'').toLowerCase().includes(q);
|
||||
})};
|
||||
}).filter(function(g) { return g.projects.length > 0; });
|
||||
},
|
||||
|
||||
async init() {
|
||||
await this.load();
|
||||
await this.loadGitea();
|
||||
},
|
||||
|
||||
async load() {
|
||||
const r = await fetch('/api/workspaces');
|
||||
const d = await r.json();
|
||||
this.workspaces = d.workspaces || [];
|
||||
this.activeId = d.active_id;
|
||||
},
|
||||
|
||||
async selectLocal(ws) {
|
||||
await fetch(`/api/workspaces/${ws.id}/select`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'POST'});
|
||||
window.location = '/local-workspace';
|
||||
},
|
||||
|
||||
async doCreate() {
|
||||
if (!this.wsName.trim()) return;
|
||||
await fetch('/api/workspaces', {
|
||||
method:'POST',
|
||||
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body:JSON.stringify({name:this.wsName.trim()})
|
||||
});
|
||||
this.wsName = '';
|
||||
this.showCreate = false;
|
||||
await this.load();
|
||||
},
|
||||
|
||||
renameWs(ws) {
|
||||
this.renameTarget = ws;
|
||||
this.wsName = ws.name;
|
||||
this.showRename = true;
|
||||
},
|
||||
|
||||
async doRename() {
|
||||
if (!this.wsName.trim()||!this.renameTarget) return;
|
||||
await fetch(`/api/workspaces/${this.renameTarget.id}`, {
|
||||
method:'PUT',
|
||||
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body:JSON.stringify({name:this.wsName.trim()})
|
||||
});
|
||||
this.wsName = '';
|
||||
this.showRename = false;
|
||||
this.renameTarget = null;
|
||||
await this.load();
|
||||
},
|
||||
|
||||
async deleteWs(ws) {
|
||||
if (!confirm(`Delete workspace "${ws.name}" and all its pages?`)) return;
|
||||
await fetch(`/api/workspaces/${ws.id}`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE'});
|
||||
await this.load();
|
||||
},
|
||||
|
||||
// ── Gitea ──
|
||||
|
||||
async loadGitea() {
|
||||
this.giteaStatus = 'loading';
|
||||
try {
|
||||
// Get orgs
|
||||
const orgsRes = await fetch('/api/gitea/orgs');
|
||||
if (orgsRes.status === 401) { this.giteaStatus = 'not_linked'; return; }
|
||||
if (!orgsRes.ok) { this.giteaStatus = 'error'; return; }
|
||||
const orgsData = await orgsRes.json();
|
||||
const orgs = orgsData.orgs || [];
|
||||
|
||||
// Fetch repos: user repos + org repos
|
||||
const groups = [];
|
||||
|
||||
// Get username for the "personal" tab
|
||||
let myLogin = 'Me';
|
||||
try {
|
||||
const meRes = await fetch('/auth/user');
|
||||
if (meRes.ok) {
|
||||
const meData = await meRes.json();
|
||||
myLogin = meData.user?.login || 'Me';
|
||||
if (myLogin.includes('_')) myLogin = myLogin.split('_').pop(); // strip gitea_ prefix if present
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// User repos (no org filter)
|
||||
try {
|
||||
const userRes = await fetch('/api/gitea/projects');
|
||||
if (userRes.ok) {
|
||||
const d = await userRes.json();
|
||||
const repos = d.projects || [];
|
||||
if (repos.length) {
|
||||
groups.push({org: myLogin, avatar: '', projects: repos.map(r => ({...r, owner: r.owner || {login: myLogin}}))});
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// Org repos
|
||||
for (const org of orgs) {
|
||||
try {
|
||||
const res = await fetch('/api/gitea/projects?org=' + encodeURIComponent(org.username || org.login || org.name));
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
if (d.projects && d.projects.length) {
|
||||
groups.push({org: org.username || org.login || org.name, avatar: org.avatar_url || '', projects: d.projects});
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
this.giteaGroups = groups;
|
||||
this.giteaTotal = groups.reduce((sum, g) => sum + g.projects.length, 0);
|
||||
this.giteaStatus = 'ok';
|
||||
} catch(e) {
|
||||
this.giteaStatus = 'error';
|
||||
}
|
||||
},
|
||||
|
||||
openGitea(proj) {
|
||||
const owner = proj.owner?.login || proj.owner?.username || proj.full_name?.split('/')[0] || '';
|
||||
const repo = proj.name;
|
||||
if (owner && repo) {
|
||||
// Set workspace cookie so sidebar shows tree
|
||||
document.cookie = 'flowdeck_workspace=gitea:' + owner + ':' + repo + ';path=/;SameSite=Lax';
|
||||
window.location = `/gitea-workspace?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/workspaces.js?v={{ asset_version }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,9 +7,15 @@ les `|safe` du codebase étaient des no-op.
|
||||
from __future__ import annotations
|
||||
|
||||
import pathlib
|
||||
from contextvars import ContextVar
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
# A20 : nonce de script par requête, posé par le middleware CSP, lu par les
|
||||
# templates via `{{ csp_nonce() }}` (vide hors requête — pas de header CSP
|
||||
# dans ce cas, donc rien n'est bloqué).
|
||||
CSP_NONCE: ContextVar[str] = ContextVar("csp_nonce", default="")
|
||||
|
||||
ENV = Environment(
|
||||
loader=FileSystemLoader("app/templates"),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
@@ -26,3 +32,4 @@ except OSError: # pragma: no cover
|
||||
ASSET_VERSION = "dev"
|
||||
|
||||
ENV.globals["asset_version"] = ASSET_VERSION
|
||||
ENV.globals["csp_nonce"] = lambda: CSP_NONCE.get()
|
||||
|
||||
+518
-84
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "FlowDeck",
|
||||
"version": "7.5.0"
|
||||
"version": "7.23.0"
|
||||
},
|
||||
"paths": {
|
||||
"/auth/register": {
|
||||
@@ -10765,47 +10765,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/library/local-workspace-children/{item_id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"library"
|
||||
],
|
||||
"summary": "Library Local Workspace Children",
|
||||
"description": "Return children of a local workspace item for tree expansion.",
|
||||
"operationId": "library_local_workspace_children_api_library_local_workspace_children__item_id__get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "item_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"title": "Item Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/library/children/{page_id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -10899,48 +10858,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/library/local-workspace": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"library"
|
||||
],
|
||||
"summary": "Library Local Workspace",
|
||||
"description": "Return local workspace items (files/folders) formatted for Library display.",
|
||||
"operationId": "library_local_workspace_api_library_local_workspace_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "workspace_id",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 0,
|
||||
"title": "Workspace Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/library/workspace": {
|
||||
"get": {
|
||||
"tags": [
|
||||
@@ -16725,6 +16642,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -16974,6 +16902,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -17123,6 +17062,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -17233,6 +17183,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -17397,6 +17358,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -17462,6 +17434,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -17617,6 +17600,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -17727,6 +17721,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -17837,6 +17842,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -18003,6 +18019,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -18178,6 +18205,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -18288,6 +18326,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -18454,6 +18503,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -18564,6 +18624,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -18674,6 +18745,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -18849,6 +18931,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -18905,6 +18998,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -19015,6 +19119,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -19062,6 +19177,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -19109,6 +19235,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -19219,6 +19356,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -19275,6 +19423,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -19385,6 +19544,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -19495,6 +19665,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -19551,6 +19732,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -19715,6 +19907,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -19771,6 +19974,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -19881,6 +20095,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -20125,6 +20350,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -20217,6 +20453,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -20365,6 +20612,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -20421,6 +20679,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -20531,6 +20800,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -20753,6 +21033,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -20809,6 +21100,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -20919,6 +21221,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -21085,6 +21398,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -21195,6 +21519,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -21251,6 +21586,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -21361,6 +21707,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -21592,6 +21949,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -21648,6 +22016,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -21861,6 +22240,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -22367,6 +22757,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -22618,6 +23019,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -22674,6 +23086,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
@@ -22961,6 +23384,17 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"default": {},
|
||||
"title": "Body"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
|
||||
/* ═════════════════════════════════════════════════════════════════════
|
||||
fdCtx — store Alpine UNIQUE du menu contextuel partagé.
|
||||
Enregistré dans 'alpine:init' avec garde d'exécution : que la page
|
||||
inclue ce partial dans base.html ou directement, le js ne s'exécute
|
||||
qu'une seule fois. Chaque page, à l'ouverture du menu, positionne :
|
||||
fdCtx.openMenu(evt, node, pageHash, handlers)
|
||||
─────────────────────────────────────────────────────────────────── */
|
||||
(function () {
|
||||
if (window.__fdCtxMenuRegistered) return;
|
||||
window.__fdCtxMenuRegistered = true;
|
||||
|
||||
// Sur un chargement complet de page, ce script inline s'exécute AVANT
|
||||
// alpine.min.js (defer) → Alpine n'existe pas encore : on attend 'alpine:init'.
|
||||
// Sur une navigation partielle (fdNavigate → htmx), Alpine est déjà démarré et
|
||||
// 'alpine:init' ne sera plus jamais émis → on enregistre le store tout de suite,
|
||||
// sinon le menu contextuel reste mort jusqu'au prochain chargement complet.
|
||||
function registerFdCtx() {
|
||||
if (!window.Alpine) return;
|
||||
if (window.Alpine.__fdCtxStore) return;
|
||||
window.Alpine.__fdCtxStore = true;
|
||||
|
||||
Alpine.store('fdCtx', {
|
||||
open: false, x: 0, y: 0, node: null, page: null,
|
||||
maxH: 0, _cx: 0, _cy: 0, _ro: null,
|
||||
handlers: {},
|
||||
/* Tags (workspace) */
|
||||
availTags: [], tagAdding: false, tagExistingOpen: false,
|
||||
newTagColor: '#787774',
|
||||
tagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
|
||||
/* Icon picker */
|
||||
iconOpen: false,
|
||||
iconChoices: ['📄','📝','📕','📁','⭐','🔖','📌','✅','💡','🔥','🚀','🎯','📊','🗓️','🔗','🧩','📎','🎨','🐛','⚙️','❤️','👍','✨','🏷️','🗒️','📚'],
|
||||
|
||||
/* Positionne le menu à l'écran et expose les handlers de la page.
|
||||
⚠️ Nom : openMenu (et PAS open) — « open » est réservé au booléen
|
||||
d'état x-show. Une collision ici rendrait le menu PERMANENT. */
|
||||
openMenu(ev, node, pageHash, handlers) {
|
||||
handlers = handlers || {};
|
||||
this.node = node;
|
||||
this.page = pageHash;
|
||||
this.handlers = handlers;
|
||||
this.tagAdding = false;
|
||||
this.tagExistingOpen = false;
|
||||
this.iconOpen = false;
|
||||
this.newTagColor = (node && node.tags && node.tags[0] && node.tags[0].color) || '#787774';
|
||||
var cx = (ev && ev.clientX) || 0;
|
||||
var cy = (ev && ev.clientY) || 0;
|
||||
this._cx = cx;
|
||||
this._cy = cy;
|
||||
this.x = Math.max(8, Math.min(cx, window.innerWidth - 240));
|
||||
this.y = Math.max(8, Math.min(cy, window.innerHeight - 48));
|
||||
this.open = true;
|
||||
var self = this;
|
||||
/* Mesure la taille réelle du menu (une fois rendu) puis le replace
|
||||
pour qu'il reste TOUJOURS entièrement visible dans le viewport.
|
||||
Un ResizeObserver relance le placement à chaque fois que le menu
|
||||
change de taille (ouverture d'un sous-menu « tag », icônes, …),
|
||||
sinon il grandissait vers le bas et sortait de l'écran. */
|
||||
var after = function () {
|
||||
self._place();
|
||||
var el = document.querySelector('.fd-ctx-menu');
|
||||
if (el && window.ResizeObserver) {
|
||||
if (self._ro) { try { self._ro.disconnect(); } catch (e) {} }
|
||||
self._ro = new ResizeObserver(function () { self._place(); });
|
||||
self._ro.observe(el);
|
||||
}
|
||||
};
|
||||
if (window.Alpine && window.Alpine.nextTick) window.Alpine.nextTick(after);
|
||||
else setTimeout(after, 0);
|
||||
},
|
||||
/* Reclasse le menu dans le viewport et limite sa hauteur à l'espace
|
||||
disponible sous son ancre (le contenu déborde en scroll interne). */
|
||||
_place() {
|
||||
var el = document.querySelector('.fd-ctx-menu');
|
||||
if (!el) return;
|
||||
var prev = el.style.maxHeight;
|
||||
el.style.maxHeight = 'none';
|
||||
var w = el.offsetWidth || 240;
|
||||
var h = el.offsetHeight || 320;
|
||||
el.style.maxHeight = prev || '';
|
||||
var vw = window.innerWidth, vh = window.innerHeight;
|
||||
var cx = (this._cx == null ? this.x : this._cx);
|
||||
var cy = (this._cy == null ? this.y : this._cy);
|
||||
var nx = cx;
|
||||
if (nx + w > vw - 8) nx = vw - w - 8;
|
||||
nx = Math.max(8, nx);
|
||||
var ny = cy;
|
||||
if (ny + h > vh - 8) {
|
||||
if (cy - h >= 8) ny = cy - h;
|
||||
else ny = Math.max(8, vh - h - 8);
|
||||
}
|
||||
this.x = nx;
|
||||
this.y = ny;
|
||||
this.maxH = Math.max(120, vh - ny - 8);
|
||||
},
|
||||
close() {
|
||||
if (this._ro) { try { this._ro.disconnect(); } catch (e) {} this._ro = null; }
|
||||
this.open = false;
|
||||
this.node = null;
|
||||
this.handlers = {};
|
||||
this.tagAdding = false;
|
||||
this.tagExistingOpen = false;
|
||||
this.iconOpen = false;
|
||||
},
|
||||
|
||||
/* Un item est-il disponible pour CETTE page ? (union vs capacités) */
|
||||
has(key) { return typeof this.handlers[key] === 'function'; },
|
||||
|
||||
/* Exécute l'action → handler fourni par la page courante. */
|
||||
run(key) {
|
||||
if (!this.node) { this.close(); return; }
|
||||
var fn = this.handlers[key];
|
||||
if (fn) try { fn(this.node); } catch (e) { console.error('fdCtx.run', e); }
|
||||
this.close();
|
||||
},
|
||||
|
||||
/* ── Tags ── */
|
||||
avail() { return this.availTags || []; },
|
||||
setAvail(a) { this.availTags = a || []; },
|
||||
removeTag(id) {
|
||||
var fn = this.handlers.tagRemove;
|
||||
if (fn) try { fn(id); } catch (e) { console.error('fdCtx.removeTag', e); }
|
||||
},
|
||||
addExistingTag(t) {
|
||||
var fn = this.handlers.tagExisting;
|
||||
if (fn) try { fn(t); } catch (e) { console.error('fdCtx.addExistingTag', e); }
|
||||
this.tagExistingOpen = false;
|
||||
},
|
||||
addNewTag(name, color) {
|
||||
name = (name || '').trim();
|
||||
if (!name) return;
|
||||
var fn = this.handlers.tagAdd;
|
||||
if (fn) try { fn(name, color || this.newTagColor); } catch (e) { console.error('fdCtx.addNewTag', e); }
|
||||
this.tagAdding = false;
|
||||
},
|
||||
|
||||
/* ── Icon picker ── */
|
||||
setIcon(icon) {
|
||||
icon = (icon || '').trim();
|
||||
var fn = this.handlers.setIcon;
|
||||
if (fn) try { fn(icon); } catch (e) { console.error('fdCtx.setIcon', e); }
|
||||
this.close();
|
||||
},
|
||||
openIconPicker() {
|
||||
var fn = this.handlers.setIcon;
|
||||
if (!fn) return;
|
||||
if (!window.FDIconPicker) return;
|
||||
window.FDIconPicker.openFor({
|
||||
x: this.x,
|
||||
y: this.y,
|
||||
onPick: fn,
|
||||
onRemove: function () { fn(''); }
|
||||
});
|
||||
this.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (window.Alpine) {
|
||||
registerFdCtx();
|
||||
} else {
|
||||
document.addEventListener('alpine:init', registerFdCtx);
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,77 @@
|
||||
|
||||
/* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */
|
||||
(function () {
|
||||
var FD_ICONS = {
|
||||
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
|
||||
'file': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',
|
||||
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
|
||||
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
|
||||
'bot': '<rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v3"/><line x1="12" y1="20" x2="12" y2="24"/><circle cx="8" cy="13" r="1"/><circle cx="16" cy="13" r="1"/>',
|
||||
'users': '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
|
||||
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
'book': '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>',
|
||||
'check-square': '<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
|
||||
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
|
||||
'help-circle': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
|
||||
'refresh': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
|
||||
'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
|
||||
'message-square': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
||||
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
||||
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
|
||||
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||
'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
|
||||
'bell': '<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
|
||||
'image': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
|
||||
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
|
||||
'chevron-left': '<polyline points="15 18 9 12 15 6"/>',
|
||||
'chevron-right': '<polyline points="9 18 15 12 9 6"/>',
|
||||
'list': '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
|
||||
'bar-chart': '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
|
||||
'grid': '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
|
||||
'align-left': '<line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="17" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/>',
|
||||
'corner-down-right': '<polyline points="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/>',
|
||||
'copy': '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
||||
'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
|
||||
'key': '<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
|
||||
'inbox': '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
|
||||
'edit': '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>',
|
||||
'eye-off': '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/>',
|
||||
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
|
||||
'share': '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>',
|
||||
'more-horizontal': '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
|
||||
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
||||
'paperclip': '<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
|
||||
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
|
||||
'sparkles': '<path d="M12 3l1.9 5.8 6.1.5-4.9 3.6 1.7 5.8-4.8-3.5-4.8 3.5 1.7-5.8-4.9-3.6 6.1-.5z"/>',
|
||||
'lightbulb': '<path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/>',
|
||||
'tag': '<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>',
|
||||
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
|
||||
'save': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
|
||||
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
|
||||
'trending-up': '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
|
||||
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
||||
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||
'user': '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>'
|
||||
};
|
||||
window.FD_ICONS = FD_ICONS;
|
||||
window.fd_icon = function (name, size) {
|
||||
size = size || 18;
|
||||
var inner = FD_ICONS[name];
|
||||
if (inner) return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + inner + '</svg>';
|
||||
return (window.getSvgIcon ? getSvgIcon(name, size) : '');
|
||||
};
|
||||
/* Render a page_icon value: image URL, known icon name, or emoji text. */
|
||||
window.fdIconHtml = function (value, size) {
|
||||
size = size || 16;
|
||||
var v = (value == null ? '' : String(value)).trim();
|
||||
if (!v) return '';
|
||||
if (v.charAt(0) === '/' || v.slice(0, 4) === 'http') {
|
||||
return '<img src="' + v.replace(/"/g, '"') + '" alt="" style="width:' + size + 'px;height:' + size + 'px;object-fit:contain;vertical-align:middle;display:inline-block;">';
|
||||
}
|
||||
if (FD_ICONS[v] || (window.getSvgIcon && getSvgIcon(v, size))) return window.fd_icon(v, size);
|
||||
return v;
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,220 @@
|
||||
|
||||
(function () {
|
||||
if (window.__fdIconPickerRegistered) return;
|
||||
window.__fdIconPickerRegistered = true;
|
||||
|
||||
var TONES = ['', '\u{1F3FB}', '\u{1F3FC}', '\u{1F3FD}', '\u{1F3FE}', '\u{1F3FF}'];
|
||||
|
||||
var TONEABLE = {};
|
||||
['👋','🤚','🖐️','✋','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘','🤙','👈','👉','👆','👇','☝️','👍','👎','✊','👊','🤛','🤜','👏','🙌','🫶','👐','🤲','🤝','🙏','💪','🦵','🦶','👂','👃','👶','🧒','👦','👧','🧑','👨','👩','🧓','👴','👵','🙍','🙎','🙅','🙆','💁','🙋','🧏','🙇','🤦','🤷','👮','🕵️','💂','👷','🤴','👸','👳','👲','🧕','🤵','👰','🤰','🤱','👼','🎅','🤶','🦸','🦹','🧙','🧚','🧛','🧜','🧝','💆','💇','🚶','🧍','🧎','🏃','💃','🕺','👯','🧖','🧗','🏇','⛷️','🏂','🏌️','🏄','🚣','🏊','⛹️','🏋️','🚴','🚵','🤸','🤼','🤽','🤾','🤹','🧘','🛀','🛌'].forEach(function (e) { TONEABLE[e] = true; });
|
||||
|
||||
var CATS = [
|
||||
{ id: 'people', label: 'People', items: [
|
||||
['😀','grinning face smile happy'],['😃','smiley happy'],['😄','smile laugh happy'],['😁','grin happy'],['😆','laughing happy'],['😅','sweat smile'],['🤣','rofl rolling laugh'],['😂','joy tears laugh'],['🙂','slight smile'],['🙃','upside down'],['😉','wink'],['😊','blush smile happy'],['😇','innocent halo angel'],['🥰','love hearts'],['😍','heart eyes love'],['🤩','star struck wow'],['😘','kiss love'],['😗','kissing'],['😚','kissing'],['😙','kissing'],['🥲','tear smile happy'],['😋','yum tasty'],['😛','tongue'],['😜','wink tongue'],['🤪','crazy zany'],['😝','tongue'],['🤑','money rich'],['🤗','hug'],['🤭','giggle'],['🤫','shush quiet'],['🤔','thinking'],['🤐','zip quiet'],['🤨','raised eyebrow'],['😐','neutral'],['😑','expressionless'],['😶','no mouth'],['😏','smirk'],['😒','unamused'],['🙄','roll eyes'],['😬','grimace'],['🤥','lying'],['😌','relieved'],['😔','pensive sad'],['😪','sleepy'],['🤤','drool'],['😴','sleeping'],['😷','mask sick'],['🤒','sick thermometer'],['🤕','hurt bandage'],['🤢','nauseated'],['🤮','vomit'],['🤧','sneeze'],['🥵','hot'],['🥶','cold'],['🥴','woozy'],['😵','dizzy'],['🤯','mind blown'],['🤠','cowboy'],['🥳','party'],['🥺','pleading'],['😎','cool sunglasses'],['🤓','nerd'],['🧐','monocle'],['😕','confused'],['😟','worried'],['🙁','frown'],['☹️','frown sad'],['😮','surprised'],['😯','hushed'],['😲','astonished'],['😳','flushed'],['😨','fearful'],['😰','anxious'],['😥','sad'],['😢','cry'],['😭','sob cry'],['😱','scream fear'],['😖','confounded'],['😣','persevere'],['😞','disappointed'],['😓','sweat'],['😩','weary'],['😫','tired'],['🥱','yawn'],['😤','triumph'],['😡','angry'],['😠','rage'],['🤬','cursing'],['😈','devil'],['👿','imp'],['💀','skull'],['💩','poop'],['🤡','clown'],['👻','ghost'],['👽','alien'],['🤖','robot'],['😺','cat'],['🙈','monkey see'],['🙉','monkey hear'],['🙊','monkey speak'],['👋','wave hand'],['🤚','raised hand'],['🖐️','hand'],['✋','raised hand'],['🖖','vulcan'],['👌','ok'],['🤌','pinched'],['🤏','pinch'],['✌️','peace'],['🤞','cross fingers luck'],['🤟','love you'],['🤘','rock'],['🤙','call me'],['👈','point left'],['👉','point right'],['👆','point up'],['👇','point down'],['☝️','point up'],['👍','thumbs up like'],['👎','thumbs down dislike'],['✊','fist'],['👊','fist bump'],['🤛','fist'],['🤜','fist'],['👏','clap'],['🙌','raise hands celebrate'],['🫶','heart hands'],['👐','open hands'],['🤲','palms'],['🤝','handshake'],['🙏','pray thanks'],['💪','muscle strong'],['👂','ear'],['👃','nose'],['👀','eyes look'],['👁️','eye'],['🧠','brain'],['👶','baby'],['🧒','child'],['👦','boy'],['👧','girl'],['🧑','person'],['👨','man'],['👩','woman'],['🧓','older person'],['👴','old man'],['👵','old woman'],['👮','police'],['🕵️','detective'],['💂','guard'],['👷','worker'],['🤴','prince'],['👸','princess'],['👳','turban'],['🧕','hijab'],['🤵','tuxedo'],['👰','bride'],['🤰','pregnant'],['🤱','breastfeeding'],['👼','angel'],['🎅','santa'],['🤶','mrs claus'],['🦸','superhero'],['🦹','supervillain'],['🧙','mage wizard'],['🧚','fairy'],['🧛','vampire'],['🧜','mermaid'],['🧝','elf'],['💆','massage'],['💇','haircut'],['🚶','walk'],['🏃','run'],['💃','dance'],['🕺','dance'],['👯','people dancing'],['🧗','climb'],['🏇','horse race'],['🏂','snowboard'],['🏄','surf'],['🚣','row'],['🏊','swim'],['🚴','bike'],['🚵','mountain bike'],['🤸','cartwheel'],['🤼','wrestle'],['🤽','water polo'],['🤾','handball'],['🤹','juggle'],['🧘','meditate yoga'],['🛌','sleeping bed'],['💋','kiss mark'],['💌','love letter'],['❤️','heart love red'],['🧡','orange heart'],['💛','yellow heart'],['💚','green heart'],['💙','blue heart'],['💜','purple heart'],['🖤','black heart'],['🤍','white heart'],['🤎','brown heart'],['💔','broken heart'],['❣️','heart exclamation'],['💕','two hearts'],['💞','revolving hearts'],['💓','beating heart'],['💗','growing heart'],['💖','sparkling heart'],['💘','cupid heart'],['💝','heart gift'],['✨','sparkles'],['⭐','star'],['🌟','glowing star'],['💫','dizzy star'],['💥','boom'],['💯','hundred perfect']
|
||||
]},
|
||||
{ id: 'nature', label: 'Nature', items: [
|
||||
['🐶','dog'],['🐱','cat'],['🐭','mouse'],['🐹','hamster'],['🐰','rabbit'],['🦊','fox'],['🐻','bear'],['🐼','panda'],['🐨','koala'],['🐯','tiger'],['🦁','lion'],['🐮','cow'],['🐷','pig'],['🐸','frog'],['🐵','monkey'],['🐔','chicken'],['🐧','penguin'],['🐦','bird'],['🐤','chick'],['🦆','duck'],['🦅','eagle'],['🦉','owl'],['🦇','bat'],['🐺','wolf'],['🐗','boar'],['🐴','horse'],['🦄','unicorn'],['🐝','bee'],['🐛','bug'],['🦋','butterfly'],['🐌','snail'],['🐞','ladybug'],['🐜','ant'],['🦗','cricket'],['🕷️','spider'],['🦂','scorpion'],['🐢','turtle'],['🐍','snake'],['🦎','lizard'],['🦖','dinosaur'],['🐙','octopus'],['🦑','squid'],['🦐','shrimp'],['🦀','crab'],['🐡','fish'],['🐠','fish'],['🐟','fish'],['🐬','dolphin'],['🐳','whale'],['🦈','shark'],['🐊','crocodile'],['🌵','cactus'],['🎄','tree christmas'],['🌲','tree evergreen'],['🌳','tree'],['🌴','palm tree'],['🌱','seedling plant'],['🌿','herb leaf'],['☘️','shamrock'],['🍀','clover luck'],['🎍','bamboo'],['🌾','wheat'],['🌷','tulip flower'],['🌹','rose flower'],['🌺','hibiscus flower'],['🌸','cherry blossom'],['🌼','flower'],['🌻','sunflower'],['🌞','sun'],['🌝','moon'],['🌚','moon'],['🌙','moon crescent'],['⭐','star'],['🌟','star'],['☀️','sun sunny'],['⛅','cloud sun'],['☁️','cloud'],['🌧️','rain'],['⛈️','storm'],['🌩️','lightning'],['❄️','snowflake'],['☃️','snowman'],['⛄','snowman'],['🔥','fire'],['💧','droplet water'],['🌊','wave water'],['🌈','rainbow'],['🌍','earth globe'],['🌎','earth globe'],['🌏','earth globe']
|
||||
]},
|
||||
{ id: 'food', label: 'Food', items: [
|
||||
['🍏','apple green'],['🍎','apple red'],['🍐','pear'],['🍊','orange tangerine'],['🍋','lemon'],['🍌','banana'],['🍉','watermelon'],['🍇','grapes'],['🍓','strawberry'],['🫐','blueberry'],['🍈','melon'],['🍒','cherry'],['🍑','peach'],['🥭','mango'],['🍍','pineapple'],['🥥','coconut'],['🥝','kiwi'],['🍅','tomato'],['🍆','eggplant'],['🥑','avocado'],['🥦','broccoli'],['🥬','lettuce'],['🥒','cucumber'],['🌶️','pepper hot'],['🌽','corn'],['🥕','carrot'],['🧄','garlic'],['🧅','onion'],['🥔','potato'],['🍠','sweet potato'],['🥐','croissant'],['🥯','bagel'],['🍞','bread'],['🥖','baguette'],['🧀','cheese'],['🥚','egg'],['🍳','cooking egg'],['🥓','bacon'],['🥩','meat steak'],['🍗','chicken leg'],['🍖','meat'],['🌭','hot dog'],['🍔','burger'],['🍟','fries'],['🍕','pizza'],['🥪','sandwich'],['🥙','pita'],['🌮','taco'],['🌯','burrito'],['🥗','salad'],['🍝','pasta spaghetti'],['🍜','ramen noodles'],['🍲','stew'],['🍛','curry rice'],['🍣','sushi'],['🍱','bento'],['🥟','dumpling'],['🍤','shrimp fried'],['🍙','rice ball'],['🍚','rice'],['🍘','rice cracker'],['🍥','fish cake'],['🥠','fortune cookie'],['🍢','oden'],['🍡','dango'],['🍧','shaved ice'],['🍨','ice cream'],['🍦','ice cream'],['🥧','pie'],['🧁','cupcake'],['🍰','cake'],['🎂','birthday cake'],['🍮','custard'],['🍭','lollipop'],['🍬','candy'],['🍫','chocolate'],['🍿','popcorn'],['🍩','donut'],['🍪','cookie'],['☕','coffee'],['🍵','tea'],['🧃','juice'],['🥤','cup drink'],['🍺','beer'],['🍻','beers cheers'],['🥂','champagne'],['🍷','wine'],['🥃','whiskey'],['🍸','cocktail'],['🍹','tropical drink'],['🧉','mate'],['🍾','champagne bottle']
|
||||
]},
|
||||
{ id: 'activity', label: 'Activity', items: [
|
||||
['⚽','soccer football'],['🏀','basketball'],['🏈','football'],['⚾','baseball'],['🥎','softball'],['🎾','tennis'],['🏐','volleyball'],['🏉','rugby'],['🥏','frisbee'],['🎱','pool billiards'],['🪀','yo-yo'],['🏓','ping pong'],['🏸','badminton'],['🏒','hockey'],['🏑','field hockey'],['🥍','lacrosse'],['🏏','cricket'],['🥅','goal'],['⛳','golf'],['🏹','archery'],['🎣','fishing'],['🥊','boxing'],['🥋','martial arts'],['🎽','running shirt'],['🛹','skateboard'],['🛼','roller skate'],['🛷','sled'],['⛸️','ice skate'],['🥌','curling'],['🎿','ski'],['⛷️','ski'],['🏂','snowboard'],['🏋️','weight lift'],['🤼','wrestle'],['🤸','cartwheel'],['⛹️','basketball'],['🤺','fencing'],['🤾','handball'],['🏌️','golf'],['🏇','horse race'],['🧘','yoga meditate'],['🏄','surf'],['🏊','swim'],['🤽','water polo'],['🚣','row'],['🧗','climb'],['🚴','bike'],['🚵','mountain bike'],['🎪','circus'],['🎭','theater masks'],['🎨','art palette'],['🎬','clapper film'],['🎤','microphone'],['🎧','headphones'],['🎼','music score'],['🎹','piano'],['🥁','drum'],['🎷','saxophone'],['🎺','trumpet'],['🎸','guitar'],['🪕','banjo'],['🎻','violin'],['🎲','dice random game'],['♟️','chess'],['🎯','target dart'],['🎳','bowling'],['🎮','game controller'],['🎰','slot machine'],['🧩','puzzle'],['🏆','trophy win'],['🥇','gold medal first'],['🥈','silver medal'],['🥉','bronze medal'],['🏅','medal'],['🎖️','military medal'],['🎗️','reminder ribbon'],['🎫','ticket'],['🎟️','tickets'],['🎁','gift present'],['🎉','party popper celebrate'],['🎊','confetti'],['🎈','balloon'],['🎂','cake birthday'],['🎃','pumpkin halloween'],['🎄','christmas tree'],['🎆','fireworks'],['🎇','fireworks'],['🧨','firecracker'],['✨','sparkles'],['🎓','graduation cap']
|
||||
]},
|
||||
{ id: 'travel', label: 'Travel', items: [
|
||||
['🚗','car'],['🚕','taxi'],['🚙','car suv'],['🚌','bus'],['🚎','trolley bus'],['🏎️','race car'],['🚓','police car'],['🚑','ambulance'],['🚒','fire truck'],['🚐','van'],['🛻','pickup truck'],['🚚','truck'],['🚛','truck'],['🚜','tractor'],['🏍️','motorcycle'],['🛵','scooter'],['🚲','bicycle'],['🛴','kick scooter'],['🚨','police light'],['🚔','police car'],['🚍','bus'],['🚝','monorail'],['🚄','train'],['🚅','train bullet'],['🚈','train'],['🚂','locomotive train'],['🚆','train'],['🚇','metro subway'],['🚊','tram'],['🚉','station'],['✈️','airplane flight'],['🛫','airplane takeoff'],['🛬','airplane landing'],['🛩️','plane'],['💺','seat'],['🚁','helicopter'],['🛸','ufo'],['🚀','rocket launch'],['🛰️','satellite'],['🚢','ship'],['⛵','sailboat'],['🛥️','motor boat'],['🚤','speedboat'],['⛴️','ferry'],['🛳️','cruise ship'],['⚓','anchor'],['🚧','construction'],['⛽','fuel gas'],['🚏','bus stop'],['🗺️','map world'],['🗿','moai'],['🗽','statue liberty'],['🗼','tokyo tower'],['🏰','castle'],['🏯','castle japanese'],['🏟️','stadium'],['🎡','ferris wheel'],['🎢','roller coaster'],['🎠','carousel'],['⛲','fountain'],['⛱️','beach umbrella'],['🏖️','beach'],['🏝️','island'],['🏜️','desert'],['🌋','volcano'],['⛰️','mountain'],['🏔️','snow mountain'],['🗻','mount fuji'],['🏕️','camping'],['🏠','house home'],['🏡','house garden'],['🏢','office building'],['🏥','hospital'],['🏦','bank'],['🏨','hotel'],['🏫','school'],['🏭','factory'],['🏛️','classical building'],['⛪','church'],['🕌','mosque'],['🕍','synagogue'],['🛕','temple'],['🗼','tower'],['🌆','city sunset'],['🌃','night city'],['🌉','bridge night'],['🌌','milky way'],['🌠','shooting star'],['🌅','sunrise'],['🌄','sunrise mountain'],['🌇','sunset city']
|
||||
]},
|
||||
{ id: 'objects', label: 'Objects', items: [
|
||||
['⌚','watch'],['📱','phone mobile'],['💻','laptop computer'],['⌨️','keyboard'],['🖥️','desktop computer'],['🖨️','printer'],['🖱️','mouse computer'],['💽','minidisc'],['💾','floppy disk save'],['💿','cd disk'],['📀','dvd'],['🧮','abacus'],['🎥','movie camera'],['🎞️','film frames'],['📽️','projector'],['📺','tv television'],['📷','camera'],['📸','camera flash'],['📹','video camera'],['📼','videocassette'],['🔍','magnifying search'],['🔎','magnifying search'],['🕯️','candle'],['💡','bulb idea'],['🔦','flashlight'],['🏮','lantern'],['🪔','lamp diya'],['📔','notebook'],['📕','book closed'],['📖','book open'],['📗','book green'],['📘','book blue'],['📙','book orange'],['📚','books library'],['📓','notebook'],['📒','ledger'],['📃','page'],['📜','scroll'],['📄','page document'],['📰','newspaper'],['🗞️','newspaper'],['📑','bookmark tabs'],['🔖','bookmark'],['🏷️','label tag'],['💰','money bag'],['🪙','coin'],['💴','yen'],['💵','dollar'],['💶','euro'],['💷','pound'],['💸','money wings'],['💳','credit card'],['🧾','receipt'],['✉️','envelope mail'],['📧','email'],['📨','envelope'],['📩','envelope'],['📤','outbox'],['📥','inbox'],['📦','package box'],['📫','mailbox'],['📪','mailbox'],['📬','mailbox'],['📭','mailbox'],['📮','postbox'],['🗳️','ballot box'],['✏️','pencil'],['✒️','pen nib'],['🖋️','pen'],['🖊️','pen'],['🖌️','paintbrush'],['🖍️','crayon'],['📝','memo note write'],['💼','briefcase work'],['📁','folder'],['📂','folder open'],['🗂️','card index'],['📅','calendar date'],['📆','calendar'],['🗒️','notepad'],['🗓️','calendar'],['📇','card index'],['📈','chart up trending'],['📉','chart down'],['📊','bar chart stats'],['📋','clipboard'],['📌','pushpin'],['📍','pin location'],['📎','paperclip attach'],['🖇️','paperclips'],['📏','ruler'],['📐','triangle ruler'],['✂️','scissors cut'],['🗃️','file box'],['🗄️','file cabinet'],['🗑️','trash waste'],['🔒','lock locked'],['🔓','lock open'],['🔑','key'],['🗝️','old key'],['🔨','hammer'],['🪓','axe'],['⛏️','pick'],['⚒️','tools'],['🛠️','tools'],['🔧','wrench'],['🔩','bolt nut'],['⚙️','gear settings'],['🧰','toolbox'],['🧲','magnet'],['🔫','water gun'],['💣','bomb'],['🧪','test tube science'],['🧫','petri dish'],['🧬','dna'],['🔬','microscope'],['🔭','telescope'],['📡','satellite antenna'],['💉','syringe'],['💊','pill medicine'],['🩹','bandage'],['🩺','stethoscope'],['🚪','door'],['🛏️','bed'],['🛋️','couch'],['🪑','chair'],['🚽','toilet'],['🚿','shower'],['🛁','bathtub'],['🧴','lotion'],['🧷','safety pin'],['🧹','broom'],['🧺','basket'],['🧻','toilet paper'],['🧼','soap'],['🪒','razor'],['🧽','sponge'],['🧯','extinguisher'],['🛒','cart shopping'],['🚬','cigarette'],['⚰️','coffin'],['🪦','headstone'],['⚱️','urn']
|
||||
]},
|
||||
{ id: 'symbols', label: 'Symbols', items: [
|
||||
['❤️','heart love'],['🧡','orange heart'],['💛','yellow heart'],['💚','green heart'],['💙','blue heart'],['💜','purple heart'],['🖤','black heart'],['🤍','white heart'],['🤎','brown heart'],['💔','broken heart'],['❣️','heart'],['💕','hearts'],['💞','hearts'],['💓','heartbeat'],['💗','heart'],['💖','heart'],['💘','heart arrow'],['💝','heart gift'],['💟','heart decoration'],['☮️','peace'],['✝️','cross'],['☪️','star crescent'],['🕉️','om'],['☸️','dharma'],['✡️','star david'],['🔯','star'],['🕎','menorah'],['☯️','yin yang'],['☦️','orthodox cross'],['🛐','worship'],['⛎','ophiuchus'],['♈','aries'],['♉','taurus'],['♊','gemini'],['♋','cancer'],['♌','leo'],['♍','virgo'],['♎','libra'],['♏','scorpio'],['♐','sagittarius'],['♑','capricorn'],['♒','aquarius'],['♓','pisces'],['🆔','id'],['⚛️','atom'],['🉑','accept'],['☢️','radioactive'],['☣️','biohazard'],['📴','phone off'],['📳','vibrate'],['📵','no phone'],['🚭','no smoking'],['❗','exclamation'],['❕','exclamation'],['❓','question'],['❔','question'],['‼️','double exclamation'],['⁉️','exclamation question'],['🔅','dim'],['🔆','bright'],['〽️','part alternation'],['⚠️','warning'],['🚸','children crossing'],['🔱','trident'],['⚜️','fleur de lis'],['🔰','beginner'],['♻️','recycle'],['✅','check done'],['🈯','reserved'],['💹','chart yen'],['❇️','sparkle'],['✳️','asterisk'],['❎','cross mark'],['🌐','globe'],['💠','diamond'],['Ⓜ️','m'],['🌀','cyclone'],['💤','zzz sleep'],['🏧','atm'],['🚾','wc'],['♿','wheelchair'],['🅿️','parking'],['🈳','vacancy'],['🈂️','sa'],['🛂','passport control'],['🛃','customs'],['🛄','baggage'],['🛅','left luggage'],['🚹','men'],['🚺','women'],['🚼','baby'],['🚻','restroom'],['🚮','litter'],['🎦','cinema'],['📶','signal'],['🈁','here'],['🔣','symbols'],['ℹ️','info'],['🔤','abc'],['🔡','abcd'],['🔠','abcd'],['🆖','ng'],['🆗','ok'],['🆙','up'],['🆒','cool'],['🆕','new'],['🆓','free'],['0️⃣','zero'],['1️⃣','one'],['2️⃣','two'],['3️⃣','three'],['4️⃣','four'],['5️⃣','five'],['6️⃣','six'],['7️⃣','seven'],['8️⃣','eight'],['9️⃣','nine'],['🔟','ten'],['🔢','numbers'],['#️⃣','hash'],['*️⃣','asterisk'],['⏏️','eject'],['▶️','play'],['⏸️','pause'],['⏹️','stop'],['⏺️','record'],['⏭️','next'],['⏮️','previous'],['⏩','fast forward'],['⏪','rewind'],['⏫','up'],['⏬','down'],['◀️','left'],['🔼','up'],['🔽','down'],['➡️','right'],['⬅️','left'],['⬆️','up'],['⬇️','down'],['↗️','up right'],['↘️','down right'],['↙️','down left'],['↖️','up left'],['↕️','up down'],['↔️','left right'],['↩️','return'],['↪️','redo'],['⤴️','up'],['⤵️','down'],['🔀','shuffle'],['🔁','repeat'],['🔂','repeat one'],['🔄','refresh'],['🔃','refresh'],['🎵','music note'],['🎶','music notes'],['➕','plus'],['➖','minus'],['➗','divide'],['✖️','multiply'],['♾️','infinity'],['💲','dollar'],['💱','currency'],['™️','tm'],['©️','copyright'],['®️','registered'],['〰️','wavy'],['➰','curly loop'],['➿','double loop'],['🔚','end'],['🔙','back'],['🔛','on'],['🔝','top'],['🔜','soon'],['✔️','check'],['☑️','checkbox'],['🔘','radio'],['🔴','red circle'],['🟠','orange circle'],['🟡','yellow circle'],['🟢','green circle'],['🔵','blue circle'],['🟣','purple circle'],['⚫','black circle'],['⚪','white circle'],['🟤','brown circle'],['🔺','red triangle'],['🔻','red triangle'],['🔸','orange diamond'],['🔹','blue diamond'],['🔶','orange diamond'],['🔷','blue diamond'],['🔳','white square'],['🔲','black square'],['▪️','black square'],['▫️','white square'],['◾','black square'],['◽','white square'],['◼️','black square'],['◻️','white square'],['🟥','red square'],['🟧','orange square'],['🟨','yellow square'],['🟩','green square'],['🟦','blue square'],['🟪','purple square'],['⬛','black square'],['⬜','white square'],['🟫','brown square'],['🔈','speaker'],['🔇','mute'],['🔉','speaker'],['🔊','speaker loud'],['🔔','bell'],['🔕','bell off'],['📣','megaphone'],['📢','loudspeaker'],['💬','speech bubble'],['💭','thought bubble'],['🗯️','anger bubble'],['♠️','spade'],['♣️','club'],['♥️','heart suit'],['♦️','diamond suit'],['🃏','joker'],['🎴','flower cards'],['🀄','mahjong']
|
||||
]},
|
||||
{ id: 'flags', label: 'Flags', items: [
|
||||
['🏁','chequered flag finish'],['🚩','triangular flag'],['🎌','crossed flags'],['🏴','black flag'],['🏳️','white flag'],['🏳️🌈','rainbow flag pride'],['🏴☠️','pirate flag'],['🇺🇸','usa united states'],['🇬🇧','uk united kingdom'],['🇫🇷','france french'],['🇩🇪','germany german'],['🇪🇸','spain spanish'],['🇮🇹','italy italian'],['🇵🇹','portugal'],['🇳🇱','netherlands'],['🇧🇪','belgium'],['🇨🇭','switzerland'],['🇦🇹','austria'],['🇸🇪','sweden'],['🇳🇴','norway'],['🇩🇰','denmark'],['🇫🇮','finland'],['🇮🇪','ireland'],['🇵🇱','poland'],['🇬🇷','greece'],['🇷🇺','russia'],['🇺🇦','ukraine'],['🇹🇷','turkey'],['🇨🇦','canada'],['🇲🇽','mexico'],['🇧🇷','brazil'],['🇦🇷','argentina'],['🇨🇱','chile'],['🇨🇴','colombia'],['🇨🇳','china chinese'],['🇯🇵','japan japanese'],['🇰🇷','korea south'],['🇮🇳','india'],['🇦🇺','australia'],['🇳🇿','new zealand'],['🇿🇦','south africa'],['🇪🇬','egypt'],['🇲🇦','morocco'],['🇳🇬','nigeria'],['🇰🇪','kenya'],['🇸🇦','saudi arabia'],['🇦🇪','uae emirates'],['🇮🇱','israel'],['🇸🇬','singapore'],['🇹🇭','thailand'],['🇻🇳','vietnam'],['🇮🇩','indonesia'],['🇵🇭','philippines'],['🇲🇾','malaysia'],['🇵🇰','pakistan'],['🇧🇩','bangladesh'],['🇺🇳','united nations']
|
||||
]}
|
||||
];
|
||||
|
||||
document.addEventListener('alpine:init', function () {
|
||||
if (window.Alpine && window.Alpine.__fdIconPicker) return;
|
||||
if (window.Alpine) window.Alpine.__fdIconPicker = true;
|
||||
|
||||
Alpine.store('fdIconPicker', {
|
||||
open: false, x: 0, y: 0, tab: 'emoji', query: '', category: 'people',
|
||||
skinTone: 0, toneOpen: false, recent: [], custom: [], customLoaded: false,
|
||||
customModal: false, customName: '', customPreview: '', customFile: null, customBusy: false,
|
||||
onPick: null, onRemove: null, _cx: 0, _cy: 0,
|
||||
cats: CATS,
|
||||
|
||||
openFor(opts) {
|
||||
opts = opts || {};
|
||||
this.onPick = opts.onPick || null;
|
||||
this.onRemove = opts.onRemove || null;
|
||||
this.query = '';
|
||||
this.toneOpen = false;
|
||||
this.customModal = false;
|
||||
this._cx = (opts.x != null) ? opts.x : 240;
|
||||
this._cy = (opts.y != null) ? opts.y : 200;
|
||||
this.x = this._cx;
|
||||
this.y = this._cy;
|
||||
this.open = true;
|
||||
var self = this;
|
||||
var place = function () {
|
||||
var el = document.querySelector('.fd-icon-picker');
|
||||
if (!el) return;
|
||||
var w = el.offsetWidth || 344, h = el.offsetHeight || 440;
|
||||
var vw = window.innerWidth, vh = window.innerHeight;
|
||||
var nx = self._cx, ny = self._cy;
|
||||
if (nx + w > vw - 8) nx = vw - w - 8;
|
||||
if (ny + h > vh - 8) ny = vh - h - 8;
|
||||
self.x = Math.max(8, nx);
|
||||
self.y = Math.max(8, ny);
|
||||
};
|
||||
if (window.Alpine && Alpine.nextTick) Alpine.nextTick(place);
|
||||
else setTimeout(place, 0);
|
||||
this.loadRecent();
|
||||
this.loadCustom();
|
||||
},
|
||||
|
||||
close() {
|
||||
this.open = false;
|
||||
this.customModal = false;
|
||||
this.toneOpen = false;
|
||||
this.onPick = null;
|
||||
this.onRemove = null;
|
||||
},
|
||||
|
||||
matches(name) {
|
||||
var q = (this.query || '').trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return name.toLowerCase().indexOf(q) >= 0;
|
||||
},
|
||||
|
||||
items() {
|
||||
var q = (this.query || '').trim().toLowerCase();
|
||||
if (q) {
|
||||
var out = [];
|
||||
this.cats.forEach(function (c) {
|
||||
c.items.forEach(function (it) {
|
||||
if ((it[1] || '').toLowerCase().indexOf(q) >= 0 || it[0].indexOf(q) >= 0) out.push(it[0]);
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
if (this.category === 'recent') return this.recent;
|
||||
var found = [];
|
||||
for (var i = 0; i < this.cats.length; i++) {
|
||||
if (this.cats[i].id === this.category) { found = this.cats[i].items.map(function (it) { return it[0]; }); break; }
|
||||
}
|
||||
return found;
|
||||
},
|
||||
|
||||
withTone(e) {
|
||||
if (!this.skinTone) return e;
|
||||
if (TONEABLE[e]) return e + TONES[this.skinTone];
|
||||
return e;
|
||||
},
|
||||
|
||||
pick(v) {
|
||||
v = (v || '').trim();
|
||||
if (!v) return;
|
||||
this.pushRecent(v);
|
||||
var fn = this.onPick;
|
||||
if (fn) { try { fn(v); } catch (e) { console.error('fdIconPicker.pick', e); } }
|
||||
this.close();
|
||||
},
|
||||
|
||||
remove() {
|
||||
var fn = this.onRemove || this.onPick;
|
||||
if (fn) { try { fn(''); } catch (e) {} }
|
||||
this.close();
|
||||
},
|
||||
|
||||
random() {
|
||||
var pool = [];
|
||||
this.cats.forEach(function (c) { c.items.forEach(function (it) { pool.push(it[0]); }); });
|
||||
if (!pool.length) return;
|
||||
this.pick(pool[Math.floor(Math.random() * pool.length)]);
|
||||
},
|
||||
|
||||
setTone(i) { this.skinTone = i; this.toneOpen = false; },
|
||||
setCategory(id) { this.category = id; this.tab = 'emoji'; this.query = ''; },
|
||||
setTab(t) { this.tab = t; this.query = ''; if (t === 'upload') this.loadCustom(); },
|
||||
|
||||
loadRecent() {
|
||||
try { this.recent = JSON.parse(localStorage.getItem('fd_icon_recent') || '[]'); }
|
||||
catch (e) { this.recent = []; }
|
||||
},
|
||||
pushRecent(e) {
|
||||
try {
|
||||
var r = this.recent.filter(function (x) { return x !== e; });
|
||||
r.unshift(e);
|
||||
this.recent = r.slice(0, 32);
|
||||
localStorage.setItem('fd_icon_recent', JSON.stringify(this.recent));
|
||||
} catch (err) {}
|
||||
},
|
||||
|
||||
async loadCustom() {
|
||||
try {
|
||||
var r = await fetch('/api/custom-emojis', { credentials: 'same-origin' });
|
||||
var d = await r.json();
|
||||
this.custom = (d && d.emojis) || [];
|
||||
this.customLoaded = true;
|
||||
} catch (e) { this.custom = []; }
|
||||
},
|
||||
|
||||
openCustomModal() {
|
||||
this.customModal = true;
|
||||
this.customName = '';
|
||||
this.customPreview = '';
|
||||
this.customFile = null;
|
||||
this.tab = 'upload';
|
||||
},
|
||||
closeCustomModal() { this.customModal = false; },
|
||||
onCustomFile(ev) {
|
||||
var f = ev.target.files && ev.target.files[0];
|
||||
if (!f) return;
|
||||
this.customFile = f;
|
||||
var self = this;
|
||||
var fr = new FileReader();
|
||||
fr.onload = function () { self.customPreview = fr.result; };
|
||||
fr.readAsDataURL(f);
|
||||
if (!this.customName) this.customName = (f.name || '').replace(/\.[^.]+$/, '').slice(0, 40);
|
||||
},
|
||||
async saveCustom() {
|
||||
if (!this.customFile || this.customBusy) return;
|
||||
this.customBusy = true;
|
||||
try {
|
||||
var fd = new FormData();
|
||||
fd.append('name', this.customName || 'emoji');
|
||||
fd.append('file', this.customFile);
|
||||
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
var r = await fetch('/api/custom-emojis', { method: 'POST', headers: { 'X-CSRF-Token': csrf }, body: fd, credentials: 'same-origin' });
|
||||
var d = await r.json();
|
||||
if (d && d.emoji) {
|
||||
this.custom.unshift(d.emoji);
|
||||
this.customModal = false;
|
||||
this.pick(d.emoji.url);
|
||||
}
|
||||
} catch (e) {
|
||||
if (window.showToast) window.showToast('Emoji upload failed', 'error');
|
||||
}
|
||||
this.customBusy = false;
|
||||
},
|
||||
async deleteCustom(id) {
|
||||
try {
|
||||
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
|
||||
await fetch('/api/custom-emojis/' + id, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf }, credentials: 'same-origin' });
|
||||
this.custom = this.custom.filter(function (e) { return e.id !== id; });
|
||||
} catch (e) {}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
window.FDIconPicker = {
|
||||
openFor: function (opts) {
|
||||
var s = window.Alpine && Alpine.store('fdIconPicker');
|
||||
if (s) s.openFor(opts);
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,17 @@
|
||||
|
||||
(function(){
|
||||
var defaultAPI = {
|
||||
open: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle')); },
|
||||
close: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle', { detail: { close: true } })); },
|
||||
toggle: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle')); },
|
||||
ask: function(){}, generate: function(){ return Promise.resolve(''); }
|
||||
};
|
||||
window.fdAgent = window.fdAgent || defaultAPI;
|
||||
// Ctrl/Cmd+J — open / close the Agent panel from anywhere.
|
||||
document.addEventListener('keydown', function(e){
|
||||
if((e.ctrlKey || e.metaKey) && (e.key === 'j' || e.key === 'J')){
|
||||
e.preventDefault();
|
||||
if(window.fdAgent && window.fdAgent.toggle) window.fdAgent.toggle();
|
||||
}
|
||||
});
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.parse(el.textContent):{}}catch(e){return {}}})();
|
||||
|
||||
var owner = BD.owner;
|
||||
var repo = BD.repo;
|
||||
var initialView = BD.initial_view;
|
||||
|
||||
// Auto-switch to view from URL param
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (initialView && initialView !== 'kanban') {
|
||||
const tab = document.querySelector(`.view-tab[data-view="${initialView}"]`);
|
||||
if (tab) tab.click();
|
||||
}
|
||||
});
|
||||
|
||||
function setActiveTab(el) {
|
||||
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
}
|
||||
|
||||
function kanbanBoard() {
|
||||
return {
|
||||
collapsedGroups: [],
|
||||
toggleGroup(id) {
|
||||
const idx = this.collapsedGroups.indexOf(id);
|
||||
idx >= 0 ? this.collapsedGroups.splice(idx, 1) : this.collapsedGroups.push(id);
|
||||
},
|
||||
newCard(groupId, status) {
|
||||
document.getElementById('new-issue-form').style.display = 'block';
|
||||
document.querySelector('#new-issue-form').__x.$data.status = status;
|
||||
},
|
||||
newGroup() { console.log('New group'); }
|
||||
};
|
||||
}
|
||||
|
||||
function filterSystem() {
|
||||
return {
|
||||
activeFilters: [],
|
||||
statusFilters: [],
|
||||
showStatusMenu: false,
|
||||
statusOptions: [
|
||||
{ value: 'todo', label: 'To-do', color: 'var(--gray)' },
|
||||
{ value: 'progress', label: 'In progress', color: 'var(--blue)' },
|
||||
{ value: 'done', label: 'Complete', color: 'var(--green)' },
|
||||
],
|
||||
get statusFilterLabel() {
|
||||
return this.statusFilters.length ? this.statusFilters.join(', ') : 'All';
|
||||
},
|
||||
toggleStatus(val) {
|
||||
const idx = this.statusFilters.indexOf(val);
|
||||
idx >= 0 ? this.statusFilters.splice(idx, 1) : this.statusFilters.push(val);
|
||||
},
|
||||
addFilter() {
|
||||
const prop = prompt('Filter by property (status, assignee, label):');
|
||||
if (!prop) return;
|
||||
const val = prompt('Value:');
|
||||
if (!val) return;
|
||||
this.activeFilters.push({ property: prop, value: val });
|
||||
this.refreshView();
|
||||
},
|
||||
removeFilter(i) { this.activeFilters.splice(i, 1); },
|
||||
resetFilters() { this.activeFilters = []; this.statusFilters = []; },
|
||||
refreshView() {
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function sortSystem() {
|
||||
return {
|
||||
sorts: [],
|
||||
showSortPanel: false,
|
||||
addSort() {
|
||||
const field = prompt('Sort by (name, status, assignee, deadline):');
|
||||
if (!field) return;
|
||||
this.sorts.push({ field, dir: 'asc' });
|
||||
this.applySorts();
|
||||
},
|
||||
removeSort(i) { this.sorts.splice(i, 1); this.applySorts(); },
|
||||
toggleDir(i) { this.sorts[i].dir = this.sorts[i].dir === 'asc' ? 'desc' : 'asc'; this.applySorts(); },
|
||||
clearSorts() { this.sorts = []; this.applySorts(); },
|
||||
applySorts() {
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
const url = new URL(activeTab.getAttribute('hx-get'), window.location.origin);
|
||||
this.sorts.forEach(s => url.searchParams.append('sort', s.field + ':' + s.dir));
|
||||
htmx.ajax('GET', url.pathname + url.search, { target: '#view-content', swap: 'innerHTML' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function newIssueForm() {
|
||||
return {
|
||||
title: '', status: 'todo',
|
||||
create() {
|
||||
if (!this.title.trim()) return;
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
this.title = '';
|
||||
this.hide();
|
||||
// Refresh current view
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
});
|
||||
},
|
||||
hide() { document.getElementById('new-issue-form').style.display = 'none'; },
|
||||
show() { document.getElementById('new-issue-form').style.display = 'block'; }
|
||||
};
|
||||
}
|
||||
|
||||
function showNewIssue() {
|
||||
const form = document.getElementById('new-issue-form');
|
||||
form.style.display = form.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Init SortableJS after HTMX swaps
|
||||
document.addEventListener('htmx:afterSwap', function(evt) {
|
||||
if (evt.target.id === 'view-content') {
|
||||
document.querySelectorAll('.kanban-cards').forEach(el => {
|
||||
if (el._sortable) el._sortable.destroy();
|
||||
el._sortable = new Sortable(el, {
|
||||
group: 'kanban',
|
||||
animation: 200,
|
||||
ghostClass: 'sortable-ghost',
|
||||
dragClass: 'sortable-drag',
|
||||
onEnd: function(evt) {
|
||||
const cardId = evt.item.dataset.cardId;
|
||||
const toStatus = evt.to.dataset.status;
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(() => {
|
||||
// ponytail: refresh current view after move
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,626 @@
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('giteaWorkspace', () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const owner = params.get('owner') || '';
|
||||
const repo = params.get('repo') || '';
|
||||
|
||||
return {
|
||||
owner, repo,
|
||||
showFile: false,
|
||||
showEditor: false,
|
||||
showPrivate: false,
|
||||
privatePages: [],
|
||||
editingPrivate: null,
|
||||
editingPrivateTitle: '',
|
||||
editingPrivateContent: '',
|
||||
filePath: '',
|
||||
fileContent: '',
|
||||
fileSize: 0,
|
||||
fileSha: '',
|
||||
fileLoading: false,
|
||||
fileLanguage: 'text',
|
||||
editContent: '',
|
||||
commitMessage: 'Update via FlowDeck',
|
||||
commitHistory: JSON.parse(localStorage.getItem('fd_commit_msgs') || '[]'),
|
||||
// File tree browser
|
||||
treeItems: [],
|
||||
sortedTreeItems: [],
|
||||
treeLoading: false,
|
||||
folderStack: [],
|
||||
currentPath: '',
|
||||
// Context menu
|
||||
gwCtx: { visible: false, x: 0, y: 0, item: null },
|
||||
|
||||
_sortTree() {
|
||||
this.sortedTreeItems = [...this.treeItems].sort(function(a, b) {
|
||||
if (a.type === b.type) return a.name.localeCompare(b.name);
|
||||
return a.type === 'folder' ? -1 : 1;
|
||||
});
|
||||
},
|
||||
|
||||
init() {
|
||||
// Expose globally for header to access
|
||||
window._gwData = this;
|
||||
// Set cookie for sidebar
|
||||
document.cookie = 'flowdeck_workspace=gitea:' + this.owner + ':' + this.repo + ';path=/;SameSite=Lax';
|
||||
// Load sidebar tree (into gitea section)
|
||||
this.loadSidebarTree();
|
||||
// Load main content tree
|
||||
this.loadMainTree('');
|
||||
// Listen for sidebar clicks on Gitea items
|
||||
this.setupSidebarClicks();
|
||||
},
|
||||
|
||||
async loadMainTree(path) {
|
||||
this.treeLoading = true;
|
||||
this.currentPath = path;
|
||||
try {
|
||||
var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree';
|
||||
if (path) url += '?path=' + encodeURIComponent(path);
|
||||
var r = await fetch(url);
|
||||
if (!r.ok) { this.treeItems = []; this.sortedTreeItems = []; return; }
|
||||
var d = await r.json();
|
||||
this.treeItems = d.tree || [];
|
||||
this._sortTree();
|
||||
} catch(e) { this.treeItems = []; this.sortedTreeItems = []; }
|
||||
finally { this.treeLoading = false; }
|
||||
},
|
||||
|
||||
drillDown(path) {
|
||||
this.folderStack.push(path.split('/').pop());
|
||||
this.loadMainTree(path);
|
||||
},
|
||||
|
||||
navigateToFolder(idx) {
|
||||
// Truncate stack and rebuild path
|
||||
this.folderStack = this.folderStack.slice(0, idx + 1);
|
||||
var path = this.folderStack.join('/');
|
||||
this.loadMainTree(path);
|
||||
},
|
||||
|
||||
navigateToRoot() {
|
||||
this.folderStack = [];
|
||||
this.loadMainTree('');
|
||||
},
|
||||
|
||||
openGwContext(ev, item) {
|
||||
this.gwCtx = { visible: true, x: ev.clientX, y: ev.clientY, item: item };
|
||||
},
|
||||
gwRename() {
|
||||
this.gwCtx.visible = false;
|
||||
var item = this.gwCtx.item;
|
||||
if (!item) return;
|
||||
var newName = prompt('Rename:', item.name);
|
||||
if (!newName || !newName.trim() || newName.trim() === item.name) return;
|
||||
var self = this;
|
||||
var oldPath = item.path;
|
||||
var newPath = oldPath.replace(/[^/]+$/, newName.trim());
|
||||
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
|
||||
method: 'PUT',
|
||||
headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: oldPath, new_path: newPath, message: 'Rename ' + oldPath + ' to ' + newPath })
|
||||
}).then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
if (d.status === 'ok') { self.refreshTree(); }
|
||||
else { window.showToast('Rename failed', 'error'); }
|
||||
})
|
||||
.catch(function(){ window.showToast('Rename failed', 'error'); });
|
||||
},
|
||||
gwDelete() {
|
||||
this.gwCtx.visible = false;
|
||||
var item = this.gwCtx.item;
|
||||
if (!item) return;
|
||||
if (!confirm('Delete ' + item.name + '?')) return;
|
||||
var self = this;
|
||||
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(item.path) + '&sha=' + (item.sha || '') + '&message=Delete ' + item.path, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
|
||||
method: 'DELETE'
|
||||
}).then(function(r){
|
||||
if (r.ok) { self.refreshTree(); }
|
||||
else { window.showToast('Delete failed', 'error'); }
|
||||
}).catch(function(){ window.showToast('Delete failed', 'error'); });
|
||||
},
|
||||
|
||||
async loadSidebarTree() {
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree');
|
||||
if (!r.ok) {
|
||||
// If API fails (e.g. no Gitea token), set a message
|
||||
var container = document.getElementById('sidebar-gitea-items');
|
||||
if (container) container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon">'+getSvgIcon('link',14)+'</span><span class="page-name text-dim">Connect Gitea to browse files</span></li>';
|
||||
return;
|
||||
}
|
||||
var d = await r.json();
|
||||
var items = d.tree || [];
|
||||
var container = document.getElementById('sidebar-gitea-items');
|
||||
if (!container) return;
|
||||
// Ensure gitea section is visible
|
||||
if (window.appState && window.appState.sectionsOpen && !window.appState.sectionsOpen.gitea) {
|
||||
window.appState.sectionsOpen.gitea = true;
|
||||
}
|
||||
container.innerHTML = '';
|
||||
// Build tree HTML as string and set once (more performant)
|
||||
var html = '';
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var item = items[i];
|
||||
var icon = item.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
|
||||
html += '<li class="sidebar-item" data-gitea-path="' + item.path + '" data-gitea-type="' + (item.type === 'folder' ? 'folder' : 'file') + '" data-gitea-sha="' + (item.sha || '') + '" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
|
||||
html += '<span class="page-icon">' + icon + '</span>';
|
||||
html += '<span class="page-name">' + item.name + '</span>';
|
||||
html += '</li>';
|
||||
}
|
||||
// Add Private Pages link
|
||||
html += '<li style="border-top:1px solid var(--border);margin:8px 0;"></li>';
|
||||
html += '<li class="sidebar-item" id="gitea-private-link" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
|
||||
html += '<span class="page-icon">'+getSvgIcon('lock',14)+'</span><span class="page-name">Private Pages</span></li>';
|
||||
container.innerHTML = html;
|
||||
// Re-attach event listeners
|
||||
this.setupSidebarClicks();
|
||||
} catch(e) {
|
||||
console.error('Gitea sidebar tree load failed:', e);
|
||||
}
|
||||
},
|
||||
|
||||
setupSidebarClicks() {
|
||||
var self = this;
|
||||
document.addEventListener('click', function(e) {
|
||||
var el = e.target.closest('.sidebar-item[data-gitea-path]');
|
||||
if (!el) return;
|
||||
|
||||
var path = el.getAttribute('data-gitea-path');
|
||||
var type = el.getAttribute('data-gitea-type');
|
||||
|
||||
// If clicking the checkbox, let its own handler deal with selection
|
||||
if (e.target.classList.contains('gitea-checkbox')) return;
|
||||
|
||||
// If clicking the inline rename input, don't navigate
|
||||
if (e.target.classList.contains('inline-rename-input')) return;
|
||||
|
||||
// If Shift or Ctrl/Meta is pressed, use multi-selection
|
||||
if (e.shiftKey || e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
self.selectItem(path, el, e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Normal click: navigate (open file/folder)
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Update visual "active" state
|
||||
document.querySelectorAll('.sidebar-item.active').forEach(function(si) {
|
||||
si.classList.remove('active');
|
||||
});
|
||||
el.classList.add('active');
|
||||
|
||||
if (type === 'folder') {
|
||||
self.loadSubdir(path, el);
|
||||
} else {
|
||||
self.openFile(path, el.getAttribute('data-gitea-sha'));
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for custom delete event on gitea sidebar items
|
||||
document.addEventListener('gitea-delete', function(e) {
|
||||
var el = e.target;
|
||||
var path = el.getAttribute('data-gitea-path');
|
||||
if (!path) return;
|
||||
if (!confirm('Delete ' + path + ' from ' + self.owner + '/' + self.repo + '?')) return;
|
||||
var sha = el.getAttribute('data-gitea-sha') || '';
|
||||
fetch('/api/gitea/projects/' + self.owner + '/' + self.repo + '/file?path=' + encodeURIComponent(path) + '&sha=' + encodeURIComponent(sha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
|
||||
method: 'DELETE',
|
||||
}).then(function(r) {
|
||||
if (r.ok) self.refreshTree();
|
||||
}).catch(function() {});
|
||||
});
|
||||
},
|
||||
|
||||
async loadSubdir(path, el) {
|
||||
var self = this;
|
||||
// Check if already loaded
|
||||
var ul = el.querySelector('ul');
|
||||
if (ul) {
|
||||
ul.style.display = ul.style.display === 'none' ? '' : 'none';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/tree?path=' + encodeURIComponent(path));
|
||||
if (!r.ok) return;
|
||||
var d = await r.json();
|
||||
var children = d.tree || [];
|
||||
ul = document.createElement('ul');
|
||||
ul.className = 'sidebar-items';
|
||||
ul.style.paddingLeft = '20px';
|
||||
children.forEach(function(child) {
|
||||
var icon = child.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
|
||||
var li = document.createElement('li');
|
||||
li.className = 'sidebar-item';
|
||||
li.setAttribute('data-gitea-path', child.path);
|
||||
li.setAttribute('data-gitea-type', child.type === 'folder' ? 'folder' : 'file');
|
||||
li.setAttribute('data-gitea-sha', child.sha || '');
|
||||
li.style.cssText = 'cursor:pointer;display:flex;align-items:center;gap:4px;';
|
||||
// Checkbox
|
||||
var cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.className = 'gitea-checkbox';
|
||||
cb.style.cssText = 'flex-shrink:0;margin:0;display:none;';
|
||||
cb.addEventListener('click', function(ev) {
|
||||
ev.stopPropagation();
|
||||
self.selectItem(child.path, li, ev);
|
||||
});
|
||||
li.appendChild(cb);
|
||||
// Icon
|
||||
var iconSpan = document.createElement('span');
|
||||
iconSpan.className = 'sidebar-icon';
|
||||
iconSpan.innerHTML = icon; // icon = HTML SVG from getSvgIcon(), NOT text
|
||||
li.appendChild(iconSpan);
|
||||
// Name
|
||||
var nameSpan = document.createElement('span');
|
||||
nameSpan.textContent = child.name;
|
||||
nameSpan.addEventListener('dblclick', function(ev) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
self.startInlineRename(nameSpan, child.path, li);
|
||||
});
|
||||
li.appendChild(nameSpan);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
el.appendChild(ul);
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
async openFile(path, sha) {
|
||||
this.filePath = path;
|
||||
this.fileSha = sha || '';
|
||||
this.showEditor = false;
|
||||
this.showFile = true;
|
||||
this.fileLoading = true;
|
||||
this.fileLanguage = this.getLanguage(path);
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(path));
|
||||
if (r.ok) {
|
||||
var d = await r.json();
|
||||
this.fileContent = d.content || '';
|
||||
this.fileSize = d.content ? d.content.length : 0;
|
||||
} else {
|
||||
this.fileContent = '[Error loading file]';
|
||||
}
|
||||
} catch(e) {
|
||||
this.fileContent = '[Error loading file]';
|
||||
}
|
||||
this.fileLoading = false;
|
||||
// Highlight after Alpine renders
|
||||
var self = this;
|
||||
this.$nextTick(function() {
|
||||
var block = self.$refs.codeBlock;
|
||||
if (block && block.textContent && typeof Prism !== 'undefined') {
|
||||
Prism.highlightElement(block);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
getLanguage(path) {
|
||||
var ext = (path || '').split('.').pop().toLowerCase();
|
||||
var map = {
|
||||
js:'javascript', jsx:'javascript', ts:'typescript', tsx:'typescript',
|
||||
py:'python', rb:'ruby', rs:'rust', go:'go', java:'java', kt:'kotlin',
|
||||
c:'c', cpp:'c', h:'c', hpp:'c', cs:'csharp',
|
||||
html:'markup', htm:'markup', xml:'markup', svg:'markup', css:'css', scss:'css',
|
||||
json:'json', yaml:'yaml', yml:'yaml', toml:'toml', ini:'ini',
|
||||
md:'markdown', sql:'sql', sh:'bash', bash:'bash', zsh:'bash',
|
||||
php:'php', swift:'swift', r:'r', lua:'lua', dart:'dart',
|
||||
dockerfile:'docker', makefile:'makefile', cmake:'cmake',
|
||||
};
|
||||
return map[ext] || 'text';
|
||||
},
|
||||
|
||||
openEditor() {
|
||||
this.editContent = this.fileContent;
|
||||
this.showEditor = true;
|
||||
},
|
||||
|
||||
async saveFile() {
|
||||
if (!this.filePath) return;
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken()},
|
||||
body: JSON.stringify({
|
||||
path: this.filePath, content: this.editContent,
|
||||
message: this.commitMessage, sha: this.fileSha,
|
||||
})
|
||||
});
|
||||
if (r.ok) {
|
||||
this.fileContent = this.editContent;
|
||||
this.showEditor = false;
|
||||
if (!this.commitHistory.includes(this.commitMessage)) {
|
||||
this.commitHistory.unshift(this.commitMessage);
|
||||
if (this.commitHistory.length > 10) this.commitHistory.pop();
|
||||
localStorage.setItem('fd_commit_msgs', JSON.stringify(this.commitHistory));
|
||||
}
|
||||
} else {
|
||||
var d = await r.json();
|
||||
window.showToast('Failed to save: ' + (d.error || 'Unknown error'), 'error');
|
||||
}
|
||||
} catch(e) { window.showToast('Network error', 'error'); }
|
||||
},
|
||||
|
||||
async deleteCurrentFile() {
|
||||
if (!this.filePath) return;
|
||||
if (!confirm('Delete ' + this.filePath + ' from ' + this.owner + '/' + this.repo + '?')) return;
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file?path=' + encodeURIComponent(this.filePath) + '&sha=' + encodeURIComponent(this.fileSha) + '&message=' + encodeURIComponent('Delete via FlowDeck'), {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''},
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (r.ok) {
|
||||
this.showFile = false;
|
||||
this.filePath = '';
|
||||
await this.refreshTree();
|
||||
}
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
async refreshTree() {
|
||||
// Reload main tree and sidebar tree without full page reload
|
||||
this.loadMainTree(this.currentPath);
|
||||
this.loadSidebarTree();
|
||||
},
|
||||
|
||||
formatSize(bytes) {
|
||||
if (!bytes) return '';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes/1024).toFixed(1) + ' KB';
|
||||
return (bytes/1048576).toFixed(1) + ' MB';
|
||||
},
|
||||
|
||||
// ── Private Pages ──
|
||||
|
||||
async loadPrivatePages() {
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages');
|
||||
if (r.ok) { var d = await r.json(); this.privatePages = d.pages || []; }
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
newPrivate() {
|
||||
this.editingPrivate = 'new';
|
||||
this.editingPrivateTitle = '';
|
||||
this.editingPrivateContent = '';
|
||||
},
|
||||
|
||||
async openPrivate(id) {
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id);
|
||||
if (r.ok) {
|
||||
var d = await r.json();
|
||||
this.editingPrivate = id;
|
||||
this.editingPrivateTitle = d.page.title;
|
||||
this.editingPrivateContent = d.page.content;
|
||||
}
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
async savePrivate() {
|
||||
var url = '/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages';
|
||||
var method = 'POST';
|
||||
if (this.editingPrivate !== 'new') {
|
||||
url += '/' + this.editingPrivate;
|
||||
method = 'PUT';
|
||||
}
|
||||
try {
|
||||
var r = await fetch(url, {
|
||||
method, headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''},
|
||||
body: JSON.stringify({title: this.editingPrivateTitle, content: this.editingPrivateContent})
|
||||
});
|
||||
if (r.ok) {
|
||||
this.editingPrivate = null;
|
||||
this.editingPrivateTitle = '';
|
||||
this.editingPrivateContent = '';
|
||||
await this.loadPrivatePages();
|
||||
}
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
// Create new file
|
||||
showNewFile: false,
|
||||
newFilePath: '',
|
||||
newFileContent: '',
|
||||
newFileMsg: 'Create via FlowDeck',
|
||||
async createNewFile() {
|
||||
if (!this.newFilePath.trim()) return;
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/file', {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type':'application/json', 'X-CSRF-Token': this.getCsrfToken()},
|
||||
body: JSON.stringify({path: this.newFilePath.trim(), content: this.newFileContent, message: this.newFileMsg || 'Create via FlowDeck'})
|
||||
});
|
||||
if (r.ok) {
|
||||
this.showNewFile = false;
|
||||
this.newFilePath = '';
|
||||
this.newFileContent = '';
|
||||
this.newFileMsg = 'Create via FlowDeck';
|
||||
await this.loadSidebarTree();
|
||||
} else {
|
||||
var d = await r.json();
|
||||
window.showToast('Failed: ' + (d.error || d.detail || 'Unknown error'), 'error');
|
||||
}
|
||||
} catch(e) { window.showToast('Error: ' + e.message, 'error'); }
|
||||
},
|
||||
|
||||
// Upload files
|
||||
triggerUpload() { document.getElementById('gitea-upload-input').click(); },
|
||||
async doUpload(ev) {
|
||||
var files = ev.target.files;
|
||||
if (!files.length) return;
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
var form = new FormData();
|
||||
form.append('file', files[i]);
|
||||
form.append('message', 'Upload ' + files[i].name + ' via FlowDeck');
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/upload', {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRF-Token': this.getCsrfToken()},
|
||||
body: form
|
||||
});
|
||||
if (!r.ok) { var d = await r.json(); console.error('Upload failed:', d.error); }
|
||||
} catch(e) { console.error('Upload error:', e); }
|
||||
}
|
||||
await this.loadSidebarTree();
|
||||
ev.target.value = '';
|
||||
},
|
||||
|
||||
async deletePrivate(id) {
|
||||
if (!confirm('Delete this private page?')) return;
|
||||
try {
|
||||
var r = await fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/private-pages/' + id, {
|
||||
method: 'DELETE', headers: {'X-CSRF-Token': this.getCsrfToken ? this.getCsrfToken() : ''}
|
||||
});
|
||||
if (r.ok) await this.loadPrivatePages();
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
getCsrfToken() {
|
||||
return document.cookie.split('; ').find(function(c) { return c.startsWith('csrf_token='); })?.split('=')[1] || '';
|
||||
},
|
||||
|
||||
// ── Multi-selection ──
|
||||
multiSelect: false,
|
||||
selectedPaths: [],
|
||||
lastClickedPath: null,
|
||||
|
||||
toggleMultiSelect() {
|
||||
this.multiSelect = !this.multiSelect;
|
||||
var cbs = document.querySelectorAll('.gitea-checkbox');
|
||||
var self = this;
|
||||
cbs.forEach(function(cb) { cb.style.display = self.multiSelect ? '' : 'none'; });
|
||||
if (!this.multiSelect) {
|
||||
// Clear selection when leaving multi-select mode
|
||||
cbs.forEach(function(cb) { cb.checked = false; });
|
||||
this.selectedPaths = [];
|
||||
this.lastClickedPath = null;
|
||||
// Remove selected class
|
||||
document.querySelectorAll('.sidebar-item.gitea-selected').forEach(function(el) {
|
||||
el.classList.remove('gitea-selected');
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
selectItem(path, li, ev) {
|
||||
if (ev.shiftKey && this.lastClickedPath) {
|
||||
// Range select
|
||||
var all = Array.from(document.querySelectorAll('.sidebar-item[data-gitea-path]'));
|
||||
var startIdx = all.findIndex(function(el) { return el.getAttribute('data-gitea-path') === this.lastClickedPath; }.bind(this));
|
||||
var endIdx = all.findIndex(function(el) { return el.getAttribute('data-gitea-path') === path; }.bind(this));
|
||||
if (startIdx >= 0 && endIdx >= 0) {
|
||||
var lo = Math.min(startIdx, endIdx);
|
||||
var hi = Math.max(startIdx, endIdx);
|
||||
this.selectedPaths = [];
|
||||
for (var i = lo; i <= hi; i++) {
|
||||
this.selectedPaths.push(all[i].getAttribute('data-gitea-path'));
|
||||
all[i].classList.add('gitea-selected');
|
||||
var cb = all[i].querySelector('.gitea-checkbox');
|
||||
if (cb) cb.checked = true;
|
||||
}
|
||||
}
|
||||
this.multiSelect = true;
|
||||
this.toggleMultiSelect(); // ensure checkboxes are visible
|
||||
} else if (ev.ctrlKey || ev.metaKey) {
|
||||
// Toggle single
|
||||
var idx = this.selectedPaths.indexOf(path);
|
||||
if (idx >= 0) {
|
||||
this.selectedPaths.splice(idx, 1);
|
||||
li.classList.remove('gitea-selected');
|
||||
var cb = li.querySelector('.gitea-checkbox');
|
||||
if (cb) cb.checked = false;
|
||||
} else {
|
||||
this.selectedPaths.push(path);
|
||||
li.classList.add('gitea-selected');
|
||||
var cb = li.querySelector('.gitea-checkbox');
|
||||
if (cb) cb.checked = true;
|
||||
}
|
||||
this.multiSelect = this.selectedPaths.length > 0;
|
||||
this.toggleMultiSelect();
|
||||
} else {
|
||||
// Normal select — clear multi-selection
|
||||
this.selectedPaths = [path];
|
||||
this.lastClickedPath = path;
|
||||
document.querySelectorAll('.sidebar-item.gitea-selected').forEach(function(el) {
|
||||
el.classList.remove('gitea-selected');
|
||||
});
|
||||
li.classList.add('gitea-selected', 'active');
|
||||
var cb = li.querySelector('.gitea-checkbox');
|
||||
if (cb) cb.checked = true;
|
||||
}
|
||||
},
|
||||
|
||||
isSelected(path) {
|
||||
return this.selectedPaths.indexOf(path) >= 0;
|
||||
},
|
||||
|
||||
// ── Inline rename ──
|
||||
startInlineRename(nameSpan, path, li) {
|
||||
var originalName = nameSpan.textContent;
|
||||
var input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.value = originalName;
|
||||
input.className = 'inline-rename-input';
|
||||
input.style.cssText = 'flex:1;min-width:0;padding:2px 6px;background:var(--bg-tertiary);border:1px solid var(--accent);border-radius:4px;color:var(--text);font-size:13px;outline:none;';
|
||||
nameSpan.replaceWith(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
var self = this;
|
||||
var finish = function() { self.finishInlineRename(input, originalName, path, li); };
|
||||
var cancel = function() {
|
||||
var span = document.createElement('span');
|
||||
span.className = 'page-name';
|
||||
span.textContent = originalName;
|
||||
input.replaceWith(span);
|
||||
};
|
||||
input.addEventListener('blur', finish);
|
||||
input.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') finish();
|
||||
if (e.key === 'Escape') { e.stopPropagation(); cancel(); }
|
||||
});
|
||||
},
|
||||
|
||||
finishInlineRename(input, originalName, path, li) {
|
||||
if (input._renaming) return; // guard against double-fire
|
||||
input._renaming = true;
|
||||
var newName = input.value.trim();
|
||||
if (!newName || newName === originalName) {
|
||||
var span = document.createElement('span');
|
||||
span.className = 'page-name';
|
||||
span.textContent = originalName;
|
||||
input.replaceWith(span);
|
||||
return;
|
||||
}
|
||||
var self = this;
|
||||
// Construct new path by replacing the last segment
|
||||
var parts = path.split('/');
|
||||
parts.pop();
|
||||
var newPath = (parts.length > 0 ? parts.join('/') + '/' : '') + newName;
|
||||
fetch('/api/gitea/projects/' + this.owner + '/' + this.repo + '/rename', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken()},
|
||||
body: JSON.stringify({old_path: path, new_path: newPath, new_name: newName})
|
||||
}).then(function(r) {
|
||||
if (r.ok) {
|
||||
self.refreshTree();
|
||||
} else {
|
||||
var span = document.createElement('span');
|
||||
span.className = 'page-name';
|
||||
span.textContent = originalName;
|
||||
input.replaceWith(span);
|
||||
window.showToast('Rename failed', 'error');
|
||||
}
|
||||
}).catch(function() {
|
||||
var span = document.createElement('span');
|
||||
span.className = 'page-name';
|
||||
span.textContent = originalName;
|
||||
input.replaceWith(span);
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
|
||||
function importWizard() {
|
||||
return {
|
||||
sources: [],
|
||||
sourceId: '',
|
||||
files: [],
|
||||
dragOver: false,
|
||||
mode: 'skip',
|
||||
busy: false,
|
||||
progress: 0,
|
||||
error: '',
|
||||
preview: null,
|
||||
report: null,
|
||||
mapping: {},
|
||||
csrf: '',
|
||||
urlValue: '',
|
||||
urlError: '',
|
||||
forgeProvider: 'gitea',
|
||||
forgeOwner: '',
|
||||
forgeRepo: '',
|
||||
forgeState: 'all',
|
||||
forgeError: '',
|
||||
types: ['text','number','date','checkbox','email','url','phone','select','multi_select','status'],
|
||||
async init() {
|
||||
try {
|
||||
const r = await fetch('/api/import/sources');
|
||||
this.sources = (await r.json()).sources || [];
|
||||
} catch(e) {}
|
||||
try {
|
||||
const c = await fetch('/api/csrf-token');
|
||||
this.csrf = (await c.json()).csrf_token;
|
||||
} catch(e) {}
|
||||
},
|
||||
humanSize(n) { return n > 1048576 ? (n/1048576).toFixed(1)+' Mo' : Math.max(1, Math.round(n/1024))+' Ko'; },
|
||||
addFiles(list) {
|
||||
for (const f of list) this.files.push({file:f, name:f.name, size:f.size, status:'queued'});
|
||||
this.preview = null; this.report = null; this.error = '';
|
||||
},
|
||||
onFiles(e) { this.addFiles(e.target.files); e.target.value=''; },
|
||||
onDrop(e) { this.dragOver=false; this.addFiles(e.dataTransfer.files); },
|
||||
buildForm(f) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f.file);
|
||||
if (this.sourceId) fd.append('source', this.sourceId);
|
||||
fd.append('mode', this.mode);
|
||||
return fd;
|
||||
},
|
||||
async doPreview() {
|
||||
this.busy = true; this.error=''; this.report=null; this.progress=10;
|
||||
try {
|
||||
let merged = null;
|
||||
for (let i=0;i<this.files.length;i++) {
|
||||
const r = await fetch('/api/import/preview', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:this.buildForm(this.files[i])});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { this.error = (this.files[i].name+': '+(d.detail||'Erreur')); continue; }
|
||||
if (!merged) merged = {source_label:d.source_label, pages:[], warnings:[], stats:{}};
|
||||
merged.pages.push(...(d.pages||[]));
|
||||
merged.warnings.push(...(d.warnings||[]));
|
||||
this.progress = Math.round(((i+1)/this.files.length)*100);
|
||||
}
|
||||
this.preview = merged;
|
||||
this.mapping = {};
|
||||
(merged ? merged.pages : []).forEach(p => (p.schema||[]).forEach(c => { this.mapping[c.name]=c.type; }));
|
||||
} catch(e) { this.error = 'Erreur réseau'; }
|
||||
finally { this.busy = false; setTimeout(()=>{this.progress=0;},800); }
|
||||
},
|
||||
async importOne(f) {
|
||||
f.status = 'running';
|
||||
const fd = this.buildForm(f);
|
||||
if (Object.keys(this.mapping).length) fd.append('mapping', JSON.stringify(this.mapping));
|
||||
const big = f.size > 5*1024*1024;
|
||||
try {
|
||||
if (big) {
|
||||
fd.append('async','true');
|
||||
const r = await fetch('/api/import/run', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:fd});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; }
|
||||
return await this.pollJob(d.job_id, f);
|
||||
}
|
||||
const r = await fetch('/api/import/run', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:fd});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; }
|
||||
f.status = (d.status==='partial' ? 'partial' : 'done');
|
||||
return d;
|
||||
} catch(e) { f.status='error'; f.error='Erreur réseau'; return null; }
|
||||
},
|
||||
async doImport() {
|
||||
this.busy = true; this.error=''; this.report=null; this.progress=0;
|
||||
const aggregate = {pages_created:0,pages_updated:0,collections_created:0,rows_created:0,
|
||||
attachments:0,skipped:0,warnings:[],errors:[],per_file:[]};
|
||||
for (let i=0;i<this.files.length;i++) {
|
||||
const d = await this.importOne(this.files[i]);
|
||||
if (d) {
|
||||
for (const k of ['pages_created','pages_updated','collections_created','rows_created','attachments','skipped'])
|
||||
aggregate[k] += (d[k]||0);
|
||||
aggregate.warnings.push(...(d.warnings||[]));
|
||||
aggregate.errors.push(...(d.errors||[]));
|
||||
aggregate.per_file.push({filename:this.files[i].name, report:d});
|
||||
} else {
|
||||
aggregate.errors.push({title:this.files[i].name, error:this.files[i].error||'Erreur'});
|
||||
aggregate.per_file.push({filename:this.files[i].name, report:{status:'error'}});
|
||||
}
|
||||
this.progress = Math.round(((i+1)/this.files.length)*100);
|
||||
}
|
||||
this.report = aggregate;
|
||||
this.busy = false;
|
||||
},
|
||||
async doUrl() {
|
||||
this.busy = true; this.urlError = ''; this.report = null; this.progress = 40;
|
||||
try {
|
||||
const r = await fetch('/api/import/url', {
|
||||
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
|
||||
body: JSON.stringify({url:this.urlValue.trim()})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { this.urlError = d.detail || 'Erreur'; return; }
|
||||
this.report = d; this.progress = 100;
|
||||
} catch(e) { this.urlError = 'Erreur réseau'; }
|
||||
finally { this.busy = false; }
|
||||
},
|
||||
async doForge(kind) {
|
||||
this.busy = true; this.forgeError = ''; this.report = null; this.progress = 30;
|
||||
const endpoint = kind === 'repo' ? '/api/import/forge-repo' : '/api/import/forge';
|
||||
try {
|
||||
const r = await fetch(endpoint, {
|
||||
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
|
||||
body: JSON.stringify({
|
||||
provider:this.forgeProvider, owner:this.forgeOwner.trim(),
|
||||
repo:this.forgeRepo.trim(), state:this.forgeState
|
||||
})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { this.forgeError = d.detail || 'Erreur'; return; }
|
||||
this.report = d; this.progress = 100;
|
||||
} catch(e) { this.forgeError = 'Erreur réseau'; }
|
||||
finally { this.busy = false; }
|
||||
},
|
||||
async pollJob(jobId, f) {
|
||||
for (let i=0;i<600;i++) {
|
||||
await new Promise(res => setTimeout(res, 700));
|
||||
const r = await fetch('/api/import/jobs/'+jobId);
|
||||
const j = await r.json();
|
||||
if (j.status === 'done') { if (f) f.status='done'; return j.report; }
|
||||
if (j.status === 'error') { if (f) { f.status='error'; f.error=j.error; } return null; }
|
||||
}
|
||||
if (f) { f.status='error'; f.error='Délai dépassé'; }
|
||||
return null;
|
||||
},
|
||||
downloadReport() {
|
||||
const blob = new Blob([JSON.stringify(this.report, null, 2)], {type:'application/json'});
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'flowdeck-import-report.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,532 @@
|
||||
const RT=(()=>{try{const el=document.getElementById('rt-config');return el?JSON.parse(el.textContent):{}}catch(e){return {}}})();
|
||||
|
||||
/* eslint-disable */
|
||||
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
|
||||
window.__fdRT = (function () {
|
||||
const SELF = {
|
||||
id: RT.id,
|
||||
login: RT.login,
|
||||
full_name: RT.full_name,
|
||||
color: RT.color,
|
||||
};
|
||||
const COLORS = [
|
||||
"#2383E2", "#46A758", "#E5484D", "#F76B15", "#8E4EC6", "#12A594",
|
||||
"#FFC53D", "#D6409F", "#0091FF", "#3E63DD", "#30A46C", "#FF3333",
|
||||
];
|
||||
let E = null; // editorState (window.E)
|
||||
let ws = null;
|
||||
let mode = 'off'; // 'ws' | 'poll'
|
||||
let open = false;
|
||||
let base = []; // dernier snapshot serveur des blocs
|
||||
let version = 0;
|
||||
let pendingOps = 0;
|
||||
let needSync = false;
|
||||
let peers = []; // liste des présents (hors moi)
|
||||
let remoteCursors = {}; // userId -> {peer, block, offset}
|
||||
let myCursor = null; // {block, offset}
|
||||
let emitT = null, selT = null, titleT = null, drawT = null, retryT = null, pollT = null;
|
||||
let _pid = 0;
|
||||
|
||||
// Detach global listeners from a previous editor instance so that
|
||||
// partial (HTMX) navigation doesn't accumulate stale handlers.
|
||||
function unbindGlobal() {
|
||||
const g = window.__fdRTGlobal;
|
||||
if (!g) return;
|
||||
try {
|
||||
document.removeEventListener('selectionchange', g.onSel, true);
|
||||
window.removeEventListener('scroll', g.onScroll, true);
|
||||
window.removeEventListener('resize', g.onResize);
|
||||
} catch (e) { /* noop */ }
|
||||
window.__fdRTGlobal = null;
|
||||
}
|
||||
|
||||
const clone = (o) => JSON.parse(JSON.stringify(o));
|
||||
|
||||
function colorOf(uid) { return COLORS[Math.abs(uid || 0) % COLORS.length]; }
|
||||
|
||||
function initials(p) {
|
||||
const n = (p && (p.full_name || p.login)) || '';
|
||||
const parts = String(n).trim().split(/\s+/);
|
||||
if (!parts[0]) return '?';
|
||||
return ((parts[0][0] || '') + (parts.length > 1 ? (parts[1][0] || '') : '')).toUpperCase().slice(0, 2);
|
||||
}
|
||||
|
||||
function send(obj) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try { ws.send(JSON.stringify(obj)); } catch (e) { /* noop */ }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── ops miroir du serveur (apply_op/merge) ── */
|
||||
function applyOpJS(blocks, op) {
|
||||
const t = op && op.type;
|
||||
if (t === 'insert') {
|
||||
const blk = op.block || {};
|
||||
const id = blk.id || ('rb' + Date.now().toString(36));
|
||||
blk.id = id;
|
||||
if (typeof ensureBlockIds === 'function') ensureBlockIds([blk]);
|
||||
let idx = op.index != null ? op.index : blocks.length;
|
||||
idx = Math.max(0, Math.min(idx, blocks.length));
|
||||
return blocks.slice(0, idx).concat([blk]).concat(blocks.slice(idx));
|
||||
}
|
||||
if (t === 'update') {
|
||||
const nb = op.block || {};
|
||||
if (!nb.id) return blocks;
|
||||
return blocks.map(b => (b.id === nb.id ? nb : b));
|
||||
}
|
||||
if (t === 'delete') {
|
||||
const bid = op.id;
|
||||
return blocks.filter(b => b.id !== bid);
|
||||
}
|
||||
if (t === 'move') {
|
||||
const bid = op.id, idx = op.index || 0;
|
||||
const out = blocks.filter(b => b.id !== bid);
|
||||
const moved = blocks.find(b => b.id === bid);
|
||||
if (!moved) return blocks;
|
||||
const i2 = Math.max(0, Math.min(idx, out.length));
|
||||
out.splice(i2, 0, moved);
|
||||
return out;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/* Diff base → courants : update/delete/move/insert (ordre pour le serveur). */
|
||||
function diffOps(cur) {
|
||||
if (!base.length && !cur.length) return [];
|
||||
const ops = [];
|
||||
const baseById = {}, curById = {};
|
||||
base.forEach(b => { baseById[b.id] = b; });
|
||||
cur.forEach(b => { curById[b.id] = b; });
|
||||
|
||||
cur.forEach(b => {
|
||||
if (baseById[b.id] !== undefined && JSON.stringify(baseById[b.id]) !== JSON.stringify(b)) {
|
||||
// v6.4.0 : on embarque la `base` dont dérive la saisie → le serveur
|
||||
// fait un merge 3-voix au lieu d'écraser le bloc (LWW).
|
||||
ops.push({ type: 'update', block: clone(b), base: clone(baseById[b.id]) });
|
||||
}
|
||||
});
|
||||
base.forEach(b => {
|
||||
if (curById[b.id] === undefined) ops.push({ type: 'delete', id: b.id });
|
||||
});
|
||||
|
||||
// structure : simule l'état serveur (base − supprimés) pour indices valides
|
||||
let sim = base.filter(b => curById[b.id] !== undefined).map(b => clone(b));
|
||||
const simById = {}; sim.forEach(b => { simById[b.id] = b; });
|
||||
const finalOrder = cur.map(b => b.id);
|
||||
let si = 0;
|
||||
finalOrder.forEach(id => {
|
||||
if (simById[id] !== undefined) {
|
||||
const curPos = sim.findIndex(b => b.id === id);
|
||||
if (curPos !== si) ops.push({ type: 'move', id, index: si });
|
||||
const [mv] = sim.splice(curPos, 1);
|
||||
sim.splice(si, 0, mv);
|
||||
si++;
|
||||
} else {
|
||||
ops.push({ type: 'insert', index: si, block: clone(curById[id]) });
|
||||
sim.splice(si, 0, curById[id]);
|
||||
simById[id] = curById[id];
|
||||
si++;
|
||||
}
|
||||
});
|
||||
return ops;
|
||||
}
|
||||
|
||||
/* ── rendu + présence ── */
|
||||
function activeBlockId() {
|
||||
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
|
||||
return a ? a.bid : null;
|
||||
}
|
||||
|
||||
function refocus(fid) {
|
||||
if (!fid) return;
|
||||
setTimeout(() => {
|
||||
const el = E.getEl(fid);
|
||||
if (el) { el.focus(); try { (typeof ce === 'function') && ce(el); } catch (e) { /* noop */ } }
|
||||
}, 30);
|
||||
}
|
||||
|
||||
function renderPresence() {
|
||||
let host = document.querySelector('.topbar-right.header-actions');
|
||||
if (!host) return;
|
||||
let box = document.getElementById('rtPresence');
|
||||
if (!box) {
|
||||
box = document.createElement('span');
|
||||
box.id = 'rtPresence';
|
||||
box.className = 'rt-presence';
|
||||
host.insertBefore(box, host.firstChild);
|
||||
}
|
||||
const shown = peers.filter(p => p.id !== (SELF.id || 0));
|
||||
if (!shown.length) { box.style.display = 'none'; return; }
|
||||
box.style.display = 'inline-flex';
|
||||
box.innerHTML = '';
|
||||
shown.forEach(p => {
|
||||
const c = document.createElement('span');
|
||||
c.className = 'rt-avatar';
|
||||
c.title = (p.full_name || p.login) + ' est en train d\u2019éditer' + (mode === 'poll' ? ' (polling)' : '');
|
||||
c.textContent = initials(p);
|
||||
c.style.background = p.color || colorOf(p.id);
|
||||
box.appendChild(c);
|
||||
});
|
||||
if (mode === 'poll') {
|
||||
let off = document.getElementById('rtOffline');
|
||||
if (!off) {
|
||||
off = document.createElement('span');
|
||||
off.id = 'rtOffline';
|
||||
off.className = 'rt-offline';
|
||||
off.textContent = '●';
|
||||
off.title = 'Realtime indisponible — rafraîchissement toutes les 10 s';
|
||||
host.insertBefore(off, box.nextSibling || null);
|
||||
}
|
||||
off.style.display = 'inline';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── curseurs ── */
|
||||
function rangeFromOffset(el, offset) {
|
||||
try {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
let n = walker.nextNode(), count = 0;
|
||||
while (n) {
|
||||
const len = (n.nodeValue || '').length;
|
||||
if (count + len >= offset) {
|
||||
const r = document.createRange();
|
||||
r.setStart(n, Math.min(offset - count, len));
|
||||
r.collapse(true);
|
||||
return r;
|
||||
}
|
||||
count += len;
|
||||
n = walker.nextNode();
|
||||
}
|
||||
const r = document.createRange();
|
||||
r.selectNodeContents(el);
|
||||
r.collapse(false);
|
||||
return r;
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
function drawCursors() {
|
||||
const layer = document.getElementById('rtCursors');
|
||||
if (!layer) return;
|
||||
layer.innerHTML = '';
|
||||
const ids = Object.keys(remoteCursors);
|
||||
if (!ids.length) return;
|
||||
ids.forEach(uid => {
|
||||
const c = remoteCursors[uid];
|
||||
if (!c || !c.block) return;
|
||||
const el = E.getEl(c.block);
|
||||
if (!el) return;
|
||||
const r = rangeFromOffset(el, c.offset || 0);
|
||||
let x, y, h;
|
||||
if (r) {
|
||||
const rc = r.getBoundingClientRect();
|
||||
if (!rc.width && !rc.height) return; // hors viewport positionné
|
||||
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
|
||||
} else {
|
||||
const rc = el.getBoundingClientRect();
|
||||
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
|
||||
}
|
||||
const m = document.createElement('div');
|
||||
m.className = 'rt-cursor';
|
||||
m.style.left = (x - 1) + 'px';
|
||||
m.style.top = (y - 1) + 'px';
|
||||
m.style.height = h + 'px';
|
||||
m.style.background = c.peer.color || colorOf(c.peer.id);
|
||||
const nm = document.createElement('span');
|
||||
nm.className = 'rt-cursor-name';
|
||||
nm.textContent = initials(c.peer);
|
||||
nm.style.background = m.style.background;
|
||||
m.appendChild(nm);
|
||||
layer.appendChild(m);
|
||||
});
|
||||
}
|
||||
|
||||
function emitCursor() {
|
||||
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
|
||||
let block = null, offset = 0;
|
||||
if (a && a.el && a.bid) {
|
||||
block = a.bid;
|
||||
try { offset = (typeof cp === 'function') ? cp(a.el) : 0; } catch (e) { offset = 0; }
|
||||
}
|
||||
const changed = !myCursor || myCursor.block !== block || myCursor.offset !== offset;
|
||||
myCursor = { block, offset };
|
||||
if (!changed) return;
|
||||
send({ t: 'sel', block, offset });
|
||||
}
|
||||
|
||||
function scheduleDraw() {
|
||||
clearTimeout(drawT);
|
||||
drawT = setTimeout(drawCursors, 40);
|
||||
}
|
||||
|
||||
/* ── application des changements distants ── */
|
||||
function applySync(blocks, title) {
|
||||
if (!E || !E.blocks) return;
|
||||
// v5.13.1: guarantee ids before comparing/merging. Server rooms may still
|
||||
// carry legacy id-less blocks; without this they collide on
|
||||
// data-bid="undefined" and the merge below duplicated every line.
|
||||
blocks = (blocks || []).slice();
|
||||
if (typeof ensureBlockIds === 'function') ensureBlockIds(blocks);
|
||||
const curIds = JSON.stringify((E.blocks || []).map(b => b.id));
|
||||
const srvIds = JSON.stringify(blocks.map(b => b.id));
|
||||
if (curIds === srvIds) {
|
||||
base = clone(E.blocks);
|
||||
E.dirty = false;
|
||||
return;
|
||||
}
|
||||
const fid = activeBlockId();
|
||||
const curById = {};
|
||||
(E.blocks || []).forEach(b => { curById[b.id] = b; });
|
||||
let out;
|
||||
try {
|
||||
if (!blocks.length) {
|
||||
// serveur vide : ne pas effacer le contenu local (page neuve).
|
||||
out = (E.blocks || []).slice();
|
||||
} else {
|
||||
out = blocks.map(b => {
|
||||
const cb = curById[b.id];
|
||||
if (cb && b.id === fid) return cb; // garde la frappe locale en cours
|
||||
return b;
|
||||
});
|
||||
}
|
||||
} catch (e) { return; }
|
||||
E.blocks = out;
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (title && title !== E.pageTitle && document.activeElement !== tEl) {
|
||||
tEl.textContent = title;
|
||||
E.pageTitle = title;
|
||||
}
|
||||
E.dirty = true;
|
||||
base = clone(E.blocks);
|
||||
E.render();
|
||||
refocus(fid);
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
function applyRemoteOp(op) {
|
||||
const fid = activeBlockId();
|
||||
if (op.type === 'update') {
|
||||
const nb = op.block || {};
|
||||
if (nb.id === fid) {
|
||||
// bloc en cours d'édition : on garde la valeur locale, le serveur a déjà
|
||||
// l'op ; la prochaine frappe locale repartira (LWW).
|
||||
base = applyOpJS(base, op);
|
||||
return;
|
||||
}
|
||||
E.blocks = applyOpJS(E.blocks, op);
|
||||
base = applyOpJS(base, op);
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
} else {
|
||||
E.blocks = applyOpJS(E.blocks, op);
|
||||
base = applyOpJS(base, op);
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
}
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
/* ── diffusion des modifications locales ── */
|
||||
function emit() {
|
||||
if (mode !== 'ws' || !open || !E || !E.blocks) return;
|
||||
if (needSync) { send({ t: 'sync_req' }); return; }
|
||||
const cur = E.blocks.filter(b => b && b.id);
|
||||
const ops = diffOps(cur);
|
||||
if (!ops.length) return;
|
||||
ops.forEach(op => { send({ t: 'op', op, v: version }); pendingOps++; });
|
||||
base = clone(cur);
|
||||
}
|
||||
|
||||
function onMsg(m) {
|
||||
if (!m || !m.t) return;
|
||||
if (m.t === 'sync') {
|
||||
version = m.version || 0;
|
||||
base = clone(m.blocks || []);
|
||||
needSync = false;
|
||||
pendingOps = 0;
|
||||
if (typeof m.blocks === 'undefined') return;
|
||||
applySync(m.blocks || [], m.title || '');
|
||||
} else if (m.t === 'ack') {
|
||||
version = m.v || 0;
|
||||
if (pendingOps > 0) pendingOps--;
|
||||
// v6.4.0 : le serveur renvoie le bloc fusionné (merge 3-voix). On
|
||||
// l'adopte comme nouvelle base ; s'il diffère de notre saisie locale
|
||||
// c'est qu'un autre utilisateur avait modifié le même bloc.
|
||||
if (m.merged) {
|
||||
const mid = m.merged.id;
|
||||
const localBlock = (E && E.blocks || []).find(b => b.id === mid);
|
||||
const differs = localBlock && JSON.stringify(localBlock) !== JSON.stringify(m.merged);
|
||||
base = applyOpJS(base, { type: 'update', block: m.merged });
|
||||
if (differs && E) {
|
||||
const fid = activeBlockId();
|
||||
if (fid !== mid) {
|
||||
E.blocks = applyOpJS(E.blocks, { type: 'update', block: m.merged });
|
||||
E.dirty = true;
|
||||
E.render();
|
||||
refocus(fid);
|
||||
}
|
||||
if (m.conflict && window.showToast) {
|
||||
window.showToast('Editing conflict merged on a block', 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
if (m.stale || needSync) { needSync = true; send({ t: 'sync_req' }); }
|
||||
} else if (m.t === 'op') {
|
||||
if (m.v) version = m.v;
|
||||
if (m.from === (SELF.id || 0)) { base = applyOpJS(base, m.op); return; }
|
||||
applyRemoteOp(m.op);
|
||||
} else if (m.t === 'title') {
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (m.from !== (SELF.id || 0) && tEl && document.activeElement !== tEl && E) {
|
||||
tEl.textContent = m.title || '';
|
||||
E.pageTitle = m.title || '';
|
||||
}
|
||||
if (m.v) version = m.v;
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'sel') {
|
||||
if (m.from === (SELF.id || 0)) return;
|
||||
if (!m.block) { delete remoteCursors[m.from]; scheduleDraw(); return; }
|
||||
remoteCursors[m.from] = { peer: m.peer || { id: m.from }, block: m.block, offset: m.offset || 0 };
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'welcome') {
|
||||
peers = (m.peers || []).filter(p => p.id !== (SELF.id || 0));
|
||||
renderPresence();
|
||||
} else if (m.t === 'peer_join') {
|
||||
if (m.peer && m.peer.id !== (SELF.id || 0)) {
|
||||
peers = peers.filter(p => p.id !== m.peer.id).concat([m.peer]);
|
||||
renderPresence();
|
||||
}
|
||||
} else if (m.t === 'peer_leave') {
|
||||
peers = peers.filter(p => p.id !== m.id);
|
||||
delete remoteCursors[m.id];
|
||||
renderPresence();
|
||||
scheduleDraw();
|
||||
} else if (m.t === 'synced_update') {
|
||||
// A synced block was updated — re-sync the whole page
|
||||
if (m.synced_id) {
|
||||
needSync = true;
|
||||
send({ t: 'sync_req' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── connexion WS + fallback polling ── */
|
||||
function startPolling() {
|
||||
if (pollT) return;
|
||||
mode = 'poll';
|
||||
renderPresence();
|
||||
scheduleDraw();
|
||||
pollT = setInterval(poll, 10000);
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollT) { clearInterval(pollT); pollT = null; }
|
||||
const off = document.getElementById('rtOffline');
|
||||
if (off) off.style.display = 'none';
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
if (!E || !_pid) return;
|
||||
try {
|
||||
const r = await fetch('/board/api/pages/' + _pid, { credentials: 'same-origin' });
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
if ((d.content_format || 'blocks') !== 'blocks' || !d.content) return;
|
||||
const serverBlocks = JSON.parse(d.content);
|
||||
// en cas de modifs locales non persistées : on garde local (LWW au prochain save)
|
||||
if (E.dirty) return;
|
||||
const cur = JSON.stringify((E.blocks || []).map(b => b.id));
|
||||
const srv = JSON.stringify(serverBlocks.map(b => b.id));
|
||||
if (cur === srv) return;
|
||||
version = version; // pas de version via polling — on adopte l'état serveur
|
||||
applySync(serverBlocks, d.title || E.pageTitle);
|
||||
} catch (e) { /* noop */ }
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (!E || !_pid || ws) return;
|
||||
const proto = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
try {
|
||||
ws = new WebSocket(proto + window.location.host + '/ws/pages/' + _pid);
|
||||
} catch (e) {
|
||||
startPolling();
|
||||
return;
|
||||
}
|
||||
ws.onopen = () => {
|
||||
open = true;
|
||||
mode = 'ws';
|
||||
stopPolling();
|
||||
send({ t: 'hello' });
|
||||
};
|
||||
ws.onmessage = (ev) => {
|
||||
let m;
|
||||
try { m = JSON.parse(ev.data); } catch (e) { return; }
|
||||
onMsg(m);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
open = false;
|
||||
ws = null;
|
||||
if (retryT) clearTimeout(retryT);
|
||||
retryT = setTimeout(connect, 15000);
|
||||
startPolling();
|
||||
};
|
||||
ws.onerror = () => { try { ws.close(); } catch (e) { /* noop */ } };
|
||||
setTimeout(() => {
|
||||
if (!open) { try { ws && ws.close(); } catch (e) { /* noop */ } }
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function titleInput() {
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (!tEl) return;
|
||||
clearTimeout(titleT);
|
||||
titleT = setTimeout(() => {
|
||||
send({ t: 'title', title: (tEl.textContent || '').trim() });
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function wire() {
|
||||
const ct = document.getElementById('_blocksCt');
|
||||
if (ct) {
|
||||
ct.addEventListener('input', () => {
|
||||
clearTimeout(emitT);
|
||||
emitT = setTimeout(emit, 350);
|
||||
setTimeout(emitCursor, 80);
|
||||
}, true);
|
||||
ct.addEventListener('keyup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
ct.addEventListener('click', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
ct.addEventListener('mouseup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
|
||||
}
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (tEl) tEl.addEventListener('input', titleInput);
|
||||
unbindGlobal();
|
||||
const onSel = () => { clearTimeout(selT); selT = setTimeout(emitCursor, 150); };
|
||||
const onScroll = () => scheduleDraw();
|
||||
const onResize = () => scheduleDraw();
|
||||
document.addEventListener('selectionchange', onSel, true);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
window.addEventListener('resize', onResize);
|
||||
window.__fdRTGlobal = { onSel, onScroll, onResize };
|
||||
}
|
||||
|
||||
function start(ed) {
|
||||
if (!ed) return;
|
||||
if ((ed.contentFormat || 'blocks') !== 'blocks') return;
|
||||
E = ed;
|
||||
_pid = ed.pid || 0;
|
||||
if (!_pid) return;
|
||||
base = clone(ed.blocks || []);
|
||||
wire();
|
||||
connect();
|
||||
}
|
||||
|
||||
// poussée immédiate des ops après une mutation programmatique (v5.10.0)
|
||||
function syncNow() {
|
||||
if (mode !== 'ws' || !open || !E || !E.blocks) return;
|
||||
clearTimeout(emitT);
|
||||
emit();
|
||||
}
|
||||
|
||||
return { start, syncNow };
|
||||
})();
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,153 @@
|
||||
|
||||
function workspacesPage() {
|
||||
return {
|
||||
workspaces: [],
|
||||
activeId: null,
|
||||
showCreate: false,
|
||||
showRename: false,
|
||||
wsName: '',
|
||||
renameTarget: null,
|
||||
|
||||
// Gitea
|
||||
giteaStatus: 'loading', // loading|ok|not_linked|error
|
||||
giteaGroups: [],
|
||||
giteaTotal: 0,
|
||||
activeOrg: 'all',
|
||||
giteaSearch: '',
|
||||
|
||||
get filteredGroups() {
|
||||
var self = this;
|
||||
var q = (this.giteaSearch || '').toLowerCase();
|
||||
var groups = this.activeOrg === 'all' ? this.giteaGroups : this.giteaGroups.filter(function(g) { return g.org === self.activeOrg; });
|
||||
if (!q) return groups;
|
||||
return groups.map(function(g) {
|
||||
return {org: g.org, avatar: g.avatar, projects: g.projects.filter(function(p) {
|
||||
return p.name.toLowerCase().includes(q) || (p.description||'').toLowerCase().includes(q);
|
||||
})};
|
||||
}).filter(function(g) { return g.projects.length > 0; });
|
||||
},
|
||||
|
||||
async init() {
|
||||
await this.load();
|
||||
await this.loadGitea();
|
||||
},
|
||||
|
||||
async load() {
|
||||
const r = await fetch('/api/workspaces');
|
||||
const d = await r.json();
|
||||
this.workspaces = d.workspaces || [];
|
||||
this.activeId = d.active_id;
|
||||
},
|
||||
|
||||
async selectLocal(ws) {
|
||||
await fetch(`/api/workspaces/${ws.id}/select`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'POST'});
|
||||
window.location = '/local-workspace';
|
||||
},
|
||||
|
||||
async doCreate() {
|
||||
if (!this.wsName.trim()) return;
|
||||
await fetch('/api/workspaces', {
|
||||
method:'POST',
|
||||
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body:JSON.stringify({name:this.wsName.trim()})
|
||||
});
|
||||
this.wsName = '';
|
||||
this.showCreate = false;
|
||||
await this.load();
|
||||
},
|
||||
|
||||
renameWs(ws) {
|
||||
this.renameTarget = ws;
|
||||
this.wsName = ws.name;
|
||||
this.showRename = true;
|
||||
},
|
||||
|
||||
async doRename() {
|
||||
if (!this.wsName.trim()||!this.renameTarget) return;
|
||||
await fetch(`/api/workspaces/${this.renameTarget.id}`, {
|
||||
method:'PUT',
|
||||
headers:{'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||'', 'Content-Type':'application/json'},
|
||||
body:JSON.stringify({name:this.wsName.trim()})
|
||||
});
|
||||
this.wsName = '';
|
||||
this.showRename = false;
|
||||
this.renameTarget = null;
|
||||
await this.load();
|
||||
},
|
||||
|
||||
async deleteWs(ws) {
|
||||
if (!confirm(`Delete workspace "${ws.name}" and all its pages?`)) return;
|
||||
await fetch(`/api/workspaces/${ws.id}`, {headers: {'X-CSRF-Token': (document.cookie.match(/csrf_token=([^;]+)/)||[])[1]||''}, method:'DELETE'});
|
||||
await this.load();
|
||||
},
|
||||
|
||||
// ── Gitea ──
|
||||
|
||||
async loadGitea() {
|
||||
this.giteaStatus = 'loading';
|
||||
try {
|
||||
// Get orgs
|
||||
const orgsRes = await fetch('/api/gitea/orgs');
|
||||
if (orgsRes.status === 401) { this.giteaStatus = 'not_linked'; return; }
|
||||
if (!orgsRes.ok) { this.giteaStatus = 'error'; return; }
|
||||
const orgsData = await orgsRes.json();
|
||||
const orgs = orgsData.orgs || [];
|
||||
|
||||
// Fetch repos: user repos + org repos
|
||||
const groups = [];
|
||||
|
||||
// Get username for the "personal" tab
|
||||
let myLogin = 'Me';
|
||||
try {
|
||||
const meRes = await fetch('/auth/user');
|
||||
if (meRes.ok) {
|
||||
const meData = await meRes.json();
|
||||
myLogin = meData.user?.login || 'Me';
|
||||
if (myLogin.includes('_')) myLogin = myLogin.split('_').pop(); // strip gitea_ prefix if present
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// User repos (no org filter)
|
||||
try {
|
||||
const userRes = await fetch('/api/gitea/projects');
|
||||
if (userRes.ok) {
|
||||
const d = await userRes.json();
|
||||
const repos = d.projects || [];
|
||||
if (repos.length) {
|
||||
groups.push({org: myLogin, avatar: '', projects: repos.map(r => ({...r, owner: r.owner || {login: myLogin}}))});
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// Org repos
|
||||
for (const org of orgs) {
|
||||
try {
|
||||
const res = await fetch('/api/gitea/projects?org=' + encodeURIComponent(org.username || org.login || org.name));
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
if (d.projects && d.projects.length) {
|
||||
groups.push({org: org.username || org.login || org.name, avatar: org.avatar_url || '', projects: d.projects});
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
this.giteaGroups = groups;
|
||||
this.giteaTotal = groups.reduce((sum, g) => sum + g.projects.length, 0);
|
||||
this.giteaStatus = 'ok';
|
||||
} catch(e) {
|
||||
this.giteaStatus = 'error';
|
||||
}
|
||||
},
|
||||
|
||||
openGitea(proj) {
|
||||
const owner = proj.owner?.login || proj.owner?.username || proj.full_name?.split('/')[0] || '';
|
||||
const repo = proj.name;
|
||||
if (owner && repo) {
|
||||
// Set workspace cookie so sidebar shows tree
|
||||
document.cookie = 'flowdeck_workspace=gitea:' + owner + ':' + repo + ';path=/;SameSite=Lax';
|
||||
window.location = `/gitea-workspace?owner=${encodeURIComponent(owner)}&repo=${encodeURIComponent(repo)}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -270,8 +270,15 @@ def _read_template(name: str) -> str:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def _read_js(name: str) -> str:
|
||||
"""A27 : le JS de l'éditeur vit dans static/js depuis l'extraction."""
|
||||
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
with open(os.path.join(base, "static", "js", name), encoding="utf-8") as fh:
|
||||
return fh.read()
|
||||
|
||||
|
||||
def test_editor_has_ai_slash_commands(client):
|
||||
src = _read_template("_page_editor_scripts.html")
|
||||
src = _read_js("page_editor_scripts.js")
|
||||
for cmd in ("ai_write", "ai_summarize", "ai_translate", "ai_continue"):
|
||||
assert cmd in src
|
||||
assert "AI writing" in src
|
||||
@@ -280,7 +287,7 @@ def test_editor_has_ai_slash_commands(client):
|
||||
|
||||
|
||||
def test_editor_has_autocomplete(client):
|
||||
src = _read_template("_page_editor_scripts.html")
|
||||
src = _read_js("page_editor_scripts.js")
|
||||
assert "AIAC" in src
|
||||
assert "autocomplete" in src
|
||||
assert "AIAC.schedule" in src
|
||||
|
||||
@@ -163,6 +163,113 @@ def test_gitea_cache_evicts_expired():
|
||||
assert "k" not in c._cache and c._cache["k2"][1] == "v2"
|
||||
|
||||
|
||||
def test_migration_transaction_rolls_back():
|
||||
"""A31 : un échec au milieu d'une migration ne laisse ni DDL partiel, ni
|
||||
ligne dans schema_version → la reprise rejoue proprement."""
|
||||
import sqlite3 as _sqlite3
|
||||
|
||||
import pytest as _pytest
|
||||
|
||||
from app.migrations import _apply_one, _ensure_table
|
||||
|
||||
conn = _sqlite3.connect(":memory:")
|
||||
_ensure_table(conn)
|
||||
|
||||
def boom(c):
|
||||
c.execute("CREATE TABLE partial_x (id INTEGER)")
|
||||
raise RuntimeError("boom")
|
||||
|
||||
with _pytest.raises(RuntimeError, match="boom"):
|
||||
_apply_one(conn, 9999, "boom", boom)
|
||||
assert (
|
||||
conn.execute("SELECT name FROM sqlite_master WHERE name='partial_x'").fetchone()
|
||||
is None
|
||||
), "DDL partiel non annulé"
|
||||
assert (
|
||||
conn.execute("SELECT COUNT(*) FROM schema_version WHERE version=9999").fetchone()[0]
|
||||
== 0
|
||||
)
|
||||
# chemin nominal : DDL + marque de version dans la même transaction
|
||||
_apply_one(conn, 9998, "ok", lambda c: c.execute("CREATE TABLE ok_x (id INTEGER)"))
|
||||
assert (
|
||||
conn.execute("SELECT COUNT(*) FROM schema_version WHERE version=9998").fetchone()[0]
|
||||
== 1
|
||||
)
|
||||
conn.close()
|
||||
|
||||
|
||||
def test_columns_helper_validates_table_name():
|
||||
"""A31 : `columns()` remplace les 24 copies de PRAGMA table_info + valide l'identifiant."""
|
||||
import sqlite3 as _sqlite3
|
||||
|
||||
from app.migrations import columns
|
||||
|
||||
conn = _sqlite3.connect(":memory:")
|
||||
conn.execute("CREATE TABLE t1 (id INTEGER, nom TEXT)")
|
||||
assert columns(conn, "t1") == {"id", "nom"}
|
||||
try:
|
||||
columns(conn, "t1; DROP TABLE users")
|
||||
raise AssertionError("identifiant non validé")
|
||||
except ValueError:
|
||||
pass
|
||||
conn.close()
|
||||
|
||||
|
||||
def _assert_nonce(csp: str, html: str) -> str:
|
||||
"""Header CSP : script-src sans unsafe-inline + TOUS les scripts inline noncés."""
|
||||
import re as _re
|
||||
|
||||
m = _re.search(r"script-src ([^;]*);", csp)
|
||||
assert m, csp
|
||||
script_src = m.group(1)
|
||||
nm = _re.search(r"'nonce-([^']+)'", script_src)
|
||||
assert nm, script_src
|
||||
nonce = nm.group(1)
|
||||
assert "'unsafe-inline'" not in script_src, script_src
|
||||
assert "'unsafe-eval'" in script_src # Alpine/htmx — reste d'A20
|
||||
assert "script-src-attr 'unsafe-inline'" in csp
|
||||
tags = [
|
||||
mm.group(0)
|
||||
for mm in _re.finditer(r"<script[^>]*>", html)
|
||||
if "src=" not in mm.group(0)
|
||||
]
|
||||
assert tags, "aucun script inline"
|
||||
missing = [t for t in tags if f'nonce="{nonce}"' not in t]
|
||||
assert missing == [], missing[:3]
|
||||
return nonce
|
||||
|
||||
|
||||
def test_csp_nonce_per_request(client):
|
||||
"""A20 : nonce par requête — page base.html (avec meta htmx-config) et page
|
||||
hors template (LOCAL_LOGIN_HTML, constante de module → nonce au rendu)."""
|
||||
# 1) une page qui étend base.html (la meta htmx-config y est)
|
||||
base = None
|
||||
for url in ("/", "/dashboard", "/board", "/notes", "/settings", "/workspaces"):
|
||||
cand = client.get(url)
|
||||
if cand.status_code == 200 and "htmx-config" in cand.text:
|
||||
base = cand
|
||||
break
|
||||
assert base is not None, "aucune page base.html atteignable"
|
||||
nonce = _assert_nonce(base.headers.get("content-security-policy", ""), base.text)
|
||||
assert f'"inlineScriptNonce": "{nonce}"' in base.text
|
||||
# deux requêtes = deux nonces différents
|
||||
other = client.get("/dashboard")
|
||||
if other.status_code == 200 and "htmx-config" in other.text:
|
||||
other_nonce = _re_search_nonce(other.headers.get("content-security-policy", ""))
|
||||
assert other_nonce != nonce
|
||||
|
||||
# 2) la page de login hors template (script injecté par _with_nonce)
|
||||
r = client.get("/auth/login?provider=local")
|
||||
assert r.status_code == 200, r.status_code
|
||||
_assert_nonce(r.headers.get("content-security-policy", ""), r.text)
|
||||
|
||||
|
||||
def _re_search_nonce(csp: str) -> str:
|
||||
import re as _re
|
||||
|
||||
return _re.search(r"'nonce-([^']+)'", _re.search(r"script-src ([^;]*);", csp).group(1)).group(1)
|
||||
|
||||
|
||||
def test_no_duplicate_routes():
|
||||
"""A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre."""
|
||||
from app.main import app
|
||||
|
||||
@@ -98,7 +98,7 @@ def test_workspace_offline_banner_shows_pending(client):
|
||||
|
||||
|
||||
def test_editor_save_has_offline_hook():
|
||||
src = (ROOT / "app" / "templates" / "_page_editor_scripts.html").read_text(encoding="utf-8")
|
||||
src = (ROOT / "static" / "js" / "page_editor_scripts.js").read_text(encoding="utf-8")
|
||||
assert "FlowOffline" in src
|
||||
assert "savePageOffline" in src
|
||||
assert "navigator.onLine" in src
|
||||
|
||||
@@ -295,7 +295,14 @@ def test_page_editor_renders_page_is_shared(client):
|
||||
|
||||
r = client.get(f"/pages/{a}")
|
||||
assert r.status_code == 200
|
||||
assert "pageIsShared:true" in r.text
|
||||
# A27 : la valeur n'est plus interpolée dans le JS inline mais exposée
|
||||
# dans le JSON #page-data (le JS lit PD.is_shared)
|
||||
import json as _json
|
||||
import re as _re
|
||||
|
||||
m = _re.search(r'id="page-data"[^>]*>(.*?)</script>', r.text, _re.S)
|
||||
assert m, "bloc #page-data absent de la page"
|
||||
assert _json.loads(m.group(1))["is_shared"] is True
|
||||
|
||||
|
||||
def test_tree_is_shared_includes_link_shared_pages(client):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user