Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a5fe0524a | ||
|
|
3bb8e87ef2 | ||
|
|
069c438aae | ||
|
|
45e59009c3 | ||
|
|
8d0d69e7b8 | ||
|
|
103bc57418 | ||
|
|
45917c194d | ||
|
|
ee1d46e965 | ||
|
|
587ec8d61b | ||
|
|
7a38ddd0f6 | ||
|
|
113374e499 | ||
|
|
0cb476e336 | ||
|
|
2339fa2586 | ||
|
|
8b48dbdd4b | ||
|
|
360c705fd4 | ||
|
|
0698645dbd | ||
|
|
b2e38aece7 |
+432
@@ -1,5 +1,437 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v7.29.0 (2026-10-01) — Audit : A28 lot 1 (api_v2 → package 14 fichiers)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A28 lot 1** : `app/routers/api_v2.py` (**2 110 lignes, 115 routes**)
|
||||
devient le package `app/routers/api_v2/` — un module par concern :
|
||||
· `collections` 566 L (23 routes), `engagement` 338 (21), `workspaces`
|
||||
230 (9), `templates_io` 205 (9), `webhooks` 195 (8), `identity` 195 (7),
|
||||
`views` 164 (8), `sharing` 160 (8), `properties` 151 (7),
|
||||
`planning` 148 (7), `projects` 93 (4), `admin` 91 (4)
|
||||
· `_common.py` : helpers partagés (`_hash`, `_v2_rate_check`)
|
||||
· `__init__.py` : `router = APIRouter(prefix="/api/v2")` + agrégat
|
||||
`include_router` (section routers sans prefix, tags `api-v2`)
|
||||
- Preuve contractuelle : **`docs/openapi-v2.json` régénéré = identique
|
||||
byte-à-byte** (0 changement de chemin, tag ni operation_id)
|
||||
- Seul importateur (`app/main.py` → `from app.routers.api_v2 import
|
||||
router`) fonctionne via le package ; en-tête d'imports copié par module,
|
||||
émondé par `ruff --fix` (143 imports morts supprimés automatiquement)
|
||||
|
||||
### Notes
|
||||
|
||||
- Reste A28 : `dashboard.py` 2 735 L, `collections.py` 2 622 L,
|
||||
`board.py` 2 101 L (même recette, lots suivants)
|
||||
- Suite complète : **1091/1091** · ruff OK
|
||||
|
||||
## v7.28.0 (2026-10-01) — Audit : A42 TERMINÉ (client httpx partagé)
|
||||
|
||||
### Changed
|
||||
|
||||
- **`app/services/http_client.py`** : `async with shared_client(timeout=15)
|
||||
as client:` remplace les **49 créations `async with httpx.AsyncClient(`**
|
||||
réparties dans 14 fichiers (gitea ×21, providers ×11, calendar ×4,
|
||||
automations ×3, …) — le pool de connexions est réutilisé au lieu d'être
|
||||
recréé à chaque appel
|
||||
- Cache **par (boucle d'event, kwargs)** en `WeakKeyDictionary` : un
|
||||
`AsyncClient` n'est jamais partagé entre deux loops (le piège des tests :
|
||||
« Event loop is closed ») — une boucle par test = un client propre,
|
||||
collecté avec elle. Clé = kwargs triés, `repr()` pour les valeurs non
|
||||
hashables (`headers=` dict)
|
||||
- Context manager no-op à la sortie (pas de fermeture du client partagé) ;
|
||||
`ponytail:` documenté : pas d'`aclose` explicite, plafond = pools non
|
||||
fermés à la main (GC des sockets), upgrade = lifespan
|
||||
- **Laissés délibérément** : `github_adapter` (transport MockTransport
|
||||
injecté), `webhook_outbound` (client « own_client » fermé par la fonction)
|
||||
|
||||
### Tests
|
||||
|
||||
- `test_http_client_shared_and_loop_scoped` : réutilisation (mêmes kwargs),
|
||||
cloisonnement par kwargs, cloisonnement par boucle (2× `asyncio.run`)
|
||||
- Stub webhooks : patch étendu à la fabrique `http_client.httpx` + purge du
|
||||
cache (les tests patchaient `webhook_outbound.httpx`, contourné par la
|
||||
fabrique partagée)
|
||||
- Suite complète : **1091/1091**
|
||||
|
||||
## v7.27.0 (2026-10-01) — Audit : A20 phase 2 (CDN retiré, connect-src fermé)
|
||||
|
||||
### Changed
|
||||
|
||||
- **Vendorisation** : chart.js 4.5.1 + leaflet 1.9 (js, css, 5 images
|
||||
marker/layer) téléchargés vers `static/js/vendor/` ; les 3 URL CDN des
|
||||
vues chart/map (`collections.py`) pointent en local → **la CSP n'a plus
|
||||
aucun hôte tiers** dans `script-src` ni `style-src`
|
||||
- **`connect-src` fermé** : `'self' ws://{host} wss://{host}` (Host de la
|
||||
requête, caractères hors base URL filtrés) — le `https:` universel
|
||||
(canal d'exfil JS) et les `ws:`/`wss:` tout-hôtes disparaissent.
|
||||
Grep négatif : 0 fetch cross-origin côté front
|
||||
- **Google Fonts** : entrées CSP mortes (0 référence dans le code) retirées
|
||||
de `style-src`/`font-src`
|
||||
- `img-src https:` **conservé volontairement** (unfurls YouTube/Vimeo… et
|
||||
tuiles OSM inénumérables) — `ponytail:` commenté dans `security.py`
|
||||
|
||||
### Tests
|
||||
|
||||
- `test_csp_no_cdn_and_vendor` : CSP sans CDN/Google, connect-src exact
|
||||
(`'self' ws://testserver wss://testserver`), 4 assets vendor servis (200),
|
||||
source `collections.py` sans référence CDN
|
||||
- `test_view_chart_renders` : assert sur le chemin vendor
|
||||
- Suite complète : **1090/1090**
|
||||
|
||||
### Notes
|
||||
|
||||
- Reste A20 : `unsafe-eval` (Alpine x-data + htmx hx-on/hx-vars = eval)
|
||||
→ build `@alpinejs/csp` + couverture E2E des vues d'abord (même logique
|
||||
que la décision A39)
|
||||
|
||||
## v7.26.0 (2026-10-01) — Audit : A21 phase 2c (190 routes hors loop)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A21 phase 2c** — **283 → 93 routes async** (**86 % des 667 routes** en
|
||||
threadpool, avant : 61 %) en 4 passes :
|
||||
· **Racine auth** : `get_current_user` (session.py) était `async def`
|
||||
**sans aucun await** (cookie decode = synchrone) ; idem ses clones
|
||||
(`agent._current_user_id/_workspace_id/_current_admin`,
|
||||
`sso._require_admin` au corps 0 await) → `def` + **47 `await`
|
||||
supprimés** (dont 3 via l'alias `gcu` — le piège : le grep littéral ne
|
||||
les voyait pas, la suite les a attrapés)
|
||||
· Re-scan : 19 routes devenues sans await → `def`
|
||||
· **155 routes** dont les seuls awaits étaient `request.json()` /
|
||||
événements → `Body(default={})` + `run_event_sync(...)` puis `def` :
|
||||
- try/except `body = {}` → défaut `{}` (même tolérance, laissée intacte)
|
||||
- try/except `raise HTTPException(400)` → `Body(...)` **requis**
|
||||
(422 FastAPI — **aucun test ne couvrait le 400**, aucun call front
|
||||
n'envoie de JSON invalide)
|
||||
- forme conditionnelle `request.json() if content-type else {}`
|
||||
(54 sites) → défaut `{}` : sans corps = `{}` dans les 2 cas
|
||||
- Import `Body`/`run_event_sync` ajoutés aux routers convertis
|
||||
|
||||
### Notes
|
||||
|
||||
- **Reste async (93, justifié)** : `request.form`/`upload.read`/`file.read`
|
||||
(corps réellement asynchrone), gitea/llm/oidc (réseau), `_json_body`
|
||||
(9 — wrapper de validation), 2 JSON inline en argument d'appel,
|
||||
1 fallback à logique (`capture_frontend_error`), 1 lecture conditionnelle
|
||||
de taille (web_clipper)
|
||||
- Suite complète : **1089/1089** · ruff OK
|
||||
|
||||
## v7.25.0 (2026-10-01) — Audit : A27 CLOSED (eslint 0/0)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Toasts settings jamais affichés** : `settings.js` appelait
|
||||
`typeof toast === 'function'` — guard **toujours faux** (le `toast` global
|
||||
n'existait pas) → les retours « Timezone saved » / « SP metadata URL
|
||||
copiée » ne s'affichaient jamais. → `window.showToast` (global `base.html`)
|
||||
- `_wsInitData = window._wsInitData` (auto-affectation d'un nom non déclaré,
|
||||
sans effet observable car `window._wsInitData` existe depuis L4) supprimé
|
||||
|
||||
### Changed
|
||||
|
||||
- **A27 lint terminé : `eslint static/js` = 0 erreur / 0 warning** (285 → 0,
|
||||
22 fichiers) :
|
||||
· no-empty ×70 = `catch (x) {}` vides → `catch { /* volontaire */ }`
|
||||
(binding optionnel ES2019 ; zéro changement de comportement)
|
||||
· no-unused-vars ×171 = bindings de catch retirés + 24 lignes mortes
|
||||
déterministes (assert sur texte exact avant suppression) +
|
||||
`/* exported */` sur les 10 fonctions appelées depuis les attributs
|
||||
HTML des templates (vérifiées par grep)
|
||||
· no-undef ×44 = vrais globaux déclarés dans `eslint.config.mjs`
|
||||
(getSvgIcon = inline base.html, TextDecoder = API, Prism = CDN)
|
||||
|
||||
### Notes
|
||||
|
||||
- `node --check` vert sur tous les fichiers ; suite complète **1089/1089**
|
||||
- A27 reste ouvert uniquement pour : `base` 1 523 L structurel (inline par
|
||||
nature, décision assumée) + ~500 L de petits blocs hors cibles
|
||||
|
||||
## v7.24.0 (2026-10-01) — Audit : A27 extraction TERMINÉE (bilan -85 %)
|
||||
|
||||
### Changed
|
||||
|
||||
- **A27 (phase 2c)** — `_database_table_scripts.html` : **1 314 L extraites**
|
||||
vers `static/js/database_table.js`. Le Jinja du bloc était confiné à la
|
||||
construction de l'objet de config (4 clés + `{% if collection_data %}`) →
|
||||
config JSON `#db-config` **null-vs-objet** : `new DBInstance(container,
|
||||
PAGE_COLLECTION_ID, DB_CONFIG)` remplace les 2 branches Jinja
|
||||
- 2 tests adaptés (lisaient le template source → `database_table.js`) ;
|
||||
`FlowDeckDB` plus dans le HTML → assert sur le JS
|
||||
|
||||
### Notes
|
||||
|
||||
- **BILAN A27 : 11 874 L extraites en 4 phases** (4 243 + 2 516 + 3 801 +
|
||||
1 314), **inline 13 904 → 2 022 L (-85 %)**, 22 fichiers `static/js/*.js`,
|
||||
`node --check` vert partout, eslint **0 erreur / 285 warnings**
|
||||
- Reste : `base` 1 523 L structurel (`{% block %}`/`{% for %}` — reste
|
||||
inline par nature, décision assumée), ~500 L de petits blocs hors cibles,
|
||||
nettoyage des 285 warnings
|
||||
- Suite complète : **1089/1089**
|
||||
|
||||
## v7.23.0 (2026-10-01) — Audit : A27 phase 2b (+3 801 L)
|
||||
|
||||
### Changed
|
||||
|
||||
- **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
|
||||
|
||||
+7
-7
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.12.0 (audit — A32 phase 2 : library 10/10, 2 routes mortes supprimées) | **Statut**: EN COURS 🔄
|
||||
> **Début**: 2026-07-08 | **Version**: v7.29.0 (audit — A28 lot 1 : api_v2.py 2 110 L → package 14 fichiers) | **Statut**: EN COURS 🔄
|
||||
> **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
|
||||
|
||||
+3
-4
@@ -4,9 +4,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,7 +38,7 @@ class GiteaOAuth:
|
||||
async def exchange_code(self, code: str) -> dict | None:
|
||||
"""Exchange authorization code for access token."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
self.TOKEN_URL,
|
||||
data={
|
||||
@@ -61,7 +60,7 @@ class GiteaOAuth:
|
||||
async def get_user(self, access_token: str) -> dict | None:
|
||||
"""Get user info from Gitea API."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
self.USER_URL,
|
||||
headers={"Authorization": f"token {access_token}"},
|
||||
|
||||
@@ -7,7 +7,7 @@ import time
|
||||
from abc import ABC, abstractmethod
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -76,7 +76,7 @@ class GiteaProvider(OAuthProvider):
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": redirect_uri or self.redirect_uri,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.post(url, json=data, headers={"Accept": "application/json"})
|
||||
if r.status_code != 200:
|
||||
logger.error("Gitea token exchange failed: %s", r.text)
|
||||
@@ -85,7 +85,7 @@ class GiteaProvider(OAuthProvider):
|
||||
|
||||
async def get_user(self, access_token: str) -> dict | None:
|
||||
url = f"{self.base}/api/v1/user"
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.get(url, headers={"Authorization": f"token {access_token}"})
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
@@ -100,7 +100,7 @@ class GiteaProvider(OAuthProvider):
|
||||
|
||||
async def list_repositories(self, access_token: str) -> list[dict]:
|
||||
repos = []
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
async with shared_client(timeout=30) as client:
|
||||
for page in range(1, 6):
|
||||
r = await client.get(
|
||||
f"{self.base}/api/v1/user/repos",
|
||||
@@ -158,7 +158,7 @@ class GitHubProvider(OAuthProvider):
|
||||
)
|
||||
|
||||
async def exchange_code(self, code: str, redirect_uri: str | None = None) -> dict | None:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.post(
|
||||
self.token_url,
|
||||
data={
|
||||
@@ -179,7 +179,7 @@ class GitHubProvider(OAuthProvider):
|
||||
|
||||
async def get_user(self, access_token: str) -> dict | None:
|
||||
url = f"{self.api_url}/user"
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.get(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {access_token}", "Accept": "application/vnd.github.v3+json"},
|
||||
@@ -197,7 +197,7 @@ class GitHubProvider(OAuthProvider):
|
||||
|
||||
async def list_repositories(self, access_token: str) -> list[dict]:
|
||||
repos = []
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
async with shared_client(timeout=30) as client:
|
||||
for page in range(1, 6):
|
||||
r = await client.get(
|
||||
f"{self.api_url}/user/repos",
|
||||
|
||||
@@ -15,7 +15,7 @@ import secrets
|
||||
import time
|
||||
import warnings
|
||||
|
||||
import httpx
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -59,7 +59,7 @@ async def discover(issuer_url: str) -> dict:
|
||||
if hit and now - hit[0] < _DISCOVERY_TTL:
|
||||
return hit[1]
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
doc = r.json()
|
||||
@@ -112,7 +112,7 @@ async def exchange_code(
|
||||
if client_secret:
|
||||
auth = (client_id, client_secret)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.post(doc["token_endpoint"], data=data, auth=auth)
|
||||
except Exception as err:
|
||||
raise OIDCError(f"OIDC token request failed: {err}") from err
|
||||
@@ -133,7 +133,7 @@ async def fetch_userinfo(doc: dict, access_token: str) -> dict:
|
||||
if not endpoint or not access_token:
|
||||
return {}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.get(endpoint, headers={"Authorization": f"Bearer {access_token}"})
|
||||
if r.status_code != 200:
|
||||
return {}
|
||||
|
||||
+1
-1
@@ -175,7 +175,7 @@ def _touch_session(sid: str) -> None:
|
||||
|
||||
|
||||
# FastAPI dependency
|
||||
async def get_current_user(request) -> dict | None:
|
||||
def get_current_user(request) -> dict | None:
|
||||
"""FastAPI dependency: extract current user from session cookie."""
|
||||
session = request.cookies.get("flowdeck_session")
|
||||
if session:
|
||||
|
||||
+1
-1
@@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="7.12.0",
|
||||
version="7.29.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from collections import defaultdict
|
||||
@@ -74,16 +75,24 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
# besoin → à retirer avec le build CSP d'Alpine (voir reste d'A20).
|
||||
CSP_VALUE = (
|
||||
"default-src 'self'; "
|
||||
# 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; "
|
||||
# A20 phase 2 : chart.js/leaflet vendorisés dans /static/js/vendor
|
||||
# (test_csp_no_cdn_and_vendor), plus aucun hôte CDN tiers.
|
||||
# `unsafe-eval` reste : Alpine (x-data) + htmx (hx-on/hx-vars) en
|
||||
# ont besoin → retrait avec le build CSP d'Alpine (reste d'A20).
|
||||
"script-src 'self' 'unsafe-eval' 'nonce-{nonce}'; "
|
||||
"script-src-attr 'unsafe-inline'; "
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://unpkg.com; "
|
||||
# ponytail: aucun @font-face Google (grep négatif) → les deux
|
||||
# hôtes fonts étaient morts, supprimés.
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
# ponytail: `https:` reste ouvert — unfurls (YouTube/Vimeo/…) et
|
||||
# tuiles OSM sont inénumérables ; plafond assumé, à resserrer si
|
||||
# un proxy d'images local arrive.
|
||||
"img-src 'self' data: blob: https:; "
|
||||
"font-src 'self' data: https://fonts.gstatic.com; "
|
||||
"connect-src 'self' https: wss: ws:; "
|
||||
"font-src 'self' data:; "
|
||||
# connect-src fermé : plus de `https:` (aucun fetch cross-origin
|
||||
# côté front — grep négatif) et websockets scopés à l'hôte de la
|
||||
# requête ({host}) → plus de canal d'exfil vers un tiers.
|
||||
"connect-src 'self' ws://{host} wss://{host}; "
|
||||
"media-src 'self' blob:; "
|
||||
"frame-src 'self'; "
|
||||
"object-src 'none'; "
|
||||
@@ -101,7 +110,13 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
# 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.format(nonce=nonce)
|
||||
# Host du navigateur (uvicorn rejette les Host invalides) ;
|
||||
# on retire quand même tout caractère hors base URL par sécurité.
|
||||
host = re.sub(r"[^0-9A-Za-z.\-:\[\]]", "",
|
||||
request.headers.get("host", ""))
|
||||
response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(
|
||||
nonce=nonce, host=host
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
|
||||
+4
-12
@@ -1,5 +1,5 @@
|
||||
"""FlowDeck — Admin API: users, roles, stats, audit."""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(tags=["admin"], prefix="/api/admin")
|
||||
@@ -8,7 +8,7 @@ router = APIRouter(tags=["admin"], prefix="/api/admin")
|
||||
# ── Dependency ──
|
||||
async def admin_required(request: Request):
|
||||
from app.auth.session import get_current_user
|
||||
user = await get_current_user(request)
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
# Also check DB directly (session cookie may be stale)
|
||||
@@ -46,15 +46,11 @@ def list_users(_admin=Depends(admin_required)):
|
||||
|
||||
|
||||
@router.post("/users")
|
||||
async def create_user(request: Request, _admin=Depends(admin_required)):
|
||||
def create_user(request: Request, _admin=Depends(admin_required), body: dict = Body(default={})):
|
||||
"""Create a new user (admin only)."""
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
login = body.get("login", "").strip()
|
||||
name = body.get("name", login)
|
||||
email = body.get("email", login)
|
||||
@@ -78,15 +74,11 @@ async def create_user(request: Request, _admin=Depends(admin_required)):
|
||||
|
||||
|
||||
@router.put("/users/{user_id:int}")
|
||||
async def update_user(user_id: int, request: Request, _admin=Depends(admin_required)):
|
||||
def update_user(user_id: int, request: Request, _admin=Depends(admin_required), body: dict = Body(default={})):
|
||||
"""Update a user: name, email, password, admin status, active status."""
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not user:
|
||||
|
||||
+58
-68
@@ -9,7 +9,7 @@ import json
|
||||
import logging
|
||||
from datetime import UTC
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.auth.session import get_current_user
|
||||
@@ -93,16 +93,16 @@ async def agent_scheduler(interval_seconds: int = 60):
|
||||
logger.exception("Agent scheduler tick failed")
|
||||
|
||||
|
||||
async def _current_user_id(request: Request) -> int:
|
||||
def _current_user_id(request: Request) -> int:
|
||||
"""A14 : plus de fallback sur la row `admin` — 401 sans session."""
|
||||
user = await get_current_user(request)
|
||||
user = get_current_user(request)
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user["id"]
|
||||
|
||||
|
||||
async def _workspace_id(request: Request) -> int | None:
|
||||
user = await get_current_user(request)
|
||||
def _workspace_id(request: Request) -> int | None:
|
||||
user = get_current_user(request)
|
||||
if user and user.get("workspace_id"):
|
||||
return user["workspace_id"]
|
||||
try:
|
||||
@@ -112,11 +112,11 @@ async def _workspace_id(request: Request) -> int | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _current_admin(request: Request) -> dict:
|
||||
def _current_admin(request: Request) -> dict:
|
||||
"""A14 : session obligatoire, puis admin. L'ancien fallback « row admin »
|
||||
laissait un anonymous diriger `PATCH /api/agent/providers` (et donc le
|
||||
`ping()` vers un `api_base` de son choix = SSRF)."""
|
||||
user = await get_current_user(request)
|
||||
user = get_current_user(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
if not user.get("is_admin"):
|
||||
@@ -146,9 +146,9 @@ def _default_agent(conn, user_id: int) -> dict:
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_agents(request: Request):
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
def list_agents(request: Request):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
_default_agent(conn, user_id)
|
||||
rows = conn.execute("SELECT * FROM agents WHERE workspace_id IS ? OR workspace_id=? ORDER BY agent_type, name", (ws, ws)).fetchall()
|
||||
@@ -156,10 +156,9 @@ async def list_agents(request: Request):
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_agent(request: Request):
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_agent(request: Request, body: dict = Body(default={})):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
name = (body.get("name") or "").strip() or "Custom Agent"
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
@@ -184,8 +183,8 @@ async def create_agent(request: Request):
|
||||
|
||||
|
||||
@router.get("/conversations")
|
||||
async def list_conversations(request: Request):
|
||||
user_id = await _current_user_id(request)
|
||||
def list_conversations(request: Request):
|
||||
user_id = _current_user_id(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM agent_conversations WHERE user_id=? ORDER BY updated_at DESC",
|
||||
@@ -195,10 +194,9 @@ async def list_conversations(request: Request):
|
||||
|
||||
|
||||
@router.post("/conversations")
|
||||
async def create_conversation(request: Request):
|
||||
user_id = await _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
ws = await _workspace_id(request)
|
||||
def create_conversation(request: Request, body: dict = Body(default={})):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
agent = _default_agent(conn, user_id)
|
||||
cur = conn.execute(
|
||||
@@ -236,10 +234,9 @@ def delete_conversation(request: Request, conversation_id: int):
|
||||
|
||||
|
||||
@router.patch("/conversations/{conversation_id}")
|
||||
async def patch_conversation(request: Request, conversation_id: int):
|
||||
def patch_conversation(request: Request, conversation_id: int, body: dict = Body(default={})):
|
||||
"""Update a conversation's title / provider / model (slash-command support)."""
|
||||
user_id = await _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
user_id = _current_user_id(request)
|
||||
with get_conn() as conn:
|
||||
conv = conn.execute(
|
||||
"SELECT id FROM agent_conversations WHERE id=? AND user_id=?",
|
||||
@@ -262,10 +259,9 @@ async def patch_conversation(request: Request, conversation_id: int):
|
||||
|
||||
|
||||
@router.post("/conversations/{conversation_id}/run")
|
||||
async def run_conversation(request: Request, conversation_id: int):
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
async def run_conversation(request: Request, conversation_id: int, body: dict = Body(default={})):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
objective = (body.get("message") or "").strip()
|
||||
if not objective:
|
||||
raise HTTPException(status_code=400, detail="message est requis")
|
||||
@@ -323,7 +319,7 @@ async def agent_generate(request: Request):
|
||||
Because no tool schema is offered, the model answers with plain text based on
|
||||
the provided document context instead of issuing search_workspace / tools.
|
||||
"""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
prompt = (body.get("prompt") or "").strip()
|
||||
if not prompt:
|
||||
@@ -383,7 +379,7 @@ async def agent_writing(request: Request):
|
||||
"""
|
||||
from app.services.ai_writing import WRITING_ACTIONS, AIWritingService
|
||||
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
action = (body.get("action") or "").strip().lower()
|
||||
if not action:
|
||||
@@ -422,7 +418,7 @@ async def agent_writing_properties(request: Request):
|
||||
"""
|
||||
from app.services.ai_writing import AIWritingService
|
||||
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
properties = body.get("properties") or []
|
||||
if not isinstance(properties, list) or not properties:
|
||||
@@ -474,18 +470,17 @@ def undo(request: Request, action_id: int):
|
||||
|
||||
|
||||
@router.get("/skills")
|
||||
async def list_skills(request: Request):
|
||||
ws = await _workspace_id(request)
|
||||
def list_skills(request: Request):
|
||||
ws = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM agent_skills WHERE workspace_id IS ? OR workspace_id=? ORDER BY name", (ws, ws)).fetchall()
|
||||
return {"skills": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/skills")
|
||||
async def create_skill(request: Request):
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_skill(request: Request, body: dict = Body(default={})):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name est requis")
|
||||
@@ -504,10 +499,10 @@ async def create_skill(request: Request):
|
||||
|
||||
|
||||
@router.post("/skills/{skill_id}/apply")
|
||||
async def apply_skill(request: Request, skill_id: int):
|
||||
def apply_skill(request: Request, skill_id: int):
|
||||
"""Create a conversation pre-loaded with a skill, ready to run."""
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
skill = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not skill:
|
||||
@@ -535,13 +530,12 @@ def skills_gallery(request: Request):
|
||||
|
||||
|
||||
@router.post("/skills/gallery/{slug}/install")
|
||||
async def install_gallery_skill(request: Request, slug: str):
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
def install_gallery_skill(request: Request, slug: str, body: dict = Body(default={})):
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
preset = skill_gallery.get_gallery(slug)
|
||||
if not preset:
|
||||
raise HTTPException(status_code=404, detail=f"Skill inconnue dans la galerie: {slug}")
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
try:
|
||||
row, created = skill_gallery.upsert_skill(
|
||||
skill_gallery.parse_payload(preset),
|
||||
@@ -555,11 +549,10 @@ async def install_gallery_skill(request: Request, slug: str):
|
||||
|
||||
|
||||
@router.post("/skills/import")
|
||||
async def import_skill(request: Request):
|
||||
def import_skill(request: Request, body: dict = Body(default={})):
|
||||
"""Importe un skill portable (JSON exporté depuis une autre instance)."""
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
payload = body.get("payload") if isinstance(body.get("payload"), dict) else body
|
||||
try:
|
||||
fields = skill_gallery.parse_payload(payload)
|
||||
@@ -601,7 +594,7 @@ def delete_skill(request: Request, skill_id: int):
|
||||
|
||||
|
||||
@router.get("/mentions")
|
||||
async def list_mentions(request: Request, q: str = ""):
|
||||
def list_mentions(request: Request, q: str = ""):
|
||||
"""Éléments mentionnables dans le panneau agent (commande « @ » / bouton « + »).
|
||||
|
||||
Retourne des sections d'objets FlowDeck que l'utilisateur peut épingler au
|
||||
@@ -624,7 +617,7 @@ async def list_mentions(request: Request, q: str = ""):
|
||||
except (TypeError, ValueError):
|
||||
ws = None
|
||||
if not ws:
|
||||
ws = await _workspace_id(request)
|
||||
ws = _workspace_id(request)
|
||||
|
||||
def dedupe(items: list[dict]) -> list[dict]:
|
||||
seen: set = set()
|
||||
@@ -729,10 +722,9 @@ async def list_mentions(request: Request, q: str = ""):
|
||||
|
||||
|
||||
@router.post("/feedback")
|
||||
async def add_feedback(request: Request):
|
||||
def add_feedback(request: Request, body: dict = Body(default={})):
|
||||
"""Enregistre le retour (👍 / 👎) porté sur une réponse de l'agent."""
|
||||
user_id = await _current_user_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
user_id = _current_user_id(request)
|
||||
rating = (body.get("rating") or "").strip().lower()
|
||||
if rating not in ("up", "down"):
|
||||
raise HTTPException(status_code=400, detail="rating doit être 'up' ou 'down'")
|
||||
@@ -766,8 +758,8 @@ async def add_feedback(request: Request):
|
||||
async def trigger_agent(request: Request, agent_id: int):
|
||||
"""Manually fire a custom agent: create a conversation and run it with the
|
||||
agent's instructions as the objective (falls back to a generic prompt)."""
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
ws = _workspace_id(request)
|
||||
with get_conn() as conn:
|
||||
agent = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not agent:
|
||||
@@ -814,7 +806,7 @@ async def list_providers(request: Request):
|
||||
- ``verified`` : the last connection test / model fetch succeeded.
|
||||
- ``functional`` : the provider is ready to chat (verified, or `offline`).
|
||||
"""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
llm = LLMClient()
|
||||
cfg = get_llm_config()
|
||||
keys = list_user_llm_keys(user_id)
|
||||
@@ -869,20 +861,19 @@ async def list_providers(request: Request):
|
||||
|
||||
|
||||
@router.get("/keys")
|
||||
async def list_llm_keys(request: Request):
|
||||
def list_llm_keys(request: Request):
|
||||
"""The user's saved provider keys + API keys (masked)."""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
return {"keys": list_user_llm_keys(user_id)}
|
||||
|
||||
|
||||
@router.put("/keys/{llm_provider}")
|
||||
async def save_llm_key(request: Request, llm_provider: str):
|
||||
def save_llm_key(request: Request, llm_provider: str, body: dict = Body(default={})):
|
||||
"""Upsert a provider key for the current user (masked in responses)."""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
provider = llm_provider.lower()
|
||||
if provider not in PROVIDERS:
|
||||
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
api_base_raw = body.get("api_base")
|
||||
raw = upsert_user_llm_key(
|
||||
user_id,
|
||||
@@ -898,9 +889,9 @@ async def save_llm_key(request: Request, llm_provider: str):
|
||||
|
||||
|
||||
@router.delete("/keys/{llm_provider}")
|
||||
async def delete_llm_key(request: Request, llm_provider: str):
|
||||
def delete_llm_key(request: Request, llm_provider: str):
|
||||
"""Remove a saved provider key for the current user."""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
provider = llm_provider.lower()
|
||||
if provider not in PROVIDERS:
|
||||
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
|
||||
@@ -915,7 +906,7 @@ async def test_user_llm_key(request: Request, llm_provider: str):
|
||||
On success the provider is flagged ``verified`` so it can be offered in the
|
||||
Agent panel; on failure the stored error is kept for display in Settings.
|
||||
"""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
provider = llm_provider.lower()
|
||||
if provider not in PROVIDERS:
|
||||
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
|
||||
@@ -970,7 +961,7 @@ async def fetch_llm_models(request: Request, llm_provider: str):
|
||||
A successful fetch proves connectivity, so when it used the *stored* key the
|
||||
provider is flagged ``verified`` (functional) for the Agent panel.
|
||||
"""
|
||||
user_id = await _current_user_id(request)
|
||||
user_id = _current_user_id(request)
|
||||
provider = llm_provider.lower()
|
||||
if provider not in PROVIDERS:
|
||||
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
|
||||
@@ -1019,7 +1010,7 @@ def _check_api_base(value: str) -> str:
|
||||
|
||||
@router.patch("/providers")
|
||||
async def update_provider_config(request: Request):
|
||||
await _current_admin(request)
|
||||
_current_admin(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
provider = (body.get("provider") or "").strip().lower()
|
||||
if provider and provider not in PROVIDERS:
|
||||
@@ -1049,7 +1040,7 @@ async def test_provider_config(request: Request):
|
||||
A successful test flags the workspace default provider as ``verified`` so it
|
||||
becomes available (functional) for every user in the Agent panel.
|
||||
"""
|
||||
await _current_admin(request)
|
||||
_current_admin(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
provider = (body.get("provider") or "").strip().lower() or None
|
||||
if provider and provider not in PROVIDERS:
|
||||
@@ -1090,8 +1081,7 @@ def get_agent(request: Request, agent_id: int):
|
||||
|
||||
|
||||
@router.put("/{agent_id}")
|
||||
async def update_agent(request: Request, agent_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def update_agent(request: Request, agent_id: int, body: dict = Body(default={})):
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not existing:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
"""FlowDeck — Public API v2.
|
||||
|
||||
Découpe A28 : l'ancien `api_v2.py` (2 110 lignes, 115 routes) est devenu
|
||||
ce package — un module par concern (`_common` = helpers), `router`
|
||||
agrégé ci-dessous avec le même prefix/tags qu'avant → 0 changement
|
||||
d'URL, 0 changement d'operation_id.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import (
|
||||
admin,
|
||||
collections,
|
||||
engagement,
|
||||
identity,
|
||||
planning,
|
||||
projects,
|
||||
properties,
|
||||
sharing,
|
||||
templates_io,
|
||||
views,
|
||||
webhooks,
|
||||
workspaces,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v2")
|
||||
for _mod in (
|
||||
identity,
|
||||
workspaces,
|
||||
collections,
|
||||
properties,
|
||||
views,
|
||||
engagement,
|
||||
sharing,
|
||||
planning,
|
||||
templates_io,
|
||||
projects,
|
||||
admin,
|
||||
webhooks,
|
||||
):
|
||||
router.include_router(_mod.router)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""FlowDeck — API v2 : helpers partagés des modules de routes (A28)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def _hash(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
def _v2_rate_check(request: Request, user: dict) -> None:
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
th = user.get("_token_hash")
|
||||
if not check_v2_rate_limit(th, ip):
|
||||
raise HTTPException(status_code=429, detail="Rate limit exceeded: 300 req/min per token")
|
||||
|
||||
# ── Tokens ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""FlowDeck — Public API v2 : admin.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.patch("/admin/users/{uid}")
|
||||
def admin_patch_user_v2(uid: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"):
|
||||
raise HTTPException(403, "Admin scope required")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone():
|
||||
raise HTTPException(404, "User not found")
|
||||
sets = []
|
||||
params: list = []
|
||||
for k in ("is_active", "is_admin", "full_name", "email"):
|
||||
if k in body:
|
||||
sets.append(f"{k}=?")
|
||||
params.append(int(body[k]) if k in ("is_active", "is_admin") else body[k])
|
||||
if not sets:
|
||||
raise HTTPException(400, "No fields")
|
||||
params.append(uid)
|
||||
conn.execute(f"UPDATE users SET {', '.join(sets)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
audit_log(user, "admin.user_update", "user", uid, "", request)
|
||||
return {"id": uid, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/admin/users/{uid}")
|
||||
def admin_delete_user_v2(uid: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"):
|
||||
raise HTTPException(403, "Admin scope required")
|
||||
if uid == user["id"]:
|
||||
raise HTTPException(400, "Cannot delete yourself")
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM users WHERE id=?", (uid,))
|
||||
conn.commit()
|
||||
audit_log(user, "admin.user_delete", "user", uid, "", request)
|
||||
return {"id": uid, "status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/admin/audit-logs")
|
||||
def admin_audit_logs_v2(request: Request, limit: int = 50, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"):
|
||||
raise HTTPException(403, "Admin scope required")
|
||||
limit = max(1, min(limit, 200))
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM api_audit_log ORDER BY created_at DESC LIMIT ?", (limit,)).fetchall()
|
||||
return {"logs": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.get("/webhooks/events")
|
||||
def list_webhook_events_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
"""Catalogue of deliverable events (+ wildcard syntax)."""
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
from app.services.webhook_outbound import EVENTS
|
||||
return {"events": EVENTS, "wildcards": ["*", "page.*", "collection.*"]}
|
||||
@@ -0,0 +1,566 @@
|
||||
"""FlowDeck — Public API v2 : collections.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.automations import run_event_sync
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.get("/collections")
|
||||
def list_collections_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
ws_filter = request.query_params.get("workspace_id")
|
||||
q = (request.query_params.get("query") or "").strip()
|
||||
with get_conn() as conn:
|
||||
where = []
|
||||
params: list = []
|
||||
if ws_filter:
|
||||
try:
|
||||
wid = int(ws_filter)
|
||||
where.append("c.workspace_id=?")
|
||||
params.append(wid)
|
||||
except ValueError:
|
||||
pass
|
||||
if q:
|
||||
where.append("(c.name LIKE ? OR c.description LIKE ?)")
|
||||
like = f"%{q}%"
|
||||
params.extend([like, like])
|
||||
clause = ("WHERE " + " AND ".join(where)) if where else ""
|
||||
total = conn.execute(f"SELECT COUNT(*) FROM collections c {clause}", params).fetchone()[0]
|
||||
rows = conn.execute(f"SELECT c.* FROM collections c {clause} ORDER BY c.name LIMIT ? OFFSET ?", (*params, limit, offset)).fetchall()
|
||||
cols = []
|
||||
for r in rows:
|
||||
d = row_to_dict(r)
|
||||
# filter by visibility: skip private not visible (best-effort)
|
||||
cols.append(d)
|
||||
resp = {"collections": cols, "total": total, "limit": limit, "offset": offset}
|
||||
return JSONResponse(content=resp, headers=paginate_headers(total))
|
||||
|
||||
|
||||
@router.post("/collections")
|
||||
def create_collection_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
description = body.get("description", "")
|
||||
icon = body.get("icon", "📋")
|
||||
workspace_id = body.get("workspace_id")
|
||||
schema = body.get("schema") or body.get("schema_json") or []
|
||||
if isinstance(schema, str):
|
||||
try:
|
||||
schema = json.loads(schema)
|
||||
except Exception:
|
||||
schema = []
|
||||
schema_json = json.dumps(schema)
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, description, icon, schema_json, workspace_id, user["id"]))
|
||||
cid = cur.lastrowid
|
||||
# materialize properties if schema provided — A25 : PAS de try ici,
|
||||
# une exception doit interrompre la transaction (sinon la collection est
|
||||
# commitée sans son schéma et l'erreur disparaît).
|
||||
from app.services.db_templates import materialize_properties
|
||||
materialize_properties(conn, cid, schema)
|
||||
# default view
|
||||
conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)", (cid, "Default View", "table", json.dumps({"visible_properties": ["Title"]})))
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
|
||||
audit_log(user, "collection.create", "collection", cid, name, request)
|
||||
data = {"id": cid, "name": name, "status": "created", "collection": row_to_dict(row)}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 201)
|
||||
return JSONResponse(content=data, status_code=201)
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}")
|
||||
def get_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
pages = conn.execute("SELECT id, title, icon, position, property_values_json, created_at FROM collection_pages WHERE collection_id=? ORDER BY position LIMIT 50", (collection_id,)).fetchall()
|
||||
d = row_to_dict(row)
|
||||
d["pages"] = [row_to_dict(p) for p in pages]
|
||||
return d
|
||||
|
||||
|
||||
@router.patch("/collections/{collection_id}")
|
||||
def patch_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
name = body.get("name", row["name"])
|
||||
description = body.get("description", row["description"])
|
||||
icon = body.get("icon", row["icon"])
|
||||
schema = body.get("schema") or body.get("schema_json")
|
||||
if schema is not None:
|
||||
sj = json.dumps(schema) if isinstance(schema, (list, dict)) else str(schema)
|
||||
else:
|
||||
sj = row["schema_json"]
|
||||
conn.execute("UPDATE collections SET name=?, description=?, icon=?, schema_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (name, description, icon, sj, collection_id))
|
||||
conn.commit()
|
||||
audit_log(user, "collection.update", "collection", collection_id, "", request)
|
||||
return {"id": collection_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}")
|
||||
def delete_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "collection.delete", "collection", collection_id, "", request)
|
||||
return {"id": collection_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/linked")
|
||||
def create_linked_db(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "").strip() or f"Linked DB {collection_id}"
|
||||
with get_conn() as conn:
|
||||
src = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not src:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, src["description"], src["icon"], src["schema_json"], src["workspace_id"] if "workspace_id" in src.keys() else None, user["id"]))
|
||||
nid = cur.lastrowid
|
||||
# copy data source as linked
|
||||
try:
|
||||
conn.execute("INSERT INTO collection_data_sources (collection_id, source_collection_id, is_linked) VALUES (?, ?, 1)", (nid, collection_id))
|
||||
except Exception:
|
||||
logger.exception("create_linked_db")
|
||||
# copy views + properties (light)
|
||||
rows = conn.execute("SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall()
|
||||
for p in rows:
|
||||
try:
|
||||
conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, options_json, position) VALUES (?, ?, ?, ?, ?)", (nid, p["name"], p["prop_type"], p["options_json"], p["position"]))
|
||||
except Exception:
|
||||
logger.exception("create_linked_db")
|
||||
vrows = conn.execute("SELECT * FROM collection_views WHERE collection_id=?", (collection_id,)).fetchall()
|
||||
for v in vrows:
|
||||
try:
|
||||
conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)", (nid, v["name"], v["view_type"], v["config_json"], v["position"]))
|
||||
except Exception:
|
||||
logger.exception("create_linked_db")
|
||||
conn.commit()
|
||||
audit_log(user, "collection.linked", "collection", nid, f"src={collection_id}", request)
|
||||
return {"id": nid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/task")
|
||||
def toggle_task(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Collection not found")
|
||||
cur_val = row["is_task"] if "is_task" in row.keys() else 0
|
||||
new_val = 0 if cur_val else 1
|
||||
conn.execute("UPDATE collections SET is_task=? WHERE id=?", (new_val, collection_id))
|
||||
conn.commit()
|
||||
audit_log(user, "collection.toggle_task", "collection", collection_id, str(new_val), request)
|
||||
return {"id": collection_id, "is_task": bool(new_val)}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/sources")
|
||||
def list_sources(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
rows = conn.execute("SELECT * FROM collection_data_sources WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()
|
||||
return {"sources": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/sources")
|
||||
def add_source(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
src_id = body.get("source_collection_id") or body.get("source_id")
|
||||
if not src_id:
|
||||
raise HTTPException(400, "source_collection_id required")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (src_id,)).fetchone():
|
||||
raise HTTPException(404, "Source collection not found")
|
||||
try:
|
||||
conn.execute("INSERT INTO collection_data_sources (collection_id, source_collection_id) VALUES (?, ?)", (collection_id, src_id))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(409, str(e)) from None
|
||||
audit_log(user, "collection.add_source", "collection", collection_id, str(src_id), request)
|
||||
return {"collection_id": collection_id, "source_collection_id": src_id, "status": "added"}
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/sources/{source_id}")
|
||||
def remove_source(collection_id: int, source_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM collection_data_sources WHERE collection_id=? AND (id=? OR source_collection_id=?)", (collection_id, source_id, source_id))
|
||||
conn.commit()
|
||||
audit_log(user, "collection.remove_source", "collection", collection_id, str(source_id), request)
|
||||
return {"status": "removed"}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/pages")
|
||||
def list_collection_pages_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
total = conn.execute("SELECT COUNT(*) FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0]
|
||||
# filters: filter[status]=Done etc., sort, fields
|
||||
# Simple: filter by property name via property_values_json LIKE (best-effort), sort by position or title
|
||||
sort = request.query_params.get("sort") or ""
|
||||
order = "position"
|
||||
desc = False
|
||||
if sort:
|
||||
if sort.startswith("-"):
|
||||
desc = True
|
||||
sort = sort[1:]
|
||||
# allow sorting by title/position/created_at
|
||||
if sort in ("title", "position", "created_at", "updated_at"):
|
||||
order = sort
|
||||
direction = "DESC" if desc else "ASC"
|
||||
rows = conn.execute(f"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY {order} {direction} LIMIT ? OFFSET ?", (collection_id, limit, offset)).fetchall()
|
||||
# apply filter[xxx] in-memory (small)
|
||||
filters = {k[7:-1]: v for k, v in request.query_params.items() if k.startswith("filter[") and k.endswith("]")}
|
||||
fields = request.query_params.get("fields")
|
||||
fields_set = set(fields.split(",")) if fields else None
|
||||
out = []
|
||||
for r in rows:
|
||||
d = row_to_dict(r)
|
||||
# property filter (AND)
|
||||
if filters:
|
||||
try:
|
||||
pv = json.loads(r["property_values_json"] or "{}") if isinstance(r["property_values_json"], str) else r["property_values_json"]
|
||||
except Exception:
|
||||
pv = {}
|
||||
ok = True
|
||||
for fk, fv in filters.items():
|
||||
# lookup by prop id or name
|
||||
found = False
|
||||
for kk, vv in (pv or {}).items():
|
||||
if str(kk) == str(fk) or str(kk).lower() == fk.lower():
|
||||
if str(vv) == str(fv):
|
||||
found = True
|
||||
break
|
||||
# also check title if filter field is title
|
||||
if fk == "title" and d.get("title") == fv:
|
||||
found = True
|
||||
if not found:
|
||||
ok = False
|
||||
break
|
||||
if not ok:
|
||||
continue
|
||||
if fields_set:
|
||||
d = {k: v for k, v in d.items() if k in fields_set or k in ("id", "collection_id")}
|
||||
out.append(d)
|
||||
return JSONResponse(content={"pages": out, "total": total, "limit": limit, "offset": offset}, headers=paginate_headers(total))
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/pages")
|
||||
def create_collection_page_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
title = (body.get("title") or body.get("name") or "Untitled").strip() or "Untitled"
|
||||
icon = body.get("icon", "file")
|
||||
parent_id = body.get("parent_id")
|
||||
prop_vals = body.get("property_values") or body.get("properties") or body.get("property_values_json") or {}
|
||||
if isinstance(prop_vals, str):
|
||||
try:
|
||||
prop_vals = json.loads(prop_vals)
|
||||
except Exception:
|
||||
prop_vals = {}
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
# validate properties if helper exists
|
||||
try:
|
||||
pass
|
||||
# light validation: we rely on existing validators
|
||||
except Exception:
|
||||
logger.exception("create_collection_page_v2")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0]
|
||||
# apply auto props
|
||||
try:
|
||||
props_list = [dict(r) for r in conn.execute("SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall()]
|
||||
from app.services.property_types import apply_auto_properties as _aap
|
||||
_aap(props_list, prop_vals, user, is_create=True)
|
||||
except Exception:
|
||||
logger.exception("create_collection_page_v2")
|
||||
cur = conn.execute("INSERT INTO collection_pages (collection_id, title, icon, position, parent_id, property_values_json) VALUES (?, ?, ?, ?, ?, ?)", (collection_id, title, icon, max_pos, parent_id, json.dumps(prop_vals)))
|
||||
pid = cur.lastrowid
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
|
||||
audit_log(user, "page.create", "collection_page", pid, title, request)
|
||||
try:
|
||||
run_event_sync(_fire_event("collection.page.created", {"page_id": pid, "collection_id": collection_id, "title": title}))
|
||||
except Exception:
|
||||
logger.exception("create_collection_page_v2")
|
||||
data = {"id": pid, "title": title, "status": "created", "page": row_to_dict(row)}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 201)
|
||||
return JSONResponse(content=data, status_code=201)
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}")
|
||||
def get_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
# also try pages table (block pages)
|
||||
row2 = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row2:
|
||||
raise HTTPException(404, "Page not found")
|
||||
d = row_to_dict(row2)
|
||||
# v6.5.0: resolve synced blocks server-side (fresh content).
|
||||
if (d.get("content_format") or "blocks") == "blocks" and d.get("content"):
|
||||
from app.services.synced_blocks import resolve_content_json
|
||||
d["content"] = resolve_content_json(d["content"], d["content_format"])
|
||||
return d
|
||||
d = row_to_dict(row)
|
||||
# property_values_json already parsed by row_to_dict
|
||||
# v6.5.0: expose the row's content page when it exists (no lazy
|
||||
# creation on a read-only endpoint).
|
||||
content_page_id = conn.execute(
|
||||
"SELECT id FROM pages WHERE collection_row_id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
d["content_page_id"] = content_page_id["id"] if content_page_id else None
|
||||
return d
|
||||
|
||||
|
||||
@router.patch("/pages/{page_id}")
|
||||
def patch_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
title = body.get("title", row["title"])
|
||||
icon = body.get("icon", row["icon"])
|
||||
pos = body.get("position", row["position"])
|
||||
parent_id = body.get("parent_id", row["parent_id"])
|
||||
pv_raw = row["property_values_json"] or "{}"
|
||||
try:
|
||||
stored = json.loads(pv_raw) if isinstance(pv_raw, str) else dict(pv_raw)
|
||||
except Exception:
|
||||
stored = {}
|
||||
incoming = body.get("property_values") or body.get("properties")
|
||||
if incoming is not None:
|
||||
if isinstance(incoming, str):
|
||||
try:
|
||||
incoming = json.loads(incoming)
|
||||
except Exception:
|
||||
incoming = {}
|
||||
# merge
|
||||
for k, v in (incoming or {}).items():
|
||||
stored[str(k)] = v
|
||||
# apply auto props
|
||||
try:
|
||||
props_list = [dict(r) for r in conn.execute("SELECT * FROM collection_properties WHERE collection_id=?", (row["collection_id"],)).fetchall()]
|
||||
from app.services.property_types import apply_auto_properties as _aap
|
||||
_aap(props_list, stored, user, is_create=False)
|
||||
except Exception:
|
||||
logger.exception("patch_page_v2")
|
||||
conn.execute("UPDATE collection_pages SET title=?, icon=?, position=?, parent_id=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, icon, pos, parent_id, json.dumps(stored), page_id))
|
||||
conn.commit()
|
||||
audit_log(user, "page.update", "collection_page", page_id, "", request)
|
||||
try:
|
||||
run_event_sync(_fire_event("collection.page.updated", {"page_id": page_id, "collection_id": row["collection_id"], "title": title}))
|
||||
except Exception:
|
||||
logger.exception("patch_page_v2")
|
||||
return {"id": page_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}")
|
||||
def delete_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "page.delete", "collection_page", page_id, "", request)
|
||||
try:
|
||||
run_event_sync(_fire_event("collection.page.deleted", {"page_id": page_id, "collection_id": row["collection_id"]}))
|
||||
except Exception:
|
||||
logger.exception("delete_page_v2")
|
||||
return {"id": page_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/restore")
|
||||
def restore_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
# For soft-deleted pages (deleted_at) - but collection_pages has no deleted_at; handle pages table
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT deleted_at FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if row and row["deleted_at"]:
|
||||
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
try:
|
||||
run_event_sync(_fire_event("page.restored", {"page_id": page_id}))
|
||||
except Exception:
|
||||
logger.exception("restore_page_v2")
|
||||
return {"id": page_id, "status": "restored"}
|
||||
raise HTTPException(404, "Page not found or not deleted")
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/move")
|
||||
def move_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
parent_id = body.get("parent_id", row["parent_id"])
|
||||
position = body.get("position", row["position"])
|
||||
conn.execute("UPDATE collection_pages SET parent_id=?, position=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (parent_id, position, page_id))
|
||||
conn.commit()
|
||||
audit_log(user, "page.move", "collection_page", page_id, f"parent={parent_id} pos={position}", request)
|
||||
return {"id": page_id, "status": "moved"}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/sub-items")
|
||||
def list_sub_items_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise HTTPException(404, "Page not found")
|
||||
rows = conn.execute("SELECT * FROM collection_pages WHERE parent_id=? ORDER BY position", (page_id,)).fetchall()
|
||||
return {"sub_items": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/sub-items")
|
||||
def create_sub_item_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
parent = conn.execute("SELECT collection_id FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not parent:
|
||||
raise HTTPException(404, "Page not found")
|
||||
title = (body.get("title") or "Untitled").strip()
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE parent_id=?", (page_id,)).fetchone()[0]
|
||||
pv = json.dumps(body.get("property_values") or {})
|
||||
cur = conn.execute("INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?, ?, ?, ?, ?)", (parent["collection_id"], title, page_id, max_pos, pv))
|
||||
nid = cur.lastrowid
|
||||
conn.commit()
|
||||
audit_log(user, "page.create_subitem", "collection_page", nid, title, request)
|
||||
return {"id": nid, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/dependencies")
|
||||
def list_dependencies_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM page_dependencies WHERE page_id=?", (page_id,)).fetchall()
|
||||
return {"dependencies": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/dependencies")
|
||||
def add_dependency_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
dep_id = body.get("dependency_id") or body.get("depends_on")
|
||||
dtype = body.get("dependency_type") or "blocks"
|
||||
if not dep_id:
|
||||
raise HTTPException(400, "dependency_id required")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (page_id,)).fetchone():
|
||||
raise HTTPException(404, "Page not found")
|
||||
if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (dep_id,)).fetchone():
|
||||
raise HTTPException(404, "Dependency page not found")
|
||||
try:
|
||||
conn.execute("INSERT INTO page_dependencies (page_id, dependency_id, dependency_type) VALUES (?, ?, ?)", (page_id, dep_id, dtype))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(409, str(e)) from None
|
||||
audit_log(user, "page.add_dependency", "collection_page", page_id, str(dep_id), request)
|
||||
return {"status": "added"}
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/dependencies/{dep_id}")
|
||||
def remove_dependency_v2(page_id: int, dep_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM page_dependencies WHERE page_id=? AND dependency_id=?", (page_id, dep_id))
|
||||
conn.commit()
|
||||
audit_log(user, "page.remove_dependency", "collection_page", page_id, str(dep_id), request)
|
||||
return {"status": "removed"}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/properties")
|
||||
def list_properties_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
rows = conn.execute("SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()
|
||||
return {"properties": [row_to_dict(r) for r in rows]}
|
||||
@@ -0,0 +1,338 @@
|
||||
"""FlowDeck — Public API v2 : engagement.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.automations import run_event_sync
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/comments")
|
||||
def list_comments_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM comments WHERE target_id=? OR page_id=?", (page_id, page_id)).fetchone()[0]
|
||||
rows = conn.execute("SELECT c.*, u.login, u.full_name FROM comments c LEFT JOIN users u ON u.id=c.user_id WHERE c.target_id=? OR c.page_id=? ORDER BY c.created_at LIMIT ? OFFSET ?", (page_id, page_id, limit, offset)).fetchall()
|
||||
return {"comments": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/comments")
|
||||
def create_comment_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
text = (body.get("body") or body.get("content") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "body is required")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO comments (page_id, user_id, body, target_type, target_id, anchor_block_id, anchor_start, anchor_end) VALUES (?, ?, ?, 'page', ?, ?, ?, ?)", (page_id, user["id"], text, page_id, body.get("anchor_block_id"), body.get("anchor_start"), body.get("anchor_end")))
|
||||
nid = cur.lastrowid
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM comments WHERE id=?", (nid,)).fetchone()
|
||||
audit_log(user, "comment.create", "comment", nid, text[:80], request)
|
||||
try:
|
||||
run_event_sync(_fire_event("comment.added", {"comment_id": nid, "page_id": page_id, "user_id": user["id"]}))
|
||||
except Exception:
|
||||
logger.exception("create_comment_v2")
|
||||
return {"id": nid, "status": "created", "comment": row_to_dict(row)}
|
||||
|
||||
|
||||
@router.patch("/comments/{comment_id}")
|
||||
def patch_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Comment not found")
|
||||
if row["user_id"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(403, "Not your comment")
|
||||
body_text = body.get("body", row["body"])
|
||||
resolved = body.get("resolved", row["resolved"])
|
||||
was_resolved = int(row["resolved"] or 0)
|
||||
conn.execute("UPDATE comments SET body=?, resolved=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (body_text, int(bool(resolved)), comment_id))
|
||||
conn.commit()
|
||||
if int(bool(resolved)) and not was_resolved:
|
||||
try:
|
||||
run_event_sync(_fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}))
|
||||
except Exception:
|
||||
logger.exception("patch_comment_v2")
|
||||
return {"id": comment_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/comments/{comment_id}")
|
||||
def delete_comment_v2(comment_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Comment not found")
|
||||
if row["user_id"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(403, "Not your comment")
|
||||
conn.execute("DELETE FROM comments WHERE id=?", (comment_id,))
|
||||
conn.commit()
|
||||
return {"id": comment_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/mentions")
|
||||
def create_mention_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
targets = body.get("user_ids") or body.get("mentions") or []
|
||||
if isinstance(targets, int):
|
||||
targets = [targets]
|
||||
if not targets:
|
||||
raise HTTPException(400, "user_ids required")
|
||||
created = 0
|
||||
with get_conn() as conn:
|
||||
for uid in targets:
|
||||
try:
|
||||
conn.execute("INSERT INTO notifications (user_id, actor_id, ntype, title, message, resource_type, resource_id, url) VALUES (?, ?, 'mention', 'You were mentioned', ?, 'page', ?, ?)", (uid, user["id"], body.get("message") or f"Mentioned in page {page_id}", page_id, f"/pages/{page_id}"))
|
||||
created += 1
|
||||
except Exception:
|
||||
logger.exception("create_mention_v2")
|
||||
conn.commit()
|
||||
if created:
|
||||
try:
|
||||
run_event_sync(_fire_event("mention.added", {"page_id": page_id, "user_ids": [u for u in targets if isinstance(u, int)], "count": created}))
|
||||
except Exception:
|
||||
logger.exception("create_mention_v2")
|
||||
return {"mentions": created, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/notifications")
|
||||
def list_notifications_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
unread = request.query_params.get("unread")
|
||||
with get_conn() as conn:
|
||||
where = "user_id=?"
|
||||
params: list = [user["id"]]
|
||||
if unread == "1":
|
||||
where += " AND is_read=0"
|
||||
total = conn.execute(f"SELECT COUNT(*) FROM notifications WHERE {where}", params).fetchone()[0]
|
||||
rows = conn.execute(f"SELECT * FROM notifications WHERE {where} ORDER BY created_at DESC LIMIT ? OFFSET ?", (*params, limit, offset)).fetchall()
|
||||
return {"notifications": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.get("/notifications/unread-count")
|
||||
def unread_count(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
cnt = conn.execute("SELECT COUNT(*) FROM notifications WHERE user_id=? AND is_read=0", (user["id"],)).fetchone()[0]
|
||||
return {"unread": cnt}
|
||||
|
||||
|
||||
@router.post("/notifications/{notif_id}/read")
|
||||
def mark_read(notif_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE notifications SET is_read=1 WHERE id=? AND user_id=?", (notif_id, user["id"]))
|
||||
conn.commit()
|
||||
return {"id": notif_id, "status": "read"}
|
||||
|
||||
|
||||
@router.post("/notifications/read-all")
|
||||
def mark_all_read(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE notifications SET is_read=1 WHERE user_id=?", (user["id"],))
|
||||
conn.commit()
|
||||
return {"status": "all read"}
|
||||
|
||||
|
||||
@router.patch("/users/me/preferences")
|
||||
def patch_prefs(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT notification_prefs FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
try:
|
||||
cur = json.loads(row["notification_prefs"] or "{}")
|
||||
except Exception:
|
||||
cur = {}
|
||||
cur.update(body)
|
||||
conn.execute("UPDATE users SET notification_prefs=? WHERE id=?", (json.dumps(cur), user["id"]))
|
||||
conn.commit()
|
||||
return {"preferences": cur}
|
||||
|
||||
|
||||
@router.get("/favorites")
|
||||
def list_favorites_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT f.*, p.title, p.workspace_id FROM favorites f JOIN pages p ON p.id=f.page_id WHERE f.user_id=? ORDER BY f.position", (user["id"],)).fetchall()
|
||||
return {"favorites": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/favorites")
|
||||
def add_favorite_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
pid = body.get("page_id")
|
||||
if not pid:
|
||||
raise HTTPException(400, "page_id required")
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
conn.execute("INSERT INTO favorites (user_id, page_id) VALUES (?, ?)", (user["id"], pid))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(409, str(e)) from None
|
||||
try:
|
||||
run_event_sync(_fire_event("favorite.added", {"page_id": pid, "user_id": user["id"]}))
|
||||
except Exception:
|
||||
logger.exception("add_favorite_v2")
|
||||
return {"page_id": pid, "status": "added"}
|
||||
|
||||
|
||||
@router.delete("/favorites/{page_id}")
|
||||
def remove_favorite_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (user["id"], page_id))
|
||||
conn.commit()
|
||||
try:
|
||||
run_event_sync(_fire_event("favorite.removed", {"page_id": page_id, "user_id": user["id"]}))
|
||||
except Exception:
|
||||
logger.exception("remove_favorite_v2")
|
||||
return {"page_id": page_id, "status": "removed"}
|
||||
|
||||
|
||||
@router.get("/tags")
|
||||
def list_tags_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
q = (request.query_params.get("q") or "").strip()
|
||||
with get_conn() as conn:
|
||||
if q:
|
||||
rows = conn.execute("SELECT * FROM tags WHERE user_id=? AND name LIKE ? ORDER BY name", (user["id"], f"%{q}%")).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM tags WHERE user_id=? ORDER BY name", (user["id"],)).fetchall()
|
||||
return {"tags": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/tags")
|
||||
def create_tag_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name required")
|
||||
color = body.get("color", "#787774")
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
cur = conn.execute("INSERT INTO tags (name, color, user_id) VALUES (?, ?, ?)", (name, color, user["id"]))
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(409, str(e)) from None
|
||||
return {"id": tid, "name": name, "color": color, "status": "created"}
|
||||
|
||||
|
||||
@router.patch("/tags/{tag_id}")
|
||||
def patch_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM tags WHERE id=? AND user_id=?", (tag_id, user["id"])).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Tag not found")
|
||||
name = body.get("name", row["name"])
|
||||
color = body.get("color", row["color"])
|
||||
conn.execute("UPDATE tags SET name=?, color=? WHERE id=?", (name, color, tag_id))
|
||||
conn.commit()
|
||||
return {"id": tag_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/tags/{tag_id}")
|
||||
def delete_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM tags WHERE id=? AND user_id=?", (tag_id, user["id"]))
|
||||
conn.commit()
|
||||
return {"id": tag_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/tags")
|
||||
def attach_tag_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
tag_id = body.get("tag_id")
|
||||
if not tag_id:
|
||||
raise HTTPException(400, "tag_id required")
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
conn.execute("INSERT INTO page_tags (page_id, tag_id) VALUES (?, ?)", (page_id, tag_id))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(409, str(e)) from None
|
||||
return {"page_id": page_id, "tag_id": tag_id, "status": "attached"}
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/tags/{tag_id}")
|
||||
def detach_tag_v2(page_id: int, tag_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM page_tags WHERE page_id=? AND tag_id=?", (page_id, tag_id))
|
||||
conn.commit()
|
||||
return {"status": "detached"}
|
||||
|
||||
|
||||
@router.get("/recents")
|
||||
def list_recents_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit = int(request.query_params.get("limit", "20"))
|
||||
st = request.query_params.get("source_type")
|
||||
with get_conn() as conn:
|
||||
if st:
|
||||
rows = conn.execute("SELECT * FROM recents WHERE user_id=? AND source_type=? ORDER BY accessed_at DESC LIMIT ?", (user["id"], st, limit)).fetchall()
|
||||
else:
|
||||
rows = conn.execute("SELECT * FROM recents WHERE user_id=? ORDER BY accessed_at DESC LIMIT ?", (user["id"], limit)).fetchall()
|
||||
return {"recents": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/shares")
|
||||
def list_shares_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM page_shares WHERE page_id=?", (page_id,)).fetchall()
|
||||
return {"shares": [dict(r) for r in rows]}
|
||||
@@ -0,0 +1,195 @@
|
||||
"""FlowDeck — Public API v2 : identity.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
from ._common import _hash, _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.post("/tokens")
|
||||
def create_token(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "API token").strip()[:100]
|
||||
scopes = validate_scopes_input(body.get("scopes") or "read,write")
|
||||
expires_at = body.get("expires_at")
|
||||
# Idempotency
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
token = f"fd_{secrets.token_urlsafe(32)}"
|
||||
prefix = token[:12]
|
||||
th = _hash(token)
|
||||
exp_val = None
|
||||
if expires_at:
|
||||
try:
|
||||
# accept ISO string
|
||||
exp_val = str(expires_at)
|
||||
# validate parse
|
||||
datetime.fromisoformat(exp_val.replace("Z", "+00:00"))
|
||||
except Exception as err:
|
||||
raise HTTPException(400, "Invalid expires_at, use ISO-8601") from err
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes, expires_at) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(user["id"], name, th, prefix, scopes, exp_val),
|
||||
)
|
||||
conn.commit()
|
||||
tid = cur.lastrowid
|
||||
except Exception as e:
|
||||
raise HTTPException(409, f"Token creation failed: {e}") from None
|
||||
row = conn.execute("SELECT id, name, token_prefix, scopes, expires_at, created_at FROM api_tokens WHERE id=?", (tid,)).fetchone()
|
||||
audit_log(user, "token.create", "api_token", tid, f"scopes={scopes}", request)
|
||||
data = {"id": tid, "name": row["name"], "token": token, "prefix": prefix, "scopes": scopes, "expires_at": to_iso8601(row["expires_at"]) if row["expires_at"] else None, "note": "Copy token now — shown once. Use as Authorization: Bearer <token>"}
|
||||
key = (request.headers.get("Idempotency-Key") or request.headers.get("idempotency-key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 200)
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/tokens")
|
||||
def list_tokens(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, name, token_prefix, scopes, expires_at, last_used_at, created_at, revoked FROM api_tokens WHERE user_id=? ORDER BY created_at DESC", (user["id"],)).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["created_at"] = to_iso8601(d.get("created_at"))
|
||||
d["expires_at"] = to_iso8601(d.get("expires_at")) if d.get("expires_at") else None
|
||||
d["last_used_at"] = to_iso8601(d.get("last_used_at")) if d.get("last_used_at") else None
|
||||
# never expose hash
|
||||
out.append({k: v for k, v in d.items() if k != "token_hash"})
|
||||
return {"tokens": out}
|
||||
|
||||
|
||||
@router.delete("/tokens/{token_id}")
|
||||
def revoke_token(token_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, user_id FROM api_tokens WHERE id=?", (token_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Token not found")
|
||||
if row["user_id"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(403, "Not your token")
|
||||
conn.execute("UPDATE api_tokens SET revoked=1 WHERE id=?", (token_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "token.revoke", "api_token", token_id, "", request)
|
||||
return {"id": token_id, "status": "revoked"}
|
||||
|
||||
|
||||
@router.post("/tokens/{token_id}/rotate")
|
||||
def rotate_token(token_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, user_id, name, scopes FROM api_tokens WHERE id=?", (token_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Token not found")
|
||||
if row["user_id"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(403, "Not your token")
|
||||
# revoke old
|
||||
conn.execute("UPDATE api_tokens SET revoked=1 WHERE id=?", (token_id,))
|
||||
new_token = f"fd_{secrets.token_urlsafe(32)}"
|
||||
th = _hash(new_token)
|
||||
prefix = new_token[:12]
|
||||
cur = conn.execute("INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, scopes) VALUES (?, ?, ?, ?, ?)", (row["user_id"], row["name"], th, prefix, row["scopes"] or "read,write"))
|
||||
conn.commit()
|
||||
nid = cur.lastrowid
|
||||
audit_log(user, "token.rotate", "api_token", token_id, f"new_id={nid}", request)
|
||||
return {"id": nid, "token": new_token, "prefix": prefix, "scopes": row["scopes"], "note": "Copy token now — shown once"}
|
||||
|
||||
|
||||
@router.get("/users/me")
|
||||
def get_me(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color, is_admin, is_active, auth_method, sidebar_config, notification_prefs, timezone, created_at FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "User not found")
|
||||
d = row_to_dict(row)
|
||||
# parse json prefs
|
||||
for k in ("notification_prefs", "sidebar_config"):
|
||||
if isinstance(d.get(k), str):
|
||||
try:
|
||||
d[k] = json.loads(d[k] or "{}")
|
||||
except Exception:
|
||||
logger.exception("get_me")
|
||||
# never expose secrets
|
||||
return d
|
||||
|
||||
|
||||
@router.patch("/users/me")
|
||||
def patch_me(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
allowed = {"full_name", "email", "avatar_color", "notification_prefs", "sidebar_config", "timezone"}
|
||||
updates = {}
|
||||
for k in allowed:
|
||||
if k in body:
|
||||
updates[k] = body[k]
|
||||
if not updates:
|
||||
raise HTTPException(400, "No updatable fields")
|
||||
# validation
|
||||
if "email" in updates and updates["email"] and "@" not in str(updates["email"]):
|
||||
raise HTTPException(400, "Invalid email")
|
||||
with get_conn() as conn:
|
||||
sets = []
|
||||
params = []
|
||||
for k, v in updates.items():
|
||||
if k in ("notification_prefs", "sidebar_config"):
|
||||
v = json.dumps(v) if isinstance(v, (dict, list)) else str(v)
|
||||
sets.append(f"{k}=?")
|
||||
params.append(v)
|
||||
params.append(user["id"])
|
||||
conn.execute(f"UPDATE users SET {', '.join(sets)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color, is_admin, timezone, notification_prefs FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
audit_log(user, "user.update", "user", user["id"], "", request)
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
@router.get("/users/search")
|
||||
def search_users(request: Request, q: str = "", authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
q = (q or request.query_params.get("q") or "").strip()
|
||||
if not q:
|
||||
return {"users": []}
|
||||
like = f"%{q}%"
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT id, login, full_name, email, avatar_url, avatar_color FROM users WHERE login LIKE ? OR email LIKE ? OR full_name LIKE ? LIMIT 20", (like, like, like)).fetchall()
|
||||
return {"users": [dict(r) for r in rows]}
|
||||
@@ -0,0 +1,148 @@
|
||||
"""FlowDeck — Public API v2 : planning.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.automations import run_event_sync
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/sprints")
|
||||
def create_sprint_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "Sprint").strip()
|
||||
start = body.get("start_date") or body.get("start") or ""
|
||||
end = body.get("end_date") or body.get("end") or ""
|
||||
if not start or not end:
|
||||
raise HTTPException(400, "start_date and end_date required (YYYY-MM-DD)")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO sprints (collection_id, name, start_date, end_date, goal) VALUES (?, ?, ?, ?, ?)", (collection_id, name, start, end, body.get("goal") or ""))
|
||||
sid = cur.lastrowid
|
||||
conn.commit()
|
||||
try:
|
||||
run_event_sync(_fire_event("sprint.created", {"sprint_id": sid, "collection_id": collection_id, "name": name}))
|
||||
except Exception:
|
||||
logger.exception("create_sprint_v2")
|
||||
return {"id": sid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.patch("/sprints/{sprint_id}")
|
||||
def patch_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sprints WHERE id=?", (sprint_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Sprint not found")
|
||||
name = body.get("name", row["name"])
|
||||
start = body.get("start_date", row["start_date"])
|
||||
end = body.get("end_date", row["end_date"])
|
||||
goal = body.get("goal", row["goal"])
|
||||
status = body.get("status", row["status"])
|
||||
conn.execute("UPDATE sprints SET name=?, start_date=?, end_date=?, goal=?, status=? WHERE id=?", (name, start, end, goal, status, sprint_id))
|
||||
conn.commit()
|
||||
try:
|
||||
run_event_sync(_fire_event("sprint.updated", {"sprint_id": sprint_id, "collection_id": row["collection_id"], "name": name, "status": status}))
|
||||
except Exception:
|
||||
logger.exception("patch_sprint_v2")
|
||||
return {"id": sprint_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/sprints/{sprint_id}")
|
||||
def delete_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM sprints WHERE id=?", (sprint_id,))
|
||||
conn.commit()
|
||||
return {"id": sprint_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/sprints/{sprint_id}/assign")
|
||||
def assign_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
pid = body.get("page_id")
|
||||
if not pid:
|
||||
raise HTTPException(400, "page_id required")
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
conn.execute("INSERT INTO sprint_pages (sprint_id, page_id, velocity_points) VALUES (?, ?, ?)", (sprint_id, pid, body.get("velocity_points", 1)))
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
raise HTTPException(409, str(e)) from None
|
||||
return {"sprint_id": sprint_id, "page_id": pid, "status": "assigned"}
|
||||
|
||||
|
||||
@router.delete("/sprints/{sprint_id}/assign/{page_id}")
|
||||
def unassign_sprint_v2(sprint_id: int, page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sprint_id, page_id))
|
||||
conn.commit()
|
||||
return {"status": "removed"}
|
||||
|
||||
|
||||
@router.get("/sprints/{sprint_id}/burndown")
|
||||
def burndown_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
s = conn.execute("SELECT * FROM sprints WHERE id=?", (sprint_id,)).fetchone()
|
||||
if not s:
|
||||
raise HTTPException(404, "Sprint not found")
|
||||
pages = conn.execute("SELECT sp.*, cp.property_values_json FROM sprint_pages sp JOIN collection_pages cp ON cp.id=sp.page_id WHERE sp.sprint_id=?", (sprint_id,)).fetchall()
|
||||
total = len(pages)
|
||||
# crude: completed where status property == Done (best-effort)
|
||||
completed = 0
|
||||
for p in pages:
|
||||
try:
|
||||
pv = json.loads(p["property_values_json"] or "{}")
|
||||
for v in pv.values():
|
||||
if str(v).lower() in ("done", "completed", "terminé"):
|
||||
completed += 1
|
||||
break
|
||||
except Exception:
|
||||
logger.exception("burndown_v2")
|
||||
remaining = total - completed
|
||||
# ideal linear
|
||||
ideal = [round(total * (1 - i / 10)) for i in range(11)]
|
||||
return {"sprint_id": sprint_id, "total": total, "completed": completed, "remaining": remaining, "ideal": ideal}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/templates")
|
||||
def list_templates_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM page_templates WHERE collection_id=? ORDER BY created_at", (collection_id,)).fetchall()
|
||||
return {"templates": [row_to_dict(r) for r in rows]}
|
||||
@@ -0,0 +1,93 @@
|
||||
"""FlowDeck — Public API v2 : projects.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.get("/projects")
|
||||
def list_projects_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM projects ORDER BY proj_type, owner, name").fetchall()
|
||||
return {"projects": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.get("/projects/{owner}/{repo}/tree")
|
||||
async def project_tree_v2(owner: str, repo: str, request: Request, path: str = "", authorization: str | None = Header(default=None)):
|
||||
get_bearer_user(request, authorization)
|
||||
# proxy to gitea client? Return placeholder listing from projects table
|
||||
with get_conn() as conn:
|
||||
proj = conn.execute("SELECT * FROM projects WHERE owner=? AND name=?", (owner, repo)).fetchone()
|
||||
if not proj:
|
||||
raise HTTPException(404, "Project not found")
|
||||
# delegate to gitea API if available (best-effort)
|
||||
try:
|
||||
from app.services.gitea_client import gitea
|
||||
tree = await gitea.list_repo_files(owner, repo, path or "")
|
||||
return {"owner": owner, "repo": repo, "path": path, "tree": tree}
|
||||
except Exception:
|
||||
return {"owner": owner, "repo": repo, "path": path, "tree": []}
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
def search_v2(request: Request, query: str = "", workspace_id: int | None = None, type: str = "all", authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
q = (query or request.query_params.get("query") or "").strip()
|
||||
if not q:
|
||||
return {"results": [], "query": q}
|
||||
like = f"%{q}%"
|
||||
with get_conn() as conn:
|
||||
pages = []
|
||||
# try FTS5
|
||||
try:
|
||||
rows = conn.execute("SELECT p.id, p.title, p.content, p.workspace_id, snippet(pages_fts, -1, '<mark>', '</mark>', '...', 32) as snippet FROM pages_fts f JOIN pages p ON p.id=f.rowid WHERE pages_fts MATCH ? LIMIT 20", (q,)).fetchall()
|
||||
pages = [{"type": "page", "id": r["id"], "title": r["title"], "snippet": r["snippet"]} for r in rows]
|
||||
except Exception:
|
||||
rows = conn.execute("SELECT id, title FROM pages WHERE title LIKE ? OR content LIKE ? LIMIT 20", (like, like)).fetchall()
|
||||
pages = [{"type": "page", "id": r["id"], "title": r["title"]} for r in rows]
|
||||
# collections
|
||||
colls = conn.execute("SELECT id, name FROM collections WHERE name LIKE ? LIMIT 10", (like,)).fetchall()
|
||||
results = pages + [{"type": "collection", "id": r["id"], "title": r["name"]} for r in colls]
|
||||
return {"query": q, "results": results}
|
||||
|
||||
|
||||
@router.get("/admin/users")
|
||||
def admin_list_users_v2(request: Request, limit: int = 30, offset: int = 0, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"):
|
||||
raise HTTPException(403, "Admin scope required")
|
||||
limit = max(1, min(limit, 100))
|
||||
with get_conn() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
|
||||
rows = conn.execute("SELECT id, login, full_name, email, is_admin, is_active, created_at FROM users ORDER BY id LIMIT ? OFFSET ?", (limit, offset)).fetchall()
|
||||
return {"users": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
|
||||
@@ -0,0 +1,151 @@
|
||||
"""FlowDeck — Public API v2 : properties.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/properties")
|
||||
def create_property_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
ptype = body.get("prop_type") or body.get("type") or "text"
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchone()[0]
|
||||
try:
|
||||
cur = conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, options_json, number_format, position, required, visible_in_views, validation_json, group_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", (collection_id, name, ptype, json.dumps(body.get("options") or []), body.get("number_format") or "number", max_pos, int(bool(body.get("required"))), int(bool(body.get("visible_in_views", True))), json.dumps(body.get("validation") or {}), (body.get("group_name") or "").strip()))
|
||||
conn.commit()
|
||||
pid = cur.lastrowid
|
||||
except Exception as e:
|
||||
raise HTTPException(409, f"Property exists: {e}") from None
|
||||
audit_log(user, "property.create", "property", pid, name, request)
|
||||
return {"id": pid, "name": name, "prop_type": ptype, "status": "created"}
|
||||
|
||||
|
||||
@router.patch("/properties/{prop_id}")
|
||||
def patch_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_properties WHERE id=?", (prop_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Property not found")
|
||||
name = body.get("name", row["name"])
|
||||
opts = json.dumps(body.get("options", json.loads(row["options_json"] or "[]")))
|
||||
nf = body.get("number_format", row["number_format"])
|
||||
req = int(bool(body.get("required", row["required"])))
|
||||
vis = int(bool(body.get("visible_in_views", row["visible_in_views"])))
|
||||
vj = json.dumps(body.get("validation", json.loads(row["validation_json"] or "{}"))) if "validation" in body else (row["validation_json"] if "validation_json" in row.keys() else "{}")
|
||||
grp = body.get("group_name", row["group_name"] if "group_name" in row.keys() else "")
|
||||
conn.execute("UPDATE collection_properties SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?, validation_json=?, group_name=? WHERE id=?", (name, opts, nf, req, vis, vj, grp, prop_id))
|
||||
conn.commit()
|
||||
audit_log(user, "property.update", "property", prop_id, "", request)
|
||||
return {"id": prop_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/properties/{prop_id}")
|
||||
def delete_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collection_properties WHERE id=?", (prop_id,)).fetchone():
|
||||
raise HTTPException(404, "Property not found")
|
||||
conn.execute("DELETE FROM collection_properties WHERE id=?", (prop_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "property.delete", "property", prop_id, "", request)
|
||||
return {"id": prop_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/properties/{prop_id}/relation")
|
||||
def create_relation_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
related_id = body.get("related_collection_id")
|
||||
reverse = (body.get("reverse_name") or "").strip()
|
||||
if not related_id:
|
||||
raise HTTPException(400, "related_collection_id required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT collection_id FROM collection_properties WHERE id=?", (prop_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Property not found")
|
||||
conn.execute("UPDATE collection_properties SET prop_type='relation', related_collection_id=?, reverse_name=? WHERE id=?", (related_id, reverse, prop_id))
|
||||
if reverse:
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_properties WHERE collection_id=?", (related_id,)).fetchone()[0]
|
||||
try:
|
||||
conn.execute("INSERT INTO collection_properties (collection_id, name, prop_type, related_collection_id, reverse_name, position) VALUES (?, ?, 'relation', ?, ?, ?)", (related_id, reverse, row["collection_id"], "", max_pos))
|
||||
except Exception:
|
||||
logger.exception("create_relation_v2")
|
||||
conn.commit()
|
||||
return {"id": prop_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.post("/properties/evaluate-formula")
|
||||
def evaluate_formula_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
expr = body.get("expression") or body.get("formula")
|
||||
if not expr:
|
||||
raise HTTPException(400, "expression required")
|
||||
ctx = body.get("context") or {}
|
||||
try:
|
||||
from app.services.formula_engine import FormulaEngine
|
||||
res = FormulaEngine().evaluate(expr, ctx)
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Formula error: {e}") from None
|
||||
return {"result": res, "expression": expr}
|
||||
|
||||
|
||||
@router.post("/properties/compute-rollup")
|
||||
def compute_rollup_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
for k in ("collection_id", "relation_property_id", "target_property_id", "page_id"):
|
||||
if k not in body:
|
||||
raise HTTPException(400, f"{k} required")
|
||||
try:
|
||||
from app.services.rollup_engine import RollupEngine
|
||||
res = RollupEngine().compute(body["collection_id"], body["relation_property_id"], body["target_property_id"], body["page_id"], body.get("function", "count"))
|
||||
except Exception as e:
|
||||
raise HTTPException(400, f"Rollup error: {e}") from None
|
||||
return {"result": res}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/views")
|
||||
def list_views_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM collection_views WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()
|
||||
return {"views": [row_to_dict(r) for r in rows]}
|
||||
@@ -0,0 +1,160 @@
|
||||
"""FlowDeck — Public API v2 : sharing.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.automations import run_event_sync
|
||||
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/shares")
|
||||
def create_share_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
perm = (body.get("permission") or "view").strip().lower()
|
||||
if perm not in ("view", "comment", "edit"):
|
||||
raise HTTPException(400, "Invalid permission. Use view, comment, or edit")
|
||||
email = (body.get("email") or "").strip()
|
||||
uid = body.get("user_id")
|
||||
if not email and not uid:
|
||||
raise HTTPException(400, "email or user_id required")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by) VALUES (?, ?, ?, ?, ?)", (page_id, uid, email, perm, user["id"]))
|
||||
conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
nid = cur.lastrowid
|
||||
audit_log(user, "share.create", "share", nid, f"page={page_id}", request)
|
||||
try:
|
||||
run_event_sync(_fire_event("page.shared", {"page_id": page_id, "share_id": nid, "permission": perm}))
|
||||
except Exception:
|
||||
logger.exception("create_share_v2")
|
||||
return {"id": nid, "page_id": page_id, "status": "shared"}
|
||||
|
||||
|
||||
@router.patch("/shares/{share_id}")
|
||||
def patch_share_v2(share_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
perm = (body.get("permission") or "").strip().lower()
|
||||
if perm not in ("view", "comment", "edit"):
|
||||
raise HTTPException(400, "Invalid permission")
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM page_shares WHERE id=?", (share_id,)).fetchone():
|
||||
raise HTTPException(404, "Share not found")
|
||||
conn.execute("UPDATE page_shares SET permission=? WHERE id=?", (perm, share_id))
|
||||
conn.commit()
|
||||
return {"id": share_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/shares/{share_id}")
|
||||
def delete_share_v2(share_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT page_id FROM page_shares WHERE id=?", (share_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Share not found")
|
||||
conn.execute("DELETE FROM page_shares WHERE id=?", (share_id,))
|
||||
# unset is_shared if no shares left
|
||||
cnt = conn.execute("SELECT COUNT(*) FROM page_shares WHERE page_id=?", (row["page_id"],)).fetchone()[0]
|
||||
if cnt == 0:
|
||||
conn.execute("UPDATE pages SET is_shared=0 WHERE id=?", (row["page_id"],))
|
||||
conn.commit()
|
||||
return {"id": share_id, "status": "revoked"}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/publish")
|
||||
def publish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
slug_in = (body.get("slug") or body.get("publish_slug") or "").strip() or None
|
||||
slug, _title = publish(page_id, explicit_slug=slug_in)
|
||||
audit_log(user, "page.publish", "page", page_id, slug, request)
|
||||
run_event_sync(fire_published(page_id, slug))
|
||||
return {"page_id": page_id, "slug": slug, "url": f"/p/{slug}", "status": "published"}
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/publish")
|
||||
def unpublish_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
unpublish(page_id)
|
||||
run_event_sync(fire_unpublished(page_id))
|
||||
return {"page_id": page_id, "status": "unpublished"}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/history")
|
||||
def list_history_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT h.*, u.login FROM page_history h LEFT JOIN users u ON u.id=h.user_id WHERE h.page_id=? ORDER BY h.created_at DESC", (page_id,)).fetchall()
|
||||
# also page_versions for block pages
|
||||
vrows = conn.execute("SELECT * FROM page_versions WHERE page_id=? ORDER BY created_at DESC", (page_id,)).fetchall()
|
||||
return {"history": [row_to_dict(r) for r in rows], "versions": [row_to_dict(r) for r in vrows]}
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/history/restore")
|
||||
def restore_history_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
hid = body.get("history_id") or body.get("id") or body.get("version_id")
|
||||
if not hid:
|
||||
raise HTTPException(400, "history_id required")
|
||||
with get_conn() as conn:
|
||||
h = conn.execute("SELECT * FROM page_versions WHERE id=? AND page_id=?", (hid, page_id)).fetchone()
|
||||
if h:
|
||||
conn.execute("UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (h["blocks_json"], h["title"], page_id))
|
||||
conn.commit()
|
||||
return {"page_id": page_id, "restored_version": hid, "status": "restored"}
|
||||
h2 = conn.execute("SELECT * FROM page_history WHERE id=? AND page_id=?", (hid, page_id)).fetchone()
|
||||
if h2:
|
||||
try:
|
||||
snap = json.loads(h2["snapshot_json"] or "{}")
|
||||
except Exception:
|
||||
snap = {}
|
||||
# best-effort restore content
|
||||
if snap.get("content"):
|
||||
conn.execute("UPDATE pages SET content=? WHERE id=?", (snap["content"], page_id))
|
||||
conn.commit()
|
||||
return {"page_id": page_id, "restored_version": hid, "status": "restored"}
|
||||
raise HTTPException(404, "History not found")
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/sprints")
|
||||
def list_sprints_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM sprints WHERE collection_id=?", (collection_id,)).fetchone()[0]
|
||||
rows = conn.execute("SELECT * FROM sprints WHERE collection_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?", (collection_id, limit, offset)).fetchall()
|
||||
return {"sprints": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset}
|
||||
@@ -0,0 +1,205 @@
|
||||
"""FlowDeck — Public API v2 : templates_io.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/templates")
|
||||
def create_template_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "Template").strip()
|
||||
pv = json.dumps(body.get("property_values") or body.get("property_values_json") or {})
|
||||
cj = json.dumps(body.get("content") or body.get("content_json") or [])
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO page_templates (collection_id, name, property_values_json, content_json) VALUES (?, ?, ?, ?)", (collection_id, name, pv, cj))
|
||||
tid = cur.lastrowid
|
||||
conn.commit()
|
||||
return {"id": tid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.patch("/templates/{template_id}")
|
||||
def patch_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM page_templates WHERE id=?", (template_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Template not found")
|
||||
name = body.get("name", row["name"])
|
||||
pv = json.dumps(body.get("property_values", json.loads(row["property_values_json"] or "{}"))) if "property_values" in body else row["property_values_json"]
|
||||
cj = json.dumps(body.get("content", json.loads(row["content_json"] or "[]"))) if "content" in body else row["content_json"]
|
||||
conn.execute("UPDATE page_templates SET name=?, property_values_json=?, content_json=? WHERE id=?", (name, pv, cj, template_id))
|
||||
conn.commit()
|
||||
return {"id": template_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/templates/{template_id}")
|
||||
def delete_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM page_templates WHERE id=?", (template_id,))
|
||||
conn.commit()
|
||||
return {"id": template_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/templates/{template_id}/apply")
|
||||
def apply_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
tpl = conn.execute("SELECT * FROM page_templates WHERE id=?", (template_id,)).fetchone()
|
||||
if not tpl:
|
||||
raise HTTPException(404, "Template not found")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (tpl["collection_id"],)).fetchone()[0]
|
||||
pv = tpl["property_values_json"] or "{}"
|
||||
cur = conn.execute("INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?, ?, ?, ?)", (tpl["collection_id"], tpl["name"], max_pos, pv))
|
||||
pid = cur.lastrowid
|
||||
conn.commit()
|
||||
return {"template_id": template_id, "page_id": pid, "status": "applied"}
|
||||
|
||||
|
||||
@router.get("/templates/database")
|
||||
def list_db_templates_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall()
|
||||
return {"templates": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/templates/database/{template_id}/apply")
|
||||
def apply_db_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
tpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (template_id,)).fetchone()
|
||||
if not tpl:
|
||||
raise HTTPException(404, "Template not found")
|
||||
name = (body.get("name") or tpl["name"]).strip()
|
||||
schema = json.loads(tpl["schema_json"] or "[]")
|
||||
cur = conn.execute("INSERT INTO collections (name, description, icon, schema_json, workspace_id, created_by) VALUES (?, ?, ?, ?, ?, ?)", (name, tpl["description"], tpl["icon"] if "icon" in tpl.keys() else "📋", json.dumps(schema), body.get("workspace_id"), user["id"]))
|
||||
cid = cur.lastrowid
|
||||
# A25 : pas de try — un échec de matérialisation doit interrompre la
|
||||
# transaction plutôt que de commiter une collection sans schéma.
|
||||
from app.services.db_templates import materialize_properties
|
||||
materialize_properties(conn, cid, schema)
|
||||
conn.commit()
|
||||
return {"collection_id": cid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/export")
|
||||
def export_page_v2(page_id: int, request: Request, format: str = "markdown", authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
fmt = (format or request.query_params.get("format") or "markdown").lower()
|
||||
if fmt not in ("markdown", "html", "pdf"):
|
||||
raise HTTPException(400, "format must be markdown, html or pdf")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
# try collection_pages
|
||||
row2 = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row2:
|
||||
raise HTTPException(404, "Page not found")
|
||||
# collection pages: return JSON
|
||||
return {"page": row_to_dict(row2), "format": fmt}
|
||||
# block pages: delegate to export service
|
||||
from app.services.export import export_page as _export
|
||||
try:
|
||||
data, mime, fname = _export(row, fmt) # type: ignore
|
||||
return Response(content=data, media_type=mime, headers={"Content-Disposition": f'attachment; filename="{fname}"'})
|
||||
except Exception as e:
|
||||
raise HTTPException(500, f"Export failed: {e}") from None
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/export/csv")
|
||||
def export_csv_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
import csv
|
||||
import io
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
props = [dict(r) for r in conn.execute("SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()]
|
||||
rows = conn.execute("SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)).fetchall()
|
||||
out = io.StringIO()
|
||||
writer = csv.writer(out)
|
||||
header = ["Title"] + [p["name"] for p in props]
|
||||
writer.writerow(header)
|
||||
for r in rows:
|
||||
try:
|
||||
pv = json.loads(r["property_values_json"] or "{}")
|
||||
except Exception:
|
||||
pv = {}
|
||||
vals = [r["title"]]
|
||||
for p in props:
|
||||
vals.append(str(pv.get(str(p["id"])) or pv.get(p["name"]) or ""))
|
||||
writer.writerow(vals)
|
||||
return Response(content=out.getvalue().encode("utf-8"), media_type="text/csv", headers={"Content-Disposition": f'attachment; filename="collection-{collection_id}.csv"'})
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/import/csv")
|
||||
async def import_csv_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
try:
|
||||
form = await request.form()
|
||||
file = form.get("file")
|
||||
data = await file.read() if file else b""
|
||||
text = data.decode("utf-8", errors="ignore")
|
||||
except Exception as err:
|
||||
raise HTTPException(400, "file required (multipart)") from err
|
||||
import csv
|
||||
import io
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
created = 0
|
||||
with get_conn() as conn:
|
||||
for row in reader:
|
||||
title = row.get("Title") or row.get("title") or "Untitled"
|
||||
# map remaining columns to property names
|
||||
pv = {}
|
||||
# resolve prop name -> id
|
||||
props = {p["name"]: p["id"] for p in conn.execute("SELECT id, name FROM collection_properties WHERE collection_id=?", (collection_id,)).fetchall()}
|
||||
for k, v in row.items():
|
||||
if k in ("Title", "title"):
|
||||
continue
|
||||
pid = props.get(k)
|
||||
if pid:
|
||||
pv[str(pid)] = v
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_pages WHERE collection_id=?", (collection_id,)).fetchone()[0]
|
||||
conn.execute("INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?, ?, ?, ?)", (collection_id, title, max_pos, json.dumps(pv)))
|
||||
created += 1
|
||||
conn.commit()
|
||||
return {"imported": created, "status": "ok"}
|
||||
@@ -0,0 +1,164 @@
|
||||
"""FlowDeck — Public API v2 : views.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.automations import run_event_sync
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/views")
|
||||
def create_view_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "New View").strip()
|
||||
vtype = body.get("view_type") or body.get("type") or "table"
|
||||
config = body.get("config") or body.get("config_json") or {}
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone():
|
||||
raise HTTPException(404, "Collection not found")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_views WHERE collection_id=?", (collection_id,)).fetchone()[0]
|
||||
cur = conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position, created_by) VALUES (?, ?, ?, ?, ?, ?)", (collection_id, name, vtype, json.dumps(config), max_pos, user["id"]))
|
||||
vid = cur.lastrowid
|
||||
conn.commit()
|
||||
audit_log(user, "view.create", "view", vid, name, request)
|
||||
try:
|
||||
run_event_sync(_fire_event("collection.view.created", {"view_id": vid, "collection_id": collection_id, "name": name, "view_type": vtype}))
|
||||
except Exception:
|
||||
logger.exception("create_view_v2")
|
||||
return {"id": vid, "name": name, "view_type": vtype, "status": "created"}
|
||||
|
||||
|
||||
@router.patch("/views/{view_id}")
|
||||
def patch_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "View not found")
|
||||
cfg = json.loads(row["config_json"] or "{}")
|
||||
if "config" in body:
|
||||
cfg.update(body["config"])
|
||||
elif "config_json" in body:
|
||||
try:
|
||||
cfg.update(json.loads(body["config_json"]) if isinstance(body["config_json"], str) else body["config_json"])
|
||||
except Exception:
|
||||
logger.exception("patch_view_v2")
|
||||
# also flat keys
|
||||
for k in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property", "cover_mode", "card_properties", "visible_properties", "filters", "sorts", "date_property"):
|
||||
if k in body:
|
||||
cfg[k] = body[k]
|
||||
name = body.get("name", row["name"])
|
||||
vtype = body.get("view_type") or body.get("type") or row["view_type"]
|
||||
conn.execute("UPDATE collection_views SET name=?, view_type=?, config_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (name, vtype, json.dumps(cfg), view_id))
|
||||
conn.commit()
|
||||
audit_log(user, "view.update", "view", view_id, "", request)
|
||||
return {"id": view_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/views/{view_id}")
|
||||
def delete_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM collection_views WHERE id=?", (view_id,)).fetchone():
|
||||
raise HTTPException(404, "View not found")
|
||||
conn.execute("DELETE FROM collection_views WHERE id=?", (view_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "view.delete", "view", view_id, "", request)
|
||||
return {"id": view_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/views/{view_id}/save-as")
|
||||
def save_as_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "").strip() or "Copy"
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "View not found")
|
||||
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1)+1 FROM collection_views WHERE collection_id=?", (row["collection_id"],)).fetchone()[0]
|
||||
cur = conn.execute("INSERT INTO collection_views (collection_id, name, view_type, config_json, position, created_by) VALUES (?, ?, ?, ?, ?, ?)", (row["collection_id"], name, row["view_type"], row["config_json"], max_pos, user["id"]))
|
||||
nid = cur.lastrowid
|
||||
conn.commit()
|
||||
return {"id": nid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/dashboards")
|
||||
def list_dashboards_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM collection_dashboards WHERE collection_id=? ORDER BY created_at", (collection_id,)).fetchall()
|
||||
return {"dashboards": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/dashboards")
|
||||
def create_dashboard_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
name = (body.get("name") or "Dashboard").strip()
|
||||
layout = body.get("layout") or body.get("layout_json") or {"columns": 1, "widgets": []}
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO collection_dashboards (collection_id, name, layout_json) VALUES (?, ?, ?)", (collection_id, name, json.dumps(layout)))
|
||||
did = cur.lastrowid
|
||||
conn.commit()
|
||||
return {"id": did, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.patch("/dashboards/{dashboard_id}")
|
||||
def patch_dashboard_v2(dashboard_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collection_dashboards WHERE id=?", (dashboard_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Dashboard not found")
|
||||
name = body.get("name", row["name"])
|
||||
layout = body.get("layout") or body.get("layout_json")
|
||||
if layout is not None:
|
||||
layout_json = json.dumps(layout)
|
||||
else:
|
||||
layout_json = row["layout_json"]
|
||||
conn.execute("UPDATE collection_dashboards SET name=?, layout_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (name, layout_json, dashboard_id))
|
||||
conn.commit()
|
||||
return {"id": dashboard_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/dashboards/{dashboard_id}")
|
||||
def delete_dashboard_v2(dashboard_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM collection_dashboards WHERE id=?", (dashboard_id,))
|
||||
conn.commit()
|
||||
return {"id": dashboard_id, "status": "deleted"}
|
||||
@@ -0,0 +1,195 @@
|
||||
"""FlowDeck — Public API v2 : webhooks.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.get("/webhooks")
|
||||
def list_webhooks_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) AS n FROM webhook_subscriptions").fetchone()["n"]
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM webhook_subscriptions ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
).fetchall()
|
||||
return JSONResponse(
|
||||
content={"webhooks": [dict(r) for r in rows]},
|
||||
headers=paginate_headers(total),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/webhooks")
|
||||
def create_webhook_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
from app.services.webhook_outbound import EVENTS, _event_matches
|
||||
url = (body.get("url") or "").strip()
|
||||
event = (body.get("event") or "page.created").strip()
|
||||
secret = (body.get("secret") or "").strip()
|
||||
if not url or not url.startswith("http"):
|
||||
raise HTTPException(400, "url must start with http")
|
||||
if not event or (event not in EVENTS and not (event.endswith(".*") or event in ("*", "all"))):
|
||||
raise HTTPException(400, f"Unknown event '{event}'. See GET /api/v2/webhooks/events")
|
||||
# make sure the pattern matches at least one known event
|
||||
if not any(_event_matches(event, e) for e in EVENTS):
|
||||
raise HTTPException(400, f"Event pattern '{event}' matches no known event")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO webhook_subscriptions (url, event, secret) VALUES (?, ?, ?)", (url, event, secret))
|
||||
wid = cur.lastrowid
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (wid,)).fetchone()
|
||||
audit_log(user, "webhook.create", "webhook", wid, url, request)
|
||||
return {"id": wid, "status": "created", "webhook": dict(row) if row else {},
|
||||
"signature_header": "X-FlowDeck-Signature (HMAC-SHA256, sha256=<hex>)" if secret else None}
|
||||
|
||||
|
||||
@router.patch("/webhooks/{webhook_id}")
|
||||
def patch_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (webhook_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Webhook not found")
|
||||
url = body.get("url", row["url"])
|
||||
event = body.get("event", row["event"])
|
||||
secret = body.get("secret", row["secret"])
|
||||
active = int(bool(body.get("active", row["active"])))
|
||||
conn.execute("UPDATE webhook_subscriptions SET url=?, event=?, secret=?, active=? WHERE id=?", (url, event, secret, active, webhook_id))
|
||||
conn.commit()
|
||||
audit_log(user, "webhook.update", "webhook", webhook_id, "", request)
|
||||
return {"id": webhook_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/webhooks/{webhook_id}")
|
||||
def delete_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (webhook_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "webhook.delete", "webhook", webhook_id, "", request)
|
||||
return {"id": webhook_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/webhooks/{webhook_id}/test")
|
||||
async def test_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (webhook_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Webhook not found")
|
||||
# live delivery via the prod dispatcher (HMAC + retry + journal),
|
||||
# direct to this subscription only (no wildcard fan-out)
|
||||
from app.services.webhook_outbound import deliver_to_sub
|
||||
ok = await deliver_to_sub(webhook_id, row["url"], "ping",
|
||||
{"webhook_id": webhook_id, "test": True},
|
||||
row["secret"] or "")
|
||||
audit_log(user, "webhook.test", "webhook", webhook_id, f"ok={ok}", request)
|
||||
return {"webhook_id": webhook_id, "status": "tested", "delivered": ok}
|
||||
|
||||
|
||||
@router.get("/webhooks/{webhook_id}/deliveries")
|
||||
def list_deliveries_v2(webhook_id: int, request: Request,
|
||||
status: str | None = None,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
if status:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM webhook_deliveries WHERE webhook_id=? AND status=?",
|
||||
(webhook_id, status)).fetchone()["n"]
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM webhook_deliveries WHERE webhook_id=? AND status=? "
|
||||
"ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
(webhook_id, status, limit, offset)).fetchall()
|
||||
else:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM webhook_deliveries WHERE webhook_id=?",
|
||||
(webhook_id,)).fetchone()["n"]
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM webhook_deliveries WHERE webhook_id=? "
|
||||
"ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||
(webhook_id, limit, offset)).fetchall()
|
||||
return JSONResponse(
|
||||
content={"deliveries": [row_to_dict(r) for r in rows]},
|
||||
headers=paginate_headers(total),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/webhooks/{webhook_id}/retry")
|
||||
async def retry_webhook_deliveries(webhook_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Manually retry failed deliveries for a webhook."""
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
|
||||
from app.services.webhook_outbound import retry_due_deliveries
|
||||
|
||||
with get_conn() as conn:
|
||||
# Force retry by setting next_retry_at to the past
|
||||
conn.execute(
|
||||
"""UPDATE webhook_deliveries
|
||||
SET next_retry_at = strftime('%s', 'now', '-1 second')
|
||||
WHERE webhook_id = ? AND status = 'retrying'""",
|
||||
(webhook_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
retried = await retry_due_deliveries()
|
||||
audit_log(user, "webhook.retry", "webhook", webhook_id, f"retried={retried}", request)
|
||||
return {"webhook_id": webhook_id, "status": "retried", "retried_count": retried}
|
||||
|
||||
|
||||
@router.post("/webhooks/verify-signature")
|
||||
def verify_webhook_signature(request: Request,
|
||||
authorization: str | None = Header(default=None),
|
||||
body: dict = Body(default={})):
|
||||
"""Verify a webhook signature (for debugging/testing)."""
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
|
||||
from app.services.webhook_outbound import verify_signature
|
||||
|
||||
secret = body.get("secret", "")
|
||||
payload = body.get("payload", "{}")
|
||||
signature = body.get("signature", "")
|
||||
|
||||
is_valid = verify_signature(secret, payload.encode(), signature)
|
||||
|
||||
audit_log(user, "webhook.signature_verify", "webhook", 0, f"valid={is_valid}", request)
|
||||
return {"valid": is_valid, "secret": secret[:10] + "..." if len(secret) > 10 else secret}
|
||||
@@ -0,0 +1,230 @@
|
||||
"""FlowDeck — Public API v2 : workspaces.
|
||||
|
||||
Découpe A28 de l'ancien app/routers/api_v2.py (2 110 lignes, 115 routes) — un module par concern, contrat inchangé (Bearer+scopes, pagination, RFC7807, audit + idempotency).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Body, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import ( # noqa: F401 — require_scope est utilisé par les handlers
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
require_scope,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
to_iso8601,
|
||||
validate_scopes_input,
|
||||
)
|
||||
|
||||
from ._common import _v2_rate_check
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["api-v2"])
|
||||
|
||||
|
||||
|
||||
@router.get("/workspaces")
|
||||
def list_workspaces(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM workspaces WHERE owner_id=? OR id IN (SELECT workspace_id FROM workspace_members WHERE user_id=?)", (user["id"], user["id"])).fetchone()[0]
|
||||
rows = conn.execute("SELECT w.*, wm.role FROM workspaces w LEFT JOIN workspace_members wm ON wm.workspace_id=w.id AND wm.user_id=? WHERE w.owner_id=? OR w.id IN (SELECT workspace_id FROM workspace_members WHERE user_id=?) ORDER BY w.created_at DESC LIMIT ? OFFSET ?", (user["id"], user["id"], user["id"], limit, offset)).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
d["created_at"] = to_iso8601(d.get("created_at"))
|
||||
try:
|
||||
d["settings"] = json.loads(d.get("settings_json") or "{}")
|
||||
except Exception:
|
||||
d["settings"] = {}
|
||||
out.append(d)
|
||||
return {"workspaces": out, "total": total, "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.post("/workspaces")
|
||||
def create_workspace(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
settings_json = json.dumps(body.get("settings") or body.get("settings_json") or {})
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, ?)", (name, user["id"], settings_json))
|
||||
wid = cur.lastrowid
|
||||
# owner is implicitly admin member
|
||||
try:
|
||||
conn.execute("INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')", (wid, user["id"]))
|
||||
except Exception:
|
||||
logger.exception("create_workspace")
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (wid,)).fetchone()
|
||||
audit_log(user, "workspace.create", "workspace", wid, name, request)
|
||||
data = {"id": wid, "name": name, "owner_id": user["id"], "status": "created", "workspace": row_to_dict(row)}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 200)
|
||||
return JSONResponse(content=data, status_code=201)
|
||||
|
||||
|
||||
@router.get("/workspaces/{workspace_id}")
|
||||
def get_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
# ACL: must be member or owner
|
||||
member = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
|
||||
is_owner = row["owner_id"] == user["id"]
|
||||
if not is_owner and not member and not user.get("is_admin"):
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
members = conn.execute("SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role FROM workspace_members wm JOIN users u ON u.id=wm.user_id WHERE wm.workspace_id=? ORDER BY wm.joined_at", (workspace_id,)).fetchall()
|
||||
d = row_to_dict(row)
|
||||
d["members"] = [dict(m) for m in members]
|
||||
return d
|
||||
|
||||
|
||||
@router.patch("/workspaces/{workspace_id}")
|
||||
def patch_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
if row["owner_id"] != user["id"] and not user.get("is_admin"):
|
||||
# check admin member
|
||||
mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
|
||||
if not mem or mem["role"] not in ("owner", "admin"):
|
||||
raise HTTPException(403, "Only owner/admin can edit workspace")
|
||||
name = body.get("name", row["name"])
|
||||
sj = body.get("settings_json") or body.get("settings")
|
||||
if sj is not None:
|
||||
sj = json.dumps(sj) if isinstance(sj, (dict, list)) else str(sj)
|
||||
else:
|
||||
sj = row["settings_json"]
|
||||
conn.execute("UPDATE workspaces SET name=?, settings_json=? WHERE id=?", (name, sj, workspace_id))
|
||||
conn.commit()
|
||||
audit_log(user, "workspace.update", "workspace", workspace_id, "", request)
|
||||
return {"id": workspace_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/workspaces/{workspace_id}")
|
||||
def delete_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
if row["owner_id"] != user["id"] and not user.get("is_admin"):
|
||||
raise HTTPException(403, "Only owner can delete workspace")
|
||||
conn.execute("DELETE FROM workspaces WHERE id=?", (workspace_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "workspace.delete", "workspace", workspace_id, "", request)
|
||||
return {"id": workspace_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/workspaces/{workspace_id}/members")
|
||||
def list_workspace_members(workspace_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = get_bearer_user(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute("SELECT id FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not ws:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
rows = conn.execute("SELECT u.id, u.login, u.full_name, u.avatar_url, wm.role, wm.joined_at FROM workspace_members wm JOIN users u ON u.id=wm.user_id WHERE wm.workspace_id=? ORDER BY wm.joined_at", (workspace_id,)).fetchall()
|
||||
return {"members": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/workspaces/{workspace_id}/members")
|
||||
def invite_member(workspace_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
target_id = body.get("user_id") or body.get("uid")
|
||||
email = (body.get("email") or "").strip()
|
||||
role = (body.get("role") or "editor").strip().lower()
|
||||
if role not in ("owner", "admin", "editor", "viewer", "commenter"):
|
||||
raise HTTPException(400, "Invalid role")
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not ws:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
# only owner/admin can invite
|
||||
if ws["owner_id"] != user["id"] and not user.get("is_admin"):
|
||||
mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
|
||||
if not mem or mem["role"] not in ("owner", "admin"):
|
||||
raise HTTPException(403, "Only owner/admin can invite")
|
||||
uid = target_id
|
||||
if not uid and email:
|
||||
u = conn.execute("SELECT id FROM users WHERE email=?", (email,)).fetchone()
|
||||
if not u:
|
||||
raise HTTPException(404, f"User with email {email} not found")
|
||||
uid = u["id"]
|
||||
if not uid:
|
||||
raise HTTPException(400, "user_id or email required")
|
||||
try:
|
||||
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, ?)", (workspace_id, uid, role))
|
||||
except Exception:
|
||||
conn.execute("UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?", (role, workspace_id, uid))
|
||||
conn.commit()
|
||||
audit_log(user, "workspace.invite", "workspace", workspace_id, f"uid={uid} role={role}", request)
|
||||
return {"workspace_id": workspace_id, "user_id": uid, "role": role, "status": "added"}
|
||||
|
||||
|
||||
@router.patch("/workspaces/{workspace_id}/members/{uid}")
|
||||
def update_member_role(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
role = (body.get("role") or "").strip().lower()
|
||||
if role not in ("owner", "admin", "editor", "viewer", "commenter"):
|
||||
raise HTTPException(400, "Invalid role")
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not ws:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
if ws["owner_id"] != user["id"] and not user.get("is_admin"):
|
||||
mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
|
||||
if not mem or mem["role"] not in ("owner", "admin"):
|
||||
raise HTTPException(403, "Only owner/admin can change roles")
|
||||
conn.execute("UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?", (role, workspace_id, uid))
|
||||
if conn.total_changes == 0:
|
||||
raise HTTPException(404, "Member not found")
|
||||
conn.commit()
|
||||
audit_log(user, "workspace.role_change", "workspace", workspace_id, f"uid={uid} role={role}", request)
|
||||
return {"workspace_id": workspace_id, "user_id": uid, "role": role, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/workspaces/{workspace_id}/members/{uid}")
|
||||
def remove_member(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = require_scope("write")(request, authorization)
|
||||
_v2_rate_check(request, user)
|
||||
with get_conn() as conn:
|
||||
ws = conn.execute("SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if not ws:
|
||||
raise HTTPException(404, "Workspace not found")
|
||||
if ws["owner_id"] != user["id"] and not user.get("is_admin"):
|
||||
mem = conn.execute("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user["id"])).fetchone()
|
||||
if not mem or mem["role"] not in ("owner", "admin"):
|
||||
raise HTTPException(403, "Only owner/admin can remove members")
|
||||
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, uid))
|
||||
conn.commit()
|
||||
audit_log(user, "workspace.remove_member", "workspace", workspace_id, f"uid={uid}", request)
|
||||
return {"workspace_id": workspace_id, "user_id": uid, "status": "removed"}
|
||||
+8
-24
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Query, Request
|
||||
from fastapi import APIRouter, Body, Query, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -220,15 +220,11 @@ def login(request: Request, provider: str = Query("gitea")):
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(request: Request):
|
||||
def register(request: Request, body: dict = Body(default={})):
|
||||
"""Register a new local account."""
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import hash_password
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
email = body.get("email", "").strip()
|
||||
password = body.get("password", "").strip()
|
||||
name = body.get("name", email.split("@")[0] if "@" in email else email)
|
||||
@@ -277,7 +273,7 @@ async def register(request: Request):
|
||||
|
||||
|
||||
@router.post("/local-login")
|
||||
async def local_login(request: Request):
|
||||
def local_login(request: Request, body: dict = Body(default={})):
|
||||
"""Login with email + password."""
|
||||
import time
|
||||
|
||||
@@ -285,10 +281,6 @@ async def local_login(request: Request):
|
||||
|
||||
from app.db import get_conn
|
||||
from app.password_utils import is_locked, verify_password
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
email = body.get("email", "").strip()
|
||||
password = body.get("password", "").strip()
|
||||
|
||||
@@ -410,7 +402,7 @@ async def callback(
|
||||
oauth_mode = request.session.pop("oauth_mode", "")
|
||||
if oauth_mode == "link":
|
||||
from app.auth.session import get_current_user as gcu
|
||||
current = await gcu(request)
|
||||
current = gcu(request)
|
||||
if not current:
|
||||
return HTMLResponse("<h1>Not logged in — please log in first</h1>", status_code=400)
|
||||
from app.db import get_conn as _gc
|
||||
@@ -480,10 +472,10 @@ def logout(request: Request):
|
||||
|
||||
|
||||
@router.get("/user")
|
||||
async def current_user(request: Request):
|
||||
def current_user(request: Request):
|
||||
"""Return current user info as JSON."""
|
||||
from app.auth.session import get_current_user as gcu
|
||||
user = await gcu(request)
|
||||
user = gcu(request)
|
||||
if not user:
|
||||
return {"authenticated": False}
|
||||
return {"authenticated": True, "user": user}
|
||||
@@ -492,16 +484,12 @@ async def current_user(request: Request):
|
||||
# ── v7.2.0 — TOTP 2FA ─────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/local-verify")
|
||||
async def local_verify(request: Request):
|
||||
def local_verify(request: Request, body: dict = Body(default={})):
|
||||
"""Exchange a 2FA ``pending`` token + TOTP/backup code for a session."""
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import two_factor as _2fa
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
user_id = _2fa.redeem_pending(body.get("pending", ""))
|
||||
if not user_id:
|
||||
return JSONResponse({"error": "Challenge expired — log in again"}, status_code=401)
|
||||
@@ -544,15 +532,11 @@ def twofa_setup(request: Request):
|
||||
|
||||
|
||||
@router.post("/2fa/activate")
|
||||
async def twofa_activate(request: Request):
|
||||
def twofa_activate(request: Request, body: dict = Body(default={})):
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.services import two_factor as _2fa
|
||||
user = _session_user_or_401(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
try:
|
||||
codes = _2fa.activate_secret(user["id"], body.get("secret", ""),
|
||||
body.get("code", ""))
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -79,8 +79,7 @@ def list_automations(request: Request):
|
||||
|
||||
|
||||
@router.post("/workspace/automations")
|
||||
async def create_automation(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_automation(request: Request, body: dict = Body(default={})):
|
||||
_validate_payload(body)
|
||||
user = _current_user(request)
|
||||
by = user["id"]
|
||||
@@ -118,8 +117,7 @@ def get_automation(request: Request, auto_id: int):
|
||||
|
||||
|
||||
@router.put("/workspace/automations/{auto_id}")
|
||||
async def update_automation(request: Request, auto_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def update_automation(request: Request, auto_id: int, body: dict = Body(default={})):
|
||||
_validate_payload(body)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM automations WHERE id=?", (auto_id,)).fetchone()
|
||||
@@ -233,11 +231,10 @@ def list_steps(request: Request, auto_id: int):
|
||||
|
||||
|
||||
@router.post("/workspace/automations/{auto_id}/steps")
|
||||
async def create_step(request: Request, auto_id: int):
|
||||
def create_step(request: Request, auto_id: int, body: dict = Body(default={})):
|
||||
_require_session(request)
|
||||
if _get_auto(auto_id) is None:
|
||||
return _auto_404()
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
kind = body.get("kind", "")
|
||||
config = body.get("config", {}) or {}
|
||||
validate_step(kind, config)
|
||||
@@ -256,9 +253,8 @@ async def create_step(request: Request, auto_id: int):
|
||||
|
||||
|
||||
@router.put("/workspace/automations/steps/{step_id}")
|
||||
async def update_step(request: Request, step_id: int):
|
||||
def update_step(request: Request, step_id: int, body: dict = Body(default={})):
|
||||
_require_session(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM automation_steps WHERE id=?", (step_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -288,12 +284,11 @@ def delete_step(request: Request, step_id: int):
|
||||
|
||||
|
||||
@router.put("/workspace/automations/{auto_id}/mode")
|
||||
async def set_trigger_mode(request: Request, auto_id: int):
|
||||
def set_trigger_mode(request: Request, auto_id: int, body: dict = Body(default={})):
|
||||
"""Set multi-trigger mode: any (default) or all (5-minute window)."""
|
||||
_require_session(request)
|
||||
if _get_auto(auto_id) is None:
|
||||
return _auto_404()
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
mode = (body.get("mode") or "any").lower()
|
||||
if mode not in ("any", "all"):
|
||||
raise HTTPException(status_code=400, detail="mode must be any or all")
|
||||
|
||||
+47
-85
@@ -6,7 +6,7 @@ import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -14,8 +14,9 @@ from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.routers.dashboard import _get_app_version
|
||||
from app.routers.sidebar_config import get_sidebar_config_sync
|
||||
from app.services.automations import fire_event
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.gitea_client import gitea
|
||||
from app.services.http_client import shared_client
|
||||
from app.services.permission_manager import PermissionManager
|
||||
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||
|
||||
@@ -124,16 +125,12 @@ def wiki_titles(request: Request, ids: str = Query(default="")):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/lock")
|
||||
async def set_page_lock(request: Request, page_id: int):
|
||||
def set_page_lock(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""v5.12.0: lock/unlock a page (read-only for everyone except the locker,
|
||||
admins and the page creator)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
locked = bool(body.get("locked"))
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, is_locked, locked_by, workspace FROM pages WHERE id=?",
|
||||
@@ -146,21 +143,17 @@ async def set_page_lock(request: Request, page_id: int):
|
||||
conn.execute("UPDATE pages SET is_locked=?, locked_by=? WHERE id=?",
|
||||
(1 if locked else 0, user["id"] if locked else None, page_id))
|
||||
conn.commit()
|
||||
await fire_event("page.locked" if locked else "page.unlocked",
|
||||
{"page_id": page_id, "by": user["id"]})
|
||||
run_event_sync(fire_event("page.locked" if locked else "page.unlocked",
|
||||
{"page_id": page_id, "by": user["id"]}))
|
||||
return {"status": "ok", "is_locked": int(locked)}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/options")
|
||||
async def set_page_options(request: Request, page_id: int):
|
||||
def set_page_options(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""v5.12.0: page layout options — full-width and compact typography."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
updates = {}
|
||||
for key in ("full_width", "font_small"):
|
||||
if key in body:
|
||||
@@ -199,16 +192,12 @@ def list_page_templates_api(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/page-templates")
|
||||
async def create_page_template(request: Request):
|
||||
def create_page_template(request: Request, body: dict = Body(default={})):
|
||||
"""v5.12.0: save the current page (or a raw block list) as a personal
|
||||
global template: {name, icon?, description?, page_id? | blocks?}."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
@@ -239,17 +228,13 @@ async def create_page_template(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/page-templates/{template_id}/use")
|
||||
async def use_page_template(request: Request, template_id: int):
|
||||
def use_page_template(request: Request, template_id: int, body: dict = Body(default={})):
|
||||
"""v5.12.0: instantiate a page from a template (built-in or user).
|
||||
|
||||
Body: {key?} for built-ins OR uses the row id for user templates.
|
||||
Creates 'blocks'-format page in the caller's workspace and returns its id.
|
||||
"""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = (body.get("title") or "").strip()
|
||||
blocks_json = None
|
||||
if template_id == 0:
|
||||
@@ -331,8 +316,8 @@ async def use_page_template(request: Request, template_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
await fire_event("page.created", {"page_id": page_id, "title": title or name,
|
||||
"workspace": ws_key, "from_template": name})
|
||||
run_event_sync(fire_event("page.created", {"page_id": page_id, "title": title or name,
|
||||
"workspace": ws_key, "from_template": name}))
|
||||
return {"status": "ok", "id": page_id, "title": title or name}
|
||||
|
||||
|
||||
@@ -955,7 +940,7 @@ def list_favorites(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/favorites/{page_id:int}")
|
||||
async def add_favorite(request: Request, page_id: int):
|
||||
def add_favorite(request: Request, page_id: int):
|
||||
"""Add a page to favorites."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
@@ -973,14 +958,14 @@ async def add_favorite(request: Request, page_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("favorite.added", {"page_id": page_id, "user_id": uid})
|
||||
run_event_sync(fire_event("favorite.added", {"page_id": page_id, "user_id": uid}))
|
||||
except Exception:
|
||||
logger.exception("add_favorite")
|
||||
return {"status": "added", "page_id": page_id}
|
||||
|
||||
|
||||
@router.delete("/api/favorites/{page_id:int}")
|
||||
async def remove_favorite(request: Request, page_id: int):
|
||||
def remove_favorite(request: Request, page_id: int):
|
||||
"""Remove a page from favorites."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
@@ -988,7 +973,7 @@ async def remove_favorite(request: Request, page_id: int):
|
||||
conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id))
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("favorite.removed", {"page_id": page_id, "user_id": uid})
|
||||
run_event_sync(fire_event("favorite.removed", {"page_id": page_id, "user_id": uid}))
|
||||
except Exception:
|
||||
logger.exception("remove_favorite")
|
||||
return {"status": "removed", "page_id": page_id}
|
||||
@@ -996,9 +981,8 @@ async def remove_favorite(request: Request, page_id: int):
|
||||
# ═══════════ Share API ═══════════
|
||||
|
||||
@router.post("/api/share/{page_id:int}")
|
||||
async def update_share(request: Request, page_id: int):
|
||||
def update_share(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Save share settings for a page."""
|
||||
body = await request.json()
|
||||
mode = body.get("mode", "private")
|
||||
published = body.get("published", False)
|
||||
with get_conn() as conn:
|
||||
@@ -1011,22 +995,22 @@ async def update_share(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/publish")
|
||||
async def publish_page(request: Request, page_id: int):
|
||||
def publish_page(request: Request, page_id: int):
|
||||
"""Publish a page to the web (generates publish_slug)."""
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
slug, title = publish(page_id)
|
||||
await fire_published(page_id, slug)
|
||||
run_event_sync(fire_published(page_id, slug))
|
||||
return {"is_published": True, "publish_slug": slug, "title": title}
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id:int}/publish")
|
||||
async def unpublish_page(request: Request, page_id: int):
|
||||
def unpublish_page(request: Request, page_id: int):
|
||||
"""Unpublish a page from the web."""
|
||||
if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
unpublish(page_id)
|
||||
await fire_unpublished(page_id)
|
||||
run_event_sync(fire_unpublished(page_id))
|
||||
return {"is_published": False}
|
||||
|
||||
|
||||
@@ -1040,12 +1024,12 @@ def list_trash(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/trash/{page_id}/restore")
|
||||
async def restore_page(request: Request, page_id: int):
|
||||
def restore_page(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("page.restored", {"page_id": page_id})
|
||||
run_event_sync(fire_event("page.restored", {"page_id": page_id}))
|
||||
except Exception:
|
||||
logger.exception("restore_page")
|
||||
return {"status": "ok", "restored": page_id}
|
||||
@@ -1080,12 +1064,8 @@ def list_synced_blocks_api(request: Request, workspace: str = Query(default=""))
|
||||
|
||||
|
||||
@router.post("/api/synced-blocks")
|
||||
async def create_synced_block_api(request: Request):
|
||||
def create_synced_block_api(request: Request, body: dict = Body(...)):
|
||||
"""Create a new synced block."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
from app.services.synced_blocks import create_synced_block
|
||||
sid = create_synced_block(
|
||||
@@ -1157,12 +1137,8 @@ def get_synced_block_api(sid: int):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/synced")
|
||||
async def add_synced_to_page(request: Request, page_id: int):
|
||||
def add_synced_to_page(request: Request, page_id: int, body: dict = Body(...)):
|
||||
"""Add a synced block reference to a page."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
from app.services.synced_blocks import add_page_synced, get_synced_block
|
||||
sid = body.get("synced_block_id")
|
||||
sb = get_synced_block(sid)
|
||||
@@ -1357,7 +1333,7 @@ async def extract_ai_keywords(owner: str, repo: str):
|
||||
# ═══════════ Pages Markdown ═══════════
|
||||
|
||||
@router.post("/api/pages")
|
||||
async def create_page(request: Request, title: str = Query(default=""),
|
||||
def create_page(request: Request, title: str = Query(default=""),
|
||||
section: str = Query(default="Private"),
|
||||
project: str = Query(default=""),
|
||||
parent_id: int = Query(default=0)):
|
||||
@@ -1385,8 +1361,8 @@ async def create_page(request: Request, title: str = Query(default=""),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
await fire_event("page.created", {"page_id": page_id, "title": page_title,
|
||||
"workspace": ws_key, "parent_id": parent_id})
|
||||
run_event_sync(fire_event("page.created", {"page_id": page_id, "title": page_title,
|
||||
"workspace": ws_key, "parent_id": parent_id}))
|
||||
return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id}
|
||||
except Exception as e:
|
||||
logger.error("create_page failed: %s", e)
|
||||
@@ -1411,7 +1387,7 @@ def get_page(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id}")
|
||||
async def update_page(request: Request, page_id: int, title: str = Query(default=""),
|
||||
def update_page(request: Request, page_id: int, title: str = Query(default=""),
|
||||
content: str = Query(default=""),
|
||||
content_format: str = Query(default="")):
|
||||
"""Update a page's title and/or content. Accepts JSON body for blocks."""
|
||||
@@ -1437,24 +1413,20 @@ async def update_page(request: Request, page_id: int, title: str = Query(default
|
||||
if content_format:
|
||||
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": title,
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title,
|
||||
"content_format": content_format or "markdown",
|
||||
"actor_id": user.get("id")})
|
||||
"actor_id": user.get("id")}))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/blocks")
|
||||
async def save_page_blocks(request: Request, page_id: int):
|
||||
def save_page_blocks(request: Request, page_id: int, body: dict = Body(...)):
|
||||
"""Save blocks JSON content (Notion-style block editor).
|
||||
|
||||
v5.4.0: a version snapshot is recorded (if the block content actually
|
||||
changed) so the UI can browse the version history and restore any of them.
|
||||
v5.14.0: synced block references are tracked in page_synced_blocks.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
blocks = body.get("blocks", [])
|
||||
blocks_json = json.dumps(blocks)
|
||||
title = body.get("title", "")
|
||||
@@ -1504,9 +1476,9 @@ async def save_page_blocks(request: Request, page_id: int):
|
||||
(page_id, sid),
|
||||
)
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": title or "",
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title or "",
|
||||
"content_format": "blocks",
|
||||
"actor_id": uid})
|
||||
"actor_id": uid}))
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
@@ -1605,7 +1577,7 @@ def page_versions(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/versions/{version_id}/restore")
|
||||
async def restore_version(request: Request, page_id: int, version_id: int):
|
||||
def restore_version(request: Request, page_id: int, version_id: int):
|
||||
"""v5.4.0: restore a page from a version snapshot."""
|
||||
with get_conn() as conn:
|
||||
ver = conn.execute(
|
||||
@@ -1619,8 +1591,8 @@ async def restore_version(request: Request, page_id: int, version_id: int):
|
||||
(ver["blocks_json"], ver["title"] or "", page_id),
|
||||
)
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "",
|
||||
"content_format": "blocks"})
|
||||
run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "",
|
||||
"content_format": "blocks"}))
|
||||
return {"status": "ok", "restored": version_id}
|
||||
|
||||
|
||||
@@ -1628,7 +1600,7 @@ async def restore_version(request: Request, page_id: int, version_id: int):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/duplicate")
|
||||
async def duplicate_page(request: Request, page_id: int):
|
||||
def duplicate_page(request: Request, page_id: int):
|
||||
"""Duplicate a page (block/markdown content included) as a sibling."""
|
||||
SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
with get_conn() as conn:
|
||||
@@ -1664,8 +1636,8 @@ async def duplicate_page(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET title=? WHERE id=?", (title, new_id))
|
||||
conn.commit()
|
||||
await fire_event("page.created", {"page_id": new_id, "title": title,
|
||||
"workspace": page.get("workspace")})
|
||||
run_event_sync(fire_event("page.created", {"page_id": new_id, "title": title,
|
||||
"workspace": page.get("workspace")}))
|
||||
return {"status": "ok", "id": new_id, "title": title}
|
||||
|
||||
|
||||
@@ -1758,9 +1730,8 @@ def remove_page_cover(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/icon")
|
||||
async def set_page_icon(request: Request, page_id: int):
|
||||
def set_page_icon(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""v5.5.0: set a page emoji/icon label (or a custom-emoji image URL)."""
|
||||
body = await request.json()
|
||||
icon = (body.get("icon") or "").strip()
|
||||
if len(icon) > 512:
|
||||
raise HTTPException(400, "icon too long")
|
||||
@@ -1910,8 +1881,7 @@ async def _unfurl_repo(forge: str, owner: str, repo: str):
|
||||
if token:
|
||||
info = await GitHubAdapter(access_token=token).get_repo_info(owner, repo)
|
||||
else:
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
r = await client.get(
|
||||
f"https://api.github.com/repos/{owner}/{repo}",
|
||||
headers={"Accept": "application/vnd.github+json"},
|
||||
@@ -1935,16 +1905,12 @@ async def _unfurl_repo(forge: str, owner: str, repo: str):
|
||||
|
||||
|
||||
@router.post("/api/embed/resolve")
|
||||
async def resolve_embed(request: Request):
|
||||
def resolve_embed(request: Request, body: dict = Body(...)):
|
||||
"""v5.5.0: rewrite a pasted URL to its provider embed src.
|
||||
|
||||
Powers the universal ``/embed`` block (YouTube, Vimeo, Figma, Maps,
|
||||
Docs, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter…).
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
url = (body.get("url") or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(400, "url required")
|
||||
@@ -1955,7 +1921,7 @@ async def resolve_embed(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id}/move")
|
||||
async def move_page(request: Request, page_id: int):
|
||||
def move_page(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Move a page to another workspace or reorder within tree.
|
||||
|
||||
Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int }
|
||||
@@ -1963,10 +1929,6 @@ async def move_page(request: Request, page_id: int):
|
||||
- parent_id: change parent (0 = root level)
|
||||
- new_order: position among siblings (0 = append)
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
new_ws_id = body.get("workspace_id")
|
||||
new_parent_id = body.get("parent_id", 0)
|
||||
new_order = body.get("new_order", 0)
|
||||
@@ -1997,13 +1959,13 @@ async def move_page(request: Request, page_id: int):
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
await fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0,
|
||||
"parent_id": new_parent_id})
|
||||
run_event_sync(fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0,
|
||||
"parent_id": new_parent_id}))
|
||||
return {"status": "ok", "id": page_id}
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}")
|
||||
async def delete_page(request: Request, page_id: int):
|
||||
def delete_page(request: Request, page_id: int):
|
||||
"""Move a page to trash (soft delete)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
@@ -2019,7 +1981,7 @@ async def delete_page(request: Request, page_id: int):
|
||||
import datetime
|
||||
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,))
|
||||
conn.commit()
|
||||
await fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})
|
||||
run_event_sync(fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""}))
|
||||
return {"status": "ok", "deleted": page_id, "title": row["title"]}
|
||||
|
||||
|
||||
|
||||
@@ -8,12 +8,13 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services import notifications as notif
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.automations import run_event_sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collaboration"], prefix="/api")
|
||||
@@ -70,10 +71,9 @@ def list_comments(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/comments")
|
||||
async def add_comment(request: Request, page_id: int):
|
||||
def add_comment(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Create a page or inline comment. Mentions (@login) notify users."""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
text = (body.get("body") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "body required")
|
||||
@@ -124,10 +124,10 @@ async def add_comment(request: Request, page_id: int):
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("comment.added", {"comment_id": comment_id, "page_id": page_id, "user_id": uid})
|
||||
run_event_sync(_fire_event("comment.added", {"comment_id": comment_id, "page_id": page_id, "user_id": uid}))
|
||||
mentioned_ids = notif.extract_mentions(text)
|
||||
if mentioned_ids:
|
||||
await _fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)})
|
||||
run_event_sync(_fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)}))
|
||||
except Exception:
|
||||
logger.exception("add_comment")
|
||||
|
||||
@@ -135,14 +135,13 @@ async def add_comment(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/mentions")
|
||||
async def notify_page_mentions(request: Request, page_id: int):
|
||||
def notify_page_mentions(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""Notify users @-mentioned in a page's content (called on save).
|
||||
|
||||
Accepts {"text": "..."} containing @login handles. Deduplicated server-side
|
||||
against a per-page cache so repeated auto-saves don't spam notifications.
|
||||
"""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
text = body.get("text") or ""
|
||||
with get_conn() as conn:
|
||||
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
@@ -157,17 +156,16 @@ async def notify_page_mentions(request: Request, page_id: int):
|
||||
conn.commit()
|
||||
if mentioned:
|
||||
try:
|
||||
await _fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)})
|
||||
run_event_sync(_fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)}))
|
||||
except Exception:
|
||||
logger.exception("notify_page_mentions")
|
||||
return {"mentioned": mentioned}
|
||||
|
||||
|
||||
@router.put("/comments/{comment_id}")
|
||||
async def update_comment(request: Request, comment_id: int):
|
||||
def update_comment(request: Request, comment_id: int, body: dict = Body(default={})):
|
||||
"""Update a comment body or resolve/unresolve it."""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM comments WHERE id=?", (comment_id,)
|
||||
@@ -188,7 +186,7 @@ async def update_comment(request: Request, comment_id: int):
|
||||
conn.commit()
|
||||
if body.get("resolved") and not was_resolved:
|
||||
try:
|
||||
await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
|
||||
run_event_sync(_fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}))
|
||||
except Exception:
|
||||
logger.exception("update_comment")
|
||||
return {"id": comment_id, "status": "updated"}
|
||||
|
||||
+55
-143
@@ -6,12 +6,12 @@ import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
from app.services.db_templates import materialize_properties
|
||||
from app.services.permission_manager import PermissionManager
|
||||
from app.services.property_types import (
|
||||
@@ -222,12 +222,8 @@ def list_collections_api(request: Request):
|
||||
|
||||
|
||||
@router.post("/api")
|
||||
async def create_collection_api(request: Request):
|
||||
def create_collection_api(request: Request, body: dict = Body(default={})):
|
||||
"""API: create a new collection, optionally from a database template."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
@@ -277,7 +273,7 @@ async def create_collection_api(request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
await fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon})
|
||||
run_event_sync(fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon}))
|
||||
return {"id": collection_id, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@@ -285,12 +281,8 @@ async def create_collection_api(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/{collection_id}")
|
||||
async def update_collection_api(request: Request, collection_id: int):
|
||||
def update_collection_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: update a collection."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -316,12 +308,12 @@ async def update_collection_api(request: Request, collection_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
await fire_event("collection.updated", {"collection_id": collection_id, "name": name})
|
||||
run_event_sync(fire_event("collection.updated", {"collection_id": collection_id, "name": name}))
|
||||
return {"id": collection_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/api/{collection_id}")
|
||||
async def delete_collection_api(request: Request, collection_id: int):
|
||||
def delete_collection_api(request: Request, collection_id: int):
|
||||
"""API: delete a collection and its pages (CASCADE)."""
|
||||
# v6.0.0: granular collection permissions — owner/admin only.
|
||||
user = _session_user(request)
|
||||
@@ -340,13 +332,13 @@ async def delete_collection_api(request: Request, collection_id: int):
|
||||
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
|
||||
conn.commit()
|
||||
|
||||
await fire_event("collection.deleted", {"collection_id": collection_id,
|
||||
"name": existing["name"] if existing else ""})
|
||||
run_event_sync(fire_event("collection.deleted", {"collection_id": collection_id,
|
||||
"name": existing["name"] if existing else ""}))
|
||||
return {"id": collection_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/{collection_id}/duplicate")
|
||||
async def duplicate_collection_api(request: Request, collection_id: int):
|
||||
def duplicate_collection_api(request: Request, collection_id: int):
|
||||
"""v5.4.0: deep-duplicate a database (views + properties + pages + data
|
||||
sources) into a new collection named '<original> (copy)'."""
|
||||
with get_conn() as conn:
|
||||
@@ -494,7 +486,7 @@ async def duplicate_collection_api(request: Request, collection_id: int):
|
||||
|
||||
conn.commit()
|
||||
|
||||
await fire_event("collection.created", {"collection_id": new_id, "name": new_name})
|
||||
run_event_sync(fire_event("collection.created", {"collection_id": new_id, "name": new_name}))
|
||||
return {"id": new_id, "name": new_name, "status": "duplicated"}
|
||||
|
||||
|
||||
@@ -542,12 +534,8 @@ def open_row_page_api(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.put("/pages/{page_id}/api")
|
||||
async def update_page_api(request: Request, page_id: int):
|
||||
def update_page_api(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
"""API: update a page's properties."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -599,18 +587,18 @@ async def update_page_api(request: Request, page_id: int):
|
||||
sync_row_title_to_page(conn, page_id)
|
||||
conn.commit()
|
||||
|
||||
await fire_event("page.updated", {
|
||||
run_event_sync(fire_event("page.updated", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"properties": props,
|
||||
})
|
||||
await fire_event("collection.page.updated", {
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.updated", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": title,
|
||||
})
|
||||
}))
|
||||
# Notify newly assigned people (person properties) — v5.8.0.
|
||||
from app.services.notifications import notify_assignment
|
||||
user = _current_user(request)
|
||||
@@ -620,7 +608,7 @@ async def update_page_api(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/api")
|
||||
async def delete_page_api(request: Request, page_id: int):
|
||||
def delete_page_api(request: Request, page_id: int):
|
||||
"""API: delete a page from its collection."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -634,15 +622,15 @@ async def delete_page_api(request: Request, page_id: int):
|
||||
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
|
||||
await fire_event("page.deleted", {
|
||||
run_event_sync(fire_event("page.deleted", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": existing["title"],
|
||||
})
|
||||
await fire_event("collection.page.deleted", {
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.deleted", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
})
|
||||
}))
|
||||
return {"id": page_id, "status": "deleted"}
|
||||
|
||||
|
||||
@@ -851,16 +839,12 @@ def timezones_api(request: Request):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/property-groups/api")
|
||||
async def set_property_groups_api(request: Request, collection_id: int):
|
||||
def set_property_groups_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: (re)assign properties to collapsible groups in the table header.
|
||||
|
||||
Body: ``{"groups": [{"name": "Basics", "property_ids": [1, 2]}]}``. Properties
|
||||
omitted from any group have their group cleared. Empty group names clear.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
groups = body.get("groups", [])
|
||||
|
||||
with get_conn() as conn:
|
||||
@@ -887,12 +871,8 @@ async def set_property_groups_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/api")
|
||||
async def create_property_api(request: Request, collection_id: int):
|
||||
def create_property_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: create a new property on a collection."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
@@ -934,12 +914,8 @@ async def create_property_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.put("/properties/{prop_id}/api")
|
||||
async def update_property_api(request: Request, prop_id: int):
|
||||
def update_property_api(request: Request, prop_id: int, body: dict = Body(default={})):
|
||||
"""API: update a property."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
@@ -991,12 +967,8 @@ def delete_property_api(request: Request, prop_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/relation")
|
||||
async def create_relation_property(request: Request, collection_id: int):
|
||||
def create_relation_property(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Create a relation property between two collections."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
related_collection_id = body.get("related_collection_id")
|
||||
@@ -1043,12 +1015,8 @@ async def create_relation_property(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/relation/link")
|
||||
async def link_pages(request: Request, collection_id: int):
|
||||
def link_pages(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Link two pages via a relation property."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
property_id = body.get("property_id")
|
||||
source_page_id = body.get("source_page_id")
|
||||
@@ -1117,12 +1085,8 @@ async def link_pages(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/rollup/compute")
|
||||
async def compute_rollup(request: Request):
|
||||
def compute_rollup(request: Request, body: dict = Body(default={})):
|
||||
"""Compute a rollup aggregation."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
collection_id = body.get("collection_id")
|
||||
relation_property_id = body.get("relation_property_id")
|
||||
@@ -1143,12 +1107,8 @@ async def compute_rollup(request: Request):
|
||||
|
||||
|
||||
@router.post("/formula/evaluate")
|
||||
async def evaluate_formula(request: Request):
|
||||
def evaluate_formula(request: Request, body: dict = Body(default={})):
|
||||
"""Evaluate a formula expression."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
expression = body.get("expression", "")
|
||||
context = body.get("context", {})
|
||||
@@ -1177,12 +1137,8 @@ def get_view_api(request: Request, view_id: int):
|
||||
|
||||
|
||||
@router.put("/views/{view_id}/config")
|
||||
async def update_view_config(request: Request, view_id: int):
|
||||
def update_view_config(request: Request, view_id: int, body: dict = Body(default={})):
|
||||
"""API: update view configuration (group_by, card_size, visible_properties, etc.)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
@@ -1209,12 +1165,8 @@ async def update_view_config(request: Request, view_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/views/save-as")
|
||||
async def save_view_as(request: Request, collection_id: int):
|
||||
def save_view_as(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: save current view state as a new named view."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "New View")
|
||||
config = body.get("config", {})
|
||||
@@ -1241,12 +1193,12 @@ async def save_view_as(request: Request, collection_id: int):
|
||||
conn.commit()
|
||||
new_view_id = cur.lastrowid
|
||||
|
||||
await fire_event("collection.view.created", {
|
||||
run_event_sync(fire_event("collection.view.created", {
|
||||
"view_id": new_view_id,
|
||||
"collection_id": collection_id,
|
||||
"name": name,
|
||||
"view_type": view_type,
|
||||
})
|
||||
}))
|
||||
return {"id": new_view_id, "name": name, "view_type": view_type,
|
||||
"config_json": json.dumps(config), "created_by": user_id, "status": "saved"}
|
||||
|
||||
@@ -1289,12 +1241,8 @@ def delete_view_api(request: Request, view_id: int):
|
||||
|
||||
|
||||
@router.post("/views/{view_id}/duplicate")
|
||||
async def duplicate_view_api(request: Request, view_id: int):
|
||||
def duplicate_view_api(request: Request, view_id: int, body: dict = Body(default={})):
|
||||
"""API: duplicate a view (config + type), owned by the current user."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
@@ -1319,12 +1267,12 @@ async def duplicate_view_api(request: Request, view_id: int):
|
||||
conn.commit()
|
||||
dup_view_id = cur.lastrowid
|
||||
|
||||
await fire_event("collection.view.created", {
|
||||
run_event_sync(fire_event("collection.view.created", {
|
||||
"view_id": dup_view_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"name": name,
|
||||
"view_type": existing["view_type"],
|
||||
})
|
||||
}))
|
||||
return {"id": dup_view_id, "name": name, "view_type": existing["view_type"],
|
||||
"status": "duplicated"}
|
||||
|
||||
@@ -1344,12 +1292,8 @@ def list_sub_items(request: Request, collection_id: int, page_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/sub-items")
|
||||
async def create_sub_item(request: Request, collection_id: int, page_id: int):
|
||||
def create_sub_item(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: create a sub-item under a page."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
title = body.get("title", "New sub-item").strip()
|
||||
if not title:
|
||||
@@ -1380,18 +1324,18 @@ async def create_sub_item(request: Request, collection_id: int, page_id: int):
|
||||
conn.commit()
|
||||
new_id = cur.lastrowid
|
||||
|
||||
await fire_event("page.created", {
|
||||
run_event_sync(fire_event("page.created", {
|
||||
"page_id": new_id,
|
||||
"collection_id": collection_id,
|
||||
"parent_id": page_id,
|
||||
"title": title,
|
||||
"properties": body.get("properties", {}),
|
||||
})
|
||||
await fire_event("collection.page.created", {
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.created", {
|
||||
"page_id": new_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
})
|
||||
}))
|
||||
return {"id": new_id, "title": title, "parent_id": page_id, "status": "created"}
|
||||
|
||||
|
||||
@@ -1420,12 +1364,8 @@ def aggregate_child_status(request: Request, collection_id: int, page_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/dependencies")
|
||||
async def set_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
def set_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: set blocking dependencies for a page (stored as 'blocks' property)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
blocks_ids = body.get("blocks", [])
|
||||
|
||||
@@ -1447,12 +1387,8 @@ async def set_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/check-deps")
|
||||
async def check_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
def check_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: check if a page can transition to a new status."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
body.get("new_status", "Done")
|
||||
|
||||
@@ -1510,12 +1446,8 @@ def list_data_sources(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/sources/api")
|
||||
async def add_data_source(request: Request, collection_id: int):
|
||||
def add_data_source(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: add a data source to a collection."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
source_collection_id = body.get("source_collection_id")
|
||||
if not source_collection_id:
|
||||
@@ -1572,15 +1504,11 @@ def remove_data_source(request: Request, collection_id: int, source_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/linked/api")
|
||||
async def create_linked_database(request: Request, collection_id: int):
|
||||
def create_linked_database(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: create a linked database view from a source collection.
|
||||
A linked database copies the structure (views, filters, sorts) of a source
|
||||
but shares the same pages — edits to pages propagate to the source.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
body.get("workspace_id")
|
||||
@@ -1693,12 +1621,8 @@ def toggle_inline(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/inline/api")
|
||||
async def create_inline_database(request: Request):
|
||||
def create_inline_database(request: Request, body: dict = Body(default={})):
|
||||
"""API: create an inline database within a parent page (optionally from a template)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
@@ -1792,12 +1716,8 @@ def list_page_dependencies(request: Request, collection_id: int, page_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/dependencies/api")
|
||||
async def add_page_dependency(request: Request, collection_id: int, page_id: int):
|
||||
def add_page_dependency(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: add a dependency (blocks/blocked_by/related) between two pages."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
dependency_id = body.get("dependency_id")
|
||||
if not dependency_id:
|
||||
raise HTTPException(status_code=400, detail="dependency_id is required")
|
||||
@@ -1834,15 +1754,11 @@ def remove_page_dependency(request: Request, collection_id: int, page_id: int, d
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/{page_id}/auto-shift/api")
|
||||
async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
|
||||
def auto_shift_dates(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})):
|
||||
"""API: auto-shift dates based on blocking dependencies."""
|
||||
from datetime import date as dt_date
|
||||
from datetime import timedelta
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
skip_weekends = body.get("skip_weekends", False)
|
||||
|
||||
with get_conn() as conn:
|
||||
@@ -2376,7 +2292,7 @@ def _render_chart(view_type: str, collection: dict, pages: list[dict], config: d
|
||||
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 src="/static/js/vendor/chart.umd.js"></script>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
new Chart(document.getElementById('chartCanvas'), {{
|
||||
type: '{chart_type}',
|
||||
@@ -2488,9 +2404,9 @@ def _render_map(view_type: str, collection: dict, pages: list[dict], config: dic
|
||||
<style>
|
||||
#map{{height:400px;border-radius:8px}}
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" />
|
||||
<link rel="stylesheet" href="/static/js/vendor/leaflet.css" />
|
||||
<div id="map"></div>
|
||||
<script src="https://unpkg.com/[email protected]/dist/leaflet.js"></script>
|
||||
<script src="/static/js/vendor/leaflet.js"></script>
|
||||
<script nonce="{CSP_NONCE.get()}">
|
||||
const map = L.map('map').setView([{center_lat}, {center_lng}], 6);
|
||||
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map);
|
||||
@@ -2646,15 +2562,11 @@ def get_collection_api(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/{collection_id}/pages/api")
|
||||
async def create_page_api(request: Request, collection_id: int):
|
||||
def create_page_api(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""API: create a page in a collection."""
|
||||
# v6.0.0: granular collection permissions — viewer/commenter cannot create.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
_require_edit(collection_id, _session_user(request))
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
title = body.get("title", "").strip()
|
||||
if not title:
|
||||
@@ -2695,16 +2607,16 @@ async def create_page_api(request: Request, collection_id: int):
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
|
||||
await fire_event("page.created", {
|
||||
run_event_sync(fire_event("page.created", {
|
||||
"page_id": page_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"properties": property_values,
|
||||
})
|
||||
await fire_event("collection.page.created", {
|
||||
}))
|
||||
run_event_sync(fire_event("collection.page.created", {
|
||||
"page_id": page_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
})
|
||||
}))
|
||||
return {"id": page_id, "title": title, "status": "created"}
|
||||
|
||||
+39
-47
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from datetime import UTC
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -413,17 +413,36 @@ 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")
|
||||
@@ -701,8 +720,7 @@ def _require_user_id(request: Request) -> int:
|
||||
|
||||
|
||||
@router.put("/api/user/profile")
|
||||
async def update_profile(request: Request):
|
||||
body = await request.json()
|
||||
def update_profile(request: Request, body: dict = Body(default={})):
|
||||
full_name = body.get("full_name", "").strip()
|
||||
uid = _require_user_id(request)
|
||||
with get_conn() as conn:
|
||||
@@ -712,9 +730,8 @@ async def update_profile(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/user/password")
|
||||
async def update_password(request: Request):
|
||||
def update_password(request: Request, body: dict = Body(default={})):
|
||||
from app.password_utils import hash_password, verify_password
|
||||
body = await request.json()
|
||||
password = body.get("password", "").strip()
|
||||
if len(password) < 6:
|
||||
return {"error": "Password must be at least 6 characters"}
|
||||
@@ -953,8 +970,7 @@ async def list_workspace_projects(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/workspace/projects")
|
||||
async def create_workspace_project(request: Request):
|
||||
body = await request.json()
|
||||
def create_workspace_project(request: Request, body: dict = Body(default={})):
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
@@ -983,9 +999,8 @@ def list_members(request: Request, ws_id: int):
|
||||
|
||||
|
||||
@router.post("/api/workspace/{ws_id:int}/members")
|
||||
async def invite_member(request: Request, ws_id: int):
|
||||
def invite_member(request: Request, ws_id: int, body: dict = Body(default={})):
|
||||
"""Invite a user to a workspace by email."""
|
||||
body = await request.json()
|
||||
email = body.get("email", "").strip()
|
||||
role = body.get("role", "editor")
|
||||
if role not in ("owner", "admin", "editor", "viewer"):
|
||||
@@ -1006,9 +1021,8 @@ async def invite_member(request: Request, ws_id: int):
|
||||
|
||||
|
||||
@router.put("/api/workspace/{ws_id:int}/members/{user_id:int}")
|
||||
async def update_member_role(request: Request, ws_id: int, user_id: int):
|
||||
def update_member_role(request: Request, ws_id: int, user_id: int, body: dict = Body(default={})):
|
||||
"""Change a member's role."""
|
||||
body = await request.json()
|
||||
role = body.get("role", "editor")
|
||||
if role not in ("owner", "admin", "editor", "viewer"):
|
||||
return {"error": "Invalid role"}
|
||||
@@ -1449,9 +1463,8 @@ def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None):
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/items")
|
||||
async def create_local_workspace_item(request: Request):
|
||||
def create_local_workspace_item(request: Request, body: dict = Body(default={})):
|
||||
"""Create a new file in the active workspace."""
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip() or "Untitled"
|
||||
item_type = body.get("type", "page")
|
||||
parent_id = body.get("parent_id")
|
||||
@@ -1472,9 +1485,8 @@ async def create_local_workspace_item(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/local-workspace/items/{item_id:int}")
|
||||
async def rename_local_workspace_item(request: Request, item_id: int):
|
||||
def rename_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Rename a file."""
|
||||
body = await request.json()
|
||||
name = body.get("name", "Untitled").strip()
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET title=? WHERE id=?", (name, item_id))
|
||||
@@ -1528,9 +1540,8 @@ def serve_uploaded_file(ws_id: int, filename: str):
|
||||
|
||||
|
||||
@router.put("/api/local-workspace/items/{item_id:int}/move")
|
||||
async def move_local_workspace_item(request: Request, item_id: int):
|
||||
def move_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Move an item to a new parent (drag & drop)."""
|
||||
body = await request.json()
|
||||
new_parent_id = body.get("parent_id") # None = move to root
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
@@ -1810,9 +1821,8 @@ def list_workspaces(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/workspaces")
|
||||
async def create_workspace(request: Request):
|
||||
def create_workspace(request: Request, body: dict = Body(default={})):
|
||||
"""Create a new workspace."""
|
||||
body = await request.json()
|
||||
name = body.get("name", "New Workspace").strip()
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
@@ -1842,9 +1852,8 @@ async def create_workspace(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/workspaces/{ws_id:int}")
|
||||
async def rename_workspace(request: Request, ws_id: int):
|
||||
def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})):
|
||||
"""Rename a workspace."""
|
||||
body = await request.json()
|
||||
name = body.get("name", "").strip()
|
||||
if not name:
|
||||
return {"error": "Name required"}
|
||||
@@ -1952,9 +1961,8 @@ def get_avatar(user_id: int):
|
||||
|
||||
|
||||
@router.post("/api/settings/avatar-color")
|
||||
async def set_avatar_color(request: Request):
|
||||
def set_avatar_color(request: Request, body: dict = Body(default={})):
|
||||
"""Set the user's avatar background color."""
|
||||
body = await request.json()
|
||||
color = body.get("color", "#3A3A3A")
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
@@ -1968,9 +1976,8 @@ async def set_avatar_color(request: Request):
|
||||
# ═══════════ Tag Management API (per-user) ═══════════
|
||||
|
||||
@router.post("/api/settings/tags")
|
||||
async def create_tag_global(request: Request):
|
||||
def create_tag_global(request: Request, body: dict = Body(default={})):
|
||||
"""Create a tag for the current user."""
|
||||
body = await request.json()
|
||||
tag_name = body.get("name", "").strip().lower()
|
||||
color = body.get("color", "#787774")
|
||||
uid = _get_user_id(request)
|
||||
@@ -1988,9 +1995,8 @@ async def create_tag_global(request: Request):
|
||||
|
||||
|
||||
@router.put("/api/settings/tags/{tag_id:int}")
|
||||
async def update_tag_global(tag_id: int, request: Request):
|
||||
def update_tag_global(tag_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Update a tag (name or color) — only if owned by user."""
|
||||
body = await request.json()
|
||||
uid = _get_user_id(request)
|
||||
with get_conn() as conn:
|
||||
if "name" in body:
|
||||
@@ -2068,9 +2074,8 @@ def get_item_tags(item_id: int):
|
||||
|
||||
|
||||
@router.post("/api/local-workspace/items/{item_id:int}/tags")
|
||||
async def add_item_tag(request: Request, item_id: int):
|
||||
def add_item_tag(request: Request, item_id: int, body: dict = Body(default={})):
|
||||
"""Add a tag to an item (creates tag if new, scoped to user)."""
|
||||
body = await request.json()
|
||||
tag_name = body.get("name", "").strip().lower()
|
||||
tag_color = body.get("color", "#787774")
|
||||
uid = _get_user_id(request)
|
||||
@@ -2156,16 +2161,12 @@ def search_by_tags(request: Request, tags: str = ""):
|
||||
|
||||
# ── Account update ──
|
||||
@router.put("/api/settings/account")
|
||||
async def update_account(request: Request):
|
||||
def update_account(request: Request, body: dict = Body(default={})):
|
||||
"""Update current user's profile: full_name, login, email, password."""
|
||||
from app.password_utils import hash_password
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
uid = user["id"]
|
||||
if "full_name" in body:
|
||||
@@ -2544,9 +2545,8 @@ def api_page_content(page_id: int):
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id:int}/rename")
|
||||
async def api_rename_page(page_id: int, request: Request):
|
||||
def api_rename_page(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Inline rename a page title."""
|
||||
body = await request.json()
|
||||
title = (body.get("title") or "").strip()
|
||||
if not title:
|
||||
return JSONResponse({"error": "Title required"}, status_code=400)
|
||||
@@ -2575,17 +2575,13 @@ def api_trash_page(page_id: int):
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id:int}/convert-to-database")
|
||||
async def api_convert_to_database(page_id: int, request: Request):
|
||||
def api_convert_to_database(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Convert a page into a full-page database (Notion-style).
|
||||
|
||||
Creates a collection linked to this page, adds the default 'Name' property,
|
||||
and sets the page's content_format to 'collection'.
|
||||
"""
|
||||
import json as _json
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
db_name = (body.get("name") or "").strip()
|
||||
|
||||
with get_conn() as conn:
|
||||
@@ -2680,13 +2676,9 @@ def api_collection_table_data(collection_id: int):
|
||||
|
||||
|
||||
@router.post("/api/collections/{collection_id:int}/pages")
|
||||
async def api_create_collection_page(collection_id: int, request: Request):
|
||||
def api_create_collection_page(collection_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Create a new page (row) in a collection."""
|
||||
import json as _json
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = body.get("title", "New page").strip() or "New page"
|
||||
icon = body.get("icon", "file")
|
||||
|
||||
|
||||
+4
-12
@@ -1,5 +1,5 @@
|
||||
"""FlowDeck — Gitea integration API routes."""
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
router = APIRouter(tags=["gitea"], prefix="/api/gitea")
|
||||
@@ -202,7 +202,7 @@ def list_private_pages(owner: str, repo: str, request: Request):
|
||||
|
||||
|
||||
@router.post("/projects/{owner}/{repo}/private-pages")
|
||||
async def create_private_page(owner: str, repo: str, request: Request):
|
||||
def create_private_page(owner: str, repo: str, request: Request, body: dict = Body(default={})):
|
||||
"""Create a new private page for this Gitea project."""
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -210,10 +210,6 @@ async def create_private_page(owner: str, repo: str, request: Request):
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = body.get("title", "Untitled").strip() or "Untitled"
|
||||
with get_conn() as conn:
|
||||
cursor = conn.execute(
|
||||
@@ -243,7 +239,7 @@ def get_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.put("/projects/{owner}/{repo}/private-pages/{page_id}")
|
||||
async def update_private_page(owner: str, repo: str, page_id: int, request: Request):
|
||||
def update_private_page(owner: str, repo: str, page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Update a private page."""
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -251,10 +247,6 @@ async def update_private_page(owner: str, repo: str, page_id: int, request: Requ
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = body.get("title", "").strip()
|
||||
content = body.get("content", "")
|
||||
with get_conn() as conn:
|
||||
@@ -306,7 +298,7 @@ async def sync_labels(request: Request, owner: str, repo: str):
|
||||
"""Sync Gitea labels to FlowDeck tags for the current user."""
|
||||
from app.auth.session import get_current_user as gcu
|
||||
from app.db import get_conn
|
||||
user = await gcu(request)
|
||||
user = gcu(request)
|
||||
if not user:
|
||||
return JSONResponse({"error": "Not authenticated"}, status_code=401)
|
||||
gitea = _require_gitea(request)
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -45,12 +45,8 @@ def list_policies(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/agent-policies")
|
||||
async def upsert_policy(request: Request):
|
||||
def upsert_policy(request: Request, body: dict = Body(default={})):
|
||||
user = _owner_or_admin(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
wid = body.get("workspace_id")
|
||||
tools = body.get("allowed_tools")
|
||||
if tools is not None and not isinstance(tools, list):
|
||||
@@ -84,12 +80,8 @@ def list_approvals(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/agent-approvals/{approval_id}/decide")
|
||||
async def decide_approval(approval_id: int, request: Request):
|
||||
def decide_approval(approval_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _owner_or_admin(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
out = policies.decide_approval(approval_id, user["id"], bool(body.get("approve")))
|
||||
if out is None:
|
||||
raise HTTPException(404, "Pending approval not found")
|
||||
|
||||
@@ -39,7 +39,7 @@ def _current_user(request: Request) -> dict:
|
||||
|
||||
|
||||
@page_router.get("/import", response_class=HTMLResponse)
|
||||
async def import_page(request: Request):
|
||||
def import_page(request: Request):
|
||||
"""Standalone import wizard (source picker, dry-run, mapping, progress)."""
|
||||
user = _current_user(request)
|
||||
if not user:
|
||||
|
||||
+3
-11
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -46,12 +46,8 @@ def _auth_user(request: Request, *, require_write: bool = False) -> dict:
|
||||
# ── calendar links ─────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/calendar-links")
|
||||
async def create_link(request: Request):
|
||||
def create_link(request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
provider = (body.get("provider") or "").lower()
|
||||
if provider not in cal.PROVIDERS:
|
||||
raise HTTPException(400, "provider must be google|caldav")
|
||||
@@ -171,13 +167,9 @@ async def upload_and_transcribe(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/meetings/transcripts/{transcript_id}/text")
|
||||
async def set_transcript_text(transcript_id: int, request: Request):
|
||||
def set_transcript_text(transcript_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Store a client-side (manual) transcript on an existing row."""
|
||||
_auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
text = (body.get("transcript") or "").strip()
|
||||
if not text:
|
||||
raise HTTPException(400, "transcript required")
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -56,10 +56,9 @@ def unread_count(request: Request):
|
||||
|
||||
|
||||
@router.post("/read")
|
||||
async def mark_read(request: Request):
|
||||
def mark_read(request: Request, body: dict = Body(default={})):
|
||||
"""Mark one notification as read (id) or all (id omitted)."""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
nid = body.get("id")
|
||||
with get_conn() as conn:
|
||||
if nid:
|
||||
@@ -91,11 +90,10 @@ def get_prefs(request: Request):
|
||||
|
||||
|
||||
@router.post("/prefs")
|
||||
async def set_prefs(request: Request):
|
||||
def set_prefs(request: Request, body: dict = Body(default={})):
|
||||
"""Update the current user's notification email preferences."""
|
||||
user = _current_user(request)
|
||||
from app.services import notifications as notif
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
prefs = notif.get_user_prefs(user["id"])
|
||||
for key in ("comments", "mentions", "reminders", "assignments"):
|
||||
if key in body:
|
||||
@@ -116,10 +114,9 @@ def get_timezone(request: Request):
|
||||
|
||||
|
||||
@router.post("/timezone")
|
||||
async def set_timezone(request: Request):
|
||||
def set_timezone(request: Request, body: dict = Body(default={})):
|
||||
"""Update the current user's IANA timezone (empty string = UTC)."""
|
||||
user = _current_user(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
tz = (body.get("timezone") or "").strip()
|
||||
from app.services.recurrence import is_valid_timezone
|
||||
if tz and not is_valid_timezone(tz):
|
||||
|
||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -63,13 +63,9 @@ def _forge_configured(provider: str) -> bool:
|
||||
|
||||
|
||||
@router.post("/api/onboarding/workspace")
|
||||
async def onboarding_create_workspace(request: Request):
|
||||
def onboarding_create_workspace(request: Request, body: dict = Body(default={})):
|
||||
"""Step 1 — create the first local workspace."""
|
||||
user = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "").strip() or "My Workspace"
|
||||
|
||||
with get_conn() as conn:
|
||||
@@ -90,13 +86,9 @@ async def onboarding_create_workspace(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/onboarding/project")
|
||||
async def onboarding_create_project(request: Request):
|
||||
def onboarding_create_project(request: Request, body: dict = Body(default={})):
|
||||
"""Step 3 — create the first project: a welcome page in the workspace."""
|
||||
user = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = (body.get("title") or "").strip() or "Welcome to FlowDeck"
|
||||
workspace_id = body.get("workspace_id")
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -197,22 +197,20 @@ def _page_type(page_id: int) -> str:
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/permissions")
|
||||
async def grant_page_permission(page_id: int, request: Request):
|
||||
def grant_page_permission(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_page_permissions(page_id):
|
||||
raise HTTPException(403, "Only a page owner can manage its permissions")
|
||||
body = await request.json()
|
||||
return _grant_common(request, pm, "page", page_id, body,
|
||||
"page_permissions", "page_id", PAGE_ROLES)
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/permissions/batch")
|
||||
async def batch_page_permissions(page_id: int, request: Request):
|
||||
def batch_page_permissions(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Grant several permissions in one call: {grants: [{user_id|group_id, role}, ...]}."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_page_permissions(page_id):
|
||||
raise HTTPException(403, "Only a page owner can manage its permissions")
|
||||
body = await request.json()
|
||||
grants = body.get("grants") or []
|
||||
if not isinstance(grants, list) or not grants:
|
||||
raise HTTPException(400, "grants must be a non-empty list")
|
||||
@@ -266,11 +264,10 @@ def _collection_type(collection_id: int) -> str:
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/permissions")
|
||||
async def grant_collection_permission(collection_id: int, request: Request):
|
||||
def grant_collection_permission(collection_id: int, request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage its permissions")
|
||||
body = await request.json()
|
||||
return _grant_common(request, pm, "collection", collection_id, body,
|
||||
"collection_permissions", "collection_id", COLLECTION_ROLES)
|
||||
|
||||
@@ -331,11 +328,10 @@ def list_property_permissions(collection_id: int, property_id: int, request: Req
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/properties/{property_id}/permissions")
|
||||
async def grant_property_permission(collection_id: int, property_id: int, request: Request):
|
||||
def grant_property_permission(collection_id: int, property_id: int, request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage property permissions")
|
||||
body = await request.json()
|
||||
with get_conn() as conn:
|
||||
prop = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE id=? AND collection_id=?",
|
||||
@@ -369,9 +365,8 @@ def list_groups(request: Request, workspace_id: int | None = None):
|
||||
|
||||
|
||||
@router.post("/groups")
|
||||
async def create_group(request: Request):
|
||||
def create_group(request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
body = await request.json()
|
||||
ws_id = body.get("workspace_id")
|
||||
gid = pm.create_group(ws_id, body.get("name") or "", body.get("description") or "",
|
||||
created_by=pm.user_id)
|
||||
@@ -382,9 +377,8 @@ async def create_group(request: Request):
|
||||
|
||||
|
||||
@router.put("/groups/{group_id}")
|
||||
async def update_group(group_id: int, request: Request):
|
||||
def update_group(group_id: int, request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
@@ -428,9 +422,8 @@ def list_group_members(group_id: int, request: Request):
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/members")
|
||||
async def add_group_member(group_id: int, request: Request):
|
||||
def add_group_member(group_id: int, request: Request, body: dict = Body(default={})):
|
||||
pm = _pm(request)
|
||||
body = await request.json()
|
||||
user_id = body.get("user_id")
|
||||
if not user_id or not isinstance(user_id, int):
|
||||
raise HTTPException(400, "user_id is required")
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.services import projects as projects_svc
|
||||
@@ -29,9 +29,8 @@ def list_projects(request: Request):
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_project(request: Request):
|
||||
def create_project(request: Request, body: dict = Body(default={})):
|
||||
"""Register a standalone (builtin) project."""
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name required")
|
||||
@@ -51,7 +50,7 @@ async def sync_projects(request: Request):
|
||||
|
||||
|
||||
@backups_router.post("/api/settings/backups/run")
|
||||
async def run_backup_now(request: Request):
|
||||
def run_backup_now(request: Request):
|
||||
"""Admin: create a database backup immediately."""
|
||||
_require_admin(request)
|
||||
filename = backup_db()
|
||||
@@ -61,7 +60,7 @@ async def run_backup_now(request: Request):
|
||||
|
||||
|
||||
@backups_router.get("/api/settings/backups")
|
||||
async def admin_list_backups(request: Request):
|
||||
def admin_list_backups(request: Request):
|
||||
"""Admin: list stored backups."""
|
||||
_require_admin(request)
|
||||
return {"backups": list_backups()}
|
||||
|
||||
+8
-28
@@ -12,12 +12,13 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
router = APIRouter(tags=["scim"])
|
||||
SCIM_SCHEMAS = ["urn:ietf:params:scim:schemas:core:2.0:User"]
|
||||
@@ -70,12 +71,8 @@ def scim_list(request: Request):
|
||||
|
||||
|
||||
@router.post("/scim/v2/Users")
|
||||
async def scim_create(request: Request):
|
||||
def scim_create(request: Request, body: dict = Body(default={})):
|
||||
_scim_guard(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
username = (body.get("userName") or "").strip()
|
||||
if not username:
|
||||
raise HTTPException(400, "userName required")
|
||||
@@ -133,12 +130,8 @@ def _apply_scim_update(conn, user_id: str, body: dict) -> None:
|
||||
|
||||
|
||||
@router.put("/scim/v2/Users/{user_id}")
|
||||
async def scim_replace(user_id: str, request: Request):
|
||||
def scim_replace(user_id: str, request: Request, body: dict = Body(default={})):
|
||||
_scim_guard(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
_apply_scim_update(conn, user_id, body)
|
||||
row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
@@ -146,12 +139,8 @@ async def scim_replace(user_id: str, request: Request):
|
||||
|
||||
|
||||
@router.patch("/scim/v2/Users/{user_id}")
|
||||
async def scim_patch(user_id: str, request: Request):
|
||||
def scim_patch(user_id: str, request: Request, body: dict = Body(default={})):
|
||||
_scim_guard(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
flat: dict = {}
|
||||
for op in body.get("Operations") or []:
|
||||
path = (op.get("path") or "").lower()
|
||||
@@ -180,12 +169,8 @@ def scim_delete(user_id: str, request: Request):
|
||||
# ── SCIM token management (admin, session) ─────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/scim/tokens")
|
||||
async def create_scim_token(request: Request):
|
||||
def create_scim_token(request: Request, body: dict = Body(default={})):
|
||||
admin = _admin_session(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
raw = f"scim_{secrets.token_urlsafe(32)}"
|
||||
digest = hashlib.sha256(raw.encode()).hexdigest()
|
||||
with get_conn() as conn:
|
||||
@@ -234,12 +219,8 @@ def list_domains(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/domain-claims")
|
||||
async def create_domain(request: Request):
|
||||
def create_domain(request: Request, body: dict = Body(default={})):
|
||||
admin = _admin_session(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
domain = (body.get("domain") or "").strip().lower()
|
||||
if not domain or "." not in domain or "/" in domain:
|
||||
raise HTTPException(400, "valid domain required")
|
||||
@@ -266,7 +247,6 @@ async def create_domain(request: Request):
|
||||
@router.post("/api/v2/domain-claims/{domain_id}/verify")
|
||||
async def verify_domain(domain_id: int, request: Request):
|
||||
admin = _admin_session(request)
|
||||
import httpx
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM domain_claims WHERE id=?", (domain_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -274,7 +254,7 @@ async def verify_domain(domain_id: int, request: Request):
|
||||
claim = dict(row)
|
||||
url = f"https://{claim['domain']}/.well-known/flowdeck-verify.txt"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
|
||||
async with shared_client(timeout=10, follow_redirects=True) as client:
|
||||
resp = await client.get(url)
|
||||
ok = resp.status_code == 200 and claim["txt_token"] in (resp.text or "")
|
||||
except Exception: # noqa: BLE001 — unreachable domain = not verified
|
||||
|
||||
@@ -8,7 +8,7 @@ import hashlib
|
||||
import logging
|
||||
from secrets import token_urlsafe
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -45,10 +45,9 @@ def list_tokens(request: Request):
|
||||
|
||||
|
||||
@router.post("/tokens")
|
||||
async def create_token(request: Request):
|
||||
def create_token(request: Request, body: dict = Body(default={})):
|
||||
"""Create an API token for the current user. The secret is returned once."""
|
||||
uid = _current_user_id(request)
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip() or "API token"
|
||||
token = f"fd_{token_urlsafe(24)}"
|
||||
with get_conn() as conn:
|
||||
|
||||
+10
-12
@@ -4,11 +4,12 @@ from __future__ import annotations
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
from app.services.automations import run_event_sync
|
||||
from app.services.publish import fire_published, fire_unpublished, publish, unpublish
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -27,10 +28,9 @@ def _require_auth(request: Request) -> dict:
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/share")
|
||||
async def share_page(page_id: int, request: Request):
|
||||
def share_page(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Invite a user, an email, or a group to a page."""
|
||||
user = _require_auth(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
target_user_id = body.get("user_id")
|
||||
target_group_id = body.get("group_id")
|
||||
email = body.get("email", "")
|
||||
@@ -119,7 +119,7 @@ async def share_page(page_id: int, request: Request):
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission})
|
||||
run_event_sync(_fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission}))
|
||||
except Exception:
|
||||
logger.exception("share_page")
|
||||
|
||||
@@ -164,11 +164,10 @@ def _mirror_share_revoke(conn, page_id: int, group_id: int) -> None:
|
||||
|
||||
|
||||
@router.put("/pages/{page_id}/share/{share_id}", description="Update a share's permission.")
|
||||
async def update_share_permission(page_id: int, share_id: int, request: Request):
|
||||
def update_share_permission(page_id: int, share_id: int, request: Request, body: dict = Body(default={})):
|
||||
"""Change the permission level of an existing share entry."""
|
||||
user = _require_auth(request)
|
||||
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
permission = body.get("permission", "")
|
||||
|
||||
if permission not in ("view", "comment", "edit"):
|
||||
@@ -293,11 +292,11 @@ def list_shares(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/publish")
|
||||
async def publish_page(page_id: int, request: Request):
|
||||
def publish_page(page_id: int, request: Request):
|
||||
"""Publish a page (is_published=1) with a URL slug."""
|
||||
_require_auth(request)
|
||||
slug, _title = publish(page_id)
|
||||
await fire_published(page_id, slug)
|
||||
run_event_sync(fire_published(page_id, slug))
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"is_published": True,
|
||||
@@ -307,11 +306,11 @@ async def publish_page(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/publish")
|
||||
async def unpublish_page(page_id: int, request: Request):
|
||||
def unpublish_page(page_id: int, request: Request):
|
||||
"""Unpublish a page."""
|
||||
_require_auth(request)
|
||||
unpublish(page_id)
|
||||
await fire_unpublished(page_id)
|
||||
run_event_sync(fire_unpublished(page_id))
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"is_published": False,
|
||||
@@ -323,10 +322,9 @@ async def unpublish_page(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.post("/recents/track")
|
||||
async def track_recent(request: Request):
|
||||
def track_recent(request: Request, body: dict = Body(default={})):
|
||||
"""Record a page access in recents."""
|
||||
user = _require_auth(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
page_id = body.get("page_id")
|
||||
workspace = body.get("workspace", "")
|
||||
source_type = body.get("source_type", "local")
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
@@ -81,13 +81,9 @@ def get_sidebar_config_sync(user_id: int) -> dict:
|
||||
|
||||
|
||||
@router.put("/config")
|
||||
async def save_sidebar_config(request: Request):
|
||||
def save_sidebar_config(request: Request, body: dict = Body(...)):
|
||||
"""Save the current user's sidebar customization config."""
|
||||
user = _get_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body") from None
|
||||
|
||||
config = body.get("config")
|
||||
if not config or not isinstance(config, dict):
|
||||
|
||||
+5
-21
@@ -19,7 +19,7 @@ import time
|
||||
import unicodedata
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -256,12 +256,8 @@ def _check_form_rate(ip: str) -> None:
|
||||
# ── Sites CRUD (session or Bearer) ─────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/sites")
|
||||
async def create_site(request: Request):
|
||||
def create_site(request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
root_page_id = body.get("root_page_id")
|
||||
if not root_page_id:
|
||||
raise HTTPException(400, "root_page_id is required")
|
||||
@@ -349,12 +345,8 @@ def get_site(site_id: int, request: Request):
|
||||
|
||||
|
||||
@router.patch("/api/v2/sites/{site_id}")
|
||||
async def update_site(site_id: int, request: Request):
|
||||
def update_site(site_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -432,12 +424,8 @@ def list_site_pages(site_id: int, request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/sites/{site_id}/pages")
|
||||
async def add_site_page(site_id: int, request: Request):
|
||||
def add_site_page(site_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
page_id = body.get("page_id")
|
||||
if not page_id:
|
||||
raise HTTPException(400, "page_id is required")
|
||||
@@ -622,12 +610,8 @@ def get_form_config(collection_id: int, request: Request):
|
||||
|
||||
|
||||
@router.put("/api/v2/collections/{collection_id}/form")
|
||||
async def put_form_config(collection_id: int, request: Request):
|
||||
def put_form_config(collection_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
info = _form_config(conn, collection_id)
|
||||
cfg = info["config"] if isinstance(info["config"], dict) else {}
|
||||
|
||||
+16
-20
@@ -17,13 +17,14 @@ import logging
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
|
||||
from app.auth.providers import oidc_provider, saml_provider
|
||||
from app.auth.session import SessionManager
|
||||
from app.services import sso_provisioning as sso
|
||||
from app.services.api_v2_helpers import has_scope, resolve_bearer_token
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["sso"])
|
||||
@@ -436,10 +437,9 @@ async def _fetch_jwks(doc: dict) -> dict:
|
||||
url = doc.get("jwks_uri")
|
||||
if not url:
|
||||
raise oidc_provider.OIDCError("Discovery document has no jwks_uri")
|
||||
import httpx
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
r = await client.get(url)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
@@ -496,7 +496,7 @@ async def oidc_logout_post(request: Request, next: str = "/auth/login?provider=l
|
||||
# ═══════════════════════ Admin configuration API ══════════════════════════
|
||||
|
||||
|
||||
async def _require_admin(request: Request, *, write: bool) -> dict:
|
||||
def _require_admin(request: Request, *, write: bool) -> dict:
|
||||
"""Admin identity: Bearer token (scope read/write) or an admin session.
|
||||
|
||||
Session-authenticated writes also need the CSRF header — ``/api/v2`` is
|
||||
@@ -558,22 +558,18 @@ def sso_providers(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/config")
|
||||
async def get_sso_config_api(request: Request):
|
||||
def get_sso_config_api(request: Request):
|
||||
"""Read the current SSO configuration (secrets never returned)."""
|
||||
await _require_admin(request, write=False)
|
||||
_require_admin(request, write=False)
|
||||
cfg = _sso_config_or_error()
|
||||
return sso.public_config_view(cfg)
|
||||
|
||||
|
||||
@router.post("/api/v2/sso/config")
|
||||
@router.put("/api/v2/sso/config")
|
||||
async def save_sso_config_api(request: Request):
|
||||
def save_sso_config_api(request: Request, payload: dict = Body(...)):
|
||||
"""Create/replace the SSO configuration (admin, scope write)."""
|
||||
admin = await _require_admin(request, write=True)
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception as err:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body") from err
|
||||
admin = _require_admin(request, write=True)
|
||||
try:
|
||||
saved = sso.save_sso_config(payload, created_by=admin.get("id"))
|
||||
except sso.SSOConfigError as err:
|
||||
@@ -586,9 +582,9 @@ async def save_sso_config_api(request: Request):
|
||||
|
||||
|
||||
@router.delete("/api/v2/sso/config")
|
||||
async def delete_sso_config_api(request: Request):
|
||||
def delete_sso_config_api(request: Request):
|
||||
"""Disable SSO — local logins keep working (design §8 « SSO disable »)."""
|
||||
admin = await _require_admin(request, write=True)
|
||||
admin = _require_admin(request, write=True)
|
||||
removed = sso.delete_sso_config()
|
||||
from app.services.api_v2_helpers import audit_log
|
||||
|
||||
@@ -597,9 +593,9 @@ async def delete_sso_config_api(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/workspaces")
|
||||
async def sso_workspaces(request: Request):
|
||||
def sso_workspaces(request: Request):
|
||||
"""Workspaces available for default assignment / group mapping."""
|
||||
await _require_admin(request, write=False)
|
||||
_require_admin(request, write=False)
|
||||
from app.db import get_conn
|
||||
|
||||
with get_conn() as conn:
|
||||
@@ -616,9 +612,9 @@ async def sso_workspaces(request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/sso/sync")
|
||||
async def sso_sync(request: Request):
|
||||
def sso_sync(request: Request):
|
||||
"""Re-apply group → workspace role mapping for every SSO user."""
|
||||
admin = await _require_admin(request, write=True)
|
||||
admin = _require_admin(request, write=True)
|
||||
try:
|
||||
result = sso.force_sync_all_groups()
|
||||
except sso.SSOProvisioningError as err:
|
||||
@@ -630,9 +626,9 @@ async def sso_sync(request: Request):
|
||||
|
||||
|
||||
@router.get("/api/v2/sso/history")
|
||||
async def sso_history(request: Request, limit: int = 50):
|
||||
def sso_history(request: Request, limit: int = 50):
|
||||
"""Audit trail of SSO login attempts (successes and rejections)."""
|
||||
await _require_admin(request, write=False)
|
||||
_require_admin(request, write=False)
|
||||
from app.db import get_conn
|
||||
|
||||
limit = max(1, min(int(limit or 50), 200))
|
||||
|
||||
@@ -15,7 +15,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -88,7 +88,7 @@ def _require_user(request: Request) -> dict:
|
||||
# ── API: status ──
|
||||
|
||||
@api_router.get("/status")
|
||||
async def clipper_status(request: Request):
|
||||
def clipper_status(request: Request):
|
||||
user = _user_from_request(request)
|
||||
if not user:
|
||||
return {"authenticated": False}
|
||||
@@ -101,12 +101,8 @@ async def clipper_status(request: Request):
|
||||
# ── API: auth verify / device registration ──
|
||||
|
||||
@api_router.post("/auth/verify")
|
||||
async def auth_verify(request: Request):
|
||||
def auth_verify(request: Request, body: dict = Body(default={})):
|
||||
user = _require_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
device_id = (body.get("device_id") or request.headers.get("x-device-id") or "").strip()
|
||||
device_name = (body.get("device_name") or body.get("deviceName") or "").strip()[:200]
|
||||
extension_name = (body.get("extension_name") or body.get("extensionName") or "chrome").strip()[:20].lower()
|
||||
@@ -124,7 +120,7 @@ async def auth_verify(request: Request):
|
||||
|
||||
|
||||
@api_router.post("/clip")
|
||||
async def clip_page(request: Request):
|
||||
def clip_page(request: Request, body: dict = Body(...)):
|
||||
user = _require_user(request)
|
||||
# Enforce max body size early (10 MB)
|
||||
clen = request.headers.get("content-length")
|
||||
@@ -134,10 +130,6 @@ async def clip_page(request: Request):
|
||||
raise HTTPException(status_code=413, detail="Clip too large (max 10 MB)")
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON") from None
|
||||
|
||||
# Device identification for rate limiting and logging
|
||||
device_id = (body.get("device_id") or request.headers.get("x-device-id") or "web").strip()[:128] or "web"
|
||||
@@ -209,14 +201,14 @@ async def clip_page(request: Request):
|
||||
|
||||
|
||||
@api_router.get("/devices")
|
||||
async def list_extension_devices(request: Request):
|
||||
def list_extension_devices(request: Request):
|
||||
user = _require_user(request)
|
||||
devices = list_devices(user["id"])
|
||||
return {"devices": devices}
|
||||
|
||||
|
||||
@api_router.delete("/devices/{device_id}")
|
||||
async def revoke_extension_device(device_id: int, request: Request):
|
||||
def revoke_extension_device(device_id: int, request: Request):
|
||||
user = _require_user(request)
|
||||
ok = revoke_device(user["id"], device_id)
|
||||
if not ok:
|
||||
|
||||
+4
-16
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -76,15 +76,11 @@ def register_begin(request: Request):
|
||||
|
||||
|
||||
@router.post("/register/finish")
|
||||
async def register_finish(request: Request):
|
||||
def register_finish(request: Request, body: dict = Body(default={})):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import verify_registration_response
|
||||
user = _session_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
challenge = _take_challenge(f"reg:{user['id']}")
|
||||
if not challenge:
|
||||
raise HTTPException(400, "Challenge expired — begin again")
|
||||
@@ -115,14 +111,10 @@ async def register_finish(request: Request):
|
||||
|
||||
|
||||
@router.post("/login/begin")
|
||||
async def login_begin(request: Request):
|
||||
def login_begin(request: Request, body: dict = Body(default={})):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import generate_authentication_options, options_to_json
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
login = (body.get("login") or "").strip()
|
||||
if not login:
|
||||
raise HTTPException(400, "login required")
|
||||
@@ -144,14 +136,10 @@ async def login_begin(request: Request):
|
||||
|
||||
|
||||
@router.post("/login/finish")
|
||||
async def login_finish(request: Request):
|
||||
def login_finish(request: Request, body: dict = Body(default={})):
|
||||
if not _require_lib():
|
||||
raise HTTPException(501, "WebAuthn library not installed")
|
||||
from webauthn import verify_authentication_response
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
login = (body.get("login") or "").strip()
|
||||
challenge = _take_challenge(f"login:{login}")
|
||||
if not login or not challenge:
|
||||
|
||||
+7
-31
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
|
||||
import html
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -172,12 +172,8 @@ def teamspace_page(teamspace_id: int, request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/wiki/teamspaces")
|
||||
async def create_teamspace(request: Request):
|
||||
def create_teamspace(request: Request, body: dict = Body(default={})):
|
||||
user = _user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name or len(name) > 120:
|
||||
raise HTTPException(400, "name required (max 120 chars)")
|
||||
@@ -225,15 +221,11 @@ def list_members(teamspace_id: int, request: Request):
|
||||
|
||||
|
||||
@router.put("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}")
|
||||
async def set_member(teamspace_id: int, member_id: int, request: Request):
|
||||
def set_member(teamspace_id: int, member_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _user(request)
|
||||
_teamspace_or_404(teamspace_id, user["id"])
|
||||
if not wiki.can_write_teamspace(user["id"], teamspace_id):
|
||||
raise HTTPException(403, "Editor role required")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
role = body.get("role")
|
||||
if role not in wiki.TEAMSPACE_ROLES:
|
||||
raise HTTPException(400, f"role must be one of {', '.join(wiki.TEAMSPACE_ROLES)}")
|
||||
@@ -275,15 +267,11 @@ def get_verification(page_id: int, request: Request):
|
||||
|
||||
|
||||
@router.post("/api/v2/wiki/pages/{page_id}/verify")
|
||||
async def verify_page(page_id: int, request: Request):
|
||||
def verify_page(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _user(request)
|
||||
page = _page_or_404(page_id)
|
||||
if not _can_verify(user, page):
|
||||
raise HTTPException(403, "Editor role required to verify a page")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
out = wiki.verify_page(page_id, user["id"],
|
||||
days=body.get("days") or wiki.VERIFICATION_DAYS_DEFAULT,
|
||||
note=body.get("note") or "")
|
||||
@@ -355,12 +343,8 @@ def list_followers(page_id: int, request: Request):
|
||||
# ── comment reactions ──────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/wiki/comments/{comment_id}/reactions")
|
||||
async def react(comment_id: int, request: Request):
|
||||
def react(comment_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
emoji = (body.get("emoji") or "").strip()
|
||||
if not emoji:
|
||||
raise HTTPException(400, "emoji required")
|
||||
@@ -380,15 +364,11 @@ def list_reactions(comment_id: int, request: Request):
|
||||
# ── guest shares ───────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/api/v2/wiki/pages/{page_id}/guests")
|
||||
async def create_guest(page_id: int, request: Request):
|
||||
def create_guest(page_id: int, request: Request, body: dict = Body(default={})):
|
||||
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"]):
|
||||
raise HTTPException(403, "Editor role required to share")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
try:
|
||||
share = wiki.create_guest_share(page_id, body.get("email") or "",
|
||||
body.get("role") or "viewer",
|
||||
@@ -519,13 +499,9 @@ def sweep_expiry(request: Request):
|
||||
# ── blocks (mermaid / equation_inline / progress) ───────────────────────────
|
||||
|
||||
@router.post("/api/v2/wiki/blocks/preview")
|
||||
async def preview_blocks(request: Request):
|
||||
def preview_blocks(request: Request, body: dict = Body(default={})):
|
||||
"""Render v7.3 blocks to HTML (same renderer used by the export pipeline)."""
|
||||
_user(request)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
blocks = body.get("blocks")
|
||||
if not isinstance(blocks, list):
|
||||
raise HTTPException(400, "blocks must be a list")
|
||||
|
||||
+3
-11
@@ -1,7 +1,7 @@
|
||||
"""FlowDeck — Workers API (v7.0.0): CRUD, manual run, history, fork, usage."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -41,12 +41,8 @@ def _row_to_api(row) -> dict:
|
||||
|
||||
|
||||
@router.post("/api/v2/workers")
|
||||
async def create_worker(request: Request):
|
||||
def create_worker(request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "Untitled worker").strip()[:200]
|
||||
code = body.get("code_py") or ""
|
||||
try:
|
||||
@@ -101,12 +97,8 @@ def get_worker(worker_id: int, request: Request):
|
||||
|
||||
|
||||
@router.patch("/api/v2/workers/{worker_id}")
|
||||
async def update_worker(worker_id: int, request: Request):
|
||||
def update_worker(worker_id: int, request: Request, body: dict = Body(default={})):
|
||||
user = _auth_user(request, require_write=True)
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone()
|
||||
if not row:
|
||||
|
||||
+28
-47
@@ -8,12 +8,12 @@ import json
|
||||
import logging
|
||||
import sqlite3
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, Body, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event
|
||||
from app.services.automations import fire_event, run_event_sync
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["workspace"], prefix="/workspace")
|
||||
@@ -57,8 +57,7 @@ def _require_ws_admin(request: Request, ws_id: int) -> None:
|
||||
# ── Workspaces ──
|
||||
|
||||
@router.post("")
|
||||
async def create_workspace(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_workspace(request: Request, body: dict = Body(default={})):
|
||||
name = body.get("name", "Default Workspace")
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
@@ -91,9 +90,8 @@ def list_members(request: Request, ws_id: int):
|
||||
|
||||
|
||||
@router.post("/{ws_id}/members")
|
||||
async def add_member(request: Request, ws_id: int):
|
||||
def add_member(request: Request, ws_id: int, body: dict = Body(default={})):
|
||||
_require_ws_admin(request, ws_id)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
user_id = body.get("user_id")
|
||||
role = body.get("role", "editor")
|
||||
if role not in ROLES:
|
||||
@@ -108,9 +106,8 @@ async def add_member(request: Request, ws_id: int):
|
||||
|
||||
|
||||
@router.put("/{ws_id}/members/{user_id}")
|
||||
async def update_member_role(request: Request, ws_id: int, user_id: int):
|
||||
def update_member_role(request: Request, ws_id: int, user_id: int, body: dict = Body(default={})):
|
||||
_require_ws_admin(request, ws_id)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
role = body.get("role", "editor")
|
||||
if role not in ROLES:
|
||||
raise HTTPException(400, f"Invalid role: {role}")
|
||||
@@ -143,8 +140,7 @@ def list_comments(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/comments")
|
||||
async def add_comment(request: Request, page_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def add_comment(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
b = body.get("body", "").strip()
|
||||
if not b:
|
||||
raise HTTPException(400, "body required")
|
||||
@@ -158,15 +154,14 @@ async def add_comment(request: Request, page_id: int):
|
||||
(page_id, uid, b, parent_id))
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid})
|
||||
run_event_sync(fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid}))
|
||||
except Exception:
|
||||
logger.exception("add_comment")
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/comments/{comment_id}")
|
||||
async def update_comment(request: Request, comment_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def update_comment(request: Request, comment_id: int, body: dict = Body(default={})):
|
||||
b = body.get("body")
|
||||
resolved = body.get("resolved")
|
||||
with get_conn() as conn:
|
||||
@@ -178,7 +173,7 @@ async def update_comment(request: Request, comment_id: int):
|
||||
conn.commit()
|
||||
if resolved and row and not int(row["resolved"] or 0):
|
||||
try:
|
||||
await fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})
|
||||
run_event_sync(fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}))
|
||||
except Exception:
|
||||
logger.exception("update_comment")
|
||||
return {"status": "updated"}
|
||||
@@ -197,8 +192,7 @@ def page_history(request: Request, page_id: int):
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/history")
|
||||
async def record_history(request: Request, page_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def record_history(request: Request, page_id: int, body: dict = Body(default={})):
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
with get_conn() as conn:
|
||||
@@ -230,8 +224,7 @@ def list_favorites(request: Request):
|
||||
|
||||
|
||||
@router.post("/favorites")
|
||||
async def add_favorite(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def add_favorite(request: Request, body: dict = Body(default={})):
|
||||
user = _current_user(request)
|
||||
uid = user.get("id", 1)
|
||||
page_id = body.get("page_id")
|
||||
@@ -244,7 +237,7 @@ async def add_favorite(request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid})
|
||||
run_event_sync(fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid}))
|
||||
except Exception:
|
||||
logger.exception("add_favorite")
|
||||
return {"status": "favorited"}
|
||||
@@ -268,8 +261,7 @@ def list_db_templates(request: Request):
|
||||
|
||||
|
||||
@router.post("/templates/database")
|
||||
async def create_db_template(request: Request):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_db_template(request: Request, body: dict = Body(default={})):
|
||||
cur = None
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
@@ -282,9 +274,8 @@ async def create_db_template(request: Request):
|
||||
|
||||
|
||||
@router.post("/templates/database/{tid}/apply")
|
||||
async def apply_db_template(request: Request, tid: int):
|
||||
def apply_db_template(request: Request, tid: int, body: dict = Body(default={})):
|
||||
from app.services.db_templates import create_from_template
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
name = body.get("name", "New Database")
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (tid,)).fetchone()
|
||||
@@ -305,8 +296,7 @@ def list_page_templates(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/templates/page")
|
||||
async def create_page_template(request: Request, collection_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def create_page_template(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO page_templates (collection_id, name, property_values_json) VALUES (?,?,?)",
|
||||
@@ -317,8 +307,7 @@ async def create_page_template(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/templates/page/{tid}/apply")
|
||||
async def apply_page_template(request: Request, collection_id: int, tid: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def apply_page_template(request: Request, collection_id: int, tid: int, body: dict = Body(default={})):
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute("SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)).fetchone()
|
||||
if not tmpl:
|
||||
@@ -331,19 +320,18 @@ async def apply_page_template(request: Request, collection_id: int, tid: int):
|
||||
(collection_id, body.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
|
||||
)
|
||||
conn.commit()
|
||||
await fire_event("page.created", {
|
||||
run_event_sync(fire_event("page.created", {
|
||||
"page_id": cur.lastrowid,
|
||||
"collection_id": collection_id,
|
||||
"title": body.get("title", "New Page"),
|
||||
"properties": json.loads(tmpl["property_values_json"]) if tmpl["property_values_json"] else {},
|
||||
})
|
||||
}))
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/collections/{collection_id}/templates/page/{tid}")
|
||||
async def update_page_template(request: Request, collection_id: int, tid: int):
|
||||
def update_page_template(request: Request, collection_id: int, tid: int, body: dict = Body(default={})):
|
||||
"""Update a page template — name, properties, content, recurrence."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
tmpl = conn.execute(
|
||||
"SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)
|
||||
@@ -395,9 +383,8 @@ def list_dashboards(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/dashboards")
|
||||
async def create_dashboard(request: Request, collection_id: int):
|
||||
def create_dashboard(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Create a new dashboard for a collection."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
name = body.get("name", "Dashboard").strip()
|
||||
layout = json.dumps(body.get("layout", {"columns": 1, "widgets": []}))
|
||||
|
||||
@@ -414,9 +401,8 @@ async def create_dashboard(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.put("/collections/{collection_id}/dashboards/{did}")
|
||||
async def update_dashboard(request: Request, collection_id: int, did: int):
|
||||
def update_dashboard(request: Request, collection_id: int, did: int, body: dict = Body(default={})):
|
||||
"""Update a dashboard — name or layout (widgets grid)."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
dash = conn.execute(
|
||||
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (did, collection_id)
|
||||
@@ -471,9 +457,8 @@ def list_sprints(request: Request, collection_id: int):
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/sprints")
|
||||
async def create_sprint(request: Request, collection_id: int):
|
||||
def create_sprint(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
"""Create a new sprint."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
name = body.get("name", "").strip()
|
||||
start_date = body.get("start_date", "")
|
||||
end_date = body.get("end_date", "")
|
||||
@@ -491,16 +476,15 @@ async def create_sprint(request: Request, collection_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name})
|
||||
run_event_sync(fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name}))
|
||||
except Exception:
|
||||
logger.exception("create_sprint")
|
||||
return {"id": cur.lastrowid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/collections/{collection_id}/sprints/{sid}")
|
||||
async def update_sprint(request: Request, collection_id: int, sid: int):
|
||||
def update_sprint(request: Request, collection_id: int, sid: int, body: dict = Body(default={})):
|
||||
"""Update a sprint."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
with get_conn() as conn:
|
||||
sprint = conn.execute(
|
||||
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
|
||||
@@ -521,7 +505,7 @@ async def update_sprint(request: Request, collection_id: int, sid: int):
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status})
|
||||
run_event_sync(fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status}))
|
||||
except Exception:
|
||||
logger.exception("update_sprint")
|
||||
return {"id": sid, "status": "updated"}
|
||||
@@ -542,9 +526,8 @@ def delete_sprint(request: Request, collection_id: int, sid: int):
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/sprints/{sid}/assign")
|
||||
async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
|
||||
def assign_page_to_sprint(request: Request, collection_id: int, sid: int, body: dict = Body(default={})):
|
||||
"""Assign a page to a sprint."""
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
page_id = body.get("page_id")
|
||||
if not page_id:
|
||||
raise HTTPException(400, "page_id is required")
|
||||
@@ -634,8 +617,7 @@ def sprint_burndown(request: Request, collection_id: int, sid: int):
|
||||
# ── CSV Import/Export ──
|
||||
|
||||
@router.post("/collections/{collection_id}/import/csv")
|
||||
async def import_csv(request: Request, collection_id: int):
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
def import_csv(request: Request, collection_id: int, body: dict = Body(default={})):
|
||||
csv_data = body.get("csv", "")
|
||||
if not csv_data:
|
||||
raise HTTPException(400, "csv field required")
|
||||
@@ -698,9 +680,8 @@ def list_webhooks(request: Request):
|
||||
|
||||
|
||||
@router.post("/webhooks")
|
||||
async def create_webhook(request: Request):
|
||||
def create_webhook(request: Request, body: dict = Body(default={})):
|
||||
_require_admin(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
url = body.get("url", "").strip()
|
||||
event = body.get("event", "page.created")
|
||||
secret = body.get("secret", "")
|
||||
|
||||
@@ -26,10 +26,9 @@ import logging
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services import notifications
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -179,7 +178,7 @@ async def _run_action(action: dict, context: dict, trigger_source: str) -> str:
|
||||
headers = {"Content-Type": "application/json", "X-FlowDeck-Event": context.get("event", "")}
|
||||
if secret:
|
||||
headers["X-FlowDeck-Secret"] = secret
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.post(url, json=context, headers=headers)
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"webhook returned HTTP {resp.status_code}")
|
||||
@@ -719,7 +718,7 @@ async def fire_stepped_event(event: str, payload: dict) -> None:
|
||||
# ── v7.0.0 action backends (module-level = monkeypatchable in tests) ───────
|
||||
|
||||
async def _post_slack(webhook_url: str, text: str) -> str:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.post(webhook_url, json={"text": text})
|
||||
if resp.status_code >= 400:
|
||||
raise RuntimeError(f"slack webhook returned HTTP {resp.status_code}")
|
||||
@@ -765,7 +764,7 @@ async def _create_forge_issue(provider: str, owner: str, repo: str, title: str,
|
||||
if provider == "github":
|
||||
if not token:
|
||||
raise ValueError("github action needs a linked GitHub account (token)")
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.post(
|
||||
f"https://api.github.com/repos/{owner}/{repo}/issues",
|
||||
headers={"Authorization": f"Bearer {token}",
|
||||
|
||||
@@ -19,9 +19,8 @@ import time
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -125,7 +124,7 @@ async def google_list_events(tokens: dict, calendar_id: str,
|
||||
url = (f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}"
|
||||
f"/events?singleEvents=true&orderBy=startTime"
|
||||
f"&timeMin={time_min}&timeMax={time_max}")
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.get(url, headers={"Authorization": f"Bearer {access}"})
|
||||
if resp.status_code == 401:
|
||||
raise SyncError("google token expired — relink the calendar")
|
||||
@@ -150,7 +149,7 @@ async def google_push_event(tokens: dict, calendar_id: str, event: dict,
|
||||
"start": {"date": event.get("start", "")[:10]},
|
||||
"end": {"date": event.get("start", "")[:10]}}
|
||||
base = f"https://www.googleapis.com/calendar/v3/calendars/{calendar_id}/events"
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
if remote_id:
|
||||
resp = await client.patch(f"{base}/{remote_id}",
|
||||
headers={"Authorization": f"Bearer {access}"}, json=body)
|
||||
@@ -224,7 +223,7 @@ async def caldav_list_events(creds: dict, time_min: str, time_max: str) -> list[
|
||||
body = _CALDAV_REPORT.format(
|
||||
start=time_min.replace("-", "").split("T")[0] + "T000000Z",
|
||||
end=time_max.replace("-", "").split("T")[0] + "T000000Z")
|
||||
async with httpx.AsyncClient(timeout=15, auth=auth if auth[0] else None) as client:
|
||||
async with shared_client(timeout=15, auth=auth if auth[0] else None) as client:
|
||||
resp = await client.request("REPORT", url, content=body,
|
||||
headers={"Depth": "1",
|
||||
"Content-Type": "application/xml"})
|
||||
@@ -245,7 +244,7 @@ async def caldav_push_event(creds: dict, event: dict, remote_id: str = "") -> st
|
||||
remote_id if remote_id.startswith("http") else f"{url}/{remote_id}")
|
||||
ics = _event_to_ics(uid.split("@")[0], event.get("title", ""),
|
||||
event.get("start", ""), event.get("description", ""))
|
||||
async with httpx.AsyncClient(timeout=15, auth=auth if auth[0] else None) as client:
|
||||
async with shared_client(timeout=15, auth=auth if auth[0] else None) as client:
|
||||
resp = await client.put(href, content=ics, headers={"Content-Type": "text/calendar"})
|
||||
if resp.status_code >= 400:
|
||||
raise SyncError(f"caldav returned HTTP {resp.status_code}")
|
||||
|
||||
@@ -5,9 +5,8 @@ import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -48,7 +47,7 @@ class GiteaClient:
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/user/repos",
|
||||
headers=self._headers,
|
||||
@@ -65,7 +64,7 @@ class GiteaClient:
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/orgs/{org}/repos",
|
||||
headers=self._headers,
|
||||
@@ -82,7 +81,7 @@ class GiteaClient:
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}",
|
||||
headers=self._headers,
|
||||
@@ -109,7 +108,7 @@ class GiteaClient:
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/user/orgs", headers=self._headers
|
||||
)
|
||||
@@ -128,7 +127,7 @@ class GiteaClient:
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/issues",
|
||||
headers=self._headers,
|
||||
@@ -140,7 +139,7 @@ class GiteaClient:
|
||||
return data
|
||||
|
||||
async def get_issue(self, owner: str, repo: str, issue_id: int) -> dict:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
|
||||
headers=self._headers,
|
||||
@@ -163,7 +162,7 @@ class GiteaClient:
|
||||
payload["assignees"] = [assignee]
|
||||
|
||||
self._invalidate_issue_cache(owner, repo)
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
f"{self._base}/repos/{owner}/{repo}/issues",
|
||||
headers=self._headers,
|
||||
@@ -177,7 +176,7 @@ class GiteaClient:
|
||||
) -> dict:
|
||||
"""Update an issue (title, body, state, labels, milestone, assignees)."""
|
||||
self._invalidate_issue_cache(owner, repo)
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.patch(
|
||||
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
|
||||
headers=self._headers,
|
||||
@@ -191,7 +190,7 @@ class GiteaClient:
|
||||
) -> list[dict]:
|
||||
"""Replace all labels on an issue (PUT endpoint)."""
|
||||
self._invalidate_issue_cache(owner, repo)
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.put(
|
||||
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}/labels",
|
||||
headers=self._headers,
|
||||
@@ -210,7 +209,7 @@ class GiteaClient:
|
||||
self, owner: str, repo: str, issue_id: int
|
||||
) -> list[dict]:
|
||||
"""Get comments for an issue."""
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}/comments",
|
||||
headers=self._headers,
|
||||
@@ -232,7 +231,7 @@ class GiteaClient:
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/labels",
|
||||
headers=self._headers,
|
||||
@@ -252,7 +251,7 @@ class GiteaClient:
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/milestones",
|
||||
headers=self._headers,
|
||||
@@ -267,7 +266,7 @@ class GiteaClient:
|
||||
|
||||
async def list_webhooks(self, owner: str, repo: str) -> list[dict]:
|
||||
"""List webhooks for a repo."""
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/hooks",
|
||||
headers=self._headers,
|
||||
@@ -292,7 +291,7 @@ class GiteaClient:
|
||||
"events": events,
|
||||
"active": True,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.post(
|
||||
f"{self._base}/repos/{owner}/{repo}/hooks",
|
||||
headers=self._headers,
|
||||
@@ -303,7 +302,7 @@ class GiteaClient:
|
||||
|
||||
async def delete_webhook(self, owner: str, repo: str, hook_id: int) -> bool:
|
||||
"""Delete a webhook."""
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.delete(
|
||||
f"{self._base}/repos/{owner}/{repo}/hooks/{hook_id}",
|
||||
headers=self._headers,
|
||||
@@ -319,7 +318,7 @@ class GiteaClient:
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/collaborators",
|
||||
headers=self._headers,
|
||||
@@ -341,7 +340,7 @@ class GiteaClient:
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.get(url, headers=self._headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
@@ -357,7 +356,7 @@ class GiteaClient:
|
||||
cached = self._cached(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
|
||||
headers=self._headers,
|
||||
@@ -382,7 +381,7 @@ class GiteaClient:
|
||||
}
|
||||
if sha:
|
||||
body["sha"] = sha
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.put(
|
||||
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
|
||||
headers=self._headers,
|
||||
@@ -398,7 +397,7 @@ class GiteaClient:
|
||||
|
||||
async def delete_file(self, owner, repo, path, sha, message):
|
||||
"""Delete a file from the repo."""
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.delete(
|
||||
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
|
||||
headers=self._headers,
|
||||
@@ -422,7 +421,7 @@ class GiteaClient:
|
||||
cached = self._cached(key)
|
||||
if cached:
|
||||
return cached
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
async with shared_client(timeout=15) as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/commits",
|
||||
headers=self._headers,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Client HTTP partagé (A42 — reliquat du audit).
|
||||
|
||||
51 créations `httpx.AsyncClient(...)` éparpillées dans 15 fichiers = un
|
||||
nouveau pool de connexions par appel (pas de keep-alive). Ici le pool est
|
||||
réutilisé, **closé par boucle d'event** : un `AsyncClient` ne traverse pas
|
||||
de boucle à boucle (les tests en créent une par test → client propre par
|
||||
boucle, collecté avec elle).
|
||||
|
||||
`async with shared_client(timeout=15) as client:` — même syntaxe que
|
||||
before, l'`__aexit__` est un no-op (on ne ferme pas le client partagé).
|
||||
|
||||
ponytail: pas d'`aclose` explicite — le client meurt avec sa boucle
|
||||
(WeakKeyDictionary, les sockets sont fermés par le GC) ; plafond : le pool
|
||||
du loop produit n'est pas fermé à la main. Upgrade si besoin : lifespan
|
||||
qui ferme le client du loop principal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import weakref
|
||||
|
||||
import httpx
|
||||
|
||||
_clients: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict] = (
|
||||
weakref.WeakKeyDictionary()
|
||||
)
|
||||
|
||||
|
||||
def _hashable(v) -> bool:
|
||||
try:
|
||||
hash(v)
|
||||
return True
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
|
||||
def _key(kwargs: dict) -> tuple:
|
||||
"""Clé de cache = kwargs (timeout/auth/transport/headers…). Valeurs non
|
||||
hashables (dict `headers=`…) → repr, même contrat que la clé."""
|
||||
return tuple(
|
||||
(k, v if _hashable(v) else repr(v))
|
||||
for k, v in sorted(kwargs.items())
|
||||
)
|
||||
|
||||
|
||||
def get_shared_client(**kwargs) -> httpx.AsyncClient:
|
||||
loop = asyncio.get_running_loop()
|
||||
per_loop = _clients.setdefault(loop, {})
|
||||
k = _key(kwargs)
|
||||
client = per_loop.get(k)
|
||||
if client is None:
|
||||
client = httpx.AsyncClient(**kwargs)
|
||||
per_loop[k] = client
|
||||
return client
|
||||
|
||||
|
||||
class shared_client:
|
||||
"""Context manager async : yield du client partagé, no-op à la sortie."""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self._kwargs = kwargs
|
||||
|
||||
async def __aenter__(self) -> httpx.AsyncClient:
|
||||
return get_shared_client(**self._kwargs)
|
||||
|
||||
async def __aexit__(self, *exc) -> bool:
|
||||
return False
|
||||
@@ -12,6 +12,7 @@ from urllib.parse import urlparse
|
||||
import httpx
|
||||
|
||||
from app.services.export import markdown_to_blocks
|
||||
from app.services.http_client import shared_client
|
||||
from app.services.importers.base import ImportPage, ImportResult
|
||||
from app.services.importers.html_notes import _html_to_markdown
|
||||
|
||||
@@ -61,7 +62,7 @@ async def fetch_url_result(url: str, *, transport: httpx.BaseTransport | None =
|
||||
safe_url = _validate_url(url)
|
||||
result = ImportResult(source="url")
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
async with shared_client(
|
||||
timeout=15, follow_redirects=True, transport=transport,
|
||||
headers={"User-Agent": "FlowDeck-Importer/1.0"},
|
||||
) as client:
|
||||
|
||||
@@ -27,6 +27,7 @@ from dataclasses import dataclass, field
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -270,7 +271,7 @@ class LLMClient:
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=settings.agent_run_timeout_seconds) as client:
|
||||
async with shared_client(timeout=settings.agent_run_timeout_seconds) as client:
|
||||
resp = await client.post(self._endpoint(), json=payload, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
@@ -14,6 +14,7 @@ import json
|
||||
import time
|
||||
|
||||
from app.config import settings
|
||||
from app.services.http_client import shared_client
|
||||
from app.services.llm_client import PROVIDER_LABELS, PROVIDER_MODELS, PROVIDERS
|
||||
|
||||
# Providers whose /v1/models lists far more entries than /v1/chat/completions
|
||||
@@ -331,7 +332,6 @@ async def fetch_provider_models(provider: str, *, api_key: str = "",
|
||||
Anthropic's native model listing uses `x-api-key` + `anthropic-version`.
|
||||
Returns a de-duplicated list capped at 300 models.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
provider = provider.lower()
|
||||
base = (api_base or "").strip() or (PROVIDERS.get(provider) or (None, None))[0]
|
||||
@@ -345,7 +345,7 @@ async def fetch_provider_models(provider: str, *, api_key: str = "",
|
||||
elif api_key:
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with shared_client(timeout=timeout) as client:
|
||||
resp = await client.get(f"{base_url}/models", headers=headers)
|
||||
if resp.status_code >= 400:
|
||||
body = (resp.text or "").strip()
|
||||
@@ -405,7 +405,6 @@ async def _validate_chat_models(base_url: str, api_key: str, candidates: list[st
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
sem = asyncio.Semaphore(concurrency)
|
||||
start = time.monotonic()
|
||||
@@ -427,7 +426,7 @@ async def _validate_chat_models(base_url: str, api_key: str, candidates: list[st
|
||||
return None
|
||||
try:
|
||||
async with sem:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
async with shared_client(timeout=timeout) as client:
|
||||
resp = await client.post(url, headers=headers, json=payload)
|
||||
code = resp.status_code
|
||||
if code < 300 or code == 429:
|
||||
|
||||
@@ -12,6 +12,8 @@ import logging
|
||||
import re
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_META_TAG_RE = re.compile(r"<meta\b[^>]*?>", re.I)
|
||||
@@ -143,7 +145,6 @@ async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> d
|
||||
src = "https://" + src
|
||||
base = {"url": src, "title": "", "description": "", "image": "", "site_name": "", "favicon": ""}
|
||||
try:
|
||||
import httpx
|
||||
|
||||
headers = {
|
||||
"User-Agent": "FlowDeck/5.5 bookmark-fetcher (+https://flowdeck.dracodev.net)",
|
||||
@@ -152,7 +153,7 @@ async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> d
|
||||
kwargs = {"timeout": timeout}
|
||||
if transport is not None:
|
||||
kwargs["transport"] = transport
|
||||
async with httpx.AsyncClient(**kwargs) as client:
|
||||
async with shared_client(**kwargs) as client:
|
||||
resp = await _get_checked(client, src, headers)
|
||||
except ValueError:
|
||||
# A12 : hôte privé/loopback ou trop de redirections → refus explicite.
|
||||
|
||||
@@ -15,6 +15,7 @@ import time
|
||||
import httpx
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -189,7 +190,7 @@ async def fire_event(event: str, payload: dict):
|
||||
if not subs:
|
||||
logger.debug("No subscribers for event: %s", event)
|
||||
return
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
async with shared_client(timeout=30.0) as client:
|
||||
for sub in subs:
|
||||
try:
|
||||
await deliver_to_sub(sub["id"], sub["url"], event,
|
||||
@@ -219,7 +220,7 @@ async def retry_due_deliveries(now: float | None = None) -> int:
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return 0
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
async with shared_client(timeout=10) as client:
|
||||
for r in rows:
|
||||
try:
|
||||
payload = json.loads(r["payload"] or "{}")
|
||||
|
||||
@@ -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" nonce="{{ csp_nonce() }}">
|
||||
/* ═════════════════════════════════════════════════════════════════════
|
||||
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;}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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" nonce="{{ csp_nonce() }}">
|
||||
/* 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" nonce="{{ csp_nonce() }}">
|
||||
(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;}
|
||||
|
||||
@@ -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" nonce="{{ csp_nonce() }}">
|
||||
/* 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
+2
-1807
File diff suppressed because it is too large
Load Diff
+2
-147
@@ -151,151 +151,6 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
var owner = '{{ owner }}';
|
||||
var repo = '{{ repo }}';
|
||||
var initialView = '{{ initial_view }}';
|
||||
|
||||
// Auto-switch to view from URL param
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (initialView && initialView !== 'kanban') {
|
||||
const tab = document.querySelector(`.view-tab[data-view="${initialView}"]`);
|
||||
if (tab) tab.click();
|
||||
}
|
||||
});
|
||||
|
||||
function setActiveTab(el) {
|
||||
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
|
||||
el.classList.add('active');
|
||||
}
|
||||
|
||||
function kanbanBoard() {
|
||||
return {
|
||||
collapsedGroups: [],
|
||||
toggleGroup(id) {
|
||||
const idx = this.collapsedGroups.indexOf(id);
|
||||
idx >= 0 ? this.collapsedGroups.splice(idx, 1) : this.collapsedGroups.push(id);
|
||||
},
|
||||
newCard(groupId, status) {
|
||||
document.getElementById('new-issue-form').style.display = 'block';
|
||||
document.querySelector('#new-issue-form').__x.$data.status = status;
|
||||
},
|
||||
newGroup() { console.log('New group'); }
|
||||
};
|
||||
}
|
||||
|
||||
function filterSystem() {
|
||||
return {
|
||||
activeFilters: [],
|
||||
statusFilters: [],
|
||||
showStatusMenu: false,
|
||||
statusOptions: [
|
||||
{ value: 'todo', label: 'To-do', color: 'var(--gray)' },
|
||||
{ value: 'progress', label: 'In progress', color: 'var(--blue)' },
|
||||
{ value: 'done', label: 'Complete', color: 'var(--green)' },
|
||||
],
|
||||
get statusFilterLabel() {
|
||||
return this.statusFilters.length ? this.statusFilters.join(', ') : 'All';
|
||||
},
|
||||
toggleStatus(val) {
|
||||
const idx = this.statusFilters.indexOf(val);
|
||||
idx >= 0 ? this.statusFilters.splice(idx, 1) : this.statusFilters.push(val);
|
||||
},
|
||||
addFilter() {
|
||||
const prop = prompt('Filter by property (status, assignee, label):');
|
||||
if (!prop) return;
|
||||
const val = prompt('Value:');
|
||||
if (!val) return;
|
||||
this.activeFilters.push({ property: prop, value: val });
|
||||
this.refreshView();
|
||||
},
|
||||
removeFilter(i) { this.activeFilters.splice(i, 1); },
|
||||
resetFilters() { this.activeFilters = []; this.statusFilters = []; },
|
||||
refreshView() {
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function sortSystem() {
|
||||
return {
|
||||
sorts: [],
|
||||
showSortPanel: false,
|
||||
addSort() {
|
||||
const field = prompt('Sort by (name, status, assignee, deadline):');
|
||||
if (!field) return;
|
||||
this.sorts.push({ field, dir: 'asc' });
|
||||
this.applySorts();
|
||||
},
|
||||
removeSort(i) { this.sorts.splice(i, 1); this.applySorts(); },
|
||||
toggleDir(i) { this.sorts[i].dir = this.sorts[i].dir === 'asc' ? 'desc' : 'asc'; this.applySorts(); },
|
||||
clearSorts() { this.sorts = []; this.applySorts(); },
|
||||
applySorts() {
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
const url = new URL(activeTab.getAttribute('hx-get'), window.location.origin);
|
||||
this.sorts.forEach(s => url.searchParams.append('sort', s.field + ':' + s.dir));
|
||||
htmx.ajax('GET', url.pathname + url.search, { target: '#view-content', swap: 'innerHTML' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function newIssueForm() {
|
||||
return {
|
||||
title: '', status: 'todo',
|
||||
create() {
|
||||
if (!this.title.trim()) return;
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/api/issues/${owner}/${repo}?title=${encodeURIComponent(this.title)}&labels=${this.status}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
this.title = '';
|
||||
this.hide();
|
||||
// Refresh current view
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
});
|
||||
},
|
||||
hide() { document.getElementById('new-issue-form').style.display = 'none'; },
|
||||
show() { document.getElementById('new-issue-form').style.display = 'block'; }
|
||||
};
|
||||
}
|
||||
|
||||
function showNewIssue() {
|
||||
const form = document.getElementById('new-issue-form');
|
||||
form.style.display = form.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Init SortableJS after HTMX swaps
|
||||
document.addEventListener('htmx:afterSwap', function(evt) {
|
||||
if (evt.target.id === 'view-content') {
|
||||
document.querySelectorAll('.kanban-cards').forEach(el => {
|
||||
if (el._sortable) el._sortable.destroy();
|
||||
el._sortable = new Sortable(el, {
|
||||
group: 'kanban',
|
||||
animation: 200,
|
||||
ghostClass: 'sortable-ghost',
|
||||
dragClass: 'sortable-drag',
|
||||
onEnd: function(evt) {
|
||||
const cardId = evt.item.dataset.cardId;
|
||||
const toStatus = evt.to.dataset.status;
|
||||
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${cardId}&column=${toStatus}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
|
||||
})
|
||||
.then(() => {
|
||||
// ponytail: refresh current view after move
|
||||
const activeTab = document.querySelector('.view-tab.active');
|
||||
if (activeTab) activeTab.click();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script type="application/json" id="bd-config" nonce="{{ csp_nonce() }}">{{ {"owner": owner, "repo": repo, "initial_view": initial_view} | tojson }}</script>
|
||||
<script data-cfasync="false" src="/static/js/board.js?v={{ asset_version }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,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;">
|
||||
|
||||
@@ -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" nonce="{{ csp_nonce() }}">
|
||||
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" nonce="{{ csp_nonce() }}">
|
||||
function importWizard() {
|
||||
return {
|
||||
sources: [],
|
||||
sourceId: '',
|
||||
files: [],
|
||||
dragOver: false,
|
||||
mode: 'skip',
|
||||
busy: false,
|
||||
progress: 0,
|
||||
error: '',
|
||||
preview: null,
|
||||
report: null,
|
||||
mapping: {},
|
||||
csrf: '',
|
||||
urlValue: '',
|
||||
urlError: '',
|
||||
forgeProvider: 'gitea',
|
||||
forgeOwner: '',
|
||||
forgeRepo: '',
|
||||
forgeState: 'all',
|
||||
forgeError: '',
|
||||
types: ['text','number','date','checkbox','email','url','phone','select','multi_select','status'],
|
||||
async init() {
|
||||
try {
|
||||
const r = await fetch('/api/import/sources');
|
||||
this.sources = (await r.json()).sources || [];
|
||||
} catch(e) {}
|
||||
try {
|
||||
const c = await fetch('/api/csrf-token');
|
||||
this.csrf = (await c.json()).csrf_token;
|
||||
} catch(e) {}
|
||||
},
|
||||
humanSize(n) { return n > 1048576 ? (n/1048576).toFixed(1)+' Mo' : Math.max(1, Math.round(n/1024))+' Ko'; },
|
||||
addFiles(list) {
|
||||
for (const f of list) this.files.push({file:f, name:f.name, size:f.size, status:'queued'});
|
||||
this.preview = null; this.report = null; this.error = '';
|
||||
},
|
||||
onFiles(e) { this.addFiles(e.target.files); e.target.value=''; },
|
||||
onDrop(e) { this.dragOver=false; this.addFiles(e.dataTransfer.files); },
|
||||
buildForm(f) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f.file);
|
||||
if (this.sourceId) fd.append('source', this.sourceId);
|
||||
fd.append('mode', this.mode);
|
||||
return fd;
|
||||
},
|
||||
async doPreview() {
|
||||
this.busy = true; this.error=''; this.report=null; this.progress=10;
|
||||
try {
|
||||
let merged = null;
|
||||
for (let i=0;i<this.files.length;i++) {
|
||||
const r = await fetch('/api/import/preview', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:this.buildForm(this.files[i])});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { this.error = (this.files[i].name+': '+(d.detail||'Erreur')); continue; }
|
||||
if (!merged) merged = {source_label:d.source_label, pages:[], warnings:[], stats:{}};
|
||||
merged.pages.push(...(d.pages||[]));
|
||||
merged.warnings.push(...(d.warnings||[]));
|
||||
this.progress = Math.round(((i+1)/this.files.length)*100);
|
||||
}
|
||||
this.preview = merged;
|
||||
this.mapping = {};
|
||||
(merged ? merged.pages : []).forEach(p => (p.schema||[]).forEach(c => { this.mapping[c.name]=c.type; }));
|
||||
} catch(e) { this.error = 'Erreur réseau'; }
|
||||
finally { this.busy = false; setTimeout(()=>{this.progress=0;},800); }
|
||||
},
|
||||
async importOne(f) {
|
||||
f.status = 'running';
|
||||
const fd = this.buildForm(f);
|
||||
if (Object.keys(this.mapping).length) fd.append('mapping', JSON.stringify(this.mapping));
|
||||
const big = f.size > 5*1024*1024;
|
||||
try {
|
||||
if (big) {
|
||||
fd.append('async','true');
|
||||
const r = await fetch('/api/import/run', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:fd});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; }
|
||||
return await this.pollJob(d.job_id, f);
|
||||
}
|
||||
const r = await fetch('/api/import/run', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:fd});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; }
|
||||
f.status = (d.status==='partial' ? 'partial' : 'done');
|
||||
return d;
|
||||
} catch(e) { f.status='error'; f.error='Erreur réseau'; return null; }
|
||||
},
|
||||
async doImport() {
|
||||
this.busy = true; this.error=''; this.report=null; this.progress=0;
|
||||
const aggregate = {pages_created:0,pages_updated:0,collections_created:0,rows_created:0,
|
||||
attachments:0,skipped:0,warnings:[],errors:[],per_file:[]};
|
||||
for (let i=0;i<this.files.length;i++) {
|
||||
const d = await this.importOne(this.files[i]);
|
||||
if (d) {
|
||||
for (const k of ['pages_created','pages_updated','collections_created','rows_created','attachments','skipped'])
|
||||
aggregate[k] += (d[k]||0);
|
||||
aggregate.warnings.push(...(d.warnings||[]));
|
||||
aggregate.errors.push(...(d.errors||[]));
|
||||
aggregate.per_file.push({filename:this.files[i].name, report:d});
|
||||
} else {
|
||||
aggregate.errors.push({title:this.files[i].name, error:this.files[i].error||'Erreur'});
|
||||
aggregate.per_file.push({filename:this.files[i].name, report:{status:'error'}});
|
||||
}
|
||||
this.progress = Math.round(((i+1)/this.files.length)*100);
|
||||
}
|
||||
this.report = aggregate;
|
||||
this.busy = false;
|
||||
},
|
||||
async doUrl() {
|
||||
this.busy = true; this.urlError = ''; this.report = null; this.progress = 40;
|
||||
try {
|
||||
const r = await fetch('/api/import/url', {
|
||||
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
|
||||
body: JSON.stringify({url:this.urlValue.trim()})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { this.urlError = d.detail || 'Erreur'; return; }
|
||||
this.report = d; this.progress = 100;
|
||||
} catch(e) { this.urlError = 'Erreur réseau'; }
|
||||
finally { this.busy = false; }
|
||||
},
|
||||
async doForge(kind) {
|
||||
this.busy = true; this.forgeError = ''; this.report = null; this.progress = 30;
|
||||
const endpoint = kind === 'repo' ? '/api/import/forge-repo' : '/api/import/forge';
|
||||
try {
|
||||
const r = await fetch(endpoint, {
|
||||
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
|
||||
body: JSON.stringify({
|
||||
provider:this.forgeProvider, owner:this.forgeOwner.trim(),
|
||||
repo:this.forgeRepo.trim(), state:this.forgeState
|
||||
})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { this.forgeError = d.detail || 'Erreur'; return; }
|
||||
this.report = d; this.progress = 100;
|
||||
} catch(e) { this.forgeError = 'Erreur réseau'; }
|
||||
finally { this.busy = false; }
|
||||
},
|
||||
async pollJob(jobId, f) {
|
||||
for (let i=0;i<600;i++) {
|
||||
await new Promise(res => setTimeout(res, 700));
|
||||
const r = await fetch('/api/import/jobs/'+jobId);
|
||||
const j = await r.json();
|
||||
if (j.status === 'done') { if (f) f.status='done'; return j.report; }
|
||||
if (j.status === 'error') { if (f) { f.status='error'; f.error=j.error; } return null; }
|
||||
}
|
||||
if (f) { f.status='error'; f.error='Délai dépassé'; }
|
||||
return null;
|
||||
},
|
||||
downloadReport() {
|
||||
const blob = new Blob([JSON.stringify(this.report, null, 2)], {type:'application/json'});
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'flowdeck-import-report.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/import.js?v={{ asset_version }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1040
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2
-1094
File diff suppressed because it is too large
Load Diff
@@ -172,158 +172,5 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script data-cfasync="false" nonce="{{ csp_nonce() }}">
|
||||
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 %}
|
||||
|
||||
+2123
-1
File diff suppressed because it is too large
Load Diff
@@ -40,6 +40,9 @@ const browserGlobals = {
|
||||
Alpine: "readonly", htmx: "readonly", Sortable: "readonly",
|
||||
// Globals exposed on window by app.js
|
||||
openModal: "readonly", closeModal: "readonly",
|
||||
// getSvgIcon : script inline de base.html (nonce) ; Prism : CDN des vues ;
|
||||
// TextDecoder : API navigateur (ES2015)
|
||||
getSvgIcon: "readonly", Prism: "readonly", TextDecoder: "readonly",
|
||||
};
|
||||
|
||||
export default [
|
||||
|
||||
@@ -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 { /* volontaire */ } }
|
||||
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 { /* volontaire */ } 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 { /* volontaire */ } }
|
||||
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 { 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 { /* volontaire */ }
|
||||
},
|
||||
|
||||
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 { 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 {
|
||||
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 { /* volontaire */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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,148 @@
|
||||
/* exported setActiveTab, kanbanBoard, filterSystem, sortSystem, newIssueForm, showNewIssue -- appeles depuis les attributs HTML des templates */
|
||||
const BD=(()=>{try{const el=document.getElementById('bd-config');return el?JSON.parse(el.textContent):{}}catch{return {}}})();
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 { 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 { /* volontaire */ }
|
||||
},
|
||||
|
||||
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 {
|
||||
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 { 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 { /* volontaire */ }
|
||||
},
|
||||
|
||||
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 { /* volontaire */ }
|
||||
},
|
||||
|
||||
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 { /* volontaire */ }
|
||||
},
|
||||
|
||||
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 { /* volontaire */ }
|
||||
},
|
||||
|
||||
// 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 { /* volontaire */ }
|
||||
},
|
||||
|
||||
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,159 @@
|
||||
/* exported importWizard -- appeles depuis les attributs HTML des templates */
|
||||
|
||||
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 { /* volontaire */ }
|
||||
try {
|
||||
const c = await fetch('/api/csrf-token');
|
||||
this.csrf = (await c.json()).csrf_token;
|
||||
} catch { /* volontaire */ }
|
||||
},
|
||||
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 { 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 { 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 { 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 { 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{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
Vendored
+14
File diff suppressed because one or more lines are too long
Vendored
+661
@@ -0,0 +1,661 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 696 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 618 B |
@@ -0,0 +1,154 @@
|
||||
/* exported workspacesPage -- appeles depuis les attributs HTML des templates */
|
||||
|
||||
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 { /* volontaire */ }
|
||||
|
||||
// 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 { /* volontaire */ }
|
||||
|
||||
// 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 { /* volontaire */ }
|
||||
}
|
||||
|
||||
this.giteaGroups = groups;
|
||||
this.giteaTotal = groups.reduce((sum, g) => sum + g.projects.length, 0);
|
||||
this.giteaStatus = 'ok';
|
||||
} catch {
|
||||
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)}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user