Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5951c707eb | ||
|
|
f2f2f3209e | ||
|
|
f2e5684e4e | ||
|
|
b56b181c3e | ||
|
|
2fceed0da2 | ||
|
|
436898d86d | ||
|
|
e0237e576f | ||
|
|
189ed5bbca | ||
|
|
ce0d561ade | ||
|
|
95bc861cdb | ||
|
|
ea19d1d050 | ||
|
|
7f998faf7b | ||
|
|
0b251649e5 | ||
|
|
13d5f8625a | ||
|
|
d707a6850a | ||
|
|
f1ce34a8a6 | ||
|
|
b5207216f1 | ||
|
|
62620ef884 | ||
|
|
b0cb3a3923 | ||
|
|
e6afa004d0 | ||
|
|
bf3d1ac0cc | ||
|
|
f9da57c9e0 | ||
|
|
88e4ae1db8 | ||
|
|
a0db4d6e65 | ||
|
|
fb14c7e709 | ||
|
|
0d475c3d2d | ||
|
|
98af112ba1 | ||
|
|
7096707b3e | ||
|
|
9ab47d8113 | ||
|
|
41c1d315d3 | ||
|
|
4038e9bdad | ||
|
|
334a937507 | ||
|
|
c7d4fd901f | ||
|
|
7794a03934 | ||
|
|
9dfc38706c | ||
|
|
d4adf89db5 | ||
|
|
055956a351 | ||
|
|
3b3e95e23a | ||
|
|
37337a5de7 | ||
|
|
e8797afa05 | ||
|
|
3b00cbc371 | ||
|
|
d986959927 | ||
|
|
714642363b | ||
|
|
df78badd0c | ||
|
|
9836c85e24 | ||
|
|
61a33a7891 | ||
|
|
e707becbf8 | ||
|
|
da3e09c215 | ||
|
|
19e8ba0e4a | ||
|
|
7a6c66a609 | ||
|
|
3bb17eb984 | ||
|
|
bdc15c7328 | ||
|
|
c435e277f2 | ||
|
|
d7e0ace2b7 | ||
|
|
292f3b5851 | ||
|
|
d50fed52ce |
@@ -44,6 +44,11 @@ BACKUP_KEEP=30
|
||||
PROJECT_SYNC_ENABLED=true
|
||||
PROJECT_SYNC_INTERVAL_HOURS=1
|
||||
|
||||
# ── Public API v2 (v6.3.0) ──
|
||||
# PUBLIC_API_INSECURE_OK=true autorise le token de dev fd-public-key (jamais en prod).
|
||||
PUBLIC_API_INSECURE_OK=false
|
||||
API_V2_RATE_LIMIT_PER_TOKEN=300
|
||||
|
||||
# ── Email notifications (v4.9.0) ──
|
||||
# Laisser SMTP_HOST vide = pas d'envoi d'email (seulement les notifications in-app).
|
||||
SMTP_HOST=
|
||||
|
||||
+6
-3
@@ -109,8 +109,11 @@ FlowDeck est un **clone de Notion** intégré à Gitea. Il recrée l'expérience
|
||||
│ │ ├─ pages.py — /pages/... Pages CRUD │ │
|
||||
│ │ ├─ collections.py — /db/... Collections │ │
|
||||
│ │ ├─ editor.py — /api/editor/... Block editor │ │
|
||||
│ │ ├─ private.py — /api/private/* Section privée │ │
|
||||
│ │ ├─ public_api.py — /api/public/* Public API │ │
|
||||
│ │ ├─ public_api.py — /api/v1 Public API v1 │ │
|
||||
│ │ ├─ api_v2.py — /api/v2 Public API v2 │ │
|
||||
│ │ │ — Bearer + scopes, CRUD complet │ │
|
||||
│ │ ├─ web_clipper.py — /api/v2/web-clipper Web Clipper │ │
|
||||
│ │ ├─ permissions.py — /api/v2 (ACL) Permissions │ │
|
||||
│ │ ├─ workspace.py — Workspaces API + Gitea projets │ │
|
||||
│ │ ├─ webhooks.py — /webhooks/... Gitea hooks │ │
|
||||
│ │ └─ admin.py — /api/admin/* Admin users │ │
|
||||
@@ -1705,6 +1708,6 @@ docker compose restart flowdeck
|
||||
- **Automatisations** — Règles déclenchées sur événements (Notion-style)
|
||||
- **Base de données avancée** — Relations inter-collections, rollups
|
||||
- **Kanban flexible** — Colonnes custom, WIP limits
|
||||
- **API publique REST** — Tokens d'accès pour intégrations tierces
|
||||
- **API publique REST v2** — `/api/v2` (v6.3.0) : Bearer + scopes `read/write/admin`, CRUD complet, pagination, RFC 7807, idempotence, audit, OpenAPI (`/docs`, `docs/openapi-v2.json`) ; `/api/v1` lecture seule (compat)
|
||||
- **Volume Docker persistant** — `/data` monté pour survie des données
|
||||
- **PostgreSQL** — Migration optionnelle pour scaling
|
||||
|
||||
+505
@@ -1,5 +1,510 @@
|
||||
# Changelog - FlowDeck
|
||||
|
||||
## v6.5.0 (2026-09-24) — Synced blocks production (databases & vues)
|
||||
|
||||
> Les synced blocks passent en production et existent enfin dans les databases :
|
||||
> chaque ligne de database gagne une page contenu (éditeur complet), la lecture
|
||||
> résout la source côté serveur (zéro cache périmé) et la mise à jour d'une
|
||||
> source réécrit réellement toutes les pages référentes.
|
||||
|
||||
### Added
|
||||
|
||||
- **Page contenu par ligne de database** — migration 22 (`pages.collection_row_id INTEGER REFERENCES collection_pages(id) ON DELETE CASCADE` + index) ; `app/services/row_pages.py` : `ensure_row_page()` (création lazy, réparation si trashée, workspace hérité de la page hôte, `collection_id` hérité pour les ACL) ; sync de titre bidirectionnelle ligne ↔ page
|
||||
- **Endpoint `GET /db/pages/{row_id}/open/api`** — renvoie le `page_id` de contenu d'une ligne (garde `_require_view` collection) ; le peek de toute vue DB (table/board/gallery/list/calendar) passe par là
|
||||
- **Résolution serveur à chaque lecture** — `resolve_content_json()` sur les 2 routes `/pages/{id}` (dashboard + board), `GET /api/pages/{id}/content`, `GET /api/local-workspace/page-content/{id}`, page publique `/p/{slug}`, `GET /api/v2/pages/{id}` et les rooms realtime (`load_room`)
|
||||
- **Rendu public des synced blocks** — branche `synced` dans `_render_blocks_public` (avant : JSON brut en `<p>`) + état « Deleted synced block »
|
||||
- **Éditeur** — état « Deleted synced block » quand la source a été supprimée (au lieu du cache fantôme / « Loading… » éternel)
|
||||
- **API v2** — `content_page_id` sur `GET /api/v2/pages/{id}` pour une ligne (lecture sans création lazy)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Propagation des synced blocks** — `PUT /api/synced-blocks/{id}` réécrit le contenu stocké de chaque page référente via `sync_synced_blocks_in_page()` (fonction morte depuis v5.14.0), récursif dans les `children` (colonnes/toggles), broadcast WS seulement après réécriture DB
|
||||
- **Suppression d'une source** — ids des pages référentes collectés avant la cascade FK, `_synced_deleted` marqué dans le stocké (`mark_synced_block_deleted`), broadcast dédié `manager._broadcast_synced_to()`
|
||||
- **Ouverture des lignes de database** — le peek appelait `/pages/{rowId}` qui lit la table `pages` : avec des ids croisés il ouvrait une page au hasard (ligne id 5 « Ask AI » → page id 5 « Données ») ; désormais résolution via l'endpoint dédié
|
||||
- **Listings** — les pages contenu (`parent_section='DbRow'`) sont exclues (`AND collection_row_id IS NULL`) de l'arbre sidebar, des racines workspace, de la liste des pages du board, des projets builtins, de l'arbre Library et du `page_count` des workspaces
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v65_synced_db.py` — **17 tests** ; suite complète **742 verts** (`pytest -n auto`) ; `ruff check app tests` OK ; `eslint static/js` 0 problème
|
||||
|
||||
## v6.4.0 (2026-09-22) — Realtime editing (production)
|
||||
|
||||
> Le realtime passe en mode production : résolution de conflits au-delà du last-write-wins (merge à trois versions) et édition à grande échelle (broadcast non bloquant, coalescence des curseurs, corrections de fuites, observabilité).
|
||||
|
||||
### Added
|
||||
|
||||
- **Merge à 3-voix (diff3-lite)** — nouveau `app/services/realtime_merge.py` :
|
||||
- `merge_text_3way(base, current, incoming)` : fusion de caractères. Les régions modifiées qui ne se chevauchent pas sont **toutes conservées** (les deux saisies survivent) ; ordre d'arrivée indifférent ; un chevauchement réel retombe en LWW et est signalé.
|
||||
- `merge_block_3way(base, current, incoming)` : fusion **champ-par-champ**. Serveur et client modifiant des champs différents n'ont plus de conflit ; conflit limité au seul champ divergent (plus « tout le bloc perdu »).
|
||||
- Fonctions pures, testées sans WebSocket ni base de données.
|
||||
- **Protocole `base`** — chaque `update` envoyé par le client embarque la version du bloc dont dérive sa saisie ; le serveur calcule `merge_block_3way(base, current, incoming)`. Sans `base` (ancien client) → LWW historique, rétro-compatible.
|
||||
- **Adoption du résultat fusionné** — l'`ack` renvoie le bloc fusionné + drapeau `conflict` ; le client met à jour sa `base`, adopte le résultat (hors bloc en cours d'édition) et affiche un toast en cas de conflit. Le serveur diffuse toujours le bloc final fusionné pour convergence de tous les pairs.
|
||||
- **Broadcast non bloquant** — chaque connexion a une file sortante (`asyncio.Queue`) + une tâche `_writer` dédiée ; `_broadcast()` fait `put_nowait` et n'attend plus le socket → un client lent ne fige plus la room entière.
|
||||
- **Coalescence des curseurs** — le writer réduit les messages `sel` empilés à la position la plus récente (seule la dernière compte), en préservant l'ordre des messages importants.
|
||||
- **Anti-flood** — budget d'opérations par connexion (`OP_WINDOW_MAX=400` / fenêtre 10 s) ; au-delà, réponse `ack stale` sans application.
|
||||
- **Observabilité** — `GET /api/realtime/stats` (authentifié) : rooms, connexions (dont totales), ops, merges, conflits, déconnexions lentes, + détail par page.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Déconnexion des clients trop lents** — file sortante pleine (`MAX_OUT_QUEUE=512`) → fermeture 4413 + compteur `slow_disconnects`, pour qu'une room ne stagne jamais sur un pair mortel.
|
||||
- `app/templates/_page_editor_realtime.html` — les updates incluent désormais `base` ; nouveau traitement de l'`ack` avec `merged`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Fuite de rooms** — une tentative de connexion WS sur une page inexistante (4404) enregistrait une `Room` orpheline en mémoire pour toujours ; la room n'est plus enregistrée tant que la page n'est pas validée.
|
||||
- **`room_state()` sur page inexistante** — retournait/plantait sur `None` ; retourne désormais `{"blocks": [], "title": "", "version": 0}` sans laisser d'entrée dans `_rooms`.
|
||||
|
||||
### Tests
|
||||
|
||||
- **26 nouveaux tests** `tests/test_realtime_v64.py` : merge purs (régions disjointes, ordre indépendant, chevauchement → conflit, suppression de champ, id préservé), protocole WS (ack fusionné, convergence de 2 clients sur le même bloc, rétro-compat LWW sans `base`, conflit scalaire), fuite 4404, coalescence des curseurs, endpoint stats, anti-flood.
|
||||
- **14 tests** `tests/test_realtime.py` existants préservés (non-régression).
|
||||
- `ruff check app tests` OK · `eslint static/js` 0 problème.
|
||||
|
||||
---
|
||||
|
||||
## v6.3.0 (2026-09-21) — API publique complète v2 (REST + scopes + OpenAPI)
|
||||
|
||||
> L'API publique `/api/v2` devient une surface REST complète façon Notion : CRUD sur tous les domaines (collections, pages, propriétés, vues, commentaires, notifications, favoris, tags, partage, sprints, templates, forges, admin…), auth Bearer + scopes hiérarchiques, pagination/filtres/tri, erreurs RFC 7807, idempotence, audit et webhooks. Wrappers sur les services existants — un seul chemin de code.
|
||||
|
||||
### Migrations (v20)
|
||||
|
||||
- `api_tokens` : colonnes `scopes TEXT DEFAULT 'read,write'` et `expires_at TIMESTAMP` (ALTER idempotents, rétro-compatibles : anciens tokens → `read,write`).
|
||||
- Nouvelles tables `api_audit_log` (user, token, action, ressource, IP), `webhook_deliveries` (statut, http_code, durée, payload) et `idempotency_keys` (clé → réponse rejouée).
|
||||
|
||||
### Auth, scopes & conventions (`app/services/api_v2_helpers.py`)
|
||||
|
||||
- **Bearer unifié** `resolve_bearer_token()` / `get_bearer_user()` : hash sha256 sur `api_tokens`, support `extension_devices` (clipper) et legacy `user_tokens`, vérification `revoked` + `expires_at`, mise à jour `last_used_at`.
|
||||
- **Scopes hiérarchiques** `read < write < admin` (un scope supérieur satisfait un besoin inférieur), dépendance `require_scope()` → 403 explicite ; `validate_scopes_input()` → 400 sur scope inconnu.
|
||||
- **Pagination** `parse_pagination()` (défaut 30 / max 100) + header `X-Total-Count` ; **ISO-8601 UTC** via `to_iso8601()` ; parsing des colonnes `*_json`.
|
||||
- **Erreurs RFC 7807** `application/problem+json` (`type/title/status/detail/instance`) pour tout `/api/v2`, via le handler unifié sur `StarletteHTTPException` (`app/main.py`).
|
||||
- **Idempotence** `Idempotency-Key` sur les POST de création (`check_idempotency` / `store_idempotency`).
|
||||
- **Audit** `audit_log()` sur toutes les mutations v2 (`api_audit_log`).
|
||||
- **Rate limit par token** `check_v2_rate_limit()` (300 req/min, configurable `API_V2_RATE_LIMIT_PER_TOKEN`).
|
||||
- **`public_api_insecure_ok`** : le token de dev `fd-public-key` n'est accepté que si `PUBLIC_API_INSECURE_OK=true` (dev/test) — refusé par défaut en production.
|
||||
|
||||
### Router `app/routers/api_v2.py` (`prefix /api/v2`, ~100 endpoints)
|
||||
|
||||
- **Tokens** : `POST /tokens` (name, scopes, expires_at, montré une fois), `GET /tokens` (prefix only, jamais le hash), `DELETE /tokens/{id}`, `POST /tokens/{id}/rotate`.
|
||||
- **Users** : `GET/PATCH /users/me`, `GET /users/search`, préférences.
|
||||
- **Workspaces & membres** : CRUD + `GET/POST/PATCH/DELETE .../members`.
|
||||
- **Collections** : CRUD, `/linked`, `/task`, `/sources`.
|
||||
- **Pages** : `GET /collections/{id}/pages` (filtres `filter[prop]`, `sort`, `fields`, `query`), CRUD, `/restore`, `/move`, `/sub-items`, `/dependencies`.
|
||||
- **Propriétés** : CRUD, `/relation`, `evaluate-formula`, `compute-rollup`.
|
||||
- **Vues & dashboards** : CRUD + `save-as`.
|
||||
- **Comments / mentions / notifications / favoris / tags / recents**.
|
||||
- **Partage & publication** : `pages/{id}/shares`, `publish` / dé-publish (`/p/<slug>`).
|
||||
- **Historique** (`page_history` + `page_versions`) + restore.
|
||||
- **Sprints** (CRUD, assign, burndown), **templates** (page + database, apply), **export/import** (MD/HTML/PDF, CSV).
|
||||
- **Forges** : `GET /projects`, `/projects/{owner}/{repo}/tree`.
|
||||
- **Recherche** FTS5 (`GET /search`, repli LIKE).
|
||||
- **Admin** : users (list/patch/delete) + `GET /admin/audit-logs`.
|
||||
- **Webhooks** : CRUD subscriptions + `POST /{id}/test` + `GET /{id}/deliveries` (CRUD simple d'abord).
|
||||
|
||||
### OpenAPI & config
|
||||
|
||||
- `docs_url="/docs"` + `redoc_url="/redoc"` activés ; `docs/openapi-v2.json` généré (402 chemins).
|
||||
- `.env.example` : `PUBLIC_API_INSECURE_OK=false`, `API_V2_RATE_LIMIT_PER_TOKEN=300`.
|
||||
- `VERSION` et `app/main.py` passés à **6.3.0**.
|
||||
|
||||
### Tests & robustesse
|
||||
|
||||
- `tests/test_public_api_v2.py` — **24 tests** (auth 401, token lifecycle, scopes read/write/admin, pagination + `X-Total-Count`, CRUD collections/pages/propriétés/vues, filtres, RFC7807, idempotence, search, notifications, tags, sharing, webhooks, workspaces, sprints, templates, admin).
|
||||
- `tests/conftest.py` + fixtures locales : `PUBLIC_API_INSECURE_OK=true` (le token de dev reste testable sans impacter la prod).
|
||||
- Suite complète **668 verte** (`pytest -n auto`) ; `ruff check app tests` OK.
|
||||
|
||||
## v6.2.1 (2026-09-20) — Web Clipper : polish & fix bloc bookmark
|
||||
|
||||
> Patch UX et correctif bloc bookmark pour l'extension v6.2.0.
|
||||
|
||||
- **Extension — bouton flottant** — rond transparent draggable (évite de masquer le contenu), toggle d'affichage persistant (chrome.storage), clic → popup de capture ; position restaurée au reload.
|
||||
- **Refresh auto sidebar** — après `POST /api/v2/web-clipper/clip` le workspace sidebar est rafraîchi sans reload (polling + event `clipper:clipped`) pour que la page clippée apparaisse immédiatement.
|
||||
- **Bloc `bookmark`** — le service `create_page_from_clip()` renvoie désormais un bloc `bookmark` fidèle au payload OG (URL d'origine + `embed_src` résolu via `embeds.py`), rendu correct en éditeur / page publique `/p/<slug>` / exports MD/HTML/PDF (préserve favicon + description).
|
||||
- **Fix éditeur** `7f998fa` — SyntaxError `duplicate inner` dans `_page_editor_scripts.html` (variable `inner` redéclarée lors du drag & drop multi-sélection) corrigé ; autosave + WS realtime non bloqués.
|
||||
- **Tests & build** — `flowdeck-clipper.zip` régénéré (`static/extension/`), `ruff`/`eslint` verts, `16 tests` `test_web_clipper.py` verts.
|
||||
|
||||
## v6.2.0 (2026-09-19) — Web Clipper : extension navigateur (capture article/selection/bookmark/screenshot)
|
||||
|
||||
> Capturez n'importe quelle page web en une page FlowDeck : article complet, sélection, bookmark ou screenshot — depuis une extension Manifest V3 (Chrome/Edge/Firefox) + API directe `POST /api/v2/web-clipper/clip`.
|
||||
|
||||
### Extension navigateur (`extension/` + `static/extension/`)
|
||||
|
||||
- **Manifest V3** — `manifest.json` (permissions `activeTab, storage, scripting, contextMenus`, `host_permissions <all_urls>`), `background.js` (service worker OAuth + clip → fetch Bearer), `content.js` (bouton flottant + menu contextuel sélection + `Ctrl+Shift+C`), `popup.html`/`popup.js` (sélection workspace, type de capture), `clipper.css`, icônes 16/32/48/128, bundle `flowdeck-clipper.zip` servi à `/static/extension/`.
|
||||
- **Types de capture** — `article` (HTML complet → `sanitize_html` + `html_to_blocks`), `selection` (sélection HTML → Markdown), `bookmark` (URL + OG → carte bookmark v5.5.0), `screenshot` (base64 → upload image + page). Cap `10 MB` / `200 blocs`, garde SSRF inexistante (validation URL), sanitisation HTML côté serveur.
|
||||
|
||||
### Serveur (`app/routers/web_clipper.py` + `app/services/web_clipper.py`)
|
||||
|
||||
- **Endpoints** `prefix /api/v2/web-clipper` — `POST /clip` (crée page via `create_page_from_clip()` + `log_clip()`), `GET /status` (auth + compteurs devices/clips), `POST /auth/verify` (enregistre device `register_device()` → token `fd_…` montré une fois), `GET /devices`, `DELETE /devices/{id}` ; page HTML `GET /extensions` (téléchargement + liste devices/clips).
|
||||
- **Auth triple** — session cookie `flowdeck_session` OU Bearer `api_tokens` (hash `sha256`) OU Bearer `extension_devices` OU legacy `user_tokens` (`_user_from_request()`), rate-limit `50 clips/heure/device` (`_check_rate_limit`, 429), payload normalisé (`url`, `title`, `content`, `content_type`, `selection_html`, `image_base64`, `tags`, `target_workspace_id`).
|
||||
- **Tables migration 19** — `extension_devices` (`user_id, extension_name, device_id, device_name, token_hash, scopes, last_used_at, revoked, UNIQUE(user_id,extension_name,device_id)`) + `extension_clips` (`user_id, device_id, clip_type, source_url, target_page_id, target_workspace_id, title`) avec index `idx_ext_*`.
|
||||
- **Settings UI** — onglet Extensions dans `app/templates/settings.html` (devices, clips count, revoke, token copy).
|
||||
|
||||
### Tests & wiring
|
||||
|
||||
- `tests/test_web_clipper.py` — **16 tests** (sanitize, blocks, article/bookmark/selection/screenshot, Bearer, rate-limit, devices, `/extensions`).
|
||||
- Wiring `app/main.py:50,158` — `web_clipper_api_router` + `web_clipper_router` inclus ; `VERSION` bump `6.1.0 → 6.2.0`.
|
||||
|
||||
## v6.1.0 (2026-09-19) — Granular Permissions : page / collection / property ACL + groupes + audit
|
||||
|
||||
> Permissions fines héritables : chaque page / database / propriété peut être restreinte à des utilisateurs ou groupes explicites. L'héritage suit la chaîne page → collection → workspace (moindre privilège), avec bypass owner/admin et audit complet.
|
||||
|
||||
### Base de données (migration 18)
|
||||
|
||||
- Tables `user_groups` (workspace-scoped, UNIQUE(name)), `group_members` (N-ary), `page_permissions`, `collection_permissions`, `property_permissions` (user_id XOR group_id, CHECK, UNIQUE), `permission_audit_log`.
|
||||
- Colonnes `pages.permission_type`, `collections.permission_type`, `collection_pages.permission_type` (`inherit|restricted|private`, défaut `inherit`).
|
||||
- Index `idx_pp_page`, `idx_pp_user`, `idx_cp_collection`, `idx_propp_prop`, `idx_perm_audit_res`, `idx_gm_*`.
|
||||
|
||||
### PermissionManager (`app/services/permission_manager.py`)
|
||||
|
||||
- Résolution héritage + moindre privilège : grant explicite sur page > grant collection > rôle workspace ; `restricted`/`private` sans grant → 404.
|
||||
- Méthodes page : `get_page_permission()`, `can_view/edit/comment/manag` ; collection : `get_collection_permission()`, `can_view/edit/manag` ; propriété : `can_view/edit_property()`, `get_visible_properties()`.
|
||||
- Groupes : `create_group()`, `add/remove_user_from_group()`, `get_groups_for_workspace()`, `get_group_members()`, `is_workspace_admin()`, `user_group_ids()`.
|
||||
- Helpers : `_explicit_grant_role()` (best rank user+groups), `_collection_role()`, `_property_*`, `_owns_workspace()`, `_is_admin()`.
|
||||
- Cache 60 s (`_cached()`) + `invalidate()` appelé après chaque grant/revoke/type_change.
|
||||
- `log_permission_change()` (audit, never-throw).
|
||||
|
||||
### API (`app/routers/permissions.py` — `prefix /api/v2`)
|
||||
|
||||
- Pages : `GET /pages/{id}/permissions` (grants + mine + type + can_manage), `GET /pages/{id}/permissions/mine`, `POST /pages/{id}/permissions` (user_id|group_id + role viewer/commenter/editor/owner), `POST /pages/{id}/permissions/batch`, `DELETE /pages/{id}/permissions/{perm_id}`, `POST /pages/{id}/permission-type`.
|
||||
- Collections : `GET /collections/{id}/permissions`, `POST /collections/{id}/permissions`, `DELETE /collections/{id}/permissions/{perm_id}`, `POST /collections/{id}/permission-type`, `GET /collections/{id}/properties/visible` (split visible/hidden).
|
||||
- Properties : `GET /collections/{cid}/properties/{pid}/permissions`, `POST …/permissions` (role viewer|editor), `DELETE …/permissions/{perm_id}` (extra_cols collection_id).
|
||||
- Groupes : `GET /groups?workspace_id`, `POST /groups`, `PUT /groups/{id}`, `DELETE /groups/{id}`, `GET/POST /groups/{id}/members`, `DELETE /groups/{id}/members/{uid}` (workspace owner/admin only).
|
||||
- Access pickers + audit : `GET /users?workspace_id&q`, `GET /audit/permissions?limit` (owner/admin, 500 max).
|
||||
- Validation : 401 sans session, 403 sans can_manage, 400 rôle invalide / cible manquante, 404 user/group/property inconnu.
|
||||
|
||||
### Guards existants
|
||||
|
||||
- `board.py` : `GET /board/api/pages/{id}` (404 si !can_view_page), `PUT /board/api/pages/{id}` (404 si non-view, 403 si viewer/commenter, 423 si locked reste prioritaire).
|
||||
- `collections.py` : `DELETE /db/api/{id}` (404 si !can_view_collection, 403 si !can_manage), `GET /db/pages/{id}/api` (404), `PUT/DELETE /db/pages/{id}/api` (403), `GET /db/{id}/properties/api` filtré par visible, `_require_view/_require_edit` helpers avec `_session_user()` (pas de fallback admin sur null session).
|
||||
|
||||
### UI (backend-ready)
|
||||
|
||||
- Endpoints prêts pour panneau Permissions de l'éditeur (icône 🔒), panneau collection, masquage colonnes via `visible`/`hidden`, et gestion des groupes en Settings. Les grants sont consommés par `can_view_property` / `get_visible_properties` côté vues.
|
||||
|
||||
### Tests
|
||||
|
||||
- `tests/test_v60_granular_permissions.py` — **21 tests** : inherit allow + edit 403, restricted hidden (owner 200 / member 404), grant viewer unlock + viewer cannot edit, editor can edit but not manage, revoke 404, private + admin override, mine + batch, permission-type via API, restricted collection hidden (API 404 + HTML 302), collection viewer 403 create/delete, owner delete, collection editor create pages, property visibility, property hidden, property grant 403, group grant inherits + listing shows group, group removal revokes, groups listing/members + non-owner 403, audit log (grant+type_change, member 403), endpoints 401, validation 404/400. Suite 21/21 verte.
|
||||
|
||||
## v6.0.0 (2026-09-18) — PWA : Progressive Web App, offline support
|
||||
|
||||
> FlowDeck devient une PWA installable et utilisable hors ligne : le shell est
|
||||
> mis en cache par un service worker, les pages visitées restent disponibles,
|
||||
> et les modifications faites sans réseau sont mises en file d'attente dans
|
||||
> IndexedDB puis synchronisées au retour de la connexion, avec résolution de
|
||||
> conflits côté serveur.
|
||||
|
||||
### PWA & Service Worker
|
||||
- **`static/manifest.json`** + **`static/icons/*`** (72→512 + maskable + apple-touch) générés par `scripts/generate_pwa_icons.py`.
|
||||
- **`static/sw.js`** — precache du shell (CSS/JS/fonts/icônes), stratégies cache-first (assets) / network-first (HTML, API GET), page offline inline, Background Sync (`sync-flowdeck`), purge des anciens caches.
|
||||
- **`base.html` / `landing.html`** — meta PWA (manifest, theme-color, apple-mobile-web-app) + enregistrement du service worker. Routes `GET /manifest.json` et `GET /sw.js` servies par FastAPI.
|
||||
|
||||
### Offline client (`static/js/offline.js`)
|
||||
- Base IndexedDB `flowdeck-offline` : `pages_offline`, `collections_offline`, `sync_queue`, `sync_meta`.
|
||||
- `window.FlowOffline` : `enqueue`, `flush`, `delta`, `status`, `savePageOffline`, `getQueue`, `pendingCount`, `onChange`, `markDirtySidebar`.
|
||||
- Sauvegarde optimiste hors ligne (hook de l'éditeur), file de mutations rejouée automatiquement (online, Background Sync, polling 30 s).
|
||||
- Durcissement : max 100 mutations/batch, timeout 30 s (`AbortController`), rétention queue 30 jours.
|
||||
|
||||
### Serveur de synchronisation
|
||||
- **`app/services/sync_engine.py`** — `get_delta` (pull des changements, détection des soft-deletes), `apply_batch` (mutations optimistes + contrôle de version `sync_version`).
|
||||
- **`app/routers/sync.py`** — `GET /api/v2/sync/delta`, `POST /api/v2/sync/batch`, `GET /api/v2/sync/status` (auth session, CSRF-exempt via `/api/v2`).
|
||||
- **Migrations** — table `offline_sync_queue` (+ index) et colonnes `sync_version` sur `pages`, `collection_pages`, `collections` avec triggers `AFTER UPDATE`.
|
||||
- **Conflits** — edit-edit (last-write-wins + rapport), edit-delete (page orpheline recréée), create-create (renommage « copie offline »).
|
||||
|
||||
### UI
|
||||
- Banner hors ligne avec compteur de modifications en attente, badge de synchronisation (spinner), toasts de fin de sync, icône ⟳ sur les pages non synchronisées.
|
||||
- Section `/help` « Offline mode (PWA) ».
|
||||
|
||||
### Tests
|
||||
- `tests/test_sync.py`, `tests/test_sync_migrations.py`, `tests/test_service_worker.py`, `tests/test_pwa_offline.py` ; E2E Playwright `e2e/pwa_offline.spec.js` (manifest, SW, queue offline, replay, navigation hors ligne).
|
||||
|
||||
## v5.12.0 (2026-09-14) — v5.11.0 Wiki-links & v5.12.0 Templates & lock
|
||||
|
||||
> Deux versions roadmap livrées ensemble : le graphe de connaissances
|
||||
> Notion (liens `[[`, mentions de page et de date, renommage propagé) et
|
||||
> les options de page (galerie de templates globale, verrouillage
|
||||
> lecture-seule, pleine largeur, texte compact).
|
||||
|
||||
### v5.11.0 — Wiki-links & mentions de page
|
||||
- **Service** `app/services/wiki_links.py` — tokens stockés dans le texte
|
||||
des blocs : `[[fdpage:ID]]` (lien interne résolu à l'affichage) et
|
||||
`[[fddate:YYYY-MM-DD]]` (chip de date). Le token ne contient que l'id →
|
||||
**renommage propagé** automatiquement.
|
||||
- **Endpoints** — `GET /board/api/wiki/pages?q=` (picker, recherche
|
||||
substring + sous-séquence floue « mnt » → « Meeting notes ») et
|
||||
`GET /board/api/wiki/titles?ids=` (labels courants en batch, icônes
|
||||
incluses, « Deleted page » si supprimée).
|
||||
- **Éditeur** — taper `[[` ouvre le picker de pages (recherche live,
|
||||
navigation clavier ↑↓/Entrée/Échap, sélection à la souris), Enter
|
||||
insère une **chip de page** atomique (icône + titre, clique = navigate).
|
||||
Le menu `@` existant gagne deux sections : **Pages** (`@Nom` → lien
|
||||
inline) et **Date** (`@today`, `@tomorrow`, `@YYYY-MM-DD` → chips).
|
||||
Les chips sont relu en tokens bruts (`gtTok`) à chaque sync : le drag,
|
||||
le split Entrée, l'undo/redo et l'autosave préservent les liens.
|
||||
- **Backlinks** — le scanner v5.4.0 reconnaît désormais les tokens
|
||||
`[[fdpage:ID]]` : « Lié depuis » fonctionne avec les wiki-links.
|
||||
- **Page publique** — `_render_blocks_public` résout les chips (liens
|
||||
absolus + dates lisibles, HTML échappé).
|
||||
|
||||
### v5.12.0 — Templates & verrouillage de page
|
||||
- **Template picker global** — « + New page » (sidebar, footer, palette
|
||||
Ctrl+K) ouvre la galerie : 5 templates built-in (`app/services/
|
||||
block_templates.py` : Empty, Meeting notes, Weekly report, To-do list,
|
||||
Project doc) + les templates personnels sauvegardés ; « Empty » retombe
|
||||
sur la création classique (workspace item).
|
||||
- **Save as template** — menu « … » de la page → capture le contenu blocs
|
||||
courant comme template personnel (`page_global_templates`, visibles
|
||||
uniquement par leur créateur, `created_by IS NULL` = partagé).
|
||||
- **Bouton « Use template »** — `POST /board/api/page-templates/{id}/use`
|
||||
(id 0 = built-in par clé) duplique le contenu des blocs dans une
|
||||
nouvelle page du workspace de l'utilisateur et renvoie son id.
|
||||
- **Page lock** — menu « … » → « 🔒 Lock page » : bannière sticky
|
||||
« locked (read-only) », blocs et titre passés `contenteditable=false`,
|
||||
handles/actions masquées. Serveur : `POST /board/api/pages/{id}/lock`,
|
||||
garde `_ensure_page_editable` → **423** sur `PUT /api/pages/{id}` et
|
||||
`POST /api/pages/{id}/blocks` pour tout non-privileged ; déverrouillage
|
||||
réservé à la personne qui a verrouillé (`locked_by`) ou un admin (403
|
||||
sinon).
|
||||
- **Full-width / Small text** — toggles dans le menu « … », persistés par
|
||||
page (`pages.full_width`, `pages.font_small`), options `POST
|
||||
/board/api/pages/{id}/options`, rendu via classes CSS
|
||||
(`.full-width` / `.small-text`).
|
||||
- **Migrations 13** — `pages.is_locked`, `pages.locked_by`,
|
||||
`pages.full_width`, `pages.font_small`, table `page_global_templates`.
|
||||
- **Tests** — `tests/test_v511_v512_wiki_templates.py` : **15 tests**
|
||||
(schéma, helpers tokens + échappement, picker flou, renommage propagé,
|
||||
persistance des tokens + backlinks, chips en page publiée, lock 423 /
|
||||
403 / admin / ré-ouverture, options, templates built-in + perso +
|
||||
cloisonnement, shapes service, câblage front). Suite complète
|
||||
**538 verte**, `ruff check` OK.
|
||||
- **Version** — 5.12.0.
|
||||
|
||||
## v5.11.7 (2026-09-13) — v5.8.0 Calendrier & Rappels
|
||||
|
||||
> Calendrier complet (jour / semaine / mois) avec récurrences expandues
|
||||
> côté serveur, rappels avant échéance (in-app + email), fuseaux horaires
|
||||
> par utilisateur et par événement, notifications d'assignation et centre
|
||||
> de notifications enrichi.
|
||||
|
||||
- **Moteur de récurrence** — `app/services/recurrence.py` : sous-ensemble
|
||||
RRULE (daily/weekly/monthly, intervalle, count, until, byweekday lundi=0,
|
||||
timezone IANA) ; expansion virtuelle par fenêtre, jamais persistée,
|
||||
bornée (garde anti-explosion). `validate_rule()` pour le 400 en écriture.
|
||||
- **Vues Jour / Semaine / Mois** — la vue calendar de
|
||||
`_database_table_scripts.html` gagne un sélecteur de mode (persisté dans
|
||||
`viewConfig.calendar_mode`), navigation ‹/› adaptée au mode, vue Jour en
|
||||
agenda trié par heure, Semaine en 7 colonnes. Les événements viennent de
|
||||
`GET /db/{id}/calendar/api` (expansion serveur des occurrences).
|
||||
- **Récurrences dans l'UI** — double-clic sur un événement : popover Time /
|
||||
Timezone / Repeat (Every + Ends never|after count|on date) / Remind.
|
||||
Règle stockée dans `property_values_json` sous `__recurrence__` (clé méta
|
||||
par id de propriété date), badge ↻ sur les chips et cellules date.
|
||||
- **Rappels** — `app/services/reminders.py` : `scan_and_fire(now)`
|
||||
déterministe (lead minutes/heures/jours, occurrence suivante
|
||||
timezone-aware), `reminder_scheduler()` toutes les 60 s ; dédup via la
|
||||
table `reminder_log (page_id, occurrence_date)` ; cible = personnes
|
||||
assignées, repli premier admin ; notifie en in-app + email (pref
|
||||
`reminders`). Config `reminders_enabled` /
|
||||
`reminder_scan_interval_seconds`.
|
||||
- **Fuseaux horaires** — colonne `users.timezone` ; réglable dans Settings →
|
||||
Notifications (picker IANA via `GET/POST /api/notifications/timezone`,
|
||||
liste `GET /db/timezones/api`) ; priorité de résolution par ligne :
|
||||
`__timezone__` de l'événement → règle de récurrence → timezone de
|
||||
l'utilisateur.
|
||||
- **Notifications d'assignation** — `notify_assignment()` compare les
|
||||
propriétés `person` avant/après sur `PUT /db/pages/{id}/api` : les
|
||||
nouvellement assignés reçoivent une notif in-app + email (pref
|
||||
`assignments`). Préférences étendues : `comments`, `mentions`,
|
||||
`reminders`, `assignments`.
|
||||
- **Template Meeting notes** — propriétés `Agenda` et `Notes` ajoutées
|
||||
(migration 12 ; ne touche pas une version personnalisée du template).
|
||||
- **Migrations 11 & 12** — `reminder_log`, `users.timezone`, enrichissement
|
||||
du template seed.
|
||||
- **Tests** — `tests/test_v58_calendar_reminders.py` : **20 tests**
|
||||
(moteur pur, schéma, validation des méta-clés 400, calendar/api,
|
||||
timezone user, scanner avec horloge injectée + dédup + récurrence +
|
||||
cible assignée, notifications d'assignation, prefs, câblage front).
|
||||
Suite complète **523 verte**, `ruff check` OK.
|
||||
- **Version** — 5.11.7.
|
||||
|
||||
## v5.11.6 (2026-09-13) — v5.7.0 Database Avancée (Pt. 2)
|
||||
|
||||
> Complète la parité Notion des bases de données : personnes et propriétés
|
||||
> automatiques, groupes de propriétés, vues sauvegardées par utilisateur,
|
||||
> swimlanes Kanban + WIP limits, cartes configurables, calendar avec
|
||||
> glisser-déposer et gallery avec couvertures.
|
||||
|
||||
- **Types propriété** — `person`, `created_time`, `created_by`,
|
||||
`last_edited_time`, `last_edited_by` câblés de bout en bout.
|
||||
`apply_auto_properties()` (property_types) remplit/rafraîchit les valeurs
|
||||
automatiques à la création et à la mise à jour des lignes ; `created_*` est
|
||||
figé, `last_edited_*` est rafraîchi. Nouvel endpoint
|
||||
`GET /db/{collection_id}/members/api` (membres du workspace, repli tous
|
||||
utilisateurs) alimente le sélecteur `person` (chips + avatars).
|
||||
- **Groupes de propriétés** — colonne `collection_properties.group_name`,
|
||||
en-tête de table avec sections pliables (`db-group-row` + chevron) et
|
||||
`POST /db/{collection_id}/property-groups/api` pour réassigner les colonnes.
|
||||
- **Vues sauvegardées par utilisateur** — `collection_views.created_by` ;
|
||||
`GET /db/{id}/views/api` ne renvoie que les vues de l'utilisateur (les vues
|
||||
partagées `NULL` restent visibles), `save-as`/`duplicate` attribuent le
|
||||
propriétaire, `DELETE /db/views/{id}/api` et renommage.
|
||||
- **Swimlanes Kanban** — `sub_group_by` : une rangée de colonnes par lane
|
||||
(2e dimension de groupement).
|
||||
- **WIP limits** — `wip_limits` par colonne, colonne surlignée + compteur
|
||||
`WIP n/limit` au dépassement.
|
||||
- **Cartes configurables** — `card_properties` (propriétés affichées),
|
||||
`card_size` (compact/détaillé) et couverture `cover_mode`
|
||||
(`none`/`icon`/`color`/`property` via `cover_property`).
|
||||
- **Calendar drag & drop** — vue mois (`date_property` configurable),
|
||||
navigation ‹/›/Today, glisser une carte sur un jour = reschedule immédiat.
|
||||
- **Gallery avec couvertures** — vignettes image (propriété `files`/`url`),
|
||||
icône, couleur ou cover de ligne ; tailles small/medium/large.
|
||||
- **Composant multi-vues** — `_database_table_scripts.html` réécrit :
|
||||
barre de vues (ajout/suppression/duplication/paramètres), vues
|
||||
table/board/calendar/gallery/list, éditeurs de cellules par type
|
||||
(select/status/date/number/checkbox/multi_select/person), cellules
|
||||
read-only pour les types automatiques, popover de configuration.
|
||||
- **Correctif** — `PUT /db/pages/{id}/api` fusionne désormais les valeurs
|
||||
soumises avec l'existant (PATCH partiel) au lieu de remplacer toute la
|
||||
ligne ; `cover_url` par ligne (`collection_pages.cover_url`).
|
||||
- **Migration 10** — `collection_properties.group_name`,
|
||||
`collection_views.created_by`/`updated_at`, `collection_pages.cover_url`.
|
||||
- **Tests** — `tests/test_v57_db_advanced.py` : **12 tests**. Suite complète
|
||||
**503 verte**, `ruff check` OK.
|
||||
- **Version** — 5.11.6.
|
||||
|
||||
## v5.11.5 (2026-09-13) — v5.6.0 Import de données (Phase 5 — durcissement)
|
||||
|
||||
> Finalise la solution d'import : ré-import incrémental, lots, URL, dépôt forge,
|
||||
> relations Notion et rapports exportables. **v5.6.0 est complet (Phases 0→5).**
|
||||
|
||||
- **Import incrémental / re-sync** — nouveau paramètre `mode` (`skip` par défaut,
|
||||
`update` = mise à jour des pages et upsert des lignes par titre, `duplicate`).
|
||||
Compteurs `pages_updated` / `rows_updated` dans le rapport.
|
||||
- **Erreurs partielles** — l'import continue page par page en cas d'échec
|
||||
(`status: "partial"`, liste `errors`), au lieu d'abandonner.
|
||||
- **Import de dépôt forge** — `POST /api/import/forge-repo` : arborescence
|
||||
Gitea/GitHub → pages (dossiers conservés, fichiers texte, code en blocs ;
|
||||
`GitHubAdapter.list_repo_files`, `GiteaForgeAdapter.list_repo_files`).
|
||||
- **Web clipper** — `POST /api/import/url` : fetch d'une page (garde SSRF :
|
||||
loopback/privé/link-local refusés), conversion HTML → blocs + carte bookmark
|
||||
(OG metadata).
|
||||
- **Lot multi-fichiers** — `POST /api/import/run-batch` (rapport par fichier) et
|
||||
file d'attente dans l'assistant `/import` (sélection multiple, statut par
|
||||
fichier, barre de progression).
|
||||
- **Relations Notion** — `POST /api/import/relations/resolve` : les colonnes
|
||||
texte qui référencent les titres d'une autre collection deviennent des
|
||||
propriétés `relation` (valeurs = ids de `collection_pages`). Auto-exécuté
|
||||
après un import Notion (clé `relations` du rapport).
|
||||
- **Rapport exportable** — `GET /api/import/jobs/{id}/report` (JSON) + bouton
|
||||
« Télécharger le rapport » dans l'UI.
|
||||
- **Correctif** — les lignes importées stockent désormais leurs valeurs par **id
|
||||
de propriété** (au lieu du nom) : rendu correct dans les vues database et
|
||||
compatibilité avec les relations/rollups.
|
||||
- **UI** — assistant `/import` enrichi : multi-fichiers, mode de ré-import,
|
||||
import URL, import forge (issues **et** fichiers), téléchargement du rapport.
|
||||
- **Tests** — `tests/test_v56_import.py` porté à **43 tests** (incrémental ×4,
|
||||
lot, URL ×2, forge-repo ×2, relations, rapport de job). Suite complète
|
||||
**491 verte**, `ruff check` OK.
|
||||
- **Version** — 5.11.5.
|
||||
|
||||
## v5.11.4 (2026-09-13) — v5.6.0 Import de données (Phases 3 & 4)
|
||||
|
||||
> Complète le chantier v5.6.0 : documents/bureautique (Phase 3) et
|
||||
> signets/dev/divers (Phase 4). **v5.6.0 est désormais complet** (5 phases).
|
||||
|
||||
- **Phase 3 — Documents & bureautique**
|
||||
- **Word `.docx`** (`python-docx`) — titres, listes, citations, tableaux GFM
|
||||
et images inline → page ; couvre aussi les exports Google Docs Takeout.
|
||||
- **PDF** (`pypdf`) — extraction texte par page + images → page ; avertissement
|
||||
si pages sans couche texte (scan).
|
||||
- **HTML fichiers/dossier** — détection élargie (archive contenant ≥ 1 HTML),
|
||||
assets collectés comme pièces jointes.
|
||||
- **Phase 4 — Signets, dev & divers**
|
||||
- **Forge issues** — `POST /api/import/forge` (Gitea/GitHub) : issues → collection
|
||||
(Number, State, Labels, Milestone, Assignee, dates, URL, Body) + collections
|
||||
Labels et Milestones. `GitHubAdapter.list_issues/list_labels/list_milestones`
|
||||
ajoutés ; token par utilisateur (`user_oauth_tokens`).
|
||||
- **Signets** — Raindrop.io, Pocket (CSV/HTML), Readwise (highlights CSV/MD),
|
||||
Shaarli (JSON) et signets Netscape HTML → collection (URL, description,
|
||||
tags, date).
|
||||
- **Calendrier `.ics`** — parseur RFC 5545 (VEVENT) → collection d'événements.
|
||||
- **OPML** — flux RSS/outlines → collection (URL, dossier).
|
||||
- **Standard Notes** — sauvegarde JSON → pages (notes chiffrées ignorées).
|
||||
- **UI** — l'assistant `/import` liste désormais les 20 sources, accepte
|
||||
`.docx/.pdf/.ics/.opml` et propose un panneau « import depuis une forge ».
|
||||
- **Dépendances** — `python-docx`, `pypdf` ajoutés.
|
||||
- **Tests** — `tests/test_v56_import.py` étendu à **32 tests** (signets ×5,
|
||||
ics/opml/standard notes, docx, pdf, forge ×4). Suite complète verte,
|
||||
`ruff check` OK.
|
||||
- **Version** — 5.11.4.
|
||||
|
||||
## v5.11.3 (2026-09-13) — v5.6.0 Import de données (Phases 0, 1, 2)
|
||||
|
||||
> Démarre le chantier v5.6.0 « Import de données » avec le **socle unifié**
|
||||
> (Phase 0) et les deux premières familles de sources : **Notes & Markdown**
|
||||
> (Phase 1) et **Données & tableaux** (Phase 2).
|
||||
|
||||
- **Socle d'import unifié (Phase 0)** — package `app/services/importers/` :
|
||||
modèle normalisé (`ImportResult`, `ImportPage`, `ImportAttachment`), interface
|
||||
`Importer` (`detect()` / `parse()`) + registre auto-détection, pipeline commun
|
||||
(hiérarchie `parent_id`, pièces jointes, réécriture des liens, collections +
|
||||
lignes, rapport d'import), jobs d'arrière-plan (`app/services/importers/jobs.py`,
|
||||
polling), déduplication idempotente (table `import_items`), migration 9
|
||||
(`import_items` + `import_jobs`).
|
||||
- **Assistant UI d'import** — page `/import` (template `import.html`) : choix de
|
||||
source ou détection auto, glisser-déposer, **aperçu dry-run**, **mapping
|
||||
colonnes → types** (override des types inférés), barre de progression, rapport.
|
||||
Lien « Assistant d'import avancé » ajouté à la modale Import de l'éditeur.
|
||||
- **API** — `GET /api/import/sources`, `POST /api/import/preview`,
|
||||
`POST /api/import/run` (sync ou `async=true`), `GET /api/import/jobs[/{id}]`.
|
||||
- **Phase 1 — Notes & Markdown** : Obsidian (vault `.zip`, frontmatter YAML,
|
||||
`[[wikilinks]]`, `![[embeds]]`, pièces jointes, hiérarchie de dossiers),
|
||||
Notion (export `.zip`, hiérarchie complète, databases `.csv` → collections,
|
||||
images), Logseq & Roam (outliner, propriétés `key:: value`, pages journal,
|
||||
`{{[[TODO]]}}`), HTML (Apple Notes, Bear, Ulysses, OneNote), Google Keep
|
||||
(Takeout JSON), plus l'import Markdown/`.zip` générique.
|
||||
- **Phase 2 — Données & tableaux** : CSV/TSV **typé** (inférence
|
||||
texte/nombre/date/checkbox/email/url/select/multi_select + mapping manuel),
|
||||
Excel `.xlsx` (une collection par feuille, openpyxl), JSON générique (tableau
|
||||
d'objets → collection).
|
||||
- **Correctif** — `_md_to_blocks` reconnaît désormais les cases à cocher
|
||||
`- [ ]`/`- [x]` (elles étaient absorbées par la branche puces à puces).
|
||||
- **Dépendances** — `openpyxl`, `beautifulsoup4`, `PyYAML` ajoutés.
|
||||
- **Tests** — `tests/test_v56_import.py` : 18 tests (registre, dry-run, CSV typé,
|
||||
dédup, TSV, Excel multi-feuilles, JSON, Obsidian, Notion, Logseq, Roam, HTML,
|
||||
Google Keep, mapping, wizard, job async). Suite complète **463+ verte**,
|
||||
`ruff check` OK.
|
||||
|
||||
## v5.11.2 (2026-09-12) — v5.5.0 Embeds & Média riche (complétion)
|
||||
|
||||
> Valide et complète le chantier v5.5.0 (parité embeds Notion). Une grande
|
||||
> partie était ébauchée ; ce patch répare les chemins d'API cassés côté éditeur,
|
||||
> finalise la résolution d'embed, le parsing OG, la lightbox publique et couvre
|
||||
> le tout de 47 tests dédiés.
|
||||
|
||||
- **Embed universel** — `app/services/embeds.py` réécrit : détection
|
||||
sous-domaine correcte, YouTube (watch/shorts/youtu.be), Vimeo, Figma,
|
||||
Google Maps, Google Docs/Sheets/Slides, Loom, CodePen, Miro, Spotify,
|
||||
SoundCloud, Twitch (parent réel), X/Twitter, Pinterest, Microsoft Office ;
|
||||
helpers `resolve_embed()`, `inline_kind()`, `provider()`. Nouvel endpoint
|
||||
`POST /board/api/embed/resolve`.
|
||||
- **Résolution d'embed dans l'éditeur** — la saisie d'une URL appelle le
|
||||
résolveur et met en cache `embed_src`/`embed_provider` (l'URL d'origine est
|
||||
conservée) ; le rendu (éditeur, pages publiques, exports MD/HTML/PDF) préfère
|
||||
`embed_src`. Le champ est persisté via la sérialisation des blocs.
|
||||
- **Bookmark cards** — `app/services/og_fetcher.py` : parseur `<meta>` robuste
|
||||
(ordre d'attributs libre), titre de repli, favicon, transport injectable pour
|
||||
les tests ; `fetch_og_metadata` ne lève jamais sur erreur réseau.
|
||||
- **Image lightbox** — navigation clavier ←/→ et Escape, multi-images, dans
|
||||
l'éditeur **et** les pages publiques `/p/<slug>` (CSS `.fd-lightbox`).
|
||||
- **Fix chemins API (bug v5.4.0/v5.5.0)** — l'éditeur appelait
|
||||
`/api/pages/...` alors que les routes sont `/board/api/pages/...` : cover,
|
||||
icon, versions, backlinks, import, move et OG metadata étaient donc en 404.
|
||||
Tous corrigés, avec un test de régression statique.
|
||||
- **Tests** — `tests/test_v55.py` : 47 tests (embeds par provider, resolve API,
|
||||
parse OG + MockTransport, lightbox, préviews, cover upload/URL/suppression,
|
||||
icône, page publique). Suite complète **444 verte**, `ruff check` OK.
|
||||
- **Version** — 5.11.2.
|
||||
|
||||
## v5.11.1 (2026-09-11) — v5.2.0 Infrastructure & Polish (complétion)
|
||||
|
||||
> Finalise le chantier v5.2.0 : la plupart des briques étaient déjà livrées
|
||||
|
||||
+1
-1
@@ -41,4 +41,4 @@ EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:8080/api/health || exit 1
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers", "--forwarded-allow-ips", "*"]
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Clone complet de **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Calendar, Gallery, Timeline, List, Multi-Users.
|
||||
|
||||
> **v2.1.0** — API publique, Webhooks sortants, PWA
|
||||
> **v6.4.0** — Realtime production (merge 3-voix, broadcast non bloquant), API publique v2, PWA offline
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -49,10 +49,12 @@ docker compose up -d
|
||||
- **CSV Import/Export**
|
||||
- **Public Sharing**: lien de partage lecture seule
|
||||
|
||||
### API & Intégrations (v2.1)
|
||||
- **API publique REST**: `/api/v1` avec token auth
|
||||
- **Webhooks sortants**: gestion + dispatcher d'événements
|
||||
- **PWA**: manifest.json, prêt pour installation mobile
|
||||
### API & Intégrations (v6.3)
|
||||
- **API publique REST v2**: `/api/v2` — CRUD complet, Bearer + scopes `read/write/admin`, pagination, filtres, erreurs RFC 7807, idempotence, audit — [guide](docs/API_GUIDE_V6.md) · OpenAPI `/docs`
|
||||
- **API publique v1**: `/api/v1` (lecture seule, compat)
|
||||
- **Webhooks sortants**: gestion + dispatcher d'événements (CRUD v2)
|
||||
- **Web Clipper**: extension navigateur Manifest V3 (article/sélection/bookmark/screenshot)
|
||||
- **PWA**: manifest.json + service worker, offline support
|
||||
|
||||
### UI Notion-Style (v1.1–v1.2)
|
||||
- Sidebar gauche avec sections hiérarchiques
|
||||
@@ -85,7 +87,7 @@ DATABASE_URL=sqlite:////data/flowdeck.db
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
python3 -m pytest tests/ -v # 73/73 passent
|
||||
python3 -m pytest tests/ -v # 725/725 passent
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
+350
-69
@@ -513,50 +513,142 @@ app/
|
||||
- [x] **Fallback polling** — si WS indisponible, rafraîchissement diff toutes les 10 s (adopté seulement sans brouillon local) + reconnexion automatique
|
||||
- [x] CSP `connect-src` étendu à `ws:` ; **12 tests** `tests/test_realtime.py` ; suite complète 298 verte (+3 PDF pré-existants)
|
||||
|
||||
### v5.4.0 — Expérience éditeur (nouveautés, parité Notion)
|
||||
- [ ] **Backlinks** — section « Lié depuis… » en bas de page (scan des liens internes)
|
||||
- [ ] **Duplicates** — « Duplicate » sur page + collection (menu `...`)
|
||||
- [ ] **Corbeille globale améliorée** — vue cross-workspace + purge automatique après 30 jours
|
||||
- [ ] **Historique de version UI** — browser + restaurer une version (table `page_history` existe)
|
||||
- [ ] **Import** — Markdown/CSV/Notion (complète l'export v4.7.0, facilite la migration d'utilisateurs)
|
||||
### v5.4.0 — Expérience éditeur ✅ (2026-09-11)
|
||||
> **Objectif** : parité éditeur Notion — backlinks, duplication, corbeille, versions, import. **COMPLETED**.
|
||||
|
||||
### v5.5.0 — Embeds & Média Riche
|
||||
- [x] **Backlinks** — `GET /board/api/pages/{id}/backlinks` (scan des liens internes `/pages/<id>`) + popover « Lié depuis… »
|
||||
- [x] **Duplicates** — `POST /board/api/pages/{id}/duplicate` (copie profonde des blocs) + `POST /db/{id}/duplicate` (vues + propriétés + pages) ; entrées « Duplicate » dans les menus `...`
|
||||
- [x] **Corbeille globale améliorée** — vue cross-workspace `GET /board/api/trash` + `app/services/trash.py` (purge automatique > 30 jours, scheduler quotidien)
|
||||
- [x] **Historique de version UI** — snapshots `page_versions` à chaque sauvegarde modifiée, `GET /api/pages/{id}/versions`, `POST .../versions/{vid}/restore`, popover « Version history »
|
||||
- [x] **Import** — `POST /api/pages/import` (markdown) + `POST /api/pages/import/file` (.md/.txt/.zip d'export Notion) ; import CSV collections `POST /workspace/collections/{id}/import/csv`
|
||||
- [x] **17 tests** `tests/test_v54.py` ; suite complète **397 verte**
|
||||
|
||||
### v5.5.0 — Embeds & Média Riche ✅ (2026-09-12)
|
||||
> **Objectif** : parité avec les 60+ embeds Notion — contenu tiers rendu dans la page.
|
||||
> **Source** : analyse Notion clone (delta v4 → v5.5, 2026-09-04).
|
||||
> **Source** : analyse Notion clone (delta v4 → v5.5, 2026-09-04). **COMPLETED**.
|
||||
|
||||
- [ ] **Bloc Embed universel** — `/embed` : YouTube, Vimeo, Figma, Google Maps, Loom, X/Twitter, CodePen, Miro, Spotify, SoundCloud, Twitch, Office…
|
||||
- [ ] **Bookmark cards** — aperçu riche des URLs (métadonnées OG : titre, image, description)
|
||||
- [ ] **Image lightbox** — clic pour agrandir, navigation clavier/consultation plein écran
|
||||
- [ ] **Previews inline** — PDF, vidéo, audio rendus directement dans la page
|
||||
- [ ] **Cover & icône de page** — upload image de couverture + emoji/icône custom (déjà partiel ?)
|
||||
- [x] **Bloc Embed universel** — `/embed` : YouTube (watch/shorts/youtu.be), Vimeo, Figma, Google Maps, Google Docs/Sheets/Slides, Loom, X/Twitter, CodePen, Miro, Spotify, SoundCloud, Twitch, Pinterest, Office…
|
||||
- [x] **Bookmark cards** — aperçu riche des URLs (métadonnées OG : titre, image, description, site, favicon)
|
||||
- [x] **Image lightbox** — clic pour agrandir, navigation clavier (←/→) + plein écran dans l'éditeur **et** les pages publiques
|
||||
- [x] **Previews inline** — PDF, vidéo, audio rendus directement dans la page
|
||||
- [x] **Cover & icône de page** — upload image de couverture (fichier ou URL) + emoji/icône custom
|
||||
|
||||
### v5.6.0 — Import de données (étendu)
|
||||
> **Objectif** : compléter l'import de base (Markdown/CSV/Notion, déjà dans v5.4.0) par les formats pro et l'inférence de types.
|
||||
Détails livrés :
|
||||
- Service `app/services/embeds.py` : détection multi-provider + réécriture d'URL, `resolve_embed()`, `inline_kind()`, `embed_html()` ; endpoint `POST /board/api/embed/resolve`
|
||||
- Service `app/services/og_fetcher.py` : parseur OG robuste (ordre d'attributs libre), favicon, repli sans réseau ; endpoint `POST /board/api/og/metadata`
|
||||
- Endpoints `POST/DELETE /board/api/pages/{id}/cover` (JSON URL ou upload image) et `POST /board/api/pages/{id}/icon`
|
||||
- Éditeur : résolution d'embed à la saisie (URL d'origine conservée + `embed_src` mis en cache), lightbox multi-images navigable, préviews vidéo/audio/PDF
|
||||
- Pages publiques `/p/<slug>` : cover + icône, embed résolu, lightbox clavier
|
||||
- Export Markdown/HTML/PDF : nouveau bloc `embed`/`bookmark` avec `embed_src` résolu
|
||||
- **Fix chemins API** : l'éditeur appelait `/api/pages/...` alors que les routes sont `/board/api/pages/...` (cover, icon, versions, backlinks, import, move, OG) — corrigé
|
||||
- **47 tests** `tests/test_v55.py` ; suite complète **444 verte** ; `ruff check` OK
|
||||
|
||||
- [ ] **Import CSV typé** — inférence automatique des types de propriétés (texte, nombre, date, select)
|
||||
- [ ] **Import Confluence / Evernote / Asana / Trello** — via leurs formats d'export (HTML/JSON)
|
||||
### v5.6.0 — Import de données (étendu) ✅ (2026-09-13)
|
||||
> **Objectif** : faire de FlowDeck la cible d'import universelle pour les outils réellement utilisés
|
||||
> (notes Markdown, bureautique, tableaux, signets, dev), en complétant l'import de base
|
||||
> (Markdown/CSV/Notion, déjà dans v5.4.0). **COMPLETED**.
|
||||
> **Sources retenues** : Obsidian, Notion, Logseq/Roam, Apple Notes/Bear/Ulysses, Google Keep,
|
||||
> OneNote, Word, Google Docs, HTML, PDF, CSV/Excel/Sheets/JSON, Gitea/GitHub, Raindrop, Pocket,
|
||||
> Readwise, Shaarli, `.ics`, OPML, Standard Notes.
|
||||
> **Sources exclues** : Confluence, Evernote, Asana, Trello (non utilisées).
|
||||
|
||||
### v5.7.0 — Database Avancée (Pt. 2)
|
||||
> **Objectif** : compléter la parité sur les propriétés, les vues sauvegardées et le Kanban pro.
|
||||
#### Phase 0 — Socle d'import unifié (prérequis) ✅
|
||||
> Toutes les sources partagent le même pipeline ; à livrer avant les imports.
|
||||
- [x] **Service `app/services/importers/`** — interface commune (`detect()`, `parse()`, `to_pages()`) + résultat normalisé (`ImportResult` : pages, pièces jointes, warnings, stats)
|
||||
- [x] **Pipeline commun** — upload → parse → normalisation blocs + métadonnées → création (pages + hiérarchie `parent_id`) → rapport d'import
|
||||
- [x] **Pièces jointes** — extraction (images/fichiers), upload, réécriture des liens dans les blocs
|
||||
- [x] **Frontmatter YAML → propriétés** (title, tags, dates, champs custom)
|
||||
- [x] **Assistant UI d'import** — choix de la source, mapping colonnes→types, preview/dry-run, barre de progression
|
||||
- [x] **Import asynchrone** (job en arrière-plan + polling) pour les gros volumes (vaults, zips)
|
||||
- [x] **Idempotence / déduplication** + reprise sur erreur partielle
|
||||
- [x] **Tests** — harnais d'import + fixtures par source
|
||||
- [x] **Réutilise** : `_md_to_blocks` (`app/services/export.py`), `PROPERTY_TYPES` + `validate_property_rule` (`property_types.py`), `db_templates.materialize_properties`, endpoints upload/cover existants
|
||||
|
||||
- [ ] **Types propriété manquants** — `person`, `created_time`, `created_by`, `last_edited_time`, `last_edited_by`
|
||||
- [ ] **Groupes de propriétés** — sections pliables dans le header de DB
|
||||
- [ ] **Vues sauvegardées par utilisateur** — save view (per-user, pas workspace-wide)
|
||||
- [ ] **Swimlanes Kanban** — sous-groupes horizontaux (2e dimension de groupement)
|
||||
- [ ] **WIP limits** — par colonne, alerte visuelle au dépassement
|
||||
- [ ] **Cartes configurables** — propriétés affichées par carte, couverture (image/icône/couleur), mode compact / détaillé
|
||||
- [ ] **Calendar avec drag & drop** — reschedule direct sur la grille
|
||||
- [ ] **Gallery avec couvertures** — image/icône comme vignette de carte
|
||||
#### Phase 1 — Notes & Markdown (réutilise `_md_to_blocks`) — faible effort ✅
|
||||
- [x] **Obsidian** (vault `.zip`/dossier) — frontmatter YAML, `[[wikilinks]]`, `![[embeds]]`, attachments, hiérarchie de dossiers *(prépare v5.11.0)*
|
||||
- [x] **Notion** (améliorer l'existant) — hiérarchie complète, databases (CSV) → collections, images
|
||||
- [x] **Logseq / Roam Research** — markdown outliné (puces imbriquées), `((block refs))`, pages journal
|
||||
- [x] **Apple Notes / Bear / Ulysses** — import export HTML/Markdown
|
||||
- [x] **Google Keep** — Takeout JSON + HTML
|
||||
- [x] **OneNote** — export HTML/PDF, best-effort (fidélité limitée)
|
||||
|
||||
### v5.8.0 — Calendrier & Rappels
|
||||
> **Objectif** : calendrier complet + notifications proactives.
|
||||
#### Phase 2 — Données & tableaux (fort ROI databases) — effort faible/moyen ✅
|
||||
- [x] **CSV/TSV typé** — inférence auto (texte/nombre/date/bool/select/multi_select) + UI mapping + options
|
||||
- [x] **Excel `.xlsx`** (openpyxl) — multi-feuilles → collections
|
||||
- [x] **Google Sheets** — export CSV/XLSX
|
||||
- [x] **JSON générique** — mapping configurable (JSONPath → propriétés)
|
||||
|
||||
- [ ] **Vues Jour / Semaine / Mois** — calendrier complet (actuellement mois seulement)
|
||||
- [ ] **Récurrence d'événements** — daily/weekly/monthly/custom (RRULE)
|
||||
- [ ] **Support timezone** — par utilisateur + par événement
|
||||
- [ ] **Rappels** — in-app + email (avant échéance : N minutes/heures/jours)
|
||||
- [ ] **Centre de notifications** — cloche in-app (mentions, assignations, commentaires, rappels)
|
||||
- [ ] **Template Meeting Notes** — Attendees, Agenda, Notes, Action Items
|
||||
#### Phase 3 — Documents & bureautique — effort moyen ✅
|
||||
- [x] **Word `.docx`** (mammoth/pandoc) — titres, listes, tableaux, images
|
||||
- [x] **Google Docs** — Takeout `.docx`/HTML
|
||||
- [x] **HTML** (fichiers/dossier, web clipper) — conversion HTML → blocs
|
||||
- [x] **PDF** — extraction texte + images (fidélité limitée)
|
||||
|
||||
#### Phase 4 — Signets, dev & divers ✅
|
||||
- [x] **Gitea / GitHub issues + labels + milestones** → collection (via API — quasi natif)
|
||||
- [x] **Raindrop.io** (CSV/HTML) → bookmark cards (réutilise v5.5.0)
|
||||
- [x] **Pocket** (CSV/HTML)
|
||||
- [x] **Readwise** (highlights CSV/Markdown)
|
||||
- [x] **Shaarli** (API/export JSON) → bookmark cards
|
||||
- [x] **Calendrier `.ics`** (Google/Outlook/Apple)
|
||||
- [x] **OPML** (flux/outlines)
|
||||
- [x] **Standard Notes / autres**
|
||||
|
||||
#### Phase 5 — Durcissement & finition ✅
|
||||
> **Objectif** : industrialiser l'import (ré-import, lots, URL, forge, relations, rapports).
|
||||
- [x] **Import incrémental / re-sync** — modes `skip` / `update` (upsert des pages + lignes par titre) / `duplicate`, via `import_items`
|
||||
- [x] **Import de dépôt forge** — arborescence de fichiers Gitea/GitHub → pages (hiérarchie de dossiers, fichiers texte, code en blocs)
|
||||
- [x] **Import par URL / web clipper** — `POST /api/import/url` : fetch (garde SSRF) + OG metadata → page (carte bookmark + contenu)
|
||||
- [x] **Lot multi-fichiers + file d'attente** — `POST /api/import/run-batch` + file d'attente UI (statut par fichier)
|
||||
- [x] **Relations Notion** — `POST /api/import/relations/resolve` : colonnes texte référençant une autre collection → propriétés `relation` (ids de pages) ; auto-exécuté après un import Notion
|
||||
- [x] **Reprise / erreurs partielles + rapport exportable** — import qui continue par page en cas d'erreur (`status: partial`, liste `errors`) ; rapport JSON téléchargeable (`GET /api/import/jobs/{id}/report` + bouton UI)
|
||||
- [x] **Valeurs de propriétés par id** — correction : les lignes importées stockent les valeurs par id de propriété (rendu correct dans les vues DB)
|
||||
|
||||
#### Priorisation
|
||||
| Ordre | Phase | Effort | Impact |
|
||||
|---|---|---|---|
|
||||
| 1 | Phase 0 — Socle unifié | M | 🔴 prérequis |
|
||||
| 2 | Phase 1 — Notes/Markdown | S–M | 🔴 fort (Obsidian, Notion) |
|
||||
| 3 | Phase 2 — Données/tableaux | S–M | 🔴 fort (databases) |
|
||||
| 4 | Phase 4 — Signets/dev/divers | S | 🟠 moyen (Gitea natif, Raindrop) |
|
||||
| 5 | Phase 3 — Documents | M | 🟠 moyen (docx, HTML) |
|
||||
|
||||
### v5.7.0 — Database Avancée (Pt. 2) ✅ (2026-09-13)
|
||||
> **Objectif** : compléter la parité sur les propriétés, les vues sauvegardées et le Kanban pro. **COMPLETED**.
|
||||
|
||||
- [x] **Types propriété** — `person`, `created_time`, `created_by`, `last_edited_time`, `last_edited_by` câblés : auto-valeurs calculées serveur (`apply_auto_properties`) à la création/mise à jour, sélecteur `person` (membres du workspace via `GET /db/{id}/members/api`), rendu chips/avatars et édition dédiée dans l'UI
|
||||
- [x] **Groupes de propriétés** — colonne `collection_properties.group_name`, header de table avec sections pliables (`db-group-row` + toggle) et `POST /db/{id}/property-groups/api`
|
||||
- [x] **Vues sauvegardées par utilisateur** — `collection_views.created_by` ; `GET /db/{id}/views/api` filtre par propriétaire (les vues partagées `NULL` restent visibles), `save-as`/`duplicate`/`DELETE` + renommage
|
||||
- [x] **Swimlanes Kanban** — `sub_group_by` (2e dimension de groupement) : une rangée de colonnes par lane
|
||||
- [x] **WIP limits** — `wip_limits` par colonne, alerte visuelle au dépassement (`wip-exceeded`)
|
||||
- [x] **Cartes configurables** — `card_properties`, `card_size` (compact/détaillé), couverture `cover_mode` (`none`/`icon`/`color`/`property`) via `cover_property`
|
||||
- [x] **Calendar avec drag & drop** — grille mensuelle, `date_property`, navigation mois/Today, drop d'une carte sur un jour = reschedule
|
||||
- [x] **Gallery avec couvertures** — cartes avec vignette (image de propriété, icône, couleur, ou cover de ligne), tailles small/medium/large
|
||||
|
||||
Détails livrés :
|
||||
- Migration 10 : `collection_properties.group_name`, `collection_views.created_by`/`updated_at`, `collection_pages.cover_url`
|
||||
- `app/services/property_types.py` : `user_ref()`, `apply_auto_properties()`, validation `person`
|
||||
- `collections.py` : auto-props create/update/sub-item, PATCH partiel fusionné, membres, groupes, vues per-user (list/save/duplicate/delete), config de vue étendue (`sub_group_by`, `wip_limits`, `card_size`, `cover_mode`, `card_properties`, `date_property`, `property_groups`)
|
||||
- `dashboard.py` : auto-props à la création de ligne, `views` exposées dans `table-data` et le contexte de page
|
||||
- `_database_table_scripts.html` réécrit en composant multi-vues (table/board/calendar/gallery/list) + barre de vues, éditeurs de cellules par type, picker personne, popovers de configuration
|
||||
- **12 tests** `tests/test_v57_db_advanced.py` ; suite complète **503 verte** ; `ruff check` OK
|
||||
|
||||
### v5.8.0 — Calendrier & Rappels ✅ (2026-09-13)
|
||||
> **Objectif** : calendrier complet + notifications proactives. **COMPLETED**.
|
||||
|
||||
- [x] **Vues Jour / Semaine / Mois** — sélecteur de mode dans la vue calendar (persisté en config de vue), navigation ‹/› et Today adaptées, vue Jour en agenda horodaté, Semaine en 7 colonnes ; événements servis par `GET /db/{id}/calendar/api` avec expansion serveur des occurrences
|
||||
- [x] **Récurrence d'événements** — moteur `app/services/recurrence.py` (daily/weekly/monthly, intervalle, count, until, byweekday lundi=0, timezone) ; règle stockée dans `property_values_json.__recurrence__` ; popover événement (double-clic) avec Repeat/Every/Ends ; validation serveur (400)
|
||||
- [x] **Support timezone** — colonne `users.timezone` + picker dans Settings → Notifications (`GET/POST /api/notifications/timezone`, zones via `GET /db/timezones/api`) ; timezone par événement (`__timezone__`) > règle de récurrence > préférence utilisateur
|
||||
- [x] **Rappels** — `app/services/reminders.py` : lead minutes/heures/jours avant chaque occurrence, scan toutes les 60 s (`reminder_scheduler`), dédup `reminder_log`, notifie les personnes assignées (repli admin), in-app + email (pref `reminders`) ; stockés dans `property_values_json.__reminder__`, édités dans le popover événement
|
||||
- [x] **Centre de notifications** — la cloche (v4.9.0) couvre désormais rappels, assignations et commentaires ; nouvelles préférences `reminders` + `assignments` ; notification d'assignation émise par `notify_assignment()` sur `PUT /db/pages/{id}/api` (comparaison avant/après des propriétés `person`)
|
||||
- [x] **Template Meeting Notes** — propriétés `Agenda` et `Notes` ajoutées au template seed (migration 12, préserve les personnalisations) ; le schéma Attendees/Date/Status/Action items était déjà là
|
||||
|
||||
Détails livrés :
|
||||
- Migrations 11 (`reminder_log`, `users.timezone`) et 12 (template Meeting notes)
|
||||
- `app/config.py` : `reminders_enabled`, `reminder_scan_interval_seconds`
|
||||
- `_database_table_scripts.html` : modes jour/semaine/mois, chips d'occurrences (badge ↻ + heure), popover Time/Timezone/Repeat/Remind, badges ↻/🔔 dans les cellules date, drag & drop de reschedule préservé
|
||||
- **20 tests** `tests/test_v58_calendar_reminders.py` ; suite complète **523 verte** ; `ruff check` OK
|
||||
- **Version** — 5.11.7
|
||||
|
||||
### v5.9.0 — AI Writing Assist (éditeur) ✅ (2026-09-10)
|
||||
> **Objectif** : l'IA Notion dans l'éditeur, au-dessus du moteur v4.10.0 (Agent IA). **COMPLETED**.
|
||||
@@ -572,47 +664,229 @@ app/
|
||||
|
||||
## v5.10.0 — Éditeur : interactions de bloc ✅ (livré — voir section Completed)
|
||||
|
||||
## v5.11.0 — Wiki-links & mentions de page ⬜ (non commencé)
|
||||
## v5.11.0 — Wiki-links & mentions de page ✅ (2026-09-14)
|
||||
> **Objectif** : le graphe de connaissances Notion. Complète les backlinks de v5.4.0
|
||||
> (section « Lié depuis ») par la création des liens depuis l'éditeur.
|
||||
> (section « Lié depuis ») par la création des liens depuis l'éditeur. **COMPLETED**.
|
||||
|
||||
- [ ] **Wiki-links `[[`** — taper `[[` ouvre un picker de pages (recherche fuzzy, toutes collections), Enter insère un lien interne rendu comme chip de page (icône + titre mis à jour dynamiquement)
|
||||
- [ ] **Mention de page `@`** — dans le menu @ existant (utilisateurs v4.9.0), ajouter l'onglet « Pages » : `@` suivi d'un nom de page crée un lien inline
|
||||
- [ ] **Mention de date `@`** — `@today`, `@tomorrow`, `@2026-10-01` rendus comme chips de date
|
||||
- [ ] **Renommage propagé** — renommer une page met à jour le libellé affiché de tous ses liens internes (résolution au rendu via `page_id`, pas de texte dur)
|
||||
- [x] **Wiki-links `[[`** — taper `[[` ouvre le picker de pages (`GET /board/api/wiki/pages`, recherche substring + sous-séquence floue), Entrée insère un lien interne rendu comme chip atomique (icône + titre, cliquable) ; module `WM` dans `_page_editor_scripts.html` ; tokens `[[fdpage:ID]]` stockés dans le texte des blocs et relus intacts par `gtTok()` (autosave, drag, undo/redo préservés)
|
||||
- [x] **Mention de page `@`** — le menu `@` (utilisateurs v4.9.0) gagne la section « Pages » : `@nom` cherche les pages et insère le chip inline
|
||||
- [x] **Mention de date `@`** — section « Date » : `@today`, `@tomorrow`, `@hier` ou `@YYYY-MM-DD` insèrent `[[fddate:…]]`, rendus comme chips de date lisibles (« Fri 25 Dec 2026 »)
|
||||
- [x] **Renommage propagé** — le token ne stocke que `page_id` ; `GET /board/api/wiki/titles` résout les libellés au rendu (editeur + page publique) → renommer une page met à jour tous ses liens ; page supprimée → « Deleted page »
|
||||
- [x] Bonus — le scanner de backlinks v5.4.0 reconnaît les tokens wiki ; `_render_blocks_public` rend les chips en page publiée (HTML échappé) ; service `app/services/wiki_links.py` ; 15 tests dédiés (voir v5.12.0)
|
||||
|
||||
## v5.12.0 — Templates & verrouillage de page ⬜ (non commencé)
|
||||
## v5.12.0 — Templates & verrouillage de page ✅ (2026-09-14)
|
||||
> **Objectif** : démarrage rapide productif et protection des pages stabilisées.
|
||||
> Les `page_templates` existent (v2.0.0/v4.2.0) mais uniquement côté collections.
|
||||
> Les `page_templates` existent (v2.0.0/v4.2.0) mais uniquement côté collections. **COMPLETED**.
|
||||
|
||||
- [ ] **Template picker global** — sur « + New page » : galerie de templates (Empty, Meeting notes, Weekly report, To-do list…) + templates custom utilisateur
|
||||
- [ ] **Bouton « Use template »** — duplication du contenu du template dans la nouvelle page
|
||||
- [ ] **Page lock** — toggle 🔒 dans le menu « … » : page en lecture seule (édition désactivée pour tous sauf owner/admin), indicateur visuel en topbar
|
||||
- [ ] **Full-width mode** — toggle pour passer la page en pleine largeur (comme Notion)
|
||||
- [ ] **Small text / typo options** — option de page : taille de police réduite, serif/mono
|
||||
- [x] **Template picker global** — « + New page » (sidebar, footer, Ctrl+K) ouvre la galerie : 5 templates built-in (`app/services/block_templates.py` — Empty, Meeting notes, Weekly report, To-do list, Project doc) + templates custom utilisateur (`page_global_templates`, migration 13, cloisonnés par `created_by`)
|
||||
- [x] **Bouton « Use template »** — `POST /board/api/page-templates/{id}/use` (id 0 = built-in par clé) duplique le contenu des blocs dans une nouvelle page ; « Empty » retombe sur la création classique ; menu « … » → « 📑 Save as template » capture la page courante
|
||||
- [x] **Page lock** — toggle 🔒 dans le menu « … » : bannière sticky read-only, blocs/titre non éditables ; serveur `POST /board/api/pages/{id}/lock` + garde `_ensure_page_editable` → 423 sur `PUT /api/pages/{id}` et `POST /api/pages/{id}/blocks` ; déverrouillage réservé au poseur du lock (`locked_by`) ou admin (403 sinon) ; indicateur visuel = bannière en tête de page
|
||||
- [x] **Full-width mode** — toggle dans le menu « … », persisté par page (`pages.full_width`), `POST /board/api/pages/{id}/options`, classe CSS `.full-width`
|
||||
- [x] **Small text / typo options** — toggle « Aa Small text » (`pages.font_small`), classe `.small-text` (taille réduite ; serif/mono : non demandé au-delà du compact — volontairement hors scope)
|
||||
|
||||
## v5.13.0 — Collaboration temps réel ✅ (livré — voir section Completed)
|
||||
|
||||
## v5.14.0 — Synced blocks ⬜ (non commencé)
|
||||
## v5.14.0 — Synced blocks ✅ (2026-09-15)
|
||||
> **Objectif** : avancer depuis v6.0.0 un bloc Notion très utilisé (même contenu dans
|
||||
> plusieurs pages, édité une fois).
|
||||
> plusieurs pages, édité une fois). **COMPLETED**.
|
||||
|
||||
- [ ] **Bloc `synced_block`** — table `synced_blocks` (source de vérité) + références par page ; slash command `/synced`
|
||||
- [ ] **Rendu** — ring rouge + badge « Synced » sur le bloc ; édition à un endroit => mise à jour partout (via rooms WS v5.13.0 si actives, sinon au reload)
|
||||
- [ ] **Unsync** — action « Unsync » qui convertit l'instance en copie indépendante
|
||||
- [ ] **Copy & sync across pages** — copier un bloc dans une autre page avec option « Paste and sync »
|
||||
- [x] **Bloc `synced_block`** — table `synced_blocks` (source de vérité) + références par page ; slash command `/synced`
|
||||
- [x] **Rendu** — ring rouge + badge « Synced » sur le bloc ; édition à un endroit => mise à jour partout (via rooms WS v5.13.0 si actives, sinon au reload)
|
||||
- [x] **Unsync** — action « Unsync » qui convertit l'instance en copie indépendante
|
||||
- [x] **Copy & sync across pages** — copier un bloc dans une autre page avec option « Paste and sync »
|
||||
|
||||
### v5.15.0 — Webhooks v2 ✅ (2026-09-21)
|
||||
> **Objectif** : webhooks sécurisés, fiables et complets pour les intégrations tierces. **COMPLETED**.
|
||||
|
||||
- [x] **Signature HMAC SHA-256** — header `X-FlowDeck-Signature` sur chaque payload, vérification côté receveur
|
||||
- [x] **Retries avec backoff** — 4 tentatives max, délais 2s / 10s / 60s, logs détaillés
|
||||
- [x] **20+ nouveaux événements** — total ~50 événements (pages, blocs, utilisateurs, collections, workspaces, etc.)
|
||||
- [x] **API v2 endpoints** — `/api/v2/webhooks/test-signature` (test HMAC), `/api/v2/webhooks/retry` (relancer les échecs)
|
||||
- [x] **21 tests** `tests/test_webhooks_v2.py` (HMAC, retries, événements, intégration)
|
||||
|
||||
---
|
||||
|
||||
## v6.0.0 — PWA : Progressive Web App, offline ✅ (2026-09-18)
|
||||
> **Objectif** : support hors ligne complet (manifest, service worker, IndexedDB,
|
||||
> queue de mutations, sync serveur + résolution de conflits). **COMPLETED**.
|
||||
|
||||
- [x] **Manifest & icônes PWA** — `static/manifest.json`, `static/icons/*`, `scripts/generate_pwa_icons.py`
|
||||
- [x] **Service Worker** — `static/sw.js` (precache shell, network-first HTML/API, page offline, Background Sync)
|
||||
- [x] **IndexedDB client** — `static/js/offline.js` (`window.FlowOffline`) : queue, delta, flush, marqueurs dirty
|
||||
- [x] **Endpoints sync** — `app/routers/sync.py` + `app/services/sync_engine.py` (`/api/v2/sync/delta|batch|status`)
|
||||
- [x] **Migrations** — table `offline_sync_queue` + colonnes `sync_version` (triggers AFTER UPDATE)
|
||||
- [x] **Conflits** — edit-edit (last-write-wins), edit-delete (page orpheline), create-create (« copie offline »)
|
||||
- [x] **UI** — banner offline + pending count, badge de synchronisation, toasts, icône ⟳ sur pages dirty
|
||||
- [x] **Durcissement** — max 100 mutations/batch, timeout 30 s, rétention queue 30 j
|
||||
- [x] **Tests** — `test_sync.py`, `test_sync_migrations.py`, `test_service_worker.py`, `test_pwa_offline.py`, E2E `e2e/pwa_offline.spec.js`
|
||||
- [x] **Doc** — section `/help` « Offline mode (PWA) »
|
||||
|
||||
---
|
||||
|
||||
## v6.1.0 — Granular Permissions ✅ (2026-09-19)
|
||||
|
||||
> **Objectif** : page-level, collection-level & property-level ACL + groupes + audit. **COMPLETED**.
|
||||
|
||||
- [x] **Page permissions** — modes `inherit|restricted|private` (`pages.permission_type`, `collections.permission_type`, `collection_pages.permission_type`), grants explicites user/group (`page_permissions` role viewer/commenter/editor/owner), héritage page→collection→workspace, 404 masqué pour non-grantees, owner/admin bypass
|
||||
- [x] **Collection permissions** — `collection_permissions` + permission_type, `/db/{id}` et `/db/{id}/api` masqués (404→302), création page et delete collection gatés (viewer 403), editor bypass
|
||||
- [x] **Property-level visibility** — `property_permissions` (viewer|editor), `GET /db/{id}/properties/api` filtré par `get_visible_properties()`, `GET /api/v2/collections/{id}/properties/visible` (visible/hidden), grant = owner collection uniquement
|
||||
- [x] **Groupes réutilisables** — tables `user_groups` + `group_members` (workspace-scoped, UNIQUE(name)), CRUD `/api/v2/groups` + `/groups/{id}/members`, grant par `group_id` (page/collection/property), retrait membre révoque l'accès
|
||||
- [x] **API** — `app/routers/permissions.py` : pages (list/mine, grant, batch, revoke, permission-type), collections (list/grant/revoke/type + visible), properties (list/grant/revoke), groups (list/create/update/delete + members), users picker, audit `GET /api/v2/audit/permissions`
|
||||
- [x] **Guards** — `board.py` (`GET/PUT /board/api/pages/{id}`) + `collections.py` (`delete collection`, `GET/PUT/DELETE page`, `properties`), 403/404 conformes, is_admin/owner bypass + `_session_user()` (no admin fallback)
|
||||
- [x] **PermissionManager** — `app/services/permission_manager.py` étendu : `_explicit_grant_role()` (best rank user+groups), `get_page/collection_permission()`, `can_view/edit_page|collection`, `can_view/edit_property()`, `get_visible_properties()`, groups, `log_permission_change()`, cache 60s + `invalidate()`
|
||||
- [x] **Audit** — table `permission_audit_log` (resource_type, action grant/revoke/type_change/group_*), index, log sur tous les mutateurs, `GET /api/v2/audit/permissions` (owner/admin only, limit 500)
|
||||
- [x] **Migration 18** — `migrations.py` : création 6 tables + 3 colonnes `permission_type` + indexes (idempotent)
|
||||
- [x] **Tests** — `tests/test_v60_granular_permissions.py` **21 tests** (inherit/restricted/private, grant viewer/editor, revoke, batch, type via API, collection restricted+grant, property visibility/hidden, group inherits + revoke, audit, auth 401, validation 400/404)
|
||||
|
||||
## v6.2.0 — Web Clipper : extension navigateur ✅ (2026-09-19)
|
||||
|
||||
> **Objectif** : capturer n'importe quelle page web en page FlowDeck (article, sélection, bookmark, screenshot) depuis une extension Manifest V3 + API directe. **COMPLETED**.
|
||||
|
||||
- [x] **Extension Manifest V3** — `extension/` + `static/extension/` (manifest, `background.js`, `content.js`, `popup.html/js`, `clipper.css`, icônes 16/32/48/128, `flowdeck-clipper.zip` servi à `/static/extension/`)
|
||||
- [x] **4 types de capture** — article (HTML complet → `sanitize_html` + `html_to_blocks`), sélection, bookmark (carte OG v5.5.0), screenshot (base64) ; cap 10 MB / 200 blocs
|
||||
- [x] **Serveur** — `POST /api/v2/web-clipper/clip`, `GET /status`, `POST /auth/verify` (register device → token `fd_…` montré une fois), `GET /devices`, `DELETE /devices/{id}` + page HTML `GET /extensions` ; auth triple (session OU Bearer `api_tokens` OU Bearer `extension_devices` OU legacy `user_tokens`), rate-limit 50/h/device
|
||||
- [x] **Tables migration 19** — `extension_devices`, `extension_clips` + index `idx_ext_*`
|
||||
- [x] **Settings UI** — onglet Extensions (devices, clips count, revoke)
|
||||
- [x] **16 tests** `tests/test_web_clipper.py` ; `VERSION` 6.2.0 ; wiring `app/main.py:50,158`
|
||||
|
||||
### v6.2.1 — Web Clipper polish ✅ (2026-09-20)
|
||||
|
||||
- [x] **Bouton flottant rond transparent draggable** — toggle d'affichage persistant, `clipper:clipped` refresh auto sidebar
|
||||
- [x] **Fix bloc bookmark** — `create_page_from_clip()` émet un bloc `bookmark` fidèle (OG + `embed_src` résolu), rendu/correct en éditeur + `/p/<slug>` + exports
|
||||
- [x] Fix `duplicate inner` SyntaxError dans `_page_editor_scripts.html`
|
||||
- [x] `flowdeck-clipper.zip` régénéré
|
||||
|
||||
## v6.3.0 — API publique complète v2 ✅ (2026-09-21)
|
||||
|
||||
> **Objectif** : REST API documentée OpenAPI, CRUD complet, un seul chemin de code (wrappers sur les services internes). Parité `docs/API_GUIDE_V6.md` §4 (~80 endpoints) + scopes hiérarchiques `read < write < admin`. **COMPLETED**.
|
||||
> **Route** : `feat/v6-api-v2` → `develop` → `main` — livraison **en une fois** (tous domaines).
|
||||
> **Doc** : [`docs/API_GUIDE_V6.md`](/docs/API_GUIDE_V6.md) · OpenAPI : `/docs` + `docs/openapi-v2.json` (402 chemins)
|
||||
|
||||
#### Phase 0 — Roadmap & doc catch-up ✅
|
||||
|
||||
- [x] Tagguer v6.2.0/v6.2.1 dans `CHANGELOG.md` + `ROADMAP.md` + `docs/V6_Web_Clipper.md` → `COMPLETED`
|
||||
- [x] Détailler v6.3.0 phases 1-8 dans `ROADMAP.md` (plan gelé)
|
||||
|
||||
#### Phase 1 — Migrations socles (v20) ✅
|
||||
|
||||
- [x] `api_tokens` : colonnes `scopes TEXT DEFAULT 'read,write'`, `expires_at TIMESTAMP` (idempotent, rétro-compat)
|
||||
- [x] `webhook_deliveries` : `id, webhook_id FK, status, http_code, error, duration_ms, attempt, created_at`
|
||||
- [x] `api_audit_log` : `id, user_id, token_id, action, resource_type, resource_id, ip, created_at`
|
||||
- [x] `idempotency_keys` : `key TEXT PRIMARY KEY, user_id, response_json, created_at`
|
||||
|
||||
#### Phase 2 — Helpers & auth v2 unifiée (scopes hiérarchiques) ✅
|
||||
|
||||
- [x] `app/config.py` : `public_api_insecure_ok: bool = False` — `fd-public-key` accepté seulement si `True` (dev local)
|
||||
- [x] `app/services/api_v2_helpers.py` : `parse_pagination()` (+`X-Total-Count`), `to_iso8601()`, handler RFC 7807 `application/problem+json`, `require_scope()` (hiérarchie `admin ⊇ write ⊇ read`), `resolve_bearer_token()` / `get_bearer_user()` (hash sha256, `revoked` + `expires_at` + scopes, `last_used_at`, `extension_devices`)
|
||||
- [x] Auth Bearer unifié partagé (clipper + legacy `user_tokens` supportés) ; handler d'erreurs unifié sur `StarletteHTTPException`
|
||||
|
||||
#### Phase 3 — Tokens CRUD v2 ✅
|
||||
|
||||
- [x] `POST /api/v2/tokens` (`name, scopes, expires_at`) → `fd_{urlsafe(32)}` hashé, montré une fois
|
||||
- [x] `GET /api/v2/tokens` (prefix only), `DELETE /api/v2/tokens/{id}` (revoke), `POST /api/v2/tokens/{id}/rotate`
|
||||
|
||||
#### Phase 4 — Wrappers read (pagination/filtres/tri/fields) ✅
|
||||
|
||||
- [x] `app/routers/api_v2.py` (`prefix="/api/v2"`, tag `api-v2`) — `GET /collections`, `GET /collections/{id}`, `GET /pages/{id}`, `GET /collections/{id}/pages?filter[]=&sort=&fields=&query=`, `GET /search?query=&workspace_id=&type=` (FTS5), header `X-Total-Count`, `filter[]` AND, `sort=prop/-prop`
|
||||
|
||||
#### Phase 5 — Wrappers write critiques (`Idempotency-Key`) ✅
|
||||
|
||||
- [x] Collections : `POST/GET/PATCH/DELETE /collections/{id}` + `/linked`, `/task`, `/sources`
|
||||
- [x] Pages : `POST/GET/PATCH/DELETE /pages/{id}` + `/restore`, `/move`, `/sub-items`, `/dependencies`
|
||||
- [x] Properties : `GET/POST /collections/{id}/properties`, `PATCH/DELETE /properties/{id}`, `POST .../relation`, `evaluate-formula`, `compute-rollup`
|
||||
- [x] Views/Dashboards/Comments/Notifications/Favorites/Tags/Recents/Sharing/History/Sprints/Templates/Export/Workspaces/Users/Admin — regroupés par ressource
|
||||
|
||||
#### Phase 6 — Webhooks v2 — CRUD simple ✅
|
||||
|
||||
- [x] `GET/POST/PATCH/DELETE /api/v2/webhooks`, `POST /api/v2/webhooks/{id}/test` (ping)
|
||||
- [x] `GET /api/v2/webhooks/{id}/deliveries` (journal basique)
|
||||
- [x] *(reporté v6.4, livré plus tôt)* **v5.15.0** : signature HMAC `X-FlowDeck-Signature`, retry 2s/10s/60s, +20 events (Webhooks v2)
|
||||
|
||||
#### Phase 7 — Forges & ressources restantes ✅
|
||||
|
||||
- [x] Sprints, dashboards, templates, export/import, workspaces/members, users, admin
|
||||
- [x] Forges : `GET /projects`, `/projects/{owner}/{repo}/tree` (best-effort via `gitea_client`)
|
||||
- [ ] *(reporté)* : migration de `sync.py` vers Bearer (reste session, CSRF-exempt)
|
||||
|
||||
#### Phase 8 — OpenAPI, tests & docs ✅
|
||||
|
||||
- [x] `docs_url="/docs"` + `redoc_url="/redoc"` activés ; `docs/openapi-v2.json` généré (402 chemins)
|
||||
- [x] `tests/test_public_api_v2.py` — **24 tests** (auth scopes, pagination, filtres, RFC 7807, idempotency, webhooks deliveries, CRUD multi-domaines)
|
||||
- [x] Vérif `ruff check app tests` + `pytest -n auto` → **668 verte**
|
||||
|
||||
## v6.4.0 — Realtime editing (production) ✅ (2026-09-22)
|
||||
|
||||
> **Objectif** : passer le realtime v5.13.0 en « production » — résolution de
|
||||
> conflits au-delà du last-write-wins + édition à grande échelle (broadcast non
|
||||
> bloquant, plusieurs rooms/pages, observabilité). **COMPLETED**.
|
||||
|
||||
#### Conflits au-delà du LWW
|
||||
|
||||
- [x] **Merge à 3 voix (diff3-lite)** — nouveau service `app/services/realtime_merge.py` : `merge_text_3way()` (merge de caractères) + `merge_block_3way()` (merge champ-par-champ), fonctions pures et testées sans WebSocket ni base
|
||||
- [x] **Régions disjointes conservées** — deux utilisateurs tapant à des endroits différents du *même* bloc voient leurs deux saisies survivre (au lieu d'écraser l'une par l'autre) ; ordre d'arrivée indifférent
|
||||
- [x] **Chevauchement réel → LWW par champ + drapeau** — un conflit n'est plus « tout le bloc perdu » mais limité au champ concerné ; `conflict: true` renvoyé dans l'`ack` et le broadcast
|
||||
- [x] **Protocole `base`** — le client embarque dans chaque `update` la version du bloc dont dérive sa saisie ; le serveur fait `merge_block_3way(base, current, incoming)` ; sans `base` → LWW historique (rétro-compat ancien client)
|
||||
- [x] **Adoption côté client** — l'`ack` renvoie le bloc fusionné ; le client met à jour sa `base`, adopte le résultat (hors bloc en cours d'édition) et affiche un toast en cas de conflit
|
||||
- [x] **Broadcast du résultat fusionné** — le serveur diffuse toujours le bloc final fusionné (jamais la proposition brute) pour convergence garantie de tous les clients
|
||||
|
||||
#### Échelle & robustesse
|
||||
|
||||
- [x] **Broadcast non bloquant** — chaque connexion a une file sortante (`asyncio.Queue`) + une tâche `_writer` dédiée ; `_broadcast()` fait `put_nowait` et n'attend plus le socket → un client lent ne fige plus la room
|
||||
- [x] **Coalescence des curseurs** — le writer réduit les messages `sel` empilés à la position la plus récente (seule la dernière compte), tout en préservant l'ordre des messages importants
|
||||
- [x] **Déconnexion des clients trop lents** — file pleine (`MAX_OUT_QUEUE=512`) → fermeture 4413 + compteur `slow_disconnects` (évite qu'une room entière stagne sur un pair mortel)
|
||||
- [x] **Anti-flood** — budget d'opérations par connexion (`OP_WINDOW_MAX=400` / 10 s) ; au-delà, réponse `ack stale` sans application
|
||||
- [x] **Fix fuite de rooms** — une connexion 4404 n'enregistre plus de `Room` orpheline en mémoire ; `room_state()` sur page inexistante retourne un dict vide au lieu de planter sur `None`
|
||||
- [x] **Observabilité** — `GET /api/realtime/stats` (authentifié) : rooms, connexions, ops, merges, conflits, déconnexions lentes + détail par page
|
||||
|
||||
Détails livrés :
|
||||
- `app/services/realtime_merge.py` — merge 3-voix (nouveau, ~170 lignes)
|
||||
- `app/services/realtime_server.py` — `RTConn` (file + writer + budget), `_apply()` avec merge, `_evict_slow()`, `stats()`, fix fuites
|
||||
- `app/routers/realtime.py` — endpoint `GET /api/realtime/stats`
|
||||
- `app/templates/_page_editor_realtime.html` — envoi de `base`, adoption du bloc fusionné, toast de conflit
|
||||
- **26 tests** `tests/test_realtime_v64.py` (merge purs, protocole WS, convergence 2 clients, rétro-compat LWW, fuite 4404, coalescence, stats, anti-flood) ; **14 tests** `tests/test_realtime.py` préservés
|
||||
- `ruff check app tests` OK · `eslint static/js` 0 problème
|
||||
- [x] **Version** — 6.4.0
|
||||
|
||||
---
|
||||
|
||||
## v6.5.0 — Synced blocks production (databases & vues) ✅ (2026-09-24)
|
||||
> **Objectif** : passer les synced blocks en « production » (résolution +
|
||||
> propagation fiables partout) et les faire vivre dans les databases/vues —
|
||||
> le « reste en v6 » du point **v5.14.0**. **COMPLETED**.
|
||||
|
||||
#### Contenu des lignes de database (le point « databases »)
|
||||
- [x] **Migration 22** — `pages.collection_row_id INTEGER REFERENCES collection_pages(id) ON DELETE CASCADE` + index partiel ; chaque ligne de database gagne une **page contenu** (page `blocks` standard) portant son éditeur complet
|
||||
- [x] **Service `app/services/row_pages.py`** — `ensure_row_page()` : création lazy/réparation (page trashée restaurée, `parent_section='DbRow'`), workspace hérité de la page hôte (DB full-page → page parente inline → `collections.gitea_owner/repo`), `collection_id` hérité pour que les ACL de collection s'appliquent au contenu
|
||||
- [x] **Endpoint `GET /db/pages/{row_id}/open/api`** — renvoie `{page_id}` (garde `_require_view` de la collection) ; utilisé par le peek de **toute** vue (table/board/gallery/list/calendar)
|
||||
- [x] **Fix bug d'ouverture de ligne** — le peek appelait `/pages/{rowId}` qui lit la table `pages` → avec des ids croisés il ouvrait une **page au hasard** (ligne id 5 « Ask AI » → page id 5 « Données ») ; désormais résolution via l'endpoint puis iframe `/pages/{pageId}`
|
||||
- [x] **Titres synchronisés bidirectionnellement** — renommer la ligne (`PUT /db/pages/{id}/api`) → page contenu ; renommer dans l'éditeur (`PUT /board/api/pages/{id}`, `POST .../blocks`, `PUT /api/pages/{id}/rename`) → ligne
|
||||
- [x] **Cascade** — supprimer une ligne supprime sa page contenu (FK `ON DELETE CASCADE`, refs `page_synced_blocks` en cascade)
|
||||
- [x] **Exclusions des listings** — `AND collection_row_id IS NULL` sur : arbre sidebar (`_build_page_tree`), racines workspace (`_load_workspace_pages`), liste des pages du board, projets builtins, arbre Library (`_build_tree_children` + API tree), `page_count` des workspaces ; `parent_section='DbRow'` les tient hors des sections Private/Trash
|
||||
|
||||
#### Résolution serveur + propagation (le point « production »)
|
||||
- [x] **Résolution à chaque lecture** — `resolve_content_json()` (`app/services/synced_blocks.py`) appliqué sur : les 2 routes de rendu `/pages/{id}` (dashboard + board, dual-route), `GET /api/pages/{id}/content`, `GET /api/local-workspace/page-content/{id}`, page publique `/p/{slug}`, `GET /api/v2/pages/{id}`, et les rooms realtime (`load_room`) → le cache `_synced_content` périmé n'est plus jamais servi
|
||||
- [x] **Propagation écrite réelle** — `PUT /api/synced-blocks/{id}` réécrit le contenu stocké de **chaque** page référente (`sync_synced_blocks_in_page`, jusqu'ici jamais appelé = code mort depuis v5.14.0) **avant** le broadcast WS ; récursif dans les `children` (colonnes, toggles)
|
||||
- [x] **Suppression de source** — ids des pages référentes collectés **avant** la cascade FK, `mark_synced_block_deleted()` marque `_synced_deleted` dans le stocké, broadcast dédié `manager._broadcast_synced_to()`
|
||||
- [x] **État « deleted »** — `resolve_synced_block()` marque `_synced_deleted` (source absente) ; rendu dédié dans l'éditeur (« Deleted synced block » au lieu d'un cache fantôme / « Loading… » éternel)
|
||||
- [x] **Rendu public des synced blocks** — branche `synced` ajoutée à `_render_blocks_public` (avant : JSON brut en `<p>`) + état deleted
|
||||
- [x] **API v2** — `content_page_id` sur `GET /api/v2/pages/{id}` pour une ligne (sans création lazy en lecture)
|
||||
|
||||
#### Tests
|
||||
- [x] **`tests/test_v65_synced_db.py` — 17 tests** (migration, création/idempotence/cascade/restauration de la page contenu, sync des titres, exclusions de listings, résolution lecture éditeur/content/public, propagation écrite + récursivité + deleted, synced block dans une ligne de DB de bout en bout, `content_page_id` v2)
|
||||
- [x] Suite complète **742 verts** (`pytest -n auto`) · `ruff check app tests` OK · `eslint static/js` 0 problème
|
||||
- [x] **Version** — 6.5.0 (VERSION + `app/main.py`)
|
||||
|
||||
---
|
||||
|
||||
## v6.0.0 — Pro (futur)
|
||||
|
||||
- [ ] **PWA** — Progressive Web App, offline support
|
||||
- [ ] **SSO/SAML** — enterprise authentication
|
||||
- [ ] **Granular permissions** — page-level, property-level access control
|
||||
- [ ] **Web Clipper** — extension navigateur
|
||||
- [ ] **API publique complète** — REST API documentée (OpenAPI) *(base existante : `public_api.py`, à étendre + documenter)* — voir [`docs/API_GUIDE_V6.md`](docs/API_GUIDE_V6.md) : référence complète (conventions, CRUD par ressource, webhooks, sécurité, checklist)
|
||||
- [ ] **Realtime editing (production)** — voir **v5.13.0** (curseurs + présence déjà avancés ici) ; reste en v6 : conflits avancés, édition large échelle
|
||||
- [ ] **Synced blocks (production)** — voir **v5.14.0** (bloc de base) ; reste en v6 : syncing côté databases/vues
|
||||
- [x] **PWA** — Progressive Web App, offline support ✅ (livré) — [📄 Conception détaillée](/docs/V6_PWA_Progressive_Web_App.md)
|
||||
- [x] **Granular permissions** — page-level, property-level access control ✅ (livré v6.1.0) — [📄 Conception détaillée](/docs/V6_Granular_Permissions.md)
|
||||
- [x] **Web Clipper** — extension navigateur ✅ (livré v6.2.0/6.2.1) — [📄 Conception détaillée](/docs/V6_Web_Clipper.md)
|
||||
- [x] **API publique complète** — REST API documentée (OpenAPI) ✅ (livré v6.3.0) — [📄 API Guide v2](/docs/API_GUIDE_V6.md) · [📄 OpenAPI](/docs/openapi-v2.json)
|
||||
- [ ] **SSO/SAML** — enterprise authentication — [📄 Conception détaillée](/docs/V6_SSO_SAML_Enterprise_Auth.md)
|
||||
- [x] **Realtime editing (production)** ✅ livré **v6.4.0** (merge 3-voix au-delà du LWW, broadcast non bloquant) ; voir **v5.13.0** pour le socle (curseurs + présence)
|
||||
- [x] **Synced blocks (production)** ✅ livré **v6.5.0** (page contenu par ligne de database, résolution serveur à chaque lecture, propagation écrite réelle, état deleted, rendu public) ; socle : **v5.14.0** (bloc de base)
|
||||
|
||||
---
|
||||
|
||||
@@ -632,7 +906,14 @@ app/
|
||||
3. ~~**v5.3.0 → Inline databases + templates + validation**~~ ✅ livré (slash `/database`, 6 templates, validation propriétés)
|
||||
4. ~~**v5.13.0 → Realtime (WS + présence)**~~ ✅ livré (`app/services/realtime_server.py` + `WS /ws/pages/{id}`, présence, curseurs live, merge LWW, 12 tests)
|
||||
5. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, 9 tests)
|
||||
6. **v5.4.0 → Expérience éditeur** — backlinks, duplicate, corbeille globale, historique de version UI, import (prochain)
|
||||
6. ~~**v5.4.0 → Expérience éditeur**~~ ✅ livré (backlinks, page/collection duplicate, corbeille globale + purge 30 j, historique de version UI, import Markdown/CSV/Notion)
|
||||
7. ~~**v5.5.0 → Embeds & Média riche**~~ ✅ livré (embed universel 15 providers, bookmark cards OG, lightbox clavier, préviews PDF/vidéo/audio, cover & icône ; 47 tests dédiés)
|
||||
8. ~~**v5.6.0 → Import de données (6 phases)**~~ ✅ **livré** — Phase 0 socle unifié · Phase 1 notes/Markdown (Obsidian, Notion, Logseq/Roam, Apple Notes/Bear, Google Keep, OneNote) · Phase 2 données/tableaux (CSV typé, Excel, Sheets, JSON) · Phase 3 documents (Word, Google Docs, HTML, PDF) · Phase 4 signets/dev/divers (Gitea/GitHub, Raindrop, Pocket, Readwise, Shaarli, `.ics`, OPML, Standard Notes) · Phase 5 durcissement (re-sync, dépôt forge, URL/web clipper, lot multi-fichiers, relations Notion, rapports exportables)
|
||||
9. ~~**v5.7.0 → Database Avancée (Pt. 2)**~~ ✅ **livré** (person + auto-propriétés, groupes de propriétés, vues sauvegardées par utilisateur, swimlanes, WIP limits, cartes configurables, calendar drag & drop, gallery couvertures ; 12 tests dédiés)
|
||||
10. ~~**v5.8.0 → Calendrier & Rappels**~~ ✅ **livré** (vues jour/semaine/mois, récurrences RRULE expandues serveur, rappels in-app + email avec dédup, fuseaux par utilisateur/événement, notifications d'assignation, template Meeting notes enrichi ; 20 tests dédiés)
|
||||
11. ~~**v5.11.0 → Wiki-links & mentions de page**~~ ✅ **livré** (picker `[[`, mentions `@` pages/date, chips atomiques, renommage propagé, backlinks wiki, chips en page publique)
|
||||
12. ~~**v5.12.0 → Templates & verrouillage de page**~~ ✅ **livré** (template picker global 5 built-in + templates perso, use-template, page lock 423, full-width, small text)
|
||||
13. **v5.14.0 → v5.14.0 COMPLETED** ✅ (synced blocks)
|
||||
---
|
||||
|
||||
## Résumé des phases
|
||||
@@ -644,11 +925,11 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard
|
||||
+ UI Notion + Tags + Admin + Sharing COMPLETED
|
||||
+ GitHub OAuth + Library
|
||||
|
||||
v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.3 ✅ v5.13 ✅ · v5.10 ✅ v5.14 ⬜ v6.0 ⬜
|
||||
Quality DB views, Agent IA Palette → Realtime + Synced Pro + Agent
|
||||
& Tests Templates & COMPLETED Automations Interactions blocks
|
||||
Collaboration Realtime, de bloc (undo/
|
||||
DB avancée, redo, drag&drop,
|
||||
Calendrier, AI duplicate)
|
||||
v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.3 ✅ v5.13 ✅ · v5.10 ✅ v5.5 ✅ · v5.7 ✅ v5.14 ✅ · v6.0 ✅
|
||||
Quality DB views, Agent IA Palette → Realtime + Embeds & Synced PWA · Perms
|
||||
& Tests Templates & COMPLETED Automations Interactions Média riche blocks Clipper · API v2
|
||||
Collaboration Realtime, de bloc (undo/ (embed, + DB
|
||||
DB avancée, redo, drag&drop, bookmark, avancée
|
||||
Calendrier, AI duplicate) lightbox…) (Pt.2) v6.1 ✅ v6.2 ✅ v6.3 ✅
|
||||
|
||||
*Dernière mise à jour: 2026-09-11 — **v5.2.0 Infrastructure & Polish validé** (18/18 tests dédiés, 397 tests suite complète, ruff/eslint verts, CI Gitea success sur push + PR #15) ; reste v5.4, v5.11 → v6.0*
|
||||
*Dernière mise à jour: 2026-09-24 — **v6.5.0 Synced blocks production (databases & vues) COMPLETED** (page contenu par ligne de DB + ouverture correcte des lignes dans les vues, résolution serveur à chaque lecture, propagation écrite réelle, état deleted, rendu public des synced blocks ; 17 tests dédiés, suite 742) + **v6.4.0** realtime + **v6.3.0** API v2. Reste: SSO/SAML, migration de `sync.py` vers Bearer.*
|
||||
|
||||
+7
-4
@@ -1,7 +1,7 @@
|
||||
# WORKLOAD — FlowDeck Notion Clone
|
||||
|
||||
> **Début**: 2026-07-08 | **Version**: v2.2.0 | **Statut**: EN COURS 🔄
|
||||
> **Cible v3.0**: Multi-User, Multi-Forge (Gitea/GitHub), Standalone
|
||||
> **Début**: 2026-07-08 | **Version**: v6.4.0 | **Statut**: EN COURS 🔄
|
||||
> **Cible**: parité Notion + intégration forge · **Reste roadmap**: SSO/SAML, synced blocks prod (databases/vues)
|
||||
|
||||
## Avancement Global
|
||||
|
||||
@@ -21,7 +21,10 @@
|
||||
| v2.0 | Multi-User + Editor Complete | ✅ | 67/67 |
|
||||
| v2.1 | Public API, Webhooks, PWA | ✅ | 73/73 |
|
||||
| v2.2 | Share/Publish, Favorites, Library | ✅ | 73/73 |
|
||||
| v3.0 | **Auth locale, Multi-Forge, Standalone** | 🔲 | — |
|
||||
| v3.0 | Auth locale, Multi-Forge, Standalone | ✅ | — |
|
||||
| v4.x–v5.x | MVP → Agent IA, palette, automations, import, calendrier, wiki-links, synced blocks | ✅ | 523+ |
|
||||
| v6.0–v6.3 | PWA offline, permissions granulaires, web clipper, API publique v2 | ✅ | 668+ |
|
||||
| **v6.4.0** | **Realtime production (merge 3-voix, broadcast non bloquant)** | ✅ | **749+** |
|
||||
|
||||
## Blocs Complétés
|
||||
|
||||
@@ -58,5 +61,5 @@ CRUD collections/pages, 5 vues HTML, relations/rollups/formulas, sub-items/depen
|
||||
- **BDD**: SQLite WAL mode, 21 tables, foreign keys ON
|
||||
- **Auth**: OAuth2 Gitea + sessions signed (itsdangerous) + token API
|
||||
- **Déploiement**: Docker (python:3.12-slim), docker-compose, port 8080
|
||||
- **Tests**: pytest, 73 tests, TestClient avec SQLite temporaire
|
||||
- **Tests**: pytest, 749+ tests, TestClient avec SQLite temporaire
|
||||
- **CI/CD**: Gitea Actions (.gitea/workflows/ci.yml)
|
||||
|
||||
+19
-2
@@ -43,6 +43,10 @@ class Settings(BaseSettings):
|
||||
rate_limit_enabled: bool = True
|
||||
rate_limit_requests: int = 60 # per minute
|
||||
|
||||
# Public API v2 (v6.3.0)
|
||||
public_api_insecure_ok: bool = False # if True, fd-public-key is accepted (dev only)
|
||||
api_v2_rate_limit_per_token: int = 300 # req/min per token for /api/v2
|
||||
|
||||
# Database
|
||||
database_url: str = "sqlite:////data/flowdeck.db"
|
||||
|
||||
@@ -60,6 +64,14 @@ class Settings(BaseSettings):
|
||||
project_sync_enabled: bool = True
|
||||
project_sync_interval_hours: int = 1
|
||||
|
||||
# Reminders (v5.8.0) — background scan for due date reminders
|
||||
reminders_enabled: bool = True
|
||||
reminder_scan_interval_seconds: int = 60
|
||||
|
||||
# Webhooks outbound (v6.4.0) — retry of failed deliveries
|
||||
webhook_retry_enabled: bool = True
|
||||
webhook_retry_interval_seconds: int = 60
|
||||
|
||||
# Email / SMTP notifications (v4.9.0) — optional. If smtp_host is empty,
|
||||
# email notifications are skipped (only in-app notifications are delivered).
|
||||
smtp_host: str = ""
|
||||
@@ -73,8 +85,13 @@ class Settings(BaseSettings):
|
||||
# FlowDeck Agent (v4.10.0) — multi-LLM. Empty keys → offline/mock mode
|
||||
# (deterministic rule-based planner so the agent works without any API key).
|
||||
agent_enabled: bool = True
|
||||
llm_provider: str = "offline" # openai | anthropic | google | ollama |
|
||||
# deepseek | qwencloud | nvidia | openrouter | offline
|
||||
llm_provider: str = "offline" # any id from llm_client.PROVIDERS
|
||||
# (openai, anthropic, mistral, cohere,
|
||||
# google, groq, deepseek, openrouter,
|
||||
# nvidia, together, perplexity, xai,
|
||||
# qwencloud, minimax, morph, fireworks,
|
||||
# cerebras, sambanova, chutes, xiaomi,
|
||||
# sealion, sensenova, ollama, offline)
|
||||
llm_model: str = "gpt-4o"
|
||||
llm_api_key: str = ""
|
||||
llm_api_base: str = "" # custom base URL (Ollama, OpenRouter, ...)
|
||||
|
||||
@@ -442,12 +442,18 @@ def init_db():
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
shared_with_user_id INTEGER REFERENCES users(id),
|
||||
shared_with_group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
shared_with_email TEXT DEFAULT '',
|
||||
permission TEXT NOT NULL DEFAULT 'view',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER REFERENCES users(id)
|
||||
)
|
||||
""")
|
||||
# v5.x: migration — partage par groupes (colonne manquante sur DB existantes)
|
||||
try:
|
||||
conn.execute("ALTER TABLE page_shares ADD COLUMN shared_with_group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE")
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
# 4) recents table
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS recents (
|
||||
|
||||
+70
-24
@@ -8,6 +8,7 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.exceptions import HTTPException as _StarHTTPException
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from app.config import settings
|
||||
@@ -33,15 +34,23 @@ from app.routers import (
|
||||
security,
|
||||
sharing,
|
||||
sidebar_config,
|
||||
sync,
|
||||
webhooks,
|
||||
workspace,
|
||||
)
|
||||
from app.routers.api_v2 import router as api_v2_router
|
||||
from app.routers.automations import router as automations_router
|
||||
from app.routers.collaboration import router as collaboration_router
|
||||
from app.routers.emoji import router as emoji_router
|
||||
from app.routers.gitea import router as gitea_router
|
||||
from app.routers.github_routes import router as github_router
|
||||
from app.routers.imports import page_router as import_page_router
|
||||
from app.routers.imports import router as imports_router
|
||||
from app.routers.notifications import router as notifications_router
|
||||
from app.routers.permissions import router as permissions_router
|
||||
from app.routers.realtime import router as realtime_router
|
||||
from app.routers.web_clipper import api_router as web_clipper_api_router
|
||||
from app.routers.web_clipper import router as web_clipper_router
|
||||
from app.services.webhook_outbound import init_webhook_tables
|
||||
|
||||
logging.basicConfig(
|
||||
@@ -85,13 +94,26 @@ async def lifespan(_app: FastAPI):
|
||||
from app.services.trash import trash_purge_scheduler
|
||||
trash_task = asyncio.create_task(trash_purge_scheduler())
|
||||
|
||||
logger.info("FlowDeck v5.11.1 started on port %d", settings.app_port)
|
||||
# ── Reminders (v5.8.0): due-reminder scan for database rows ──
|
||||
from app.services.reminders import reminder_scheduler
|
||||
reminder_task = asyncio.create_task(reminder_scheduler())
|
||||
|
||||
# ── Webhooks outbound (v6.4.0): retry failed deliveries ──
|
||||
from app.services.webhook_outbound import webhook_retry_scheduler
|
||||
webhook_task = None
|
||||
if settings.webhook_retry_enabled:
|
||||
webhook_task = asyncio.create_task(webhook_retry_scheduler())
|
||||
|
||||
logger.info("FlowDeck v%s started on port %d", dashboard._get_app_version(), settings.app_port)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task):
|
||||
_tasks = (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task)
|
||||
if webhook_task is not None:
|
||||
_tasks = _tasks + (webhook_task,)
|
||||
for task in _tasks:
|
||||
task.cancel()
|
||||
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task):
|
||||
for task in _tasks:
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
@@ -100,9 +122,9 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="5.11.1",
|
||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||
redoc_url=None,
|
||||
version="6.5.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
@@ -134,26 +156,35 @@ app.include_router(export.router)
|
||||
app.include_router(notifications_router)
|
||||
app.include_router(automations_router)
|
||||
app.include_router(collaboration_router)
|
||||
app.include_router(emoji_router)
|
||||
app.include_router(realtime_router)
|
||||
app.include_router(agent.router)
|
||||
app.include_router(search.router)
|
||||
app.include_router(security.router)
|
||||
app.include_router(onboarding.router)
|
||||
app.include_router(sync.router)
|
||||
app.include_router(imports_router)
|
||||
app.include_router(import_page_router)
|
||||
app.include_router(permissions_router)
|
||||
app.include_router(web_clipper_api_router)
|
||||
app.include_router(web_clipper_router)
|
||||
app.include_router(api_v2_router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
@app.get("/manifest.json")
|
||||
async def pwa_manifest():
|
||||
return {
|
||||
"name": "FlowDeck",
|
||||
"short_name": "FlowDeck",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#191919",
|
||||
"theme_color": "#191919",
|
||||
"icons": [{"src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png"}],
|
||||
}
|
||||
"""Serve the static web manifest from disk (same URL as before v6.0.0)."""
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse("static/manifest.json", media_type="application/manifest+json")
|
||||
|
||||
|
||||
@app.get("/sw.js")
|
||||
async def service_worker():
|
||||
"""Serve the PWA service worker at top-level scope (/)."""
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse("static/sw.js", media_type="application/javascript")
|
||||
|
||||
|
||||
# ═══════════ API aliases (v4.0.1) ═══════════
|
||||
@@ -218,12 +249,27 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
|
||||
</html>"""
|
||||
|
||||
|
||||
@app.exception_handler(404)
|
||||
async def not_found_handler(request: Request, exc):
|
||||
"""Redirect 404 HTML pages to /workspaces. API routes still get JSON."""
|
||||
# Preserve JSON 404 for all API-like paths (including /db/xxx/api)
|
||||
if "/api" in request.url.path:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": "Not found"}, status_code=404)
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
@app.exception_handler(_StarHTTPException)
|
||||
async def http_exception_handler(request: Request, exc: _StarHTTPException):
|
||||
"""Unified handler: RFC7807 for /api/v2, JSON for other /api, redirect for HTML.
|
||||
|
||||
Registered on Starlette's HTTPException (the base class) so it catches both
|
||||
raised exceptions and route-miss 404s.
|
||||
"""
|
||||
status = getattr(exc, "status_code", 500)
|
||||
detail = getattr(exc, "detail", str(exc))
|
||||
if status == 404:
|
||||
if request.url.path.startswith("/api/v2"):
|
||||
from app.services.api_v2_helpers import problem_response
|
||||
return problem_response(request, exc)
|
||||
if "/api" in request.url.path:
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": detail if isinstance(detail, str) else "Not found"}, status_code=404)
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
# Non-404: RFC7807 for /api/v2
|
||||
if request.url.path.startswith("/api/v2"):
|
||||
from app.services.api_v2_helpers import problem_response
|
||||
return problem_response(request, exc)
|
||||
from fastapi.responses import JSONResponse
|
||||
return JSONResponse({"detail": detail if isinstance(detail, str) else str(detail)}, status_code=status)
|
||||
|
||||
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
|
||||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
|
||||
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"}
|
||||
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"}
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# Webhook receiver, OAuth callback, and internal API are exempt
|
||||
|
||||
@@ -335,6 +335,25 @@ def _migration_v54_page_versions_cover(conn: sqlite3.Connection) -> None:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN page_icon TEXT DEFAULT ''")
|
||||
|
||||
|
||||
@register(8, "v5.6.0: custom workspace emojis")
|
||||
def _migration_custom_emojis(conn: sqlite3.Connection) -> None:
|
||||
"""Workspace-wide custom emojis (uploaded images) used as page icons."""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS custom_emojis (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER NOT NULL DEFAULT 1,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
url TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_custom_emojis_ws ON custom_emojis(workspace_id, created_at)"
|
||||
)
|
||||
|
||||
|
||||
@register(4, "database templates (icon) + property validation")
|
||||
def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
|
||||
"""v5.3.0: database templates get an icon, properties a validation config,
|
||||
@@ -356,3 +375,611 @@ def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
|
||||
(tpl["name"], tpl.get("icon", "📋"), tpl.get("description", ""),
|
||||
__import__("json").dumps(tpl.get("schema", []))),
|
||||
)
|
||||
|
||||
|
||||
@register(9, "v5.6.0: import items (dedup) + import jobs")
|
||||
def _migration_import_framework(conn: sqlite3.Connection) -> None:
|
||||
"""Unified import framework (Phase 0).
|
||||
|
||||
``import_items`` — one row per imported page, keyed by workspace + source +
|
||||
external id, so re-importing the same vault is idempotent.
|
||||
``import_jobs`` — background import job status/history for UI polling.
|
||||
"""
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS import_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
external_id TEXT NOT NULL DEFAULT '',
|
||||
page_id INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(workspace_id, source, external_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_import_items_lookup "
|
||||
"ON import_items(workspace_id, source, external_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS import_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT NOT NULL DEFAULT '',
|
||||
filename TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
report_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_import_jobs_created ON import_jobs(created_at)"
|
||||
)
|
||||
|
||||
|
||||
@register(10, "v5.7.0: property groups, per-user views, row covers")
|
||||
def _migration_v57_db_advanced(conn: sqlite3.Connection) -> None:
|
||||
"""v5.7.0 — Database Avancée (Pt. 2).
|
||||
|
||||
``collection_properties.group_name`` — groups properties into collapsible
|
||||
sections in the table header (Notion property groups).
|
||||
``collection_views.created_by`` — owner of a saved view; ``NULL`` means
|
||||
a shared/legacy view visible to everyone, otherwise it is personal to a user.
|
||||
``collection_pages.cover_url`` — per-row cover image (gallery/board
|
||||
cards), independent from the block-page ``pages.cover_url``.
|
||||
"""
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
|
||||
if "group_name" not in _pcols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collection_properties ADD COLUMN group_name TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
|
||||
_vcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_views)").fetchall()}
|
||||
if "created_by" not in _vcols:
|
||||
conn.execute("ALTER TABLE collection_views ADD COLUMN created_by INTEGER")
|
||||
if "updated_at" not in _vcols:
|
||||
conn.execute("ALTER TABLE collection_views ADD COLUMN updated_at TIMESTAMP")
|
||||
|
||||
_cpcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
|
||||
if "cover_url" not in _cpcols:
|
||||
conn.execute("ALTER TABLE collection_pages ADD COLUMN cover_url TEXT DEFAULT ''")
|
||||
|
||||
|
||||
@register(11, "v5.8.0: reminder log + user timezones")
|
||||
def _migration_v58_calendar_reminders(conn: sqlite3.Connection) -> None:
|
||||
"""v5.8.0 — Calendrier & Rappels.
|
||||
|
||||
``reminder_log`` — dedup ledger: one row per (page, occurrence date) so
|
||||
a reminder fires exactly once even across restarts.
|
||||
``users.timezone`` — personal IANA timezone used for "today" in calendar
|
||||
views and reminder firing (empty = UTC).
|
||||
"""
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS reminder_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
|
||||
occurrence_date TEXT NOT NULL,
|
||||
fired_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(page_id, occurrence_date)
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_remlog_page ON reminder_log(page_id)"
|
||||
)
|
||||
|
||||
_ucols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
|
||||
if "timezone" not in _ucols:
|
||||
conn.execute("ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT ''")
|
||||
|
||||
|
||||
@register(12, "v5.8.0: enrich Meeting notes template")
|
||||
def _migration_v58_meeting_template(conn: sqlite3.Connection) -> None:
|
||||
"""v5.8.0 — the seeded 'Meeting notes' database template gains Agenda and
|
||||
Notes text properties. Only refreshed when the row still matches the old
|
||||
built-in schema (user edits are never clobbered)."""
|
||||
import json as _json
|
||||
row = conn.execute(
|
||||
"SELECT schema_json FROM database_templates WHERE name='Meeting notes'"
|
||||
).fetchone()
|
||||
if not row:
|
||||
return
|
||||
try:
|
||||
schema = _json.loads(row[0] or "[]")
|
||||
except (ValueError, TypeError):
|
||||
return
|
||||
names = [p.get("name") for p in schema]
|
||||
if "Agenda" in names or "Notes" in names:
|
||||
return
|
||||
if names != ["Title", "Date", "Attendees", "Status", "Action items"]:
|
||||
return # customised — leave alone
|
||||
idx = names.index("Action items")
|
||||
schema[idx:idx] = [
|
||||
{"name": "Agenda", "type": "text"},
|
||||
{"name": "Notes", "type": "text"},
|
||||
]
|
||||
conn.execute(
|
||||
"UPDATE database_templates SET schema_json=? WHERE name='Meeting notes'",
|
||||
(_json.dumps(schema),),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE database_templates SET description=? WHERE name='Meeting notes'",
|
||||
("Notes de réunion avec participants, agenda, notes et actions.",),
|
||||
)
|
||||
|
||||
|
||||
@register(13, "v5.11.0/v5.12.0: page lock, user typo prefs, global page templates")
|
||||
def _migration_v511_wiki_v512_templates(conn: sqlite3.Connection) -> None:
|
||||
"""v5.11.0 Wiki-links + v5.12.0 Templates & verrouillage.
|
||||
|
||||
``pages.is_locked`` — read-only page (locker/admin can unlock).
|
||||
``pages.locked_by`` — user that locked the page.
|
||||
``pages.full_width`` — per-page full-width layout toggle.
|
||||
``pages.font_small`` — per-page compact typography toggle.
|
||||
``page_global_templates`` — user-created global page templates
|
||||
(blocks_json = same format as the block editor saves).
|
||||
"""
|
||||
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
if "is_locked" not in _pcols:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0")
|
||||
if "locked_by" not in _pcols:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN locked_by INTEGER REFERENCES users(id) ON DELETE SET NULL")
|
||||
if "full_width" not in _pcols:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN full_width INTEGER NOT NULL DEFAULT 0")
|
||||
if "font_small" not in _pcols:
|
||||
conn.execute("ALTER TABLE pages ADD COLUMN font_small INTEGER NOT NULL DEFAULT 0")
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS page_global_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
icon TEXT NOT NULL DEFAULT '📄',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
blocks_json TEXT NOT NULL DEFAULT '[]',
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_pgt_creator ON page_global_templates(created_by)"
|
||||
)
|
||||
|
||||
|
||||
@register(14, "v5.13.0: fix stale LLM provider api_base values")
|
||||
def _migration_fix_llm_api_bases(conn: sqlite3.Connection) -> None:
|
||||
"""Clear `api_base` values that freeze a provider URL to a wrong/default value.
|
||||
|
||||
The Agent "Test connection" uses the per-user (or global) stored `api_base`
|
||||
when present, so an old/incorrect value (e.g. Mistral `…/v2`, Cohere
|
||||
`…/v2`, Google native `/v1beta`) keeps failing even after `PROVIDERS` is
|
||||
corrected. Two kinds of rows are reset to the provider default:
|
||||
|
||||
* known-wrong legacy bases from earlier releases;
|
||||
* a stored base identical to the current provider default (a no-op
|
||||
override that would block future default changes).
|
||||
"""
|
||||
from app.services.llm_client import PROVIDERS
|
||||
|
||||
legacy: dict[str, set[str]] = {
|
||||
"mistral": {"https://api.mistral.ai/v2"},
|
||||
"cohere": {"https://api.cohere.com/v2", "https://api.cohere.com/v1",
|
||||
"https://api.cohere.ai/v2"},
|
||||
"google": {"https://generativelanguage.googleapis.com/v1beta"},
|
||||
"perplexity": {"https://api.perplexity.ai/v1"},
|
||||
"chutes": {"https://api.chutes.ai/v1"},
|
||||
"sensenova": {"https://token.sensenova.cn/v1"},
|
||||
"ltx": {"https://api.ltx.io/v1"},
|
||||
"memtensor": {"https://memos.memtensor.cn/api/openmem/v1"},
|
||||
}
|
||||
for provider, (base, _) in PROVIDERS.items():
|
||||
if base:
|
||||
legacy.setdefault(provider, set()).update({base, base.rstrip("/")})
|
||||
|
||||
for provider, bases in legacy.items():
|
||||
variants = {b for b in bases if b}
|
||||
variants |= {b.rstrip("/") for b in bases if b}
|
||||
for table in ("user_llm_keys", "llm_config"):
|
||||
for value in variants:
|
||||
conn.execute(
|
||||
f"UPDATE {table} SET api_base='' WHERE provider=? AND api_base=?",
|
||||
(provider, value),
|
||||
)
|
||||
|
||||
|
||||
@register(15, "v5.14.0: synced blocks")
|
||||
def _migration_synced_blocks(conn: sqlite3.Connection) -> None:
|
||||
"""v5.14.0 — Synced blocks: a block created once, displayed &
|
||||
edited across multiple pages.
|
||||
|
||||
``synced_blocks`` — source-of-truth content for synced blocks.
|
||||
``page_blocks`` — per-page reference to a synced block
|
||||
(so each page can independently decide to use/unsync).
|
||||
"""
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS synced_blocks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '[]',
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
workspace TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_synced_blocks_ws ON synced_blocks(workspace)"
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS page_synced_blocks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
synced_block_id INTEGER NOT NULL REFERENCES synced_blocks(id) ON DELETE CASCADE,
|
||||
block_index INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(page_id, synced_block_id)
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_psb_page ON page_synced_blocks(page_id)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_psb_synced ON page_synced_blocks(synced_block_id)"
|
||||
)
|
||||
|
||||
|
||||
@register(16, "v6.0.0: offline sync queue")
|
||||
def _migration_offline_sync_queue(conn: sqlite3.Connection) -> None:
|
||||
"""v6.0.0 — PWA offline support.
|
||||
|
||||
``offline_sync_queue`` persists server-side the mutations received from
|
||||
offline clients (``/api/v2/sync/batch``) so work is not lost and can be
|
||||
audited/replayed per device.
|
||||
"""
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS offline_sync_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device_id TEXT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
-- 'page_create', 'page_update', 'page_delete', 'page_move',
|
||||
-- 'collection_create', 'collection_update', 'collection_delete'
|
||||
payload TEXT NOT NULL,
|
||||
client_timestamp REAL NOT NULL,
|
||||
server_version INTEGER DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
-- 'pending', 'syncing', 'synced', 'failed'
|
||||
retries INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_syncqueue_user ON offline_sync_queue(user_id, status)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_syncqueue_device ON offline_sync_queue(device_id, status)"
|
||||
)
|
||||
|
||||
|
||||
@register(18, "v6.0.0: granular permissions (page/collection/property ACL + groups)")
|
||||
def _migration_v600_granular_permissions(conn: sqlite3.Connection) -> None:
|
||||
"""v6.0.0 — Granular permissions (page-level, collection-level,
|
||||
property-level access control + reusable user groups).
|
||||
|
||||
``user_groups`` — named groups scoped to a workspace.
|
||||
``group_members`` — users inside a group (N-ary join).
|
||||
``page_permissions`` — explicit grants for block-editor pages
|
||||
(``pages`` table). user_id XOR group_id.
|
||||
``collection_permissions`` — explicit grants for databases.
|
||||
``property_permissions`` — explicit viewer/editor grants per property.
|
||||
``permission_audit_log`` — immutable trail of every grant/revoke.
|
||||
``pages.permission_type`` / ``collections.permission_type`` — access
|
||||
mode: 'inherit' (default, follows the
|
||||
workspace/collection chain) | 'restricted'
|
||||
| 'private' (explicit grants only).
|
||||
"""
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS user_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id INTEGER REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(workspace_id, name)
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS group_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(group_id, user_id)
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_gm_group ON group_members(group_id)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_gm_user ON group_members(user_id)")
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS page_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL, -- viewer | commenter | editor | owner
|
||||
grant_type TEXT NOT NULL DEFAULT 'explicit', -- explicit | group
|
||||
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (user_id IS NOT NULL OR group_id IS NOT NULL),
|
||||
UNIQUE(page_id, user_id, group_id)
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_pp_page ON page_permissions(page_id, role)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_pp_user ON page_permissions(user_id)")
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS collection_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL, -- viewer | commenter | editor | owner
|
||||
grant_type TEXT NOT NULL DEFAULT 'explicit',
|
||||
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (user_id IS NOT NULL OR group_id IS NOT NULL),
|
||||
UNIQUE(collection_id, user_id, group_id)
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_cp_collection ON collection_permissions(collection_id, role)")
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS property_permissions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
|
||||
property_id INTEGER NOT NULL REFERENCES collection_properties(id) ON DELETE CASCADE,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id INTEGER REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL, -- viewer | editor
|
||||
grant_type TEXT NOT NULL DEFAULT 'explicit',
|
||||
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
CHECK (user_id IS NOT NULL OR group_id IS NOT NULL),
|
||||
UNIQUE(collection_id, property_id, user_id, group_id)
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_propp_prop ON property_permissions(property_id, role)")
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS permission_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
resource_type TEXT NOT NULL, -- page | collection | property | group
|
||||
resource_id INTEGER NOT NULL,
|
||||
action TEXT NOT NULL, -- grant | revoke | type_change | group_create | group_delete | member_add | member_remove
|
||||
target_user_id INTEGER,
|
||||
target_group_id INTEGER,
|
||||
old_role TEXT,
|
||||
new_role TEXT,
|
||||
performed_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_perm_audit_res "
|
||||
"ON permission_audit_log(resource_type, resource_id, created_at)"
|
||||
)
|
||||
|
||||
for table in ("pages", "collection_pages"):
|
||||
cols = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
if "permission_type" not in cols:
|
||||
conn.execute(
|
||||
f"ALTER TABLE {table} ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
|
||||
)
|
||||
_ccols = {r[1] for r in conn.execute("PRAGMA table_info(collections)").fetchall()}
|
||||
if "permission_type" not in _ccols:
|
||||
conn.execute(
|
||||
"ALTER TABLE collections ADD COLUMN permission_type TEXT NOT NULL DEFAULT 'inherit'"
|
||||
)
|
||||
|
||||
|
||||
def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
|
||||
"""Add ``sync_version`` to ``table`` if it is not already present."""
|
||||
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
|
||||
if "sync_version" not in cols:
|
||||
conn.execute(f"ALTER TABLE {table} ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1")
|
||||
|
||||
|
||||
@register(19, "v6.0.0: web clipper — extension devices & clips")
|
||||
def _migration_web_clipper(conn: sqlite3.Connection) -> None:
|
||||
"""v6.0.0 — Web Clipper: extension browser + capture."""
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS extension_devices (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
extension_name TEXT NOT NULL DEFAULT 'clipper',
|
||||
device_id TEXT NOT NULL,
|
||||
device_name TEXT DEFAULT '',
|
||||
token_hash TEXT NOT NULL DEFAULT '',
|
||||
scopes TEXT NOT NULL DEFAULT 'read,write',
|
||||
last_used_at TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
revoked INTEGER NOT NULL DEFAULT 0,
|
||||
UNIQUE(user_id, extension_name, device_id)
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ext_devices_user ON extension_devices(user_id, revoked)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ext_devices_device ON extension_devices(device_id)"
|
||||
)
|
||||
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS extension_clips (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
clip_type TEXT NOT NULL DEFAULT 'article',
|
||||
source_url TEXT NOT NULL DEFAULT '',
|
||||
target_page_id INTEGER REFERENCES pages(id) ON DELETE SET NULL,
|
||||
target_workspace_id INTEGER REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||
title TEXT DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ext_clips_user ON extension_clips(user_id, created_at)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ext_clips_device ON extension_clips(device_id)"
|
||||
)
|
||||
|
||||
|
||||
@register(20, "v6.3.0: api v2 — scopes, expires_at, audit, webhooks, idempotency")
|
||||
def _migration_v630_api_v2(conn: sqlite3.Connection) -> None:
|
||||
"""v6.3.0 — API publique complète v2.
|
||||
|
||||
``api_tokens`` — adds ``scopes`` + ``expires_at`` (idempotent ALTER).
|
||||
``webhook_deliveries`` — delivery log for outbound webhooks (CRUD simple phase 1).
|
||||
``api_audit_log`` — immutable audit trail for v2 mutations.
|
||||
``idempotency_keys`` — Idempotency-Key support for POST creations.
|
||||
"""
|
||||
# api_tokens extra columns
|
||||
_cols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
|
||||
if "scopes" not in _cols:
|
||||
conn.execute("ALTER TABLE api_tokens ADD COLUMN scopes TEXT NOT NULL DEFAULT 'read,write'")
|
||||
if "expires_at" not in _cols:
|
||||
conn.execute("ALTER TABLE api_tokens ADD COLUMN expires_at TIMESTAMP")
|
||||
# Backfill existing tokens without scopes
|
||||
try:
|
||||
conn.execute("UPDATE api_tokens SET scopes='read,write' WHERE scopes='' OR scopes IS NULL")
|
||||
except Exception:
|
||||
pass
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS api_audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
token_id INTEGER REFERENCES api_tokens(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
resource_type TEXT NOT NULL DEFAULT '',
|
||||
resource_id TEXT NOT NULL DEFAULT '',
|
||||
ip_address TEXT NOT NULL DEFAULT '',
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_api_audit_user ON api_audit_log(user_id, created_at)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_api_audit_resource ON api_audit_log(resource_type, resource_id)")
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
webhook_id INTEGER NOT NULL REFERENCES webhook_subscriptions(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
http_code INTEGER,
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
attempt INTEGER NOT NULL DEFAULT 0,
|
||||
payload TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_wd_webhook ON webhook_deliveries(webhook_id, created_at)")
|
||||
conn.execute(
|
||||
"""CREATE TABLE IF NOT EXISTS idempotency_keys (
|
||||
key TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
response_json TEXT NOT NULL DEFAULT '{}',
|
||||
status_code INTEGER NOT NULL DEFAULT 200,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_idemp_user ON idempotency_keys(user_id, created_at)")
|
||||
|
||||
|
||||
@register(21, "v6.4.0: webhooks prod — event, retry ledger")
|
||||
def _migration_v640_webhooks_prod(conn: sqlite3.Connection) -> None:
|
||||
"""v6.4.0 — Webhooks v2 production.
|
||||
|
||||
``webhook_deliveries`` gains ``event`` (which event was delivered) and
|
||||
``next_retry_at`` (epoch seconds; picked up by the retry scheduler).
|
||||
New statuses: ``retrying`` (a later attempt is scheduled) and
|
||||
``superseded`` (a retry row replaced this attempt).
|
||||
"""
|
||||
_cols = {r[1] for r in conn.execute("PRAGMA table_info(webhook_deliveries)").fetchall()}
|
||||
if "event" not in _cols:
|
||||
conn.execute("ALTER TABLE webhook_deliveries ADD COLUMN event TEXT NOT NULL DEFAULT ''")
|
||||
if "next_retry_at" not in _cols:
|
||||
conn.execute("ALTER TABLE webhook_deliveries ADD COLUMN next_retry_at REAL")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_wd_retry ON webhook_deliveries(status, next_retry_at)"
|
||||
)
|
||||
|
||||
|
||||
@register(17, "v6.0.0: sync_version columns")
|
||||
def _migration_sync_version_columns(conn: sqlite3.Connection) -> None:
|
||||
"""v6.0.0 — optimistic-concurrency version counters for offline sync.
|
||||
|
||||
Every write on a page / collection increments its ``sync_version`` so a
|
||||
reconnecting client can detect edit-edit conflicts via version mismatch.
|
||||
A ``BEFORE UPDATE`` trigger performs the increment automatically on every
|
||||
write path (no need to patch dozens of ``UPDATE`` call-sites).
|
||||
"""
|
||||
for table in ("pages", "collection_pages", "collections"):
|
||||
_add_sync_version(conn, table)
|
||||
|
||||
# AFTER UPDATE + inner UPDATE: bumps sync_version on every write path.
|
||||
# `recursive_triggers` is OFF by default, so the inner UPDATE never
|
||||
# re-fires the trigger (no infinite loop), incl. the page FTS triggers.
|
||||
conn.execute(
|
||||
"""CREATE TRIGGER IF NOT EXISTS pages_sync_version_bu
|
||||
AFTER UPDATE ON pages
|
||||
FOR EACH ROW BEGIN
|
||||
UPDATE pages SET sync_version = sync_version + 1 WHERE id = NEW.id;
|
||||
END"""
|
||||
)
|
||||
conn.execute(
|
||||
"""CREATE TRIGGER IF NOT EXISTS collection_pages_sync_version_bu
|
||||
AFTER UPDATE ON collection_pages
|
||||
FOR EACH ROW BEGIN
|
||||
UPDATE collection_pages SET sync_version = sync_version + 1 WHERE id = NEW.id;
|
||||
END"""
|
||||
)
|
||||
conn.execute(
|
||||
"""CREATE TRIGGER IF NOT EXISTS collections_sync_version_bu
|
||||
AFTER UPDATE ON collections
|
||||
FOR EACH ROW BEGIN
|
||||
UPDATE collections SET sync_version = sync_version + 1 WHERE id = NEW.id;
|
||||
END"""
|
||||
)
|
||||
|
||||
|
||||
@register(22, "v6.5.0: database row content pages")
|
||||
def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
|
||||
"""v6.5.0 — Synced blocks production: content for database rows.
|
||||
|
||||
A database row (``collection_pages``) gains a shadow ``pages`` row
|
||||
(``pages.collection_row_id``) that carries the Notion-style block
|
||||
content of the row: the full page editor, synced blocks, versions and
|
||||
realtime all work on it unchanged.
|
||||
|
||||
``ON DELETE CASCADE``: deleting a database row deletes its content
|
||||
page (and ``page_synced_blocks`` cascades from ``pages``).
|
||||
"""
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
if "collection_row_id" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE pages ADD COLUMN collection_row_id INTEGER "
|
||||
"REFERENCES collection_pages(id) ON DELETE CASCADE"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_pages_row "
|
||||
"ON pages(collection_row_id) WHERE collection_row_id IS NOT NULL"
|
||||
)
|
||||
|
||||
@@ -810,11 +810,13 @@ async def save_llm_key(request: Request, llm_provider: str):
|
||||
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,
|
||||
provider,
|
||||
api_key=(body.get("api_key") or "").strip(),
|
||||
api_base=(body.get("api_base") or "").strip(),
|
||||
# None = keep the stored base, "" = reset to the provider default.
|
||||
api_base=api_base_raw.strip() if isinstance(api_base_raw, str) else None,
|
||||
default_model=(body.get("default_model") or "").strip(),
|
||||
models=body.get("models"),
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+575
-20
@@ -14,6 +14,7 @@ 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.gitea_client import gitea
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["board"], prefix="/board")
|
||||
@@ -26,6 +27,312 @@ AI_KEYWORD_COLORS = [
|
||||
"#E255A1", "#787774", "#6B4E3D", "#6374C4", "#5A9E4B",
|
||||
]
|
||||
|
||||
def _ensure_page_editable(conn, page_id: int, user: dict | None) -> None:
|
||||
"""v5.12.0: raise 423 when the page is locked and the actor may not edit.
|
||||
|
||||
Allowed to edit a locked page: admins and the user who locked it
|
||||
(locked_by). Unauthenticated callers only pass when the page is unlocked.
|
||||
"""
|
||||
row = conn.execute("SELECT is_locked, locked_by FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row or not row["is_locked"]:
|
||||
return
|
||||
uid = (user or {}).get("id")
|
||||
is_admin = bool((user or {}).get("is_admin"))
|
||||
if is_admin or (uid and row["locked_by"] == uid):
|
||||
return
|
||||
raise HTTPException(423, "Page is locked — only the owner of the lock or an admin can edit")
|
||||
|
||||
|
||||
def _ensure_block_ids(blocks) -> None:
|
||||
"""Assign unique ids to blocks missing one, recursively.
|
||||
|
||||
Built-in page templates ship without ids (the editor used to assign them
|
||||
client-side only). Without persisted ids, the realtime layer and the editor
|
||||
disagree on block identity, which duplicated lines / shuffled blocks when
|
||||
editing a template-created page. We now materialize ids at creation time.
|
||||
"""
|
||||
import uuid
|
||||
if not isinstance(blocks, list):
|
||||
return
|
||||
for b in blocks:
|
||||
if isinstance(b, dict):
|
||||
if not b.get("id"):
|
||||
b["id"] = "b" + uuid.uuid4().hex[:12]
|
||||
if isinstance(b.get("children"), list):
|
||||
_ensure_block_ids(b["children"])
|
||||
|
||||
|
||||
@router.get("/api/wiki/pages")
|
||||
async def wiki_page_search(request: Request, q: str = Query(default="")):
|
||||
"""v5.11.0: page picker for [[ wiki-links. Fuzzy-ish title search across
|
||||
every non-deleted page the current user can see (single source: pages)."""
|
||||
q = (q or "").strip().lower()
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT id, title, page_icon, workspace FROM pages
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY updated_at DESC LIMIT 500"""
|
||||
).fetchall()
|
||||
results = []
|
||||
for r in rows:
|
||||
title = r["title"] or "Untitled"
|
||||
if q:
|
||||
# subsequence match ("mtg" → "Meeting notes") or plain substring.
|
||||
hay = title.lower()
|
||||
it = iter(hay)
|
||||
subseq = all(ch in it for ch in q)
|
||||
if q not in hay and not subseq:
|
||||
continue
|
||||
results.append({
|
||||
"id": r["id"],
|
||||
"title": title,
|
||||
"icon": r["page_icon"] or "",
|
||||
"workspace": r["workspace"] or "",
|
||||
})
|
||||
if len(results) >= 20:
|
||||
break
|
||||
return {"pages": results}
|
||||
|
||||
|
||||
@router.get("/api/wiki/titles")
|
||||
async def wiki_titles(request: Request, ids: str = Query(default="")):
|
||||
"""v5.11.0: resolve page-id lists to current labels (rename propagation)."""
|
||||
parsed: list[int] = []
|
||||
for part in (ids or "").split(","):
|
||||
part = part.strip()
|
||||
if part.isdigit():
|
||||
parsed.append(int(part))
|
||||
parsed = parsed[:200]
|
||||
out: dict[str, str] = {}
|
||||
if parsed:
|
||||
placeholders = ",".join("?" * len(parsed))
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT id, title, page_icon, deleted_at FROM pages WHERE id IN ({placeholders})",
|
||||
parsed,
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
if r["deleted_at"]:
|
||||
out[str(r["id"])] = "Deleted page"
|
||||
else:
|
||||
icon = (r["page_icon"] or "")
|
||||
out[str(r["id"])] = (icon + " " if icon else "") + (r["title"] or "Untitled")
|
||||
return {"titles": out}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/lock")
|
||||
async def set_page_lock(request: Request, page_id: int):
|
||||
"""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=?",
|
||||
(page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
is_admin = 1 if user.get("is_admin") else 0
|
||||
if row["is_locked"] and not is_admin and row["locked_by"] != user["id"]:
|
||||
raise HTTPException(403, "Only the person who locked this page (or an admin) can unlock it")
|
||||
conn.execute("UPDATE pages SET is_locked=?, locked_by=? WHERE id=?",
|
||||
(1 if locked else 0, user["id"] if locked else None, page_id))
|
||||
conn.commit()
|
||||
await 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):
|
||||
"""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:
|
||||
updates[key] = 1 if body[key] else 0
|
||||
if not updates:
|
||||
raise HTTPException(400, "nothing to update")
|
||||
sets = ", ".join(f"{k}=?" for k in updates)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
conn.execute(f"UPDATE pages SET {sets}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(*updates.values(), page_id))
|
||||
conn.commit()
|
||||
return {"status": "ok", **{k: bool(v) for k, v in updates.items()}}
|
||||
|
||||
|
||||
@router.get("/api/page-templates")
|
||||
async def list_page_templates_api(request: Request):
|
||||
"""v5.12.0: built-in + user global page templates for the picker."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
from app.services.block_templates import template_list
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT id, name, icon, description, created_by
|
||||
FROM page_global_templates
|
||||
WHERE created_by IS NULL OR created_by=?
|
||||
ORDER BY created_at""",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
mine = [dict(r) for r in rows]
|
||||
for t in mine:
|
||||
t["builtin"] = False
|
||||
return {"templates": template_list() + mine}
|
||||
|
||||
|
||||
@router.post("/api/page-templates")
|
||||
async def create_page_template(request: Request):
|
||||
"""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")
|
||||
blocks = body.get("blocks")
|
||||
if body.get("page_id"):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT content, content_format FROM pages WHERE id=?",
|
||||
(int(body["page_id"]),)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
if row["content_format"] == "blocks" and row["content"]:
|
||||
try:
|
||||
blocks = json.loads(row["content"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raise HTTPException(400, "Page content is not block JSON") from None
|
||||
if not isinstance(blocks, list) or not blocks:
|
||||
raise HTTPException(400, "blocks (or page_id) required")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO page_global_templates (name, icon, description, blocks_json, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(name, body.get("icon") or "📄", body.get("description") or "",
|
||||
json.dumps(blocks), user["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
tid = cur.lastrowid
|
||||
return {"status": "ok", "id": tid}
|
||||
|
||||
|
||||
@router.post("/api/page-templates/{template_id}/use")
|
||||
async def use_page_template(request: Request, template_id: int):
|
||||
"""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:
|
||||
from app.services.block_templates import blocks_json_for
|
||||
key = body.get("key") or "empty"
|
||||
blocks_json = blocks_json_for(key)
|
||||
name = key
|
||||
if blocks_json is None:
|
||||
raise HTTPException(404, "Unknown built-in template")
|
||||
else:
|
||||
with get_conn() as conn:
|
||||
uid = (user or {}).get("id")
|
||||
row = conn.execute(
|
||||
"SELECT * FROM page_global_templates WHERE id=? AND (created_by IS NULL OR created_by=?)",
|
||||
(template_id, uid),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Template not found")
|
||||
blocks_json = row["blocks_json"]
|
||||
name = row["name"]
|
||||
title = title or row["name"]
|
||||
# Resolve the target workspace so the new page actually shows up in the
|
||||
# active local workspace (bugfix: template pages previously got
|
||||
# workspace_id = NULL and never appeared in the sidebar/tree).
|
||||
uid = (user or {}).get("id")
|
||||
ws_id_raw = body.get("workspace_id")
|
||||
ws_id = None
|
||||
if ws_id_raw is not None:
|
||||
try:
|
||||
ws_id = int(ws_id_raw)
|
||||
except (TypeError, ValueError):
|
||||
ws_id = None
|
||||
ws_key = user.get("login", "Bruno") if user else "Bruno"
|
||||
if ws_id is not None:
|
||||
with get_conn() as conn:
|
||||
ws_row = conn.execute(
|
||||
"SELECT id, name, owner_id FROM workspaces WHERE id=?", (ws_id,)
|
||||
).fetchone()
|
||||
if ws_row and (uid is None or ws_row["owner_id"] == uid):
|
||||
ws_key = ws_row["name"] or ws_key
|
||||
else:
|
||||
ws_id = None
|
||||
else:
|
||||
body_ws = (body.get("workspace") or "").strip()
|
||||
if body_ws:
|
||||
ws_key = body_ws
|
||||
# Optional target folder: instantiate the template as a child of it.
|
||||
parent_id = body.get("parent_id")
|
||||
try:
|
||||
parent_id = int(parent_id) if parent_id not in (None, "", 0, "0") else None
|
||||
except (TypeError, ValueError):
|
||||
parent_id = None
|
||||
try:
|
||||
parsed_blocks = json.loads(blocks_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raise HTTPException(500, "Template content corrupted") from None
|
||||
_ensure_block_ids(parsed_blocks)
|
||||
blocks_json = json.dumps(parsed_blocks)
|
||||
with get_conn() as conn:
|
||||
if parent_id is not None:
|
||||
next_order = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE parent_id=?",
|
||||
(parent_id,),
|
||||
).fetchone()[0]
|
||||
elif ws_id is not None:
|
||||
next_order = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace_id=? AND parent_id IS NULL",
|
||||
(ws_id,),
|
||||
).fetchone()[0]
|
||||
else:
|
||||
next_order = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL",
|
||||
(ws_key,),
|
||||
).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id, sort_order)
|
||||
VALUES (?,?,?,?,?, 'Private', ?, ?)""",
|
||||
(ws_key, ws_id, title or name, blocks_json, "blocks", parent_id, next_order),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
await fire_event("page.created", {"page_id": page_id, "title": title or name,
|
||||
"workspace": ws_key, "from_template": name})
|
||||
return {"status": "ok", "id": page_id, "title": title or name}
|
||||
|
||||
|
||||
# ── Core helpers ──
|
||||
|
||||
def _issue_column(issue: dict, columns: list[str], board_id: int) -> str:
|
||||
@@ -124,7 +431,7 @@ def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, m
|
||||
if depth >= max_depth:
|
||||
return []
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? AND deleted_at IS NULL ORDER BY sort_order ASC, updated_at DESC",
|
||||
"SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY sort_order ASC, updated_at DESC",
|
||||
(ws_key, parent_id),
|
||||
).fetchall()
|
||||
items = []
|
||||
@@ -192,7 +499,7 @@ def _load_workspace_pages(ws_cookie: str) -> list:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format, "
|
||||
"is_shared, share_mode, COALESCE(published,0) AS published "
|
||||
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC",
|
||||
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
items = []
|
||||
@@ -305,12 +612,21 @@ def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]:
|
||||
f"ORDER BY updated_at DESC LIMIT 20",
|
||||
scope_params,
|
||||
).fetchall()
|
||||
received_rows = conn.execute(
|
||||
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
||||
"JOIN pages p ON p.id=s.page_id "
|
||||
"WHERE s.shared_with_user_id=? AND p.deleted_at IS NULL",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
try:
|
||||
received_rows = conn.execute(
|
||||
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
||||
"JOIN pages p ON p.id=s.page_id "
|
||||
"LEFT JOIN group_members gm ON gm.group_id = s.shared_with_group_id AND gm.user_id=? "
|
||||
"WHERE (s.shared_with_user_id=? OR gm.user_id=?) AND p.deleted_at IS NULL",
|
||||
(user_id, user_id, user_id),
|
||||
).fetchall()
|
||||
except Exception:
|
||||
received_rows = conn.execute(
|
||||
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
|
||||
"JOIN pages p ON p.id=s.page_id "
|
||||
"WHERE s.shared_with_user_id=? AND p.deleted_at IS NULL",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
|
||||
def _entry(r, icon):
|
||||
return {
|
||||
@@ -600,13 +916,13 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
|
||||
if ws_key:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, updated_at FROM pages "
|
||||
"WHERE workspace=? ORDER BY updated_at DESC",
|
||||
"WHERE workspace=? AND collection_row_id IS NULL ORDER BY updated_at DESC",
|
||||
(ws_key,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, updated_at FROM pages "
|
||||
"ORDER BY updated_at DESC",
|
||||
"WHERE collection_row_id IS NULL ORDER BY updated_at DESC",
|
||||
).fetchall()
|
||||
all_pages = []
|
||||
for r in rows:
|
||||
@@ -662,6 +978,10 @@ async def add_favorite(request: Request, page_id: int):
|
||||
(uid, page_id, pos),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("favorite.added", {"page_id": page_id, "user_id": uid})
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "added", "page_id": page_id}
|
||||
|
||||
|
||||
@@ -673,6 +993,10 @@ async def remove_favorite(request: Request, page_id: int):
|
||||
with get_conn() as conn:
|
||||
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})
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "removed", "page_id": page_id}
|
||||
|
||||
# ═══════════ Share API ═══════════
|
||||
@@ -704,6 +1028,10 @@ async def publish_page(request: Request, page_id: int):
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
try:
|
||||
await fire_event("page.published", {"page_id": page_id, "slug": slug})
|
||||
except Exception:
|
||||
pass
|
||||
return {"is_published": True, "publish_slug": slug, "title": row["title"] if row else ""}
|
||||
|
||||
|
||||
@@ -716,6 +1044,10 @@ async def unpublish_page(request: Request, page_id: int):
|
||||
(page_id,),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("page.unpublished", {"page_id": page_id})
|
||||
except Exception:
|
||||
pass
|
||||
return {"is_published": False}
|
||||
|
||||
|
||||
@@ -733,6 +1065,10 @@ async 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})
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "ok", "restored": page_id}
|
||||
|
||||
|
||||
@@ -753,6 +1089,125 @@ async def trash_page(request: Request):
|
||||
return template.render(**_sidebar_data(request))
|
||||
|
||||
|
||||
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════
|
||||
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
|
||||
|
||||
@router.get("/api/synced-blocks")
|
||||
async def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
|
||||
"""List synced blocks for a workspace."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
from app.services.synced_blocks import list_synced_blocks
|
||||
return {"synced_blocks": list_synced_blocks(workspace or user.get("login", ""))}
|
||||
|
||||
|
||||
@router.post("/api/synced-blocks")
|
||||
async def create_synced_block_api(request: Request):
|
||||
"""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(
|
||||
workspace=body.get("workspace", ""),
|
||||
title=body.get("title", "Synced block"),
|
||||
content=body.get("content", []),
|
||||
created_by=user.get("id"),
|
||||
)
|
||||
return {"status": "ok", "synced_block_id": sid}
|
||||
|
||||
|
||||
@router.put("/api/synced-blocks/{sid}")
|
||||
async def update_synced_block_api(request: Request, sid: int):
|
||||
"""Update a synced block's content (propagates to all pages)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
from app.services.synced_blocks import (
|
||||
get_synced_block,
|
||||
page_ids_for_synced,
|
||||
sync_synced_blocks_in_page,
|
||||
update_synced_block,
|
||||
)
|
||||
sb = get_synced_block(sid)
|
||||
if not sb:
|
||||
raise HTTPException(404, "Synced block not found")
|
||||
update_synced_block(sid, body.get("title", sb["title"]), body.get("content", []))
|
||||
# v6.5.0: rewrite every referencing page's stored content first (DB row
|
||||
# content pages included), THEN push the realtime update so open rooms
|
||||
# reload the fresh content from the DB.
|
||||
for pid in page_ids_for_synced(sid):
|
||||
sync_synced_blocks_in_page(pid)
|
||||
from app.services.realtime_server import manager
|
||||
await manager._propagate_synced(sid)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/api/synced-blocks/{sid}")
|
||||
async def delete_synced_block_api(request: Request, sid: int):
|
||||
"""Delete a synced block."""
|
||||
from app.services.synced_blocks import (
|
||||
delete_synced_block,
|
||||
get_synced_block,
|
||||
mark_synced_block_deleted,
|
||||
page_ids_for_synced,
|
||||
)
|
||||
sb = get_synced_block(sid)
|
||||
if not sb:
|
||||
raise HTTPException(404, "Synced block not found")
|
||||
# v6.5.0: collect referencing pages BEFORE the FK cascade wipes the
|
||||
# refs, rewrite their stored content (deleted state), then broadcast.
|
||||
pids = page_ids_for_synced(sid)
|
||||
delete_synced_block(sid)
|
||||
mark_synced_block_deleted(sid, pids)
|
||||
from app.services.realtime_server import manager
|
||||
await manager._broadcast_synced_to(pids, sid)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/api/synced-blocks/{sid}")
|
||||
async def get_synced_block_api(sid: int):
|
||||
"""Get a synced block by id."""
|
||||
from app.services.synced_blocks import get_synced_block
|
||||
sb = get_synced_block(sid)
|
||||
if not sb:
|
||||
raise HTTPException(404, "Synced block not found")
|
||||
return dict(sb)
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/synced")
|
||||
async def add_synced_to_page(request: Request, page_id: int):
|
||||
"""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)
|
||||
if not sb:
|
||||
raise HTTPException(404, "Synced block not found")
|
||||
add_page_synced(page_id, sid, body.get("block_index", 0))
|
||||
return {"status": "ok", "synced_block_id": sid}
|
||||
|
||||
|
||||
@router.delete("/api/pages/{page_id}/synced/{sid}")
|
||||
async def remove_synced_from_page(request: Request, page_id: int, sid: int):
|
||||
"""Remove a synced block reference from a page (unsync)."""
|
||||
from app.services.synced_blocks import remove_page_synced
|
||||
remove_page_synced(page_id, sid)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/synced")
|
||||
async def get_page_synced_refs(request: Request, page_id: int):
|
||||
"""Get all synced block references for a page."""
|
||||
from app.services.synced_blocks import get_page_synced
|
||||
return {"synced_blocks": get_page_synced(page_id)}
|
||||
|
||||
|
||||
# ═══════════ Board page ═══════════
|
||||
|
||||
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
|
||||
@@ -958,8 +1413,14 @@ async def create_page(request: Request, title: str = Query(default=""),
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}")
|
||||
async def get_page(page_id: int):
|
||||
async def get_page(request: Request, page_id: int):
|
||||
"""Get a Markdown page."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
# No session → legacy single-user behaviour (matches collections `_require_view`).
|
||||
if user and user.get("id"):
|
||||
# v6.0.0: granular page permissions — 404 (not 403) hides restricted pages.
|
||||
if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -972,9 +1433,23 @@ async def update_page(request: Request, page_id: int, title: str = Query(default
|
||||
content: str = Query(default=""),
|
||||
content_format: str = Query(default="")):
|
||||
"""Update a page's title and/or content. Accepts JSON body for blocks."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(403, "Authentication required")
|
||||
# v6.0.0: granular page permissions — hidden pages 404 (not 403); a visible
|
||||
# page the caller cannot edit yields 403.
|
||||
pm = PermissionManager(user["id"], bool(user.get("is_admin")))
|
||||
if not pm.can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
if not pm.can_edit_page(page_id):
|
||||
raise HTTPException(403, "You don't have edit access to this page")
|
||||
with get_conn() as conn:
|
||||
_ensure_page_editable(conn, page_id, user)
|
||||
if title:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
||||
# v6.5.0: renaming a database row's content page updates the row.
|
||||
from app.services.row_pages import sync_page_title_to_row
|
||||
sync_page_title_to_row(conn, page_id)
|
||||
if content:
|
||||
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content, page_id))
|
||||
if content_format:
|
||||
@@ -991,24 +1466,60 @@ async def save_page_blocks(request: Request, page_id: int):
|
||||
|
||||
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_json = json.dumps(body.get("blocks", []))
|
||||
blocks = body.get("blocks", [])
|
||||
blocks_json = json.dumps(blocks)
|
||||
title = body.get("title", "")
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
# No session → legacy single-user behaviour; otherwise enforce edit rights.
|
||||
if uid and not PermissionManager(uid).can_edit_page(page_id):
|
||||
raise HTTPException(403, "You don't have edit access to this page")
|
||||
|
||||
# Extract synced block ids from the blocks
|
||||
def _extract_synced(blocks: list[dict]) -> set[int]:
|
||||
ids: set[int] = set()
|
||||
for b in blocks:
|
||||
if b.get("type") == "synced" and b.get("synced_id"):
|
||||
ids.add(b["synced_id"])
|
||||
if isinstance(b.get("children"), list):
|
||||
ids |= _extract_synced(b["children"])
|
||||
return ids
|
||||
|
||||
synced_ids = _extract_synced(blocks)
|
||||
|
||||
with get_conn() as conn:
|
||||
_ensure_page_editable(conn, page_id, user)
|
||||
if title:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
||||
# v6.5.0: renaming a database row's content page updates the row.
|
||||
from app.services.row_pages import sync_page_title_to_row
|
||||
sync_page_title_to_row(conn, page_id)
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(blocks_json, page_id),
|
||||
)
|
||||
_record_version(conn, page_id, uid, title or "", blocks_json)
|
||||
# Update synced block references
|
||||
existing = {r["synced_block_id"] for r in conn.execute(
|
||||
"SELECT synced_block_id FROM page_synced_blocks WHERE page_id=?", (page_id,)
|
||||
).fetchall()}
|
||||
for sid in synced_ids:
|
||||
if sid not in existing:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO page_synced_blocks (page_id, synced_block_id, block_index) VALUES (?, ?, 0)",
|
||||
(page_id, sid),
|
||||
)
|
||||
for sid in existing - synced_ids:
|
||||
conn.execute(
|
||||
"DELETE FROM page_synced_blocks WHERE page_id=? AND synced_block_id=?",
|
||||
(page_id, sid),
|
||||
)
|
||||
conn.commit()
|
||||
await fire_event("page.updated", {"page_id": page_id, "title": title or "",
|
||||
"content_format": "blocks"})
|
||||
@@ -1055,6 +1566,7 @@ async def page_backlinks(request: Request, page_id: int):
|
||||
reference to ``/pages/{page_id}`` or ``#fdblk-…`` inside ``/pages/{page_id}``.
|
||||
"""
|
||||
target = f"/pages/{page_id}" if page_id else None
|
||||
wiki_target = f"[[fdpage:{page_id}]]" if page_id else None
|
||||
backlinks = []
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -1070,17 +1582,17 @@ async def page_backlinks(request: Request, page_id: int):
|
||||
blocks = json.loads(r["content"])
|
||||
for b in blocks if isinstance(blocks, list) else []:
|
||||
for text in _block_texts(b):
|
||||
if target and f"/pages/{page_id}" in text:
|
||||
if target and (target in text or (wiki_target and wiki_target in text)):
|
||||
hits = True
|
||||
break
|
||||
if hits:
|
||||
break
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
hits = target and f"/pages/{page_id}" in (r["content"] or "")
|
||||
hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or "")))
|
||||
elif fmt == "markdown":
|
||||
hits = target and f"/pages/{page_id}" in (r["content"] or "")
|
||||
hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or "")))
|
||||
elif r["content"]:
|
||||
hits = target and f"/pages/{page_id}" in json.dumps(r["content"])
|
||||
hits = target and (target in json.dumps(r["content"]) or (wiki_target and wiki_target in json.dumps(r["content"])))
|
||||
if not hits and target:
|
||||
hits = f"/pages/{page_id}" in (r["content"] or "")
|
||||
if hits:
|
||||
@@ -1264,10 +1776,10 @@ async 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):
|
||||
"""v5.5.0: set a page emoji/icon label."""
|
||||
"""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) > 16:
|
||||
if len(icon) > 512:
|
||||
raise HTTPException(400, "icon too long")
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE pages SET page_icon=? WHERE id=?", (icon, page_id))
|
||||
@@ -1384,6 +1896,26 @@ async def og_metadata(request: Request):
|
||||
return {"ok": True, **data}
|
||||
|
||||
|
||||
@router.post("/api/embed/resolve")
|
||||
async def resolve_embed(request: Request):
|
||||
"""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")
|
||||
from app.config import settings
|
||||
from app.services.embeds import resolve_embed as _resolve
|
||||
data = _resolve(url, parent=settings.app_base_url)
|
||||
return {"ok": True, "url": url, **data}
|
||||
|
||||
|
||||
@router.put("/api/pages/{page_id}/move")
|
||||
async def move_page(request: Request, page_id: int):
|
||||
"""Move a page to another workspace or reorder within tree.
|
||||
@@ -1435,6 +1967,13 @@ async def move_page(request: Request, page_id: int):
|
||||
@router.delete("/api/pages/{page_id}")
|
||||
async def delete_page(request: Request, page_id: int):
|
||||
"""Move a page to trash (soft delete)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
if not uid:
|
||||
raise HTTPException(403, "Authentication required")
|
||||
# v6.0.0: granular page permissions — need at least edit access to trash.
|
||||
if not PermissionManager(uid).can_edit_page(page_id):
|
||||
raise HTTPException(403, "You don't have edit access to this page")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)).fetchone()
|
||||
if not row:
|
||||
@@ -1453,11 +1992,19 @@ async def view_page(request: Request, page_id: int):
|
||||
embed = request.query_params.get("embed") == "1"
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
# v6.0.0: granular page permissions — hide restricted pages (404).
|
||||
user_hdr = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if user_hdr and user_hdr.get("id") and not PermissionManager(user_hdr["id"]).can_view_page(page_id):
|
||||
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
|
||||
page = dict(row)
|
||||
# v6.5.0: synced blocks resolve server-side at read time (fresh content
|
||||
# even when the stored cache is stale).
|
||||
from app.services.synced_blocks import resolve_content_json
|
||||
page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format"))
|
||||
|
||||
ws = page.get("workspace", "")
|
||||
parts = ws.split("/") if "/" in ws else ["", ""]
|
||||
@@ -1472,7 +2019,15 @@ async def view_page(request: Request, page_id: int):
|
||||
).fetchone()
|
||||
|
||||
# Build page_data, including file metadata for uploaded files
|
||||
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)), "cover_url": page.get("cover_url", "") or "", "page_icon": page.get("page_icon", "") or ""}
|
||||
_locked = bool(page.get("is_locked", 0))
|
||||
_locked_by = page.get("locked_by") if "locked_by" in page else None
|
||||
_can_edit = (not _locked) or bool(user and user.get("is_admin")) or (uid and _locked_by == uid)
|
||||
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)), "cover_url": page.get("cover_url", "") or "", "page_icon": page.get("page_icon", "") or "",
|
||||
"is_locked": _locked,
|
||||
"locked_by": _locked_by,
|
||||
"can_edit": _can_edit,
|
||||
"full_width": bool(page.get("full_width", 0)) if "full_width" in page else False,
|
||||
"font_small": bool(page.get("font_small", 0)) if "font_small" in page else False}
|
||||
|
||||
# For file pages, extract file metadata and add to page_data
|
||||
if page.get("content_format") == "file":
|
||||
@@ -1538,6 +2093,6 @@ async def sync_project(owner: str, repo: str):
|
||||
# Extract AI keywords from each issue
|
||||
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
|
||||
conn.commit()
|
||||
return {"status": "ok", "issues_synced": len(issues_only)}
|
||||
return {"status": "ok", "issues_synced": len(issues_only)}
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e)) from e
|
||||
|
||||
@@ -13,6 +13,7 @@ from fastapi import APIRouter, 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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collaboration"], prefix="/api")
|
||||
@@ -116,6 +117,14 @@ 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})
|
||||
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)})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"id": comment_id, "status": "created"}
|
||||
|
||||
|
||||
@@ -140,6 +149,11 @@ async def notify_page_mentions(request: Request, page_id: int):
|
||||
"page", page_id, url, conn=conn,
|
||||
)
|
||||
conn.commit()
|
||||
if mentioned:
|
||||
try:
|
||||
await _fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)})
|
||||
except Exception:
|
||||
pass
|
||||
return {"mentioned": mentioned}
|
||||
|
||||
|
||||
@@ -161,10 +175,16 @@ async def update_comment(request: Request, comment_id: int):
|
||||
"UPDATE comments SET body=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(body["body"].strip(), comment_id),
|
||||
)
|
||||
was_resolved = int(row["resolved"] or 0)
|
||||
if "resolved" in body and body.get("resolved") is not None:
|
||||
conn.execute("UPDATE comments SET resolved=? WHERE id=?",
|
||||
(1 if body["resolved"] else 0, comment_id))
|
||||
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"]})
|
||||
except Exception:
|
||||
pass
|
||||
return {"id": comment_id, "status": "updated"}
|
||||
|
||||
|
||||
|
||||
+514
-35
@@ -8,10 +8,69 @@ import sqlite3
|
||||
from fastapi import APIRouter, 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.db_templates import materialize_properties
|
||||
from app.services.property_types import validate_property_rule
|
||||
from app.services.permission_manager import PermissionManager
|
||||
from app.services.property_types import (
|
||||
AUTO_TYPES,
|
||||
apply_auto_properties,
|
||||
validate_property_rule,
|
||||
)
|
||||
from app.services.recurrence import (
|
||||
RECURRENCE_KEY,
|
||||
expand_rule,
|
||||
is_valid_timezone,
|
||||
parse_date,
|
||||
validate_rule,
|
||||
)
|
||||
from app.services.reminders import REMINDER_KEY, parse_lead
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
"""Resolve the session user, falling back to the local admin (single-user)."""
|
||||
s = request.cookies.get("flowdeck_session", "")
|
||||
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
|
||||
|
||||
|
||||
def _session_user(request: Request) -> dict | None:
|
||||
"""Resolve the session user WITHOUT the admin fallback (for ACL checks)."""
|
||||
s = request.cookies.get("flowdeck_session", "")
|
||||
user = SessionManager.decode_session(s)
|
||||
return user if user and user.get("id") else None
|
||||
|
||||
|
||||
def _require_view(collection_id: int, user: dict | None) -> None:
|
||||
"""Return None when a user may view the collection, else raise 404.
|
||||
|
||||
A missing/userless session keeps the legacy single-user behaviour (owner on
|
||||
un-workspaced collections); explicit ``restricted`` / ``private`` collections
|
||||
are hidden for non-owners unless granted.
|
||||
"""
|
||||
if not user:
|
||||
return
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
|
||||
def _require_edit(collection_id: int, user: dict | None) -> None:
|
||||
"""Raise 403 when the user may not edit pages in the collection."""
|
||||
if not user:
|
||||
return
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_edit_collection(collection_id):
|
||||
raise HTTPException(status_code=403, detail="You don't have edit access to this collection")
|
||||
|
||||
|
||||
def _collection_properties(conn, collection_id: int) -> list[dict]:
|
||||
return [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["collections"], prefix="/db")
|
||||
@@ -42,7 +101,7 @@ def _validate_page_properties(conn, collection_id: int, properties: dict, exclud
|
||||
|
||||
for prop in props:
|
||||
ptype = prop["prop_type"]
|
||||
if ptype == "title":
|
||||
if ptype == "title" or ptype in AUTO_TYPES:
|
||||
continue
|
||||
pid = prop["id"]
|
||||
# Values may be keyed by property id (FlowDeckDB UI) or by name (agent).
|
||||
@@ -77,6 +136,61 @@ def _validate_page_properties(conn, collection_id: int, properties: dict, exclud
|
||||
raise HTTPException(status_code=400, detail=f"Property '{prop['name']}': {msg}")
|
||||
|
||||
|
||||
def _validate_meta_keys(conn, collection_id: int, properties: dict) -> None:
|
||||
"""Validate the ``__recurrence__`` / ``__reminder__`` meta keys stored
|
||||
alongside real property values. Raises HTTPException(400) on bad shape.
|
||||
|
||||
Each meta key maps a date-property id to a rule/reminder object. We verify
|
||||
the target is actually a date property and the payload parses.
|
||||
"""
|
||||
date_ids = {
|
||||
str(r["id"]) for r in conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='date'",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
}
|
||||
|
||||
rec = properties.get(RECURRENCE_KEY)
|
||||
if rec not in (None, {}):
|
||||
if not isinstance(rec, dict):
|
||||
raise HTTPException(status_code=400, detail="Recurrence must be an object")
|
||||
for prop_id, rule in rec.items():
|
||||
if rule is None:
|
||||
continue
|
||||
if str(prop_id) not in date_ids:
|
||||
raise HTTPException(status_code=400, detail="Recurrence target must be a date property")
|
||||
ok, msg = validate_rule(rule)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail=f"Recurrence: {msg}")
|
||||
|
||||
rem = properties.get(REMINDER_KEY)
|
||||
if rem not in (None, {}):
|
||||
if not isinstance(rem, dict):
|
||||
raise HTTPException(status_code=400, detail="Reminder must be an object")
|
||||
for prop_id, reminder in rem.items():
|
||||
if reminder is None:
|
||||
continue
|
||||
if str(prop_id) not in date_ids:
|
||||
raise HTTPException(status_code=400, detail="Reminder target must be a date property")
|
||||
if not isinstance(reminder, dict):
|
||||
raise HTTPException(status_code=400, detail="Reminder must be an object")
|
||||
if reminder.get("unit") not in (None, "none", "minutes", "hours", "days"):
|
||||
raise HTTPException(status_code=400, detail="Reminder unit must be minutes/hours/days/none")
|
||||
if parse_lead(reminder) is None and reminder.get("unit") != "none":
|
||||
raise HTTPException(status_code=400, detail="Reminder value must be a positive integer")
|
||||
|
||||
from app.services.recurrence import TIMEZONE_KEY
|
||||
tzmap = properties.get(TIMEZONE_KEY)
|
||||
if tzmap not in (None, {}):
|
||||
if not isinstance(tzmap, dict):
|
||||
raise HTTPException(status_code=400, detail="Timezone map must be an object")
|
||||
for prop_id, value in tzmap.items():
|
||||
if str(prop_id) not in date_ids:
|
||||
raise HTTPException(status_code=400, detail="Timezone target must be a date property")
|
||||
if value and not is_valid_timezone(str(value)):
|
||||
raise HTTPException(status_code=400, detail=f"Unknown timezone '{value}'")
|
||||
|
||||
|
||||
# ── API: List & Create (no path params) ──
|
||||
|
||||
|
||||
@@ -208,6 +322,13 @@ async def update_collection_api(request: Request, collection_id: int):
|
||||
@router.delete("/api/{collection_id}")
|
||||
async def delete_collection_api(request: Request, collection_id: int):
|
||||
"""API: delete a collection and its pages (CASCADE)."""
|
||||
# v6.0.0: granular collection permissions — owner/admin only.
|
||||
user = _session_user(request)
|
||||
_require_view(collection_id, user)
|
||||
if user:
|
||||
pm = PermissionManager(user["id"])
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(status_code=403, detail="Only a collection owner can delete it")
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
@@ -373,10 +494,37 @@ async def get_page_api(request: Request, page_id: int):
|
||||
).fetchone()
|
||||
if not page:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
|
||||
# v6.0.0: granular collection/page permissions.
|
||||
_require_view(page["collection_id"], _session_user(request))
|
||||
return dict(page)
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/open/api")
|
||||
async def open_row_page_api(request: Request, page_id: int):
|
||||
"""v6.5.0 — content page of a database row (lazy-created).
|
||||
|
||||
Any DB view (table/board/gallery/list/calendar) opens a row through
|
||||
this endpoint: it returns the shadow ``pages`` id whose full page
|
||||
editor carries the row's block content (synced blocks included).
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT collection_id FROM collection_pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
coll_id = row["collection_id"]
|
||||
# v6.0.0: granular collection permissions (same gate as the row itself).
|
||||
_require_view(coll_id, _session_user(request))
|
||||
from app.services.row_pages import ensure_row_page
|
||||
try:
|
||||
content_page_id = ensure_row_page(page_id)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Page not found") from None
|
||||
return {"page_id": content_page_id, "row_id": page_id}
|
||||
|
||||
|
||||
@router.put("/pages/{page_id}/api")
|
||||
async def update_page_api(request: Request, page_id: int):
|
||||
"""API: update a page's properties."""
|
||||
@@ -391,31 +539,48 @@ async def update_page_api(request: Request, page_id: int):
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
# v6.0.0: granular collection/page permissions.
|
||||
_require_edit(existing["collection_id"], _session_user(request))
|
||||
|
||||
title = body.get("title", existing["title"])
|
||||
icon = body.get("icon", existing["icon"])
|
||||
cover_url = body.get("cover_url", existing["cover_url"] if "cover_url" in existing.keys() else "")
|
||||
position = body.get("position", existing["position"])
|
||||
parent_id = body.get("parent_id", existing["parent_id"])
|
||||
|
||||
try:
|
||||
stored = json.loads(existing["property_values_json"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
stored = {}
|
||||
|
||||
if "properties" in body:
|
||||
props = body["properties"]
|
||||
# Partial PATCH semantics: merge submitted values over stored ones.
|
||||
props = dict(stored)
|
||||
props.update(body["properties"])
|
||||
else:
|
||||
try:
|
||||
props = json.loads(existing["property_values_json"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
props = {}
|
||||
props = stored
|
||||
|
||||
_validate_page_properties(conn, existing["collection_id"], props, exclude_page_id=page_id)
|
||||
_validate_meta_keys(conn, existing["collection_id"], props)
|
||||
apply_auto_properties(
|
||||
_collection_properties(conn, existing["collection_id"]),
|
||||
props,
|
||||
_current_user(request),
|
||||
is_create=False,
|
||||
)
|
||||
|
||||
property_values = json.dumps(props)
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE collection_pages
|
||||
SET title=?, icon=?, position=?, parent_id=?, property_values_json=?,
|
||||
SET title=?, icon=?, cover_url=?, position=?, parent_id=?, property_values_json=?,
|
||||
updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=?""",
|
||||
(title, icon, position, parent_id, property_values, page_id),
|
||||
(title, icon, cover_url, position, parent_id, property_values, page_id),
|
||||
)
|
||||
# v6.5.0: keep the row's content page title in sync (row → page).
|
||||
from app.services.row_pages import sync_row_title_to_page
|
||||
sync_row_title_to_page(conn, page_id)
|
||||
conn.commit()
|
||||
|
||||
await fire_event("page.updated", {
|
||||
@@ -425,6 +590,16 @@ async def update_page_api(request: Request, page_id: int):
|
||||
"icon": icon,
|
||||
"properties": props,
|
||||
})
|
||||
await fire_event("collection.page.updated", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": title,
|
||||
})
|
||||
# Notify newly assigned people (person properties) — v5.8.0.
|
||||
from app.services.notifications import notify_assignment
|
||||
user = _current_user(request)
|
||||
notify_assignment(existing["collection_id"], page_id, title,
|
||||
stored, props, user.get("id"))
|
||||
return {"id": page_id, "status": "updated"}
|
||||
|
||||
|
||||
@@ -437,6 +612,8 @@ async def delete_page_api(request: Request, page_id: int):
|
||||
).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Page not found")
|
||||
# v6.0.0: granular collection/page permissions.
|
||||
_require_edit(existing["collection_id"], _session_user(request))
|
||||
|
||||
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
|
||||
conn.commit()
|
||||
@@ -446,6 +623,10 @@ async def delete_page_api(request: Request, page_id: int):
|
||||
"collection_id": existing["collection_id"],
|
||||
"title": existing["title"],
|
||||
})
|
||||
await fire_event("collection.page.deleted", {
|
||||
"page_id": page_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
})
|
||||
return {"id": page_id, "status": "deleted"}
|
||||
|
||||
|
||||
@@ -501,7 +682,9 @@ async def list_property_types_api(request: Request):
|
||||
|
||||
@router.get("/{collection_id}/properties/api")
|
||||
async def list_properties_api(request: Request, collection_id: int):
|
||||
"""API: list all properties for a collection."""
|
||||
"""API: list all properties visible to the current user."""
|
||||
user = _session_user(request)
|
||||
_require_view(collection_id, user)
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
@@ -510,7 +693,181 @@ async def list_properties_api(request: Request, collection_id: int):
|
||||
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
return {"properties": [dict(r) for r in rows]}
|
||||
props = [dict(r) for r in rows]
|
||||
# v6.0.0: property-level visibility — owners/editors see everything, other
|
||||
# users only the properties explicitly granted or left open.
|
||||
if user:
|
||||
pm = PermissionManager(user["id"])
|
||||
visible = pm.get_visible_properties(collection_id)
|
||||
props = [p for p in props if p["id"] in visible]
|
||||
return {"properties": props}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/members/api")
|
||||
async def list_collection_members_api(request: Request, collection_id: int):
|
||||
"""API: list workspace members available for a ``person`` property.
|
||||
|
||||
Resolves the collection's workspace and returns its members (falling back to
|
||||
every active user for standalone databases without a workspace).
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute(
|
||||
"SELECT workspace_id FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
ws_id = coll["workspace_id"] if "workspace_id" in coll.keys() else None
|
||||
if ws_id:
|
||||
rows = conn.execute(
|
||||
"""SELECT u.id, u.login, u.full_name, u.avatar_url, u.avatar_color, wm.role
|
||||
FROM workspace_members wm JOIN users u ON wm.user_id=u.id
|
||||
WHERE wm.workspace_id=? AND u.is_active=1 ORDER BY u.full_name, u.login""",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = []
|
||||
if not rows:
|
||||
rows = conn.execute(
|
||||
"""SELECT id, login, full_name, avatar_url, avatar_color, '' AS role
|
||||
FROM users WHERE is_active=1 ORDER BY full_name, login"""
|
||||
).fetchall()
|
||||
|
||||
return {"members": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/calendar/api")
|
||||
async def collection_calendar_api(request: Request, collection_id: int,
|
||||
start: str = "", end: str = "",
|
||||
date_property: str = ""):
|
||||
"""API (v5.8.0): expanded calendar events for a window [start, end].
|
||||
|
||||
Returns every occurrence (recurrence-aware, virtual — never persisted)
|
||||
of the rows in the collection whose ``date_property`` falls inside the
|
||||
inclusive window. Rows without a rule yield their base date.
|
||||
"""
|
||||
user = _current_user(request)
|
||||
s = parse_date(start)
|
||||
e = parse_date(end)
|
||||
if s is None or e is None or s > e:
|
||||
raise HTTPException(status_code=400, detail="start/end must be YYYY-MM-DD")
|
||||
if (e - s).days > 370:
|
||||
raise HTTPException(status_code=400, detail="window too large (max 370 days)")
|
||||
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
props = _collection_properties(conn, collection_id)
|
||||
date_props = [p for p in props if p["prop_type"] == "date"]
|
||||
target = None
|
||||
if date_property:
|
||||
target = next((p for p in date_props
|
||||
if str(p["id"]) == str(date_property) or p["name"] == date_property), None)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=400, detail="Unknown date property")
|
||||
elif date_props:
|
||||
target = date_props[0]
|
||||
if target is None:
|
||||
return {"events": [], "timezone": "", "property": None}
|
||||
|
||||
urow = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone()
|
||||
user_tz = (urow["timezone"] if urow and "timezone" in urow.keys() else "") or ""
|
||||
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
|
||||
pid = str(target["id"])
|
||||
events: list[dict] = []
|
||||
for r in rows:
|
||||
try:
|
||||
pv = json.loads(r["property_values_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
base_value = pv.get(pid)
|
||||
if base_value is None:
|
||||
base_value = pv.get(target["name"])
|
||||
if parse_date(base_value) is None:
|
||||
continue
|
||||
rec_all = pv.get(RECURRENCE_KEY) if isinstance(pv.get(RECURRENCE_KEY), dict) else {}
|
||||
rule = rec_all.get(pid) or rec_all.get(target["name"])
|
||||
row_tz = user_tz
|
||||
if isinstance(rule, dict) and rule.get("timezone"):
|
||||
row_tz = rule["timezone"]
|
||||
tzmap = pv.get("__timezone__")
|
||||
if isinstance(tzmap, dict):
|
||||
ev_tz = tzmap.get(pid) or tzmap.get(target["name"])
|
||||
if ev_tz:
|
||||
row_tz = str(ev_tz)
|
||||
if rule:
|
||||
dates = expand_rule(base_value, rule, s, e, max_occurrences=500)
|
||||
else:
|
||||
d = parse_date(base_value)
|
||||
dates = [d.isoformat()] if d and s <= d <= e else []
|
||||
for iso in dates:
|
||||
events.append({
|
||||
"date": iso,
|
||||
"page_id": r["id"],
|
||||
"title": r["title"],
|
||||
"icon": r["icon"],
|
||||
"recurring": bool(rule),
|
||||
"time": str(base_value)[11:16] if len(str(base_value)) >= 16 else "",
|
||||
"timezone": row_tz,
|
||||
})
|
||||
events.sort(key=lambda ev: (ev["date"], ev["page_id"]))
|
||||
return {"events": events, "timezone": user_tz, "property": {"id": target["id"], "name": target["name"]}}
|
||||
|
||||
|
||||
@router.get("/timezones/api")
|
||||
async def timezones_api(request: Request):
|
||||
"""API (v5.8.0): the user's timezone plus a picker-friendly zone list."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone()
|
||||
from app.services.recurrence import common_timezones
|
||||
return {
|
||||
"timezone": (row["timezone"] if row and "timezone" in row.keys() else "") or "",
|
||||
"zones": common_timezones(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{collection_id}/property-groups/api")
|
||||
async def set_property_groups_api(request: Request, collection_id: int):
|
||||
"""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:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not coll:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
conn.execute(
|
||||
"UPDATE collection_properties SET group_name='' WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
)
|
||||
for grp in groups:
|
||||
gname = (grp.get("name") or "").strip()
|
||||
if not gname:
|
||||
continue
|
||||
for pid in grp.get("property_ids", []) or []:
|
||||
conn.execute(
|
||||
"UPDATE collection_properties SET group_name=? WHERE id=? AND collection_id=?",
|
||||
(gname, pid, collection_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
@router.post("/{collection_id}/properties/api")
|
||||
@@ -531,6 +888,7 @@ async def create_property_api(request: Request, collection_id: int):
|
||||
required = int(body.get("required", False))
|
||||
visible = int(body.get("visible_in_views", True))
|
||||
validation_json = json.dumps(body.get("validation", {}))
|
||||
group_name = (body.get("group_name") or "").strip()
|
||||
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
@@ -546,16 +904,17 @@ async def create_property_api(request: Request, collection_id: int):
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_properties
|
||||
(collection_id, name, prop_type, options_json, number_format,
|
||||
position, required, visible_in_views, validation_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
position, required, visible_in_views, validation_json, group_name)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(collection_id, name, prop_type, options_json, number_format, max_pos,
|
||||
required, visible, validation_json),
|
||||
required, visible, validation_json, group_name),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=409, detail=f"Property '{name}' already exists") from None
|
||||
|
||||
return {"id": cur.lastrowid, "name": name, "prop_type": prop_type, "status": "created"}
|
||||
return {"id": cur.lastrowid, "name": name, "prop_type": prop_type,
|
||||
"group_name": group_name, "status": "created"}
|
||||
|
||||
|
||||
@router.put("/properties/{prop_id}/api")
|
||||
@@ -582,12 +941,15 @@ async def update_property_api(request: Request, prop_id: int):
|
||||
validation_json = json.dumps(body.get("validation", {}))
|
||||
else:
|
||||
validation_json = existing["validation_json"] if "validation_json" in existing.keys() else "{}"
|
||||
group_name = body.get("group_name", existing["group_name"] if "group_name" in existing.keys() else "")
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE collection_properties
|
||||
SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?, validation_json=?
|
||||
SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?,
|
||||
validation_json=?, group_name=?
|
||||
WHERE id=?""",
|
||||
(name, options_json, number_format, required, visible, validation_json, prop_id),
|
||||
(name, options_json, number_format, required, visible, validation_json,
|
||||
group_name, prop_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -812,18 +1174,22 @@ async def update_view_config(request: Request, view_id: int):
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
|
||||
config = json.loads(existing["config_json"])
|
||||
for key in ("group_by", "card_size", "cover_property", "visible_properties",
|
||||
"filters", "sorts", "filter_conjunction", "date_property", "date_range_property"):
|
||||
for key in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property",
|
||||
"cover_mode", "card_properties", "visible_properties", "filters", "sorts",
|
||||
"filter_conjunction", "date_property", "date_range_property",
|
||||
"property_groups", "view_type"):
|
||||
if key in body:
|
||||
config[key] = body[key]
|
||||
|
||||
new_type = body.get("view_type") or existing["view_type"]
|
||||
conn.execute(
|
||||
"UPDATE collection_views SET config_json=?, name=COALESCE(?, name) WHERE id=?",
|
||||
(json.dumps(config), body.get("name"), view_id),
|
||||
"UPDATE collection_views SET config_json=?, name=COALESCE(?, name), view_type=?, "
|
||||
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(config), body.get("name"), new_type, view_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return {"id": view_id, "status": "updated", "config": config}
|
||||
return {"id": view_id, "status": "updated", "config": config, "view_type": new_type}
|
||||
|
||||
|
||||
@router.post("/{collection_id}/views/save-as")
|
||||
@@ -836,6 +1202,8 @@ async def save_view_as(request: Request, collection_id: int):
|
||||
|
||||
name = body.get("name", "New View")
|
||||
config = body.get("config", {})
|
||||
user = _current_user(request)
|
||||
user_id = user.get("id") if user else None
|
||||
|
||||
with get_conn() as conn:
|
||||
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
@@ -847,26 +1215,104 @@ async def save_view_as(request: Request, collection_id: int):
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
view_type = body.get("view_type", "table")
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)",
|
||||
(collection_id, name, body.get("view_type", "table"), json.dumps(config), max_pos),
|
||||
"""INSERT INTO collection_views
|
||||
(collection_id, name, view_type, config_json, position, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(collection_id, name, view_type, json.dumps(config), max_pos, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
new_view_id = cur.lastrowid
|
||||
|
||||
return {"id": cur.lastrowid, "name": name, "status": "saved"}
|
||||
await fire_event("collection.view.created", {
|
||||
"view_id": new_view_id,
|
||||
"collection_id": collection_id,
|
||||
"name": name,
|
||||
"view_type": view_type,
|
||||
})
|
||||
return {"id": new_view_id, "name": name, "view_type": view_type,
|
||||
"config_json": json.dumps(config), "created_by": user_id, "status": "saved"}
|
||||
|
||||
|
||||
@router.get("/{collection_id}/views/api")
|
||||
async def list_views_api(request: Request, collection_id: int):
|
||||
"""API: list all views for a collection."""
|
||||
"""API: list views for a collection visible to the current user.
|
||||
|
||||
Shared/legacy views (``created_by IS NULL``) are visible to everyone;
|
||||
personal views (``created_by = user``) only to their owner.
|
||||
"""
|
||||
user = _current_user(request)
|
||||
user_id = user.get("id") if user else None
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
if user_id is not None:
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM collection_views
|
||||
WHERE collection_id=? AND (created_by IS NULL OR created_by=?)
|
||||
ORDER BY position""",
|
||||
(collection_id, user_id),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? AND created_by IS NULL ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
return {"views": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.delete("/views/{view_id}/api")
|
||||
async def delete_view_api(request: Request, view_id: int):
|
||||
"""API: delete a saved view."""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
conn.execute("DELETE FROM collection_views WHERE id=?", (view_id,))
|
||||
conn.commit()
|
||||
return {"id": view_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/views/{view_id}/duplicate")
|
||||
async def duplicate_view_api(request: Request, view_id: int):
|
||||
"""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()
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="View not found")
|
||||
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?",
|
||||
(existing["collection_id"],),
|
||||
).fetchone()[0]
|
||||
|
||||
user = _current_user(request)
|
||||
user_id = user.get("id") if user else None
|
||||
name = body.get("name") or (existing["name"] + " copy")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_views
|
||||
(collection_id, name, view_type, config_json, position, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(existing["collection_id"], name, existing["view_type"],
|
||||
existing["config_json"], max_pos, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
dup_view_id = cur.lastrowid
|
||||
|
||||
await fire_event("collection.view.created", {
|
||||
"view_id": dup_view_id,
|
||||
"collection_id": existing["collection_id"],
|
||||
"name": name,
|
||||
"view_type": existing["view_type"],
|
||||
})
|
||||
return {"id": dup_view_id, "name": name, "view_type": existing["view_type"],
|
||||
"status": "duplicated"}
|
||||
|
||||
|
||||
# ── v1.8.0 Sub-items & Dependencies ──
|
||||
|
||||
|
||||
@@ -903,9 +1349,17 @@ async def create_sub_item(request: Request, collection_id: int, page_id: int):
|
||||
(page_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
sub_props = body.get("properties", {}) or {}
|
||||
apply_auto_properties(
|
||||
_collection_properties(conn, collection_id),
|
||||
sub_props,
|
||||
_current_user(request),
|
||||
is_create=True,
|
||||
)
|
||||
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?, ?, ?, ?, ?)",
|
||||
(collection_id, title, page_id, max_pos, json.dumps(body.get("properties", {}))),
|
||||
(collection_id, title, page_id, max_pos, json.dumps(sub_props)),
|
||||
)
|
||||
conn.commit()
|
||||
new_id = cur.lastrowid
|
||||
@@ -917,6 +1371,11 @@ async def create_sub_item(request: Request, collection_id: int, page_id: int):
|
||||
"title": title,
|
||||
"properties": body.get("properties", {}),
|
||||
})
|
||||
await 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"}
|
||||
|
||||
|
||||
@@ -1443,6 +1902,8 @@ async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
|
||||
@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse)
|
||||
async def view_collection(request: Request, collection_id: int, view_type: str = "table"):
|
||||
"""Main view — renders collection in the requested view type."""
|
||||
# v6.0.0: granular collection permissions — hide restricted collections.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
with get_conn() as conn:
|
||||
collection = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
@@ -1999,6 +2460,8 @@ tr:hover td{{background:#222}}
|
||||
@router.get("/{collection_id}/api")
|
||||
async def get_collection_api(request: Request, collection_id: int):
|
||||
"""API: get a single collection with its pages."""
|
||||
# v6.0.0: granular collection permissions — hide restricted collections.
|
||||
_require_view(collection_id, _session_user(request))
|
||||
with get_conn() as conn:
|
||||
collection = conn.execute(
|
||||
"SELECT * FROM collections WHERE id=?", (collection_id,)
|
||||
@@ -2025,6 +2488,9 @@ async 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):
|
||||
"""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:
|
||||
@@ -2036,6 +2502,7 @@ async def create_page_api(request: Request, collection_id: int):
|
||||
|
||||
icon = body.get("icon", "📄")
|
||||
property_values = body.get("properties", {})
|
||||
cover_url = body.get("cover_url", "")
|
||||
gitea_issue_id = body.get("gitea_issue_id")
|
||||
gitea_issue_number = body.get("gitea_issue_number")
|
||||
|
||||
@@ -2045,6 +2512,13 @@ async def create_page_api(request: Request, collection_id: int):
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
_validate_page_properties(conn, collection_id, property_values)
|
||||
_validate_meta_keys(conn, collection_id, property_values)
|
||||
apply_auto_properties(
|
||||
_collection_properties(conn, collection_id),
|
||||
property_values,
|
||||
_current_user(request),
|
||||
is_create=True,
|
||||
)
|
||||
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
|
||||
@@ -2053,9 +2527,9 @@ async def create_page_api(request: Request, collection_id: int):
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_pages
|
||||
(collection_id, title, icon, position, gitea_issue_id, gitea_issue_number, property_values_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(collection_id, title, icon, max_pos, gitea_issue_id, gitea_issue_number,
|
||||
(collection_id, title, icon, cover_url, position, gitea_issue_id, gitea_issue_number, property_values_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(collection_id, title, icon, cover_url, max_pos, gitea_issue_id, gitea_issue_number,
|
||||
json.dumps(property_values)),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -2068,4 +2542,9 @@ async def create_page_api(request: Request, collection_id: int):
|
||||
"icon": icon,
|
||||
"properties": property_values,
|
||||
})
|
||||
await fire_event("collection.page.created", {
|
||||
"page_id": page_id,
|
||||
"collection_id": collection_id,
|
||||
"title": title,
|
||||
})
|
||||
return {"id": page_id, "title": title, "status": "created"}
|
||||
|
||||
+261
-31
@@ -321,6 +321,9 @@ async def view_page_root(request: Request, page_id: int):
|
||||
if not row:
|
||||
return RedirectResponse("/workspaces", status_code=302)
|
||||
page = dict(row)
|
||||
# v6.5.0: synced blocks resolve server-side at read time.
|
||||
from app.services.synced_blocks import resolve_content_json
|
||||
page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format"))
|
||||
|
||||
ws = page.get("workspace", "")
|
||||
parts = ws.split("/") if "/" in ws else ["", ""]
|
||||
@@ -338,7 +341,17 @@ async def view_page_root(request: Request, page_id: int):
|
||||
).fetchone()
|
||||
|
||||
# Build page_data, including file metadata for uploaded files
|
||||
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0))}
|
||||
_locked = bool(page.get("is_locked", 0))
|
||||
_locked_by = page.get("locked_by") if "locked_by" in page else None
|
||||
_sess_user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
_uid = _sess_user.get("id") if _sess_user and _sess_user.get("id") else None
|
||||
_can_edit = (not _locked) or bool(_sess_user and _sess_user.get("is_admin")) or (_locked_by and _uid and _locked_by == _uid)
|
||||
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)),
|
||||
"is_locked": _locked,
|
||||
"locked_by": _locked_by,
|
||||
"can_edit": _can_edit,
|
||||
"full_width": bool(page.get("full_width", 0)) if "full_width" in page else False,
|
||||
"font_small": bool(page.get("font_small", 0)) if "font_small" in page else False}
|
||||
|
||||
# For file pages, extract file metadata and add to page_data
|
||||
if page.get("content_format") == "file":
|
||||
@@ -385,10 +398,17 @@ async def view_page_root(request: Request, page_id: int):
|
||||
(page["collection_id"],),
|
||||
).fetchall()
|
||||
]
|
||||
cviews = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
||||
(page["collection_id"],),
|
||||
).fetchall()
|
||||
]
|
||||
collection_data = {
|
||||
"collection": dict(col),
|
||||
"properties": props,
|
||||
"pages": cpages,
|
||||
"views": cviews,
|
||||
}
|
||||
page_data["collection_id"] = page["collection_id"]
|
||||
|
||||
@@ -537,6 +557,16 @@ async def help_page(request: Request):
|
||||
<li><b>My Tasks</b> — Aggregated tasks from all collections</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="help-card">
|
||||
<h3><span class="icon">📶</span>Offline & PWA</h3>
|
||||
<p>Install FlowDeck as an app and keep working without a connection.</p>
|
||||
<ul>
|
||||
<li><b>Install</b> — browser menu → <i>Install app</i> / <i>Add to Home Screen</i></li>
|
||||
<li>Edits made offline are queued locally and synced automatically</li>
|
||||
<li>A <b>⟳</b> marker shows pages with pending changes</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
@@ -562,6 +592,41 @@ FlowDeck supports three authentication methods:<br>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>📶 Offline mode (PWA)</h2>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
FlowDeck is a Progressive Web App: pages you visited stay available offline and your
|
||||
edits are saved locally, then synchronised when the connection returns.<br><br>
|
||||
<b>Install:</b> open your browser menu and choose <i>Install app</i> (Chrome/Edge) or
|
||||
<i>Add to Home Screen</i> (Safari/iOS). FlowDeck then opens in its own window.<br>
|
||||
<b>Offline editing:</b> while offline, the editor stores changes in the browser
|
||||
(IndexedDB) and shows an offline banner with the number of pending changes. A
|
||||
<b>⟳</b> icon appears next to pages that have unsynced edits.<br>
|
||||
<b>Reconnection:</b> the queue is replayed automatically (and via Background Sync).
|
||||
A spinner badge appears while syncing, followed by a confirmation toast.<br>
|
||||
<b>Conflicts:</b> if a page changed on the server, the latest edit wins and a notice is
|
||||
shown. If a page was deleted server-side, your offline copy is recreated as an orphan
|
||||
page. If a page with the same title already exists, the offline copy is renamed
|
||||
<i>“Title (copie offline)”</i>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>🔌 API publique v2</h2>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
FlowDeck exposes a full REST API under <b>/api/v2</b> for third-party integrations.<br>
|
||||
<b>Auth:</b> create a token in Settings → API tokens, then send it as
|
||||
<code>Authorization: Bearer <token></code>. Tokens carry scopes
|
||||
<code>read</code>, <code>write</code> or <code>admin</code> (a higher scope implies the lower ones).<br>
|
||||
<b>Features:</b> CRUD on collections, pages, properties, views, comments, notifications,
|
||||
favorites, tags, sharing, sprints and templates; pagination (<code>?limit=&offset=</code> +
|
||||
<code>X-Total-Count</code>), filters (<code>filter[prop]=value</code>), sorting, full-text search
|
||||
(<code>/api/v2/search</code>), idempotency (<code>Idempotency-Key</code>) and RFC 7807 error bodies.<br>
|
||||
<b>Reference:</b> interactive OpenAPI docs at <a href="/docs" target="_blank" rel="noopener">/docs</a>
|
||||
(also <code>/redoc</code>, <code>docs/openapi-v2.json</code>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="help-section">
|
||||
<h2>💡 Tips</h2>
|
||||
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
|
||||
@@ -823,7 +888,7 @@ async def list_workspace_projects(request: Request):
|
||||
builtin = []
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title FROM pages WHERE parent_id IS NULL ORDER BY updated_at DESC LIMIT 20"
|
||||
"SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL ORDER BY updated_at DESC LIMIT 20"
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
count = conn.execute("SELECT COUNT(*) FROM pages WHERE parent_id=?", (r["id"],)).fetchone()[0]
|
||||
@@ -969,6 +1034,7 @@ async def local_workspace_page(request: Request, folder: int = None):
|
||||
"workspace_id": ws_id or 0,
|
||||
"nav_workspace_id": ws_id or 0,
|
||||
"breadcrumb": breadcrumb,
|
||||
"breadcrumbs": breadcrumb,
|
||||
}
|
||||
template = env.get_template("local_workspace.html")
|
||||
return HTMLResponse(
|
||||
@@ -1018,7 +1084,95 @@ async def get_page_content(page_id: int):
|
||||
fmt = row["content_format"]
|
||||
if fmt == "file":
|
||||
return JSONResponse({"content": "(uploaded file)", "format": fmt})
|
||||
return {"content": row["content"] or "", "format": fmt}
|
||||
# v6.5.0: resolve synced blocks server-side (fresh content on read).
|
||||
from app.services.synced_blocks import resolve_content_json
|
||||
return {"content": resolve_content_json(row["content"] or "", fmt), "format": fmt}
|
||||
|
||||
|
||||
def _file_page_disk_path(page: dict):
|
||||
"""Resolve the on-disk file behind a ``content_format == 'file'`` page.
|
||||
|
||||
Returns ``(abs_path: Path, filename: str, mime: str, size: int)`` or None
|
||||
when the row is not a file page, references a non-textual/missing file, or
|
||||
the path escapes the data root (path-traversal guard).
|
||||
"""
|
||||
if (page.get("content_format") or "") != "file":
|
||||
return None
|
||||
import json as _json
|
||||
try:
|
||||
meta = _json.loads(page.get("content", "{}"))
|
||||
except (_json.JSONDecodeError, TypeError):
|
||||
meta = {}
|
||||
if not isinstance(meta, dict):
|
||||
return None
|
||||
rel = (meta.get("file_path") or "").replace("\\", "/").strip()
|
||||
if not rel or not rel.startswith("uploads/"):
|
||||
return None
|
||||
parts = rel.split("/")
|
||||
if ".." in parts or "." in parts:
|
||||
return None
|
||||
import os as _os
|
||||
from pathlib import Path
|
||||
root = Path(_os.environ.get("FLOWDECK_DATA_DIR", "/data")).resolve()
|
||||
full = (root / rel).resolve()
|
||||
try:
|
||||
full.relative_to(root)
|
||||
except ValueError:
|
||||
return None
|
||||
if not full.exists() or not full.is_file():
|
||||
return None
|
||||
filename = parts[-1] or page.get("title", "file")
|
||||
mime = meta.get("mime_type") or "application/octet-stream"
|
||||
size = meta.get("size") or 0
|
||||
return (full, filename, mime, size)
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/download")
|
||||
async def download_page_file(page_id: int):
|
||||
"""Download the original uploaded file of a ``file`` page (attachment)."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format FROM pages "
|
||||
"WHERE id=? AND deleted_at IS NULL",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
resolved = _file_page_disk_path(dict(row))
|
||||
if not resolved:
|
||||
return JSONResponse({"error": "No downloadable file"}, status_code=404)
|
||||
full, filename, mime, _size = resolved
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse(
|
||||
str(full), media_type=mime or "application/octet-stream",
|
||||
filename=filename, content_disposition_type="attachment",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/pages/{page_id}/file-content")
|
||||
async def page_file_content(page_id: int):
|
||||
"""Return the textual content of a ``file`` page (for copy to clipboard).
|
||||
|
||||
Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is
|
||||
only meaningful for plain-text / code / markdown files.
|
||||
"""
|
||||
from app.services.export import _file_text
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, title, content, content_format FROM pages "
|
||||
"WHERE id=? AND deleted_at IS NULL",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
page = dict(row)
|
||||
text = _file_text(page)
|
||||
if text is None:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Not a textual file", "name": page.get("title", "")},
|
||||
status_code=415,
|
||||
)
|
||||
return {"ok": True, "name": page.get("title") or "File", "content": text}
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/breadcrumb")
|
||||
@@ -1036,7 +1190,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | Non
|
||||
"SELECT id, title, parent_section, content_format, content, page_icon, "
|
||||
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
|
||||
"created_at, updated_at FROM pages "
|
||||
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL "
|
||||
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL "
|
||||
"ORDER BY created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
@@ -1215,7 +1369,7 @@ async def nav_menu(request: Request, workspace_id: int = None, parent_id: int =
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format FROM pages "
|
||||
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL "
|
||||
"WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL "
|
||||
"ORDER BY sort_order ASC, created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
@@ -1249,7 +1403,7 @@ async def nav_menu(request: Request, workspace_id: int = None, parent_id: int =
|
||||
async def create_local_workspace_item(request: Request):
|
||||
"""Create a new file in the active workspace."""
|
||||
body = await request.json()
|
||||
name = body.get("name", "Untitled").strip()
|
||||
name = (body.get("name") or "").strip() or "Untitled"
|
||||
item_type = body.get("type", "page")
|
||||
parent_id = body.get("parent_id")
|
||||
explicit_ws_id = body.get("workspace_id")
|
||||
@@ -1308,8 +1462,10 @@ async def restore_local_workspace_item(request: Request, item_id: int):
|
||||
async def serve_uploaded_file(ws_id: int, filename: str):
|
||||
"""Serve an uploaded file from disk."""
|
||||
import mimetypes
|
||||
import os
|
||||
from pathlib import Path
|
||||
base_dir = Path(f"/data/uploads/workspace_{ws_id}").resolve()
|
||||
root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
base_dir = (root / f"uploads/workspace_{ws_id}").resolve()
|
||||
fp = (base_dir / filename).resolve()
|
||||
try:
|
||||
fp.relative_to(base_dir)
|
||||
@@ -1575,7 +1731,7 @@ async def list_workspaces(request: Request):
|
||||
uid = user["id"] if user and user.get("id") else 1
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id) as page_count "
|
||||
"SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id AND collection_row_id IS NULL) as page_count "
|
||||
"FROM workspaces w WHERE w.owner_id=? ORDER BY w.created_at DESC",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
@@ -1794,14 +1950,17 @@ async def list_all_tags_global(request: Request):
|
||||
"WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
return {"tags": [dict(r) for r in rows]}
|
||||
return JSONResponse(
|
||||
{"tags": [dict(r) for r in rows]},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
# ═══════════ Workspace Tags API ═══════════
|
||||
|
||||
@router.get("/api/local-workspace/tags")
|
||||
async def list_tags(request: Request):
|
||||
"""List tags used in the active workspace (current user's tags only)."""
|
||||
"""List ALL user tags with counts scoped to the active workspace."""
|
||||
ws = _get_active_workspace(request, user_id=_get_user_id(request))
|
||||
ws_id = ws["id"] if ws else None
|
||||
uid = _get_user_id(request)
|
||||
@@ -1809,14 +1968,17 @@ async def list_tags(request: Request):
|
||||
return {"tags": []}
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT DISTINCT t.id, t.name, t.color, COUNT(pt.page_id) as count "
|
||||
"FROM tags t "
|
||||
"JOIN page_tags pt ON pt.tag_id = t.id "
|
||||
"JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? "
|
||||
"WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name",
|
||||
"SELECT t.id, t.name, t.color, "
|
||||
"(SELECT COUNT(*) FROM page_tags pt "
|
||||
" JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? "
|
||||
" WHERE pt.tag_id = t.id) as count "
|
||||
"FROM tags t WHERE t.user_id = ? ORDER BY t.name",
|
||||
(ws_id, uid),
|
||||
).fetchall()
|
||||
return {"tags": [dict(r) for r in rows]}
|
||||
return JSONResponse(
|
||||
{"tags": [dict(r) for r in rows]},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/local-workspace/items/{item_id:int}/tags")
|
||||
@@ -2056,7 +2218,13 @@ h1{font-size:3rem;opacity:.3}</style></head><body><h1>404</h1></body></html>""",
|
||||
import json as _json
|
||||
try:
|
||||
blocks = _json.loads(page["content"])
|
||||
content_html = _render_blocks_public(blocks)
|
||||
from app.services.synced_blocks import resolve_synced_block
|
||||
blocks = resolve_synced_block(blocks)
|
||||
from app.db import get_conn as _gc
|
||||
from app.services.wiki_links import token_labels
|
||||
with _gc() as conn:
|
||||
wiki_titles_map = token_labels(conn, page["content"])
|
||||
content_html = _render_blocks_public(blocks, wiki_titles_map)
|
||||
except (_json.JSONDecodeError, Exception):
|
||||
content_html = f"<p>{page.get('content', '')}</p>"
|
||||
elif page.get("content"):
|
||||
@@ -2082,12 +2250,23 @@ def _sanitize_id(block_id: str) -> str:
|
||||
return "".join(ch for ch in str(block_id) if ch.isalnum())
|
||||
|
||||
|
||||
def _render_blocks_public(blocks: list) -> str:
|
||||
"""Render FlowDeck blocks as plain HTML for public pages."""
|
||||
def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
|
||||
"""Render FlowDeck blocks as plain HTML for public pages.
|
||||
|
||||
v5.11.0: ``titles`` (token → label, see app.services.wiki_links) turns
|
||||
``[[fdpage:ID]]`` / ``[[fddate:...]]`` tokens into chips/links.
|
||||
"""
|
||||
html_parts = []
|
||||
|
||||
def _wiki(c: str) -> str:
|
||||
if titles and ("[[fdpage:" in c or "[[fddate:" in c):
|
||||
from app.services.wiki_links import resolve_tokens_html
|
||||
return resolve_tokens_html(c, titles)
|
||||
return c
|
||||
|
||||
for b in blocks:
|
||||
t = b.get("type", "paragraph")
|
||||
c = b.get("content", "") or ""
|
||||
c = _wiki(b.get("content", "") or "")
|
||||
if t == "heading_1":
|
||||
html_parts.append(f'<h1 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:2.5rem;font-weight:700;margin:32px 0 8px;">{c}</h1>')
|
||||
elif t == "heading_2":
|
||||
@@ -2113,7 +2292,7 @@ def _render_blocks_public(blocks: list) -> str:
|
||||
children_html = ""
|
||||
if b.get("children"):
|
||||
children_html = '<div style="margin-left:22px;padding-left:12px;border-left:1px solid rgba(255,255,255,.1);margin-top:4px;">'
|
||||
children_html += _render_blocks_public(b["children"])
|
||||
children_html += _render_blocks_public(b["children"], titles)
|
||||
children_html += "</div>"
|
||||
html_parts.append(
|
||||
f'<details style="margin:8px 0;" open><summary style="cursor:pointer;font-weight:500;">{c}</summary>{children_html}</details>'
|
||||
@@ -2151,7 +2330,7 @@ def _render_blocks_public(blocks: list) -> str:
|
||||
cols_html += (
|
||||
'<div style="flex:1;min-width:0;padding:10px 12px;background:rgba(255,255,255,.05);'
|
||||
'border-radius:8px;box-sizing:border-box;">'
|
||||
+ _render_blocks_public([child]) + "</div>"
|
||||
+ _render_blocks_public([child], titles) + "</div>"
|
||||
)
|
||||
html_parts.append(
|
||||
f'<div style="display:flex;gap:12px;margin:8px 0 16px;align-items:stretch;">{cols_html}</div>'
|
||||
@@ -2230,7 +2409,7 @@ def _render_blocks_public(blocks: list) -> str:
|
||||
)
|
||||
elif url:
|
||||
from app.services.embeds import embed_src
|
||||
src = embed_src(url) or url
|
||||
src = b.get("embed_src") or embed_src(url) or url
|
||||
height = b.get("height") or 520
|
||||
try:
|
||||
height = int(height)
|
||||
@@ -2241,6 +2420,31 @@ def _render_blocks_public(blocks: list) -> str:
|
||||
f'background:#0a0a0a;"><iframe src="{src}" loading="lazy" frameborder="0" '
|
||||
f'style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div>'
|
||||
)
|
||||
elif t == "synced":
|
||||
# v6.5.0: render synced block instances (resolved server-side).
|
||||
if b.get("_synced_deleted"):
|
||||
html_parts.append(
|
||||
'<div style="margin:8px 0;padding:8px 12px;border-left:3px solid #e05e5e;'
|
||||
'background:rgba(224,94,94,.08);border-radius:4px;font-size:13px;opacity:.8;">'
|
||||
'Deleted synced block</div>'
|
||||
)
|
||||
else:
|
||||
inner = b.get("_synced_content")
|
||||
if not isinstance(inner, list) or not inner:
|
||||
try:
|
||||
import json as _sj
|
||||
parsed = _sj.loads(b.get("content") or "[]")
|
||||
inner = parsed if isinstance(parsed, list) else []
|
||||
except (ValueError, TypeError):
|
||||
inner = []
|
||||
inner = [{"type": "paragraph", "content": str(x)} if not isinstance(x, dict) else x
|
||||
for x in inner]
|
||||
if inner:
|
||||
html_parts.append(
|
||||
'<div style="margin:8px 0;padding-left:12px;'
|
||||
'border-left:3px solid var(--accent,#4c9aff);">'
|
||||
+ _render_blocks_public(inner, titles) + '</div>'
|
||||
)
|
||||
else:
|
||||
html_parts.append(f'<p style="margin:4px 0;line-height:1.7;">{c}</p>')
|
||||
return "\n".join(html_parts)
|
||||
@@ -2258,9 +2462,11 @@ async def api_page_content(page_id: int):
|
||||
).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
# v6.5.0: resolve synced blocks server-side (fresh content on read).
|
||||
from app.services.synced_blocks import resolve_content_json
|
||||
return {
|
||||
"title": row["title"],
|
||||
"content": row["content"],
|
||||
"content": resolve_content_json(row["content"], row["content_format"]),
|
||||
"format": row["content_format"] or "blocks",
|
||||
}
|
||||
|
||||
@@ -2277,6 +2483,9 @@ async def api_rename_page(page_id: int, request: Request):
|
||||
"UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL",
|
||||
(title, page_id),
|
||||
)
|
||||
# v6.5.0: renaming a database row's content page updates the row.
|
||||
from app.services.row_pages import sync_page_title_to_row
|
||||
sync_page_title_to_row(conn, page_id)
|
||||
conn.commit()
|
||||
return {"status": "ok", "title": title}
|
||||
|
||||
@@ -2383,10 +2592,18 @@ async def api_collection_table_data(collection_id: int):
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
views = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
return {
|
||||
"collection": dict(coll),
|
||||
"properties": properties,
|
||||
"pages": pages,
|
||||
"views": views,
|
||||
}
|
||||
|
||||
|
||||
@@ -2415,20 +2632,31 @@ async def api_create_collection_page(collection_id: int, request: Request):
|
||||
).fetchone()[0]
|
||||
|
||||
# Load default property values from collection properties
|
||||
props = conn.execute(
|
||||
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
props = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=? ORDER BY position",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
]
|
||||
default_values = {}
|
||||
for p in props:
|
||||
if p["prop_type"] == "title":
|
||||
default_values[str(p["id"])] = title
|
||||
# Caller-provided values (e.g. board "add card in column X") win.
|
||||
incoming = body.get("properties") or {}
|
||||
if isinstance(incoming, dict):
|
||||
default_values.update(incoming)
|
||||
|
||||
from app.services.property_types import apply_auto_properties
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) or {"login": "admin", "id": 1}
|
||||
apply_auto_properties(props, default_values, user, is_create=True)
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collection_pages
|
||||
(collection_id, title, icon, position, property_values_json)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(collection_id, title, icon, max_pos, _json.dumps(default_values)),
|
||||
(collection_id, title, icon, cover_url, position, property_values_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(collection_id, title, icon, body.get("cover_url", ""), max_pos,
|
||||
_json.dumps(default_values)),
|
||||
)
|
||||
page_id = cur.lastrowid
|
||||
conn.commit()
|
||||
@@ -2436,6 +2664,8 @@ async def api_create_collection_page(collection_id: int, request: Request):
|
||||
return {
|
||||
"id": page_id,
|
||||
"title": title,
|
||||
"icon": icon,
|
||||
"position": max_pos,
|
||||
"property_values_json": default_values,
|
||||
"status": "created",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""FlowDeck — custom workspace emojis (v5.6.0).
|
||||
|
||||
Uploaded emoji images stored per-workspace and usable as page icons. Kept on a
|
||||
prefix-less router so the paths stay ``/api/custom-emojis`` (the board router's
|
||||
``/{owner}/{repo}`` HTML catch-all would otherwise shadow them).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
router = APIRouter(tags=["emojis"])
|
||||
|
||||
_IMAGE_EXTS = {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}
|
||||
|
||||
|
||||
def _upload_root() -> Path:
|
||||
import os
|
||||
|
||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
|
||||
|
||||
def _active_ws(request: Request) -> int:
|
||||
"""Workspace id from the active-workspace cookie, fallback 1."""
|
||||
try:
|
||||
ws_id = int(request.cookies.get("flowdeck_workspace", "") or 0)
|
||||
if ws_id > 0:
|
||||
return ws_id
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return 1
|
||||
|
||||
|
||||
@router.get("/api/custom-emojis")
|
||||
async def list_custom_emojis(request: Request):
|
||||
"""List the current workspace's custom emojis."""
|
||||
ws_id = _active_ws(request)
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, name, url FROM custom_emojis WHERE workspace_id=? ORDER BY created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
return {"status": "ok", "emojis": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/api/custom-emojis")
|
||||
async def create_custom_emoji(request: Request):
|
||||
"""Upload a custom emoji image (multipart: ``name`` + ``file``)."""
|
||||
form = await request.form()
|
||||
name = (form.get("name") or "").strip()[:40] or "emoji"
|
||||
upload = form.get("file")
|
||||
if upload is None or not hasattr(upload, "filename"):
|
||||
raise HTTPException(400, "file field required")
|
||||
original = (upload.filename or "emoji.png").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
safe = re.sub(r"[^A-Za-z0-9._-]", "_", original)[:80]
|
||||
ext = safe.rsplit(".", 1)[-1].lower() if "." in safe else "png"
|
||||
if ext not in _IMAGE_EXTS:
|
||||
raise HTTPException(400, "Unsupported image format")
|
||||
ws_id = _active_ws(request)
|
||||
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S%f")
|
||||
folder = _upload_root() / f"uploads/workspace_{ws_id}"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
final = f"emoji_{stamp}_{safe}"
|
||||
(folder / final).write_bytes(await upload.read())
|
||||
url = f"/api/files/{ws_id}/{final}"
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO custom_emojis (workspace_id, name, url) VALUES (?, ?, ?)",
|
||||
(ws_id, name, url),
|
||||
)
|
||||
conn.commit()
|
||||
emoji_id = cur.lastrowid
|
||||
return {"status": "ok", "emoji": {"id": emoji_id, "name": name, "url": url}}
|
||||
|
||||
|
||||
@router.delete("/api/custom-emojis/{emoji_id}")
|
||||
async def delete_custom_emoji(request: Request, emoji_id: int):
|
||||
"""Delete a custom emoji (and its stored file)."""
|
||||
ws_id = _active_ws(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT url FROM custom_emojis WHERE id=? AND workspace_id=?",
|
||||
(emoji_id, ws_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "emoji not found")
|
||||
conn.execute("DELETE FROM custom_emojis WHERE id=?", (emoji_id,))
|
||||
conn.commit()
|
||||
try:
|
||||
fname = (row["url"] or "").rsplit("/", 1)[-1]
|
||||
if fname:
|
||||
(_upload_root() / f"uploads/workspace_{ws_id}" / fname).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
return {"status": "ok", "id": emoji_id}
|
||||
@@ -0,0 +1,378 @@
|
||||
"""FlowDeck — unified import API (v5.6.0, Phase 0/1/2).
|
||||
|
||||
Exposes the importer registry, a dry-run preview, a synchronous run and an
|
||||
optional background job with polling. Works with the existing workspace cookie
|
||||
(``flowdeck_workspace``) and session.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event
|
||||
from app.services.importers import (
|
||||
get_job,
|
||||
list_jobs,
|
||||
list_sources,
|
||||
parse_upload,
|
||||
preview_result,
|
||||
resolve_relations,
|
||||
run_import,
|
||||
start_import_job,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/import", tags=["import"])
|
||||
page_router = APIRouter(tags=["import"])
|
||||
|
||||
MAX_UPLOAD_BYTES = 200 * 1024 * 1024
|
||||
|
||||
|
||||
def _current_user(request: Request) -> dict:
|
||||
return SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) or {}
|
||||
|
||||
|
||||
@page_router.get("/import", response_class=HTMLResponse)
|
||||
async def import_page(request: Request):
|
||||
"""Standalone import wizard (source picker, dry-run, mapping, progress)."""
|
||||
user = _current_user(request)
|
||||
if not user:
|
||||
return RedirectResponse("/auth/login?provider=local", status_code=302)
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
return HTMLResponse(content=env.get_template("import.html").render(user=user))
|
||||
|
||||
|
||||
def _workspace(request: Request) -> tuple[int | None, str]:
|
||||
"""Resolve (workspace_id, login) from the workspace cookie + session."""
|
||||
ws_id: int | None = None
|
||||
cookie = request.cookies.get("flowdeck_workspace", "")
|
||||
try:
|
||||
value = int(cookie)
|
||||
if value > 0:
|
||||
ws_id = value
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
user = _current_user(request)
|
||||
login = user.get("login", "") if user else ""
|
||||
return ws_id, login
|
||||
|
||||
|
||||
async def _read_upload(request: Request) -> tuple[str, bytes, str | None]:
|
||||
form = await request.form()
|
||||
upload = form.get("file")
|
||||
if upload is None or not hasattr(upload, "filename"):
|
||||
raise HTTPException(400, "file field required")
|
||||
filename = (upload.filename or "import").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
data = await upload.read()
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(413, "File too large (max 200 MB)")
|
||||
source_id = form.get("source") or None
|
||||
return filename, data, source_id
|
||||
|
||||
|
||||
@router.get("/sources")
|
||||
async def import_sources(request: Request):
|
||||
"""List every available importer for the UI source picker."""
|
||||
return {"sources": list_sources()}
|
||||
|
||||
|
||||
@router.post("/preview")
|
||||
async def import_preview(request: Request):
|
||||
"""Dry-run: parse the upload and describe what would be created."""
|
||||
filename, data, source_id = await _read_upload(request)
|
||||
imp, result = parse_upload(filename, data, source_id)
|
||||
if imp is None:
|
||||
raise HTTPException(400, "Format non reconnu — choisissez une source")
|
||||
out = preview_result(result)
|
||||
out["detected_source"] = imp.source_id
|
||||
out["source_label"] = imp.label
|
||||
return out
|
||||
|
||||
|
||||
@router.post("/run")
|
||||
async def import_run(request: Request):
|
||||
"""Import an upload (synchronously, or as a background job when async=true)."""
|
||||
form = await request.form()
|
||||
upload = form.get("file")
|
||||
if upload is None or not hasattr(upload, "filename"):
|
||||
raise HTTPException(400, "file field required")
|
||||
filename = (upload.filename or "import").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
data = await upload.read()
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(413, "File too large (max 200 MB)")
|
||||
|
||||
source_id = form.get("source") or None
|
||||
parent_id = _int_or_none(form.get("parent_id"))
|
||||
target = _int_or_none(form.get("target_collection_id"))
|
||||
dedup = str(form.get("dedup", "true")).lower() not in ("false", "0", "no")
|
||||
async_mode = str(form.get("async", "false")).lower() in ("true", "1", "yes")
|
||||
mapping = _parse_mapping(form.get("mapping"))
|
||||
mode = _parse_mode(form.get("mode"))
|
||||
ws_id, login = _workspace(request)
|
||||
|
||||
if async_mode:
|
||||
job = start_import_job(
|
||||
filename=filename, data=data, source_id=source_id,
|
||||
workspace_id=ws_id, workspace_name=login, user_login=login,
|
||||
parent_page_id=parent_id, target_collection_id=target, dedup=dedup,
|
||||
mapping=mapping, mode=mode,
|
||||
)
|
||||
return {"status": "queued", "job_id": job["id"]}
|
||||
|
||||
imp, result = parse_upload(filename, data, source_id)
|
||||
if imp is None:
|
||||
raise HTTPException(400, "Format non reconnu — choisissez une source")
|
||||
report = run_import(
|
||||
result, workspace_id=ws_id, workspace_name=login, user_login=login,
|
||||
parent_page_id=parent_id, target_collection_id=target, dedup=dedup,
|
||||
mapping=mapping, mode=mode,
|
||||
)
|
||||
report["detected_source"] = imp.source_id
|
||||
if imp.source_id == "notion":
|
||||
with get_conn() as conn:
|
||||
report["relations"] = resolve_relations(conn, ws_id)
|
||||
for page_id in report.get("page_ids", [])[:100]:
|
||||
try:
|
||||
await fire_event("page.created", {"page_id": page_id, "title": "", "workspace": login})
|
||||
except Exception: # noqa: BLE001 - events are best-effort
|
||||
pass
|
||||
return report
|
||||
|
||||
|
||||
@router.post("/forge")
|
||||
async def import_forge(request: Request):
|
||||
"""Import a forge repo's issues (+ labels/milestones) into collections."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
provider = str(body.get("provider") or "gitea").lower()
|
||||
owner = str(body.get("owner") or "").strip()
|
||||
repo = str(body.get("repo") or "").strip()
|
||||
if not owner or not repo:
|
||||
raise HTTPException(400, "owner and repo are required")
|
||||
state = str(body.get("state") or "all")
|
||||
include_labels = bool(body.get("include_labels", True))
|
||||
include_milestones = bool(body.get("include_milestones", True))
|
||||
ws_id, login = _workspace(request)
|
||||
|
||||
if provider == "gitea":
|
||||
from app.services.gitea_client import get_user_gitea_client
|
||||
from app.services.importers.forge import GiteaForgeAdapter
|
||||
client = get_user_gitea_client(request)
|
||||
if client is None:
|
||||
raise HTTPException(400, "Gitea non connecté")
|
||||
adapter = GiteaForgeAdapter(client)
|
||||
elif provider == "github":
|
||||
from app.services.github_adapter import GitHubAdapter
|
||||
token = _user_oauth_token(request, "github")
|
||||
if not token:
|
||||
raise HTTPException(400, "GitHub non connecté")
|
||||
adapter = GitHubAdapter(token)
|
||||
else:
|
||||
raise HTTPException(400, "provider must be 'gitea' or 'github'")
|
||||
|
||||
from app.services.importers.forge import fetch_forge_issues
|
||||
result = await fetch_forge_issues(
|
||||
adapter, owner, repo, provider=provider, state=state,
|
||||
include_labels=include_labels, include_milestones=include_milestones,
|
||||
)
|
||||
report = run_import(
|
||||
result, workspace_id=ws_id, workspace_name=login, user_login=login,
|
||||
)
|
||||
report["detected_source"] = f"forge:{provider}"
|
||||
return report
|
||||
|
||||
|
||||
@router.post("/forge-repo")
|
||||
async def import_forge_repo(request: Request):
|
||||
"""Import a forge repo's text files as pages (folder hierarchy preserved)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
provider = str(body.get("provider") or "gitea").lower()
|
||||
owner = str(body.get("owner") or "").strip()
|
||||
repo = str(body.get("repo") or "").strip()
|
||||
if not owner or not repo:
|
||||
raise HTTPException(400, "owner and repo are required")
|
||||
path = str(body.get("path") or "")
|
||||
max_files = min(int(body.get("max_files") or 200), 1000)
|
||||
ws_id, login = _workspace(request)
|
||||
|
||||
adapter = _forge_adapter(request, provider)
|
||||
|
||||
from app.services.importers.forge_repo import fetch_forge_repo
|
||||
result = await fetch_forge_repo(
|
||||
adapter, owner, repo, provider=provider, path=path, max_files=max_files,
|
||||
)
|
||||
report = run_import(result, workspace_id=ws_id, workspace_name=login, user_login=login)
|
||||
report["detected_source"] = f"forge-repo:{provider}"
|
||||
return report
|
||||
|
||||
|
||||
@router.post("/url")
|
||||
async def import_url(request: Request):
|
||||
"""Web clipper: fetch a URL and create a page (bookmark card + content)."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
url = str(body.get("url") or "").strip()
|
||||
if not url:
|
||||
raise HTTPException(400, "url is required")
|
||||
ws_id, login = _workspace(request)
|
||||
|
||||
from app.services.importers.url_fetch import fetch_url_result
|
||||
try:
|
||||
result = await fetch_url_result(url)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from None
|
||||
if not result.pages:
|
||||
raise HTTPException(422, "; ".join(result.warnings) or "Page introuvable")
|
||||
report = run_import(result, workspace_id=ws_id, workspace_name=login, user_login=login)
|
||||
report["detected_source"] = "url"
|
||||
return report
|
||||
|
||||
|
||||
@router.post("/run-batch")
|
||||
async def import_run_batch(request: Request):
|
||||
"""Import several uploaded files sequentially, returning one report each."""
|
||||
form = await request.form()
|
||||
uploads = form.getlist("file")
|
||||
if not uploads:
|
||||
raise HTTPException(400, "file field required")
|
||||
source_id = form.get("source") or None
|
||||
parent_id = _int_or_none(form.get("parent_id"))
|
||||
target = _int_or_none(form.get("target_collection_id"))
|
||||
dedup = str(form.get("dedup", "true")).lower() not in ("false", "0", "no")
|
||||
mode = _parse_mode(form.get("mode"))
|
||||
mapping = _parse_mapping(form.get("mapping"))
|
||||
ws_id, login = _workspace(request)
|
||||
|
||||
results: list[dict] = []
|
||||
summary = {"files": 0, "pages_created": 0, "rows_created": 0, "errors": 0}
|
||||
for upload in uploads:
|
||||
filename = (getattr(upload, "filename", "") or "import").replace("\\", "/").rsplit("/", 1)[-1]
|
||||
data = await upload.read()
|
||||
if len(data) > MAX_UPLOAD_BYTES:
|
||||
results.append({"filename": filename, "report": {"status": "error",
|
||||
"errors": [{"title": filename, "error": "File too large"}]}})
|
||||
summary["errors"] += 1
|
||||
continue
|
||||
imp, result = parse_upload(filename, data, source_id)
|
||||
if imp is None:
|
||||
results.append({"filename": filename, "report": {"status": "error",
|
||||
"errors": [{"title": filename, "error": "Format non reconnu"}]}})
|
||||
summary["errors"] += 1
|
||||
continue
|
||||
report = run_import(
|
||||
result, workspace_id=ws_id, workspace_name=login, user_login=login,
|
||||
parent_page_id=parent_id, target_collection_id=target, dedup=dedup,
|
||||
mapping=mapping, mode=mode,
|
||||
)
|
||||
report["detected_source"] = imp.source_id
|
||||
results.append({"filename": filename, "report": report})
|
||||
summary["files"] += 1
|
||||
summary["pages_created"] += report.get("pages_created", 0)
|
||||
summary["rows_created"] += report.get("rows_created", 0)
|
||||
summary["errors"] += len(report.get("errors", []))
|
||||
return {"status": "ok", "summary": summary, "results": results}
|
||||
|
||||
|
||||
@router.post("/relations/resolve")
|
||||
async def import_resolve_relations(request: Request):
|
||||
"""Convert text columns referencing another collection into relation props."""
|
||||
ws_id, _ = _workspace(request)
|
||||
with get_conn() as conn:
|
||||
return resolve_relations(conn, ws_id)
|
||||
|
||||
|
||||
@router.get("/jobs")
|
||||
async def import_jobs(request: Request):
|
||||
return {"jobs": list_jobs()}
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}")
|
||||
async def import_job(job_id: str):
|
||||
job = get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
return job
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}/report")
|
||||
async def import_job_report(job_id: str):
|
||||
"""Download a job's import report as JSON."""
|
||||
job = get_job(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "Job not found")
|
||||
payload = json.dumps(job.get("report") or {}, ensure_ascii=False, indent=2)
|
||||
return Response(
|
||||
content=payload,
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="import-{job_id}.json"'},
|
||||
)
|
||||
|
||||
|
||||
def _forge_adapter(request: Request, provider: str):
|
||||
if provider == "gitea":
|
||||
from app.services.gitea_client import get_user_gitea_client
|
||||
from app.services.importers.forge import GiteaForgeAdapter
|
||||
client = get_user_gitea_client(request)
|
||||
if client is None:
|
||||
raise HTTPException(400, "Gitea non connecté")
|
||||
return GiteaForgeAdapter(client)
|
||||
if provider == "github":
|
||||
from app.services.github_adapter import GitHubAdapter
|
||||
token = _user_oauth_token(request, "github")
|
||||
if not token:
|
||||
raise HTTPException(400, "GitHub non connecté")
|
||||
return GitHubAdapter(token)
|
||||
raise HTTPException(400, "provider must be 'gitea' or 'github'")
|
||||
|
||||
|
||||
def _int_or_none(value) -> int | None:
|
||||
try:
|
||||
ivalue = int(value)
|
||||
return ivalue if ivalue > 0 else None
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_mapping(value) -> dict[str, str] | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
if isinstance(parsed, dict):
|
||||
return {str(k): str(v) for k, v in parsed.items() if v}
|
||||
return None
|
||||
|
||||
|
||||
def _parse_mode(value) -> str | None:
|
||||
mode = str(value or "").strip().lower()
|
||||
return mode if mode in ("skip", "update", "duplicate") else None
|
||||
|
||||
|
||||
def _user_oauth_token(request: Request, provider: str) -> str:
|
||||
user = _current_user(request)
|
||||
if not user or not user.get("id"):
|
||||
return ""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider=? "
|
||||
"ORDER BY updated_at DESC LIMIT 1",
|
||||
(user["id"], provider),
|
||||
).fetchone()
|
||||
return row["access_token"] if row else ""
|
||||
+13
-4
@@ -235,15 +235,24 @@ async def library_shared(
|
||||
uid = _get_user_id(request)
|
||||
|
||||
# Page ids the user shares toward others (nominal page_shares) or receives
|
||||
# (direct shares + group shares via group_members)
|
||||
with get_conn() as conn:
|
||||
made_rows = conn.execute(
|
||||
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.created_by=?",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
recv_rows = conn.execute(
|
||||
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.shared_with_user_id=?",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
try:
|
||||
recv_rows = conn.execute(
|
||||
"""SELECT DISTINCT s.page_id FROM page_shares s
|
||||
LEFT JOIN group_members gm ON gm.group_id = s.shared_with_group_id AND gm.user_id=?
|
||||
WHERE s.shared_with_user_id=? OR gm.user_id=?""",
|
||||
(uid, uid, uid),
|
||||
).fetchall()
|
||||
except Exception:
|
||||
recv_rows = conn.execute(
|
||||
"SELECT DISTINCT s.page_id FROM page_shares s WHERE s.shared_with_user_id=?",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
made_ids = {r[0] for r in made_rows}
|
||||
recv_ids = {r[0] for r in recv_rows}
|
||||
|
||||
|
||||
@@ -97,13 +97,39 @@ async def set_prefs(request: 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"):
|
||||
for key in ("comments", "mentions", "reminders", "assignments"):
|
||||
if key in body:
|
||||
prefs[key] = bool(body[key])
|
||||
notif.set_user_prefs(user["id"], prefs)
|
||||
return {"status": "ok", "prefs": prefs}
|
||||
|
||||
|
||||
@router.get("/timezone")
|
||||
async def get_timezone(request: Request):
|
||||
"""Return the current user's IANA timezone ('' = UTC)."""
|
||||
user = _current_user(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT timezone FROM users WHERE id=?", (user["id"],)).fetchone()
|
||||
tz = (row["timezone"] if row and "timezone" in row.keys() else "") or ""
|
||||
from app.services.recurrence import common_timezones
|
||||
return {"timezone": tz, "zones": common_timezones()}
|
||||
|
||||
|
||||
@router.post("/timezone")
|
||||
async def set_timezone(request: Request):
|
||||
"""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):
|
||||
raise HTTPException(status_code=400, detail=f"Unknown timezone '{tz}'")
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE users SET timezone=? WHERE id=?", (tz, user["id"]))
|
||||
conn.commit()
|
||||
return {"status": "ok", "timezone": tz}
|
||||
|
||||
|
||||
@router.get("/users/search")
|
||||
async def search_users(request: Request, q: str = ""):
|
||||
"""User autocomplete for @mentions."""
|
||||
|
||||
@@ -0,0 +1,525 @@
|
||||
"""FlowDeck — v6.0.0 Granular permissions API (page/collection/property ACL).
|
||||
|
||||
Backend for the page-editor "Permissions" panel, database property visibility
|
||||
and user-group management. Grants are stored in ``page_permissions`` /
|
||||
``collection_permissions`` / ``property_permissions``; every mutation is logged
|
||||
into ``permission_audit_log`` for the admin audit view.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.permission_manager import PermissionManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["permissions"], prefix="/api/v2")
|
||||
|
||||
|
||||
PAGE_ROLES = ("viewer", "commenter", "editor", "owner")
|
||||
COLLECTION_ROLES = ("viewer", "commenter", "editor", "owner")
|
||||
PROPERTY_ROLES = ("viewer", "editor")
|
||||
PERMISSION_TYPES = ("inherit", "restricted", "private")
|
||||
|
||||
|
||||
def _require_user(request: Request) -> dict:
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
return user
|
||||
|
||||
|
||||
def _pm(request: Request) -> PermissionManager:
|
||||
return PermissionManager(_require_user(request)["id"])
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str:
|
||||
try:
|
||||
return request.client.host if request.client else ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _perm_list(conn, table: str, fk: str, resource_id: int) -> list[dict]:
|
||||
rows = conn.execute(
|
||||
f"""SELECT p.*,
|
||||
u.login AS user_login, u.full_name AS user_name,
|
||||
g.name AS group_name
|
||||
FROM {table} p
|
||||
LEFT JOIN users u ON u.id = p.user_id
|
||||
LEFT JOIN user_groups g ON g.id = p.group_id
|
||||
WHERE p.{fk}=? ORDER BY p.id""",
|
||||
(resource_id,),
|
||||
).fetchall()
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
if d.get("user_id"):
|
||||
d["name"] = d["user_name"] or d["user_login"] or f"User #{d['user_id']}"
|
||||
d["kind"] = "user"
|
||||
else:
|
||||
d["name"] = d["group_name"] or f"Group #{d['group_id']}"
|
||||
d["kind"] = "group"
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def _grant_common(request: Request, pm: PermissionManager, resource_type: str,
|
||||
resource_id: int, body: dict, table: str, fk: str,
|
||||
allowed_roles: tuple[str, ...],
|
||||
extra_cols: dict | None = None) -> dict:
|
||||
user_id = body.get("user_id")
|
||||
group_id = body.get("group_id")
|
||||
role = (body.get("role") or "").strip()
|
||||
if role not in allowed_roles:
|
||||
raise HTTPException(400, f"role must be one of {', '.join(allowed_roles)}")
|
||||
if not user_id and not group_id:
|
||||
raise HTTPException(400, "Provide either user_id or group_id")
|
||||
if user_id and not isinstance(user_id, int):
|
||||
raise HTTPException(400, "user_id must be an integer")
|
||||
if group_id and not isinstance(group_id, int):
|
||||
raise HTTPException(400, "group_id must be an integer")
|
||||
actor = _require_user(request)["id"]
|
||||
with get_conn() as conn:
|
||||
if user_id:
|
||||
exists = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone()
|
||||
if not exists:
|
||||
raise HTTPException(404, "User not found")
|
||||
if group_id:
|
||||
exists = conn.execute("SELECT id FROM user_groups WHERE id=?", (group_id,)).fetchone()
|
||||
if not exists:
|
||||
raise HTTPException(404, "Group not found")
|
||||
existing = conn.execute(
|
||||
f"SELECT id, role FROM {table} WHERE {fk}=? AND user_id IS ? AND group_id IS ?",
|
||||
(resource_id, user_id, group_id),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(f"UPDATE {table} SET role=? WHERE id=?",
|
||||
(role, existing["id"]))
|
||||
old_role = existing["role"]
|
||||
perm_id = existing["id"]
|
||||
else:
|
||||
cols = [fk, "user_id", "group_id", "role", "granted_by"]
|
||||
vals: list = [resource_id, user_id, group_id, role, actor]
|
||||
for col, val in (extra_cols or {}).items():
|
||||
cols.append(col)
|
||||
vals.append(val)
|
||||
placeholders = ", ".join("?" for _ in cols)
|
||||
cur = conn.execute(
|
||||
f"INSERT INTO {table} ({', '.join(cols)}) VALUES ({placeholders})",
|
||||
tuple(vals),
|
||||
)
|
||||
perm_id = cur.lastrowid
|
||||
old_role = None
|
||||
conn.commit()
|
||||
pm.invalidate()
|
||||
pm.log_permission_change(resource_type, resource_id, "grant",
|
||||
target_user_id=user_id, target_group_id=group_id,
|
||||
old_role=old_role, new_role=role, ip_address=_client_ip(request))
|
||||
return {"status": "ok", "id": perm_id, "role": role, "user_id": user_id, "group_id": group_id}
|
||||
|
||||
|
||||
def _revoke_common(request: Request, pm: PermissionManager, resource_type: str,
|
||||
resource_id: int, table: str, fk: str, perm_id: int) -> dict:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
f"SELECT user_id, group_id, role FROM {table} WHERE id=? AND {fk}=?",
|
||||
(perm_id, resource_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Permission not found")
|
||||
conn.execute(f"DELETE FROM {table} WHERE id=?", (perm_id,))
|
||||
conn.commit()
|
||||
pm.invalidate()
|
||||
pm.log_permission_change(resource_type, resource_id, "revoke",
|
||||
target_user_id=row["user_id"], target_group_id=row["group_id"],
|
||||
old_role=row["role"], new_role=None, ip_address=_client_ip(request))
|
||||
return {"status": "revoked"}
|
||||
|
||||
|
||||
def _set_permission_type(request: Request, pm: PermissionManager, resource_type: str,
|
||||
resource_id: int, table: str, body: dict) -> dict:
|
||||
ptype = (body.get("permission_type") or "").strip()
|
||||
if ptype not in PERMISSION_TYPES:
|
||||
raise HTTPException(400, f"permission_type must be one of {', '.join(PERMISSION_TYPES)}")
|
||||
with get_conn() as conn:
|
||||
conn.execute(f"UPDATE {table} SET permission_type=? WHERE id=?", (ptype, resource_id))
|
||||
conn.commit()
|
||||
pm.invalidate()
|
||||
pm.log_permission_change(resource_type, resource_id, "type_change",
|
||||
new_role=ptype, ip_address=_client_ip(request))
|
||||
return {"status": "ok", "permission_type": ptype}
|
||||
|
||||
|
||||
# ═══════════════ Page permissions ═══════════════
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/permissions")
|
||||
async def list_page_permissions(page_id: int, request: Request):
|
||||
"""List explicit page grants + the caller's effective role."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
with get_conn() as conn:
|
||||
grants = _perm_list(conn, "page_permissions", "page_id", page_id)
|
||||
return {
|
||||
"permissions": grants,
|
||||
"mine": pm.get_page_permission(page_id),
|
||||
"permission_type": _page_type(page_id),
|
||||
"can_manage": pm.can_manage_page_permissions(page_id),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/pages/{page_id}/permissions/mine")
|
||||
async def my_page_permission(page_id: int, request: Request):
|
||||
"""Effective role of the current user on a page (UI gating)."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_page(page_id):
|
||||
raise HTTPException(404, "Page not found")
|
||||
return {
|
||||
"role": pm.get_page_permission(page_id),
|
||||
"can_edit": pm.can_edit_page(page_id),
|
||||
"can_comment": pm.can_comment_page(page_id),
|
||||
"can_manage": pm.can_manage_page_permissions(page_id),
|
||||
"permission_type": _page_type(page_id),
|
||||
}
|
||||
|
||||
|
||||
def _page_type(page_id: int) -> str:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT permission_type FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
return (row["permission_type"] if row else "inherit") or "inherit"
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/permissions")
|
||||
async def grant_page_permission(page_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_page_permissions(page_id):
|
||||
raise HTTPException(403, "Only a page owner can manage its permissions")
|
||||
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):
|
||||
"""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")
|
||||
results = []
|
||||
for g in grants:
|
||||
results.append(_grant_common(request, pm, "page", page_id, g,
|
||||
"page_permissions", "page_id", PAGE_ROLES))
|
||||
return {"status": "ok", "granted": results}
|
||||
|
||||
|
||||
@router.delete("/pages/{page_id}/permissions/{perm_id}")
|
||||
async def revoke_page_permission(page_id: int, perm_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_page_permissions(page_id):
|
||||
raise HTTPException(403, "Only a page owner can manage its permissions")
|
||||
return _revoke_common(request, pm, "page", page_id, "page_permissions", "page_id", perm_id)
|
||||
|
||||
|
||||
@router.post("/pages/{page_id}/permission-type")
|
||||
async def set_page_permission_type(page_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_page_permissions(page_id):
|
||||
raise HTTPException(403, "Only a page owner can manage its permissions")
|
||||
return _set_permission_type(request, pm, "page", page_id, "pages", await request.json())
|
||||
|
||||
|
||||
# ═══════════════ Collection permissions ═══════════════
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/permissions")
|
||||
async def list_collection_permissions(collection_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(404, "Collection not found")
|
||||
with get_conn() as conn:
|
||||
grants = _perm_list(conn, "collection_permissions", "collection_id", collection_id)
|
||||
return {
|
||||
"permissions": grants,
|
||||
"mine": pm.get_collection_permission(collection_id),
|
||||
"permission_type": _collection_type(collection_id),
|
||||
"can_manage": pm.can_manage_collection_permissions(collection_id),
|
||||
}
|
||||
|
||||
|
||||
def _collection_type(collection_id: int) -> str:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT permission_type FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
return (row["permission_type"] if row else "inherit") or "inherit"
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/permissions")
|
||||
async def grant_collection_permission(collection_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage its permissions")
|
||||
body = await request.json()
|
||||
return _grant_common(request, pm, "collection", collection_id, body,
|
||||
"collection_permissions", "collection_id", COLLECTION_ROLES)
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/permissions/{perm_id}")
|
||||
async def revoke_collection_permission(collection_id: int, perm_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage its permissions")
|
||||
return _revoke_common(request, pm, "collection", collection_id,
|
||||
"collection_permissions", "collection_id", perm_id)
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/permission-type")
|
||||
async def set_collection_permission_type(collection_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage its permissions")
|
||||
return _set_permission_type(request, pm, "collection", collection_id,
|
||||
"collections", await request.json())
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/properties/visible")
|
||||
async def visible_properties(collection_id: int, request: Request):
|
||||
"""Split property ids into visible / hidden for the current user."""
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(404, "Collection not found")
|
||||
visible = pm.get_visible_properties(collection_id)
|
||||
with get_conn() as conn:
|
||||
all_ids = [r["id"] for r in conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()]
|
||||
return {
|
||||
"visible": visible,
|
||||
"hidden": [pid for pid in all_ids if pid not in visible],
|
||||
"can_edit": pm.can_edit_collection(collection_id),
|
||||
}
|
||||
|
||||
|
||||
# ═══════════════ Property permissions ═══════════════
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}/properties/{property_id}/permissions")
|
||||
async def list_property_permissions(collection_id: int, property_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_view_collection(collection_id):
|
||||
raise HTTPException(404, "Collection not found")
|
||||
with get_conn() as conn:
|
||||
grants = _perm_list(conn, "property_permissions", "property_id", property_id)
|
||||
return {
|
||||
"permissions": grants,
|
||||
"mine_view": pm.can_view_property(collection_id, property_id),
|
||||
"mine_edit": pm.can_edit_property(collection_id, property_id),
|
||||
"can_manage": pm.can_manage_collection_permissions(collection_id),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/properties/{property_id}/permissions")
|
||||
async def grant_property_permission(collection_id: int, property_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage property permissions")
|
||||
body = await request.json()
|
||||
with get_conn() as conn:
|
||||
prop = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE id=? AND collection_id=?",
|
||||
(property_id, collection_id),
|
||||
).fetchone()
|
||||
if not prop:
|
||||
raise HTTPException(404, "Property not found")
|
||||
return _grant_common(request, pm, "property", property_id, body,
|
||||
"property_permissions", "property_id", PROPERTY_ROLES,
|
||||
extra_cols={"collection_id": collection_id})
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}/properties/{property_id}/permissions/{perm_id}")
|
||||
async def revoke_property_permission(collection_id: int, property_id: int,
|
||||
perm_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
if not pm.can_manage_collection_permissions(collection_id):
|
||||
raise HTTPException(403, "Only a collection owner can manage property permissions")
|
||||
return _revoke_common(request, pm, "property", property_id,
|
||||
"property_permissions", "property_id", perm_id)
|
||||
|
||||
|
||||
# ═══════════════ Groups ═══════════════
|
||||
|
||||
|
||||
@router.get("/groups")
|
||||
async def list_groups(request: Request, workspace_id: int | None = None):
|
||||
user = _require_user(request)
|
||||
pm = PermissionManager(user["id"])
|
||||
return {"groups": pm.get_groups_for_workspace(workspace_id)}
|
||||
|
||||
|
||||
@router.post("/groups")
|
||||
async def create_group(request: Request):
|
||||
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)
|
||||
pm.log_permission_change("group", gid, "group_create",
|
||||
target_group_id=gid, new_role="",
|
||||
ip_address=_client_ip(request))
|
||||
return {"status": "ok", "id": gid}
|
||||
|
||||
|
||||
@router.put("/groups/{group_id}")
|
||||
async def update_group(group_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
body = await request.json()
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Group not found")
|
||||
if not pm.is_workspace_admin(row["workspace_id"]):
|
||||
raise HTTPException(403, "Only a workspace owner or admin can edit groups")
|
||||
conn.execute(
|
||||
"UPDATE user_groups SET name=?, description=? WHERE id=?",
|
||||
(name, body.get("description") or "", group_id),
|
||||
)
|
||||
conn.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}")
|
||||
async def delete_group(group_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Group not found")
|
||||
if not pm.is_workspace_admin(row["workspace_id"]):
|
||||
raise HTTPException(403, "Only a workspace owner or admin can delete groups")
|
||||
pm.delete_group(group_id)
|
||||
pm.log_permission_change("group", group_id, "group_delete",
|
||||
target_group_id=group_id, ip_address=_client_ip(request))
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/groups/{group_id}/members")
|
||||
async def list_group_members(group_id: int, request: Request):
|
||||
user = _require_user(request)
|
||||
return {"members": PermissionManager(user["id"]).get_group_members(group_id)}
|
||||
|
||||
|
||||
@router.post("/groups/{group_id}/members")
|
||||
async def add_group_member(group_id: int, request: Request):
|
||||
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")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Group not found")
|
||||
if not pm.is_workspace_admin(row["workspace_id"]):
|
||||
raise HTTPException(403, "Only a workspace owner or admin can manage groups")
|
||||
pm.add_user_to_group(group_id, user_id)
|
||||
pm.invalidate()
|
||||
pm.log_permission_change("group", group_id, "member_add",
|
||||
target_user_id=user_id, target_group_id=group_id,
|
||||
ip_address=_client_ip(request))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/groups/{group_id}/members/{user_id}")
|
||||
async def remove_group_member(group_id: int, user_id: int, request: Request):
|
||||
pm = _pm(request)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Group not found")
|
||||
if not pm.is_workspace_admin(row["workspace_id"]):
|
||||
raise HTTPException(403, "Only a workspace owner or admin can manage groups")
|
||||
pm.remove_user_from_group(group_id, user_id)
|
||||
pm.invalidate()
|
||||
pm.log_permission_change("group", group_id, "member_remove",
|
||||
target_user_id=user_id, target_group_id=group_id,
|
||||
ip_address=_client_ip(request))
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ═══════════════ Users (access pickers) + audit ═══════════════
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(request: Request, workspace_id: int | None = None, q: str = ""):
|
||||
"""Workspace members (+ admins) for the grant pickers."""
|
||||
_require_user(request)
|
||||
q = (q or "").strip().lower()
|
||||
with get_conn() as conn:
|
||||
if workspace_id:
|
||||
rows = conn.execute(
|
||||
"""SELECT DISTINCT u.id, u.login, u.full_name, u.email, u.avatar_color
|
||||
FROM users u
|
||||
LEFT JOIN workspace_members wm ON wm.user_id=u.id AND wm.workspace_id=?
|
||||
WHERE u.is_admin=1 OR wm.id IS NOT NULL
|
||||
ORDER BY u.login""",
|
||||
(workspace_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, login, full_name, email, avatar_color FROM users ORDER BY login"
|
||||
).fetchall()
|
||||
users = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
if q and q not in (d["login"].lower(), d["full_name"].lower(),
|
||||
d["email"].lower()):
|
||||
continue
|
||||
users.append({"id": d["id"], "login": d["login"], "name": d["full_name"] or d["login"],
|
||||
"email": d["email"], "avatar_color": d["avatar_color"]})
|
||||
return {"users": users}
|
||||
|
||||
|
||||
@router.get("/audit/permissions")
|
||||
async def permission_audit(request: Request, limit: int = 100):
|
||||
"""Full permission change history — workspace owner/admin only."""
|
||||
user = _require_user(request)
|
||||
uid = user["id"]
|
||||
is_admin = bool(user.get("is_admin"))
|
||||
limit = max(1, min(int(limit), 500))
|
||||
with get_conn() as conn:
|
||||
if not is_admin:
|
||||
owned = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE owner_id=?", (uid,)
|
||||
).fetchall()
|
||||
if not owned:
|
||||
raise HTTPException(403, "Only a workspace owner or admin can view the audit log")
|
||||
rows = conn.execute(
|
||||
"""SELECT a.*, u.login AS actor_login
|
||||
FROM permission_audit_log a LEFT JOIN users u ON u.id=a.performed_by
|
||||
ORDER BY a.created_at DESC, a.id DESC LIMIT ?""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return {"events": [dict(r) for r in rows]}
|
||||
@@ -40,6 +40,9 @@ def verify_token(authorization: str | None = Header(None)):
|
||||
raise HTTPException(401, "API token required. Generate one via Settings → API tokens.")
|
||||
token = authorization[7:] # strip "Bearer "
|
||||
if token == DEFAULT_TOKEN:
|
||||
from app.config import settings as _s
|
||||
if not _s.public_api_insecure_ok:
|
||||
raise HTTPException(401, "Default token disabled. Set PUBLIC_API_INSECURE_OK=true in dev or use a real Bearer token.")
|
||||
return token
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT 1 FROM user_tokens WHERE gitea_token=?", (token,)).fetchone()
|
||||
|
||||
+15
-1
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket
|
||||
from fastapi import APIRouter, Request, WebSocket
|
||||
from starlette.websockets import WebSocketDisconnect
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
@@ -17,6 +17,20 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["realtime"])
|
||||
|
||||
|
||||
@router.get("/api/realtime/stats")
|
||||
async def realtime_stats(request: Request):
|
||||
"""Observabilité realtime v6.4.0 : rooms, connexions, ops, merges, conflits.
|
||||
|
||||
Réservé aux utilisateurs authentifiés (données d'activité internes).
|
||||
"""
|
||||
user = SessionManager.decode_session(
|
||||
request.cookies.get("flowdeck_session", "")
|
||||
)
|
||||
if not user or not user.get("id"):
|
||||
return {"error": "unauthorized"}
|
||||
return manager.stats()
|
||||
|
||||
|
||||
@router.websocket("/ws/pages/{page_id}")
|
||||
async def ws_page(websocket: WebSocket, page_id: int):
|
||||
await websocket.accept()
|
||||
|
||||
+134
-21
@@ -10,6 +10,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.automations import fire_event as _fire_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["sharing"], prefix="/api")
|
||||
@@ -36,18 +37,23 @@ def _slugify(title: str) -> str:
|
||||
|
||||
@router.post("/pages/{page_id}/share")
|
||||
async def share_page(page_id: int, request: Request):
|
||||
"""Invite a user or email to a page."""
|
||||
"""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", "")
|
||||
permission = body.get("permission", "view")
|
||||
|
||||
if permission not in ("view", "comment", "edit"):
|
||||
raise HTTPException(400, "Invalid permission. Use view, comment, or edit.")
|
||||
|
||||
if not target_user_id and not email:
|
||||
raise HTTPException(400, "Provide user_id or email to share with.")
|
||||
if not target_user_id and not target_group_id and not email:
|
||||
raise HTTPException(400, "Provide user_id, group_id or email to share with.")
|
||||
|
||||
# Bridge share permission (view/comment/edit) → granular role
|
||||
# (viewer/commenter/editor) so page_permissions grants stay in sync.
|
||||
_SHARE_TO_ROLE = {"view": "viewer", "comment": "commenter", "edit": "editor"}
|
||||
|
||||
with get_conn() as conn:
|
||||
# Verify page exists
|
||||
@@ -61,18 +67,29 @@ async def share_page(page_id: int, request: Request):
|
||||
if not target:
|
||||
raise HTTPException(404, "Target user not found")
|
||||
|
||||
# Verify target group exists if group_id given
|
||||
if target_group_id:
|
||||
gtarget = conn.execute("SELECT id FROM user_groups WHERE id=?", (target_group_id,)).fetchone()
|
||||
if not gtarget:
|
||||
raise HTTPException(404, "Target group not found")
|
||||
|
||||
# Upsert to avoid duplicates: update the existing permission if the same
|
||||
# target (user or email) is already shared on this page.
|
||||
# target (user, group or email) is already shared on this page.
|
||||
target_row = None
|
||||
if target_user_id:
|
||||
target_row = conn.execute(
|
||||
"SELECT id FROM page_shares WHERE page_id=? AND shared_with_user_id=?",
|
||||
(page_id, target_user_id),
|
||||
).fetchone()
|
||||
elif target_group_id:
|
||||
target_row = conn.execute(
|
||||
"SELECT id FROM page_shares WHERE page_id=? AND shared_with_group_id=?",
|
||||
(page_id, target_group_id),
|
||||
).fetchone()
|
||||
elif email:
|
||||
target_row = conn.execute(
|
||||
"""SELECT id FROM page_shares
|
||||
WHERE page_id=? AND shared_with_email=? AND shared_with_user_id IS NULL""",
|
||||
WHERE page_id=? AND shared_with_email=? AND shared_with_user_id IS NULL AND shared_with_group_id IS NULL""",
|
||||
(page_id, email.strip()),
|
||||
).fetchone()
|
||||
|
||||
@@ -85,29 +102,80 @@ async def share_page(page_id: int, request: Request):
|
||||
else:
|
||||
if not email:
|
||||
email = ""
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(page_id, target_user_id, email.strip(), permission, user["id"]),
|
||||
)
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_group_id, shared_with_email, permission, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(page_id, target_user_id, target_group_id, email.strip(), permission, user["id"]),
|
||||
)
|
||||
except Exception:
|
||||
# Fallback for DBs where the migration has not run yet
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO page_shares (page_id, shared_with_user_id, shared_with_email, permission, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(page_id, target_user_id, email.strip(), permission, user["id"]),
|
||||
)
|
||||
share_id = cur.lastrowid
|
||||
conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,))
|
||||
# ── Mirror group shares into page_permissions so the ACL used by
|
||||
# PermissionManager (can_view/edit/comment) grants real access to
|
||||
# every group member. Best-effort: never break legacy page_shares.
|
||||
if target_group_id:
|
||||
try:
|
||||
_mirror_share_grant(conn, page_id, target_group_id, _SHARE_TO_ROLE[permission], user["id"])
|
||||
except Exception:
|
||||
logger.warning("share→page_permissions mirror failed (page=%s group=%s)", page_id, target_group_id)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"id": share_id,
|
||||
"page_id": page_id,
|
||||
"shared_with_user_id": target_user_id,
|
||||
"shared_with_group_id": target_group_id,
|
||||
"shared_with_email": email,
|
||||
"permission": permission,
|
||||
"status": "shared",
|
||||
}
|
||||
|
||||
|
||||
def _mirror_share_grant(conn, page_id: int, group_id: int, role: str, granted_by: int) -> None:
|
||||
"""Upsert a ``page_permissions`` grant mirroring a group ``page_shares`` row.
|
||||
|
||||
Keeps the granular ACL (used by ``PermissionManager``) in sync with what
|
||||
the share dialog shows, so invited groups get effective view/edit rights.
|
||||
"""
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM page_permissions WHERE page_id=? AND user_id IS NULL AND group_id=?",
|
||||
(page_id, group_id),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute("UPDATE page_permissions SET role=?, granted_by=? WHERE id=?",
|
||||
(role, granted_by, existing["id"]))
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO page_permissions (page_id, user_id, group_id, role, granted_by) "
|
||||
"VALUES (?, NULL, ?, ?, ?)",
|
||||
(page_id, group_id, role, granted_by),
|
||||
)
|
||||
|
||||
|
||||
def _mirror_share_revoke(conn, page_id: int, group_id: int) -> None:
|
||||
"""Remove the mirrored grant when a group share is updated away or deleted."""
|
||||
conn.execute(
|
||||
"DELETE FROM page_permissions WHERE page_id=? AND user_id IS NULL AND group_id=?",
|
||||
(page_id, group_id),
|
||||
)
|
||||
|
||||
|
||||
@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):
|
||||
"""Change the permission level of an existing share entry."""
|
||||
_require_auth(request)
|
||||
user = _require_auth(request)
|
||||
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
permission = body.get("permission", "")
|
||||
@@ -117,7 +185,7 @@ async def update_share_permission(page_id: int, share_id: int, request: Request)
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM page_shares WHERE id=? AND page_id=?",
|
||||
"SELECT id, shared_with_group_id FROM page_shares WHERE id=? AND page_id=?",
|
||||
(share_id, page_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
@@ -127,6 +195,18 @@ async def update_share_permission(page_id: int, share_id: int, request: Request)
|
||||
"UPDATE page_shares SET permission=? WHERE id=?",
|
||||
(permission, share_id),
|
||||
)
|
||||
# Keep the mirrored ACL grant in sync for group shares.
|
||||
try:
|
||||
gid = row["shared_with_group_id"] if "shared_with_group_id" in row.keys() else None
|
||||
except Exception:
|
||||
gid = None
|
||||
if gid:
|
||||
try:
|
||||
_mirror_share_grant(conn, page_id, gid,
|
||||
{"view": "viewer", "comment": "commenter", "edit": "editor"}[permission],
|
||||
user["id"])
|
||||
except Exception:
|
||||
logger.warning("share→page_permissions mirror failed (share=%s)", share_id)
|
||||
conn.commit()
|
||||
|
||||
return {"status": "updated", "share_id": share_id, "permission": permission}
|
||||
@@ -139,13 +219,22 @@ async def remove_share(page_id: int, share_id: int, request: Request):
|
||||
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM page_shares WHERE id=? AND page_id=?",
|
||||
"SELECT id, shared_with_group_id FROM page_shares WHERE id=? AND page_id=?",
|
||||
(share_id, page_id),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Share entry not found")
|
||||
|
||||
conn.execute("DELETE FROM page_shares WHERE id=?", (share_id,))
|
||||
try:
|
||||
gid = row["shared_with_group_id"] if "shared_with_group_id" in row.keys() else None
|
||||
except Exception:
|
||||
gid = None
|
||||
if gid:
|
||||
try:
|
||||
_mirror_share_revoke(conn, page_id, gid)
|
||||
except Exception:
|
||||
logger.warning("share→page_permissions revoke failed (share=%s)", share_id)
|
||||
# If no more shares, unset is_shared
|
||||
remaining = conn.execute(
|
||||
"SELECT COUNT(*) AS c FROM page_shares WHERE page_id=?", (page_id,)
|
||||
@@ -167,14 +256,25 @@ async def list_shares(page_id: int, request: Request):
|
||||
if not page:
|
||||
raise HTTPException(404, "Page not found")
|
||||
|
||||
rows = conn.execute(
|
||||
"""SELECT s.*, u.login, u.full_name, u.avatar_url
|
||||
FROM page_shares s
|
||||
LEFT JOIN users u ON s.shared_with_user_id = u.id
|
||||
WHERE s.page_id=?
|
||||
ORDER BY s.created_at DESC""",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""SELECT s.*, u.login, u.full_name, u.avatar_url, g.name AS group_name
|
||||
FROM page_shares s
|
||||
LEFT JOIN users u ON s.shared_with_user_id = u.id
|
||||
LEFT JOIN user_groups g ON s.shared_with_group_id = g.id
|
||||
WHERE s.page_id=?
|
||||
ORDER BY s.created_at DESC""",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
except Exception:
|
||||
rows = conn.execute(
|
||||
"""SELECT s.*, u.login, u.full_name, u.avatar_url
|
||||
FROM page_shares s
|
||||
LEFT JOIN users u ON s.shared_with_user_id = u.id
|
||||
WHERE s.page_id=?
|
||||
ORDER BY s.created_at DESC""",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
"page_id": page_id,
|
||||
@@ -182,6 +282,7 @@ async def list_shares(page_id: int, request: Request):
|
||||
{
|
||||
"id": r["id"],
|
||||
"shared_with_user_id": r["shared_with_user_id"],
|
||||
"shared_with_group_id": r["shared_with_group_id"] if "shared_with_group_id" in r.keys() else None,
|
||||
"shared_with_email": r["shared_with_email"],
|
||||
"permission": r["permission"],
|
||||
"created_at": r["created_at"],
|
||||
@@ -189,6 +290,8 @@ async def list_shares(page_id: int, request: Request):
|
||||
"user_login": r["login"],
|
||||
"user_full_name": r["full_name"],
|
||||
"user_avatar_url": r["avatar_url"],
|
||||
"group_name": r["group_name"] if "group_name" in r.keys() else None,
|
||||
"kind": "group" if (("shared_with_group_id" in r.keys() and r["shared_with_group_id"]) or ("group_name" in r.keys() and r["group_name"])) else "user",
|
||||
}
|
||||
for r in rows
|
||||
],
|
||||
@@ -226,6 +329,11 @@ async def publish_page(page_id: int, request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("page.published", {"page_id": page_id, "slug": slug})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"is_published": True,
|
||||
@@ -252,6 +360,11 @@ async def unpublish_page(page_id: int, request: Request):
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
await _fire_event("page.unpublished", {"page_id": page_id})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"page_id": page_id,
|
||||
"is_published": False,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""FlowDeck — /api/v2/sync endpoints (v6.0.0 PWA offline sync, Bearer v6.4.0).
|
||||
|
||||
Auth: ``Authorization: Bearer <token>`` (scopes ``read`` for delta/status,
|
||||
``write`` for batch). The legacy ``flowdeck_session`` cookie is still accepted
|
||||
as a fallback so the installed PWA/service worker keeps syncing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Query, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.services.api_v2_helpers import get_bearer_user, has_scope
|
||||
from app.services.sync_engine import SyncEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v2/sync", tags=["sync"])
|
||||
|
||||
_engine = SyncEngine()
|
||||
|
||||
|
||||
def _user(request: Request, authorization: str | None = None,
|
||||
*, required_scope: str = "read") -> dict:
|
||||
"""Bearer-first auth with session-cookie fallback (offline.js compat)."""
|
||||
auth = authorization or request.headers.get("authorization") or ""
|
||||
if auth and auth.lower().startswith("bearer "):
|
||||
try:
|
||||
user = get_bearer_user(request, authorization)
|
||||
except HTTPException:
|
||||
raise HTTPException(
|
||||
status_code=401, detail="Invalid or expired API token"
|
||||
) from None
|
||||
if not has_scope(user.get("_token_scopes"), required_scope):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Insufficient scope. Required: {required_scope}",
|
||||
)
|
||||
return user
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
@router.get("/delta")
|
||||
async def sync_delta(
|
||||
request: Request,
|
||||
since: float = Query(default=0, description="Epoch seconds (ou ms) du dernier sync"),
|
||||
workspace_id: int = Query(default=None),
|
||||
authorization: str | None = Header(default=None),
|
||||
):
|
||||
"""Pull server-side changes since `since` (for the given workspace)."""
|
||||
user = _user(request, authorization, required_scope="read")
|
||||
if workspace_id is None:
|
||||
raise HTTPException(status_code=400, detail="workspace_id is required")
|
||||
result = await _engine.get_delta(user["id"], since, workspace_id)
|
||||
if result.get("error") == "forbidden":
|
||||
return JSONResponse({"detail": "Forbidden"}, status_code=403)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/batch")
|
||||
async def sync_batch(request: Request, authorization: str | None = Header(default=None)):
|
||||
"""Apply a batch of offline mutations and return per-mutation results."""
|
||||
user = _user(request, authorization, required_scope="write")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid JSON body") from None
|
||||
|
||||
mutations = body.get("mutations") or []
|
||||
device_id = body.get("device_id") or "unknown"
|
||||
if not isinstance(mutations, list) or not mutations:
|
||||
return {"results": [], "conflicts": [], "server_time": SyncEngine._now_epoch()}
|
||||
|
||||
result = await _engine.apply_batch(user["id"], mutations, device_id)
|
||||
result["server_time"] = SyncEngine._now_epoch()
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def sync_status(request: Request, workspace_id: int = Query(default=None),
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Synchronization status for the workspace (pending server queue, last sync)."""
|
||||
user = _user(request, authorization, required_scope="read")
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
if not SyncEngine._can_access(conn, user["id"], workspace_id):
|
||||
return JSONResponse({"detail": "Forbidden"}, status_code=403)
|
||||
pending = conn.execute(
|
||||
"SELECT COUNT(*) AS n FROM offline_sync_queue WHERE user_id=? AND status='pending'",
|
||||
(user["id"],),
|
||||
).fetchone()["n"]
|
||||
last = conn.execute(
|
||||
"SELECT MAX(created_at) AS last FROM offline_sync_queue "
|
||||
"WHERE user_id=? AND status='synced'",
|
||||
(user["id"],),
|
||||
).fetchone()["last"]
|
||||
return {
|
||||
"pending_count": pending,
|
||||
"last_sync": last,
|
||||
"is_syncing": False,
|
||||
"server_time": SyncEngine._now_epoch(),
|
||||
"workspace_id": workspace_id,
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
"""FlowDeck — Web Clipper router (v6.0.0).
|
||||
|
||||
Endpoints:
|
||||
GET /api/v2/web-clipper/status
|
||||
POST /api/v2/web-clipper/auth/verify
|
||||
POST /api/v2/web-clipper/clip
|
||||
GET /api/v2/web-clipper/devices
|
||||
DELETE /api/v2/web-clipper/devices/{id}
|
||||
GET /extensions (HTML download page)
|
||||
|
||||
Auth: session cookie OR Bearer api_token OR Bearer extension device token.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.db import get_conn
|
||||
from app.services.web_clipper import (
|
||||
MAX_CLIP_BYTES,
|
||||
_check_rate_limit,
|
||||
create_page_from_clip,
|
||||
list_devices,
|
||||
log_clip,
|
||||
register_device,
|
||||
revoke_device,
|
||||
sanitize_html,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["web-clipper"])
|
||||
api_router = APIRouter(prefix="/api/v2/web-clipper", tags=["web-clipper"])
|
||||
|
||||
|
||||
def _hash(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def _user_from_request(request: Request) -> dict | None:
|
||||
# 1) session cookie
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if user:
|
||||
return user
|
||||
# 2) Authorization Bearer
|
||||
auth = request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if auth.lower().startswith("bearer "):
|
||||
token = auth[7:].strip()
|
||||
if not token:
|
||||
return None
|
||||
th = _hash(token)
|
||||
with get_conn() as conn:
|
||||
# api_tokens (Settings → API tokens)
|
||||
row = conn.execute(
|
||||
"SELECT user_id FROM api_tokens WHERE token_hash=? AND revoked=0", (th,)
|
||||
).fetchone()
|
||||
if row:
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
|
||||
if u:
|
||||
return dict(u)
|
||||
# extension_devices
|
||||
row = conn.execute(
|
||||
"SELECT user_id FROM extension_devices WHERE token_hash=? AND revoked=0", (th,)
|
||||
).fetchone()
|
||||
if row:
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
|
||||
if u:
|
||||
return dict(u)
|
||||
# legacy user_tokens
|
||||
row = conn.execute("SELECT gitea_user_id FROM user_tokens WHERE gitea_token=?", (token,)).fetchone()
|
||||
if row:
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["gitea_user_id"],)).fetchone()
|
||||
if u:
|
||||
return dict(u)
|
||||
return None
|
||||
|
||||
|
||||
def _require_user(request: Request) -> dict:
|
||||
user = _user_from_request(request)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return user
|
||||
|
||||
|
||||
# ── API: status ──
|
||||
|
||||
@api_router.get("/status")
|
||||
async def clipper_status(request: Request):
|
||||
user = _user_from_request(request)
|
||||
if not user:
|
||||
return {"authenticated": False}
|
||||
with get_conn() as conn:
|
||||
dev_cnt = conn.execute("SELECT COUNT(*) FROM extension_devices WHERE user_id=? AND revoked=0", (user["id"],)).fetchone()[0]
|
||||
clip_cnt = conn.execute("SELECT COUNT(*) FROM extension_clips WHERE user_id=?", (user["id"],)).fetchone()[0]
|
||||
return {"authenticated": True, "user": {"id": user["id"], "login": user.get("login")}, "devices": dev_cnt, "clips": clip_cnt}
|
||||
|
||||
|
||||
# ── API: auth verify / device registration ──
|
||||
|
||||
@api_router.post("/auth/verify")
|
||||
async def auth_verify(request: Request):
|
||||
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()
|
||||
if not device_id:
|
||||
raise HTTPException(status_code=400, detail="device_id required")
|
||||
if len(device_id) > 128:
|
||||
raise HTTPException(status_code=400, detail="device_id too long")
|
||||
try:
|
||||
res = register_device(user["id"], device_id, device_name, extension_name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from None
|
||||
if res["existing"]:
|
||||
return {"status": "ok", "device_id": device_id, "existing": True, "message": "Device already registered"}
|
||||
return {"status": "ok", "device_id": device_id, "token": res["token"], "note": "Copy token now — shown once. Use as Authorization: Bearer <token>"}
|
||||
|
||||
|
||||
@api_router.post("/clip")
|
||||
async def clip_page(request: Request):
|
||||
user = _require_user(request)
|
||||
# Enforce max body size early (10 MB)
|
||||
clen = request.headers.get("content-length")
|
||||
if clen:
|
||||
try:
|
||||
if int(clen) > MAX_CLIP_BYTES + 1024:
|
||||
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"
|
||||
# Rate limit 50/hour per device
|
||||
if not _check_rate_limit(f"{user['id']}:{device_id}"):
|
||||
raise HTTPException(status_code=429, detail="Rate limit: max 50 clips/hour per device")
|
||||
|
||||
url = (body.get("url") or body.get("source_url") or "").strip()
|
||||
title = (body.get("title") or "").strip()
|
||||
content = body.get("content") or body.get("html") or ""
|
||||
clip_type = (body.get("content_type") or body.get("clip_type") or "article").strip().lower()
|
||||
if clip_type not in ("article", "selection", "bookmark", "screenshot"):
|
||||
clip_type = "article"
|
||||
|
||||
if not url and not title and not content:
|
||||
raise HTTPException(status_code=400, detail="url, title or content required")
|
||||
|
||||
# Validate url if present
|
||||
if url:
|
||||
if not (url.startswith("http://") or url.startswith("https://")):
|
||||
# allow bare domain? reject javascript:
|
||||
if url.lower().startswith("javascript:") or url.lower().startswith("data:"):
|
||||
raise HTTPException(status_code=400, detail="Invalid URL")
|
||||
|
||||
# Cap content bytes
|
||||
if content and len(content.encode("utf-8")) > MAX_CLIP_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Content too large (max 10 MB)")
|
||||
|
||||
# Sanitize HTML content if present
|
||||
if content and "<" in content:
|
||||
# sanitize but keep structure for blocks converter
|
||||
content = sanitize_html(content)[: MAX_CLIP_BYTES]
|
||||
|
||||
# Prepare payload for service
|
||||
_img_b64 = body.get("image_base64") or body.get("screenshot") or ""
|
||||
if not _img_b64 and body.get("images"):
|
||||
try:
|
||||
_imgs = body.get("images")
|
||||
if isinstance(_imgs, list) and _imgs:
|
||||
_img_b64 = _imgs[0].get("base64") or _imgs[0].get("src") or ""
|
||||
except Exception:
|
||||
pass
|
||||
clip_data = {
|
||||
"url": url,
|
||||
"title": title[:200],
|
||||
"content": content,
|
||||
"content_type": clip_type,
|
||||
"selection_html": body.get("selection_html") or body.get("selection") or "",
|
||||
"image_base64": _img_b64,
|
||||
"tags": body.get("tags") or [],
|
||||
"target_workspace_id": body.get("target_workspace_id") or body.get("workspace_id"),
|
||||
"target_page_id": body.get("target_page_id") or body.get("parent_page_id"),
|
||||
"metadata": body.get("metadata") or {},
|
||||
}
|
||||
|
||||
try:
|
||||
result = create_page_from_clip(clip_data, user["id"])
|
||||
except Exception as e:
|
||||
logger.exception("clip creation failed: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Failed to create page") from None
|
||||
|
||||
# Log clip
|
||||
try:
|
||||
log_clip(user["id"], device_id, clip_type, url, result["page_id"], result["workspace_id"], result["title"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"status": "ok", "page_id": result["page_id"], "title": result["title"], "workspace_id": result["workspace_id"], "url": f"/pages/{result['page_id']}"}
|
||||
|
||||
|
||||
@api_router.get("/devices")
|
||||
async 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):
|
||||
user = _require_user(request)
|
||||
ok = revoke_device(user["id"], device_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
return {"status": "revoked"}
|
||||
|
||||
|
||||
# ── HTML: /extensions download page ──
|
||||
|
||||
@router.get("/extensions", response_class=HTMLResponse)
|
||||
async def extensions_page(request: Request):
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
from app.routers.dashboard import _sidebar_data
|
||||
|
||||
env = Environment(loader=FileSystemLoader("app/templates"))
|
||||
try:
|
||||
sidebar = _sidebar_data(request, [])
|
||||
except Exception:
|
||||
sidebar = {}
|
||||
# Simple standalone page reusing base.html
|
||||
block_tpl = env.from_string(
|
||||
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
|
||||
)
|
||||
user = _user_from_request(request)
|
||||
# Count for auth user
|
||||
devices = []
|
||||
clips = 0
|
||||
if user:
|
||||
try:
|
||||
devices = list_devices(user["id"])
|
||||
clips = sum(d.get("clips_count", 0) for d in devices)
|
||||
except Exception:
|
||||
pass
|
||||
content_html = f"""
|
||||
<style>
|
||||
.ext-page{{max-width:900px;margin:0 auto;padding:32px 24px 80px;}}
|
||||
.ext-hero{{text-align:center;padding:28px 0 8px;}}
|
||||
.ext-hero h1{{font-size:30px;font-weight:800;margin:0 0 6px;}}
|
||||
.ext-hero p{{color:var(--text-dim);font-size:14px;max-width:560px;margin:0 auto;line-height:1.6;}}
|
||||
.ext-grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:14px;margin:28px 0;}}
|
||||
.ext-card{{border:1px solid var(--border);border-radius:12px;padding:18px;background:var(--bg-card);}}
|
||||
.ext-card h3{{font-size:15px;margin:0 0 6px;display:flex;align-items:center;gap:8px;}}
|
||||
.ext-card p{{font-size:12.5px;color:var(--text-dim);line-height:1.5;margin:0 0 10px;}}
|
||||
.ext-card a{{font-size:13px;color:var(--accent);text-decoration:none;}}
|
||||
.ext-card a:hover{{text-decoration:underline;}}
|
||||
.ext-section{{margin:28px 0;}}
|
||||
.ext-section h2{{font-size:18px;font-weight:700;margin:0 0 10px;}}
|
||||
.ext-steps{{counter-reset:step;list-style:none;padding:0;margin:0;}}
|
||||
.ext-steps li{{display:flex;gap:12px;padding:10px 0;border-bottom:1px solid var(--border);font-size:13px;color:var(--text-dim);}}
|
||||
.ext-steps li::before{{counter-increment:step;content:counter(step);flex:0 0 26px;height:26px;display:flex;align-items:center;justify-content:center;background:var(--accent);color:#fff;border-radius:50%;font-size:12px;font-weight:600;}}
|
||||
.ext-dev-list{{margin-top:12px;}}
|
||||
.ext-dev-item{{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;border:1px solid var(--border);border-radius:8px;margin-bottom:6px;background:var(--bg-tertiary);}}
|
||||
.ext-badge{{font-size:10px;padding:2px 8px;border-radius:99px;background:rgba(46,160,67,.14);color:#2ea043;font-weight:600;}}
|
||||
</style>
|
||||
<div class="ext-page">
|
||||
<div class="ext-hero">
|
||||
<h1>🧩 FlowDeck Web Clipper</h1>
|
||||
<p>Capture any web page — article, selection, bookmark or screenshot — directly into FlowDeck. Install the browser extension, connect it once, then clip in one click.</p>
|
||||
</div>
|
||||
<div class="ext-grid">
|
||||
<div class="ext-card">
|
||||
<h3>🟢 Chrome / Edge</h3>
|
||||
<p>Manifest V3 — Chrome 88+, Edge 88+.</p>
|
||||
<a href="/static/extension/flowdeck-clipper.zip" download>Download .zip</a> · <span style="font-size:11px;color:var(--text-dim);">load unpacked in chrome://extensions</span>
|
||||
</div>
|
||||
<div class="ext-card">
|
||||
<h3>🟠 Firefox</h3>
|
||||
<p>Firefox 109+ (Manifest V2 compat).</p>
|
||||
<a href="/static/extension/flowdeck-clipper.zip" download>Download .zip</a> · <span style="font-size:11px;color:var(--text-dim);">load temporary add-on</span>
|
||||
</div>
|
||||
<div class="ext-card">
|
||||
<h3>⌨️ Sans extension</h3>
|
||||
<p>API directe — <code>POST /api/v2/web-clipper/clip</code> avec Bearer token.</p>
|
||||
<a href="/help">Docs /help</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ext-section">
|
||||
<h2>How it works</h2>
|
||||
<ol class="ext-steps">
|
||||
<li>Install the extension (.zip) → enable in your browser.</li>
|
||||
<li>Open FlowDeck, go to <b>Settings → Extensions</b> and copy a Bearer token (or the clipper verifies via your session cookie).</li>
|
||||
<li>On any web page, click <b>📌 Clip to FlowDeck</b> (floating button, right-click selection, or extension popup).</li>
|
||||
<li>Choose type: Article (full), Selection, Bookmark or Screenshot — the page is created instantly in your workspace.</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="ext-section">
|
||||
<h2>Captures on this account</h2>
|
||||
<p style="font-size:12px;color:var(--text-dim);">{len(devices)} device(s) · {clips} clip(s) total</p>
|
||||
<div class="ext-dev-list">
|
||||
{"".join(f'<div class="ext-dev-item"><span><b>{d.get("device_name") or d.get("extension_name")}</b> <code style="font-size:11px;color:var(--text-dim);">{d.get("device_id")[:24]}</code></span><span><span class="ext-badge">{d.get("clips_count",0)} clips</span> <span style="font-size:11px;color:var(--text-dim);">{d.get("last_clip_at") or ""}</span></span></div>' for d in devices[:10]) or '<p style="font-size:13px;color:var(--text-dim);">No devices yet — clip your first page from the extension to appear here.</p>'}
|
||||
</div>
|
||||
<p style="margin-top:10px;"><a href="/accounts/settings" style="font-size:13px;color:var(--accent);">Manage in Settings → Extensions</a></p>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
return HTMLResponse(block_tpl.render(**sidebar, request=request, page_title="Extensions", title_prefix="Extensions", page_icon="🧩", content_html=content_html))
|
||||
@@ -130,6 +130,10 @@ async def add_comment(request: Request, page_id: int):
|
||||
cur = conn.execute("INSERT INTO comments (page_id, user_id, body, parent_id) VALUES (?,?,?,?)",
|
||||
(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})
|
||||
except Exception:
|
||||
pass
|
||||
return {"id": cur.lastrowid, "status": "created"}
|
||||
|
||||
|
||||
@@ -139,11 +143,17 @@ async def update_comment(request: Request, comment_id: int):
|
||||
b = body.get("body")
|
||||
resolved = body.get("resolved")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT page_id, resolved FROM comments WHERE id=?", (comment_id,)).fetchone()
|
||||
if b is not None:
|
||||
conn.execute("UPDATE comments SET body=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (b, comment_id))
|
||||
if resolved is not None:
|
||||
conn.execute("UPDATE comments SET resolved=? WHERE id=?", (int(resolved), comment_id))
|
||||
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"]})
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "updated"}
|
||||
|
||||
|
||||
@@ -206,6 +216,10 @@ async def add_favorite(request: Request):
|
||||
(uid, page_id, collection_id),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid})
|
||||
except Exception:
|
||||
pass
|
||||
return {"status": "favorited"}
|
||||
|
||||
|
||||
@@ -449,6 +463,10 @@ async def create_sprint(request: Request, collection_id: int):
|
||||
(collection_id, name, start_date, end_date, goal, status, auto_complete),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name})
|
||||
except Exception:
|
||||
pass
|
||||
return {"id": cur.lastrowid, "name": name, "status": "created"}
|
||||
|
||||
|
||||
@@ -475,6 +493,10 @@ async def update_sprint(request: Request, collection_id: int, sid: int):
|
||||
(name, start_date, end_date, goal, status, auto_complete, sid),
|
||||
)
|
||||
conn.commit()
|
||||
try:
|
||||
await fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status})
|
||||
except Exception:
|
||||
pass
|
||||
return {"id": sid, "status": "updated"}
|
||||
|
||||
|
||||
|
||||
@@ -259,6 +259,17 @@ class AgentEngine:
|
||||
self._persist_message(conversation_id, "assistant", final_text,
|
||||
model=used_model, tokens=self._tokens)
|
||||
await self._autotitle(conversation_id, objective, final_text)
|
||||
# v6.4.0: emit agent.run.finished (outbound webhooks only).
|
||||
try:
|
||||
from app.services.webhook_outbound import fire_event as _fire_wh
|
||||
await _fire_wh("agent.run.finished", {
|
||||
"conversation_id": conversation_id,
|
||||
"objective": objective[:500],
|
||||
"model": used_model,
|
||||
"tokens": self._tokens,
|
||||
})
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("agent.run.finished webhook dispatch failed")
|
||||
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("AgentEngine run failed")
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
"""FlowDeck — helpers for API v2 (v6.3.0).
|
||||
|
||||
Pagination, ISO-8601, RFC7807 errors, hierarchical scopes, Bearer auth.
|
||||
No duplication: thin wrappers over existing services.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
# ── ISO-8601 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def to_iso8601(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
# SQLite stores "YYYY-MM-DD HH:MM:SS" or with T; convert to UTC Z
|
||||
try:
|
||||
# try with seconds
|
||||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S.%f"):
|
||||
try:
|
||||
dt = datetime.strptime(value[:19], fmt[:8] if "." in value else fmt)
|
||||
# SQLite has no tz => assume UTC
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
return dt.isoformat().replace("+00:00", "Z")
|
||||
except ValueError:
|
||||
continue
|
||||
# fallback: if already ISO with T/Z, return as-is
|
||||
if "T" in value:
|
||||
return value
|
||||
return value
|
||||
except Exception:
|
||||
return value
|
||||
|
||||
def row_to_dict(row, *, iso_fields: tuple[str, ...] = ("created_at", "updated_at", "created_at_ts", "last_login", "joined_at", "accessed_at", "fired_at", "start_date", "end_date", "logged_at", "last_seen_at", "last_used_at", "verified_at", "last_login_at")) -> dict:
|
||||
if row is None:
|
||||
return {}
|
||||
d = dict(row)
|
||||
for k in list(d.keys()):
|
||||
if k in iso_fields and d[k]:
|
||||
iso = to_iso8601(str(d[k]))
|
||||
if iso:
|
||||
d[k] = iso
|
||||
# parse *_json columns
|
||||
if k.endswith("_json") and isinstance(d[k], str):
|
||||
try:
|
||||
d[k] = json.loads(d[k] or "{}" if d[k].strip().startswith("{") or d[k].strip().startswith("[") else d[k])
|
||||
except Exception:
|
||||
pass
|
||||
return d
|
||||
|
||||
# ── Pagination ────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_pagination(request: Request, default_limit: int = 30, max_limit: int = 100) -> tuple[int, int]:
|
||||
try:
|
||||
limit = int(request.query_params.get("limit", str(default_limit)))
|
||||
except ValueError:
|
||||
limit = default_limit
|
||||
try:
|
||||
offset = int(request.query_params.get("offset", "0"))
|
||||
except ValueError:
|
||||
offset = 0
|
||||
limit = max(1, min(limit, max_limit))
|
||||
offset = max(0, offset)
|
||||
return limit, offset
|
||||
|
||||
def paginate_headers(total: int) -> dict[str, str]:
|
||||
return {"X-Total-Count": str(total)}
|
||||
|
||||
# ── Scopes (hierarchical: read < write < admin) ──────────────────────────
|
||||
|
||||
SCOPE_RANK = {"read": 1, "write": 2, "admin": 3}
|
||||
VALID_SCOPES = set(SCOPE_RANK.keys())
|
||||
|
||||
def normalize_scopes(raw: str | None) -> set[str]:
|
||||
if not raw:
|
||||
return set()
|
||||
parts = [p.strip().lower() for p in raw.split(",") if p.strip()]
|
||||
return {p for p in parts if p in VALID_SCOPES}
|
||||
|
||||
def has_scope(token_scopes: str | None, required: str) -> bool:
|
||||
req_rank = SCOPE_RANK.get(required, 99)
|
||||
# token with higher rank satisfies lower requirement
|
||||
# admin => write => read
|
||||
token_set = normalize_scopes(token_scopes)
|
||||
if not token_set:
|
||||
return False
|
||||
# effective rank = max rank among token scopes
|
||||
eff = max((SCOPE_RANK.get(s, 0) for s in token_set), default=0)
|
||||
return eff >= req_rank
|
||||
|
||||
def validate_scopes_input(scopes_raw: str | None) -> str:
|
||||
if not scopes_raw:
|
||||
return "read"
|
||||
parts = [p.strip().lower() for p in scopes_raw.split(",") if p.strip()]
|
||||
for p in parts:
|
||||
if p not in VALID_SCOPES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid scope: {p}. Valid: read, write, admin")
|
||||
if not parts:
|
||||
return "read"
|
||||
# dedup preserve order
|
||||
seen = []
|
||||
for p in parts:
|
||||
if p not in seen:
|
||||
seen.append(p)
|
||||
return ",".join(seen)
|
||||
|
||||
# ── Bearer auth (unified) ─────────────────────────────────────────────────
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
def resolve_bearer_token(token: str) -> dict | None:
|
||||
"""Resolve Bearer token to user dict. Returns None if invalid/expired/revoked.
|
||||
Supports api_tokens (hashed), extension_devices (hashed), and legacy user_tokens (plain).
|
||||
"""
|
||||
if not token:
|
||||
return None
|
||||
# dev-only fallback
|
||||
if token == "fd-public-key":
|
||||
if not settings.public_api_insecure_ok:
|
||||
return None
|
||||
# return a synthetic admin-like user? Use first admin or id 1
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE is_admin=1 ORDER BY id LIMIT 1").fetchone()
|
||||
if row:
|
||||
d = dict(row)
|
||||
d["_token_id"] = None
|
||||
d["_token_scopes"] = "read,write,admin"
|
||||
d["_token_hash"] = None
|
||||
return d
|
||||
row = conn.execute("SELECT id, login, full_name, email, is_admin FROM users ORDER BY id LIMIT 1").fetchone()
|
||||
if row:
|
||||
d = dict(row)
|
||||
d["_token_id"] = None
|
||||
d["_token_scopes"] = "read,write,admin"
|
||||
d["_token_hash"] = None
|
||||
return d
|
||||
return None
|
||||
th = _hash_token(token)
|
||||
with get_conn() as conn:
|
||||
# 1) api_tokens
|
||||
row = conn.execute("SELECT id, user_id, scopes, expires_at, revoked FROM api_tokens WHERE token_hash=?", (th,)).fetchone()
|
||||
if row:
|
||||
if row["revoked"]:
|
||||
return None
|
||||
exp = row["expires_at"]
|
||||
if exp:
|
||||
try:
|
||||
# compare as timestamp; SQLite format "YYYY-MM-DD HH:MM:SS"
|
||||
# parse to epoch
|
||||
dt = datetime.fromisoformat(str(exp).replace("Z", "+00:00")) if "T" in str(exp) else datetime.strptime(str(exp)[:19], "%Y-%m-%d %H:%M:%S")
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=UTC)
|
||||
if dt.timestamp() < time.time():
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
|
||||
if u:
|
||||
d = dict(u)
|
||||
d["_token_id"] = row["id"]
|
||||
d["_token_scopes"] = row["scopes"] or "read,write"
|
||||
d["_token_hash"] = th
|
||||
# touch last_used_at best-effort
|
||||
try:
|
||||
conn.execute("UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
return d
|
||||
# 2) extension_devices
|
||||
row = conn.execute("SELECT user_id, scopes FROM extension_devices WHERE token_hash=? AND revoked=0", (th,)).fetchone()
|
||||
if row:
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["user_id"],)).fetchone()
|
||||
if u:
|
||||
d = dict(u)
|
||||
d["_token_id"] = None
|
||||
d["_token_scopes"] = row["scopes"] or "read,write"
|
||||
d["_token_hash"] = th
|
||||
return d
|
||||
# 3) legacy user_tokens (plain storage)
|
||||
row = conn.execute("SELECT gitea_user_id FROM user_tokens WHERE gitea_token=?", (token,)).fetchone()
|
||||
if row:
|
||||
u = conn.execute("SELECT id, login, full_name, email, is_admin FROM users WHERE id=?", (row["gitea_user_id"],)).fetchone()
|
||||
if u:
|
||||
d = dict(u)
|
||||
d["_token_id"] = None
|
||||
d["_token_scopes"] = "read,write"
|
||||
d["_token_hash"] = th
|
||||
return d
|
||||
return None
|
||||
|
||||
def get_bearer_user(request: Request, authorization: str | None = Header(default=None)) -> dict:
|
||||
# Prefer explicit Authorization header, fallback to lowercase
|
||||
auth = authorization or request.headers.get("authorization") or request.headers.get("Authorization") or ""
|
||||
if not auth or not auth.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=401, detail="API token required. Use Authorization: Bearer <token>")
|
||||
token = auth[7:].strip()
|
||||
user = resolve_bearer_token(token)
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired API token")
|
||||
return user
|
||||
|
||||
def require_scope(required: str):
|
||||
def _dep(request: Request, authorization: str | None = Header(default=None)) -> dict:
|
||||
user = get_bearer_user(request, authorization)
|
||||
scopes = user.get("_token_scopes") or "read"
|
||||
if not has_scope(scopes, required):
|
||||
raise HTTPException(status_code=403, detail=f"Insufficient scope. Required: {required}, token scopes: {scopes}")
|
||||
return user
|
||||
return _dep
|
||||
|
||||
# ── RFC 7807 ──────────────────────────────────────────────────────────────
|
||||
|
||||
def problem_response(request: Request, exc: HTTPException) -> JSONResponse:
|
||||
title_map = {
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
403: "Forbidden",
|
||||
404: "Not Found",
|
||||
409: "Conflict",
|
||||
422: "Unprocessable Entity",
|
||||
429: "Too Many Requests",
|
||||
500: "Internal Server Error",
|
||||
}
|
||||
status = exc.status_code
|
||||
detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
|
||||
body = {
|
||||
"type": f"https://flowdeck/api/errors/{status}",
|
||||
"title": title_map.get(status, "Error"),
|
||||
"status": status,
|
||||
"detail": detail,
|
||||
"instance": str(request.url.path),
|
||||
}
|
||||
return JSONResponse(status_code=status, content=body, media_type="application/problem+json")
|
||||
|
||||
# ── Audit ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def audit_log(user: dict, action: str, resource_type: str = "", resource_id: str | int = "", detail: str = "", request: Request | None = None) -> None:
|
||||
try:
|
||||
ip = ""
|
||||
if request and request.client:
|
||||
ip = request.client.host or ""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO api_audit_log (user_id, token_id, action, resource_type, resource_id, ip_address, detail) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(user.get("id"), user.get("_token_id"), action, resource_type, str(resource_id), ip, detail[:1000]),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Rate limit per token (in-memory) ─────────────────────────────────────
|
||||
|
||||
_v2_rate_store: dict[str, tuple[float, int]] = {}
|
||||
def check_v2_rate_limit(token_hash: str | None, ip: str) -> bool:
|
||||
"""Return True if allowed, False if 429. Uses api_v2_rate_limit_per_token."""
|
||||
key = token_hash or f"ip:{ip}"
|
||||
now = time.time()
|
||||
window = 60.0
|
||||
max_req = settings.api_v2_rate_limit_per_token
|
||||
start, count = _v2_rate_store.get(key, (now, 0))
|
||||
if now - start > window:
|
||||
_v2_rate_store[key] = (now, 1)
|
||||
return True
|
||||
if count >= max_req:
|
||||
return False
|
||||
_v2_rate_store[key] = (start, count + 1)
|
||||
return True
|
||||
|
||||
# ── Idempotency ───────────────────────────────────────────────────────────
|
||||
|
||||
def check_idempotency(request: Request, user_id: int) -> dict | None:
|
||||
key = request.headers.get("Idempotency-Key") or request.headers.get("idempotency-key")
|
||||
if not key:
|
||||
return None
|
||||
key = key.strip()[:200]
|
||||
if not key:
|
||||
return None
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT response_json, status_code FROM idempotency_keys WHERE key=? AND user_id=?", (key, user_id)).fetchone()
|
||||
if row:
|
||||
try:
|
||||
data = json.loads(row["response_json"])
|
||||
return {"data": data, "status": row["status_code"], "key": key}
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
def store_idempotency(key: str, user_id: int, data: Any, status_code: int = 200) -> None:
|
||||
if not key:
|
||||
return
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO idempotency_keys (key, user_id, response_json, status_code) VALUES (?, ?, ?, ?)",
|
||||
(key.strip()[:200], user_id, json.dumps(data), status_code),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -283,6 +283,20 @@ async def run_automation(automation_id: int, trigger_source: str, context: dict)
|
||||
detail = "; ".join(results)
|
||||
_save_run(automation_id, trigger_source, "fired", detail,
|
||||
ctx.get("collection_id"), ctx.get("page_id"))
|
||||
# v6.4.0: emit automation.fired (goes through fire_event → outbound
|
||||
# webhooks, but NOT back through automations to avoid recursion).
|
||||
try:
|
||||
from app.services.webhook_outbound import fire_event as _fire_wh
|
||||
await _fire_wh("automation.fired", {
|
||||
"automation_id": automation_id,
|
||||
"name": auto["name"],
|
||||
"trigger": trigger_source,
|
||||
"collection_id": ctx.get("collection_id"),
|
||||
"page_id": ctx.get("page_id"),
|
||||
"detail": detail,
|
||||
})
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("automation.fired webhook dispatch failed")
|
||||
return {"status": "fired", "detail": detail}
|
||||
except Exception as exc: # noqa: BLE001 — record every failure in history
|
||||
logger.warning("Automation %s failed: %s", automation_id, exc)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""FlowDeck — Built-in page (block) templates (v5.12.0).
|
||||
|
||||
Global page templates used by the « + New page » picker. Built-ins live here
|
||||
(code, versioned); user templates live in the ``page_global_templates``
|
||||
table. Block shapes match the editor's storage format (see
|
||||
``app/routers/board.py::save_page_blocks``) — ids are assigned client-side.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
|
||||
def _b(btype: str, content: str = "", **extra) -> dict:
|
||||
out = {"type": btype, "content": content}
|
||||
out.update(extra)
|
||||
return out
|
||||
|
||||
|
||||
BUILTIN_TEMPLATES: dict[str, dict] = {
|
||||
"empty": {
|
||||
"name": "Empty",
|
||||
"icon": "📄",
|
||||
"description": "A blank page.",
|
||||
"blocks": [_b("paragraph")],
|
||||
},
|
||||
"meeting_notes": {
|
||||
"name": "Meeting notes",
|
||||
"icon": "🗒️",
|
||||
"description": "Attendees, agenda, notes, action items.",
|
||||
"blocks": [
|
||||
_b("heading_1", "Meeting notes"),
|
||||
_b("callout", "Date: · Time: · Attendees: ", icon="📅"),
|
||||
_b("heading_2", "Agenda"),
|
||||
_b("bulleted_list", "Topic 1"),
|
||||
_b("bulleted_list", "Topic 2"),
|
||||
_b("heading_2", "Notes"),
|
||||
_b("paragraph"),
|
||||
_b("heading_2", "Decisions"),
|
||||
_b("bulleted_list"),
|
||||
_b("heading_2", "Action items"),
|
||||
_b("to_do", "Owner — due date", checked=False),
|
||||
_b("to_do", "", checked=False),
|
||||
],
|
||||
},
|
||||
"weekly_report": {
|
||||
"name": "Weekly report",
|
||||
"icon": "📊",
|
||||
"description": "Wins, in progress, blockers, next week.",
|
||||
"blocks": [
|
||||
_b("heading_1", "Week of [[fddate:2026-01-05]]"),
|
||||
_b("heading_2", "🎉 Wins"),
|
||||
_b("bulleted_list"),
|
||||
_b("heading_2", "🚧 In progress"),
|
||||
_b("bulleted_list"),
|
||||
_b("heading_2", "⛔ Blockers"),
|
||||
_b("bulleted_list"),
|
||||
_b("heading_2", "🗓️ Next week"),
|
||||
_b("to_do", "", checked=False),
|
||||
],
|
||||
},
|
||||
"todo_list": {
|
||||
"name": "To-do list",
|
||||
"icon": "✅",
|
||||
"description": "A simple checklist.",
|
||||
"blocks": [
|
||||
_b("heading_1", "To-do"),
|
||||
_b("to_do", "", checked=False),
|
||||
_b("to_do", "", checked=False),
|
||||
_b("to_do", "", checked=False),
|
||||
],
|
||||
},
|
||||
"project_doc": {
|
||||
"name": "Project doc",
|
||||
"icon": "🚀",
|
||||
"description": "Goals, status, team, links.",
|
||||
"blocks": [
|
||||
_b("heading_1", "Project title"),
|
||||
_b("callout", "One-line description of the project.", icon="💡"),
|
||||
_b("heading_2", "Goals"),
|
||||
_b("numbered_list"),
|
||||
_b("heading_2", "Status"),
|
||||
_b("toggle", "This week", expanded=True, children=[_b("paragraph")]),
|
||||
_b("heading_2", "Team"),
|
||||
_b("bulleted_list"),
|
||||
_b("heading_2", "Resources"),
|
||||
_b("bulleted_list"),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def template_list() -> list[dict]:
|
||||
"""Public shape of the built-in templates for the picker UI."""
|
||||
return [
|
||||
{"key": key, "name": t["name"], "icon": t["icon"],
|
||||
"description": t["description"], "builtin": True}
|
||||
for key, t in BUILTIN_TEMPLATES.items()
|
||||
]
|
||||
|
||||
|
||||
def blocks_json_for(key: str) -> str | None:
|
||||
t = BUILTIN_TEMPLATES.get(key)
|
||||
return json.dumps(t["blocks"]) if t else None
|
||||
@@ -95,7 +95,7 @@ SEED_TEMPLATES: list[dict] = [
|
||||
{
|
||||
"name": "Meeting notes",
|
||||
"icon": "🗒️",
|
||||
"description": "Notes de réunion avec participants et décisions.",
|
||||
"description": "Notes de réunion avec participants, agenda, notes et actions.",
|
||||
"schema": [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Date", "type": "date"},
|
||||
@@ -104,6 +104,8 @@ SEED_TEMPLATES: list[dict] = [
|
||||
{"name": "Scheduled", "color": "gray"},
|
||||
{"name": "Done", "color": "green"},
|
||||
]},
|
||||
{"name": "Agenda", "type": "text"},
|
||||
{"name": "Notes", "type": "text"},
|
||||
{"name": "Action items", "type": "multi_select"},
|
||||
],
|
||||
},
|
||||
|
||||
+146
-62
@@ -1,9 +1,10 @@
|
||||
"""FlowDeck — Media embeds (v5.5.0): provider detection + iframe rewriting.
|
||||
|
||||
Maps a raw http(s) URL to a provider-specific embed URL so that one generic
|
||||
``embed`` block can render YouTube, Vimeo, Figma, Google Maps, Loom,
|
||||
CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter, Pinterest, Office
|
||||
docs… exactly like Notion's universal embed.
|
||||
``embed`` block can render YouTube, Vimeo, Figma, Google Maps, Google
|
||||
Docs/Sheets/Slides, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch,
|
||||
X/Twitter, Pinterest, Microsoft Office docs… exactly like Notion's universal
|
||||
embed.
|
||||
|
||||
Unknown/showable URLs (PDF, images, direct video/audio files, plain http)
|
||||
fall back to a plain iframe so the link is still visible inline.
|
||||
@@ -11,11 +12,7 @@ fall back to a plain iframe so the link is still visible inline.
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
_PROVIDERS = (
|
||||
# (host pattern, extra path check, to_embed(url, path, query) -> str | None)
|
||||
)
|
||||
from urllib.parse import parse_qs, quote, urlparse
|
||||
|
||||
|
||||
def _q(params, key):
|
||||
@@ -23,9 +20,21 @@ def _q(params, key):
|
||||
return vals[0] if vals else ""
|
||||
|
||||
|
||||
def _embed_youtube(url: str, path: str, params) -> str | None:
|
||||
m = re.search(r"((?:v|shorts|embed)/|be/)([A-Za-z0-9_-]{6,20})", path)
|
||||
vid = m.group(2) if m else _q(params, "v")
|
||||
def _host_matches(netloc: str, host: str) -> bool:
|
||||
"""True when ``netloc`` is ``host`` or one of its subdomains."""
|
||||
netloc = (netloc or "").lower().split(":")[0]
|
||||
host = host.lower()
|
||||
return netloc == host or netloc.endswith("." + host)
|
||||
|
||||
|
||||
def _embed_youtube(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"/(?:v|shorts|embed|live)/([A-Za-z0-9_-]{6,20})", path)
|
||||
vid = m.group(1) if m else _q(params, "v")
|
||||
if not vid:
|
||||
# youtu.be/<id> (short link) — the id is the first path segment.
|
||||
seg = path.strip("/").split("/")[0]
|
||||
if re.fullmatch(r"[A-Za-z0-9_-]{6,20}", seg or ""):
|
||||
vid = seg
|
||||
if not vid:
|
||||
return None
|
||||
start = _q(params, "t") or _q(params, "start")
|
||||
@@ -33,98 +42,122 @@ def _embed_youtube(url: str, path: str, params) -> str | None:
|
||||
return f"https://www.youtube.com/embed/{vid}{frag}"
|
||||
|
||||
|
||||
def _embed_vimeo(url: str, path: str, params) -> str | None:
|
||||
def _embed_vimeo(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"/(\d{6,12})", path)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://player.vimeo.com/video/{m.group(1)}"
|
||||
|
||||
|
||||
def _embed_loom(url: str, path: str, params) -> str | None:
|
||||
m = re.search(r"/(embed/)?([0-9a-f]{32})", path)
|
||||
def _embed_loom(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"/(?:embed/|share/)?([0-9a-f]{32})", path)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://www.loom.com/embed/{m.group(2)}"
|
||||
return f"https://www.loom.com/embed/{m.group(1)}"
|
||||
|
||||
|
||||
def _embed_figma(url: str, path: str, params) -> str | None:
|
||||
if "figma.com/file/" not in url and "figma.com/proto/" not in url:
|
||||
def _embed_figma(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
clean = url.split("?", 1)[0]
|
||||
if "figma.com/file/" not in clean and "figma.com/proto/" not in clean and "figma.com/design/" not in clean:
|
||||
return None
|
||||
m = re.search(r"(/[^/]+/[^?]+)", url.split("?", 1)[0])
|
||||
if not m:
|
||||
return None
|
||||
return "https://www.figma.com/embed?embed_host=flowdeck&url=" + url.split("?", 1)[0]
|
||||
return "https://www.figma.com/embed?embed_host=flowdeck&url=" + quote(clean, safe="")
|
||||
|
||||
|
||||
def _embed_map(url: str, path: str, params) -> str | None:
|
||||
def _embed_map(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
if "google.com/maps" not in url and "maps.app.goo.gl" not in url:
|
||||
return None
|
||||
return f"https://www.google.com/maps?output=embed&q={url}"
|
||||
return "https://maps.google.com/maps?q=" + quote(url, safe="") + "&output=embed"
|
||||
|
||||
|
||||
def _embed_codepen(url: str, path: str, params) -> str | None:
|
||||
def _embed_gdocs(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(
|
||||
r"docs\.google\.com/(document|spreadsheets|presentation|forms)/d/([A-Za-z0-9_-]+)", url
|
||||
)
|
||||
if not m:
|
||||
return None
|
||||
kind, doc_id = m.group(1), m.group(2)
|
||||
if kind == "forms":
|
||||
return f"https://docs.google.com/forms/d/{doc_id}/viewform?embedded=true"
|
||||
return f"https://docs.google.com/{kind}/d/{doc_id}/preview"
|
||||
|
||||
|
||||
def _embed_codepen(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"codepen\.io/([^/]+)/pen/([^/?#]+)", url)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://codepen.io/{m.group(1)}/embed/{m.group(2)}?default-tab=result"
|
||||
|
||||
|
||||
def _embed_miro(url: str, path: str, params) -> str | None:
|
||||
m = re.search(r"miro\.com/app/board/([^/?#]+)", url)
|
||||
def _embed_miro(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"miro\.com/app/(?:board|live-embed)/([^/?#]+)", url)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://miro.com/app/live-embed/{m.group(1)}"
|
||||
|
||||
|
||||
def _embed_spotify(url: str, path: str, params) -> str | None:
|
||||
def _embed_spotify(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
m = re.search(r"/(track|playlist|album|episode|show|artist)/([A-Za-z0-9]+)", url)
|
||||
if not m:
|
||||
return None
|
||||
return f"https://open.spotify.com/embed/{m.group(1)}/{m.group(2)}"
|
||||
|
||||
|
||||
def _embed_soundcloud(url: str, path: str, params) -> str | None:
|
||||
m = re.search(r"(?:soundcloud\.com/[^/]+/[^/?#]+)", url)
|
||||
if not m:
|
||||
def _embed_soundcloud(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
if "soundcloud.com" not in url:
|
||||
return None
|
||||
return f"https://w.soundcloud.com/player/?url={url}&color=%2300aaff"
|
||||
return "https://w.soundcloud.com/player/?url=" + quote(url, safe="") + "&color=%2300aaff"
|
||||
|
||||
|
||||
def _embed_twitch(url: str, path: str, params) -> str | None:
|
||||
def _embed_twitch(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
if "twitch.tv" not in url:
|
||||
return None
|
||||
parent = (ctx.get("parent") or "localhost").replace("https://", "").replace("http://", "").split("/")[0]
|
||||
video = re.search(r"twitch\.tv/videos/(\d+)", url)
|
||||
if video:
|
||||
return f"https://player.twitch.tv/?video={video.group(1)}&parent={parent}"
|
||||
m = re.search(r"twitch\.tv/([^/?#]+)", url)
|
||||
if not m:
|
||||
if not m or m.group(1) in ("videos", "directory"):
|
||||
return None
|
||||
return f"https://player.twitch.tv/?channel={m.group(1)}&parent="
|
||||
return f"https://player.twitch.tv/?channel={m.group(1)}&parent={parent}"
|
||||
|
||||
|
||||
def _embed_twitter(url: str, path: str, params) -> str | None:
|
||||
def _embed_twitter(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
if "twitter.com" not in url and "x.com" not in url:
|
||||
return None
|
||||
m = re.search(r"/([^/?#]+)/status/(\d+)", url)
|
||||
m = re.search(r"/status(?:es)?/(\d+)", url)
|
||||
if not m:
|
||||
return f"https://platform.twitter.com/embed/Tweet.html?url={url}"
|
||||
return f"https://platform.twitter.com/embed/Tweet.html?id={m.group(2)}"
|
||||
return f"https://platform.twitter.com/embed/Tweet.html?url={quote(url, safe='')}"
|
||||
return f"https://platform.twitter.com/embed/Tweet.html?id={m.group(1)}"
|
||||
|
||||
|
||||
def _embed_pinterest(url: str, path: str, params) -> str | None:
|
||||
def _embed_pinterest(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
if "pinterest" not in url:
|
||||
return None
|
||||
return f"https://pinterest.com/pin/embed?url={url}"
|
||||
return f"https://pinterest.com/pin/embed?url={quote(url, safe='')}"
|
||||
|
||||
|
||||
def _embed_files(url: str, path: str, params) -> str | None:
|
||||
def _embed_office(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
low = url.lower().split("?", 1)[0]
|
||||
if low.endswith((".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp")):
|
||||
return "https://view.officeapps.live.com/op/embed.aspx?src=" + quote(url, safe="")
|
||||
if "officeapps.live.com" in low or "sharepoint.com" in low or "1drv.ms" in low:
|
||||
return "https://view.officeapps.live.com/op/embed.aspx?src=" + quote(url, safe="")
|
||||
return None
|
||||
|
||||
|
||||
def _embed_files(url: str, path: str, params, ctx: dict) -> str | None:
|
||||
"""Direct media: PDF/images/videos/audio can live in a plain iframe."""
|
||||
return url
|
||||
|
||||
|
||||
# (host, handler) — order matters: more specific hosts first.
|
||||
_HANDLERS = (
|
||||
("youtube.com", _embed_youtube),
|
||||
("youtu.be", _embed_youtube),
|
||||
("vimeo.com", _embed_vimeo),
|
||||
("loom.com", _embed_loom),
|
||||
("figma.com", _embed_figma),
|
||||
("docs.google.com", _embed_gdocs),
|
||||
("google.com/maps", _embed_map),
|
||||
("maps.app.goo.gl", _embed_map),
|
||||
("codepen.io", _embed_codepen),
|
||||
@@ -135,27 +168,54 @@ _HANDLERS = (
|
||||
("twitch.tv", _embed_twitch),
|
||||
("twitter.com", _embed_twitter),
|
||||
("x.com", _embed_twitter),
|
||||
("pinterest", _embed_pinterest),
|
||||
("pinterest.", _embed_pinterest),
|
||||
("office.com", _embed_office),
|
||||
("officeapps.live.com", _embed_office),
|
||||
("sharepoint.com", _embed_office),
|
||||
("1drv.ms", _embed_office),
|
||||
)
|
||||
|
||||
|
||||
def embed_src(url: str) -> str | None:
|
||||
_SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*):")
|
||||
|
||||
|
||||
def _parse(url: str):
|
||||
raw = url.strip()
|
||||
if not raw:
|
||||
return None, None, None
|
||||
m = _SCHEME_RE.match(raw)
|
||||
if m:
|
||||
if m.group(1).lower() not in ("http", "https"):
|
||||
return None, None, None # mailto:, tel:, javascript:, data:…
|
||||
else:
|
||||
raw = "https://" + raw
|
||||
u = urlparse(raw)
|
||||
if u.scheme not in ("http", "https") or not u.netloc:
|
||||
return None, None, None
|
||||
host = u.hostname or ""
|
||||
if "." not in host and host != "localhost":
|
||||
return None, None, None # a bare word is not a URL
|
||||
return raw, u, parse_qs(u.query)
|
||||
|
||||
|
||||
def embed_src(url: str, *, parent: str = "") -> str | None:
|
||||
"""Return the embeddable iframe src for a URL, or None if it can't embed."""
|
||||
if not url:
|
||||
raw, u, params = _parse(url)
|
||||
if raw is None:
|
||||
return None
|
||||
url = url.strip()
|
||||
u = urlparse(url if url.startswith("http") else "https://" + url)
|
||||
if u.scheme not in ("http", "https"):
|
||||
return None
|
||||
(u.hostname or "").lower().replace("www.", "")
|
||||
ctx = {"parent": parent}
|
||||
netloc = (u.netloc or "").lower()
|
||||
params = parse_qs(u.query)
|
||||
# Google Maps share links encode the query in the path (…&q=/maps/…)
|
||||
encoded = url
|
||||
for needle, handler in _HANDLERS:
|
||||
if needle in netloc:
|
||||
return handler(encoded, u.path, params)
|
||||
return _embed_files(encoded, u.path, params)
|
||||
if "/" in needle or needle.endswith("."):
|
||||
if needle in raw.lower():
|
||||
return handler(raw, u.path, params, ctx)
|
||||
elif _host_matches(netloc, needle):
|
||||
return handler(raw, u.path, params, ctx)
|
||||
# Office documents hosted on arbitrary domains.
|
||||
office = _embed_office(raw, u.path, params, ctx)
|
||||
if office:
|
||||
return office
|
||||
return _embed_files(raw, u.path, params, ctx)
|
||||
|
||||
|
||||
_IMAGE_EXT = re.compile(r"\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$", re.I)
|
||||
@@ -165,12 +225,12 @@ _AUDIO_EXT = re.compile(r"\.(mp3|wav|ogg|oga|m4a|flac|aac)$", re.I)
|
||||
|
||||
|
||||
def inline_kind(url: str) -> str | None:
|
||||
"""Best inline renderer for a URL: 'iframe' | 'image' | 'pdf' | 'video' |
|
||||
"""Best inline renderer for a URL: 'iframe' | 'image' | 'pdf' | 'video'
|
||||
| 'audio'. Returns None when the URL should open in a new tab."""
|
||||
if not url:
|
||||
raw, u, _params = _parse(url)
|
||||
if raw is None:
|
||||
return None
|
||||
url = url.strip()
|
||||
path = urlparse(url).path
|
||||
path = u.path or ""
|
||||
if _IMAGE_EXT.search(path):
|
||||
return "image"
|
||||
if _PDF_EXT.search(path):
|
||||
@@ -179,9 +239,33 @@ def inline_kind(url: str) -> str | None:
|
||||
return "video"
|
||||
if _AUDIO_EXT.search(path):
|
||||
return "audio"
|
||||
if url.startswith(("http://", "https://")):
|
||||
return "iframe"
|
||||
return None
|
||||
return "iframe"
|
||||
|
||||
|
||||
def provider(url: str) -> str:
|
||||
"""Human-readable provider name for a URL (used by the editor)."""
|
||||
raw, u, _params = _parse(url)
|
||||
if raw is None:
|
||||
return ""
|
||||
netloc = (u.netloc or "").lower()
|
||||
for needle, _handler in _HANDLERS:
|
||||
if "/" in needle or needle.endswith("."):
|
||||
if needle in raw.lower():
|
||||
return needle.split(".")[0].rstrip(".")
|
||||
elif _host_matches(netloc, needle):
|
||||
name = needle.split(".")[0]
|
||||
return "youtube" if name == "youtu" else name
|
||||
return ""
|
||||
|
||||
|
||||
def resolve_embed(url: str, *, parent: str = "") -> dict:
|
||||
"""Resolve a URL to ``{src, kind, provider}`` for the generic embed block."""
|
||||
kind = inline_kind(url)
|
||||
return {
|
||||
"src": embed_src(url, parent=parent) or "",
|
||||
"kind": kind or "",
|
||||
"provider": provider(url),
|
||||
}
|
||||
|
||||
|
||||
def embed_html(src: str, *, height: int = 520) -> str:
|
||||
|
||||
+43
-16
@@ -390,6 +390,20 @@ def _md_to_blocks(md: str) -> list:
|
||||
blocks.append({"type": "divider", "content": ""})
|
||||
i += 1
|
||||
continue
|
||||
if re.match(r"^\s*[-*+]\s+\[[ xX]\]\s+", line):
|
||||
flush_para()
|
||||
while i < n:
|
||||
s = lines[i].strip()
|
||||
m2 = re.match(r"^[-*+]\s+\[([ xX])\]\s+(.*)$", s)
|
||||
if not m2:
|
||||
break
|
||||
blocks.append({
|
||||
"type": "to_do",
|
||||
"content": m2.group(2).strip(),
|
||||
"checked": m2.group(1).lower() == "x",
|
||||
})
|
||||
i += 1
|
||||
continue
|
||||
if re.match(r"^\s*[-*+]\s+", line):
|
||||
flush_para()
|
||||
while i < n:
|
||||
@@ -410,21 +424,6 @@ def _md_to_blocks(md: str) -> list:
|
||||
blocks.append({"type": "numbered_list", "content": m2.group(1).strip()})
|
||||
i += 1
|
||||
continue
|
||||
mtodo = re.match(r"^\s*[-*+]\s+\[([ xX])\]\s+(.*)$", stripped)
|
||||
if mtodo:
|
||||
flush_para()
|
||||
while i < n:
|
||||
s = lines[i].strip()
|
||||
m2 = re.match(r"^[-*+]\s+\[([ xX])\]\s+(.*)$", s)
|
||||
if not m2:
|
||||
break
|
||||
blocks.append({
|
||||
"type": "to_do",
|
||||
"content": m2.group(2).strip(),
|
||||
"checked": m2.group(1).lower() == "x",
|
||||
})
|
||||
i += 1
|
||||
continue
|
||||
mq = re.match(r"^>\s?(.*)$", stripped)
|
||||
if mq and line == stripped:
|
||||
flush_para()
|
||||
@@ -530,6 +529,20 @@ def blocks_to_markdown(blocks: list) -> str:
|
||||
out.append(f"[{url}]({url})" if url else "[embed]")
|
||||
elif t == "table":
|
||||
out.append(_table_to_markdown(b))
|
||||
elif t == "synced":
|
||||
synced_id = b.get("synced_id")
|
||||
if synced_id:
|
||||
try:
|
||||
from app.services.synced_blocks import get_synced_block
|
||||
sb = get_synced_block(synced_id)
|
||||
if sb and sb.get("content"):
|
||||
resolved = json.loads(sb["content"])
|
||||
if isinstance(resolved, list):
|
||||
out.append(blocks_to_markdown(resolved))
|
||||
else:
|
||||
out.append(str(resolved))
|
||||
except Exception:
|
||||
out.append(f"[Synced block {synced_id}]")
|
||||
else:
|
||||
out.append(c)
|
||||
return "\n\n".join(filter(None, out))
|
||||
@@ -665,7 +678,7 @@ def blocks_to_html(blocks: list) -> str:
|
||||
parts.append(f'<iframe src="{_text(url)}" style="width:100%;height:70vh;border:none;border-radius:8px;"></iframe>')
|
||||
elif url:
|
||||
from app.services.embeds import embed_src
|
||||
src = embed_src(url) or url
|
||||
src = b.get("embed_src") or embed_src(url) or url
|
||||
height = 520
|
||||
if b.get("height"):
|
||||
try:
|
||||
@@ -679,6 +692,20 @@ def blocks_to_html(blocks: list) -> str:
|
||||
)
|
||||
elif t == "table":
|
||||
parts.append(_table_to_html(b))
|
||||
elif t == "synced":
|
||||
synced_id = b.get("synced_id")
|
||||
if synced_id:
|
||||
try:
|
||||
from app.services.synced_blocks import get_synced_block
|
||||
sb = get_synced_block(synced_id)
|
||||
if sb and sb.get("content"):
|
||||
resolved = json.loads(sb["content"])
|
||||
if isinstance(resolved, list):
|
||||
parts.append(blocks_to_html(resolved))
|
||||
else:
|
||||
parts.append(f"<p>{_text(resolved)}</p>")
|
||||
except Exception:
|
||||
parts.append(f"<p>[Synced block {synced_id}]</p>")
|
||||
else:
|
||||
parts.append(f"<p>{c}</p>")
|
||||
return "\n".join(parts)
|
||||
|
||||
@@ -209,6 +209,60 @@ class GitHubAdapter(ForgeAdapter):
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# ── issues / labels / milestones ──
|
||||
|
||||
async def list_issues(self, owner: str, repo: str, state: str = "all") -> list[dict]:
|
||||
"""List issues (pull requests are filtered out)."""
|
||||
issues: list[dict] = []
|
||||
for page in range(1, 6):
|
||||
async with self._client() as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/issues",
|
||||
headers=self._headers,
|
||||
params={"state": state, "per_page": 100, "page": page},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
batch = resp.json()
|
||||
if not batch:
|
||||
break
|
||||
issues.extend(i for i in batch if "pull_request" not in i)
|
||||
if len(batch) < 100:
|
||||
break
|
||||
return issues
|
||||
|
||||
async def list_labels(self, owner: str, repo: str) -> list[dict]:
|
||||
async with self._client() as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/labels",
|
||||
headers=self._headers,
|
||||
params={"per_page": 100},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def list_milestones(self, owner: str, repo: str, state: str = "all") -> list[dict]:
|
||||
async with self._client() as client:
|
||||
resp = await client.get(
|
||||
f"{self._base}/repos/{owner}/{repo}/milestones",
|
||||
headers=self._headers,
|
||||
params={"state": state, "per_page": 100},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def list_repo_files(self, owner: str, repo: str, path: str = "") -> list[dict]:
|
||||
"""Flatten the repo tree into file entries (``path``, ``size``)."""
|
||||
tree = await self.get_repo_tree(owner, repo)
|
||||
prefix = path.strip("/")
|
||||
files = [
|
||||
{"path": item["path"], "size": item.get("size", 0)}
|
||||
for item in tree
|
||||
if item.get("type") == "blob" and item.get("path")
|
||||
]
|
||||
if prefix:
|
||||
files = [f for f in files if f["path"].startswith(prefix + "/") or f["path"] == prefix]
|
||||
return files
|
||||
|
||||
# ── token validation ──
|
||||
|
||||
async def validate_token(self) -> bool:
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""FlowDeck — importers package (v5.6.0).
|
||||
|
||||
Importing this package registers every built-in importer. Use
|
||||
:func:`parse_upload` to detect a source and :func:`run_import` to persist it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.importers import ( # noqa: F401 - registration side effects
|
||||
bookmarks,
|
||||
calendar,
|
||||
docx,
|
||||
html_notes,
|
||||
markdown,
|
||||
notion,
|
||||
obsidian,
|
||||
opml,
|
||||
outline,
|
||||
pdf,
|
||||
standard_notes,
|
||||
tabular,
|
||||
)
|
||||
from app.services.importers.base import ( # noqa: F401
|
||||
ImportAttachment,
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
all_importers,
|
||||
detect_importer,
|
||||
get_importer,
|
||||
list_sources,
|
||||
)
|
||||
from app.services.importers.jobs import ( # noqa: F401
|
||||
create_job,
|
||||
get_job,
|
||||
list_jobs,
|
||||
parse_upload,
|
||||
start_import_job,
|
||||
)
|
||||
from app.services.importers.pipeline import preview_result, resolve_relations, run_import # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
"ImportAttachment",
|
||||
"ImportPage",
|
||||
"ImportResult",
|
||||
"Importer",
|
||||
"all_importers",
|
||||
"detect_importer",
|
||||
"get_importer",
|
||||
"list_sources",
|
||||
"create_job",
|
||||
"get_job",
|
||||
"list_jobs",
|
||||
"parse_upload",
|
||||
"start_import_job",
|
||||
"preview_result",
|
||||
"run_import",
|
||||
"resolve_relations",
|
||||
]
|
||||
@@ -0,0 +1,106 @@
|
||||
"""FlowDeck — shared helpers for note importers (frontmatter, wikilinks)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
try: # PyYAML ships transitively via uvicorn[standard]
|
||||
import yaml
|
||||
except Exception: # pragma: no cover - fallback parser below
|
||||
yaml = None
|
||||
|
||||
|
||||
_FRONTMATTER_RE = re.compile(r"^\ufeff?---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
|
||||
_WIKILINK_RE = re.compile(r"(!?)\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|([^\]]+))?\]\]")
|
||||
|
||||
|
||||
def split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
|
||||
"""Split YAML frontmatter from the body. Returns ``(metadata, body)``."""
|
||||
m = _FRONTMATTER_RE.match(text)
|
||||
if not m:
|
||||
return {}, text
|
||||
raw = m.group(1)
|
||||
body = text[m.end():]
|
||||
if yaml is not None:
|
||||
try:
|
||||
meta = yaml.safe_load(raw)
|
||||
if isinstance(meta, dict):
|
||||
return meta, body
|
||||
except Exception: # noqa: BLE001 - fall back to the simple parser
|
||||
pass
|
||||
return _simple_yaml(raw), body
|
||||
|
||||
|
||||
def _simple_yaml(raw: str) -> dict[str, Any]:
|
||||
"""Minimal YAML subset parser (scalars, inline lists, block lists)."""
|
||||
meta: dict[str, Any] = {}
|
||||
current: str | None = None
|
||||
for line in raw.splitlines():
|
||||
if not line.strip() or line.lstrip().startswith("#"):
|
||||
continue
|
||||
if line.lstrip().startswith("- ") and current:
|
||||
meta.setdefault(current, [])
|
||||
if isinstance(meta[current], list):
|
||||
meta[current].append(_scalar(line.lstrip()[2:].strip()))
|
||||
continue
|
||||
if ":" in line:
|
||||
key, _, value = line.partition(":")
|
||||
key = key.strip()
|
||||
value = value.strip()
|
||||
current = key
|
||||
if not value:
|
||||
meta[key] = []
|
||||
elif value.startswith("[") and value.endswith("]"):
|
||||
inner = value[1:-1].strip()
|
||||
meta[key] = [_scalar(v.strip()) for v in inner.split(",") if v.strip()] if inner else []
|
||||
else:
|
||||
meta[key] = _scalar(value)
|
||||
return meta
|
||||
|
||||
|
||||
def _scalar(value: str) -> Any:
|
||||
v = value.strip().strip('"').strip("'")
|
||||
if v.lower() in ("true", "false"):
|
||||
return v.lower() == "true"
|
||||
if re.fullmatch(r"-?\d+", v):
|
||||
return int(v)
|
||||
if re.fullmatch(r"-?\d+\.\d+", v):
|
||||
return float(v)
|
||||
return v
|
||||
|
||||
|
||||
def convert_wikilinks(text: str, *, embeds: bool = True) -> str:
|
||||
"""Turn Obsidian/Logseq ``[[link]]`` into Markdown links and ``![[img]]``
|
||||
into Markdown images so the block converter can render them."""
|
||||
|
||||
def repl(m: re.Match) -> str:
|
||||
bang, target, alias = m.group(1), m.group(2).strip(), m.group(3)
|
||||
label = (alias or target).strip()
|
||||
if bang == "!":
|
||||
return f"" if embeds else label
|
||||
return f"[{label}]({target})"
|
||||
|
||||
return _WIKILINK_RE.sub(repl, text)
|
||||
|
||||
|
||||
def normalize_title(value: Any) -> str:
|
||||
return str(value).strip() if value is not None else ""
|
||||
|
||||
|
||||
def coerce_tags(value: Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [str(v).strip().lstrip("#") for v in value if str(v).strip()]
|
||||
if isinstance(value, str):
|
||||
parts = re.split(r"[,\s]+", value)
|
||||
return [p.strip().lstrip("#") for p in parts if p.strip()]
|
||||
return [str(value)]
|
||||
|
||||
|
||||
def strip_markdown(text: str) -> str:
|
||||
text = re.sub(r"`{1,3}([^`]*)`{1,3}", r"\1", text)
|
||||
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text)
|
||||
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
|
||||
text = re.sub(r"[*_~#>]+", "", text)
|
||||
return text.strip()
|
||||
@@ -0,0 +1,147 @@
|
||||
"""FlowDeck — unified import framework (v5.6.0, Phase 0).
|
||||
|
||||
Defines the normalized data model shared by every importer and the registry
|
||||
used to auto-detect a source. An :class:`Importer` turns an uploaded file into
|
||||
an :class:`ImportResult` (pages, attachments, warnings, stats) which the
|
||||
pipeline (:mod:`app.services.importers.pipeline`) persists into FlowDeck.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
def decode_text(data: bytes) -> str:
|
||||
"""Best-effort decode of uploaded bytes (BOM aware, latin-1 fallback)."""
|
||||
for enc in ("utf-8-sig", "utf-8", "utf-16", "latin-1"):
|
||||
try:
|
||||
return data.decode(enc)
|
||||
except (UnicodeDecodeError, UnicodeError):
|
||||
continue
|
||||
return data.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportAttachment:
|
||||
"""A binary asset extracted from an archive/vault."""
|
||||
|
||||
source_path: str
|
||||
filename: str
|
||||
data: bytes = b""
|
||||
mime: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportPage:
|
||||
"""One page to create. ``markdown`` is converted to blocks by the pipeline
|
||||
unless ``blocks`` is already provided. ``collection`` marks a database
|
||||
(Notion database, Excel sheet…) whose rows become ``collection_pages``."""
|
||||
|
||||
title: str = "Untitled"
|
||||
markdown: str = ""
|
||||
blocks: list[dict] = field(default_factory=list)
|
||||
source_path: str = ""
|
||||
parent_path: str = ""
|
||||
properties: dict[str, Any] = field(default_factory=dict)
|
||||
collection: dict[str, Any] | None = None
|
||||
external_id: str = ""
|
||||
page_id: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImportResult:
|
||||
"""Normalized output of any importer."""
|
||||
|
||||
source: str = ""
|
||||
pages: list[ImportPage] = field(default_factory=list)
|
||||
attachments: list[ImportAttachment] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
stats: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def warn(self, message: str) -> None:
|
||||
if message and message not in self.warnings:
|
||||
self.warnings.append(message)
|
||||
|
||||
def finalize(self) -> ImportResult:
|
||||
self.stats.setdefault("pages", len(self.pages))
|
||||
self.stats.setdefault("collections", sum(1 for p in self.pages if p.collection))
|
||||
self.stats.setdefault("attachments", len(self.attachments))
|
||||
self.stats.setdefault("warnings", len(self.warnings))
|
||||
return self
|
||||
|
||||
|
||||
class Importer(ABC):
|
||||
"""Base class for a source importer."""
|
||||
|
||||
source_id: str = ""
|
||||
label: str = ""
|
||||
description: str = ""
|
||||
extensions: tuple[str, ...] = ()
|
||||
order: int = 100
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
"""Return True when this importer recognizes the uploaded file."""
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
"""Parse the upload into a normalized :class:`ImportResult`."""
|
||||
|
||||
def info(self) -> dict[str, Any]:
|
||||
return {
|
||||
"source_id": self.source_id,
|
||||
"label": self.label,
|
||||
"description": self.description,
|
||||
"extensions": list(self.extensions),
|
||||
}
|
||||
|
||||
|
||||
REGISTRY: list[Importer] = []
|
||||
|
||||
|
||||
def register_importer(cls: type[Importer]) -> type[Importer]:
|
||||
"""Class decorator registering an importer instance."""
|
||||
REGISTRY.append(cls())
|
||||
REGISTRY.sort(key=lambda i: i.order)
|
||||
return cls
|
||||
|
||||
|
||||
def all_importers() -> list[Importer]:
|
||||
return list(REGISTRY)
|
||||
|
||||
|
||||
def get_importer(source_id: str) -> Importer | None:
|
||||
for imp in REGISTRY:
|
||||
if imp.source_id == source_id:
|
||||
return imp
|
||||
return None
|
||||
|
||||
|
||||
def detect_importer(filename: str, data: bytes) -> Importer | None:
|
||||
"""First importer that recognizes the file, else None."""
|
||||
for imp in REGISTRY:
|
||||
try:
|
||||
if imp.detect(filename, data):
|
||||
return imp
|
||||
except Exception: # noqa: BLE001 - a broken detector must not break detection
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def list_sources() -> list[dict[str, Any]]:
|
||||
return [imp.info() for imp in REGISTRY]
|
||||
|
||||
|
||||
def make_collection(name: str, schema: list[dict], rows: list[dict],
|
||||
*, source_path: str = "", external_id: str = "") -> ImportPage:
|
||||
"""Build an ImportPage carrying a collection spec (database import).
|
||||
|
||||
``rows`` entries are ``{"title": str, "properties": {name: value}}``.
|
||||
"""
|
||||
return ImportPage(
|
||||
title=name or "Imported database",
|
||||
collection={"name": name or "Imported database", "schema": schema, "rows": rows},
|
||||
source_path=source_path or name,
|
||||
external_id=external_id or source_path or name,
|
||||
)
|
||||
@@ -0,0 +1,284 @@
|
||||
"""FlowDeck — bookmark importers (v5.6.0, Phase 4).
|
||||
|
||||
Raindrop.io, Pocket, Readwise, Shaarli and generic Netscape bookmark files are
|
||||
normalized into a FlowDeck collection (URL, description, tags, created date).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.services.importers._common import coerce_tags
|
||||
from app.services.importers.base import (
|
||||
Importer,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
make_collection,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_SCHEMA = [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "URL", "type": "url"},
|
||||
{"name": "Description", "type": "text"},
|
||||
{"name": "Tags", "type": "multi_select"},
|
||||
{"name": "Created", "type": "date"},
|
||||
]
|
||||
|
||||
_TAG_RE = re.compile(
|
||||
r"<h3[^>]*>(?P<folder>.*?)</h3>|<a\s+(?P<attrs>[^>]*?)>(?P<title>.*?)</a>",
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
_ATTR_RE = re.compile(r'([a-zA-Z_:-]+)\s*=\s*"([^"]*)"')
|
||||
|
||||
|
||||
def _strip_tags(value: str) -> str:
|
||||
return re.sub(r"<[^>]+>", "", value or "").strip()
|
||||
|
||||
|
||||
def _iso_from_epoch(value: Any) -> str:
|
||||
try:
|
||||
return datetime.fromtimestamp(int(str(value)[:10]), tz=UTC).date().isoformat()
|
||||
except (ValueError, TypeError, OSError, OverflowError):
|
||||
return ""
|
||||
|
||||
|
||||
def _iso(value: Any) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
if re.fullmatch(r"\d{10}", text):
|
||||
return _iso_from_epoch(text)
|
||||
return text[:10] if re.match(r"^\d{4}-\d{2}-\d{2}", text) else text
|
||||
|
||||
|
||||
def _row(title: str, url: str, description: str = "", tags=None, created: str = "") -> dict:
|
||||
props: dict[str, Any] = {}
|
||||
if url:
|
||||
props["URL"] = url
|
||||
if description:
|
||||
props["Description"] = description
|
||||
tag_list = coerce_tags(tags)
|
||||
if tag_list:
|
||||
props["Tags"] = tag_list
|
||||
if created:
|
||||
props["Created"] = created
|
||||
return {"title": (title or url or "Bookmark").strip()[:200], "properties": props}
|
||||
|
||||
|
||||
def parse_netscape(html: str) -> list[dict]:
|
||||
"""Parse a Netscape bookmark file (browser / Pocket / Raindrop HTML)."""
|
||||
rows: list[dict] = []
|
||||
folder = ""
|
||||
for match in _TAG_RE.finditer(html):
|
||||
if match.group("folder") is not None:
|
||||
folder = _strip_tags(match.group("folder"))
|
||||
continue
|
||||
attrs = dict(_ATTR_RE.findall(match.group("attrs") or ""))
|
||||
url = attrs.get("href") or attrs.get("HREF") or ""
|
||||
if not url:
|
||||
continue
|
||||
title = _strip_tags(match.group("title"))
|
||||
tags = attrs.get("tags") or attrs.get("TAGS") or folder
|
||||
rows.append(_row(title, url, tags=tags, created=_iso_from_epoch(attrs.get("add_date", ""))))
|
||||
return rows
|
||||
|
||||
|
||||
def _csv_rows(text: str) -> list[dict]:
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
return [{(k or "").strip().lower(): v for k, v in row.items()} for row in reader]
|
||||
|
||||
|
||||
class _BookmarkBase(Importer):
|
||||
source_id = "bookmarks"
|
||||
label = "Signets"
|
||||
description = ""
|
||||
order = 44
|
||||
keywords: tuple[str, ...] = ()
|
||||
|
||||
def _hint(self, filename: str, text: str) -> bool:
|
||||
low = filename.lower()
|
||||
return any(k in low or k in text.lower() for k in self.keywords)
|
||||
|
||||
|
||||
@register_importer
|
||||
class RaindropImporter(_BookmarkBase):
|
||||
source_id = "raindrop"
|
||||
label = "Raindrop.io"
|
||||
description = "Export Raindrop.io (CSV ou HTML) → collection de signets."
|
||||
extensions = (".csv", ".html", ".htm")
|
||||
order = 46
|
||||
keywords = ("raindrop",)
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
text = decode_text(data)
|
||||
if not self._hint(filename, text):
|
||||
return False
|
||||
return filename.lower().endswith((".csv", ".html", ".htm"))
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
text = decode_text(data)
|
||||
rows: list[dict] = []
|
||||
if filename.lower().endswith(".csv"):
|
||||
for r in _csv_rows(text):
|
||||
rows.append(_row(
|
||||
r.get("title", ""), r.get("url", ""),
|
||||
r.get("note") or r.get("excerpt") or "",
|
||||
r.get("tags", ""), _iso(r.get("created", "")),
|
||||
))
|
||||
else:
|
||||
rows = parse_netscape(text)
|
||||
result.pages.append(make_collection("Raindrop", _SCHEMA, rows, source_path=filename))
|
||||
result.stats["rows"] = len(rows)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
@register_importer
|
||||
class PocketImporter(_BookmarkBase):
|
||||
source_id = "pocket"
|
||||
label = "Pocket"
|
||||
description = "Export Pocket (CSV ou HTML) → collection de signets."
|
||||
extensions = (".csv", ".html", ".htm")
|
||||
order = 45
|
||||
keywords = ("pocket",)
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
text = decode_text(data)
|
||||
if not self._hint(filename, text):
|
||||
return False
|
||||
return filename.lower().endswith((".csv", ".html", ".htm"))
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
text = decode_text(data)
|
||||
rows: list[dict] = []
|
||||
if filename.lower().endswith(".csv"):
|
||||
for r in _csv_rows(text):
|
||||
rows.append(_row(
|
||||
r.get("title", ""), r.get("url", ""),
|
||||
"", r.get("tags", ""), _iso(r.get("time_added", "")),
|
||||
))
|
||||
else:
|
||||
rows = parse_netscape(text)
|
||||
result.pages.append(make_collection("Pocket", _SCHEMA, rows, source_path=filename))
|
||||
result.stats["rows"] = len(rows)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
@register_importer
|
||||
class ReadwiseImporter(_BookmarkBase):
|
||||
source_id = "readwise"
|
||||
label = "Readwise"
|
||||
description = "Export Readwise (highlights CSV) → collection de surlignages."
|
||||
extensions = (".csv", ".md", ".markdown")
|
||||
order = 47
|
||||
keywords = ("readwise",)
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
text = decode_text(data)
|
||||
if not self._hint(filename, text):
|
||||
return False
|
||||
if filename.lower().endswith(".csv"):
|
||||
header = text.splitlines()[0].lower() if text.strip() else ""
|
||||
return "highlight" in header or "book title" in header
|
||||
return True
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
text = decode_text(data)
|
||||
schema = [
|
||||
{"name": "Highlight", "type": "title"},
|
||||
{"name": "Book", "type": "text"},
|
||||
{"name": "Author", "type": "text"},
|
||||
{"name": "Note", "type": "text"},
|
||||
{"name": "Tags", "type": "multi_select"},
|
||||
{"name": "Highlighted at", "type": "date"},
|
||||
]
|
||||
rows: list[dict] = []
|
||||
if filename.lower().endswith(".csv"):
|
||||
for r in _csv_rows(text):
|
||||
props: dict[str, Any] = {}
|
||||
if r.get("book title"):
|
||||
props["Book"] = r["book title"]
|
||||
if r.get("book author"):
|
||||
props["Author"] = r["book author"]
|
||||
if r.get("note"):
|
||||
props["Note"] = r["note"]
|
||||
tags = coerce_tags(r.get("document tags") or r.get("tags"))
|
||||
if tags:
|
||||
props["Tags"] = tags
|
||||
created = _iso(r.get("highlighted at", ""))
|
||||
if created:
|
||||
props["Highlighted at"] = created
|
||||
rows.append({"title": (r.get("highlight") or "Highlight").strip()[:200], "properties": props})
|
||||
else:
|
||||
rows = [{"title": ln.lstrip("-* ").strip()[:200], "properties": {}} for ln in text.splitlines() if ln.strip()]
|
||||
result.pages.append(make_collection("Readwise", schema, rows, source_path=filename))
|
||||
result.stats["rows"] = len(rows)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
@register_importer
|
||||
class ShaarliImporter(_BookmarkBase):
|
||||
source_id = "shaarli"
|
||||
label = "Shaarli"
|
||||
description = "Export Shaarli (JSON) → collection de signets."
|
||||
extensions = (".json",)
|
||||
order = 48
|
||||
keywords = ("shaarli",)
|
||||
|
||||
def _records(self, data: bytes) -> list[dict] | None:
|
||||
try:
|
||||
obj = json.loads(decode_text(data))
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
if isinstance(obj, dict) and isinstance(obj.get("links"), list):
|
||||
obj = obj["links"]
|
||||
if isinstance(obj, list) and obj and all(isinstance(x, dict) and x.get("url") for x in obj):
|
||||
return obj
|
||||
return None
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
if not filename.lower().endswith(".json"):
|
||||
return False
|
||||
return self._records(data) is not None
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
records = self._records(data) or []
|
||||
rows = [
|
||||
_row(r.get("title", ""), r.get("url", ""), r.get("description", ""),
|
||||
r.get("tags", ""), _iso(r.get("created", "")))
|
||||
for r in records
|
||||
]
|
||||
result.pages.append(make_collection("Shaarli", _SCHEMA, rows, source_path=filename))
|
||||
result.stats["rows"] = len(rows)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
@register_importer
|
||||
class BookmarksImporter(_BookmarkBase):
|
||||
source_id = "bookmarks"
|
||||
label = "Signets HTML (navigateur)"
|
||||
description = "Fichier de signets Netscape HTML (Chrome/Firefox/Edge…)."
|
||||
extensions = (".html", ".htm")
|
||||
order = 49
|
||||
keywords = ()
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
if not filename.lower().endswith((".html", ".htm")):
|
||||
return False
|
||||
text = decode_text(data)[:4000].lower()
|
||||
return "netscape-bookmark-file" in text or "<dt><a href" in text
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
rows = parse_netscape(decode_text(data))
|
||||
result.pages.append(make_collection("Bookmarks", _SCHEMA, rows, source_path=filename))
|
||||
result.stats["rows"] = len(rows)
|
||||
return result.finalize()
|
||||
@@ -0,0 +1,138 @@
|
||||
"""FlowDeck — iCalendar (.ics) importer (v5.6.0, Phase 4).
|
||||
|
||||
Parses VEVENT blocks (RFC 5545, best-effort) into a FlowDeck calendar
|
||||
collection (start/end, all-day, location, description).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.services.importers.base import (
|
||||
Importer,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
make_collection,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_SCHEMA = [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Start", "type": "date"},
|
||||
{"name": "End", "type": "date"},
|
||||
{"name": "All day", "type": "checkbox"},
|
||||
{"name": "Location", "type": "text"},
|
||||
{"name": "Description", "type": "text"},
|
||||
{"name": "Calendar", "type": "text"},
|
||||
]
|
||||
_UNESCAPE = [("\\n", "\n"), ("\\N", "\n"), ("\\,", ","), ("\\;", ";"), ("\\\\", "\\")]
|
||||
|
||||
|
||||
def _unfold(text: str) -> list[str]:
|
||||
lines: list[str] = []
|
||||
for raw in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
||||
if raw[:1] in (" ", "\t") and lines:
|
||||
lines[-1] += raw[1:]
|
||||
else:
|
||||
lines.append(raw)
|
||||
return lines
|
||||
|
||||
|
||||
def _unescape(value: str) -> str:
|
||||
for src, dst in _UNESCAPE:
|
||||
value = value.replace(src, dst)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _parse_prop(line: str) -> tuple[str, dict[str, str], str]:
|
||||
if ":" not in line:
|
||||
return "", {}, ""
|
||||
head, _, value = line.partition(":")
|
||||
parts = head.split(";")
|
||||
name = parts[0].upper()
|
||||
params: dict[str, str] = {}
|
||||
for p in parts[1:]:
|
||||
if "=" in p:
|
||||
k, _, v = p.partition("=")
|
||||
params[k.upper()] = v
|
||||
return name, params, value
|
||||
|
||||
|
||||
def _iso_datetime(value: str, params: dict[str, str]) -> tuple[str, bool]:
|
||||
"""Return (iso, is_all_day)."""
|
||||
v = value.strip()
|
||||
if params.get("VALUE") == "DATE" or re.fullmatch(r"\d{8}", v):
|
||||
m = re.fullmatch(r"(\d{4})(\d{2})(\d{2})", v)
|
||||
return (f"{m.group(1)}-{m.group(2)}-{m.group(3)}", True) if m else ("", True)
|
||||
m = re.fullmatch(r"(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)", v)
|
||||
if not m:
|
||||
return v, False
|
||||
date = f"{m.group(1)}-{m.group(2)}-{m.group(3)}T{m.group(4)}:{m.group(5)}:{m.group(6)}"
|
||||
return (date + "+00:00" if m.group(7) else date), False
|
||||
|
||||
|
||||
@register_importer
|
||||
class IcsImporter(Importer):
|
||||
source_id = "ics"
|
||||
label = "Calendrier (.ics)"
|
||||
description = "Export iCalendar (Google/Outlook/Apple) → collection d'événements."
|
||||
extensions = (".ics", ".ical")
|
||||
order = 33
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
if filename.lower().endswith((".ics", ".ical")):
|
||||
return True
|
||||
return "BEGIN:VCALENDAR" in decode_text(data)[:2000]
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
lines = _unfold(decode_text(data))
|
||||
calendar = ""
|
||||
rows: list[dict] = []
|
||||
event: dict[str, Any] | None = None
|
||||
for line in lines:
|
||||
upper = line.strip().upper()
|
||||
if upper == "BEGIN:VEVENT":
|
||||
event = {}
|
||||
continue
|
||||
if upper == "END:VEVENT":
|
||||
if event is not None:
|
||||
rows.append(_event_row(event, calendar))
|
||||
event = None
|
||||
continue
|
||||
name, params, value = _parse_prop(line.strip())
|
||||
if name == "X-WR-CALNAME" and not event:
|
||||
calendar = _unescape(value)
|
||||
if event is None:
|
||||
continue
|
||||
if name == "SUMMARY":
|
||||
event["title"] = _unescape(value)
|
||||
elif name == "DTSTART":
|
||||
event["start"], event["all_day"] = _iso_datetime(value, params)
|
||||
elif name == "DTEND":
|
||||
event["end"], _ = _iso_datetime(value, params)
|
||||
elif name == "LOCATION":
|
||||
event["location"] = _unescape(value)
|
||||
elif name == "DESCRIPTION":
|
||||
event["description"] = _unescape(value)
|
||||
elif name == "UID":
|
||||
event["uid"] = value
|
||||
result.pages.append(make_collection("Calendar", _SCHEMA, rows, source_path=filename))
|
||||
result.stats["rows"] = len(rows)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
def _event_row(event: dict[str, Any], calendar: str) -> dict:
|
||||
props: dict[str, Any] = {}
|
||||
if event.get("start"):
|
||||
props["Start"] = event["start"]
|
||||
if event.get("end"):
|
||||
props["End"] = event["end"]
|
||||
props["All day"] = bool(event.get("all_day"))
|
||||
if event.get("location"):
|
||||
props["Location"] = event["location"]
|
||||
if event.get("description"):
|
||||
props["Description"] = event["description"]
|
||||
if calendar:
|
||||
props["Calendar"] = calendar
|
||||
return {"title": (event.get("title") or "Event").strip()[:200], "properties": props}
|
||||
@@ -0,0 +1,122 @@
|
||||
"""FlowDeck — Word (.docx) importer (v5.6.0, Phase 3).
|
||||
|
||||
Converts a Word document (including Google Docs Takeout ``.docx`` exports) into
|
||||
a FlowDeck page: headings, lists, tables and inline images.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.importers.base import (
|
||||
ImportAttachment,
|
||||
Importer,
|
||||
ImportResult,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_HEADING_STYLES = {
|
||||
"title": 1, "heading 1": 1, "heading 2": 2, "heading 3": 3,
|
||||
"heading 4": 4, "heading 5": 4, "heading 6": 4,
|
||||
}
|
||||
_MIME = {
|
||||
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
|
||||
".emf": "image/emf", ".wmf": "image/wmf", ".tiff": "image/tiff",
|
||||
}
|
||||
|
||||
|
||||
def _escape_cell(text: str) -> str:
|
||||
return text.strip().replace("|", "\\|").replace("\n", " ")
|
||||
|
||||
|
||||
def _table_markdown(table) -> str:
|
||||
rows: list[list[str]] = []
|
||||
for row in table.rows:
|
||||
rows.append([_escape_cell(cell.text) for cell in row.cells])
|
||||
if not rows:
|
||||
return ""
|
||||
width = max(len(r) for r in rows)
|
||||
rows = [r + [""] * (width - len(r)) for r in rows]
|
||||
header = "| " + " | ".join(rows[0]) + " |"
|
||||
sep = "| " + " | ".join(["---"] * width) + " |"
|
||||
body = "\n".join("| " + " | ".join(r) + " |" for r in rows[1:])
|
||||
return "\n".join(x for x in (header, sep, body) if x)
|
||||
|
||||
|
||||
@register_importer
|
||||
class DocxImporter(Importer):
|
||||
source_id = "docx"
|
||||
label = "Word / Google Docs (.docx)"
|
||||
description = "Document Word : titres, listes, tableaux et images."
|
||||
extensions = (".docx", ".docm")
|
||||
order = 36
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
return filename.lower().endswith((".docx", ".docm"))
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
try:
|
||||
from docx import Document
|
||||
from docx.oxml.ns import qn
|
||||
except ImportError:
|
||||
result.warn("python-docx n'est pas installé : import Word indisponible")
|
||||
return result.finalize()
|
||||
try:
|
||||
doc = Document(io.BytesIO(data))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"Document illisible : {exc}")
|
||||
return result.finalize()
|
||||
|
||||
lines: list[str] = []
|
||||
for para in doc.paragraphs:
|
||||
text = para.text.strip()
|
||||
style = (para.style.name or "").lower() if para.style else ""
|
||||
images = self._paragraph_images(doc, para, qn, result)
|
||||
if text:
|
||||
level = _HEADING_STYLES.get(style)
|
||||
if level:
|
||||
lines.append("#" * level + " " + text)
|
||||
elif "list bullet" in style or "list paragraph" in style:
|
||||
lines.append("- " + text)
|
||||
elif "list number" in style:
|
||||
lines.append("1. " + text)
|
||||
elif style == "quote":
|
||||
lines.append("> " + text)
|
||||
else:
|
||||
lines.append(text)
|
||||
lines.extend(images)
|
||||
for table in doc.tables:
|
||||
md = _table_markdown(table)
|
||||
if md:
|
||||
lines.append(md)
|
||||
|
||||
markdown = re.sub(r"\n{3,}", "\n\n", "\n\n".join(lines)).strip()
|
||||
title = Path(filename).stem or "Document"
|
||||
result.pages.append(_page(title, markdown, filename))
|
||||
return result.finalize()
|
||||
|
||||
def _paragraph_images(self, doc, para, qn, result: ImportResult) -> list[str]:
|
||||
images: list[str] = []
|
||||
for blip in para._p.iter(qn("a:blip")):
|
||||
rid = blip.get(qn("r:embed")) or blip.get(qn("r:link"))
|
||||
if not rid:
|
||||
continue
|
||||
part = doc.part.related_parts.get(rid)
|
||||
if part is None or not hasattr(part, "blob"):
|
||||
continue
|
||||
name = Path(str(part.partname)).name or f"image_{len(result.attachments)}.png"
|
||||
result.attachments.append(ImportAttachment(
|
||||
source_path=name, filename=name, data=part.blob,
|
||||
mime=_MIME.get(Path(name).suffix.lower(), "application/octet-stream"),
|
||||
))
|
||||
images.append(f"")
|
||||
return images
|
||||
|
||||
|
||||
def _page(title: str, markdown: str, filename: str):
|
||||
from app.services.importers.base import ImportPage
|
||||
|
||||
return ImportPage(title=title, markdown=markdown, source_path=filename, external_id=filename)
|
||||
@@ -0,0 +1,232 @@
|
||||
"""FlowDeck — forge (Gitea/GitHub) issues importer (v5.6.0, Phase 4).
|
||||
|
||||
Pulls a repository's issues, labels and milestones through a forge adapter and
|
||||
normalizes them into FlowDeck collections.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.services.importers.base import ImportResult, make_collection
|
||||
|
||||
|
||||
def _label_names(issue: dict) -> list[str]:
|
||||
labels = issue.get("labels") or []
|
||||
names: list[str] = []
|
||||
for label in labels:
|
||||
if isinstance(label, dict):
|
||||
name = label.get("name") or label.get("title")
|
||||
else:
|
||||
name = str(label)
|
||||
if name and name not in names:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def _milestone_name(issue: dict) -> str:
|
||||
milestone = issue.get("milestone")
|
||||
if isinstance(milestone, dict):
|
||||
return str(milestone.get("title") or milestone.get("name") or "")
|
||||
return str(milestone or "")
|
||||
|
||||
|
||||
def _assignees(issue: dict) -> list[str]:
|
||||
out: list[str] = []
|
||||
for key in ("assignees", "assignee"):
|
||||
value = issue.get(key)
|
||||
if isinstance(value, list):
|
||||
for a in value:
|
||||
login = a.get("login") if isinstance(a, dict) else str(a)
|
||||
if login and login not in out:
|
||||
out.append(login)
|
||||
elif isinstance(value, dict):
|
||||
login = value.get("login")
|
||||
if login and login not in out:
|
||||
out.append(login)
|
||||
elif isinstance(value, str) and value and value not in out:
|
||||
out.append(value)
|
||||
return out
|
||||
|
||||
|
||||
_ISSUE_SCHEMA = [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Number", "type": "number"},
|
||||
{"name": "State", "type": "select", "options": [
|
||||
{"name": "open", "color": "green"}, {"name": "closed", "color": "red"},
|
||||
]},
|
||||
{"name": "Labels", "type": "multi_select"},
|
||||
{"name": "Milestone", "type": "text"},
|
||||
{"name": "Assignee", "type": "text"},
|
||||
{"name": "Created", "type": "date"},
|
||||
{"name": "Updated", "type": "date"},
|
||||
{"name": "URL", "type": "url"},
|
||||
{"name": "Body", "type": "text"},
|
||||
]
|
||||
|
||||
|
||||
def build_issues_result(
|
||||
issues: list[dict],
|
||||
*,
|
||||
labels: list[dict] | None = None,
|
||||
milestones: list[dict] | None = None,
|
||||
owner: str = "",
|
||||
repo: str = "",
|
||||
provider: str = "",
|
||||
) -> ImportResult:
|
||||
"""Normalize forge issues/labels/milestones into an ImportResult."""
|
||||
result = ImportResult(source=f"forge:{provider}" if provider else "forge")
|
||||
name = f"{owner}/{repo} issues".strip("/ ") or "Issues"
|
||||
rows: list[dict] = []
|
||||
for issue in issues:
|
||||
if issue.get("pull_request"):
|
||||
continue
|
||||
props: dict[str, Any] = {}
|
||||
if issue.get("number") is not None:
|
||||
props["Number"] = issue["number"]
|
||||
if issue.get("state"):
|
||||
props["State"] = issue["state"]
|
||||
label_names = _label_names(issue)
|
||||
if label_names:
|
||||
props["Labels"] = label_names
|
||||
milestone = _milestone_name(issue)
|
||||
if milestone:
|
||||
props["Milestone"] = milestone
|
||||
assignees = _assignees(issue)
|
||||
if assignees:
|
||||
props["Assignee"] = ", ".join(assignees)
|
||||
if issue.get("created_at"):
|
||||
props["Created"] = issue["created_at"]
|
||||
if issue.get("updated_at"):
|
||||
props["Updated"] = issue["updated_at"]
|
||||
if issue.get("html_url"):
|
||||
props["URL"] = issue["html_url"]
|
||||
if issue.get("body"):
|
||||
props["Body"] = issue["body"]
|
||||
title = issue.get("title") or f"#{issue.get('number', '')}".strip()
|
||||
rows.append({"title": title[:200], "properties": props})
|
||||
|
||||
result.pages.append(make_collection(
|
||||
name, _ISSUE_SCHEMA, rows,
|
||||
source_path=f"{provider}:{owner}/{repo}:issues",
|
||||
external_id=f"{provider}:{owner}/{repo}:issues",
|
||||
))
|
||||
result.stats["rows"] = len(rows)
|
||||
|
||||
if labels:
|
||||
label_schema = [
|
||||
{"name": "Name", "type": "title"},
|
||||
{"name": "Color", "type": "text"},
|
||||
{"name": "Description", "type": "text"},
|
||||
]
|
||||
label_rows = [{
|
||||
"title": (lbl.get("name") or lbl.get("title") or "Label")[:200],
|
||||
"properties": {
|
||||
k: v for k, v in (
|
||||
("Color", lbl.get("color")),
|
||||
("Description", lbl.get("description")),
|
||||
) if v
|
||||
},
|
||||
} for lbl in labels]
|
||||
result.pages.append(make_collection(
|
||||
f"{owner}/{repo} labels".strip("/ "), label_schema, label_rows,
|
||||
source_path=f"{provider}:{owner}/{repo}:labels",
|
||||
external_id=f"{provider}:{owner}/{repo}:labels",
|
||||
))
|
||||
|
||||
if milestones:
|
||||
ms_schema = [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "State", "type": "select", "options": [
|
||||
{"name": "open", "color": "green"}, {"name": "closed", "color": "red"},
|
||||
]},
|
||||
{"name": "Due date", "type": "date"},
|
||||
{"name": "Description", "type": "text"},
|
||||
]
|
||||
ms_rows = []
|
||||
for ms in milestones:
|
||||
props: dict[str, Any] = {}
|
||||
if ms.get("state"):
|
||||
props["State"] = ms["state"]
|
||||
if ms.get("due_on"):
|
||||
props["Due date"] = ms["due_on"]
|
||||
if ms.get("description"):
|
||||
props["Description"] = ms["description"]
|
||||
ms_rows.append({"title": (ms.get("title") or "Milestone")[:200], "properties": props})
|
||||
result.pages.append(make_collection(
|
||||
f"{owner}/{repo} milestones".strip("/ "), ms_schema, ms_rows,
|
||||
source_path=f"{provider}:{owner}/{repo}:milestones",
|
||||
external_id=f"{provider}:{owner}/{repo}:milestones",
|
||||
))
|
||||
|
||||
return result.finalize()
|
||||
|
||||
|
||||
class GiteaForgeAdapter:
|
||||
"""Adapts a :class:`GiteaClient` to the ``list_*`` interface used here."""
|
||||
|
||||
def __init__(self, client) -> None:
|
||||
self._client = client
|
||||
|
||||
async def list_issues(self, owner: str, repo: str, state: str = "all") -> list[dict]:
|
||||
issues: list[dict] = []
|
||||
for page in range(1, 6):
|
||||
batch = await self._client.get_issues(owner, repo, state=state, page=page, limit=50)
|
||||
if not batch:
|
||||
break
|
||||
issues.extend(batch)
|
||||
if len(batch) < 50:
|
||||
break
|
||||
return issues
|
||||
|
||||
async def list_labels(self, owner: str, repo: str) -> list[dict]:
|
||||
return await self._client.get_labels(owner, repo)
|
||||
|
||||
async def list_milestones(self, owner: str, repo: str, state: str = "all") -> list[dict]:
|
||||
return await self._client.get_milestones(owner, repo, state=state)
|
||||
|
||||
async def list_repo_files(self, owner: str, repo: str, path: str = "") -> list[dict]:
|
||||
"""Recursively flatten Gitea repo contents into file entries."""
|
||||
files: list[dict] = []
|
||||
pending = [path.strip("/")]
|
||||
while pending and len(files) < 5000:
|
||||
current = pending.pop()
|
||||
items = await self._client.get_repo_contents(owner, repo, current)
|
||||
for item in items:
|
||||
if item.get("type") == "dir":
|
||||
pending.append(item.get("path") or item.get("name"))
|
||||
elif item.get("type") == "file":
|
||||
files.append({"path": item.get("path") or item.get("name"), "size": item.get("size", 0)})
|
||||
return files
|
||||
|
||||
async def get_file_content(self, owner: str, repo: str, path: str) -> str:
|
||||
return await self._client.get_file_content(owner, repo, path)
|
||||
|
||||
|
||||
async def fetch_forge_issues(
|
||||
adapter,
|
||||
owner: str,
|
||||
repo: str,
|
||||
*,
|
||||
provider: str = "",
|
||||
state: str = "all",
|
||||
include_labels: bool = True,
|
||||
include_milestones: bool = True,
|
||||
) -> ImportResult:
|
||||
"""Fetch issues (and optionally labels/milestones) then normalize them."""
|
||||
issues = await adapter.list_issues(owner, repo, state)
|
||||
labels = None
|
||||
milestones = None
|
||||
if include_labels:
|
||||
try:
|
||||
labels = await adapter.list_labels(owner, repo)
|
||||
except Exception: # noqa: BLE001 - labels are optional
|
||||
labels = None
|
||||
if include_milestones:
|
||||
try:
|
||||
milestones = await adapter.list_milestones(owner, repo, state)
|
||||
except Exception: # noqa: BLE001
|
||||
milestones = None
|
||||
return build_issues_result(
|
||||
issues, labels=labels, milestones=milestones,
|
||||
owner=owner, repo=repo, provider=provider,
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""FlowDeck — forge repository file importer (v5.6.0, Phase 5).
|
||||
|
||||
Imports a Gitea/GitHub repository's text files as pages, preserving the folder
|
||||
hierarchy. Markdown files become pages; other text files become code blocks.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.export import _CODE_LANG, _TEXTUAL_EXTS
|
||||
from app.services.importers.base import ImportPage, ImportResult
|
||||
|
||||
_MD_EXTS = {"md", "markdown"}
|
||||
|
||||
|
||||
def _ext(path: str) -> str:
|
||||
return Path(path).suffix.lower().lstrip(".")
|
||||
|
||||
|
||||
def build_repo_result(
|
||||
files: list[tuple[str, str]],
|
||||
*,
|
||||
owner: str,
|
||||
repo: str,
|
||||
provider: str = "",
|
||||
) -> ImportResult:
|
||||
"""Turn ``[(path, content)]`` into pages with folder hierarchy."""
|
||||
result = ImportResult(source=f"forge-repo:{provider}" if provider else "forge-repo")
|
||||
for path, content in files:
|
||||
clean = path.replace("\\", "/").strip("/")
|
||||
if not clean:
|
||||
continue
|
||||
ext = _ext(clean)
|
||||
if ext in _MD_EXTS:
|
||||
markdown = content
|
||||
else:
|
||||
lang = _CODE_LANG.get(ext, "")
|
||||
markdown = f"```{lang}\n{content.rstrip()}\n```"
|
||||
parts = clean.split("/")
|
||||
result.pages.append(ImportPage(
|
||||
title=parts[-1] or clean,
|
||||
markdown=markdown,
|
||||
source_path=clean,
|
||||
parent_path="/".join(parts[:-1]),
|
||||
external_id=f"{provider}:{owner}/{repo}:{clean}",
|
||||
))
|
||||
result.stats["rows"] = len(result.pages)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
async def fetch_forge_repo(
|
||||
adapter,
|
||||
owner: str,
|
||||
repo: str,
|
||||
*,
|
||||
provider: str = "",
|
||||
path: str = "",
|
||||
max_files: int = 200,
|
||||
max_file_bytes: int = 512_000,
|
||||
) -> ImportResult:
|
||||
"""List a repo's files and fetch the textual ones."""
|
||||
try:
|
||||
metas = await adapter.list_repo_files(owner, repo, path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result = ImportResult(source=f"forge-repo:{provider}" if provider else "forge-repo")
|
||||
result.warn(f"Arborescence illisible : {exc}")
|
||||
return result.finalize()
|
||||
|
||||
files: list[tuple[str, str]] = []
|
||||
for meta in metas:
|
||||
file_path = meta.get("path") or ""
|
||||
if _ext(file_path) not in _TEXTUAL_EXTS:
|
||||
continue
|
||||
if int(meta.get("size") or 0) > max_file_bytes:
|
||||
continue
|
||||
if len(files) >= max_files:
|
||||
break
|
||||
try:
|
||||
content = await adapter.get_file_content(owner, repo, file_path)
|
||||
except Exception: # noqa: BLE001 - skip unreadable files
|
||||
continue
|
||||
if not content or content == "[binary file]":
|
||||
continue
|
||||
files.append((file_path, content))
|
||||
return build_repo_result(files, owner=owner, repo=repo, provider=provider)
|
||||
@@ -0,0 +1,300 @@
|
||||
"""FlowDeck — HTML notes & Google Keep importer (v5.6.0, Phase 1).
|
||||
|
||||
Covers HTML exports from Apple Notes, Bear, Ulysses and OneNote, plus the
|
||||
Google Takeout ``Keep`` JSON/HTML format. HTML is converted to Markdown and then
|
||||
to FlowDeck blocks by the pipeline.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import zipfile
|
||||
|
||||
from bs4 import BeautifulSoup, NavigableString, Tag
|
||||
|
||||
from app.services.importers._common import coerce_tags, normalize_title
|
||||
from app.services.importers.base import (
|
||||
ImportAttachment,
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_HTML_EXTS = (".html", ".htm")
|
||||
|
||||
|
||||
def _inline(node: Tag) -> str:
|
||||
out: list[str] = []
|
||||
for child in node.children:
|
||||
if isinstance(child, NavigableString):
|
||||
out.append(str(child))
|
||||
elif isinstance(child, Tag):
|
||||
name = child.name.lower()
|
||||
if name in ("strong", "b"):
|
||||
out.append(f"**{_inline(child).strip()}**")
|
||||
elif name in ("em", "i"):
|
||||
out.append(f"*{_inline(child).strip()}*")
|
||||
elif name == "code":
|
||||
out.append(f"`{child.get_text()}`")
|
||||
elif name == "br":
|
||||
out.append("\n")
|
||||
elif name == "a":
|
||||
href = child.get("href", "")
|
||||
label = _inline(child).strip() or href
|
||||
out.append(f"[{label}]({href})" if href else label)
|
||||
elif name == "img":
|
||||
src = child.get("src", "")
|
||||
alt = child.get("alt", "")
|
||||
out.append(f"" if src else "")
|
||||
elif name in ("del", "s", "strike"):
|
||||
out.append(f"~~{_inline(child).strip()}~~")
|
||||
else:
|
||||
out.append(_inline(child))
|
||||
return re.sub(r"[ \t]+", " ", "".join(out))
|
||||
|
||||
|
||||
def _table(node: Tag) -> str:
|
||||
rows: list[list[str]] = []
|
||||
for tr in node.find_all("tr"):
|
||||
cells = tr.find_all(["th", "td"])
|
||||
rows.append([_inline(c).strip().replace("|", "\\|") for c in cells])
|
||||
if not rows:
|
||||
return ""
|
||||
width = max(len(r) for r in rows)
|
||||
rows = [r + [""] * (width - len(r)) for r in rows]
|
||||
header = "| " + " | ".join(rows[0]) + " |"
|
||||
sep = "| " + " | ".join(["---"] * width) + " |"
|
||||
body = "\n".join("| " + " | ".join(r) + " |" for r in rows[1:])
|
||||
return "\n".join(x for x in (header, sep, body) if x)
|
||||
|
||||
|
||||
def _block(node: Tag, depth: int = 0) -> str:
|
||||
name = node.name.lower()
|
||||
if name in ("h1", "h2", "h3", "h4", "h5", "h6"):
|
||||
return "#" * int(name[1]) + " " + _inline(node).strip()
|
||||
if name == "p":
|
||||
return _inline(node).strip()
|
||||
if name in ("ul", "ol"):
|
||||
lines = []
|
||||
for i, li in enumerate(node.find_all("li", recursive=False)):
|
||||
marker = f"{i + 1}." if name == "ol" else "-"
|
||||
text = _inline(li).strip()
|
||||
lines.append(f"{' ' * depth}{marker} {text}")
|
||||
return "\n".join(lines)
|
||||
if name == "blockquote":
|
||||
return "\n".join(f"> {ln}" for ln in _inline(node).strip().splitlines())
|
||||
if name == "pre":
|
||||
code = node.get_text()
|
||||
lang = ""
|
||||
cls = " ".join(node.get("class", [])) if node.get("class") else ""
|
||||
m = re.search(r"(?:language|lang)-([\w+-]+)", cls)
|
||||
if m:
|
||||
lang = m.group(1)
|
||||
return f"```{lang}\n{code.rstrip()}\n```"
|
||||
if name == "hr":
|
||||
return "---"
|
||||
if name == "table":
|
||||
return _table(node)
|
||||
if name == "img":
|
||||
src = node.get("src", "")
|
||||
return f"" if src else ""
|
||||
if name in ("div", "section", "article", "body", "main", "html", "span", "font", "center"):
|
||||
inner = "\n\n".join(
|
||||
_block(c, depth) for c in node.children if isinstance(c, Tag)
|
||||
).strip()
|
||||
if inner:
|
||||
return inner
|
||||
text = _inline(node).strip()
|
||||
return text
|
||||
return _inline(node).strip()
|
||||
|
||||
|
||||
def _html_to_markdown(html: str) -> str:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
for tag in soup(["script", "style", "head", "nav", "footer"]):
|
||||
tag.decompose()
|
||||
root = soup.body or soup
|
||||
blocks = [_block(c) for c in root.children if isinstance(c, Tag)]
|
||||
md = "\n\n".join(b for b in blocks if b and b.strip())
|
||||
return re.sub(r"\n{3,}", "\n\n", md).strip()
|
||||
|
||||
|
||||
def _title_from_html(html: str, fallback: str) -> str:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
if soup.title and soup.title.string:
|
||||
return soup.title.string.strip()
|
||||
h1 = soup.find(["h1", "h2"])
|
||||
if h1:
|
||||
return h1.get_text().strip()
|
||||
return fallback
|
||||
|
||||
|
||||
@register_importer
|
||||
class HtmlNotesImporter(Importer):
|
||||
source_id = "html_notes"
|
||||
label = "HTML (Apple Notes, Bear, Ulysses, OneNote)"
|
||||
description = "Fichiers HTML ou archive .zip (notes exportées en HTML)."
|
||||
extensions = (".html", ".htm", ".zip")
|
||||
order = 50
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
low = filename.lower()
|
||||
if low.endswith(_HTML_EXTS):
|
||||
return True
|
||||
if low.endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
return False
|
||||
names = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
return any(n.lower().endswith(_HTML_EXTS) for n in names)
|
||||
return False
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
entries: list[tuple[str, bytes]] = []
|
||||
if filename.lower().endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
result.warn(f"Archive invalide : {exc}")
|
||||
return result.finalize()
|
||||
for name in zf.namelist():
|
||||
if name.endswith("/"):
|
||||
continue
|
||||
clean = name.replace("\\", "/")
|
||||
if clean.lower().endswith(_HTML_EXTS):
|
||||
entries.append((clean, zf.read(name)))
|
||||
else:
|
||||
result.attachments.append(ImportAttachment(
|
||||
source_path=clean,
|
||||
filename=clean.rsplit("/", 1)[-1],
|
||||
data=zf.read(name),
|
||||
))
|
||||
else:
|
||||
entries.append((filename, data))
|
||||
|
||||
for name, payload in entries:
|
||||
html = decode_text(payload)
|
||||
fallback = name.replace("\\", "/").rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
||||
parts = name.replace("\\", "/").split("/")
|
||||
result.pages.append(ImportPage(
|
||||
title=_title_from_html(html, fallback) or "Untitled",
|
||||
markdown=_html_to_markdown(html),
|
||||
source_path=name,
|
||||
parent_path="/".join(parts[:-1]),
|
||||
external_id=name,
|
||||
))
|
||||
return result.finalize()
|
||||
|
||||
|
||||
@register_importer
|
||||
class GoogleKeepImporter(Importer):
|
||||
source_id = "google_keep"
|
||||
label = "Google Keep (Takeout)"
|
||||
description = "Export Google Takeout : Keep/*.json (notes, listes, labels, pièces jointes)."
|
||||
extensions = (".json", ".zip")
|
||||
order = 40
|
||||
|
||||
def _is_keep_json(self, data: bytes) -> bool:
|
||||
try:
|
||||
obj = json.loads(decode_text(data))
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
return isinstance(obj, dict) and any(
|
||||
k in obj for k in ("textContent", "listContent", "isTrashed", "color")
|
||||
)
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
low = filename.lower()
|
||||
if low.endswith(".json"):
|
||||
return self._is_keep_json(data)
|
||||
if low.endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
return False
|
||||
for n in zf.namelist():
|
||||
if n.lower().endswith(".json") and "keep" in n.lower():
|
||||
try:
|
||||
if self._is_keep_json(zf.read(n)):
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
return False
|
||||
|
||||
def _page_from_keep(self, obj: dict, name: str) -> ImportPage | None:
|
||||
if obj.get("isTrashed"):
|
||||
return None
|
||||
title = normalize_title(obj.get("title"))
|
||||
lines: list[str] = []
|
||||
for item in obj.get("listContent") or []:
|
||||
mark = "x" if item.get("isChecked") else " "
|
||||
lines.append(f"- [{mark}] {item.get('text', '')}")
|
||||
if obj.get("textContent"):
|
||||
lines.insert(0, obj["textContent"])
|
||||
body = "\n\n".join(lines)
|
||||
if not title:
|
||||
first = next((ln for ln in body.splitlines() if ln.strip()), "")
|
||||
first = re.sub(r"^[-*+]\s*(\[[ xX]\]\s*)?", "", first).strip()
|
||||
title = first[:60] or "Note"
|
||||
labels = coerce_tags(obj.get("labels"))
|
||||
props = {"tags": labels} if labels else {}
|
||||
return ImportPage(
|
||||
title=title,
|
||||
markdown=body,
|
||||
source_path=name,
|
||||
parent_path="",
|
||||
properties=props,
|
||||
external_id=name,
|
||||
)
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
if filename.lower().endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
result.warn(f"Archive invalide : {exc}")
|
||||
return result.finalize()
|
||||
for name in zf.namelist():
|
||||
if name.endswith("/"):
|
||||
continue
|
||||
clean = name.replace("\\", "/")
|
||||
if clean.lower().endswith(".json") and "keep" in clean.lower():
|
||||
try:
|
||||
obj = json.loads(decode_text(zf.read(name)))
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if not isinstance(obj, dict):
|
||||
continue
|
||||
page = self._page_from_keep(obj, clean)
|
||||
if page:
|
||||
result.pages.append(page)
|
||||
elif "/keep/" in clean.lower() and not clean.lower().endswith(".json"):
|
||||
result.attachments.append(ImportAttachment(
|
||||
source_path=clean,
|
||||
filename=clean.rsplit("/", 1)[-1],
|
||||
data=zf.read(name),
|
||||
))
|
||||
return result.finalize()
|
||||
|
||||
try:
|
||||
obj = json.loads(decode_text(data))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"JSON invalide : {exc}")
|
||||
return result.finalize()
|
||||
if isinstance(obj, list):
|
||||
for i, item in enumerate(obj):
|
||||
if isinstance(item, dict):
|
||||
page = self._page_from_keep(item, f"{filename}#{i}")
|
||||
if page:
|
||||
result.pages.append(page)
|
||||
else:
|
||||
page = self._page_from_keep(obj, filename)
|
||||
if page:
|
||||
result.pages.append(page)
|
||||
return result.finalize()
|
||||
@@ -0,0 +1,150 @@
|
||||
"""FlowDeck — background import jobs (v5.6.0, Phase 0).
|
||||
|
||||
Small in-process job manager used for large uploads (vaults, zips): the upload
|
||||
is parsed and persisted in a worker thread while the UI polls job status.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.importers.base import (
|
||||
Importer,
|
||||
ImportResult,
|
||||
detect_importer,
|
||||
get_importer,
|
||||
)
|
||||
from app.services.importers.pipeline import run_import
|
||||
|
||||
_JOBS: dict[str, dict[str, Any]] = {}
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def parse_upload(filename: str, data: bytes, source_id: str | None = None) -> tuple[Importer | None, ImportResult]:
|
||||
"""Detect (or use) an importer and parse the upload synchronously."""
|
||||
imp = get_importer(source_id) if source_id else None
|
||||
if imp is None:
|
||||
imp = detect_importer(filename, data)
|
||||
if imp is None:
|
||||
return None, ImportResult(source=source_id or "unknown", warnings=["Format non reconnu"])
|
||||
return imp, imp.parse(filename, data)
|
||||
|
||||
|
||||
def _record(job: dict, *, status: str | None = None, error: str = "",
|
||||
report: dict | None = None, progress: int | None = None) -> None:
|
||||
with _LOCK:
|
||||
if status:
|
||||
job["status"] = status
|
||||
if error:
|
||||
job["error"] = error
|
||||
if report is not None:
|
||||
job["report"] = report
|
||||
if progress is not None:
|
||||
job["progress"] = progress
|
||||
job["updated_at"] = time.time()
|
||||
_persist(job)
|
||||
|
||||
|
||||
def _persist(job: dict) -> None:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO import_jobs (id, source, filename, status, error, report_json, created_at, updated_at) "
|
||||
"VALUES (?,?,?,?,?,?,?,?) "
|
||||
"ON CONFLICT(id) DO UPDATE SET status=excluded.status, error=excluded.error, "
|
||||
"report_json=excluded.report_json, updated_at=excluded.updated_at",
|
||||
(job["id"], job["source"], job["filename"], job["status"], job.get("error", ""),
|
||||
_json(job.get("report")), job["created_at"], job["updated_at"]),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception: # noqa: BLE001 - persistence is best-effort
|
||||
pass
|
||||
|
||||
|
||||
def _json(value: Any) -> str:
|
||||
import json
|
||||
|
||||
try:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
except (TypeError, ValueError):
|
||||
return "{}"
|
||||
|
||||
|
||||
def create_job(source: str, filename: str) -> dict:
|
||||
job = {
|
||||
"id": uuid.uuid4().hex[:16],
|
||||
"source": source,
|
||||
"filename": filename,
|
||||
"status": "queued",
|
||||
"progress": 0,
|
||||
"error": "",
|
||||
"report": None,
|
||||
"created_at": time.time(),
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
with _LOCK:
|
||||
_JOBS[job["id"]] = job
|
||||
_persist(job)
|
||||
return job
|
||||
|
||||
|
||||
def get_job(job_id: str) -> dict | None:
|
||||
with _LOCK:
|
||||
job = _JOBS.get(job_id)
|
||||
return dict(job) if job else None
|
||||
|
||||
|
||||
def list_jobs(limit: int = 50) -> list[dict]:
|
||||
with _LOCK:
|
||||
jobs = sorted(_JOBS.values(), key=lambda j: j["created_at"], reverse=True)
|
||||
return [dict(j) for j in jobs[:limit]]
|
||||
|
||||
|
||||
def start_import_job(
|
||||
*,
|
||||
filename: str,
|
||||
data: bytes,
|
||||
source_id: str | None,
|
||||
workspace_id: int | None,
|
||||
workspace_name: str | None,
|
||||
user_login: str,
|
||||
parent_page_id: int | None,
|
||||
target_collection_id: int | None,
|
||||
dedup: bool = True,
|
||||
mapping: dict[str, str] | None = None,
|
||||
mode: str | None = None,
|
||||
) -> dict:
|
||||
"""Create a job and run parse + persist in a background thread."""
|
||||
job = create_job(source_id or "auto", filename)
|
||||
_record(job, status="running", progress=5)
|
||||
|
||||
def worker() -> None:
|
||||
try:
|
||||
imp, result = parse_upload(filename, data, source_id)
|
||||
if imp is None:
|
||||
_record(job, status="error", error="Format non reconnu")
|
||||
return
|
||||
_record(job, progress=40)
|
||||
report = run_import(
|
||||
result,
|
||||
workspace_id=workspace_id,
|
||||
workspace_name=workspace_name,
|
||||
user_login=user_login,
|
||||
parent_page_id=parent_page_id,
|
||||
target_collection_id=target_collection_id,
|
||||
dedup=dedup,
|
||||
mapping=mapping,
|
||||
mode=mode,
|
||||
)
|
||||
_record(job, status="done", progress=100, report=report)
|
||||
except Exception as exc: # noqa: BLE001 - surface the error to the UI
|
||||
_record(job, status="error", error=f"{exc}", report={
|
||||
"traceback": traceback.format_exc()[-2000:],
|
||||
})
|
||||
|
||||
threading.Thread(target=worker, name=f"import-{job['id']}", daemon=True).start()
|
||||
return job
|
||||
@@ -0,0 +1,87 @@
|
||||
"""FlowDeck — generic Markdown / text importer (v5.6.0, Phase 1)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
from app.services.importers.base import (
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_MD_EXTS = (".md", ".markdown", ".txt", ".mdx")
|
||||
|
||||
|
||||
def _title_from_name(name: str) -> str:
|
||||
base = name.replace("\\", "/").rsplit("/", 1)[-1]
|
||||
for ext in (".markdown", ".markdown", ".mdx", ".md", ".txt"):
|
||||
if base.lower().endswith(ext):
|
||||
base = base[: -len(ext)]
|
||||
break
|
||||
return base.strip() or "Untitled"
|
||||
|
||||
|
||||
@register_importer
|
||||
class MarkdownImporter(Importer):
|
||||
source_id = "markdown"
|
||||
label = "Markdown / texte"
|
||||
description = "Fichiers .md/.markdown/.txt ou archive .zip de fichiers Markdown."
|
||||
extensions = (".md", ".markdown", ".txt", ".zip")
|
||||
order = 90
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
low = filename.lower()
|
||||
if low.endswith(_MD_EXTS):
|
||||
return True
|
||||
if low.endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
return False
|
||||
names = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
return bool(names) and all(n.lower().endswith(_MD_EXTS) for n in names)
|
||||
return False
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
if filename.lower().endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
result.warn(f"Archive invalide : {exc}")
|
||||
return result.finalize()
|
||||
entries = sorted(
|
||||
(n for n in zf.namelist()
|
||||
if not n.endswith("/") and n.lower().endswith(_MD_EXTS)),
|
||||
key=lambda n: (n.count("/"), n.lower()),
|
||||
)
|
||||
if not entries:
|
||||
result.warn("Aucun fichier Markdown trouvé dans l'archive")
|
||||
return result.finalize()
|
||||
for name in entries:
|
||||
try:
|
||||
text = decode_text(zf.read(name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"Lecture impossible : {name} ({exc})")
|
||||
continue
|
||||
parts = name.replace("\\", "/").split("/")
|
||||
result.pages.append(ImportPage(
|
||||
title=_title_from_name(name),
|
||||
markdown=text,
|
||||
source_path=name,
|
||||
parent_path="/".join(parts[:-1]),
|
||||
external_id=name,
|
||||
))
|
||||
return result.finalize()
|
||||
|
||||
text = decode_text(data)
|
||||
result.pages.append(ImportPage(
|
||||
title=_title_from_name(filename),
|
||||
markdown=text,
|
||||
source_path=filename,
|
||||
external_id=filename,
|
||||
))
|
||||
return result.finalize()
|
||||
@@ -0,0 +1,135 @@
|
||||
"""FlowDeck — Notion export importer (v5.6.0, Phase 1, amélioration v5.4.0).
|
||||
|
||||
Imports a Notion "Export as Markdown & CSV" ``.zip``: complete page hierarchy,
|
||||
databases (``.csv``) turned into FlowDeck collections, and image attachments.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
import zipfile
|
||||
from urllib.parse import unquote
|
||||
|
||||
from app.services.importers._common import split_frontmatter
|
||||
from app.services.importers.base import (
|
||||
ImportAttachment,
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
register_importer,
|
||||
)
|
||||
from app.services.importers.tabular import rows_to_collection
|
||||
|
||||
_HASH_RE = re.compile(r"\s+[0-9a-f]{32}$")
|
||||
_MD_LINK_RE = re.compile(r"\]\(([^)]+)\.md\)")
|
||||
|
||||
|
||||
def _clean_name(name: str) -> str:
|
||||
base = unquote(name.replace("\\", "/").rsplit("/", 1)[-1])
|
||||
base = re.sub(r"\.(md|csv|markdown)$", "", base, flags=re.IGNORECASE)
|
||||
return _HASH_RE.sub("", base).strip() or "Untitled"
|
||||
|
||||
|
||||
def _strip_hash_link(match: re.Match) -> str:
|
||||
target = unquote(match.group(1)).strip()
|
||||
return f"]({_HASH_RE.sub('', target).strip() or target})"
|
||||
|
||||
|
||||
@register_importer
|
||||
class NotionImporter(Importer):
|
||||
source_id = "notion"
|
||||
label = "Notion (export .zip)"
|
||||
description = "Export Notion Markdown & CSV : hiérarchie, databases → collections, images."
|
||||
extensions = (".zip",)
|
||||
order = 20
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
if not filename.lower().endswith(".zip"):
|
||||
return False
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
return False
|
||||
names = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
md = [n for n in names if n.lower().endswith(".md")]
|
||||
csvs = [n for n in names if n.lower().endswith(".csv")]
|
||||
if not md:
|
||||
return False
|
||||
if csvs:
|
||||
return True
|
||||
return any(_HASH_RE.search(unquote(n.rsplit("/", 1)[-1])) for n in md)
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
result.warn(f"Archive invalide : {exc}")
|
||||
return result.finalize()
|
||||
|
||||
names = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
md_names = [n for n in names if n.lower().endswith(".md")]
|
||||
csv_names = [n for n in names if n.lower().endswith(".csv")]
|
||||
|
||||
pages_by_title: dict[str, ImportPage] = {}
|
||||
for name in sorted(md_names, key=lambda n: (n.count("/"), n.lower())):
|
||||
try:
|
||||
text = decode_text(zf.read(name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"Lecture impossible : {name} ({exc})")
|
||||
continue
|
||||
meta, body = split_frontmatter(text)
|
||||
title = _clean_name(name)
|
||||
body = _MD_LINK_RE.sub(_strip_hash_link, body)
|
||||
first_line = body.lstrip().splitlines()[0] if body.strip() else ""
|
||||
if first_line.strip().startswith("# ") and first_line.strip()[2:].strip() == title:
|
||||
body = "\n".join(body.lstrip().splitlines()[1:]).lstrip("\n")
|
||||
parts = unquote(name).replace("\\", "/").split("/")
|
||||
page = ImportPage(
|
||||
title=title,
|
||||
markdown=body,
|
||||
source_path=name,
|
||||
parent_path="/".join(parts[:-1]),
|
||||
properties={k: v for k, v in meta.items() if k != "title"},
|
||||
external_id=name,
|
||||
)
|
||||
pages_by_title.setdefault(title, page)
|
||||
result.pages.append(page)
|
||||
|
||||
for name in sorted(csv_names, key=lambda n: (n.count("/"), n.lower())):
|
||||
try:
|
||||
text = decode_text(zf.read(name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"Lecture impossible : {name} ({exc})")
|
||||
continue
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
headers = [h for h in (reader.fieldnames or []) if h is not None]
|
||||
rows = [dict(r) for r in reader]
|
||||
title = _clean_name(name)
|
||||
spec_page = rows_to_collection(title, headers, rows)
|
||||
parts = unquote(name).replace("\\", "/").split("/")
|
||||
existing = pages_by_title.get(title)
|
||||
if existing is not None:
|
||||
existing.collection = spec_page.collection
|
||||
existing.source_path = existing.source_path or name
|
||||
else:
|
||||
spec_page.parent_path = "/".join(parts[:-1])
|
||||
spec_page.source_path = name
|
||||
spec_page.external_id = name
|
||||
result.pages.append(spec_page)
|
||||
|
||||
for name in names:
|
||||
low = name.lower()
|
||||
if low.endswith((".md", ".csv")):
|
||||
continue
|
||||
try:
|
||||
result.attachments.append(ImportAttachment(
|
||||
source_path=name,
|
||||
filename=unquote(name).replace("\\", "/").rsplit("/", 1)[-1],
|
||||
data=zf.read(name),
|
||||
))
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
return result.finalize()
|
||||
@@ -0,0 +1,118 @@
|
||||
"""FlowDeck — Obsidian vault importer (v5.6.0, Phase 1).
|
||||
|
||||
Imports a vault exported as a ``.zip``: Markdown notes (with YAML frontmatter),
|
||||
the folder hierarchy, ``[[wikilinks]]``/``![[embeds]]`` and binary attachments.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
from app.services.importers._common import (
|
||||
coerce_tags,
|
||||
convert_wikilinks,
|
||||
normalize_title,
|
||||
split_frontmatter,
|
||||
)
|
||||
from app.services.importers.base import (
|
||||
ImportAttachment,
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_SKIP_DIRS = (".obsidian/", ".trash/", ".git/", ".DS_Store")
|
||||
|
||||
|
||||
def _mime_for(name: str) -> str:
|
||||
import mimetypes
|
||||
|
||||
return mimetypes.guess_type(name)[0] or "application/octet-stream"
|
||||
|
||||
|
||||
@register_importer
|
||||
class ObsidianImporter(Importer):
|
||||
source_id = "obsidian"
|
||||
label = "Obsidian (vault .zip)"
|
||||
description = "Vault Obsidian : notes Markdown, frontmatter YAML, wikilinks, pièces jointes."
|
||||
extensions = (".zip",)
|
||||
order = 10
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
if not filename.lower().endswith(".zip"):
|
||||
return False
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
return False
|
||||
names = zf.namelist()
|
||||
if any("/.obsidian/" in n or n.startswith(".obsidian/") for n in names):
|
||||
return True
|
||||
# Heuristic: mostly-markdown archive containing wikilinks.
|
||||
md = [n for n in names if n.lower().endswith(".md")]
|
||||
if not md:
|
||||
return False
|
||||
for n in md[:20]:
|
||||
try:
|
||||
if "[[" in decode_text(zf.read(n)):
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
return False
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
result.warn(f"Archive invalide : {exc}")
|
||||
return result.finalize()
|
||||
|
||||
names = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
notes = [n for n in names if n.lower().endswith(".md")]
|
||||
assets = [n for n in names if not n.lower().endswith(".md")]
|
||||
|
||||
for name in assets:
|
||||
clean = name.replace("\\", "/")
|
||||
if any(part in clean for part in _SKIP_DIRS) or clean.split("/")[-1].startswith("."):
|
||||
continue
|
||||
try:
|
||||
payload = zf.read(name)
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
result.attachments.append(ImportAttachment(
|
||||
source_path=name,
|
||||
filename=clean.rsplit("/", 1)[-1],
|
||||
data=payload,
|
||||
mime=_mime_for(name),
|
||||
))
|
||||
|
||||
for name in sorted(notes, key=lambda n: (n.count("/"), n.lower())):
|
||||
clean = name.replace("\\", "/")
|
||||
if any(part in clean for part in _SKIP_DIRS):
|
||||
continue
|
||||
try:
|
||||
text = decode_text(zf.read(name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"Lecture impossible : {name} ({exc})")
|
||||
continue
|
||||
meta, body = split_frontmatter(text)
|
||||
body = convert_wikilinks(body)
|
||||
parts = clean.split("/")
|
||||
title = normalize_title(meta.get("title")) or parts[-1][:-3]
|
||||
props = dict(meta)
|
||||
props.pop("title", None)
|
||||
tags = coerce_tags(meta.get("tags"))
|
||||
if tags:
|
||||
props["tags"] = tags
|
||||
result.pages.append(ImportPage(
|
||||
title=title or "Untitled",
|
||||
markdown=body,
|
||||
source_path=clean,
|
||||
parent_path="/".join(parts[:-1]),
|
||||
properties=props,
|
||||
external_id=clean,
|
||||
))
|
||||
return result.finalize()
|
||||
@@ -0,0 +1,86 @@
|
||||
"""FlowDeck — OPML importer (v5.6.0, Phase 4).
|
||||
|
||||
Imports an OPML outline (RSS readers, feed lists) as a collection of feeds.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any
|
||||
|
||||
from app.services.importers.base import (
|
||||
Importer,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
make_collection,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_SCHEMA = [
|
||||
{"name": "Title", "type": "title"},
|
||||
{"name": "Feed URL", "type": "url"},
|
||||
{"name": "Site URL", "type": "url"},
|
||||
{"name": "Type", "type": "select", "options": [
|
||||
{"name": "rss", "color": "orange"},
|
||||
{"name": "folder", "color": "gray"},
|
||||
]},
|
||||
{"name": "Folder", "type": "text"},
|
||||
]
|
||||
|
||||
|
||||
@register_importer
|
||||
class OpmlImporter(Importer):
|
||||
source_id = "opml"
|
||||
label = "OPML (flux RSS)"
|
||||
description = "Outline OPML → collection de flux (titre, URL, dossier)."
|
||||
extensions = (".opml", ".xml")
|
||||
order = 34
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
if filename.lower().endswith(".opml"):
|
||||
return True
|
||||
head = decode_text(data)[:1000].lower()
|
||||
return "<opml" in head and "<outline" in head
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
rows: list[dict] = []
|
||||
try:
|
||||
root = ET.fromstring(decode_text(data))
|
||||
except ET.ParseError as exc:
|
||||
result.warn(f"OPML invalide : {exc}")
|
||||
return result.finalize()
|
||||
for outline in root.iter("outline"):
|
||||
attrs = {k.lower(): v for k, v in outline.attrib.items()}
|
||||
feed = attrs.get("xmlurl")
|
||||
title = attrs.get("title") or attrs.get("text") or feed or ""
|
||||
if not feed and not title:
|
||||
continue
|
||||
props: dict[str, Any] = {}
|
||||
if feed:
|
||||
props["Feed URL"] = feed
|
||||
props["Type"] = "rss"
|
||||
else:
|
||||
props["Type"] = "folder"
|
||||
if attrs.get("htmlurl"):
|
||||
props["Site URL"] = attrs["htmlurl"]
|
||||
folder = _folder_of(outline, root)
|
||||
if folder:
|
||||
props["Folder"] = folder
|
||||
rows.append({"title": title[:200] or "Feed", "properties": props})
|
||||
result.pages.append(make_collection("OPML feeds", _SCHEMA, rows, source_path=filename))
|
||||
result.stats["rows"] = len(rows)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
def _folder_of(node: ET.Element, root: ET.Element) -> str:
|
||||
parents = {child: parent for parent in root.iter() for child in parent}
|
||||
parts: list[str] = []
|
||||
current = parents.get(node)
|
||||
while current is not None:
|
||||
attrs = {k.lower(): v for k, v in current.attrib.items()}
|
||||
if not attrs.get("xmlurl"):
|
||||
label = attrs.get("title") or attrs.get("text")
|
||||
if label:
|
||||
parts.append(label)
|
||||
current = parents.get(current)
|
||||
return " / ".join(reversed(parts))
|
||||
@@ -0,0 +1,164 @@
|
||||
"""FlowDeck — Logseq / Roam Research outliner importer (v5.6.0, Phase 1)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import zipfile
|
||||
|
||||
from app.services.importers._common import (
|
||||
coerce_tags,
|
||||
convert_wikilinks,
|
||||
normalize_title,
|
||||
split_frontmatter,
|
||||
)
|
||||
from app.services.importers.base import (
|
||||
ImportAttachment,
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_LOGSEQ_MARKERS = ("property::", "logseq/", "journals/")
|
||||
_ROAM_MARKERS = ("{{[[TODO]]}}", "{{[[DONE]]}}", "{{[[query]]}}")
|
||||
_PROP_RE = re.compile(r"^\s*([a-zA-Z][\w-]*)::\s*(.*)$")
|
||||
|
||||
|
||||
def _journal_title(name: str) -> str:
|
||||
m = re.match(r"^(\d{4})[_-](\d{2})[_-](\d{2})", name)
|
||||
if m:
|
||||
return f"{m.group(1)}-{m.group(2)}-{m.group(3)}"
|
||||
return name
|
||||
|
||||
|
||||
def _clean_outline(text: str) -> tuple[dict, str]:
|
||||
meta, body = split_frontmatter(text)
|
||||
lines_out: list[str] = []
|
||||
for line in body.splitlines():
|
||||
m = _PROP_RE.match(line)
|
||||
if m and line.lstrip().startswith("-"):
|
||||
continue
|
||||
# Logseq properties appear as bare ``key:: value`` lines too.
|
||||
m2 = _PROP_RE.match(line)
|
||||
if m2 and not line.lstrip().startswith(("-", "*", "#", "|")):
|
||||
key = m2.group(1)
|
||||
if key not in meta:
|
||||
meta[key] = m2.group(2).strip()
|
||||
continue
|
||||
lines_out.append(line)
|
||||
body = "\n".join(lines_out)
|
||||
# Roam task markers → GFM checkboxes.
|
||||
body = body.replace("{{[[TODO]]}}", "[ ] ").replace("{{[[DONE]]}}", "[x] ")
|
||||
# Block references ((uuid)) → plain anchors.
|
||||
body = re.sub(r"\(\(([0-9a-fA-F-]{6,})\)\)", r"[[\1]]", body)
|
||||
body = convert_wikilinks(body)
|
||||
return meta, body
|
||||
|
||||
|
||||
class _OutlineBase(Importer):
|
||||
markers: tuple[str, ...] = ()
|
||||
property_syntax = False
|
||||
source_id = "outline"
|
||||
label = "Outliner"
|
||||
description = ""
|
||||
order = 30
|
||||
|
||||
def _text_matches(self, text: str) -> bool:
|
||||
if any(m in text for m in self.markers if not m.endswith("/")):
|
||||
return True
|
||||
return bool(self.property_syntax and re.search(r"^\s*[a-zA-Z][\w-]*::", text, re.M))
|
||||
|
||||
def _looks_like(self, filename: str, data: bytes) -> bool:
|
||||
low = filename.lower()
|
||||
if low.endswith((".md", ".markdown", ".txt")):
|
||||
return self._text_matches(decode_text(data))
|
||||
if low.endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError):
|
||||
return False
|
||||
names = zf.namelist()
|
||||
if any(m in name for m in self.markers if m.endswith("/") for name in names):
|
||||
return True
|
||||
for n in [x for x in names if x.lower().endswith(".md")][:10]:
|
||||
try:
|
||||
if self._text_matches(decode_text(zf.read(n))):
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
return False
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
return self._looks_like(filename, data)
|
||||
|
||||
def _emit(self, result: ImportResult, name: str, text: str, is_journal: bool) -> None:
|
||||
meta, body = _clean_outline(text)
|
||||
parts = name.replace("\\", "/").split("/")
|
||||
raw_title = parts[-1].rsplit(".", 1)[0]
|
||||
title = normalize_title(meta.get("title")) or (
|
||||
_journal_title(raw_title) if is_journal else raw_title
|
||||
)
|
||||
props = {k: v for k, v in meta.items() if k != "title"}
|
||||
tags = coerce_tags(meta.get("tags"))
|
||||
if tags:
|
||||
props["tags"] = tags
|
||||
result.pages.append(ImportPage(
|
||||
title=title or "Untitled",
|
||||
markdown=body,
|
||||
source_path=name,
|
||||
parent_path="/".join(parts[:-1]),
|
||||
properties=props,
|
||||
external_id=name,
|
||||
))
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
if filename.lower().endswith(".zip"):
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(data))
|
||||
except (zipfile.BadZipFile, OSError) as exc:
|
||||
result.warn(f"Archive invalide : {exc}")
|
||||
return result.finalize()
|
||||
names = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
for name in [n for n in names if not n.lower().endswith(".md")]:
|
||||
try:
|
||||
result.attachments.append(ImportAttachment(
|
||||
source_path=name,
|
||||
filename=name.replace("\\", "/").rsplit("/", 1)[-1],
|
||||
data=zf.read(name),
|
||||
))
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for name in sorted(
|
||||
(n for n in names if n.lower().endswith(".md")),
|
||||
key=lambda n: (n.count("/"), n.lower()),
|
||||
):
|
||||
try:
|
||||
text = decode_text(zf.read(name))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"Lecture impossible : {name} ({exc})")
|
||||
continue
|
||||
self._emit(result, name, text, is_journal="journal" in name.lower())
|
||||
return result.finalize()
|
||||
|
||||
text = decode_text(data)
|
||||
self._emit(result, filename, text, is_journal=False)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
@register_importer
|
||||
class LogseqImporter(_OutlineBase):
|
||||
source_id = "logseq"
|
||||
label = "Logseq"
|
||||
description = "Outliner Logseq : pages/journal, propriétés `key:: value`, block refs."
|
||||
markers = ("property::", "logseq/", "journals/")
|
||||
property_syntax = True
|
||||
|
||||
|
||||
@register_importer
|
||||
class RoamImporter(_OutlineBase):
|
||||
source_id = "roam"
|
||||
label = "Roam Research"
|
||||
description = "Outliner Roam : `{{[[TODO]]}}`, block refs, wikilinks."
|
||||
markers = _ROAM_MARKERS
|
||||
@@ -0,0 +1,94 @@
|
||||
"""FlowDeck — PDF importer (v5.6.0, Phase 3).
|
||||
|
||||
Best-effort text + image extraction from a PDF into a FlowDeck page (fidelity
|
||||
depends on the source PDF; scanned documents have no text layer).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from app.services.importers.base import (
|
||||
ImportAttachment,
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_MIME = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
||||
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
|
||||
".tiff": "image/tiff", ".tif": "image/tiff"}
|
||||
|
||||
|
||||
@register_importer
|
||||
class PdfImporter(Importer):
|
||||
source_id = "pdf"
|
||||
label = "PDF"
|
||||
description = "Extraction texte + images d'un PDF (fidélité limitée)."
|
||||
extensions = (".pdf",)
|
||||
order = 37
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
return filename.lower().endswith(".pdf")
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
try:
|
||||
from pypdf import PdfReader
|
||||
except ImportError:
|
||||
result.warn("pypdf n'est pas installé : import PDF indisponible")
|
||||
return result.finalize()
|
||||
try:
|
||||
reader = PdfReader(io.BytesIO(data))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"PDF illisible : {exc}")
|
||||
return result.finalize()
|
||||
|
||||
chunks: list[str] = []
|
||||
empty_pages = 0
|
||||
for index, page in enumerate(reader.pages, start=1):
|
||||
try:
|
||||
text = (page.extract_text() or "").strip()
|
||||
except Exception: # noqa: BLE001
|
||||
text = ""
|
||||
if text:
|
||||
if len(reader.pages) > 1:
|
||||
chunks.append(f"## Page {index}\n\n{text}")
|
||||
else:
|
||||
chunks.append(text)
|
||||
else:
|
||||
empty_pages += 1
|
||||
chunks.extend(self._page_images(page, index, result))
|
||||
|
||||
if empty_pages:
|
||||
result.warn(f"{empty_pages} page(s) sans couche texte (document scanné ?)")
|
||||
markdown = re.sub(r"\n{3,}", "\n\n", "\n\n".join(chunks)).strip()
|
||||
title = Path(filename).stem or "Document"
|
||||
result.pages.append(ImportPage(
|
||||
title=title, markdown=markdown, source_path=filename, external_id=filename,
|
||||
))
|
||||
return result.finalize()
|
||||
|
||||
def _page_images(self, page, index: int, result: ImportResult) -> list[str]:
|
||||
images: list[str] = []
|
||||
try:
|
||||
page_images = list(page.images)
|
||||
except Exception: # noqa: BLE001
|
||||
return images
|
||||
for i, image in enumerate(page_images, start=1):
|
||||
name = getattr(image, "name", "") or f"page{index}_img{i}.png"
|
||||
name = Path(name).name
|
||||
try:
|
||||
payload = image.data
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
if not payload:
|
||||
continue
|
||||
result.attachments.append(ImportAttachment(
|
||||
source_path=f"page{index}/{name}", filename=name, data=payload,
|
||||
mime=_MIME.get(Path(name).suffix.lower(), "application/octet-stream"),
|
||||
))
|
||||
images.append(f"")
|
||||
return images
|
||||
@@ -0,0 +1,566 @@
|
||||
"""FlowDeck — common import pipeline (v5.6.0, Phase 0).
|
||||
|
||||
Persists an :class:`~app.services.importers.base.ImportResult` into FlowDeck:
|
||||
resolves the workspace, rebuilds the folder hierarchy (``parent_id``), stores
|
||||
attachments, rewrites links, creates collections + rows, and records imported
|
||||
items for idempotent re-imports.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.db_templates import materialize_properties
|
||||
from app.services.export import markdown_to_blocks
|
||||
from app.services.importers.base import ImportPage, ImportResult
|
||||
from app.services.importers.tabular import apply_type_mapping
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_IMG_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
|
||||
|
||||
|
||||
def _data_dir() -> Path:
|
||||
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
|
||||
|
||||
|
||||
def _safe_filename(name: str) -> str:
|
||||
base = Path(name.replace("\\", "/")).name
|
||||
base = re.sub(r"[^\w.\- ()]+", "_", base).strip() or "file"
|
||||
return base[:150]
|
||||
|
||||
|
||||
def _sha1(*parts: str) -> str:
|
||||
return hashlib.sha1("||".join(parts).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _resolve_workspace(conn, workspace_id: int | None, workspace_name: str | None,
|
||||
user_login: str) -> tuple[int | None, str]:
|
||||
if workspace_id:
|
||||
row = conn.execute("SELECT id, name FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if row:
|
||||
return row["id"], row["name"]
|
||||
name = workspace_name or user_login
|
||||
if name:
|
||||
row = conn.execute("SELECT id, name FROM workspaces WHERE name=?", (name,)).fetchone()
|
||||
if row:
|
||||
return row["id"], row["name"]
|
||||
return workspace_id, name or ""
|
||||
|
||||
|
||||
def _rewrite_links(text: str, attachment_map: dict[str, str]) -> str:
|
||||
"""Point Markdown links/images at uploaded attachment URLs."""
|
||||
def repl(m: re.Match) -> str:
|
||||
alt, target = m.group(1), m.group(2)
|
||||
url = attachment_map.get(target) or attachment_map.get(Path(target).name.lower())
|
||||
return f"" if url else m.group(0)
|
||||
|
||||
text = re.sub(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)", repl, text)
|
||||
return text
|
||||
|
||||
|
||||
def _extract_media(blocks: list[dict]) -> list[dict]:
|
||||
"""Split inline image markdown out of paragraphs into real image blocks."""
|
||||
out: list[dict] = []
|
||||
for block in blocks:
|
||||
if block.get("type") != "paragraph":
|
||||
out.append(block)
|
||||
continue
|
||||
content = str(block.get("content", ""))
|
||||
pos = 0
|
||||
found = False
|
||||
for match in _IMG_RE.finditer(content):
|
||||
found = True
|
||||
before = content[pos:match.start()].strip()
|
||||
if before:
|
||||
out.append({"type": "paragraph", "content": before})
|
||||
out.append({"type": "image", "src": match.group(2), "alt": match.group(1)})
|
||||
pos = match.end()
|
||||
if not found:
|
||||
out.append(block)
|
||||
continue
|
||||
tail = content[pos:].strip()
|
||||
if tail:
|
||||
out.append({"type": "paragraph", "content": tail})
|
||||
return out
|
||||
|
||||
|
||||
def _properties_callout(props: dict) -> dict | None:
|
||||
if not props:
|
||||
return None
|
||||
lines = [f"**{k}** : {', '.join(map(str, v)) if isinstance(v, list) else v}" for k, v in props.items()]
|
||||
return {"type": "callout", "icon": "ℹ️", "content": "\n".join(lines)}
|
||||
|
||||
|
||||
def _blocks_for(page: ImportPage, attachment_map: dict[str, str], *, include_properties: bool) -> list[dict]:
|
||||
if page.blocks:
|
||||
return _extract_media(page.blocks)
|
||||
md = _rewrite_links(page.markdown or "", attachment_map)
|
||||
blocks = _extract_media(markdown_to_blocks(md))
|
||||
if include_properties and page.properties:
|
||||
callout = _properties_callout(page.properties)
|
||||
if callout:
|
||||
blocks.insert(0, callout)
|
||||
return blocks
|
||||
|
||||
|
||||
def preview_result(result: ImportResult) -> dict[str, Any]:
|
||||
"""Dry-run preview: what would be created, without touching the database."""
|
||||
pages = []
|
||||
for page in result.pages:
|
||||
if page.collection:
|
||||
kind = "collection"
|
||||
rows = len(page.collection.get("rows", []))
|
||||
blocks = len(_blocks_for(page, {}, include_properties=False))
|
||||
schema = page.collection.get("schema", [])
|
||||
else:
|
||||
kind = "page"
|
||||
rows = 0
|
||||
blocks = len(_blocks_for(page, {}, include_properties=False))
|
||||
schema = []
|
||||
pages.append({
|
||||
"title": page.title,
|
||||
"type": kind,
|
||||
"source_path": page.source_path,
|
||||
"parent_path": page.parent_path,
|
||||
"properties": list(page.properties.keys()),
|
||||
"rows": rows,
|
||||
"blocks": blocks,
|
||||
"schema": schema,
|
||||
})
|
||||
return {
|
||||
"source": result.source,
|
||||
"dry_run": True,
|
||||
"pages": pages,
|
||||
"stats": {
|
||||
**result.stats,
|
||||
"pages": len(result.pages),
|
||||
"collections": sum(1 for p in result.pages if p.collection),
|
||||
"attachments": len(result.attachments),
|
||||
"warnings": len(result.warnings),
|
||||
},
|
||||
"warnings": result.warnings,
|
||||
}
|
||||
|
||||
|
||||
def _insert_page(conn, *, workspace: str, workspace_id: int | None, title: str,
|
||||
blocks: list[dict], parent_id: int | None, sort_order: int,
|
||||
content_format: str = "blocks") -> int:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO pages (workspace, title, content, content_format, parent_section, "
|
||||
"sort_order, workspace_id, parent_id) VALUES (?,?,?,?,'Private',?,?,?)",
|
||||
(workspace, title or "Untitled", json.dumps(blocks), content_format,
|
||||
sort_order, workspace_id, parent_id),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def _prop_id_map(conn, collection_id: int) -> dict[str, int]:
|
||||
return {
|
||||
r["name"]: r["id"]
|
||||
for r in conn.execute(
|
||||
"SELECT id, name FROM collection_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
}
|
||||
|
||||
|
||||
def _rows_to_values(rows: list[dict], id_map: dict[str, int]) -> list[tuple[str, dict]]:
|
||||
"""Key row properties by property id (the shape the editor reads)."""
|
||||
out: list[tuple[str, dict]] = []
|
||||
for row in rows:
|
||||
values: dict[str, Any] = {}
|
||||
for name, value in (row.get("properties") or {}).items():
|
||||
prop_id = id_map.get(name)
|
||||
if prop_id is not None:
|
||||
values[str(prop_id)] = value
|
||||
out.append((row.get("title") or "Untitled", values))
|
||||
return out
|
||||
|
||||
|
||||
def _ensure_default_view(conn, collection_id: int) -> None:
|
||||
conn.execute(
|
||||
"INSERT INTO collection_views (collection_id, name, view_type, config_json) "
|
||||
"VALUES (?,?,?,?)",
|
||||
(collection_id, "Default View", "table",
|
||||
json.dumps({"visible_properties": ["Title"], "sorts": [], "filters": []})),
|
||||
)
|
||||
|
||||
|
||||
def _insert_collection(conn, page: ImportPage, *, workspace_id: int | None,
|
||||
parent_page_id: int | None) -> tuple[int, int]:
|
||||
spec = page.collection or {}
|
||||
schema = spec.get("schema", [])
|
||||
cur = conn.execute(
|
||||
"INSERT INTO collections (name, description, icon, schema_json, is_inline, "
|
||||
"parent_page_id, workspace_id) VALUES (?,?,?,?,1,?,?)",
|
||||
(page.title or spec.get("name") or "Imported database", "", "📥",
|
||||
json.dumps(schema), parent_page_id, workspace_id),
|
||||
)
|
||||
collection_id = cur.lastrowid
|
||||
materialize_properties(conn, collection_id, schema)
|
||||
_ensure_default_view(conn, collection_id)
|
||||
id_map = _prop_id_map(conn, collection_id)
|
||||
position = 0
|
||||
for title, values in _rows_to_values(spec.get("rows", []), id_map):
|
||||
conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) "
|
||||
"VALUES (?,?,?,?)",
|
||||
(collection_id, title, position, json.dumps(values)),
|
||||
)
|
||||
position += 1
|
||||
return collection_id, position
|
||||
|
||||
|
||||
def _upsert_collection_rows(conn, collection_id: int, rows: list[dict]) -> tuple[int, int]:
|
||||
"""Update existing rows by title, insert the new ones. Returns (created, updated)."""
|
||||
id_map = _prop_id_map(conn, collection_id)
|
||||
existing = {
|
||||
(r["title"] or "").strip(): r["id"]
|
||||
for r in conn.execute(
|
||||
"SELECT id, title FROM collection_pages WHERE collection_id=?", (collection_id,)
|
||||
).fetchall()
|
||||
}
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
created = updated = 0
|
||||
for title, values in _rows_to_values(rows, id_map):
|
||||
pid = existing.get((title or "").strip())
|
||||
if pid:
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(values), pid),
|
||||
)
|
||||
updated += 1
|
||||
else:
|
||||
max_pos += 1
|
||||
conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) "
|
||||
"VALUES (?,?,?,?)",
|
||||
(collection_id, title, max_pos, json.dumps(values)),
|
||||
)
|
||||
created += 1
|
||||
return created, updated
|
||||
|
||||
|
||||
def _update_page(conn, page_id: int, title: str, blocks: list[dict]) -> None:
|
||||
conn.execute(
|
||||
"UPDATE pages SET title=?, content=?, content_format='blocks', "
|
||||
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(title or "Untitled", json.dumps(blocks), page_id),
|
||||
)
|
||||
|
||||
|
||||
def run_import(
|
||||
result: ImportResult,
|
||||
*,
|
||||
workspace_id: int | None = None,
|
||||
workspace_name: str | None = None,
|
||||
user_login: str = "",
|
||||
parent_page_id: int | None = None,
|
||||
target_collection_id: int | None = None,
|
||||
dry_run: bool = False,
|
||||
dedup: bool = True,
|
||||
include_properties: bool = True,
|
||||
mapping: dict[str, str] | None = None,
|
||||
mode: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Persist an import result. Returns a report dict.
|
||||
|
||||
``mode`` controls re-import behaviour for items already imported (matched by
|
||||
``import_items``): ``skip`` (default), ``update`` (re-sync in place) or
|
||||
``duplicate`` (always create a new page).
|
||||
"""
|
||||
if dry_run:
|
||||
return preview_result(result)
|
||||
|
||||
if mapping:
|
||||
for page in result.pages:
|
||||
if page.collection:
|
||||
apply_type_mapping(page.collection, mapping)
|
||||
|
||||
if not mode:
|
||||
mode = "skip" if dedup else "duplicate"
|
||||
|
||||
report: dict[str, Any] = {
|
||||
"source": result.source,
|
||||
"status": "ok",
|
||||
"mode": mode,
|
||||
"pages_created": 0,
|
||||
"pages_updated": 0,
|
||||
"collections_created": 0,
|
||||
"rows_created": 0,
|
||||
"rows_updated": 0,
|
||||
"attachments": 0,
|
||||
"skipped": 0,
|
||||
"page_ids": [],
|
||||
"errors": [],
|
||||
"warnings": list(result.warnings),
|
||||
}
|
||||
|
||||
with get_conn() as conn:
|
||||
ws_id, ws_name = _resolve_workspace(conn, workspace_id, workspace_name, user_login)
|
||||
if not ws_name:
|
||||
report["status"] = "error"
|
||||
report["warnings"].append("Workspace introuvable")
|
||||
return report
|
||||
|
||||
attachment_map: dict[str, str] = {}
|
||||
if result.attachments and ws_id:
|
||||
dest_dir = _data_dir() / "uploads" / f"workspace_{ws_id}" / "import"
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
for att in result.attachments:
|
||||
safe = _safe_filename(att.filename)
|
||||
target = dest_dir / safe
|
||||
if target.exists():
|
||||
target = dest_dir / f"{_sha1(att.source_path)[:8]}_{safe}"
|
||||
try:
|
||||
target.write_bytes(att.data)
|
||||
except OSError:
|
||||
continue
|
||||
url = f"/api/files/{ws_id}/import/{target.name}"
|
||||
attachment_map[att.source_path] = url
|
||||
attachment_map[att.source_path.lower()] = url
|
||||
attachment_map[Path(att.source_path).name.lower()] = url
|
||||
report["attachments"] += 1
|
||||
|
||||
if target_collection_id:
|
||||
return _import_into_collection(
|
||||
conn, result, target_collection_id, report, attachment_map,
|
||||
dedup=dedup, workspace_id=ws_id, include_properties=include_properties,
|
||||
)
|
||||
|
||||
next_order = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL",
|
||||
(ws_name,),
|
||||
).fetchone()[0]
|
||||
order_counter = [next_order]
|
||||
path_to_page: dict[str, int] = {}
|
||||
folder_cache: dict[str, int | None] = {}
|
||||
|
||||
def ensure_folder(path: str, _depth: int = 0) -> int | None:
|
||||
path = (path or "").strip("/")
|
||||
if not path:
|
||||
return parent_page_id
|
||||
if path in folder_cache:
|
||||
return folder_cache[path]
|
||||
parent_path = "/".join(path.split("/")[:-1])
|
||||
parent_id = ensure_folder(parent_path, _depth + 1)
|
||||
title = path.split("/")[-1] or "Folder"
|
||||
pid = _insert_page(
|
||||
conn, workspace=ws_name, workspace_id=ws_id, title=title,
|
||||
blocks=[], parent_id=parent_id, sort_order=order_counter[0],
|
||||
)
|
||||
order_counter[0] += 1
|
||||
folder_cache[path] = pid
|
||||
path_to_page[path] = pid
|
||||
report["pages_created"] += 1
|
||||
report["page_ids"].append(pid)
|
||||
return pid
|
||||
|
||||
ordered = sorted(
|
||||
result.pages,
|
||||
key=lambda p: (p.parent_path.count("/") if p.parent_path else -1, p.source_path.lower()),
|
||||
)
|
||||
for page in ordered:
|
||||
external = page.external_id or page.source_path or page.title
|
||||
existing_pid = None
|
||||
if external:
|
||||
row = conn.execute(
|
||||
"SELECT page_id FROM import_items WHERE workspace_id IS ? AND source=? AND external_id=?",
|
||||
(ws_id, result.source, external),
|
||||
).fetchone()
|
||||
if row:
|
||||
existing_pid = row["page_id"]
|
||||
if existing_pid and mode == "skip":
|
||||
report["skipped"] += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
page_parent = ensure_folder(page.parent_path) if page.parent_path else parent_page_id
|
||||
blocks = _blocks_for(page, attachment_map, include_properties=include_properties)
|
||||
|
||||
if existing_pid and mode == "update":
|
||||
if page.collection:
|
||||
cid = _collection_for_page(conn, existing_pid)
|
||||
if cid:
|
||||
materialize_properties(conn, cid, (page.collection or {}).get("schema", []))
|
||||
created, updated = _upsert_collection_rows(
|
||||
conn, cid, (page.collection or {}).get("rows", []))
|
||||
report["rows_created"] += created
|
||||
report["rows_updated"] += updated
|
||||
blocks = blocks + [{"type": "embed", "embed_type": "collection",
|
||||
"collection_id": cid, "content": ""}]
|
||||
_update_page(conn, existing_pid, page.title, blocks)
|
||||
report["pages_updated"] += 1
|
||||
report["page_ids"].append(existing_pid)
|
||||
path_to_page[page.source_path] = existing_pid
|
||||
continue
|
||||
|
||||
if page.collection:
|
||||
pid = _insert_page(
|
||||
conn, workspace=ws_name, workspace_id=ws_id, title=page.title,
|
||||
blocks=blocks, parent_id=page_parent, sort_order=order_counter[0],
|
||||
)
|
||||
order_counter[0] += 1
|
||||
cid, rows = _insert_collection(conn, page, workspace_id=ws_id, parent_page_id=pid)
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=? WHERE id=?",
|
||||
(json.dumps(blocks + [{"type": "embed", "embed_type": "collection",
|
||||
"collection_id": cid, "content": ""}]), pid),
|
||||
)
|
||||
report["collections_created"] += 1
|
||||
report["rows_created"] += rows
|
||||
report["pages_created"] += 1
|
||||
report["page_ids"].append(pid)
|
||||
else:
|
||||
pid = _insert_page(
|
||||
conn, workspace=ws_name, workspace_id=ws_id, title=page.title,
|
||||
blocks=blocks, parent_id=page_parent, sort_order=order_counter[0],
|
||||
)
|
||||
order_counter[0] += 1
|
||||
report["pages_created"] += 1
|
||||
report["page_ids"].append(pid)
|
||||
|
||||
path_to_page[page.source_path] = pid
|
||||
if external:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO import_items (workspace_id, source, external_id, page_id) VALUES (?,?,?,?)",
|
||||
(ws_id, result.source, external, pid),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - partial import must keep going
|
||||
logger.warning("import failed for %r: %s", page.title, exc)
|
||||
report["errors"].append({"title": page.title, "error": str(exc)})
|
||||
conn.commit()
|
||||
if report["errors"]:
|
||||
report["status"] = "partial"
|
||||
return report
|
||||
|
||||
|
||||
def _collection_for_page(conn, page_id: int) -> int | None:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM collections WHERE parent_page_id=? ORDER BY id LIMIT 1", (page_id,)
|
||||
).fetchone()
|
||||
return row["id"] if row else None
|
||||
|
||||
|
||||
def _import_into_collection(conn, result: ImportResult, collection_id: int, report: dict,
|
||||
attachment_map: dict[str, str], *, dedup: bool,
|
||||
workspace_id: int | None, include_properties: bool) -> dict:
|
||||
exists = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
|
||||
if not exists:
|
||||
report["status"] = "error"
|
||||
report["warnings"].append("Collection cible introuvable")
|
||||
return report
|
||||
max_pos = conn.execute(
|
||||
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchone()[0]
|
||||
id_map = _prop_id_map(conn, collection_id)
|
||||
for page in result.pages:
|
||||
external = page.external_id or page.source_path or page.title
|
||||
if dedup and external:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM collection_pages WHERE collection_id=? AND title=?",
|
||||
(collection_id, page.title),
|
||||
).fetchone()
|
||||
if row:
|
||||
report["skipped"] += 1
|
||||
continue
|
||||
props = {
|
||||
str(id_map[name]): value
|
||||
for name, value in page.properties.items()
|
||||
if name in id_map
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) "
|
||||
"VALUES (?,?,?,?)",
|
||||
(collection_id, page.title, max_pos, json.dumps(props)),
|
||||
)
|
||||
max_pos += 1
|
||||
report["rows_created"] += 1
|
||||
conn.commit()
|
||||
return report
|
||||
|
||||
|
||||
def resolve_relations(conn, workspace_id: int | None) -> dict[str, Any]:
|
||||
"""Convert text columns that reference another imported collection's titles
|
||||
into real ``relation`` properties (array of ``collection_pages`` ids)."""
|
||||
collections = conn.execute(
|
||||
"SELECT id, name FROM collections WHERE workspace_id IS ?", (workspace_id,)
|
||||
).fetchall()
|
||||
if not collections:
|
||||
return {"relations_resolved": 0, "details": []}
|
||||
|
||||
titles: dict[int, dict[str, int]] = {}
|
||||
for coll in collections:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title FROM collection_pages WHERE collection_id=?", (coll["id"],)
|
||||
).fetchall()
|
||||
titles[coll["id"]] = {
|
||||
(r["title"] or "").strip(): r["id"]
|
||||
for r in rows if (r["title"] or "").strip()
|
||||
}
|
||||
|
||||
resolved = 0
|
||||
details: list[dict] = []
|
||||
for coll in collections:
|
||||
props = conn.execute(
|
||||
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=?",
|
||||
(coll["id"],),
|
||||
).fetchall()
|
||||
pages = conn.execute(
|
||||
"SELECT id, property_values_json FROM collection_pages WHERE collection_id=?",
|
||||
(coll["id"],),
|
||||
).fetchall()
|
||||
for prop in props:
|
||||
if prop["prop_type"] not in ("text", "select", "multi_select"):
|
||||
continue
|
||||
key = str(prop["id"])
|
||||
values: list[str] = []
|
||||
for page in pages:
|
||||
pv = json.loads(page["property_values_json"] or "{}")
|
||||
value = pv.get(key)
|
||||
if value in (None, "", []):
|
||||
continue
|
||||
items = value if isinstance(value, list) else [value]
|
||||
values.extend(str(v) for v in items)
|
||||
if not values:
|
||||
continue
|
||||
for target in collections:
|
||||
if target["id"] == coll["id"]:
|
||||
continue
|
||||
target_titles = titles.get(target["id"]) or {}
|
||||
if target_titles and all(v in target_titles for v in values):
|
||||
conn.execute(
|
||||
"UPDATE collection_properties SET prop_type='relation', "
|
||||
"related_collection_id=? WHERE id=?",
|
||||
(target["id"], prop["id"]),
|
||||
)
|
||||
for page in pages:
|
||||
pv = json.loads(page["property_values_json"] or "{}")
|
||||
value = pv.get(key)
|
||||
if value in (None, "", []):
|
||||
continue
|
||||
items = value if isinstance(value, list) else [value]
|
||||
pv[key] = [target_titles[str(v)] for v in items if str(v) in target_titles]
|
||||
conn.execute(
|
||||
"UPDATE collection_pages SET property_values_json=? WHERE id=?",
|
||||
(json.dumps(pv), page["id"]),
|
||||
)
|
||||
resolved += 1
|
||||
details.append({
|
||||
"collection": coll["name"], "property": prop["name"],
|
||||
"related": target["name"],
|
||||
})
|
||||
break
|
||||
conn.commit()
|
||||
return {"relations_resolved": resolved, "details": details}
|
||||
@@ -0,0 +1,96 @@
|
||||
"""FlowDeck — Standard Notes importer (v5.6.0, Phase 4).
|
||||
|
||||
Imports a Standard Notes backup (``.json``): each non-encrypted note becomes a
|
||||
FlowDeck page. Encrypted notes are reported as warnings.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.services.importers.base import (
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_NOTE_TYPES = ("note", "org.standardnotes.sn", "org.standardnotes.plain-text")
|
||||
|
||||
|
||||
def _note_body(content: Any) -> tuple[str, str, bool]:
|
||||
"""Return (title, markdown, encrypted)."""
|
||||
if isinstance(content, dict):
|
||||
if content.get("encrypted"):
|
||||
return "", "", True
|
||||
text = content.get("text") or content.get("preview_plain") or ""
|
||||
title = content.get("title") or ""
|
||||
return title, text, False
|
||||
if isinstance(content, str):
|
||||
stripped = content.strip()
|
||||
if stripped.startswith("{"):
|
||||
try:
|
||||
parsed = json.loads(stripped)
|
||||
if isinstance(parsed, dict) and ("encrypted" in parsed or "000" in parsed):
|
||||
return "", "", True
|
||||
if isinstance(parsed, dict):
|
||||
return parsed.get("title", ""), parsed.get("text", "") or parsed.get("preview_plain", ""), False
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return "", content, False
|
||||
return "", "", False
|
||||
|
||||
|
||||
@register_importer
|
||||
class StandardNotesImporter(Importer):
|
||||
source_id = "standard_notes"
|
||||
label = "Standard Notes"
|
||||
description = "Sauvegarde JSON Standard Notes → pages (notes chiffrées ignorées)."
|
||||
extensions = (".json",)
|
||||
order = 39
|
||||
|
||||
def _items(self, data: bytes) -> list[dict] | None:
|
||||
try:
|
||||
obj = json.loads(decode_text(data))
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
if isinstance(obj, dict) and isinstance(obj.get("items"), list):
|
||||
return [x for x in obj["items"] if isinstance(x, dict)]
|
||||
return None
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
if not filename.lower().endswith(".json"):
|
||||
return False
|
||||
items = self._items(data)
|
||||
if not items:
|
||||
return False
|
||||
return any("content_type" in it for it in items)
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
items = self._items(data) or []
|
||||
count = 0
|
||||
for item in items:
|
||||
if item.get("deleted"):
|
||||
continue
|
||||
ctype = str(item.get("content_type", "")).lower()
|
||||
if ctype and not any(t in ctype for t in _NOTE_TYPES):
|
||||
continue
|
||||
title, body, encrypted = _note_body(item.get("content"))
|
||||
if encrypted:
|
||||
result.warn("Note chiffrée ignorée (déchiffrement non pris en charge)")
|
||||
continue
|
||||
if not body.strip():
|
||||
continue
|
||||
if not title:
|
||||
title = next((ln.strip(" #") for ln in body.splitlines() if ln.strip()), "Note")
|
||||
result.pages.append(ImportPage(
|
||||
title=title[:200] or "Note",
|
||||
markdown=body,
|
||||
source_path=item.get("uuid") or filename,
|
||||
external_id=item.get("uuid") or f"{filename}#{count}",
|
||||
))
|
||||
count += 1
|
||||
result.stats["rows"] = count
|
||||
return result.finalize()
|
||||
@@ -0,0 +1,305 @@
|
||||
"""FlowDeck — tabular importers: typed CSV/TSV, Excel, generic JSON (v5.6.0, Phase 2).
|
||||
|
||||
Each source becomes a FlowDeck collection (database): columns are inferred from
|
||||
the data (text/number/date/checkbox/email/url/select/multi_select) and rows are
|
||||
inserted as ``collection_pages``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.services.importers.base import (
|
||||
Importer,
|
||||
ImportPage,
|
||||
ImportResult,
|
||||
decode_text,
|
||||
register_importer,
|
||||
)
|
||||
|
||||
_TITLE_HEADERS = ("title", "name", "task", "nom", "titre", "subject", "label")
|
||||
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?)?")
|
||||
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||
_URL_RE = re.compile(r"^https?://\S+$", re.IGNORECASE)
|
||||
_BOOL_TRUE = {"true", "yes", "oui", "1", "x", "vrai"}
|
||||
_BOOL_FALSE = {"false", "no", "non", "0", "faux", ""}
|
||||
|
||||
|
||||
def _is_number(value: str) -> bool:
|
||||
try:
|
||||
float(str(value).replace(",", ".").replace(" ", ""))
|
||||
return True
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
def _is_bool(value: str) -> bool:
|
||||
return str(value).strip().lower() in _BOOL_TRUE | _BOOL_FALSE
|
||||
|
||||
|
||||
def infer_column_type(values: list[str]) -> str:
|
||||
"""Infer the FlowDeck property type from a list of raw string cells."""
|
||||
sample = [str(v).strip() for v in values if str(v).strip()]
|
||||
if not sample:
|
||||
return "text"
|
||||
if all(_is_bool(v) for v in sample):
|
||||
return "checkbox"
|
||||
if all(_is_number(v) for v in sample):
|
||||
return "number"
|
||||
if all(_DATE_RE.match(v) for v in sample):
|
||||
return "date"
|
||||
if all(_EMAIL_RE.match(v) for v in sample):
|
||||
return "email"
|
||||
if all(_URL_RE.match(v) for v in sample):
|
||||
return "url"
|
||||
unique = {v for v in sample}
|
||||
if len(unique) <= 20 and len(unique) <= max(2, len(sample) // 2):
|
||||
if any(("," in v or ";" in v) for v in sample):
|
||||
return "multi_select"
|
||||
return "select"
|
||||
return "text"
|
||||
|
||||
|
||||
def _split_multi(value: str) -> list[str]:
|
||||
return [p.strip() for p in re.split(r"[;,]", value) if p.strip()]
|
||||
|
||||
|
||||
def coerce_value(prop_type: str, value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
raw = str(value).strip()
|
||||
if raw == "":
|
||||
return None
|
||||
if prop_type == "number":
|
||||
try:
|
||||
num = float(raw.replace(",", ".").replace(" ", ""))
|
||||
return int(num) if num.is_integer() else num
|
||||
except (ValueError, TypeError):
|
||||
return raw
|
||||
if prop_type == "checkbox":
|
||||
return raw.lower() in _BOOL_TRUE
|
||||
if prop_type == "multi_select":
|
||||
return _split_multi(raw)
|
||||
return raw
|
||||
|
||||
|
||||
def build_schema(headers: list[str], rows: list[dict[str, Any]]) -> tuple[list[dict], str]:
|
||||
"""Return ``(schema, title_header)`` from headers + row dicts."""
|
||||
title_header = ""
|
||||
for h in headers:
|
||||
if h and h.strip().lower() in _TITLE_HEADERS:
|
||||
title_header = h
|
||||
break
|
||||
schema: list[dict] = []
|
||||
for h in headers:
|
||||
if not h or h == title_header:
|
||||
continue
|
||||
ptype = infer_column_type([r.get(h, "") for r in rows])
|
||||
entry: dict[str, Any] = {"name": h, "type": ptype}
|
||||
if ptype in ("select", "status", "multi_select"):
|
||||
seen: list[str] = []
|
||||
for r in rows:
|
||||
vals = _split_multi(str(r.get(h, ""))) if ptype == "multi_select" else [str(r.get(h, "")).strip()]
|
||||
for v in vals:
|
||||
if v and v not in seen:
|
||||
seen.append(v)
|
||||
entry["options"] = [{"name": v, "color": "gray"} for v in seen[:100]]
|
||||
schema.append(entry)
|
||||
if title_header:
|
||||
schema.insert(0, {"name": title_header, "type": "title"})
|
||||
return schema, title_header
|
||||
|
||||
|
||||
def rows_to_collection(name: str, headers: list[str], raw_rows: list[dict[str, Any]]) -> ImportPage:
|
||||
"""Normalize parsed rows into an ImportPage carrying a collection spec."""
|
||||
schema, title_header = build_schema(headers, raw_rows)
|
||||
rows = _normalize_rows(headers, raw_rows, schema, title_header)
|
||||
return ImportPage(
|
||||
title=name or "Imported database",
|
||||
collection={
|
||||
"name": name or "Imported database",
|
||||
"schema": schema,
|
||||
"rows": rows,
|
||||
"headers": headers,
|
||||
"title_header": title_header,
|
||||
"raw_rows": raw_rows,
|
||||
},
|
||||
source_path=name,
|
||||
external_id=name,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_rows(headers: list[str], raw_rows: list[dict[str, Any]],
|
||||
schema: list[dict], title_header: str) -> list[dict[str, Any]]:
|
||||
types = {s["name"]: s["type"] for s in schema}
|
||||
rows: list[dict[str, Any]] = []
|
||||
for raw in raw_rows:
|
||||
title = ""
|
||||
if title_header:
|
||||
title = str(raw.get(title_header, "")).strip()
|
||||
if not title:
|
||||
for h in headers:
|
||||
if h and str(raw.get(h, "")).strip():
|
||||
title = str(raw[h]).strip()
|
||||
break
|
||||
props: dict[str, Any] = {}
|
||||
for h in headers:
|
||||
if not h or h == title_header:
|
||||
continue
|
||||
val = coerce_value(types.get(h, "text"), raw.get(h))
|
||||
if val is not None and val != "":
|
||||
props[h] = val
|
||||
rows.append({"title": title or "Untitled", "properties": props})
|
||||
return rows
|
||||
|
||||
|
||||
def apply_type_mapping(spec: dict, mapping: dict[str, str]) -> dict:
|
||||
"""Override inferred column types (UI mapping) and re-coerce the rows."""
|
||||
if not mapping:
|
||||
return spec
|
||||
for entry in spec.get("schema", []):
|
||||
if entry.get("name") in mapping:
|
||||
entry["type"] = mapping[entry["name"]]
|
||||
headers = spec.get("headers")
|
||||
raw_rows = spec.get("raw_rows")
|
||||
if headers is not None and raw_rows is not None:
|
||||
spec["rows"] = _normalize_rows(headers, raw_rows, spec.get("schema", []),
|
||||
spec.get("title_header", ""))
|
||||
return spec
|
||||
|
||||
|
||||
def _sniff_delimiter(sample: str) -> str:
|
||||
try:
|
||||
return csv.Sniffer().sniff(sample, delimiters=",;\t|").delimiter
|
||||
except csv.Error:
|
||||
return "\t" if sample.count("\t") > sample.count(",") else ","
|
||||
|
||||
|
||||
@register_importer
|
||||
class CsvImporter(Importer):
|
||||
source_id = "csv"
|
||||
label = "CSV / TSV (typé)"
|
||||
description = "Tableur CSV/TSV : types inférés automatiquement, une collection par fichier."
|
||||
extensions = (".csv", ".tsv")
|
||||
order = 60
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
return filename.lower().endswith((".csv", ".tsv"))
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
text = decode_text(data)
|
||||
if not text.strip():
|
||||
result.warn("Fichier vide")
|
||||
return result.finalize()
|
||||
delimiter = "\t" if filename.lower().endswith(".tsv") else _sniff_delimiter(text[:4096])
|
||||
reader = csv.DictReader(io.StringIO(text), delimiter=delimiter)
|
||||
headers = [h for h in (reader.fieldnames or []) if h is not None]
|
||||
rows = [dict(r) for r in reader]
|
||||
name = filename.replace("\\", "/").rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
||||
result.pages.append(rows_to_collection(name, headers, rows))
|
||||
result.stats["rows"] = len(rows)
|
||||
return result.finalize()
|
||||
|
||||
|
||||
@register_importer
|
||||
class ExcelImporter(Importer):
|
||||
source_id = "excel"
|
||||
label = "Excel (.xlsx)"
|
||||
description = "Classeur Excel : une collection par feuille (openpyxl)."
|
||||
extensions = (".xlsx", ".xlsm")
|
||||
order = 61
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
low = filename.lower()
|
||||
if low.endswith((".xlsx", ".xlsm")):
|
||||
return True
|
||||
return low.endswith(".xls")
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
try:
|
||||
from openpyxl import load_workbook
|
||||
except ImportError:
|
||||
result.warn("openpyxl n'est pas installé : import Excel indisponible")
|
||||
return result.finalize()
|
||||
try:
|
||||
wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result.warn(f"Classeur illisible : {exc}")
|
||||
return result.finalize()
|
||||
base = filename.replace("\\", "/").rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
||||
total_rows = 0
|
||||
for ws in wb.worksheets:
|
||||
values = list(ws.iter_rows(values_only=True))
|
||||
if not values:
|
||||
continue
|
||||
headers = [str(h).strip() if h is not None else f"Column {i + 1}" for i, h in enumerate(values[0])]
|
||||
rows: list[dict[str, Any]] = []
|
||||
for row in values[1:]:
|
||||
if row is None or all(c is None or str(c).strip() == "" for c in row):
|
||||
continue
|
||||
rows.append({headers[i]: row[i] for i in range(min(len(headers), len(row)))})
|
||||
if not rows:
|
||||
continue
|
||||
name = f"{base} — {ws.title}" if len(wb.worksheets) > 1 else (base or ws.title)
|
||||
page = rows_to_collection(name, headers, rows)
|
||||
page.source_path = f"{filename}#{ws.title}"
|
||||
page.external_id = page.source_path
|
||||
result.pages.append(page)
|
||||
total_rows += len(rows)
|
||||
result.stats["rows"] = total_rows
|
||||
return result.finalize()
|
||||
|
||||
|
||||
@register_importer
|
||||
class JsonImporter(Importer):
|
||||
source_id = "json"
|
||||
label = "JSON (mapping générique)"
|
||||
description = "Tableau d'objets JSON → collection (union des clés)."
|
||||
extensions = (".json",)
|
||||
order = 65
|
||||
|
||||
def _records(self, data: bytes) -> list[dict] | None:
|
||||
try:
|
||||
obj = json.loads(decode_text(data))
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
if isinstance(obj, list) and obj and all(isinstance(x, dict) for x in obj):
|
||||
return obj
|
||||
if isinstance(obj, dict):
|
||||
for value in obj.values():
|
||||
if isinstance(value, list) and value and all(isinstance(x, dict) for x in value):
|
||||
return value
|
||||
return None
|
||||
|
||||
def detect(self, filename: str, data: bytes) -> bool:
|
||||
if not filename.lower().endswith(".json"):
|
||||
return False
|
||||
return self._records(data) is not None
|
||||
|
||||
def parse(self, filename: str, data: bytes) -> ImportResult:
|
||||
result = ImportResult(source=self.source_id)
|
||||
records = self._records(data)
|
||||
if not records:
|
||||
result.warn("Aucun tableau d'objets JSON détecté")
|
||||
return result.finalize()
|
||||
headers: list[str] = []
|
||||
for rec in records:
|
||||
for key in rec:
|
||||
if key not in headers:
|
||||
headers.append(key)
|
||||
flat: list[dict[str, Any]] = []
|
||||
for rec in records:
|
||||
row = {}
|
||||
for h in headers:
|
||||
v = rec.get(h)
|
||||
row[h] = json.dumps(v, ensure_ascii=False) if isinstance(v, (dict, list)) else v
|
||||
flat.append(row)
|
||||
name = filename.replace("\\", "/").rsplit("/", 1)[-1].rsplit(".", 1)[0]
|
||||
result.pages.append(rows_to_collection(name, headers, flat))
|
||||
result.stats["rows"] = len(flat)
|
||||
return result.finalize()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""FlowDeck — URL / web clipper importer (v5.6.0, Phase 5).
|
||||
|
||||
Fetches a web page and turns it into a page: a bookmark card (OG metadata)
|
||||
followed by the article converted to FlowDeck blocks.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from app.services.export import markdown_to_blocks
|
||||
from app.services.importers.base import ImportPage, ImportResult
|
||||
from app.services.importers.html_notes import _html_to_markdown
|
||||
|
||||
_BLOCKED_HOSTS = {"localhost", "localhost.localdomain"}
|
||||
_MAX_BYTES = 3_000_000
|
||||
|
||||
|
||||
def _is_public_host(host: str) -> bool:
|
||||
"""SSRF guard: reject loopback/private/link-local/reserved addresses."""
|
||||
if not host or host.lower() in _BLOCKED_HOSTS:
|
||||
return False
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, None)
|
||||
except socket.gaierror:
|
||||
return False
|
||||
for info in infos:
|
||||
address = info[4][0]
|
||||
try:
|
||||
ip = ipaddress.ip_address(address)
|
||||
except ValueError:
|
||||
return False
|
||||
if (ip.is_private or ip.is_loopback or ip.is_link_local
|
||||
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _validate_url(url: str) -> str:
|
||||
parsed = urlparse(url.strip())
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
raise ValueError("Seules les URLs http(s) sont autorisées")
|
||||
if not parsed.hostname or not _is_public_host(parsed.hostname):
|
||||
raise ValueError("Hôte non autorisé")
|
||||
return url.strip()
|
||||
|
||||
|
||||
def _bookmark_block(url: str, meta: dict) -> dict:
|
||||
block = {"type": "bookmark", "url": url}
|
||||
for key in ("title", "description", "image", "site_name"):
|
||||
if meta.get(key):
|
||||
block[key] = meta[key]
|
||||
return block
|
||||
|
||||
|
||||
async def fetch_url_result(url: str, *, transport: httpx.BaseTransport | None = None) -> ImportResult:
|
||||
"""Fetch ``url`` and build a single-page ImportResult (raises on bad URL)."""
|
||||
safe_url = _validate_url(url)
|
||||
result = ImportResult(source="url")
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=15, follow_redirects=True, transport=transport,
|
||||
headers={"User-Agent": "FlowDeck-Importer/1.0"},
|
||||
) as client:
|
||||
response = await client.get(safe_url)
|
||||
response.raise_for_status()
|
||||
if response.url.host and not _is_public_host(response.url.host):
|
||||
raise ValueError("Redirection vers un hôte non autorisé")
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if "html" not in content_type.lower():
|
||||
raise ValueError("La ressource n'est pas une page HTML")
|
||||
body = response.text[:_MAX_BYTES]
|
||||
except httpx.HTTPError as exc:
|
||||
result.warn(f"Échec du téléchargement : {exc}")
|
||||
return result.finalize()
|
||||
|
||||
from app.services.og_fetcher import parse_og
|
||||
|
||||
meta = parse_og(body, safe_url)
|
||||
title = (meta.get("title") or urlparse(safe_url).hostname or "Page").strip()
|
||||
markdown = _html_to_markdown(body)
|
||||
blocks = [_bookmark_block(safe_url, meta)]
|
||||
if markdown:
|
||||
blocks.extend(markdown_to_blocks(markdown))
|
||||
result.pages.append(ImportPage(
|
||||
title=title[:200],
|
||||
blocks=blocks,
|
||||
source_path=safe_url,
|
||||
external_id=safe_url,
|
||||
))
|
||||
return result.finalize()
|
||||
+106
-10
@@ -5,8 +5,11 @@ directly — it emits *tool intentions* (function calls) that AgentEngine turns
|
||||
into guarded internal actions.
|
||||
|
||||
Supported providers (OpenAI-compatible chat-completions JSON response):
|
||||
openai, anthropic*, google*, deepseek, qwencloud, nvidia, openrouter, ollama.
|
||||
(* routed through an OpenAI-compatible gateway / any configured api_base)
|
||||
openai, anthropic, mistral, cohere, google, groq, deepseek, openrouter,
|
||||
nvidia, together, perplexity, xai, qwencloud, minimax, morph, fireworks,
|
||||
cerebras, sambanova, chutes, xiaomi, sealion, sensenova,
|
||||
ollama (local, no key). Anthropic, Google (`/v1beta/openai`) and Cohere
|
||||
(`/compatibility/v1`) expose an OpenAI-compatible surface at their base URL.
|
||||
|
||||
When no API key is configured (or provider == "offline") the client falls back
|
||||
to a deterministic, dependency-free *mock planner*. This keeps the whole agent
|
||||
@@ -28,30 +31,104 @@ from app.config import settings
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Provider → default model + base URL when llm_model/api_base are empty.
|
||||
# All entries speak the OpenAI-compatible chat-completions protocol (Anthropic,
|
||||
# Google and Cohere expose an OpenAI-compatible surface at their given base).
|
||||
PROVIDERS = {
|
||||
"openai": ("https://api.openai.com/v1", "gpt-4o"),
|
||||
"anthropic": ("https://api.anthropic.com/v1", "claude-opus-4-8"),
|
||||
"google": ("https://generativelanguage.googleapis.com/v1beta", "gemini-2.0-pro"),
|
||||
"mistral": ("https://api.mistral.ai/v1", "mistral-large-latest"),
|
||||
"cohere": ("https://api.cohere.ai/compatibility/v1", "command-a-plus-05-2026"),
|
||||
"google": ("https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.0-flash"),
|
||||
"groq": ("https://api.groq.com/openai/v1", "llama-3.3-70b-versatile"),
|
||||
"deepseek": ("https://api.deepseek.com/v1", "deepseek-chat"),
|
||||
"qwencloud": ("https://dashscope.aliyuncs.com/compatible-mode/v1", "qwen-max"),
|
||||
"nvidia": ("https://integrate.api.nvidia.com/v1", "nvidia/nemotron-3-super-120b-a12b"),
|
||||
"openrouter": ("https://openrouter.ai/api/v1", "meta-llama/llama-3.3-70b-instruct"),
|
||||
"nvidia": ("https://integrate.api.nvidia.com/v1", "nvidia/nemotron-3-super-120b-a12b"),
|
||||
"together": ("https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo"),
|
||||
"perplexity": ("https://api.perplexity.ai", "sonar-pro"),
|
||||
"xai": ("https://api.x.ai/v1", "grok-4.6"),
|
||||
"qwencloud": ("https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "qwen-max"),
|
||||
"minimax": ("https://api.minimax.chat/v1", "MiniMax-Text-01"),
|
||||
"morph": ("https://api.morphllm.com/v1", "morph-v3-large"),
|
||||
"fireworks": ("https://api.fireworks.ai/inference/v1",
|
||||
"accounts/fireworks/models/deepseek-v4-pro-0813"),
|
||||
"cerebras": ("https://api.cerebras.ai/v1", "llama-3.3-70b"),
|
||||
"sambanova": ("https://api.sambanova.ai/v1", "Meta-Llama-3.3-70B-Instruct"),
|
||||
"chutes": ("https://llm.chutes.ai/v1", "deepseek-ai/DeepSeek-V3"),
|
||||
"xiaomi": ("https://api.xiaomimimo.com/v1", "mimo-7b-rl"),
|
||||
"sealion": ("https://api.sea-lion.ai/v1", "aisingapore/Llama-SEA-LION-v3-70B-IT"),
|
||||
"sensenova": ("https://api.sensenova.cn/compatible-mode/v1", "SenseChat-5"),
|
||||
"ollama": ("http://localhost:11434/v1", "llama3.1"),
|
||||
"offline": (None, None),
|
||||
}
|
||||
|
||||
# Friendly display names for the Settings / Agent UIs.
|
||||
PROVIDER_LABELS: dict[str, str] = {
|
||||
"openai": "OpenAI",
|
||||
"anthropic": "Anthropic",
|
||||
"mistral": "Mistral",
|
||||
"cohere": "Cohere",
|
||||
"google": "Google Gemini",
|
||||
"groq": "Groq",
|
||||
"deepseek": "DeepSeek",
|
||||
"openrouter": "OpenRouter",
|
||||
"nvidia": "NVIDIA NIM",
|
||||
"together": "Together AI",
|
||||
"perplexity": "Perplexity",
|
||||
"xai": "xAI (Grok)",
|
||||
"qwencloud": "DashScope (Alibaba)",
|
||||
"minimax": "MiniMax",
|
||||
"morph": "Morph",
|
||||
"fireworks": "Fireworks AI",
|
||||
"cerebras": "Cerebras",
|
||||
"sambanova": "SambaNova",
|
||||
"chutes": "Chutes AI",
|
||||
"xiaomi": "Xiaomi (MiMo)",
|
||||
"sealion": "SEA-LION",
|
||||
"sensenova": "SenseNova",
|
||||
"ollama": "Ollama (local)",
|
||||
"offline": "Hors-ligne (mock)",
|
||||
}
|
||||
|
||||
# Curated model presets surfaced by /api/agent/providers for the UI selectors.
|
||||
PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"openai": ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "o3-mini", "gpt-4-turbo"],
|
||||
"anthropic": ["claude-opus-4-8", "claude-sonnet-4-5", "claude-3-5-sonnet", "claude-haiku-4-5"],
|
||||
"google": ["gemini-2.0-pro", "gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash"],
|
||||
"mistral": ["mistral-large-latest", "mistral-medium-latest", "mistral-small-latest",
|
||||
"codestral-latest", "open-mistral-nemo", "pixtral-large-latest"],
|
||||
"cohere": ["command-a-plus-05-2026", "command-r-plus", "command-r", "command-a-03-2025"],
|
||||
"google": ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-pro", "gemini-1.5-flash"],
|
||||
"groq": ["llama-3.3-70b-versatile", "llama-3.1-8b-instant",
|
||||
"mixtral-8x7b-32768", "gemma2-9b-it"],
|
||||
"deepseek": ["deepseek-chat", "deepseek-reasoner"],
|
||||
"qwencloud": ["qwen-max", "qwen-plus", "qwen-turbo", "qwen-long"],
|
||||
"openrouter": ["meta-llama/llama-3.3-70b-instruct", "anthropic/claude-3.5-sonnet",
|
||||
"openai/gpt-4o", "mistralai/mistral-large"],
|
||||
"nvidia": ["nvidia/nemotron-3-super-120b-a12b", "nvidia/nemotron-3-nano-30b-a3b",
|
||||
"meta/llama-3.1-70b-instruct", "nvidia/llama-3.3-nemotron-super-49b-v1.5",
|
||||
"deepseek-ai/deepseek-v4-pro", "z-ai/glm-5.2"],
|
||||
"openrouter": ["meta-llama/llama-3.3-70b-instruct", "anthropic/claude-3.5-sonnet",
|
||||
"openai/gpt-4o", "mistralai/mistral-large"],
|
||||
"together": ["meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
"meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo",
|
||||
"Qwen/Qwen2.5-72B-Instruct-Turbo", "mistralai/Mixtral-8x7B-Instruct-v0.1"],
|
||||
"perplexity": ["sonar-pro", "sonar", "sonar-reasoning", "sonar-deep-research"],
|
||||
"xai": ["grok-4.6", "grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning",
|
||||
"grok-build-0.1"],
|
||||
"qwencloud": ["qwen-max", "qwen-plus", "qwen-turbo", "qwen-long"],
|
||||
"minimax": ["MiniMax-Text-01", "abab6.5s-chat", "abab6.5-chat"],
|
||||
"morph": ["morph-v3-large", "morph-v3-fast"],
|
||||
"fireworks": ["accounts/fireworks/models/deepseek-v4-pro-0813",
|
||||
"accounts/fireworks/models/kimi-k2p6",
|
||||
"accounts/fireworks/models/glm-5p2",
|
||||
"accounts/fireworks/models/minimax-m3",
|
||||
"accounts/fireworks/models/gpt-oss-120b",
|
||||
"accounts/fireworks/models/qwen3-8b"],
|
||||
"cerebras": ["llama-3.3-70b", "llama3.1-8b", "llama-3.1-70b"],
|
||||
"sambanova": ["Meta-Llama-3.3-70B-Instruct", "Meta-Llama-3.1-405B-Instruct",
|
||||
"Qwen2.5-72B-Instruct"],
|
||||
"chutes": ["deepseek-ai/DeepSeek-V3", "deepseek-ai/DeepSeek-R1",
|
||||
"Qwen/Qwen2.5-72B-Instruct"],
|
||||
"xiaomi": ["mimo-7b-rl", "mimo-7b"],
|
||||
"sealion": ["aisingapore/Llama-SEA-LION-v3-70B-IT",
|
||||
"aisingapore/Gemma-SEA-LION-v3-9B-IT"],
|
||||
"sensenova": ["SenseChat-5", "SenseChat-5-Cantonese", "SenseChat-Turbo"],
|
||||
"ollama": ["llama3.1", "llama3", "mistral", "qwen2.5", "gemma2", "mixtral"],
|
||||
"offline": [],
|
||||
}
|
||||
@@ -162,6 +239,24 @@ class LLMClient:
|
||||
def _endpoint(self) -> str:
|
||||
return f"{self.api_base.rstrip('/')}/chat/completions"
|
||||
|
||||
@staticmethod
|
||||
def _http_error_detail(exc: httpx.HTTPStatusError) -> str:
|
||||
"""Human-readable HTTP error including the provider's response body.
|
||||
|
||||
Providers return actionable JSON on 4xx (e.g. « model not allowed »,
|
||||
« country not supported »); surfacing it makes the Settings test
|
||||
debuggable instead of a bare « 403 Forbidden ».
|
||||
"""
|
||||
resp = exc.response
|
||||
try:
|
||||
body = (resp.text or "").strip()
|
||||
except Exception: # noqa: BLE001 — body already consumed / undecodable
|
||||
body = ""
|
||||
if len(body) > 500:
|
||||
body = body[:500] + "…"
|
||||
base = f"{resp.status_code} {resp.reason_phrase} ({resp.url})"
|
||||
return f"{base}: {body}" if body else base
|
||||
|
||||
async def _http_complete(self, messages, model, tools, *, _noticer: str = "") -> LLMResponse:
|
||||
payload: dict = {
|
||||
"model": model,
|
||||
@@ -195,7 +290,8 @@ class LLMClient:
|
||||
f"Le modèle « {model} » n'est plus disponible ({exc.response.status_code}). "
|
||||
f"Réponse générée avec « {self.default_model} » à la place."
|
||||
))
|
||||
raise
|
||||
# Surface the provider's own error body (403 « forbidden », 400 …).
|
||||
raise RuntimeError(self._http_error_detail(exc)) from exc
|
||||
|
||||
response = self._parse_response(data, model)
|
||||
response.notice = _noticer or ""
|
||||
|
||||
+47
-12
@@ -14,7 +14,7 @@ import json
|
||||
import time
|
||||
|
||||
from app.config import settings
|
||||
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS
|
||||
from app.services.llm_client import PROVIDER_LABELS, PROVIDER_MODELS, PROVIDERS
|
||||
|
||||
# Providers whose /v1/models lists far more entries than /v1/chat/completions
|
||||
# actually serves. The fetched list is validated (name filter + live probe)
|
||||
@@ -101,8 +101,12 @@ def set_llm_config(*, provider: str | None = None, model: str | None = None,
|
||||
cfg["verified"] = 0
|
||||
cfg["verified_model"] = ""
|
||||
cfg["last_error"] = ""
|
||||
if api_base is not None and api_base.strip():
|
||||
cfg["api_base"] = api_base.strip()
|
||||
if api_base is not None:
|
||||
# Normalise so a base equal to the provider default is stored as "use
|
||||
# default" — a later correction of PROVIDERS then applies automatically.
|
||||
cfg["api_base"] = _normalize_api_base(
|
||||
provider or cfg.get("provider") or "offline", api_base
|
||||
)
|
||||
if clear_keys:
|
||||
cfg["api_key"] = ""
|
||||
cfg["api_base"] = ""
|
||||
@@ -165,7 +169,7 @@ def provider_info() -> list[dict]:
|
||||
models.insert(0, default_model)
|
||||
out.append({
|
||||
"id": name,
|
||||
"name": name.capitalize(),
|
||||
"name": PROVIDER_LABELS.get(name) or name.replace("_", " ").title(),
|
||||
"default_model": default_model,
|
||||
"models": models,
|
||||
"base_url": (base or ""),
|
||||
@@ -222,18 +226,44 @@ def list_user_llm_keys(user_id: int) -> list[dict]:
|
||||
return [_mask_key(r) for r in rows]
|
||||
|
||||
|
||||
def _default_api_base(provider: str) -> str:
|
||||
"""The OpenAI-compatible base URL the app uses by default for a provider."""
|
||||
base = (PROVIDERS.get(provider.lower()) or (None, None))[0]
|
||||
return (base or "").rstrip("/")
|
||||
|
||||
|
||||
def _normalize_api_base(provider: str, api_base: str) -> str:
|
||||
"""Drop an api_base that just repeats the provider default.
|
||||
|
||||
Storing the default as a per-user override freezes it: a later correction
|
||||
of the provider URL (e.g. Cohere `/v2` → `/compatibility/v1`) would never
|
||||
apply. An empty value means "use the provider default".
|
||||
"""
|
||||
value = (api_base or "").strip()
|
||||
if value.rstrip("/") == _default_api_base(provider):
|
||||
return ""
|
||||
return value
|
||||
|
||||
|
||||
def upsert_user_llm_key(user_id: int, provider: str, *, api_key: str = "",
|
||||
api_base: str = "", default_model: str = "",
|
||||
api_base: str | None = None, default_model: str = "",
|
||||
models: list[str] | None = None) -> dict:
|
||||
"""Upsert a user's provider key. Empty api_key keeps the existing one
|
||||
(allows saving model/base without re-typing the key). Saving a *different*
|
||||
key resets the `verified` flag so the provider must pass a test again."""
|
||||
key resets the `verified` flag so the provider must pass a test again.
|
||||
|
||||
``api_base`` uses ``None`` to mean "keep the stored value" and an empty
|
||||
string to explicitly reset it to the provider default.
|
||||
"""
|
||||
from app.db import get_conn
|
||||
|
||||
provider = provider.lower()
|
||||
existing = get_user_llm_key(user_id, provider)
|
||||
new_key = api_key if api_key else (existing.get("api_key", "") if existing else "")
|
||||
new_base = api_base if api_base else (existing.get("api_base", "") if existing else "")
|
||||
if api_base is None:
|
||||
new_base = (existing.get("api_base", "") if existing else "")
|
||||
else:
|
||||
new_base = _normalize_api_base(provider, api_base)
|
||||
new_model = default_model if default_model else (existing.get("default_model", "") if existing else "")
|
||||
new_models = models if models is not None else (
|
||||
json.loads(existing["models_json"]) if existing and existing.get("models_json") else []
|
||||
@@ -296,8 +326,9 @@ async def fetch_provider_models(provider: str, *, api_key: str = "",
|
||||
api_base: str = "", timeout: int = 20) -> list[str]:
|
||||
"""Fetch the live model list from a provider (best-effort, no mock).
|
||||
|
||||
OpenAI-compatible providers use `GET {base}/models` with a Bearer token;
|
||||
Anthropic uses `x-api-key` + `anthropic-version`; Gemini an `x-goog-api-key`.
|
||||
OpenAI-compatible providers (including Google's `/openai` surface and
|
||||
Cohere's compatibility API) use `GET {base}/models` with a Bearer token;
|
||||
Anthropic's native model listing uses `x-api-key` + `anthropic-version`.
|
||||
Returns a de-duplicated list capped at 300 models.
|
||||
"""
|
||||
import httpx
|
||||
@@ -311,14 +342,18 @@ async def fetch_provider_models(provider: str, *, api_key: str = "",
|
||||
headers: dict = {}
|
||||
if provider == "anthropic":
|
||||
headers = {"x-api-key": api_key, "anthropic-version": "2023-06-01"}
|
||||
elif provider == "google":
|
||||
headers = {"x-goog-api-key": api_key}
|
||||
elif api_key:
|
||||
headers = {"Authorization": f"Bearer {api_key}"}
|
||||
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.get(f"{base_url}/models", headers=headers)
|
||||
resp.raise_for_status()
|
||||
if resp.status_code >= 400:
|
||||
body = (resp.text or "").strip()
|
||||
if len(body) > 500:
|
||||
body = body[:500] + "…"
|
||||
raise RuntimeError(
|
||||
f"{resp.status_code} {resp.reason_phrase} ({resp.url}): {body}"
|
||||
)
|
||||
data = resp.json()
|
||||
|
||||
ids: list[str] = []
|
||||
|
||||
@@ -121,13 +121,15 @@ def get_user_prefs(user_id: int, conn=None) -> dict:
|
||||
"SELECT notification_prefs FROM users WHERE id=?", (user_id,)
|
||||
).fetchone()
|
||||
if not row or not row["notification_prefs"]:
|
||||
return {"comments": True, "mentions": True}
|
||||
return {"comments": True, "mentions": True, "reminders": True, "assignments": True}
|
||||
try:
|
||||
prefs = json.loads(row["notification_prefs"])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
prefs = {}
|
||||
return {"comments": bool(prefs.get("comments", True)),
|
||||
"mentions": bool(prefs.get("mentions", True))}
|
||||
"mentions": bool(prefs.get("mentions", True)),
|
||||
"reminders": bool(prefs.get("reminders", True)),
|
||||
"assignments": bool(prefs.get("assignments", True))}
|
||||
|
||||
|
||||
def set_user_prefs(user_id: int, prefs: dict, conn=None) -> dict:
|
||||
@@ -145,3 +147,67 @@ def set_user_prefs(user_id: int, prefs: dict, conn=None) -> dict:
|
||||
)
|
||||
conn.commit()
|
||||
return prefs
|
||||
|
||||
|
||||
def _person_ids(value) -> list[int]:
|
||||
"""Extract user ids from a stored ``person`` property value."""
|
||||
ids: list[int] = []
|
||||
if isinstance(value, dict):
|
||||
value = [value]
|
||||
if isinstance(value, list):
|
||||
for person in value:
|
||||
if isinstance(person, dict) and person.get("id"):
|
||||
try:
|
||||
ids.append(int(person["id"]))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return ids
|
||||
|
||||
|
||||
def notify_assignment(collection_id: int, page_id: int, page_title: str,
|
||||
old_props: dict, new_props: dict, actor_id: int | None,
|
||||
conn=None) -> list[int]:
|
||||
"""Notify users newly assigned via a ``person`` property.
|
||||
|
||||
Compares old vs new property values keyed by property id; users present
|
||||
in the new value but not the old one get an in-app + (opt-in) email
|
||||
notification. Returns the notified user ids.
|
||||
"""
|
||||
def _run(c):
|
||||
props = c.execute(
|
||||
"SELECT id, name FROM collection_properties "
|
||||
"WHERE collection_id=? AND prop_type='person'",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
newly: list[int] = []
|
||||
for p in props:
|
||||
key = str(p["id"])
|
||||
before = set(_person_ids((old_props or {}).get(key)))
|
||||
after = _person_ids((new_props or {}).get(key))
|
||||
for uid in after:
|
||||
if uid not in before and uid != actor_id and uid not in newly:
|
||||
newly.append(uid)
|
||||
for uid in newly:
|
||||
create_notification(
|
||||
uid, actor_id, "assignment",
|
||||
title="Assigned to you",
|
||||
message=page_title or "Untitled",
|
||||
resource_type="db_page",
|
||||
resource_id=page_id,
|
||||
url=f"/collections/{collection_id}",
|
||||
conn=c, commit=False,
|
||||
)
|
||||
mailer.notify_user(
|
||||
uid, subject="[FlowDeck] Assigned to you",
|
||||
body_text=page_title or "Untitled",
|
||||
cta_url=f"/collections/{collection_id}",
|
||||
prefs_key="assignments",
|
||||
)
|
||||
return newly
|
||||
|
||||
if conn is not None:
|
||||
return _run(conn)
|
||||
with get_conn() as c:
|
||||
result = _run(c)
|
||||
c.commit()
|
||||
return result
|
||||
|
||||
+74
-56
@@ -7,39 +7,44 @@ slow/unreachable hosts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html as htmlmod
|
||||
import logging
|
||||
import re
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_META_RE = re.compile(
|
||||
r'<meta\b[^>]*?(?:content=[\'"]([^\'"]*)[\'"]|property=[\'"]([^\'"]*)[\'"]|name=[\'"]([^\'"]*)[\'"]|content=[\'"]([^\'"]*)[\'"])[^>]*?>',
|
||||
re.I,
|
||||
)
|
||||
_META_TAG_RE = re.compile(r"<meta\b[^>]*?>", re.I)
|
||||
_ATTR_RE = re.compile(r"([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*[\"']([^\"']*)[\"']")
|
||||
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.I | re.S)
|
||||
_FAVICON_RE = re.compile(r"<link\b[^>]*?>", re.I)
|
||||
_ICON_REL = re.compile(r"\b(?:shortcut\s+)?icon\b", re.I)
|
||||
|
||||
# Property/name keys we look for, in priority order, mapped to our payload keys.
|
||||
_OG_TITLE = ("og:title", "twitter:title", "title", "og:site_name")
|
||||
_OG_DESC = ("og:description", "twitter:description", "description")
|
||||
_OG_IMG = ("og:image", "twitter:image", "twitter:image:src")
|
||||
_OG_IMG = ("og:image", "twitter:image", "twitter:image:src", "image")
|
||||
_OG_SITE = ("og:site_name", "twitter:site", "application-name")
|
||||
|
||||
|
||||
def _extract_og(html: str) -> dict:
|
||||
text = html[:400_000] # only scan the beginning — that's where <head> lives
|
||||
def _attrs(tag: str) -> dict:
|
||||
return {k.lower(): v for k, v in _ATTR_RE.findall(tag)}
|
||||
|
||||
|
||||
def _extract_og(body: str) -> dict:
|
||||
"""Parse all ``<meta>`` tags into a ``{key: content}`` dict.
|
||||
|
||||
Attributes may appear in any order (``content`` before or after
|
||||
``property``/``name``), which the previous implementation mishandled.
|
||||
First value wins so the most specific tag (top of document) is kept.
|
||||
"""
|
||||
props: dict[str, str] = {}
|
||||
for m in _META_RE.finditer(text):
|
||||
m.groups()
|
||||
content = ""
|
||||
prop = ""
|
||||
for s in re.findall(r"(?:content|property|name)=[\"']([^\"']*)[\"']", m.group(0)):
|
||||
if prop == "":
|
||||
prop = s
|
||||
else:
|
||||
content = s
|
||||
break
|
||||
if prop:
|
||||
props[prop.lower()] = content
|
||||
for tag in _META_TAG_RE.finditer(body[:400_000]):
|
||||
attrs = _attrs(tag.group(0))
|
||||
key = (attrs.get("property") or attrs.get("name") or attrs.get("itemprop") or "").lower()
|
||||
content = attrs.get("content")
|
||||
if key and content is not None and key not in props:
|
||||
props[key] = content
|
||||
return props
|
||||
|
||||
|
||||
@@ -51,12 +56,12 @@ def _pick(props: dict, keys: tuple) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _title_of(props: dict, raw_title: str) -> str:
|
||||
def _title_of(props: dict, body: str) -> str:
|
||||
t = _pick(props, _OG_TITLE)
|
||||
if t:
|
||||
return t
|
||||
m = re.search(r"<title[^>]*>(.*?)</title>", raw_title[:200_000], re.I | re.S)
|
||||
return (m.group(1).strip() if m else "") or urlparse(props.get("_url", "")).netloc
|
||||
m = _TITLE_RE.search(body[:200_000])
|
||||
return m.group(1).strip() if m else ""
|
||||
|
||||
|
||||
def _site_name(url: str) -> str:
|
||||
@@ -64,20 +69,62 @@ def _site_name(url: str) -> str:
|
||||
return host.split(".")[0].capitalize() if host else ""
|
||||
|
||||
|
||||
async def fetch_og_metadata(url: str, timeout: float = 6.0) -> dict:
|
||||
def _favicon(body: str, base_url: str) -> str:
|
||||
for tag in _FAVICON_RE.finditer(body):
|
||||
attrs = _attrs(tag.group(0))
|
||||
rel = attrs.get("rel", "")
|
||||
href = attrs.get("href", "")
|
||||
if href and _ICON_REL.search(rel):
|
||||
return urljoin(base_url, htmlmod.unescape(href))
|
||||
return ""
|
||||
|
||||
|
||||
def parse_og(body: str, url: str) -> dict:
|
||||
"""Pure HTML → bookmark payload (no network). ``url`` is the base URL."""
|
||||
src = url.strip()
|
||||
if not src.startswith(("http://", "https://")):
|
||||
src = "https://" + src
|
||||
props = _extract_og(body)
|
||||
title = htmlmod.unescape(_title_of(props, body))
|
||||
desc = htmlmod.unescape(_pick(props, _OG_DESC))
|
||||
img = _pick(props, _OG_IMG)
|
||||
site = htmlmod.unescape(_pick(props, _OG_SITE)) or _site_name(src)
|
||||
|
||||
def abs_url(u: str) -> str:
|
||||
return urljoin(src, htmlmod.unescape(u)) if u else ""
|
||||
|
||||
return {
|
||||
"url": src,
|
||||
"title": title.strip()[:200] or urlparse(src).netloc or src,
|
||||
"description": desc.strip()[:400],
|
||||
"image": abs_url(img),
|
||||
"site_name": site.strip()[:100],
|
||||
"favicon": _favicon(body, src),
|
||||
}
|
||||
|
||||
|
||||
async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> dict:
|
||||
"""Fetch ``url`` and return {url, title, description, image, site_name,
|
||||
favicon}. Empty strings are omitted. Never raises for network errors."""
|
||||
favicon}. Empty strings are omitted. Never raises for network errors.
|
||||
|
||||
``transport`` is an optional ``httpx`` transport (used by tests to mock
|
||||
HTTP without hitting the network).
|
||||
"""
|
||||
src = url.strip()
|
||||
if not src.startswith(("http://", "https://")):
|
||||
src = "https://" + src
|
||||
base = {"url": src, "title": "", "description": "", "image": "", "site_name": "", "favicon": ""}
|
||||
try:
|
||||
import httpx
|
||||
|
||||
headers = {
|
||||
"User-Agent": "FlowDeck/5.10 bookmark-fetcher (+https://flowdeck.dracodev.net)",
|
||||
"User-Agent": "FlowDeck/5.5 bookmark-fetcher (+https://flowdeck.dracodev.net)",
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
}
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=timeout) as client:
|
||||
kwargs = {"follow_redirects": True, "timeout": timeout}
|
||||
if transport is not None:
|
||||
kwargs["transport"] = transport
|
||||
async with httpx.AsyncClient(**kwargs) as client:
|
||||
resp = await client.get(src, headers=headers)
|
||||
resp.raise_for_status()
|
||||
except Exception as exc: # noqa: BLE001 - network/parse failures are non-fatal
|
||||
@@ -92,33 +139,4 @@ async def fetch_og_metadata(url: str, timeout: float = 6.0) -> dict:
|
||||
base["site_name"] = _site_name(src)
|
||||
return base
|
||||
|
||||
import html as htmlmod
|
||||
|
||||
body = resp.text
|
||||
props = _extract_og(body)
|
||||
props["_url"] = src
|
||||
title = htmlmod.unescape(_title_of(props, body))
|
||||
desc = htmlmod.unescape(_pick(props, _OG_DESC))
|
||||
img = _pick(props, _OG_IMG)
|
||||
site = htmlmod.unescape(_pick(props, _OG_SITE)) or _site_name(src)
|
||||
|
||||
def abs_url(u: str) -> str:
|
||||
if not u:
|
||||
return ""
|
||||
return urljoin(src, htmlmod.unescape(u))
|
||||
|
||||
favicon = ""
|
||||
fm = re.search(r"<link[^>]+rel=[\"'](?:shortcut )?icon[\"'][^>]+href=[\"']([^\"']+)[\"']", body, re.I)
|
||||
if not fm:
|
||||
fm = re.search(r"<link[^>]+href=[\"']([^\"']+)[\"'][^>]+rel=[\"'](?:shortcut )?icon[\"']", body, re.I)
|
||||
if fm:
|
||||
favicon = abs_url(fm.group(1))
|
||||
|
||||
return {
|
||||
"url": src,
|
||||
"title": title.strip()[:200] or urlparse(src).netloc or src,
|
||||
"description": desc.strip()[:400],
|
||||
"image": abs_url(img),
|
||||
"site_name": site.strip()[:100],
|
||||
"favicon": favicon,
|
||||
}
|
||||
return parse_og(resp.text, src)
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
"""FlowDeck — Agent permission guard (v4.10.0).
|
||||
"""FlowDeck — Permission manager: workspace roles + granular ACL (v6.0.0).
|
||||
|
||||
The agent always acts with *at most* the permissions of the invoking user
|
||||
(Notion Agent principle). This manager resolves the user's role in the active
|
||||
workspace and gates tool execution before any write reaches the database.
|
||||
Two layers:
|
||||
|
||||
1. **Workspace roles** (v4.10.0, agent guard): every user has a single role in
|
||||
each workspace (owner > owner-membership > editor > commenter > viewer).
|
||||
The FlowDeck Agent always acts with *at most* the permissions of the
|
||||
invoking user (Notion Agent principle).
|
||||
|
||||
2. **Granular permissions** (v6.0.0): explicit page / collection / property
|
||||
grants plus reusable user groups. Resolution follows the least-privilege
|
||||
rule — an explicit grant on a resource overrides the inherited chain
|
||||
(page → collection → workspace), while ``restricted`` / ``private``
|
||||
resources deny access unless a grant (or the workspace owner / admin)
|
||||
applies.
|
||||
|
||||
Resolution results are cached for 60 s to keep the hot paths (sidebar, view
|
||||
rendering, route guards) < 10 ms per check; ``PermissionManager.invalidate()``
|
||||
drops the cache after any grant/revoke/type change.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
@@ -19,6 +34,12 @@ READ_ROLES = {"viewer", "commenter", "editor", "admin", "owner"}
|
||||
WRITE_ROLES = {"editor", "admin", "owner"}
|
||||
DESTRUCTIVE_ROLES = {"admin", "owner"}
|
||||
|
||||
# Granular resource roles (ranked, least → most privileged).
|
||||
_GRANULAR_ROLES = ("viewer", "commenter", "editor", "owner")
|
||||
_ROLE_RANK = {role: i for i, role in enumerate(_GRANULAR_ROLES)}
|
||||
_PROPERTY_ROLES = ("viewer", "editor")
|
||||
_PROPERTY_RANK = {"viewer": 0, "editor": 1}
|
||||
|
||||
# Tools that mutate state and therefore require at least an editor role.
|
||||
WRITE_TOOLS = {
|
||||
"create_collection", "create_view", "create_page", "update_page",
|
||||
@@ -35,10 +56,27 @@ DESTRUCTIVE_TOOLS = {
|
||||
|
||||
|
||||
class PermissionManager:
|
||||
"""Resolves workspace role and gates agent tool calls."""
|
||||
"""Resolves workspace role and gates agent + granular ACL checks."""
|
||||
|
||||
def __init__(self, user_id: int):
|
||||
def __init__(self, user_id: int, is_admin: bool = False):
|
||||
self.user_id = user_id
|
||||
self._is_admin_override = bool(is_admin)
|
||||
self._cache: dict[str, tuple[float, object]] = {}
|
||||
|
||||
# ── Cache helpers ──
|
||||
|
||||
def _cached(self, key: str, ttl: float, fn):
|
||||
now = time.monotonic()
|
||||
hit = self._cache.get(key)
|
||||
if hit and now - hit[0] < ttl:
|
||||
return hit[1]
|
||||
val = fn()
|
||||
self._cache[key] = (now, val)
|
||||
return val
|
||||
|
||||
def invalidate(self) -> None:
|
||||
"""Drop the resolution cache after a grant/revoke/type change."""
|
||||
self._cache.clear()
|
||||
|
||||
# ── Role resolution ──
|
||||
|
||||
@@ -100,3 +138,315 @@ class PermissionManager:
|
||||
# A viewer can always read; editor can read+write.
|
||||
if role not in READ_ROLES:
|
||||
raise HTTPException(status_code=403, detail="User has no access to this workspace")
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Granular permissions (v6.0.0)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _is_admin(self, conn) -> bool:
|
||||
if self._is_admin_override:
|
||||
return True
|
||||
row = conn.execute(
|
||||
"SELECT is_admin FROM users WHERE id=?", (self.user_id,)
|
||||
).fetchone()
|
||||
return bool(row and row["is_admin"])
|
||||
|
||||
def _owns_workspace(self, conn, workspace_id: int | None) -> bool:
|
||||
if workspace_id is None:
|
||||
# No workspace → single-user semantics: the actor is the owner.
|
||||
return True
|
||||
row = conn.execute(
|
||||
"SELECT id FROM workspaces WHERE id=? AND owner_id=?",
|
||||
(workspace_id, self.user_id),
|
||||
).fetchone()
|
||||
return bool(row)
|
||||
|
||||
def user_group_ids(self, conn) -> list[int]:
|
||||
return [
|
||||
r["group_id"]
|
||||
for r in conn.execute(
|
||||
"SELECT group_id FROM group_members WHERE user_id=?", (self.user_id,)
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
def _explicit_grant_role(self, conn, table: str, fk: str, resource_id: int,
|
||||
role_rank: dict[str, int] | None = None) -> str | None:
|
||||
"""Most-privileged explicit role on ``table`` for the user / groups."""
|
||||
rank = role_rank or _ROLE_RANK
|
||||
groups = self.user_group_ids(conn)
|
||||
if groups:
|
||||
placeholders = ", ".join("?" * len(groups))
|
||||
rows = conn.execute(
|
||||
f"SELECT role FROM {table} WHERE {fk}=? "
|
||||
f"AND (user_id=? OR group_id IN ({placeholders}))",
|
||||
(resource_id, self.user_id, *groups),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
f"SELECT role FROM {table} WHERE {fk}=? AND user_id=?",
|
||||
(resource_id, self.user_id),
|
||||
).fetchall()
|
||||
best = max((rank.get(r["role"], -1) for r in rows), default=-1)
|
||||
if best < 0:
|
||||
return None
|
||||
rev = {rank[k]: k for k in rank}
|
||||
return rev[best]
|
||||
|
||||
# ── Page-level ──
|
||||
|
||||
def get_page_permission(self, page_id: int) -> str | None:
|
||||
"""Effective page role for ``self.user_id`` (least privilege).
|
||||
|
||||
Chain: explicit page grant > explicit collection grant > workspace
|
||||
role. ``restricted`` / ``private`` pages ignore the inherited chain.
|
||||
Returns ``None`` when the user must not see the page at all.
|
||||
"""
|
||||
|
||||
def _resolve() -> str | None:
|
||||
with get_conn() as conn:
|
||||
page = conn.execute(
|
||||
"SELECT permission_type, workspace_id, collection_id FROM pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not page:
|
||||
return None
|
||||
if self._is_admin(conn) or self._owns_workspace(conn, page["workspace_id"]):
|
||||
return "owner"
|
||||
explicit = self._explicit_grant_role(
|
||||
conn, "page_permissions", "page_id", page_id
|
||||
)
|
||||
if explicit:
|
||||
return explicit
|
||||
ptype = page["permission_type"] or "inherit"
|
||||
if ptype in ("restricted", "private"):
|
||||
return None
|
||||
if page["collection_id"]:
|
||||
coll_role = self._collection_role(conn, page["collection_id"])
|
||||
if coll_role:
|
||||
return coll_role
|
||||
return self.role_in_workspace(page["workspace_id"])
|
||||
return self._cached(f"page:{page_id}", 60, _resolve)
|
||||
|
||||
def can_view_page(self, page_id: int) -> bool:
|
||||
return self.get_page_permission(page_id) is not None
|
||||
|
||||
def can_edit_page(self, page_id: int) -> bool:
|
||||
role = self.get_page_permission(page_id)
|
||||
return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["editor"])
|
||||
|
||||
def can_comment_page(self, page_id: int) -> bool:
|
||||
role = self.get_page_permission(page_id)
|
||||
return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["commenter"])
|
||||
|
||||
def can_manage_page_permissions(self, page_id: int) -> bool:
|
||||
role = self.get_page_permission(page_id)
|
||||
return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["owner"])
|
||||
|
||||
# ── Collection-level ──
|
||||
|
||||
def _collection_role(self, conn, collection_id: int) -> str | None:
|
||||
coll = conn.execute(
|
||||
"SELECT permission_type, workspace_id FROM collections WHERE id=?",
|
||||
(collection_id,),
|
||||
).fetchone()
|
||||
if not coll:
|
||||
return None
|
||||
if self._is_admin(conn) or self._owns_workspace(conn, coll["workspace_id"]):
|
||||
return "owner"
|
||||
explicit = self._explicit_grant_role(
|
||||
conn, "collection_permissions", "collection_id", collection_id
|
||||
)
|
||||
if explicit:
|
||||
return explicit
|
||||
ptype = coll["permission_type"] or "inherit"
|
||||
if ptype in ("restricted", "private"):
|
||||
return None
|
||||
return self.role_in_workspace(coll["workspace_id"])
|
||||
|
||||
def get_collection_permission(self, collection_id: int) -> str | None:
|
||||
def _resolve() -> str | None:
|
||||
with get_conn() as conn:
|
||||
return self._collection_role(conn, collection_id)
|
||||
return self._cached(f"collection:{collection_id}", 60, _resolve)
|
||||
|
||||
def can_view_collection(self, collection_id: int) -> bool:
|
||||
return self.get_collection_permission(collection_id) is not None
|
||||
|
||||
def can_edit_collection(self, collection_id: int) -> bool:
|
||||
role = self.get_collection_permission(collection_id)
|
||||
return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["editor"])
|
||||
|
||||
def can_manage_collection_permissions(self, collection_id: int) -> bool:
|
||||
role = self.get_collection_permission(collection_id)
|
||||
return bool(role and _ROLE_RANK[role] >= _ROLE_RANK["owner"])
|
||||
|
||||
# ── Property-level ──
|
||||
|
||||
def _property_grants_exist(self, conn, property_id: int) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM property_permissions WHERE property_id=? LIMIT 1",
|
||||
(property_id,),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
def _has_property_grant(self, conn, property_id: int, min_rank: int) -> bool:
|
||||
groups = self.user_group_ids(conn)
|
||||
if groups:
|
||||
placeholders = ", ".join("?" * len(groups))
|
||||
rows = conn.execute(
|
||||
f"SELECT role FROM property_permissions WHERE property_id=? "
|
||||
f"AND (user_id=? OR group_id IN ({placeholders}))",
|
||||
(property_id, self.user_id, *groups),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT role FROM property_permissions WHERE property_id=? AND user_id=?",
|
||||
(property_id, self.user_id),
|
||||
).fetchall()
|
||||
return any(_PROPERTY_RANK.get(r["role"], -1) >= min_rank for r in rows)
|
||||
|
||||
def can_view_property(self, collection_id: int, property_id: int) -> bool:
|
||||
"""A property is visible unless it carries explicit grants excluding
|
||||
the user; without any grant it inherits from the collection. Collection
|
||||
owners/admins always see every property."""
|
||||
if not self.can_view_collection(collection_id):
|
||||
return False
|
||||
return self._cached(
|
||||
f"prop:{property_id}", 60, lambda: self._property_visible(collection_id, property_id)
|
||||
)
|
||||
|
||||
def _collection_workspace_id(self, conn, collection_id: int) -> int | None:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM collections WHERE id=?", (collection_id,)
|
||||
).fetchone()
|
||||
return row["workspace_id"] if row else None
|
||||
|
||||
def _property_visible(self, collection_id: int, property_id: int) -> bool:
|
||||
with get_conn() as conn:
|
||||
workspace_id = self._collection_workspace_id(conn, collection_id)
|
||||
if self._is_admin(conn) or self._owns_workspace(conn, workspace_id):
|
||||
return True
|
||||
if self.can_manage_collection_permissions(collection_id):
|
||||
return True
|
||||
if not self._property_grants_exist(conn, property_id):
|
||||
return True
|
||||
return self._has_property_grant(conn, property_id, _PROPERTY_RANK["viewer"])
|
||||
|
||||
def can_edit_property(self, collection_id: int, property_id: int) -> bool:
|
||||
if not self.can_edit_collection(collection_id):
|
||||
return False
|
||||
with get_conn() as conn:
|
||||
workspace_id = self._collection_workspace_id(conn, collection_id)
|
||||
if self._is_admin(conn) or self._owns_workspace(conn, workspace_id):
|
||||
return True
|
||||
if self.can_manage_collection_permissions(collection_id):
|
||||
return True
|
||||
if not self._property_grants_exist(conn, property_id):
|
||||
return True
|
||||
return self._has_property_grant(conn, property_id, _PROPERTY_RANK["editor"])
|
||||
|
||||
def get_visible_properties(self, collection_id: int) -> list[int]:
|
||||
def _resolve() -> list[int]:
|
||||
with get_conn() as conn:
|
||||
props = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=?",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
return [p["id"] for p in props if self.can_view_property(collection_id, p["id"])]
|
||||
return self._cached(f"visible_props:{collection_id}", 60, _resolve)
|
||||
|
||||
# ── Groups ──
|
||||
|
||||
def is_workspace_admin(self, workspace_id: int | None) -> bool:
|
||||
with get_conn() as conn:
|
||||
return self._is_admin(conn) or self._owns_workspace(conn, workspace_id)
|
||||
|
||||
def create_group(self, workspace_id: int | None, name: str,
|
||||
description: str = "", created_by: int | None = None) -> int:
|
||||
if not self.is_workspace_admin(workspace_id):
|
||||
raise HTTPException(403, "Only a workspace owner or admin can create groups")
|
||||
if not name.strip():
|
||||
raise HTTPException(400, "name is required")
|
||||
with get_conn() as conn:
|
||||
dupe = conn.execute(
|
||||
"SELECT id FROM user_groups WHERE workspace_id IS ? AND name=?",
|
||||
(workspace_id, name.strip()),
|
||||
).fetchone()
|
||||
if dupe:
|
||||
raise HTTPException(400, "A group with this name already exists")
|
||||
cur = conn.execute(
|
||||
"INSERT INTO user_groups (workspace_id, name, description, created_by) "
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
(workspace_id, name.strip(), description or "", created_by),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
def add_user_to_group(self, group_id: int, user_id: int) -> None:
|
||||
with get_conn() as conn:
|
||||
group = conn.execute(
|
||||
"SELECT workspace_id FROM user_groups WHERE id=?", (group_id,)
|
||||
).fetchone()
|
||||
if not group:
|
||||
raise HTTPException(404, "Group not found")
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)",
|
||||
(group_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def remove_user_from_group(self, group_id: int, user_id: int) -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM group_members WHERE group_id=? AND user_id=?",
|
||||
(group_id, user_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def delete_group(self, group_id: int) -> None:
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM user_groups WHERE id=?", (group_id,))
|
||||
conn.commit()
|
||||
|
||||
def get_groups_for_workspace(self, workspace_id: int | None) -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT g.id, g.name, g.description, g.created_by, g.created_at,
|
||||
(SELECT COUNT(*) FROM group_members m WHERE m.group_id=g.id) AS member_count
|
||||
FROM user_groups g WHERE g.workspace_id IS ? ORDER BY g.name""",
|
||||
(workspace_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def get_group_members(self, group_id: int) -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT u.id, u.login, u.full_name, u.email, m.joined_at
|
||||
FROM group_members m JOIN users u ON u.id=m.user_id
|
||||
WHERE m.group_id=? ORDER BY u.login""",
|
||||
(group_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ── Audit log ──
|
||||
|
||||
def log_permission_change(self, resource_type: str, resource_id: int, action: str,
|
||||
target_user_id: int | None = None,
|
||||
target_group_id: int | None = None,
|
||||
old_role: str | None = None,
|
||||
new_role: str | None = None,
|
||||
ip_address: str = "") -> None:
|
||||
"""Write one immutable audit row for a permission change."""
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO permission_audit_log
|
||||
(resource_type, resource_id, action, target_user_id, target_group_id,
|
||||
old_role, new_role, performed_by, ip_address)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(resource_type, resource_id, action, target_user_id, target_group_id,
|
||||
old_role, new_role, self.user_id, ip_address),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as exc: # audit must never break the caller
|
||||
logger.warning("permission audit log failed: %s", exc)
|
||||
|
||||
@@ -136,6 +136,9 @@ def validate_property_value(prop_type: str, value: Any, options: list | None = N
|
||||
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except (ValueError, TypeError):
|
||||
return False, f"'{value}' is not a valid ISO 8601 date"
|
||||
elif prop_type == "person":
|
||||
if not isinstance(value, list):
|
||||
return False, "Person must be a list of workspace members"
|
||||
elif prop_type == "url":
|
||||
if not isinstance(value, str):
|
||||
return False, "URL must be a string"
|
||||
@@ -227,17 +230,63 @@ def validate_property_rule(
|
||||
return True, ""
|
||||
|
||||
|
||||
def user_ref(user: dict | None) -> dict | None:
|
||||
"""Normalize a session user dict into the stored ``person`` value shape."""
|
||||
if not user:
|
||||
return None
|
||||
return {
|
||||
"id": user.get("id"),
|
||||
"login": user.get("login") or user.get("full_name") or "",
|
||||
"full_name": user.get("full_name") or "",
|
||||
"avatar_url": user.get("avatar_url") or "",
|
||||
"avatar_color": user.get("avatar_color") or "#3A3A3A",
|
||||
}
|
||||
|
||||
|
||||
def get_auto_property_value(prop_type: str, user: dict | None = None) -> Any:
|
||||
"""Compute the value of an auto-property."""
|
||||
if prop_type == "created_time" or prop_type == "last_edited_time":
|
||||
return datetime.now(UTC).isoformat()
|
||||
if prop_type == "created_by" or prop_type == "last_edited_by":
|
||||
if user:
|
||||
return {"id": user.get("id"), "login": user.get("login")}
|
||||
return None
|
||||
return user_ref(user)
|
||||
return None
|
||||
|
||||
|
||||
def apply_auto_properties(
|
||||
properties: list[dict],
|
||||
values: dict,
|
||||
user: dict | None = None,
|
||||
*,
|
||||
is_create: bool = False,
|
||||
now: str | None = None,
|
||||
) -> dict:
|
||||
"""Fill/refresh auto-property values (``created_time``, ``created_by``,
|
||||
``last_edited_time``, ``last_edited_by``) in ``values`` (keyed by property id).
|
||||
|
||||
``created_*`` are only written on creation (or when missing); ``last_edited_*``
|
||||
are refreshed on every call. Returns the mutated dict.
|
||||
"""
|
||||
if values is None:
|
||||
values = {}
|
||||
stamp = now or datetime.now(UTC).isoformat()
|
||||
for prop in properties or []:
|
||||
ptype = prop.get("prop_type")
|
||||
if ptype not in AUTO_TYPES:
|
||||
continue
|
||||
pid = str(prop.get("id"))
|
||||
if ptype == "created_time":
|
||||
if is_create or pid not in values or values.get(pid) in (None, ""):
|
||||
values[pid] = stamp
|
||||
elif ptype == "last_edited_time":
|
||||
values[pid] = stamp
|
||||
elif ptype == "created_by":
|
||||
if is_create or pid not in values or values.get(pid) in (None, ""):
|
||||
values[pid] = user_ref(user)
|
||||
elif ptype == "last_edited_by":
|
||||
values[pid] = user_ref(user)
|
||||
return values
|
||||
|
||||
|
||||
def get_next_unique_id(collection_id: int, conn) -> int:
|
||||
"""Get the next unique_id for a collection (max + 1)."""
|
||||
row = conn.execute(
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
"""FlowDeck — v6.4.0 Realtime: résolution de conflits au-delà du last-write-wins.
|
||||
|
||||
Le LWW par bloc (v5.13.0) écrase intégralement le bloc du dernier arrivé : si deux
|
||||
utilisateurs tapent dans le *même* bloc, la saisie du premier est perdue. Ce module
|
||||
implémente un vrai *merge à trois versions* (diff3-lite) :
|
||||
|
||||
base = l'état du bloc dont le client dérive sa saisie (envoyé avec l'op)
|
||||
current = l'état actuel du bloc côté serveur (déjà mis à jour par d'autres)
|
||||
incoming = la nouvelle proposition du client
|
||||
|
||||
Règle champ-par-champ :
|
||||
* incoming == base → le client n'a pas touché ce champ → on garde current
|
||||
* current == base → le serveur n'a pas touché ce champ → on garde incoming
|
||||
* current == incoming → les deux ont fait la même chose → sans conflit
|
||||
* sinon (conflit)
|
||||
- champ texte (str) : merge de caractères. Les régions modifiées qui ne se
|
||||
chevauchent pas sont *toutes conservées* (les deux saisies survivent) ;
|
||||
chevauchement réel → LWW sur ce champ + drapeau de conflit.
|
||||
- autre type (bool, nombre…) : LWW sur ce champ + drapeau de conflit.
|
||||
|
||||
Toutes les fonctions sont pures et testables sans WebSocket ni base de données.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
__all__ = ["merge_text_3way", "merge_block_3way", "changed_region"]
|
||||
|
||||
|
||||
def changed_region(base: str, other: str) -> tuple[int, int, str] | None:
|
||||
"""Région de `base` remplacée par `other` (trim préfixe/suffixe commun).
|
||||
|
||||
Retourne ``(start, end, replacement)`` tel que ``base[:start] + replacement +
|
||||
base[end:] == other``, ou ``None`` si ``other == base`` (aucun changement).
|
||||
"""
|
||||
if base == other:
|
||||
return None
|
||||
minlen = min(len(base), len(other))
|
||||
prefix = 0
|
||||
while prefix < minlen and base[prefix] == other[prefix]:
|
||||
prefix += 1
|
||||
suffix = 0
|
||||
# ne jamais chevaucher le préfixe déjà consommé
|
||||
while (suffix < len(base) - prefix and suffix < len(other) - prefix
|
||||
and base[len(base) - 1 - suffix] == other[len(other) - 1 - suffix]):
|
||||
suffix += 1
|
||||
return prefix, len(base) - suffix, other[prefix:len(other) - suffix]
|
||||
|
||||
|
||||
def merge_text_3way(base: str, current: str, incoming: str) -> tuple[str, bool]:
|
||||
"""Merge à trois versions d'une chaîne. Retourne ``(texte, conflit)``.
|
||||
|
||||
Les éditions qui ne se chevauchent pas sont toutes les deux conservées ;
|
||||
un chevauchement réel retombe en LWW (``incoming`` gagne) et signale le conflit.
|
||||
"""
|
||||
if current == incoming:
|
||||
return current, False
|
||||
if current == base:
|
||||
return incoming, False
|
||||
if incoming == base:
|
||||
return current, False
|
||||
|
||||
rc = changed_region(base, current)
|
||||
ri = changed_region(base, incoming)
|
||||
if rc is None:
|
||||
return incoming, False
|
||||
if ri is None:
|
||||
return current, False
|
||||
|
||||
c_start, c_end, c_text = rc
|
||||
i_start, i_end, i_text = ri
|
||||
|
||||
# Régions disjointes (ou juste adjacentes) → appliquer les deux sur base.
|
||||
if c_end <= i_start or i_end <= c_start:
|
||||
edits = sorted([(c_start, c_end, c_text), (i_start, i_end, i_text)],
|
||||
key=lambda e: e[0])
|
||||
out: list[str] = []
|
||||
pos = 0
|
||||
for start, end, text in edits:
|
||||
if start < pos:
|
||||
continue # sécurité: ne jamais réappliquer par-dessus
|
||||
out.append(base[pos:start])
|
||||
out.append(text)
|
||||
pos = end
|
||||
out.append(base[pos:])
|
||||
return "".join(out), False
|
||||
|
||||
# Chevauchement réel → LWW sur ce champ, conflit signalé.
|
||||
return incoming, True
|
||||
|
||||
|
||||
def _scalar_conflict(base: Any, current: Any, incoming: Any) -> tuple[Any, bool]:
|
||||
"""Conflit sur un champ non-texte : LWW (incoming gagne)."""
|
||||
if current == incoming:
|
||||
return current, False
|
||||
if current == base:
|
||||
return incoming, False
|
||||
if incoming == base:
|
||||
return current, False
|
||||
return incoming, True
|
||||
|
||||
|
||||
def merge_block_3way(base_blk: Any, current_blk: Any,
|
||||
incoming_blk: Any) -> tuple[dict, list[str]]:
|
||||
"""Merge à trois versions d'un bloc entier.
|
||||
|
||||
Retourne ``(bloc fusionné, champs en conflit)``. Ne lève jamais d'exception :
|
||||
une entrée non-dict retombe en LWW (``incoming``) avec conflit signalé sur
|
||||
``__block__`` pour que l'appelant puisse journaliser.
|
||||
"""
|
||||
if not isinstance(base_blk, dict):
|
||||
base_blk = {}
|
||||
if not isinstance(current_blk, dict):
|
||||
current_blk = {}
|
||||
if not isinstance(incoming_blk, dict):
|
||||
# proposition invalide → on garde l'état serveur
|
||||
return dict(current_blk), ["__block__"]
|
||||
|
||||
keys = set(base_blk) | set(current_blk) | set(incoming_blk)
|
||||
merged: dict[str, Any] = {}
|
||||
conflicts: list[str] = []
|
||||
|
||||
for key in keys:
|
||||
b = base_blk.get(key)
|
||||
c = current_blk.get(key)
|
||||
i = incoming_blk.get(key)
|
||||
|
||||
if i == b:
|
||||
# le client n'a pas modifié ce champ → valeur serveur. Si le serveur
|
||||
# a *supprimé* le champ (absent de current) alors la suppression doit
|
||||
# gagner : on n'insère pas de clé fantôme value=None.
|
||||
if key not in current_blk:
|
||||
continue
|
||||
merged[key] = c
|
||||
elif c == b:
|
||||
# le serveur n'a pas modifié ce champ → valeur client
|
||||
merged[key] = i
|
||||
elif c == i:
|
||||
merged[key] = c
|
||||
else:
|
||||
# les deux ont changé, différemment
|
||||
if isinstance(b, str) and isinstance(c, str) and isinstance(i, str):
|
||||
text, conflict = merge_text_3way(b, c, i)
|
||||
merged[key] = text
|
||||
if conflict:
|
||||
conflicts.append(key)
|
||||
else:
|
||||
value, conflict = _scalar_conflict(b, c, i)
|
||||
merged[key] = value
|
||||
if conflict:
|
||||
conflicts.append(key)
|
||||
|
||||
# Un champ supprimé par le serveur et absent de la proposition client doit
|
||||
# rester supprimé (pas de réapparition d'une valeur None fantôme).
|
||||
merged = {k: v for k, v in merged.items()
|
||||
if not (v is None and k not in incoming_blk)}
|
||||
|
||||
if "id" not in merged:
|
||||
# garantir l'identité du bloc même si base était vide
|
||||
ident = incoming_blk.get("id") or current_blk.get("id")
|
||||
if ident:
|
||||
merged["id"] = ident
|
||||
|
||||
return merged, conflicts
|
||||
+309
-73
@@ -1,5 +1,23 @@
|
||||
"""FlowDeck — v5.13.0 Realtime: WebSocket gateway, présences, curseurs live,
|
||||
merge des opérations de blocs (last-write-wins par bloc) + version de page.
|
||||
"""FlowDeck — v6.4.0 Realtime: WebSocket gateway, présences, curseurs live,
|
||||
merge à trois versions des opérations de blocs (au-delà du last-write-wins) +
|
||||
version de page.
|
||||
|
||||
Améliorations « production » par rapport à v5.13.0 :
|
||||
|
||||
* **Conflits** — les mises à jour de bloc embarquent la ``base`` dont dérive la
|
||||
saisie du client ; le serveur fait un merge 3-voix champ-par-champ + texte
|
||||
(voir ``realtime_merge``) au lieu d'écraser le bloc entier. Les deux saisies
|
||||
disjointes survivent, les chevauchements réels retombent en LWW *par champ*
|
||||
avec drapeau de conflit renvoyé au client.
|
||||
* **Échelle** — chaque connexion possède une file sortante + une tâche writer
|
||||
dédiée ; le broadcast devient non bloquant (un client lent ne bloque plus la
|
||||
room), les mises à jour de curseur se coalescent (une seule par flush), et un
|
||||
client trop lent (file pleine) est déconnecté proprement (4413).
|
||||
* **Anti-flood** — budget d'opérations par connexion (fenêtre glissante).
|
||||
* **Fuites corrigées** — une room 4404 n'est plus enregistrée ; ``room_state``
|
||||
ne crée plus d'objet None ; les rooms vides sont évacuées.
|
||||
* **Observabilité** — compteurs (rooms, conns, ops, merges, conflits, déconnexions
|
||||
lentes) exposés par ``stats()``.
|
||||
|
||||
Rooms in-memory (un seul worker uvicorn). Persistance en base (page.content)
|
||||
avec debounce. Fallback polling côté client si le WS est indisponible.
|
||||
@@ -9,34 +27,66 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.realtime_merge import merge_block_3way
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
COLORS = ["#2383E2", "#46A758", "#E5484D", "#F76B15", "#8E4EC6", "#12A594",
|
||||
"#FFC53D", "#D6409F", "#0091FF", "#3E63DD", "#30A46C", "#FF3333"]
|
||||
|
||||
# File sortante maximale par connexion au-delà de laquelle le client est
|
||||
# considéré comme trop lent et déconnecté (évite qu'une room entière stagne).
|
||||
MAX_OUT_QUEUE = 512
|
||||
# Nombre maximal d'opérations acceptées par connexion et par fenêtre (anti-flood).
|
||||
OP_WINDOW_SECONDS = 10.0
|
||||
OP_WINDOW_MAX = 400
|
||||
|
||||
|
||||
def color_for(uid: int) -> str:
|
||||
return COLORS[(uid or 0) % len(COLORS)]
|
||||
|
||||
|
||||
def block_id() -> str:
|
||||
import time
|
||||
return f"b{int(time.time()*1000)}"
|
||||
import uuid
|
||||
return "b" + uuid.uuid4().hex[:12]
|
||||
|
||||
|
||||
def ensure_block_ids(blocks: list[dict]) -> list[dict]:
|
||||
"""Assign unique ids to blocks missing one, recursively.
|
||||
|
||||
Template-created pages may have been persisted without block ids; without
|
||||
them the room state is not addressable by ops and the editor ends up with
|
||||
``data-bid="undefined"`` blocks (duplicated / reordered lines).
|
||||
"""
|
||||
if not isinstance(blocks, list):
|
||||
return blocks
|
||||
for b in blocks:
|
||||
if isinstance(b, dict):
|
||||
if not b.get("id"):
|
||||
b["id"] = block_id()
|
||||
if isinstance(b.get("children"), list):
|
||||
ensure_block_ids(b["children"])
|
||||
return blocks
|
||||
|
||||
|
||||
def apply_op(blocks: list[dict], op: dict) -> list[dict]:
|
||||
"""Apply one block op (insert/update/delete/move) — LWW par bloc."""
|
||||
"""Apply one block op (insert/update/delete/move) — LWW par bloc.
|
||||
|
||||
Conservé pour la compatibilité : tests et chemins sans ``base`` continuent
|
||||
de fonctionner. Le merge 3-voix vit dans ``RealtimeManager._apply``.
|
||||
"""
|
||||
t = op.get("type")
|
||||
if t == "insert":
|
||||
blk = op.get("block") or {}
|
||||
if not blk.get("id"):
|
||||
blk = dict(blk)
|
||||
blk["id"] = block_id()
|
||||
ensure_block_ids([blk])
|
||||
idx = op.get("index")
|
||||
if not isinstance(idx, int):
|
||||
idx = len(blocks)
|
||||
@@ -71,9 +121,40 @@ def merge_ops(blocks: list[dict], ops: list[dict]) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
class RTConn:
|
||||
"""Une connexion WS : file sortante + tâche writer dédiée.
|
||||
|
||||
Le broadcast ne fait que ``put_nowait`` dans la file ; c'est la tâche writer
|
||||
qui consomme et écrit sur le socket. Un client lent n'empêche donc jamais
|
||||
les autres membres de la room de recevoir les messages.
|
||||
"""
|
||||
__slots__ = ("ws", "user", "page_id", "out_q", "writer", "closed",
|
||||
"_ops_count", "_ops_window_start", "created_at")
|
||||
|
||||
def __init__(self, ws: WebSocket, user: dict, page_id: int):
|
||||
self.ws = ws
|
||||
self.user = user
|
||||
self.page_id = page_id
|
||||
self.out_q: asyncio.Queue = asyncio.Queue(maxsize=MAX_OUT_QUEUE)
|
||||
self.writer: asyncio.Task | None = None
|
||||
self.closed = False
|
||||
self._ops_count = 0
|
||||
self._ops_window_start = time.monotonic()
|
||||
self.created_at = time.monotonic()
|
||||
|
||||
def op_budget_ok(self) -> bool:
|
||||
"""Fenêtre glissante simple anti-flood d'opérations."""
|
||||
now = time.monotonic()
|
||||
if now - self._ops_window_start > OP_WINDOW_SECONDS:
|
||||
self._ops_window_start = now
|
||||
self._ops_count = 0
|
||||
self._ops_count += 1
|
||||
return self._ops_count <= OP_WINDOW_MAX
|
||||
|
||||
|
||||
class Room:
|
||||
__slots__ = ("page_id", "blocks", "title", "version", "conns",
|
||||
"persist_task", "dirty")
|
||||
"persist_task", "dirty", "merge_count", "conflict_count")
|
||||
|
||||
def __init__(self, page_id: int):
|
||||
self.page_id = page_id
|
||||
@@ -83,21 +164,21 @@ class Room:
|
||||
self.conns: set[RTConn] = set()
|
||||
self.persist_task: asyncio.Task | None = None
|
||||
self.dirty = False
|
||||
|
||||
|
||||
class RTConn:
|
||||
__slots__ = ("ws", "user", "page_id")
|
||||
|
||||
def __init__(self, ws: WebSocket, user: dict, page_id: int):
|
||||
self.ws = ws
|
||||
self.user = user
|
||||
self.page_id = page_id
|
||||
self.merge_count = 0
|
||||
self.conflict_count = 0
|
||||
|
||||
|
||||
class RealtimeManager:
|
||||
def __init__(self):
|
||||
self._rooms: dict[int, Room] = {}
|
||||
# compteurs globaux (observabilité)
|
||||
self.stat_ops = 0
|
||||
self.stat_merges = 0
|
||||
self.stat_conflicts = 0
|
||||
self.stat_slow_disconnects = 0
|
||||
self.stat_connections_total = 0
|
||||
|
||||
# ── rooms ────────────────────────────────────────────────────────────
|
||||
def room(self, page_id: int) -> Room:
|
||||
return self._rooms.setdefault(page_id, Room(page_id))
|
||||
|
||||
@@ -126,47 +207,95 @@ class RealtimeManager:
|
||||
room.title = row["title"] or ""
|
||||
if (row["content_format"] or "") == "blocks" and row["content"]:
|
||||
try:
|
||||
room.blocks = json.loads(row["content"])
|
||||
blocks = ensure_block_ids(json.loads(row["content"]))
|
||||
# v6.5.0: rooms serve server-resolved synced blocks so a
|
||||
# sync_req never returns a stale cached copy.
|
||||
from app.services.synced_blocks import resolve_synced_block
|
||||
room.blocks = resolve_synced_block(blocks)
|
||||
except Exception:
|
||||
room.blocks = []
|
||||
return True
|
||||
|
||||
# ── entrées / sorties ────────────────────────────────────────────────
|
||||
async def connect(self, ws: WebSocket, page_id: int, user: dict) -> RTConn | None:
|
||||
room = self.room(page_id)
|
||||
# Ne JAMAIS enregistrer la room avant d'avoir validé l'existence de la
|
||||
# page : avant, un 4404 laissait une Room orpheline en mémoire pour
|
||||
# toujours (fuite).
|
||||
existing = self._rooms.get(page_id)
|
||||
room = existing if existing is not None else Room(page_id)
|
||||
if not room.conns and not await self.load_room(room):
|
||||
await ws.close(code=4404)
|
||||
return None
|
||||
return None # room non enregistrée → pas de fuite
|
||||
if existing is None:
|
||||
self._rooms[page_id] = room
|
||||
|
||||
conn = RTConn(ws, user, page_id)
|
||||
self.stat_connections_total += 1
|
||||
conn.writer = asyncio.create_task(self._writer(conn))
|
||||
room.conns.add(conn)
|
||||
|
||||
me = self._peer(user)
|
||||
peers = [self._peer(c.user) for c in room.conns if c is not conn]
|
||||
await ws.send_json({"t": "welcome", "self": me,
|
||||
"peers": peers, "color": me["color"]})
|
||||
await ws.send_json({"t": "sync", "blocks": room.blocks,
|
||||
"title": room.title, "version": room.version})
|
||||
for c in room.conns:
|
||||
if c is not conn:
|
||||
try:
|
||||
await c.ws.send_json({"t": "peer_join", "peer": me})
|
||||
except Exception:
|
||||
pass
|
||||
await self._broadcast(room, {"t": "peer_join", "peer": me}, exclude=conn)
|
||||
return conn
|
||||
|
||||
async def disconnect(self, conn: RTConn):
|
||||
room = self._rooms.get(conn.page_id)
|
||||
if not room:
|
||||
if room is None:
|
||||
self._close_writer(conn)
|
||||
return
|
||||
room.conns.discard(conn)
|
||||
for c in room.conns:
|
||||
try:
|
||||
await c.ws.send_json({"t": "peer_leave",
|
||||
"id": conn.user.get("id") or 0})
|
||||
except Exception:
|
||||
pass
|
||||
self._close_writer(conn)
|
||||
await self._broadcast(room, {"t": "peer_leave",
|
||||
"id": conn.user.get("id") or 0})
|
||||
if not room.conns:
|
||||
await self.flush(room)
|
||||
self._rooms.pop(conn.page_id, None)
|
||||
|
||||
def _close_writer(self, conn: RTConn):
|
||||
conn.closed = True
|
||||
t = conn.writer
|
||||
if t is not None and not t.done():
|
||||
t.cancel()
|
||||
conn.writer = None
|
||||
|
||||
async def _writer(self, conn: RTConn):
|
||||
"""Tâche dédiée : consomme la file sortante et écrit sur le socket.
|
||||
|
||||
Sert aussi de filet de coalescence : après chaque message consommé, les
|
||||
mises à jour de curseur (``sel``) déjà empilées sont réduites à la
|
||||
dernière (les curseurs n'ont pas besoin d'être ordonnés entre eux, seule
|
||||
la position la plus récente compte).
|
||||
"""
|
||||
try:
|
||||
while not conn.closed:
|
||||
msg = await conn.out_q.get()
|
||||
if msg is None:
|
||||
return
|
||||
await conn.ws.send_json(msg)
|
||||
# coalescence des curseurs en attente
|
||||
pending_sel = []
|
||||
while not conn.out_q.empty():
|
||||
nxt = conn.out_q.get_nowait()
|
||||
if nxt is None:
|
||||
return
|
||||
if isinstance(nxt, dict) and nxt.get("t") == "sel":
|
||||
pending_sel.append(nxt)
|
||||
else:
|
||||
await conn.ws.send_json(nxt)
|
||||
if pending_sel:
|
||||
await conn.ws.send_json(pending_sel[-1])
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e: # socket mort → on arrête proprement
|
||||
logger.debug("writer stopped: %s", e)
|
||||
conn.closed = True
|
||||
|
||||
# ── persistance ──────────────────────────────────────────────────────
|
||||
async def flush(self, room: Room):
|
||||
"""Write current room state to DB (sync, used on idle + disconnect)."""
|
||||
if room.persist_task and not room.persist_task.done():
|
||||
@@ -199,67 +328,102 @@ class RealtimeManager:
|
||||
|
||||
room.persist_task = asyncio.create_task(_run())
|
||||
|
||||
# ── broadcast non bloquant ───────────────────────────────────────────
|
||||
def _enqueue(self, conn: RTConn, msg: dict) -> bool:
|
||||
"""Pose le message dans la file du client. Retourne False si trop lent."""
|
||||
if conn.closed:
|
||||
return False
|
||||
try:
|
||||
conn.out_q.put_nowait(msg)
|
||||
return True
|
||||
except asyncio.QueueFull:
|
||||
return False
|
||||
|
||||
async def _evict_slow(self, room: Room, conn: RTConn):
|
||||
"""Client dépassé : on le déconnecte pour ne pas figer la room."""
|
||||
self.stat_slow_disconnects += 1
|
||||
logger.info("realtime: evicting slow client (user=%s page=%s)",
|
||||
conn.user.get("login"), conn.page_id)
|
||||
room.conns.discard(conn)
|
||||
self._close_writer(conn)
|
||||
try:
|
||||
await conn.ws.close(code=4413)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _broadcast(self, room: Room, msg: dict, exclude: RTConn | None = None):
|
||||
for c in room.conns:
|
||||
"""Enfile ``msg`` chez chaque membre — jamais d'attente sur le socket."""
|
||||
slow: list[RTConn] = []
|
||||
for c in list(room.conns):
|
||||
if c is exclude:
|
||||
continue
|
||||
try:
|
||||
await c.ws.send_json(msg)
|
||||
except Exception:
|
||||
pass
|
||||
if not self._enqueue(c, msg):
|
||||
slow.append(c)
|
||||
for c in slow:
|
||||
await self._evict_slow(room, c)
|
||||
|
||||
async def _send(self, conn: RTConn, msg: dict):
|
||||
if not self._enqueue(conn, msg):
|
||||
room = self._rooms.get(conn.page_id)
|
||||
if room is not None:
|
||||
await self._evict_slow(room, conn)
|
||||
|
||||
# ── protocole ────────────────────────────────────────────────────────
|
||||
async def handle(self, conn: RTConn, msg: dict):
|
||||
room = self._rooms.get(conn.page_id)
|
||||
if not room:
|
||||
if room is None:
|
||||
return
|
||||
t = msg.get("t")
|
||||
me = (conn.user.get("id") or 0)
|
||||
|
||||
if t == "hello":
|
||||
try:
|
||||
await conn.ws.send_json({"t": "sync", "blocks": room.blocks,
|
||||
"title": room.title, "version": room.version})
|
||||
except Exception:
|
||||
pass
|
||||
await self._send(conn, {"t": "sync", "blocks": room.blocks,
|
||||
"title": room.title, "version": room.version})
|
||||
return
|
||||
|
||||
if t == "sync_req":
|
||||
try:
|
||||
await conn.ws.send_json({"t": "sync", "blocks": room.blocks,
|
||||
"title": room.title, "version": room.version})
|
||||
except Exception:
|
||||
pass
|
||||
await self._send(conn, {"t": "sync", "blocks": room.blocks,
|
||||
"title": room.title, "version": room.version})
|
||||
return
|
||||
|
||||
if t == "ping":
|
||||
try:
|
||||
await conn.ws.send_json({"t": "pong"})
|
||||
except Exception:
|
||||
pass
|
||||
await self._send(conn, {"t": "pong"})
|
||||
return
|
||||
|
||||
if t == "op":
|
||||
if not conn.op_budget_ok():
|
||||
# trop d'ops : on ignore silencieusement (le client resync)
|
||||
await self._send(conn, {"t": "ack", "v": room.version,
|
||||
"stale": True})
|
||||
return
|
||||
op = msg.get("op") or {}
|
||||
client_v = msg.get("v", 0)
|
||||
room.blocks = apply_op(room.blocks, op)
|
||||
result = self._apply(room, op)
|
||||
room.version += 1
|
||||
self.stat_ops += 1
|
||||
self._schedule_persist(room)
|
||||
stale = client_v < room.version - 1
|
||||
await self._broadcast(room, {"t": "op", "op": op, "from": me,
|
||||
"v": room.version}, exclude=conn)
|
||||
try:
|
||||
await conn.ws.send_json({"t": "ack", "v": room.version,
|
||||
"stale": stale})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# broadcast : on diffuse TOUJOURS le bloc final fusionné (pas la
|
||||
# proposition brute) pour que tous les clients convergent.
|
||||
out_op = dict(op)
|
||||
if result.get("merged") is not None:
|
||||
out_op["block"] = result["merged"]
|
||||
out_op["merged"] = True
|
||||
await self._broadcast(room, {"t": "op", "op": out_op, "from": me,
|
||||
"v": room.version,
|
||||
"conflict": result.get("conflict", False)},
|
||||
exclude=conn)
|
||||
|
||||
ack = {"t": "ack", "v": room.version, "stale": stale}
|
||||
if result.get("merged") is not None:
|
||||
ack["merged"] = result["merged"]
|
||||
ack["conflict"] = result.get("conflict", False)
|
||||
await self._send(conn, ack)
|
||||
|
||||
if stale:
|
||||
try:
|
||||
await conn.ws.send_json({"t": "sync",
|
||||
"blocks": room.blocks,
|
||||
"title": room.title,
|
||||
"version": room.version})
|
||||
except Exception:
|
||||
pass
|
||||
await self._send(conn, {"t": "sync", "blocks": room.blocks,
|
||||
"title": room.title, "version": room.version})
|
||||
return
|
||||
|
||||
if t == "title":
|
||||
@@ -270,11 +434,7 @@ class RealtimeManager:
|
||||
self._schedule_persist(room)
|
||||
await self._broadcast(room, {"t": "title", "title": room.title,
|
||||
"from": me, "v": room.version}, exclude=conn)
|
||||
try:
|
||||
await conn.ws.send_json({"t": "ack", "v": room.version,
|
||||
"stale": False})
|
||||
except Exception:
|
||||
pass
|
||||
await self._send(conn, {"t": "ack", "v": room.version, "stale": False})
|
||||
return
|
||||
|
||||
if t == "sel":
|
||||
@@ -284,11 +444,87 @@ class RealtimeManager:
|
||||
"offset": msg.get("offset", 0)}, exclude=conn)
|
||||
return
|
||||
|
||||
def _apply(self, room: Room, op: dict) -> dict:
|
||||
"""Applique une opération en mergeant à 3 voix si le client fournit
|
||||
une ``base``. Retourne ``{"merged": bloc|None, "conflict": bool}``.
|
||||
|
||||
* ``update`` avec ``base`` → merge 3-voix (au-delà du LWW).
|
||||
* le reste (insert/delete/move, ou update sans base) → LWW historique.
|
||||
"""
|
||||
if op.get("type") != "update" or "base" not in op:
|
||||
room.blocks = apply_op(room.blocks, op)
|
||||
return {"merged": None, "conflict": False}
|
||||
|
||||
incoming = op.get("block") or {}
|
||||
base = op.get("base")
|
||||
bid = incoming.get("id")
|
||||
if not bid:
|
||||
return {"merged": None, "conflict": False}
|
||||
|
||||
current = next((b for b in room.blocks if b.get("id") == bid), None)
|
||||
if current is None:
|
||||
# bloc introuvable : LWW historique = pas d'update possible
|
||||
return {"merged": None, "conflict": False}
|
||||
|
||||
merged, conflicts = merge_block_3way(base, current, incoming)
|
||||
room.merge_count += 1
|
||||
conflict = bool(conflicts)
|
||||
if conflict:
|
||||
room.conflict_count += 1
|
||||
self.stat_conflicts += 1
|
||||
logger.debug("realtime conflict page=%s block=%s fields=%s",
|
||||
room.page_id, bid, conflicts)
|
||||
self.stat_merges += 1
|
||||
room.blocks = [merged if b.get("id") == bid else b for b in room.blocks]
|
||||
return {"merged": merged, "conflict": conflict}
|
||||
|
||||
# ── observabilité ────────────────────────────────────────────────────
|
||||
async def room_state(self, page_id: int) -> dict:
|
||||
room = self.room(page_id)
|
||||
if not room.conns and not room.blocks:
|
||||
room = self._rooms.get(page_id)
|
||||
if room is None:
|
||||
# ne pas créer d'objet None : on charge dans une room jetable
|
||||
room = Room(page_id)
|
||||
if not await self.load_room(room):
|
||||
return {"blocks": [], "title": "", "version": 0}
|
||||
elif not room.conns and not room.blocks:
|
||||
await self.load_room(room)
|
||||
return {"blocks": room.blocks, "title": room.title, "version": room.version}
|
||||
|
||||
def stats(self) -> dict:
|
||||
rooms = len(self._rooms)
|
||||
conns = sum(len(r.conns) for r in self._rooms.values())
|
||||
return {
|
||||
"rooms": rooms,
|
||||
"connections": conns,
|
||||
"connections_total": self.stat_connections_total,
|
||||
"ops": self.stat_ops,
|
||||
"merges": self.stat_merges,
|
||||
"conflicts": self.stat_conflicts,
|
||||
"slow_disconnects": self.stat_slow_disconnects,
|
||||
"pages": [{"page_id": r.page_id, "conns": len(r.conns),
|
||||
"version": r.version, "merges": r.merge_count,
|
||||
"conflicts": r.conflict_count}
|
||||
for r in self._rooms.values()],
|
||||
}
|
||||
|
||||
async def _broadcast_synced_to(self, pages: list[int], synced_id: int) -> None:
|
||||
"""Broadcast a synced-block event to the given pages' open rooms."""
|
||||
for pid in pages:
|
||||
room = self._rooms.get(pid)
|
||||
if room and room.conns:
|
||||
await self.load_room(room)
|
||||
await self._broadcast(room, {"t": "synced_update",
|
||||
"synced_id": synced_id,
|
||||
"version": room.version})
|
||||
|
||||
async def _propagate_synced(self, synced_id: int) -> None:
|
||||
"""Broadcast a synced-block update to all rooms that reference it."""
|
||||
from app.services.synced_blocks import page_ids_for_synced
|
||||
try:
|
||||
pages = page_ids_for_synced(synced_id)
|
||||
except Exception:
|
||||
return
|
||||
await self._broadcast_synced_to(pages, synced_id)
|
||||
|
||||
|
||||
manager = RealtimeManager()
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
"""FlowDeck — Recurring events engine (v5.8.0 Calendrier & Rappels).
|
||||
|
||||
A recurring row stores its rule in ``property_values_json`` under the special
|
||||
key ``__recurrence__``::
|
||||
|
||||
"__recurrence__": { "<date_prop_id>": {"freq": "weekly", "interval": 1,
|
||||
"count": null, "until": null, "byweekday": [0,2,4],
|
||||
"timezone": "Europe/Paris"} }
|
||||
|
||||
Only a RRULE subset is supported (matching the roadmap: daily/weekly/monthly
|
||||
+ custom interval, COUNT, UNTIL and BYDAY for weekly). Expansion is computed
|
||||
on the fly for a visible window — occurrences are virtual, never persisted.
|
||||
|
||||
All pure-Python (stdlib only) so it is trivially testable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar as _cal
|
||||
import datetime as dt
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FREQS = ("daily", "weekly", "monthly")
|
||||
# Monday-first weekday numbering (matches the UI calendar grid).
|
||||
DOW_NAMES = {"mo": 0, "tu": 1, "we": 2, "th": 3, "fr": 4, "sa": 5, "su": 6}
|
||||
|
||||
RECURRENCE_KEY = "__recurrence__"
|
||||
TIMEZONE_KEY = "__timezone__"
|
||||
|
||||
try: # Python >= 3.9 ships zoneinfo; keep a graceful fallback anyway.
|
||||
from zoneinfo import ZoneInfo, available_timezones
|
||||
|
||||
def _zone(tz_name: str):
|
||||
try:
|
||||
return ZoneInfo(tz_name)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def is_valid_timezone(tz_name: str) -> bool:
|
||||
if not tz_name:
|
||||
return True
|
||||
try:
|
||||
return tz_name in available_timezones() or tz_name == "UTC"
|
||||
except Exception:
|
||||
return False
|
||||
except Exception: # pragma: no cover - environment without tz database
|
||||
def _zone(tz_name: str):
|
||||
return None
|
||||
|
||||
def is_valid_timezone(tz_name: str) -> bool:
|
||||
return True # no tz database → accept anything (zoneinfo absent)
|
||||
|
||||
|
||||
def parse_date(value) -> dt.date | None:
|
||||
"""Parse a stored date property value ('YYYY-MM-DD[THH:MM]') into a date."""
|
||||
s = str(value or "").strip()
|
||||
if not s:
|
||||
return None
|
||||
s = s.replace(" ", "T")
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d"):
|
||||
try:
|
||||
return dt.datetime.strptime(s[:19], fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
return dt.date.fromisoformat(s[:10])
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def validate_rule(rule) -> tuple[bool, str]:
|
||||
"""Validate a recurrence rule dict. Returns (ok, error_message)."""
|
||||
if rule is None:
|
||||
return True, ""
|
||||
if not isinstance(rule, dict):
|
||||
return False, "Recurrence must be an object"
|
||||
freq = rule.get("freq")
|
||||
if freq not in FREQS:
|
||||
return False, f"freq must be one of {FREQS}"
|
||||
interval = rule.get("interval", 1)
|
||||
if not isinstance(interval, int) or isinstance(interval, bool) or interval < 1 or interval > 365:
|
||||
return False, "interval must be an integer between 1 and 365"
|
||||
count = rule.get("count")
|
||||
if count is not None:
|
||||
if not isinstance(count, int) or isinstance(count, bool) or count < 1 or count > 1000:
|
||||
return False, "count must be an integer between 1 and 1000"
|
||||
until = rule.get("until")
|
||||
if until not in (None, "") and parse_date(until) is None:
|
||||
return False, "until must be a YYYY-MM-DD date"
|
||||
if count is not None and until not in (None, ""):
|
||||
return False, "count and until are mutually exclusive"
|
||||
bywd = rule.get("byweekday")
|
||||
if bywd not in (None, []):
|
||||
if not isinstance(bywd, list) or any(
|
||||
(not isinstance(d, int) or isinstance(d, bool) or d < 0 or d > 6) for d in bywd
|
||||
):
|
||||
return False, "byweekday must be a list of integers 0-6 (Monday=0)"
|
||||
if freq != "weekly":
|
||||
return False, "byweekday is only valid for weekly recurrence"
|
||||
tz = rule.get("timezone")
|
||||
if tz not in (None, "") and not is_valid_timezone(tz):
|
||||
return False, f"unknown timezone '{tz}'"
|
||||
return True, ""
|
||||
|
||||
|
||||
def _generate_occurrences(base: dt.date, rule: dict):
|
||||
"""Yield every occurrence date in chronological order, honouring
|
||||
freq/interval/count/until/byweekday. Infinite rules yield forever —
|
||||
the caller must bound the consumption (see expand_rule)."""
|
||||
freq = rule.get("freq", "daily")
|
||||
interval = max(1, int(rule.get("interval") or 1))
|
||||
count = rule.get("count")
|
||||
until = parse_date(rule.get("until")) if rule.get("until") else None
|
||||
weekdays = sorted(set(rule.get("byweekday") or [])) if freq == "weekly" else []
|
||||
|
||||
produced = 0
|
||||
|
||||
def limit_hit(d: dt.date) -> bool:
|
||||
return (count is not None and produced >= count) or bool(until and d > until)
|
||||
|
||||
if freq == "daily":
|
||||
d = base
|
||||
while not limit_hit(d):
|
||||
yield d
|
||||
produced += 1
|
||||
d += dt.timedelta(days=interval)
|
||||
elif freq == "weekly":
|
||||
# Monday=0 weekday numbering (matches the UI calendar grid).
|
||||
days = weekdays or [(base.isoweekday() - 1) % 7]
|
||||
week0 = base - dt.timedelta(days=(base.isoweekday() - 1) % 7)
|
||||
w = week0
|
||||
while True:
|
||||
for off in days:
|
||||
d = w + dt.timedelta(days=off)
|
||||
if d < base or limit_hit(d):
|
||||
continue
|
||||
yield d
|
||||
produced += 1
|
||||
w += dt.timedelta(weeks=interval)
|
||||
if limit_hit(w):
|
||||
return
|
||||
else: # monthly
|
||||
i = 0
|
||||
while True:
|
||||
d = _nth_month_occurrence(base, i * interval)
|
||||
if limit_hit(d):
|
||||
return
|
||||
yield d
|
||||
produced += 1
|
||||
i += 1
|
||||
|
||||
|
||||
def expand_rule(base_value, rule: dict, window_start: dt.date, window_end: dt.date,
|
||||
max_occurrences: int = 500) -> list[str]:
|
||||
"""Return ISO dates ('YYYY-MM-DD') of occurrences inside the inclusive
|
||||
window [window_start, window_end] for a rule anchored at ``base_value``.
|
||||
|
||||
Occurrences before the window are skipped (but still count against
|
||||
``count``); the expansion is hard-capped so pathological rules can never
|
||||
blow up a request.
|
||||
"""
|
||||
base = parse_date(base_value)
|
||||
ok, _err = validate_rule(rule)
|
||||
if base is None or rule is None or not ok:
|
||||
return []
|
||||
|
||||
out: list[str] = []
|
||||
scanned = 0
|
||||
for d in _generate_occurrences(base, rule or {}):
|
||||
if d > window_end:
|
||||
break
|
||||
scanned += 1
|
||||
if scanned > 200000: # safety valve for old, very long-running rules
|
||||
break
|
||||
if d >= window_start:
|
||||
out.append(d.isoformat())
|
||||
if len(out) >= max_occurrences:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _nth_month_occurrence(base: dt.date, months_offset: int) -> dt.date:
|
||||
month_index = (base.year * 12 + (base.month - 1)) + months_offset
|
||||
year, month0 = divmod(month_index, 12)
|
||||
month = month0 + 1
|
||||
day = min(base.day, _cal.monthrange(year, month)[1])
|
||||
return dt.date(year, month, day)
|
||||
|
||||
|
||||
def now_in_tz(tz_name: str | None = None) -> dt.datetime:
|
||||
"""Current time in the given IANA timezone (fallback: UTC)."""
|
||||
zone = _zone(tz_name) if tz_name else None
|
||||
if zone is not None:
|
||||
return dt.datetime.now(zone)
|
||||
return dt.datetime.now(dt.UTC)
|
||||
|
||||
|
||||
def local_date_in_tz(tz_name: str | None = None) -> dt.date:
|
||||
"""'Today' from the point of view of ``tz_name`` (fallback UTC)."""
|
||||
return now_in_tz(tz_name).date()
|
||||
|
||||
|
||||
def _zone_dt(d: dt.date, time_str: str, tz_name: str | None):
|
||||
"""Combine an occurrence date + 'HH:MM' time into an aware datetime in
|
||||
``tz_name`` (fallback naive→UTC). Default time 09:00 when none stored."""
|
||||
hh, mm = 9, 0
|
||||
if time_str and len(time_str) >= 16:
|
||||
try:
|
||||
hh, mm = int(time_str[11:13]), int(time_str[14:16])
|
||||
except ValueError:
|
||||
pass
|
||||
zone = _zone(tz_name) if tz_name else None
|
||||
naive = dt.datetime(d.year, d.month, d.day, hh, mm)
|
||||
if zone is not None:
|
||||
return naive.replace(tzinfo=zone)
|
||||
return naive.replace(tzinfo=dt.UTC)
|
||||
|
||||
|
||||
def next_occurrences(base_value, rule: dict, tz_name: str | None,
|
||||
after: dt.datetime, horizon_days: int = 365,
|
||||
max_count: int = 50) -> list[tuple[dt.date, dt.datetime]]:
|
||||
"""Upcoming occurrences (date, aware event datetime) strictly on/after
|
||||
``after`` for a row anchored at ``base_value``. Non-recurring rules
|
||||
(rule falsy) yield the single base occurrence. Bounded by horizon."""
|
||||
base = parse_date(base_value)
|
||||
if base is None:
|
||||
return []
|
||||
time_str = str(base_value or "")
|
||||
start = after.date()
|
||||
if not rule:
|
||||
event = _zone_dt(base, time_str, tz_name)
|
||||
return [(base, event)] if event >= after else []
|
||||
ok, _ = validate_rule(rule)
|
||||
if not ok:
|
||||
return []
|
||||
out: list[tuple[dt.date, dt.datetime]] = []
|
||||
window_end = start + dt.timedelta(days=horizon_days)
|
||||
for iso in expand_rule(base_value, rule, start, window_end, max_occurrences=max_count):
|
||||
d = dt.date.fromisoformat(iso)
|
||||
event = _zone_dt(d, time_str, tz_name)
|
||||
if event >= after:
|
||||
out.append((d, event))
|
||||
if len(out) >= max_count:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
# A pragmatic list for pickers (zoneinfo's full list is ~600 entries).
|
||||
COMMON_TIMEZONES = [
|
||||
"UTC", "Europe/Paris", "Europe/London", "Europe/Berlin", "Europe/Madrid",
|
||||
"Europe/Rome", "Europe/Amsterdam", "Europe/Lisbon", "Europe/Zurich",
|
||||
"Europe/Stockholm", "Europe/Warsaw", "Europe/Moscow", "Europe/Istanbul",
|
||||
"America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles",
|
||||
"America/Toronto", "America/Vancouver", "America/Mexico_City", "America/Sao_Paulo",
|
||||
"America/Buenos_Aires", "Asia/Dubai", "Asia/Karachi", "Asia/Kolkata",
|
||||
"Asia/Bangkok", "Asia/Singapore", "Asia/Shanghai", "Asia/Tokyo", "Asia/Seoul",
|
||||
"Australia/Sydney", "Australia/Perth", "Pacific/Auckland", "Africa/Cairo",
|
||||
"Africa/Lagos", "Africa/Johannesburg", "Africa/Algiers", "Africa/Casablanca",
|
||||
]
|
||||
|
||||
|
||||
def common_timezones() -> list[str]:
|
||||
"""Timezone list for pickers: curated common zones + all installed ones
|
||||
whose region matches, deduplicated and sorted."""
|
||||
try:
|
||||
from zoneinfo import available_timezones
|
||||
all_zones = sorted(available_timezones())
|
||||
# Merge: keep curated first, then everything else available.
|
||||
extra = [z for z in all_zones if z not in COMMON_TIMEZONES]
|
||||
return COMMON_TIMEZONES + extra
|
||||
except Exception:
|
||||
return list(COMMON_TIMEZONES)
|
||||
@@ -0,0 +1,225 @@
|
||||
"""FlowDeck — Reminder service (v5.8.0 Calendrier & Rappels).
|
||||
|
||||
Reminders live on database rows, next to the recurrence rule, inside
|
||||
``property_values_json`` under the special key ``__reminder__``::
|
||||
|
||||
"__reminder__": { "<date_prop_id>": {"value": 30, "unit": "minutes"} }
|
||||
|
||||
A background loop scans rows with reminders, computes the next occurrence
|
||||
(recurrence-aware, timezone-aware), and fires an in-app notification (+ email
|
||||
when the user opted in) at ``event - lead``. Fired reminders are deduplicated
|
||||
through the ``reminder_log`` table keyed by (page_id, occurrence date).
|
||||
|
||||
The scan is a plain synchronous function (:func:`scan_and_fire`) so tests can
|
||||
drive it deterministically with an injected ``now``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import logging
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.recurrence import (
|
||||
RECURRENCE_KEY,
|
||||
TIMEZONE_KEY,
|
||||
next_occurrences,
|
||||
parse_date,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
REMINDER_KEY = "__reminder__"
|
||||
_UNITS = {"minutes": 60, "hours": 3600, "days": 86400}
|
||||
# Fire late is still useful; ignore events that happened long ago.
|
||||
FIRE_GRACE_SECONDS = 3600
|
||||
|
||||
|
||||
def parse_lead(reminder) -> int | None:
|
||||
"""Return the lead in seconds for a reminder dict, or None if disabled."""
|
||||
if not isinstance(reminder, dict):
|
||||
return None
|
||||
unit = reminder.get("unit", "none")
|
||||
if unit not in _UNITS:
|
||||
return None
|
||||
try:
|
||||
value = int(reminder.get("value", 0))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if value <= 0:
|
||||
return None
|
||||
return value * _UNITS[unit]
|
||||
|
||||
|
||||
def lead_human(reminder: dict) -> str:
|
||||
try:
|
||||
value = int(reminder.get("value", 0))
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
unit = reminder.get("unit", "")
|
||||
if value == 1 and unit.endswith("s"):
|
||||
unit = unit[:-1]
|
||||
return f"{value} {unit}"
|
||||
|
||||
|
||||
def _person_targets(conn, collection_id: int, props: dict) -> list[int]:
|
||||
"""User ids assigned via any ``person`` property of the row."""
|
||||
targets: list[int] = []
|
||||
person_props = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='person'",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
for pp in person_props:
|
||||
value = props.get(str(pp["id"])) or props.get(pp["name"] if "name" in pp.keys() else None)
|
||||
if isinstance(value, list):
|
||||
for person in value:
|
||||
if isinstance(person, dict) and person.get("id"):
|
||||
targets.append(int(person["id"]))
|
||||
return targets
|
||||
|
||||
|
||||
def _row_tz(conn, collection_id: int, props: dict) -> str:
|
||||
"""Effective timezone of a row: per-event ``__timezone__`` first, then the
|
||||
recurrence rule tz, then the created_by user's personal timezone,
|
||||
then '' (UTC)."""
|
||||
tzmap = props.get(TIMEZONE_KEY)
|
||||
if isinstance(tzmap, dict):
|
||||
for value in tzmap.values():
|
||||
if value:
|
||||
return str(value)
|
||||
rec = props.get(RECURRENCE_KEY) or {}
|
||||
for rule in rec.values():
|
||||
if isinstance(rule, dict) and rule.get("timezone"):
|
||||
return rule["timezone"]
|
||||
auto_props = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='created_by'",
|
||||
(collection_id,),
|
||||
).fetchall()
|
||||
for ap in auto_props:
|
||||
value = props.get(str(ap["id"]))
|
||||
people = value if isinstance(value, list) else ([value] if isinstance(value, dict) else [])
|
||||
for person in people:
|
||||
if isinstance(person, dict) and person.get("id"):
|
||||
row = conn.execute(
|
||||
"SELECT timezone FROM users WHERE id=?", (person["id"],)
|
||||
).fetchone()
|
||||
if row and row["timezone"]:
|
||||
return row["timezone"]
|
||||
return ""
|
||||
|
||||
|
||||
def scan_and_fire(now: dt.datetime | None = None) -> int:
|
||||
"""Fire every reminder that is due. Returns the number of notifications
|
||||
created. Safe to call repeatedly (dedup via reminder_log)."""
|
||||
now = now or dt.datetime.now(dt.UTC)
|
||||
fired = 0
|
||||
with get_conn() as conn:
|
||||
pages = conn.execute(
|
||||
"SELECT id, collection_id, title, property_values_json FROM collection_pages"
|
||||
).fetchall()
|
||||
for page in pages:
|
||||
try:
|
||||
props = json.loads(page["property_values_json"] or "{}")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
reminders = props.get(REMINDER_KEY)
|
||||
if not isinstance(reminders, dict):
|
||||
continue
|
||||
rec_all = props.get(RECURRENCE_KEY) if isinstance(props.get(RECURRENCE_KEY), dict) else {}
|
||||
for prop_key, reminder in reminders.items():
|
||||
lead = parse_lead(reminder)
|
||||
if lead is None:
|
||||
continue
|
||||
base_value = props.get(str(prop_key))
|
||||
if not base_value and str(prop_key).isdigit():
|
||||
# fall back to name-keyed storage
|
||||
pname = conn.execute(
|
||||
"SELECT name FROM collection_properties WHERE id=?", (int(prop_key),)
|
||||
).fetchone()
|
||||
if pname:
|
||||
base_value = props.get(pname["name"])
|
||||
if parse_date(base_value) is None:
|
||||
continue
|
||||
rule = rec_all.get(str(prop_key))
|
||||
if rule is None and not str(prop_key).isdigit():
|
||||
pid = conn.execute(
|
||||
"SELECT id FROM collection_properties WHERE collection_id=? AND name=?",
|
||||
(page["collection_id"], str(prop_key)),
|
||||
).fetchone()
|
||||
if pid:
|
||||
rule = rec_all.get(str(pid["id"]))
|
||||
tz_name = _row_tz(conn, page["collection_id"], props)
|
||||
# Look for the occurrence whose reminder moment is in
|
||||
# [now - grace, now].
|
||||
events = next_occurrences(
|
||||
base_value, rule, tz_name or None,
|
||||
after=now - dt.timedelta(seconds=lead + FIRE_GRACE_SECONDS),
|
||||
horizon_days=400, max_count=2000,
|
||||
)
|
||||
due = None
|
||||
for _d, event in events:
|
||||
fire_at = event - dt.timedelta(seconds=lead)
|
||||
if fire_at <= now and now < event + dt.timedelta(seconds=FIRE_GRACE_SECONDS):
|
||||
due = (event, fire_at)
|
||||
break
|
||||
if due is None:
|
||||
continue
|
||||
event, _fire_at = due
|
||||
if event < now - dt.timedelta(seconds=FIRE_GRACE_SECONDS):
|
||||
continue
|
||||
target_ids = _person_targets(conn, page["collection_id"], props)
|
||||
if not target_ids:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM users WHERE is_admin=1 ORDER BY id LIMIT 1"
|
||||
).fetchone()
|
||||
target_ids = [row["id"]] if row else []
|
||||
for uid in dict.fromkeys(target_ids):
|
||||
cur = conn.execute(
|
||||
"INSERT OR IGNORE INTO reminder_log (page_id, occurrence_date) VALUES (?, ?)",
|
||||
(page["id"], event.date().isoformat()),
|
||||
)
|
||||
if cur.rowcount != 1:
|
||||
continue
|
||||
from app.services import mailer, notifications
|
||||
lead_text = lead_human(reminder)
|
||||
notifications.create_notification(
|
||||
uid, None, "reminder",
|
||||
title=f"Reminder: {page['title'] or 'Untitled'}",
|
||||
message=(f"Due in {lead_text} — {event.strftime('%Y-%m-%d %H:%M')}"
|
||||
if lead else
|
||||
f"Happening now — {event.strftime('%Y-%m-%d %H:%M')}"),
|
||||
resource_type="db_page",
|
||||
resource_id=page["id"],
|
||||
url=f"/collections/{page['collection_id']}",
|
||||
conn=conn, commit=False,
|
||||
)
|
||||
mailer.notify_user(
|
||||
uid,
|
||||
subject=f"[FlowDeck] Reminder: {page['title'] or 'Untitled'}",
|
||||
body_text=f"« {page['title'] or 'Untitled'} » le {event.strftime('%Y-%m-%d %H:%M')} ({tz_name or 'UTC'}).",
|
||||
cta_url=f"/collections/{page['collection_id']}",
|
||||
prefs_key="reminders",
|
||||
)
|
||||
fired += 1
|
||||
conn.commit()
|
||||
return fired
|
||||
|
||||
|
||||
async def reminder_scheduler(interval_seconds: int = 60):
|
||||
"""Background loop: scan for due reminders once a minute."""
|
||||
import asyncio
|
||||
|
||||
from app.config import settings
|
||||
|
||||
if not getattr(settings, "reminders_enabled", True):
|
||||
logger.info("Reminder scheduler disabled via settings")
|
||||
return
|
||||
await asyncio.sleep(5)
|
||||
while True:
|
||||
try:
|
||||
n = scan_and_fire()
|
||||
if n:
|
||||
logger.info("Reminders: fired %d notification(s)", n)
|
||||
except Exception as exc: # pragma: no cover - defensive only
|
||||
logger.warning("Reminder scan failed: %s", exc)
|
||||
await asyncio.sleep(max(10, int(getattr(settings, "reminder_scan_interval_seconds", interval_seconds))))
|
||||
@@ -0,0 +1,123 @@
|
||||
"""FlowDeck — v6.5.0 database row content pages (synced blocks production).
|
||||
|
||||
A database row (``collection_pages``) has no block content of its own:
|
||||
its Notion-style content lives in a shadow ``pages`` row linked via
|
||||
``pages.collection_row_id``. The full page editor (synced blocks,
|
||||
versions, realtime, permissions) is reused unchanged on that page.
|
||||
|
||||
``ensure_row_page()`` lazily creates (or heals) the shadow page and is
|
||||
the single entry point used by the row-open endpoint.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _host_workspace(conn, coll: dict) -> tuple[str, int | None]:
|
||||
"""Workspace (key, id) the shadow page should live in.
|
||||
|
||||
Resolution order: full-page database host page → inline parent page →
|
||||
the collection's own ``workspace_id`` (key left empty).
|
||||
"""
|
||||
host = conn.execute(
|
||||
"SELECT workspace, workspace_id FROM pages "
|
||||
"WHERE collection_id=? AND deleted_at IS NULL "
|
||||
"AND collection_row_id IS NULL LIMIT 1",
|
||||
(coll.get("id"),),
|
||||
).fetchone()
|
||||
if not host and coll.get("parent_page_id"):
|
||||
host = conn.execute(
|
||||
"SELECT workspace, workspace_id FROM pages WHERE id=?",
|
||||
(coll["parent_page_id"],),
|
||||
).fetchone()
|
||||
if host:
|
||||
return host["workspace"] or "", host["workspace_id"]
|
||||
if coll.get("gitea_owner") and coll.get("gitea_repo"):
|
||||
return f"{coll['gitea_owner']}/{coll['gitea_repo']}", coll.get("workspace_id")
|
||||
return "", coll.get("workspace_id")
|
||||
|
||||
|
||||
def ensure_row_page(row_id: int) -> int:
|
||||
"""Return the content page id for a database row, creating it if needed.
|
||||
|
||||
- existing live page → returned as-is;
|
||||
- soft-deleted page → restored (title resynced);
|
||||
- missing → created with the row's title and the collection workspace.
|
||||
|
||||
Raises ``KeyError`` when the row does not exist.
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, collection_id, title FROM collection_pages WHERE id=?",
|
||||
(row_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise KeyError(f"row {row_id} not found")
|
||||
coll_row = conn.execute(
|
||||
"SELECT id, workspace_id, parent_page_id, gitea_owner, gitea_repo "
|
||||
"FROM collections WHERE id=?",
|
||||
(row["collection_id"],),
|
||||
).fetchone()
|
||||
coll = dict(coll_row) if coll_row else {"id": row["collection_id"]}
|
||||
|
||||
existing = conn.execute(
|
||||
"SELECT id, deleted_at, title FROM pages WHERE collection_row_id=?",
|
||||
(row_id,),
|
||||
).fetchone()
|
||||
if existing:
|
||||
if existing["deleted_at"]:
|
||||
conn.execute(
|
||||
"UPDATE pages SET deleted_at=NULL, parent_section='DbRow', "
|
||||
"title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(row["title"] or existing["title"] or "Untitled", existing["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return existing["id"]
|
||||
|
||||
ws_key, ws_id = _host_workspace(conn, coll)
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO pages
|
||||
(workspace, workspace_id, title, content, content_format,
|
||||
parent_section, collection_id, collection_row_id)
|
||||
VALUES (?, ?, ?, '', 'blocks', 'DbRow', ?, ?)""",
|
||||
(ws_key, ws_id, row["title"] or "Untitled",
|
||||
row["collection_id"], row_id),
|
||||
)
|
||||
page_id = cur.lastrowid
|
||||
conn.commit()
|
||||
logger.debug("created content page %s for row %s", page_id, row_id)
|
||||
return page_id
|
||||
|
||||
|
||||
def sync_row_title_to_page(conn, row_id: int) -> None:
|
||||
"""Row → page: copy the row's title onto its content page (if any)."""
|
||||
conn.execute(
|
||||
"""UPDATE pages SET title=(
|
||||
SELECT title FROM collection_pages WHERE id=pages.collection_row_id
|
||||
), updated_at=CURRENT_TIMESTAMP
|
||||
WHERE collection_row_id=? AND EXISTS (
|
||||
SELECT 1 FROM collection_pages cp
|
||||
WHERE cp.id=pages.collection_row_id AND cp.title<>pages.title
|
||||
)""",
|
||||
(row_id,),
|
||||
)
|
||||
|
||||
|
||||
def sync_page_title_to_row(conn, page_id: int) -> None:
|
||||
"""Page → row: a rename inside the editor propagates to the row."""
|
||||
conn.execute(
|
||||
"""UPDATE collection_pages SET title=(
|
||||
SELECT title FROM pages WHERE id=?
|
||||
), updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=(SELECT collection_row_id FROM pages WHERE id=?)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM pages p
|
||||
WHERE p.id=? AND p.collection_row_id IS NOT NULL
|
||||
AND p.title<>collection_pages.title
|
||||
)""",
|
||||
(page_id, page_id, page_id),
|
||||
)
|
||||
@@ -0,0 +1,421 @@
|
||||
"""FlowDeck — offline synchronization engine (v6.0.0 PWA).
|
||||
|
||||
Reconciles offline mutations (queued on the client) with server state:
|
||||
|
||||
- ``get_delta`` — changes on the server since a given timestamp, for offline
|
||||
clients to pull before pushing their own batch.
|
||||
- ``apply_batch`` — replays a batch of offline mutations with optimistic
|
||||
concurrency control. ``sync_version`` (auto-bumped by SQLite triggers added
|
||||
in migration v17) is the version token.
|
||||
|
||||
Conflict model (from docs/V6_PWA_Progressive_Web_App.md):
|
||||
- edit-edit → last-write-wins by default (applied + reported)
|
||||
- edit-delete→ the page was deleted server-side → orphan copy created
|
||||
- create-create → same title already exists server-side → renamed "… (copie offline)"
|
||||
- delete → soft-delete (idempotent)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConflictError(Exception):
|
||||
"""Raised when a mutation conflicts with server state."""
|
||||
|
||||
def __init__(self, detail: dict):
|
||||
super().__init__(detail.get("type"))
|
||||
self.details = detail
|
||||
|
||||
|
||||
class SyncEngine:
|
||||
"""Apply/read offline mutations. Stateless methods, thin sqlite access."""
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _now_epoch() -> float:
|
||||
return time.time()
|
||||
|
||||
@staticmethod
|
||||
def _can_access(conn, user_id: int, workspace_id: int | None) -> bool:
|
||||
"""Owner or member of the workspace; legacy NULL workspace → allow."""
|
||||
if not workspace_id:
|
||||
return True
|
||||
ws = conn.execute(
|
||||
"SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)
|
||||
).fetchone()
|
||||
if ws and ws["owner_id"] == user_id:
|
||||
return True
|
||||
member = conn.execute(
|
||||
"SELECT 1 FROM workspace_members WHERE workspace_id=? AND user_id=?",
|
||||
(workspace_id, user_id),
|
||||
).fetchone()
|
||||
return bool(member)
|
||||
|
||||
@staticmethod
|
||||
def _page_workspace_id(conn, page_id: int) -> int | None:
|
||||
row = conn.execute(
|
||||
"SELECT workspace_id FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()
|
||||
return row["workspace_id"] if row else None
|
||||
|
||||
# ── delta ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def get_delta(self, user_id: int, since: float, workspace_id: int | None) -> dict:
|
||||
"""Return server-side changes since `since` (epoch seconds)."""
|
||||
if since and since > 1e12:
|
||||
since /= 1000.0 # accept epoch-millis from legacy clients
|
||||
|
||||
changes: list[dict] = []
|
||||
with get_conn() as conn:
|
||||
if not self._can_access(conn, user_id, workspace_id):
|
||||
return {"error": "forbidden"}
|
||||
|
||||
# ── pages (created / updated / soft-deleted) ──
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM pages
|
||||
WHERE (? IS NULL OR workspace_id = ?)
|
||||
AND (CAST(strftime('%s', COALESCE(updated_at, created_at)) AS REAL) > ?
|
||||
OR (deleted_at IS NOT NULL
|
||||
AND CAST(strftime('%s', deleted_at) AS REAL) > ?))""",
|
||||
(workspace_id, workspace_id, since, since),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
data = dict(r)
|
||||
deleted = data.get("deleted_at") is not None
|
||||
created_epoch = _iso_epoch(data.get("created_at"))
|
||||
created_after = bool(created_epoch and created_epoch > since)
|
||||
if deleted:
|
||||
ctype = "page_deleted"
|
||||
elif created_after:
|
||||
ctype = "page_created"
|
||||
else:
|
||||
ctype = "page_updated"
|
||||
changes.append({"change_type": ctype, "data": data})
|
||||
|
||||
# ── collections (created / updated) ──
|
||||
coll_rows = conn.execute(
|
||||
"SELECT * FROM collections WHERE CAST(strftime('%s', updated_at) AS REAL) > ?",
|
||||
(since,),
|
||||
).fetchall()
|
||||
for r in coll_rows:
|
||||
data = dict(r)
|
||||
created_epoch = _iso_epoch(data.get("created_at"))
|
||||
changes.append({
|
||||
"change_type": "collection_created" if created_epoch and created_epoch > since
|
||||
else "collection_updated",
|
||||
"data": data,
|
||||
})
|
||||
|
||||
# ── collection rows (informational for offline reading) ──
|
||||
cp_rows = conn.execute(
|
||||
"SELECT * FROM collection_pages WHERE CAST(strftime('%s', updated_at) AS REAL) > ?",
|
||||
(since,),
|
||||
).fetchall()
|
||||
for r in cp_rows:
|
||||
changes.append({"change_type": "collection_row_updated", "data": dict(r)})
|
||||
|
||||
return {
|
||||
"changes": changes,
|
||||
"server_time": self._now_epoch(),
|
||||
"has_more": False,
|
||||
}
|
||||
|
||||
# ── batch ──────────────────────────────────────────────────────────────
|
||||
|
||||
async def apply_batch(self, user_id: int, mutations: list[dict], device_id: str) -> dict:
|
||||
"""Apply a batch of offline mutations; returns per-mutation results.
|
||||
|
||||
``mutations`` = [{"id"|"mutation_id", "type", "payload", "client_timestamp"}]
|
||||
"""
|
||||
results: list[dict] = []
|
||||
conflicts: list[dict] = []
|
||||
|
||||
for mut in mutations:
|
||||
mut_id = mut.get("id") or mut.get("mutation_id") or f"m{len(results)}"
|
||||
mtype = mut.get("type", "")
|
||||
payload = mut.get("payload") or {}
|
||||
client_ts = float(mut.get("client_timestamp") or 0)
|
||||
result = {
|
||||
"mutation_id": mut_id,
|
||||
"type": mtype,
|
||||
"status": "synced",
|
||||
"server_version": None,
|
||||
}
|
||||
try:
|
||||
handler = getattr(self, f"_mut_{mtype}", None)
|
||||
if handler is None:
|
||||
raise ValueError(f"unknown mutation type: {mtype}")
|
||||
outcome = handler(user_id, payload)
|
||||
result.update(outcome)
|
||||
status = "conflict" if outcome.get("conflict") else "synced"
|
||||
result["status"] = status
|
||||
if outcome.get("conflict"):
|
||||
conflicts.append({
|
||||
"mutation_id": mut_id,
|
||||
"type": mtype,
|
||||
**outcome["conflict"],
|
||||
})
|
||||
except ConflictError as exc:
|
||||
result["status"] = "conflict"
|
||||
result["conflict"] = exc.details
|
||||
conflicts.append({"mutation_id": mut_id, "type": mtype, **exc.details})
|
||||
except Exception as exc: # noqa: BLE001 — report, don't kill the batch
|
||||
logger.warning("sync mutation %s failed: %s", mut_id, exc)
|
||||
result["status"] = "failed"
|
||||
result["error"] = str(exc)
|
||||
finally:
|
||||
self._record(user_id, device_id, mtype, mut, client_ts, result)
|
||||
results.append(result)
|
||||
|
||||
return {"results": results, "conflicts": conflicts}
|
||||
|
||||
# ── page mutations ─────────────────────────────────────────────────────
|
||||
|
||||
def _mut_page_create(self, user_id: int, payload: dict) -> dict:
|
||||
title = (payload.get("title") or "New page").strip() or "New page"
|
||||
workspace_id = payload.get("workspace_id")
|
||||
parent_id = payload.get("parent_id")
|
||||
|
||||
with get_conn() as conn:
|
||||
if not self._can_access(conn, user_id, workspace_id):
|
||||
raise ConflictError({"type": "forbidden", "workspace_id": workspace_id})
|
||||
|
||||
# create-create conflict: same title already present at same parent
|
||||
new_title = title
|
||||
dup = conn.execute(
|
||||
"""SELECT id FROM pages
|
||||
WHERE title=? AND (? IS NULL OR workspace_id = ?)
|
||||
AND (? IS NULL OR parent_id IS ?)
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1""",
|
||||
(title, workspace_id, workspace_id, parent_id, parent_id),
|
||||
).fetchone()
|
||||
if dup:
|
||||
new_title = f"{title} (copie offline)"
|
||||
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO pages
|
||||
(workspace, workspace_id, title, content, content_format,
|
||||
parent_id, parent_section, sort_order)
|
||||
VALUES ('', ?, ?, ?, ?, ?, 'Private', ?)""",
|
||||
(workspace_id, new_title,
|
||||
payload.get("content", ""),
|
||||
payload.get("content_format", "blocks"),
|
||||
parent_id,
|
||||
payload.get("sort_order", 0)),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
synced = payload.get("client_page_id")
|
||||
return {"page_id": page_id, "server_version": 1, "client_page_id": synced,
|
||||
"conflict": {"type": "create_create", "renamed": new_title != title}
|
||||
if new_title != title else None}
|
||||
|
||||
def _mut_page_update(self, user_id: int, payload: dict) -> dict:
|
||||
page_id = payload.get("page_id")
|
||||
base_version = payload.get("base_version")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise ConflictError({"type": "page_not_found", "page_id": page_id})
|
||||
|
||||
ws_id = row["workspace_id"] or payload.get("workspace_id")
|
||||
if not self._can_access(conn, user_id, ws_id):
|
||||
raise ConflictError({"type": "forbidden", "page_id": page_id})
|
||||
|
||||
# edit-delete: page soft-deleted server-side → orphan copy
|
||||
if row["deleted_at"] is not None:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO pages
|
||||
(workspace, workspace_id, title, content, content_format, parent_section)
|
||||
VALUES ('', ?, ?, ?, ?, 'Private')""",
|
||||
(ws_id,
|
||||
payload.get("title") or row["title"],
|
||||
(payload.get("content")
|
||||
if payload.get("content") is not None else row["content"]),
|
||||
payload.get("content_format") or row["content_format"]),
|
||||
)
|
||||
conn.commit()
|
||||
orphan_id = cur.lastrowid
|
||||
raise ConflictError({
|
||||
"type": "edit_delete",
|
||||
"page_id": page_id,
|
||||
"new_page_id": orphan_id,
|
||||
"detail": "La page a été supprimée côté serveur — copie récréée en page orpheline",
|
||||
})
|
||||
|
||||
# edit-edit: version mismatch → last-write-wins + conflict report
|
||||
server_version = row["sync_version"]
|
||||
conflict = None
|
||||
if base_version is not None and server_version != base_version:
|
||||
conflict = {
|
||||
"type": "edit_edit",
|
||||
"page_id": page_id,
|
||||
"client_version": base_version,
|
||||
"server_version": server_version,
|
||||
}
|
||||
|
||||
sets, params = [], []
|
||||
if payload.get("title") is not None:
|
||||
sets.append("title=?")
|
||||
params.append(payload["title"])
|
||||
if payload.get("content") is not None:
|
||||
sets.append("content=?")
|
||||
params.append(payload["content"])
|
||||
if payload.get("content_format") is not None:
|
||||
sets.append("content_format=?")
|
||||
params.append(payload["content_format"])
|
||||
sets_str = ", ".join(sets) if sets else "updated_at=updated_at"
|
||||
params.append(page_id)
|
||||
conn.execute(
|
||||
f"UPDATE pages SET {sets_str}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
params,
|
||||
)
|
||||
conn.commit()
|
||||
new_version = conn.execute(
|
||||
"SELECT sync_version FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()["sync_version"]
|
||||
return {"page_id": page_id, "server_version": new_version,
|
||||
"conflict": conflict}
|
||||
|
||||
def _mut_page_delete(self, user_id: int, payload: dict) -> dict:
|
||||
page_id = payload.get("page_id")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
return {"page_id": page_id, "server_version": None} # idempotent / already hard-deleted
|
||||
ws_id = row["workspace_id"]
|
||||
if not self._can_access(conn, user_id, ws_id):
|
||||
raise ConflictError({"type": "forbidden", "page_id": page_id})
|
||||
conn.execute(
|
||||
"UPDATE pages SET deleted_at=CURRENT_TIMESTAMP, "
|
||||
"updated_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL",
|
||||
(page_id,),
|
||||
)
|
||||
conn.commit()
|
||||
new_version = conn.execute(
|
||||
"SELECT sync_version FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()["sync_version"]
|
||||
return {"page_id": page_id, "server_version": new_version}
|
||||
|
||||
def _mut_page_move(self, user_id: int, payload: dict) -> dict:
|
||||
page_id = payload.get("page_id")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
return {"page_id": page_id, "server_version": None}
|
||||
ws_id = row["workspace_id"]
|
||||
if not self._can_access(conn, user_id, ws_id):
|
||||
raise ConflictError({"type": "forbidden", "page_id": page_id})
|
||||
sets, params = [], []
|
||||
if payload.get("parent_id") is not None:
|
||||
sets.append("parent_id=?")
|
||||
params.append(payload["parent_id"])
|
||||
if payload.get("sort_order") is not None:
|
||||
sets.append("sort_order=?")
|
||||
params.append(payload["sort_order"])
|
||||
if sets:
|
||||
params.append(page_id)
|
||||
conn.execute(
|
||||
f"UPDATE pages SET {', '.join(sets)}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
params,
|
||||
)
|
||||
conn.commit()
|
||||
new_version = conn.execute(
|
||||
"SELECT sync_version FROM pages WHERE id=?", (page_id,)
|
||||
).fetchone()["sync_version"]
|
||||
return {"page_id": page_id, "server_version": new_version}
|
||||
|
||||
# ── collection mutations ───────────────────────────────────────────────
|
||||
|
||||
def _mut_collection_create(self, user_id: int, payload: dict) -> dict:
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO collections (name, description, icon, schema_json)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(payload.get("name", ""), payload.get("description", ""),
|
||||
payload.get("icon", "📋"), json.dumps(payload.get("schema", []))),
|
||||
)
|
||||
conn.commit()
|
||||
return {"collection_id": cur.lastrowid, "server_version": 1}
|
||||
|
||||
def _mut_collection_update(self, user_id: int, payload: dict) -> dict:
|
||||
cid = payload.get("collection_id")
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
|
||||
if not row:
|
||||
raise ConflictError({"type": "collection_not_found", "collection_id": cid})
|
||||
sets, params = [], []
|
||||
if payload.get("name") is not None:
|
||||
sets.append("name=?")
|
||||
params.append(payload["name"])
|
||||
if payload.get("description") is not None:
|
||||
sets.append("description=?")
|
||||
params.append(payload["description"])
|
||||
if payload.get("icon") is not None:
|
||||
sets.append("icon=?")
|
||||
params.append(payload["icon"])
|
||||
if payload.get("schema") is not None:
|
||||
sets.append("schema_json=?")
|
||||
params.append(json.dumps(payload["schema"]))
|
||||
if sets:
|
||||
params.append(cid)
|
||||
conn.execute(
|
||||
f"UPDATE collections SET {', '.join(sets)}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
params,
|
||||
)
|
||||
conn.commit()
|
||||
new_version = conn.execute(
|
||||
"SELECT sync_version FROM collections WHERE id=?", (cid,)
|
||||
).fetchone()["sync_version"]
|
||||
return {"collection_id": cid, "server_version": new_version}
|
||||
|
||||
def _mut_collection_delete(self, user_id: int, payload: dict) -> dict:
|
||||
cid = payload.get("collection_id")
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM collections WHERE id=?", (cid,))
|
||||
conn.commit()
|
||||
return {"collection_id": cid, "server_version": None}
|
||||
|
||||
# ── audit ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _record(self, user_id: int, device_id: str, mtype: str, mut: dict,
|
||||
client_ts: float, result: dict) -> None:
|
||||
"""Persist every mutation in the server-side audit queue."""
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO offline_sync_queue
|
||||
(user_id, device_id, type, payload, client_timestamp,
|
||||
server_version, status, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(user_id, device_id, mtype, json.dumps(mut),
|
||||
client_ts, result.get("server_version"),
|
||||
result.get("status", "synced"),
|
||||
result.get("error") or (json.dumps(result["conflict"], ensure_ascii=False)
|
||||
if result.get("conflict") else None)),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception: # noqa: BLE001 — audit must never break the batch
|
||||
logger.warning("failed to record sync audit row", exc_info=True)
|
||||
|
||||
|
||||
def _iso_epoch(value) -> float | None:
|
||||
"""Best-effort ISO→epoch. SQLite CURRENT_TIMESTAMP → 'YYYY-MM-DD HH:MM:SS'."""
|
||||
try:
|
||||
if value is None:
|
||||
return None
|
||||
# append a timezone so strptime behaves on naive timestamps
|
||||
text = str(value).replace("T", " ").split(".")[0]
|
||||
parsed = _dt.datetime.strptime(text, "%Y-%m-%d %H:%M:%S")
|
||||
return parsed.replace(tzinfo=_dt.UTC).timestamp()
|
||||
except ValueError:
|
||||
return None
|
||||
@@ -0,0 +1,319 @@
|
||||
"""FlowDeck — v5.14.0 Synced blocks: a block created once, displayed
|
||||
and edited across multiple pages.
|
||||
|
||||
The ``synced_blocks`` table stores the source-of-truth content. Each
|
||||
page that uses a synced block stores a reference in ``page_synced_blocks``.
|
||||
Editing the source propagates to all referencing pages (via realtime
|
||||
rooms or on next load).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def block_id() -> str:
|
||||
return "sb" + uuid.uuid4().hex[:10]
|
||||
|
||||
|
||||
# ── SyncedBlock CRUD ──────────────────────────────────────────────
|
||||
|
||||
def create_synced_block(workspace: str, title: str, content: list[dict],
|
||||
created_by: int | None = None) -> int:
|
||||
"""Create a new synced block and return its id."""
|
||||
content_json = json.dumps(content, ensure_ascii=False) if content else "[]"
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO synced_blocks (title, content, created_by, workspace)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(title, content_json, created_by, workspace),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def get_synced_block(synced_id: int) -> dict | None:
|
||||
"""Fetch a synced block by id."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM synced_blocks WHERE id=?", (synced_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return dict(row)
|
||||
|
||||
|
||||
def update_synced_block(synced_id: int, title: str,
|
||||
content: list[dict]) -> None:
|
||||
"""Update a synced block's title and content."""
|
||||
content_json = json.dumps(content, ensure_ascii=False)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""UPDATE synced_blocks SET title=?, content=?,
|
||||
updated_at=CURRENT_TIMESTAMP WHERE id=?""",
|
||||
(title, content_json, synced_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def delete_synced_block(synced_id: int) -> list[int]:
|
||||
"""Delete a synced block (cascades via FK).
|
||||
|
||||
v6.5.0: returns the page ids that referenced it so the caller can
|
||||
refresh/broadcast them (the FK cascade removes ``page_synced_blocks``).
|
||||
"""
|
||||
page_ids = page_ids_for_synced(synced_id)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM synced_blocks WHERE id=?", (synced_id,))
|
||||
conn.commit()
|
||||
return page_ids
|
||||
|
||||
|
||||
def list_synced_blocks(workspace: str) -> list[dict]:
|
||||
"""List all synced blocks in a workspace."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM synced_blocks WHERE workspace=? ORDER BY updated_at DESC",
|
||||
(workspace,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ── Page references ───────────────────────────────────────────────
|
||||
|
||||
def add_page_synced(page_id: int, synced_block_id: int,
|
||||
block_index: int = 0) -> None:
|
||||
"""Record that a page references a synced block at a given index."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO page_synced_blocks
|
||||
(page_id, synced_block_id, block_index)
|
||||
VALUES (?, ?, ?)""",
|
||||
(page_id, synced_block_id, block_index),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def remove_page_synced(page_id: int, synced_block_id: int) -> None:
|
||||
"""Remove a page's reference to a synced block."""
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM page_synced_blocks WHERE page_id=? AND synced_block_id=?",
|
||||
(page_id, synced_block_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_page_synced(page_id: int) -> list[dict]:
|
||||
"""Get all synced block references for a page."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT psb.*, sb.title, sb.content AS synced_content
|
||||
FROM page_synced_blocks psb
|
||||
JOIN synced_blocks sb ON sb.id=psb.synced_block_id
|
||||
WHERE psb.page_id=? ORDER BY psb.block_index""",
|
||||
(page_id,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
# ── Block resolution ──────────────────────────────────────────────
|
||||
|
||||
def resolve_synced_block(blocks: list[dict]) -> list[dict]:
|
||||
"""Replace synced block references with actual content for rendering.
|
||||
|
||||
A synced block in the block list looks like:
|
||||
{"type": "synced", "synced_id": 42, "content": "..."}
|
||||
This resolves it to the current content from the synced_blocks table.
|
||||
"""
|
||||
for b in blocks:
|
||||
if b.get("type") == "synced" and b.get("synced_id"):
|
||||
sb = get_synced_block(b["synced_id"])
|
||||
if sb:
|
||||
try:
|
||||
resolved = json.loads(sb["content"])
|
||||
if isinstance(resolved, list):
|
||||
b["_synced_content"] = resolved
|
||||
b["_synced_title"] = sb.get("title", "")
|
||||
b["_synced_id"] = sb["id"]
|
||||
b["_synced_updated"] = sb.get("updated_at", "")
|
||||
b.pop("_synced_deleted", None)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
elif not b.get("_synced_deleted"):
|
||||
# v6.5.0: source deleted → instances render a clear state
|
||||
# instead of a stale cache / eternal "Loading…".
|
||||
b["_synced_deleted"] = True
|
||||
b.pop("_synced_content", None)
|
||||
b.pop("_synced_title", None)
|
||||
if isinstance(b.get("children"), list):
|
||||
resolve_synced_block(b["children"])
|
||||
return blocks
|
||||
|
||||
|
||||
def page_ids_for_synced(synced_id: int) -> list[int]:
|
||||
"""Pages currently referencing a synced block (before any cascade)."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT page_id FROM page_synced_blocks WHERE synced_block_id=?",
|
||||
(synced_id,),
|
||||
).fetchall()
|
||||
return [r["page_id"] for r in rows]
|
||||
|
||||
|
||||
def resolve_content_json(content: str, content_format: str | None) -> str:
|
||||
"""Resolve synced-block references inside a page's stored blocks JSON.
|
||||
|
||||
Server-side freshness guarantee (v6.5.0): every read path passes through
|
||||
this so a stale cached ``_synced_content`` is replaced by the current
|
||||
source-of-truth content. Non-blocks content is returned unchanged.
|
||||
"""
|
||||
if (content_format or "blocks") != "blocks" or not content:
|
||||
return content
|
||||
try:
|
||||
blocks = json.loads(content)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return content
|
||||
if not isinstance(blocks, list):
|
||||
return content
|
||||
return json.dumps(resolve_synced_block(blocks), ensure_ascii=False)
|
||||
|
||||
|
||||
def sync_synced_blocks_in_page(page_id: int) -> None:
|
||||
"""Re-resolve all synced blocks in a page's content.
|
||||
|
||||
Called after a synced block is updated — refreshes the referencing
|
||||
pages' stored content so they reflect the latest source (v6.5.0: this
|
||||
used to be dead code; it is now invoked by the update/delete routes,
|
||||
recursively covering blocks nested in columns/toggles).
|
||||
"""
|
||||
refs = get_page_synced(page_id)
|
||||
if not refs:
|
||||
return
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT content, content_format FROM pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
if not row or row["content_format"] != "blocks":
|
||||
return
|
||||
try:
|
||||
blocks = json.loads(row["content"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return
|
||||
if not isinstance(blocks, list):
|
||||
return
|
||||
|
||||
sources: dict[int, dict] = {r["synced_block_id"]: get_synced_block(r["synced_block_id"])
|
||||
for r in refs}
|
||||
changed = False
|
||||
|
||||
def _walk(bl: list[dict]) -> None:
|
||||
nonlocal changed
|
||||
for b in bl:
|
||||
if b.get("type") == "synced" and b.get("synced_id"):
|
||||
sid = b["synced_id"]
|
||||
if sid not in sources:
|
||||
sources[sid] = get_synced_block(sid)
|
||||
sb = sources.get(sid)
|
||||
if sb:
|
||||
try:
|
||||
resolved = json.loads(sb["content"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
content_str = (json.dumps(resolved, ensure_ascii=False)
|
||||
if isinstance(resolved, list)
|
||||
else json.dumps(resolved))
|
||||
title = sb.get("title", "")
|
||||
if (b.get("content") != content_str
|
||||
or b.get("_synced_content") != resolved
|
||||
or b.get("_synced_title") != title
|
||||
or b.get("_synced_deleted")):
|
||||
b["content"] = content_str
|
||||
b["_synced_content"] = resolved
|
||||
b["_synced_title"] = title
|
||||
b.pop("_synced_deleted", None)
|
||||
changed = True
|
||||
elif not b.get("_synced_deleted"):
|
||||
b["_synced_deleted"] = True
|
||||
b.pop("_synced_content", None)
|
||||
b.pop("_synced_title", None)
|
||||
changed = True
|
||||
if isinstance(b.get("children"), list):
|
||||
_walk(b["children"])
|
||||
|
||||
_walk(blocks)
|
||||
|
||||
if changed:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(blocks, ensure_ascii=False), page_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def mark_synced_block_deleted(synced_id: int, page_ids: list[int]) -> None:
|
||||
"""Flag a deleted synced block's instances as ``_synced_deleted``.
|
||||
|
||||
v6.5.0 — called after ``delete_synced_block()``: the FK cascade already
|
||||
removed ``page_synced_blocks`` refs, so ``sync_synced_blocks_in_page``
|
||||
cannot be used here; this rewrites the stored blocks directly so every
|
||||
surface (editor, offline sync, export) sees the deleted state.
|
||||
"""
|
||||
for pid in page_ids:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT content, content_format FROM pages WHERE id=?",
|
||||
(pid,),
|
||||
).fetchone()
|
||||
if not row or row["content_format"] != "blocks":
|
||||
continue
|
||||
try:
|
||||
blocks = json.loads(row["content"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if not isinstance(blocks, list):
|
||||
continue
|
||||
changed = False
|
||||
|
||||
def _walk(bl: list[dict]) -> None:
|
||||
nonlocal changed
|
||||
for b in bl:
|
||||
if (b.get("type") == "synced"
|
||||
and b.get("synced_id") == synced_id
|
||||
and not b.get("_synced_deleted")):
|
||||
b["_synced_deleted"] = True
|
||||
b.pop("_synced_content", None)
|
||||
b.pop("_synced_title", None)
|
||||
changed = True
|
||||
if isinstance(b.get("children"), list):
|
||||
_walk(b["children"])
|
||||
|
||||
_walk(blocks)
|
||||
if changed:
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(blocks, ensure_ascii=False), pid),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ── Unsync: convert synced block to independent copy ──────────────
|
||||
|
||||
def unsync_block(page_id: int, synced_block_id: int) -> list[dict] | None:
|
||||
"""Remove a page's sync reference and return the current content
|
||||
so the caller can turn it into an independent block."""
|
||||
sb = get_synced_block(synced_block_id)
|
||||
if not sb:
|
||||
return None
|
||||
remove_page_synced(page_id, synced_block_id)
|
||||
try:
|
||||
return json.loads(sb["content"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
@@ -0,0 +1,435 @@
|
||||
"""FlowDeck — Web Clipper service (v6.0.0).
|
||||
|
||||
Handles web content capture → FlowDeck page creation.
|
||||
|
||||
- Sanitizes incoming HTML (removes scripts, styles, event handlers).
|
||||
- Extracts readable content via BeautifulSoup heuristics (article/main/body).
|
||||
- Converts HTML → FlowDeck block list (headings, paragraphs, lists, quotes,
|
||||
code, images, bookmarks).
|
||||
- Creates a pages row (workspace-aware) and logs the clip.
|
||||
|
||||
No external network calls: images stay as remote URLs (no download in MVP);
|
||||
a future iteration can download + re-host inline images.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_CLIP_BYTES = 10 * 1024 * 1024 # 10 MB per clip
|
||||
MAX_CLIPS_PER_HOUR = 50
|
||||
|
||||
# In-memory rate limiter per device: {device_id: [timestamps]}
|
||||
_rate_store: dict[str, list[float]] = {}
|
||||
|
||||
|
||||
def _check_rate_limit(device_id: str) -> bool:
|
||||
"""Return True if allowed, False if rate-limited (50/hour)."""
|
||||
now = time.time()
|
||||
window = 3600
|
||||
bucket = _rate_store.get(device_id, [])
|
||||
bucket = [t for t in bucket if now - t < window]
|
||||
if len(bucket) >= MAX_CLIPS_PER_HOUR:
|
||||
_rate_store[device_id] = bucket
|
||||
return False
|
||||
bucket.append(now)
|
||||
_rate_store[device_id] = bucket
|
||||
return True
|
||||
|
||||
|
||||
def _hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def sanitize_html(html_str: str) -> str:
|
||||
"""Strip dangerous tags/attributes, return sanitized HTML string."""
|
||||
if not html_str:
|
||||
return ""
|
||||
# Cap size
|
||||
if len(html_str.encode("utf-8")) > MAX_CLIP_BYTES:
|
||||
html_str = html_str[: MAX_CLIP_BYTES // 2]
|
||||
soup = BeautifulSoup(html_str, "html.parser")
|
||||
for tag in soup(["script", "style", "noscript", "iframe"]):
|
||||
tag.decompose()
|
||||
# Strip event handlers and javascript: URLs
|
||||
for el in soup.find_all(True):
|
||||
for attr in list(el.attrs):
|
||||
if attr.lower().startswith("on"):
|
||||
del el.attrs[attr]
|
||||
elif attr in ("href", "src", "action"):
|
||||
val = str(el.attrs[attr]).strip()
|
||||
if val.lower().startswith("javascript:") or val.lower().startswith("data:text/html"):
|
||||
del el.attrs[attr]
|
||||
return str(soup)
|
||||
|
||||
|
||||
def _extract_main(soup: BeautifulSoup) -> BeautifulSoup:
|
||||
"""Pick the most content-rich container: article > main > body."""
|
||||
for sel in ["article", "main", "[role=article]", "#content", ".post-content", ".article-content"]:
|
||||
el = soup.select_one(sel)
|
||||
if el and len(el.get_text(strip=True)) > 120:
|
||||
return el
|
||||
return soup.body or soup
|
||||
|
||||
|
||||
def _text_node(el) -> str:
|
||||
return el.get_text(separator=" ", strip=True) if el else ""
|
||||
|
||||
|
||||
def html_to_blocks(html_str: str, source_url: str = "") -> list[dict]:
|
||||
"""Convert HTML → FlowDeck block list.
|
||||
|
||||
Covers: headings (h1-h4), paragraphs, blockquotes, code, lists,
|
||||
images, links as bookmark when standalone.
|
||||
"""
|
||||
if not html_str or not html_str.strip():
|
||||
return []
|
||||
|
||||
soup = BeautifulSoup(html_str, "html.parser")
|
||||
main = _extract_main(soup)
|
||||
blocks: list[dict] = []
|
||||
|
||||
def _add(b):
|
||||
if b.get("content") or b.get("src") or b.get("url"):
|
||||
blocks.append(b)
|
||||
|
||||
# Walk direct children and deeper elements
|
||||
for el in main.find_all(["h1", "h2", "h3", "h4", "p", "blockquote", "pre", "ul", "ol", "img", "figure", "a"], recursive=True):
|
||||
tag = el.name.lower()
|
||||
if tag in ("h1", "h2", "h3", "h4"):
|
||||
level = int(tag[1])
|
||||
level = min(level, 4)
|
||||
txt = _text_node(el)
|
||||
if txt:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": f"heading_{level}", "content": txt})
|
||||
elif tag == "p":
|
||||
txt = _text_node(el)
|
||||
# Skip if parent is blockquote or li already handled
|
||||
if el.find_parent(["blockquote", "li"]):
|
||||
continue
|
||||
# If p contains an image, emit image even when text empty
|
||||
img = el.find("img")
|
||||
if img and img.get("src"):
|
||||
src = img.get("src", "").strip()
|
||||
alt = img.get("alt", "") or ""
|
||||
if src and not src.startswith("data:"):
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "image", "src": src, "alt": alt})
|
||||
# If there is also text alongside image, emit paragraph too
|
||||
if txt:
|
||||
# Remove image alt from paragraph? Keep text
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": txt})
|
||||
continue
|
||||
if txt:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": txt})
|
||||
elif tag == "blockquote":
|
||||
txt = _text_node(el)
|
||||
if txt:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "quote", "content": txt})
|
||||
elif tag == "pre":
|
||||
code_el = el.find("code")
|
||||
txt = (code_el.get_text() if code_el else el.get_text())
|
||||
if txt.strip():
|
||||
lang = ""
|
||||
if code_el and code_el.get("class"):
|
||||
for c in code_el.get("class"):
|
||||
if c.startswith("language-"):
|
||||
lang = c.replace("language-", "")
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "code", "content": txt.strip("\n"), "language": lang})
|
||||
elif tag in ("ul", "ol"):
|
||||
# Only top-level lists - skip nested
|
||||
if el.find_parent(["ul", "ol"]):
|
||||
continue
|
||||
is_ordered = tag == "ol"
|
||||
for li in el.find_all("li", recursive=False):
|
||||
txt = _text_node(li)
|
||||
if not txt:
|
||||
continue
|
||||
# Detect todo
|
||||
if re.match(r"^\[ ?[xX] ?\]\s*", txt):
|
||||
checked = bool(re.match(r"^\[ ?[xX] ?\]", txt))
|
||||
txt = re.sub(r"^\[ ?[xX] ?\]\s*", "", txt)
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "to_do", "content": txt, "checked": checked})
|
||||
else:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "bulleted_list" if not is_ordered else "numbered_list", "content": txt})
|
||||
elif tag == "img":
|
||||
# Avoid double-count when inside p/figure already emitted
|
||||
if el.find_parent("p") or el.find_parent("figure"):
|
||||
# Still emit if parent p wasn't counted
|
||||
parent_p = el.find_parent("p")
|
||||
if parent_p and parent_p.find("img") == el:
|
||||
continue
|
||||
src = el.get("src", "").strip()
|
||||
if not src:
|
||||
continue
|
||||
# Skip data URIs for size
|
||||
if src.startswith("data:"):
|
||||
continue
|
||||
alt = el.get("alt", "") or ""
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "image", "src": src, "alt": alt})
|
||||
elif tag == "a":
|
||||
# Standalone links as bookmark when they are the only content in p
|
||||
parent = el.find_parent("p")
|
||||
txt = el.get_text(strip=True)
|
||||
href = el.get("href", "").strip()
|
||||
if href and parent and _text_node(parent) == txt and href.startswith("http"):
|
||||
# Will be handled as paragraph already; add bookmark variant if distinct
|
||||
pass
|
||||
elif tag == "figure":
|
||||
img = el.find("img")
|
||||
if img and img.get("src"):
|
||||
src = img.get("src", "").strip()
|
||||
alt = img.get("alt", "") or ""
|
||||
if src and not src.startswith("data:"):
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "image", "src": src, "alt": alt})
|
||||
|
||||
if not blocks:
|
||||
# Fallback: whole text as paragraphs
|
||||
texts = [t.strip() for t in main.get_text(separator="\n").split("\n") if t.strip()]
|
||||
for t in texts[:30]:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": t})
|
||||
|
||||
# Always ensure at least one block when source_url present
|
||||
if not blocks and source_url:
|
||||
_add({"id": str(uuid.uuid4())[:8], "type": "bookmark", "url": source_url, "title": source_url})
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def extract_article(html_str: str, url: str = "") -> dict:
|
||||
"""High-level extraction returning title, text, images, metadata."""
|
||||
soup = BeautifulSoup(html_str, "html.parser")
|
||||
title = ""
|
||||
if soup.title and soup.title.string:
|
||||
title = soup.title.string.strip()
|
||||
og_title = soup.find("meta", property="og:title")
|
||||
if og_title and og_title.get("content"):
|
||||
title = og_title["content"].strip() or title
|
||||
# Sanitize and convert
|
||||
clean = sanitize_html(html_str)
|
||||
blocks = html_to_blocks(clean, source_url=url)
|
||||
# Images
|
||||
images = []
|
||||
for b in blocks:
|
||||
if b.get("type") == "image" and b.get("src"):
|
||||
images.append(b["src"])
|
||||
# Text
|
||||
text_parts = []
|
||||
for b in blocks:
|
||||
if b.get("content"):
|
||||
text_parts.append(b["content"])
|
||||
return {"title": title or "Clipped page", "blocks": blocks, "images": images, "text": "\n\n".join(text_parts)}
|
||||
|
||||
|
||||
def _ensure_workspace(conn, user_id: int, workspace_id: int | None) -> tuple[int, str]:
|
||||
"""Return (workspace_id, workspace_key) for clip insertion."""
|
||||
if workspace_id:
|
||||
row = conn.execute("SELECT id, name FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
|
||||
if row:
|
||||
# Check membership or owner
|
||||
mem = conn.execute(
|
||||
"SELECT 1 FROM workspace_members WHERE workspace_id=? AND user_id=?", (workspace_id, user_id)
|
||||
).fetchone()
|
||||
if mem or conn.execute("SELECT 1 FROM workspaces WHERE id=? AND owner_id=?", (workspace_id, user_id)).fetchone():
|
||||
return workspace_id, row["name"]
|
||||
# Fallback: first workspace owned or member, else create one
|
||||
row = conn.execute(
|
||||
"SELECT w.id, w.name FROM workspaces w LEFT JOIN workspace_members wm ON w.id=wm.workspace_id "
|
||||
"WHERE w.owner_id=? OR wm.user_id=? ORDER BY w.id LIMIT 1",
|
||||
(user_id, user_id),
|
||||
).fetchone()
|
||||
if row:
|
||||
return row["id"], row["name"]
|
||||
# Create default workspace
|
||||
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES (?, ?)", ("My Workspace", user_id))
|
||||
ws_id = cur.lastrowid
|
||||
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'admin')", (ws_id, user_id))
|
||||
return ws_id, "My Workspace"
|
||||
|
||||
|
||||
def create_page_from_clip(clip_data: dict, user_id: int) -> dict:
|
||||
"""Create a FlowDeck page from a clip payload. Returns {page_id, title}."""
|
||||
url = (clip_data.get("url") or clip_data.get("source_url") or "").strip()
|
||||
title = (clip_data.get("title") or "").strip()
|
||||
content = clip_data.get("content") or clip_data.get("html") or ""
|
||||
content_type = clip_data.get("content_type") or clip_data.get("clip_type") or "article"
|
||||
selection_html = clip_data.get("selection_html") or ""
|
||||
tags = clip_data.get("tags") or []
|
||||
target_ws = clip_data.get("target_workspace_id") or clip_data.get("workspace_id")
|
||||
target_page_id = clip_data.get("target_page_id") or clip_data.get("parent_page_id")
|
||||
# Normalize workspace id
|
||||
try:
|
||||
target_ws = int(target_ws) if target_ws is not None else None
|
||||
except (ValueError, TypeError):
|
||||
target_ws = None
|
||||
|
||||
# Determine title and blocks
|
||||
if content_type == "screenshot":
|
||||
# Screenshot: base64 image block + source bookmark (even with empty html)
|
||||
img_b64 = clip_data.get("image_base64") or clip_data.get("screenshot") or ""
|
||||
blocks = []
|
||||
if img_b64:
|
||||
src = img_b64 if img_b64.startswith("data:") else f"data:image/png;base64,{img_b64}"
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "image", "src": src, "alt": title or "Screenshot"})
|
||||
if url:
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "bookmark", "url": url, "title": title or url})
|
||||
if not blocks:
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": title or "Screenshot"})
|
||||
title = title or "Screenshot"
|
||||
elif content_type == "selection" and selection_html and selection_html.strip():
|
||||
clean = sanitize_html(selection_html)
|
||||
blocks = html_to_blocks(clean, source_url=url)
|
||||
if url:
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "bookmark", "url": url, "title": title or url})
|
||||
title = title or "Clipped selection"
|
||||
elif content_type == "bookmark" or not content.strip():
|
||||
# Bookmark mode: no HTML body, just link card
|
||||
bookmark_title = title or (url or "Bookmark")
|
||||
blocks = [
|
||||
{"id": str(uuid.uuid4())[:8], "type": "bookmark", "url": url, "title": bookmark_title, "description": clip_data.get("metadata", {}).get("og_description", "") or ""}
|
||||
]
|
||||
if url:
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": f"Source: {url}"})
|
||||
title = bookmark_title
|
||||
else:
|
||||
# Article full
|
||||
result = extract_article(content, url=url)
|
||||
blocks = result["blocks"]
|
||||
if not title:
|
||||
title = result["title"]
|
||||
# Append source bookmark if not already dominant
|
||||
if url:
|
||||
# avoid duplicate bookmark if last block already is bookmark to same url
|
||||
if not blocks or blocks[-1].get("url") != url:
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "bookmark", "url": url, "title": "Source"})
|
||||
blocks.append({"id": str(uuid.uuid4())[:8], "type": "paragraph", "content": url})
|
||||
|
||||
# Cap blocks
|
||||
if len(blocks) > 200:
|
||||
blocks = blocks[:200]
|
||||
title = (title or "Clipped page").strip()[:200] or "Clipped page"
|
||||
|
||||
# Persist
|
||||
with get_conn() as conn:
|
||||
ws_id, ws_key = _ensure_workspace(conn, user_id, target_ws)
|
||||
# Validate target_page_id belongs to same workspace
|
||||
parent_id = None
|
||||
if target_page_id:
|
||||
try:
|
||||
pid = int(target_page_id)
|
||||
pr = conn.execute("SELECT id, workspace_id FROM pages WHERE id=? AND deleted_at IS NULL", (pid,)).fetchone()
|
||||
if pr and (pr["workspace_id"] == ws_id or pr["workspace_id"] is None):
|
||||
parent_id = pid
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Determine sort order
|
||||
if parent_id is not None:
|
||||
next_order = conn.execute("SELECT COALESCE(MAX(sort_order), -1)+1 FROM pages WHERE parent_id=?", (parent_id,)).fetchone()[0]
|
||||
else:
|
||||
next_order = conn.execute("SELECT COALESCE(MAX(sort_order), -1)+1 FROM pages WHERE workspace_id=? AND parent_id IS NULL", (ws_id,)).fetchone()[0]
|
||||
blocks_json = json.dumps(blocks, ensure_ascii=False)
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id, sort_order)
|
||||
VALUES (?, ?, ?, ?, 'blocks', 'Private', ?, ?)""",
|
||||
(ws_key or "", ws_id, title, blocks_json, parent_id, next_order),
|
||||
)
|
||||
page_id = cur.lastrowid
|
||||
# Tags: create per-user tags if needed and attach via page_tags
|
||||
for tname in tags[:10]:
|
||||
tn = tname.strip()[:50]
|
||||
if not tn:
|
||||
continue
|
||||
conn.execute("INSERT OR IGNORE INTO tags (name, color, user_id) VALUES (?, '#787774', ?)", (tn, user_id))
|
||||
tr = conn.execute("SELECT id FROM tags WHERE name=? AND user_id=?", (tn, user_id)).fetchone()
|
||||
if tr:
|
||||
conn.execute("INSERT OR IGNORE INTO page_tags (page_id, tag_id) VALUES (?, ?)", (page_id, tr["id"]))
|
||||
conn.commit()
|
||||
|
||||
return {"page_id": page_id, "title": title, "blocks": blocks, "workspace_id": ws_id}
|
||||
|
||||
|
||||
def register_device(user_id: int, device_id: str, device_name: str = "", extension_name: str = "chrome") -> dict:
|
||||
"""Register or update an extension device, returns {device, token} (token shown once if new)."""
|
||||
if not device_id or len(device_id) > 128:
|
||||
raise ValueError("Invalid device_id")
|
||||
token = f"fd_clip_{uuid.uuid4().hex}{uuid.uuid4().hex[:8]}"
|
||||
thash = _hash_token(token)
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT id, token_hash FROM extension_devices WHERE user_id=? AND device_id=? AND extension_name=?",
|
||||
(user_id, device_id, extension_name),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"UPDATE extension_devices SET device_name=?, last_used_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(device_name[:200], existing["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": existing["id"], "device_id": device_id, "token": None, "existing": True}
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO extension_devices (user_id, extension_name, device_id, device_name, token_hash)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(user_id, extension_name, device_id, device_name[:200], thash),
|
||||
)
|
||||
conn.commit()
|
||||
return {"id": cur.lastrowid, "device_id": device_id, "token": token, "existing": False}
|
||||
|
||||
|
||||
def verify_device_token(device_id: str, token: str) -> dict | None:
|
||||
"""Verify a device token, returns device row or None."""
|
||||
thash = _hash_token(token)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM extension_devices WHERE device_id=? AND token_hash=? AND revoked=0",
|
||||
(device_id, thash),
|
||||
).fetchone()
|
||||
if row:
|
||||
conn.execute("UPDATE extension_devices SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],))
|
||||
conn.commit()
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
|
||||
def log_clip(user_id: int, device_id: str, clip_type: str, source_url: str, target_page_id: int, workspace_id: int, title: str):
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO extension_clips (user_id, device_id, clip_type, source_url, target_page_id, target_workspace_id, title)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(user_id, device_id, clip_type[:20], source_url[:2000], target_page_id, workspace_id, title[:200]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def list_devices(user_id: int) -> list[dict]:
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, extension_name, device_id, device_name, scopes, last_used_at, created_at, revoked FROM extension_devices WHERE user_id=? ORDER BY last_used_at DESC, created_at DESC",
|
||||
(user_id,),
|
||||
).fetchall()
|
||||
# Enrich with clip counts
|
||||
out = []
|
||||
for r in rows:
|
||||
d = dict(r)
|
||||
cnt = conn.execute("SELECT COUNT(*) AS n FROM extension_clips WHERE user_id=? AND device_id=?", (user_id, r["device_id"])).fetchone()["n"]
|
||||
d["clips_count"] = cnt
|
||||
# Last clip
|
||||
last = conn.execute("SELECT created_at FROM extension_clips WHERE user_id=? AND device_id=? ORDER BY created_at DESC LIMIT 1", (user_id, r["device_id"])).fetchone()
|
||||
d["last_clip_at"] = last["created_at"] if last else None
|
||||
out.append(d)
|
||||
return out
|
||||
|
||||
|
||||
def revoke_device(user_id: int, device_row_id: int) -> bool:
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute("UPDATE extension_devices SET revoked=1 WHERE id=? AND user_id=?", (device_row_id, user_id))
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
@@ -1,7 +1,16 @@
|
||||
"""FlowDeck — Webhook outbound dispatcher (v2.1.0)."""
|
||||
"""FlowDeck — Webhook outbound dispatcher (v2.1.0, prod v6.4.0).
|
||||
|
||||
Production-grade delivery: HMAC-SHA256 signature (``X-FlowDeck-Signature``),
|
||||
retry with backoff 2s/10s/60s, full delivery journal in ``webhook_deliveries``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -9,29 +18,254 @@ from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Event catalogue (≈50 events) ────────────────────────────────────────────
|
||||
EVENTS = [
|
||||
"page.created", "page.updated", "page.deleted",
|
||||
# pages (block-editor documents)
|
||||
"page.created", "page.updated", "page.deleted", "page.moved",
|
||||
"page.restored", "page.locked", "page.unlocked",
|
||||
"page.shared", "page.published", "page.unpublished",
|
||||
"page.renamed", "page.duplicated", "page.archived",
|
||||
# comments & collaboration
|
||||
"comment.added", "comment.updated", "comment.resolved", "comment.deleted",
|
||||
"mention.added", "mention.resolved",
|
||||
# collections (databases)
|
||||
"collection.created", "collection.updated", "collection.deleted",
|
||||
"comment.added", "page.moved",
|
||||
"collection.renamed", "collection.duplicated",
|
||||
"collection.page.created", "collection.page.updated", "collection.page.deleted",
|
||||
"collection.page.moved", "collection.view.created", "collection.view.updated",
|
||||
"collection.view.deleted", "collection.property.created", "collection.property.updated",
|
||||
"collection.property.deleted",
|
||||
# sprints / tasks
|
||||
"sprint.created", "sprint.updated", "sprint.deleted",
|
||||
"sprint.started", "sprint.completed", "sprint.canceled",
|
||||
# sharing / favorites
|
||||
"favorite.added", "favorite.removed",
|
||||
"share.created", "share.updated", "share.revoked",
|
||||
# automations & agent
|
||||
"automation.fired", "automation.failed", "automation.retrying",
|
||||
"agent.run.started", "agent.run.finished", "agent.run.failed",
|
||||
# workspace & users
|
||||
"workspace.created", "workspace.updated", "workspace.deleted",
|
||||
"workspace.member.added", "workspace.member.removed", "workspace.member.role_changed",
|
||||
# files & imports
|
||||
"file.uploaded", "file.deleted",
|
||||
"import.started", "import.completed", "import.failed",
|
||||
# generic
|
||||
"ping",
|
||||
]
|
||||
|
||||
# Retry schedule: delays in seconds between attempts (attempt 0 = immediate).
|
||||
RETRY_DELAYS = (2, 10, 60)
|
||||
MAX_ATTEMPTS = len(RETRY_DELAYS) + 1 # 1 initial + 3 retries
|
||||
|
||||
|
||||
def sign_payload(secret: str, body: bytes) -> str:
|
||||
"""HMAC-SHA256 hex signature of the raw JSON body (``sha256=<hex>``)."""
|
||||
return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
||||
|
||||
|
||||
def verify_signature(secret: str, body: bytes, signature: str) -> bool:
|
||||
"""Constant-time check of an ``X-FlowDeck-Signature`` header value."""
|
||||
if not secret or not signature:
|
||||
return False
|
||||
expected = sign_payload(secret, body)
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
|
||||
def _event_matches(sub_event: str, event: str) -> bool:
|
||||
"""Wildcard matching: ``*`` matches all, ``page.*`` matches page.* events."""
|
||||
if sub_event in ("*", "all"):
|
||||
return True
|
||||
if sub_event.endswith(".*"):
|
||||
return event.startswith(sub_event[:-1])
|
||||
return sub_event == event
|
||||
|
||||
|
||||
def _matching_subs(conn, event: str) -> list:
|
||||
rows = conn.execute(
|
||||
"SELECT id, url, event, secret FROM webhook_subscriptions WHERE active=1"
|
||||
).fetchall()
|
||||
return [r for r in rows if _event_matches((r["event"] or "").strip(), event)]
|
||||
|
||||
|
||||
def _log_delivery(webhook_id: int, event: str, status: str, http_code: int | None,
|
||||
error: str, duration_ms: int, attempt: int, payload: dict,
|
||||
next_retry_at: float | None = None) -> None:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO webhook_deliveries
|
||||
(webhook_id, event, status, http_code, error, duration_ms,
|
||||
attempt, payload, next_retry_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(webhook_id, event, status,
|
||||
http_code if http_code is not None else 0,
|
||||
(error or "")[:1000], duration_ms, attempt,
|
||||
json.dumps(payload)[:8000],
|
||||
next_retry_at),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception: # noqa: BLE001 — logging must never break delivery
|
||||
logger.debug("Failed to log webhook delivery", exc_info=True)
|
||||
|
||||
|
||||
async def _deliver_once(client: httpx.AsyncClient, url: str, event: str,
|
||||
payload: dict, secret: str) -> tuple[int | None, str]:
|
||||
"""Single POST attempt. Returns (http_code, error).
|
||||
|
||||
Includes HMAC-SHA256 signature in X-FlowDeck-Signature header.
|
||||
"""
|
||||
body = json.dumps({"event": event, **payload}).encode()
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-FlowDeck-Event": event,
|
||||
"User-Agent": "FlowDeck-Webhooks/1.0",
|
||||
}
|
||||
if secret:
|
||||
headers["X-FlowDeck-Signature"] = sign_payload(secret, body)
|
||||
# legacy header kept for backward compatibility
|
||||
headers["X-FlowDeck-Secret"] = secret
|
||||
try:
|
||||
resp = await client.post(url, content=body, headers=headers, timeout=30.0)
|
||||
if 200 <= resp.status_code < 300:
|
||||
return resp.status_code, ""
|
||||
return resp.status_code, f"HTTP {resp.status_code}"
|
||||
except httpx.TimeoutException as exc:
|
||||
return None, f"Timeout: {str(exc)[:400]}"
|
||||
except httpx.RequestError as exc:
|
||||
return None, f"Request error: {str(exc)[:400]}"
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return None, str(exc)[:500]
|
||||
|
||||
|
||||
async def deliver_to_sub(sub_id: int, url: str, event: str, payload: dict,
|
||||
secret: str, *, _client: httpx.AsyncClient | None = None) -> bool:
|
||||
"""Deliver with retry (immediate + 2s/10s/60s). Logs every attempt.
|
||||
|
||||
Returns True on success. Failures are re-queued via ``next_retry_at`` so
|
||||
the background scheduler can pick them up even if this process restarts.
|
||||
|
||||
Retry schedule: 2s, 10s, 60s (3 retries total + initial attempt).
|
||||
"""
|
||||
own_client = _client is None
|
||||
client = _client or httpx.AsyncClient(timeout=30.0)
|
||||
try:
|
||||
for attempt in range(MAX_ATTEMPTS):
|
||||
if attempt > 0:
|
||||
delay = RETRY_DELAYS[attempt - 1]
|
||||
logger.debug("Webhook retry attempt %d/%d for %s after %ds", attempt, MAX_ATTEMPTS, url, delay)
|
||||
await asyncio.sleep(delay)
|
||||
start = time.monotonic()
|
||||
code, err = await _deliver_once(client, url, event, payload, secret or "")
|
||||
duration = int((time.monotonic() - start) * 1000)
|
||||
ok = code is not None and 200 <= code < 300
|
||||
last = attempt == MAX_ATTEMPTS - 1
|
||||
if ok or last:
|
||||
_log_delivery(sub_id, event, "delivered" if ok else "failed",
|
||||
code, err, duration, attempt, payload)
|
||||
return ok
|
||||
# schedule retry
|
||||
delay = RETRY_DELAYS[attempt]
|
||||
_log_delivery(sub_id, event, "retrying", code, err, duration,
|
||||
attempt, payload,
|
||||
next_retry_at=time.time() + delay)
|
||||
return False
|
||||
finally:
|
||||
if own_client:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def fire_event(event: str, payload: dict):
|
||||
"""Fire a webhook event to all registered subscribers."""
|
||||
"""Fire a webhook event to all registered subscribers (wildcard-aware).
|
||||
|
||||
Unknown events are ignored. Delivery itself never raises: each
|
||||
subscription is attempted independently and journaled.
|
||||
"""
|
||||
if event not in EVENTS:
|
||||
logger.debug("Ignoring unknown webhook event: %s", event)
|
||||
return
|
||||
with get_conn() as conn:
|
||||
subs = conn.execute("SELECT url, secret FROM webhook_subscriptions WHERE event=?", (event,)).fetchall()
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
subs = _matching_subs(conn, event)
|
||||
if not subs:
|
||||
logger.debug("No subscribers for event: %s", event)
|
||||
return
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
for sub in subs:
|
||||
url, secret = sub["url"], sub["secret"]
|
||||
headers = {"Content-Type": "application/json", "X-FlowDeck-Event": event}
|
||||
if secret:
|
||||
headers["X-FlowDeck-Secret"] = secret
|
||||
try:
|
||||
await client.post(url, json=payload, headers=headers)
|
||||
await deliver_to_sub(sub["id"], sub["url"], event,
|
||||
dict(payload), sub["secret"] or "",
|
||||
_client=client)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("Webhook delivery failed to %s: %s", sub["url"], exc)
|
||||
|
||||
|
||||
async def retry_due_deliveries(now: float | None = None) -> int:
|
||||
"""Re-fire deliveries stuck in ``retrying`` whose ``next_retry_at`` passed.
|
||||
|
||||
Returns the number of deliveries retried. Called by the background
|
||||
scheduler and directly testable.
|
||||
"""
|
||||
now = now if now is not None else time.time()
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT d.id, d.webhook_id, d.event, d.payload, d.attempt,
|
||||
s.url, s.secret
|
||||
FROM webhook_deliveries d
|
||||
JOIN webhook_subscriptions s ON s.id = d.webhook_id
|
||||
WHERE d.status='retrying' AND s.active=1
|
||||
AND d.next_retry_at IS NOT NULL AND d.next_retry_at <= ?
|
||||
ORDER BY d.next_retry_at LIMIT 50""",
|
||||
(now,),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return 0
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
for r in rows:
|
||||
try:
|
||||
payload = json.loads(r["payload"] or "{}")
|
||||
if not isinstance(payload, dict):
|
||||
payload = {"data": payload}
|
||||
except Exception:
|
||||
logger.debug("Webhook delivery failed to %s", url)
|
||||
payload = {}
|
||||
event = r["event"] or "ping"
|
||||
# resume where the previous run stopped, up to MAX_ATTEMPTS
|
||||
start_attempt = min((r["attempt"] or 0) + 1, MAX_ATTEMPTS - 1)
|
||||
for att in range(start_attempt, MAX_ATTEMPTS):
|
||||
delay = RETRY_DELAYS[att - 1] if att > 0 else 0
|
||||
if delay:
|
||||
await asyncio.sleep(delay)
|
||||
t0 = time.monotonic()
|
||||
code, err = await _deliver_once(client, r["url"], event,
|
||||
payload, r["secret"] or "")
|
||||
duration = int((time.monotonic() - t0) * 1000)
|
||||
ok = code is not None and 200 <= code < 300
|
||||
last = att == MAX_ATTEMPTS - 1
|
||||
if ok or last:
|
||||
_log_delivery(r["webhook_id"], event,
|
||||
"delivered" if ok else "failed",
|
||||
code, err, duration, att, payload)
|
||||
break
|
||||
_log_delivery(r["webhook_id"], event, "retrying", code, err,
|
||||
duration, att, payload,
|
||||
next_retry_at=time.time() + RETRY_DELAYS[att])
|
||||
with get_conn() as conn:
|
||||
conn.execute("UPDATE webhook_deliveries SET status='superseded' WHERE id=?", (r["id"],))
|
||||
conn.commit()
|
||||
return len(rows)
|
||||
|
||||
|
||||
async def webhook_retry_scheduler(interval_seconds: int = 60) -> None:
|
||||
"""Background loop: retry due webhook deliveries every minute."""
|
||||
from app.config import settings as _settings
|
||||
interval = getattr(_settings, "webhook_retry_interval_seconds", interval_seconds)
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(interval)
|
||||
await retry_due_deliveries()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("webhook_retry_scheduler tick failed", exc_info=True)
|
||||
|
||||
|
||||
def init_webhook_tables():
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""FlowDeck — Wiki-links & page mentions (v5.11.0).
|
||||
|
||||
Inline references live inside plain-text block content as tokens::
|
||||
|
||||
[[fdpage:123]] link to page id 123, label resolved at render time
|
||||
[[fddate:2026-10-01]] date chip (static label)
|
||||
|
||||
The page token stores only the id, so renaming a page propagates everywhere
|
||||
(the label is resolved through the DB / the titles endpoint at render time).
|
||||
Backlinks keep working because the server renderer emits ``/pages/<id>``
|
||||
anchors, and the backlink scanner also matches the raw token.
|
||||
|
||||
Pure-stdlib helpers shared by the public renderer and the block export.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import html as _html
|
||||
import re
|
||||
|
||||
WIKI_PAGE_RE = re.compile(r"\[\[fdpage:(\d+)\]\]")
|
||||
WIKI_DATE_RE = re.compile(r"\[\[fddate:(\d{4}-\d{2}-\d{2})(?:T[0-9:]{5,8})?\]\]")
|
||||
_DATE_LABELS = {0: "Mon", 1: "Tue", 2: "Wed", 3: "Thu", 4: "Fri", 5: "Sat", 6: "Sun"}
|
||||
_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||||
|
||||
|
||||
def extract_page_ids(text: str) -> list[int]:
|
||||
"""All page ids referenced by wiki tokens in a content string."""
|
||||
return sorted({int(m) for m in WIKI_PAGE_RE.findall(text or "")})
|
||||
|
||||
|
||||
def _date_label(iso: str) -> str:
|
||||
try:
|
||||
parts = iso.split("-")
|
||||
y, m, d = int(parts[0]), int(parts[1]), int(parts[2])
|
||||
import datetime as _dt
|
||||
wd = _dt.date(y, m, d).weekday()
|
||||
return f"{_DATE_LABELS[wd]} {d} {_MONTHS[m-1]} {y}"
|
||||
except (ValueError, IndexError):
|
||||
return iso
|
||||
|
||||
|
||||
def token_labels(conn, content: str) -> dict[str, str]:
|
||||
"""Map every token in ``content`` to its display label (page title)."""
|
||||
out: dict[str, str] = {}
|
||||
ids = extract_page_ids(content)
|
||||
if ids:
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
rows = conn.execute(
|
||||
f"SELECT id, title, page_icon FROM pages WHERE id IN ({placeholders})", ids,
|
||||
).fetchall()
|
||||
by_id = {str(r["id"]): (r["title"] or "Untitled", r["page_icon"] or "") for r in rows}
|
||||
for token_id in ids:
|
||||
title, icon = by_id.get(str(token_id), ("Deleted page", ""))
|
||||
out[f"[[fdpage:{token_id}]]"] = f"{icon + ' ' if icon else ''}{title}"
|
||||
for m in WIKI_DATE_RE.finditer(content or ""):
|
||||
iso = m.group(1)
|
||||
out[f"[[fddate:{iso}]]"] = _date_label(iso)
|
||||
return out
|
||||
|
||||
|
||||
def resolve_tokens_html(content: str, titles: dict[str, str]) -> str:
|
||||
"""Escape plain-text content then turn wiki tokens into HTML chips/links.
|
||||
|
||||
``titles`` maps token → label (from :func:`token_labels`). Used by the
|
||||
public page renderer and any server-side HTML output of block content.
|
||||
"""
|
||||
s = _html.escape(content or "")
|
||||
|
||||
def _page(m: re.Match) -> str:
|
||||
pid = m.group(1)
|
||||
label = titles.get(m.group(0)) or "Deleted page"
|
||||
return (f'<a class="fd-wiki-link" href="/pages/{pid}" '
|
||||
f'data-pid="{pid}" title="{_html.escape(label)}">{_html.escape(label)}</a>')
|
||||
|
||||
def _date(m: re.Match) -> str:
|
||||
iso = m.group(1)
|
||||
label = titles.get(f"[[fddate:{iso}]]") or iso
|
||||
return f'<span class="fd-wiki-date" title="{_html.escape(iso)}">{_html.escape(label)}</span>'
|
||||
|
||||
s = WIKI_PAGE_RE.sub(_page, s)
|
||||
s = WIKI_DATE_RE.sub(_date, s)
|
||||
return s
|
||||
|
||||
|
||||
def find_referring(content: str, page_id: int) -> bool:
|
||||
"""True when the content references ``page_id`` (anchor or wiki token)."""
|
||||
if not content:
|
||||
return False
|
||||
return (f"/pages/{page_id}" in content) or (f"[[fdpage:{page_id}]]" in content)
|
||||
@@ -12,8 +12,8 @@
|
||||
avec la capacité respective de la page au clic.
|
||||
|
||||
Handlers attendus (tous optionnels) :
|
||||
open, openTab, peek, folder, rename, setIcon, duplicate, link, move,
|
||||
fav, recent, delete, tagExisting, tagAdd, tagRemove
|
||||
open, openTab, peek, folder, rename, setIcon, duplicate, link, download,
|
||||
copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove
|
||||
############################################################################}
|
||||
<script data-cfasync="false">
|
||||
/* ═════════════════════════════════════════════════════════════════════
|
||||
@@ -27,12 +27,19 @@
|
||||
if (window.__fdCtxMenuRegistered) return;
|
||||
window.__fdCtxMenuRegistered = true;
|
||||
|
||||
document.addEventListener('alpine:init', function () {
|
||||
if (window.Alpine && window.Alpine.__fdCtxStore) return;
|
||||
if (window.Alpine) window.Alpine.__fdCtxStore = 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,
|
||||
@@ -56,27 +63,56 @@
|
||||
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. */
|
||||
var place = function () {
|
||||
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');
|
||||
var w = el ? el.offsetWidth : 240;
|
||||
var h = el ? el.offsetHeight : 320;
|
||||
var vw = window.innerWidth, vh = window.innerHeight;
|
||||
var nx = cx, ny = 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 (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(place);
|
||||
else setTimeout(place, 0);
|
||||
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 = {};
|
||||
@@ -119,13 +155,30 @@
|
||||
/* ── Icon picker ── */
|
||||
setIcon(icon) {
|
||||
icon = (icon || '').trim();
|
||||
if (!icon) return;
|
||||
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>
|
||||
|
||||
@@ -144,7 +197,7 @@
|
||||
.fd-ctx-menu .ctx-caret{font-size:10px;color:var(--text-dim);}
|
||||
.fd-ctx-menu .ctx-x{color:var(--text-dim);font-size:14px;line-height:1;padding:0 2px;}
|
||||
.fd-ctx-menu .ctx-x:hover{color:var(--danger);}
|
||||
.fd-ctx-menu .ctx-sub{padding:2px 0 4px;background:var(--bg-tertiary);border-radius:6px;margin:2px 6px;}
|
||||
.fd-ctx-menu .ctx-sub{padding:2px 0 4px;background:var(--bg-tertiary);border-radius:6px;margin:2px 6px;max-height:38vh;overflow-y:auto;}
|
||||
.fd-ctx-menu .ctx-tag-color{width:10px;height:10px;border-radius:3px;display:inline-block;flex-shrink:0;}
|
||||
.fd-ctx-menu .ctx-colors{display:grid;grid-template-columns:repeat(7,1fr);gap:4px;padding:2px 4px 6px;}
|
||||
.fd-ctx-menu .ctx-swatch{width:16px;height:16px;border-radius:4px;cursor:pointer;border:2px solid transparent;}
|
||||
@@ -161,7 +214,7 @@
|
||||
@click.outside="$store.fdCtx.close()"
|
||||
@keydown.escape.window="$store.fdCtx.close()"
|
||||
x-transition
|
||||
:style="{ top: $store.fdCtx.y + 'px', left: $store.fdCtx.x + 'px' }">
|
||||
:style="{ top: $store.fdCtx.y + 'px', left: $store.fdCtx.x + 'px', maxHeight: $store.fdCtx.maxH ? $store.fdCtx.maxH + 'px' : 'calc(100vh - 16px)' }">
|
||||
|
||||
{# ── Open ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('open')" @click="$store.fdCtx.run('open')">
|
||||
@@ -195,21 +248,10 @@
|
||||
<span style="flex:1;">Rename</span><span class="menu-shortcut">Ctrl+⇧+R</span>
|
||||
</div>
|
||||
|
||||
{# ── Edit icon ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('setIcon')" @click.stop="$store.fdCtx.iconOpen = !$store.fdCtx.iconOpen">
|
||||
{# ── Edit icon (sélecteur façon Notion) ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('setIcon')" @click.stop="$store.fdCtx.openIconPicker()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
|
||||
<span style="flex:1;">Edit icon</span><span class="ctx-caret" x-text="$store.fdCtx.iconOpen ? '▾' : '▸'"></span>
|
||||
</div>
|
||||
<div x-show="$store.fdCtx.iconOpen" class="ctx-sub">
|
||||
<div class="ctx-icons">
|
||||
<template x-for="ic in $store.fdCtx.iconChoices" :key="'ic'+ic">
|
||||
<button type="button" class="ctx-icon" @click.stop="$store.fdCtx.setIcon(ic)" x-text="ic"></button>
|
||||
</template>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;padding:0 4px;">
|
||||
<input class="ctx-input" x-ref="ctxIconInput" placeholder="Custom emoji…" @click.stop @keydown.enter.prevent="$store.fdCtx.setIcon($refs.ctxIconInput.value)">
|
||||
<button type="button" class="ctx-btn" @click.stop="$store.fdCtx.setIcon($refs.ctxIconInput.value)">Set</button>
|
||||
</div>
|
||||
<span style="flex:1;">Edit icon</span>
|
||||
</div>
|
||||
|
||||
{# ── Duplicate ── #}
|
||||
@@ -224,6 +266,18 @@
|
||||
<span style="flex:1;">Copy link</span>
|
||||
</div>
|
||||
|
||||
{# ── Download file (file pages) ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('download')" @click="$store.fdCtx.run('download')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||
<span style="flex:1;">Download</span>
|
||||
</div>
|
||||
|
||||
{# ── Copy file content (textual file pages) ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('copyContent')" @click="$store.fdCtx.run('copyContent')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
|
||||
<span style="flex:1;">Copy content</span>
|
||||
</div>
|
||||
|
||||
{# ── Move to ── #}
|
||||
<div class="menu-item" x-show="$store.fdCtx.has('move')" @click="$store.fdCtx.run('move')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,7 +36,8 @@
|
||||
<header class="topbar unified-header">
|
||||
{% if not hide_hamburger %}
|
||||
<button class="hamburger-btn"
|
||||
@click="{{ hamburger_click|default('sidebarCollapsed ? (sidebarCollapsed = false) : (mobileSidebarOpen = true, sidebarCollapsed = false)') }}"
|
||||
:class="{ 'hamburger-desktop-show': sidebarCollapsed }"
|
||||
@click="{{ hamburger_click|default('sidebarCollapsed ? toggleSidebar() : (mobileSidebarOpen = true, sidebarCollapsed = false)') }}"
|
||||
title="Toggle sidebar">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
@@ -51,7 +52,7 @@
|
||||
<div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()"
|
||||
@mouseleave="dragCloseTimer()">
|
||||
{% if page_icon %}
|
||||
<span class="header-page-icon">{% if page_icon in ("folder","file","star","link","trash","book","home","settings","lock","globe","image","edit","calendar","users","user","search","tag","bar-chart","grid","list","align-left","zap","paperclip","key","refresh","upload") %}{{ fd_icon(page_icon,18) }}{% else %}{{ page_icon }}{% endif %}</span>
|
||||
<span class="header-page-icon">{{ fd_page_icon(page_icon, 18) }}</span>
|
||||
{% endif %}
|
||||
|
||||
<template x-for="(crumb, di) in display" :key="di">
|
||||
@@ -141,7 +142,7 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<script>
|
||||
<script data-cfasync="false">
|
||||
document.addEventListener('alpine:init', function () {
|
||||
if (window.Alpine && window.Alpine.__fdBreadcrumbRegistered) return;
|
||||
if (window.Alpine) window.Alpine.__fdBreadcrumbRegistered = true;
|
||||
@@ -167,6 +168,16 @@ document.addEventListener('alpine:init', function () {
|
||||
this.wsId = d.workspace_id || 0;
|
||||
} catch (e) { this.crumbs = []; }
|
||||
}
|
||||
// Keep the breadcrumb in sync when the open page is renamed in the editor.
|
||||
var self = this;
|
||||
window.addEventListener('flowdeck:page-renamed', function (e) {
|
||||
var d = e && e.detail;
|
||||
if (!d || d.id == null) return;
|
||||
var id = String(d.id);
|
||||
self.crumbs = self.crumbs.map(function (c) {
|
||||
return String(c.id) === id ? Object.assign({}, c, { label: d.title || 'Untitled' }) : c;
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
get display() {
|
||||
@@ -247,7 +258,11 @@ document.addEventListener('alpine:init', function () {
|
||||
this.activeItems = [];
|
||||
},
|
||||
|
||||
go: function (url) { if (url) window.location.href = url; }
|
||||
go: function (url) {
|
||||
if (!url) return;
|
||||
if (window.fdNavigate) window.fdNavigate(url);
|
||||
else window.location.href = url;
|
||||
}
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
{###############################################################################
|
||||
_icon_picker.html — Sélecteur d'icône/emoji façon Notion (v5.6.0)
|
||||
═══════════════════════════════════════════════════════════════════════════
|
||||
Popover global (inclus par base.html) utilisé par :
|
||||
• le menu contextuel (library / local-workspace) via fdCtx.setIcon
|
||||
• l'éditeur de page (clic sur l'icône du titre)
|
||||
Expose le store Alpine `fdIconPicker` + `window.FDIconPicker`.
|
||||
Onglets Emoji / Icons / Upload, recherche, aléatoire, teintes de peau,
|
||||
récents (localStorage) et emojis personnalisés du workspace (API).
|
||||
############################################################################}
|
||||
{% set picker_icons = ['folder','file','calendar','clock','star','bot','users','globe','lock','book','check-square','trash','help-circle','settings','refresh','log-out','message-square','home','search','link','plus','bell','image','download','list','bar-chart','grid','align-left','corner-down-right','copy','key','inbox','edit','eye','share','x','paperclip','external-link','sparkles','lightbulb','tag','file-text','save','upload','trending-up','zap','alert-triangle','user'] %}
|
||||
|
||||
<script data-cfasync="false">
|
||||
/* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */
|
||||
(function () {
|
||||
var FD_ICONS = {
|
||||
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
|
||||
'file': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',
|
||||
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
|
||||
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
|
||||
'bot': '<rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v3"/><line x1="12" y1="20" x2="12" y2="24"/><circle cx="8" cy="13" r="1"/><circle cx="16" cy="13" r="1"/>',
|
||||
'users': '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
|
||||
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
'book': '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>',
|
||||
'check-square': '<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
|
||||
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
|
||||
'help-circle': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
|
||||
'refresh': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
|
||||
'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
|
||||
'message-square': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
|
||||
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
|
||||
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
|
||||
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||
'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
|
||||
'bell': '<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
|
||||
'image': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
|
||||
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
|
||||
'chevron-left': '<polyline points="15 18 9 12 15 6"/>',
|
||||
'chevron-right': '<polyline points="9 18 15 12 9 6"/>',
|
||||
'list': '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
|
||||
'bar-chart': '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
|
||||
'grid': '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
|
||||
'align-left': '<line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="17" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/>',
|
||||
'corner-down-right': '<polyline points="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/>',
|
||||
'copy': '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
|
||||
'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
|
||||
'key': '<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
|
||||
'inbox': '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
|
||||
'edit': '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>',
|
||||
'eye-off': '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/>',
|
||||
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
|
||||
'share': '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>',
|
||||
'more-horizontal': '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
|
||||
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
|
||||
'paperclip': '<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
|
||||
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
|
||||
'sparkles': '<path d="M12 3l1.9 5.8 6.1.5-4.9 3.6 1.7 5.8-4.8-3.5-4.8 3.5 1.7-5.8-4.9-3.6 6.1-.5z"/>',
|
||||
'lightbulb': '<path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/>',
|
||||
'tag': '<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>',
|
||||
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
|
||||
'save': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
|
||||
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
|
||||
'trending-up': '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
|
||||
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
||||
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||
'user': '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>'
|
||||
};
|
||||
window.FD_ICONS = FD_ICONS;
|
||||
window.fd_icon = function (name, size) {
|
||||
size = size || 18;
|
||||
var inner = FD_ICONS[name];
|
||||
if (inner) return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + inner + '</svg>';
|
||||
return (window.getSvgIcon ? getSvgIcon(name, size) : '');
|
||||
};
|
||||
/* Render a page_icon value: image URL, known icon name, or emoji text. */
|
||||
window.fdIconHtml = function (value, size) {
|
||||
size = size || 16;
|
||||
var v = (value == null ? '' : String(value)).trim();
|
||||
if (!v) return '';
|
||||
if (v.charAt(0) === '/' || v.slice(0, 4) === 'http') {
|
||||
return '<img src="' + v.replace(/"/g, '"') + '" alt="" style="width:' + size + 'px;height:' + size + 'px;object-fit:contain;vertical-align:middle;display:inline-block;">';
|
||||
}
|
||||
if (FD_ICONS[v] || (window.getSvgIcon && getSvgIcon(v, size))) return window.fd_icon(v, size);
|
||||
return v;
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script data-cfasync="false">
|
||||
(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>
|
||||
|
||||
<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;}
|
||||
.fd-icon-picker .fdip-tabs{display:flex;align-items:center;gap:2px;padding:8px 10px 6px;border-bottom:1px solid var(--border);}
|
||||
.fd-icon-picker .fdip-tab{background:transparent;border:none;color:var(--text-secondary);font-size:13px;padding:5px 10px;border-radius:6px;cursor:pointer;}
|
||||
.fd-icon-picker .fdip-tab:hover{background:var(--bg-hover);}
|
||||
.fd-icon-picker .fdip-tab.active{background:var(--bg-hover);color:var(--text-primary);font-weight:600;}
|
||||
.fd-icon-picker .fdip-remove{margin-left:auto;background:transparent;border:none;color:var(--text-secondary);font-size:12px;padding:5px 8px;border-radius:6px;cursor:pointer;}
|
||||
.fd-icon-picker .fdip-remove:hover{background:var(--bg-hover);color:var(--danger);}
|
||||
.fd-icon-picker .fdip-tools{display:flex;align-items:center;gap:6px;padding:8px 10px;}
|
||||
.fd-icon-picker .fdip-search{flex:1;min-width:0;font-size:13px;padding:6px 10px;background:var(--bg-primary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);outline:none;}
|
||||
.fd-icon-picker .fdip-search:focus{border-color:var(--accent);}
|
||||
.fd-icon-picker .fdip-tool{width:30px;height:30px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:transparent;border:1px solid var(--border);border-radius:6px;cursor:pointer;font-size:15px;color:var(--text-primary);}
|
||||
.fd-icon-picker .fdip-tool:hover{background:var(--bg-hover);}
|
||||
.fd-icon-picker .fdip-tone-wrap{position:relative;}
|
||||
.fd-icon-picker .fdip-tone-menu{position:absolute;right:0;top:34px;display:flex;gap:2px;padding:6px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,.25);z-index:5;}
|
||||
.fd-icon-picker .fdip-tone{width:26px;height:26px;border:none;background:transparent;border-radius:6px;cursor:pointer;font-size:16px;}
|
||||
.fd-icon-picker .fdip-tone:hover,.fd-icon-picker .fdip-tone.selected{background:var(--bg-hover);}
|
||||
.fd-icon-picker .fdip-body{flex:1;overflow-y:auto;padding:0 10px 6px;}
|
||||
.fd-icon-picker .fdip-section{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--text-dim);padding:6px 2px 2px;}
|
||||
.fd-icon-picker .fdip-grid{display:grid;grid-template-columns:repeat(8,1fr);gap:2px;}
|
||||
.fd-icon-picker .fdip-emoji,.fd-icon-picker .fdip-icon{width:100%;aspect-ratio:1;display:flex;align-items:center;justify-content:center;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:21px;line-height:1;color:var(--text-primary);position:relative;}
|
||||
.fd-icon-picker .fdip-emoji:hover,.fd-icon-picker .fdip-icon:hover{background:var(--bg-hover);}
|
||||
.fd-icon-picker .fdip-custom img{width:24px;height:24px;object-fit:contain;}
|
||||
.fd-icon-picker .fdip-del{position:absolute;top:-2px;right:-2px;width:14px;height:14px;border-radius:50%;background:var(--danger);color:#fff;font-size:10px;line-height:14px;text-align:center;display:none;}
|
||||
.fd-icon-picker .fdip-custom:hover .fdip-del{display:block;}
|
||||
.fd-icon-picker .fdip-empty{color:var(--text-dim);font-size:12px;text-align:center;padding:24px 0;}
|
||||
.fd-icon-picker .fdip-cats{display:flex;align-items:center;gap:2px;padding:6px 10px;border-top:1px solid var(--border);}
|
||||
.fd-icon-picker .fdip-cat{flex:1;height:30px;display:flex;align-items:center;justify-content:center;background:transparent;border:none;border-radius:6px;cursor:pointer;color:var(--text-secondary);}
|
||||
.fd-icon-picker .fdip-cat:hover{background:var(--bg-hover);}
|
||||
.fd-icon-picker .fdip-cat.active{background:var(--bg-hover);color:var(--accent);}
|
||||
.fd-icon-picker .fdip-cat svg{width:16px;height:16px;}
|
||||
.fdip-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:2300;display:flex;align-items:center;justify-content:center;}
|
||||
.fdip-modal{width:420px;max-width:92vw;background:var(--bg-modal);border:1px solid var(--border);border-radius:12px;padding:20px;box-shadow:0 16px 48px rgba(0,0,0,.35);}
|
||||
.fdip-modal-title{font-size:16px;font-weight:600;color:var(--text-primary);margin-bottom:6px;}
|
||||
.fdip-modal-desc{font-size:13px;color:var(--text-secondary);margin-bottom:16px;}
|
||||
.fdip-upload-btn{display:inline-block;padding:8px 14px;font-size:13px;border:1px solid var(--border);border-radius:8px;cursor:pointer;color:var(--text-primary);background:var(--bg-secondary);}
|
||||
.fdip-upload-btn:hover{background:var(--bg-hover);}
|
||||
.fdip-preview-boxes{display:flex;gap:10px;margin:14px 0;}
|
||||
.fdip-preview{width:64px;height:64px;border-radius:8px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border);}
|
||||
.fdip-preview.dark{background:#111;}
|
||||
.fdip-preview.light{background:#fff;}
|
||||
.fdip-preview img{max-width:44px;max-height:44px;object-fit:contain;}
|
||||
.fdip-modal-label{display:block;font-size:12px;color:var(--text-secondary);margin:8px 0 4px;}
|
||||
.fdip-modal-input{width:100%;font-size:14px;padding:8px 10px;background:var(--bg-primary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);outline:none;box-sizing:border-box;}
|
||||
.fdip-modal-input:focus{border-color:var(--accent);}
|
||||
.fdip-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:18px;}
|
||||
.fdip-modal-actions button{padding:8px 16px;font-size:13px;border-radius:8px;cursor:pointer;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);}
|
||||
.fdip-modal-actions button.primary{background:var(--accent);border-color:var(--accent);color:#fff;}
|
||||
.fdip-modal-actions button:disabled{opacity:.5;cursor:default;}
|
||||
</style>
|
||||
|
||||
<div x-data>
|
||||
<div class="fd-icon-picker" x-show="$store.fdIconPicker.open" x-cloak
|
||||
@click.outside="$store.fdIconPicker.customModal || $store.fdIconPicker.close()"
|
||||
@keydown.escape.window="$store.fdIconPicker.close()"
|
||||
:style="'top:' + $store.fdIconPicker.y + 'px;left:' + $store.fdIconPicker.x + 'px'">
|
||||
<div class="fdip-tabs">
|
||||
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'emoji' }" @click="$store.fdIconPicker.setTab('emoji')">Emoji</button>
|
||||
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'icons' }" @click="$store.fdIconPicker.setTab('icons')">Icons</button>
|
||||
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'upload' }" @click="$store.fdIconPicker.setTab('upload')">Upload</button>
|
||||
<button class="fdip-remove" @click="$store.fdIconPicker.remove()">Remove</button>
|
||||
</div>
|
||||
|
||||
<div class="fdip-tools" x-show="$store.fdIconPicker.tab !== 'upload'">
|
||||
<input class="fdip-search" placeholder="Filter..." x-model="$store.fdIconPicker.query">
|
||||
<button class="fdip-tool" title="Random" @click="$store.fdIconPicker.random()">🎲</button>
|
||||
<div class="fdip-tone-wrap" x-show="$store.fdIconPicker.tab === 'emoji'">
|
||||
<button class="fdip-tool" title="Select skin tone" @click.stop="$store.fdIconPicker.toneOpen = !$store.fdIconPicker.toneOpen">👋</button>
|
||||
<div class="fdip-tone-menu" x-show="$store.fdIconPicker.toneOpen" @click.outside="$store.fdIconPicker.toneOpen = false">
|
||||
<template x-for="(t, ti) in ['👋','👋🏻','👋🏼','👋🏽','👋🏾','👋🏿']" :key="ti">
|
||||
<button class="fdip-tone" :class="{ selected: $store.fdIconPicker.skinTone === ti }" @click="$store.fdIconPicker.setTone(ti)" x-text="t"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fdip-body">
|
||||
<template x-if="$store.fdIconPicker.tab === 'emoji'">
|
||||
<div>
|
||||
<div class="fdip-section" x-show="$store.fdIconPicker.category === 'recent' && !$store.fdIconPicker.query">Recent</div>
|
||||
<div class="fdip-grid">
|
||||
<template x-for="e in $store.fdIconPicker.items()" :key="e">
|
||||
<button class="fdip-emoji" @click="$store.fdIconPicker.pick($store.fdIconPicker.withTone(e))" x-text="$store.fdIconPicker.withTone(e)"></button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="fdip-empty" x-show="!$store.fdIconPicker.items().length">No emoji found</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="$store.fdIconPicker.tab === 'icons'">
|
||||
<div>
|
||||
<div class="fdip-grid">
|
||||
{% for name in picker_icons %}
|
||||
<button class="fdip-icon" x-show="$store.fdIconPicker.matches('{{ name }}')" @click="$store.fdIconPicker.pick('{{ name }}')" title="{{ name }}">{{ fd_icon(name, 20) }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="$store.fdIconPicker.tab === 'upload'">
|
||||
<div>
|
||||
<div class="fdip-custom-head" style="display:flex;align-items:center;justify-content:space-between;padding:6px 2px;">
|
||||
<span class="fdip-section" style="padding:0;">Custom emojis</span>
|
||||
<button class="fdip-tool" style="width:auto;padding:0 10px;" @click="$store.fdIconPicker.openCustomModal()">+ Add</button>
|
||||
</div>
|
||||
<div class="fdip-grid">
|
||||
<template x-for="ce in $store.fdIconPicker.custom" :key="ce.id">
|
||||
<button class="fdip-emoji fdip-custom" @click="$store.fdIconPicker.pick(ce.url)" :title="ce.name">
|
||||
<img :src="ce.url" :alt="ce.name">
|
||||
<span class="fdip-del" @click.stop="$store.fdIconPicker.deleteCustom(ce.id)">×</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="fdip-empty" x-show="!$store.fdIconPicker.custom.length">No custom emoji yet</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="fdip-cats" x-show="$store.fdIconPicker.tab === 'emoji'">
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'recent' }" title="Recent" @click="$store.fdIconPicker.setCategory('recent')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'people' }" title="People" @click="$store.fdIconPicker.setCategory('people')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M8.5 14s1.3 2 3.5 2 3.5-2 3.5-2"/><line x1="9" y1="9.5" x2="9.01" y2="9.5"/><line x1="15" y1="9.5" x2="15.01" y2="9.5"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'nature' }" title="Nature" @click="$store.fdIconPicker.setCategory('nature')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 20A7 7 0 0 1 4 13c0-6 7-10 16-10 0 9-4 16-10 16z"/><path d="M4 21c2-4 5-7 9-9"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'food' }" title="Food" @click="$store.fdIconPicker.setCategory('food')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21l6-6"/><path d="M14 3s5 1 7 7c-4 4-8 3-10 1s-1-6 3-8z"/><path d="M9 12l-4 4"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'activity' }" title="Activity" @click="$store.fdIconPicker.setCategory('activity')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 11V6a2 2 0 0 1 4 0v5"/><path d="M10 11V4a2 2 0 0 1 4 0v7"/><path d="M14 11V6a2 2 0 0 1 4 0v9a7 7 0 0 1-7 7H9a6 6 0 0 1-6-6v-3a2 2 0 0 1 3-1.7"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'travel' }" title="Travel" @click="$store.fdIconPicker.setCategory('travel')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 16l20-6-3-3-7 1-5-5-2 1 3 5-4 1-2-2-1 1z"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'objects' }" title="Objects" @click="$store.fdIconPicker.setCategory('objects')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a6 6 0 0 0-4 10.5c.8.7 1 1.5 1 2.5h6c0-1 .2-1.8 1-2.5A6 6 0 0 0 12 2z"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'symbols' }" title="Symbols" @click="$store.fdIconPicker.setCategory('symbols')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/></svg></button>
|
||||
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'flags' }" title="Flags" @click="$store.fdIconPicker.setCategory('flags')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 22V4"/><path d="M4 4h13l-2 4 2 4H4"/></svg></button>
|
||||
<button class="fdip-cat" title="Add custom emoji" @click="$store.fdIconPicker.openCustomModal()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fdip-modal-overlay" x-show="$store.fdIconPicker.customModal" x-cloak @click.self="$store.fdIconPicker.closeCustomModal()">
|
||||
<div class="fdip-modal">
|
||||
<div class="fdip-modal-title">Add custom emoji</div>
|
||||
<div class="fdip-modal-desc">Custom emoji can be used by anyone in your workspace.</div>
|
||||
<label class="fdip-upload-btn">
|
||||
<span x-show="!$store.fdIconPicker.customPreview">Upload an image</span>
|
||||
<span x-show="$store.fdIconPicker.customPreview">Replace</span>
|
||||
<input type="file" accept="image/*" @change="$store.fdIconPicker.onCustomFile($event)" style="display:none">
|
||||
</label>
|
||||
<div class="fdip-preview-boxes" x-show="$store.fdIconPicker.customPreview">
|
||||
<div class="fdip-preview dark"><img :src="$store.fdIconPicker.customPreview" alt=""></div>
|
||||
<div class="fdip-preview light"><img :src="$store.fdIconPicker.customPreview" alt=""></div>
|
||||
</div>
|
||||
<label class="fdip-modal-label">Emoji name</label>
|
||||
<input class="fdip-modal-input" x-model="$store.fdIconPicker.customName" placeholder="pushup">
|
||||
<div class="fdip-modal-actions">
|
||||
<button @click="$store.fdIconPicker.closeCustomModal()">Cancel</button>
|
||||
<button class="primary" :disabled="$store.fdIconPicker.customBusy || !$store.fdIconPicker.customFile" @click="$store.fdIconPicker.saveCustom()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,3 +114,15 @@
|
||||
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
{%- endif -%}
|
||||
{%- endmacro -%}
|
||||
|
||||
{#- Renders a page_icon value: custom-emoji image URL, known icon name, or emoji. -#}
|
||||
{%- macro fd_page_icon(value, size=18) -%}
|
||||
{%- set v = (value or '')|string|trim -%}
|
||||
{%- if v.startswith('/') or v.startswith('http') -%}
|
||||
<img src="{{ v }}" alt="" style="width:{{ size }}px;height:{{ size }}px;object-fit:contain;vertical-align:middle;display:inline-block;">
|
||||
{%- elif v in ("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") -%}
|
||||
{{ fd_icon(v, size) }}
|
||||
{%- else -%}
|
||||
{{ v }}
|
||||
{%- endif -%}
|
||||
{%- endmacro -%}
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<script>
|
||||
<script data-cfasync="false">
|
||||
document.addEventListener('alpine:init', function () {
|
||||
if (window.Alpine && window.Alpine.__fdNotificationsRegistered) return;
|
||||
if (window.Alpine) window.Alpine.__fdNotificationsRegistered = true;
|
||||
@@ -73,6 +73,9 @@ document.addEventListener('alpine:init', function () {
|
||||
var self = this;
|
||||
this._timer = setInterval(function () { self.refreshCount(); }, 30000);
|
||||
},
|
||||
destroy() {
|
||||
if (this._timer) { clearInterval(this._timer); this._timer = null; }
|
||||
},
|
||||
toggle() { this.open = !this.open; if (this.open) this.load(); },
|
||||
timeAgo(s) {
|
||||
if (!s) return '';
|
||||
|
||||
@@ -94,7 +94,27 @@
|
||||
<div class="sd-user-add">Invite</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="sd-user-empty" x-show="!inviteUsers.length">No matching users</div>
|
||||
<template x-if="inviteGroups && inviteGroups.length">
|
||||
<div>
|
||||
<div class="sd-user-empty" style="font-weight:600;">Groups</div>
|
||||
<template x-for="g in inviteGroups" :key="'g'+g.id">
|
||||
<div
|
||||
class="sd-user-item"
|
||||
@click="pickInviteGroup(g)"
|
||||
>
|
||||
<div class="sd-user-avatar" style="background:#5b5bd6;color:#fff;">
|
||||
<span x-text="(g.name || '?').charAt(0).toUpperCase()"></span>
|
||||
</div>
|
||||
<div class="sd-user-meta">
|
||||
<div class="sd-user-login" x-text="g.name"></div>
|
||||
<div class="sd-user-name" x-text="'Group · ' + (g.member_count || 0) + ' members'"></div>
|
||||
</div>
|
||||
<div class="sd-user-add">Invite</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div class="sd-user-empty" x-show="!inviteUsers.length && !(inviteGroups && inviteGroups.length)">No matching users</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,17 +133,22 @@
|
||||
<div class="sd-avatar" x-show="a.user_avatar_url" style="overflow:hidden">
|
||||
<img :src="a.user_avatar_url" style="width:100%;height:100%;border-radius:50%;object-fit:cover;" />
|
||||
</div>
|
||||
<div class="sd-avatar" x-show="!a.user_avatar_url"
|
||||
<!-- Group avatar -->
|
||||
<div class="sd-avatar" x-show="(a.kind === 'group' || a.shared_with_group_id) && !a.user_avatar_url"
|
||||
style="background:#5b5bd6;color:#fff;"
|
||||
x-text="(a.group_name || '?')[0].toUpperCase()"
|
||||
></div>
|
||||
<div class="sd-avatar" x-show="!(a.kind === 'group' || a.shared_with_group_id) && !a.user_avatar_url"
|
||||
x-text="(a.user_full_name || a.user_login || a.shared_with_email || '?')[0].toUpperCase()"
|
||||
></div>
|
||||
<div>
|
||||
<div
|
||||
class="sd-participant-name"
|
||||
x-text="a.user_full_name || a.user_login || a.shared_with_email"
|
||||
x-text="(a.kind === 'group' || a.shared_with_group_id) ? (a.group_name || 'Group') : (a.user_full_name || a.user_login || a.shared_with_email)"
|
||||
></div>
|
||||
<div
|
||||
class="sd-participant-email"
|
||||
x-text="a.shared_with_email || a.user_login || ''"
|
||||
x-text="(a.kind === 'group' || a.shared_with_group_id) ? 'Group' : (a.shared_with_email || a.user_login || '')"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -284,7 +309,7 @@
|
||||
</div>
|
||||
<div x-show="iconOpen" style="margin-top:12px;" x-transition>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
|
||||
<template x-for="ic in ['📄','📝','📋','📊','🗂️','📁','📂','🗒️','🗓️','📌','🔖','⭐','🚀','💡','🎯','🔑','📚','🧪','🌍','💰','📝','🧩','🎨','🔧','⚙️','🗃️','📦','🏷️','📍','🏠','👤']" :key="ic">
|
||||
<template x-for="(ic, ici) in ['📄','📝','📋','📊','🗂️','📁','📂','🗒️','🗓️','📌','🔖','⭐','🚀','💡','🎯','🔑','📚','🧪','🌍','💰','📝','🧩','🎨','🔧','⚙️','🗃️','📦','🏷️','📍','🏠','👤']" :key="ici">
|
||||
<button type="button" class="sd-icon-btn" @click="setIcon(ic)" :class="{ active: iconValue === ic }" style="width:36px;height:36px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);font-size:18px;display:flex;align-items:center;justify-content:center;cursor:pointer;" x-text="ic"></button>
|
||||
</template>
|
||||
<input type="text" class="sd-input" x-model="iconValue" placeholder="Custom emoji" style="max-width:80px;" @keydown.enter.prevent="setIcon(iconValue);iconOpen=false;">
|
||||
@@ -457,11 +482,35 @@
|
||||
<div class="more-menu-item" @click="exportPage('site')"> Site statique (.zip)</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="contentFormat === 'file' && fileUrl">
|
||||
<div>
|
||||
<div class="more-menu-sep"></div>
|
||||
<div class="more-menu-item" @click="downloadFile()">
|
||||
{{ fd_icon("download",14) }} Download
|
||||
</div>
|
||||
<div class="more-menu-item" @click="copyFileContent()">
|
||||
{{ fd_icon("copy",14) }} Copy content
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="more-menu-item" @click="duplicatePage">
|
||||
{{ fd_icon("copy",14) }} Duplicate
|
||||
</div>
|
||||
<div class="more-menu-item" @click="movePage">↗ Move to</div>
|
||||
<div class="more-menu-sep"></div>
|
||||
<div class="more-menu-item" @click="moreOpen=false; toggleLock()">
|
||||
<span x-text="isLocked ? '🔓 Unlock page' : '🔒 Lock page'"></span>
|
||||
</div>
|
||||
<div class="more-menu-item" @click="moreOpen=false; setPageOption('full_width', !fullWidth)">
|
||||
↔ Full width <span x-show="fullWidth" style="margin-left:auto;font-size:11px">✓</span>
|
||||
</div>
|
||||
<div class="more-menu-item" @click="moreOpen=false; setPageOption('font_small', !fontSmall)">
|
||||
Aa Small text <span x-show="fontSmall" style="margin-left:auto;font-size:11px">✓</span>
|
||||
</div>
|
||||
<div class="more-menu-item" @click="moreOpen=false; saveAsTemplate()">
|
||||
📑 Save as template
|
||||
</div>
|
||||
<div class="more-menu-sep"></div>
|
||||
<div class="more-menu-item danger" @click="deletePage">
|
||||
{{ fd_icon("trash",14) }} Move to Trash
|
||||
</div>
|
||||
@@ -497,7 +546,7 @@
|
||||
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,.6) 100%);z-index:1;"></div>
|
||||
</div>
|
||||
<div class="page-title-block">
|
||||
<span class="page-icon-emoji" x-text="iconValue || (page.content_format == 'file' ? fd_icon('paperclip',14) : fd_icon('file',14))"></span>
|
||||
<span class="page-icon-emoji" @click="openIconPicker()" title="Change icon" x-html="iconHtml()"></span>
|
||||
<div
|
||||
class="page-title-input"
|
||||
contenteditable="true"
|
||||
@@ -804,16 +853,21 @@
|
||||
<div style="border-top:1px solid var(--border);padding-top:16px;">
|
||||
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:8px;color:var(--text);">{{ fd_icon('file',14) }} File (.md, .txt, .zip)</label>
|
||||
<input type="file" accept=".md,.markdown,.txt,.zip" @change="handleImportFile($event)" style="width:100%;padding:12px;background:var(--bg-primary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;box-sizing:border-box;">
|
||||
<div x-show="importFile" style="margin-top:8px;display:flex;align-items:center;gap:12px;color:var(--text-dim);font-size:13px;">
|
||||
<span x-text="importFile.name"></span>
|
||||
<span x-text="Math.round(importFile.size/1024)+' KB'"></span>
|
||||
</div>
|
||||
<template x-if="importFile">
|
||||
<div style="margin-top:8px;display:flex;align-items:center;gap:12px;color:var(--text-dim);font-size:13px;">
|
||||
<span x-text="importFile.name"></span>
|
||||
<span x-text="Math.round(importFile.size/1024)+' KB'"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div style="display:flex;gap:8px;margin-top:10px;">
|
||||
<button class="sd-btn-primary" @click="doImport()" :disabled="!importText.trim() && !importFile">{{ fd_icon('download',14) }} Import file</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:12px 20px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;">
|
||||
<a href="/import" style="font-size:13px;color:var(--accent);text-decoration:none;">Assistant d'import avancé (Obsidian, Notion, CSV, Excel…) →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
.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>
|
||||
<script data-cfasync="false">
|
||||
/* eslint-disable */
|
||||
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
|
||||
window.__fdRT = (function () {
|
||||
@@ -37,6 +37,19 @@ window.__fdRT = (function () {
|
||||
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]; }
|
||||
@@ -61,6 +74,7 @@ window.__fdRT = (function () {
|
||||
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));
|
||||
@@ -96,7 +110,9 @@ window.__fdRT = (function () {
|
||||
|
||||
cur.forEach(b => {
|
||||
if (baseById[b.id] !== undefined && JSON.stringify(baseById[b.id]) !== JSON.stringify(b)) {
|
||||
ops.push({ type: 'update', block: clone(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 => {
|
||||
@@ -255,8 +271,13 @@ window.__fdRT = (function () {
|
||||
/* ── 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));
|
||||
const srvIds = JSON.stringify(blocks.map(b => b.id));
|
||||
if (curIds === srvIds) {
|
||||
base = clone(E.blocks);
|
||||
E.dirty = false;
|
||||
@@ -267,14 +288,17 @@ window.__fdRT = (function () {
|
||||
(E.blocks || []).forEach(b => { curById[b.id] = b; });
|
||||
let out;
|
||||
try {
|
||||
out = (blocks || []).map(b => {
|
||||
const cb = curById[b.id];
|
||||
if (cb && b.id === fid) return cb; // garde la frappe locale en cours
|
||||
return b;
|
||||
});
|
||||
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; }
|
||||
const seen = {}; (blocks || []).forEach(b => { seen[b.id] = 1; });
|
||||
(E.blocks || []).forEach(b => { if (!seen[b.id]) out.push(b); });
|
||||
E.blocks = out;
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (title && title !== E.pageTitle && document.activeElement !== tEl) {
|
||||
@@ -336,6 +360,27 @@ window.__fdRT = (function () {
|
||||
} 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;
|
||||
@@ -362,13 +407,19 @@ window.__fdRT = (function () {
|
||||
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 === '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() {
|
||||
@@ -459,9 +510,14 @@ window.__fdRT = (function () {
|
||||
}
|
||||
const tEl = document.getElementById('_titleEl');
|
||||
if (tEl) tEl.addEventListener('input', titleInput);
|
||||
document.addEventListener('selectionchange', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 150); }, true);
|
||||
window.addEventListener('scroll', scheduleDraw, true);
|
||||
window.addEventListener('resize', scheduleDraw);
|
||||
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) {
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
</script>
|
||||
<script data-cfasync="false">
|
||||
/* eslint-disable */
|
||||
if (!window.__fdEditorScriptsLoaded) {
|
||||
window.__fdEditorScriptsLoaded = true;
|
||||
const CMDS=[{name:'BASIC',items:[
|
||||
{id:'paragraph',name:'Text',icon:'¶'},{id:'heading_1',name:'Heading 1',icon:'H1'},{id:'heading_2',name:'Heading 2',icon:'H2'},
|
||||
{id:'heading_3',name:'Heading 3',icon:'H3'},{id:'heading_4',name:'Heading 4',icon:'H4'},{id:'bulleted_list',name:'Bulleted list',icon:'•'},{id:'numbered_list',name:'Numbered list',icon:'1.'},
|
||||
@@ -20,16 +22,43 @@
|
||||
{id:'audio',name:'Audio',icon:'🎵'},
|
||||
]},
|
||||
{name:'DATA',items:[{id:'database',name:'Database / Inline database',icon:'📊'}]},
|
||||
{name:'SYNCED',items:[{id:'synced',name:'Synced block',icon:'🔗'}]},
|
||||
{name:'ACTIONS',items:[{id:'duplicate',name:'Duplicate',icon:'▣'},{id:'turn_into',name:'Turn into…',icon:'↩'},{id:'copy_link',name:'Copy link to block',icon:'🔗'},{id:'delete',name:'Delete block',icon:'✕'}]}];
|
||||
|
||||
let _bid=0;function genId(){return 'b'+(++_bid)+'_'+Date.now().toString(36);}
|
||||
// v5.13.1: guarantee every block (and nested child) has a stable id.
|
||||
// Template blocks may be persisted without ids; leaving them undefined
|
||||
// makes `data-bid="undefined"` collide and corrupts editing.
|
||||
function ensureBlockIds(list){if(!Array.isArray(list))return list;list.forEach(function(b){if(b&&typeof b==='object'){if(!b.id)b.id=genId();if(Array.isArray(b.children))ensureBlockIds(b.children);}});return list;}
|
||||
window.ensureBlockIds=ensureBlockIds;
|
||||
function cp(e){const s=window.getSelection();if(!s.rangeCount)return 0;const r=s.getRangeAt(0).cloneRange();r.selectNodeContents(e);r.setEnd(s.getRangeAt(0).endContainer,s.getRangeAt(0).endOffset);return r.toString().length;}
|
||||
function gt(e){return (e&&e.innerText!==undefined)?e.innerText:(e?e.textContent:'');}
|
||||
// v5.11.0: read a contenteditable block back to plain text, converting
|
||||
// wiki chip nodes (contenteditable=false spans) back into their raw
|
||||
// [[fdpage:ID]] / [[fddate:YYYY-MM-DD]] tokens.
|
||||
function gtTok(e){
|
||||
if(!e) return '';
|
||||
if(!e.querySelector||!e.querySelector('.fd-wiki-chip,.fd-wiki-date')) return gt(e);
|
||||
let out='';
|
||||
(function walk(node){
|
||||
for(const n of node.childNodes){
|
||||
if(n.nodeType===3){out+=n.nodeValue;continue;}
|
||||
if(n.nodeType===1){
|
||||
if(n.classList&&n.classList.contains('fd-wiki-chip')){out+=n.getAttribute('data-token')||'';continue;}
|
||||
if(n.classList&&n.classList.contains('fd-wiki-date')){out+=n.getAttribute('data-token')||'';continue;}
|
||||
if(n.tagName==='BR'){out+='\n';continue;}
|
||||
walk(n);
|
||||
}
|
||||
}
|
||||
})(e);
|
||||
return out;
|
||||
}
|
||||
window.gtTok=gtTok;
|
||||
function splitCaret(e){
|
||||
const s=window.getSelection(); if(!s.rangeCount||!s.rangeCount)return null; const sel=s.getRangeAt(0);
|
||||
const MK='\uF000FD'; const mark=document.createElement('span'); mark.textContent=MK;
|
||||
sel.insertNode(mark);
|
||||
const full=gt(e)||'';
|
||||
const full=gtTok(e)||'';
|
||||
const parts=full.split(MK);
|
||||
mark.remove(); e.normalize();
|
||||
return {before:parts[0]||'',after:(parts.length>1?parts[1]:'')||'',content:full.split(MK).join('')};
|
||||
@@ -46,9 +75,45 @@
|
||||
.replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>')
|
||||
.replace(/\*([^*]+)\*/g,'<em>$1</em>')
|
||||
.replace(/~~([^~]+)~~/g,'<s>$1</s>')
|
||||
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g,'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
.replace(/\[\[fdpage:(\d+)\]\]/g,function(m,pid){
|
||||
return '<a class="fd-wiki-chip" contenteditable="false" href="/pages/'+pid+'" data-token="'+m+'" data-pid="'+pid+'" title="Linked page"><span class="fd-wiki-label">…</span></a>';})
|
||||
.replace(/\[\[fddate:([0-9]{4}-[0-9]{2}-[0-9]{2})(?:T[0-9:]{5,8})?\]\]/g,function(m,iso){
|
||||
return '<span class="fd-wiki-date" contenteditable="false" data-token="'+m+'">'+fdDateLabel(iso)+'</span>';})
|
||||
.replace(/\[([^\]]+)\]\((https?:[^\)\s]+)\)/g,'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
|
||||
.replace(/\n/g,'<br>');
|
||||
}
|
||||
// v5.11.0: labels of [[fdpage:ID]] chips are resolved from the live DB
|
||||
// (rename propagation — tokens store only the id).
|
||||
function fdDateLabel(iso){
|
||||
try{
|
||||
const p=iso.split('-'); const d=new Date(+p[0],+p[1]-1,+p[2]);
|
||||
const DOW=['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
||||
const MON=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
||||
return DOW[d.getDay()]+' '+(+p[2])+' '+MON[+p[1]-1]+' '+p[0];
|
||||
}catch(e){return iso;}
|
||||
}
|
||||
function _wikiResolve(root){
|
||||
const ct=root||document.getElementById('_blocksCt'); if(!ct)return;
|
||||
const pids={};
|
||||
ct.querySelectorAll('.fd-wiki-chip[data-pid]').forEach(function(a){
|
||||
const lab=a.querySelector('.fd-wiki-label');
|
||||
if(!lab||lab.getAttribute('data-done'))return;
|
||||
lab.setAttribute('data-done','1');
|
||||
pids[a.dataset.pid]=true;
|
||||
});
|
||||
const ids=Object.keys(pids); if(!ids.length)return;
|
||||
fetch('/board/api/wiki/titles?ids='+encodeURIComponent(ids.join(',')),{credentials:'same-origin'})
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(d){
|
||||
const titles=d.titles||{};
|
||||
ct.querySelectorAll('.fd-wiki-chip[data-pid]').forEach(function(a){
|
||||
const t=titles[a.dataset.pid];
|
||||
const lab=a.querySelector('.fd-wiki-label');
|
||||
if(lab) lab.textContent = t||'Deleted page';
|
||||
});
|
||||
}).catch(function(){});
|
||||
}
|
||||
window._wikiResolve=_wikiResolve;
|
||||
|
||||
const CALLOUT_ICONS=['💡','💡','📌','⚠️','ℹ️','✅','❌','🚀','🔔','📣','⭐','❤️','🔥','💬','👀','🧠','🎯','🔑','📅','💰','💡','📚','🧪','🌍'];
|
||||
|
||||
@@ -295,6 +360,64 @@
|
||||
sel(id){if(window.E)window.E.applySlash(id);SM.close();},
|
||||
get isOpen(){return SM._o;}};
|
||||
|
||||
// ═══════════ v5.11.0 Wiki-link picker ([[ → page search) ═══════════
|
||||
const WM={_o:false,_idx:-1,_s:0,_it:[],_root:null,_in:null,_li:null,_pv:null,
|
||||
mk(){ if(this._root)return this._root;
|
||||
const d=document.createElement('div'); d.id='_wikiMenu';
|
||||
d.style.cssText='position:fixed;z-index:1250;display:none;width:340px;max-height:320px;flex-direction:column;background:var(--bg-modal,#1f1f1f);border:1px solid var(--border,#333);border-radius:10px;box-shadow:0 12px 40px rgba(0,0,0,.5);overflow:hidden';
|
||||
const list=document.createElement('div'); list.style.cssText='overflow-y:auto;flex:1;min-height:60px;max-height:250px;padding:6px';
|
||||
const inp=document.createElement('input'); inp.type='text'; inp.placeholder='Search pages…';
|
||||
inp.style.cssText='width:100%;padding:9px 12px;background:var(--bg-secondary);border:none;border-top:1px solid var(--border);color:var(--text-primary);font-size:14px;outline:none;box-sizing:border-box';
|
||||
d.appendChild(list); d.appendChild(inp); document.body.appendChild(d);
|
||||
this._root=d; this._li=list; this._in=inp;
|
||||
inp.oninput=()=>{clearTimeout(this._t);this._t=setTimeout(()=>this.load(),180);};
|
||||
list.addEventListener('click',ev=>{const it=ev.target.closest('.wm-item');if(it){ev.preventDefault();WM.sel(parseInt(it.dataset.wpid,10));}});
|
||||
document.addEventListener('mousedown',ev=>{if(!d.contains(ev.target))WM.close();});
|
||||
document.addEventListener('keydown',function(ev){
|
||||
if(!WM._o)return;
|
||||
if(ev.metaKey||ev.ctrlKey||ev.altKey)return;
|
||||
if(ev.key==='Escape'){ev.preventDefault();ev.stopPropagation();WM.close();}
|
||||
else if(ev.key==='ArrowDown'){ev.preventDefault();ev.stopPropagation();WM._mv(1);}
|
||||
else if(ev.key==='ArrowUp'){ev.preventDefault();ev.stopPropagation();WM._mv(-1);}
|
||||
else if(ev.key==='Enter'){ev.preventDefault();ev.stopPropagation();const c=WM._it[WM._s];if(c)WM.sel(c.id);}
|
||||
},true);
|
||||
return d; },
|
||||
open(i,el){ this.mk(); const r=el.getBoundingClientRect();
|
||||
this._root.style.display='flex';
|
||||
this._root.style.top=Math.max(4,(r.bottom+4>window.innerHeight-340)?r.top-344:r.bottom+4)+'px';
|
||||
this._root.style.left=Math.min(r.left,window.innerWidth-350)+'px';
|
||||
this._idx=i; this._s=0; this._in.value=''; this._o=true;
|
||||
this.load(); setTimeout(()=>this._in.focus(),10); },
|
||||
close(){ if(this._root)this._root.style.display='none'; this._o=false; },
|
||||
load(){ const self=this; const q=(this._in.value||'').toLowerCase();
|
||||
fetch('/board/api/wiki/pages?q='+encodeURIComponent(q),{credentials:'same-origin'})
|
||||
.then(r=>r.json()).then(d=>{
|
||||
if(!self._o)return;
|
||||
self._it=d.pages||[]; self._s=0;
|
||||
let h='<div style="padding:6px 10px 2px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase">Pages</div>';
|
||||
self._it.forEach(function(p,idx){
|
||||
h+='<div class="wm-item" data-wpid="'+p.id+'" style="display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:13px;color:var(--text);cursor:pointer;border-radius:6px;'+(idx===0?'background:rgba(35,131,226,.15)':'')+'">'
|
||||
+'<span style="width:18px;text-align:center;flex-shrink:0">'+esc(p.icon||'📄')+'</span>'
|
||||
+'<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(p.title)+'</span>'
|
||||
+'<span style="font-size:11px;color:var(--text-dim)">'+esc(p.workspace||'')+'</span></div>';
|
||||
});
|
||||
if(!self._it.length)h+='<div style="padding:12px;color:var(--text-dim);font-size:13px">No page found</div>';
|
||||
self._li.innerHTML=h;
|
||||
}).catch(()=>{}); },
|
||||
_mv(dir){ if(!this._it.length)return;
|
||||
this._s=(this._s+dir+this._it.length)%this._it.length;
|
||||
this._li.querySelectorAll('.wm-item').forEach((el,i)=>{el.style.background=i===this._s?'rgba(35,131,226,.15)':'';});
|
||||
const cur=this._li.querySelectorAll('.wm-item')[this._s]; if(cur&&cur.scrollIntoView)cur.scrollIntoView({block:'nearest'}); },
|
||||
sel(pid){ const E=window.E; if(!E||this._idx<0)return;
|
||||
const b=E.blocks[this._idx]; if(!b)return;
|
||||
// Drop the typed "[[" from the live DOM text, append the token chip.
|
||||
const el=E.getEl(b.id); let text=(el?gtTok(el):b.content)||'';
|
||||
if(text.endsWith('[[')) text=text.slice(0,-2);
|
||||
else text=text.replace(/\[\[[^\]]*$/,'');
|
||||
E._commitBlockText(b, text+'[[fdpage:'+pid+']] ');
|
||||
this.close(); },
|
||||
get isOpen(){return this._o;}};
|
||||
|
||||
// ═══════════ Inline AI composer (Notion AI-style) ═══════════
|
||||
// Space on an empty paragraph → "Edit with AI" pill → Enter → "Brewing…"
|
||||
// → framed result → "Insert below" → applyAIBlocks.
|
||||
@@ -425,10 +548,11 @@
|
||||
}
|
||||
if(b.type==='bookmark'){
|
||||
var url=b.url||b.src||'';var title=esc(b.title||url);var desc=esc(b.description||'');var img=b.image||'';var site=esc(b.site_name||'');
|
||||
var imgHtml=img?'<img src="'+img+'" alt="" style="width:120px;height:90px;object-fit:cover;border-radius:8px;flex-shrink:0;">':'';
|
||||
var imgHtml=img?'<img src="'+img.replace(/"/g,'"')+'" alt="" style="width:120px;height:90px;object-fit:cover;border-radius:8px;flex-shrink:0;">':'';
|
||||
var descHtml=desc?'<div style="font-size:13px;color:var(--text-dim);margin-top:4px;">'+desc+'</div>':'';
|
||||
var siteHtml=site?'<div style="font-size:11px;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:.5px;margin-top:6px;">'+site+'</div>':'';
|
||||
return url?`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-bookmark"><a href="'+url+'" target="_blank" rel="noopener noreferrer" style="text-decoration:none;color:inherit;"><div style="display:flex;gap:14px;align-items:center;border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin:0;background:var(--bg-secondary);"><div style="flex:1;min-width:0;"><div style="font-weight:600;font-size:15px;">'+title+'</div>'+descHtml+siteHtml+'</div>'+imgHtml+'</div></a></div></div>`:`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-bookmark" style="text-align:center;padding:60px 20px;background:var(--bg-secondary);border:1px dashed var(--border);border-radius:8px;cursor:pointer;" onclick="E.setBookmarkUrl('${b.id}')"><div style="font-size:48px;margin-bottom:12px;">🔖</div><div style="font-size:14px;color:var(--text-dim);">Click to add bookmark URL</div></div></div>`;
|
||||
var safeUrl=url.replace(/"/g,'"');
|
||||
return url?`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-bookmark"><a href="${safeUrl}" target="_blank" rel="noopener noreferrer" style="text-decoration:none;color:inherit;"><div style="display:flex;gap:14px;align-items:center;border:1px solid var(--border);border-radius:10px;padding:14px 16px;margin:0;background:var(--bg-secondary);"><div style="flex:1;min-width:0;"><div style="font-weight:600;font-size:15px;">${title}</div>${descHtml}${siteHtml}</div>${imgHtml}</div></a></div></div>`:`<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-bookmark" style="text-align:center;padding:60px 20px;background:var(--bg-secondary);border:1px dashed var(--border);border-radius:8px;cursor:pointer;" onclick="E.setBookmarkUrl('${b.id}')"><div style="font-size:48px;margin-bottom:12px;">🔖</div><div style="font-size:14px;color:var(--text-dim);">Click to add bookmark URL</div></div></div>`;
|
||||
}
|
||||
if(b.type==='embed'){
|
||||
if(b.embed_type==='inline_dbs')return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-inline-dbs"><div style="padding:20px;text-align:center;color:var(--text-secondary);">📊 Inline Databases</div><div class="inline-dbs-grid">${(b.dbs||[]).map(db=>`<a href="/db/${db.id}/view/${db.view_type||'table'}" class="inline-db-card" target="_blank" style="display:block;padding:12px 16px;background:var(--bg-secondary);border-radius:8px;margin:8px 0;text-decoration:none;color:var(--text-primary);border:1px solid var(--border);"><span style="font-weight:500">📋 ${db.name}</span><span style="float:right;font-size:12px;color:var(--text-tertiary);">${db.view_type==='form'?'📝 Form':'📊 Table'} →</span></a>`).join('')}</div></div></div>`;
|
||||
@@ -439,13 +563,29 @@
|
||||
return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="text-align:center;padding:60px 20px;"><div style="font-size:48px;margin-bottom:12px;">{{ fd_icon("paperclip",14) }}</div><div style="font-size:16px;font-weight:500;color:var(--text-primary);margin-bottom:4px;">${esc(b.file_name||'File')}</div><div style="font-size:12px;color:var(--text-tertiary);margin-bottom:16px;">${szStr} · ${esc(b.file_mime||'')}</div><a href="${b.src||''}" download style="display:inline-block;padding:8px 20px;background:var(--accent);color:#fff;text-decoration:none;border-radius:8px;font-size:13px;font-weight:500;">⬇ Download</a></div></div>`;
|
||||
}
|
||||
var url=b.src||'';if(url){
|
||||
var src=url;
|
||||
try{var h=window.location.href.split('/').slice(0,3).join('/');var urlNoProto=url.replace(/^https?:\/\//,'');if(/^(youtube\.com|youtu\.be|vimeo\.com|loom\.com|figma\.com|google\.com\/maps|codepen\.io|miro\.com|open\.spotify\.com|soundcloud\.com|twitch\.tv|twitter\.com|x\.com|pinterest\.)/.test(urlNoProto.split('/')[0])){src=url;}}catch(e){}
|
||||
var src=b.embed_src||url;
|
||||
var height=b.height||520;
|
||||
return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="position:relative;width:100%;height:${height}px;border-radius:8px;overflow:hidden;background:#0a0a0a;"><iframe src="${src}" loading="lazy" frameborder="0" style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div></div>`;
|
||||
}
|
||||
return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="text-align:center;padding:60px 20px;background:var(--bg-secondary);border:1px dashed var(--border);border-radius:8px;cursor:pointer;" onclick="E.setEmbedUrl('${b.id}')"><div style="font-size:48px;margin-bottom:12px;">🔗</div><div style="font-size:14px;color:var(--text-dim);">Click to add embed URL</div></div></div>`;
|
||||
}
|
||||
if(b.type==='synced'){
|
||||
if(b._synced_deleted){
|
||||
// v6.5.0: source deleted → clear state instead of stale cache.
|
||||
return `<div class="block-wrapper synced-block-wrapper" data-id="${b.id}" style="border-left:3px solid #e05e5e;background:var(--bg-secondary);opacity:.8">
|
||||
<div class="synced-badge" style="font-size:11px;color:#e05e5e;padding:4px 8px;font-weight:600;">🔗 Deleted synced block</div>
|
||||
<div style="color:var(--text-dim);padding:0 8px 8px;font-size:12px;">The original synced block was deleted — this copy no longer updates.</div>
|
||||
</div>`;
|
||||
}
|
||||
var syncedTitle=b._synced_title||'Synced block';
|
||||
var syncedContent=b._synced_content||[];
|
||||
var sid=b.synced_id||'';
|
||||
var syncedInner=syncedContent.map(function(ch){return'<div class="block-content" style="padding-left:16px;border-left:2px solid var(--accent);margin:4px 0;">'+mdEsc(typeof ch==='object'?ch.content||'':ch)+'</div>';}).join('');
|
||||
return `<div class="block-wrapper synced-block-wrapper" data-id="${b.id}" style="border-left:3px solid var(--accent);background:var(--synced-bg,var(--bg-secondary))">
|
||||
<div class="synced-badge" style="font-size:11px;color:var(--accent);padding:4px 8px;font-weight:600;">🔗 Synced — ${esc(syncedTitle)}</div>
|
||||
<div class="synced-content">${syncedInner||'<div style="color:var(--text-dim);padding:8px;">Loading synced content…</div>'}</div>
|
||||
</div>`;
|
||||
}
|
||||
const ph=b.type==='heading_1'?'Heading 1':b.type==='heading_2'?'Heading 2':b.type==='heading_3'?'Heading 3':b.type==='heading_4'?'Heading 4':b.type==='bulleted_list'?'List':b.type==='numbered_list'?'List':b.type==='to_do'?'To-do':b.type==='toggle'?'Toggle list':b.type==='quote'?'Empty quote':b.type==='code'?'Type code...':b.type==='callout'?'Type something...':(b.type==='paragraph'&&!b.content?'Press \'space\' for AI or \'/\' for commands':'Press \'/\' for commands...');
|
||||
let inner='';
|
||||
if(b.type==='to_do')inner=`<input type="checkbox" class="todo-checkbox" ${b.checked?'checked':''} onchange="E._doToggle('${b.id}',this.checked)"><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${mdEsc(b.content)}</div>`;
|
||||
@@ -782,6 +922,9 @@
|
||||
if(act==='turn'){var m=document.getElementById('blockMenu');if(m){var r=m.getBoundingClientRect();_blockTurnMenuOpen(r.right+4,r.top,bid||'');}return;}
|
||||
if(act==='duplicate'){E.duplicateBlock(idx);}
|
||||
else if(act==='copy-link'){E.copyBlockLink(bid||'');}
|
||||
else if(act==='copy-blocks'){E.copySelectedBlocks();}
|
||||
else if(act==='cut-blocks'){E.cutSelectedBlocks();}
|
||||
else if(act==='paste-blocks'){E.pasteBlocks();}
|
||||
else if(act==='move-to'){_blockMoveToOpen(idx);}
|
||||
else if(act==='delete'){E.deleteSelected(idx);}
|
||||
else if(act==='color'){var key=t.getAttribute('data-color-key'),val=t.getAttribute('data-color-val');E.setBlockColor(idx,key,val);}
|
||||
@@ -801,6 +944,11 @@
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-item" data-bm-act="duplicate" data-bid="'+b.id+'"><span class="bm-ico">▣</span>Duplicate <span class="bm-hint">'+(_rtMac()?'⌘D':'Ctrl+D')+'</span></div>'
|
||||
+'<div class="bm-item" data-bm-act="copy-link" data-bid="'+b.id+'"><span class="bm-ico">🔗</span>Copy link to block</div>'
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-item" data-bm-act="copy-blocks" data-bid="'+b.id+'"><span class="bm-ico">⎘</span>Copy blocks <span class="bm-hint">'+(_rtMac()?'⌘C':'Ctrl+C')+'</span></div>'
|
||||
+'<div class="bm-item" data-bm-act="cut-blocks" data-bid="'+b.id+'"><span class="bm-ico">✂</span>Cut blocks <span class="bm-hint">'+(_rtMac()?'⌘X':'Ctrl+X')+'</span></div>'
|
||||
+'<div class="bm-item" data-bm-act="paste-blocks" data-bid="'+b.id+'"><span class="bm-ico">⎘</span>Paste blocks <span class="bm-hint">'+(_rtMac()?'⌘V':'Ctrl+V')+'</span></div>'
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-item" data-bm-act="move-to" data-bid="'+b.id+'"><span class="bm-ico">↪</span>Move to…</div>'
|
||||
+'<div class="bm-sep"></div>'
|
||||
+'<div class="bm-colors"><div class="bm-colors-label">Text</div><div class="bm-swatches">'+colorsText+'</div></div>'
|
||||
@@ -882,7 +1030,7 @@
|
||||
generalAccess:'{{ page_share_mode }}',publicPerm:'viewer',
|
||||
pubAllowEdit:false,pubAllowComments:true,
|
||||
pageUrl:window.location.href,publishedUrl:'',
|
||||
inviteEmail:'',invitePermission:'edit',inviteUserId:null,inviteUsers:[],inviteSuggestOpen:false,inviteSel:-1,accessList:[],
|
||||
inviteEmail:'',invitePermission:'edit',inviteUserId:null,inviteGroupId:null,inviteGroups:[],inviteUsers:[],inviteSuggestOpen:false,inviteSel:-1,accessList:[],
|
||||
toastVisible:false,toastMsg:'',
|
||||
// ── v4.9.0: Collaboration — comments & mentions ──
|
||||
commentsOpen:false,commentDraft:'',comments:[],commentCount:0,
|
||||
@@ -892,10 +1040,15 @@
|
||||
undoStack:[],redoStack:[],_histCap:100,_histLock:false,
|
||||
// ── Cover & icon (v5.10+) ──
|
||||
coverUrl:'',
|
||||
coverOpen:false,
|
||||
coverLoading:false,
|
||||
iconOpen:false,
|
||||
iconValue:'',
|
||||
// ── Import ──
|
||||
importOpen:false,importText:'',importFile:null,
|
||||
// ── v5.11.0 wiki-links & v5.12.0 page options ──
|
||||
isLocked:false,lockedBy:null,canEdit:true,fullWidth:false,fontSmall:false,
|
||||
tplSaving:false,
|
||||
// ── DB Templates ──
|
||||
dbTplOpen:false,dbTplLoading:false,dbTemplates:[],
|
||||
// ── Version history ──
|
||||
@@ -920,14 +1073,18 @@
|
||||
_bid=Math.floor(Math.random()*10000);
|
||||
const dataEl=document.getElementById('page-data');
|
||||
if(dataEl){try{const data=JSON.parse(dataEl.textContent);this.pid=data.id;this.pageTitle=data.title||'';this.favorited=data.favorited||false;const fmt=data.content_format||'blocks';this.contentFormat=fmt;const raw=data.content||'';
|
||||
if(fmt==='file'){this.fileData=data;this.loadFileContent(data);}
|
||||
else if(fmt==='blocks'&&raw){try{this.blocks=JSON.parse(raw);this.blocks.forEach(b=>{if(!b.id)b.id=genId()});}catch(e){this.blocks=[];}}
|
||||
else if(raw&&raw.trim())this.blocks=this.md2b(raw);}catch(e){}}
|
||||
if(fmt==='file'){this.fileData=data;this.fileUrl=data.file_url||'';this.loadFileContent(data);}
|
||||
else if(fmt==='blocks'&&raw){try{this.blocks=JSON.parse(raw);ensureBlockIds(this.blocks);}catch(e){this.blocks=[];}}
|
||||
else if(raw&&raw.trim())this.blocks=this.md2b(raw);
|
||||
// v5.12.0: page lock + layout options
|
||||
this.isLocked=!!data.is_locked;this.lockedBy=data.locked_by||null;
|
||||
this.canEdit=data.can_edit!==false;this.fullWidth=!!data.full_width;this.fontSmall=!!data.font_small;}catch(e){}}
|
||||
if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];
|
||||
this.loadInlineDBs();
|
||||
window.E=this;
|
||||
this.insertTable=function(){this.sync();this.pushHistory();var nb={id:genId(),type:'table',content:'',rows:_tblNew(3,3)};this.blocks.push(nb);this.dirty=true;this.autoSave();this.render();_rtSync();setTimeout(function(){_tblFocus(nb.id,0,0);},60);};
|
||||
SM.init();_tblInitDelegate();this.render();
|
||||
this._applyLockMode();this._applyLayout();_wikiResolve();
|
||||
const tEl=document.getElementById('_titleEl');
|
||||
if(tEl){const syncT=()=>{tEl.classList.toggle('empty',!(tEl.textContent||'').trim());};syncT();tEl.addEventListener('input',syncT);tEl.addEventListener('blur',syncT);}
|
||||
if(this.contentFormat==='blocks'&&window.__fdRT&&window.__fdRT.start){setTimeout(()=>{try{window.__fdRT.start(this);}catch(e){}},150);}
|
||||
@@ -943,7 +1100,69 @@
|
||||
if(window.location.hash&&window.location.hash.indexOf('#fdblk-')===0){const hb=window.location.hash.slice(7);setTimeout(()=>{const selEl=document.querySelector(`[data-bid="${hb}"]`);if(selEl&&selEl.scrollIntoView)selEl.scrollIntoView({behavior:'smooth',block:'center'});if(selEl){selEl.focus();ce(selEl);}},500);}
|
||||
},100);
|
||||
},
|
||||
// ── v5.12.0: lock + layout helpers ──
|
||||
_applyLockMode(){
|
||||
document.body.classList.toggle('page-locked',!!this.isLocked);
|
||||
let banner=document.getElementById('_lockBanner');
|
||||
if(this.isLocked){
|
||||
if(!banner){
|
||||
banner=document.createElement('div');banner.id='_lockBanner';
|
||||
banner.className='fd-lock-banner';
|
||||
banner.innerHTML='<span>🔒</span><span>This page is locked (read-only)</span>'
|
||||
+'<button type="button" class="btn-sm" id="_lockUnlockBtn" style="margin-left:auto;font-size:12px;cursor:pointer;">Unlock</button>';
|
||||
const wrap=document.querySelector('.page-editor-wrapper');
|
||||
if(wrap)wrap.insertBefore(banner,wrap.firstChild);
|
||||
}
|
||||
const ub=banner.querySelector('#_lockUnlockBtn');
|
||||
if(ub)ub.style.display=this.canEdit?'':'none';
|
||||
}else if(banner){banner.remove();}
|
||||
const ct=document.getElementById('_blocksCt');
|
||||
if(ct){ct.querySelectorAll('[data-bid]').forEach(el=>el.setAttribute('contenteditable',this.canEdit?'true':'false'));}
|
||||
const t=document.getElementById('_titleEl');
|
||||
if(t)t.setAttribute('contenteditable',this.canEdit?'true':'false');
|
||||
},
|
||||
_applyLayout(){
|
||||
const wrap=document.querySelector('.page-editor-wrapper');
|
||||
if(!wrap)return;
|
||||
wrap.classList.toggle('full-width',!!this.fullWidth);
|
||||
wrap.classList.toggle('small-text',!!this.fontSmall);
|
||||
},
|
||||
async toggleLock(){
|
||||
if(!this.canEdit){this.showToast&&this.showToast('Only the person who locked this page (or an admin) can unlock it','error');return;}
|
||||
const next=!this.isLocked;
|
||||
try{
|
||||
const r=await fetch('/board/api/pages/'+this.pid+'/lock',{method:'POST',credentials:'same-origin',
|
||||
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
|
||||
body:JSON.stringify({locked:next})});
|
||||
if(!r.ok){const d=await r.json().catch(()=>({}));this.showToast((d&&d.detail)||'Lock failed','error');return;}
|
||||
this.isLocked=next;this._applyLockMode();
|
||||
this.showToast(next?'🔒 Page locked':'🔓 Page unlocked');
|
||||
}catch(e){this.showToast('Lock failed','error');}
|
||||
},
|
||||
async setPageOption(key,val){
|
||||
try{
|
||||
const body={};body[key]=val;
|
||||
const r=await fetch('/board/api/pages/'+this.pid+'/options',{method:'POST',credentials:'same-origin',
|
||||
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify(body)});
|
||||
if(r.ok){this[key]=val;this._applyLayout();}
|
||||
}catch(e){}
|
||||
},
|
||||
async saveAsTemplate(){
|
||||
if(!this.pageTitle||!(this.pageTitle||'').trim()){this.showToast('Give the page a title first','error');return;}
|
||||
const name=(this.pageTitle||'').trim()+' template';
|
||||
this.tplSaving=true;this.sync();
|
||||
const blocks=this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','children','rows'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;});
|
||||
try{
|
||||
const r=await fetch('/board/api/page-templates',{method:'POST',credentials:'same-origin',
|
||||
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
|
||||
body:JSON.stringify({name:name,blocks:blocks})});
|
||||
if(r.ok){this.moreOpen=false;this.showToast('📑 Saved as template: '+name);}
|
||||
else{const d=await r.json().catch(()=>({}));this.showToast((d&&d.detail)||'Save failed','error');}
|
||||
}catch(e){this.showToast('Save failed','error');}
|
||||
this.tplSaving=false;
|
||||
},
|
||||
fileData:null,
|
||||
fileUrl:'',
|
||||
async loadFileContent(data){
|
||||
const url=data.file_url||'';const mime=data.file_mime||'';const name=data.file_name||'';const self=this;
|
||||
if(!url){this.blocks=[this.mkB('paragraph','File not available')];this.render();return;}
|
||||
@@ -967,6 +1186,16 @@
|
||||
this.blocks=[this.mkB('embed','',{src:url,embed_type:'download',file_name:name,file_size:data.file_size||0,file_mime:mime})];this.render();
|
||||
},
|
||||
mkB(type,content,extras){const b={id:genId(),type,content:content||'',...(extras||{})};if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='toggle')b.children=[];if(type==='button'){b.automation_id=0;b.automation_name='';}return b;},
|
||||
// v5.11.0: rewrite one block's text (e.g. after inserting a wiki token)
|
||||
// and re-render in place, caret at end.
|
||||
_commitBlockText(b,text){
|
||||
this.sync();this.pushHistory();
|
||||
b.content=text;
|
||||
this.dirty=true;this.autoSave();this.render();_rtSync();
|
||||
const el=this.getEl(b.id);
|
||||
if(el){el.focus();const r=document.createRange();r.selectNodeContents(el);r.collapse(false);const s=window.getSelection();s.removeAllRanges();s.addRange(r);}
|
||||
if(window.__fdRT&&window.__fdRT.syncNow)window.__fdRT.syncNow();
|
||||
},
|
||||
getEl(bid){const ct=document.getElementById('_blocksCt');return ct?ct.querySelector(`[data-bid="${bid}"]`):null;},
|
||||
getIdx(bid){return this.blocks.findIndex(b=>b.id===bid);},
|
||||
getActiveBlock(){const el=document.activeElement;if(!el||!el.hasAttribute('data-bid'))return null;return{el,bid:el.dataset.bid,idx:this.getIdx(el.dataset.bid)};},
|
||||
@@ -1000,9 +1229,9 @@
|
||||
sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='table'){const wrap=ct.querySelector(`[data-bid="${b.id}"].ftable-editor`);if(wrap){const rows=[];wrap.querySelectorAll('tr.ftable-row').forEach(function(tr){const cells=tr.querySelectorAll('th.ftable-cell,td.ftable-cell');if(!cells.length)return;const rowarr=[];cells.forEach(function(cell){rowarr.push(cell.textContent||'');});rows.push(rowarr);});if(rows.length)b.rows=rows;}continue;}
|
||||
if(b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='table_of_contents'||b.type==='button')continue;
|
||||
if(b.type==='math'){const te=ct.querySelector(`[data-math-bid="${b.id}"]`);if(te)b.content=te.value||'';continue;}
|
||||
const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=gt(el)||'';else if(el)b.content=gt(el)||'';
|
||||
if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gt(che)||'';});
|
||||
if(b.type==='columns'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gt(che)||'';});
|
||||
const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=gtTok(el)||'';else if(el)b.content=gtTok(el)||'';
|
||||
if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gtTok(che)||'';});
|
||||
if(b.type==='columns'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gtTok(che)||'';});
|
||||
}},
|
||||
render(){const ct=document.getElementById('_blocksCt');if(!ct)return;let html='';for(let i=0;i<this.blocks.length;i++)html+=renderBlock(this.blocks[i],i);ct.innerHTML=html;
|
||||
// Render math blocks with KaTeX
|
||||
@@ -1015,8 +1244,15 @@
|
||||
setTimeout(()=>{ct.querySelectorAll('[data-meeting-bid]').forEach(function(el){if(window.FDMeeting)window.FDMeeting.mount(el.getAttribute('data-meeting-bid'),el);});},50);
|
||||
ct.querySelectorAll('[data-bid]').forEach(el=>{el.addEventListener('input',()=>{self.dirty=true;self.autoSave();if(el.hasAttribute&&el.hasAttribute('data-placeholder'))el.classList.toggle('empty',!(el.textContent||'').trim());if(window.AIAC)window.AIAC.schedule(el.getAttribute('data-bid'));})});
|
||||
ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/<br[^>]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());});
|
||||
_selApply();
|
||||
},
|
||||
_selApply();
|
||||
var self2=this;
|
||||
setTimeout(function(){
|
||||
if(window._wikiResolve)_wikiResolve();
|
||||
if(!self2.canEdit)self2._applyLockMode();
|
||||
const ub=document.getElementById('_lockUnlockBtn');
|
||||
if(ub&&!ub._wired){ub._wired=true;ub.onclick=function(){window.E.toggleLock();};}
|
||||
},30);
|
||||
},
|
||||
_renderKatex(el,tex){
|
||||
if(!el)return;if(!tex||!tex.trim()){el.innerHTML='<span style="color:var(--text-dim);font-style:italic;">Empty math block</span>';return;}
|
||||
if(window.katex){try{el.innerHTML=window.katex.renderToString(tex,{displayMode:true,throwOnError:false});}catch(e){el.innerHTML='<span style="color:#e5484d;">Invalid LaTeX</span>';}}
|
||||
@@ -1036,7 +1272,20 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
if(u.trim()===cur.trim())return;
|
||||
this.sync();this.pushHistory();b[field]=u.trim();this.dirty=true;this.autoSave();this.render();_rtSync();
|
||||
},
|
||||
setEmbedUrl(bid){this._mediaPrompt(bid,'src','Embed');},
|
||||
async setEmbedUrl(bid){
|
||||
const idx=this.getIdx(bid); if(idx<0)return;
|
||||
const b=this.blocks[idx]; if(!b)return;
|
||||
const cur=b.src||''; const u=prompt('Embed URL (YouTube, Vimeo, Figma, Maps…):', cur);
|
||||
if(u===null)return;
|
||||
if(u.trim()===cur.trim())return;
|
||||
this.sync();this.pushHistory();b.src=u.trim();b.embed_type='';this.dirty=true;
|
||||
try{
|
||||
const r=await fetch('/board/api/embed/resolve',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({url:u.trim()})});
|
||||
const d=await r.json();
|
||||
if(d&&d.ok&&d.src){b.embed_src=d.src;b.embed_provider=d.provider||'';}
|
||||
}catch(e){}
|
||||
this.render();this.autoSave();_rtSync();
|
||||
},
|
||||
setBookmarkUrl(bid){
|
||||
const idx=this.getIdx(bid); if(idx<0)return;
|
||||
const b=this.blocks[idx]; if(!b)return;
|
||||
@@ -1048,7 +1297,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
},
|
||||
async _fetchBookmarkPreview(bid, url){
|
||||
try{
|
||||
const r=await fetch('/api/og/metadata',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({url})});
|
||||
const r=await fetch('/board/api/og/metadata',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({url})});
|
||||
const d=await r.json();
|
||||
if(d&&d.ok){
|
||||
const idx=this.getIdx(bid); if(idx<0)return;
|
||||
@@ -1064,17 +1313,29 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
setVideoSrc(bid){this._mediaPrompt(bid,'src','Video');},
|
||||
setAudioSrc(bid){this._mediaPrompt(bid,'src','Audio');},
|
||||
|
||||
// ── v5.5.0: Image lightbox ──
|
||||
// ── v5.5.0: Image lightbox (fullscreen + keyboard navigation) ──
|
||||
_openLightbox(imgEl){
|
||||
const full=imgEl.dataset.full||imgEl.src;
|
||||
if(!full)return;
|
||||
const ov=document.createElement('div');ov.className='fd-lightbox';ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.95);z-index:5000;display:flex;align-items:center;justify-content:center;flex-direction:column;';
|
||||
ov.innerHTML='<img src="'+full+'" style="max-width:95vw;max-height:90vh;border-radius:4px;box-shadow:0 0 40px rgba(0,0,0,.8);"><div style="margin-top:16px;color:var(--text-dim);font-size:13px;">Esc to close</div>';
|
||||
document.body.appendChild(ov);
|
||||
const close=()=>{document.body.removeChild(ov);document.removeEventListener('keydown',kd);};
|
||||
const kd=(e)=>{if(e.key==='Escape')close();};
|
||||
const imgs=Array.from(document.querySelectorAll('.block-image img, .fd-img')).filter(i=>i.dataset.full||i.src);
|
||||
let idx=imgs.indexOf(imgEl);
|
||||
if(idx<0)idx=0;
|
||||
const overlay=document.createElement('div');overlay.className='fd-lightbox';overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.95);z-index:5000;display:flex;align-items:center;justify-content:center;flex-direction:column;';
|
||||
overlay.innerHTML='<button class="fd-lightbox-nav" data-nav="prev" aria-label="Previous">‹</button>'
|
||||
+'<img class="fd-lightbox-img" style="max-width:95vw;max-height:88vh;border-radius:4px;box-shadow:0 0 40px rgba(0,0,0,.8);">'
|
||||
+'<button class="fd-lightbox-nav" data-nav="next" aria-label="Next">›</button>'
|
||||
+'<div class="fd-lightbox-hint" style="margin-top:14px;color:#999;font-size:13px;"></div>';
|
||||
document.body.appendChild(overlay);
|
||||
const img=overlay.querySelector('.fd-lightbox-img');
|
||||
const hint=overlay.querySelector('.fd-lightbox-hint');
|
||||
const show=()=>{const cur=imgs[idx];img.src=cur.dataset.full||cur.src;hint.textContent=(imgs.length>1?(idx+1)+' / '+imgs.length+' — ':'')+'← → to navigate · Esc to close';};
|
||||
const step=(d)=>{if(imgs.length<2)return;idx=(idx+d+imgs.length)%imgs.length;show();};
|
||||
show();
|
||||
const close=()=>{overlay.remove();document.removeEventListener('keydown',kd);};
|
||||
const kd=(e)=>{if(e.key==='Escape')close();else if(e.key==='ArrowRight')step(1);else if(e.key==='ArrowLeft')step(-1);};
|
||||
document.addEventListener('keydown',kd);
|
||||
ov.addEventListener('click',e=>{if(e.target===ov)close();});
|
||||
overlay.querySelectorAll('[data-nav]').forEach(btn=>btn.addEventListener('click',e=>{e.stopPropagation();step(btn.dataset.nav==='next'?1:-1);}));
|
||||
overlay.addEventListener('click',e=>{if(e.target===overlay)close();});
|
||||
},
|
||||
|
||||
// ── v5.4.0: Version history UI ──
|
||||
@@ -1086,7 +1347,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
async loadVersions(){
|
||||
this.versionsLoading=true;
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/versions');
|
||||
const r=await fetch('/board/api/pages/'+this.pid+'/versions');
|
||||
const d=await r.json();
|
||||
this.versionsList=d.versions||[];
|
||||
}catch(e){this.versionsList=[];}
|
||||
@@ -1095,7 +1356,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
async restoreVersion(vid){
|
||||
if(!confirm('Restore this version? Current content will be replaced.'))return;
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/versions/'+vid+'/restore',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()}});
|
||||
const r=await fetch('/board/api/pages/'+this.pid+'/versions/'+vid+'/restore',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()}});
|
||||
const d=await r.json();
|
||||
if(d.status==='ok'){
|
||||
this.showToast('Version restored');this.versionsOpen=false;
|
||||
@@ -1113,7 +1374,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
async loadBacklinks(){
|
||||
this.backlinksLoading=true;
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/backlinks');
|
||||
const r=await fetch('/board/api/pages/'+this.pid+'/backlinks');
|
||||
const d=await r.json();
|
||||
this.backlinksList=d.backlinks||[];
|
||||
}catch(e){this.backlinksList=[];}
|
||||
@@ -1135,7 +1396,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
if(u===null)return;
|
||||
this.coverLoading=true;
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/cover',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({cover_url:u})});
|
||||
const r=await fetch('/board/api/pages/'+this.pid+'/cover',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({cover_url:u})});
|
||||
const d=await r.json();
|
||||
if(d.status==='ok'){this.coverUrl=d.cover_url;this.coverOpen=false;this.showToast('Cover updated');}
|
||||
}catch(e){this.showToast('Cover update failed','error');}
|
||||
@@ -1146,7 +1407,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
this.coverLoading=true;
|
||||
const fd=new FormData();fd.append('file',f);
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/cover',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()},body:fd});
|
||||
const r=await fetch('/board/api/pages/'+this.pid+'/cover',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()},body:fd});
|
||||
const d=await r.json();
|
||||
if(d.status==='ok'){this.coverUrl=d.cover_url;this.coverOpen=false;this.showToast('Cover uploaded');}
|
||||
}catch(e){this.showToast('Cover upload failed','error');}
|
||||
@@ -1156,18 +1417,31 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
async removeCover(){
|
||||
if(!confirm('Remove cover?'))return;
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/cover',{method:'DELETE',headers:{'X-CSRF-Token':this.csrfTok()}});
|
||||
const r=await fetch('/board/api/pages/'+this.pid+'/cover',{method:'DELETE',headers:{'X-CSRF-Token':this.csrfTok()}});
|
||||
const d=await r.json();
|
||||
if(d.status==='ok'){this.coverUrl='';this.showToast('Cover removed');}
|
||||
}catch(e){this.showToast('Remove failed','error');}
|
||||
},
|
||||
toggleIcon(){this.iconOpen=!this.iconOpen;},
|
||||
iconHtml(){
|
||||
var v=this.iconValue;
|
||||
if(!v)v=(this.contentFormat==='file')?'paperclip':'file';
|
||||
return window.fdIconHtml?window.fdIconHtml(v,36):v;
|
||||
},
|
||||
openIconPicker(){
|
||||
var self=this;
|
||||
if(window.FDIconPicker)window.FDIconPicker.openFor({
|
||||
x:120,y:160,
|
||||
onPick:function(v){self.setIcon(v);},
|
||||
onRemove:function(){self.setIcon('');}
|
||||
});
|
||||
},
|
||||
async setIcon(icon){
|
||||
this.iconValue=icon;
|
||||
try{
|
||||
const r=await fetch('/api/pages/'+this.pid+'/icon',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({icon})});
|
||||
const r=await fetch('/board/api/pages/'+this.pid+'/icon',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({icon})});
|
||||
const d=await r.json();
|
||||
if(d.status==='ok'){this.iconOpen=false;this.showToast('Icon updated');}
|
||||
if(d.status==='ok'){this.iconOpen=false;this.showToast(icon?'Icon updated':'Icon removed');}
|
||||
}catch(e){this.showToast('Icon update failed','error');}
|
||||
},
|
||||
|
||||
@@ -1186,13 +1460,13 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
if(this.importFile){
|
||||
const fd=new FormData();fd.append('file',this.importFile);
|
||||
try{
|
||||
const r=await fetch('/api/pages/import/file',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()},body:fd});
|
||||
const r=await fetch('/board/api/pages/import/file',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()},body:fd});
|
||||
const d=await r.json();
|
||||
if(d.status==='ok'){this.showToast(d.count+' page(s) imported');this.importOpen=false;this.importFile=null;this.importText='';}
|
||||
}catch(e){this.showToast('Import failed','error');}
|
||||
}else{
|
||||
try{
|
||||
const r=await fetch('/api/pages/import',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({markdown:this.importText,title:this.pageTitle||'Import'})});
|
||||
const r=await fetch('/board/api/pages/import',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({markdown:this.importText,title:this.pageTitle||'Import'})});
|
||||
const d=await r.json();
|
||||
if(d.status==='ok'){this.showToast('Page imported');this.importOpen=false;this.importText='';setTimeout(()=>{window.location.href='/pages/'+d.id;},400);}
|
||||
}catch(e){this.showToast('Import failed','error');}
|
||||
@@ -1221,6 +1495,12 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
|
||||
setBlockColor(idx,key,val){if(idx<0||idx>=this.blocks.length)return;const targets=this._blockTargets(idx);const v=(!val||val==='default')?null:val;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(!b.style)b.style={};if(v==null)delete b.style[key];else b.style[key]=v;});this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();},
|
||||
copyBlockLink(bid){const url=window.location.origin+window.location.pathname+'#fdblk-'+bid;if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(url).then(()=>this.showToast('Lien du bloc copié')).catch(()=>this.showToast('Copie impossible','error'));}else{this.showToast(url,'error');}},
|
||||
deleteSelected(idx){const indices=_selIndicesSorted();if(indices.length<=1)return this.removeBlock(idx);this.sync();this.pushHistory();const keep=this.blocks.filter((b,i)=>indices.indexOf(i)<0);this.blocks=keep.length?keep:[this.mkB('paragraph','')];const fi=Math.min(idx,this.blocks.length-1);this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);},
|
||||
// ── v5.11: Copy / Cut / Paste selected blocks ──
|
||||
_blocksToMarkdown(blocks){return blocks.map(blocksToMarkdown).join('\n\n');},
|
||||
copySelectedBlocks(){const indices=_selIndicesSorted();if(!indices.length)return;const selected=indices.map(i=>this.blocks[i]).filter(Boolean);if(!selected.length)return;const md=this._blocksToMarkdown(selected);if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md).then(()=>this.showToast('Blocs copiés')).catch(()=>this.showToast('Copie impossible','error'));}else{this.showToast(md,'error');}},
|
||||
cutSelectedBlocks(){const indices=_selIndicesSorted();if(!indices.length)return;this.sync();this.pushHistory();const selected=indices.map(i=>this.blocks[i]).filter(Boolean);if(!selected.length)return;const md=this._blocksToMarkdown(selected);if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md).then(()=>{});}this.blocks=this.blocks.filter((b,i)=>indices.indexOf(i)<0);if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();this.showToast('Blocs coupés');},
|
||||
async pasteBlocks(){const idx=this._focusedIdx();if(idx<0)return;try{const text=await navigator.clipboard.readText();if(!text||!text.trim())return;const parsed=this.md2b(text);if(!parsed.length)return;this.sync();this.pushHistory();this.blocks.splice(idx+1,0,...parsed);this.dirty=true;this.autoSave();this.render();_rtSync();this._focusBid(parsed[parsed.length-1].id);_selClear();}catch(e){if(e.name==='NotAllowedError'){this.showToast('Permission presse-papier refusée');}else{this.showToast('Coller impossible','error');}}},
|
||||
_focusedIdx(){const a=this.getActiveBlock();return a?a.idx:-1;},
|
||||
async moveBlockToPage(idx,pageId){if(idx<0||idx>=this.blocks.length||pageId===this.pid)return;this.sync();this.pushHistory();const block=this.blocks[idx];const self=this;const csrf=document.cookie.match(/csrf_token=([^;]+)/);this.showToast('Déplacement du bloc…');try{const r=await fetch(`/board/api/pages/${pageId}`,{credentials:'same-origin'});const d=await r.json();let tblocks=[];if(d.content_format==='blocks'&&d.content){try{tblocks=JSON.parse(d.content);}catch(e){tblocks=[];}}tblocks.push(block);await fetch(`/board/api/pages/${pageId}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:d.title||'',blocks:tblocks})});
|
||||
if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();this.showToast('Bloc déplacé');
|
||||
}catch(e){this.showToast('Échec du déplacement','error');}},
|
||||
@@ -1386,15 +1666,30 @@ applyAIBlocks(text){
|
||||
},
|
||||
shareInvite() {
|
||||
var self = this;
|
||||
if (this.inviteGroupId) {
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch('/api/pages/' + this.pid + '/share', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
|
||||
body: JSON.stringify({ group_id: this.inviteGroupId, permission: this.invitePermission })
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
if (d.status === 'shared') {
|
||||
self.inviteEmail = ''; self.inviteUserId = null; self.inviteGroupId = null;
|
||||
self.inviteUsers = []; self.inviteGroups = []; self.inviteSuggestOpen = false;
|
||||
self.showToast('Group shared'); self.loadShares();
|
||||
} else { self.showToast(d.detail || 'Share failed'); }
|
||||
}).catch(function(){ self.showToast('Share failed'); });
|
||||
return;
|
||||
}
|
||||
if (!this.inviteEmail.trim()) return;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
var csrf2 = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch('/api/pages/' + this.pid + '/share', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf2 ? csrf2[1] : '' },
|
||||
body: JSON.stringify({ user_id: this.inviteUserId, email: this.inviteEmail.trim(), permission: this.invitePermission })
|
||||
}).then(function(r){ return r.json(); }).then(function(d){
|
||||
if (d.status === 'shared') {
|
||||
self.inviteEmail = ''; self.inviteUserId = null; self.inviteUsers = []; self.inviteSuggestOpen = false;
|
||||
self.inviteEmail = ''; self.inviteUserId = null; self.inviteUsers = []; self.inviteGroups = []; self.inviteSuggestOpen = false;
|
||||
self.showToast('Invitation sent'); self.loadShares();
|
||||
} else { self.showToast(d.detail || 'Share failed'); }
|
||||
}).catch(function(){ self.showToast('Share failed'); });
|
||||
@@ -1402,7 +1697,7 @@ applyAIBlocks(text){
|
||||
inviteInput(){
|
||||
var self = this;
|
||||
var q = (this.inviteEmail || '').trim();
|
||||
if (!q) { this.inviteUsers = []; this.inviteSuggestOpen = false; this.inviteUserId = null; this.inviteSel = -1; return; }
|
||||
if (!q) { this.inviteUsers = []; this.inviteGroups = []; this.inviteSuggestOpen = false; this.inviteUserId = null; this.inviteGroupId = null; this.inviteSel = -1; return; }
|
||||
clearTimeout(this._inviteTimer);
|
||||
this._inviteTimer = setTimeout(function(){ self._loadInviteUsers(q); }, 150);
|
||||
},
|
||||
@@ -1416,9 +1711,20 @@ applyAIBlocks(text){
|
||||
this.inviteUsers = (d.users || []).filter(function(u){
|
||||
return u.id !== me && !shared[(u.login || '').toLowerCase()];
|
||||
});
|
||||
// Groups: charger les groupes du workspace et filtrer
|
||||
try {
|
||||
const gr = await fetch('/api/v2/groups', { credentials: 'same-origin' });
|
||||
const gd = await gr.json();
|
||||
var sharedGroups = {};
|
||||
(this.accessList || []).forEach(function(a){ if (a.shared_with_group_id) sharedGroups[a.shared_with_group_id] = true; });
|
||||
var ql = (q || '').toLowerCase();
|
||||
this.inviteGroups = (gd.groups || []).filter(function(g){
|
||||
return !sharedGroups[g.id] && (!ql || (g.name || '').toLowerCase().indexOf(ql) >= 0);
|
||||
}).slice(0, 8);
|
||||
} catch(e2) { this.inviteGroups = []; }
|
||||
this.inviteSel = this.inviteUsers.length ? 0 : -1;
|
||||
this.inviteSuggestOpen = true;
|
||||
} catch(e) { this.inviteUsers = []; this.inviteSuggestOpen = false; }
|
||||
} catch(e) { this.inviteUsers = []; this.inviteGroups = []; this.inviteSuggestOpen = false; }
|
||||
},
|
||||
inviteMove(dir){
|
||||
var el = this.$refs.inviteSuggest;
|
||||
@@ -1440,8 +1746,17 @@ applyAIBlocks(text){
|
||||
},
|
||||
pickInviteUser(u){
|
||||
this.inviteUserId = u.id;
|
||||
this.inviteGroupId = null;
|
||||
this.inviteEmail = u.login || u.full_name || '';
|
||||
this.inviteUsers = []; this.inviteSuggestOpen = false; this.inviteSel = -1;
|
||||
this.inviteUsers = []; this.inviteGroups = []; this.inviteSuggestOpen = false; this.inviteSel = -1;
|
||||
var el = this.$refs.inviteInput;
|
||||
if (el) el.focus();
|
||||
},
|
||||
pickInviteGroup(g){
|
||||
this.inviteGroupId = g.id;
|
||||
this.inviteUserId = null;
|
||||
this.inviteEmail = g.name || '';
|
||||
this.inviteUsers = []; this.inviteGroups = []; this.inviteSuggestOpen = false; this.inviteSel = -1;
|
||||
var el = this.$refs.inviteInput;
|
||||
if (el) el.focus();
|
||||
},
|
||||
@@ -1518,7 +1833,7 @@ applyAIBlocks(text){
|
||||
this.moveOpen = false;
|
||||
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
|
||||
var self = this;
|
||||
fetch('/api/pages/' + this.pid + '/move', {
|
||||
fetch('/board/api/pages/' + this.pid + '/move', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
|
||||
body: JSON.stringify({ workspace_id: wsId })
|
||||
@@ -1534,6 +1849,8 @@ applyAIBlocks(text){
|
||||
.catch(function(){ self.showToast('Move failed'); });
|
||||
},
|
||||
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});},
|
||||
downloadFile(){this.moreOpen=false;if(!this.fileUrl)return;var a=document.createElement('a');a.href=this.fileUrl;a.download='';document.body.appendChild(a);a.click();document.body.removeChild(a);this.showToast('Download started','success');},
|
||||
async copyFileContent(){this.moreOpen=false;if(!this.fileUrl)return;try{var r=await fetch('/api/pages/'+this.pid+'/file-content');var d=await r.json();if(d.ok&&d.content!==undefined){await navigator.clipboard.writeText(d.content);this.showToast('Content copied to clipboard','success');}else{this.showToast('Not a text file','error');}}catch(e){this.showToast('Copy failed','error');}},
|
||||
|
||||
// ── v4.6.0 / v5.3.0: Get Started actions — Database (template picker) ──
|
||||
dbTplOpen:false, dbTplLoading:false, dbTemplates:[], dbTplTargetIdx:-1,
|
||||
@@ -1621,7 +1938,7 @@ applyAIBlocks(text){
|
||||
}
|
||||
},
|
||||
|
||||
onKd(e){
|
||||
async onKd(e){
|
||||
const ab=this.getActiveBlock();if(!ab)return;
|
||||
const{el,bid,idx}=ab;const block=this.blocks[idx];if(!block)return;
|
||||
if(SM.isOpen){
|
||||
@@ -1635,7 +1952,8 @@ applyAIBlocks(text){
|
||||
if(e.key==='Escape'){e.preventDefault();window.AIAC.clear();return;}
|
||||
if(e.key==='Enter'||e.key==='Backspace'){window.AIAC.clear();}
|
||||
}
|
||||
if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id);if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen)SM.open(i2,e2);},15);}
|
||||
if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id);if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen&&!WM.isOpen)SM.open(i2,e2);
|
||||
if(e2&&!SM.isOpen&&!WM.isOpen){const t=gtTok(e2)||'';const mm=t.match(/\[\[$/);if(mm){WM.open(i2,e2);}}},15);}
|
||||
if(e.key===' '){
|
||||
if(block.type==='paragraph'&&!(block.content||'').trim()&&!(el.textContent||'').trim()&&!SM.isOpen&&window.AIC){
|
||||
e.preventDefault(); window.AIC.open(idx); return;
|
||||
@@ -1646,7 +1964,7 @@ applyAIBlocks(text){
|
||||
if(e.shiftKey){this.dirty=true;this.autoSave();return;}
|
||||
e.preventDefault();
|
||||
const sp=splitCaret(el);
|
||||
const before=sp?sp.before:gt(el)||'',after=sp?sp.after:'';
|
||||
const before=sp?sp.before:gtTok(el)||'',after=sp?sp.after:'';
|
||||
if(!before.trim()&&!after.trim()){
|
||||
const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph';
|
||||
this.sync();this.pushHistory();this.blocks.splice(idx+1,0,this.mkB(nt,''));this.render();_rtSync();
|
||||
@@ -1666,6 +1984,20 @@ applyAIBlocks(text){
|
||||
if(e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)this.redo();else this.undo();return;}
|
||||
if(e.key.toLowerCase()==='y'){e.preventDefault();this.redo();return;}
|
||||
if(e.key.toLowerCase()==='d'){e.preventDefault();const ab2=this.getActiveBlock();if(ab2)this.duplicateBlock(ab2.idx);return;}
|
||||
if(e.key.toLowerCase()==='c'){
|
||||
e.preventDefault();
|
||||
const sel=_selIndicesSorted();
|
||||
if(sel.length>0){this.copySelectedBlocks();return;}
|
||||
}
|
||||
if(e.key.toLowerCase()==='x'){
|
||||
e.preventDefault();
|
||||
const sel=_selIndicesSorted();
|
||||
if(sel.length>0){this.cutSelectedBlocks();return;}
|
||||
}
|
||||
if(e.key.toLowerCase()==='v'){
|
||||
e.preventDefault();
|
||||
await this.pasteBlocks();return;
|
||||
}
|
||||
const m={b:'bold',i:'italic',u:'underline'};if(m[e.key]){e.preventDefault();document.execCommand(m[e.key]);this.dirty=true;this.autoSave();}}
|
||||
if(e.key==='Tab'&&block.type==='code'){e.preventDefault();document.execCommand('insertText',false,' ');}
|
||||
this.dirty=true;this.autoSave();
|
||||
@@ -1680,8 +2012,16 @@ applyAIBlocks(text){
|
||||
},
|
||||
applySlash(id){
|
||||
const idx=SM._i;if(idx<0||idx>=this.blocks.length)return;
|
||||
if(id==='database'){ this.openDbTemplatePicker(idx); return; }
|
||||
if(id.indexOf('ai_')===0){ this.aiSlash(id,idx); return; }
|
||||
if(id==='database'){ this.openDbTemplatePicker(idx); return; }
|
||||
if(id==='synced'){
|
||||
const block=this.blocks[idx];this.sync();this.pushHistory();
|
||||
block.type='synced';block.synced_id=0;block._synced_content=[];block._synced_title='';
|
||||
this.render();this.dirty=true;this.autoSave();_rtSync();
|
||||
// Open synced block picker
|
||||
this._pickSyncedBlock(idx);
|
||||
return;
|
||||
}
|
||||
if(id.indexOf('ai_')===0){ this.aiSlash(id,idx); return; }
|
||||
if(id==='duplicate'){this.duplicateBlock(idx);return;}
|
||||
if(id==='delete'){this.removeBlock(idx);return;}
|
||||
if(id==='copy_link'){const blk=this.blocks[idx];if(blk)this.copyBlockLink(blk.id);return;}
|
||||
@@ -1738,8 +2078,30 @@ applyAIBlocks(text){
|
||||
_addColumn(parentId){const idx=this.getIdx(parentId);if(idx<0)return;const b=this.blocks[idx];this.pushHistory();b.children.push({id:genId(),type:'paragraph',content:''});this.render();this.dirty=true;this.autoSave();_rtSync();setTimeout(()=>{const ne=this.getEl(b.children[b.children.length-1].id);if(ne)ne.focus();},60);},
|
||||
_removeColumn(parentId,childId){const idx=this.getIdx(parentId);if(idx<0)return;const b=this.blocks[idx];this.pushHistory();b.children=b.children.filter(function(c){return c.id!==childId;});if(b.children.length===0)b.children=[{id:genId(),type:'paragraph',content:''}];this.render();this.dirty=true;this.autoSave();_rtSync();},
|
||||
_toggleCalloutIcon(bid){const picker=document.getElementById('callout-picker-'+bid);if(picker)picker.style.display=picker.style.display==='none'?'flex':'none';},
|
||||
_setCalloutIcon(bid,icon){const idx=this.getIdx(bid);if(idx>=0){this.pushHistory();this.blocks[idx].icon=icon;this.dirty=true;this.autoSave();this.render();_rtSync();}},
|
||||
showFmt(idx){const s=window.getSelection();if(!s.rangeCount||s.isCollapsed){this.fmt.open=false;return;}const r=s.getRangeAt(0).getBoundingClientRect();this.fmt.open=true;this.fmt.idx=idx;this.fmt.top=Math.max(r.top-44,0);this.fmt.left=Math.min(r.left+r.width/2-120,window.innerWidth-260);this._sel={range:s.getRangeAt(0).cloneRange(),idx};},
|
||||
_setCalloutIcon(bid,icon){const idx=this.getIdx(bid);if(idx>=0){this.pushHistory();this.blocks[idx].icon=icon;this.dirty=true;this.autoSave();this.render();_rtSync();}},
|
||||
// ── v5.14.0: Synced blocks picker ──
|
||||
async _pickSyncedBlock(idx){
|
||||
const block=this.blocks[idx];if(!block)return;
|
||||
try{
|
||||
const r=await fetch('/api/synced-blocks');const d=await r.json();
|
||||
const items=d.synced_blocks||[];
|
||||
if(!items.length){this.showToast('No synced blocks yet. Create one first in Settings → Synced Blocks.');return;}
|
||||
const names=items.map(function(s){return (s.title||'Untitled')+' (id:'+s.id+')';});
|
||||
const choice=window.prompt('Choose a synced block:\n\n'+names.join('\n'));
|
||||
if(!choice)return;
|
||||
const match=choice.match(/id:(\d+)/);
|
||||
if(!match)return;
|
||||
const sid=parseInt(match[1]);
|
||||
if(!sid){this.showToast('Invalid synced block','error');return;}
|
||||
const sr=await fetch('/api/synced-blocks/'+sid);const sd=await sr.json();
|
||||
if(!sd||!sd.content){this.showToast('Synced block not found','error');return;}
|
||||
block.synced_id=sid;
|
||||
block._synced_content=sd.content||[];
|
||||
block._synced_title=sd.title||'';
|
||||
this.render();this.dirty=true;this.autoSave();_rtSync();
|
||||
}catch(e){this.showToast('Failed to load synced blocks','error');}
|
||||
},
|
||||
showFmt(idx){const s=window.getSelection();if(!s.rangeCount||s.isCollapsed){this.fmt.open=false;return;}const r=s.getRangeAt(0).getBoundingClientRect();this.fmt.open=true;this.fmt.idx=idx;this.fmt.top=Math.max(r.top-44,0);this.fmt.left=Math.min(r.left+r.width/2-120,window.innerWidth-260);this._sel={range:s.getRangeAt(0).cloneRange(),idx};},
|
||||
fmtApply(f){this.fmt.open=false;if(!this._sel)return;const s=window.getSelection();s.removeAllRanges();s.addRange(this._sel.range);document.execCommand(f==='strikeThrough'?'strikeThrough':f);this._sel=null;this.dirty=true;this.autoSave();},
|
||||
fmtLink(){this.fmt.open=false;const u=prompt('URL:');if(u){document.execCommand('createLink',false,u);this.dirty=true;this.autoSave();}},
|
||||
// ── v4.9.0: Collaboration — comments & mentions ──
|
||||
@@ -1787,53 +2149,115 @@ applyAIBlocks(text){
|
||||
btn.style.left=x+'px';btn.style.top=y+'px';
|
||||
},
|
||||
hideCommentBtn(){const btn=document.getElementById('_commentSelBtn');if(btn)btn.style.display='none';},
|
||||
// ── @mention autocomplete ──
|
||||
mentionOpen:false,mentionQuery:'',mentionResults:[],mentionEl:null,
|
||||
// ── @mention autocomplete (v4.9.0 users + v5.11.0 pages & dates) ──
|
||||
mentionOpen:false,mentionQuery:'',mentionResults:[],mentionPages:[],mentionEl:null,
|
||||
openMention(el){
|
||||
const s=window.getSelection();if(!s.rangeCount)return;
|
||||
const text=el.textContent||'';const pos=s.getRangeAt(0).startOffset;
|
||||
const before=text.substring(0,pos);
|
||||
const range=s.getRangeAt(0).cloneRange();
|
||||
range.selectNodeContents(el);range.setEnd(s.getRangeAt(0).startContainer,s.getRangeAt(0).startOffset);
|
||||
const before=range.toString();
|
||||
const m=before.match(/@([\w\-\.]*)$/);
|
||||
if(!m)return;
|
||||
if(!m){return;}
|
||||
this.mentionQuery=m[1];this.mentionEl=el;
|
||||
const r=s.getRangeAt(0).getBoundingClientRect();
|
||||
const menu=document.getElementById('_mentionMenu');
|
||||
menu.style.display='block';
|
||||
menu.style.top=Math.max(4,(r.bottom+4>window.innerHeight-280)?r.top-284:r.bottom+4)+'px';
|
||||
menu.style.top=Math.max(4,(r.bottom+4>window.innerHeight-300)?r.top-304:r.bottom+4)+'px';
|
||||
menu.style.left=Math.min(r.left,window.innerWidth-260)+'px';
|
||||
this.mentionOpen=true;this.loadMentionUsers();
|
||||
this.mentionOpen=true;this.loadMentionResults();
|
||||
},
|
||||
async loadMentionUsers(){
|
||||
async loadMentionResults(){
|
||||
const q=this.mentionQuery||'';
|
||||
try{const r=await fetch('/api/notifications/users/search?q='+encodeURIComponent(q),{credentials:'same-origin'});const d=await r.json();this.mentionResults=d.users||[];this.renderMentionMenu();}catch(e){this.mentionResults=[];}
|
||||
const self=this;
|
||||
const [u,p]=await Promise.all([
|
||||
fetch('/api/notifications/users/search?q='+encodeURIComponent(q),{credentials:'same-origin'}).then(r=>r.ok?r.json():{users:[]}).catch(()=>({users:[]})),
|
||||
fetch('/board/api/wiki/pages?q='+encodeURIComponent(q),{credentials:'same-origin'}).then(r=>r.ok?r.json():{pages:[]}).catch(()=>({pages:[]}))
|
||||
]);
|
||||
if(!this.mentionOpen)return;
|
||||
this.mentionResults=u.users||[];this.mentionPages=p.pages||[];
|
||||
this.renderMentionMenu();
|
||||
},
|
||||
_dateSuggestions(q){
|
||||
const out=[];const lq=(q||'').toLowerCase();
|
||||
const iso=s=>s.toISOString().slice(0,10);
|
||||
const now=new Date();
|
||||
if(!lq||'today'.startsWith(lq))out.push({label:'Today',iso:iso(now)});
|
||||
if(!lq||'tomorrow'.startsWith(lq))out.push({label:'Tomorrow',iso:iso(new Date(now.getTime()+864e5))});
|
||||
if(!lq||'yesterday'.startsWith(lq))out.push({label:'Yesterday',iso:iso(new Date(now.getTime()-864e5))});
|
||||
const m=lq.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
|
||||
if(m)out.push({label:'Set date',iso:m[1]+'-'+m[2].padStart(2,'0')+'-'+m[3].padStart(2,'0')});
|
||||
return out;
|
||||
},
|
||||
renderMentionMenu(){
|
||||
const menu=document.getElementById('_mentionMenu');if(!menu)return;
|
||||
let h='';const self=this;
|
||||
(this.mentionResults||[]).forEach(function(u,idx){
|
||||
h+='<div class="mention-item" data-login="'+u.login+'" style="display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:13px;color:var(--text);cursor:pointer;border-radius:6px;">'
|
||||
+'<div style="width:24px;height:24px;border-radius:50%;background:'+(u.avatar_color||'#3A3A3A')+';color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;">'
|
||||
h+='<div class="mention-group-label">People</div>';
|
||||
(this.mentionResults||[]).slice(0,6).forEach(function(u){
|
||||
h+='<div class="mention-item" data-mkind="user" data-mval="'+esc(u.login)+'" style="display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:13px;color:var(--text);cursor:pointer;border-radius:6px;">'
|
||||
+'<div style="width:24px;height:24px;border-radius:50%;background:'+(u.avatar_color||'#3A3A3A')+';color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;overflow:hidden;">'
|
||||
+((u.avatar_url)?'<img src="'+u.avatar_url+'" style="width:24px;height:24px;border-radius:50%;object-fit:cover;">':((u.full_name||u.login||'?').charAt(0).toUpperCase()))
|
||||
+'</div><div><div style="font-weight:500">'+u.login+'</div><div style="font-size:11px;color:var(--text-dim,#999)">'+(u.full_name||'')+'</div></div></div>';
|
||||
+'</div><div><div style="font-weight:500">'+esc(u.login)+'</div><div style="font-size:11px;color:var(--text-dim,#999)">'+esc(u.full_name||'')+'</div></div></div>';
|
||||
});
|
||||
if(!h)h='<div style="padding:10px;color:var(--text-dim,#999);font-size:13px;">No users found</div>';
|
||||
if((this.mentionPages||[]).length){
|
||||
h+='<div class="mention-group-label">Pages</div>';
|
||||
this.mentionPages.slice(0,6).forEach(function(p){
|
||||
h+='<div class="mention-item" data-mkind="page" data-mval="'+p.id+'" style="display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:13px;color:var(--text);cursor:pointer;border-radius:6px;">'
|
||||
+'<span style="width:24px;text-align:center;flex-shrink:0">'+esc(p.icon||'📄')+'</span>'
|
||||
+'<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(p.title)+'</span></div>';
|
||||
});
|
||||
}
|
||||
const ds=this._dateSuggestions(this.mentionQuery);
|
||||
if(ds.length){
|
||||
h+='<div class="mention-group-label">Date</div>';
|
||||
ds.forEach(function(d){
|
||||
h+='<div class="mention-item" data-mkind="date" data-mval="'+esc(d.iso)+'" style="display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:13px;color:var(--text);cursor:pointer;border-radius:6px;">'
|
||||
+'<span style="width:24px;text-align:center;flex-shrink:0">📅</span>'
|
||||
+'<span style="flex:1">'+esc(d.label)+'</span><span style="font-size:11px;color:var(--text-dim)">'+esc(d.iso)+'</span></div>';
|
||||
});
|
||||
}
|
||||
if(this.mentionResults.length===0&&this.mentionPages.length===0&&ds.length===0)h='<div style="padding:10px;color:var(--text-dim,#999);font-size:13px;">No matches</div>';
|
||||
menu.innerHTML=h;
|
||||
menu.querySelectorAll('.mention-item').forEach(function(it){
|
||||
it.addEventListener('click',function(){self.insertMention(it.getAttribute('data-login'));});
|
||||
it.addEventListener('click',function(){
|
||||
const kind=it.getAttribute('data-mkind');const val=it.getAttribute('data-mval');
|
||||
if(kind==='user')self.insertMention(val);
|
||||
else if(kind==='page')self.insertMentionToken('[[fdpage:'+val+']] ');
|
||||
else if(kind==='date')self.insertMentionToken('[[fddate:'+val+']] ');
|
||||
});
|
||||
});
|
||||
},
|
||||
// Shared non-destructive insertion: replaces the typed "@query" right
|
||||
// before the caret inside the token-reconstructed text (chips kept).
|
||||
_replaceAtMention(replacement){
|
||||
const el=this.mentionEl;if(!el)return null;
|
||||
const idx=this.getIdx(el.dataset.bid);if(idx<0)return null;
|
||||
const b=this.blocks[idx];
|
||||
const text=gtTok(el)||'';
|
||||
// robust: compute caret by walking with a marker
|
||||
let caretPos=text.length;
|
||||
try{
|
||||
const MK='\uF000AT';const mark=document.createElement('span');mark.textContent=MK;
|
||||
const live=s.getRangeAt(0).cloneRange();live.collapse(true);live.insertNode(mark);
|
||||
const full=gtTok(el)||'';const at=full.indexOf(MK);
|
||||
if(at>=0)caretPos=at;
|
||||
mark.remove();el.normalize();
|
||||
}catch(e){}
|
||||
const before=text.slice(0,caretPos);
|
||||
const m=before.match(/@([\w\-\.]*)$/);
|
||||
if(!m)return null;
|
||||
const start=caretPos-m[0].length;
|
||||
const newText=text.slice(0,start)+replacement+text.slice(caretPos);
|
||||
this._commitBlockText(b,newText);
|
||||
return true;
|
||||
},
|
||||
insertMentionToken(token){
|
||||
if(this._replaceAtMention(token)){this.closeMention();this.dirty=true;this.autoSave();}
|
||||
},
|
||||
insertMention(login){
|
||||
const el=this.mentionEl;if(!el)return;
|
||||
const s=window.getSelection();if(s.rangeCount){s.deleteFromDocument();}
|
||||
const text=el.textContent||'';const pos=s.rangeCount? (()=>{const r=s.getRangeAt(0);return r.startOffset;} )():text.length;
|
||||
const before=text.substring(0,pos);
|
||||
const at=before.lastIndexOf('@');
|
||||
const full=before.substring(0,at)+'@'+login+' ';
|
||||
const after=text.substring(pos);
|
||||
el.textContent=full+after;
|
||||
const ns=window.getSelection();const nr=document.createRange();nr.selectNodeContents(el);nr.collapse(false);ns.removeAllRanges();ns.addRange(nr);
|
||||
this.closeMention();this.dirty=true;this.autoSave();
|
||||
this.notifyNewMentions(full);
|
||||
if(this._replaceAtMention('@'+login+' ')){
|
||||
this.closeMention();this.dirty=true;this.autoSave();
|
||||
this.notifyNewMentions('@'+login);
|
||||
}
|
||||
},
|
||||
closeMention(){const menu=document.getElementById('_mentionMenu');if(menu)menu.style.display='none';this.mentionOpen=false;this.mentionEl=null;},
|
||||
notifyNewMentions(text){
|
||||
@@ -1934,18 +2358,56 @@ applyAIBlocks(text){
|
||||
setTimeout(()=>{const fe=this.getEl(focusId);if(fe){fe.focus();ce(fe);}},60);
|
||||
},
|
||||
autoSave(){if(this.fileData)return;clearTimeout(this.st);this.st=setTimeout(()=>this.save(),1500);},
|
||||
// Reflect the page title in the left navigation, the header breadcrumb
|
||||
// and the browser tab as soon as it changes.
|
||||
_syncTitleUI(){
|
||||
const title=(this.pageTitle||'').trim()||'Untitled';
|
||||
document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=title;});
|
||||
document.querySelectorAll(`.sidebar-item.ws-tree-item[data-id="${this.pid}"]`).forEach(item=>{item.setAttribute('data-name',title);const n=item.querySelector('.page-name');if(n)n.textContent=title;});
|
||||
document.title='FlowDeck — '+title;
|
||||
window.dispatchEvent(new CustomEvent('flowdeck:page-renamed',{detail:{id:this.pid,title:title}}));
|
||||
if(window.parent&&window.parent!==window){try{window.parent.postMessage({type:'fd-page-renamed',id:this.pid,title:title},window.location.origin);}catch(e){}}
|
||||
},
|
||||
save(cb){
|
||||
if(this.fileData)return;
|
||||
if(this.fileData){
|
||||
// File page: only the title is editable here. Sync the sidebar,
|
||||
// header breadcrumb and browser tab live, and persist the title
|
||||
// without rewriting the file's blocks.
|
||||
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'';
|
||||
this._syncTitleUI();
|
||||
if(this._fileTitleT)clearTimeout(this._fileTitleT);
|
||||
this._fileTitleT=setTimeout(()=>{
|
||||
this._fileTitleT=null;
|
||||
const title=(this.pageTitle||'').trim();
|
||||
if(!title)return;
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/board/api/pages/${this.pid}?title=${encodeURIComponent(title)}`,{method:'PUT',headers:{'X-CSRF-Token':csrf?csrf[1]:''}})
|
||||
.then(()=>{this.dirty=false;})
|
||||
.catch(()=>{});
|
||||
},600);
|
||||
if(cb)cb();
|
||||
return;
|
||||
}
|
||||
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'';
|
||||
this._syncTitleUI();
|
||||
if(this.saving){ if(cb) cb(); return; }
|
||||
this.sync();
|
||||
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'';
|
||||
this.saving=true;
|
||||
const blocksArr=this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;});
|
||||
// v6.0.0 PWA: hors ligne (ou échec réseau) → file IndexedDB rejouée au retour du réseau
|
||||
const queueOffline=()=>{
|
||||
if(!window.FlowOffline){this.saving=false;return;}
|
||||
window.FlowOffline.savePageOffline({id:this.pid,title:this.pageTitle,content:JSON.stringify(blocksArr),content_format:'blocks'})
|
||||
.then(()=>{this.saving=false;this.dirty=false;this.lastSaved='offline';if(cb)cb();})
|
||||
.catch(()=>{this.saving=false;});
|
||||
};
|
||||
if(!navigator.onLine){queueOffline();return;}
|
||||
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
|
||||
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
|
||||
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:blocksArr})})
|
||||
.then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();
|
||||
document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=this.pageTitle||'New page';});
|
||||
this._syncTitleUI();
|
||||
if(cb)cb();
|
||||
}).catch(()=>{this.saving=false;});
|
||||
}).catch(()=>{this.saving=false;queueOffline();});
|
||||
},
|
||||
runAutomationButton(bid){
|
||||
const idx=this.getIdx(bid);if(idx<0)return;
|
||||
@@ -2052,4 +2514,6 @@ applyAIBlocks(text){
|
||||
default: return c;
|
||||
}
|
||||
}
|
||||
window.editorState = editorState;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
<script data-cfasync="false">
|
||||
function accountsData() {
|
||||
return {
|
||||
profile: { full_name: '', email: '' },
|
||||
|
||||
@@ -204,7 +204,7 @@
|
||||
</style>
|
||||
|
||||
{# ── Global Agent API (available before Alpine for the FAB / shortcut) ── #}
|
||||
<script>
|
||||
<script data-cfasync="false">
|
||||
(function(){
|
||||
var defaultAPI = {
|
||||
open: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle')); },
|
||||
@@ -293,7 +293,7 @@
|
||||
<div class="fd-steps" x-show="m.steps && m.steps.length" x-cloak>
|
||||
<button class="fd-steps-head" type="button" @click="toggleSteps(m)">
|
||||
<span class="fd-steps-chev" :class="{down: m.stepsOpen}">▶</span>
|
||||
<span class="fd-steps-count" x-text="m.steps.length === 1 ? '1 étape' : m.steps.length + ' étapes'"></span>
|
||||
<span class="fd-steps-count" x-text="!m.steps ? '' : (m.steps.length === 1 ? '1 étape' : m.steps.length + ' étapes')"></span>
|
||||
<span class="fd-steps-elem" x-text="m.generating ? 'en cours…' : (m.stepsOpen ? 'réduire' : 'voir le déroulé')"></span>
|
||||
</button>
|
||||
<div class="fd-steps-body" x-show="m.stepsOpen" x-cloak>
|
||||
@@ -373,18 +373,20 @@
|
||||
<template x-if="!mentionLoad && !hasMentionItems">
|
||||
<div class="fd-mention-empty">Aucun élément. Tapez un nom de document, page ou base.</div>
|
||||
</template>
|
||||
<template x-if="!mentionLoad" x-for="it in mentionItems" :key="it.token || it.key">
|
||||
<div>
|
||||
<div class="fd-mention-sep" x-show="it._sep" x-text="it._sep"></div>
|
||||
<div class="fd-mention-item" x-show="!it._sep" :class="{focus: it._i===mentionFocus}"
|
||||
@click="pickMention(it)" @mouseenter="setMentionFocus(it._i)">
|
||||
<span class="fd-mention-ico" x-text="it.icon"></span>
|
||||
<div class="fd-mention-main">
|
||||
<div class="fd-mention-lbl" x-text="it.label"></div>
|
||||
<div class="fd-mention-sub" x-text="it.sub"></div>
|
||||
<template x-if="!mentionLoad">
|
||||
<template x-for="it in mentionItems" :key="it.token || it.key">
|
||||
<div>
|
||||
<div class="fd-mention-sep" x-show="it._sep" x-text="it._sep"></div>
|
||||
<div class="fd-mention-item" x-show="!it._sep" :class="{focus: it._i===mentionFocus}"
|
||||
@click="pickMention(it)" @mouseenter="setMentionFocus(it._i)">
|
||||
<span class="fd-mention-ico" x-text="it.icon"></span>
|
||||
<div class="fd-mention-main">
|
||||
<div class="fd-mention-lbl" x-text="it.label"></div>
|
||||
<div class="fd-mention-sub" x-text="it.sub"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -454,7 +456,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script data-cfasync="false">
|
||||
(function(){
|
||||
// ── Skills intégrés (workflows « / ») et commandes admin du panneau ──
|
||||
var FD_ADMIN_CMDS = [
|
||||
|
||||
+381
-63
@@ -5,7 +5,12 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FlowDeck — {% block title_prefix %}Home{% endblock %}</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/css/app.css?v=5.0.0">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<meta name="theme-color" content="#191919">
|
||||
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<link rel="stylesheet" href="/static/css/app.css?v=5.1.1">
|
||||
<link rel="stylesheet" href="/static/css/design-tokens.css?v=5.2.0">
|
||||
<link rel="stylesheet" href="/static/css/components.css?v=5.2.0">
|
||||
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
|
||||
@@ -45,6 +50,15 @@
|
||||
.page-title-block {
|
||||
padding: 16px 8px;
|
||||
}
|
||||
.page-title-block::before {
|
||||
display: none;
|
||||
}
|
||||
.page-title-block .page-icon-emoji {
|
||||
margin-left: 0;
|
||||
}
|
||||
.page-title-block .page-title-input {
|
||||
padding-left: 0;
|
||||
}
|
||||
.blocks-container {
|
||||
padding: 0 4px;
|
||||
}
|
||||
@@ -99,9 +113,9 @@
|
||||
body.embed-mode .page-editor-wrapper { padding: 8px 16px !important; max-width: 100% !important; }
|
||||
body.embed-mode .page-cover-area { padding-top: 0 !important; }
|
||||
</style>
|
||||
<script src="/static/js/htmx.min.js"></script>
|
||||
<script src="/static/js/htmx.min.js" data-cfasync="false"></script>
|
||||
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
||||
<script src="/static/js/sortable.min.js" defer></script>
|
||||
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
|
||||
</head>
|
||||
<body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'{% if embed_mode %} class="embed-mode"{% endif %}>
|
||||
<div class="app-layout" x-data="appState()">
|
||||
@@ -109,9 +123,14 @@
|
||||
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
|
||||
<div class="sidebar-overlay"
|
||||
:class="{ visible: mobileSidebarOpen }"
|
||||
@click="mobileSidebarOpen = false, sidebarCollapsed = true">
|
||||
@click="mobileSidebarOpen = false">
|
||||
</div>
|
||||
|
||||
<!-- ═══════════ SIDEBAR PEEK ZONE (collapsed, desktop) ═══════════ -->
|
||||
<div class="sidebar-peek-zone"
|
||||
x-show="sidebarCollapsed" x-cloak
|
||||
@mouseenter="sidebarPeek = true"></div>
|
||||
|
||||
{# ─── Recursive tree macro ─── #}
|
||||
{% macro render_tree_item(page, depth=0) %}
|
||||
{% set indent_px = depth * 16 %}
|
||||
@@ -133,7 +152,7 @@
|
||||
{% else %}
|
||||
<span class="tree-toggle-placeholder"></span>
|
||||
{% endif %}
|
||||
<span class="page-icon">{% if page.icon in ("folder","file","star","link","trash","book","home","settings","lock","globe","image","edit","calendar","users","user","search","tag","bar-chart","grid","list","align-left","zap") %}{{ fd_icon(page.icon,14) }}{% elif page.icon and page.icon|length <= 2 %}{{ fd_icon("file",14) }}{% else %}{{ fd_icon("folder",14) }}{% endif %}</span>
|
||||
<span class="page-icon">{{ fd_page_icon(page.icon, 14) }}</span>
|
||||
<span class="page-name">{{ page.name }}</span>
|
||||
<span class="item-actions">
|
||||
<button class="item-action-btn" @click.stop="newSubPage('{{ page.id }}')">+</button>
|
||||
@@ -150,12 +169,14 @@
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% from '_icons.html' import fd_icon %}
|
||||
{% from '_icons.html' import fd_icon, fd_page_icon %}
|
||||
|
||||
<!-- ═══════════ SIDEBAR Notion ═══════════ -->
|
||||
<aside class="sidebar"
|
||||
:class="{ collapsed: sidebarCollapsed, 'mobile-open': mobileSidebarOpen }"
|
||||
:class="{ collapsed: sidebarCollapsed && !sidebarPeek, 'mobile-open': mobileSidebarOpen, peeking: sidebarPeek }"
|
||||
id="sidebar"
|
||||
@mouseleave="!sidebarResizing && (sidebarPeek = false)"
|
||||
x-effect="document.documentElement.classList.toggle('fd-sidebar-collapsed', sidebarCollapsed && !sidebarPeek)"
|
||||
x-init="$nextTick(() => { if(typeof initTreeSortable==='function') initTreeSortable(); })">
|
||||
<!-- Header + dropdown wrapper -->
|
||||
<div style="position:relative;">
|
||||
@@ -171,8 +192,10 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<span class="workspace-chevron" :class="{ open: workspaceMenuOpen }">▾</span>
|
||||
<button class="sidebar-collapse-inline" @click.stop="sidebarCollapsed = !sidebarCollapsed" title="Collapse sidebar">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="11 17 6 12 11 7"/><polyline points="18 17 13 12 18 7"/></svg>
|
||||
<button class="sidebar-collapse-inline" @click.stop="toggleSidebar()"
|
||||
:title="sidebarPeek ? 'Pin sidebar open' : 'Close sidebar'">
|
||||
<svg x-show="!sidebarPeek" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="11 17 6 12 11 7"/><polyline points="18 17 13 12 18 7"/></svg>
|
||||
<svg x-show="sidebarPeek" x-cloak width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="user-menu-dropdown" x-show="workspaceMenuOpen" @click.outside="workspaceMenuOpen = false" x-transition>
|
||||
@@ -202,7 +225,7 @@
|
||||
<div class="um-sep"></div>
|
||||
<div class="um-version">FlowDeck v{{ app_version }}</div>
|
||||
<div class="um-sep"></div>
|
||||
<a href="/auth/logout" class="um-item um-danger" @click="workspaceMenuOpen=false">{{ fd_icon("log-out", 16) }} Log out</a>
|
||||
<a href="/auth/logout" class="um-item um-danger" hx-boost="false" @click="workspaceMenuOpen=false">{{ fd_icon("log-out", 16) }} Log out</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -256,7 +279,7 @@
|
||||
{% else %}
|
||||
<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("folder",16) }}</span><span class="page-name text-dim">Open a workspace to see your files</span></li>
|
||||
{% endif %}
|
||||
<li class="sidebar-item" style="margin-top:4px;border-top:1px solid var(--border);padding-top:8px;" onclick="window.location='/workspaces'">
|
||||
<li class="sidebar-item" style="margin-top:4px;border-top:1px solid var(--border);padding-top:8px;" onclick="window.fdNavigate ? window.fdNavigate('/workspaces') : window.location='/workspaces'">
|
||||
<span class="page-icon page-icon-svg">{{ fd_icon("settings",16) }}</span><span class="page-name text-dim">Manage Workspaces</span>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -573,8 +596,29 @@
|
||||
|
||||
<!-- Customize sidebar overlay + panel (full sidebar takeover) -->
|
||||
<div class="customize-overlay" x-show="customizing" @click="toggleCustomize()" x-cloak></div>
|
||||
|
||||
<!-- Right-edge resize handle: click to close, drag to resize -->
|
||||
<div class="sidebar-resize-handle"
|
||||
@mouseenter="sidebarResizeHover = true"
|
||||
@mouseleave="sidebarResizeHover = false"
|
||||
@pointerdown.prevent="startSidebarResize($event)"
|
||||
title=""></div>
|
||||
</aside>
|
||||
|
||||
<!-- Resize / close contextual tooltip -->
|
||||
<div class="sidebar-resize-tip"
|
||||
x-show="sidebarResizeHover && (!sidebarCollapsed || sidebarPeek)" x-cloak
|
||||
:style="'left:' + (sidebarWidth + 12) + 'px'">
|
||||
<div class="srt-row">
|
||||
<span class="srt-key">Close</span>
|
||||
<span class="srt-action">Click · <span class="srt-kbd">Ctrl+\</span></span>
|
||||
</div>
|
||||
<div class="srt-row">
|
||||
<span class="srt-key">Resize</span>
|
||||
<span class="srt-action">Drag ↔</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Uncollapse button → inside topbar, no overlap -->
|
||||
<!-- (Moved into topbar block below) -->
|
||||
|
||||
@@ -589,6 +633,7 @@
|
||||
<!-- Content -->
|
||||
<div class="content-area" id="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
{% block scripts %}{% endblock %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -653,6 +698,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include '_icon_picker.html' %}
|
||||
|
||||
<!-- ═══════════ WORKSPACE TREE CONTEXT MENU ═══════════ -->
|
||||
<div class="context-menu" id="ws-context-menu"
|
||||
:class="{ visible: wsCtx.visible }"
|
||||
@@ -708,6 +755,11 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ============ PWA SYNC BADGE (v6.0.0) ============ -->
|
||||
<div id="fd-sync-badge" class="fd-sync-badge" style="display:none" role="status" aria-live="polite">
|
||||
<span class="fd-sync-spinner"></span><span>Synchronisation…</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script data-cfasync="false">
|
||||
@@ -774,6 +826,26 @@
|
||||
return m ? m[1] : '';
|
||||
},
|
||||
|
||||
/** Reflète le rename d'une page/dossier dans toute l'UI : sidebar gauche
|
||||
(Récents, Privé, Favoris + arbre du workspace), breadcrumb du header
|
||||
(event flowdeck:page-renamed) et frame parente (postMessage). */
|
||||
syncPageTitle: function(pid, title) {
|
||||
var t = (title || '').trim() || 'Untitled';
|
||||
document.querySelectorAll('.sidebar-item[data-page-id="page/' + pid + '"]').forEach(function(item) {
|
||||
var n = item.querySelector('.page-name');
|
||||
if (n) n.textContent = t;
|
||||
});
|
||||
document.querySelectorAll('.sidebar-item.ws-tree-item[data-id="' + pid + '"]').forEach(function(item) {
|
||||
item.setAttribute('data-name', t);
|
||||
var n = item.querySelector('.page-name');
|
||||
if (n) n.textContent = t;
|
||||
});
|
||||
window.dispatchEvent(new CustomEvent('flowdeck:page-renamed', { detail: { id: pid, title: t } }));
|
||||
if (window.parent && window.parent !== window) {
|
||||
try { window.parent.postMessage({ type: 'fd-page-renamed', id: pid, title: t }, window.location.origin); } catch (e) {}
|
||||
}
|
||||
},
|
||||
|
||||
/** Refresh CSRF token from the server. Returns a promise resolving to the new token. */
|
||||
refreshCsrfToken: async function() {
|
||||
try {
|
||||
@@ -855,6 +927,15 @@
|
||||
return { wsId: 0, isGitea: false, workspaceKey: wk };
|
||||
},
|
||||
|
||||
/** Folder currently open on the local-workspace page (0 when at root). */
|
||||
_currentFolderId: function() {
|
||||
var ws = window._wsData;
|
||||
if (ws && typeof ws.currentFolder === 'number' && ws.currentFolder > 0) {
|
||||
return ws.currentFolder;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
|
||||
/** Return a toast function that works across pages. */
|
||||
_toast: function(msg, type) {
|
||||
if (window.appState && window.appState.toast) window.appState.toast(msg, type);
|
||||
@@ -862,47 +943,111 @@
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new page/file in the correct workspace context.
|
||||
* - Local workspace active → workspace item (prompts for name)
|
||||
* - Gitea workspace active → workspace item with workspace_id (prompts for name)
|
||||
* - No workspace → general Notion page (no prompt, title "New page")
|
||||
* Create a new page: opens the v5.12.0 template picker (built-in + the
|
||||
* user's saved templates), then instantiates the chosen one.
|
||||
*/
|
||||
createPage: function() {
|
||||
var ctx = this._getContext();
|
||||
createPage: function(parentId) {
|
||||
var self = this;
|
||||
|
||||
// ── Workspace context → create workspace item ──
|
||||
if (ctx.wsId > 0) {
|
||||
var body = { name: '', type: 'page' };
|
||||
if (ctx.isGitea) body.workspace_id = ctx.wsId;
|
||||
|
||||
fetch('/api/local-workspace/items', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(function(r) { if (!r.ok) throw new Error('Failed'); return r.json(); })
|
||||
.then(function(data) {
|
||||
window.location.href = '/pages/' + data.id;
|
||||
// Explicit folder (row/context-menu button) wins; otherwise create in the
|
||||
// folder the user is currently browsing on the local-workspace page.
|
||||
if (parentId == null || parentId === '') parentId = this._currentFolderId();
|
||||
this._createParentId = parentId || 0;
|
||||
// Direct creation path (fallback / no-template choice).
|
||||
this._createPlainPage = function() {
|
||||
var ctx = self._getContext();
|
||||
if (ctx.wsId > 0) {
|
||||
var body = { name: '', type: 'page' };
|
||||
if (ctx.isGitea) body.workspace_id = ctx.wsId;
|
||||
if (self._createParentId) body.parent_id = self._createParentId;
|
||||
fetch('/api/local-workspace/items', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': self.getCsrfToken() },
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.catch(function(err) { self._toast('Failed: ' + err.message, 'error'); });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── No workspace → general Notion page ──
|
||||
var project = ctx.workspaceKey;
|
||||
var url = '/board/api/pages?section=Private&project=' + encodeURIComponent(project);
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': this.getCsrfToken() }
|
||||
})
|
||||
.then(function(r) { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
|
||||
.then(function(data) { window.location.href = '/pages/' + data.id; })
|
||||
.catch(function(err) { self._toast('Failed to create page: ' + err.message, 'error'); });
|
||||
.then(function(r) { if (!r.ok) throw new Error('Failed'); return r.json(); })
|
||||
.then(function(data) { window.location.href = '/pages/' + data.id; })
|
||||
.catch(function(err) { self._toast('Failed: ' + err.message, 'error'); });
|
||||
return;
|
||||
}
|
||||
var project = ctx.workspaceKey;
|
||||
var url = '/board/api/pages?section=Private&project=' + encodeURIComponent(project);
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': self.getCsrfToken() }
|
||||
})
|
||||
.then(function(r) { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
|
||||
.then(function(data) { window.location.href = '/pages/' + data.id; })
|
||||
.catch(function(err) { self._toast('Failed to create page: ' + err.message, 'error'); });
|
||||
};
|
||||
this._openTemplatePicker();
|
||||
},
|
||||
|
||||
/** Show the global New Folder modal. */
|
||||
showCreateFolderModal: function() {
|
||||
_openTemplatePicker: function() {
|
||||
var self = this;
|
||||
function escAttr(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
||||
if (document.getElementById('fd-tpl-picker')) { document.getElementById('fd-tpl-picker').style.display = 'flex'; return; }
|
||||
var ov = document.createElement('div');
|
||||
ov.id = 'fd-tpl-picker';
|
||||
ov.className = 'tpl-picker-overlay';
|
||||
ov.innerHTML = '<div class="tpl-picker-modal"><div class="tpl-picker-title">New page from template</div>'
|
||||
+ '<div class="tpl-picker-sub">Pick a starting point — or start blank.</div>'
|
||||
+ '<div class="tpl-grid" id="fd-tpl-grid"><div style="color:var(--text-dim);font-size:13px;padding:12px">Loading…</div></div>'
|
||||
+ '<div style="text-align:right;margin-top:14px"><button class="btn-sm" id="fd-tpl-close" style="cursor:pointer;padding:6px 14px">Cancel</button></div></div>';
|
||||
document.body.appendChild(ov);
|
||||
ov.addEventListener('click', function(e){ if(e.target===ov) ov.style.display='none'; });
|
||||
document.getElementById('fd-tpl-close').onclick = function(){ ov.style.display='none'; };
|
||||
fetch('/board/api/page-templates', {credentials:'same-origin'})
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){
|
||||
var list = d.templates || [];
|
||||
var h = '';
|
||||
list.forEach(function(t){
|
||||
var use = t.builtin
|
||||
? "key:"+t.key
|
||||
: "id:"+t.id;
|
||||
h += '<div class="tpl-card" data-use="'+escAttr(use)+'">'
|
||||
+ '<span class="tpl-card-icon">'+escAttr(t.icon||'📄')+'</span>'
|
||||
+ '<span><span class="tpl-card-name">'+escAttr(t.name)+'</span>'
|
||||
+ '<div class="tpl-card-desc">'+escAttr(t.description||'')+'</div></span></div>';
|
||||
});
|
||||
document.getElementById('fd-tpl-grid').innerHTML = h || '<div style="color:var(--text-dim)">No templates</div>';
|
||||
document.querySelectorAll('#fd-tpl-grid .tpl-card').forEach(function(card){
|
||||
card.onclick = function(){ self._useTemplate(card.dataset.use); };
|
||||
});
|
||||
}).catch(function(){ document.getElementById('fd-tpl-grid').innerHTML='<div style="color:var(--text-dim)">Failed to load templates</div>'; });
|
||||
},
|
||||
|
||||
_useTemplate: function(use){
|
||||
var ov = document.getElementById('fd-tpl-picker');
|
||||
if (ov) ov.style.display = 'none';
|
||||
var self = this;
|
||||
if (!use || use === 'key:empty') { this._createPlainPage(); return; }
|
||||
var parts = use.split(':');
|
||||
var body = {};
|
||||
var url;
|
||||
// Pass the current workspace context so the created page is persisted
|
||||
// in the active local/Gitea workspace (mirrors _createPlainPage()).
|
||||
var ctx = self._getContext();
|
||||
if (ctx.wsId > 0) { body.workspace_id = ctx.wsId; }
|
||||
else if (ctx.workspaceKey) { body.workspace = ctx.workspaceKey; }
|
||||
if (self._createParentId) { body.parent_id = self._createParentId; }
|
||||
if (parts[0] === 'key') { body.key = parts.slice(1).join(':'); url = '/board/api/page-templates/0/use'; }
|
||||
else { url = '/board/api/page-templates/' + parts[1] + '/use'; }
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
.then(function(r){ if (!r.ok) throw new Error('Template failed'); return r.json(); })
|
||||
.then(function(d){ window.location.href = '/pages/' + d.id; })
|
||||
.catch(function(){ self._createPlainPage(); });
|
||||
},
|
||||
|
||||
/** Show the global New Folder modal (parentId = target folder, optional). */
|
||||
showCreateFolderModal: function(parentId) {
|
||||
if (parentId == null || parentId === '') parentId = this._currentFolderId();
|
||||
this._folderParentId = parentId || 0;
|
||||
var modal = document.getElementById('fd-global-folder-modal');
|
||||
if (!modal) return;
|
||||
modal.style.display = 'flex';
|
||||
@@ -921,6 +1066,8 @@
|
||||
var ctx = this._getContext();
|
||||
var body = { name: name, type: 'folder' };
|
||||
if (ctx.wsId > 0 && ctx.isGitea) body.workspace_id = ctx.wsId;
|
||||
if (this._folderParentId) body.parent_id = this._folderParentId;
|
||||
this._folderParentId = 0;
|
||||
|
||||
var self = this;
|
||||
fetch('/api/local-workspace/items', {
|
||||
@@ -951,6 +1098,49 @@
|
||||
window.appState = this;
|
||||
this.loadSidebarConfig();
|
||||
this.loadAgents();
|
||||
this.initSidebarWidth();
|
||||
document.addEventListener('fd-toggle-sidebar', () => this.toggleSidebar());
|
||||
this.startClipAutoRefresh();
|
||||
},
|
||||
// ── Auto-refresh sidebar after a web clip (no manual reload) ──
|
||||
_clipRefreshTimer: null,
|
||||
_clipRefreshInterval: null,
|
||||
startClipAutoRefresh() {
|
||||
const self = this;
|
||||
// Instant refresh when extension notifies (content script forwards event)
|
||||
window.addEventListener('flowdeck:clip-created', () => {
|
||||
clearTimeout(self._clipRefreshTimer);
|
||||
self._clipRefreshTimer = setTimeout(() => self.refreshSidebarTree(), 400);
|
||||
});
|
||||
document.addEventListener('flowdeck:workspace-changed', () => {
|
||||
clearTimeout(self._clipRefreshTimer);
|
||||
self._clipRefreshTimer = setTimeout(() => self.refreshSidebarTree(), 400);
|
||||
});
|
||||
// Also refresh when tab becomes visible (user returns to FlowDeck after clipping)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (!document.hidden) self.refreshSidebarTree();
|
||||
});
|
||||
window.addEventListener('focus', () => self.refreshSidebarTree());
|
||||
// Polling fallback every 15s while tab is visible (covers extension clip without message)
|
||||
try {
|
||||
self._clipRefreshInterval = setInterval(() => {
|
||||
if (!document.hidden) self.refreshSidebarTree();
|
||||
}, 15000);
|
||||
} catch (_e) {}
|
||||
// BroadcastChannel cross-tab sync (all FlowDeck tabs refresh together)
|
||||
try {
|
||||
if (window.BroadcastChannel) {
|
||||
const bc = new BroadcastChannel('flowdeck');
|
||||
bc.onmessage = (ev) => {
|
||||
if (ev.data && ev.data.type === 'clip-created') self.refreshSidebarTree();
|
||||
};
|
||||
window._fdBC = bc;
|
||||
// Bridge DOM event → BroadcastChannel for extension-triggered clips
|
||||
window.addEventListener('flowdeck:clip-created', () => {
|
||||
try { bc.postMessage({ type: 'clip-created', at: Date.now() }); } catch (_e) {}
|
||||
});
|
||||
}
|
||||
} catch (_e) {}
|
||||
},
|
||||
agentList: [],
|
||||
loadAgents() {
|
||||
@@ -968,7 +1158,14 @@
|
||||
else { document.dispatchEvent(new CustomEvent('fd-agent-toggle')); }
|
||||
},
|
||||
sidebarCollapsed: false,
|
||||
sidebarPeek: false,
|
||||
mobileSidebarOpen: false,
|
||||
sidebarWidth: 240,
|
||||
sidebarResizing: false,
|
||||
sidebarResizeHover: false,
|
||||
sidebarResizeMoved: false,
|
||||
sidebarResizeStartX: 0,
|
||||
sidebarResizeStartW: 0,
|
||||
showInvite: true,
|
||||
// -- Toast notifications --
|
||||
toasts: [],
|
||||
@@ -1056,6 +1253,7 @@
|
||||
body: JSON.stringify({ name: newName.trim() })
|
||||
}).then(function(r) {
|
||||
if (r.ok) {
|
||||
if (window.FlowDeck && window.FlowDeck.syncPageTitle) window.FlowDeck.syncPageTitle(id, newName.trim());
|
||||
self.refreshSidebarTree();
|
||||
document.dispatchEvent(new CustomEvent('flowdeck:workspace-changed'));
|
||||
if (window._wsData && window._wsData._reloadAfterAction) {
|
||||
@@ -1090,10 +1288,7 @@
|
||||
} else {
|
||||
favList.innerHTML = items.map(function(item) {
|
||||
var iconName = item.icon || 'file';
|
||||
// Map known icon names, anything else (including emojis) → file
|
||||
var validNames = ['folder','file','star','link','trash','book','home','settings','lock','globe','image','edit','calendar','users','user','search','tag'];
|
||||
if (validNames.indexOf(iconName) === -1) iconName = 'file';
|
||||
var icon = '<span class="page-icon-svg">'+getSvgIcon(iconName,14)+'</span>';
|
||||
var icon = '<span class="page-icon-svg">' + (window.fdIconHtml ? window.fdIconHtml(iconName, 14) : getSvgIcon('file',14)) + '</span>';
|
||||
const name = (item.title || 'Untitled');
|
||||
const safeName = name.replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
const url = item.url || ('/pages/' + item.id);
|
||||
@@ -1247,11 +1442,6 @@
|
||||
toggleWorkspaceMenu() {
|
||||
this.workspaceMenuOpen = !this.workspaceMenuOpen;
|
||||
},
|
||||
navigateTo(url) {
|
||||
this.mobileSidebarOpen = false;
|
||||
this.sidebarCollapsed = true;
|
||||
window.location.href = url;
|
||||
},
|
||||
openQuickFind() { if (window.FlowDeckPalette) window.FlowDeckPalette.open(); },
|
||||
|
||||
// ── Tree state ──
|
||||
@@ -1320,12 +1510,76 @@
|
||||
},
|
||||
|
||||
navigateTo(url) {
|
||||
this.mobileSidebarOpen = false;
|
||||
this.sidebarPeek = false;
|
||||
// Preserve workspace context when navigating to library/trash
|
||||
if ((url === '/library' || url === '/trash') && this.workspaceKey && this.workspaceKey.includes('/')) {
|
||||
const parts = this.workspaceKey.split('/');
|
||||
url = `${url}?owner=${parts[0]}&repo=${parts.slice(1).join('/')}`;
|
||||
}
|
||||
window.location.href = url;
|
||||
if (window.fdNavigate) window.fdNavigate(url);
|
||||
else window.location.href = url;
|
||||
},
|
||||
|
||||
toggleSidebar() {
|
||||
this.sidebarPeek = false;
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed;
|
||||
},
|
||||
|
||||
closeSidebar() {
|
||||
if (this.sidebarPeek) { this.sidebarPeek = false; return; }
|
||||
this.sidebarCollapsed = true;
|
||||
},
|
||||
|
||||
// ── Sidebar width: persisted, applies to fixed + floating ──
|
||||
initSidebarWidth() {
|
||||
var saved = parseInt(localStorage.getItem('fd.sidebarWidth') || '', 10);
|
||||
if (!isNaN(saved) && saved >= 200 && saved <= 560) {
|
||||
this.sidebarWidth = saved;
|
||||
this.applySidebarWidth();
|
||||
} else {
|
||||
var el = document.getElementById('sidebar');
|
||||
if (el) {
|
||||
var w = el.getBoundingClientRect().width;
|
||||
if (w > 0) { this.sidebarWidth = Math.round(w); }
|
||||
}
|
||||
}
|
||||
},
|
||||
applySidebarWidth() {
|
||||
document.documentElement.style.setProperty('--sidebar-width', this.sidebarWidth + 'px');
|
||||
},
|
||||
startSidebarResize(e) {
|
||||
e.preventDefault();
|
||||
var self = this;
|
||||
this.sidebarResizing = true;
|
||||
this.sidebarResizeMoved = false;
|
||||
this.sidebarResizeStartX = e.clientX;
|
||||
var el = document.getElementById('sidebar');
|
||||
this.sidebarResizeStartW = el ? el.getBoundingClientRect().width : this.sidebarWidth;
|
||||
document.body.classList.add('fd-sidebar-resizing');
|
||||
|
||||
function onMove(ev) {
|
||||
var dx = ev.clientX - self.sidebarResizeStartX;
|
||||
if (Math.abs(dx) > 3) { self.sidebarResizeMoved = true; }
|
||||
var w = Math.max(200, Math.min(560, self.sidebarResizeStartW + dx));
|
||||
self.sidebarWidth = w;
|
||||
self.applySidebarWidth();
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener('pointermove', onMove);
|
||||
document.removeEventListener('pointerup', onUp);
|
||||
document.removeEventListener('pointercancel', onUp);
|
||||
document.body.classList.remove('fd-sidebar-resizing');
|
||||
self.sidebarResizing = false;
|
||||
if (self.sidebarResizeMoved) {
|
||||
try { localStorage.setItem('fd.sidebarWidth', String(Math.round(self.sidebarWidth))); } catch (err) {}
|
||||
} else {
|
||||
self.closeSidebar();
|
||||
}
|
||||
}
|
||||
document.addEventListener('pointermove', onMove);
|
||||
document.addEventListener('pointerup', onUp);
|
||||
document.addEventListener('pointercancel', onUp);
|
||||
},
|
||||
|
||||
showContextMenu(event, pageId, pageName) {
|
||||
@@ -1661,7 +1915,8 @@
|
||||
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
|
||||
},
|
||||
openWorkspacePage(pageId) {
|
||||
window.location = `/pages/${pageId}`;
|
||||
if (window.fdNavigate) window.fdNavigate('/pages/' + pageId);
|
||||
else window.location = `/pages/${pageId}`;
|
||||
},
|
||||
|
||||
// ── Tree expand/collapse + folder navigation ──
|
||||
@@ -1681,7 +1936,8 @@
|
||||
localStorage.setItem('fd_expanded_folders', JSON.stringify(this.expandedFolders));
|
||||
localStorage.setItem('fd_sections', JSON.stringify(this.sectionsOpen));
|
||||
} catch(e) {}
|
||||
window.location = `/local-workspace?folder=${id}`;
|
||||
if (window.fdNavigate) window.fdNavigate(`/local-workspace?folder=${id}`);
|
||||
else window.location = `/local-workspace?folder=${id}`;
|
||||
},
|
||||
|
||||
createEmptyPage() {
|
||||
@@ -1833,8 +2089,7 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="/static/js/app.js?v=2.4.6" defer></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
<script src="/static/js/app.js?v=2.4.8" defer data-cfasync="false"></script>
|
||||
|
||||
<style>
|
||||
.flowdeck-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:2000;display:flex;align-items:center;justify-content:center;}
|
||||
@@ -2022,11 +2277,24 @@
|
||||
input.blur();
|
||||
}
|
||||
function toggle(){ state.open ? close() : open(); }
|
||||
function setIndex(i){ if(i>=0 && i<state.items.length){ state.index=i; render(); } }
|
||||
function setIndex(i){
|
||||
if(i>=0 && i<state.items.length) state.index=i;
|
||||
// Toggle the active class without rebuilding #fd-cp-list: re-rendering on
|
||||
// hover destroys the element under the cursor and breaks the next click.
|
||||
var nodes = list.querySelectorAll('.cmd-palette-item');
|
||||
for(var j=0;j<nodes.length;j++){
|
||||
if(parseInt(nodes[j].getAttribute('data-idx'),10)===state.index){
|
||||
nodes[j].classList.add('active');
|
||||
nodes[j].scrollIntoView({block:'nearest'});
|
||||
} else {
|
||||
nodes[j].classList.remove('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
function choose(i){
|
||||
var it = state.items[i]; if(!it) return;
|
||||
if(it.run){ var ok = it.run(); if(ok) close(); return; }
|
||||
if(it.url){ window.location.href=it.url; close(); return; }
|
||||
if(it.url){ if(window.fdNavigate) window.fdNavigate(it.url); else window.location.href=it.url; close(); return; }
|
||||
}
|
||||
|
||||
function onKey(e){
|
||||
@@ -2054,5 +2322,55 @@
|
||||
})();
|
||||
</script>
|
||||
|
||||
{# ─── PWA: sync indicators (v6.0.0) ─── #}
|
||||
<style>
|
||||
.fd-sync-badge{position:fixed;top:10px;right:14px;z-index:600;display:flex;align-items:center;gap:8px;
|
||||
background:var(--bg-secondary,#262626);border:1px solid var(--border,rgba(255,255,255,.1));
|
||||
color:var(--text-secondary,#b8b8b8);border-radius:20px;padding:5px 12px;font-size:12px;
|
||||
box-shadow:0 4px 16px rgba(0,0,0,.35);}
|
||||
.fd-sync-spinner{width:12px;height:12px;border:2px solid rgba(255,255,255,.25);
|
||||
border-top-color:var(--accent,#2383E2);border-radius:50%;display:inline-block;
|
||||
animation:fd-spin .8s linear infinite;}
|
||||
@keyframes fd-spin{to{transform:rotate(360deg)}}
|
||||
.sidebar-item.fd-dirty .page-name::after{content:'⟳';margin-left:6px;font-size:11px;
|
||||
color:var(--accent,#2383E2);opacity:.85;}
|
||||
</style>
|
||||
|
||||
{# ─── PWA: offline client module + service worker registration (v6.0.0) ─── #}
|
||||
<script src="/static/js/offline.js?v=6.0.0" defer data-cfasync="false"></script>
|
||||
<script data-cfasync="false">
|
||||
(function() {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
window.addEventListener('load', function() {
|
||||
navigator.serviceWorker.register('/sw.js').then(function(reg) {
|
||||
if ('sync' in reg && reg.sync) {
|
||||
reg.sync.register('sync-flowdeck').catch(function() {});
|
||||
}
|
||||
}).catch(function() {});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
{# ─── PWA: sync badge + toasts wiring (v6.0.0) ─── #}
|
||||
<script data-cfasync="false">
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
if (!window.FlowOffline) return;
|
||||
window.FlowOffline.onChange(function(s) {
|
||||
var badge = document.getElementById('fd-sync-badge');
|
||||
if (badge) badge.style.display = s.isSyncing ? 'flex' : 'none';
|
||||
});
|
||||
window.addEventListener('flowdeck:sync-done', function(ev) {
|
||||
var d = ev.detail || {};
|
||||
if (d.failed) {
|
||||
if (window.showToast) window.showToast(d.failed + ' modification(s) non synchronisée(s)', 'error');
|
||||
} else if (d.conflicts) {
|
||||
if (window.showToast) window.showToast('Synchronisé — ' + d.conflicts + ' conflit(s) résolu(s)', 'info');
|
||||
} else if (d.synced) {
|
||||
if (window.showToast) window.showToast('Modifications synchronisées', 'success');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -151,10 +151,10 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const owner = '{{ owner }}';
|
||||
const repo = '{{ repo }}';
|
||||
const initialView = '{{ initial_view }}';
|
||||
<script data-cfasync="false">
|
||||
var owner = '{{ owner }}';
|
||||
var repo = '{{ repo }}';
|
||||
var initialView = '{{ initial_view }}';
|
||||
|
||||
// Auto-switch to view from URL param
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
@@ -35,7 +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>
|
||||
<script data-cfasync="false">
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('giteaWorkspace', () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Importer — FlowDeck</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
|
||||
<style>
|
||||
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;--warn:#D9730D;}
|
||||
*{margin:0;padding:0;box-sizing:border-box;}
|
||||
body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;padding:32px 20px;}
|
||||
.wrap{width:860px;max-width:96vw;margin:0 auto;}
|
||||
.brand{font-size:15px;font-weight:700;margin-bottom:22px;display:flex;align-items:center;gap:8px;color:var(--dim);}
|
||||
.brand a{color:var(--dim);text-decoration:none;}
|
||||
.brand a:hover{color:var(--text);}
|
||||
h1{font-size:24px;font-weight:700;margin-bottom:6px;}
|
||||
.sub{color:var(--dim);font-size:14px;margin-bottom:24px;line-height:1.5;}
|
||||
.panel{background:var(--bg2);border:1px solid var(--border);border-radius:14px;padding:22px;margin-bottom:18px;}
|
||||
.label{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--dim);margin-bottom:10px;}
|
||||
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px;}
|
||||
.src{background:var(--bg3);border:1px solid var(--border);border-radius:10px;padding:12px;cursor:pointer;transition:border-color .15s;}
|
||||
.src:hover{border-color:var(--accent);}
|
||||
.src.selected{border-color:var(--accent);box-shadow:0 0 0 2px rgba(35,131,226,.25);}
|
||||
.src .t{font-size:13px;font-weight:600;}
|
||||
.src .d{font-size:11px;color:var(--dim);margin-top:3px;line-height:1.35;}
|
||||
.drop{border:2px dashed var(--border);border-radius:12px;padding:26px;text-align:center;color:var(--dim);cursor:pointer;transition:border-color .15s,background .15s;}
|
||||
.drop:hover,.drop.over{border-color:var(--accent);background:rgba(35,131,226,.06);color:var(--text);}
|
||||
.drop strong{color:var(--text);}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:10px 18px;border:none;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;transition:background .15s;}
|
||||
.btn-primary{background:var(--accent);color:#fff;}
|
||||
.btn-primary:hover{background:var(--accent-h);}
|
||||
.btn-ghost{background:transparent;color:var(--dim);border:1px solid var(--border);}
|
||||
.btn-ghost:hover{color:var(--text);}
|
||||
.btn:disabled{opacity:.5;cursor:not-allowed;}
|
||||
.actions{display:flex;gap:10px;align-items:center;margin-top:16px;flex-wrap:wrap;}
|
||||
.check{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--dim);margin-top:12px;cursor:pointer;}
|
||||
.bar{height:6px;background:var(--bg3);border-radius:3px;overflow:hidden;margin-top:16px;}
|
||||
.bar>i{display:block;height:100%;background:var(--accent);width:0;transition:width .3s;}
|
||||
table{width:100%;border-collapse:collapse;font-size:13px;}
|
||||
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--border);}
|
||||
th{color:var(--dim);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.4px;}
|
||||
.pill{font-size:11px;font-weight:600;padding:2px 8px;border-radius:99px;background:var(--bg3);color:var(--dim);}
|
||||
.pill.coll{background:rgba(35,131,226,.16);color:#79b8ff;}
|
||||
.pill.ok{background:rgba(15,123,108,.2);color:#4fd1c5;}
|
||||
.pill.err{background:rgba(224,62,62,.18);color:#f28b82;}
|
||||
.pill.run{background:rgba(217,115,13,.18);color:#e3a15b;}
|
||||
select,input[type=text]{background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-size:13px;}
|
||||
.warnings{font-size:12px;color:var(--warn);margin-top:10px;line-height:1.5;}
|
||||
.report{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:10px;}
|
||||
.stat{background:var(--bg3);border-radius:10px;padding:12px;text-align:center;}
|
||||
.stat .n{font-size:22px;font-weight:700;}
|
||||
.stat .l{font-size:11px;color:var(--dim);margin-top:2px;}
|
||||
.error{color:var(--danger);font-size:13px;margin-top:10px;}
|
||||
.x-cloak,[x-cloak]{display:none!important;}
|
||||
</style>
|
||||
</head>
|
||||
<body x-data="importWizard()" x-init="init()">
|
||||
<div class="wrap">
|
||||
<div class="brand">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#2383E2" stroke-width="2"><path d="M13 2L3 14h7l-1 8 10-12h-7l1-8z"/></svg>
|
||||
<a href="/">FlowDeck</a> · Import
|
||||
</div>
|
||||
<h1>Importer des données</h1>
|
||||
<p class="sub">Notes, vaults, bases de données, documents ou signets — choisissez une source ou laissez la détection automatique.</p>
|
||||
|
||||
<div class="panel">
|
||||
<div class="label">1 · Source</div>
|
||||
<div class="grid">
|
||||
<div class="src" :class="{selected: sourceId===''}" @click="sourceId=''">
|
||||
<div class="t">✨ Détection auto</div>
|
||||
<div class="d">Reconnaît le format d'après le fichier.</div>
|
||||
</div>
|
||||
<template x-for="s in sources" :key="s.source_id">
|
||||
<div class="src" :class="{selected: sourceId===s.source_id}" @click="sourceId=s.source_id">
|
||||
<div class="t" x-text="s.label"></div>
|
||||
<div class="d" x-text="s.description"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="label">2 · Fichiers <span style="text-transform:none;font-weight:400">(sélection multiple possible)</span></div>
|
||||
<div class="drop" :class="{over: dragOver}"
|
||||
@click="$refs.input.click()"
|
||||
@dragover.prevent="dragOver=true" @dragleave.prevent="dragOver=false"
|
||||
@drop.prevent="onDrop($event)">
|
||||
<span>Glissez un ou plusieurs fichiers ici ou <strong>cliquez pour parcourir</strong><br><small>Markdown, .zip, .csv, .tsv, .xlsx, .json, .html, .docx, .pdf, .ics, .opml…</small></span>
|
||||
</div>
|
||||
<input x-ref="input" type="file" multiple style="display:none"
|
||||
accept=".md,.markdown,.txt,.zip,.csv,.tsv,.xlsx,.xlsm,.json,.html,.htm,.docx,.docm,.pdf,.ics,.ical,.opml"
|
||||
@change="onFiles($event)">
|
||||
<template x-if="files.length">
|
||||
<table style="margin-top:12px;">
|
||||
<thead><tr><th>Fichier</th><th>Taille</th><th>Statut</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="(f,i) in files" :key="i">
|
||||
<tr>
|
||||
<td x-text="f.name"></td>
|
||||
<td x-text="humanSize(f.size)"></td>
|
||||
<td><span class="pill" :class="f.status==='done'?'ok':(f.status==='error'?'err':(f.status==='running'?'run':''))" x-text="f.status"></span></td>
|
||||
<td><button class="btn btn-ghost" style="padding:2px 8px" @click="files.splice(i,1)" :disabled="busy">✕</button></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin-top:12px;">
|
||||
<label class="check" style="margin:0">Mode de ré-import :
|
||||
<select x-model="mode">
|
||||
<option value="skip">Ignorer l'existant</option>
|
||||
<option value="update">Mettre à jour (re-sync)</option>
|
||||
<option value="duplicate">Toujours dupliquer</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-ghost" @click="doPreview()" :disabled="!files.length || busy">Aperçu (dry-run)</button>
|
||||
<button class="btn btn-primary" @click="doImport()" :disabled="!files.length || busy">Importer</button>
|
||||
</div>
|
||||
<div class="bar" x-show="busy || progress>0"><i :style="'width:'+progress+'%'"></i></div>
|
||||
<div class="error" x-show="error" x-text="error"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="label">Ou · importer une page web (URL)</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<input type="text" style="flex:1;min-width:260px;" x-model="urlValue" placeholder="https://exemple.com/article" @keydown.enter="doUrl()">
|
||||
<button class="btn btn-ghost" @click="doUrl()" :disabled="!urlValue || busy">Importer l'URL</button>
|
||||
</div>
|
||||
<div class="error" x-show="urlError" x-text="urlError"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="label">Ou · importer depuis une forge</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
|
||||
<select x-model="forgeProvider">
|
||||
<option value="gitea">Gitea</option>
|
||||
<option value="github">GitHub</option>
|
||||
</select>
|
||||
<input type="text" style="width:150px;" x-model="forgeOwner" placeholder="owner">
|
||||
<input type="text" style="width:150px;" x-model="forgeRepo" placeholder="repo">
|
||||
<select x-model="forgeState">
|
||||
<option value="all">Toutes</option>
|
||||
<option value="open">Ouvertes</option>
|
||||
<option value="closed">Fermées</option>
|
||||
</select>
|
||||
<button class="btn btn-ghost" @click="doForge('issues')" :disabled="!forgeOwner || !forgeRepo || busy">Importer les issues</button>
|
||||
<button class="btn btn-ghost" @click="doForge('repo')" :disabled="!forgeOwner || !forgeRepo || busy">Importer les fichiers</button>
|
||||
</div>
|
||||
<div class="error" x-show="forgeError" x-text="forgeError"></div>
|
||||
</div>
|
||||
|
||||
<div class="panel" x-show="preview" x-cloak>
|
||||
<div class="label">Aperçu — <span x-text="preview && preview.source_label"></span></div>
|
||||
<table>
|
||||
<thead><tr><th>Titre</th><th>Type</th><th>Éléments</th><th>Colonnes</th></tr></thead>
|
||||
<tbody>
|
||||
<template x-for="(p,i) in (preview ? preview.pages : [])" :key="i">
|
||||
<tr>
|
||||
<td x-text="p.title"></td>
|
||||
<td><span class="pill" :class="{coll: p.type==='collection'}" x-text="p.type==='collection' ? 'database' : 'page'"></span></td>
|
||||
<td x-text="p.type==='collection' ? (p.rows+' lignes') : (p.blocks+' blocs')"></td>
|
||||
<td>
|
||||
<template x-for="col in (p.schema||[])" :key="col.name">
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;margin:2px 6px 2px 0;">
|
||||
<span x-text="col.name" style="font-size:12px;"></span>
|
||||
<select @change="mapping[col.name]=$event.target.value">
|
||||
<template x-for="t in types" :key="t">
|
||||
<option :value="t" :selected="t===col.type" x-text="t"></option>
|
||||
</template>
|
||||
</select>
|
||||
</span>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="warnings" x-show="preview && preview.warnings && preview.warnings.length">
|
||||
<template x-for="(w,i) in (preview ? preview.warnings : [])" :key="i"><div x-text="'⚠ '+w"></div></template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel" x-show="report" x-cloak>
|
||||
<div class="label">Rapport d'import</div>
|
||||
<div class="report">
|
||||
<div class="stat"><div class="n" x-text="report ? report.pages_created : 0"></div><div class="l">Pages créées</div></div>
|
||||
<div class="stat"><div class="n" x-text="report ? report.pages_updated : 0"></div><div class="l">Mises à jour</div></div>
|
||||
<div class="stat"><div class="n" x-text="report ? report.collections_created : 0"></div><div class="l">Collections</div></div>
|
||||
<div class="stat"><div class="n" x-text="report ? report.rows_created : 0"></div><div class="l">Lignes</div></div>
|
||||
<div class="stat"><div class="n" x-text="report ? report.attachments : 0"></div><div class="l">Fichiers</div></div>
|
||||
<div class="stat"><div class="n" x-text="report ? report.skipped : 0"></div><div class="l">Ignorés</div></div>
|
||||
<div class="stat"><div class="n" x-text="report ? (report.errors ? report.errors.length : 0) : 0"></div><div class="l">Erreurs</div></div>
|
||||
</div>
|
||||
<div class="warnings" x-show="report && report.warnings && report.warnings.length">
|
||||
<template x-for="(w,i) in (report ? report.warnings : [])" :key="i"><div x-text="'⚠ '+w"></div></template>
|
||||
</div>
|
||||
<div class="warnings" x-show="report && report.errors && report.errors.length">
|
||||
<template x-for="(e,i) in (report ? report.errors : [])" :key="i"><div x-text="'✕ '+e.title+' : '+e.error"></div></template>
|
||||
</div>
|
||||
<div class="warnings" x-show="report && report.relations && report.relations.relations_resolved">
|
||||
<div x-text="'🔗 '+report.relations.relations_resolved+' relation(s) reconstruite(s)'"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-ghost" @click="downloadReport()">Télécharger le rapport (JSON)</button>
|
||||
<a class="btn btn-primary" href="/workspaces">Retour au workspace</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script data-cfasync="false">
|
||||
function importWizard() {
|
||||
return {
|
||||
sources: [],
|
||||
sourceId: '',
|
||||
files: [],
|
||||
dragOver: false,
|
||||
mode: 'skip',
|
||||
busy: false,
|
||||
progress: 0,
|
||||
error: '',
|
||||
preview: null,
|
||||
report: null,
|
||||
mapping: {},
|
||||
csrf: '',
|
||||
urlValue: '',
|
||||
urlError: '',
|
||||
forgeProvider: 'gitea',
|
||||
forgeOwner: '',
|
||||
forgeRepo: '',
|
||||
forgeState: 'all',
|
||||
forgeError: '',
|
||||
types: ['text','number','date','checkbox','email','url','phone','select','multi_select','status'],
|
||||
async init() {
|
||||
try {
|
||||
const r = await fetch('/api/import/sources');
|
||||
this.sources = (await r.json()).sources || [];
|
||||
} catch(e) {}
|
||||
try {
|
||||
const c = await fetch('/api/csrf-token');
|
||||
this.csrf = (await c.json()).csrf_token;
|
||||
} catch(e) {}
|
||||
},
|
||||
humanSize(n) { return n > 1048576 ? (n/1048576).toFixed(1)+' Mo' : Math.max(1, Math.round(n/1024))+' Ko'; },
|
||||
addFiles(list) {
|
||||
for (const f of list) this.files.push({file:f, name:f.name, size:f.size, status:'queued'});
|
||||
this.preview = null; this.report = null; this.error = '';
|
||||
},
|
||||
onFiles(e) { this.addFiles(e.target.files); e.target.value=''; },
|
||||
onDrop(e) { this.dragOver=false; this.addFiles(e.dataTransfer.files); },
|
||||
buildForm(f) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f.file);
|
||||
if (this.sourceId) fd.append('source', this.sourceId);
|
||||
fd.append('mode', this.mode);
|
||||
return fd;
|
||||
},
|
||||
async doPreview() {
|
||||
this.busy = true; this.error=''; this.report=null; this.progress=10;
|
||||
try {
|
||||
let merged = null;
|
||||
for (let i=0;i<this.files.length;i++) {
|
||||
const r = await fetch('/api/import/preview', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:this.buildForm(this.files[i])});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { this.error = (this.files[i].name+': '+(d.detail||'Erreur')); continue; }
|
||||
if (!merged) merged = {source_label:d.source_label, pages:[], warnings:[], stats:{}};
|
||||
merged.pages.push(...(d.pages||[]));
|
||||
merged.warnings.push(...(d.warnings||[]));
|
||||
this.progress = Math.round(((i+1)/this.files.length)*100);
|
||||
}
|
||||
this.preview = merged;
|
||||
this.mapping = {};
|
||||
(merged ? merged.pages : []).forEach(p => (p.schema||[]).forEach(c => { this.mapping[c.name]=c.type; }));
|
||||
} catch(e) { this.error = 'Erreur réseau'; }
|
||||
finally { this.busy = false; setTimeout(()=>{this.progress=0;},800); }
|
||||
},
|
||||
async importOne(f) {
|
||||
f.status = 'running';
|
||||
const fd = this.buildForm(f);
|
||||
if (Object.keys(this.mapping).length) fd.append('mapping', JSON.stringify(this.mapping));
|
||||
const big = f.size > 5*1024*1024;
|
||||
try {
|
||||
if (big) {
|
||||
fd.append('async','true');
|
||||
const r = await fetch('/api/import/run', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:fd});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; }
|
||||
return await this.pollJob(d.job_id, f);
|
||||
}
|
||||
const r = await fetch('/api/import/run', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:fd});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; }
|
||||
f.status = (d.status==='partial' ? 'partial' : 'done');
|
||||
return d;
|
||||
} catch(e) { f.status='error'; f.error='Erreur réseau'; return null; }
|
||||
},
|
||||
async doImport() {
|
||||
this.busy = true; this.error=''; this.report=null; this.progress=0;
|
||||
const aggregate = {pages_created:0,pages_updated:0,collections_created:0,rows_created:0,
|
||||
attachments:0,skipped:0,warnings:[],errors:[],per_file:[]};
|
||||
for (let i=0;i<this.files.length;i++) {
|
||||
const d = await this.importOne(this.files[i]);
|
||||
if (d) {
|
||||
for (const k of ['pages_created','pages_updated','collections_created','rows_created','attachments','skipped'])
|
||||
aggregate[k] += (d[k]||0);
|
||||
aggregate.warnings.push(...(d.warnings||[]));
|
||||
aggregate.errors.push(...(d.errors||[]));
|
||||
aggregate.per_file.push({filename:this.files[i].name, report:d});
|
||||
} else {
|
||||
aggregate.errors.push({title:this.files[i].name, error:this.files[i].error||'Erreur'});
|
||||
aggregate.per_file.push({filename:this.files[i].name, report:{status:'error'}});
|
||||
}
|
||||
this.progress = Math.round(((i+1)/this.files.length)*100);
|
||||
}
|
||||
this.report = aggregate;
|
||||
this.busy = false;
|
||||
},
|
||||
async doUrl() {
|
||||
this.busy = true; this.urlError = ''; this.report = null; this.progress = 40;
|
||||
try {
|
||||
const r = await fetch('/api/import/url', {
|
||||
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
|
||||
body: JSON.stringify({url:this.urlValue.trim()})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { this.urlError = d.detail || 'Erreur'; return; }
|
||||
this.report = d; this.progress = 100;
|
||||
} catch(e) { this.urlError = 'Erreur réseau'; }
|
||||
finally { this.busy = false; }
|
||||
},
|
||||
async doForge(kind) {
|
||||
this.busy = true; this.forgeError = ''; this.report = null; this.progress = 30;
|
||||
const endpoint = kind === 'repo' ? '/api/import/forge-repo' : '/api/import/forge';
|
||||
try {
|
||||
const r = await fetch(endpoint, {
|
||||
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
|
||||
body: JSON.stringify({
|
||||
provider:this.forgeProvider, owner:this.forgeOwner.trim(),
|
||||
repo:this.forgeRepo.trim(), state:this.forgeState
|
||||
})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) { this.forgeError = d.detail || 'Erreur'; return; }
|
||||
this.report = d; this.progress = 100;
|
||||
} catch(e) { this.forgeError = 'Erreur réseau'; }
|
||||
finally { this.busy = false; }
|
||||
},
|
||||
async pollJob(jobId, f) {
|
||||
for (let i=0;i<600;i++) {
|
||||
await new Promise(res => setTimeout(res, 700));
|
||||
const r = await fetch('/api/import/jobs/'+jobId);
|
||||
const j = await r.json();
|
||||
if (j.status === 'done') { if (f) f.status='done'; return j.report; }
|
||||
if (j.status === 'error') { if (f) { f.status='error'; f.error=j.error; } return null; }
|
||||
}
|
||||
if (f) { f.status='error'; f.error='Délai dépassé'; }
|
||||
return null;
|
||||
},
|
||||
downloadReport() {
|
||||
const blob = new Blob([JSON.stringify(this.report, null, 2)], {type:'application/json'});
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = 'flowdeck-import-report.json';
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,6 +6,11 @@
|
||||
{% from '_icons.html' import fd_icon %}
|
||||
<title>FlowDeck — All-in-one workspace</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<meta name="theme-color" content="#191919">
|
||||
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #191919;
|
||||
@@ -201,5 +206,14 @@
|
||||
FlowDeck v4.0.1 — Open source · Self-hosted · Notion-compatible
|
||||
</footer>
|
||||
|
||||
<script data-cfasync="false">
|
||||
(function() {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
window.addEventListener('load', function() {
|
||||
navigator.serviceWorker.register('/sw.js').catch(function() {});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+48
-10
@@ -446,7 +446,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="peek-body" style="flex:1;display:flex;flex-direction:column;">
|
||||
<iframe id="lib-peek-iframe" src="" style="width:100%;height:100%;border:none;flex:1;"></iframe>
|
||||
<iframe id="lib-peek-iframe" :src="peekItem ? '/pages/' + peekItem.id + '?embed=1' : ''" style="width:100%;height:100%;border:none;flex:1;"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -619,8 +619,8 @@ function libraryPage() {
|
||||
_escAttr(s) { return String(s||'').replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); },
|
||||
_renderIcon(item) {
|
||||
if (!item) return getSvgIcon('file', 14);
|
||||
// Custom emoji / non-ASCII icon (e.g. 📝) — render as-is
|
||||
if (item.page_icon) return '<span style="font-size:14px;line-height:1;">' + this._escHtml(item.page_icon) + '</span>';
|
||||
// Custom emoji (image URL), icon name, or unicode emoji
|
||||
if (item.page_icon) return '<span style="font-size:14px;line-height:1;display:inline-flex;align-items:center;">' + (window.fdIconHtml ? window.fdIconHtml(item.page_icon, 14) : this._escHtml(item.page_icon)) + '</span>';
|
||||
if (item.is_folder || item.has_children) return getSvgIcon('folder', 14);
|
||||
if (item.content_format && item.content_format !== 'file') return getSvgIcon('edit', 14);
|
||||
var n = (item.title || '').toLowerCase();
|
||||
@@ -718,6 +718,8 @@ function libraryPage() {
|
||||
setIcon: function (icon) { self.setItemIcon(item, icon); },
|
||||
duplicate: function (n) { self.duplicateItem(n); },
|
||||
link: function (n) { self.copyLink(n); },
|
||||
download: function (n) { self.downloadItem(n); },
|
||||
copyContent: function (n) { self.copyItemContent(n); },
|
||||
move: function (n) { self.openMovePicker([n.id]); },
|
||||
fav: function (n) { self.toggleFavoriteItem(n); },
|
||||
delete: function (n) { self.deleteItem(n); },
|
||||
@@ -731,6 +733,34 @@ function libraryPage() {
|
||||
store.openMenu(evt, item, 'library', handlers);
|
||||
},
|
||||
|
||||
downloadItem(node) {
|
||||
if (!node || node.is_folder) return;
|
||||
var url = '/api/pages/' + node.id + '/download';
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
if (window.showToast) window.showToast('Download started', 'success');
|
||||
},
|
||||
|
||||
async copyItemContent(node) {
|
||||
if (!node || node.is_folder) return;
|
||||
try {
|
||||
var r = await fetch('/api/pages/' + node.id + '/file-content');
|
||||
var d = await r.json();
|
||||
if (d.ok && d.content !== undefined) {
|
||||
await navigator.clipboard.writeText(d.content);
|
||||
if (window.showToast) window.showToast('Content copied to clipboard', 'success');
|
||||
} else {
|
||||
if (window.showToast) window.showToast('Not a text file', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
if (window.showToast) window.showToast('Copy failed', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// ── Drag & drop (move / reparent) ──
|
||||
onRowDragStart(evt, id) {
|
||||
if (!evt.target.closest('.drag-handle')) { evt.preventDefault(); return; }
|
||||
@@ -794,6 +824,11 @@ function libraryPage() {
|
||||
return m ? m[1] : '';
|
||||
},
|
||||
|
||||
_syncSidebar() {
|
||||
if (window.appState && window.appState.refreshSidebarTree) window.appState.refreshSidebarTree();
|
||||
if (window.appState && window.appState.refreshFavorites) window.appState.refreshFavorites();
|
||||
},
|
||||
|
||||
_isDescendant(node, targetId) {
|
||||
if (!node || !node.children) return false;
|
||||
for (var i = 0; i < node.children.length; i++) {
|
||||
@@ -816,6 +851,7 @@ function libraryPage() {
|
||||
}
|
||||
this.clearSelection();
|
||||
this.loadTab();
|
||||
this._syncSidebar();
|
||||
},
|
||||
|
||||
// ── Empty states ──
|
||||
@@ -1058,6 +1094,7 @@ function libraryPage() {
|
||||
}
|
||||
this.clearSelection();
|
||||
this.loadTab();
|
||||
this._syncSidebar();
|
||||
if (window.showToast) window.showToast('Moved to trash', 'success');
|
||||
},
|
||||
|
||||
@@ -1067,6 +1104,7 @@ function libraryPage() {
|
||||
var csrf = this._getCsrf();
|
||||
await fetch('/board/api/pages/' + item.id, {method:'DELETE', headers:{'X-CSRF-Token': csrf}});
|
||||
this.loadTab();
|
||||
this._syncSidebar();
|
||||
if (window.showToast) window.showToast('Moved to trash', 'success');
|
||||
},
|
||||
|
||||
@@ -1111,7 +1149,6 @@ function libraryPage() {
|
||||
async setItemIcon(item, icon) {
|
||||
if (!item || !item.id) return;
|
||||
icon = (icon || '').trim();
|
||||
if (!icon) return;
|
||||
try {
|
||||
var r = await fetch('/board/api/pages/' + item.id + '/icon', {
|
||||
method: 'POST',
|
||||
@@ -1228,6 +1265,7 @@ function libraryPage() {
|
||||
await fetch(updUrl, { method: 'PUT', headers: {'X-CSRF-Token': csrf} });
|
||||
}
|
||||
this.loadTab();
|
||||
this._syncSidebar();
|
||||
if (window.showToast) window.showToast('Page duplicated', 'success');
|
||||
} catch(e) {
|
||||
if (window.showToast) window.showToast('Failed to duplicate page', 'error');
|
||||
@@ -1274,7 +1312,8 @@ function libraryPage() {
|
||||
// ── Item actions ──
|
||||
openItem(item) {
|
||||
if (!item || !item.id) return;
|
||||
window.location.href = '/pages/' + item.id;
|
||||
if (window.fdNavigate) window.fdNavigate('/pages/' + item.id);
|
||||
else window.location.href = '/pages/' + item.id;
|
||||
},
|
||||
|
||||
startRename(item) {
|
||||
@@ -1302,6 +1341,7 @@ function libraryPage() {
|
||||
}
|
||||
} catch(e) {}
|
||||
this._renderTable();
|
||||
this._syncSidebar();
|
||||
},
|
||||
|
||||
// ── Add new ──
|
||||
@@ -1332,7 +1372,8 @@ function libraryPage() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
window.location.href = '/pages/' + d.id;
|
||||
if (window.fdNavigate) window.fdNavigate('/pages/' + d.id);
|
||||
else window.location.href = '/pages/' + d.id;
|
||||
}
|
||||
} catch(e) {
|
||||
if (window.showToast) window.showToast('Failed to create page', 'error');
|
||||
@@ -1345,14 +1386,10 @@ function libraryPage() {
|
||||
async openPeek(item) {
|
||||
this.peekItem = item;
|
||||
this.peekFavorited = item.favorited || false;
|
||||
var iframe = document.getElementById('lib-peek-iframe');
|
||||
if (iframe) iframe.src = '/pages/' + item.id + '?embed=1';
|
||||
},
|
||||
|
||||
closePeek() {
|
||||
this.peekItem = null;
|
||||
var iframe = document.getElementById('lib-peek-iframe');
|
||||
if (iframe) iframe.src = '';
|
||||
},
|
||||
|
||||
async toggleFavoritePeek() {
|
||||
@@ -1396,6 +1433,7 @@ function libraryPage() {
|
||||
await fetch('/board/api/pages/' + this.peekItem.id, {method:'DELETE', headers:{'X-CSRF-Token': csrf}});
|
||||
this.closePeek();
|
||||
this.loadTab();
|
||||
this._syncSidebar();
|
||||
if (window.showToast) window.showToast('Moved to trash', 'success');
|
||||
},
|
||||
|
||||
|
||||
@@ -212,7 +212,8 @@
|
||||
.modified-dot.show{display:block;}
|
||||
|
||||
/* Offline banner */
|
||||
.offline-banner{position:fixed;top:0;left:0;right:0;background:var(--danger);color:#fff;text-align:center;padding:6px 12px;font-size:12px;z-index:500;font-weight:500;}
|
||||
.offline-banner{position:fixed;top:0;left:0;right:0;background:var(--danger);color:#fff;text-align:center;padding:6px 12px;font-size:12px;z-index:500;font-weight:500;display:flex;align-items:center;justify-content:center;gap:12px;}
|
||||
.offline-badge{background:rgba(255,255,255,.22);border-radius:10px;padding:1px 8px;font-size:11px;font-weight:600;}
|
||||
|
||||
/* Pinned indicator */
|
||||
.pin-icon{font-size:10px;color:var(--text-tertiary);cursor:pointer;margin-left:4px;opacity:0;transition:opacity 150ms;}
|
||||
@@ -595,6 +596,7 @@ window._wsInitData = (function() {
|
||||
|
||||
// Online/offline
|
||||
online: navigator.onLine,
|
||||
pendingSyncCount: 0,
|
||||
pinnedIds: (function() {
|
||||
try { return JSON.parse(localStorage.getItem('fd_ws_pinned') || '[]'); }
|
||||
catch(e) { return []; }
|
||||
@@ -648,6 +650,26 @@ window._wsInitData = (function() {
|
||||
var self = this;
|
||||
window.addEventListener('online', function() { self.online = true; });
|
||||
window.addEventListener('offline', function() { self.online = false; });
|
||||
// v6.0.0 PWA: reflect offline queue state from the client module
|
||||
if (window.FlowOffline && window.FlowOffline.onChange) {
|
||||
window.FlowOffline.onChange(function(s) {
|
||||
self.online = s.online;
|
||||
self.pendingSyncCount = s.pendingSyncCount;
|
||||
});
|
||||
}
|
||||
// Side-peek editor posts renames here so the tree/peek title stay in sync.
|
||||
window.addEventListener('message', function(ev) {
|
||||
if (ev.origin !== window.location.origin) return;
|
||||
var m = ev.data;
|
||||
if (!m || m.type !== 'fd-page-renamed' || m.id == null) return;
|
||||
var node = self._findNodeById(self.tree, m.id);
|
||||
if (node) node.name = m.title || 'Untitled';
|
||||
self.displayTree = self.displayTree.slice();
|
||||
var t = document.getElementById('ws-preview-title');
|
||||
if (t && self.previewItem && self.previewItem.id === m.id) t.textContent = m.title || 'Untitled';
|
||||
var side = document.querySelector('.sidebar-item.ws-tree-item[data-id="' + m.id + '"] .page-name');
|
||||
if (side) side.textContent = m.title || 'Untitled';
|
||||
});
|
||||
// Load workspace tags
|
||||
this.loadWorkspaceTags();
|
||||
} catch(e) { console.error('Tree load error:', e); this.loaded = true; }
|
||||
@@ -864,10 +886,11 @@ window._wsInitData = (function() {
|
||||
selectForPreview(node) {
|
||||
var panel = document.getElementById('ws-preview-panel');
|
||||
if (!panel) return;
|
||||
this.previewItem = node;
|
||||
document.getElementById('ws-preview-icon').innerHTML = this._fileIcon(node.name, node.is_folder, node.content_format, node.page_icon);
|
||||
document.getElementById('ws-preview-title').textContent = node.name;
|
||||
var openBtn = document.getElementById('ws-preview-open-btn');
|
||||
openBtn.onclick = function() { window.location = '/pages/' + node.id; };
|
||||
openBtn.onclick = function() { if (window.fdNavigate) window.fdNavigate('/pages/' + node.id); else window.location = '/pages/' + node.id; };
|
||||
var favBtn = document.getElementById('ws-preview-fav-btn');
|
||||
favBtn.onclick = function() { window.showToast('Favorite toggled', 'success'); };
|
||||
var copyBtn = document.getElementById('ws-preview-copy-btn');
|
||||
@@ -1025,6 +1048,8 @@ window._wsInitData = (function() {
|
||||
setIcon: function (icon) { self.setItemIcon(node, icon); },
|
||||
duplicate: function (n) { self.duplicateItem(n); },
|
||||
link: function (n) { self.copyItemLink(n); },
|
||||
download: function (n) { self.downloadItem(n); },
|
||||
copyContent: function (n) { self.copyItemContent(n); },
|
||||
move: function (n) { self._ctxMove(n); },
|
||||
fav: function (n) { self.toggleFavorite(n); },
|
||||
delete: function (n) { self.deleteWithUndo(n); },
|
||||
@@ -1035,6 +1060,34 @@ window._wsInitData = (function() {
|
||||
store.openMenu(ev, node, 'workspace', handlers);
|
||||
},
|
||||
|
||||
downloadItem(node) {
|
||||
if (!node || node.is_folder) return;
|
||||
var url = '/api/pages/' + node.id + '/download';
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.style.display = 'none';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
if (window.showToast) window.showToast('Download started', 'success');
|
||||
},
|
||||
|
||||
async copyItemContent(node) {
|
||||
if (!node || node.is_folder) return;
|
||||
try {
|
||||
var r = await fetch('/api/pages/' + node.id + '/file-content');
|
||||
var d = await r.json();
|
||||
if (d.ok && d.content !== undefined) {
|
||||
await navigator.clipboard.writeText(d.content);
|
||||
if (window.showToast) window.showToast('Content copied to clipboard', 'success');
|
||||
} else {
|
||||
if (window.showToast) window.showToast('Not a text file', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
if (window.showToast) window.showToast('Copy failed', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
_ctxAvailTags(node) {
|
||||
return (this.workspaceTags || []).filter(function(t) {
|
||||
return !((node && node.tags) || []).some(function(nt) { return nt.id === t.id; });
|
||||
@@ -1068,7 +1121,6 @@ window._wsInitData = (function() {
|
||||
async setItemIcon(node, icon) {
|
||||
if (!node || !node.id) return;
|
||||
icon = (icon || '').trim();
|
||||
if (!icon) return;
|
||||
try {
|
||||
var r = await fetch('/board/api/pages/' + node.id + '/icon', {
|
||||
method: 'POST',
|
||||
@@ -1164,6 +1216,7 @@ window._wsInitData = (function() {
|
||||
this._updateNodeTags(this.tree, node.id, d.tag, 'add');
|
||||
this.displayTree = [...this.tree];
|
||||
this.tagsVersion++;
|
||||
this._bumpTagCount(d.tag, 1);
|
||||
await this.loadWorkspaceTags();
|
||||
this._computeFilteredTableItems();
|
||||
this._ctxRefreshAvail();
|
||||
@@ -1219,7 +1272,7 @@ window._wsInitData = (function() {
|
||||
} else {
|
||||
// Click to rename, double-click to open
|
||||
html += '<span class="name file" onclick="event.stopPropagation();window._wsData.startRenderChildRename('+escId+',\''+escName+'\')"'
|
||||
+ ' ondblclick="window.location=\'/pages/'+escId+'\'"'
|
||||
+ ' ondblclick="window.fdNavigate?window.fdNavigate(\'/pages/'+escId+'\'):window.location=\'/pages/'+escId+'\'"'
|
||||
+ ' title="Click to rename, double-click to open"'
|
||||
+ ' onmouseenter="var d=window._wsData;if(d)d.showPreview(event,{id:'+escId+',name:\''+escName+'\',is_folder:false,content_format:\''+(c.content_format||'')+'\'})"'
|
||||
+ ' onmouseleave="var d=window._wsData;if(d)d.hidePreview()">'
|
||||
@@ -1331,10 +1384,10 @@ window._wsInitData = (function() {
|
||||
|
||||
switch(action) {
|
||||
case 'new-file':
|
||||
window.FlowDeck.createPage();
|
||||
window.FlowDeck.createPage(id);
|
||||
break;
|
||||
case 'new-folder':
|
||||
window.FlowDeck.showCreateFolderModal();
|
||||
window.FlowDeck.showCreateFolderModal(id);
|
||||
break;
|
||||
case 'rename':
|
||||
this.startRename({db_id: id, name: name});
|
||||
@@ -1476,6 +1529,20 @@ window._wsInitData = (function() {
|
||||
if (r.ok) {
|
||||
node.name = n; node.db_name = n;
|
||||
this._saveExpandedState();
|
||||
if (window.appState && window.appState.refreshSidebarTree) window.appState.refreshSidebarTree();
|
||||
if (window.FlowDeck && window.FlowDeck.syncPageTitle) window.FlowDeck.syncPageTitle(node.id, n);
|
||||
var self = this;
|
||||
if (this.folderStack && this.folderStack.length) {
|
||||
this.folderStack.forEach(function(f) { if (String(f.id) === String(node.id)) f.name = n; });
|
||||
}
|
||||
if (this.breadcrumb && this.breadcrumb.length) {
|
||||
this.breadcrumb.forEach(function(b) { if (String(b.id) === String(node.id)) b.name = n; });
|
||||
}
|
||||
if (this.previewItem && String(this.previewItem.id) === String(node.id)) {
|
||||
this.previewItem.name = n;
|
||||
var pt = document.getElementById('ws-preview-title');
|
||||
if (pt) pt.textContent = n;
|
||||
}
|
||||
}
|
||||
},
|
||||
cancelRename() { this.renamingId = null; },
|
||||
@@ -1611,11 +1678,12 @@ window._wsInitData = (function() {
|
||||
navigateToPath() {
|
||||
var path = (this.$el.querySelector('.path-input') || {}).value || '';
|
||||
var parts = path.replace(/^\/workspace\/?/, '').split('/').filter(Boolean);
|
||||
if (parts.length === 0) { window.location = '/local-workspace'; return; }
|
||||
if (parts.length === 0) { if (window.fdNavigate) window.fdNavigate('/local-workspace'); else window.location = '/local-workspace'; return; }
|
||||
// Walk tree to find matching folder
|
||||
var targetId = this._findFolderByPath(this.tree, parts, 0);
|
||||
if (targetId) {
|
||||
window.location = '/local-workspace?folder=' + targetId;
|
||||
if (window.fdNavigate) window.fdNavigate('/local-workspace?folder=' + targetId);
|
||||
else window.location = '/local-workspace?folder=' + targetId;
|
||||
} else {
|
||||
window.showToast('Path not found: ' + parts.join('/'), 'error');
|
||||
}
|
||||
@@ -1744,7 +1812,10 @@ window._wsInitData = (function() {
|
||||
|
||||
// ── File type icon ──
|
||||
_fileIcon(name, isFolder, contentFormat, customIcon) {
|
||||
if (customIcon) return '<span class="page-icon-svg" style="font-size:14px;line-height:1;">'+this.esc(customIcon)+'</span>';
|
||||
if (customIcon) {
|
||||
var html = window.fdIconHtml ? window.fdIconHtml(customIcon, 14) : this.esc(customIcon);
|
||||
return '<span class="page-icon-svg" style="font-size:14px;line-height:1;display:inline-flex;align-items:center;">'+html+'</span>';
|
||||
}
|
||||
if (isFolder) return '<span class="page-icon-svg">'+getSvgIcon('folder',14)+'</span>';
|
||||
// FlowDeck internal pages (no extension, blocks/markdown format)
|
||||
if (contentFormat && contentFormat !== 'file') return '<span class="page-icon-svg">'+getSvgIcon('edit',14)+'</span>';
|
||||
@@ -1931,7 +2002,7 @@ window._wsInitData = (function() {
|
||||
return false;
|
||||
},
|
||||
|
||||
openPage(id) { window.location = '/pages/'+id; },
|
||||
openPage(id) { if (window.fdNavigate) window.fdNavigate('/pages/' + id); else window.location = '/pages/' + id; },
|
||||
openSidePeek(id) {
|
||||
var node = this._findNodeById(this.tree, id) || this._findNodeById(this.displayTree, id);
|
||||
if (node) this.selectForPreview(node);
|
||||
@@ -2208,12 +2279,31 @@ window._wsInitData = (function() {
|
||||
// ── Tag management ──
|
||||
async loadWorkspaceTags() {
|
||||
try {
|
||||
var r = await fetch('/api/settings/tags/all');
|
||||
var r = await fetch('/api/local-workspace/tags', { cache: 'no-store' });
|
||||
var d = await r.json();
|
||||
this.workspaceTags = d.tags || [];
|
||||
} catch(e) { this.workspaceTags = []; }
|
||||
},
|
||||
|
||||
// Mise à jour OPTIMISTE du compteur d'un tag dans la barre de tags :
|
||||
// rafraîchit immédiatement la pastille (delta +1/-1) sans attendre le
|
||||
// re-fetch (qui viendra ensuite confirmer/surécrire avec les valeurs serveur).
|
||||
_bumpTagCount(tag, delta) {
|
||||
if (!tag) return;
|
||||
var tags = (this.workspaceTags || []).slice();
|
||||
var i = -1;
|
||||
for (var k = 0; k < tags.length; k++) {
|
||||
if (String(tags[k].id) === String(tag.id)) { i = k; break; }
|
||||
}
|
||||
if (i >= 0) {
|
||||
tags[i] = Object.assign({}, tags[i], { count: Math.max(0, (tags[i].count || 0) + delta) });
|
||||
} else if (delta > 0) {
|
||||
tags.push({ id: tag.id, name: tag.name || '', color: tag.color || '#787774', count: delta });
|
||||
}
|
||||
// Réassigne un nouveau tableau → réactivité Alpine
|
||||
this.workspaceTags = tags;
|
||||
},
|
||||
|
||||
toggleTagFilter(tagName) {
|
||||
this.tagFilter = (this.tagFilter === tagName) ? '' : tagName;
|
||||
this.filterType = '';
|
||||
@@ -2245,6 +2335,7 @@ window._wsInitData = (function() {
|
||||
this._updateNodeTags(this.tree, itemId, d.tag, 'add');
|
||||
this.displayTree = [...this.tree];
|
||||
this.tagsVersion++;
|
||||
this._bumpTagCount(d.tag, 1);
|
||||
await this.loadWorkspaceTags();
|
||||
this._computeFilteredTableItems();
|
||||
}
|
||||
@@ -2261,6 +2352,7 @@ window._wsInitData = (function() {
|
||||
this._updateNodeTags(this.tree, itemId, null, 'remove', tagId);
|
||||
this.displayTree = [...this.tree];
|
||||
this.tagsVersion++;
|
||||
this._bumpTagCount({ id: tagId }, -1);
|
||||
await this.loadWorkspaceTags();
|
||||
this._computeFilteredTableItems();
|
||||
}
|
||||
@@ -2419,7 +2511,7 @@ for (var _i = 0; _i < _wsKeys.length; _i++) {
|
||||
_wsInitData = window._wsInitData;
|
||||
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length);
|
||||
</script>
|
||||
<script>console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
|
||||
<script data-cfasync="false">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
|
||||
|
||||
<div class="ws-split"
|
||||
@contextmenu.prevent="_wsInitData.onContextMenu($event)"
|
||||
@@ -2462,7 +2554,6 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
|
||||
|
||||
<!-- Breadcrumb + toolbar row (Library-style) -->
|
||||
<div class="breadcrumb-row">
|
||||
<button class="btn-icon nav-arrow" x-show="sidebarCollapsed" x-cloak @click="sidebarCollapsed = false" title="Show sidebar">{{ fd_icon("chevron-right",14) }}</button>
|
||||
<a href="/workspaces" class="btn-icon nav-arrow" title="All workspaces">{{ fd_icon("home",14) }}</a>
|
||||
<button class="btn-icon nav-arrow" @click="goBack()" title="Back" :disabled="!canGoBack">{{ fd_icon("chevron-left",14) }}</button>
|
||||
<button class="btn-icon nav-arrow" @click="goForward()" title="Forward" :disabled="!canGoForward">{{ fd_icon("chevron-right",14) }}</button>
|
||||
@@ -2692,8 +2783,8 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
|
||||
<div class="actions">
|
||||
<template x-if="node.is_folder">
|
||||
<span style="display:flex;gap:2px">
|
||||
<button class="ws-act" @click.stop="window.FlowDeck.createPage()" title="New file">{{ fd_icon("file",14) }}</button>
|
||||
<button class="ws-act" @click.stop="window.FlowDeck.showCreateFolderModal()" title="New folder">{{ fd_icon("folder",14) }}</button>
|
||||
<button class="ws-act" @click.stop="window.FlowDeck.createPage(node.id)" title="New file">{{ fd_icon("file",14) }}</button>
|
||||
<button class="ws-act" @click.stop="window.FlowDeck.showCreateFolderModal(node.id)" title="New folder">{{ fd_icon("folder",14) }}</button>
|
||||
<a class="ws-act" href="/local-workspace" title="Open workspace page" style="text-decoration:none;display:inline-flex;align-items:center;">{{ fd_icon("external-link",14) }}</a>
|
||||
</span>
|
||||
</template>
|
||||
@@ -2933,6 +3024,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
|
||||
<!-- Offline banner -->
|
||||
<div class="offline-banner" x-show="!online" x-transition>
|
||||
<span>{{ fd_icon('alert-triangle',14) }} You are offline. Changes will sync when connection is restored.</span>
|
||||
<span class="offline-badge" x-show="pendingSyncCount > 0" x-text="pendingSyncCount + ' modification(s) en attente'"></span>
|
||||
</div>
|
||||
|
||||
<!-- Move-to modal -->
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
{% set page_icon = "file" if page.content_format != 'file' else "paperclip" %}
|
||||
{% set page_title = page.title %}
|
||||
{% set nav_page_id = page.id %}
|
||||
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn" @click="window.E && window.E.toggleComments()" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="window.E && window.E.commentCount>0 ? window.E.commentCount : \'\'"></span></button><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
|
||||
{% include '_header.html' %}
|
||||
{% endblock %} {% block content %}
|
||||
|
||||
@@ -7,7 +7,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
|
||||
{% endblock %} {% block content %}
|
||||
{% include "_database_table.html" %}
|
||||
{% endblock %} {% block scripts %}
|
||||
<script>
|
||||
<script data-cfasync="false">
|
||||
// Initialize database table from server-rendered data
|
||||
window.__DB_PAGE_ID = {{ page.id }};
|
||||
window.__DB_COLLECTION_ID = {{ page.collection_id or 0 }};
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
{% block topbar %}{% endblock %}
|
||||
{% block content %}
|
||||
{% include '_page_editor_content.html' %}
|
||||
<script>document.body.classList.add('embed-mode');</script>
|
||||
<script data-cfasync="false">document.body.classList.add('embed-mode');</script>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
{% include '_page_editor_scripts.html' %}
|
||||
|
||||
@@ -125,6 +125,18 @@
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.pub-content img[data-full] { cursor: zoom-in; }
|
||||
.fd-lightbox { user-select: none; }
|
||||
.fd-lightbox-nav {
|
||||
position: absolute; top: 50%; transform: translateY(-50%);
|
||||
width: 48px; height: 48px; border-radius: 50%; border: none;
|
||||
background: rgba(255,255,255,.12); color: #fff; font-size: 30px;
|
||||
line-height: 1; cursor: pointer; display: flex; align-items: center;
|
||||
justify-content: center; transition: background .15s ease;
|
||||
}
|
||||
.fd-lightbox-nav:hover { background: rgba(255,255,255,.25); }
|
||||
.fd-lightbox-nav[data-nav="prev"] { left: 24px; }
|
||||
.fd-lightbox-nav[data-nav="next"] { right: 24px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -143,8 +155,9 @@
|
||||
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,.5) 100%);"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% from '_icons.html' import fd_page_icon %}
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
|
||||
<span class="pub-icon" style="font-size:2rem;">{{ page_icon or fd_icon('file',24) }}</span>
|
||||
<span class="pub-icon" style="font-size:2rem;">{{ fd_page_icon(page_icon, 32) if page_icon else fd_icon('file',24) }}</span>
|
||||
<h1 class="pub-title">{{ title }}</h1>
|
||||
</div>
|
||||
{% if updated_at %}
|
||||
@@ -160,17 +173,50 @@
|
||||
</footer>
|
||||
|
||||
<script src="/static/js/katex.min.js?v=0.16.11"></script>
|
||||
<script>
|
||||
<script data-cfasync="false">
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (!window.katex) return;
|
||||
document.querySelectorAll('div[data-katex]').forEach(function (el) {
|
||||
var tex = el.getAttribute('data-katex') || '';
|
||||
try {
|
||||
el.innerHTML = window.katex.renderToString(tex, { displayMode: true, throwOnError: false });
|
||||
} catch (e) {
|
||||
el.textContent = tex;
|
||||
if (window.katex) {
|
||||
document.querySelectorAll('div[data-katex]').forEach(function (el) {
|
||||
var tex = el.getAttribute('data-katex') || '';
|
||||
try {
|
||||
el.innerHTML = window.katex.renderToString(tex, { displayMode: true, throwOnError: false });
|
||||
} catch (e) {
|
||||
el.textContent = tex;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// v5.5.0 — Image lightbox (fullscreen + keyboard navigation)
|
||||
var imgs = Array.prototype.slice.call(document.querySelectorAll('.pub-content img[data-full]'));
|
||||
imgs.forEach(function (im) { im.addEventListener('click', function () { openLb(im); }); });
|
||||
|
||||
function openLb(target) {
|
||||
var idx = imgs.indexOf(target); if (idx < 0) idx = 0;
|
||||
var ov = document.createElement('div');
|
||||
ov.className = 'fd-lightbox';
|
||||
ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.95);z-index:5000;display:flex;align-items:center;justify-content:center;flex-direction:column;';
|
||||
ov.innerHTML = '<button class="fd-lightbox-nav" data-nav="prev" aria-label="Previous">‹</button>'
|
||||
+ '<img style="max-width:95vw;max-height:88vh;border-radius:4px;box-shadow:0 0 40px rgba(0,0,0,.8);">'
|
||||
+ '<button class="fd-lightbox-nav" data-nav="next" aria-label="Next">›</button>'
|
||||
+ '<div class="fd-lb-hint" style="margin-top:14px;color:#999;font-size:13px;"></div>';
|
||||
document.body.appendChild(ov);
|
||||
var img = ov.querySelector('img');
|
||||
var hint = ov.querySelector('.fd-lb-hint');
|
||||
function show() {
|
||||
var cur = imgs[idx];
|
||||
img.src = cur.getAttribute('data-full') || cur.src;
|
||||
hint.textContent = (imgs.length > 1 ? (idx + 1) + ' / ' + imgs.length + ' — ' : '') + '← → to navigate · Esc to close';
|
||||
}
|
||||
});
|
||||
function step(d) { if (imgs.length < 2) return; idx = (idx + d + imgs.length) % imgs.length; show(); }
|
||||
function close() { ov.remove(); document.removeEventListener('keydown', kd); }
|
||||
function kd(e) { if (e.key === 'Escape') close(); else if (e.key === 'ArrowRight') step(1); else if (e.key === 'ArrowLeft') step(-1); }
|
||||
document.addEventListener('keydown', kd);
|
||||
ov.querySelectorAll('[data-nav]').forEach(function (b) {
|
||||
b.addEventListener('click', function (e) { e.stopPropagation(); step(b.getAttribute('data-nav') === 'next' ? 1 : -1); });
|
||||
});
|
||||
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||||
show();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
+284
-66
@@ -99,6 +99,32 @@
|
||||
.prov-card .pw-wrapper .settings-input{padding-right:36px;}
|
||||
.prov-msg{font-size:12px;margin-top:8px;min-height:0;word-break:break-word;}
|
||||
.llm-hint{font-size:12px;color:var(--text-secondary);background:rgba(35,131,226,.07);border:1px solid rgba(35,131,226,.18);border-radius:8px;padding:8px 12px;margin-top:14px;line-height:1.5;}
|
||||
|
||||
/* Agent & IA — provider master/detail (scales to dozens of providers) */
|
||||
.llm-toolbar{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
|
||||
.llm-search{position:relative;flex:1;min-width:180px;}
|
||||
.llm-search svg{position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--text-dim);pointer-events:none;}
|
||||
.llm-search input{padding-left:32px;box-sizing:border-box;}
|
||||
.llm-count{font-size:11px;color:var(--text-dim);white-space:nowrap;text-transform:uppercase;letter-spacing:.4px;}
|
||||
.llm-layout{display:grid;grid-template-columns:264px 1fr;gap:14px;align-items:start;}
|
||||
.llm-list{border:1px solid var(--border);border-radius:12px;background:var(--bg-secondary);max-height:480px;overflow-y:auto;padding:6px;display:flex;flex-direction:column;gap:2px;}
|
||||
.llm-list-item{display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:8px;cursor:pointer;transition:background 100ms;border:1px solid transparent;}
|
||||
.llm-list-item:hover{background:var(--bg-hover);}
|
||||
.llm-list-item.active{background:rgba(35,131,226,.14);border-color:rgba(35,131,226,.3);}
|
||||
.llm-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;background:var(--text-dim);}
|
||||
.llm-dot.ok{background:#2ea043;}
|
||||
.llm-dot.warn{background:#d9730d;}
|
||||
.llm-dot.err{background:#e03e3e;}
|
||||
.llm-list-name{flex:1;min-width:0;font-size:13px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
||||
.llm-list-status{font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.4px;white-space:nowrap;}
|
||||
.llm-list-empty{padding:12px;font-size:12px;color:var(--text-dim);text-align:center;}
|
||||
.llm-detail{border:1px solid var(--border);border-radius:12px;background:var(--bg-secondary);padding:16px;min-height:260px;}
|
||||
.llm-detail-head{display:flex;align-items:center;gap:10px;margin-bottom:4px;flex-wrap:wrap;}
|
||||
.llm-detail-name{font-size:15px;font-weight:600;color:var(--text);}
|
||||
.llm-detail-base{font-size:11px;color:var(--text-dim);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all;margin-bottom:12px;}
|
||||
.llm-detail .prov-field-row{margin-top:12px;}
|
||||
.llm-detail-empty{display:flex;align-items:center;justify-content:center;height:260px;font-size:13px;color:var(--text-dim);text-align:center;padding:0 20px;}
|
||||
@media (max-width:760px){.llm-layout{grid-template-columns:1fr;}.llm-list{max-height:220px;}}
|
||||
</style>
|
||||
|
||||
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="window.history.back()">
|
||||
@@ -112,6 +138,7 @@
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='notifications' }" @click="activeSection='notifications'"><span class="nav-icon-inline">{{ fd_icon("bell",14) }}</span> Notifications</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='api-tokens' }" @click="activeSection='api-tokens'; loadApiTokens()"><span class="nav-icon-inline">{{ fd_icon("key",14) }}</span> API tokens</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='sessions' }" @click="activeSection='sessions'; loadSessions()"><span class="nav-icon-inline">{{ fd_icon("users",14) }}</span> Sessions</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='extensions' }" @click="activeSection='extensions'; loadClipperDevices()"><span class="nav-icon-inline">{{ fd_icon("zap",14) }}</span> Extensions</div>
|
||||
<div class="settings-nav-header">Workspace</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='workspace' }" @click="activeSection='workspace'">{{ fd_icon("file",14) }} General</div>
|
||||
<div class="settings-nav-item" :class="{ active: activeSection==='tags' }" @click="activeSection='tags'">{{ fd_icon("tag",14) }} Tags</div>
|
||||
@@ -274,6 +301,47 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Reminders</div>
|
||||
<div class="setting-desc">Upcoming events & date reminders from your databases</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch sm">
|
||||
<input type="checkbox" x-model="notifPrefs.reminders" @change="saveNotifPrefs()">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Assignments</div>
|
||||
<div class="setting-desc">When a database row is assigned to you</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch sm">
|
||||
<input type="checkbox" x-model="notifPrefs.assignments" @change="saveNotifPrefs()">
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<h3>Timezone</h3>
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Your timezone</div>
|
||||
<div class="setting-desc">Used by calendar views (today, week start) and reminder firing. Empty = UTC.</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select class="settings-input" style="max-width:260px" x-model="userTimezone" @change="saveTimezone()">
|
||||
<option value="">UTC</option>
|
||||
<template x-for="z in userZones" :key="z">
|
||||
<option :value="z" x-text="z"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -290,13 +358,15 @@
|
||||
<button class="btn btn-primary" @click="createApiToken()" :disabled="!tokenName.trim()" style="white-space:nowrap;">Generate</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row" x-show="newToken" style="flex-direction:column;align-items:stretch;gap:6px;background:rgba(46,160,67,.08);border-radius:8px;">
|
||||
<div class="setting-label" style="color:var(--toast-success-bg);">Token créé — copiez-le maintenant (affiché une seule fois)</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<code class="settings-input" x-text="newToken.token" style="flex:1;font-family:var(--font-mono);font-size:12px;user-select:all;"></code>
|
||||
<button class="btn-sm" @click="copyNewToken()">Copy</button>
|
||||
<template x-if="newToken">
|
||||
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;background:rgba(46,160,67,.08);border-radius:8px;">
|
||||
<div class="setting-label" style="color:var(--toast-success-bg);">Token créé — copiez-le maintenant (affiché une seule fois)</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;">
|
||||
<code class="settings-input" x-text="newToken.token" style="flex:1;font-family:var(--font-mono);font-size:12px;user-select:all;"></code>
|
||||
<button class="btn-sm" @click="copyNewToken()">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<h3>Tokens existants <span style="text-transform:none;font-size:11px;" x-text="'(' + apiTokens.length + ')'"></span></h3>
|
||||
@@ -351,6 +421,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extensions (Web Clipper) -->
|
||||
<div x-show="activeSection==='extensions'">
|
||||
<h2>Extensions</h2>
|
||||
<p class="section-desc">FlowDeck Web Clipper — capture web content directly into FlowDeck. Manage connected browsers.</p>
|
||||
<div class="setting-group">
|
||||
<h3>Web Clipper</h3>
|
||||
<p style="font-size:12px;color:var(--text-dim);line-height:1.5;margin-bottom:12px;">
|
||||
Install the <a href="/extensions" style="color:var(--accent);">browser extension</a> (Chrome/Firefox/Edge). After install, use <b>Verify & Connect</b> once or paste an <b>API token</b> in the extension popup.
|
||||
</p>
|
||||
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;">
|
||||
<a href="/extensions" class="btn btn-secondary" style="text-decoration:none;font-size:13px;">Get extension</a>
|
||||
<a href="/static/extension/flowdeck-clipper.zip" download class="btn btn-secondary" style="text-decoration:none;font-size:13px;">Download .zip</a>
|
||||
</div>
|
||||
<div x-show="!clipperDevices.length" class="setting-desc" style="padding:8px 0;">No extension connected yet — clip your first page to appear here.</div>
|
||||
<template x-for="d in clipperDevices" :key="d.id">
|
||||
<div class="setting-row">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div class="setting-label">
|
||||
<span x-text="(d.device_name || d.extension_name || 'Clipper') + ' — ' + d.device_id.slice(0,24)"></span>
|
||||
<span class="llm-badge" :class="d.revoked ? 'off' : 'ok'" style="margin-left:8px;" x-text="d.revoked ? 'Révoqué' : 'Actif'"></span>
|
||||
<span class="llm-badge warn" style="margin-left:6px;" x-text="d.extension_name"></span>
|
||||
</div>
|
||||
<div class="setting-desc" style="font-size:11px;">
|
||||
<span x-text="'Connecté: ' + (d.created_at||'').slice(0,16)"></span>
|
||||
<span x-show="d.last_used_at" x-text="' · Dernier: ' + d.last_used_at.slice(0,16)"></span>
|
||||
<span x-text="' · ' + d.clips_count + ' clipp(s)'"></span>
|
||||
<span x-show="d.last_clip_at" x-text="' · Dernier clip: ' + d.last_clip_at.slice(0,16)"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<button class="btn-sm" :style="{color:'var(--danger)'}" @click="revokeClipperDevice(d)" x-show="!d.revoked">Révoquer</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="setting-group">
|
||||
<h3>Tester le clipper (sans extension)</h3>
|
||||
<p style="font-size:12px;color:var(--text-dim);">Envoyez un clip тест direct via l'API :</p>
|
||||
<div style="display:flex;gap:8px;margin-top:8px;">
|
||||
<input class="settings-input" x-model="clipTestUrl" placeholder="https://example.com/article" style="flex:1;">
|
||||
<button class="btn btn-primary" @click="testClip()" :disabled="clipTesting" x-text="clipTesting ? 'Envoi…' : 'Clip URL'"></button>
|
||||
</div>
|
||||
<div x-show="clipTestMsg" style="margin-top:8px;font-size:12px;" :style="{color: clipTestOk ? 'var(--toast-success-bg)' : 'var(--danger)'}" x-text="clipTestMsg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workspace -->
|
||||
<div x-show="activeSection==='workspace'">
|
||||
<h2>Workspace</h2>
|
||||
@@ -831,61 +947,82 @@
|
||||
<!-- My providers (per-user API keys) -->
|
||||
<div class="setting-group">
|
||||
<h3>Mes fournisseurs</h3>
|
||||
<p style="font-size:12px;color:var(--text-dim);margin:0 0 12px;">Enregistrez vos propres clés : elles sont utilisées quand vous choisissez ce fournisseur dans le panneau Agent, et vous rendent le fournisseur disponible (après test réussi).</p>
|
||||
<div class="prov-grid">
|
||||
<template x-for="p in userProviders()" :key="p.id">
|
||||
<div class="prov-card">
|
||||
<div class="prov-card-head">
|
||||
<span class="prov-card-name" x-text="p.name"></span>
|
||||
<span class="llm-badge" :class="llmBadgeClass(p)" x-text="llmBadge(p)"></span>
|
||||
<p style="font-size:12px;color:var(--text-dim);margin:0 0 12px;">Enregistrez vos propres clés : elles sont utilisées quand vous choisissez ce fournisseur dans le panneau Agent, et vous rendent le fournisseur disponible (après test réussi). Sélectionnez un fournisseur dans la liste pour le configurer.</p>
|
||||
<div class="llm-toolbar">
|
||||
<div class="llm-search">
|
||||
{{ fd_icon("search",14) }}
|
||||
<input type="text" class="settings-input" x-model="llmSearch" placeholder="Rechercher un fournisseur…">
|
||||
</div>
|
||||
<span class="llm-count" x-text="filteredProviders().length + ' fournisseurs'"></span>
|
||||
</div>
|
||||
<div class="llm-layout">
|
||||
<div class="llm-list">
|
||||
<template x-for="p in filteredProviders()" :key="p.id">
|
||||
<div class="llm-list-item" :class="{active: p.id===selectedProviderId}" @click="selectProvider(p.id)">
|
||||
<span class="llm-dot" :class="llmBadgeClass(p)"></span>
|
||||
<span class="llm-list-name" x-text="p.name"></span>
|
||||
<span class="llm-list-status" x-text="llmShortStatus(p)"></span>
|
||||
</div>
|
||||
<div class="prov-card-sub" x-show="keyForm(p.id).last_error" style="color:var(--danger);" x-text="'Dernier test : ' + keyForm(p.id).last_error"></div>
|
||||
<div class="prov-field-row">
|
||||
<div class="prov-field-label">Clé API <span class="muted" x-text="keyForm(p.id).has_key ? '(enregistrée — laisser vide pour conserver)' : ''"></span></div>
|
||||
<div class="pw-wrapper" style="max-width:100%;">
|
||||
<input :type="keyForm(p.id).keyVisible ? 'text' : 'password'" class="settings-input"
|
||||
x-model="keyForm(p.id).api_key" placeholder="sk-…" style="padding-right:36px;box-sizing:border-box;">
|
||||
<button type="button" class="pw-toggle" @click="keyForm(p.id).keyVisible=!keyForm(p.id).keyVisible" style="top:50%;">
|
||||
<span x-show="keyForm(p.id).keyVisible" x-cloak>{{ fd_icon('eye-off',14) }}</span>
|
||||
<span x-show="!keyForm(p.id).keyVisible" x-cloak>{{ fd_icon('eye',14) }}</span>
|
||||
</template>
|
||||
<div class="llm-list-empty" x-show="!filteredProviders().length">Aucun fournisseur</div>
|
||||
</div>
|
||||
<div class="llm-detail">
|
||||
<template x-if="selectedProvider()">
|
||||
<div>
|
||||
<div class="llm-detail-head">
|
||||
<span class="llm-detail-name" x-text="selectedProvider().name"></span>
|
||||
<span class="llm-badge" :class="llmBadgeClass(selectedProvider())" x-text="llmBadge(selectedProvider())"></span>
|
||||
</div>
|
||||
<div class="llm-detail-base" x-text="keyForm(selectedProviderId).api_base || selectedProvider().base_url || '—'"></div>
|
||||
<div class="prov-card-sub" x-show="keyForm(selectedProviderId).last_error" style="color:var(--danger);margin-bottom:0;" x-text="'Dernier test : ' + keyForm(selectedProviderId).last_error"></div>
|
||||
<div class="prov-field-row">
|
||||
<div class="prov-field-label">Clé API <span class="muted" x-text="keyForm(selectedProviderId).has_key ? '(enregistrée — laisser vide pour conserver)' : (selectedProvider().requires_key ? '(requise)' : '(optionnelle)')"></span></div>
|
||||
<div class="pw-wrapper" style="max-width:100%;">
|
||||
<input :type="keyForm(selectedProviderId).keyVisible ? 'text' : 'password'" class="settings-input"
|
||||
x-model="keyForm(selectedProviderId).api_key" placeholder="sk-…" style="padding-right:36px;box-sizing:border-box;">
|
||||
<button type="button" class="pw-toggle" @click="keyForm(selectedProviderId).keyVisible=!keyForm(selectedProviderId).keyVisible" style="top:50%;">
|
||||
<span x-show="keyForm(selectedProviderId).keyVisible" x-cloak>{{ fd_icon('eye-off',14) }}</span>
|
||||
<span x-show="!keyForm(selectedProviderId).keyVisible" x-cloak>{{ fd_icon('eye',14) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prov-field-row">
|
||||
<div class="prov-field-label">URL API <span class="muted">(optionnelle)</span></div>
|
||||
<input type="text" class="settings-input" x-model="keyForm(selectedProviderId).api_base" placeholder="https://…" style="box-sizing:border-box;">
|
||||
</div>
|
||||
<div class="prov-field-row">
|
||||
<div class="prov-field-label">Modèle par défaut</div>
|
||||
<select class="settings-input" x-model="keyForm(selectedProviderId).model" style="box-sizing:border-box;">
|
||||
<template x-for="m in keyModels(selectedProviderId)" :key="m">
|
||||
<option :value="m" x-text="m"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="prov-actions">
|
||||
<button class="btn btn-primary" @click="saveUserKey(selectedProviderId)" :disabled="keyForm(selectedProviderId).saving" style="font-size:12px;padding:6px 12px;">
|
||||
<span x-show="!keyForm(selectedProviderId).saving">{{ fd_icon("save",14) }} Enregistrer & tester</span>
|
||||
<span x-show="keyForm(selectedProviderId).saving">Enregistrement…</span>
|
||||
</button>
|
||||
<button class="btn" @click="testUserKey(selectedProviderId)" :disabled="keyForm(selectedProviderId).testing" style="font-size:12px;padding:6px 12px;">
|
||||
<span x-show="!keyForm(selectedProviderId).testing">🔌 Tester</span>
|
||||
<span x-show="keyForm(selectedProviderId).testing">Test…</span>
|
||||
</button>
|
||||
<button class="btn" @click="loadProviderModels(selectedProviderId)" :disabled="keyForm(selectedProviderId).modelLoading" style="font-size:12px;padding:6px 12px;">
|
||||
<span x-show="!keyForm(selectedProviderId).modelLoading">{{ fd_icon("refresh",14) }} Modèles</span>
|
||||
<span x-show="keyForm(selectedProviderId).modelLoading">Chargement…</span>
|
||||
</button>
|
||||
<button class="btn" @click="deleteUserKey(selectedProviderId)" :disabled="keyForm(selectedProviderId).deleting" x-show="keyForm(selectedProviderId).has_key" style="font-size:12px;padding:6px 12px;color:var(--danger);">Supprimer</button>
|
||||
</div>
|
||||
<div class="prov-msg">
|
||||
<span x-show="keyForm(selectedProviderId).msg" x-text="keyForm(selectedProviderId).msg" :style="{color: keyForm(selectedProviderId).ok ? 'var(--toast-success-bg)' : 'var(--danger)'}"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prov-field-row">
|
||||
<div class="prov-field-label">URL API <span class="muted">(optionnelle)</span></div>
|
||||
<input type="text" class="settings-input" x-model="keyForm(p.id).api_base" placeholder="https://…" style="box-sizing:border-box;">
|
||||
</div>
|
||||
<div class="prov-field-row">
|
||||
<div class="prov-field-label">Modèle par défaut</div>
|
||||
<select class="settings-input" x-model="keyForm(p.id).model" style="box-sizing:border-box;">
|
||||
<template x-for="m in keyModels(p.id)" :key="m">
|
||||
<option :value="m" x-text="m"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="prov-actions">
|
||||
<button class="btn btn-primary" @click="saveUserKey(p.id)" :disabled="keyForm(p.id).saving" style="font-size:12px;padding:6px 12px;">
|
||||
<span x-show="!keyForm(p.id).saving">{{ fd_icon("save",14) }} Enregistrer & tester</span>
|
||||
<span x-show="keyForm(p.id).saving">Enregistrement…</span>
|
||||
</button>
|
||||
<button class="btn" @click="testUserKey(p.id)" :disabled="keyForm(p.id).testing" style="font-size:12px;padding:6px 12px;">
|
||||
<span x-show="!keyForm(p.id).testing">🔌 Tester</span>
|
||||
<span x-show="keyForm(p.id).testing">Test…</span>
|
||||
</button>
|
||||
<button class="btn" @click="loadProviderModels(p.id)" :disabled="keyForm(p.id).modelLoading" style="font-size:12px;padding:6px 12px;">
|
||||
<span x-show="!keyForm(p.id).modelLoading">{{ fd_icon("refresh",14) }} Modèles</span>
|
||||
<span x-show="keyForm(p.id).modelLoading">Chargement…</span>
|
||||
</button>
|
||||
<button class="btn" @click="deleteUserKey(p.id)" :disabled="keyForm(p.id).deleting" x-show="keyForm(p.id).has_key" style="font-size:12px;padding:6px 12px;color:var(--danger);">Supprimer</button>
|
||||
</div>
|
||||
<div class="prov-msg">
|
||||
<span x-show="keyForm(p.id).msg" x-text="keyForm(p.id).msg" :style="{color: keyForm(p.id).ok ? 'var(--toast-success-bg)' : 'var(--danger)'}"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<div class="llm-detail-empty" x-show="!selectedProvider()">Sélectionnez un fournisseur pour le configurer.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="llm-hint">
|
||||
💡 <b>Comment activer un fournisseur ?</b> Enregistrez votre clé puis cliquez « Enregistrer & tester » (ou « Tester ») : dès que la connexion réussit, le fournisseur et ses modèles apparaissent dans le sélecteur du panneau Agent.
|
||||
💡 <b>Comment activer un fournisseur ?</b> Sélectionnez-le, enregistrez votre clé puis cliquez « Enregistrer & tester » (ou « Tester ») : dès que la connexion réussit, le fournisseur et ses modèles apparaissent dans le sélecteur du panneau Agent.
|
||||
Pour Ollama, aucun fournisseur distant : renseignez l'URL locale (ex. <code>http://localhost:11434/v1</code>) puis testez.
|
||||
En mode hors-ligne (aucun fournisseur actif), l'agent reste utilisable mais génère du contenu local, sans appel réseau.
|
||||
</div>
|
||||
@@ -896,11 +1033,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('alpine:init', function() {
|
||||
Alpine.data('settingsInit', function() {
|
||||
return {
|
||||
activeSection: 'account',
|
||||
<script data-cfasync="false">
|
||||
function settingsInit() {
|
||||
return {
|
||||
activeSection: 'account',
|
||||
allTags: [],
|
||||
newTagName: '',
|
||||
newTagColor: '#787774',
|
||||
@@ -914,7 +1050,8 @@ document.addEventListener('alpine:init', function() {
|
||||
defaultView: localStorage.getItem('fd_default_view') || 'tree',
|
||||
userInfo: {full_name: '{{ user.full_name or "" }}', login: '{{ user.login or "" }}', email: '{{ user.email or "" }}'},
|
||||
userIsAdmin: {{ 'true' if user.get('is_admin') else 'false' }},
|
||||
notifPrefs: {comments: true, mentions: true},
|
||||
notifPrefs: {comments: true, mentions: true, reminders: true, assignments: true},
|
||||
userTimezone: '', userZones: [],
|
||||
// Auth & Integrations
|
||||
authMethod: '{{ auth_method }}',
|
||||
giteaLinked: false,
|
||||
@@ -951,6 +1088,8 @@ document.addEventListener('alpine:init', function() {
|
||||
llmSummaryDesc: '',
|
||||
// Per-user provider keys (multi-providers)
|
||||
keyForms: {},
|
||||
llmSearch: '',
|
||||
selectedProviderId: null,
|
||||
|
||||
// Automations (v5.1.0)
|
||||
automations: [],
|
||||
@@ -964,6 +1103,12 @@ document.addEventListener('alpine:init', function() {
|
||||
newToken: null,
|
||||
tokenName: '',
|
||||
sessions: [],
|
||||
// v6.2.0 Web Clipper
|
||||
clipperDevices: [],
|
||||
clipTestUrl: 'https://example.com',
|
||||
clipTesting: false,
|
||||
clipTestMsg: '',
|
||||
clipTestOk: false,
|
||||
// v5.2.0 Backups (admin)
|
||||
backups: [],
|
||||
backupRunning: false,
|
||||
@@ -983,7 +1128,13 @@ document.addEventListener('alpine:init', function() {
|
||||
try {
|
||||
var r = await fetch('/api/notifications/prefs', {credentials:'same-origin'});
|
||||
var d = await r.json();
|
||||
this.notifPrefs = Object.assign({comments:true, mentions:true}, d.prefs || {});
|
||||
this.notifPrefs = Object.assign({comments:true, mentions:true, reminders:true, assignments:true}, d.prefs || {});
|
||||
} catch(e) {}
|
||||
try {
|
||||
var tz = await fetch('/api/notifications/timezone', {credentials:'same-origin'});
|
||||
var td = await tz.json();
|
||||
this.userTimezone = td.timezone || '';
|
||||
this.userZones = td.zones || [];
|
||||
} catch(e) {}
|
||||
},
|
||||
async saveNotifPrefs() {
|
||||
@@ -994,6 +1145,15 @@ document.addEventListener('alpine:init', function() {
|
||||
});
|
||||
} catch(e) {}
|
||||
},
|
||||
async saveTimezone() {
|
||||
try {
|
||||
var r = await fetch('/api/notifications/timezone', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
|
||||
body: JSON.stringify({timezone: this.userTimezone})
|
||||
});
|
||||
if (r.ok && typeof toast === 'function') toast('Timezone saved');
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
async loadTags() {
|
||||
try {
|
||||
@@ -1160,6 +1320,29 @@ document.addEventListener('alpine:init', function() {
|
||||
userProviders() {
|
||||
return (this.llmProviders || []).filter(x => x.id !== 'offline');
|
||||
},
|
||||
selectedProvider() {
|
||||
return this.providerById(this.selectedProviderId);
|
||||
},
|
||||
filteredProviders() {
|
||||
var q = (this.llmSearch || '').trim().toLowerCase();
|
||||
var list = this.userProviders();
|
||||
if (!q) return list;
|
||||
return list.filter(function(p){
|
||||
return (p.name || '').toLowerCase().indexOf(q) !== -1 ||
|
||||
(p.id || '').toLowerCase().indexOf(q) !== -1;
|
||||
});
|
||||
},
|
||||
selectProvider(id) {
|
||||
this.selectedProviderId = id;
|
||||
this.keyForm(id);
|
||||
},
|
||||
llmShortStatus(p) {
|
||||
if (!p) return '';
|
||||
if (p.id === 'offline') return 'mock';
|
||||
if (p.verified) return 'actif';
|
||||
if (p.configured) return 'à tester';
|
||||
return '—';
|
||||
},
|
||||
// Set the verified/last_error state on the shared provider entry + its keyForm
|
||||
_setProviderState(id, ok, error) {
|
||||
var p = this.providerById(id);
|
||||
@@ -1249,6 +1432,12 @@ document.addEventListener('alpine:init', function() {
|
||||
f.has_key = !!(k && k.has_key);
|
||||
f.last_error = (k && k.last_error) || p.last_error || '';
|
||||
});
|
||||
if (!this.selectedProviderId || !this.providerById(this.selectedProviderId)) {
|
||||
var pool = this.userProviders();
|
||||
var pick = pool.find(x => x.verified) || pool.find(x => x.configured) || pool[0];
|
||||
this.selectedProviderId = pick ? pick.id : null;
|
||||
}
|
||||
if (this.selectedProviderId) this.keyForm(this.selectedProviderId);
|
||||
this.syncLlmStatus();
|
||||
} catch(e) {}
|
||||
},
|
||||
@@ -1344,7 +1533,8 @@ document.addEventListener('alpine:init', function() {
|
||||
finally { f.testing = false; }
|
||||
},
|
||||
async deleteUserKey(id) {
|
||||
var pname = id.charAt(0).toUpperCase() + id.slice(1);
|
||||
var p = this.providerById(id);
|
||||
var pname = (p && p.name) || id;
|
||||
if (!confirm('Supprimer la clé API pour ' + pname + ' ?\nLe fournisseur ne sera plus disponible dans le panneau Agent.')) return;
|
||||
var f = this.keyForm(id);
|
||||
f.deleting = true; f.msg = ''; f.ok = false;
|
||||
@@ -1716,6 +1906,35 @@ document.addEventListener('alpine:init', function() {
|
||||
}
|
||||
} catch(e) {}
|
||||
},
|
||||
// ── v6.2.0 Web Clipper ──
|
||||
async loadClipperDevices() {
|
||||
try {
|
||||
var r = await fetch('/api/v2/web-clipper/devices', {credentials:'same-origin'});
|
||||
var d = await r.json();
|
||||
this.clipperDevices = d.devices || [];
|
||||
} catch(e) { this.clipperDevices = []; }
|
||||
},
|
||||
async revokeClipperDevice(d) {
|
||||
if (!confirm('Révoquer l\'extension « ' + (d.device_name || d.device_id) + ' » ?')) return;
|
||||
try {
|
||||
var r = await fetch('/api/v2/web-clipper/devices/' + d.id, {method:'DELETE', credentials:'same-origin', headers: {'X-CSRF-Token': this.getCsrfToken()}});
|
||||
if (r.ok) await this.loadClipperDevices();
|
||||
} catch(e) {}
|
||||
},
|
||||
async testClip() {
|
||||
this.clipTesting = true; this.clipTestMsg = ''; this.clipTestOk = false;
|
||||
var url = (this.clipTestUrl || '').trim();
|
||||
if (!url) { this.clipTestMsg = 'URL requise'; this.clipTesting = false; return; }
|
||||
try {
|
||||
var r = await fetch('/api/v2/web-clipper/clip', {method:'POST', credentials:'same-origin',
|
||||
headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
|
||||
body: JSON.stringify({url: url, title: 'Test clipper — ' + url, content: '<html><body><h1>Test clip</h1><p>Contenu de test depuis Settings.</p></body></html>', content_type: 'article', device_id: 'settings-test'})});
|
||||
var d = await r.json();
|
||||
if (r.ok) { this.clipTestOk = true; this.clipTestMsg = '✓ Page créée : #' + d.page_id + ' — ' + d.title; await this.loadClipperDevices(); }
|
||||
else this.clipTestMsg = d.detail || 'Erreur';
|
||||
} catch(e) { this.clipTestMsg = 'Erreur réseau'; }
|
||||
finally { this.clipTesting = false; }
|
||||
},
|
||||
// ── v5.2.0 Backups (admin) ──
|
||||
async loadBackups() {
|
||||
try {
|
||||
@@ -1756,8 +1975,7 @@ document.addEventListener('alpine:init', function() {
|
||||
setTimeout(function() { window.location.reload(); }, 100);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
<script data-cfasync="false">
|
||||
function trashData() {
|
||||
return {
|
||||
search: '',
|
||||
|
||||
@@ -133,7 +133,7 @@ h1{font-size:22px;font-weight:700;margin-bottom:6px;}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script data-cfasync="false">
|
||||
function onboarding() {
|
||||
return {
|
||||
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
<script data-cfasync="false">
|
||||
function workspacePage() {
|
||||
return {
|
||||
builtinProjects: [],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user