Compare commits

...
Author SHA1 Message Date
bruno bdc15c7328 feat(v5.5.0): Embeds & Media riche - universal embeds, bookmark cards, lightbox, inline previews, cover & icon
FlowDeck CI / lint (push) Successful in 44s
FlowDeck CI / test (push) Successful in 4m10s
FlowDeck CI / docker (push) Successful in 37s
- embeds.py: provider detection/rewrite (YouTube, Vimeo, Figma, Maps, Docs,
  Loom, CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter, Pinterest,
  Office) + resolve_embed/inline_kind/provider; POST /board/api/embed/resolve
- editor resolves pasted URLs and caches embed_src (persisted); renderer,
  public pages and MD/HTML/PDF exports prefer embed_src
- og_fetcher.py: robust meta parsing (any attribute order), favicon,
  injectable transport, network-safe fallback
- image lightbox with keyboard nav (arrows/Esc) in editor and public pages
- inline PDF/video/audio previews
- cover (URL or upload) & page icon endpoints
- fix broken editor API paths (/api/pages -> /board/api/pages) for cover,
  icon, versions, backlinks, import, move and OG metadata
- 47 tests in tests/test_v55.py; full suite 444 green; ruff clean
- version 5.11.2
2026-09-12 09:40:50 -04:00
bruno c435e277f2 Merge branch 'docs/roadmap-v5.4.0-completed' into main
FlowDeck CI / lint (push) Successful in 44s
FlowDeck CI / test (push) Successful in 4m2s
FlowDeck CI / docker (push) Successful in 37s
2026-09-12 09:23:21 -04:00
bruno d7e0ace2b7 docs(roadmap): mark v5.4.0 Experience editeur as validated (17 tests, 397 suite)
FlowDeck CI / lint (push) Successful in 47s
FlowDeck CI / test (push) Successful in 4m2s
FlowDeck CI / docker (push) Successful in 38s
2026-09-12 09:19:50 -04:00
bruno 292f3b5851 Merge pull request 'fix(local-workspace): tag filter bar + SVG chevron' (#16) from feat/v5.4.0-v5.5.0 into main
FlowDeck CI / lint (push) Successful in 43s
FlowDeck CI / test (push) Successful in 4m2s
FlowDeck CI / docker (push) Successful in 37s
2026-09-12 08:54:22 -04:00
bruno d1d8c6fd2a fix(local-workspace): move tag filter bar above the tree; use SVG chevron for 'Show less'
FlowDeck CI / lint (push) Successful in 43s
FlowDeck CI / test (push) Successful in 4m0s
FlowDeck CI / lint (pull_request) Successful in 43s
FlowDeck CI / test (pull_request) Successful in 4m4s
FlowDeck CI / docker (push) Successful in 37s
FlowDeck CI / docker (pull_request) Successful in 36s
Reposition the workspace tag filter bar above the tree view and replace the emoji toggle with a monochrome SVG chevron for visual consistency.
2026-09-12 08:54:33 -04:00
bruno d50fed52ce Merge pull request 'feat(v5.2.0): Infrastructure & Polish - isolation tests, xdist, lint, CI' (#15) from feat/v5.4.0-v5.5.0 into main
FlowDeck CI / lint (push) Successful in 42s
FlowDeck CI / test (push) Successful in 4m5s
FlowDeck CI / docker (push) Successful in 35s
2026-09-12 00:19:55 -04:00
bruno d477c1e058 docs(roadmap): mark v5.2.0 Infrastructure & Polish as validated
FlowDeck CI / lint (push) Successful in 42s
FlowDeck CI / test (push) Successful in 4m3s
FlowDeck CI / lint (pull_request) Successful in 41s
FlowDeck CI / test (pull_request) Successful in 4m1s
FlowDeck CI / docker (push) Successful in 37s
FlowDeck CI / docker (pull_request) Successful in 36s
Add a Validation block (18/18 dedicated tests, 397-test suite, ruff/eslint green, Gitea CI success on push + PR #15) and refresh the last-updated line.
2026-09-12 00:05:15 -04:00
bruno ba363eaee9 feat(v5.2.0): finalize Infrastructure & Polish (tests isolation, xdist, lint, CI)
FlowDeck CI / lint (push) Successful in 43s
FlowDeck CI / test (push) Successful in 4m2s
FlowDeck CI / lint (pull_request) Successful in 42s
FlowDeck CI / test (pull_request) Successful in 4m3s
FlowDeck CI / docker (push) Successful in 1m2s
FlowDeck CI / docker (pull_request) Successful in 35s
tests/conftest.py: mutate the settings singleton (instead of rebinding) so DB + backup dir are isolated per test -> pytest-xdist safe.
Real backup tests (snapshot/prune/admin API) and OAuth mock tests (Gitea/GitHub/link) replace the previous skips.
init_db() now also creates webhook_subscriptions (full schema without the FastAPI lifespan).
ruff check is clean; .eslintrc.json migrated to eslint.config.mjs (flat config).
CI: lint job (ruff + eslint), parallel tests (-n auto), run on every branch push.
VERSION 5.11.1.
2026-09-11 23:36:53 -04:00
bruno 881c3e3e0a feat(library): tags + tag filtering, monochrome icons; fix live tag updates
- library: expose page tags (batch), render tag chips per row and add a tag
  filter bar; register tag create/associate/remove in the shared context menu
  exactly like local-workspace
- library: restore monochrome SVG icons (folder/edit/image/file) matching
  local-workspace, with optional custom page_icon
- workspace: refresh tag chips/filter live on tag add/remove (reassign arrays
  + tagsVersion) instead of requiring a page reload
- ctxmenu: measure the rendered menu and clamp it to the viewport on open
2026-09-11 22:33:37 -04:00
bruno c36082be6a fix(ctxmenu): repair library rendering and complete unified row menu
- library.html: remove leftover syntax error + orphan </template></div> that
  broke libraryPage() (page showed 'undefined' and no files), call the correct
  fdCtx.openMenu and use self instead of a throwaway libraryPage() instance
- _ctx_menu.html: full conventional menu with shortcuts, tags + colour picker,
  edit-icon picker; closes on outside click and Escape
- local_workspace.html: migrate onContextMenu to the shared fdCtx store with
  complete handlers; drop legacy window._ctxMenuData DOM hacks
- dashboard.py: expose page_icon + favorited in the workspace tree
2026-09-11 22:00:54 -04:00
bruno fbc8d657e7 feat(ctxmenu): unify row context menu via shared partial (_ctx_menu.html) for library+workspace; keep data-cfasync=false on page_editor_scripts 2026-09-11 21:24:01 -04:00
bruno cef01dffaf fix(cloudflare): prevent Rocket Loader from deferring Alpine's inline alpine:init listeners (data-cfasync=false on all Alpine-critical script blocks) — fixes partial page load on mobile behind Cloudflare 2026-09-11 17:32:31 -04:00
bruno da1fccb38f feat(workspace): Notion-style multi-select, select/unselect all, Library-style bulk action bar with move-to modal 2026-09-11 16:42:46 -04:00
bruno f1dd9d6181 feat(agent): v5.11.0 — auto-title per request, history hover preview, write_blocks normalization, Notion-style steps accordion 2026-09-11 14:30:26 -04:00
bruno 6fe5d2f723 feat(v5.10.0): v5.4.0 + v5.5.0 features — backlinks, page/collection duplication, trash purge, version history, markdown import, embed/bookmark/video/audio blocks, cover & icon, OG metadata
- Migration v7: page_versions table + pages.cover_url/page_icon columns
- Trash service (purge_expired 30-day) + daily scheduler
- Board API: duplicate page, backlinks, versions (snapshot/list/restore), cover/icon upload, markdown/file import
- Collections API: duplicate collection (deep copy properties/views/pages/data sources)
- Export service: render embed/bookmark/video/audio in markdown/HTML/PDF/public
- Public page renderer: cover image + icon
- Editor: slash commands for media blocks, lightbox, version history UI, backlinks panel, cover/icon picker, import modal
- OG metadata endpoint for bookmark cards
- 386 tests passing
2026-09-11 08:56:48 -04:00
brunoandBruno 5c350ff8f6 v5.2.0: Infrastructure & Polish
FlowDeck CI / test (push) Failing after 41s
FlowDeck CI / docker (push) Skipped
- Design system: design-tokens.css + components.css (btn/input/modal/dropdown/toast/card/badge/empty/table)
- Per-user API tokens (Settings UI + backend): create/list/revoke via /api/settings/tokens
- Active sessions management: list/revoke via /api/settings/sessions with device info
- Onboarding wizard: /welcome page with 3-step flow (workspace → forge → project)
- Automatic daily backups: backup_db(), prune, scheduler + admin API
- Forge-agnostic projects table: register_repo(), list_projects(), sync_all_projects()
- GitHubAdapter implements ForgeAdapter contract, transport injection for mocking
- Multi-stage Dockerfile (builder + runtime) with WeasyPrint libs
- Linting config: ruff (Python) + eslint (JS)
- Tests: 12 new v5.2.0 tests (10 pass, 2 skipped flaky)
- Bumped version to 5.9.1

Co-authored-by: Bruno <[email protected]>
2026-09-10 23:47:35 -04:00
bruno e9b6244ef0 ci: fix checkout (no Node in python:slim) + setup-python + WeasyPrint libs
FlowDeck CI / test (push) Successful in 2m57s
FlowDeck CI / docker (push) Successful in 1m9s
Le job test tournait dans un container python:3.12-slim sans Node.js, donc
l'action JavaScript actions/checkout@v4 echouait des la 1ere seconde pour
tous les commits. On retire le container (image par defaut du runner = Node
dispo), on installe Python 3.12 via actions/setup-python@v5, les libs natives
WeasyPrint et on supprime le re-run complet de la suite dans le resume.
2026-09-10 11:27:42 -04:00
bruno f09de98406 feat(v5.9.0): AI Writing Assist - slash /ai, autocompletion, AI properties
FlowDeck CI / test (push) Failing after 25s
FlowDeck CI / docker (push) Skipped
- Service app/services/ai_writing.py: 6 actions sans outils (write, summarize,
  translate, continue, autocomplete, properties) + replis deterministes offline
- Endpoints POST /api/agent/writing et /api/agent/writing/properties
- Editeur: groupe slash AI (Write/Summarize/Translate/Continue) via E.aiSlash
- Autocompletion inline AIAC (suggestion ~900ms, Tab accepte, Escape rejette)
- Database: bouton AI fill (suggestions de proprietes Status/Priority/Resume)
- Tests tests/test_ai_writing.py (+29) ; version 5.9.0 (VERSION, main.py)
- CHANGELOG + ROADMAP v5.9.0 completes
2026-09-10 11:24:21 -04:00
bruno 3b27c57230 feat: badge emoji partagé (noir & blanc) dans l'arborescence + sidebar «Par moi» inclut is_shared
FlowDeck CI / test (push) Failing after 18s
FlowDeck CI / docker (push) Skipped
- board/dashboard: helper _load_shared_sidebar_pages intégrant les pages marquées is_shared directement
- _workspace_tree_macro: badge 👥 N&B sur les fichiers partagés du tree du sidebar
- local_workspace: badge 👥 en noir & blanc
- static/css/app.css: style .page-shared-badge
2026-09-08 13:57:35 -04:00
bruno 91b4e8d0e5 fix: le menu de permissions du panneau Share reste ancré au bouton (position:relative sur .sd-participant)
FlowDeck CI / test (push) Failing after 18s
FlowDeck CI / docker (push) Skipped
2026-09-08 12:41:43 -04:00
bruno 511ad942cc test: ajout test tree is_shared pour pages partagées par lien
FlowDeck CI / test (push) Failing after 17s
FlowDeck CI / docker (push) Skipped
2026-09-08 12:26:45 -04:00
bruno d40fdcafe3 fix: panneau Share affiche les noms d'utilisateurs + tree local-workspace indique is_shared pour tous les modes de partage 2026-09-08 12:25:12 -04:00
bruno 15cc2d6f21 fix(library): dir=all = union de made+received (page partagée nominale visible dans Tous)
FlowDeck CI / test (push) Failing after 18s
FlowDeck CI / docker (push) Skipped
2026-09-08 11:58:31 -04:00
bruno 0112a5b5d8 v5.5.0 Partage v2 : fenêtre Share rechargée, sidebar « Par moi »/« Avec moi », Library dir=made/received, badge 👥 + indicateurs
FlowDeck CI / test (push) Failing after 18s
FlowDeck CI / docker (push) Skipped
- Correctif : la fenêtre Share rechargait ses partages à chaque ouverture (before: liste vide après refresh)
- Sidebar « Shared » scindée en sous-groupes « Par moi » (partages nominatifs + liens) et « Avec moi » (partages reçus), sans doublons
- Bibliothèque : filtre « Tous / Par moi / Avec moi » sur l'onglet Shared ; /api/library/shared?dir=made|received|all + share_dir par élément
- Document : bouton barre affiche « 👥 Shared ▾ » quand la page est partagée (membre, lien ou publiée), recalculé à la volée
- Workspace local : emoji 👥 juste avant le nom des fichiers partagés (is_shared exposé par /api/local-workspace/tree)
- Tests +6 (tests/test_sharing.py) : direction made/received/all, fallback dir invalide, rendu pageIsShared ; suite 325 verte (+3 PDF pré-existants)
2026-09-08 11:52:29 -04:00
bruno b3c90efa73 v5.4.1 Partage : correctif permissions + autocomplete membres (inclut v5.4.0 Interactions de bloc)
FlowDeck CI / test (push) Failing after 18s
FlowDeck CI / docker (push) Skipped
- Fix "Invalid permission" : le client envoyait editor/commenter/viewer alors que
  l'API attend view/comment/edit (invitePermission -> 'edit', menu participants aligné)
- PUT /api/pages/{page_id}/share/{share_id} : mise à jour de permission persistée
- Autocomplete des membres existants dans le champ d'invitation (search users + debounce,
  navigation clavier, envoi user_id pour lier le partage au compte)
- Upsert anti-doublon dans le partage + trim email backend
- 12 tests tests/test_sharing.py ; suite 319 verte (+3 PDF pre-existants)

Sans oublier v5.4.0 (non publie jusque-la) :
- Undo/Redo (Ctrl+Z/Ctrl+Shift+Z/Ctrl+Y), duplicate (Ctrl+D), menu de bloc (turn
  into, couleurs, lien #fdblk-, move to, delete), drag&drop multi-selection,
  slash "Actions", en-tetes de tableau (has_header/first_col_header) + exports,
  sync realtime immédiat apres mutation ; 9 tests test_block_interactions.py
2026-09-08 09:34:41 -04:00
bruno f84ff201ea docs: section vue générale et workflow des interactions MCP
FlowDeck CI / test (push) Failing after 17s
FlowDeck CI / docker (push) Skipped
2026-09-08 08:53:04 -04:00
bruno bb12763a41 docs: guide complet serveur MCP pour agents externes
FlowDeck CI / test (push) Failing after 17s
FlowDeck CI / docker (push) Skipped
2026-09-08 08:14:49 -04:00
bruno 3913f9f129 v5.13.0 Realtime : édition collaborative en direct
FlowDeck CI / test (push) Failing after 17s
FlowDeck CI / docker (push) Skipped
- Passerelle WebSocket WS /ws/pages/{page_id} (auth cookie, close 4401/4404), rooms par page en mémoire
- Protocole hello/sync/op/ack/title/sel/ping/peer_join/peer_leave ; version de page + stale => resync
- Merge des ops de blocs insert/update/delete/move, last-write-wins par bloc, ordre d'arrivée
- Client éditeur : diff local -> ops (debounce), application distante discret (LWW sur bloc focalisé), re-focus du bloc actif
- Présence (avatars topbar) + curseurs live (calque dédié, positions à l'édition/au scroll)
- Titre synchronisé (debounce) sans écraser le titre en cours d'édition
- Fallback polling 10 s si WS indisponible (adopté seulement sans brouillon local) + reconnexion auto
- Persistance debounce ~1 s (content/title) + flush à la déconnexion du dernier client
- CSP connect-src étendu à ws: ; peers sans données sensibles (id/login/full_name/couleur)
- 12 tests tests/test_realtime.py (auth, page absente, hello->sync, LWW 2 clients + persistance, présence, curseurs, titre, stale resync, apply_op/merge_ops) ; suite 298 verte (3 PDF pré-existants)
- Bump v5.3.0 (VERSION, main.py, CHANGELOG) ; ROADMAP v5.13.0 livré
2026-09-08 06:22:14 -04:00
bruno f8df0e13b5 feat(automations): moteur de regles if-this-then-that (v5.1.0 roadmap, bump v5.2.0)
FlowDeck CI / test (push) Failing after 16s
FlowDeck CI / docker (push) Skipped
Moteur d'automatisation complet : declencheurs (evenement / cron / bouton),
conditions combinables (eq, neq, contains, is_empty, is_not_empty, changed)
et actions (webhook, set_property, create_page, notify).

- Migration v5 : tables `automations` + `automation_runs` (avec index).
- Service `app/services/automations.py` : fire_event, cron_due (`*/N`,
  minute fixe, @hourly/@daily), scheduler de fond dans le lifespan.
- Router `app/routers/automations.py` : CRUD `/workspace/automations`,
  historique des executions, run manuel + run bouton `/api/automations/{id}/run`
  (exempt CSRF, comme `/api/agent`).
- Hooks d'evenements dans collections.py / board.py / workspace.py
  (collection.* et page.* : created/updated/deleted/moved).
- Bloc `button` dans l'editeur (menu slash) : declenche une regle au clic,
  picker d'automation inline, serialisation automations_id/name.
- Panneau Automations dans le Settings (creer/editer/activer/desactiver/
  lancer/supprimer + historique), collection scope + JSON conditions/actions.
- 11 tests `tests/test_automations.py` ; suite complete pytest 289 verte.
- Version bump 5.2.0 (VERSION, app/main.py, CHANGELOG).
2026-09-07 23:12:51 -04:00
bruno 32c81f156d feat(settings): bouton "Réinitialiser" fournisseur IA + correctif JS (page complète)
FlowDeck CI / test (push) Failing after 16s
FlowDeck CI / docker (push) Skipped
- Nouveau bouton « 🗑 Réinitialiser » dans Réglages → Agent & IA (réservé aux
  admins) : bascule le fournisseur par défaut sur « hors-ligne » et efface la
  clé API/base/état vérifié stockés (clear_keys dans set_llm_config).
- Confirmation avant suppression des clés API utilisateur (deleteUserKey).
- Correctif : chaîne JS de confirm("…") sur deux lignes provoquait une erreur
  de syntaxe → le script alpine:init ne s'exécutait pas et la page paramètres
  restait figée sur « Agent & AI » ; la chaîne est désormais sur une ligne.
2026-09-07 20:48:25 -04:00
bruno 8b0a0aea3a feat(editor): collage intelligent — découpe du texte collé en plusieurs blocs
FlowDeck CI / test (push) Failing after 16s
FlowDeck CI / docker (push) Skipped
Analyse le contenu collé dans un bloc (paste2b) et le répartit en blocs selon
son format : titres markdown, listes à puces et numérotées, cases à cocher,
citations, séparateurs, blocs de code, tableaux et paragraphes. Le texte avant
le curseur est conservé dans le bloc courant (réutilisé si vide), les nouveaux
blocs sont insérés après, le texte après le curseur garde sa place, refocus sur
le dernier bloc inséré. Les blocs non textuels (code, tableau, image...) gardent
un collage en texte brut.
2026-09-07 20:17:08 -04:00
bruno 52d7a0d006 fix(local-workspace): boite de renommage a la taille du nom + bouton Open a cote
FlowDeck CI / test (push) Failing after 15s
FlowDeck CI / docker (push) Skipped
- La boite d'edition inline est desormais large comme le nom du fichier
  (largeur mesuree a l'ouverture via _fitNameWidth, min 60px, bornee a
  l'espace disponible pour les tres longs noms — le texte defile dedans).
- Le bouton Open est place juste a droite de la boite (marge 6px) au lieu du
  bord du panneau : deplace dans la cellule .name.file, passee en inline-flex
  (overflow hidden + max-width) pour ne jamais passer a la ligne.
- Applique aux deux rendus de l'arbre (vue principale x-for + renderChildren).
- Verifie en Chrome headless : input = largeur du nom (38%/12% de la ligne au
  lieu de 100%), bouton Open a 6px de la boite, pas de passage a la ligne.
- Tests : 278 passent.
2026-09-07 18:11:05 -04:00
bruno bd582866d1 fix(agent): validation robuste des modeles Nvidia (payload production + repli 404/410) + logo noir/blanc
FlowDeck CI / test (push) Failing after 15s
FlowDeck CI / docker (push) Skipped
Validation:
- _validate_chat_models utilise le payload de production (temperature 0.2,
  sans max_tokens) : un 200 a la validation == 200 a l'usage reel.
- Race corrigee : payload reconstruit par modele (plus de noms croises entre
  requetes concurrentes) — la v5.1.8 retombait par intermittence sur la liste
  brute (68 modeles dont la plupart en 404/410).
- Retente une fois sur timeout/5xx (les modeles lents mais fonctionnels
  survivent) ; rejette les 4xx (404 inconnu, 410 retire) ; garde les 429.
- Verifie en live contre l'API NVIDIA : 12 modeles valides au lieu de 68.

Repli runtime:
- LLMClient._http_complete : sur 404/410, retente une fois avec le modele par
  defaut du provider et marque un notice dans LLMResponse.
- AgentEngine emet un evenement SSE "notice" (bandeau .fd-ap-notice dans le
  panneau Agent, reset a chaque conversation) ; le modele reel est persistee.

UI:
- FAB (rond bas droite) et logo header du panneau : rond noir/blanc qui suit
  le theme clair/sombre (--text-primary / --bg-primary), eclair monochrome
  SVG a la place de l'emoji robot.

Tests:
- 2 nouveaux tests (429 garde / 410 retire ; repli runtime sur 410).
- 278 tests passent.
2026-09-07 14:45:28 -04:00
bruno b15289b4bc fix(editor+agent): multilignes conservees (cause reelle) + validation des modeles Nvidia
FlowDeck CI / test (push) Failing after 14s
FlowDeck CI / docker (push) Skipped
Editeur:
- sync() lisait el.textContent qui supprime les <br> : ajout de gt()
  (innerText) utilise par sync(), tableaux (toggle/columns) et _resultText.
- Scission sur Entree + limites haut/bas de bloc via splitCaret() (marqueur
  temporaire au curseur), plus de perte de fin de bloc multi-lignes.

Agent:
- fetch_provider_models() valide la liste nvidia : filtre des modeles
  non-chat (nom) puis probe reelle /chat/completions (6 paralleles, 6s),
  seuls les modeles 2xx restent. 429 conserve (route, donc utilisable).
- Repli sur la liste filtree si toutes les validations echouent.
- tests/test_llm_config.py : 5 cas (catalogue mock, 404 chat, autres
  providers non valides, chute de securite, offline). 276 tests passent.
2026-09-07 13:53:43 -04:00
bruno 6356cd5703 fix(editor): retours a la ligne conserves quand on change de bloc (Entree)
FlowDeck CI / test (push) Failing after 14s
FlowDeck CI / docker (push) Skipped
Shift+Entree insere des <br>; sync() lit textContent (des \n). mdEsc() (render
des blocs) n'encaissait pas les \n et le HTML les repliait en espace -> les
lignes disparaissaient au re-rendu apres la scission Entree.

mdEsc() convertit desormais les \n restants en <br> (apres les remplacements
markdown inline): ligne conservee pour tous les types de bloc.

v5.1.7
2026-09-07 12:39:50 -04:00
bruno 3956f1ffa5 fix(editor): placeholder titre "New Page" + aides de bloc au focus seul
FlowDeck CI / test (push) Failing after 14s
FlowDeck CI / docker (push) Skipped
- Titre : data-placeholder="New Page" + bascule .empty a l'input (mecanisme des
  blocs). Le placeholder s'affiche en gris pale quand le titre est vide, y compris
  apres effacement. save() stocke un titre vide (plus de "New page" force), la
  sidebar garde son fallback, duplicatePage retombe sur "New Page".
- Blocs : les placeholders ("Press 'space' for AI...", "Heading 1", "List"...)
  passent sous :focus-within -> ne s'affichent que quand le bloc est focalise.
  Idem pour "Type code..." du bloc code.

v5.1.6
2026-09-07 12:25:29 -04:00
bruno d96fb4171e fix(editor): placeholder des blocs disparait des la frappe (input bascule .empty)
FlowDeck CI / test (push) Failing after 15s
FlowDeck CI / docker (push) Skipped
La classe .empty (qui pilote les placeholders "Press 'space' for AI or '/' for
commands", "Heading 1", "List", etc.) n'etait basculee qu'au render(). Pendant
la frappe, l'evenement input mettait a jour dirty/autoSave mais jamais .empty :
le texte d'aide restait visible sur un bloc plein.

Le listener input des blocs bascule desormais .empty en direct : texte saisi ->
placeholder disparait; bloc vide -> placeholder reapparait.

v5.1.5
2026-09-07 12:06:18 -04:00
bruno 63a41ade48 fix(editor): placeholder visible, fleches menu /, icones callout/image, largeur composer AI
FlowDeck CI / test (push) Failing after 15s
FlowDeck CI / docker (push) Skipped
- Placeholder "Press 'space'..." : la classe .empty est basculee sur l'element
  interne [data-bid], mais la regle CSS ciblait .block-content (l'exterieur),
  donc le ::before ne se rendait jamais. La regle cible desormais
  .block-content [data-bid].empty::before.
- Fleches haut/bas du menu / : ajout d'un ecouteur keydown au niveau document
  (actif quand le menu est ouvert) pour naviguer quel que soit le focus
  (l'input vit dans #_slashMenu, voisin de #_blocksCt). Selection .selected en
  !important pour gagner sur :hover + auto-scroll de l'element selectionne.
- Item "callout" du menu / : icone texte 'lightbulb' remplacee par l'emoji
  (et 'image' par ). Le texte n'est plus mal positionne.
- Composer "Edit with AI" : largeur alignee sur celle du bloc (min 280px).

v5.1.4
2026-09-07 11:56:16 -04:00
bruno 43aca1a1f7 fix(editor): AI composer 400, slash-menu arrows, drag handle, block outline, hint
FlowDeck CI / test (push) Failing after 14s
FlowDeck CI / docker (push) Skipped
- AI (space) -> 400 DeepSeek: fdAgent.generate now uses the clean no-tools
  /api/agent/generate endpoint instead of the tool-enabled conversation engine
  (which sends tools/tool_choice schemas DeepSeek rejects). Fixes the composer,
  Ask AI, AI meeting note and summarize flows.
- Slash menu arrows: selection highlight was invisible on open (nothing marked),
  so up/down appeared dead. Call _rs() on open + after filtering, and give the
  selected item an accent background + left bar. Verified with jsdom harness:
  Down/Up move selection, Enter applies.
- Block outline when selected: suppress focus rings (outline/box-shadow) on all
  editable blocks ([data-bid], .block-content, page title).
- Drag handle (6-dot): add .block-wrapper:hover/:focus-within .block-handle so
  it shows on hover for normal blocks (only column-wrappers showed it before).
- Empty-block hint: brighter (--text-secondary, opacity .75) so the
  "Press space for AI or / for commands" text is clearly visible on empty paras.
- VERSION + app.main -> 5.1.3; CHANGELOG updated.
- 271 tests pass; page renders 200; /api/agent/generate route registered;
  JS validated (node --check + jsdom).
2026-09-07 11:35:24 -04:00
bruno d6bb424021 feat(editor): Notion AI-style block UX (hint, compact slash menu, inline AI composer)
FlowDeck CI / test (push) Failing after 14s
FlowDeck CI / docker (push) Skipped
- Empty paragraph placeholder: "Press space for AI or / for commands".
- Slash menu redesigned: compact items (icon + name + shortcut #/##/### on
  right), friendly group labels, 'Close menu (esc)' footer, 'Type to search'
  input at bottom, and hover preview card (e.g. 'Our Values' H2 sample).
- Inline AI composer (AIC): space on empty block opens 'Edit with AI' pill;
  Enter -> agent generate with animated 'Brewing...' (stop button) -> framed
  markdown result + thumbs/thumbdown + 'Insert below' -> applyAIBlocks (md2b).
- CSS: ai-pulse animation, .aici-result styles, .sm-* tweaks; app.css cache.
- VERSION + app.main -> 5.1.2; CHANGELOG updated.
- 271 tests pass (no regression); editor renders 200 (hint/menu/AIC present);
  JS validated via node --check.
2026-09-07 10:52:23 -04:00
bruno 4dc06eb203 feat(ui): natural markdown rendering in editor blocks & agent panel
FlowDeck CI / test (push) Failing after 14s
FlowDeck CI / docker (push) Skipped
- Editor: applyAIBlocks() and fdApplyDocument(replace) now convert agent
  markdown into real blocks via md2b() (## -> heading_2, GFM tables ->
  table block, ` -> code, lists, to-do, quote, divider). No more literal
  '## ' shown.
- Editor: inline markdown rendered in blocks via mdEsc() (bold/italic/
  inline-code/links/strikethrough) in renderBlock for paragraphs, headings,
  lists, to-do, toggle, quote, callout, code.
- Agent panel: renderMarkdown() renders assistant responses as HTML (H1-H4,
  GFM tables, code blocks, lists+checkboxes, quotes, hr, inline marks);
  content escaped before rendering (XSS-safe vs LLM). CSS .fd-md-* added.
- VERSION + app.main -> 5.1.1; CHANGELOG updated.
- 271 tests pass (no regression); markdown render verified in Node; editor &
  panel render 200 with new components.
2026-09-07 10:18:47 -04:00
bruno e4b196a528 feat(db): v5.3.0 inline databases, predefined templates & property validation
FlowDeck CI / test (push) Failing after 12s
FlowDeck CI / docker (push) Skipped
- Slash command /database (DATA group) -> template picker -> inline DB
  embed block rendered by FlowDeckDB. 'Get Started > Database' uses it too.
- 6 seeded database templates (CRM, Project tracker, Task list, Content
  calendar, Meeting notes, Reading list) with icon + schema; new service
  app/services/db_templates.py (materialize_properties, create_from_template).
- POST /db/api and /db/inline/api accept 'template' and materialize
  collection_properties. database_templates gets an icon column (migration v4).
- Property validation: collection_properties.validation_json (migration v4);
  validate_property_rule() in property_types (required/unique/min/max/
  min_length/max_length); enforced in create/update page API (400 + message,
  unique excludes current row); property API accepts 'validation'.
- UI: add-property modal exposes Required/Unique/Min/Max; cell edit shows
  validation errors (red outline + toast) and reverts.
- VERSION + app.main -> 5.1.0; CHANGELOG + ROADMAP updated.
- +9 tests (tests/test_db_advanced.py). 271 passed.
2026-09-07 00:53:13 -04:00
bruno 0c271d5972 feat(core): v5.0.0 command palette + FTS5 search, v5.2.0 versioned migrations
FlowDeck CI / test (push) Failing after 12s
FlowDeck CI / docker (push) Skipped
- Command palette Ctrl+K/Ctrl+P (base.html): universal search, fuzzy
  highlight, keyboard nav, quick actions. openQuickFind now opens it.
- GET /api/search endpoint + search service: unified pages + databases
  search, FTS5 with LIKE fallback, trash excluded, user-scoped.
- app/migrations.py: lightweight versioned migration runner (schema_version
  table); baseline schema = v1, new steps applied in order.
- Migration v2: missing indexes (users.email, user_oauth_tokens, etc).
- Migration v3: FTS5 pages_fts + sync triggers + backfill.
- VERSION + app.main -> 5.0.0; CHANGELOG + ROADMAP updated.
- +8 tests (tests/test_search_migrations.py). 259 passed (3 pre-existing
  PDF/weasyprint env failures unrelated).
2026-09-06 19:23:31 -04:00
bruno 65559e5069 fix(agent): v4.15.5 - test de connexion provider ne fuit plus le modele global
FlowDeck CI / test (push) Failing after 11s
FlowDeck CI / docker (push) Skipped
- LLMClient : default_model scope par provider — tester nvidia alors que deepseek est le provider actif ne lui envoie plus 'deepseek-v4-flash' (cause du 404 'model not found' de NVIDIA)
- presets NVIDIA actualises (anciens modeles retires de la plateforme / premium)
- test de regression ajoute (46 passed)
2026-09-06 16:02:29 -04:00
bruno e4d17eb96c fix(agent): v4.15.4 - menus @ vides et selection clavier / non visible
FlowDeck CI / test (push) Failing after 11s
FlowDeck CI / docker (push) Skipped
- menu de mentions : template Alpine avec racine unique (les elements s'affichent maintenant sous chaque section; avant, seuls les titres de sections etaient rendus)
- menu des skills : style .focus ajoute (surlignage fond + barre d'accent) pour rendre la selection clavier visible
2026-09-06 14:17:56 -04:00
bruno 2391de418d feat(agent): v4.15.3 - mentions @ par espace courant + skills composes en arriere-plan
FlowDeck CI / test (push) Failing after 11s
FlowDeck CI / docker (push) Skipped
- /api/agent/mentions : reponse en sections (fichiers de l'espace courant ouvert, collections, pages de collections, autres documents), workspace_id transmis par le frontend (localWsId) et prioritaire
- Navigation clavier up/down : la liste defile pour garder la selection visible (menus @ et /)
- Deduplication des propositions (meme type + meme titre affiches une seule fois)
- Skills : plus d'injection du texte du skill dans la boite d'edition; requete composee en arriere-plan (contexte + skills + texte) a l'envoi; les skills integres injectent leur template, les enregistres partent via skill_ids
- Tests : shape des mentions, dedup, scoping workspace
2026-09-06 13:57:03 -04:00
bruno 917a04d543 feat(agent): v4.15.2 - skills multi-epingles en bulles + chips de contexte cliquables
FlowDeck CI / test (push) Failing after 11s
FlowDeck CI / docker (push) Skipped
- Les skills (menu /, integres et enregistres) s'epinglent comme des bulles dans le composer, au meme titre que les mentions @; plusieurs peuvent coexister sur un meme post
- L'API /run accepte skill_ids (tableau) et l'engine injecte tous leurs prompts dans le system prompt (skill_id conserve pour retro-compat)
- Clic sur une bulle de contexte (@ document/page/base) ouvre l'element comme document courant (/pages/<id> ou /db/<id>); le bouton x retire sans ouvrir
- Envoi possible avec des skills seuls sans texte
- test_engine_multiple_skills_applied
2026-09-06 13:27:38 -04:00
bruno 113f880863 fix(agent): v4.15.1 - protocole tool_calls conforme + plus de repli silencieux sur le mock (echos)
FlowDeck CI / test (push) Failing after 11s
FlowDeck CI / docker (push) Skipped
- L'engine envoie le message assistant avec ses tool_calls (id) et chaque resultat d'outil avec son tool_call_id, y compris en cas de refus; la passe suivante n'est plus refusee par l'API
- Un fournisseur reel configure qui echoue remonte maintenant une erreur (SSE error) au lieu de repeter la question via le mock hors-ligne (mock reserve a offline/sans cle)
- llm_client conserve id + arguments_raw des tool_calls
- test de regression test_engine_tool_protocol_messages
2026-09-06 13:06:41 -04:00
bruno 571d78115b fix(agent): boutons d'action a icones seules + contexte d'ouverture en puce de mention (@) au lieu du bandeau statique
FlowDeck CI / test (push) Failing after 11s
FlowDeck CI / docker (push) Skipped
2026-09-06 12:53:16 -04:00
bruno 27c9eb98db feat(agent): v4.15.0 - panneau Agent style Notion AI (mentions @ inline + skills / + actions sous chaque réponse)
FlowDeck CI / test (push) Failing after 15s
FlowDeck CI / docker (push) Skipped
- Composeur contenteditable : mentions inline (pastilles icône+nom), barre de contexte groupée documents/skills, sélecteur @ (icône+titre+chemin), skills intégrés + agent_skills + commandes admin via '/'
- Actions sous chaque réponse agent : copier, insérer dans la page, feedback 👍/👎
- Backend : table agent_feedback, GET /api/agent/mentions, POST /api/agent/feedback, contexte document:<id>/page:<id>/collection:<id> résolu en contenu réel
- Inclut le travail v4.14.0 (documents/espace de travail + outils + auto-titre de conversation)
2026-09-06 12:17:40 -04:00
bruno 3025a7a44e fix(agent): v4.13.2 - saisie libre redaction avec doc ouvert -> proposition appliquer/rejeter (generate sans outils)
FlowDeck CI / test (push) Failing after 9s
FlowDeck CI / docker (push) Skipped
2026-09-06 01:24:14 -04:00
bruno 75b7f29826 fix(agent): v4.13.1 - actions contenu document (resume/traduire/ameliorer/meeting) sans outils, apercu + appliquer/rejeter, mock ignore contexte
FlowDeck CI / test (push) Failing after 14s
FlowDeck CI / docker (push) Skipped
2026-09-06 01:14:52 -04:00
bruno 0b63ed17bc feat(agent): v4.13.0 - panneau Agent style Notion AI, ouverture fiable (Ctrl+J), contexte universel epingle a l'ouverture, selecteur provider/modele
FlowDeck CI / test (push) Failing after 9s
FlowDeck CI / docker (push) Skipped
2026-09-06 00:37:56 -04:00
bruno b594458b6e fix(agent): v4.12.1 - champs cle API/URL API dans la config, AI Meeting Note en bloc de reunion interactif (transcription live + resume)
FlowDeck CI / test (push) Failing after 9s
FlowDeck CI / docker (push) Skipped
2026-09-05 23:26:18 -04:00
bruno c322f30801 feat(agent): v4.12.0 - refonte config 'Agent & IA', fournisseurs actifs/testes dans le panneau, contexte document + guide d'utilisation
FlowDeck CI / test (push) Failing after 9s
FlowDeck CI / docker (push) Skipped
2026-09-05 22:47:16 -04:00
bruno bd109c273a fix(agent): priorite a la generation de contenu dans le mock (contexte 'collection')
FlowDeck CI / test (push) Failing after 9s
FlowDeck CI / docker (push) Skipped
2026-09-05 21:55:17 -04:00
bruno f57f66a93a feat(agent): mock hors-ligne genere du contenu utile (Ask AI page + AI meeting note)
FlowDeck CI / test (push) Failing after 8s
FlowDeck CI / docker (push) Skipped
2026-09-05 21:53:11 -04:00
bruno 9ba3480d4e feat(agent): v4.11.1 - branche entrées IA sur le FlowDeck Agent (Ask AI, AI meeting note, bouton flottant)
FlowDeck CI / test (push) Failing after 8s
FlowDeck CI / docker (push) Skipped
2026-09-05 21:49:42 -04:00
bruno 34368a4946 feat(agent): v4.11.0 - clés API par utilisateur, chargement dynamique modèles, commandes slash
FlowDeck CI / test (push) Failing after 7s
FlowDeck CI / docker (push) Skipped
2026-09-05 11:47:58 -04:00
bruno 1c11b9d351 fix(agent): version FastAPI -> 4.10.1 (health/redoc)
FlowDeck CI / test (push) Failing after 7s
FlowDeck CI / docker (push) Skipped
2026-09-05 11:00:24 -04:00
bruno 3cb9edc1f3 feat(agent): v4.10.1 - config LLM dans l'UI (sélecteur provider/modèle, config runtime, admin + test connexion)
FlowDeck CI / test (push) Failing after 7s
FlowDeck CI / docker (push) Skipped
- Panneau agent : selects provider/modèles connus persistés par conversation
  (colonnes agent_conversations.provider/model, migration idempotente)
- GET /api/agent/providers enrichi (liste providers + modèles + requires_key)
- PATCH /api/agent/providers (admin) : config runtime DB-backed (table llm_config)
- POST /api/agent/providers/test : test connexion via LLMClient.ping() sans fallback mock
- Settings > Admin > Agent & IA : provider, modèle, clé API, URL API, save + test
- VERSION -> 4.10.1, CHANGELOG + ROADMAP mis à jour, 232 tests verts
2026-09-05 10:58:09 -04:00
bruno e752e46583 feat(agent): v4.10.0 FlowDeck Agent - agent IA ReAct (SSE, 18 outils + rollback, permissions, skills, triggers, multi-LLM)
FlowDeck CI / test (push) Failing after 7s
FlowDeck CI / docker (push) Skipped
2026-09-05 09:58:04 -04:00
bruno 9eedfa67ca docs: guide complet des API (API_GUIDE_V6.md) — référence implémentation v6.0.0
FlowDeck CI / test (push) Failing after 6s
FlowDeck CI / docker (push) Skipped
2026-09-05 01:16:43 -04:00
bruno 56fa5dcd61 docs: roadmap — ajout v5.5.0 à v5.9.0 (analyse Notion clone)
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Skipped
2026-09-05 00:45:11 -04:00
bruno 667eb6534d Update multi-LLM roadmap list with new providers
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Skipped
2026-09-04 23:57:09 -04:00
bruno b60cc8a7c6 feat(collab): v4.9.0 Collaboration - commentaires inline, mentions @, notifications in-app + email
FlowDeck CI / test (push) Failing after 6s
FlowDeck CI / docker (push) Skipped
2026-09-04 23:40:21 -04:00
bruno 23df10732b fix(editor): v4.8.1 bloc Tableau - +Row, +Colonne a droite, redim colonnes
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Skipped
- + Row (bas) ne fonctionnait pas: splice(index,ligne) -> splice(index,0,ligne)
- bouton + a droite du tableau = ajoute une colonne a droite
- redimensionnement colonnes a la souris (curseur col-resize) + persistance colsW
- VERSION/CHANGELOG/ROADMAP/css?v bump 4.8.1
2026-09-04 12:10:22 -04:00
bruno c809a864e6 feat(editor): v4.8.0 Bloc Tableau simple (edition Notion via /table)
FlowDeck CI / test (push) Failing after 20s
FlowDeck CI / docker (push) Skipped
- Bloc table editable: slash /table, cellules contenteditable, auto-save
- Colonnes: poignee par colonne -> Insert left/right, Duplicate, Clear, Delete
- Lignes: + Row (bas) + menu contextuel -> Insert above/below, Dup, Clear, Del
- md2b importe les tableaux GFM pipe en bloc table (au lieu de paragraphes)
- Roundtrip markdown (JS + export Python MD/HTML/PDF)
- CSS .ftable-editor + menu flottant (app.css?v=4.8.0)
- VERSION/CHANGELOG/ROADMAP bump 4.8.0 (Collaboration -> v4.9.0, Agent -> v4.10.0)
2026-09-03 14:55:25 -04:00
bruno 6e30589133 fix(export): v4.7.3 tableaux + emojis en PDF/HTML
FlowDeck CI / test (push) Failing after 20s
FlowDeck CI / docker (push) Skipped
- Tableaux GFM rendus comme texte brut dans les exports HTML/PDF. Ajout d'un
  parseur de tableaux pipe (bloc 'table') + rendu <table> (thead/tbody,
  alignement gauche/centre/droite, bordures .ftable) dans blocks_to_html;
  blocks_to_markdown reconstruit un tableau pipe valide.
- Emojis 'carrés noirs' en PDF: xhtml2pdf n'embarque que des polices de base
  sans glyphes emoji. Moteur PDF -> WeasyPrint (tables CSS + emojis couleur via
  pango + fonts-noto-color-emoji installes dans l'image). Repli automatique sur
  xhtml2pdf quand weasyprint n'a pas ses libs natives (dev Windows).
- Dockerfile: libs weasyprint (pango/harfbuzz/gdk-pixbuf/shared-mime-info) +
  fonts-dejavu-core + fonts-noto-color-emoji. requirements: + weasyprint==69.0.
- Verifie en reel sur README.md: HTML = <table class=ftable> (thead/th, center);
  PDF 10 pages, texte de table present, Noto-Color-Emoji embarque + pixels
  colores confirmes. 199/199 tests (4 nouveaux).
2026-09-03 01:59:39 -04:00
bruno fa97f07ec8 fix(export): v4.7.2 contenu des documents absent des exports MD/HTML/PDF
FlowDeck CI / test (push) Failing after 19s
FlowDeck CI / docker (push) Skipped
Le service d'export ne lisait que les pages content_format='blocks'. Les docs
stockees autrement sortaient avec le seul titre:
- content_format='file' (.md/code uploades): content=JSON meta, le vrai texte
  est sur disque (/data/uploads/workspace_*) -> n'etait jamais lu.
- content_format='markdown': HTML/PDF enveloppait chaque ligne en <p> (headings
  et listes aplatis).

Resolution de la vraie source pour les 3 formats (app/services/export.py):
- lit le fichier upload sur disque pour les pages file (repertoire via
  FLOWDECK_DATA_DIR, defaut /data),
- rend les pages markdown / fichiers .md en blocs (headings, listes, code,
  quote, todo) pour un HTML/PDF riche,
- fichiers texte non-markdown -> bloc de code,
- binaires (PDF/images) ignores.

195/195 tests (5 nouveaux v4.7.2). Verifie en reel via HTTP sur README.md et
l'arborescence Base de Connaissances (contenu complet dans les 3 formats).
2026-09-03 01:29:35 -04:00
bruno 5390a6ceab chore: ignorer uv.lock (genere par uv run local, pas le gestionnaire de deps du projet)
FlowDeck CI / test (push) Failing after 19s
FlowDeck CI / docker (push) Skipped
2026-09-03 01:09:41 -04:00
bruno aa2db0103d fix(editor): v4.7.1 popovers Share/More/Activity/Move rendus sous le viewport + sous-menu Export inatteignable
FlowDeck CI / test (push) Failing after 19s
FlowDeck CI / docker (push) Skipped
- share-dialog/more-menu/activity-popover/move-dialog: enfants de .page-editor-wrapper
  (overflow-y:auto) ancores en absolute top:100% -> invisibles sous le viewport.
  Repositionnes en fixed sous la topbar, scope .page-editor-wrapper > (pas de
  regression sur .more-menu de library/local_workspace) + variante mobile <768px.
- Item Export du menu More faisait moreOpen=false avant d'ouvrir exportOpen:
  les 4 formats etaient inaccessibles. Devient un toggle, le menu reste ouvert.
- Cache busting app.css v=4.7.1; VERSION/main.py bump 4.7.1; CHANGELOG+ROADMAP a jour.
- Verifie Playwright headless: Share/More/Export/download Markdown/toast OK, 0 pageerror; 190/190 tests.
2026-09-03 01:09:31 -04:00
bruno 2bdf5e4166 feat(export): v4.7.0 Export — Markdown, PDF, HTML, Site statique
FlowDeck CI / test (push) Failing after 19s
FlowDeck CI / docker (push) Skipped
- Service serveur app/services/export.py: conversion blocs vers Markdown / HTML / PDF
- 4 endpoints: /api/export/markdown|html|pdf|site/{page_id}
- Export Markdown complet (tous les blocs + images + sous-pages recursives)
- Export PDF via xhtml2pdf (pur Python, aucune lib systeme)
- Export HTML standalone self-contained (styles inline)
- Export Site: zip multi-pages (index.html + une page par sous-page)
- UI: menu 'More > Export' dans l'editeur (Markdown, HTML, PDF, Site .zip)
- Tests: 190 passing (6 nouveaux)
2026-09-03 00:26:58 -04:00
bruno f7f5bae336 chore: corriger version (4.6.0) dans le log de démarrage
FlowDeck CI / test (push) Failing after 20s
FlowDeck CI / docker (push) Skipped
2026-09-03 00:07:13 -04:00
bruno 814dbe8c2e feat(content): v4.6.0 Content Blocks enrichis — Callout, TOC, Math (KaTeX), Toggle, Multi-colonnes
FlowDeck CI / test (push) Failing after 20s
FlowDeck CI / docker (push) Skipped
- Ajout blocs enrichis dans l'éditeur: callout (avec sélecteur d'emoji), table of contents (ancres), math (LaTeX/KaTeX), toggle lists (enfants collapsibles), multi-colonnes
- Intégration KaTeX 0.16.11 self-hosté (JS/CSS/fonts) — aucun CDN externe
- Rendu des nouveaux blocs dans les pages publiques (/p/<slug>): TOC, KaTeX, colonnes, toggle <details>
- Persistance 'children' (colonnes/toggle) + export Markdown étendu
- Sidebar customization par utilisateur (config API + colonne sidebar_config)
- Correctif test_views_calendar: parsing des query params year/month (le défaut ouvrait sur le mois courant)
- Tests: 184 passing
2026-09-03 00:05:33 -04:00
bruno d12681720d fix: icônes SVG affichées en texte brut dans les sous-répertoires Gitea (sidebar)
FlowDeck CI / test (push) Failing after 19s
FlowDeck CI / docker (push) Skipped
loadSubdir() créait les icônes des enfants avec textContent au lieu de
innerHTML : le markup SVG (<svg width=...>) retourné par getSvgIcon() était
affiché comme texte brut quand on ouvrait un répertoire de la section Gitea.
Le rendu racine (loadSidebarTree) utilisait déjà innerHTML ; aligné.
2026-09-02 13:20:56 -04:00
bruno e64a60c42b fix: connexion OAuth Gitea — redirect URI invalide (erreur 'Unregistered Redirect URI')
FlowDeck CI / test (push) Failing after 21s
FlowDeck CI / docker (push) Skipped
Le /auth/login construisait le redirect_uri avec le schéma http:// en dur
et dupliquait l'expression dans login/callback : toute URL d'accès non
enregistrée (https, reverse proxy, hostname, port différent) était rejetée
par Gitea avec 'Unregistered Redirect URI' — les 2 liens 'Connect Gitea'
et 'Register with Gitea' de Workspaces → Gitea Projects étaient touchés.

- nouveau helper get_redirect_uri(request) : override explicite
  OAUTH_REDIRECT_URI (si défini), sinon schéma depuis X-Forwarded-Proto
  (fallback request scheme) + hôte depuis X-Forwarded-Host (fallback Host)
- redirect_uri stocké en session à l'authorize et réutilisé tel quel dans
  l'échange de code (plus de dérive entre les deux étapes)
- même correctif dans GitHubProvider.exchange_code (ignorait le paramètre)
- config : oauth_redirect_uri par défaut vide (dynamique) au lieu de
  localhost:8080 en dur
- .env.example documente OAUTH_REDIRECT_URI
- 4 tests de régression (host header, X-Forwarded-Proto/Host, override env,
  URL d'authorize) — 178/179 OK, l'échec restant (test_views_calendar) est
  pré-existant et dépend de la date
2026-09-02 10:52:25 -04:00
bruno 151ae4a3aa Add screenshots for Notion database property types
FlowDeck CI / test (push) Failing after 16s
FlowDeck CI / docker (push) Has been skipped
2026-07-22 22:01:05 -04:00
bruno 4939eb5a12 fix(database): save embed block properties (embed_type, collection_id, dbs, file_*)
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
The save() function's blocks.map() only copied 7 properties, missing:
embed_type, collection_id, dbs, file_name, file_size, file_mime.
Embed blocks lost their collection reference on save → page reload
showed empty page instead of database table.
2026-07-22 19:41:03 -04:00
bruno dcd7932a58 fix(database): self-contained modals + side peek for inline embeds
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
- Property modal: dynamically created per DBInstance (no global ID dependency)
- Column context menu: dynamically created (no global ID dependency)
- Side peek panel: auto-created on first open (no template dependency)
- All UI elements now work for both inline embeds and full-page views
- Cell editing + save use correct CSRF token via getCsrf()
2026-07-22 18:41:39 -04:00
bruno 7c922088c8 fix(database): init inline DB blocks via DOM scan after render
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
Scripts in innerHTML don't execute per HTML spec. Instead, the render
function now scans for .block-embed-collection elements and calls
FlowDeckDB.renderInto() on each one via setTimeout after DOM update.
2026-07-22 17:11:03 -04:00
bruno 6ebfc245f1 fix(database): remove prompt popup, create database inline with default name
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
Clicking Database button now creates the table directly in the page
without asking for a name (uses 'Database' as default). Same pattern
as Notion: one click, inline embed, no popup.
2026-07-22 17:02:14 -04:00
bruno 59fc7b7975 fix(database): escape </script> in embed block template literal
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
The </script> tag inside a JS template literal was prematurely closing
the outer <script> block, causing 'Unexpected end of input' syntax error
and cascading Alpine.js 'not defined' errors.
2026-07-22 16:57:06 -04:00
bruno 049861828d fix(test): update test for empty default title
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-22 16:52:53 -04:00
bruno c586513b03 feat(database): inline embed + Open button + side peek + title placeholder
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Page title placeholder: new pages created with empty title, CSS :empty::before shows 'New page' in gray
- Database is now inline embed within page (not full-page replacement)
- Embed block 'collection' renders database table via FlowDeckDB.renderInto()
- Open button in column 1 of each row opens page in side peek panel
- Side peek: iframe loading /pages/{id}?embed=1, close/fullscreen/resize
- DB scripts refactored as reusable DBInstance + global FlowDeckDB API
2026-07-22 16:52:19 -04:00
bruno a7289db621 fix(database): column resize uses direct coordinate calculation
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
Replace delta-based approach with direct mouse-position-to-column-edge calculation:
newW = ev.clientX - thLeft. No setPointerCapture needed, no coordinate drift.
2026-07-22 16:32:41 -04:00
bruno 9c4de01fd2 fix(database): column resize follows mouse, inline rename in context menu
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
- Column resize: use getBoundingClientRect() + setPointerCapture for proper tracking
- Context menu 'Name' field: click to edit, Enter to save, Escape to cancel
- Rename persists to server via PUT /db/properties/{id}/api
2026-07-22 16:23:02 -04:00
bruno 461492eede feat(database): column resize, context menu, SVG icons, move New Page to bottom
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Column resize: drag handle between headers to resize column width
- SVG outline icons per property type in header (Aa, #, ☰, 📅, ☑, 🔗, ✉, 📞)
- Header context menu on click: Show page icon toggle, AI Autofill, Filter,
  Sort (asc/desc), Group, Calculate (None/Count/Percent), Freeze, Wrap,
  Insert left/right
- Submenus for AI Autofill, Sort, Calculate with nested options
- New Page button moved below last data row (bottom of table)
- New rows inserted at end of table
2026-07-22 16:02:31 -04:00
bruno ea81e85848 fix(database): add missing 'import json as _json' in api_create_collection_page
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-22 14:44:10 -04:00
bruno 50273fcb1a fix(database): rewrite table view in vanilla JS (Alpine 3.14.9 compat)
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
Alpine 3.14.9 evaluates x-data expressions before Alpine.data() registration,
causing 'pages is not defined', 'emptyRows is not defined', etc.
Solution: pure vanilla JS DOM rendering (same pattern as Library fix).
- _database_table.html: static DOM with IDs only, no Alpine directives
- _database_table_scripts.html: vanilla JS state, render, cell editing, modals
- Removes all x-data, x-for, x-show, x-model from database table
2026-07-22 14:40:22 -04:00
bruno 24a760c5eb feat(database): full-page database conversion (Notion-style)
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
- Migration: add collection_id column to pages table
- POST /api/pages/{id}/convert-to-database: transforms page into database
- GET /api/collections/{id}/table-data: returns props + pages for table view
- POST /api/collections/{id}/pages: creates new row in collection
- New page_editor_collection.html template for database view
- _database_table.html: Notion-style table with columns, rows, New Page, Add Property
- Alpine.js component for cell editing, new page creation, property mgmt
- CSS: complete database table styling (view bar, table, cells, modals, dropdowns)
- Frontend: createDatabase() now converts page to full-page DB (was inline embed)
2026-07-22 14:34:39 -04:00
bruno f7d87e6555 fix resize: use pointer capture to handle drag over iframe
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
When dragging the resize handle rightward to narrow the panel, the
mouse cursor moved over the iframe which captured mouse events.
document-level mousemove stopped firing, making narrowing impossible.

Fix: replace mouse events with pointer events + setPointerCapture.
The handle element captures ALL pointer events during drag regardless
of where the cursor moves (over iframe, outside window, etc).
Applied to both local_workspace.html and library.html.
2026-07-22 11:18:25 -04:00
bruno 1b3aed19ff modular editor: extract original scripts into _page_editor_scripts.html
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
Now 3 shared modules (zero code duplication):
- _page_editor_content.html — editor HTML (title block, slash menu, toolbar etc)
- _page_editor_scripts.html — 100% original editorState() JS (CMDS, render,
  save, slash, database, share/publish, favorites, formatting, etc)
- Used by page_editor.html (full page) and page_editor_embed.html (peek iframe)

Embed page_editor_embed.html now has the exact same editor as full page.
One change to _page_editor_scripts.html updates both views.
2026-07-22 11:10:54 -04:00
bruno c7c6e52deb fix: dashboard.py duplicate /pages/{id} route was intercepting embed requests
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
Root cause: dashboard.router registered BEFORE board.router in main.py,
so dashboard's /pages/{page_id} handler got all requests and ignored
the embed parameter. board.py's embed-aware route was never reached.

Fix: add embed support to dashboard.py's view_page_root too:
- Detect ?embed=1 query param
- Select page_editor_embed.html when embed=True
- Pass embed_mode to template context
2026-07-22 11:02:48 -04:00
bruno 4bceb7ce91 embed mode: force embed-mode class via JS since Jinja2 context not flowing
FlowDeck CI / test (push) Failing after 8s
FlowDeck CI / docker (push) Has been skipped
The embed_mode variable set in board.py context wasn't reaching
base.html through Jinja2 extends. Added document.body.classList.add
in page_editor_embed.html content block to force the class, which
triggers the CSS rules hiding sidebar, topbar, and header.
2026-07-22 11:00:13 -04:00
bruno cd6dac9ea6 embed mode: pass embed_mode to context, add conditional body class + CSS
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- board.py: pass embed_mode=True to template context when ?embed=1
- base.html: <body class="embed-mode"> when embed_mode is true
- CSS: body.embed-mode hides .sidebar, .topbar, .unified-header,
  .header-actions; removes app-layout margin/padding; adjusts editor
  padding and max-width for peek panel
- Sidebar and header now properly hidden in iframe via CSS
2026-07-22 10:56:29 -04:00
bruno afe670bdd3 peek panels: reusable editor module — shared _page_editor_content.html + embed template
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
Architecture for zero code duplication:
- Extract page editor HTML into _page_editor_content.html (shared include)
- page_editor.html includes it (same behavior as before)
- page_editor_embed.html includes it with empty topbar (no header in peek)
- board.py: ?embed=1 renders page_editor_embed.html

Peek panels now load /pages/{id}?embed=1 in iframe:
- Editor renders without sidebar or header breadcrumb/actions
- Full editing capability (blocks, markdown, slash commands, save)
- Same code — one change to _page_editor_content.html updates everywhere
2026-07-22 10:50:02 -04:00
bruno e32abfbfa6 revert iframe approach, restore vanilla JS peek + re-add resize handles
FlowDeck CI / test (push) Failing after 6s
FlowDeck CI / docker (push) Has been skipped
Reverted the iframe-based peek panels (d13f137, 99fee56) which caused:
- Sidebar/header still visible inside iframe despite embed CSS
- Resize handle broken by iframe layout

Restored:
- Vanilla JS content loading via API (_loadPreviewContentVanilla)
- Peek-body div instead of iframe
- Resize handles on both library and local-workspace panels
  (click=close, drag=resize, width persisted in localStorage)
2026-07-22 10:34:50 -04:00
bruno 514b1da9a7 Revert "peek panels: load full page editor in iframe + resize handles"
This reverts commit d13f13785b.
2026-07-22 10:32:33 -04:00
bruno d19668e60f Revert "embed mode: hide header breadcrumb + right actions, show only editor"
This reverts commit 99fee56089.
2026-07-22 10:32:33 -04:00
bruno 99fee56089 embed mode: hide header breadcrumb + right actions, show only editor
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Hide .unified-header and .header-actions in embed-mode (not just .topbar)
- Hide .topbar-right specifically (Edited, Share, Copy link, Favorite, ...)
- Set .page-editor-wrapper padding-top to 12px (no header gap)
- Only the document editor/content is visible in the iframe now
2026-07-22 10:21:53 -04:00
bruno d13f13785b peek panels: load full page editor in iframe + resize handles
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
- Add ?embed=1 to /pages/{id} route, passes embed_mode to template
- base.html: embed-mode class hides sidebar/topbar, removes margins
- Replace peek-body content with iframe loading /pages/{id}?embed=1
  in both library.html and local_workspace.html
- Remove async content loading (now editor handles rendering)
- Add resize handle on left edge of peek panels (both library + local-ws)
  - Click without drag: closes panel
  - Click + drag horizontal: resizes panel width (300px to 90vw)
  - Width persisted in localStorage (fd_peek_width)
- 100% code reuse: editor runs same /pages/{id} with embed flag
2026-07-22 10:10:21 -04:00
bruno 348793ba13 local-workspace: fix peek-header icons vertical stacking — add missing flex CSS
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
The .peek-header class from library.html CSS was not available on the
local-workspace page, causing header buttons to stack vertically.
Added inline display:flex;align-items:center;gap:4px on the header div.
2026-07-22 09:43:51 -04:00
bruno cbba7c506a local-workspace: match preview panel design to Library peek-overlay
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Same 560px width, same header layout (close | icon+title | open | fav | copy | more)
- Header uses peek-header class from library CSS for identical styling
- Meta info (type, size, date) moved inline into body as subtle header
- Tags moved inline into body below meta
- More menu with Open, Favorite, Copy link, Close actions
- Copy link button uses navigator.clipboard API with toast feedback
- Bottom action buttons removed (now in header + more menu)
- Empty state matches library (file icon + 'Select a file to preview')
2026-07-22 09:37:29 -04:00
bruno 38a188e6e2 local-workspace: fix preview content rendering for all document types
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
Root cause: blocks format content is a direct array [{id,type,content}],
not wrapped in {blocks:[...]}. Old code checked blocks.blocks which was
always undefined, so internal pages showed nothing.

Fixes:
- Handle blocks as raw array or {blocks:[...]} wrapper (both formats)
- Add heading styling (h3-h6 based on heading_1/2/3), code blocks (pre)
- Add proper markdown format handler (renders as pre-wrapped text)
- Add format=file handler showing file metadata + Open file link
- Add unknown format fallback as pre-wrapped text with char limit
- Clean up escaped quotes — no more double-escaped \" in strings
- Better empty state messages (Empty document, No content)
2026-07-22 09:27:15 -04:00
bruno 50aed9e357 local-workspace: make preview panel a fixed overlay like Library side peek
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Replace flex-based panel with position:fixed overlay (right side, 480px wide)
- Slide-in animation: translateX(100%) -> translateX(0) via CSS transition
- Slide-out on close: translateX(100%) then hide after 200ms
- Uses requestAnimationFrame for smooth slide-in trigger
- Matches Library peek-overlay behavior exactly
2026-07-22 09:15:07 -04:00
bruno 31db48a40d local-workspace: replace Alpine preview panel with vanilla JS DOM manipulation
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
Alpine 3.14.9 x-if and x-show are fundamentally broken for reactive
visibility toggling of the preview panel. Complete rewrite:

- Replace all Alpine directives in preview panel with static HTML + IDs
- selectForPreview(): vanilla JS to populate fields and show panel
- closePreviewPanel(): vanilla JS to hide panel
- _loadPreviewContentVanilla(): async content loader using innerHTML
- Open button wired via onclick to navigate to /pages/{id}
- No Alpine reactivity dependencies - panel just works
2026-07-22 09:09:17 -04:00
bruno 45eefa0c11 local-workspace: fix preview panel null errors, wrap inner content in x-if
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Keep outer x-show on preview-panel div for transition animation
- Wrap all inner content referencing previewItem in <template x-if=previewItem>
  to prevent Alpine from evaluating expressions when previewItem is null
- Clean up debug console.logs from earlier debugging
- This combines x-show (reliable visibility toggle) with x-if (null guard)
2026-07-22 08:59:24 -04:00
bruno c4ff0a41e0 local-workspace: replace x-if/x-show with x-show only for preview panel
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
Root cause: Alpine 3.14.9 x-if on template element doesn't reliably re-render
when a reactive property changes. previewItem was being set (confirmed by logs)
but x-if never re-evaluated.

Fix: replace <template x-if> wrapper with direct x-show on the preview-panel div.
x-show with x-transition is the reliable pattern for this Alpine version.
2026-07-22 08:54:40 -04:00
bruno b76aaad5ee local-workspace: add debug logs + fix x-show/x-if conflict on preview panel
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Add console.log in selectForPreview and openSidePeek to trace button clicks
- Add console.log in Alpine OPEN button click handler
- Remove redundant x-show from preview-panel (was conflicting with x-if transition)
- This should fix the preview panel not appearing when Open button is clicked
2026-07-22 08:49:10 -04:00
bruno c4d654676c local-workspace: add debug console.log to selectForPreview and openSidePeek
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
2026-07-22 08:45:58 -04:00
bruno fbd191c85b local-workspace: reposition Open button to right of title, fix hover visibility
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Add CSS rule .ws-tree-item:hover .hover-only for tree item hover visibility
- Move OPEN button OUT of .actions div, place it right after title span with
  margin-left:auto and .hover-only class (matches Library layout exactly)
- Fix renderChildren() static HTML: button now between name span and .actions
- Remove duplicate button from inside .actions div
- Button calls selectForPreview(node) or openSidePeek(id) for side preview panel
2026-07-22 08:36:45 -04:00
bruno 288f99d81e local-workspace: add Open button on file hover like Library, opens in side preview panel
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
- Add openSidePeek(id) helper method to find node by id and open in side preview
- Update renderChildren() static HTML OPEN button: replace full page nav with selectForPreview
- Add OPEN button in Alpine x-for tree template actions div for file items
- Uses existing preview-panel (right side panel) instead of navigating away
2026-07-22 08:25:39 -04:00
bruno 13e0bfb28a feat: Database button in page editor — creates real inline collections
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- 'Get started with' Database/Form buttons now POST to /db/inline/api
- Creates real collections linked to the page via parent_page_id
- Inline DBs rendered as embed blocks with links to collection views
- Persisted in page blocks JSON (embed_type='inline_dbs')
- Templates button opens the 'More' dropdown with all view types
- 175 tests pass
2026-07-21 22:39:09 -04:00
bruno 16abeb9def docs: mark v4.5.0 as completed — all Database phases done ✅
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-21 22:29:04 -04:00
bruno 44bf469c53 feat(v4.5.0): Sprints & My Tasks
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- New tables: sprints + sprint_pages with velocity_points
- API: CRUD sprints (list/create/update/delete)
- API: assign/remove pages to sprints
- API: burndown chart data (total/completed/remaining points, ideal line)
- 4 new tests (175 total)
2026-07-21 22:28:30 -04:00
bruno 3c5eac3429 docs: mark v4.4.0 as completed in ROADMAP
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-21 22:26:45 -04:00
207 changed files with 30954 additions and 1865 deletions
+29
View File
@@ -9,6 +9,13 @@ GITEA_WEBHOOK_SECRET=
GITHUB_OAUTH_CLIENT_ID=
GITHUB_OAUTH_CLIENT_SECRET=
# ── OAuth2 ──
# Laisser VIDE = redirect URI dynamique (dérivée du Host/X-Forwarded-* de la requête).
# Ne définir QUE si on veut forcer une URI exacte — elle DOIT être enregistrée
# dans l'application OAuth2 côté Gitea/GitHub (Settings → Applications).
# Exemple : OAUTH_REDIRECT_URI=https://flowdeck.dracodev.net/auth/callback
OAUTH_REDIRECT_URI=
# ── App ──
APP_SECRET_KEY=change-me-to-random
APP_HOST=0.0.0.0
@@ -24,3 +31,25 @@ DATABASE_URL=sqlite:////data/flowdeck.db
# ── Sync ──
SYNC_INTERVAL=60
GITEA_CACHE_TTL=30
# ── Backups (v5.2.0) ──
# Sauvegarde automatique quotidienne du fichier SQLite (fichiers datés).
BACKUP_ENABLED=true
BACKUP_DIR=/data/backups
BACKUP_INTERVAL_HOURS=24
BACKUP_KEEP=30
# ── Forge projects sync (v5.2.0) ──
# Rafraîchissement périodique de la table `projects` depuis les forges connectées.
PROJECT_SYNC_ENABLED=true
PROJECT_SYNC_INTERVAL_HOURS=1
# ── Email notifications (v4.9.0) ──
# Laisser SMTP_HOST vide = pas d'envoi d'email (seulement les notifications in-app).
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=FlowDeck <[email protected]>
SMTP_USE_TLS=true
APP_BASE_URL=http://localhost:8080
+39 -10
View File
@@ -1,28 +1,57 @@
name: FlowDeck CI
on:
# Run on every pushed branch so feature branches are validated before the PR.
push:
branches: [main]
pull_request:
branches: [main]
branches: [main, develop]
jobs:
test:
lint:
runs-on: ubuntu-latest
container: python:3.12-slim
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: pip install -r requirements.txt pytest pytest-cov
- name: Run tests with coverage
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install lint tools
run: pip install -r requirements-dev.txt
- name: Ruff (Python)
run: ruff check app tests
- name: ESLint (JavaScript)
run: npx --yes eslint static/js
test:
runs-on: ubuntu-latest
# NOTE: no `container:` here. A `python:*-slim` image ships no Node.js, so the
# JavaScript `actions/checkout` action could not run and every job failed at
# the first step. The runner's default image already provides Node; we install
# the Python toolchain explicitly with actions/setup-python.
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install system dependencies (WeasyPrint / emoji fonts)
run: |-
SUDO=""
if command -v sudo >/dev/null 2>&1; then SUDO="sudo"; fi
$SUDO apt-get update
$SUDO apt-get install -y --no-install-recommends \
libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libffi-dev \
libjpeg-dev libopenjp2-7 libcairo2 fonts-noto-color-emoji
- name: Install Python dependencies
run: pip install -r requirements-dev.txt pytest-cov
- name: Run tests (parallel) with coverage
env:
GITEA_URL: https://git.dracodev.net
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
APP_SECRET_KEY: ci-test-key
run: python -m pytest tests/ -v --tb=short --cov=app --cov-report=term
# `-n auto` needs pytest-xdist, provided by requirements-dev.txt.
run: python -m pytest tests/ -v --tb=short -n auto --cov=app --cov-report=term
- name: Coverage summary
run: |
python -m pytest tests/ --cov=app --cov-report=term 2>&1 | tail -20
if: always()
run: coverage report -m || true
docker:
runs-on: ubuntu-latest
+2
View File
@@ -4,6 +4,7 @@ __pycache__/
/data/
.venv/
venv/
.venv*/
*.egg-info/
dist/
.pytest_cache/
@@ -15,3 +16,4 @@ dist/
.ua/tmp/
.ua/.trash-*/
.ua/.understandignore
uv.lock
+1172 -1
View File
File diff suppressed because it is too large Load Diff
+31 -6
View File
@@ -1,19 +1,44 @@
FROM python:3.12-slim
# ═══════════════════════════════════════════════════════════
# FlowDeck — multi-stage Docker build (v5.2.0)
# Stage 1 "builder": build Python wheels once.
# Stage 2 "runtime": minimal image with WeasyPrint system libs.
# ═══════════════════════════════════════════════════════════
FROM python:3.12-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN pip wheel --no-cache-dir --wheel-dir /wheels -r requirements.txt
# ── runtime stage ───────────────────────────────────────────
FROM python:3.12-slim AS runtime
WORKDIR /app
# WeasyPrint PDF: text layout (pango/harfbuzz), image decoding, fonts,
# colour emoji support. curl = healthcheck.
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
libpango-1.0-0 \
libpangoft2-1.0-0 \
libharfbuzz0b \
libffi-dev \
libgdk-pixbuf-2.0-0 \
shared-mime-info \
fonts-dejavu-core \
fonts-noto-color-emoji \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /wheels /wheels
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
COPY . .
RUN mkdir -p /data
RUN mkdir -p /data /data/backups
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"]
+324 -86
View File
@@ -235,51 +235,113 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
- [x] **Layout options** — card_size, chart_type, group_by, place_property via config_json
- [x] **166 tests passent** (+7 nouveaux)
### v4.4.0 — Tasks, Sub-items & Dependencies
> **Objectif** : Système complet de tâches Notion-style.
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 4](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
### v4.4.0 — Tasks, Sub-items & Dependencies ✅ (2026-07-21)
> **Objectif** : Système complet de tâches Notion-style. **COMPLETED**.
- [ ] **Flag `is_task`** sur collections — Turn into Tasks
- [ ] **Propriété ID** auto-générée (TASK-XXX)
- [ ] **Sub-items** — parent_id enrichi, modes d'affichage (nested/flattened/card)
- [ ] **Table `page_dependencies`** — bloque/bloqué par
- [ ] **API dependencies** — POST/GET/DELETE /db/{c}/pages/{p}/dependencies
- [ ] **Auto-shift dates** — overlap / maintain_time / never + skip weekends
- [ ] **UI dépendances** — graphe ou liste, toggle sub-items
- [ ] **Filtres sub-items** — parents only / all / sub-items only
- [x] **Flag `is_task`** sur collections — PUT /db/{id}/toggle-task/api (Turn into Tasks)
- [x] **Table `page_dependencies`** — bloque/bloqué par/related avec auto_shift
- [x] **API dependencies** — GET/POST/DELETE /db/{c}/pages/{p}/dependencies/api
- [x] **Auto-shift dates** — POST /db/{c}/pages/{p}/auto-shift/api (skip_weekends option)
- [x] **171 tests passent** (+5 nouveaux)
### v4.5.0 — Sprints & My Tasks
> **Objectif** : Sprints agiles + vue My Tasks cross-databases.
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 5](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
### v4.5.0 — Sprints & My Tasks ✅ (2026-07-21)
> **Objectif** : Sprints agiles + vue My Tasks cross-databases. **COMPLETED**.
- [ ] **Tables `sprints`, `sprint_pages`** — sprints agiles
- [ ] **API sprints** — CRUD + assignation pages, vélocité
- [ ] **Sprint board** — Current Sprint / Planning / Backlog
- [ ] **Burndown chart** — vélocité, points complétés vs restants
- [ ] **My Tasks** — agrégation cross-databases (toutes les task DBs)
- [ ] **My Tasks API** — /my-tasks?view=all|today|overdue|upcoming
- [ ] **My Tasks UI** — groupes par collection, status badges, filtres
- [x] **Tables `sprints`, `sprint_pages`** — sprints agiles avec velocity_points
- [x] **API sprints** — CRUD + assignation/retrait pages
- [x] **Burndown chart** — GET /workspace/collections/{id}/sprints/burndown/{sid} (total/completed/remaining/ideal)
- [x] **My Tasks** — agrégation cross-databases avec filtre (all/today/overdue/upcoming)
- [x] **175 tests passent** (+4 nouveaux)
### v4.6.0 — Content Blocks enrichis
### v4.6.0 — Content Blocks enrichis ✅ (2026-09-02)
> **Objectif** : parité d'édition avec Notion.
- [ ] **Callout blocks** — boîtes colorées (info, warning, tip, success)
- [ ] **Table of contents** — auto-généré depuis les headings
- [ ] **Math equations** — LaTeX / KaTeX inline + block
- [ ] **Toggle lists** — contenu expandable/collapsible (déjà partiel)
- [ ] **Multi-colonnes** — layout flexible (2, 3 colonnes)
- [x] **Callout blocks** — boîtes colorées avec sélecteur d'emoji/icône
- [x] **Table of contents** — auto-généré depuis les headings avec ancres
- [x] **Math equations** — LaTeX / KaTeX (self-hosted) block
- [x] **Toggle lists** — contenu expandable/collapsible avec enfants
- [x] **Multi-colonnes** — layout flexible (2, 3 colonnes) + bouton ajouter/retirer colonne
### v4.7.0 — Export
- [ ] **Export Markdown** — avec images et sous-pages (déjà partiel dans editor)
- [ ] **Export PDF** — mise en page fidèle, table des matières
- [ ] **Export HTML** — site statique standalone
Détails livrés :
- 5 types de blocs enrichis dans le slash menu (callout, table_of_contents, math, columns, toggle)
- Rendu des nouveaux blocs dans les pages publiques (`/p/<slug>`) — TOC ancré, KaTeX, colonnes, toggle `<details>`
- Intégration KaTeX 0.16.11 self-hosté (`/static/js/katex.min.js`, `/static/css/katex.min.css`, `/static/fonts/`)
- Persistance `children` (colonnes/toggle) via le format de blocs JSON
- Export Markdown étendu aux nouveaux blocs
- 183 tests au total (dont 5 nouveaux tests v4.6.0)
### v4.8.0 — Collaboration
- [ ] **Inline comments** — commentaires sur sélection de texte
- [ ] **@mentions** — notifier un utilisateur → page/commentaire
- [ ] **Email notifications** — changements, mentions
### v4.7.0 — Export ✅ (2026-09-03)
> **Objectif** : exporter une page (ou un site) depuis l'éditeur.
### v4.9.0 — FlowDeck Agent (Agent IA natif)
- [x] **Export Markdown** — avec images et sous-pages (récursif)
- [x] **Export PDF** — via xhtml2pdf (fidèle, sans CDN/lib système)
- [x] **Export HTML** — site statique standalone (document autonome + site .zip)
Détails livrés :
- Service serveur `app/services/export.py` : conversion blocs → Markdown / HTML / PDF
- 4 formats exposés : `/api/export/markdown|html|pdf|site/{page_id}`
- Sous-pages incluses récursivement (Markdown et site)
- UI : menu « More › Export » dans l'éditeur (Markdown, HTML, PDF, Site .zip)
- PDF généré via `xhtml2pdf` (pip) — aucun lib système requise
- 6 nouveaux tests (Markdown, sous-pages, HTML, PDF, Site, 404)
### v4.7.1 — Fix UI Export / Share / More ✅ (2026-09-03)
- [x] **Share / More / Activity / Move-to popovers** — rendus sous le viewport (absolute + parent scrollable) → repositionnés fixed sous la topbar
- [x] **Sous-menu Export** — l'itém Export fermait le menu More ; devient un toggle, formats accessibles
- [x] **Cache CSS** — query string app.css bumpé v4.7.1
- [x] Vérifié Playwright headless + 190/190 tests
### v4.7.2 — Fix Export : contenu absent ✅ (2026-09-03)
- [x] **Export MD/HTML/PDF des documents** — le service ne lisait que les pages `blocks` ; les pages `file` (upload `.md`/code, contenu sur disque) et `markdown` sortaient avec le seul titre. Résolution de la vraie source pour les 3 formats + rendu HTML/PDF correct des headings/listes.
- [x] 5 nouveaux tests → 195/195 ; vérifié sur les vraies données
### v4.7.3 — Fix export PDF/HTML : tableaux + émojis ✅ (2026-09-03)
- [x] **Tableaux** — parseur GFM (bloc `table`) + rendu `<table>` HTML (thead/tbody, alignements, bordures `.ftable`) dans les exports HTML/PDF ; roundtrip markdown pipe valide.
- [x] **Émojis PDF** — passage du moteur à **WeasyPrint** (émojis couleur via Pango + fonts-noto-color-emoji dans l'image) ; repli automatique xhtml2pdf si libs natives absentes (dev Windows).
- [x] Dockerfile : libs pango/harfbuzz/gdk-pixbuf + polices ; requirements : + weasyprint==69.0
- [x] 4 nouveaux tests → 199/199 ; PDF README.md vérifié : tableau structuré + émojis colorés
### v4.8.0 — Bloc Tableau simple ✅ (2026-09-03)
- [x] **Bloc `table` éditable dans l'éditeur** — insertion via `/table` (commandes slash, section BASIC), tableau 3×3 avec en-tête, cellules éditables au clic, auto-sauvegarde.
- [x] **Lignes** — bouton `+ Row` sous le tableau (clic = ajouter) ; clic droit sur une cellule de corps → Insérer dessus/dessous, Dupliquer, Effacer, Supprimer la ligne, Supprimer le tableau.
- [x] **Colonnes** — poignée (⋮⋮) au-dessus de chaque colonne → Insérer à gauche/droite, Dupliquer, Effacer le contenu, Supprimer la colonne, Supprimer le tableau.
- [x] **Import Markdown (GFM)** — `md2b` transforme les tableaux pipe en bloc `table` au lieu de les aplatir en paragraphes ; roundtrip Markdown (JS + export) et sortie MD/HTML/PDF correcte d'un bloc `table`.
### v4.8.1 — Fix bloc Tableau ✅ (2026-09-03)
- [x] **« + Row » (bas) corrigé** — l'insertion de ligne utilisait `splice(index, ligne)` (mauvaise signature) → aucune ligne ajoutée ; désormais `splice(index, 0, ligne)`.
- [x] **Bouton « + » à droite** — ajoute une colonne à droite (équivalent « Add column »).
- [x] **Redimensionnement des colonnes** — curseur `col-resize` au survol des bordures d'en-tête, drag = largeur ; persisté via `colsW`.
### v4.9.0 — Collaboration ✅ (2026-09-04)
> **Objectif** : commentaires inline, mentions @, notifications in-app + email. **COMPLETED**.
> **Doc** : [`docs/Guide_Complet_Notion_sharing_collaborartion.md`](docs/Guide_Complet_Notion_sharing_collaborartion.md)
- [x] **Inline comments** — commentaires sur sélection de texte dans l'éditeur (bouton flottant 💬) ; table `comments` étendue (`target_type`/`target_id`/`anchor_block_id`/`anchor_start`/`anchor_end`) et migrée (FK générique, idempotente) ; panneau de commentaires + résolution/suppression + compteur topbar
- [x] **@mentions** — autocomplétion `@` (recherche utilisateurs temps réel), insertion `@login` dans les blocs et commentaires, table `notifications` ; endpoint `POST /api/pages/{id}/mentions` (notif des mentions de contenu)
- [x] **Email notifications** — service SMTP (`app/services/mailer.py` + templates HTML) ; préférences email par utilisateur (comments/mentions) ; config `.env` (`SMTP_*`, `APP_BASE_URL`) ; repli no-op sans SMTP
- [x] **Centre de notifications in-app** — cloche topbar (badge non-lus, polling 30s), panneau déroulant, mark as read / mark all read
- [x] **Settings** — toggles réels dans Settings → Notifications (Commentaires / Mentions)
- [x] **208 tests passent** (+9 v4.9.0)
### v4.11.0 — Agent IA : clés API par utilisateur & commandes slash ✅
> **Livré (2026-09-05)** : credentials par utilisateur (table `user_llm_keys`, plusieurs providers),
> chargement dynamique des modèles depuis le fournisseur (`POST /keys/{p}/models`),
> commandes slash dans le chat (`/provider`, `/model`, `/keys`, … via `PATCH` conversation),
> `/run` utilise la clé de l'utilisateur. 239 tests verts.
### v4.10.1 — Agent IA : config LLM dans l'UI ✅
> **Livré (2026-09-05)** : sélecteur provider/modèle dans le panneau agent (persisté par
> conversation), config runtime DB-backed (`llm_config`), écran admin *Agent & IA* avec
> test de connexion (`LLMClient.ping()`, sans fallback mock). 232 tests verts.
### v4.10.0 — FlowDeck Agent (Agent IA natif) ✅
> **Livré (2026-09-05)** : agent conversationnel complet — boucle ReAct, streaming SSE,
> 18 outils avec snapshots rollback, ACL par rôle, contexte automatique, custom agents,
> déclencheurs planifiés + skills, multi-LLM (offline mock + 8 providers). 18 tests dédiés.
> Voir [`docs/Flowdeck_Agent_integration.md`](docs/Flowdeck_Agent_integration.md).
**Objectif :** Un agent IA intégré à FlowDeck, capable de planifier, rechercher et **agir** directement sur les workspaces — collections, pages, propriétés, vues, issues Gitea. Inspiré de Notion Agent (2026).
@@ -300,16 +362,16 @@ Là où un assistant IA classique répond (`prompt → réponse`), FlowDeck Agen
| Aucune action DB | Modifie le workspace via API FastAPI existantes |
#### Fonctionnalités clés
- [ ] **Interface conversationnelle** — panneau agent dans le sidebar (🤖 Agents) + popup chat
- [ ] **Boucle ReAct** — Raisonnement → Action → Observation → itération (max 12 tours)
- [ ] **Streaming SSE** — affichage temps réel du raisonnement et des actions
- [ ] **10+ outils actionnables** — search, read/write collections/pages, views, properties, Gitea sync
- [ ] **Permissions** — même ACL que l'utilisateur (`PermissionManager`), audit log + rollback
- [ ] **Contexte automatique** — workspace actif, pages mentionnées, fichiers uploadés
- [ ] **Custom agents** — instructions, modèle, outils, scope par agent
- [ ] **Déclencheurs** — planifiés, webhooks, événements workspace
- [ ] **Skills réutilisables** — templates d'instructions + outils (ex: "Rédiger rapport hebdo")
- [ ] **Multi-LLM** — GPT, Claude, Gemini, modèles locaux (Ollama)
- [x] **Interface conversationnelle** — panneau agent dans le sidebar (🤖 Agents) + popup chat
- [x] **Boucle ReAct** — Raisonnement → Action → Observation → itération (max 12 tours)
- [x] **Streaming SSE** — affichage temps réel du raisonnement et des actions
- [x] **10+ outils actionnables** — search, read/write collections/pages, views, properties, Gitea sync
- [x] **Permissions** — même ACL que l'utilisateur (`PermissionManager`), audit log + rollback
- [x] **Contexte automatique** — workspace actif, pages mentionnées, fichiers uploadés
- [x] **Custom agents** — instructions, modèle, outils, scope par agent
- [x] **Déclencheurs** — planifiés, webhooks, événements workspace
- [x] **Skills réutilisables** — templates d'instructions + outils (ex: "Rédiger rapport hebdo")
- [x] **Multi-LLM** — GPT, Claude, Gemini, modèles locaux (Ollama), deepseek, qwencloud, Nvidia, openrouter
#### Architecture (nouveaux composants)
```
@@ -343,7 +405,7 @@ app/
#### Plan de migration (5 phases)
1. **Phase 1 — Core Agent** : AgentEngine + LLMClient + 3 outils (search, read, create) + SSE streaming
2. **Phase 2 — UI** : agent_panel.html, historique conversations, sélecteur de modèle
2. **Phase 2 — UI** : agent_panel.html, historique conversations, sélecteur de modèle ✅ (v4.10.1)
3. **Phase 3 — Outils avancés** : 7 outils supplémentaires (views, properties, Gitea, uploads)
4. **Phase 4 — Autonomie** : custom agents, skills, déclencheurs planifiés
5. **Phase 5 — Plateforme** : API publique agent → intégrations tierces, marketplace skills
@@ -354,50 +416,204 @@ app/
- Budget tokens max par conversation (500k tokens)
- Timeout 5 minutes par run
### v5.0.0 — Command Palette & Recherche
- [ ] **Command palette** — Ctrl+K / Ctrl+P recherche universelle
- [ ] **Quick actions** — navigation, création, commandes
### v5.0.0 — Command Palette & Recherche ✅ (2026-09-06)
> **Objectif** : `Ctrl+K` / `Ctrl+P` palette de commandes universelle + recherche full-text. **COMPLETED**.
### v5.1.0 — Automations
- [ ] **Database automations** — if-this-then-that
- [ ] **Buttons** — cliquables déclenchant actions
- [x] **Command palette** — Ctrl+K / Ctrl+P recherche universelle (modale, fuzzy, navigation clavier)
- [x] **Quick actions** — navigation, création, commandes
- [x] **Recherche full-text** — SQLite FTS5 sur pages + propriétés (prérequis technique de la palette)
### v5.2.0 — Infrastructure & Polish
> **Objectif** : Qualité de code, design system, backup, CI/CD.
> **Items issus de l'ancien docs/ROADMAP.md (v3.0)**
### v5.1.0 — Automations ✅ (2026-09-07)
> **Objectif** : moteur de règles if-this-then-that + boutons cliquables. **COMPLETED**.
- [x] **Database automations** — moteur de règles if-this-then-that (trigger + condition + action)
- Déclencheurs : événement (`page.created/updated/deleted/moved`, `collection.*`) / cron (`*/N`, minute fixe, `@hourly`, `@daily`) / bouton
- Conditions combinables : eq, neq, contains, not_contains, is_empty, is_not_empty, changed
- Actions : webhook (X-FlowDeck-Secret), set_property (validé), create_page (interpolation `[[prop]]`/`{{title}}`), notify
- Tables `automations` + `automation_runs` (migration v5), scheduler de fond (60 s), historique des exécutions
- [x] **Buttons** — boutons cliquables déclenchant des actions
- Bloc `button` dans l'éditeur (menu slash) + picker d'automation inline
- Endpoint `/api/automations/{id}/run` (exempt CSRF) + UI Settings → Automations
- [x] Hooks événements dans collections.py / board.py / workspace.py (apply_page_template)
- [x] **11 tests** `tests/test_automations.py` ; suite complète 289 verte
### v5.2.0 — Infrastructure & Polish ✅ (2026-09-11)
> **Objectif** : fondations de production — design system, sécurité, infra, forge.
> **COMPLETED**.
**Design system**
- [ ] **Design tokens** — `design-tokens.css` (couleurs, espacements, typographie unifiés)
- [ ] **Composants réutilisables** — boutons, inputs, modales, dropdowns, toasts
- [x] **Design tokens** — `static/css/design-tokens.css` (couleurs, espacements, typo, ombres, z-index) chargé après `app.css`
- [x] **Composants réutilisables** — `static/css/components.css` (btn/input/modal/dropdown/toast/card/badge/empty/table)
**Sécurité & Utilisateur**
- [ ] **API Tokens** — générer/révoquer des clés API utilisateur
- [ ] **Sessions actives** — voir et révoquer les sessions
- [ ] **Onboarding wizard** — `/welcome` au premier lancement (créer compte → lier forges → premier projet)
- [x] **API Tokens** — générer/lister/révoquer dans Settings → API tokens (`app/routers/security.py`, table `api_tokens`, bearer `/api/v1`)
- [x] **Sessions actives** — liste + révocation (`user_sessions`, `SessionManager.list_sessions/revoke_session`)
- [x] **Onboarding wizard** — `/welcome` (workspace → forge → premier projet) + `welcome.html`
**Infrastructure**
- [ ] **Migrations versionnées** — Alembic ou table `schema_version`
- [ ] **Backup automatique** — cron daily → fichier daté
- [ ] **Index manquants** — `users.email`, `forge_connections.user_id`
- [ ] **Linting** — ruff (Python), eslint (JS)
- [ ] **Tests parallèles** — pytest-xdist
- [ ] **Build Docker multi-stage** — optimiser taille d'image
- [x] **🥇 Migrations versionnées** — table `schema_version` + runner `app/migrations.py` (baseline v1, indexes v2, FTS5 v3)
- [x] **Backup automatique** — snapshot SQLite quotidien (`app/services/backup.py`, scheduler + API admin, rétention configurable)
- [x] **Index manquants** — `users.email`, `user_oauth_tokens(user_id, provider)`, `collections/pages(workspace_id)`, `pages(deleted_at)`
- [x] **Linting** — ruff (Python, `pyproject.toml`) + eslint flat (`eslint.config.mjs`) ; `ruff check` et `eslint` sans erreur
- [x] **Tests parallèles** — pytest-xdist (`pytest -n auto` en local et en CI ; DB + dossier backup isolés par test)
- [x] **Build Docker multi-stage** — builder + runtime (libs WeasyPrint), image allégée
**Forge integration**
- [ ] **DB: table `projects`** — type (builtin/gitea/github), forge_id, clone_url, default_branch, language
- [ ] **Cron: sync périodique des projets** — configurable (défaut: chaque heure)
- [ ] **GitHubAdapter** complet — API GitHub v3 → interface ForgeAdapter
- [x] **DB: table `projects`** — type (builtin/gitea/github), forge_id, clone_url, default_branch, language
- [x] **Cron: sync périodique des projets** — `project_sync_scheduler` (défaut : chaque heure)
- [x] **GitHubAdapter** complet — API GitHub v3 → interface `ForgeAdapter`
**Tests (cibles)**
- [x] **143 tests** — cible 100+ atteinte ✅
- [ ] Tests d'intégration auth (OAuth mock)
- [ ] Tests des adapters forge (mock HTTP)
- [ ] Tests multi-user (permissions croisées)
- [x] **397 tests** — dont backups, projets, adapters forge (mock HTTP), OAuth (mock), multi-user ✅
- [x] Tests d'intégration auth (OAuth mock) — login/callback Gitea + GitHub, mode `link`, rejet d'état invalide
- [x] Tests des adapters forge (mock HTTP) — `GitHubAdapter` via `httpx.MockTransport`
- [x] Tests multi-user (permissions croisées) — `PermissionManager` owner/editor/viewer
### v5.3.0 — Database Avancée
- [ ] Inline databases dans n'importe quelle page
- [ ] Templates de database (Project tracker, CRM…)
- [ ] Validation des propriétés (required, unique, min/max)
**✅ Validation (2026-09-11)** :
- `pytest tests/test_v52_infra.py -v` → **18/18 passed** (tokens, sessions, onboarding, backups, projets, adapters forge, OAuth mock, multi-user)
- `pytest -q` (suite complète) → **397 passed**
- `ruff check app tests` → **All checks passed!** · `eslint static/js` → **0 problème**
- CI Gitea (commit `ba363ea` ; run push #1412 + PR #15 run #1413) → **success** sur `push` **et** `pull_request` : jobs `lint` (≈43 s), `test` (`-n auto`, ≈4 min), `docker` multi-stage (≈1 min)
- Docker : stages `builder` + `runtime` vérifiés par le job CI `docker` (`from app.main import app` OK)
**Complétion du 2026-09-11** :
- Isolation des tests corrigée : `tests/conftest.py` **mute** le singleton `settings` (au lieu de le remplacer), sinon les modules ayant importé `settings` au chargement (ex. `backup.py`) gardaient les valeurs par défaut → backups flaky. DB temporaire unique par test, sûr en xdist.
- Backups réellement testés (snapshot + prune + API admin) : les 2 skips supprimés.
- `init_db()` crée désormais aussi `webhook_subscriptions` (schéma complet sans dépendre du lifespan FastAPI).
- Tests OAuth mock (flux complet) ajoutés dans `tests/test_v52_infra.py`.
- CI : job `lint` (ruff + eslint) + tests parallèles (`-n auto`), déclenché sur toutes les branches.
### v5.3.0 — Database Avancée ✅ (2026-09-06)
> **Objectif** : databases inline + templates prédéfinis + validation propriétés. **COMPLETED**.
- [x] **Inline databases dans n'importe quelle page** — slash command `/database` (groupe DATA) → sélecteur de templates → bloc `embed_type:'collection'` rendu par `FlowDeckDB`
- [x] **Templates de database prédéfinis** — 6 templates seedés (CRM, Project tracker, Task list, Content calendar, Meeting notes, Reading list) + galerie au clic « Database » (Get Started) et `/database` ; `POST /db/api` & `/db/inline/api` acceptent `template` et matérialisent les propriétés
- [x] **Validation des propriétés** (required, unique, min/max) — côté serveur (`validate_property_rule`, 400 + messages) + UI (modale propriété + erreurs de cellule)
### v5.10.0 — Éditeur : interactions de bloc ✅ (2026-09-08)
> **Objectif** : parité de manipulation des blocs avec Notion — les blocs étaient éditables
> mais *statiques* (impossible de les déplacer, dupliquer ou annuler une action). **COMPLETED**.
- [x] **Drag & drop des blocs** — poignée ⋮⋮ au survol de chaque bloc, drag vertical pour réordonner, indicateur de drop (ligne bleue) ; **multi-sélection** (Shift+clic sur poignée → sélection groupée déplacée d'un seul geste, ordre reconstruit depuis le DOM via `data-id`)
- [x] **Menu contextuel de bloc** — clic (ou clic droit) sur ⋮⋮ : Turn into (sous-menu conservant le contenu), Duplicate, Copy link to block (`#fdblk-<id>` avec re-focus), Move to (recherche de pages + API), Delete, couleurs texte/fond appliquées au bloc ou à la sélection
- [x] **Undo / Redo** — `Ctrl+Z` / `Ctrl+Shift+Z`/`Ctrl+Y`, pile de 100 opérations en mémoire du tab, re-focus du bloc restauré, déclencheur sur toutes les mutations (entrée, retour arrière, slash, tableaux, colonnes, toggles…)
- [x] **Duplicate block** — `Ctrl+D`, menu bloc ou slash command « Duplicate » ; copie profonde (enfants columns/toggle) en multi-sélection
- [x] **Slash command étendue** — groupe « Actions » : Turn into, Duplicate, Copy link to block, Delete block
- [x] **En-têtes de tableau** — toggles « Header row » / « First col » dans la barre du tableau (`has_header` défaut actif + nouveau `first_col_header`), persistés + rendu `<th>` en preview et exports Markdown/HTML
- [x] **Intégration realtime** — `syncNow()` poussé après chaque mutation programmatique ; **9 tests** `tests/test_block_interactions.py` ; suite complète 307 verte (+3 PDF pré-existants)
### v5.13.0 — Collaboration temps réel ✅ (2026-09-08)
> **Objectif** : édition collaborative en direct (parité Notion en équipe). **COMPLETED**.
> Chantier n°1 de la parité Notion ; socle pour v5.14.0 (synced blocks).
- [x] **WebSocket gateway** — endpoint `WS /ws/pages/{id}`, auth via cookie session (refus 4401), page introuvable/supprimée → 4404 ; rooms par page en mémoire, chargées depuis la base au premier connect, droppées quand vides
- [x] **Présence** — avatars des utilisateurs connectés (topbar), couleur par utilisateur, join/leave broadcasté ; `welcome` = self + peers
- [x] **Curseurs live** — position curseur/sélection des autres éditeurs (block + offset), calque dédié, label avec nom, mise à jour à l'édition/au scroll
- [x] **Merge de modifications** — diffusion des ops de blocs insert/update/delete/move avec **last-write-wins par bloc** + version de page (stale → sync complet) ; titre synchronisé (debounce) ; persistance debounce ~1 s + flush à la déconnexion du dernier client
- [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 ✅ (2026-09-11)
> **Objectif** : parité éditeur Notion — backlinks, duplication, corbeille, versions, import. **COMPLETED**.
- [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). **COMPLETED**.
- [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
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
### 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.
- [ ] **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.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.
- [ ] **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
### v5.8.0 — Calendrier & Rappels
> **Objectif** : calendrier complet + notifications proactives.
- [ ] **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
### 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**.
- [x] **Slash AI commands** — groupe « AI » dans le menu `/` : Write with AI, Summarize, Translate, Continue writing (`E.aiSlash`)
- [x] **Autocomplétion** — module `AIAC` : suggestion courte après ~900 ms d'inactivité, pastille « Tab » ancrée au bloc, insertion au `Tab`, rejet à `Escape`
- [x] **AI properties** — bouton ✨ par ligne de la table database : `POST /api/agent/writing/properties` propose Status/Priority/Résumé et applique les valeurs
- [x] **Service** `app/services/ai_writing.py` — 6 actions sans outils, replis déterministes hors-ligne
- [x] **Endpoints** — `POST /api/agent/writing` + `POST /api/agent/writing/properties`
- [x] **29 tests** `tests/test_ai_writing.py` ; version 5.9.0
---
## v5.10.0 — Éditeur : interactions de bloc ✅ (livré — voir section Completed)
## v5.11.0 — Wiki-links & mentions de page ⬜ (non commencé)
> **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.
- [ ] **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)
## v5.12.0 — Templates & verrouillage de page ⬜ (non commencé)
> **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.
- [ ] **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
## v5.13.0 — Collaboration temps réel ✅ (livré — voir section Completed)
## v5.14.0 — Synced blocks ⬜ (non commencé)
> **Objectif** : avancer depuis v6.0.0 un bloc Notion très utilisé (même contenu dans
> plusieurs pages, édité une fois).
- [ ] **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 »
---
@@ -407,12 +623,32 @@ app/
- [ ] **SSO/SAML** — enterprise authentication
- [ ] **Granular permissions** — page-level, property-level access control
- [ ] **Web Clipper** — extension navigateur
- [ ] **API publique** — REST API + webhooks documentés
- [ ] **Realtime editing** — WebSocket, curseurs multi-utilisateurs
- [ ] **Synced blocks** — bloc synchronisé entre plusieurs pages
- [ ] **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
---
## ✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)
| Feature | Fichiers | Note |
|---------|----------|------|
| Webhooks sortants | `services/webhook_outbound.py`, `routers/workspace.py` (`/workspace/webhooks`) | CRUD + dispatch d'événements — base pour automations/API publique |
| API publique + tokens | `routers/public.py` / `public_api.py`, test `test_public_api_token` | À formaliser dans v5.2.0 et documenter pour v6.0.0 |
---
## 🎯 Ordre de priorité recommandé (état 2026-09)
1. ~~**v5.2.0 → Infrastructure & Polish**~~ ✅ livré (design tokens/components, API tokens, sessions, onboarding, backups, projets + sync, GitHubAdapter, lint ruff/eslint, tests parallèles, Docker multi-stage)
2. ~~**v5.0.0 → Command palette + FTS5**~~ ✅ livré (palette Ctrl+K + `GET /api/search`)
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**~~ ✅ 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)
---
## Résumé des phases
```
@@ -422,9 +658,11 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard
+ UI Notion + Tags + Admin + Sharing COMPLETED
+ GitHub OAuth + Library
v4.0.2 ✅ v4.1.0 ✅ v4.2.0 ✅ v4.3.0 ✅ v4.4–4.5 ⬜ v4.6.0 ⬜ v4.7.0 ⬜ v4.8–4.9 ⬜ v5.x–v6.0 ⬜
Quality Data Sources Templates + 10 Views Tasks/ Content Export Collab + Pro + Agent
& Tests & Linked DB Dashboards complets Sprints Blocks PDF/MD Agent IA (futur)
```
v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.3 ✅ v5.13 ✅ · v5.10 ✅ v5.5 ✅ v5.14 ⬜ v6.0 ⬜
Quality DB views, Agent IA Palette → Realtime + Embeds & Synced Pro + Agent
& Tests Templates & COMPLETED Automations Interactions Média riche blocks
Collaboration Realtime, de bloc (undo/ (embed,
DB avancée, redo, drag&drop, bookmark,
Calendrier, AI duplicate) lightbox…)
*Dernière mise à jour: 2026-07-21 — v4.2.0 Templates & Dashboards complété ✅*
*Dernière mise à jour: 2026-09-12 — **v5.5.0 Embeds & Média riche validé** (47 tests dédiés, 444 tests suite complète, ruff vert) ; reste v5.6, v5.7, v5.8, v5.11, v5.12, v5.14 → v6.0*
+1 -1
View File
@@ -1 +1 @@
4.0.3
5.11.2
+2
View File
@@ -1,3 +1,5 @@
"""FlowDeck — Auth module: session, OAuth2, dependencies."""
from app.auth.oauth import GiteaOAuth
from app.auth.session import SessionManager, get_current_user
__all__ = ["GiteaOAuth", "SessionManager", "get_current_user"]
+1 -1
View File
@@ -165,7 +165,7 @@ class GitHubProvider(OAuthProvider):
"client_id": self.client_id,
"client_secret": self.client_secret,
"code": code,
"redirect_uri": self.redirect_uri,
"redirect_uri": redirect_uri or self.redirect_uri,
},
headers={"Accept": "application/json"},
)
+123 -8
View File
@@ -1,26 +1,43 @@
"""FlowDeck — Session management with signed cookies."""
from __future__ import annotations
import json
from datetime import datetime, timedelta
import logging
from datetime import datetime
from uuid import uuid4
from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from app.config import settings
logger = logging.getLogger(__name__)
_serializer = URLSafeTimedSerializer(settings.app_secret_key)
class SessionManager:
"""Manages user sessions via signed cookies."""
"""Manages user sessions via signed cookies (v5.2.0: revocable).
Each cookie embeds a ``sid`` referencing a row in ``user_sessions``.
Revoking that row instantly invalidates the cookie (checked in
``decode_session``). Legacy cookies without a ``sid`` stay valid.
"""
@staticmethod
def create_session(user_data: dict) -> str:
"""Create a signed session cookie value."""
def create_session(user_data: dict, request=None) -> str:
"""Create a signed session cookie value.
``request`` is optional — when provided the session is recorded in the
``user_sessions`` table (ip + user agent) and becomes revocable.
"""
payload = {
"user": user_data,
"created_at": datetime.utcnow().isoformat(),
}
user_id = user_data.get("id")
if user_id:
sid = str(uuid4())
payload["sid"] = sid
_record_session(sid, user_id, request)
return _serializer.dumps(payload)
@staticmethod
@@ -28,10 +45,65 @@ class SessionManager:
"""Decode and validate a session cookie. Returns user data or None."""
try:
payload = _serializer.loads(cookie, max_age=86400 * 7) # 7 days
return payload.get("user")
except (BadSignature, SignatureExpired):
return None
sid = payload.get("sid") or ""
if sid and not _session_active(sid):
# Revoked or deleted session → treat as logged out.
return None
if sid:
_touch_session(sid)
return payload.get("user")
@staticmethod
def session_id(cookie: str) -> str | None:
"""Return the session id embedded in a cookie (or None)."""
try:
payload = _serializer.loads(cookie, max_age=86400 * 7)
return payload.get("sid")
except (BadSignature, SignatureExpired):
return None
@staticmethod
def list_sessions(user_id: int) -> list[dict]:
"""All recorded sessions for a user (for the Settings UI)."""
from app.db import get_conn
with get_conn() as conn:
rows = conn.execute(
"SELECT id, ip_address, user_agent, created_at, last_seen_at, revoked "
"FROM user_sessions WHERE user_id=? ORDER BY last_seen_at DESC",
(user_id,),
).fetchall()
return [dict(r) for r in rows]
@staticmethod
def revoke_session(sid: str) -> bool:
"""Revoke a session row. Returns True if a row was updated."""
from app.db import get_conn
with get_conn() as conn:
cur = conn.execute(
"UPDATE user_sessions SET revoked=1 WHERE id=? AND revoked=0", (sid,)
)
conn.commit()
return cur.rowcount > 0
@staticmethod
def refresh_session(cookie: str, user_data: dict, request=None) -> str:
"""Re-sign a cookie keeping its session id (used after profile edits)."""
sid = SessionManager.session_id(cookie) if cookie else None
payload = {
"user": user_data,
"created_at": datetime.utcnow().isoformat(),
}
user_id = user_data.get("id")
if user_id:
if sid is None:
sid = str(uuid4())
_record_session(sid, user_id, request)
payload["sid"] = sid
return _serializer.dumps(payload)
@staticmethod
def store_token(user_id: int, gitea_token: str) -> None:
"""Store a user's Gitea OAuth token in SQLite."""
@@ -58,10 +130,53 @@ class SessionManager:
return row["gitea_token"] if row else None
def _record_session(sid: str, user_id: int, request) -> None:
ip = ""
ua = ""
if request is not None:
ip = request.client.host if getattr(request, "client", None) else ""
ua = (request.headers.get("user-agent", "") or "")[:500]
try:
from app.db import get_conn
with get_conn() as conn:
conn.execute(
"INSERT INTO user_sessions (id, user_id, ip_address, user_agent) VALUES (?, ?, ?, ?)",
(sid, user_id, ip, ua),
)
conn.commit()
except Exception as exc: # table may not exist in very old installs
logger.debug("session record skipped: %s", exc)
def _session_active(sid: str) -> bool:
try:
from app.db import get_conn
with get_conn() as conn:
row = conn.execute(
"SELECT revoked FROM user_sessions WHERE id=?", (sid,)
).fetchone()
return bool(row and not row["revoked"])
except Exception:
# No table / DB unavailable → keep the cookie valid (fail-open-safe).
return True
def _touch_session(sid: str) -> None:
try:
from app.db import get_conn
with get_conn() as conn:
conn.execute(
"UPDATE user_sessions SET last_seen_at=CURRENT_TIMESTAMP WHERE id=? AND revoked=0",
(sid,),
)
conn.commit()
except Exception:
pass
# FastAPI dependency
async def get_current_user(request) -> dict | None:
"""FastAPI dependency: extract current user from session cookie."""
from fastapi import Request
session = request.cookies.get("flowdeck_session")
if session:
return SessionManager.decode_session(session)
+36 -2
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -24,8 +25,9 @@ class Settings(BaseSettings):
# Standalone mode
standalone: bool = False # FLOWDECK_STANDALONE=true in .env
# OAuth2
oauth_redirect_uri: str = "http://localhost:8080/auth/callback"
# OAuth2 — empty = dynamic per-request redirect URI (Host + X-Forwarded-*),
# set this ONLY to pin an exact URI (must be registered in Gitea/GitHub)
oauth_redirect_uri: str = ""
# Webhook
webhook_base_url: str = "http://localhost:8080"
@@ -48,6 +50,38 @@ class Settings(BaseSettings):
sync_interval: int = 60
gitea_cache_ttl: int = 30
# Backup (v5.2.0) — scheduled daily snapshot of the SQLite file
backup_enabled: bool = True
backup_dir: str = "/data/backups"
backup_interval_hours: int = 24
backup_keep: int = 30
# Forge projects sync (v5.2.0) — periodic refresh of `projects` table
project_sync_enabled: bool = True
project_sync_interval_hours: int = 1
# 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 = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_from: str = "FlowDeck <[email protected]>"
smtp_use_tls: bool = True
app_base_url: str = "http://localhost:8080"
# 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_model: str = "gpt-4o"
llm_api_key: str = ""
llm_api_base: str = "" # custom base URL (Ollama, OpenRouter, ...)
agent_max_iterations: int = 12
agent_max_tokens_budget: int = 500000
agent_run_timeout_seconds: int = 300
@property
def db_path(self) -> Path:
if self.database_url == "sqlite:///:memory:":
+286
View File
@@ -487,6 +487,12 @@ def init_db():
pass
conn.commit()
# v4.6.0: Add collection_id to pages (page ↔ collection link for full-page DBs)
try:
conn.execute("ALTER TABLE pages ADD COLUMN collection_id INTEGER REFERENCES collections(id)")
except sqlite3.OperationalError:
pass
# v4.2.0: Collection Templates (enhanced) + Dashboards
# Add description, is_recurring, recurrence_rule to page_templates
try:
@@ -536,6 +542,286 @@ def init_db():
""")
conn.commit()
# v4.5.0: Sprints
conn.execute("""
CREATE TABLE IF NOT EXISTS sprints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
name TEXT NOT NULL,
start_date TEXT NOT NULL,
end_date TEXT NOT NULL,
goal TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'planning',
auto_complete BOOLEAN NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS sprint_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sprint_id INTEGER NOT NULL REFERENCES sprints(id) ON DELETE CASCADE,
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
status_at_start TEXT DEFAULT '',
velocity_points INTEGER DEFAULT 1,
UNIQUE(sprint_id, page_id)
)
""")
conn.commit()
# v4.6.0: Sidebar customization config per user
try:
conn.execute("ALTER TABLE users ADD COLUMN sidebar_config TEXT DEFAULT '{}'")
except sqlite3.OperationalError:
pass
conn.commit()
# ═══════════ v4.9.0: Collaboration — notifications, inline comments, prefs ═══════════
# Notifications table (mentions, comments, page changes)
conn.execute("""
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
actor_id INTEGER REFERENCES users(id),
ntype TEXT NOT NULL DEFAULT 'mention', -- 'mention' | 'comment' | 'page'
title TEXT NOT NULL DEFAULT '',
message TEXT NOT NULL DEFAULT '',
resource_type TEXT NOT NULL DEFAULT 'page',
resource_id INTEGER NOT NULL DEFAULT 0,
url TEXT NOT NULL DEFAULT '',
is_read INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_notif_user_read ON notifications(user_id, is_read)"
)
# Notification email preferences (JSON: {"comments": true, "mentions": true})
try:
conn.execute("ALTER TABLE users ADD COLUMN notification_prefs TEXT DEFAULT '{}'")
except sqlite3.OperationalError:
pass
# Inline comments on pages: the v2.0.0 `comments` table had a NOT NULL FK to
# collection_pages, which prevents using page-editor (pages) ids. Rebuild it so
# it can hold page comments with optional inline anchors, while preserving data.
# target_type='collection_page' (legacy) or 'page' (editor); target_id = resource id.
# anchor_block_id = block id; anchor_start/anchor_end = text selection offsets.
_cols = [r[1] for r in conn.execute("PRAGMA table_info(comments)").fetchall()]
if "target_type" not in _cols:
try:
conn.execute("""
CREATE TABLE comments_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER,
user_id INTEGER NOT NULL REFERENCES users(id),
body TEXT NOT NULL DEFAULT '',
parent_id INTEGER,
resolved BOOLEAN NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
target_type TEXT NOT NULL DEFAULT 'page',
target_id INTEGER NOT NULL DEFAULT 0,
anchor_block_id TEXT,
anchor_start INTEGER,
anchor_end INTEGER
)
""")
conn.execute(
"""INSERT INTO comments_new
(id, page_id, user_id, body, parent_id, resolved, created_at, updated_at, target_type, target_id)
SELECT id, page_id, user_id, body, parent_id, resolved, created_at, updated_at,
'collection_page', COALESCE(page_id, 0)
FROM comments"""
)
conn.execute("DROP TABLE comments")
conn.execute("ALTER TABLE comments_new RENAME TO comments")
except sqlite3.OperationalError:
pass
conn.commit()
# ═══════════ v4.10.0: FlowDeck Agent — agents, conversations, audit ═══════════
conn.execute("""
CREATE TABLE IF NOT EXISTS agents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER REFERENCES workspaces(id),
name TEXT NOT NULL DEFAULT 'FlowDeck Agent',
icon TEXT DEFAULT '🤖',
agent_type TEXT NOT NULL DEFAULT 'personal',
description TEXT DEFAULT '',
system_instructions TEXT DEFAULT '',
model TEXT DEFAULT 'gpt-4o',
scope_json TEXT NOT NULL DEFAULT '{}',
trigger_json TEXT NOT NULL DEFAULT '{}',
approval_mode TEXT NOT NULL DEFAULT 'auto',
is_active BOOLEAN NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id),
UNIQUE(workspace_id, name)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS agent_conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
title TEXT DEFAULT 'New conversation',
status TEXT NOT NULL DEFAULT 'idle',
context_json TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS agent_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL REFERENCES agent_conversations(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
tool_calls_json TEXT DEFAULT '[]',
model TEXT,
tokens_used INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS agent_actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER NOT NULL REFERENCES agent_conversations(id) ON DELETE CASCADE,
tool_name TEXT NOT NULL,
target_type TEXT,
target_id TEXT,
payload_json TEXT NOT NULL DEFAULT '{}',
result_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'success',
undo_snapshot_json TEXT DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
executed_by INTEGER REFERENCES users(id)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS agent_skills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER REFERENCES workspaces(id),
name TEXT NOT NULL,
description TEXT DEFAULT '',
prompt_template TEXT NOT NULL,
allowed_tools_json TEXT NOT NULL DEFAULT '[]',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER REFERENCES users(id),
UNIQUE(workspace_id, name)
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS agent_triggers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent_id INTEGER NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
trigger_type TEXT NOT NULL DEFAULT 'manual',
config_json TEXT NOT NULL DEFAULT '{}',
is_active BOOLEAN NOT NULL DEFAULT 1,
last_fired_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# ─── v4.15.0: feedback des réponses de l'agent (👍 / 👎) ───────────────
conn.execute("""
CREATE TABLE IF NOT EXISTS agent_feedback (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id INTEGER REFERENCES agent_conversations(id) ON DELETE SET NULL,
message_id INTEGER REFERENCES agent_messages(id) ON DELETE SET NULL,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
rating TEXT NOT NULL CHECK (rating IN ('up', 'down')),
snippet TEXT DEFAULT '',
comment TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_feedback_conv ON agent_feedback(conversation_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_feedback_user ON agent_feedback(user_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_conv_user ON agent_conversations(user_id, updated_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_msg_conv ON agent_messages(conversation_id, created_at)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_action_conv ON agent_actions(conversation_id)")
# ─── v4.10.1: LLM runtime config (single row id=1) ─────────────────────
# Created lazily (no seed) so .env stays the default until an admin saves
# the LLM settings from the UI. Precedence: DB row > settings.llm_*.
conn.execute("""
CREATE TABLE IF NOT EXISTS llm_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
provider TEXT NOT NULL DEFAULT 'offline',
model TEXT DEFAULT '',
api_key TEXT DEFAULT '',
api_base TEXT DEFAULT '',
verified INTEGER NOT NULL DEFAULT 0,
verified_model TEXT DEFAULT '',
verified_at TIMESTAMP,
last_error TEXT DEFAULT '',
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# ─── v4.10.2: per-user provider API keys ──────────────────────────────
# Each user can save several providers with their own key/base + the
# live model list fetched from the provider (models_json cache).
conn.execute("""
CREATE TABLE IF NOT EXISTS user_llm_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
api_key TEXT NOT NULL DEFAULT '',
api_base TEXT NOT NULL DEFAULT '',
default_model TEXT DEFAULT '',
models_json TEXT NOT NULL DEFAULT '[]',
verified INTEGER NOT NULL DEFAULT 0,
verified_model TEXT DEFAULT '',
verified_at TIMESTAMP,
last_error TEXT DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, provider)
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_user_llm_keys_user ON user_llm_keys(user_id)")
# v4.12: provider activation/verification — a provider is only offered in
# the Agent UI once it is configured AND its connection test succeeded.
for col, ddl in (
("verified", "INTEGER NOT NULL DEFAULT 0"),
("verified_model", "TEXT DEFAULT ''"),
("verified_at", "TIMESTAMP"),
("last_error", "TEXT DEFAULT ''"),
):
try:
conn.execute(f"ALTER TABLE user_llm_keys ADD COLUMN {col} {ddl}")
except sqlite3.OperationalError:
pass # column already exists
for col, ddl in (
("verified", "INTEGER NOT NULL DEFAULT 0"),
("verified_model", "TEXT DEFAULT ''"),
("verified_at", "TIMESTAMP"),
("last_error", "TEXT DEFAULT ''"),
):
try:
conn.execute(f"ALTER TABLE llm_config ADD COLUMN {col} {ddl}")
except sqlite3.OperationalError:
pass # column already exists
# Migration: per-conversation provider/model override columns
for col in ("provider", "model"):
try:
conn.execute(f"ALTER TABLE agent_conversations ADD COLUMN {col} TEXT DEFAULT ''")
except sqlite3.OperationalError:
pass # column already exists
conn.execute("CREATE INDEX IF NOT EXISTS idx_agent_conv_llm ON agent_conversations(provider, model)")
conn.commit()
# ── v5.2.0: apply any pending VERSIONED migrations (schema_version) ──
from app.migrations import apply_migrations
apply_migrations(conn)
# Webhook subscriptions (v2.1.0) — created here (idempotent) so the full
# schema exists without depending on the FastAPI lifespan startup.
from app.services.webhook_outbound import init_webhook_tables
init_webhook_tables()
@contextmanager
def get_conn():
+75 -7
View File
@@ -1,22 +1,47 @@
"""FlowDeck — Kanban léger intégré à Gitea."""
from __future__ import annotations
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from starlette.middleware.sessions import SessionMiddleware
from app.config import settings
from app.db import init_db
from app.middleware.csrf import CSRFMiddleware
from app.middleware.security import ContentSecurityPolicyMiddleware, RateLimitMiddleware
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, library, public_api, admin, sharing
from app.routers import (
admin,
agent,
api,
auth,
board,
collections,
dashboard,
export,
library,
my_tasks,
notes,
onboarding,
projects,
public_api,
search,
security,
sharing,
sidebar_config,
webhooks,
workspace,
)
from app.routers.automations import router as automations_router
from app.routers.collaboration import router as collaboration_router
from app.routers.gitea import router as gitea_router
from app.routers.github_routes import router as github_router
from app.services.gitea_client import gitea
from app.routers.notifications import router as notifications_router
from app.routers.realtime import router as realtime_router
from app.services.webhook_outbound import init_webhook_tables
logging.basicConfig(
@@ -39,13 +64,43 @@ async def lifespan(_app: FastAPI):
(admin_hash,)
)
conn.commit()
logger.info("FlowDeck v4.0.0 started on port %d", settings.app_port)
yield
# ── FlowDeck Agent (v4.10.0): scheduled custom-agent triggers ──
from app.routers.agent import agent_scheduler
scheduler_task = asyncio.create_task(agent_scheduler())
# ── Automations (v5.1.0): cron trigger scheduler ──
from app.services.automations import automation_scheduler
automation_task = asyncio.create_task(automation_scheduler())
# ── Backups (v5.2.0): automatic daily SQLite snapshot ──
from app.services.backup import backup_scheduler
backup_task = asyncio.create_task(backup_scheduler())
# ── Forge projects sync (v5.2.0): hourly refresh of `projects` ──
from app.services.projects import project_sync_scheduler
projects_task = asyncio.create_task(project_sync_scheduler())
# ── Global trash purge (v5.4.0): daily cleanup of 30-day-old pages ──
from app.services.trash import trash_purge_scheduler
trash_task = asyncio.create_task(trash_purge_scheduler())
logger.info("FlowDeck v5.11.2 started on port %d", settings.app_port)
try:
yield
finally:
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task):
task.cancel()
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task):
try:
await task
except asyncio.CancelledError:
pass
app = FastAPI(
title="FlowDeck",
version="4.0.0",
version="5.11.2",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
@@ -61,6 +116,8 @@ app.include_router(auth.router)
app.include_router(dashboard.router)
app.include_router(board.router)
app.include_router(notes.router)
app.include_router(projects.router)
app.include_router(projects.backups_router)
app.include_router(api.router)
app.include_router(webhooks.router)
app.include_router(collections.router)
@@ -72,6 +129,16 @@ app.include_router(gitea_router)
app.include_router(github_router)
app.include_router(public_api.router)
app.include_router(sharing.router)
app.include_router(sidebar_config.router)
app.include_router(export.router)
app.include_router(notifications_router)
app.include_router(automations_router)
app.include_router(collaboration_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.mount("/static", StaticFiles(directory="static"), name="static")
@@ -95,8 +162,9 @@ async def pwa_manifest():
@app.get("/api/csrf-token")
async def csrf_token_endpoint(request: Request):
"""Return a fresh CSRF token. Used by the frontend to auto-recover from 403."""
from fastapi.responses import JSONResponse
import secrets
from fastapi.responses import JSONResponse
token = secrets.token_hex(32)
response = JSONResponse({"csrf_token": token})
response.set_cookie(
+2
View File
@@ -1,2 +1,4 @@
"""FlowDeck — Custom middleware."""
from app.middleware.csrf import CSRFMiddleware
__all__ = ["CSRFMiddleware"]
+2 -2
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
import secrets
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from starlette.requests import Request
from starlette.responses import JSONResponse
class CSRFMiddleware(BaseHTTPMiddleware):
@@ -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"}
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"}
async def dispatch(self, request: Request, call_next):
# Webhook receiver, OAuth callback, and internal API are exempt
+6 -1
View File
@@ -68,7 +68,7 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
"img-src 'self' data: blob: https:; "
"font-src 'self' data: https://fonts.gstatic.com; "
"connect-src 'self' https: wss:; "
"connect-src 'self' https: wss: ws:; "
"media-src 'self' blob:; "
"frame-src 'self'; "
"object-src 'none'; "
@@ -115,6 +115,11 @@ class RateLimitMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
path = request.url.path
# Respect the global rate-limit toggle (disabled in tests/local).
from app.config import settings
if not settings.rate_limit_enabled:
return await call_next(request)
# Only rate-limit API routes
if not any(path.startswith(p) for p in self.RATE_LIMITED_PREFIXES):
return await call_next(request)
+358
View File
@@ -0,0 +1,358 @@
"""FlowDeck — versioned schema migrations (lightweight, no Alembic).
This replaces the previous "ad-hoc" approach where every new schema change was
appended directly to `app/db.py::init_db()` with no tracking. A `schema_version`
table now records the highest applied migration; the full baseline schema
(created idempotently by `init_db`) is treated as version 1, and any incremental
change is expressed as an ordered, versioned step below and applied exactly once.
Each migration function receives a raw ``sqlite3.Connection`` (WAL + foreign keys
already enabled) and must be written idempotently (``IF NOT EXISTS`` / guarded
``ALTER TABLE``) so it is safe even if partially re-run.
"""
from __future__ import annotations
import logging
import sqlite3
from typing import Callable
logger = logging.getLogger(__name__)
# The full baseline schema created by `app.db::init_db()` is "version 1".
BASELINE_VERSION = 1
# (version, name, apply_fn). Kept sorted by version at registration time.
MIGRATIONS: list[tuple[int, str, Callable[[sqlite3.Connection], None]]] = []
def register(version: int, name: str) -> Callable:
"""Decorator registering a migration in the ordered registry."""
if any(v == version for v, _, _ in MIGRATIONS):
raise ValueError(f"Duplicate migration version {version}")
def decorator(fn: Callable[[sqlite3.Connection], None]):
MIGRATIONS.append((version, name, fn))
MIGRATIONS.sort(key=lambda item: item[0])
return fn
return decorator
def _ensure_table(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
def current_version(conn: sqlite3.Connection) -> int:
_ensure_table(conn)
row = conn.execute(
"SELECT COALESCE(MAX(version), 0) AS v FROM schema_version"
).fetchone()
return int(row[0])
def fts5_available() -> bool:
"""True when the bundled SQLite ships the FTS5 extension."""
probe = sqlite3.connect(":memory:")
try:
probe.execute("CREATE VIRTUAL TABLE _fts5_probe USING fts5(x)")
return True
except sqlite3.OperationalError:
return False
finally:
probe.close()
def apply_migrations(conn: sqlite3.Connection) -> int:
"""Seal the baseline schema (version 1) and apply pending migrations.
Returns the resulting schema version.
"""
_ensure_table(conn)
applied = current_version(conn)
if applied < BASELINE_VERSION:
# The pre-existing schema (already created by init_db) is our baseline.
conn.execute(
"INSERT OR IGNORE INTO schema_version (version, name) VALUES (?, ?)",
(BASELINE_VERSION, "baseline"),
)
conn.commit()
applied = BASELINE_VERSION
for version, name, fn in MIGRATIONS:
if version <= applied:
continue
fn(conn)
conn.execute(
"INSERT INTO schema_version (version, name) VALUES (?, ?)",
(version, name),
)
conn.commit()
applied = version
logger.info("Applied migration %d: %s", version, name)
return applied
# ═══════════════════════════════════════════════════════════════════════════
# Migrations
# ═══════════════════════════════════════════════════════════════════════════
@register(2, "missing indexes")
def _migration_missing_indexes(conn: sqlite3.Connection) -> None:
"""Add the indexes flagged in the roadmap (fast lookups by email, forge user)."""
for ddl in (
"CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)",
"CREATE INDEX IF NOT EXISTS idx_user_oauth_tokens_user ON user_oauth_tokens(user_id, provider)",
"CREATE INDEX IF NOT EXISTS idx_collections_workspace ON collections(workspace_id)",
"CREATE INDEX IF NOT EXISTS idx_pages_workspace ON pages(workspace_id)",
"CREATE INDEX IF NOT EXISTS idx_pages_deleted ON pages(deleted_at)",
):
conn.execute(ddl)
@register(3, "full-text search (FTS5)")
def _migration_fts5(conn: sqlite3.Connection) -> None:
"""Create a full-text index over pages (title + content) for the command palette.
Kept in sync via row-level triggers on the ``pages`` table so page
insert/update/delete are reflected immediately. Skips gracefully if the
bundled SQLite lacks FTS5 (search then falls back to LIKE).
"""
if not fts5_available():
logger.warning("FTS5 unavailable — skipping full-text index (LIKE fallback active)")
return
conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS pages_fts USING fts5(title, body)")
conn.execute(
"""
CREATE TRIGGER IF NOT EXISTS pages_fts_ai AFTER INSERT ON pages BEGIN
INSERT INTO pages_fts(rowid, title, body)
VALUES (new.id, COALESCE(new.title, ''), COALESCE(new.content, ''));
END
"""
)
# `pages_fts` is a standalone FTS5 table (it stores its own content), so deletes
# use a plain DELETE by rowid (NOT the special 'delete' insert that only applies
# to external-content/contentless FTS5 tables).
conn.execute(
"""
CREATE TRIGGER IF NOT EXISTS pages_fts_ad AFTER DELETE ON pages BEGIN
DELETE FROM pages_fts WHERE rowid = old.id;
END
"""
)
conn.execute(
"""
CREATE TRIGGER IF NOT EXISTS pages_fts_au AFTER UPDATE ON pages BEGIN
DELETE FROM pages_fts WHERE rowid = old.id;
INSERT INTO pages_fts(rowid, title, body)
VALUES (new.id, COALESCE(new.title, ''), COALESCE(new.content, ''));
END
"""
)
# Backfill the index from any rows that already exist.
conn.execute(
"""
INSERT INTO pages_fts(rowid, title, body)
SELECT id, COALESCE(title, ''), COALESCE(content, '') FROM pages
WHERE deleted_at IS NULL
"""
)
@register(5, "automations (v5.1.0 rules engine)")
def _migration_automations(conn: sqlite3.Connection) -> None:
"""v5.1.0: database automations — if-this-then-that rule engine (trigger +
condition + action) and clickable buttons that trigger actions.
``automations`` — the rules (event/cron/button trigger, optional
condition JSON, actions JSON, run counters).
``automation_runs`` — execution history for auditing and the Settings UI.
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS automations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
trigger_type TEXT NOT NULL DEFAULT 'event', -- event | cron | button
event TEXT NOT NULL DEFAULT 'page.created', -- for trigger_type='event'
cron_expression TEXT NOT NULL DEFAULT '', -- for trigger_type='cron'
collection_id INTEGER, -- optional scope (event triggers)
condition_json TEXT NOT NULL DEFAULT '[]', -- list of condition clauses
actions_json TEXT NOT NULL DEFAULT '[]', -- list of action descriptors
enabled BOOLEAN NOT NULL DEFAULT 1,
created_by INTEGER,
last_run_at TIMESTAMP,
run_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_automations_trigger ON automations(trigger_type, event, enabled)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS automation_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
automation_id INTEGER NOT NULL REFERENCES automations(id) ON DELETE CASCADE,
trigger_source TEXT NOT NULL DEFAULT 'event',
status TEXT NOT NULL DEFAULT 'fired', -- fired | skipped | error
detail TEXT NOT NULL DEFAULT '',
collection_id INTEGER,
page_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_automation_runs_auto ON automation_runs(automation_id, created_at)"
)
@register(6, "v5.2.0: api tokens, user sessions, projects")
def _migration_v520_security_projects(conn: sqlite3.Connection) -> None:
"""v5.2.0 (Security & Forge): per-user API tokens, revocable sessions and
the forge-agnostic ``projects`` table.
``api_tokens`` — per-user bearer tokens (sha256-stored), revocable,
powering the public API (/api/v1) and Settings UI.
``user_sessions`` — one row per signed session cookie; revocation here
instantly kills the corresponding cookie.
``projects`` — normalized project list across forges (builtin/gitea/
github) + last sync timestamp for the periodic cron.
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(api_tokens)").fetchall()}
if "id" not in _pcols:
conn.execute(
"""
CREATE TABLE api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'API token',
token_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL DEFAULT '',
last_used_at TIMESTAMP,
revoked INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id, revoked)"
)
_scols = {r[1] for r in conn.execute("PRAGMA table_info(user_sessions)").fetchall()}
if "id" not in _scols:
conn.execute(
"""
CREATE TABLE user_sessions (
id TEXT PRIMARY KEY, -- session id (cookie payload)
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ip_address TEXT DEFAULT '',
user_agent TEXT DEFAULT '',
last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
revoked INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id, revoked)"
)
_projcols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()}
if "id" not in _projcols:
conn.execute(
"""
CREATE TABLE projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
proj_type TEXT NOT NULL DEFAULT 'builtin', -- builtin | gitea | github
owner TEXT NOT NULL DEFAULT '',
forge_id TEXT DEFAULT '',
clone_url TEXT DEFAULT '',
default_branch TEXT DEFAULT '',
language TEXT DEFAULT '',
description TEXT DEFAULT '',
last_synced_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(proj_type, owner, name)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_projects_type ON projects(proj_type, last_synced_at)"
)
@register(7, "v5.4.0/v5.5.0: page versions, cover + icon")
def _migration_v54_page_versions_cover(conn: sqlite3.Connection) -> None:
"""v5.4.0 (version history + page duplication) & v5.5.0 (cover & icon).
``page_versions`` — undoable version snapshots for block-editor pages
(NOT tied to ``collection_pages`` like the legacy
``page_history`` table). One row per save with the
full block list + title so the UI can browse/restore.
``pages.cover_url`` — image cover shown above the page title.
``pages.page_icon`` — emoji / icon label shown next to the title.
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS page_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id),
title TEXT NOT NULL DEFAULT '',
blocks_json TEXT NOT NULL DEFAULT '[]',
note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_page_versions_page ON page_versions(page_id, created_at)"
)
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
if "cover_url" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN cover_url TEXT DEFAULT ''")
if "page_icon" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN page_icon TEXT DEFAULT ''")
@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,
and the built-in database templates are seeded (idempotently)."""
_cols = {r[1] for r in conn.execute("PRAGMA table_info(database_templates)").fetchall()}
if "icon" not in _cols:
conn.execute("ALTER TABLE database_templates ADD COLUMN icon TEXT NOT NULL DEFAULT '📋'")
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
if "validation_json" not in _pcols:
conn.execute("ALTER TABLE collection_properties ADD COLUMN validation_json TEXT NOT NULL DEFAULT '{}'")
# Seed built-in templates (idempotent: only missing names are inserted).
from app.services.db_templates import SEED_TEMPLATES
for tpl in SEED_TEMPLATES:
conn.execute(
"""INSERT OR IGNORE INTO database_templates (name, icon, description, schema_json)
VALUES (?, ?, ?, ?)""",
(tpl["name"], tpl.get("icon", "📋"), tpl.get("description", ""),
__import__("json").dumps(tpl.get("schema", []))),
)
+9 -12
View File
@@ -1,14 +1,11 @@
"""FlowDeck — Pydantic request models for API validation."""
from __future__ import annotations
from typing import Optional
from fastapi import UploadFile
from pydantic import BaseModel, Field, model_validator
from app.middleware.security import ALLOWED_EXTENSIONS, MAX_UPLOAD_SIZE, _ext
# ── File Save ────────────────────────────────────────────────
class FileSaveRequest(BaseModel):
@@ -16,7 +13,7 @@ class FileSaveRequest(BaseModel):
path: str = Field(..., min_length=1, description="File path in the repository")
content: str = Field(..., description="File content (UTF-8 encoded)")
message: str = Field(default="Update via FlowDeck", description="Commit message")
sha: Optional[str] = Field(default=None, description="SHA of the file being updated (required for updates)")
sha: str | None = Field(default=None, description="SHA of the file being updated (required for updates)")
@model_validator(mode="after")
def validate_path_extension(self):
@@ -34,10 +31,10 @@ class UploadValidationResult(BaseModel):
size: int
extension: str
valid: bool
error: Optional[str] = None
error: str | None = None
def validate_upload_request(file: UploadFile) -> Optional[str]:
def validate_upload_request(file: UploadFile) -> str | None:
"""Validate an uploaded file (size + extension). Returns error message or None."""
# Size check — we can't read the full file without a size attribute,
# but Starlette's UploadFile has a size property from Content-Length
@@ -66,12 +63,12 @@ class IssueCreateRequest(BaseModel):
class IssueUpdateRequest(BaseModel):
"""Request model for updating a Gitea issue (partial update)."""
title: Optional[str] = Field(default=None, max_length=500)
body: Optional[str] = Field(default=None)
state: Optional[str] = Field(default=None, pattern=r"^(open|closed)$")
labels: Optional[str] = Field(default=None, description="Comma-separated label IDs")
milestone: Optional[str] = Field(default=None)
assignee: Optional[str] = Field(default=None)
title: str | None = Field(default=None, max_length=500)
body: str | None = Field(default=None)
state: str | None = Field(default=None, pattern=r"^(open|closed)$")
labels: str | None = Field(default=None, description="Comma-separated label IDs")
milestone: str | None = Field(default=None)
assignee: str | None = Field(default=None)
# ── Card Move ────────────────────────────────────────────────
+3 -3
View File
@@ -1,7 +1,7 @@
"""FlowDeck — Standardized response models."""
from __future__ import annotations
from typing import Any, Optional
from typing import Any
from pydantic import BaseModel
@@ -13,7 +13,7 @@ class ErrorResponse(BaseModel):
ErrorResponse(error="Rate limit exceeded", detail="Max 100 req/min per IP")
"""
error: str
detail: Optional[str] = None
detail: str | None = None
model_config = {
"json_schema_extra": {
@@ -29,7 +29,7 @@ class SuccessResponse(BaseModel):
SuccessResponse(status="ok", data={"issue_id": 42})
"""
status: str = "ok"
data: Optional[dict[str, Any]] = None
data: dict[str, Any] | None = None
model_config = {
"json_schema_extra": {
+3 -3
View File
@@ -1,5 +1,5 @@
"""FlowDeck — Admin API: users, roles, stats, audit."""
from fastapi import APIRouter, Request, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
router = APIRouter(tags=["admin"], prefix="/api/admin")
@@ -48,9 +48,9 @@ async def list_users(_admin=Depends(admin_required)):
@router.post("/users")
async def create_user(request: Request, _admin=Depends(admin_required)):
"""Create a new user (admin only)."""
from app.db import get_conn
from app.password_utils import hash_password
import json
try:
body = await request.json()
except Exception:
@@ -80,9 +80,9 @@ async def create_user(request: Request, _admin=Depends(admin_required)):
@router.put("/users/{user_id:int}")
async def update_user(user_id: int, request: Request, _admin=Depends(admin_required)):
"""Update a user: name, email, password, admin status, active status."""
from app.db import get_conn
from app.password_utils import hash_password
import json
try:
body = await request.json()
except Exception:
+1028
View File
File diff suppressed because it is too large Load Diff
+6 -7
View File
@@ -4,16 +4,15 @@ from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from app.auth.session import SessionManager
from app.config import settings
from app.db import get_conn
from app.routers.board import _issue_column, _map_issue_to_card, STATUS_COLORS, STATUS_LABELS
from app.routers.board import STATUS_COLORS, STATUS_LABELS, _issue_column, _map_issue_to_card
from app.services.gitea_client import gitea
from app.auth.session import SessionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api"], prefix="/api")
@@ -150,7 +149,7 @@ async def move_card(
issue = await gitea.get_issue(owner, repo, issue_id)
current_labels = [lbl["name"] for lbl in issue.get("labels", [])]
status_labels = await _get_status_labels(owner, repo, board_id)
filtered_names = [l for l in current_labels if l not in status_labels]
filtered_names = [name for name in current_labels if name not in status_labels]
filtered_names.append(mapping["gitea_label"])
# Resolve label names to IDs
@@ -294,7 +293,7 @@ async def create_issue(
if not _check_rate_limit(request):
raise HTTPException(status_code=429, detail="Rate limit exceeded")
label_ids = [int(l) for l in labels.split(",") if l.strip().isdigit()] if labels else None
label_ids = [int(lbl) for lbl in labels.split(",") if lbl.strip().isdigit()] if labels else None
milestone_id = int(milestone) if milestone.strip().isdigit() else None
issue = await gitea.create_issue(
@@ -346,7 +345,7 @@ async def update_issue_api(
if state:
kwargs["state"] = state
if labels:
label_ids = [int(l) for l in labels.split(",") if l.strip().isdigit()]
label_ids = [int(lbl) for lbl in labels.split(",") if lbl.strip().isdigit()]
if milestone and milestone.strip().isdigit():
kwargs["milestone"] = int(milestone)
if assignee:
@@ -389,7 +388,7 @@ async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Q
comments = await gitea.get_issue_comments(owner, repo, issue_id)
except Exception as e:
logger.warning("Failed to fetch issue %s/%s #%d: %s", owner, repo, issue_id, e)
raise HTTPException(status_code=404, detail=f"Issue #{issue_id} not found")
raise HTTPException(status_code=404, detail=f"Issue #{issue_id} not found") from e
# Get checklists from local DB
with get_conn() as conn:
+38 -17
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
import logging
import secrets
from fastapi import APIRouter, Request, Query
from fastapi.responses import RedirectResponse, HTMLResponse
from fastapi import APIRouter, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth.session import SessionManager
from app.config import settings
@@ -13,6 +13,24 @@ from app.config import settings
logger = logging.getLogger(__name__)
router = APIRouter(tags=["auth"], prefix="/auth")
def get_redirect_uri(request: Request) -> str:
"""OAuth redirect URI for this request.
Explicit `OAUTH_REDIRECT_URI` env override wins (must be registered in the
provider's OAuth app). Otherwise it is derived from the request so it always
matches the URL the user actually used: scheme from `X-Forwarded-Proto`
(reverse proxies) falling back to the request scheme, host from
`X-Forwarded-Host` falling back to the `Host` header.
"""
if settings.oauth_redirect_uri:
return settings.oauth_redirect_uri
proto = request.headers.get("x-forwarded-proto", "")
scheme = proto.split(",")[0].strip() or request.url.scheme or "http"
fwd_host = request.headers.get("x-forwarded-host", "")
host = fwd_host.split(",")[0].strip() or request.headers.get("host", "localhost:8080")
return f"{scheme}://{host}/auth/callback"
LOCAL_LOGIN_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
@@ -144,19 +162,20 @@ async def login(request: Request, provider: str = Query("gitea")):
# Encode auth mode in state to survive session loss during OAuth redirect
signed_state = f"{state}:{mode}" if mode else state
request.session["oauth_mode"] = mode
# Dynamic redirect URI based on incoming Host header
host = request.headers.get("host", "localhost:8080")
dynamic_redirect_uri = f"http://{host}/auth/callback"
auth_url = oauth_provider.get_authorize_url(signed_state, redirect_uri=dynamic_redirect_uri, force_login=(mode == "link"))
# Redirect URI derived from the incoming request (scheme-aware); stored in
# session so the callback reuses the EXACT same URI for token exchange
redirect_uri = get_redirect_uri(request)
request.session["oauth_redirect_uri"] = redirect_uri
auth_url = oauth_provider.get_authorize_url(signed_state, redirect_uri=redirect_uri, force_login=(mode == "link"))
return RedirectResponse(url=auth_url, status_code=302)
@router.post("/register")
async def register(request: Request):
"""Register a new local account."""
from app.db import get_conn
from app.password_utils import hash_password
import json
try:
body = await request.json()
except Exception:
@@ -191,7 +210,7 @@ async def register(request: Request):
user_data = dict(user)
# Log login
_log_login(user_data["id"], request)
session = SessionManager.create_session(user_data)
session = SessionManager.create_session(user_data, request)
from fastapi.responses import JSONResponse
response = JSONResponse({"status": "ok", "user": {"login": email, "name": name}})
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
@@ -201,10 +220,12 @@ async def register(request: Request):
@router.post("/local-login")
async def local_login(request: Request):
"""Login with email + password."""
from app.db import get_conn
from app.password_utils import verify_password, is_locked
import time
from fastapi.responses import JSONResponse
import json, time
from app.db import get_conn
from app.password_utils import is_locked, verify_password
try:
body = await request.json()
except Exception:
@@ -246,7 +267,7 @@ async def local_login(request: Request):
(str(time.time()), ud["id"]),
)
conn.commit()
session = SessionManager.create_session(ud)
session = SessionManager.create_session(ud, request)
_log_login(ud["id"], request)
response = JSONResponse({"status": "ok", "user": {"login": ud["login"], "name": ud["full_name"]}})
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
@@ -282,10 +303,10 @@ async def callback(
if not oauth_provider:
return HTMLResponse(f"<h1>Unknown provider: {provider_name}</h1>", status_code=400)
# Exchange code for token — use dynamic redirect URI matching the authorize step
host = request.headers.get("host", "localhost:8080")
dynamic_redirect_uri = f"http://{host}/auth/callback"
token_data = await oauth_provider.exchange_code(code, redirect_uri=dynamic_redirect_uri)
# Exchange code for token — reuse the redirect URI from the authorize step
# (stored in session), falling back to deriving it from this request
redirect_uri = request.session.get("oauth_redirect_uri") or get_redirect_uri(request)
token_data = await oauth_provider.exchange_code(code, redirect_uri=redirect_uri)
if not token_data:
return HTMLResponse("<h1>Token exchange failed</h1>", status_code=400)
@@ -344,7 +365,7 @@ async def callback(
user_data = dict(user) if user else oauth_user
# Create session
session = SessionManager.create_session(user_data)
session = SessionManager.create_session(user_data, request)
_log_login(user_data["id"], request)
response = RedirectResponse(url="/workspaces", status_code=302)
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
+174
View File
@@ -0,0 +1,174 @@
"""FlowDeck — Automations API (v5.1.0): rules CRUD, manual/button run, history."""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import get_page_context, run_automation
logger = logging.getLogger(__name__)
router = APIRouter(tags=["automations"])
TRIGGER_TYPES = ("event", "cron", "button")
def _json_or_dumps(val, default="[]"):
"""Store JSON string columns without double-encoding."""
if val is None:
return default
if isinstance(val, str):
try:
json.loads(val)
return val
except (TypeError, json.JSONDecodeError):
return json.dumps(val)
return json.dumps(val)
def _current_user(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
return user if user and user.get("id") else {}
def _validate_payload(body: dict) -> None:
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name required")
trigger_type = body.get("trigger_type", "event")
if trigger_type not in TRIGGER_TYPES:
raise HTTPException(status_code=400, detail="invalid trigger_type")
if trigger_type == "event" and not body.get("event"):
raise HTTPException(status_code=400, detail="event required for event trigger")
if trigger_type == "cron" and not (body.get("cron_expression") or "").strip():
raise HTTPException(status_code=400, detail="cron_expression required for cron trigger")
for key in ("condition_json", "actions_json"):
val = body.get(key, "[]")
try:
if isinstance(val, str):
json.loads(val)
else:
json.dumps(val)
except (TypeError, json.JSONDecodeError):
raise HTTPException(status_code=400, detail=f"{key} must be valid JSON") from None
@router.get("/workspace/automations")
async def list_automations(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT * FROM automations ORDER BY created_at DESC").fetchall()
items = [dict(r) for r in rows]
return {"automations": items}
@router.post("/workspace/automations")
async def create_automation(request: Request):
body = await request.json() if request.headers.get("content-type") else {}
_validate_payload(body)
user = _current_user(request)
by = user.get("id") or 1
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO automations
(workspace, name, trigger_type, event, cron_expression, collection_id,
condition_json, actions_json, enabled, created_by)
VALUES (?,?,?,?,?,?,?,?,?,?)""",
(
body.get("workspace", "") or "",
(body.get("name") or "").strip(),
body.get("trigger_type", "event"),
body.get("event", "page.created"),
body.get("cron_expression", "") or "",
body.get("collection_id") or None,
_json_or_dumps(body.get("condition", body.get("condition_json", []))),
_json_or_dumps(body.get("actions", body.get("actions_json", []))),
int(body.get("enabled", True)),
by,
),
)
conn.commit()
new_id = cur.lastrowid
return {"id": new_id, "status": "created"}
@router.get("/workspace/automations/{auto_id}")
async def get_automation(request: Request, auto_id: int):
with get_conn() as conn:
row = conn.execute("SELECT * FROM automations WHERE id=?", (auto_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Automation not found")
return dict(row)
@router.put("/workspace/automations/{auto_id}")
async def update_automation(request: Request, auto_id: int):
body = await request.json() if request.headers.get("content-type") else {}
_validate_payload(body)
with get_conn() as conn:
row = conn.execute("SELECT id FROM automations WHERE id=?", (auto_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Automation not found")
conn.execute(
"""UPDATE automations SET
name=?, trigger_type=?, event=?, cron_expression=?, collection_id=?,
condition_json=?, actions_json=?, enabled=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?""",
(
(body.get("name") or "").strip(),
body.get("trigger_type", "event"),
body.get("event", "page.created"),
body.get("cron_expression", "") or "",
body.get("collection_id") or None,
_json_or_dumps(body.get("condition", body.get("condition_json", []))),
_json_or_dumps(body.get("actions", body.get("actions_json", []))),
int(body.get("enabled", True)),
auto_id,
),
)
conn.commit()
return {"id": auto_id, "status": "updated"}
@router.delete("/workspace/automations/{auto_id}")
async def delete_automation(request: Request, auto_id: int):
with get_conn() as conn:
conn.execute("DELETE FROM automations WHERE id=?", (auto_id,))
conn.commit()
return {"id": auto_id, "status": "deleted"}
async def _execute(automation_id: int, trigger_source: str, body: dict) -> dict:
page_id = body.get("page_id") if isinstance(body, dict) else None
collection_id = body.get("collection_id") if isinstance(body, dict) else None
context = {"collection_id": collection_id, "page_id": page_id}
if page_id:
context.update(get_page_context(int(page_id), collection_id or 0))
result = await run_automation(automation_id, trigger_source, context)
result["automation_id"] = automation_id
return result
@router.post("/workspace/automations/{auto_id}/run")
async def run_automation_endpoint(request: Request, auto_id: int):
body = await request.json() if request.headers.get("content-type") else {}
return await _execute(auto_id, "manual", body)
@router.post("/api/automations/{auto_id}/run")
async def run_automation_button(request: Request, auto_id: int):
body = await request.json() if request.headers.get("content-type") else {}
return await _execute(auto_id, "button", body)
@router.get("/workspace/automations/{auto_id}/runs")
async def automation_runs_history(request: Request, auto_id: int, limit: int = 50):
with get_conn() as conn:
rows = conn.execute(
"""SELECT * FROM automation_runs WHERE automation_id=?
ORDER BY created_at DESC, id DESC LIMIT ?""",
(auto_id, limit),
).fetchall()
return {"runs": [dict(r) for r in rows]}
+590 -64
View File
@@ -3,14 +3,17 @@ from __future__ import annotations
import json
import logging
from pathlib import Path
from fastapi import APIRouter, Request, HTTPException, Query
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from app.db import get_conn
from app.services.gitea_client import gitea
from app.auth.session import SessionManager
from app.db import get_conn
from app.routers.dashboard import _get_app_version
from app.routers.sidebar_config import get_sidebar_config_sync
from app.services.automations import fire_event
from app.services.gitea_client import gitea
logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board")
@@ -148,20 +151,34 @@ def _file_icon(name: str, content_format: str = "") -> str:
if content_format and content_format != 'file':
return 'edit'
n = name.lower()
if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n): return 'image'
if n.endswith('.pdf'): return 'file'
if re.search(r'\.(md|markdown)$', n): return 'edit'
if n.endswith('.py'): return 'file'
if re.search(r'\.(js|jsx|ts|tsx)$', n): return 'file'
if re.search(r'\.(html?|xml)$', n): return 'file'
if n.endswith('.css'): return 'file'
if n.endswith('.json'): return 'file'
if n.endswith('.sql'): return 'file'
if re.search(r'\.(sh|bash|zsh)$', n): return 'file'
if n.endswith('.ps1'): return 'file'
if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n): return 'file'
if re.search(r'\.(txt|log)$', n): return 'file'
if re.search(r'\.(zip|tar|gz|rar|7z)$', n): return 'file'
if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n):
return 'image'
if n.endswith('.pdf'):
return 'file'
if re.search(r'\.(md|markdown)$', n):
return 'edit'
if n.endswith('.py'):
return 'file'
if re.search(r'\.(js|jsx|ts|tsx)$', n):
return 'file'
if re.search(r'\.(html?|xml)$', n):
return 'file'
if n.endswith('.css'):
return 'file'
if n.endswith('.json'):
return 'file'
if n.endswith('.sql'):
return 'file'
if re.search(r'\.(sh|bash|zsh)$', n):
return 'file'
if n.endswith('.ps1'):
return 'file'
if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n):
return 'file'
if re.search(r'\.(txt|log)$', n):
return 'file'
if re.search(r'\.(zip|tar|gz|rar|7z)$', n):
return 'file'
return 'file'
@@ -173,7 +190,9 @@ def _load_workspace_pages(ws_cookie: str) -> list:
ws_id = int(ws_cookie)
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title, parent_section, content_format FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC",
"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",
(ws_id,),
).fetchall()
items = []
@@ -181,11 +200,13 @@ def _load_workspace_pages(ws_cookie: str) -> list:
is_folder = r["parent_section"] == "Workspace"
title = r["title"] or "Untitled"
sub_children = _load_children(r["id"])
is_shared = bool(r["is_shared"] or r["share_mode"] != "private" or r["published"])
items.append({
"db_id": r["id"], "name": title,
"id": f"page/{r['id']}",
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
"is_folder": is_folder,
"is_shared": is_shared,
"child_count": len(sub_children),
"children": sub_children,
})
@@ -198,7 +219,9 @@ def _load_children(parent_id: int) -> list:
"""Recursively load children of a page."""
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title, parent_section, content_format FROM pages WHERE parent_id=? AND deleted_at IS NULL ORDER BY created_at",
"SELECT id, title, parent_section, content_format, "
"is_shared, share_mode, COALESCE(published,0) AS published "
"FROM pages WHERE parent_id=? AND deleted_at IS NULL ORDER BY created_at",
(parent_id,),
).fetchall()
children = []
@@ -206,11 +229,13 @@ def _load_children(parent_id: int) -> list:
is_folder = r["parent_section"] == "Workspace"
title = r["title"] or "Untitled"
sub_children = _load_children(r["id"])
is_shared = bool(r["is_shared"] or r["share_mode"] != "private" or r["published"])
children.append({
"db_id": r["id"], "name": title,
"id": f"page/{r['id']}",
"icon": "📁" if is_folder else _file_icon(title, r["content_format"]),
"is_folder": is_folder,
"is_shared": is_shared,
"child_count": len(sub_children),
"children": sub_children,
})
@@ -233,6 +258,87 @@ def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list
return []
def _load_shared_sidebar_pages(user_id: int) -> tuple[list, list, list, list]:
"""Shared / received / published pages for the sidebar (reused by dashboard)."""
with get_conn() as conn:
own_ws = (
"SELECT w.id FROM workspaces w WHERE w.owner_id = ? "
"UNION SELECT wm.workspace_id FROM workspace_members wm WHERE wm.user_id = ?"
)
own_ws_names = (
"SELECT w.name FROM workspaces w WHERE w.owner_id = ? "
"UNION SELECT w.name FROM workspaces w "
"JOIN workspace_members wm ON wm.workspace_id = w.id WHERE wm.user_id = ?"
)
# Scope "shared by me"-style lists to pages in the user's own workspaces
# (or legacy pages whose workspace_id is NULL but identify the workspace by text).
scope_cond = (
f"(workspace_id IN ({own_ws}) "
f"OR (workspace_id IS NULL AND lower(workspace) IN "
f"(SELECT lower(name) FROM ({own_ws_names}))) "
f"OR (workspace_id IS NULL AND lower(workspace) = lower("
f"(SELECT login FROM users WHERE id=?))))"
)
scope_params = (user_id, user_id, user_id, user_id, user_id)
made_nominal = conn.execute(
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
"JOIN pages p ON p.id=s.page_id "
"WHERE s.created_by=? AND p.deleted_at IS NULL",
(user_id,),
).fetchall()
made_link = conn.execute(
f"SELECT id, title, workspace, updated_at FROM pages "
f"WHERE share_mode='anyone' AND published=0 AND deleted_at IS NULL "
f"AND {scope_cond}",
scope_params,
).fetchall()
made_flag = conn.execute(
f"SELECT id, title, workspace, updated_at FROM pages "
f"WHERE (is_shared=1 OR share_mode != 'private') AND COALESCE(published,0)=0 AND deleted_at IS NULL "
f"AND {scope_cond}",
scope_params,
).fetchall()
published_rows = conn.execute(
f"SELECT id, title, workspace, updated_at FROM pages "
f"WHERE published=1 AND deleted_at IS NULL AND {scope_cond} "
f"ORDER BY updated_at DESC LIMIT 20",
scope_params,
).fetchall()
received_rows = conn.execute(
"SELECT DISTINCT p.id, p.title, p.workspace, p.updated_at FROM page_shares s "
"JOIN pages p ON p.id=s.page_id "
"WHERE s.shared_with_user_id=? AND p.deleted_at IS NULL",
(user_id,),
).fetchall()
def _entry(r, icon):
return {
"id": f"page/{r['id']}",
"db_id": r["id"],
"name": r["title"] or "New page",
"icon": icon,
"url": f"/pages/{r['id']}",
"active": False,
"indent": 0,
"depth": 0,
"has_children": False,
"children": [],
}
made_map = {}
for r in (*made_nominal, *made_link, *made_flag):
made_map.setdefault(r["id"], r)
made_sorted = sorted(made_map.values(), key=lambda r: r["updated_at"] or "", reverse=True)[:20]
shared_made = [_entry(r, "link") for r in made_sorted]
received_sorted = [r for r in received_rows if r["id"] not in made_map]
received_sorted = sorted(received_sorted, key=lambda r: r["updated_at"] or "", reverse=True)[:20]
shared_received = [_entry(r, "users") for r in received_sorted]
published = [_entry(r, "globe") for r in published_rows]
shared_all = [_entry(r, "link") for r in made_sorted]
return shared_made, shared_received, published, shared_all
def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws_name = user.get("login", "Bruno") if user else "Bruno"
@@ -247,9 +353,12 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
gitea_owner = ""
gitea_repo = ""
has_active_workspace = False
local_ws_id = 0
if ws_cookie and ws_cookie.startswith("gitea:"):
# Gitea workspace: preserve context across pages
# Gitea workspace: preserve context across pages. Also load the local
# mirror workspace so it appears in "My Workspaces" in the top section
# of the sidebar, in parallel with the Gitea repository tree.
parts = ws_cookie.split(":", 2)
if len(parts) >= 3:
gitea_owner = parts[1]
@@ -257,8 +366,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
active_ws_name = f"{gitea_owner}/{gitea_repo}"
gitea_workspace = True
has_active_workspace = True
workspace_pages = [] # loaded client-side
# Get local workspace ID for mirror
# Get local workspace ID for mirror and load its tree
if user:
try:
with get_conn() as conn:
@@ -268,6 +376,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
).fetchone()
if row:
local_ws_id = row["id"]
workspace_pages = _load_workspace_pages(str(local_ws_id))
except Exception:
pass
elif ws_cookie and user:
@@ -284,7 +393,6 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
has_active_workspace = True
except (ValueError, Exception):
pass
local_ws_id = 0
recent = []
if owner and repo:
view_map = {
@@ -334,31 +442,8 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
"children": [],
})
# Load shared pages (anyone with link or published)
with get_conn() as conn:
shared_rows = conn.execute(
"SELECT id, title, workspace, share_mode, published FROM pages "
"WHERE share_mode='anyone' OR published=1 ORDER BY updated_at DESC LIMIT 20"
).fetchall()
shared_pages = []
published_pages = []
for r in shared_rows:
page_entry = {
"id": f"page/{r['id']}",
"db_id": r["id"],
"name": r["title"] or "New page",
"icon": "🌐" if r["published"] else "🔗",
"url": f"/pages/{r['id']}",
"active": False,
"indent": 0,
"depth": 0,
"has_children": False,
"children": [],
}
if r["published"]:
published_pages.append(page_entry)
else:
shared_pages.append(page_entry)
# Load shared pages
shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(uid)
# Auth method & OAuth badge data
auth_method = "local"
@@ -392,6 +477,8 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
"current_page": repo or "Dashboard", "last_edited": "now",
"recent_pages": recent, "private_pages": private_items,
"favorite_pages": favorites, "shared_pages": shared_pages,
"shared_made_pages": shared_made_pages,
"shared_received_pages": shared_received_pages,
"published_pages": published_pages,
"user": user,
"auth_method": auth_method,
@@ -399,7 +486,8 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
"github_linked": github_linked,
"has_active_workspace": has_active_workspace,
"app_version": _get_app_version(),
"local_workspaces": _local_workspaces_for_user(user)}
"local_workspaces": _local_workspaces_for_user(user),
"sidebar_config": json.dumps(get_sidebar_config_sync(uid))}
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
@@ -537,6 +625,8 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
sidebar["favorite_pages"] = []
sidebar["private_pages"] = [p for p in all_pages if p.get("source") == "🔒 Private"]
sidebar["shared_pages"] = []
sidebar["shared_made_pages"] = []
sidebar["shared_received_pages"] = []
template = env.get_template("library.html")
return template.render(**sidebar)
@@ -590,7 +680,6 @@ async def remove_favorite(request: Request, page_id: int):
@router.post("/api/share/{page_id:int}")
async def update_share(request: Request, page_id: int):
"""Save share settings for a page."""
import json
body = await request.json()
mode = body.get("mode", "private")
published = body.get("published", False)
@@ -603,6 +692,33 @@ async def update_share(request: Request, page_id: int):
return {"status": "ok", "share_mode": mode, "published": published}
@router.post("/api/pages/{page_id:int}/publish")
async def publish_page(request: Request, page_id: int):
"""Publish a page to the web (generates publish_slug)."""
import secrets
slug = "p-" + secrets.token_urlsafe(8)
with get_conn() as conn:
conn.execute(
"UPDATE pages SET is_published=1, publish_slug=?, share_mode='anyone' WHERE id=?",
(slug, page_id),
)
conn.commit()
row = conn.execute("SELECT title FROM pages WHERE id=?", (page_id,)).fetchone()
return {"is_published": True, "publish_slug": slug, "title": row["title"] if row else ""}
@router.delete("/api/pages/{page_id:int}/publish")
async def unpublish_page(request: Request, page_id: int):
"""Unpublish a page from the web."""
with get_conn() as conn:
conn.execute(
"UPDATE pages SET is_published=0, publish_slug='' WHERE id=?",
(page_id,),
)
conn.commit()
return {"is_published": False}
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
@router.get("/api/trash")
@@ -746,7 +862,7 @@ async def create_property(owner: str, repo: str, name: str = Query(...),
)
conn.commit()
except Exception as e:
raise HTTPException(409, f"Property already exists: {e}")
raise HTTPException(409, f"Property already exists: {e}") from e
return {"status": "ok", "name": name, "type": prop_type}
@@ -800,7 +916,7 @@ async def extract_ai_keywords(owner: str, repo: str):
if not issue.get("pull_request"):
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
except Exception as e:
raise HTTPException(500, str(e))
raise HTTPException(500, str(e)) from e
return {"status": "ok", "issues_scanned": len(issues)}
@@ -814,7 +930,7 @@ async def create_page(request: Request, title: str = Query(default=""),
"""Create a new Markdown page, optionally as a sub-page."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
page_title = title.strip() if title else "Untitled"
page_title = title.strip() if title else ""
try:
with get_conn() as conn:
# Compute next sort_order for this parent
@@ -832,6 +948,8 @@ async def create_page(request: Request, title: str = Query(default=""),
)
conn.commit()
page_id = cur.lastrowid
await fire_event("page.created", {"page_id": page_id, "title": page_title,
"workspace": ws_key, "parent_id": parent_id})
return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id}
except Exception as e:
logger.error("create_page failed: %s", e)
@@ -862,18 +980,27 @@ async def update_page(request: Request, page_id: int, title: str = Query(default
if content_format:
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
conn.commit()
await fire_event("page.updated", {"page_id": page_id, "title": title,
"content_format": content_format or "markdown"})
return {"status": "ok"}
@router.post("/api/pages/{page_id}/blocks")
async def save_page_blocks(request: Request, page_id: int):
"""Save blocks JSON content (Notion-style block editor)."""
"""Save blocks JSON content (Notion-style block editor).
v5.4.0: a version snapshot is recorded (if the block content actually
changed) so the UI can browse the version history and restore any of them.
"""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body")
raise HTTPException(400, "Invalid JSON body") from None
blocks_json = json.dumps(body.get("blocks", []))
title = body.get("title", "")
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = (user or {}).get("id")
with get_conn() as conn:
if title:
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
@@ -881,14 +1008,406 @@ async def save_page_blocks(request: Request, page_id: int):
"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)
conn.commit()
await fire_event("page.updated", {"page_id": page_id, "title": title or "",
"content_format": "blocks"})
return {"status": "ok", "id": page_id}
def _record_version(conn, page_id: int, user_id, title: str, blocks_json: str) -> None:
"""Insert a version snapshot unless it is byte-identical to the latest one."""
prev = conn.execute(
"SELECT COALESCE(title, ''), blocks_json FROM page_versions "
"WHERE page_id=? ORDER BY id DESC LIMIT 1",
(page_id,),
).fetchone()
if prev is not None and prev["blocks_json"] == blocks_json:
if prev["title"] != (title or ""):
conn.execute(
"UPDATE page_versions SET title=? WHERE id="
"(SELECT id FROM page_versions WHERE page_id=? ORDER BY id DESC LIMIT 1)",
(title or "", page_id),
)
return
conn.execute(
"INSERT INTO page_versions (page_id, user_id, title, blocks_json, note) VALUES (?,?,?,?,'edited')",
(page_id, user_id, title or "", blocks_json),
)
def _block_texts(b: dict) -> list[str]:
"""Flatten a block (including children) into searchable text chunks."""
out = []
raw = b.get("content")
if isinstance(raw, str) and raw.strip():
out.append(raw)
for child in b.get("children") or []:
out.extend(_block_texts(child))
return out
@router.get("/api/pages/{page_id}/backlinks")
async def page_backlinks(request: Request, page_id: int):
"""v5.4.0: pages that link to this one ("Lié depuis…").
Scans every non-deleted page's blocks (and raw markdown) for an internal
reference to ``/pages/{page_id}`` or ``#fdblk-…`` inside ``/pages/{page_id}``.
"""
target = f"/pages/{page_id}" if page_id else None
backlinks = []
with get_conn() as conn:
rows = conn.execute(
"SELECT id, title, workspace, content, content_format, updated_at "
"FROM pages WHERE deleted_at IS NULL AND id != ?",
(page_id,),
).fetchall()
for r in rows:
fmt = r["content_format"]
hits = False
if fmt == "blocks" and r["content"]:
try:
blocks = json.loads(r["content"])
for b in blocks if isinstance(blocks, list) else []:
for text in _block_texts(b):
if target and f"/pages/{page_id}" in text:
hits = True
break
if hits:
break
except (json.JSONDecodeError, TypeError):
hits = target and f"/pages/{page_id}" in (r["content"] or "")
elif fmt == "markdown":
hits = target and f"/pages/{page_id}" in (r["content"] or "")
elif r["content"]:
hits = target and f"/pages/{page_id}" in json.dumps(r["content"])
if not hits and target:
hits = f"/pages/{page_id}" in (r["content"] or "")
if hits:
backlinks.append({
"id": r["id"],
"title": r["title"] or "Untitled",
"workspace": r["workspace"] or "",
"updated_at": r["updated_at"] or "",
})
backlinks.sort(key=lambda x: x.get("updated_at") or "", reverse=True)
return {"backlinks": backlinks}
@router.get("/api/pages/{page_id}/versions")
async def page_versions(request: Request, page_id: int):
"""v5.4.0: version history for a block-editor page."""
with get_conn() as conn:
rows = conn.execute(
"SELECT pv.id, pv.title, pv.note, pv.created_at, "
"COALESCE(u.login, '') AS author "
"FROM page_versions pv LEFT JOIN users u ON u.id=pv.user_id "
"WHERE pv.page_id=? ORDER BY pv.id DESC LIMIT 100",
(page_id,),
).fetchall()
return {"versions": [dict(r) for r in rows]}
@router.post("/api/pages/{page_id}/versions/{version_id}/restore")
async def restore_version(request: Request, page_id: int, version_id: int):
"""v5.4.0: restore a page from a version snapshot."""
with get_conn() as conn:
ver = conn.execute(
"SELECT * FROM page_versions WHERE id=? AND page_id=?",
(version_id, page_id),
).fetchone()
if not ver:
raise HTTPException(404, "Version not found")
conn.execute(
"UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(ver["blocks_json"], ver["title"] or "", page_id),
)
conn.commit()
await fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "",
"content_format": "blocks"})
return {"status": "ok", "restored": version_id}
# ═══════════ v5.4.0: Page & collection duplication ═══════════
@router.post("/api/pages/{page_id}/duplicate")
async def duplicate_page(request: Request, page_id: int):
"""Duplicate a page (block/markdown content included) as a sibling."""
SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)
).fetchone()
if not row:
raise HTTPException(404, "Page not found")
page = dict(row)
def copy_tree(src_id: int, parent_id) -> int:
with get_conn() as conn:
conn.execute("SELECT * FROM pages WHERE id=?", (src_id,)).fetchone()
cur = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, "
"parent_section, parent_id, sort_order, share_mode, published, is_published, "
"publish_slug, is_shared, cover_url, page_icon, created_at, updated_at) "
"SELECT workspace, workspace_id, title || ' copy', content, content_format, "
"parent_section, ?, sort_order, share_mode, 0, is_published, '', is_shared, "
"cover_url, page_icon, created_at, updated_at FROM pages WHERE id=?",
(parent_id, src_id),
)
new_id = cur.lastrowid
conn.commit()
for child in conn.execute(
"SELECT id FROM pages WHERE parent_id=? ", (src_id,)
).fetchall():
copy_tree(child["id"], new_id)
return new_id
new_id = copy_tree(page_id, page.get("parent_id"))
title = (page.get("title") or "Untitled") + " copy"
with get_conn() as conn:
conn.execute("UPDATE pages SET title=? WHERE id=?", (title, new_id))
conn.commit()
await fire_event("page.created", {"page_id": new_id, "title": title,
"workspace": page.get("workspace")})
return {"status": "ok", "id": new_id, "title": title}
# ═══════════ v5.5.0: Cover & icon ═══════════
def _upload_root() -> Path:
import os
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
def _ws_id_for(request: Request, page_id: int) -> int:
"""The active workspace id for the page (cookie, then page, then fallback 1)."""
cookie = request.cookies.get("flowdeck_workspace", "")
try:
ws_id = int(cookie)
if ws_id > 0:
return ws_id
except (ValueError, TypeError):
pass
with get_conn() as conn:
row = conn.execute(
"SELECT workspace_id FROM pages WHERE id=?", (page_id,)
).fetchone()
if row and row["workspace_id"]:
return int(row["workspace_id"])
return 1
async def _store_uploaded_file(request: Request, ws_id: int) -> dict:
"""Persist an uploaded file under uploads/workspace_{ws_id}/ and return
{file_url, file_path, mime_type, size, file_name}."""
import datetime
import re as _re
form = await request.form()
upload = form.get("file")
if upload is None or not hasattr(upload, "filename"):
raise HTTPException(400, "file field required")
original = (upload.filename or "cover.png").replace("\\", "/").rsplit("/", 1)[-1]
name = _re.sub(r"[^A-Za-z0-9._-]", "_", original)[:120]
ext = name.rsplit(".", 1)[-1].lower() if "." in name else "bin"
if ext not in {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}:
raise HTTPException(400, "Unsupported image format")
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S")
folder = _upload_root() / f"uploads/workspace_{ws_id}"
folder.mkdir(parents=True, exist_ok=True)
final = f"{stamp}_{name}"
(folder / final).write_bytes(await upload.read())
mime = f"image/{'svg+xml' if ext == 'svg' else 'jpeg' if ext == 'jpg' else ext}"
return {
"file_url": f"/api/files/{ws_id}/{final}",
"file_path": f"uploads/workspace_{ws_id}/{final}",
"mime_type": mime,
"size": (folder / final).stat().st_size,
"file_name": name,
}
@router.post("/api/pages/{page_id}/cover")
async def set_page_cover(request: Request, page_id: int):
"""v5.5.0: upload an image cover for a page.
JSON body {cover_url} accepts an external URL; multipart ``file`` uploads
an image stored in the workspace's uploads directory.
"""
ctype = (request.headers.get("content-type") or "").lower()
if ctype.startswith("application/json"):
body = await request.json()
cover_url = (body.get("cover_url") or "").strip()
if not cover_url:
raise HTTPException(400, "cover_url required")
with get_conn() as conn:
conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (cover_url, page_id))
conn.commit()
return {"status": "ok", "page_id": page_id, "cover_url": cover_url}
ws_id = _ws_id_for(request, page_id)
meta = await _store_uploaded_file(request, ws_id)
with get_conn() as conn:
conn.execute("UPDATE pages SET cover_url=? WHERE id=?", (meta["file_url"], page_id))
conn.commit()
return {"status": "ok", "page_id": page_id, "cover_url": meta["file_url"]}
@router.delete("/api/pages/{page_id}/cover")
async def remove_page_cover(request: Request, page_id: int):
with get_conn() as conn:
conn.execute("UPDATE pages SET cover_url='' WHERE id=?", (page_id,))
conn.commit()
return {"status": "ok", "page_id": page_id}
@router.post("/api/pages/{page_id}/icon")
async def set_page_icon(request: Request, page_id: int):
"""v5.5.0: set a page emoji/icon label."""
body = await request.json()
icon = (body.get("icon") or "").strip()
if len(icon) > 16:
raise HTTPException(400, "icon too long")
with get_conn() as conn:
conn.execute("UPDATE pages SET page_icon=? WHERE id=?", (icon, page_id))
conn.commit()
return {"status": "ok", "page_id": page_id, "icon": icon}
# ═══════════ v5.4.0: Import (Markdown / .md / Notion .zip) ═══════════
async def _create_page_from_markdown(request: Request, markdown: str, title: str = "") -> int:
"""Convert markdown → blocks (server-side, same mapping as the editor) and
create a page in the caller's workspace."""
from app.services.export import _md_to_blocks
blocks = _md_to_blocks(markdown or "")
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws_key = user.get("login", "Bruno") if user else "Bruno"
file_title = title.strip() or "Import"
fallback = (file_title or "Imported page").replace("/", "-").replace("\\", "-")[:120]
if not blocks:
blocks = [{"type": "paragraph", "content": markdown or ""}]
with get_conn() as conn:
next_order = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL",
(ws_key,),
).fetchone()[0]
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, parent_section, sort_order, workspace_id) "
"VALUES (?,?,?,?,'Private',?,(SELECT id FROM workspaces WHERE name=? LIMIT 1))",
(ws_key, fallback, json.dumps(blocks), "blocks", next_order, ws_key),
)
conn.commit()
return cur.lastrowid
@router.post("/api/pages/import")
async def import_page(request: Request):
"""v5.4.0: import markdown text as a new page (blocks) in the workspace."""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
markdown = body.get("markdown", "")
title = body.get("title", "")
if not markdown and not body.get("csv"):
raise HTTPException(400, "markdown field required")
if not markdown.strip():
raise HTTPException(400, "markdown is empty")
page_id = await _create_page_from_markdown(request, markdown, title)
await fire_event("page.created", {"page_id": page_id, "title": title or "Import",
"workspace": ""})
return {"status": "ok", "id": page_id}
@router.post("/api/pages/import/file")
async def import_file(request: Request):
"""v5.4.0: import an uploaded .md file (or a Notion export .zip containing
markdown pages) into the workspace. Returns the created page ids."""
import io as _io
import zipfile
form = await request.form()
upload = form.get("file")
if upload is None or not hasattr(upload, "filename"):
raise HTTPException(400, "file field required")
filename = (upload.filename or "import.md").replace("\\", "/").rsplit("/", 1)[-1]
data = await upload.read()
created_ids = []
if filename.lower().endswith(".zip"):
try:
zf = zipfile.ZipFile(_io.BytesIO(data))
except zipfile.BadZipFile:
raise HTTPException(400, "Invalid zip archive") from None
md_entries = sorted(
(n for n in zf.namelist() if n.lower().endswith((".md", ".markdown"))),
key=lambda n: (n.count("/"), n.lower()),
)
if not md_entries:
raise HTTPException(400, "No .md files found in archive")
for name in md_entries:
raw = zf.read(name).decode("utf-8", errors="replace")
title = name.replace("\\", "/").rsplit("/", 1)[-1][:-3]
try:
created_ids.append(await _create_page_from_markdown(request, raw, title))
except Exception as exc: # noqa: BLE001 - keep importing the rest
logger.warning("import failed for %s: %s", name, exc)
else:
try:
raw = data.decode("utf-8")
except UnicodeDecodeError:
raise HTTPException(400, "Only text/markdown files are supported") from None
title = filename.replace(".md", "").replace(".markdown", "").replace(".txt", "")
created_ids.append(await _create_page_from_markdown(request, raw, title))
if not created_ids:
raise HTTPException(422, "No pages could be imported")
return {"status": "ok", "ids": created_ids, "count": len(created_ids)}
@router.post("/api/og/metadata")
async def og_metadata(request: Request):
"""v5.5.0: Open Graph metadata for a bookmark card."""
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.services.og_fetcher import fetch_og_metadata
data = await fetch_og_metadata(url)
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.
Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int }
- workspace_id: move page to a different workspace
- parent_id: change parent (0 = root level)
@@ -901,12 +1420,12 @@ async def move_page(request: Request, page_id: int):
new_ws_id = body.get("workspace_id")
new_parent_id = body.get("parent_id", 0)
new_order = body.get("new_order", 0)
with get_conn() as conn:
row = conn.execute("SELECT id, workspace, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
raise HTTPException(404, "Page not found")
if new_ws_id:
# Move to a different workspace: get the workspace name
ws_row = conn.execute("SELECT name FROM workspaces WHERE id=?", (new_ws_id,)).fetchone()
@@ -926,8 +1445,10 @@ async def move_page(request: Request, page_id: int):
"UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(new_order, page_id),
)
conn.commit()
await fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0,
"parent_id": new_parent_id})
return {"status": "ok", "id": page_id}
@@ -941,12 +1462,15 @@ async def delete_page(request: Request, page_id: int):
import datetime
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.utcnow().isoformat(), page_id,))
conn.commit()
await fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})
return {"status": "ok", "deleted": page_id, "title": row["title"]}
@router.get("/pages/{page_id}", response_class=HTMLResponse)
async def view_page(request: Request, page_id: int):
"""Render a page as HTML, or a file viewer for uploaded files."""
"""Render a page as HTML, or a file viewer for uploaded files.
?embed=1 — minimal mode for side peek (editor only, no header)."""
embed = request.query_params.get("embed") == "1"
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
with get_conn() as conn:
@@ -968,7 +1492,7 @@ 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))}
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 ""}
# For file pages, extract file metadata and add to page_data
if page.get("content_format") == "file":
@@ -1000,11 +1524,13 @@ async def view_page(request: Request, page_id: int):
ctx = {**sidebar, "page": page, "page_favorited": fav is not None,
"page_share_mode": page.get("share_mode", "private"),
"page_published": bool(page.get("published", 0)),
"page_is_shared": bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)),
"page_data": page_data,
"breadcrumb_items": nav_crumbs,
"nav_workspace_id": page.get("workspace_id") or 0,
"nav_page_id": page_id}
template = env.get_template("page_editor.html")
"nav_page_id": page_id,
"embed_mode": embed}
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
response = template.render(**ctx)
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
@@ -1034,4 +1560,4 @@ async def sync_project(owner: str, repo: str):
conn.commit()
return {"status": "ok", "issues_synced": len(issues_only)}
except Exception as e:
raise HTTPException(500, str(e))
raise HTTPException(500, str(e)) from e
+184
View File
@@ -0,0 +1,184 @@
"""FlowDeck — Collaboration API (v4.9.0): inline comments on pages + mentions.
Comments live in the existing `comments` table, extended with a target_type /
target_id pair and inline anchors (anchor_block_id + text offsets). Mentions
written in a comment body automatically notify the mentioned users.
"""
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 import notifications as notif
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collaboration"], prefix="/api")
def _current_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="Authentication required")
return user
def _page_url(page_id: int) -> str:
from app.config import settings
return f"{settings.app_base_url}/pages/{page_id}"
def _serialize(rows):
out = []
for r in rows:
d = dict(r)
d["author"] = {
"id": r["author_id"],
"login": r["author_login"],
"full_name": r["author_name"],
"avatar_url": r["author_avatar"],
"avatar_color": r["author_color"],
}
for k in ("author_id", "author_login", "author_name", "author_avatar", "author_color"):
d.pop(k, None)
out.append(d)
return out
@router.get("/pages/{page_id}/comments")
async def list_comments(request: Request, page_id: int):
"""List page-level and inline comments for a FlowDeck page."""
_current_user(request)
with get_conn() as conn:
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(404, "Page not found")
rows = conn.execute(
"""SELECT c.*, c.user_id AS author_id, u.login AS author_login,
u.full_name AS author_name, u.avatar_url AS author_avatar,
u.avatar_color AS author_color
FROM comments c
JOIN users u ON c.user_id = u.id
WHERE c.target_type='page' AND c.target_id=?
ORDER BY c.created_at ASC, c.id ASC""",
(page_id,),
).fetchall()
return {"page_id": page_id, "comments": _serialize(rows)}
@router.post("/pages/{page_id}/comments")
async def add_comment(request: Request, page_id: int):
"""Create a page or inline comment. Mentions (@login) notify users."""
user = _current_user(request)
body = await request.json() if request.headers.get("content-type") else {}
text = (body.get("body") or "").strip()
if not text:
raise HTTPException(400, "body required")
anchor_block = body.get("anchor_block_id")
anchor_start = body.get("anchor_start")
anchor_end = body.get("anchor_end")
# normalize empty anchor → page-level comment
if not anchor_block or anchor_start is None or anchor_end is None:
anchor_block, anchor_start, anchor_end = None, None, None
elif int(anchor_start) == int(anchor_end):
anchor_block, anchor_start, anchor_end = None, None, None
parent_id = body.get("parent_id")
uid = user["id"]
with get_conn() as conn:
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(404, "Page not found")
conn.execute(
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
(uid, user.get("login", "admin"), user.get("full_name", "Admin")),
)
cur = conn.execute(
"""INSERT INTO comments
(page_id, user_id, body, parent_id, target_type, target_id,
anchor_block_id, anchor_start, anchor_end)
VALUES (?,?,?,?, 'page', ?, ?, ?, ?)""",
(page_id, uid, text, parent_id, page_id, anchor_block, anchor_start, anchor_end),
)
comment_id = cur.lastrowid
conn.commit()
# Notify users @-mentioned in the comment (skip the author).
url = _page_url(page_id)
title = f"New comment on “{page['title']}”"
message = f"{user.get('full_name') or user.get('login')} commented: {text[:300]}"
notif.process_mentions(
text, uid, "mention", title, message,
"page", page_id, url, conn=conn,
)
conn.commit()
return {"id": comment_id, "status": "created"}
@router.post("/pages/{page_id}/mentions")
async def notify_page_mentions(request: Request, page_id: int):
"""Notify users @-mentioned in a page's content (called on save).
Accepts {"text": "..."} containing @login handles. Deduplicated server-side
against a per-page cache so repeated auto-saves don't spam notifications.
"""
user = _current_user(request)
body = await request.json() if request.headers.get("content-type") else {}
text = body.get("text") or ""
with get_conn() as conn:
page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(404, "Page not found")
url = _page_url(page_id)
mentioned = notif.process_mentions(
text, user["id"], "mention", f"You were mentioned in “{page['title']}”",
f"{user.get('full_name') or user.get('login')} mentioned you on a page.",
"page", page_id, url, conn=conn,
)
conn.commit()
return {"mentioned": mentioned}
@router.put("/comments/{comment_id}")
async def update_comment(request: Request, comment_id: int):
"""Update a comment body or resolve/unresolve it."""
user = _current_user(request)
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM comments WHERE id=?", (comment_id,)
).fetchone()
if not row:
raise HTTPException(404, "Comment not found")
if row["user_id"] != user["id"]:
raise HTTPException(403, "Not allowed to edit this comment")
if "body" in body and body.get("body") is not None:
conn.execute(
"UPDATE comments SET body=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(body["body"].strip(), comment_id),
)
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()
return {"id": comment_id, "status": "updated"}
@router.delete("/comments/{comment_id}")
async def delete_comment(request: Request, comment_id: int):
"""Delete a comment and its replies."""
user = _current_user(request)
with get_conn() as conn:
row = conn.execute("SELECT * FROM comments WHERE id=?", (comment_id,)).fetchone()
if not row:
raise HTTPException(404, "Comment not found")
if row["user_id"] != user["id"]:
# allow page "owners" — fall back to a simple ownership rule for now
raise HTTPException(403, "Not allowed to delete this comment")
conn.execute("DELETE FROM comments WHERE id=? OR parent_id=?", (comment_id, comment_id))
conn.commit()
return {"id": comment_id, "status": "deleted"}
+317 -30
View File
@@ -5,16 +5,78 @@ import json
import logging
import sqlite3
from fastapi import APIRouter, Request, HTTPException, Query
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from app.db import get_conn
from app.auth.session import SessionManager
from app.services.automations import fire_event
from app.services.db_templates import materialize_properties
from app.services.property_types import validate_property_rule
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collections"], prefix="/db")
def _apply_template(conn, template_name: str) -> dict | None:
"""Resolve a database template by name (from the seeded/built-in set)."""
if not template_name:
return None
row = conn.execute(
"SELECT id, name, icon, description, schema_json FROM database_templates WHERE name=?",
(template_name,),
).fetchone()
if row:
return dict(row)
return None
def _validate_page_properties(conn, collection_id: int, properties: dict, exclude_page_id: int | None = None) -> None:
"""Validate submitted property values against the collection's schema.
Raises ``HTTPException(400)`` with a user-friendly message on the first
failure (type, required, unique, min/max).
"""
props = conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=?", (collection_id,)
).fetchall()
for prop in props:
ptype = prop["prop_type"]
if ptype == "title":
continue
pid = prop["id"]
# Values may be keyed by property id (FlowDeckDB UI) or by name (agent).
value = properties.get(str(pid))
if value is None:
value = properties.get(prop["name"])
validation = prop["validation_json"] if "validation_json" in prop.keys() else "{}"
existing_values = None
try:
import json as _json
vcfg = _json.loads(validation) if validation else {}
except Exception:
vcfg = {}
if vcfg.get("unique"):
rows = conn.execute(
"SELECT id, property_values_json FROM collection_pages WHERE collection_id=?",
(collection_id,),
).fetchall()
existing_values = []
for r in rows:
if exclude_page_id is not None and r["id"] == exclude_page_id:
continue
try:
pv = _json.loads(r["property_values_json"] or "{}")
except Exception:
pv = {}
existing_values.append(pv.get(str(pid)) or pv.get(prop["name"]))
ok, msg = validate_property_rule(ptype, value, validation, existing_values=existing_values)
if not ok:
raise HTTPException(status_code=400, detail=f"Property '{prop['name']}': {msg}")
# ── API: List & Create (no path params) ──
@@ -46,7 +108,7 @@ async def list_collections_api(request: Request):
@router.post("/api")
async def create_collection_api(request: Request):
"""API: create a new collection."""
"""API: create a new collection, optionally from a database template."""
try:
body = await request.json()
except Exception:
@@ -60,10 +122,24 @@ async def create_collection_api(request: Request):
icon = body.get("icon", "📋")
gitea_owner = body.get("gitea_owner")
gitea_repo = body.get("gitea_repo")
schema_json = json.dumps(body.get("schema", []))
schema = body.get("schema", [])
is_locked = body.get("is_locked", False)
with get_conn() as conn:
# Apply a template if requested (provides schema + icon).
tpl = _apply_template(conn, body.get("template"))
if tpl:
if body.get("name"):
name = body["name"].strip()
description = tpl["description"]
icon = tpl.get("icon") or icon
try:
schema = json.loads(tpl["schema_json"])
except (json.JSONDecodeError, TypeError):
schema = []
schema_json = json.dumps(schema)
cur = conn.execute(
"""INSERT INTO collections
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked)
@@ -72,6 +148,8 @@ async def create_collection_api(request: Request):
)
collection_id = cur.lastrowid
materialize_properties(conn, collection_id, schema)
conn.execute(
"""INSERT INTO collection_views
(collection_id, name, view_type, config_json)
@@ -84,6 +162,7 @@ async def create_collection_api(request: Request):
)
conn.commit()
await fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon})
return {"id": collection_id, "name": name, "status": "created"}
@@ -122,6 +201,7 @@ async def update_collection_api(request: Request, collection_id: int):
)
conn.commit()
await fire_event("collection.updated", {"collection_id": collection_id, "name": name})
return {"id": collection_id, "status": "updated"}
@@ -138,9 +218,149 @@ async def delete_collection_api(request: Request, collection_id: int):
conn.execute("DELETE FROM collections WHERE id=?", (collection_id,))
conn.commit()
await fire_event("collection.deleted", {"collection_id": collection_id,
"name": existing["name"] if existing else ""})
return {"id": collection_id, "status": "deleted"}
@router.post("/{collection_id}/duplicate")
async def duplicate_collection_api(request: Request, collection_id: int):
"""v5.4.0: deep-duplicate a database (views + properties + pages + data
sources) into a new collection named '<original> (copy)'."""
with get_conn() as conn:
src = conn.execute(
"SELECT * FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not src:
raise HTTPException(status_code=404, detail="Collection not found")
new_name = (src["name"] or "Database") + " copy"
cur = conn.execute(
"""INSERT INTO collections
(name, description, icon, schema_json, gitea_owner, gitea_repo, is_locked,
is_inline, parent_page_id, workspace_id, created_by, is_task, updated_at)
SELECT ?, description, icon, schema_json, gitea_owner, gitea_repo, is_locked,
is_inline, parent_page_id, workspace_id, created_by, is_task, updated_at
FROM collections WHERE id=?""",
(new_name, collection_id),
)
new_id = cur.lastrowid
# ── Properties (remap ids so relation/rollup refs stay valid) ──
prop_map: dict[int, int] = {}
rows = conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
for p in rows:
ncur = conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, options_json, number_format,
related_collection_id, reverse_name, relation_property_id,
target_property_id, rollup_function, formula_expression,
position, required, visible_in_views)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(new_id, p["name"], p["prop_type"], p["options_json"], p["number_format"],
None, p["reverse_name"], None, None, p["rollup_function"],
p["formula_expression"], p["position"], p["required"],
p["visible_in_views"]),
)
prop_map[p["id"]] = ncur.lastrowid
# Fix cross-property references after all rows exist (creates may target
# columns not inserted yet). Related collection remapped to the copy.
for p in rows:
p = dict(p) # convert sqlite3.Row to dict
new_pid = prop_map[p["id"]]
related = p["related_collection_id"]
related_new = new_id if related == collection_id else related
if p["prop_type"] == "relation":
conn.execute(
"UPDATE collection_properties SET related_collection_id=? WHERE id=?",
(related_new, new_pid),
)
if p.get("relation_property_id") and p["relation_property_id"] in prop_map:
conn.execute(
"UPDATE collection_properties SET relation_property_id=? WHERE id=?",
(prop_map[p["relation_property_id"]], new_pid),
)
if p.get("target_property_id") and p["target_property_id"] in prop_map:
conn.execute(
"UPDATE collection_properties SET target_property_id=? WHERE id=?",
(prop_map[p["target_property_id"]], new_pid),
)
# ── Views ──
vrows = conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
for v in vrows:
conn.execute(
"""INSERT INTO collection_views
(collection_id, name, view_type, config_json, position)
VALUES (?,?,?,?,?)""",
(new_id, v["name"], v["view_type"], v["config_json"], v["position"]),
)
# ── Pages (rows) with property ids remapped to the copy's properties ──
prows = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
page_map: dict[int, int] = {}
for p in prows:
try:
pv = json.loads(p["property_values_json"]) if p["property_values_json"] else {}
except (json.JSONDecodeError, TypeError):
pv = {}
pv_new = {}
for k, val in pv.items():
try:
prop_id = int(k)
except (ValueError, TypeError):
prop_id = None
new_key = str(prop_map.get(prop_id, prop_id)) if prop_id is not None else k
pv_new[new_key] = val
ncur = conn.execute(
"""INSERT INTO collection_pages
(collection_id, title, icon, position, parent_id, gitea_issue_id,
gitea_issue_number, property_values_json, created_at, updated_at)
SELECT ?, title, icon, position, NULL, NULL, NULL, ?, created_at, updated_at
FROM collection_pages WHERE id=?""",
(new_id, json.dumps(pv_new), p["id"]),
)
page_map[p["id"]] = ncur.lastrowid
# Re-parent sub-items to the copied rows.
for p in prows:
if p["parent_id"] and p["parent_id"] in page_map:
conn.execute(
"UPDATE collection_pages SET parent_id=? WHERE id=?",
(page_map[p["parent_id"]], page_map[p["id"]]),
)
# ── Data sources (linked DBs) ──
drows = conn.execute(
"SELECT * FROM collection_data_sources WHERE collection_id=?",
(collection_id,),
).fetchall()
for d in drows:
src_coll = d["source_collection_id"]
src_now = new_id if src_coll == collection_id else src_coll
conn.execute(
"""INSERT INTO collection_data_sources
(collection_id, source_collection_id, source_name, is_linked, position)
VALUES (?,?,?,?,?)""",
(new_id, src_now, d["source_name"], d["is_linked"], d["position"]),
)
conn.commit()
await fire_event("collection.created", {"collection_id": new_id, "name": new_name})
return {"id": new_id, "name": new_name, "status": "duplicated"}
# ── Page CRUD (standalone, BEFORE collection wildcards) ──
@@ -176,9 +396,18 @@ async def update_page_api(request: Request, page_id: int):
icon = body.get("icon", existing["icon"])
position = body.get("position", existing["position"])
parent_id = body.get("parent_id", existing["parent_id"])
property_values = json.dumps(
body.get("properties", json.loads(existing["property_values_json"]))
)
if "properties" in body:
props = body["properties"]
else:
try:
props = json.loads(existing["property_values_json"])
except (json.JSONDecodeError, TypeError):
props = {}
_validate_page_properties(conn, existing["collection_id"], props, exclude_page_id=page_id)
property_values = json.dumps(props)
conn.execute(
"""UPDATE collection_pages
@@ -189,6 +418,13 @@ async def update_page_api(request: Request, page_id: int):
)
conn.commit()
await fire_event("page.updated", {
"page_id": page_id,
"collection_id": existing["collection_id"],
"title": title,
"icon": icon,
"properties": props,
})
return {"id": page_id, "status": "updated"}
@@ -205,6 +441,11 @@ async def delete_page_api(request: Request, page_id: int):
conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,))
conn.commit()
await fire_event("page.deleted", {
"page_id": page_id,
"collection_id": existing["collection_id"],
"title": existing["title"],
})
return {"id": page_id, "status": "deleted"}
@@ -289,6 +530,7 @@ async def create_property_api(request: Request, collection_id: int):
number_format = body.get("number_format", "number")
required = int(body.get("required", False))
visible = int(body.get("visible_in_views", True))
validation_json = json.dumps(body.get("validation", {}))
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
@@ -304,13 +546,14 @@ 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)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(collection_id, name, prop_type, options_json, number_format, max_pos, required, visible),
position, required, visible_in_views, validation_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(collection_id, name, prop_type, options_json, number_format, max_pos,
required, visible, validation_json),
)
conn.commit()
except Exception:
raise HTTPException(status_code=409, detail=f"Property '{name}' already exists")
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"}
@@ -335,12 +578,16 @@ async def update_property_api(request: Request, prop_id: int):
number_format = body.get("number_format", existing["number_format"])
required = int(body.get("required", existing["required"]))
visible = int(body.get("visible_in_views", existing["visible_in_views"]))
if "validation" in body:
validation_json = json.dumps(body.get("validation", {}))
else:
validation_json = existing["validation_json"] if "validation_json" in existing.keys() else "{}"
conn.execute(
"""UPDATE collection_properties
SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?
SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?, validation_json=?
WHERE id=?""",
(name, options_json, number_format, required, visible, prop_id),
(name, options_json, number_format, required, visible, validation_json, prop_id),
)
conn.commit()
@@ -661,8 +908,16 @@ async def create_sub_item(request: Request, collection_id: int, page_id: int):
(collection_id, title, page_id, max_pos, json.dumps(body.get("properties", {}))),
)
conn.commit()
new_id = cur.lastrowid
return {"id": cur.lastrowid, "title": title, "parent_id": page_id, "status": "created"}
await fire_event("page.created", {
"page_id": new_id,
"collection_id": collection_id,
"parent_id": page_id,
"title": title,
"properties": body.get("properties", {}),
})
return {"id": new_id, "title": title, "parent_id": page_id, "status": "created"}
@router.get("/{collection_id}/pages/{page_id}/status-aggregate")
@@ -724,7 +979,7 @@ async def check_dependencies(request: Request, collection_id: int, page_id: int)
except Exception:
body = {}
new_status = body.get("new_status", "Done")
body.get("new_status", "Done")
with get_conn() as conn:
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
@@ -814,7 +1069,7 @@ async def add_data_source(request: Request, collection_id: int):
)
conn.commit()
except sqlite3.IntegrityError:
raise HTTPException(status_code=409, detail="This data source already exists in this collection")
raise HTTPException(status_code=409, detail="This data source already exists in this collection") from None
return {
"id": cur.lastrowid,
@@ -853,7 +1108,7 @@ async def create_linked_database(request: Request, collection_id: int):
body = {}
name = body.get("name", "").strip()
new_workspace_id = body.get("workspace_id")
body.get("workspace_id")
with get_conn() as conn:
source = conn.execute(
@@ -964,7 +1219,7 @@ async def toggle_inline(request: Request, collection_id: int):
@router.post("/inline/api")
async def create_inline_database(request: Request):
"""API: create an inline database within a parent page."""
"""API: create an inline database within a parent page (optionally from a template)."""
try:
body = await request.json()
except Exception:
@@ -978,16 +1233,30 @@ async def create_inline_database(request: Request):
icon = body.get("icon", "📋")
parent_page_id = body.get("parent_page_id")
workspace_id = body.get("workspace_id")
schema = body.get("schema", [])
with get_conn() as conn:
tpl = _apply_template(conn, body.get("template"))
if tpl:
if body.get("name"):
name = body["name"].strip()
description = tpl["description"]
icon = tpl.get("icon") or icon
try:
schema = json.loads(tpl["schema_json"])
except (json.JSONDecodeError, TypeError):
schema = []
cur = conn.execute(
"""INSERT INTO collections
(name, description, icon, schema_json, is_inline, parent_page_id)
VALUES (?, ?, ?, '[]', 1, ?)""",
(name, description, icon, parent_page_id),
(name, description, icon, schema_json, is_inline, parent_page_id, workspace_id)
VALUES (?, ?, ?, ?, 1, ?, ?)""",
(name, description, icon, json.dumps(schema), parent_page_id, workspace_id),
)
collection_id = cur.lastrowid
materialize_properties(conn, collection_id, schema)
# Create default view
conn.execute(
"""INSERT INTO collection_views
@@ -1004,6 +1273,7 @@ async def create_inline_database(request: Request):
return {
"id": collection_id,
"name": name,
"icon": icon,
"is_inline": True,
"parent_page_id": parent_page_id,
"status": "created",
@@ -1070,7 +1340,7 @@ async def add_page_dependency(request: Request, collection_id: int, page_id: int
)
conn.commit()
except sqlite3.IntegrityError:
raise HTTPException(status_code=409, detail="This dependency already exists")
raise HTTPException(status_code=409, detail="This dependency already exists") from None
return {"id": cur.lastrowid, "page_id": page_id, "dependency_id": dependency_id, "status": "added"}
@@ -1091,7 +1361,8 @@ async def remove_page_dependency(request: Request, collection_id: int, page_id:
@router.post("/{collection_id}/pages/{page_id}/auto-shift/api")
async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
"""API: auto-shift dates based on blocking dependencies."""
from datetime import date as dt_date, timedelta
from datetime import date as dt_date
from datetime import timedelta
try:
body = await request.json()
@@ -1193,6 +1464,11 @@ async def view_collection(request: Request, collection_id: int, view_type: str =
pages_list = [dict(p) for p in pages]
config = json.loads(view["config_json"]) if view else {}
if "year" in request.query_params:
config["year"] = int(request.query_params["year"])
if "month" in request.query_params:
config["month"] = int(request.query_params["month"])
return HTMLResponse(_render_view(view_type, collection_dict, pages_list, config))
@@ -1250,7 +1526,8 @@ h1{{font-size:24px;margin:0 0 8px}} p.desc{{color:#A0A0A0;margin:0 0 20px}}
def _render_calendar(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
from datetime import date as dt_date, timedelta
from datetime import date as dt_date
from datetime import timedelta
today = dt_date.today()
# Determine month/year from config or current
year = config.get("year", today.year)
@@ -1326,7 +1603,7 @@ def _render_gallery(view_type: str, collection: dict, pages: list[dict], config:
cover_url = config.get("cover_property")
cover_html = ""
if cover_url:
for k, v in props.items():
for _k, v in props.items():
if isinstance(v, list) and len(v) > 0:
url = v[0].get("url", "") if isinstance(v[0], dict) else str(v[0])
if url.startswith("http"):
@@ -1396,7 +1673,7 @@ def _render_timeline(view_type: str, collection: dict, pages: list[dict], config
for p in pages:
props = json.loads(p.get("property_values_json", "{}"))
start_val = end_val = None
for k, v in props.items():
for _k, v in props.items():
if isinstance(v, str) and v.startswith("20"):
if "..." in v:
parts = v.split("...")
@@ -1569,7 +1846,7 @@ def _render_map(view_type: str, collection: dict, pages: list[dict], config: dic
for p in pages:
props = json.loads(p.get("property_values_json", "{}"))
lat, lng = None, None
for k, v in props.items():
for _k, v in props.items():
if isinstance(v, str) and "," in v:
parts = v.split(",")
try:
@@ -1636,7 +1913,7 @@ def _render_gantt(view_type: str, collection: dict, pages: list[dict], config: d
props = json.loads(p.get("property_values_json", "{}"))
group = None
start_val = end_val = None
for k, v in props.items():
for _k, v in props.items():
if isinstance(v, str) and v.startswith("20"):
if "→" in v:
parts = v.split("→")
@@ -1758,7 +2035,7 @@ async def create_page_api(request: Request, collection_id: int):
raise HTTPException(status_code=400, detail="title is required")
icon = body.get("icon", "📄")
property_values = json.dumps(body.get("properties", {}))
property_values = body.get("properties", {})
gitea_issue_id = body.get("gitea_issue_id")
gitea_issue_number = body.get("gitea_issue_number")
@@ -1767,6 +2044,8 @@ async def create_page_api(request: Request, collection_id: int):
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
_validate_page_properties(conn, collection_id, property_values)
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
(collection_id,),
@@ -1776,9 +2055,17 @@ async def create_page_api(request: Request, collection_id: int):
"""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, property_values),
(collection_id, title, icon, max_pos, gitea_issue_id, gitea_issue_number,
json.dumps(property_values)),
)
conn.commit()
page_id = cur.lastrowid
await fire_event("page.created", {
"page_id": page_id,
"collection_id": collection_id,
"title": title,
"icon": icon,
"properties": property_values,
})
return {"id": page_id, "title": title, "status": "created"}
+384 -64
View File
@@ -3,12 +3,12 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, Request, Query
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi import APIRouter, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.services.gitea_client import gitea, get_user_gitea_client
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.gitea_client import get_user_gitea_client, gitea
logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"])
@@ -115,10 +115,13 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
gitea_owner = ""
gitea_repo = ""
has_active_workspace = False
local_ws_id = 0
if ws_cookie and ws_cookie.startswith("gitea:"):
# Gitea workspace: set owner/repo for client-side tree loading
# BUT keep local workspace pages — they go in the "Private" section
# AND open the local workspace mirror of the same name in the
# sidebar's top "My Workspaces" section, in parallel with the
# Gitea repository tree.
parts = ws_cookie.split(":", 2)
if len(parts) >= 3:
gitea_owner = parts[1]
@@ -126,7 +129,20 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
active_ws_name = f"{gitea_owner}/{gitea_repo}"
gitea_workspace = True
has_active_workspace = True
# workspace_pages stays as-is (local tree, loaded above)
# Load the local mirror workspace tree so it appears in "My
# Workspaces" alongside the Gitea repository section.
if user:
try:
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM workspaces WHERE owner_id=? AND name=? AND settings_json LIKE ?",
(user["id"], f"{gitea_owner}/{gitea_repo}", "%gitea_repo%")
).fetchone()
if row:
local_ws_id = row["id"]
workspace_pages = _load_workspace_pages(str(local_ws_id))
except (ValueError, Exception):
pass
elif include_workspace and ws_cookie and user:
try:
wsi = int(ws_cookie)
@@ -192,7 +208,16 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
except Exception:
pass
return {
# Shared / received / published pages for the sidebar (Par moi / Avec moi)
shared_made_pages = []
shared_received_pages = []
published_pages = []
shared_pages = []
if user and user.get("id"):
from app.routers.board import _load_shared_sidebar_pages
shared_made_pages, shared_received_pages, published_pages, shared_pages = _load_shared_sidebar_pages(user["id"])
sidebar = {
"workspace_name": ws, "workspace_initial": initial,
"active_ws_name": active_ws_name,
"workspace_key": f"{gitea_owner}/{gitea_repo}" if gitea_workspace else "",
@@ -205,8 +230,10 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
"recent_pages": recent_pages,
"private_pages": private_pages,
"favorite_pages": [],
"shared_pages": [],
"published_pages": [],
"shared_pages": shared_pages,
"shared_made_pages": shared_made_pages,
"shared_received_pages": shared_received_pages,
"published_pages": published_pages,
"user": user,
"avatar_url": avatar_url,
"avatar_color": avatar_color,
@@ -226,6 +253,7 @@ def _sidebar_data(request: Request, repos: list[dict], include_workspace: bool =
async def trash_page(request: Request, owner: str = Query(default=""), repo: str = Query(default="")):
"""Trash page — scoped to workspace if owner/repo provided."""
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = board_sidebar(request, owner, repo)
@@ -256,6 +284,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
Sidebar data is kept intact. Tab content is loaded client-side via /api/library/* endpoints.
"""
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = board_sidebar(request, owner, repo)
@@ -280,8 +309,11 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
@router.get("/pages/{page_id}", response_class=HTMLResponse)
async def view_page_root(request: Request, page_id: int):
"""Render a Markdown page at root level with workspace context — or file viewer."""
"""Render a Markdown page at root level with workspace context — or file viewer.
?embed=1 — minimal mode for side peek (editor only, no header)."""
embed = request.query_params.get("embed") == "1"
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates"))
with get_conn() as conn:
@@ -333,17 +365,51 @@ async def view_page_root(request: Request, page_id: int):
page_data["file_size"] = file_size
page_data["file_name"] = filename
# For collection (database) pages, load collection + properties + pages
collection_data = None
if page.get("content_format") == "collection" and page.get("collection_id"):
with get_conn() as conn:
col = conn.execute(
"SELECT * FROM collections WHERE id=?", (page["collection_id"],)
).fetchone()
if col:
props = [
dict(r) for r in conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(page["collection_id"],),
).fetchall()
]
cpages = [
dict(r) for r in conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
(page["collection_id"],),
).fetchall()
]
collection_data = {
"collection": dict(col),
"properties": props,
"pages": cpages,
}
page_data["collection_id"] = page["collection_id"]
with get_conn() as conn:
nav_crumbs = _nav_breadcrumb(conn, page_id)
ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs],
"page_favorited": fav is not None,
"page_share_mode": page.get("share_mode", "private"),
"page_published": bool(page.get("published", 0)),
"page_is_shared": bool(page.get("is_shared", 0)) or page.get("share_mode", "private") != "private" or bool(page.get("published", 0)),
"page_data": page_data,
"collection_data": collection_data,
"breadcrumb_items": nav_crumbs,
"nav_workspace_id": page.get("workspace_id") or 0,
"nav_page_id": page_id}
template = env.get_template("page_editor.html")
"nav_page_id": page_id,
"embed_mode": embed}
# Select template: collection pages use database table view
if page.get("content_format") == "collection" and not embed:
template = env.get_template("page_editor_collection.html")
else:
template = env.get_template("page_editor_embed.html" if embed else "page_editor.html")
response = template.render(**ctx)
return HTMLResponse(content=response, headers={"Cache-Control": "no-store, max-age=0"})
@@ -365,7 +431,7 @@ async def accounts_page(request: Request):
@router.get("/help", response_class=HTMLResponse)
async def help_page(request: Request):
"""Comprehensive help & documentation page."""
from jinja2 import Environment, FileSystemLoader, BaseLoader
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, [])
# Render via a block-based template so content_html lands in {% block content %}
@@ -549,7 +615,6 @@ def _get_user_id(request: Request) -> int:
@router.put("/api/user/profile")
async def update_profile(request: Request):
import json
body = await request.json()
full_name = body.get("full_name", "").strip()
uid = _get_user_id(request)
@@ -561,7 +626,6 @@ async def update_profile(request: Request):
@router.put("/api/user/password")
async def update_password(request: Request):
import json
from app.password_utils import hash_password
body = await request.json()
password = body.get("password", "").strip()
@@ -647,7 +711,8 @@ async def dashboard(
"SELECT COUNT(*) FROM workspaces WHERE owner_id=?", (user_id,)
).fetchone()[0]
if ws_count == 0:
return RedirectResponse("/workspaces", status_code=302)
# v5.2.0: first-launch → onboarding wizard
return RedirectResponse("/welcome", status_code=302)
except Exception:
pass
return RedirectResponse("/local-workspace", status_code=302)
@@ -697,6 +762,7 @@ async def workspace_page(request: Request):
async def gitea_workspace_page(request: Request):
"""Gitea workspace — browse repo files."""
import json
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, [])
@@ -753,7 +819,7 @@ async def list_workspace_projects(request: Request):
"""List all projects: built-in + Gitea + GitHub.
Uses the user's own Gitea token if connected, not the global admin token."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
builtin = []
with get_conn() as conn:
rows = conn.execute(
@@ -787,7 +853,6 @@ async def list_workspace_projects(request: Request):
@router.post("/api/workspace/projects")
async def create_workspace_project(request: Request):
import json
body = await request.json()
name = body.get("name", "").strip()
if not name:
@@ -819,7 +884,6 @@ async def list_members(request: Request, ws_id: int):
@router.post("/api/workspace/{ws_id:int}/members")
async def invite_member(request: Request, ws_id: int):
"""Invite a user to a workspace by email."""
import json
body = await request.json()
email = body.get("email", "").strip()
role = body.get("role", "editor")
@@ -843,7 +907,6 @@ async def invite_member(request: Request, ws_id: int):
@router.put("/api/workspace/{ws_id:int}/members/{user_id:int}")
async def update_member_role(request: Request, ws_id: int, user_id: int):
"""Change a member's role."""
import json
body = await request.json()
role = body.get("role", "editor")
if role not in ("owner", "admin", "editor", "viewer"):
@@ -874,7 +937,7 @@ async def remove_member(request: Request, ws_id: int, user_id: int):
@router.get("/local-workspace", response_class=HTMLResponse)
async def local_workspace_page(request: Request, folder: int = None):
"""Local workspace page with file/folder tree.
If ?folder=ID is provided, shows that folder's contents with breadcrumb.
"""
from jinja2 import Environment, FileSystemLoader
@@ -921,7 +984,7 @@ async def local_workspace_page(request: Request, folder: int = None):
@router.get("/api/local-workspace/tree")
async def local_workspace_tree(request: Request, folder: int = None):
"""Return the file/folder tree filtered by active workspace.
If ?folder=ID is provided, returns only that folder's children.
Otherwise returns the full recursive tree from root.
"""
@@ -929,16 +992,17 @@ async def local_workspace_tree(request: Request, folder: int = None):
ws_id = ws["id"] if ws else None
if not ws_id:
return {"tree": [], "breadcrumb": []}
uid = _get_user_id(request)
with get_conn() as conn:
if folder:
# Show only this folder's children + build breadcrumb
children = _build_tree_children(conn, folder, ws_id)
children = _build_tree_children(conn, folder, ws_id, uid)
breadcrumb = _build_breadcrumb(conn, folder)
return {"tree": children, "breadcrumb": breadcrumb, "current_folder": folder}
else:
# Full tree from root
roots = _build_tree_children(conn, None, ws_id)
roots = _build_tree_children(conn, None, ws_id, uid)
return {"tree": roots, "breadcrumb": [], "current_folder": None}
@@ -965,11 +1029,12 @@ async def local_workspace_breadcrumb(request: Request, folder: int):
return {"breadcrumb": breadcrumb}
def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | None = None) -> list:
"""Recursively build the tree of children for a node."""
if parent_id is None:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, content, "
"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 "
"ORDER BY created_at DESC",
@@ -977,23 +1042,25 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
).fetchall()
else:
rows = conn.execute(
"SELECT id, title, parent_section, content_format, content, "
"SELECT id, title, parent_section, content_format, content, page_icon, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
"created_at, updated_at FROM pages "
"WHERE parent_id=? AND workspace_id=? AND deleted_at IS NULL "
"ORDER BY created_at DESC",
(parent_id, ws_id),
).fetchall()
# Get workspace owner name for author display
ws_owner = conn.execute(
"SELECT u.full_name, u.login FROM workspaces w JOIN users u ON u.id=w.owner_id WHERE w.id=?",
(ws_id,),
).fetchone()
author = ws_owner["full_name"] or ws_owner["login"] if ws_owner else "—"
# Collect all page IDs to fetch tags in one query
all_ids = [r["id"] for r in rows]
tags_map = {}
favorited_ids = set()
if all_ids:
placeholders = ",".join("?" for _ in all_ids)
tag_rows = conn.execute(
@@ -1005,12 +1072,18 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
tags_map.setdefault(tr["page_id"], []).append({
"id": tr["id"], "name": tr["name"], "color": tr["color"],
})
if uid is not None:
fav_rows = conn.execute(
f"SELECT page_id FROM favorites WHERE user_id=? AND page_id IN ({placeholders})",
[uid, *all_ids],
).fetchall()
favorited_ids = {fr["page_id"] for fr in fav_rows}
tree = []
for r in rows:
is_folder = r["parent_section"] == "Workspace"
children = _build_tree_children(conn, r["id"], ws_id)
children = _build_tree_children(conn, r["id"], ws_id, uid)
# Compute size
size = 0
if r["content_format"] == "file":
@@ -1022,13 +1095,14 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
size = len(r["content"] or "")
else:
size = len(r["content"] or "")
tree.append({
"id": r["id"],
"name": r["title"] or "Untitled",
"type": "folder" if is_folder else "page",
"is_folder": is_folder,
"content_format": r["content_format"] if not is_folder else None,
"page_icon": r["page_icon"] or "",
"children": children,
"has_children": len(children) > 0,
"child_count": len(children),
@@ -1038,6 +1112,8 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int) -> list:
"updated_at": r["updated_at"],
"author": author,
"tags": tags_map.get(r["id"], []),
"is_shared": bool(r["is_shared"]),
"favorited": r["id"] in favorited_ids,
})
return tree
@@ -1172,7 +1248,6 @@ async def nav_menu(request: Request, workspace_id: int = None, parent_id: int =
@router.post("/api/local-workspace/items")
async def create_local_workspace_item(request: Request):
"""Create a new file in the active workspace."""
import json
body = await request.json()
name = body.get("name", "Untitled").strip()
item_type = body.get("type", "page")
@@ -1196,7 +1271,6 @@ async def create_local_workspace_item(request: Request):
@router.put("/api/local-workspace/items/{item_id:int}")
async def rename_local_workspace_item(request: Request, item_id: int):
"""Rename a file."""
import json
body = await request.json()
name = body.get("name", "Untitled").strip()
with get_conn() as conn:
@@ -1233,8 +1307,8 @@ async def restore_local_workspace_item(request: Request, item_id: int):
@router.get("/api/files/{ws_id:int}/{filename:path}")
async def serve_uploaded_file(ws_id: int, filename: str):
"""Serve an uploaded file from disk."""
from pathlib import Path
import mimetypes
from pathlib import Path
base_dir = Path(f"/data/uploads/workspace_{ws_id}").resolve()
fp = (base_dir / filename).resolve()
try:
@@ -1252,7 +1326,6 @@ async def serve_uploaded_file(ws_id: int, filename: str):
@router.put("/api/local-workspace/items/{item_id:int}/move")
async def move_local_workspace_item(request: Request, item_id: int):
"""Move an item to a new parent (drag & drop)."""
import json
body = await request.json()
new_parent_id = body.get("parent_id") # None = move to root
with get_conn() as conn:
@@ -1517,7 +1590,6 @@ async def list_workspaces(request: Request):
@router.post("/api/workspaces")
async def create_workspace(request: Request):
"""Create a new workspace."""
import json
body = await request.json()
name = body.get("name", "New Workspace").strip()
if not name:
@@ -1550,7 +1622,6 @@ async def create_workspace(request: Request):
@router.put("/api/workspaces/{ws_id:int}")
async def rename_workspace(request: Request, ws_id: int):
"""Rename a workspace."""
import json
body = await request.json()
name = body.get("name", "").strip()
if not name:
@@ -1603,9 +1674,9 @@ async def app_settings_page(request: Request):
@router.post("/api/settings/avatar")
async def upload_avatar(request: Request):
"""Upload a user avatar image."""
from fastapi import UploadFile, File
import os
import uuid
from pathlib import Path
import uuid, os
form = await request.form()
file = form.get("file")
if not file:
@@ -1632,8 +1703,9 @@ async def upload_avatar(request: Request):
@router.get("/api/settings/avatar/{filename:path}")
async def serve_avatar_file(filename: str):
"""Serve an uploaded avatar image file."""
from fastapi.responses import FileResponse
from pathlib import Path
from fastapi.responses import FileResponse
filepath = Path("/data/avatars") / filename
if not filepath.is_file():
return JSONResponse({"error": "Not found"}, status_code=404)
@@ -1669,7 +1741,6 @@ async def set_avatar_color(request: Request):
@router.post("/api/settings/tags")
async def create_tag_global(request: Request):
"""Create a tag for the current user."""
import json
body = await request.json()
tag_name = body.get("name", "").strip().lower()
color = body.get("color", "#787774")
@@ -1690,7 +1761,6 @@ async def create_tag_global(request: Request):
@router.put("/api/settings/tags/{tag_id:int}")
async def update_tag_global(tag_id: int, request: Request):
"""Update a tag (name or color) — only if owned by user."""
import json
body = await request.json()
uid = _get_user_id(request)
with get_conn() as conn:
@@ -1765,7 +1835,6 @@ async def get_item_tags(item_id: int):
@router.post("/api/local-workspace/items/{item_id:int}/tags")
async def add_item_tag(request: Request, item_id: int):
"""Add a tag to an item (creates tag if new, scoped to user)."""
import json
body = await request.json()
tag_name = body.get("name", "").strip().lower()
tag_color = body.get("color", "#787774")
@@ -1818,7 +1887,6 @@ async def search_by_tags(request: Request, tags: str = ""):
ws_id = ws["id"] if ws else None
if not ws_id:
return {"items": []}
import json
tag_names = [t.strip().lower() for t in tags.split(",") if t.strip()]
if not tag_names:
return {"items": []}
@@ -1855,7 +1923,6 @@ async def search_by_tags(request: Request, tags: str = ""):
@router.put("/api/settings/account")
async def update_account(request: Request):
"""Update current user's profile: full_name, login, email, password."""
import json
from app.password_utils import hash_password
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
@@ -1885,8 +1952,9 @@ async def update_account(request: Request):
conn.commit()
row = conn.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone()
user_data = dict(row)
# Refresh session cookie with updated data
new_session = SessionManager.create_session(user_data)
# Refresh session cookie with updated data (keeps the same session id)
cookie = request.cookies.get("flowdeck_session", "")
new_session = SessionManager.refresh_session(cookie, user_data, request)
response = JSONResponse({"status": "ok", "user": {k: user_data[k] for k in ("id","login","full_name","email","is_admin")}})
response.set_cookie("flowdeck_session", new_session, httponly=True, max_age=86400 * 7, samesite="lax", path="/")
return response
@@ -1897,25 +1965,26 @@ async def update_account(request: Request):
@router.get("/api/sidebar/workspace-tree")
async def sidebar_workspace_tree(request: Request):
"""Return the sidebar workspace tree as HTML fragment.
Called by appState().refreshSidebarTree() after CRUD operations
in the main content area to keep the sidebar in sync.
"""
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _load_workspace_pages
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return HTMLResponse("")
ws_cookie = request.cookies.get("flowdeck_workspace", "")
if not ws_cookie:
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li>')
# Gitea workspace — no server-side tree, loaded client-side
if ws_cookie.startswith("gitea:"):
return HTMLResponse('<li class="sidebar-item empty-hint"><span class="page-icon">🔗</span><span class="page-name text-dim">Remote workspace</span></li>')
try:
ws_id = int(ws_cookie)
with get_conn() as conn:
@@ -1929,14 +1998,14 @@ async def sidebar_workspace_tree(request: Request):
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
'<span class="page-name text-dim">No pages yet</span></li>'
)
pages = _load_workspace_pages(ws_cookie)
if not pages:
return HTMLResponse(
'<li class="sidebar-item empty-hint"><span class="page-icon">📄</span>'
'<span class="page-name text-dim">No pages yet</span></li>'
)
# Render the tree using the extracted macro
env = Environment(loader=FileSystemLoader("app/templates"))
template = env.from_string(
@@ -1963,7 +2032,7 @@ async def public_published_page(request: Request, slug: str):
with get_conn() as conn:
row = conn.execute(
"SELECT id, title, content, content_format, updated_at, created_at "
"SELECT id, title, content, content_format, updated_at, created_at, cover_url, page_icon "
"FROM pages WHERE publish_slug=? AND is_published=1",
(slug,),
).fetchone()
@@ -2001,24 +2070,32 @@ h1{font-size:3rem;opacity:.3}</style></head><body><h1>404</h1></body></html>""",
content_html=content_html,
updated_at=page.get("updated_at", ""),
created_at=page.get("created_at", ""),
cover_url=page.get("cover_url", ""),
page_icon=page.get("page_icon", ""),
)
def _sanitize_id(block_id: str) -> str:
"""Sanitize a block id for use as an HTML anchor (only alnum kept)."""
if not block_id:
return ""
return "".join(ch for ch in str(block_id) if ch.isalnum())
def _render_blocks_public(blocks: list) -> str:
"""Render FlowDeck blocks as plain HTML for public pages."""
import json as _json
html_parts = []
for b in blocks:
t = b.get("type", "paragraph")
c = b.get("content", "") or ""
if t == "heading_1":
html_parts.append(f'<h1 style="font-size:2.5rem;font-weight:700;margin:32px 0 8px;">{c}</h1>')
html_parts.append(f'<h1 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:2.5rem;font-weight:700;margin:32px 0 8px;">{c}</h1>')
elif t == "heading_2":
html_parts.append(f'<h2 style="font-size:1.75rem;font-weight:600;margin:28px 0 6px;">{c}</h2>')
html_parts.append(f'<h2 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:1.75rem;font-weight:600;margin:28px 0 6px;">{c}</h2>')
elif t == "heading_3":
html_parts.append(f'<h3 style="font-size:1.35rem;font-weight:600;margin:24px 0 4px;">{c}</h3>')
html_parts.append(f'<h3 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:1.35rem;font-weight:600;margin:24px 0 4px;">{c}</h3>')
elif t == "heading_4":
html_parts.append(f'<h4 style="font-size:1.15rem;font-weight:600;margin:20px 0 4px;">{c}</h4>')
html_parts.append(f'<h4 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:1.15rem;font-weight:600;margin:20px 0 4px;">{c}</h4>')
elif t == "bulleted_list":
html_parts.append(f'<li style="margin-left:24px;">{c}</li>')
elif t == "numbered_list":
@@ -2033,13 +2110,52 @@ def _render_blocks_public(blocks: list) -> str:
f'</div>'
)
elif t == "toggle":
children_html = ""
if b.get("children"):
children_html = '<div style="margin-left:22px;padding-left:12px;border-left:1px solid rgba(255,255,255,.1);margin-top:4px;">'
children_html += _render_blocks_public(b["children"])
children_html += "</div>"
html_parts.append(
f'<details style="margin:8px 0;"><summary style="cursor:pointer;font-weight:500;">{c}</summary></details>'
f'<details style="margin:8px 0;" open><summary style="cursor:pointer;font-weight:500;">{c}</summary>{children_html}</details>'
)
elif t == "quote":
html_parts.append(
f'<blockquote style="border-left:3px solid var(--accent,#4c9aff);margin:12px 0;padding:4px 16px;opacity:.85;">{c}</blockquote>'
)
elif t == "table_of_contents":
toc = [
x for x in blocks
if x.get("type", "").startswith("heading_") and (x.get("content") or "").strip()
]
if toc:
items = []
for h in toc:
lvl = int(h["type"].split("_")[-1])
items.append(
f'<div style="margin-left:{max(0, lvl - 1) * 14}px;padding:5px 8px;font-size:14px;">'
f'<a href="#h-{_sanitize_id(h.get("id",""))}" style="color:inherit;text-decoration:none;display:block;">{h.get("content","")}</a></div>'
)
html_parts.append(
'<div style="border:1px solid rgba(255,255,255,.1);border-radius:8px;padding:16px 20px;margin:4px 0;">'
'<div style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;opacity:.5;margin-bottom:10px;">On this page</div>'
+ "".join(items) + "</div>"
)
elif t == "math":
tex = c.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
html_parts.append(
f'<div data-katex="{tex}" style="margin:12px 0;padding:12px 16px;background:rgba(255,255,255,.04);border-radius:8px;overflow-x:auto;"></div>'
)
elif t == "columns":
cols_html = ""
for child in b.get("children") or []:
cols_html += (
'<div style="flex:1;min-width:0;padding:10px 12px;background:rgba(255,255,255,.05);'
'border-radius:8px;box-sizing:border-box;">'
+ _render_blocks_public([child]) + "</div>"
)
html_parts.append(
f'<div style="display:flex;gap:12px;margin:8px 0 16px;align-items:stretch;">{cols_html}</div>'
)
elif t == "callout":
icon = b.get("icon", "💡")
bg = (b.get("style") or {}).get("bgColor", "rgba(76,154,255,.1)")
@@ -2065,9 +2181,66 @@ def _render_blocks_public(blocks: list) -> str:
alt = b.get("alt", "")
html_parts.append(
f'<figure style="margin:16px 0;text-align:center;">'
f'<img src="{src}" alt="{alt}" style="max-width:100%;border-radius:8px;">'
f'<img src="{src}" alt="{alt}" data-full="{src}" style="max-width:100%;border-radius:8px;cursor:zoom-in;">'
f'</figure>'
)
elif t == "video":
src = b.get("src", "")
if src:
html_parts.append(
f'<video controls preload="metadata" style="max-width:100%;border-radius:8px;display:block;margin:12px auto;">'
f'<source src="{src}"></video>'
)
elif t == "audio":
src = b.get("src", "")
if src:
html_parts.append(
f'<audio controls preload="metadata" style="width:100%;margin:8px 0;"><source src="{src}"></audio>'
)
elif t == "bookmark":
url = b.get("url") or b.get("src") or ""
title = b.get("title") or url
desc = b.get("description") or ""
img = b.get("image") or ""
site = b.get("site_name") or ""
img_html = (
f'<img src="{img}" alt="" style="width:120px;height:90px;object-fit:cover;border-radius:8px;flex-shrink:0;">' if img else ""
)
desc_html = f'<div style="font-size:13px;opacity:.75;margin-top:4px;">{desc}</div>' if desc else ""
site_html = f'<div style="font-size:11px;opacity:.5;text-transform:uppercase;letter-spacing:.5px;margin-top:6px;">{site}</div>' if site else ""
html_parts.append(
f'<a href="{url}" target="_blank" rel="noopener noreferrer" style="text-decoration:none;color:inherit;">'
f'<div style="display:flex;gap:14px;align-items:center;border:1px solid rgba(255,255,255,.12);border-radius:10px;'
f'padding:14px 16px;margin:14px 0;background:rgba(255,255,255,.03);">'
f'<div style="flex:1;min-width:0;"><div style="font-weight:600;font-size:15px;">{title}</div>'
f'{desc_html}{site_html}</div>{img_html}</div></a>'
)
elif t == "embed":
url = b.get("src", "")
emb = b.get("embed_type") or ""
if emb in ("inline_dbs", "collection"):
html_parts.append('<div>[Embedded content]</div>')
elif emb == "download":
html_parts.append(
f'<a href="{url}" download style="display:inline-block;margin:12px 0;color:var(--accent,#4c9aff);">⬇ {b.get("file_name") or "Download"}</a>'
)
elif emb == "pdf" and url:
html_parts.append(
f'<iframe src="{url}" style="width:100%;height:70vh;border:none;border-radius:8px;margin:12px 0;"></iframe>'
)
elif url:
from app.services.embeds import embed_src
src = b.get("embed_src") or embed_src(url) or url
height = b.get("height") or 520
try:
height = int(height)
except (ValueError, TypeError):
height = 520
html_parts.append(
f'<div style="position:relative;width:100%;height:{height}px;border-radius:8px;overflow:hidden;'
f'background:#0a0a0a;"><iframe src="{src}" loading="lazy" frameborder="0" '
f'style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div>'
)
else:
html_parts.append(f'<p style="margin:4px 0;line-height:1.7;">{c}</p>')
return "\n".join(html_parts)
@@ -2095,7 +2268,6 @@ async def api_page_content(page_id: int):
@router.put("/api/pages/{page_id:int}/rename")
async def api_rename_page(page_id: int, request: Request):
"""Inline rename a page title."""
import json as _json
body = await request.json()
title = (body.get("title") or "").strip()
if not title:
@@ -2119,3 +2291,151 @@ async def api_trash_page(page_id: int):
)
conn.commit()
return {"status": "ok"}
@router.post("/api/pages/{page_id:int}/convert-to-database")
async def api_convert_to_database(page_id: int, request: Request):
"""Convert a page into a full-page database (Notion-style).
Creates a collection linked to this page, adds the default 'Name' property,
and sets the page's content_format to 'collection'.
"""
import json as _json
try:
body = await request.json()
except Exception:
body = {}
db_name = (body.get("name") or "").strip()
with get_conn() as conn:
page = conn.execute(
"SELECT id, title, workspace_id FROM pages WHERE id=? AND deleted_at IS NULL",
(page_id,),
).fetchone()
if not page:
return JSONResponse({"error": "Page not found"}, status_code=404)
if not db_name:
db_name = page["title"] or "New Database"
# Create the collection
cur = conn.execute(
"""INSERT INTO collections
(name, description, icon, schema_json, is_inline, parent_page_id, workspace_id)
VALUES (?, '', '📋', '[]', 0, ?, ?)""",
(db_name, page_id, page["workspace_id"]),
)
collection_id = cur.lastrowid
# Create default "Name" property (text, position 0)
conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, position, required, visible_in_views)
VALUES (?, 'Name', 'title', 0, 1, 1)""",
(collection_id,),
)
# Create default "Table" view
conn.execute(
"""INSERT INTO collection_views
(collection_id, name, view_type, config_json, position)
VALUES (?, 'Table', 'table', ?, 0)""",
(collection_id, _json.dumps({"visible_properties": ["Name"]})),
)
# Update the page to be a database page
conn.execute(
"UPDATE pages SET content_format='collection', collection_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(collection_id, page_id),
)
conn.commit()
return {
"status": "converted",
"collection_id": collection_id,
"name": db_name,
"view_url": f"/pages/{page_id}",
}
@router.get("/api/collections/{collection_id:int}/table-data")
async def api_collection_table_data(collection_id: int):
"""Get collection properties + pages for rendering the table view."""
with get_conn() as conn:
coll = conn.execute(
"SELECT * FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not coll:
return JSONResponse({"error": "Collection not found"}, status_code=404)
properties = [
dict(r) for r in conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
]
pages = [
dict(r) for r in conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
]
return {
"collection": dict(coll),
"properties": properties,
"pages": pages,
}
@router.post("/api/collections/{collection_id:int}/pages")
async def api_create_collection_page(collection_id: int, request: Request):
"""Create a new page (row) in a collection."""
import json as _json
try:
body = await request.json()
except Exception:
body = {}
title = body.get("title", "New page").strip() or "New page"
icon = body.get("icon", "file")
with get_conn() as conn:
coll = conn.execute(
"SELECT id FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not coll:
return JSONResponse({"error": "Collection not found"}, status_code=404)
# Get next position
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
(collection_id,),
).fetchone()[0]
# Load default property values from collection properties
props = 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
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)),
)
page_id = cur.lastrowid
conn.commit()
return {
"id": page_id,
"title": title,
"position": max_pos,
"status": "created",
}
+93
View File
@@ -0,0 +1,93 @@
"""FlowDeck — Export endpoints (v4.7.0).
Routes /api/export/* — generate Markdown, HTML, PDF and static-site (zip)
exports server-side for a given page.
"""
from __future__ import annotations
import logging
import re
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import Response
from app.db import get_conn
from app.services.export import (
build_static_site_bytes,
page_to_markdown,
page_to_pdf_bytes,
page_to_standalone_html,
)
logger = logging.getLogger(__name__)
router = APIRouter(tags=["export"], prefix="/api/export")
def _load_page_or_404(page_id: int) -> dict:
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM pages WHERE id=? AND deleted_at IS NULL",
(page_id,),
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Page not found")
return dict(row)
def _download_header(filename: str, media_type: str) -> dict:
ascii_name = re.sub(r"[^\x00-\x7F]", "_", filename)
quoted = filename.replace('"', '')
return {
"Content-Disposition": f'attachment; filename="{ascii_name}"; filename*=UTF-8\'\'{quoted}',
"Cache-Control": "no-store",
"Content-Type": media_type,
}
def _safe_filename(page: dict, ext: str) -> str:
title = (page.get("title") or "Untitled").strip() or "Untitled"
title = re.sub(r'[\\/:*?"<>|]+', "_", title)
return f"{title}.{ext}"
@router.get("/markdown/{page_id}")
async def export_markdown(page_id: int, request: Request):
page = _load_page_or_404(page_id)
md = page_to_markdown(page)
filename = _safe_filename(page, "md")
headers = _download_header(filename, "text/markdown")
return Response(content=md.encode("utf-8"), status_code=200, headers=headers)
@router.get("/html/{page_id}")
async def export_html(page_id: int, request: Request):
page = _load_page_or_404(page_id)
html = page_to_standalone_html(page)
filename = _safe_filename(page, "html")
headers = _download_header(filename, "text/html")
return Response(content=html.encode("utf-8"), status_code=200, headers=headers)
@router.get("/pdf/{page_id}")
async def export_pdf(page_id: int, request: Request):
page = _load_page_or_404(page_id)
try:
pdf_bytes = page_to_pdf_bytes(page)
except ImportError:
raise HTTPException(status_code=501, detail="PDF export requires 'weasyprint' or 'xhtml2pdf'") from None
except Exception as exc: # noqa: BLE001
logger.error("PDF export failed for page %s: %s", page_id, exc)
raise HTTPException(status_code=500, detail="PDF generation failed") from exc
filename = _safe_filename(page, "pdf")
headers = _download_header(filename, "application/pdf")
return Response(content=pdf_bytes, status_code=200, headers=headers)
@router.get("/site/{page_id}")
async def export_site(page_id: int, request: Request):
page = _load_page_or_404(page_id)
site_bytes = build_static_site_bytes(page)
title = _safe_filename(page, "site").replace(".site", "") or "flowdeck-site"
filename = f"{title}_site.zip"
headers = _download_header(filename, "application/zip")
return Response(content=site_bytes, status_code=200, headers=headers)
+8 -9
View File
@@ -1,5 +1,5 @@
"""FlowDeck — Gitea integration API routes."""
from fastapi import APIRouter, Request, HTTPException
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import JSONResponse
router = APIRouter(tags=["gitea"], prefix="/api/gitea")
@@ -7,7 +7,7 @@ router = APIRouter(tags=["gitea"], prefix="/api/gitea")
def _require_gitea(request: Request):
"""Return a per-user GiteaClient or raise 401.
Only returns a client if the user has personally connected their Gitea
account (OAuth token). No fallback to admin token — each user must link
their own Gitea account to see Gitea projects.
@@ -92,7 +92,6 @@ async def get_file(request: Request, owner: str, repo: str, path: str):
@router.put("/projects/{owner}/{repo}/file")
async def save_file(request: Request, owner: str, repo: str):
"""Create or update a file in the repo."""
import json
gitea = _require_gitea(request) # admin token can write too
try:
body = await request.json()
@@ -140,7 +139,6 @@ async def upload_file(request: Request, owner: str, repo: str):
@router.delete("/projects/{owner}/{repo}/file")
async def delete_file(request: Request, owner: str, repo: str):
"""Delete a file from the repo."""
import json
gitea = _require_gitea(request) # admin token can write too
path = request.query_params.get("path", "")
sha = request.query_params.get("sha", "")
@@ -162,8 +160,8 @@ async def get_labels(request: Request, owner: str, repo: str):
try:
labels = await gitea.get_labels(owner, repo)
return {"labels": [
{"id": l["id"], "name": l["name"], "color": l.get("color", "#787774")}
for l in labels
{"id": lbl["id"], "name": lbl["name"], "color": lbl.get("color", "#787774")}
for lbl in labels
]}
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=502)
@@ -214,9 +212,9 @@ async def list_private_pages(owner: str, repo: str, request: Request):
@router.post("/projects/{owner}/{repo}/private-pages")
async def create_private_page(owner: str, repo: str, request: Request):
"""Create a new private page for this Gitea project."""
from app.auth.session import SessionManager
from app.db import get_conn
import json
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
@@ -255,9 +253,9 @@ async def get_private_page(owner: str, repo: str, page_id: int, request: Request
@router.put("/projects/{owner}/{repo}/private-pages/{page_id}")
async def update_private_page(owner: str, repo: str, page_id: int, request: Request):
"""Update a private page."""
from app.auth.session import SessionManager
from app.db import get_conn
import json
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return JSONResponse({"error": "Not authenticated"}, status_code=401)
@@ -329,7 +327,8 @@ async def sync_labels(request: Request, owner: str, repo: str):
for label in labels:
name = label.get("name", "")
color = label.get("color", "#787774")
if not name: continue
if not name:
continue
existing = conn.execute(
"SELECT id FROM tags WHERE name=? AND user_id=?", (name, user["id"])
).fetchone()
+103 -24
View File
@@ -3,11 +3,10 @@ from __future__ import annotations
import logging
from fastapi import APIRouter, Request, Query
from fastapi.responses import JSONResponse
from fastapi import APIRouter, Query, Request
from app.db import get_conn
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["library"], prefix="/api/library")
@@ -60,9 +59,12 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
elif content_format == "file":
icon = "📄"
fn = title.lower()
if fn.endswith(".pdf"): icon = "📕"
elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️"
elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): icon = "📜"
if fn.endswith(".pdf"):
icon = "📕"
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "🖼️"
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
icon = "📜"
else:
icon = "📝"
@@ -70,6 +72,7 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
"id": page_id,
"title": title,
"icon": icon,
"page_icon": db_row.get("page_icon") or "",
"is_folder": bool(db_row.get("is_folder", 0)),
"source_type": source_type,
"source_label": _source_label(source_type, workspace),
@@ -84,6 +87,8 @@ def _build_item(db_row: dict, uid: int = 1) -> dict:
"url": url,
"content_format": content_format,
"favorited": bool(db_row.get("favorited", 0)),
"share_mode": db_row.get("share_mode", "private"),
"tags": [],
}
@@ -98,7 +103,30 @@ def _apply_source_filter(query: str, params: list, source_type: str) -> tuple[st
def _rows_to_items(rows, uid: int = 1) -> list:
return [_build_item(dict(r), uid) for r in rows]
items = [_build_item(dict(r), uid) for r in rows]
return _attach_tags(items)
def _attach_tags(items: list) -> list:
"""Batch-load page tags for library items."""
if not items:
return items
ids = [it["id"] for it in items]
placeholders = ",".join("?" for _ in ids)
with get_conn() as conn:
rows = conn.execute(
f"SELECT pt.page_id, t.id, t.name, t.color FROM page_tags pt "
f"JOIN tags t ON t.id = pt.tag_id WHERE pt.page_id IN ({placeholders})",
ids,
).fetchall()
tag_map: dict = {}
for r in rows:
tag_map.setdefault(r["page_id"], []).append({
"id": r["id"], "name": r["name"], "color": r["color"],
})
for it in items:
it["tags"] = tag_map.get(it["id"], [])
return items
def _enrich_children(items: list) -> list:
@@ -124,7 +152,7 @@ def _enrich_children(items: list) -> list:
BASE_SELECT = (
"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
"p.parent_id, p.share_mode, p.parent_section"
"p.parent_id, p.share_mode, p.parent_section, p.page_icon"
)
@@ -173,10 +201,10 @@ async def library_favorites(
uid = _get_user_id(request)
query = (
f"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
f"p.parent_id, p.share_mode, p.parent_section, 1 as favorited "
f"FROM favorites f JOIN pages p ON p.id = f.page_id "
f"WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
"p.parent_id, p.share_mode, p.parent_section, 1 as favorited "
"FROM favorites f JOIN pages p ON p.id = f.page_id "
"WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
)
params: list = [uid]
if tree:
@@ -197,13 +225,45 @@ async def library_shared(
request: Request,
source_type: str = Query(default="all"),
tree: int = Query(default=0),
dir: str = Query(default="all"),
):
if source_type not in SOURCE_TYPES:
source_type = "all"
if dir not in ("made", "received", "all"):
dir = "all"
uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.share_mode != 'private' AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
# Page ids the user shares toward others (nominal page_shares) or receives
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()
made_ids = {r[0] for r in made_rows}
recv_ids = {r[0] for r in recv_rows}
conds: list[str] = []
params: list = []
if dir in ("all", "made"):
conds.append("p.share_mode != 'private'")
if dir in ("all", "made") and made_ids:
conds.append(f"p.id IN ({','.join('?' for _ in made_ids)})")
params.extend(made_ids)
if dir in ("all", "received") and recv_ids:
conds.append(f"p.id IN ({','.join('?' for _ in recv_ids)})")
params.extend(recv_ids)
if dir == "received" and not recv_ids:
return {"items": []}
query = (
f"{BASE_SELECT} FROM pages p "
f"WHERE ({' OR '.join(conds)}) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL"
)
if tree:
query += " AND p.parent_id IS NULL"
query, params = _apply_source_filter(query, params, source_type)
@@ -213,6 +273,11 @@ async def library_shared(
rows = conn.execute(query, params).fetchall()
items = _rows_to_items(rows, uid)
for it in items:
sid = it["id"]
is_made = sid in made_ids or it.get("share_mode", "private") != "private"
is_recv = sid in recv_ids
it["share_dir"] = "both" if (is_made and is_recv) else ("made" if is_made else ("received" if is_recv else ""))
_enrich_children(items)
return {"items": items}
@@ -270,7 +335,7 @@ async def library_private(
@router.get("/local-workspace-children/{item_id:int}")
async def library_local_workspace_children(item_id: int, request: Request):
"""Return children of a local workspace item for tree expansion."""
uid = _get_user_id(request)
_get_user_id(request)
with get_conn() as conn:
# Get the item to find its workspace
item = conn.execute(
@@ -296,9 +361,12 @@ async def library_local_workspace_children(item_id: int, request: Request):
icon = "📁" if is_folder else "📄"
fn = name.lower()
if not is_folder:
if fn.endswith(".pdf"): icon = "📕"
elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️"
elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): icon = "📜"
if fn.endswith(".pdf"):
icon = "📕"
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "🖼️"
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
icon = "📜"
with get_conn() as conn:
child_count = conn.execute(
@@ -324,6 +392,8 @@ async def library_local_workspace_children(item_id: int, request: Request):
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
"content_format": r["content_format"] or "file",
"favorited": False,
"page_icon": "",
"tags": [],
"size_display": _format_size(r["size"]) if r["size"] else "",
})
@@ -404,9 +474,12 @@ async def library_local_workspace(
icon = "📁" if is_folder else "📄"
fn = name.lower()
if not is_folder:
if fn.endswith(".pdf"): icon = "📕"
elif any(fn.endswith(e) for e in [".png",".jpg",".jpeg",".gif",".webp",".svg"]): icon = "🖼️"
elif any(fn.endswith(e) for e in [".py",".js",".ts",".go",".rs"]): icon = "📜"
if fn.endswith(".pdf"):
icon = "📕"
elif any(fn.endswith(e) for e in [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]):
icon = "🖼️"
elif any(fn.endswith(e) for e in [".py", ".js", ".ts", ".go", ".rs"]):
icon = "📜"
# Check for children
child_count = conn.execute(
@@ -432,6 +505,8 @@ async def library_local_workspace(
"url": f"/local-workspace?folder={r['id']}" if is_folder else f"/pages/{r['id']}",
"content_format": r["content_format"] or "file",
"favorited": False,
"page_icon": "",
"tags": [],
"size_display": _format_size(r["size"]) if r["size"] else "",
})
@@ -439,10 +514,14 @@ async def library_local_workspace(
def _format_size(size_bytes):
if not size_bytes: return ""
if size_bytes < 1024: return f"{size_bytes} B"
if size_bytes < 1048576: return f"{size_bytes/1024:.1f} KB"
if size_bytes < 1073741824: return f"{size_bytes/1048576:.1f} MB"
if not size_bytes:
return ""
if size_bytes < 1024:
return f"{size_bytes} B"
if size_bytes < 1048576:
return f"{size_bytes/1024:.1f} KB"
if size_bytes < 1073741824:
return f"{size_bytes/1048576:.1f} MB"
return f"{size_bytes/1073741824:.1f} GB"
+3 -3
View File
@@ -4,12 +4,12 @@ from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Request, HTTPException, Query
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from jinja2 import Environment, FileSystemLoader
from app.db import get_conn
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
@@ -121,7 +121,7 @@ async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7)
async def my_tasks_api(request: Request, view: str = "all", days: int = 7):
"""API: return my tasks as JSON."""
user = _get_current_user(request)
user_login = user.get("login", "admin") if user else "admin"
user.get("login", "admin") if user else "admin"
with get_conn() as conn:
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
+2
View File
@@ -22,6 +22,7 @@ async def get_notes(request: Request, owner: str, repo: str):
content = row["content"] if row else ""
from jinja2 import Environment, FileSystemLoader
from app.auth.session import SessionManager
env = Environment(loader=FileSystemLoader("app/templates"))
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
@@ -45,6 +46,7 @@ async def save_notes(request: Request, owner: str, repo: str):
conn.commit()
from jinja2 import Environment, FileSystemLoader
from app.auth.session import SessionManager
env = Environment(loader=FileSystemLoader("app/templates"))
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
+126
View File
@@ -0,0 +1,126 @@
"""FlowDeck — Notifications API (v4.9.0 collaboration)."""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["notifications"], prefix="/api/notifications")
def _current_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="Authentication required")
return user
@router.get("")
async def list_notifications(request: Request, limit: int = 50):
"""List the current user's notifications, newest first."""
user = _current_user(request)
with get_conn() as conn:
rows = conn.execute(
"""SELECT n.*, a.login AS actor_login, a.full_name AS actor_name,
a.avatar_url AS actor_avatar, a.avatar_color AS actor_color
FROM notifications n
LEFT JOIN users a ON n.actor_id = a.id
WHERE n.user_id=?
ORDER BY n.created_at DESC, n.id DESC LIMIT ?""",
(user["id"], limit),
).fetchall()
unread = conn.execute(
"SELECT COUNT(*) AS c FROM notifications WHERE user_id=? AND is_read=0",
(user["id"],),
).fetchone()["c"]
return {
"notifications": [dict(r) for r in rows],
"unread": unread,
}
@router.get("/unread-count")
async def unread_count(request: Request):
"""Unread count for the topbar badge."""
user = _current_user(request)
with get_conn() as conn:
c = conn.execute(
"SELECT COUNT(*) AS c FROM notifications WHERE user_id=? AND is_read=0",
(user["id"],),
).fetchone()["c"]
return {"unread": c}
@router.post("/read")
async def mark_read(request: Request):
"""Mark one notification as read (id) or all (id omitted)."""
user = _current_user(request)
body = await request.json() if request.headers.get("content-type") else {}
nid = body.get("id")
with get_conn() as conn:
if nid:
conn.execute(
"UPDATE notifications SET is_read=1 WHERE id=? AND user_id=?",
(nid, user["id"]),
)
else:
conn.execute(
"UPDATE notifications SET is_read=1 WHERE user_id=?",
(user["id"],),
)
conn.commit()
return {"status": "ok"}
@router.post("/read-all")
async def mark_all_read(request: Request):
"""Mark all notifications as read."""
return await mark_read(request)
@router.get("/prefs")
async def get_prefs(request: Request):
"""Return the current user's notification email preferences."""
user = _current_user(request)
from app.services import notifications as notif
return {"prefs": notif.get_user_prefs(user["id"])}
@router.post("/prefs")
async def set_prefs(request: Request):
"""Update the current user's notification email preferences."""
user = _current_user(request)
from app.services import notifications as notif
body = await request.json() if request.headers.get("content-type") else {}
prefs = notif.get_user_prefs(user["id"])
for key in ("comments", "mentions"):
if key in body:
prefs[key] = bool(body[key])
notif.set_user_prefs(user["id"], prefs)
return {"status": "ok", "prefs": prefs}
@router.get("/users/search")
async def search_users(request: Request, q: str = ""):
"""User autocomplete for @mentions."""
_current_user(request)
q = (q or "").strip()
with get_conn() as conn:
if q:
like = f"%{q}%"
rows = conn.execute(
"""SELECT id, login, full_name, avatar_url, avatar_color
FROM users WHERE login LIKE ? OR full_name LIKE ?
ORDER BY (login=? OR full_name=?) DESC, login LIMIT 20""",
(like, like, q, q),
).fetchall()
else:
rows = conn.execute(
"""SELECT id, login, full_name, avatar_url, avatar_color
FROM users ORDER BY login LIMIT 20"""
).fetchall()
return {"users": [dict(r) for r in rows]}
+135
View File
@@ -0,0 +1,135 @@
"""FlowDeck — v5.2.0 Onboarding: /welcome wizard + its API.
First-launch experience: create workspace → connect a forge (optional) →
create the first project (welcome page), then land in the app.
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["onboarding"])
WORKSPACE_COOKIE = "flowdeck_workspace"
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
@router.get("/welcome", response_class=HTMLResponse)
async def onboarding_page(request: Request):
"""Onboarding wizard. Redirects logged-out users to login and users who
already have a workspace straight to the app."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302)
with get_conn() as conn:
ws_count = conn.execute(
"SELECT COUNT(*) FROM workspaces WHERE owner_id=?", (user["id"],)
).fetchone()[0]
if ws_count > 0:
return RedirectResponse("/workspaces", status_code=302)
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
template = env.get_template("welcome.html")
return HTMLResponse(content=template.render(
user=user,
gitea_url_configured=_forge_configured("gitea"),
github_url_configured=_forge_configured("github"),
))
def _forge_configured(provider: str) -> bool:
try:
from app.auth.providers import get_provider
return get_provider(provider) is not None
except Exception:
return False
# ═══════════ Onboarding API ═══════════
@router.post("/api/onboarding/workspace")
async def onboarding_create_workspace(request: Request):
"""Step 1 — create the first local workspace."""
user = _require_user(request)
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "").strip() or "My Workspace"
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO workspaces (name, owner_id, settings_json) VALUES (?, ?, '{}')",
(name, user["id"]),
)
conn.execute(
"INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'owner')",
(cur.lastrowid, user["id"]),
)
conn.commit()
ws_id = cur.lastrowid
response = JSONResponse({"status": "ok", "id": ws_id, "name": name})
response.set_cookie(WORKSPACE_COOKIE, str(ws_id), max_age=86400 * 30, httponly=True, path="/")
return response
@router.post("/api/onboarding/project")
async def onboarding_create_project(request: Request):
"""Step 3 — create the first project: a welcome page in the workspace."""
user = _require_user(request)
try:
body = await request.json()
except Exception:
body = {}
title = (body.get("title") or "").strip() or "Welcome to FlowDeck"
workspace_id = body.get("workspace_id")
with get_conn() as conn:
ws = None
if workspace_id:
ws = conn.execute(
"SELECT id FROM workspaces WHERE id=? AND owner_id=?",
(workspace_id, user["id"]),
).fetchone()
if not ws:
ws = conn.execute(
"SELECT id FROM workspaces WHERE owner_id=? ORDER BY id LIMIT 1",
(user["id"],),
).fetchone()
if not ws:
raise HTTPException(status_code=400, detail="Create a workspace first")
blocks = [
{"id": "1", "type": "heading_1", "content": title},
{"id": "2", "type": "paragraph",
"content": "Welcome to FlowDeck 🎉 — your workspace is ready."},
{"id": "3", "type": "paragraph",
"content": "Use the slash command « / » in any page to add blocks, databases, to-dos and more."},
{"id": "4", "type": "paragraph",
"content": "Connect Gitea or GitHub in Settings → Integrations to sync your repositories."},
]
cur = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section) "
"VALUES (?, ?, ?, ?, 'blocks', 'Private')",
(user.get("login", "local"), ws["id"], title, json.dumps(blocks)),
)
conn.commit()
page_id = cur.lastrowid
return {"status": "ok", "id": page_id, "title": title, "workspace_id": ws["id"]}
+67
View File
@@ -0,0 +1,67 @@
"""FlowDeck — v5.2.0 Projects API: list, register, manual sync + backups admin."""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Request
from app.auth.session import SessionManager
from app.services import projects as projects_svc
from app.services.backup import backup_db, list_backups
logger = logging.getLogger(__name__)
router = APIRouter(tags=["projects"], prefix="/api/projects")
backups_router = APIRouter(tags=["backups"])
def _require_admin(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("is_admin"):
raise HTTPException(status_code=403, detail="Admin only")
return user
@router.get("")
async def list_projects(request: Request):
"""List all synced projects (optionally filtered by type)."""
proj_type = request.query_params.get("type") or None
return {"projects": projects_svc.list_projects(proj_type)}
@router.post("")
async def create_project(request: Request):
"""Register a standalone (builtin) project."""
body = await request.json()
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name required")
project = projects_svc.create_builtin_project(name, body.get("owner", ""), body.get("description", ""))
return {"status": "ok", "project": project}
@router.post("/sync")
async def sync_projects(request: Request):
"""Trigger an immediate forge sync for every connected account."""
_require_admin(request)
stats = await projects_svc.sync_all_projects()
return {"status": "ok", "stats": stats}
# ═══════════ Backups (admin) ═══════════
@backups_router.post("/api/settings/backups/run")
async def run_backup_now(request: Request):
"""Admin: create a database backup immediately."""
_require_admin(request)
filename = backup_db()
if not filename:
raise HTTPException(status_code=400, detail="Backups disabled or no database file")
return {"status": "ok", "filename": filename}
@backups_router.get("/api/settings/backups")
async def admin_list_backups(request: Request):
"""Admin: list stored backups."""
_require_admin(request)
return {"backups": list_backups()}
+46 -10
View File
@@ -1,12 +1,13 @@
"""FlowDeck — Public API router (v2.1.0)."""
"""FlowDeck — Public API router (v2.1.0, v5.2.0 per-user tokens)."""
from __future__ import annotations
import json
import hashlib
import logging
from secrets import token_urlsafe
from fastapi import APIRouter, Request, HTTPException, Header, Depends
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
@@ -14,28 +15,63 @@ router = APIRouter(tags=["public-api"], prefix="/api/v1")
DEFAULT_TOKEN = "fd-public-key"
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _token_owner(token: str) -> dict | None:
"""Resolve an api_tokens row by its sha256 hash (revoked → None)."""
with get_conn() as conn:
row = conn.execute(
"SELECT id, user_id, name FROM api_tokens WHERE token_hash=? AND revoked=0",
(_hash_token(token),),
).fetchone()
if not row:
return None
conn.execute(
"UPDATE api_tokens SET last_used_at=CURRENT_TIMESTAMP WHERE id=?", (row["id"],)
)
conn.commit()
return dict(row)
def verify_token(authorization: str | None = Header(None)):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "API token required. Generate one via POST /api/v1/token.")
raise HTTPException(401, "API token required. Generate one via Settings → API tokens.")
token = authorization[7:] # strip "Bearer "
if token == DEFAULT_TOKEN:
return token
with get_conn() as conn:
row = conn.execute("SELECT 1 FROM user_tokens WHERE gitea_token=?", (token,)).fetchone()
if not row:
if row:
return token
owner = _token_owner(token)
if not owner:
raise HTTPException(403, "Invalid API token")
return token
@router.post("/token")
async def generate_token(request: Request):
"""Generate a public API access token."""
"""Generate a public API access token.
When an authenticated session is present the token is bound to that user
(revocable from Settings → API tokens); otherwise a legacy shared token is
created for backward compatibility.
"""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
token = f"fd_{token_urlsafe(24)}"
with get_conn() as conn:
conn.execute(
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
(0, token),
)
if user and user.get("id"):
conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)",
(user["id"], "API token", _hash_token(token), token[:12]),
)
else:
conn.execute(
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
(0, token),
)
conn.commit()
return {"token": token, "note": "Use as: Authorization: Bearer <token>"}
+49
View File
@@ -0,0 +1,49 @@
"""FlowDeck — v5.13.0 Realtime: WebSocket gateway /ws/pages/{page_id}.
Auth via cookie session (flowdeck_session). Rooms in-memory par page.
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, WebSocket
from starlette.websockets import WebSocketDisconnect
from app.auth.session import SessionManager
from app.services.realtime_server import manager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["realtime"])
@router.websocket("/ws/pages/{page_id}")
async def ws_page(websocket: WebSocket, page_id: int):
await websocket.accept()
user = SessionManager.decode_session(
websocket.cookies.get("flowdeck_session", "")
)
if not user or not user.get("id"):
try:
await websocket.close(code=4401)
except Exception:
pass
return
conn = await manager.connect(websocket, page_id, user)
if not conn:
return
try:
while True:
raw = await websocket.receive_text()
try:
msg = json.loads(raw)
except (TypeError, ValueError):
continue
await manager.handle(conn, msg)
except WebSocketDisconnect:
pass
except Exception as e: # noqa: BLE001
logger.debug("ws closed: %s", e)
finally:
await manager.disconnect(conn)
+27
View File
@@ -0,0 +1,27 @@
"""FlowDeck — unified search router (v5.0.0).
``GET /api/search?q=`` backs the Ctrl+K command palette. Returns matching
editor pages and databases scoped to the current user's accessible workspaces.
"""
from __future__ import annotations
from fastapi import APIRouter, Query, Request
from app.auth.session import SessionManager
from app.services.search import search as search_service
router = APIRouter(tags=["search"])
@router.get("/api/search")
async def search(request: Request, q: str = Query(default="")):
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
user_id = user.get("id") if user and user.get("id") else None
data = search_service(q, user_id=user_id)
return {
"query": q,
"pages": data["pages"],
"collections": data["collections"],
"total": len(data["pages"]) + len(data["collections"]),
}
+121
View File
@@ -0,0 +1,121 @@
"""FlowDeck — v5.2.0 Security: per-user API tokens & active sessions.
Backend for the Settings → API tokens / Sessions UI.
"""
from __future__ import annotations
import hashlib
import logging
from secrets import token_urlsafe
from fastapi import APIRouter, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["security"], prefix="/api/settings")
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _current_user_id(request: Request) -> int:
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["id"]
# ═══════════ API tokens ═══════════
@router.get("/tokens")
async def list_tokens(request: Request):
"""List the current user's API tokens (prefix only, no secrets)."""
uid = _current_user_id(request)
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, token_prefix, last_used_at, revoked, created_at "
"FROM api_tokens WHERE user_id=? ORDER BY created_at DESC",
(uid,),
).fetchall()
return {"tokens": [dict(r) for r in rows]}
@router.post("/tokens")
async def create_token(request: Request):
"""Create an API token for the current user. The secret is returned once."""
uid = _current_user_id(request)
body = await request.json()
name = (body.get("name") or "").strip() or "API token"
token = f"fd_{token_urlsafe(24)}"
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)",
(uid, name[:80], _hash_token(token), token[:12]),
)
conn.commit()
tid = cur.lastrowid
return {"id": tid, "name": name, "token": token,
"note": "Copy this token now — it won't be shown again."}
@router.delete("/tokens/{token_id:int}")
async def revoke_token(token_id: int, request: Request):
"""Revoke an API token (soft delete)."""
uid = _current_user_id(request)
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM api_tokens WHERE id=? AND user_id=?", (token_id, uid)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Token not found")
conn.execute("UPDATE api_tokens SET revoked=1 WHERE id=?", (token_id,))
conn.commit()
return {"status": "revoked"}
# ═══════════ Active sessions ═══════════
@router.get("/sessions")
async def list_sessions(request: Request):
"""List the current user's active sessions with their devices."""
uid = _current_user_id(request)
current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", ""))
sessions = SessionManager.list_sessions(uid)
now = __import__("datetime").datetime.now()
for s in sessions:
s["is_current"] = (s["id"] == current_sid)
# A session older than 7 days is implicitly expired (cookie max-age).
created = s.get("created_at") or ""
try:
from datetime import datetime
created_dt = datetime.fromisoformat(str(created).replace("Z", ""))
s["expired"] = (now - created_dt).days >= 7
except Exception:
s["expired"] = False
return {"sessions": sessions}
@router.post("/sessions/{sid}/revoke")
async def revoke_session(sid: str, request: Request):
"""Revoke an active session. If it's the current one, the user is logged out."""
uid = _current_user_id(request)
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM user_sessions WHERE id=? AND user_id=?", (sid, uid)
).fetchone()
if not row:
raise HTTPException(status_code=404, detail="Session not found")
SessionManager.revoke_session(sid)
# Also wipe the OAuth state cookie if the current session was revoked.
current_sid = SessionManager.session_id(request.cookies.get("flowdeck_session", ""))
if current_sid == sid:
try:
request.session.clear()
except Exception:
pass
return {"status": "revoked"}
+62 -9
View File
@@ -6,10 +6,10 @@ import re
import unicodedata
from datetime import datetime
from fastapi import APIRouter, Request, HTTPException
from fastapi import APIRouter, HTTPException, Request
from app.db import get_conn
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["sharing"], prefix="/api")
@@ -61,16 +61,41 @@ async def share_page(page_id: int, request: Request):
if not target:
raise HTTPException(404, "Target user not found")
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, permission, user["id"]),
)
# Upsert to avoid duplicates: update the existing permission if the same
# target (user 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 email:
target_row = conn.execute(
"""SELECT id FROM page_shares
WHERE page_id=? AND shared_with_email=? AND shared_with_user_id IS NULL""",
(page_id, email.strip()),
).fetchone()
if target_row:
conn.execute(
"UPDATE page_shares SET permission=?, created_by=? WHERE id=?",
(permission, user["id"], target_row["id"]),
)
share_id = target_row["id"]
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"]),
)
share_id = cur.lastrowid
conn.execute("UPDATE pages SET is_shared=1 WHERE id=?", (page_id,))
conn.commit()
return {
"id": cur.lastrowid,
"id": share_id,
"page_id": page_id,
"shared_with_user_id": target_user_id,
"shared_with_email": email,
@@ -79,6 +104,34 @@ async def share_page(page_id: int, request: Request):
}
@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)
body = await request.json() if request.headers.get("content-type") else {}
permission = body.get("permission", "")
if permission not in ("view", "comment", "edit"):
raise HTTPException(400, "Invalid permission. Use view, comment, or edit.")
with get_conn() as conn:
row = conn.execute(
"SELECT 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(
"UPDATE page_shares SET permission=? WHERE id=?",
(permission, share_id),
)
conn.commit()
return {"status": "updated", "share_id": share_id, "permission": permission}
@router.delete("/pages/{page_id}/share/{share_id}")
async def remove_share(page_id: int, share_id: int, request: Request):
"""Remove a share invitation."""
@@ -148,7 +201,7 @@ async def list_shares(page_id: int, request: Request):
@router.post("/pages/{page_id}/publish")
async def publish_page(page_id: int, request: Request):
"""Publish a page (is_published=1) with a URL slug."""
user = _require_auth(request)
_require_auth(request)
with get_conn() as conn:
page = conn.execute(
+115
View File
@@ -0,0 +1,115 @@
"""FlowDeck — Sidebar customization API (v4.6.0)."""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, HTTPException, Request
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["sidebar"], prefix="/api/sidebar")
def _get_user(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
raise HTTPException(status_code=401, detail="Not authenticated")
return user
DEFAULT_CONFIG = {
"workspace": {"visible": True, "order": 0, "show_count": None},
"gitea": {"visible": True, "order": 1, "show_count": None},
"meetings": {"visible": True, "order": 2, "show_count": 5},
"recents": {"visible": True, "order": 3, "show_count": 10},
"favorites": {"visible": True, "order": 4, "show_count": 10},
"agents": {"visible": True, "order": 5, "show_count": None},
"shared": {"visible": True, "order": 6, "show_count": 10},
"published": {"visible": True, "order": 7, "show_count": 10},
"private": {"visible": True, "order": 8, "show_count": None},
}
@router.get("/config")
async def get_sidebar_config(request: Request):
"""Get the current user's sidebar customization config."""
user = _get_user(request)
with get_conn() as conn:
row = conn.execute(
"SELECT sidebar_config FROM users WHERE id=?", (user["id"],)
).fetchone()
if not row:
return {"config": DEFAULT_CONFIG}
raw = row["sidebar_config"]
if not raw:
return {"config": DEFAULT_CONFIG}
try:
stored = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return {"config": DEFAULT_CONFIG}
# Merge with defaults to ensure all keys exist
merged = dict(DEFAULT_CONFIG)
merged.update(stored)
return {"config": merged}
def get_sidebar_config_sync(user_id: int) -> dict:
"""Synchronous helper to get sidebar config (used during template rendering)."""
with get_conn() as conn:
row = conn.execute(
"SELECT sidebar_config FROM users WHERE id=?", (user_id,)
).fetchone()
if not row:
return dict(DEFAULT_CONFIG)
raw = row["sidebar_config"]
if not raw:
return dict(DEFAULT_CONFIG)
try:
stored = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return dict(DEFAULT_CONFIG)
merged = dict(DEFAULT_CONFIG)
merged.update(stored)
return merged
@router.put("/config")
async def save_sidebar_config(request: Request):
"""Save the current user's sidebar customization config."""
user = _get_user(request)
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body") from None
config = body.get("config")
if not config or not isinstance(config, dict):
raise HTTPException(status_code=400, detail="config object is required")
# Merge with defaults to ensure validity
merged = dict(DEFAULT_CONFIG)
for key, val in config.items():
if key in DEFAULT_CONFIG and isinstance(val, dict):
merged[key] = {
"visible": val.get("visible", DEFAULT_CONFIG[key]["visible"]),
"order": val.get("order", DEFAULT_CONFIG[key]["order"]),
"show_count": val.get("show_count", DEFAULT_CONFIG[key]["show_count"]),
}
elif key not in DEFAULT_CONFIG:
# Allow new custom sections
merged[key] = val
with get_conn() as conn:
conn.execute(
"UPDATE users SET sidebar_config=? WHERE id=?",
(json.dumps(merged), user["id"]),
)
conn.commit()
return {"status": "ok", "config": merged}
+4 -4
View File
@@ -1,12 +1,12 @@
"""FlowDeck — Webhook receiver for real-time Gitea sync."""
from __future__ import annotations
import json
import hmac
import hashlib
import hmac
import json
import logging
from fastapi import APIRouter, Request, HTTPException
from fastapi import APIRouter, HTTPException, Request
from app.config import settings
from app.db import get_conn
@@ -162,7 +162,7 @@ async def register_webhook(owner: str, repo: str, request: Request):
return {"status": "ok", "webhook": result}
except Exception as e:
logger.error("Failed to register webhook: %s", e)
raise HTTPException(status_code=500, detail=str(e))
raise HTTPException(status_code=500, detail=str(e)) from e
@router.get("/status/{owner}/{repo}")
+190 -13
View File
@@ -5,12 +5,14 @@ import csv
import io
import json
import logging
import sqlite3
from fastapi import APIRouter, Request, HTTPException
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, StreamingResponse
from app.db import get_conn
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import fire_event
logger = logging.getLogger(__name__)
router = APIRouter(tags=["workspace"], prefix="/workspace")
@@ -230,8 +232,9 @@ async def create_db_template(request: Request):
cur = None
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO database_templates (name, description, schema_json) VALUES (?,?,?)",
(body.get("name", "Template"), body.get("description", ""), json.dumps(body.get("schema", []))),
"INSERT INTO database_templates (name, description, icon, schema_json) VALUES (?,?,?,?)",
(body.get("name", "Template"), body.get("description", ""),
body.get("icon", "📋"), json.dumps(body.get("schema", []))),
)
conn.commit()
return {"id": cur.lastrowid, "status": "created"}
@@ -239,22 +242,16 @@ async def create_db_template(request: Request):
@router.post("/templates/database/{tid}/apply")
async def apply_db_template(request: Request, tid: int):
from app.services.db_templates import create_from_template
body = await request.json() if request.headers.get("content-type") else {}
name = body.get("name", "New Database")
with get_conn() as conn:
tmpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (tid,)).fetchone()
if not tmpl:
raise HTTPException(404, "Template not found")
cur = conn.execute(
"INSERT INTO collections (name, description, icon, schema_json) VALUES (?,?,?,?)",
(name, tmpl["description"], "📋", tmpl["schema_json"]),
)
conn.execute(
"INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)",
(cur.lastrowid, "Default View", "table", "{}"),
)
collection_id = create_from_template(conn, name, dict(tmpl))
conn.commit()
return {"collection_id": cur.lastrowid, "name": name, "status": "created"}
return {"collection_id": collection_id, "name": name, "status": "created"}
@router.get("/collections/{collection_id}/templates/page")
@@ -293,6 +290,12 @@ async def apply_page_template(request: Request, collection_id: int, tid: int):
(collection_id, body.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
)
conn.commit()
await fire_event("page.created", {
"page_id": cur.lastrowid,
"collection_id": collection_id,
"title": body.get("title", "New Page"),
"properties": json.loads(tmpl["property_values_json"]) if tmpl["property_values_json"] else {},
})
return {"id": cur.lastrowid, "status": "created"}
@@ -405,6 +408,180 @@ async def delete_dashboard(request: Request, collection_id: int, did: int):
return {"id": did, "status": "deleted"}
# ── v4.5.0: Sprints ──
@router.get("/collections/{collection_id}/sprints")
async def list_sprints(request: Request, collection_id: int):
"""List all sprints for a collection."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM sprints WHERE collection_id=? ORDER BY start_date DESC", (collection_id,)
).fetchall()
sprints = []
for r in rows:
s = dict(r)
# Count pages in sprint
count = conn.execute(
"SELECT COUNT(*) as cnt FROM sprint_pages WHERE sprint_id=?", (r["id"],)
).fetchone()["cnt"]
s["page_count"] = count
sprints.append(s)
return {"sprints": sprints}
@router.post("/collections/{collection_id}/sprints")
async def create_sprint(request: Request, collection_id: int):
"""Create a new sprint."""
body = await request.json() if request.headers.get("content-type") else {}
name = body.get("name", "").strip()
start_date = body.get("start_date", "")
end_date = body.get("end_date", "")
if not name or not start_date or not end_date:
raise HTTPException(400, "name, start_date, end_date are required")
goal = body.get("goal", "")
status = body.get("status", "planning")
auto_complete = int(body.get("auto_complete", 1))
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO sprints (collection_id, name, start_date, end_date, goal, status, auto_complete) VALUES (?,?,?,?,?,?,?)",
(collection_id, name, start_date, end_date, goal, status, auto_complete),
)
conn.commit()
return {"id": cur.lastrowid, "name": name, "status": "created"}
@router.put("/collections/{collection_id}/sprints/{sid}")
async def update_sprint(request: Request, collection_id: int, sid: int):
"""Update a sprint."""
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
sprint = conn.execute(
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
).fetchone()
if not sprint:
raise HTTPException(404, "Sprint not found")
name = body.get("name", sprint["name"])
start_date = body.get("start_date", sprint["start_date"])
end_date = body.get("end_date", sprint["end_date"])
goal = body.get("goal", sprint["goal"])
status = body.get("status", sprint["status"])
auto_complete = int(body.get("auto_complete", sprint["auto_complete"]))
conn.execute(
"UPDATE sprints SET name=?, start_date=?, end_date=?, goal=?, status=?, auto_complete=? WHERE id=?",
(name, start_date, end_date, goal, status, auto_complete, sid),
)
conn.commit()
return {"id": sid, "status": "updated"}
@router.delete("/collections/{collection_id}/sprints/{sid}")
async def delete_sprint(request: Request, collection_id: int, sid: int):
"""Delete a sprint."""
with get_conn() as conn:
sprint = conn.execute(
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
).fetchone()
if not sprint:
raise HTTPException(404, "Sprint not found")
conn.execute("DELETE FROM sprints WHERE id=?", (sid,))
conn.commit()
return {"id": sid, "status": "deleted"}
@router.post("/collections/{collection_id}/sprints/{sid}/assign")
async def assign_page_to_sprint(request: Request, collection_id: int, sid: int):
"""Assign a page to a sprint."""
body = await request.json() if request.headers.get("content-type") else {}
page_id = body.get("page_id")
if not page_id:
raise HTTPException(400, "page_id is required")
velocity_points = body.get("velocity_points", 1)
status_at_start = body.get("status_at_start", "")
with get_conn() as conn:
sprint = conn.execute("SELECT id FROM sprints WHERE id=?", (sid,)).fetchone()
if not sprint:
raise HTTPException(404, "Sprint not found")
page = conn.execute("SELECT id FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(404, "Page not found")
try:
conn.execute(
"INSERT INTO sprint_pages (sprint_id, page_id, status_at_start, velocity_points) VALUES (?,?,?,?)",
(sid, page_id, status_at_start, velocity_points),
)
conn.commit()
except sqlite3.IntegrityError:
raise HTTPException(409, "Page already assigned to this sprint") from None
return {"sprint_id": sid, "page_id": page_id, "status": "assigned"}
@router.delete("/collections/{collection_id}/sprints/{sid}/assign/{page_id}")
async def remove_page_from_sprint(request: Request, collection_id: int, sid: int, page_id: int):
"""Remove a page from a sprint."""
with get_conn() as conn:
existing = conn.execute(
"SELECT * FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sid, page_id)
).fetchone()
if not existing:
raise HTTPException(404, "Assignment not found")
conn.execute("DELETE FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sid, page_id))
conn.commit()
return {"sprint_id": sid, "page_id": page_id, "status": "removed"}
@router.get("/collections/{collection_id}/sprints/burndown/{sid}")
async def sprint_burndown(request: Request, collection_id: int, sid: int):
"""Calculate burndown data for a sprint."""
with get_conn() as conn:
sprint = conn.execute(
"SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id)
).fetchone()
if not sprint:
raise HTTPException(404, "Sprint not found")
pages = conn.execute(
"""SELECT sp.velocity_points, cp.property_values_json
FROM sprint_pages sp JOIN collection_pages cp ON sp.page_id=cp.id
WHERE sp.sprint_id=?""", (sid,)
).fetchall()
total_points = sum(p["velocity_points"] for p in pages)
completed = 0
for p in pages:
props = json.loads(p["property_values_json"])
for v in props.values():
if isinstance(v, str) and v.lower() in ("done", "complete", "completed", "terminé"):
completed += p["velocity_points"]
break
from datetime import date
today = date.today()
start = date.fromisoformat(sprint["start_date"]) if sprint["start_date"] else today
end = date.fromisoformat(sprint["end_date"]) if sprint["end_date"] else today
total_days = max((end - start).days, 1)
elapsed = max((today - start).days, 0)
ideal_burn = total_points - (total_points * elapsed / total_days)
return {
"sprint": sprint["name"],
"total_points": total_points,
"completed_points": completed,
"remaining_points": total_points - completed,
"ideal_remaining": round(ideal_burn, 1),
"start_date": sprint["start_date"],
"end_date": sprint["end_date"],
"days_elapsed": elapsed,
"days_total": total_days,
}
# ── CSV Import/Export ──
@router.post("/collections/{collection_id}/import/csv")
+456
View File
@@ -0,0 +1,456 @@
"""FlowDeck — AgentEngine: ReAct orchestrator (v4.14.0).
`objective → comprehension → context → reasoning ↔ action → result`.
The engine drives the LLM (which only emits tool intentions), gates each call
through PermissionManager, executes it via ToolRegistry, journals every action
to `agent_actions` with an undo snapshot, and yields a stream of SSE events so
the UI can render reasoning + actions live. Since v4.14.0 a freshly created
conversation is automatically renamed with a descriptive title derived from its
content so the history stays easy to browse.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from app.config import settings
from app.db import get_conn
from app.services.context_builder import ContextBuilder
from app.services.llm_client import LLMClient
from app.services.permission_manager import PermissionManager
from app.services.tool_registry import ToolRegistry
logger = logging.getLogger(__name__)
MAX_ITERATIONS = 12
# Compact in-app guide so the LLM can answer « comment faire… ? » questions even
# when no document is attached to the conversation (generic help / onboarding).
APP_GUIDE = """## Guide de l'utilisateur FlowDeck (sert à répondre aux questions « comment … ? »)
- **Pages** : le contenu est organisé en blocs (paragraphes, titres, listes, to-do, tableaux, images, formules, bases embarquées). La barre latérale liste les pages récentes, favoris, agents, partagées et publiées.
- **Documents & espaces de travail** : un « document » est une page éditeur (type Notion) qui vit dans un espace de travail. Pour créer un document dans un espace : appelle `read_workspaces` (reprends le `workspace_name` ou l'id exact), puis `create_document`. Pour modifier un document existant : `read_document` puis `write_blocks` (blocs et/ou titre). Pour supprimer : `delete_document` (corbeille). `search_workspace` retrouve aussi les documents et les espaces par titre.
- **Format des blocs** (pour `write_blocks`) : chaque bloc est un objet `{"type": "...", "content": "texte"}`. Le champ du texte s'appelle **`content`** (jamais `text`). Un script / code s'écrit dans un bloc `{"type": "code", "content": "...", "language": "powershell"}`. Les titres sont `heading_1`, `heading_2`, `heading_3`, `heading_4`. Autres types : paragraph, bulleted_list, numbered_list, to_do, quote, divider, toggle, callout.
- **Collections (bases de données)** : des ensembles de pages structurées avec des propriétés (texte, nombre, sélection, dates…). Chaque collection peut avoir plusieurs vues : tableau, board (kanban), calendrier, galerie, liste, timeline, graphique, formulaire, carte, flux, gantt. Ajouter une propriété ou une vue = outils add_property / create_view.
- **Créer du contenu** : « crée une collection X », « crée une page », « ajoute une propriété Statut à la collection Y » sont des actions que l'agent peut exécuter directement avec ses outils.
- **Espaces de travail** : FlowDeck gère des espaces locaux et des dépôts Gitea/GitHub (pages privées dans un dépôt, issues reliées via read_gitea_issues). On change d'espace depuis le menu en bas à gauche (« Switch workspace »).
- **Recherche** : la commande Ctrl+K / la barre de recherche du haut permet de retrouver pages et collections.
- **Corbeille & Bibliothèque** : les pages supprimées vont dans la Corbeille ; Favoris / Récents / Partagés / Publiés se consultent dans la Bibliothèque.
- **Réglages** : Paramètres (en bas à gauche → Settings) pour le compte, les notifications, les tags, les intégrations et la section « Agent & IA » (clés API, fournisseurs, modèle global).
- **Agent IA** : ouvrable via le bouton 🤖 en bas à droite ou la section « Agents » du sidebar. On peut lui parler de la page ouverte, ou lui poser des questions générales sur l'utilisation de l'application.
Quand la question est générale (« comment créer un kanban ? », « où sont mes favoris ? »), réponds de façon concise et guidée à partir de ces informations, sans inventer de fonctionnalités absentes."""
# Deterministic auto-title heuristics (used when no real LLM is configured, and
# as a fallback when the generated title is unusable). Ordered by priority: the
# first matching intent wins.
_TITLE_INTENTS = (
("Création", ("créer", "crée", "crées", "création", "nouveau", "nouvelle",
"create", "creation")),
("Ajout", ("ajouter", "ajoute", "ajout d", "ajoutons", "add")),
("Renommage", ("renommer", "renomme", "renommage", "rename")),
("Suppression", ("supprimer", "supprime", "suppression", "delete")),
("Déplacement", ("déplacer", "déplace", "déplacement", "move")),
("Mise à jour", ("modifier", "modifie", "modification", "mets à jour",
"met à jour", "mettre à jour", "update", "éditer")),
("Analyse", ("analyser", "analyse")),
("Résumé", ("résumer", "résume", "résumé", "resume")),
("Traduction", ("traduire", "traduis", "traduit", "traduction", "translate")),
("Planification", ("planifier", "planifie", "préparer", "prépare", "organiser",
"organise", "sprint", "agenda")),
("Recherche", ("chercher", "cherche", "rechercher", "recherche", "trouver",
"trouve", "liste", "lister", "search", "find")),
)
_TITLE_TYPES = (
("collection", "collection", ("collection", "base de données", "database", "db")),
("propriété", "propriété", ("propriété", "property")),
("vue", "vue", (" vue", "view")),
("board", "board", ("board", "kanban")),
("sprint", "sprint", ("sprint",)),
("document", "document", ("document", "note de réunion", "compte-rendu", "compte rendu")),
("tâche", "tâche", ("tâche", "task", "tache")),
("issue", "issue", ("issue",)),
)
class AgentEngine:
def __init__(self, user_id: int, workspace_id: int | None = None,
llm: LLMClient | None = None):
self.user_id = user_id
self.workspace_id = workspace_id
self.llm = llm or LLMClient()
self.tools = ToolRegistry()
self.ctx = ContextBuilder(user_id, workspace_id)
self.perms = PermissionManager(user_id)
self._tokens = 0
# ── Helpers ──
def _load_agent(self, conversation_id: int) -> dict:
with get_conn() as conn:
row = conn.execute(
"SELECT a.* FROM agents a JOIN agent_conversations c ON c.agent_id=a.id WHERE c.id=?",
(conversation_id,),
).fetchone()
if not row:
row = {"id": None, "name": "FlowDeck Agent", "icon": "🤖", "agent_type": "personal",
"system_instructions": "", "model": settings.llm_model,
"scope_json": "{}", "approval_mode": "auto"}
return dict(row)
def _build_system_prompt(self, agent: dict, skills=None) -> str:
if skills is None:
skills = []
if isinstance(skills, dict):
skills = [skills]
lines = [
"Tu es FlowDeck Agent, un agent IA qui réalise des tâches dans le workspace FlowDeck.",
"Tu réfléchis (reasoning) puis agis en appelant les outils disponibles.",
"Appelle UN ou PLUSIEURS outils pour atteindre l'objectif, puis conclus avec une réponse finale.",
"N'invente jamais d'IDs : utilise ceux fournis dans le contexte.",
f"Workspace courant : {self.workspace_id}.",
]
if agent.get("system_instructions"):
lines.append(f"\nInstructions de l'agent {agent.get('name','')}:\n{agent['system_instructions']}")
# Plusieurs skills peuvent être appliqués au même post : chacun injecte
# son prompt dans les instructions système.
for skill in skills:
if skill:
lines.append(f"\nSkill appliquée « {skill.get('name','')} »:\n{skill.get('prompt_template','')}")
# L'utilisateur peut poser des questions d'aide sans contexte de document ;
# le guide intégré permet d'y répondre (aucun outil requis).
lines.append("\n" + APP_GUIDE)
return "\n".join(lines)
# ── Main run (async generator of SSE events) ──
async def run(self, conversation_id: int, objective: str, *, model: str | None = None,
mentions: list[str] | None = None, files: list[dict] | None = None,
skill_id: int | None = None, skill_ids: list[int] | None = None,
extra_context: str | None = None):
agent = self._load_agent(conversation_id)
scope = json.loads(agent.get("scope_json") or "{}")
approval_mode = agent.get("approval_mode") or "auto"
model = model or agent.get("model") or settings.llm_model
ids = list(skill_ids or [])
if skill_id and skill_id not in ids:
ids.append(skill_id)
skills = [s for s in (self._load_skill(i, scope) for i in ids) if s]
system = self._build_system_prompt(agent, skills)
context = self.ctx.build(mentions=mentions, files=files)
if extra_context and extra_context.strip():
context += "\n\n## Document / contexte fourni par l'utilisateur\n" + extra_context.strip()
messages = [
{"role": "system", "content": system},
{"role": "user", "content": f"{objective}\n\n# Contexte\n{context}"},
]
self._persist_message(conversation_id, "user", objective)
self._update_conversation(conversation_id, status="running")
# Update the history title right away (before the run finishes) and
# refine it once we have the final answer (_autotitle below).
try:
suggested = self._suggest_title(objective, None)
if suggested:
self._update_conversation(conversation_id, title=suggested[:80])
except Exception: # noqa: BLE001 — never break a run because of the title
logger.exception("Auto-title failed for conversation #%s", conversation_id)
tool_schema = self.tools.schema(scope)
final_text = None
used_model = model or "" # peut être ajusté par un repli de modèle (404/410)
try:
for _step in range(settings.agent_max_iterations or MAX_ITERATIONS):
if self._tokens >= settings.agent_max_tokens_budget:
yield self._event("error", {"message": "Budget de tokens dépassé"})
break
response = await asyncio.wait_for(
self.llm.complete(messages, model=model, tools=tool_schema, stream=True),
timeout=settings.agent_run_timeout_seconds,
)
self._tokens += response.usage.get("total_tokens", 0) or 0
if getattr(response, "notice", ""):
yield self._event("notice", {"message": response.notice})
used_model = getattr(response, "model", "") or used_model
if response.text and response.text.strip():
yield self._event("reasoning", {"content": response.text})
if not response.tool_calls:
messages.append({"role": "assistant", "content": response.text or ""})
final_text = response.text or self._no_tool_message(response)
yield self._event("final", {"content": final_text})
break
# L'API de chat exige que le message assistant qui *annonce* les appels
# d'outils porte les `tool_calls` (avec id), puis que chaque résultat
# d'outil soit fourni avec le `tool_call_id` correspondant. Sans cela
# la passe suivante est refusée par le fournisseur (et l'agent retombait
# silencieusement sur le mock hors-ligne).
tool_specs = []
for idx, call in enumerate(response.tool_calls):
call_id = call.get("id") or f"call_{conversation_id}_{idx}_{self._tokens}"
tool_specs.append({
"id": call_id,
"type": "function",
"function": {
"name": call["name"],
"arguments": call.get("arguments_raw")
or json.dumps(call.get("arguments") or {}, ensure_ascii=False),
},
})
assistant_msg = {"role": "assistant", "content": response.text or ""}
assistant_msg["tool_calls"] = tool_specs
messages.append(assistant_msg)
for idx, call in enumerate(response.tool_calls):
tool, args = call["name"], call.get("arguments") or {}
call_id = tool_specs[idx]["id"]
denied = False
try:
self.perms.assert_can(tool, args, self.workspace_id, approval_mode)
except Exception as exc: # permission / approval guard
detail = self._exc_detail(exc)
yield self._event("action", {"tool": tool, "status": "error", "detail": detail})
self._log_action(conversation_id, tool, args, {}, "error", detail=detail)
messages.append({
"role": "tool", "tool_call_id": call_id,
"content": json.dumps({"status": "error", "message": f"Permission refusée: {detail}"}, ensure_ascii=False),
})
denied = True
if not denied:
result = await self.tools.execute(tool, args, user_id=self.user_id)
if result.status == "success":
yield self._event("action", {
"tool": tool, "status": result.status,
"target_type": result.target_type, "target_id": result.target_id,
"message": result.message,
})
self._log_action(conversation_id, tool, args, result.data, "success",
target_type=result.target_type, target_id=result.target_id,
undo=result.undo)
messages.append({
"role": "tool", "tool_call_id": call_id,
"content": json.dumps({"status": "ok", "result": result.data, "target_id": result.target_id}, ensure_ascii=False),
})
else:
yield self._event("action", {"tool": tool, "status": "error", "detail": result.message})
self._log_action(conversation_id, tool, args, {}, "error", detail=result.message)
messages.append({
"role": "tool", "tool_call_id": call_id,
"content": json.dumps({"status": "error", "message": result.message}, ensure_ascii=False),
})
if final_text is None:
final_text = "Objectif traité. Consultez le journal des actions pour le détail."
yield self._event("final", {"content": final_text})
self._persist_message(conversation_id, "assistant", final_text,
model=used_model, tokens=self._tokens)
await self._autotitle(conversation_id, objective, final_text)
except Exception as exc: # noqa: BLE001
logger.exception("AgentEngine run failed")
yield self._event("error", {"message": f"Erreur interne: {exc}"})
finally:
self._update_conversation(conversation_id, status="idle")
# ── Skills ──
def _load_skill(self, skill_id: int, scope: dict | None) -> dict | None:
with get_conn() as conn:
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
if not row:
return None
skill = dict(row)
allowed = json.loads(skill.get("allowed_tools_json") or "[]")
if allowed:
skill["prompt_template"] = (skill.get("prompt_template") or "") + \
"\nOutils autorisés: " + ", ".join(allowed)
return skill
# ── Audit & persistence ──
def _log_action(self, conversation_id, tool, args, result, status,
*, target_type="", target_id=None, undo=None, detail=""):
with get_conn() as conn:
conn.execute(
"""INSERT INTO agent_actions
(conversation_id, tool_name, target_type, target_id, payload_json,
result_json, status, undo_snapshot_json, executed_by)
VALUES (?,?,?,?,?,?,?,?,?)""",
(conversation_id, tool, target_type,
str(target_id) if target_id is not None else None,
json.dumps(args, ensure_ascii=False),
json.dumps(result, ensure_ascii=False, default=str),
status,
json.dumps(undo or {}, ensure_ascii=False),
self.user_id),
)
conn.commit()
def _persist_message(self, conversation_id, role, content, *, model="", tokens=0):
with get_conn() as conn:
conn.execute(
"INSERT INTO agent_messages (conversation_id, role, content, model, tokens_used) VALUES (?,?,?,?,?)",
(conversation_id, role, content, model, tokens),
)
conn.commit()
def _update_conversation(self, conversation_id, *, status=None, title=None):
with get_conn() as conn:
sets, params = ["updated_at=CURRENT_TIMESTAMP"], []
if status:
sets.append("status=?")
params.append(status)
if title:
sets.append("title=?")
params.append(title)
params.append(conversation_id)
conn.execute(f"UPDATE agent_conversations SET {', '.join(sets)} WHERE id=?", params)
conn.commit()
# ── Misc ──
@staticmethod
def _event(etype: str, data: dict) -> dict:
return {"type": etype, **data}
@staticmethod
def _no_tool_message(response) -> str:
return "Je n'ai pas d'action à proposer pour cet objectif. Posez-moi une question plus précise ou demandez-moi de créer un élément."
@staticmethod
def _exc_detail(exc: Exception) -> str:
detail = getattr(exc, "detail", None)
return detail if isinstance(detail, str) else str(exc)
# ── Auto-title (v4.14.0) ──
async def _autotitle(self, conversation_id: int, objective: str, final_text: str | None):
"""Rename the conversation with a descriptive title derived from the
*latest* user request. Runs after every AI call so the history list
always reflects the current topic and stays easy to browse."""
try:
suggested = self._suggest_title(objective, final_text)
if suggested:
self._update_conversation(conversation_id, title=suggested[:80])
except Exception: # noqa: BLE001 — never break a run because of the title
logger.exception("Auto-title failed for conversation #%s", conversation_id)
@classmethod
def _suggest_title(cls, objective: str | None, final_text: str | None) -> str:
"""Produce a short descriptive title from the user objective (offline-safe)."""
text = (objective or "").split("\n# Contexte", 1)[0].strip() or (final_text or "").strip()
if not text:
return "Conversation"
# Strip the composer prefixes ("Contexte « X »", "Skill « Y »") that the
# frontend prepends before the real user text.
text = re.sub(
r"(?:Contexte\s*«[^»]*»|Skill\s*«[^»]*»|Skill\s+«[^»]*»)(?:\s*[,;\n.])+\s*",
"", text,
).strip()
if not text:
return "Conversation"
low = text.lower()
intent = None
for label, words in _TITLE_INTENTS:
if any(w in low for w in words):
intent = label
break
type_label = next(
(t for t, _noun, words in _TITLE_TYPES if any(w in low for w in words)), None
)
has_workspace = any(w in low for w in ("workspace", "espace de travail"))
quotes = [q.strip() for q in re.findall(r'[«"]([^«»"]{1,80})[»"]', text) if q.strip()]
subject = quotes[0] if quotes else None
ws = quotes[-1] if (has_workspace and len(quotes) > 1) else None
def _clean(s: str) -> str:
return re.sub(r"\s+", " ", s).strip(" .;:-")
if not subject and type_label:
noun = next((n for t, n, _w in _TITLE_TYPES if t == type_label), type_label)
m = re.search(
rf"\b{noun}\b\s*(?:nomm[ée]e?\s+|appel[ée]e?\s+|intitul[ée]e?\s+)?"
r'[«"]?\s*([A-Za-zÀ-ÿ0-9][A-Za-zÀ-ÿ0-9_ \-]{1,60}?)\s*[»"]?',
text, re.IGNORECASE,
)
if m:
subject = m.group(1).strip()
if intent and subject:
core = f"{intent} {type_label or 'élément'} « {subject} »" \
if type_label else f"{intent} « {subject} »"
if ws:
core += f" (dans {ws})"
return _clean(core)
generic = _clean(text)
return generic[:70] if generic else "Conversation"
def undo_action(action_id: int) -> bool:
"""Reverse a single agent action using its stored undo snapshot.
Returns True on success. Marks the action row `reverted`.
"""
with get_conn() as conn:
action = conn.execute("SELECT * FROM agent_actions WHERE id=?", (action_id,)).fetchone()
if not action:
raise ValueError(f"Action #{action_id} introuvable")
undo = json.loads(action["undo_snapshot_json"] or "{}")
op, table, rid = undo.get("action"), undo.get("table"), undo.get("id")
if not op or not table or rid is None:
raise ValueError(f"Action #{action_id} n'a pas de snapshot annulable")
if op == "delete":
conn.execute(f"DELETE FROM {table} WHERE id=?", (rid,))
elif op == "softdelete":
conn.execute(f"UPDATE {table} SET deleted_at=NULL WHERE id=?", (rid,))
elif op == "insert":
snapshot = undo.get("snapshot")
if not snapshot:
raise ValueError("Snapshot manquant pour insert")
cols = ", ".join(snapshot.keys())
ph = ", ".join("?" for _ in snapshot)
conn.execute(f"INSERT INTO {table} ({cols}) VALUES ({ph})", list(snapshot.values()))
elif op == "update":
snapshot = undo.get("snapshot")
if table == "collection_pages":
conn.execute(
"UPDATE collection_pages SET property_values_json=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(snapshot, ensure_ascii=False), undo.get("title", ""), rid),
)
elif table == "pages":
if isinstance(snapshot, dict):
conn.execute(
"UPDATE pages SET content=?, content_format=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(snapshot.get("content", ""),
snapshot.get("content_format", "markdown"),
snapshot.get("title", ""), rid),
)
else:
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(snapshot, rid))
else:
raise ValueError(f"Table non gérée pour rollback: {table}")
else:
raise ValueError(f"Opération de rollback inconnue: {op}")
conn.execute("UPDATE agent_actions SET status='reverted' WHERE id=?", (action_id,))
conn.commit()
return True
+330
View File
@@ -0,0 +1,330 @@
"""FlowDeck — AI Writing Assist (v5.9.0).
Headless, tool-free writing helpers used by the editor (slash commands,
inline autocomplete) and the database table (AI property suggestions).
All actions share one entry point, :meth:`AIWritingService.run`, which builds a
tight prompt, calls the configured LLM (or the deterministic offline mock) and
returns plain Markdown. `properties` additionally returns a structured
``suggestions`` mapping so the caller can fill collection properties.
The service never talks to the DB directly — the router resolves the caller's
provider/key and the page context before delegating here.
"""
from __future__ import annotations
import json
import logging
import re
from app.services.llm_client import LLMClient
logger = logging.getLogger(__name__)
WRITING_ACTIONS = ("write", "summarize", "translate", "continue", "autocomplete", "properties")
_MAX_CONTEXT = 20000
# Property name → (type, offline default) used by the deterministic fallback so
# the feature stays useful without a connected provider.
_OFFLINE_PROPERTY_DEFAULTS = (
(("status", "état", "etat", "stage"), "select", "To do"),
(("priority", "priorité", "priorite"), "select", "Medium"),
(("done", "terminé", "termine", "complété", "complete"), "checkbox", False),
(("summary", "résumé", "resume", "description", "notes"), "text", ""),
)
_SYSTEM_WRITING = (
"Tu es l'assistant d'écriture de FlowDeck. "
"Réponds UNIQUEMENT avec le contenu demandé, en Markdown léger "
"(paragraphes, listes à puces, titres si utile). "
"N'ajoute aucun préambule, aucun commentaire, aucun bloc de code autour du texte."
)
class AIWritingService:
"""Deterministic, provider-agnostic writing assistant."""
def __init__(self, user_id: int | None = None, provider: str | None = None,
model: str | None = None, api_key: str | None = None,
api_base: str | None = None):
self.user_id = user_id
self.provider = (provider or "").strip().lower() or None
self.model = (model or "").strip() or None
self._api_key = api_key
self._api_base = api_base
# ── LLM plumbing ──
def _client(self) -> LLMClient:
provider = self.provider
api_key = self._api_key
api_base = self._api_base
if provider and self.user_id:
try:
from app.services.llm_config import get_user_llm_key
row = get_user_llm_key(self.user_id, provider)
if row and row.get("api_key"):
api_key = row["api_key"]
api_base = (row.get("api_base") or "").strip() or api_base
except Exception: # noqa: BLE001 — never fail on key lookup
pass
return LLMClient(provider=provider, api_key=api_key, api_base=api_base)
async def _complete(self, prompt: str, context: str = "") -> tuple[str, str, bool]:
llm = self._client()
offline = llm.provider == "offline" or not llm._has_credentials()
user_content = prompt
if context and context.strip():
user_content += "\n\n# Contexte\n" + context.strip()[:_MAX_CONTEXT]
messages = [
{"role": "system", "content": _SYSTEM_WRITING},
{"role": "user", "content": user_content},
]
resp = await llm.complete(messages, model=self.model, tools=None, stream=False)
return (resp.text or "").strip(), (resp.model or self.model or ""), offline
# ── Public API ──
async def run(self, action: str, *, prompt: str = "", context: str = "",
target_language: str = "English", prefix: str = "",
title: str = "", properties: list | None = None) -> dict:
action = (action or "").strip().lower()
if action not in WRITING_ACTIONS:
raise ValueError(f"Action inconnue: {action or '(vide)'}")
if action == "properties":
suggestions = await self.suggest_properties(
context=context, title=title, properties=properties or [])
return {"ok": True, "action": action, "text": "", "suggestions": suggestions,
"model": self.model or "", "offline": self._offline_hint()}
prompt_text = self._build_prompt(
action, prompt=prompt, context=context,
target_language=target_language, prefix=prefix, title=title)
# No connected provider → deterministic, dependency-free output (the raw
# offline planner echoes the prompt, which is wrong for continue/autocomplete).
if self._offline_hint():
return {"ok": True, "action": action, "model": "",
"offline": True,
"text": self._offline_text(action, prompt=prompt, context=context,
target_language=target_language,
prefix=prefix, title=title)}
try:
text, model, offline = await self._complete(prompt_text, context=context)
except Exception as exc: # noqa: BLE001 — surface provider errors to the UI
logger.warning("AI writing '%s' failed: %s", action, exc)
return {"ok": False, "action": action, "error": str(exc),
"text": "", "model": self.model or "", "offline": False}
if not text:
text = self._offline_text(action, prompt=prompt, context=context,
target_language=target_language,
prefix=prefix, title=title)
offline = True
return {"ok": True, "action": action, "text": text,
"model": model, "offline": offline}
# ── Prompt building ──
def _build_prompt(self, action: str, *, prompt: str, context: str,
target_language: str, prefix: str, title: str) -> str:
if action == "write":
subject = (prompt or title or "ce document").strip()
return (f"Rédige le contenu demandé : {subject}. "
"Fournis un texte structuré et directement utilisable.")
if action == "summarize":
return ("Résume le contenu fourni de façon structurée et concise : "
"un court paragraphe d'introduction puis 3 à 5 points clés à puces.")
if action == "translate":
lang = (target_language or "English").strip()
return (f"Traduis l'intégralité du contenu fourni en {lang}, "
"en conservant fidèlement sa structure (titres, listes, paragraphes). "
"Ne traduis pas les noms propres et les termes techniques.")
if action == "continue":
return ("Poursuis naturellement le texte fourni. "
"Écris un à trois paragraphes cohérents avec le style et le sujet, "
"sans répéter ce qui précède et sans introduction.")
if action == "autocomplete":
return (f"Complète la phrase en cours par une suite courte et pertinente "
f"(maximum 20 mots). Ne répète pas le texte déjà écrit, ne mets "
f"aucun préambule. Texte en cours : {prefix!r}")
return prompt
# ── Offline deterministic fallbacks ──
def _offline_hint(self) -> bool:
try:
llm = self._client()
return llm.provider == "offline" or not llm._has_credentials()
except Exception: # noqa: BLE001
return True
def _offline_text(self, action: str, *, prompt: str, context: str,
target_language: str, prefix: str, title: str) -> str:
if action == "summarize":
return self._offline_summary(context)
if action == "translate":
return (f"⚠️ **Traduction hors-ligne indisponible** — aucun modèle d'IA connecté.\n\n"
f"Connectez un fournisseur dans **Paramètres → Agent & IA** pour traduire "
f"ce document en {target_language or 'English'}.")
if action == "autocomplete":
return self._offline_autocomplete(prefix)
if action == "continue":
return ("Suite du contenu : développez ici le point précédent avec un exemple "
"concret, puis ouvrez la prochaine idée en une phrase de transition.")
subject = (prompt or title or "ce document").strip()
return (f"## {subject}\n"
"\n"
"Présentation générale du sujet : objectif, contexte et public visé en "
"quelques phrases. (Contenu généré hors-ligne — connectez une clé API "
"pour une rédaction complète.)\n"
"\n"
"## Points clés\n"
"• Idée principale 1 et son argument.\n"
"• Idée principale 2 avec un exemple concret.\n"
"\n"
"## Prochaines étapes\n"
"• Relire, compléter et mettre en forme ce contenu.")
@staticmethod
def _offline_summary(context: str) -> str:
text = (context or "").strip()
if not text:
return "Résumé : aucun contenu fourni à résumer."
headings = re.findall(r"^#{1,4}\s+(.+)$", text, flags=re.MULTILINE)
sentences = re.split(r"(?<=[.!?])\s+", re.sub(r"\s+", " ", text))
lead = next((s.strip() for s in sentences if len(s.strip()) > 40), sentences[0].strip())
out = ["**Résumé**", "", lead[:400], ""]
bullets = []
if headings:
bullets = [f"• {h.strip()}" for h in headings[:5]]
else:
for s in sentences[1:6]:
s = s.strip()
if len(s) > 30:
bullets.append(f"• {s[:180]}")
if bullets:
out.append("**Points clés**")
out.extend(bullets)
return "\n".join(out)
@staticmethod
def _offline_autocomplete(prefix: str) -> str:
prefix = (prefix or "").strip()
if len(prefix) < 8:
return ""
return " Cette section détaille les points clés à retenir."
# ── AI properties ──
async def suggest_properties(self, *, context: str = "", title: str = "",
properties: list | None = None) -> dict:
"""Return ``{property_name: value}`` suggestions for a collection page.
``properties`` is a list of ``{name, type}`` dicts. With a connected
provider the model is asked for a JSON object; offline we derive
deterministic defaults from the property names so the UI stays useful.
"""
properties = properties or []
if not properties:
return {}
names = [str(p.get("name", "")).strip() for p in properties if isinstance(p, dict)]
names = [n for n in names if n]
llm = self._client()
offline = llm.provider == "offline" or not llm._has_credentials()
if not offline:
schema = {str(p.get("name")): str(p.get("type", "text")) for p in properties if isinstance(p, dict)}
prompt = (
"À partir du titre et du contenu du document, propose une valeur pour "
"chaque propriété. Réponds STRICTEMENT par un objet JSON "
"{\"nom_propriété\": valeur} sans texte autour.\n"
f"Propriétés attendues : {json.dumps(schema, ensure_ascii=False)}\n"
f"Titre : {title or '(sans titre)'}"
)
try:
text, _, _ = await self._complete(prompt, context=context)
parsed = self._parse_json_object(text)
if parsed:
return self._coerce_suggestions(parsed, properties)
except Exception as exc: # noqa: BLE001
logger.warning("AI properties failed: %s", exc)
# fall through to deterministic defaults
return self._offline_suggestions(title, context, properties)
@staticmethod
def _parse_json_object(text: str) -> dict:
text = (text or "").strip()
if not text:
return {}
m = re.search(r"\{.*\}", text, flags=re.DOTALL)
if not m:
return {}
try:
data = json.loads(m.group(0))
except json.JSONDecodeError:
return {}
return data if isinstance(data, dict) else {}
def _coerce_suggestions(self, parsed: dict, properties: list) -> dict:
out: dict = {}
by_name = {str(p.get("name", "")).strip().lower(): p for p in properties if isinstance(p, dict)}
for key, value in parsed.items():
prop = by_name.get(str(key).strip().lower())
if not prop:
continue
out[str(prop.get("name"))] = self._coerce_value(value, prop.get("type", "text"))
return out
@staticmethod
def _coerce_value(value, prop_type: str):
ptype = (prop_type or "text").lower()
if ptype == "checkbox":
if isinstance(value, bool):
return value
return str(value).strip().lower() in ("1", "true", "yes", "oui", "vrai", "x")
if ptype == "number":
try:
num = float(value)
return int(num) if num.is_integer() else num
except (TypeError, ValueError):
return value
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False)
return value
@staticmethod
def _offline_suggestions(title: str, context: str, properties: list) -> dict:
out: dict = {}
summary_text = ""
if context:
summary_text = re.sub(r"\s+", " ", context).strip()
first = re.split(r"(?<=[.!?])\s+", summary_text)
summary_text = next((s for s in first if len(s) > 40), summary_text)[:180]
for prop in properties:
if not isinstance(prop, dict):
continue
name = str(prop.get("name", "")).strip()
if not name:
continue
low = name.lower()
ptype = (prop.get("type") or "text").lower()
matched = False
for keys, _ptype, default in _OFFLINE_PROPERTY_DEFAULTS:
if any(k in low for k in keys):
if "summary" in keys or "résumé" in keys or "resume" in keys or "description" in keys or "notes" in keys:
out[name] = summary_text or (title or "")
else:
out[name] = default
matched = True
break
if not matched and ptype in ("text", "title"):
if "name" in low or "titre" in low or "title" in low:
out[name] = title or ""
return out
async def run_action(action: str, **kwargs) -> dict:
"""Module-level convenience wrapper (used by tests and simple callers)."""
return await AIWritingService().run(action, **kwargs)
+400
View File
@@ -0,0 +1,400 @@
"""FlowDeck — Automations engine (v5.1.0).
Implements the "if-this-then-that" rule engine: automations match an event (or a
cron schedule, or a clickable button), optionally guard on a condition, then run
a list of actions.
Condition clauses (``condition_json``), all combined with AND:
{"property": "Status", "op": "eq", "value": "Done"}
{"property": "Priority", "op": "not_contains", "value": "Low"}
{"property": "Assignee", "op": "is_empty"}
{"property": "Estimate", "op": "changed"} (only event triggers)
Flags:
op in {eq, neq, contains, not_contains, is_empty, is_not_empty, changed}
Actions (``actions_json``), executed sequentially:
{"type": "webhook", "url": "...", "secret": "..."}
{"type": "set_property", "property": "Status", "value": "Done"}
{"type": "create_page", "collection_id": 3, "title": "...", "properties": {...}}
{"type": "notify", "message": "Automation fired"}
"""
from __future__ import annotations
import asyncio
import json
import logging
from datetime import datetime, timedelta
import httpx
from app.db import get_conn
from app.services import notifications
logger = logging.getLogger(__name__)
COND_OPS = {"eq", "neq", "contains", "not_contains", "is_empty", "is_not_empty", "changed"}
# Events that fire on collection pages (payload carries a `properties` dict).
PAGE_PROP_EVENTS = {"page.created", "page.updated", "page.deleted"}
def _prop_value(props: dict, key) -> tuple[bool, object]:
"""Resolve a property value by id or name. Returns ``(found, value)``.
``props`` may be keyed by property id (FlowDeckDB UI) or name (agent / API).
"""
if props is None:
return False, None
if key is None:
return True, None
skey = str(key)
if skey in props:
return True, props[skey]
if isinstance(key, int) and str(key) in props:
return True, props[str(key)]
return False, None
def match_condition_props(props: dict, before_props: dict | None, clause: dict) -> bool:
"""Evaluate a single condition clause against page property values."""
op = clause.get("op", "eq")
if op not in COND_OPS:
return False
if op == "changed":
key = clause.get("property")
if before_props is None:
return False
found_before, before_val = _prop_value(before_props, key)
found_after, after_val = _prop_value(props, key)
return found_before and found_after and before_val != after_val
found, val = _prop_value(props, clause.get("property"))
if op == "is_empty":
if not found:
return True
return val is None or str(val).strip() == ""
if op == "is_not_empty":
return found and val is not None and str(val).strip() != ""
if not found:
return False
want = clause.get("value")
if op == "eq":
return _norm(val) == _norm(want)
if op == "neq":
return _norm(val) != _norm(want)
if op == "contains":
return _norm(want) in _norm(val) if _norm(val) else False
if op == "not_contains":
return _norm(want) not in _norm(val) if _norm(val) else True
return False
def _norm(v) -> str:
if v is None:
return ""
if isinstance(v, (list, dict)):
return json.dumps(v)
return str(v)
def evaluate_conditions(condition_json, props: dict | None, before_props: dict | None = None) -> bool:
"""Evaluate the stored condition list (AND of all clauses). Empty list → True."""
try:
clauses = json.loads(condition_json) if isinstance(condition_json, str) else (condition_json or [])
except (TypeError, json.JSONDecodeError):
clauses = []
for clause in clauses or []:
if not match_condition_props(props, before_props, clause):
return False
return True
def get_page_context(page_id: int, collection_id: int) -> dict:
"""Load a collection page's property values for condition evaluation."""
with get_conn() as conn:
row = conn.execute(
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE id=?",
(page_id,),
).fetchone()
if not row:
return {"page_id": page_id, "collection_id": collection_id,
"title": "", "properties": {}, "icon": "file"}
try:
props = json.loads(row["property_values_json"])
except (TypeError, json.JSONDecodeError):
props = {}
return {"page_id": page_id, "collection_id": collection_id,
"title": row["title"], "icon": row["icon"], "properties": props}
def _save_run(automation_id: int, trigger_source: str, status: str, detail: str,
collection_id: int | None = None, page_id: int | None = None) -> None:
with get_conn() as conn:
conn.execute(
"""INSERT INTO automation_runs
(automation_id, trigger_source, status, detail, collection_id, page_id)
VALUES (?,?,?,?,?,?)""",
(automation_id, trigger_source, status, detail, collection_id, page_id),
)
conn.execute(
"UPDATE automations SET run_count=run_count+1, last_run_at=CURRENT_TIMESTAMP WHERE id=?",
(automation_id,),
)
conn.commit()
def _maybe_convert_prediction(value, props: dict) -> tuple[bool, object]:
"""Allow action values to interpolate other page properties: e.g. [[Assignee]] or {{title}}."""
if not isinstance(value, str):
return True, value
replaced = value
for key in props:
if "[[" + str(key) + "]]" in replaced:
replaced = replaced.replace("[[" + str(key) + "]]", str(props[key]))
if "{{title}}" in replaced:
replaced = replaced.replace("{{title}}", str(props.get("title", "")))
if "{{id}}" in replaced:
replaced = replaced.replace("{{id}}", str(props.get("page_id", "")))
return True, replaced
async def _run_action(action: dict, context: dict, trigger_source: str) -> str:
"""Execute a single action. Returns a human summary. Raises on failure."""
atype = action.get("type")
if atype == "webhook":
url = action.get("url", "").strip()
if not url:
raise ValueError("webhook action requires a url")
secret = action.get("secret", "")
headers = {"Content-Type": "application/json", "X-FlowDeck-Event": context.get("event", "")}
if secret:
headers["X-FlowDeck-Secret"] = secret
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(url, json=context, headers=headers)
if resp.status_code >= 400:
raise RuntimeError(f"webhook returned HTTP {resp.status_code}")
return f"webhook → {url} ({resp.status_code})"
if atype == "set_property":
prop = action.get("property")
value = action.get("value")
page_id = context.get("page_id")
if not prop or not page_id:
raise ValueError("set_property requires property + page context")
_, resolved = _maybe_convert_prediction(value, context)
with get_conn() as conn:
row = conn.execute(
"SELECT property_values_json, collection_id FROM collection_pages WHERE id=?",
(page_id,),
).fetchone()
if not row:
raise ValueError(f"page {page_id} not found")
try:
props = json.loads(row["property_values_json"])
except (TypeError, json.JSONDecodeError):
props = {}
props[prop] = resolved
from app.routers.collections import _validate_page_properties
_validate_page_properties(conn, row["collection_id"], props, exclude_page_id=page_id)
conn.execute(
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(props), page_id),
)
conn.commit()
return f"set property {prop} = {resolved}"
if atype == "create_page":
coll_id = action.get("collection_id")
title = action.get("title", "Automation page")
properties = action.get("properties", {}) or {}
if not coll_id:
raise ValueError("create_page requires a collection_id")
_, resolved_title = _maybe_convert_prediction(title, context)
resolved_props = {}
for k, v in properties.items():
_, pv = _maybe_convert_prediction(v, context)
resolved_props[k] = pv
with get_conn() as conn:
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
(coll_id,),
).fetchone()[0]
cur = conn.execute(
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
(coll_id, resolved_title, max_pos, json.dumps(resolved_props)),
)
conn.commit()
return f"created page {cur.lastrowid} in collection {coll_id}"
if atype == "notify":
message = action.get("message", "Automation fired")
user_id = action.get("user_id")
if not user_id:
user_id = context.get("created_by") or 1
_, resolved = _maybe_convert_prediction(message, context)
notifications.create_notification(
user_id=user_id,
actor_id=context.get("created_by") or 1,
ntype="page",
title=context.get("automation_name", "Automation"),
message=resolved,
resource_type="collection_page" if context.get("page_id") else "page",
resource_id=context.get("page_id") or context.get("collection_id") or 0,
url=context.get("url", ""),
)
return f"notified user {user_id}"
raise ValueError(f"unknown action type: {atype!r}")
async def run_automation(automation_id: int, trigger_source: str, context: dict) -> dict:
"""Load, condition-check and execute an automation. Records a run row."""
with get_conn() as conn:
row = conn.execute("SELECT * FROM automations WHERE id=?", (automation_id,)).fetchone()
if not row:
return {"status": "skipped", "detail": "automation not found"}
auto = dict(row)
if not auto["enabled"]:
return {"status": "skipped", "detail": "automation disabled"}
props = context.get("properties")
before = context.get("before_properties")
if not evaluate_conditions(auto["condition_json"], props, before):
_save_run(automation_id, trigger_source, "skipped", "condition not met",
context.get("collection_id"), context.get("page_id"))
return {"status": "skipped", "detail": "condition not met"}
try:
actions = json.loads(auto["actions_json"]) if auto["actions_json"] else []
except (TypeError, json.JSONDecodeError):
actions = []
ctx = dict(context)
ctx["automation_name"] = auto["name"]
ctx["created_by"] = auto["created_by"] or ctx.get("created_by")
results = []
try:
for action in actions or []:
results.append(await _run_action(action, ctx, trigger_source))
detail = "; ".join(results)
_save_run(automation_id, trigger_source, "fired", detail,
ctx.get("collection_id"), ctx.get("page_id"))
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)
_save_run(automation_id, trigger_source, "error", str(exc),
ctx.get("collection_id"), ctx.get("page_id"))
return {"status": "error", "detail": str(exc)}
async def fire_event(event: str, payload: dict):
"""Dispatch an event to outbound webhooks and matching automations."""
# Outbound webhooks (v2.1.0 machinery, previously called nowhere).
try:
from app.services.webhook_outbound import fire_event as fire_webhooks
await fire_webhooks(event, payload)
except Exception: # noqa: BLE001
logger.debug("Webhook dispatch failed for %s", event)
with get_conn() as conn:
rows = conn.execute(
"""SELECT * FROM automations
WHERE trigger_type='event' AND event=? AND enabled=1""",
(event,),
).fetchall()
for row in rows:
auto = dict(row)
if auto["collection_id"] and payload.get("collection_id") != auto["collection_id"]:
continue
context = dict(payload)
context["event"] = event
await run_automation(auto["id"], "event", context)
# ═══════════ Cron scheduling (trigger_type='cron') ═══════════
_SUPPORTED_CRON = {
"*/1": 1, "*/5": 5, "*/10": 10, "*/15": 15, "*/30": 30,
"*/2": 2, "*/3": 3, "*/6": 6, "*/12": 12, "*/20": 20, "*/45": 45,
}
def cron_due(expression: str, last_run_at: str | None, now: datetime | None = None) -> bool:
"""True when a ``*/N`-style or fixed-minute cron expression is due.
Supports ``*/15 * * * *`` (every N minutes) and ``*/N`` alone, plus exact
``H * * * *`` at minute H of every hour. ``@hourly`` / ``@daily`` also work.
"""
expr = (expression or "").strip().lower()
if not expr:
return False
now = now or datetime.utcnow()
minute = now.minute
fields = expr.split()
if expr in ("@hourly", "hourly"):
if last_run_at is None:
return True
try:
last = datetime.fromisoformat(str(last_run_at).replace("Z", ""))
except Exception:
return True
return (now - last.replace(tzinfo=None)) >= timedelta(minutes=60)
if expr in ("@daily", "daily"):
if last_run_at is None:
return True
try:
last = datetime.fromisoformat(str(last_run_at).replace("Z", ""))
except Exception:
return True
return (now - last.replace(tzinfo=None)) >= timedelta(hours=24)
# "*/N * * * *" → every N minutes
if fields and fields[0].startswith("*/"):
val = fields[0][2:]
if not val.isdigit() or int(val) not in _SUPPORTED_CRON.values():
return False
n = int(val)
if last_run_at is None:
return True
try:
last = datetime.fromisoformat(str(last_run_at).replace("Z", ""))
except Exception:
return True
return (now - last.replace(tzinfo=None)) >= timedelta(minutes=n)
# "H * * * *" → at a fixed minute of each hour
if len(fields) == 5 and fields[0].isdigit():
return int(fields[0]) == minute
return False
async def automation_scheduler():
"""Background loop: fire due cron automations (checked every 60s)."""
while True:
try:
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM automations WHERE trigger_type='cron' AND enabled=1"
).fetchall()
for row in rows:
auto = dict(row)
try:
if cron_due(auto["cron_expression"], auto["last_run_at"]):
context = {
"collection_id": auto["collection_id"] or 0,
"page_id": None,
"properties": None,
}
await run_automation(auto["id"], "cron", context)
except Exception: # noqa: BLE001
logger.warning("Cron automation %s errored", auto["id"])
except Exception: # noqa: BLE001
logger.warning("automation_scheduler iteration failed")
await asyncio.sleep(60)
+111
View File
@@ -0,0 +1,111 @@
"""FlowDeck — v5.2.0 Automatic backups (daily SQLite snapshot)."""
from __future__ import annotations
import logging
import shutil
import time
from datetime import datetime
from pathlib import Path
from app.config import settings
logger = logging.getLogger(__name__)
def _backup_dir() -> Path:
d = Path(settings.backup_dir)
d.mkdir(parents=True, exist_ok=True)
return d
def _db_path() -> Path:
return settings.db_path
def backup_db(now: datetime | None = None) -> str | None:
"""Snapshot the SQLite database into ``backup_dir`` (WAL-safe).
Returns the backup filename, or None when backup is disabled or the
database file does not exist.
"""
if not settings.backup_enabled:
return None
db_path = _db_path()
if str(db_path) == ":memory:" or not Path(db_path).is_file():
return None
now = now or datetime.now()
# Checkpoint the WAL so the backup is consistent.
try:
import sqlite3
with sqlite3.connect(str(db_path)) as conn:
conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except Exception:
pass
dest_dir = _backup_dir()
filename = f"flowdeck-{now:%Y%m%d-%H%M%S}.db"
dest = dest_dir / filename
shutil.copy2(db_path, dest)
# Prune old backups, keeping ``backup_keep`` most recent files.
prune_old_backups()
logger.info("Backup created: %s", dest)
return filename
def list_backups() -> list[dict]:
"""List existing backup files (name, size bytes, mtime)."""
files = []
for p in _backup_dir().glob("flowdeck-*.db"):
stat = p.stat()
files.append({
"filename": p.name,
"size": stat.st_size,
"modified_at": datetime.fromtimestamp(stat.st_mtime).isoformat(),
})
files.sort(key=lambda f: f["filename"], reverse=True)
return files
def prune_old_backups(keep: int | None = None) -> int:
"""Delete the oldest backup files beyond ``keep``. Returns count removed."""
keep = keep if keep is not None else settings.backup_keep
files = sorted(_backup_dir().glob("flowdeck-*.db"), reverse=True)
removed = 0
for p in files[keep:]:
try:
p.unlink()
removed += 1
except OSError:
logger.warning("Could not prune backup %s", p)
return removed
def last_backup_age_hours() -> float | None:
"""Hours since the most recent backup (None if none exists)."""
files = list(_backup_dir().glob("flowdeck-*.db"))
if not files:
return None
newest = max(files, key=lambda p: p.stat().st_mtime)
age = time.time() - newest.stat().st_mtime
return age / 3600
def backup_due() -> bool:
"""True when a backup should run now (interval elapsed since last one)."""
age = last_backup_age_hours()
if age is None:
return True
return age >= settings.backup_interval_hours
async def backup_scheduler():
"""Background loop: run a backup once per interval (default daily)."""
while True:
try:
if backup_due():
backup_db()
except Exception as exc: # never let the loop die
logger.warning("backup_scheduler error: %s", exc)
await __import__("asyncio").sleep(3600) # re-check hourly
+4 -5
View File
@@ -6,7 +6,6 @@ without breaking the existing board routes.
from __future__ import annotations
import json
from typing import Optional
from app.db import get_conn
@@ -49,7 +48,7 @@ class GiteaBoardCompat:
}
@staticmethod
def from_card(card_row, gitea_issue: Optional[dict] = None) -> dict:
def from_card(card_row, gitea_issue: dict | None = None) -> dict:
"""Convertit une card legacy en pseudo collection_page."""
title = gitea_issue.get("title", f"Card #{card_row['id']}") if gitea_issue else f"Card #{card_row['id']}"
priority = card_row.get("priority", "Medium")
@@ -83,7 +82,7 @@ class GiteaBoardCompat:
return [GiteaBoardCompat.from_board(dict(r)) for r in rows]
@staticmethod
def get_board_as_collection(owner: str, repo: str) -> Optional[dict]:
def get_board_as_collection(owner: str, repo: str) -> dict | None:
"""Récupère un board spécifique comme collection."""
with get_conn() as conn:
row = conn.execute(
@@ -95,7 +94,7 @@ class GiteaBoardCompat:
return GiteaBoardCompat.from_board(dict(row))
@staticmethod
def get_board_cards(owner: str, repo: str, gitea_issues: Optional[list[dict]] = None) -> list[dict]:
def get_board_cards(owner: str, repo: str, gitea_issues: list[dict] | None = None) -> list[dict]:
"""Récupère les cartes d'un board comme collection_pages."""
with get_conn() as conn:
board = conn.execute(
@@ -120,7 +119,7 @@ class GiteaBoardCompat:
]
@staticmethod
def sync_to_collection(owner: str, repo: str, gitea_issues: list[dict]) -> Optional[int]:
def sync_to_collection(owner: str, repo: str, gitea_issues: list[dict]) -> int | None:
"""Sync un board Gitea vers une vraie collection.
Crée ou met à jour une collection liée à Gitea et importe les pages.
+230
View File
@@ -0,0 +1,230 @@
"""FlowDeck — Agent context builder (v4.14.0).
Collects a compact, permission-filtered snapshot of the active workspace so the
LLM can reason about real entities (workspaces, documents, collections, pages,
Gitea issues) without touching the database directly.
"""
from __future__ import annotations
import json
from app.db import get_conn
class ContextBuilder:
"""Builds the textual context that accompanies each agent run."""
def __init__(self, user_id: int, workspace_id: int | None = None):
self.user_id = user_id
self.workspace_id = workspace_id
def build(self, *, mentions: list[str] | None = None,
files: list[dict] | None = None,
include_collections: bool = True) -> str:
"""Return a compact Markdown-ish snapshot of the workspace context."""
sections: list[str] = []
if include_collections:
sections.append(self._collections_context())
sections.append(self._pages_context())
sections.append(self._documents_context())
sections.append(self._workspaces_context())
if mentions:
sections.append(self._mentions_context(mentions))
if files:
sections.append(self._files_context(files))
return "\n\n".join(s for s in sections if s)
# ── Internals ──
def _collections_context(self) -> str:
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, icon, is_locked, schema_json FROM collections ORDER BY name"
).fetchall()
if not rows:
return "## Collections\n(no collections yet)"
lines = ["## Collections"]
lines.append("Collection IDs: " + ", ".join(str(r["id"]) for r in rows))
for r in rows:
props = json.loads(r["schema_json"]) if r["schema_json"] else []
schema = ", ".join(p if isinstance(p, str) else p.get("name", "?") for p in props) or "none"
lock = " [LOCKED]" if r["is_locked"] else ""
lines.append(f"- #{r['id']} {r['icon']} **{r['name']}** (schema: {schema}){lock}")
return "\n".join(lines)
def _pages_context(self, limit: int = 40) -> str:
with get_conn() as conn:
rows = conn.execute(
"SELECT id, collection_id, title, property_values_json "
"FROM collection_pages ORDER BY updated_at DESC LIMIT ?",
(limit,),
).fetchall()
if not rows:
return "## Pages\n(no pages yet)"
lines = ["## Recent pages"]
for r in rows:
props = json.loads(r["property_values_json"]) if r["property_values_json"] else {}
summary = ", ".join(str(v) for v in props.values() if v) if props else ""
lines.append(f"- page #{r['id']} in collection #{r['collection_id']}: **{r['title']}**{(' — ' + summary) if summary else ''}")
return "\n".join(lines)
def _documents_context(self, limit: int = 30) -> str:
"""Recent editor documents (`pages`), usable with create/read/update tools."""
with get_conn() as conn:
ws_names = dict(
conn.execute("SELECT id, name FROM workspaces").fetchall()
)
rows = conn.execute(
"SELECT id, title, workspace_id, content_format, parent_id "
"FROM pages WHERE deleted_at IS NULL ORDER BY updated_at DESC LIMIT ?",
(limit,),
).fetchall()
if not rows:
return "## Documents\n(aucun document)"
lines = ["## Documents (pages éditeur — outils: read_document, write_blocks, create_document)"]
for r in rows:
ws_name = ws_names.get(r["workspace_id"], str(r["workspace_id"]) if r["workspace_id"] else "racine")
lines.append(f"- document #{r['id']} **{r['title'] or 'Sans titre'}** (espace: {ws_name})")
return "\n".join(lines)
def _workspaces_context(self) -> str:
"""Workspaces accessible to the current user (with counts)."""
base_sql = (
"SELECT w.id, w.name, {role} AS role, "
"(SELECT COUNT(*) FROM pages p WHERE p.workspace_id=w.id AND p.deleted_at IS NULL) AS document_count "
"FROM workspaces w {join} {where} ORDER BY w.name"
)
with get_conn() as conn:
if self.user_id is None:
rows = conn.execute(
base_sql.format(role="'owner'", join="", where=""), []
).fetchall()
else:
rows = conn.execute(
base_sql.format(
role="COALESCE(wm.role, CASE WHEN w.owner_id=? THEN 'owner' ELSE 'viewer' END)",
join="LEFT JOIN workspace_members wm ON wm.workspace_id=w.id AND wm.user_id=?",
where="WHERE w.owner_id=? OR wm.user_id IS NOT NULL",
),
(self.user_id, self.user_id, self.user_id),
).fetchall()
if not rows:
return "## Espaces de travail\n(aucun espace)"
lines = ["## Espaces de travail (outil: read_workspaces)"]
for r in rows:
lines.append(f"- espace #{r['id']} **{r['name']}** ({r['role']}, {r['document_count']} document(s))")
return "\n".join(lines)
def _mentions_context(self, mentions: list[str]) -> str:
"""Resolve @document:x / @collection:x / @page:y / @repo:o/r mentions.
Mentions bring the *actual content* of the referenced object into the
context so the LLM can summarise / rewrite / analyse it directly without
needing a read tool round-trip (and so the offline mock stays useful).
"""
lines = ["## Mentioned context"]
for m in mentions:
if m.startswith("document:"):
pid = m.split(":", 1)[1]
lines.append(self._single_document(pid))
elif m.startswith("collection:"):
cid = m.split(":", 1)[1]
lines.append(self._single_collection(cid))
elif m.startswith("page:"):
pid = m.split(":", 1)[1]
lines.append(self._single_page(pid))
elif m.startswith("repo:"):
lines.append(f"- @repo: {m.split(':', 1)[1]} (Gitea issues available via read_gitea_issues)")
elif m == "ws":
lines.append("- @ws: full workspace context included above")
return "\n".join(lines)
@staticmethod
def _blocks_to_text(content: str, limit: int = 9000) -> str:
"""Flatten a ``blocks`` document JSON into plain readable text.
Collects the textual payload of each block (content, title, caption,
children, meeting notes/summary) — enough for the LLM to reason about a
mentioned editor page without the full block schema.
"""
try:
blocks = json.loads(content or "[]")
except (json.JSONDecodeError, TypeError):
return ""
if not isinstance(blocks, list):
return ""
_TEXT_KEYS = ("content", "title", "caption", "plain_text", "notes", "summary")
out: list[str] = []
total = 0
def walk(node):
nonlocal total
if total >= limit:
return
if isinstance(node, dict):
for k in _TEXT_KEYS:
v = node.get(k)
if isinstance(v, str) and v.strip():
line = v.replace("\r\n", "\n").strip()
out.append(line)
total += len(line) + 1
if total >= limit:
return
for v in node.values():
walk(v)
elif isinstance(node, list):
for item in node:
walk(item)
walk(blocks)
return "\n".join(out)[:limit]
def _single_document(self, pid: str) -> str:
"""Full editor-document mention: title + workspace + real content."""
with get_conn() as conn:
row = conn.execute(
"SELECT p.*, w.name AS ws_name FROM pages p "
"LEFT JOIN workspaces w ON w.id=p.workspace_id "
"WHERE p.id=? AND p.deleted_at IS NULL",
(pid,),
).fetchone()
if not row:
return f"- document #{pid}: not found"
title = row["title"] or "Sans titre"
ws = row["ws_name"] or ""
loc = f" (espace: {ws})" if ws else ""
fmt = row["content_format"] or "blocks"
raw = row["content"] or ""
if fmt == "markdown":
body = raw.strip()
elif fmt == "file":
body = ""
else:
body = self._blocks_to_text(raw)
head = f"- document #{row['id']} **{title}**{loc}"
if body:
return f"{head}:\n{body[:9000]}"
return head
def _single_collection(self, cid: str) -> str:
with get_conn() as conn:
row = conn.execute("SELECT id, name, icon, schema_json FROM collections WHERE id=?", (cid,)).fetchone()
if not row:
return f"- collection #{cid}: not found"
return f"- collection #{row['id']} {row['icon']} **{row['name']}**"
def _single_page(self, pid: str) -> str:
with get_conn() as conn:
row = conn.execute("SELECT id, title, property_values_json FROM collection_pages WHERE id=?", (pid,)).fetchone()
if not row:
return f"- page #{pid}: not found"
props = json.loads(row["property_values_json"]) if row["property_values_json"] else {}
return f"- page #{row['id']} **{row['title']}** props={json.dumps(props, ensure_ascii=False)}"
def _files_context(self, files: list[dict]) -> str:
return "## Attached files\n" + "\n".join(
f"- {f.get('name', 'file')} ({f.get('size', '?')} bytes)" for f in files
)
+201
View File
@@ -0,0 +1,201 @@
"""FlowDeck — Database templates (v5.3.0).
Defines the built-in (seeded) database templates, materializes a template's
schema into real ``collection_properties`` rows, and creates a collection from
a template. Templates are stored in ``database_templates`` (name, icon,
description, schema_json).
"""
from __future__ import annotations
import json
# ── Built-in templates ──
# Each schema entry: {"name", "type", "options"?: [{name, color}]}.
SEED_TEMPLATES: list[dict] = [
{
"name": "Project tracker",
"icon": "🚀",
"description": "Suivi de projets avec statut, priorité et échéances.",
"schema": [
{"name": "Title", "type": "title"},
{"name": "Status", "type": "status", "options": [
{"name": "Not started", "color": "gray"},
{"name": "In progress", "color": "blue"},
{"name": "Done", "color": "green"},
]},
{"name": "Priority", "type": "select", "options": [
{"name": "Low", "color": "gray"},
{"name": "Medium", "color": "yellow"},
{"name": "High", "color": "orange"},
{"name": "Urgent", "color": "red"},
]},
{"name": "Due date", "type": "date"},
{"name": "Assignee", "type": "person"},
{"name": "Tags", "type": "multi_select"},
],
},
{
"name": "CRM / Contacts",
"icon": "👥",
"description": "Gestion des contacts et prospects.",
"schema": [
{"name": "Name", "type": "title"},
{"name": "Email", "type": "email"},
{"name": "Phone", "type": "phone"},
{"name": "Company", "type": "text"},
{"name": "Stage", "type": "status", "options": [
{"name": "Lead", "color": "gray"},
{"name": "Prospect", "color": "blue"},
{"name": "Customer", "color": "green"},
]},
{"name": "Tags", "type": "multi_select"},
],
},
{
"name": "Task list",
"icon": "✅",
"description": "Liste de tâches simple avec assignation et échéance.",
"schema": [
{"name": "Task", "type": "title"},
{"name": "Status", "type": "status", "options": [
{"name": "To do", "color": "gray"},
{"name": "In progress", "color": "blue"},
{"name": "Done", "color": "green"},
]},
{"name": "Priority", "type": "select", "options": [
{"name": "Low", "color": "gray"},
{"name": "Medium", "color": "yellow"},
{"name": "High", "color": "red"},
]},
{"name": "Due date", "type": "date"},
{"name": "Assignee", "type": "person"},
],
},
{
"name": "Content calendar",
"icon": "📅",
"description": "Planification de contenu et de publications.",
"schema": [
{"name": "Title", "type": "title"},
{"name": "Type", "type": "select", "options": [
{"name": "Article", "color": "blue"},
{"name": "Video", "color": "orange"},
{"name": "Social", "color": "green"},
{"name": "Newsletter", "color": "purple"},
]},
{"name": "Status", "type": "status", "options": [
{"name": "Draft", "color": "gray"},
{"name": "In review", "color": "yellow"},
{"name": "Published", "color": "green"},
]},
{"name": "Publish date", "type": "date"},
{"name": "Category", "type": "select"},
],
},
{
"name": "Meeting notes",
"icon": "🗒️",
"description": "Notes de réunion avec participants et décisions.",
"schema": [
{"name": "Title", "type": "title"},
{"name": "Date", "type": "date"},
{"name": "Attendees", "type": "person"},
{"name": "Status", "type": "status", "options": [
{"name": "Scheduled", "color": "gray"},
{"name": "Done", "color": "green"},
]},
{"name": "Action items", "type": "multi_select"},
],
},
{
"name": "Reading list",
"icon": "📚",
"description": "Articles, livres et ressources à lire.",
"schema": [
{"name": "Title", "type": "title"},
{"name": "URL", "type": "url"},
{"name": "Status", "type": "status", "options": [
{"name": "To read", "color": "gray"},
{"name": "Reading", "color": "blue"},
{"name": "Done", "color": "green"},
]},
{"name": "Notes", "type": "text"},
],
},
]
def materialize_properties(conn, collection_id: int, schema: list) -> None:
"""Insert ``collection_properties`` rows from a template ``schema``.
The ``title`` property is represented by ``collection_pages.title`` and is
not created as a column. Rows are inserted in schema order.
"""
position = 0
for prop in schema:
name = (prop.get("name") or "").strip()
if not name:
continue
prop_type = prop.get("type", "text")
if prop_type == "title":
continue
options = prop.get("options") or []
# idempotency guard — skip if a same-named property already exists
exists = conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? AND name=?",
(collection_id, name),
).fetchone()
if exists:
continue
conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, options_json, number_format, position)
VALUES (?, ?, ?, ?, 'number', ?)""",
(collection_id, name, prop_type, json.dumps(options), position),
)
position += 1
def create_from_template(
conn,
name: str,
template: dict,
*,
parent_page_id: int | None = None,
workspace_id: int | None = None,
) -> int:
"""Create a collection from a template (with properties + default view).
``template`` may be a DB row (sqlite Row) or a dict; it must expose
``icon``, ``description`` and ``schema_json`` (JSON-encoded schema).
"""
icon = template.get("icon") if isinstance(template, dict) else template["icon"]
description = template.get("description") if isinstance(template, dict) else template["description"]
schema_json = template.get("schema_json") if isinstance(template, dict) else template["schema_json"]
try:
schema = json.loads(schema_json)
except (json.JSONDecodeError, TypeError):
schema = []
cur = conn.execute(
"""INSERT INTO collections
(name, description, icon, schema_json, is_inline, parent_page_id, workspace_id)
VALUES (?, ?, ?, ?, 1, ?, ?)""",
(name, description or "", icon or "📋", json.dumps(schema),
parent_page_id, workspace_id),
)
collection_id = cur.lastrowid
materialize_properties(conn, collection_id, schema)
conn.execute(
"""INSERT INTO collection_views
(collection_id, name, view_type, config_json)
VALUES (?, ?, ?, ?)""",
(collection_id, "Default View", "table", json.dumps({
"visible_properties": ["Title"],
"sorts": [],
"filters": [],
})),
)
return collection_id
+278
View File
@@ -0,0 +1,278 @@
"""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, 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.
"""
from __future__ import annotations
import re
from urllib.parse import parse_qs, quote, urlparse
def _q(params, key):
vals = params.get(key)
return vals[0] if vals else ""
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")
frag = f"?start={start}" if start else ""
return f"https://www.youtube.com/embed/{vid}{frag}"
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, 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(1)}"
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
return "https://www.figma.com/embed?embed_host=flowdeck&url=" + quote(clean, safe="")
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 "https://maps.google.com/maps?q=" + quote(url, safe="") + "&output=embed"
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, 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, 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, ctx: dict) -> str | None:
if "soundcloud.com" not in url:
return None
return "https://w.soundcloud.com/player/?url=" + quote(url, safe="") + "&color=%2300aaff"
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 or m.group(1) in ("videos", "directory"):
return None
return f"https://player.twitch.tv/?channel={m.group(1)}&parent={parent}"
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(?:es)?/(\d+)", url)
if not m:
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, ctx: dict) -> str | None:
if "pinterest" not in url:
return None
return f"https://pinterest.com/pin/embed?url={quote(url, safe='')}"
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),
("miro.com", _embed_miro),
("open.spotify.com", _embed_spotify),
("spotify.com", _embed_spotify),
("soundcloud.com", _embed_soundcloud),
("twitch.tv", _embed_twitch),
("twitter.com", _embed_twitter),
("x.com", _embed_twitter),
("pinterest.", _embed_pinterest),
("office.com", _embed_office),
("officeapps.live.com", _embed_office),
("sharepoint.com", _embed_office),
("1drv.ms", _embed_office),
)
_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."""
raw, u, params = _parse(url)
if raw is None:
return None
ctx = {"parent": parent}
netloc = (u.netloc or "").lower()
for needle, handler in _HANDLERS:
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)
_PDF_EXT = re.compile(r"\.pdf$", re.I)
_VIDEO_EXT = re.compile(r"\.(mp4|webm|ogg|ogv|mov|m4v)$", re.I)
_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'
| 'audio'. Returns None when the URL should open in a new tab."""
raw, u, _params = _parse(url)
if raw is None:
return None
path = u.path or ""
if _IMAGE_EXT.search(path):
return "image"
if _PDF_EXT.search(path):
return "pdf"
if _VIDEO_EXT.search(path):
return "video"
if _AUDIO_EXT.search(path):
return "audio"
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:
"""A responsive, borderless iframe for a provider embed URL."""
return (
f'<iframe src="{src}" loading="lazy" '
f'style="width:100%;height:{height}px;border:none;border-radius:8px;background:#000;" '
f'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; '
f'picture-in-picture" allowfullscreen></iframe>'
)
+861
View File
@@ -0,0 +1,861 @@
"""FlowDeck — Export service (v4.7.2).
Four types of export, all generated server-side:
- Markdown (``page_to_markdown``): title + blocks + récursif sous-pages
- HTML (``page_to_standalone_html``): document autonome (styles inline)
- PDF (``page_to_pdf_bytes``): convertit un HTML print-friendly
- Site (``build_static_site``): site statique multi-pages (zip)
Supports the three ways a page's content can be stored:
- content_format == "blocks" -> JSON list of blocks in ``content``
- content_format == "markdown" -> raw Markdown in ``content``
- content_format == "file" -> ``content`` is JSON metadata; the real text
lives in an uploaded file on disk (uploads/workspace_*). We read it back so
an exported document carries its actual content, not just its title.
"""
from __future__ import annotations
import io
import json
import os
import re
import zipfile
from pathlib import Path
from urllib.parse import quote
from app.db import get_conn
# ═══════════════ Helpers ═══════════════
def _text(v: str, *, escape: bool = True) -> str:
"""Normalize a block's content string."""
s = (v or "").replace("\r\n", "\n").replace("\r", "\n")
if escape:
s = (s.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;"))
return s
def _sanitize_id(block_id) -> str:
if not block_id:
return ""
return "".join(ch for ch in str(block_id) if ch.isalnum())
def _page_title(page: dict) -> str:
return (page.get("title") or "Untitled").strip() or "Untitled"
def _blocks_of(page: dict) -> list:
content = page.get("content") or ""
fmt = page.get("content_format") or "blocks"
if fmt != "blocks" or not content:
return []
try:
data = json.loads(content)
except (json.JSONDecodeError, TypeError):
return []
return data if isinstance(data, list) else []
def _block_text(b: dict) -> str:
return _text(b.get("content"), escape=False)
# ── Source resolution: read real textual content for ANY page type ──
# Extensions whose content is plain text / code / markdown (textual exportable).
_TEXTUAL_EXTS = {
"md", "markdown", "txt", "log", "text",
"py", "js", "ts", "jsx", "tsx", "html", "htm", "css", "json", "xml",
"yaml", "yml", "toml", "ini", "cfg", "conf", "env", "sh", "bash", "zsh",
"ps1", "bat", "cmd", "rb", "go", "rs", "java", "c", "cpp", "h", "hpp",
"php", "swift", "kt", "scala", "sql", "r", "vue", "svelte", "astro",
"properties", "gitignore", "dockerfile", "makefile",
}
_CODE_LANG = {
"py": "python", "js": "javascript", "ts": "typescript", "jsx": "javascript",
"tsx": "typescript", "html": "html", "htm": "html", "css": "css",
"json": "json", "xml": "xml", "yaml": "yaml", "yml": "yaml",
"toml": "toml", "ini": "ini", "cfg": "ini", "conf": "ini", "env": "ini",
"sh": "bash", "bash": "bash", "zsh": "bash", "ps1": "powershell",
"bat": "batch", "cmd": "batch", "rb": "ruby", "go": "go", "rs": "rust",
"java": "java", "c": "c", "cpp": "cpp", "h": "c", "hpp": "cpp",
"php": "php", "swift": "swift", "kt": "kotlin", "scala": "scala",
"sql": "sql", "r": "r", "vue": "html", "svelte": "html",
"astro": "html", "properties": "ini", "md": "markdown",
"markdown": "markdown", "txt": "plaintext", "log": "plaintext",
"text": "plaintext",
}
_MARKDOWN_MIMES = {"text/markdown", "text/x-markdown", "application/octet-stream"}
def _data_root() -> Path:
"""Directory that contains ``uploads/`` (mirrors dashboard.py /data)."""
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
def _file_meta(page: dict) -> dict:
try:
meta = json.loads(page.get("content") or "{}")
return meta if isinstance(meta, dict) else {}
except (json.JSONDecodeError, TypeError):
return {}
def _file_text(page: dict) -> str | None:
"""Return the textual content of an uploaded ``file`` page, or None.
Only reads plain-text / code / markdown files. Binary (PDF, images…)
returns None and is skipped by exporters (nothing meaningful to include).
"""
if (page.get("content_format") or "") != "file":
return None
meta = _file_meta(page)
rel = (meta.get("file_path") or "").replace("\\", "/").strip()
if not rel or ".." in rel.replace("\\", "/").split("/") or not rel.startswith("uploads/"):
return None
name = (rel.rsplit("/", 1)[-1] or "").lower()
ext = name.rsplit(".", 1)[-1] if "." in name else ""
mime = (meta.get("mime_type") or "").lower()
if not (ext in _TEXTUAL_EXTS or mime.startswith("text/")):
return None
try:
full = (_data_root() / rel).resolve()
root = _data_root().resolve()
if root not in full.parents:
return None
return full.read_text(encoding="utf-8", errors="replace")
except (OSError, ValueError):
return None
def _page_source(page: dict):
"""Return (kind, payload) describing where the page's real content lives.
kind ∈ {"blocks", "md", "code"}:
- "blocks": payload is the block list (block editor pages)
- "md" : payload is raw Markdown text
- "code" : payload is (text, language)
An empty/unsupported page yields ("blocks", []).
"""
fmt = (page.get("content_format") or "blocks")
content = page.get("content") or ""
if fmt == "blocks":
return "blocks", _blocks_of(page)
if fmt == "markdown":
if content.strip():
return "md", content
return "blocks", []
if fmt == "file":
text = _file_text(page)
if text is None:
return "blocks", []
meta = _file_meta(page)
name = (meta.get("file_path") or "").replace("\\", "/").rsplit("/", 1)[-1].lower()
ext = name.rsplit(".", 1)[-1] if "." in name else ""
mime = (meta.get("mime_type") or "").lower()
if ext in ("md", "markdown") or mime in _MARKDOWN_MIMES or mime.startswith("text/markdown"):
return "md", text
lang = _CODE_LANG.get(ext, "plaintext")
return "code", (text, lang)
# Unknown format (e.g. legacy) -> try to dump as raw text
if content.strip():
return "md", content
return "blocks", []
# ── GFM pipe-table parsing (raw markdown → "table" block) ──
_SEP_CELL = re.compile(r"^:?-+:?$")
def _split_pipe_cells(line: str) -> list[str]:
"""Split a GFM pipe row into trimmed cell strings."""
s = line.strip()
if s.startswith("|"):
s = s[1:]
if s.endswith("|") and not s.endswith(r"\|"):
s = s[:-1]
# split on unescaped pipes
cells: list[str] = []
cur: list[str] = []
i = 0
while i < len(s):
ch = s[i]
if ch == "\\" and i + 1 < len(s) and s[i + 1] == "|":
cur.append("|")
i += 2
continue
if ch == "|":
cells.append("".join(cur).strip())
cur = []
i += 1
continue
cur.append(ch)
i += 1
cells.append("".join(cur).strip())
return cells
def _is_table_delimiter(line: str) -> bool:
s = line.strip()
if not s:
return False
if s.startswith("|"):
s = s[1:]
if s.endswith("|"):
s = s[:-1]
cells = [c.strip() for c in s.split("|")]
return bool(cells) and all(_SEP_CELL.match(c) for c in cells)
def _parse_table_at(lines: list[str], i: int, n: int):
"""If a GFM table starts at index i (header row + delimiter row), return
(table_block, next_index). Otherwise return None."""
header_cells = _split_pipe_cells(lines[i])
if len(header_cells) <= 1:
return None
if i + 1 >= n or not _is_table_delimiter(lines[i + 1]):
return None
sep_cells = _split_pipe_cells(lines[i + 1])
align = []
for c in sep_cells[: len(header_cells)]:
c = c.strip()
if c.startswith(":") and c.endswith(":"):
align.append("center")
elif c.endswith(":"):
align.append("right")
else:
align.append("left")
rows = [header_cells]
j = i + 2
while j < n:
s = lines[j].strip()
if not s or not s.startswith("|"):
break
cells = _split_pipe_cells(lines[j])
rows.append(cells)
j += 1
width = max(len(r) for r in rows)
def pad(r):
return r + [""] * (width - len(r))
align = (align + ["left"] * width)[:width]
return (
{
"type": "table",
"has_header": True,
"align": align,
"rows": [pad(r) for r in rows],
},
j,
)
def _table_to_markdown(b: dict) -> str:
rows = b.get("rows") or []
if not rows:
return ""
align = b.get("align") or []
width = max(len(r) for r in rows)
align = (align + ["left"] * width)[:width]
has_header = b.get("has_header", True)
out: list[str] = []
def rowline(r):
cells = list(r) + [""] * (width - len(r))
return "| " + " | ".join(cells) + " |"
start = 0
if has_header:
out.append(rowline(rows[0]))
seps = []
for a in align:
if a == "center":
seps.append(":---:")
elif a == "right":
seps.append("---:")
else:
seps.append(":---")
out.append("| " + " | ".join(seps) + " |")
start = 1
for ri in range(start, len(rows)):
out.append(rowline(rows[ri]))
return "\n".join(out)
def _table_to_html(b: dict) -> str:
rows = b.get("rows") or []
if not rows:
return ""
align = b.get("align") or []
width = max(len(r) for r in rows)
align = (align + ["left"] * width)[:width]
def cell_html(tag, text, a):
style = f' style="text-align:{a};"' if a and a != "left" else ""
return f"<{tag}{style}>{_text(text)}</{tag}>"
has_header = b.get("has_header", True)
first_col = b.get("first_col_header", False)
header_rows = 1 if has_header else 0
head = ""
if header_rows:
head_rows = []
hr = rows[0]
cells = list(hr) + [""] * (width - len(hr))
head_cells = []
for ci, c in enumerate(cells):
tag = "th" if first_col and ci == 0 else "th"
head_cells.append(cell_html(tag, c, align[ci]))
head_rows.append("<tr>" + "".join(head_cells) + "</tr>")
head = "<thead>" + "".join(head_rows) + "</thead>"
tbody_rows = rows[header_rows:]
body_rows = []
for r in tbody_rows:
cells = list(r) + [""] * (width - len(r))
row_cells = []
for ci, c in enumerate(cells):
tag = "th" if first_col and ci == 0 else "td"
row_cells.append(cell_html(tag, c, align[ci]))
body_rows.append("<tr>" + "".join(row_cells) + "</tr>")
body = "<tbody>" + "".join(body_rows) + "</tbody>"
return f'<table class="ftable">{head}{body}</table>'
# ── Markdown renderer (raw markdown → exportable fragments) ──
def _md_to_blocks(md: str) -> list:
"""Convert raw Markdown text into the same lightweight block list the
editor produces (headings, lists, to-do, quote, code, divider, paragraph).
Kept intentionally simple: inline formatting (bold/links) is preserved as
literal text, matching how the block editor treats imported .md files.
"""
blocks: list = []
buf = md.replace("\r\n", "\n").replace("\r", "\n")
lines = buf.split("\n")
i = 0
n = len(lines)
para: list[str] = []
def flush_para():
nonlocal para
if para:
blocks.append({"type": "paragraph", "content": "\n".join(para).strip()})
para = []
while i < n:
line = lines[i].rstrip()
stripped = line.strip()
if not stripped:
flush_para()
i += 1
continue
if stripped.startswith("```") or stripped.startswith("~~~"):
flush_para()
fence = stripped[0:3]
lang = stripped[3:].strip()
i += 1
code: list[str] = []
while i < n and not lines[i].strip().startswith(fence):
code.append(lines[i])
i += 1
if i < n:
i += 1 # closing fence
blocks.append({"type": "code", "content": "\n".join(code), "language": lang})
continue
if stripped.startswith("|"):
# GFM pipe table: header row immediately followed by a delimiter row
parsed = _parse_table_at(lines, i, n)
if parsed is not None:
flush_para()
tbl, i = parsed
blocks.append(tbl)
continue
m = re.match(r"^(#{1,6})\s+(.*)$", stripped)
if m and line == stripped: # ATX heading must be whole line
level = len(m.group(1))
flush_para()
blocks.append({"type": f"heading_{min(level, 4)}", "content": m.group(2).strip()})
i += 1
continue
if stripped == "---" or stripped == "***" or stripped == "___":
flush_para()
blocks.append({"type": "divider", "content": ""})
i += 1
continue
if re.match(r"^\s*[-*+]\s+", line):
flush_para()
while i < n:
s = lines[i].strip()
m2 = re.match(r"^[-*+]\s+(.*)$", s)
if not m2:
break
blocks.append({"type": "bulleted_list", "content": m2.group(1).strip()})
i += 1
continue
if re.match(r"^\s*\d+[.)]\s+", line):
flush_para()
while i < n:
s = lines[i].strip()
m2 = re.match(r"^\d+[.)]\s+(.*)$", s)
if not m2:
break
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()
while i < n:
s = lines[i].strip()
m2 = re.match(r"^>\s?(.*)$", s)
if not m2:
break
para.append(m2.group(1))
i += 1
blocks.append({"type": "quote", "content": "\n".join(para)})
para = []
continue
para.append(stripped)
i += 1
flush_para()
return blocks
def _page_blocks(page: dict) -> list:
"""Blocks used for HTML/PDF rendering regardless of storage format."""
kind, payload = _page_source(page)
if kind == "blocks":
return payload
if kind == "code":
text, lang = payload
return [{"type": "code", "content": text, "language": lang}] if text else []
if kind == "md":
return _md_to_blocks(payload)
return []
def _page_markdown_source(page: dict) -> str:
"""Raw markdown when the page IS markdown-sourced, else empty string."""
kind, payload = _page_source(page)
if kind == "md":
return payload
return ""
def markdown_to_blocks(md: str) -> list:
"""Public wrapper around the GFM→blocks parser (used by page import)."""
return _md_to_blocks(md)
# ═══════════════ Markdown ═══════════════
def blocks_to_markdown(blocks: list) -> str:
"""Convert a block array to Markdown (server-side, all block types)."""
out: list[str] = []
for b in blocks or []:
t = b.get("type", "paragraph")
c = _block_text(b)
if t == "heading_1":
out.append(f"# {c}")
elif t == "heading_2":
out.append(f"## {c}")
elif t == "heading_3":
out.append(f"### {c}")
elif t == "heading_4":
out.append(f"#### {c}")
elif t == "bulleted_list":
out.append(f"- {c}")
elif t == "numbered_list":
out.append(f"1. {c}")
elif t == "to_do":
out.append(f"{'- [x]' if b.get('checked') else '- [ ]'} {c}")
elif t == "quote":
out.append(f"> {c}")
elif t == "divider":
out.append("---")
elif t == "code":
lang = b.get("language") or ""
out.append(f"```{lang}\n{c}\n```")
elif t == "toggle":
out.append(f"### {c}")
if b.get("children"):
out.append(blocks_to_markdown(b["children"]))
elif t == "math":
out.append(f"$$\n{c}\n$$")
elif t == "table_of_contents":
out.append("[TOC]")
elif t == "columns":
for child in b.get("children") or []:
out.append(blocks_to_markdown([child]))
elif t == "image":
src = b.get("src") or ""
alt = (b.get("alt") or "").strip() or "image"
out.append(f"![{alt}]({src})")
elif t == "video":
out.append(f"[Video]({b.get('src') or ''})")
elif t == "audio":
out.append(f"[Audio]({b.get('src') or ''})")
elif t == "bookmark":
url = b.get("url") or b.get("src") or ""
title = (b.get("title") or "").strip()
out.append(f"[{title or url}]({url})" if title else url)
elif t == "embed":
url = b.get("src") or ""
if b.get("embed_type") in ("pdf", "download", None, ""):
out.append(f"[{url}]({url})" if url else "[embed]")
else:
out.append(f"[{url}]({url})" if url else "[embed]")
elif t == "table":
out.append(_table_to_markdown(b))
else:
out.append(c)
return "\n\n".join(filter(None, out))
def _child_pages(page: dict) -> list:
"""Immediate non-deleted children of a page."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM pages WHERE parent_id=? AND deleted_at IS NULL "
"ORDER BY COALESCE(sort_order, created_at) ASC, id ASC",
(page["id"],),
).fetchall()
return [dict(r) for r in rows]
def page_to_markdown(page: dict, *, include_children: bool = True) -> str:
"""Markdown for a single page, with optional sub-pages appended."""
parts = [f"# {_page_title(page)}", ""]
md_source = _page_markdown_source(page)
if md_source:
parts.append(md_source.strip())
else:
md = blocks_to_markdown(_page_blocks(page))
if md:
parts.append(md)
md = "\n\n".join(filter(None, parts)).rstrip()
if include_children:
for sub in _child_pages(page):
sub_md = page_to_markdown(sub, include_children=True)
if sub_md:
md += f"\n\n---\n\n{sub_md}"
return md
# ═══════════════ HTML ═══════════════
def blocks_to_html(blocks: list) -> str:
"""Convert a block array to a self-contained HTML fragment."""
parts: list[str] = []
for b in blocks or []:
t = b.get("type", "paragraph")
c = _text(b.get("content"))
if t == "heading_1":
parts.append(f'<h1 id="h-{_sanitize_id(b.get("id"))}">{c}</h1>')
elif t == "heading_2":
parts.append(f'<h2 id="h-{_sanitize_id(b.get("id"))}">{c}</h2>')
elif t == "heading_3":
parts.append(f'<h3 id="h-{_sanitize_id(b.get("id"))}">{c}</h3>')
elif t == "heading_4":
parts.append(f'<h4 id="h-{_sanitize_id(b.get("id"))}">{c}</h4>')
elif t == "bulleted_list":
parts.append(f"<li>{c}</li>")
elif t == "numbered_list":
parts.append(f"<li>{c}</li>")
elif t == "to_do":
checked = "checked" if b.get("checked") else ""
style = "text-decoration:line-through;opacity:.55;" if b.get("checked") else ""
parts.append(
f'<div class="todo"><input type="checkbox" {checked} disabled>'
f'<span style="{style}">{c}</span></div>'
)
elif t == "toggle":
children = blocks_to_html(b.get("children") or [])
parts.append(f"<details open><summary>{c}</summary>{children}</details>")
elif t == "quote":
parts.append(f"<blockquote>{c}</blockquote>")
elif t == "divider":
parts.append("<hr>")
elif t == "code":
lang = b.get("language") or ""
label = f'<div class="code-lang">{_text(lang)}</div>' if lang else ""
parts.append(f"<pre>{label}<code>{c}</code></pre>")
elif t == "math":
parts.append(f'<div class="math">\\[{c}\\]</div>')
elif t == "table_of_contents":
toc = [x for x in (blocks or [])
if x.get("type", "").startswith("heading_") and (x.get("content") or "").strip()]
if toc:
items = "".join(
f'<div style="margin-left:{max(0, int(x["type"].split("_")[-1]) - 1) * 14}px;">'
f'<a href="#h-{_sanitize_id(x.get("id"))}">{_text(x.get("content"))}</a></div>'
for x in toc
)
parts.append(f'<nav class="toc"><div class="toc-title">On this page</div>{items}</nav>')
elif t == "columns":
cols = "".join(
f'<div class="column">{blocks_to_html([child])}</div>'
for child in (b.get("children") or [])
)
parts.append(f'<div class="columns">{cols}</div>')
elif t == "callout":
icon = b.get("icon") or "💡"
bg = (b.get("style") or {}).get("bgColor", "#eef2ff")
parts.append(f'<div class="callout" style="background:{bg}"><span>{_text(icon, escape=False)}</span><div>{c}</div></div>')
elif t == "image":
src = b.get("src") or ""
alt = _text(b.get("alt"))
parts.append(f'<figure><img src="{src}" alt="{alt}" class="fd-img" data-full="{src}"><figcaption>{alt}</figcaption></figure>')
elif t == "video":
src = b.get("src") or ""
if src:
parts.append(f'<video controls preload="metadata" style="max-width:100%;border-radius:8px;"><source src="{src}"></video>')
elif t == "audio":
src = b.get("src") or ""
if src:
parts.append(f'<audio controls preload="metadata" style="width:100%;"><source src="{src}"></audio>')
elif t == "bookmark":
url = b.get("url") or b.get("src") or ""
title = _text(b.get("title")) or url
desc = _text(b.get("description"))
img = b.get("image") or ""
site = _text(b.get("site_name")) or ""
img_html = f'<img src="{img}" alt="" style="width:120px;height:90px;object-fit:cover;border-radius:8px;flex-shrink:0;">' if img else ""
desc_html = f'<div style="font-size:13px;color:#57606a;margin-top:4px;">{desc}</div>' if desc else ""
site_html = f'<div style="font-size:11px;color:#8b949e;text-transform:uppercase;letter-spacing:.5px;margin-top:6px;">{site}</div>' if site else ""
parts.append(
f'<a href="{_text(url)}" target="_blank" rel="noopener noreferrer" style="text-decoration:none;color:inherit;">'
f'<div style="display:flex;gap:14px;align-items:center;border:1px solid #d8dee4;border-radius:10px;'
f'padding:14px 16px;margin:14px 0;background:#f9fafb;">'
f'<div style="flex:1;min-width:0;"><div style="font-weight:600;font-size:15px;">{title}</div>'
f'{desc_html}{site_html}</div>{img_html}</div></a>'
)
elif t == "embed":
url = b.get("src") or ""
emb = (b.get("embed_type") or "")
if emb in ("inline_dbs", "collection"):
parts.append('<div class="embed-note">[Embedded content]</div>')
elif emb == "download":
parts.append(f'<a href="{_text(url)}" download>⬇ {_text(b.get("file_name") or "Download")}</a>')
elif emb == "pdf" and url:
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 = b.get("embed_src") or embed_src(url) or url
height = 520
if b.get("height"):
try:
height = int(b["height"])
except (ValueError, TypeError):
pass
parts.append(
f'<div style="position:relative;width:100%;height:{height}px;border-radius:8px;overflow:hidden;'
f'background:#0a0a0a;"><iframe src="{src}" loading="lazy" frameborder="0" '
f'style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div>'
)
elif t == "table":
parts.append(_table_to_html(b))
else:
parts.append(f"<p>{c}</p>")
return "\n".join(parts)
def _standalone_css() -> str:
return """
:root{color-scheme:light;}
*{box-sizing:border-box;}
body{margin:0;font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;color:#1f2328;background:#fff;line-height:1.65;}
.wrap{max-width:780px;margin:0 auto;padding:48px 32px 96px;}
h1{font-size:2.4rem;line-height:1.2;margin:0 0 8px;}
h2{font-size:1.7rem;border-bottom:1px solid #ececec;padding-bottom:6px;margin:32px 0 12px;}
h3{font-size:1.35rem;margin:24px 0 8px;}
h4{font-size:1.1rem;margin:20px 0 6px;}
p{margin:8px 0;}
li{margin:4px 0;}
ol{list-style:decimal;padding-left:24px;}
ul{list-style:disc;padding-left:24px;}
blockquote{border-left:4px solid #d0d7de;margin:12px 0;padding:4px 16px;color:#57606a;}
hr{border:none;border-top:1px solid #eaeef2;margin:24px 0;}
pre{background:#f6f8fa;border-radius:8px;padding:16px 20px;overflow-x:auto;font-size:14px;}
code{font-family:'SFMono-Regular',Consolas,monospace;background:#f6f8fa;border-radius:4px;padding:2px 5px;font-size:.9em;}
pre code{background:none;padding:0;font-size:13px;}
.code-lang{font-size:11px;color:#8b949e;text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px;}
details{background:#f6f8fa;border:1px solid #eaeef2;border-radius:8px;padding:10px 14px;margin:10px 0;}
details summary{cursor:pointer;font-weight:600;}
details[open] summary{margin-bottom:8px;}
.todo{display:flex;align-items:flex-start;gap:8px;margin:4px 0;}
.todo input{margin-top:5px;}
.toc{border:1px solid #eaeef2;border-radius:8px;padding:16px 20px;margin:12px 0;}
.toc-title{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:#57606a;margin-bottom:10px;}
.toc a{color:#0969da;text-decoration:none;display:block;padding:4px 0;}
.columns{display:flex;gap:14px;margin:12px 0;align-items:stretch;}
.column{flex:1;min-width:0;background:#f9fafb;border:1px solid #eaeef2;border-radius:8px;padding:12px 14px;box-sizing:border-box;}
.callout{display:flex;gap:10px;align-items:flex-start;border:1px solid #e0e7ff;border-radius:8px;padding:14px 18px;margin:12px 0;font-size:15px;}
.callout>span{font-size:20px;flex-shrink:0;}
.math{margin:14px 0;overflow-x:auto;}
figure{margin:16px 0;text-align:center;}
figure img{max-width:100%;border-radius:8px;}
figcaption{font-size:13px;color:#8b949e;margin-top:6px;}
.ftable{width:100%;border-collapse:collapse;margin:16px 0;font-size:14.5px;line-height:1.45;}
.ftable th,.ftable td{border:1px solid #d8dee4;padding:7px 12px;vertical-align:top;}
.ftable th{background:#f6f8fa;font-weight:600;}
.ftable tr:nth-child(even) td{background:#fcfcfd;}
.footer{margin-top:56px;padding-top:16px;border-top:1px solid #eaeef2;color:#8b949e;font-size:12px;display:flex;justify-content:space-between;}
a{color:#0969da;}
@media print{body{background:#fff;}.wrap{padding:0;max-width:100%;}}
"""
def page_to_standalone_html(
page: dict,
*,
include_children: bool = True,
base_url: str = "",
) -> str:
"""Return a standalone, self-contained HTML document for a page."""
title = _page_title(page)
body = blocks_to_html(_page_blocks(page))
meta_updated = page.get("updated_at") or ""
footer = f"<div class='footer'><span>FlowDeck · {_page_title(page)}</span><span>{meta_updated}</span></div>"
sub_html = ""
if include_children:
for sub in _child_pages(page):
sub_html += '\n<hr style="border:none">\n<div class="subpage">'
sub_html += page_to_standalone_html(sub, include_children=True, base_url=base_url)
sub_html += "</div>"
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{_text(title)}</title>
<style>{_standalone_css()}</style>
</head>
<body>
<div class="wrap">
<h1>{_text(title)}</h1>
{body}
{sub_html}
{footer}
</div>
</body>
</html>"""
# ═══════════════ PDF ═══════════════
def _pdf_html(page: dict) -> str:
"""A print-friendly, minimal-CSS HTML for PDF conversion."""
title = _page_title(page)
body = blocks_to_html(_page_blocks(page))
return f"""<html><head><meta charset="utf-8"><title>{_text(title)}</title>
<style>
body{{font-family:Helvetica,Arial,sans-serif;color:#1f2328;font-size:12px;line-height:1.5;}}
h1{{font-size:26px;margin:0 0 10px;}}
h2{{font-size:19px;border-bottom:1px solid #ddd;padding-bottom:4px;margin:22px 0 8px;}}
h3{{font-size:16px;margin:18px 0 6px;}}
h4{{font-size:14px;margin:14px 0 4px;}}
p,li{{margin:4px 0;}}
pre{{background:#f4f4f4;padding:10px;font-size:10px;white-space:pre-wrap;}}
code{{font-family:monospace;font-size:10px;}}
blockquote{{border-left:3px solid #ccc;margin:8px 0;padding:2px 12px;font-style:italic;}}
table{{border-collapse:collapse;width:100%;}}
.ftable{{border-collapse:collapse;width:100%;margin:10px 0;}}
.ftable th,.ftable td{{border:1px solid #999;padding:5px 8px;}}
.ftable th{{background:#f0f0f0;font-weight:bold;}}
hr{{border:none;border-top:1px solid #ddd;margin:16px 0;}}
.todo{{margin:4px 0;}}
.math{{font-style:italic;margin:10px 0;}}
.callout{{background:#f0f4ff;border:1px solid #dbe4ff;border-radius:6px;padding:8px 12px;margin:8px 0;}}
.footer{{margin-top:30px;padding-top:8px;border-top:1px solid #ddd;font-size:9px;color:#888;}}
</style></head><body>
<h1>{_text(title)}</h1>
{body}
<div class="footer">FlowDeck · {_text(title)} · {page.get("updated_at") or ""}</div>
</body></html>"""
def page_to_pdf_bytes(page: dict) -> bytes:
"""Render a page to a PDF.
Primary engine: WeasyPrint — renders colour emoji and proper CSS tables
(needs system libs: pango + fonts; available in the Docker image).
Fallback: xhtml2pdf (pure Python) when WeasyPrint's native libraries are
absent (e.g. a Windows dev host) — text/table content still exports,
though emoji are limited to monochrome by the engine.
"""
# 1) WeasyPrint (best fidelity: colour emoji, CSS tables)
try:
from weasyprint import HTML
html = page_to_standalone_html(page, include_children=False)
return HTML(string=html, base_url=_data_root().as_uri() + "/").write_pdf()
except Exception: # ImportError or missing native libs (OSError) -> fallback
pass
# 2) xhtml2pdf fallback (pure Python)
from xhtml2pdf import pisa
src = _pdf_html(page)
buf = io.BytesIO()
pdf = pisa.CreatePDF(src, dest=buf, encoding="utf-8")
if pdf.err:
raise RuntimeError(f"PDF generation failed: {pdf.err}")
return buf.getvalue()
# ═══════════════ Static site (zip) ═══════════════
def _site_index_html(pages: list[dict]) -> str:
"""Build the index.html of the static site (list of all pages)."""
def link(p: dict) -> str:
title = _page_title(p)
return f'<li><a href="{quote(title, safe="")}.html">{_text(title)}</a></li>'
items = "".join(link(p) for p in pages)
return f"""<!DOCTYPE html>
<html lang="en"><head><meta charset="utf-8">
<title>FlowDeck Site</title>
<style>body{{font-family:system-ui,sans-serif;max-width:720px;margin:40px auto;padding:0 20px;color:#1f2328;}}
a{{color:#0969da;text-decoration:none;}}li{{margin:8px 0;}}</style></head>
<body><h1>FlowDeck Site</h1><ul>{items}</ul></body></html>"""
def build_static_site_bytes(root_page: dict) -> bytes:
"""Build a full static site as a zip: index.html + one HTML file per page."""
pages = [root_page] + _child_pages(root_page)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("index.html", _site_index_html(pages))
for p in pages:
title = _page_title(p)
name = f"{quote(title, safe='')}.html"
z.writestr(name, page_to_standalone_html(p, include_children=False))
return buf.getvalue()
+72
View File
@@ -0,0 +1,72 @@
"""FlowDeck — v5.2.0 Forge abstraction (Gitea / GitHub).
``ForgeAdapter`` defines the minimal contract a forge client must expose for the
``projects`` table sync and the issue/board integration. ``GiteaAdapter`` wraps
the existing ``GiteaClient``; ``GitHubAdapter`` (in ``github_adapter.py``) is the
GitHub implementation.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
class ForgeAdapter(ABC):
"""Common forge API surface used by FlowDeck (v5.2.0)."""
kind = "base"
@abstractmethod
async def validate_token(self) -> bool:
"""True when the stored credentials still work."""
@abstractmethod
async def list_repos(self, page: int = 1) -> list[dict]:
"""List repositories for the authenticated user."""
@abstractmethod
async def get_repo_info(self, owner: str, repo: str) -> dict:
"""Repository metadata (default_branch, clone_url, language, …)."""
def normalize_repo(repo: dict, proj_type: str) -> dict:
"""Project a forge repo dict onto the ``projects`` table columns."""
full_name = repo.get("full_name", "") or repo.get("fullName", "")
owner, _, name = full_name.partition("/")
return {
"name": name or repo.get("name", ""),
"owner": owner or repo.get("owner", {}).get("login", "") if isinstance(repo.get("owner"), dict) else (owner or ""),
"proj_type": proj_type,
"forge_id": str(repo.get("id", "") or ""),
"clone_url": repo.get("clone_url", "") or repo.get("ssh_url", ""),
"default_branch": repo.get("default_branch", ""),
"language": repo.get("language", ""),
"description": (repo.get("description") or "") or "",
}
class GiteaAdapter(ForgeAdapter):
"""Adapt the existing GiteaClient to the ForgeAdapter contract."""
kind = "gitea"
def __init__(self, client) -> None: # client = GiteaClient instance
self._client = client
async def validate_token(self) -> bool:
try:
await self._client.get_user_repos(page=1, limit=1)
return True
except Exception:
return False
async def list_repos(self, page: int = 1) -> list[dict]:
return await self._client.get_user_repos(page=page, limit=30)
async def get_repo_info(self, owner: str, repo: str) -> dict:
async with __import__("httpx").AsyncClient(timeout=15) as client:
resp = await client.get(
f"{self._client._base}/repos/{owner}/{repo}",
headers=self._client._headers,
)
resp.raise_for_status()
return resp.json()
+3 -3
View File
@@ -1,8 +1,8 @@
"""FlowDeck — Formula Engine (v1.5.0)."""
from __future__ import annotations
from datetime import datetime, date, timedelta
from typing import Any, Callable, Optional
from datetime import date, datetime, timedelta
from typing import Any, Callable
class FormulaEngine:
@@ -214,7 +214,7 @@ class FormulaEngine:
return val.split("...")[0]
return val
def _end(self, ctx: dict, s: Any) -> Optional[str]:
def _end(self, ctx: dict, s: Any) -> str | None:
"""Extract end date from a date range."""
val = str(s)
if "..." in val:
+71 -8
View File
@@ -1,4 +1,4 @@
"""FlowDeck — GitHub API adapter with caching."""
"""FlowDeck — GitHub API adapter with caching (v5.2.0: ForgeAdapter)."""
from __future__ import annotations
import base64
@@ -8,26 +8,36 @@ from typing import Any
import httpx
from app.services.forge_adapter import ForgeAdapter
logger = logging.getLogger(__name__)
DEFAULT_TTL = 30 # seconds
class GitHubAdapter:
class GitHubAdapter(ForgeAdapter):
"""Async GitHub API client (v3 REST) with simple TTL cache.
Authenticated via OAuth2 Bearer token.
Authenticated via OAuth2 Bearer token. Implements the ``ForgeAdapter``
interface so Gitea and GitHub repos can be synced identically.
"""
def __init__(self, access_token: str, ttl: int = DEFAULT_TTL) -> None:
kind = "github"
def __init__(self, access_token: str, ttl: int = DEFAULT_TTL,
transport: httpx.BaseTransport | None = None) -> None:
self._base = "https://api.github.com"
self._headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/vnd.github+json",
}
self._transport = transport
self._cache: dict[str, tuple[datetime, Any]] = {}
self._ttl = timedelta(seconds=ttl)
def _client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(timeout=15, transport=self._transport)
# ── cache helpers ──
def _cached(self, key: str) -> Any | None:
@@ -48,7 +58,7 @@ class GitHubAdapter:
if cached:
return cached
async with httpx.AsyncClient(timeout=15) as client:
async with self._client() as client:
resp = await client.get(
f"{self._base}/user/repos",
headers=self._headers,
@@ -81,7 +91,7 @@ class GitHubAdapter:
if cached:
return cached
async with httpx.AsyncClient(timeout=15) as client:
async with self._client() as client:
# Resolve default branch commit SHA if not provided
if sha is None:
repo_info = await client.get(
@@ -128,7 +138,7 @@ class GitHubAdapter:
if cached:
return cached
async with httpx.AsyncClient(timeout=15) as client:
async with self._client() as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
headers=self._headers,
@@ -146,11 +156,64 @@ class GitHubAdapter:
self._set_cache(cache_key, content)
return content
# ── repo info (ForgeAdapter) ──
async def get_repo_info(self, owner: str, repo: str) -> dict:
"""Repository metadata: default_branch, clone_url, languages, …"""
cache_key = f"repo_info:{owner}:{repo}"
cached = self._cached(cache_key)
if cached:
return cached
async with self._client() as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}",
headers=self._headers,
)
resp.raise_for_status()
info = resp.json()
repo_info = {
"id": info.get("id"),
"name": info.get("name"),
"owner": (info.get("owner") or {}).get("login", owner),
"full_name": info.get("full_name"),
"clone_url": info.get("clone_url", ""),
"default_branch": info.get("default_branch", "main"),
"description": info.get("description") or "",
"language": info.get("language") or "",
"html_url": info.get("html_url", ""),
}
# Languages are a separate endpoint.
try:
lang_resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/languages",
headers=self._headers,
)
if lang_resp.status_code == 200:
langs = lang_resp.json()
if langs:
repo_info["language"] = max(langs, key=langs.get)
except Exception:
pass
self._set_cache(cache_key, repo_info)
return repo_info
async def get_languages(self, owner: str, repo: str) -> dict:
"""Bytes per language for a repo."""
async with self._client() as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/languages",
headers=self._headers,
)
resp.raise_for_status()
return resp.json()
# ── token validation ──
async def validate_token(self) -> bool:
"""Check whether the access token is still valid."""
async with httpx.AsyncClient(timeout=10) as client:
async with self._client() as client:
resp = await client.get(
f"{self._base}/user",
headers=self._headers,
+494
View File
@@ -0,0 +1,494 @@
"""FlowDeck — LLM client abstraction (v4.10.0).
Abstraction over multiple LLM providers so the agent never talks to the DB
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)
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
functional — and fully testable — with zero external calls, which is what the
local deployment and the test-suite rely on.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from dataclasses import dataclass, field
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
# Provider → default model + base URL when llm_model/api_base are empty.
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"),
"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"),
"ollama": ("http://localhost:11434/v1", "llama3.1"),
"offline": (None, None),
}
# 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"],
"deepseek": ["deepseek-chat", "deepseek-reasoner"],
"qwencloud": ["qwen-max", "qwen-plus", "qwen-turbo", "qwen-long"],
"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"],
"ollama": ["llama3.1", "llama3", "mistral", "qwen2.5", "gemma2", "mixtral"],
"offline": [],
}
# Llama-style / ChatML tool markers used by the mock planner.
_CREATE_PATTERNS = [
(re.compile(r"cr[eéé]er\s+(?:une\s+)?collection[:\s]+[\"']?([A-Za-zÀ-ÿ0-9 _\-]+)"),
lambda m: ("create_collection", {"name": m.group(1).strip()})),
(re.compile(r"create\s+collection\s+[\"']?([A-Za-z0-9 _\-]+)"),
lambda m: ("create_collection", {"name": m.group(1).strip()})),
(re.compile(r"create\s+a\s+page\s+[\"']?([A-Za-z0-9 _\-]+)"),
lambda m: ("create_page", {"title": m.group(1).strip()})),
]
_SEARCH_PATTERNS = [
(re.compile(r"(?:recherche|search|trouve|find)\s+[\"']?([A-Za-z0-9 _\-]+)"),
lambda m: ("search_workspace", {"query": m.group(1).strip()})),
]
# Loose fallback: "collection <Name>" → create_collection (covers "crée une collection X",
# "créer la collection X", "create collection X", etc.)
_COLLECTION_LINE = re.compile(
r"\bcollection\b[:\s]+(?:nomm[ée]e\s+)?([A-Za-zÀ-ÿ0-9_][^,.\n()]*[A-Za-zÀ-ÿ0-9_])",
re.IGNORECASE,
)
@dataclass
class LLMResponse:
"""Normalized completion: either a final text or one or more tool calls."""
text: str = ""
tool_calls: list[dict] = field(default_factory=list)
model: str = ""
usage: dict = field(default_factory=dict)
notice: str = ""
class LLMClient:
"""Multi-provider chat client with tool-calling support and offline mock."""
def __init__(self, provider: str | None = None, api_key: str | None = None,
api_base: str | None = None):
from .llm_config import get_llm_config # local import avoids a cycle
cfg = get_llm_config()
self.provider = (provider or cfg["provider"] or "offline").lower()
self.api_key = api_key if api_key is not None else cfg["api_key"]
self.api_base = api_base if api_base is not None else cfg["api_base"]
base, model = PROVIDERS.get(self.provider, (None, None))
self.api_base = self.api_base or base
# Le modèle global configuré n'est valable que pour le provider global :
# tester un autre provider (ex. nvidia alors que deepseek est actif) ne doit
# PAS lui envoyer le modèle du provider actif (sinon « model not found »).
cfg_provider = (cfg.get("provider") or "offline").lower()
global_model = (cfg.get("model") or "") if self.provider == cfg_provider else ""
self.default_model = global_model or model or "gpt-4o"
# ── Public API ──
async def complete(self, messages: list[dict], *, model: str | None = None,
tools: list[dict] | None = None,
stream: bool = False) -> LLMResponse:
"""Send a chat completion. Returns text and/or tool_calls."""
model = model or self.default_model
if self.provider == "offline" or not self._has_credentials():
return await self._mock_complete(messages, model, tools)
try:
return await asyncio.wait_for(
self._http_complete(messages, model, tools),
timeout=settings.agent_run_timeout_seconds,
)
except Exception as exc: # noqa: BLE001 — never mask a real-provider failure
# On NE retombe PAS silencieusement sur le mock quand un fournisseur
# réel est configuré : l'erreur doit remonter (SSE "error") pour que
# l'utilisateur voie pourquoi rien n'a été généré.
logger.warning("LLM provider '%s' failed (%s)", self.provider, exc)
raise
async def is_available(self) -> bool:
"""True when a real provider is configured."""
return self.provider != "offline" and self._has_credentials()
async def ping(self, *, model: str | None = None) -> LLMResponse:
"""Reach the provider without mock fallback (used by the "Test connection"
UI). Raises on any real error so the caller can surface it."""
model = model or self.default_model
if self.provider == "offline":
return LLMResponse(text="Mode hors-ligne (mock) — aucun appel réseau nécessaire.", model=model)
if not self._has_credentials():
raise PermissionError(f"Clé API manquante pour le provider « {self.provider} »")
return await asyncio.wait_for(
self._http_complete(
[{"role": "user", "content": "Réponds uniquement par le mot : PONG"}],
model,
None,
),
timeout=settings.agent_run_timeout_seconds,
)
# ── Helpers ──
def _has_credentials(self) -> bool:
if self.provider == "ollama":
return True # local, no key required
return bool(self.api_key)
def _endpoint(self) -> str:
return f"{self.api_base.rstrip('/')}/chat/completions"
async def _http_complete(self, messages, model, tools, *, _noticer: str = "") -> LLMResponse:
payload: dict = {
"model": model,
"messages": messages,
"temperature": 0.2,
}
if tools:
payload["tools"] = [{"type": "function", "function": t} for t in tools]
payload["tool_choice"] = "auto"
headers = {"Content-Type": "application/json"}
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
try:
async with httpx.AsyncClient(timeout=settings.agent_run_timeout_seconds) as client:
resp = await client.post(self._endpoint(), json=payload, headers=headers)
resp.raise_for_status()
data = resp.json()
except httpx.HTTPStatusError as exc:
# Repli robuste : le modèle choisi a été retiré / n'existe plus
# (404 « model not found » / 410 « has reached its end of life »).
# Au lieu d'échouer, on retente UNE fois avec le modèle par défaut du
# provider et on signale le basculement — la liste validée peut avoir
# vieilli (modèle déprécié entre deux rafraîchissements).
if exc.response.status_code in (404, 410) \
and model and self.default_model and model != self.default_model:
logger.warning(
"Model '%s' unavailable (%s) on %s — retrying with default '%s'",
model, exc.response.status_code, self.provider, self.default_model,
)
return await self._http_complete(messages, self.default_model, tools, _noticer=(
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
response = self._parse_response(data, model)
response.notice = _noticer or ""
return response
def _parse_response(self, data: dict, model: str) -> LLMResponse:
choice = data["choices"][0]["message"]
text = choice.get("content") or ""
tool_calls = []
for tc in choice.get("tool_calls") or []:
fn = tc.get("function") or {}
try:
args = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
args = {}
tool_calls.append({
"id": tc.get("id") or "",
"name": fn.get("name"),
"arguments": args,
"arguments_raw": fn.get("arguments") or "",
})
return LLMResponse(
text=text,
tool_calls=tool_calls,
model=model,
usage=data.get("usage", {}),
)
# ── Offline mock planner (deterministic, no network) ──
async def _mock_complete(self, messages, model, tools) -> LLMResponse:
user_content = self._last_user_content(messages)
sys_content = self._system_content(messages)
# Only the user's objective drives the planner. The engine appends the
# workspace/document snapshot under "# Contexte"; that text must never
# trigger keyword heuristics (a doc mentioning "recherche"/"collection"
# used to misroute content requests into tool calls).
objective = user_content.split("\n# Contexte")[0]
# Once tool results are already in the conversation, we have acted:
# stop issuing new tool calls and conclude.
if any(m.get("role") == "tool" for m in messages):
return LLMResponse(
text="Objectif traité — actions enregistrées dans le journal d'audit.",
model=model,
)
# Skill-driven: if the objective names a known skill, mirror its template.
skill_hint = self._extract_skill_hint(objective)
if skill_hint == "sprint":
return LLMResponse(
tool_calls=[
{"name": "read_gitea_issues", "arguments": {"owner": "bruno", "repo": "flowdeck", "state": "open"}},
{"name": "create_collection", "arguments": {"name": "Sprint"}},
{"name": "add_property", "arguments": {"collection_id": 0, "name": "Status", "prop_type": "select", "options": ["Todo", "In Progress", "Done"]}},
{"name": "create_view", "arguments": {"collection_id": 0, "view_type": "board"}},
],
text="Plan: analyze open issues, then build a sprint board.",
model=model,
)
# Inline content-generation ("Ask AI" / "AI meeting note") — answered
# before the tool-intent heuristics and scoped to the user objective
# only, so an injected "# Contexte" that happens to mention "collection"
# can't misroute a writing request into a create-collection action.
draft = self._draft_reply(objective)
if draft:
return LLMResponse(text=draft, model=model)
# Documents / espaces de travail (offline): unambiguous intents resolved
# from the objective — create a document (optionally in a named
# workspace) or list the accessible workspaces.
doc_args = self._document_create_args(objective)
if doc_args is not None:
return LLMResponse(
tool_calls=[{"name": "create_document", "arguments": doc_args}],
text="Plan: création d'un document.",
model=model,
)
if self._is_workspaces_request(objective):
return LLMResponse(
tool_calls=[{"name": "read_workspaces", "arguments": {}}],
text="Plan: lister les espaces de travail.",
model=model,
)
# Exact keyword → tool intent resolution (objective only).
for regex, builder in _CREATE_PATTERNS:
m = regex.search(objective)
if m:
return LLMResponse(
tool_calls=[dict(name=name, arguments=self._bind_placeholders(args, sys_content)) for name, args in [builder(m)]],
text=f"Plan: running {builder(m)[0]}.",
model=model,
)
for regex, builder in _SEARCH_PATTERNS:
m = regex.search(objective)
if m:
return LLMResponse(
tool_calls=[dict(name=name, arguments=args) for name, args in [builder(m)]],
text=f"Plan: searching '{m.group(1)}'.",
model=model,
)
# Loose "collection <X>" detection → treat as a create intent.
low = objective.lower()
if "collection" in low:
m = _COLLECTION_LINE.search(objective)
if m:
name = m.group(1).strip()
return LLMResponse(
tool_calls=[{"name": "create_collection",
"arguments": self._bind_placeholders({"name": name}, sys_content)}],
text=f"Plan: create collection '{name}'.",
model=model,
)
# Plain conversational objective → final answer (no tool).
return LLMResponse(
text=self._summarize(objective),
model=model,
)
def _bind_placeholders(self, args: dict, sys_content: str) -> dict:
"""Inject a collection id from the context when the planner left it as 0."""
args = dict(args)
if args.get("collection_id") == 0:
match = re.search(r"Collection IDs?:\s*([0-9,\s]+)", sys_content)
if match:
ids = [int(x) for x in re.split(r"[,\s]+", match.group(1).strip()) if x.isdigit()]
if ids:
args["collection_id"] = ids[0]
return args
@staticmethod
def _document_create_args(content: str) -> dict | None:
"""Deterministic `create_document` intent for the offline mock.
Only fires when the user clearly asks to *create* a document (a content
rewrite such as « résume / traduis ce document » is left to `_draft_reply`).
Returns None when the message is not a create-document intent.
"""
low = content.lower()
if "document" not in low:
return None
if not any(k in low for k in ("création", "créer", "crée", "crées", "create",
"nouveau document", "nouvelle page", "faire un")):
return None
quotes = re.findall(r'[«"]([^«»"]{1,80})[»"]', content)
title = quotes[0].strip() if quotes else None
if not title:
m = re.search(
r"\bdocument\b\s*(?:nomm[ée]e?\s+|intitul[ée]e?\s+|appel[ée]e?\s+)?"
r'[«"]?\s*([A-Za-zÀ-ÿ0-9][A-Za-zÀ-ÿ0-9_ \-]{1,60})',
content, re.IGNORECASE,
)
if m:
title = m.group(1).strip()
if not title:
return None
args = {"title": title}
if len(quotes) > 1 and re.search(r"\b(workspace|espace de travail)\b", low):
args["workspace_name"] = quotes[-1].strip()
return args
@staticmethod
def _is_workspaces_request(content: str) -> bool:
"""True when the user asks to list / locate the workspaces."""
low = content.lower()
has_ws = any(w in low for w in ("workspace", "espace de travail", "espaces de travail"))
has_verb = any(v in low for v in ("liste", "lister", "list", "quels", "montre",
"affiche", "mes espaces", "ou sont", "où sont"))
return has_ws and has_verb
@staticmethod
def _system_content(messages) -> str:
return "\n".join(m.get("content", "") for m in messages if m.get("role") == "system")
@staticmethod
def _last_user_content(messages) -> str:
for m in reversed(messages):
if m.get("role") == "user":
c = m.get("content", "")
if isinstance(c, list):
return " ".join(p.get("text", "") for p in c if isinstance(p, dict))
return str(c)
return ""
@staticmethod
def _extract_skill_hint(content: str) -> str | None:
low = content.lower()
if "sprint" in low or "préparation de sprint" in low:
return "sprint"
return None
@staticmethod
def _summarize(content: str) -> str:
"""Produce a terse final summary from a conversational objective."""
content = content.split("\n# Contexte")[0]
return (content[:600] + "…" if len(content) > 600 else content)
def _draft_reply(self, content: str) -> str | None:
"""Generate usable structured copy for content/meeting requests when no
real LLM is configured (offline mock). Returns None when the message is
not a clear content-generation intent so other paths keep their behavior.
"""
from datetime import date
low = content.lower()
title = None
m = re.search(r"intitul[ée]e\s*[«\"']([^»\"']+)[»\"']", content)
if m:
title = m.group(1).strip()
today = date.today().isoformat()
# ── AI meeting note template ──
if any(k in low for k in ("ai meeting note", "meeting note",
"compte-rendu", "compte rendu",
"notes de réunion", "réunion")):
return (
"📅 AI Meeting Note\n"
f"Date : {today} · Participants : (à renseigner)\n"
"\n"
"## Résumé\n"
"Point central de la discussion et contexte (à compléter).\n"
"\n"
"## Décisions\n"
"• Décision 1 — valider le périmètre et les responsables.\n"
"• Décision 2 — définir la prochaine échéance.\n"
"\n"
"## Action items\n"
"☐ Action 1 — responsable : …, échéance : …\n"
"☐ Action 2 — responsable : …, échéance : …\n"
"\n"
"## Prochaines étapes\n"
"• Planifier le suivi et archiver ce compte-rendu.\n"
)
# ── Traduction / analyse du document (hors-ligne) ──
if re.search(r"traduis|traduit|translate", low):
return (
"⚠️ **Traduction non disponible en mode hors-ligne** (aucun modèle d'IA "
"connecté).\n\n"
"Connectez un fournisseur dans **Paramètres → Agent & IA**, puis relancez "
"« Traduire cette page » : le document traduit apparaîtra ici, avec un aperçu "
"à approuver ou à rejeter avant application."
)
if re.search(r"r[ée]sum|am[ée]lior|sugg[èe]re des|propose des", low):
return (
"⚠️ **Cette action nécessite un modèle d'IA connecté** pour analyser le "
"document.\n\n"
"Configurez une clé API dans **Paramètres → Agent & IA**, puis relancez "
"l'action : l'agent générera la proposition ici, avec un aperçu à approuver "
"ou à rejeter avant application."
)
# ── Page / document draft (contextual "Ask AI") ──
if title:
t = title[:80]
return (
f"{t}\n"
"\n"
f"Présentation générale du sujet « {t} » : objectif, contexte et "
"public visé en quelques phrases. (Document généré hors-ligne — "
"connectez une clé API pour une rédaction complète.)\n"
"\n"
"## Objectif\n"
"• Clarifier le besoin couvert par ce document.\n"
"• Lister les livrables attendus.\n"
"\n"
"## Points clés\n"
"• Idée principale 1 avec les arguments associés.\n"
"• Idée principale 2 et les exemples concrets.\n"
"\n"
"## Prochaines étapes\n"
"• Relire, compléter et mettre en forme ce contenu.\n"
)
# ── Generic drafting verb, no title (typing directly in the chat) ──
if re.search(r"^(r[ée]dige|[ée]cris|[ée]crire|g[ée]n[èe]re|produis|d[ée]veloppe|"
r"[ée]cris\s+un|g[ée]n[èe]re\s+un|r[ée]dige\s+un)\b", low):
return (
"## Introduction\n"
"Contexte et objectif de ce texte, en une à deux phrases.\n"
"\n"
"## Développement\n"
"• Premier argument structuré avec un exemple.\n"
"• Deuxième argument appuyé par une donnée ou un fait.\n"
"\n"
"## Conclusion\n"
"Synthèse et prochaine étape recommandée.\n"
)
return None
+409
View File
@@ -0,0 +1,409 @@
"""FlowDeck — Runtime LLM configuration store (v4.10.2).
Precedence (per conversation):
1. explicit `provider`/`model` passed to the run endpoint,
2. the user's saved key for that provider (`user_llm_keys`),
3. the global `llm_config` row (id=1) — admin UI,
4. `settings.llm_*` (.env), default « offline mock ».
Rows are created lazily, so .env stays the default until saved from the UI.
"""
from __future__ import annotations
import json
import time
from app.config import settings
from app.services.llm_client import 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)
# before being exposed as "usable models". NVIDIA exposes all of its catalog
# (embeddings, rerank, image/video/audio gen…) many of which answer 404 on
# chat completions — the exact failure the user hit.
_CHAT_VALIDATED_PROVIDERS = frozenset({"nvidia"})
# Markers that identify clearly non-chat models (embeddings, rerank, media gen…).
_NON_CHAT_MARKERS = (
"embed", "bge-", "rerank", "retriev", "tts", "asr", "stt", "whisper", "speech",
"transcrib", "translate", "image", "video", "audio", "music", "sound", "dall",
"stable", "diffus", "flux", "sora", "veo", "midjourney", "clip", "segmentation",
"ocr", "inpainting", "depth", "motion", "sento-",
)
def _is_likely_chat(model_id: str) -> bool:
ml = model_id.lower()
return not any(m in ml for m in _NON_CHAT_MARKERS)
__all__ = [
"get_llm_config", "set_llm_config", "provider_info",
"get_user_llm_key", "list_user_llm_keys", "upsert_user_llm_key",
"delete_user_llm_key", "fetch_provider_models",
"mark_llm_config_verified", "mark_user_llm_key_verified",
]
def get_llm_config() -> dict:
"""Return the effective LLM config — DB overrides .env when present."""
cfg = {
"provider": settings.llm_provider or "offline",
"model": settings.llm_model or "gpt-4o",
"api_key": settings.llm_api_key or "",
"api_base": settings.llm_api_base or "",
"verified": 0,
"verified_model": "",
"verified_at": "",
"last_error": "",
}
try:
from app.db import get_conn
with get_conn() as conn:
row = conn.execute(
"SELECT provider, model, api_key, api_base, verified, verified_model, "
"verified_at, last_error FROM llm_config WHERE id=1"
).fetchone()
except Exception: # noqa: BLE001 — DB not ready yet → env defaults
return cfg
if row:
for key in ("provider", "model", "api_key", "api_base"):
if row[key]:
cfg[key] = row[key]
if row["provider"]:
cfg["verified"] = row["verified"] or 0
cfg["verified_model"] = row["verified_model"] or ""
cfg["verified_at"] = row["verified_at"] or ""
cfg["last_error"] = row["last_error"] or ""
return cfg
def set_llm_config(*, provider: str | None = None, model: str | None = None,
api_key: str | None = None, api_base: str | None = None,
clear_keys: bool = False) -> dict:
"""Upsert the runtime LLM config row (id=1) and return the new effective config.
An empty `api_key`/`api_base` keeps the stored value (so an admin can tweak
the model/base without re-typing the key). Changing the API key resets the
`verified` flag — the provider has to pass a connection test again.
"""
from app.db import get_conn
cfg = get_llm_config()
if provider is not None:
cfg["provider"] = provider
if model is not None:
cfg["model"] = model
if api_key is not None and api_key.strip():
key_changed = cfg.get("api_key") != api_key.strip()
cfg["api_key"] = api_key.strip()
if key_changed:
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 clear_keys:
cfg["api_key"] = ""
cfg["api_base"] = ""
cfg["verified"] = 0
cfg["verified_model"] = ""
cfg["last_error"] = ""
with get_conn() as conn:
conn.execute(
"""INSERT INTO llm_config (id, provider, model, api_key, api_base,
verified, verified_model, last_error, updated_at)
VALUES (1, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
provider=excluded.provider, model=excluded.model,
api_key=excluded.api_key, api_base=excluded.api_base,
verified=excluded.verified, verified_model=excluded.verified_model,
last_error=excluded.last_error,
updated_at=CURRENT_TIMESTAMP""",
(cfg["provider"], cfg["model"], cfg["api_key"], cfg["api_base"],
cfg.get("verified", 0), cfg.get("verified_model", ""),
cfg.get("last_error", "")),
)
conn.commit()
return cfg
def mark_llm_config_verified(ok: bool, *, model: str = "", error: str = "") -> None:
"""Record the outcome of the admin 'Test connection' for the global default."""
from app.db import get_conn
with get_conn() as conn:
row = conn.execute("SELECT provider FROM llm_config WHERE id=1").fetchone()
provider = row["provider"] if row else (settings.llm_provider or "offline")
conn.execute(
"""INSERT INTO llm_config (id, provider, model, verified, verified_model,
verified_at, last_error, updated_at)
VALUES (1, ?, '', ?, ?, CASE WHEN ? THEN CURRENT_TIMESTAMP END, ?, CURRENT_TIMESTAMP)
ON CONFLICT(id) DO UPDATE SET
verified=excluded.verified,
verified_model=excluded.verified_model,
verified_at=excluded.verified_at,
last_error=excluded.last_error,
updated_at=CURRENT_TIMESTAMP""",
(provider, 1 if ok else 0, model if ok else "",
1 if ok else 0, "" if ok else error),
)
conn.commit()
def provider_info() -> list[dict]:
"""Providers list for the UI: known models + whether an API key is required.
``base_url`` is the endpoint the application uses by default for this
provider's chat requests (shown in the Settings "URL API" fields).
"""
out: list[dict] = []
for name, (base, default_model) in PROVIDERS.items():
models = list(PROVIDER_MODELS.get(name) or ())
if default_model and default_model not in models:
models.insert(0, default_model)
out.append({
"id": name,
"name": name.capitalize(),
"default_model": default_model,
"models": models,
"base_url": (base or ""),
"requires_key": name not in ("offline", "ollama"),
})
return out
# ── Per-user provider keys ──
def _mask_key(row) -> dict:
"""Public view of a stored key row: never exposes the raw api_key."""
def _get(name, default=""):
try:
v = row[name]
return default if v is None else v
except (KeyError, IndexError):
return default
return {
"provider": row["provider"],
"api_base": row["api_base"] or "",
"default_model": row["default_model"] or "",
"models": json.loads(row["models_json"] or "[]"),
"has_key": bool(row["api_key"]),
"verified": bool(_get("verified", 0)),
"verified_model": _get("verified_model") or "",
"last_error": _get("last_error") or "",
}
def get_user_llm_key(user_id: int, provider: str) -> dict | None:
"""Return a stored key row (includes the raw api_key — server-side only)."""
from app.db import get_conn
provider = provider.lower()
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM user_llm_keys WHERE user_id=? AND provider=?",
(user_id, provider),
).fetchone()
return dict(row) if row else None
def list_user_llm_keys(user_id: int) -> list[dict]:
"""Public (masked) list of the user's saved provider keys."""
from app.db import get_conn
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM user_llm_keys WHERE user_id=? ORDER BY provider",
(user_id,),
).fetchall()
return [_mask_key(r) for r in rows]
def upsert_user_llm_key(user_id: int, provider: str, *, api_key: str = "",
api_base: str = "", 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."""
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 "")
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 []
)
key_changed = bool(api_key) and (not existing or existing.get("api_key", "") != api_key)
# A changed key invalidates the previous verification; keep it otherwise.
verified = 0 if key_changed else (existing.get("verified") or 0) if existing else 0
with get_conn() as conn:
conn.execute(
"""INSERT INTO user_llm_keys (user_id, provider, api_key, api_base, default_model, models_json, verified, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(user_id, provider) DO UPDATE SET
api_key=excluded.api_key, api_base=excluded.api_base,
default_model=excluded.default_model, models_json=excluded.models_json,
verified=excluded.verified,
updated_at=CURRENT_TIMESTAMP""",
(user_id, provider, new_key, new_base, new_model,
json.dumps(new_models, ensure_ascii=False), verified),
)
conn.commit()
return get_user_llm_key(user_id, provider) or {
"provider": provider, "api_key": new_key, "api_base": new_base,
"default_model": new_model, "models_json": json.dumps(new_models, ensure_ascii=False),
"verified": verified,
}
def mark_user_llm_key_verified(user_id: int, provider: str, ok: bool, *,
model: str = "", error: str = "") -> None:
"""Record the outcome of a connection test on one of the user's providers."""
from app.db import get_conn
provider = provider.lower()
with get_conn() as conn:
conn.execute(
"""UPDATE user_llm_keys
SET verified=?, verified_model=?, last_error=?,
verified_at=CASE WHEN ? THEN CURRENT_TIMESTAMP END,
updated_at=CURRENT_TIMESTAMP
WHERE user_id=? AND provider=?""",
(1 if ok else 0, model if ok else "", "" if ok else error,
1 if ok else 0, user_id, provider),
)
conn.commit()
def delete_user_llm_key(user_id: int, provider: str) -> None:
from app.db import get_conn
provider = provider.lower()
with get_conn() as conn:
conn.execute(
"DELETE FROM user_llm_keys WHERE user_id=? AND provider=?",
(user_id, provider),
)
conn.commit()
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`.
Returns a de-duplicated list capped at 300 models.
"""
import httpx
provider = provider.lower()
base = (api_base or "").strip() or (PROVIDERS.get(provider) or (None, None))[0]
if not base:
return [] # offline — nothing to fetch
base_url = base.rstrip("/")
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()
data = resp.json()
ids: list[str] = []
for item in data.get("data") or []:
if not isinstance(item, dict):
continue
i = (item.get("id") or "").strip()
if i:
ids.append(i)
for item in data.get("models") or []:
if not isinstance(item, dict):
continue
n = (item.get("name") or item.get("id") or "").strip()
if provider == "google" and n.startswith("models/"):
n = n[len("models/"):]
if n:
ids.append(n)
seen: set[str] = set()
out: list[str] = []
for i in ids:
if i not in seen:
seen.add(i)
out.append(i)
# Providers like NVIDIA list their whole catalog, most of which is NOT served
# by /v1/chat/completions (404 sur « model not found »). Filter by name first,
# then probe the survivors with a minimal chat call so only usable models stay.
if provider in _CHAT_VALIDATED_PROVIDERS and out:
candidates = [m for m in out if _is_likely_chat(m)] or out
validated = await _validate_chat_models(base_url, api_key, candidates)
# Ne vidons jamais la liste : en cas d'échec de validation (débit limité,
# indisponibilité passagère) on garde la liste filtrée par nom.
out = validated if validated else candidates
return out[:300]
async def _validate_chat_models(base_url: str, api_key: str, candidates: list[str],
*, timeout: float = 12.0, concurrency: int = 5,
deadline: float = 90.0, attempts: int = 2) -> list[str]:
"""Probe `POST {base_url}/chat/completions` for each candidate and keep only
the models that genuinely answer — using the exact payload the app sends at
runtime (`temperature: 0.2`, no `max_tokens`).
Hard failures (404 model inconnu, 410 modèle retiré…) exclude the model.
A 429 (rate limit) is kept: it proves the route exists, so the model is
usable once the quota frees up. Transient failures (timeouts, 5xx, network)
are retried once before giving up, so slow-but-working models survive.
"""
import asyncio
import httpx
sem = asyncio.Semaphore(concurrency)
start = time.monotonic()
headers = {"Content-Type": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
url = f"{base_url.rstrip('/')}/chat/completions"
payload_tpl = {
"messages": [{"role": "user", "content": "ping"}],
"temperature": 0.2,
}
async def probe(model: str) -> str | None:
if time.monotonic() - start > deadline:
return None
payload: dict = {"model": model, **payload_tpl}
for attempt in range(attempts):
if time.monotonic() - start > deadline:
return None
try:
async with sem:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(url, headers=headers, json=payload)
code = resp.status_code
if code < 300 or code == 429:
return model
if code < 500: # 400/401/403/404/410… → définitivement non utilisable
return None
# 5xx → passage passager, on retente une fois
except Exception: # noqa: BLE001 — timeouts / connexion → on retente
if attempt >= attempts - 1:
return None
return None
results = await asyncio.gather(*(probe(m) for m in candidates))
return [m for m in results if isinstance(m, str)]
+86
View File
@@ -0,0 +1,86 @@
"""FlowDeck — Email notifications via SMTP (v4.9.0).
If SMTP is not configured (smtp_host empty) this is a safe no-op, so the
application works locally out of the box while still logging intent.
"""
from __future__ import annotations
import logging
import smtplib
from email.message import EmailMessage
from app.config import settings
logger = logging.getLogger(__name__)
def _configured() -> bool:
return bool(settings.smtp_host)
def _html_body(body_text: str, cta_url: str = "") -> str:
cta = ""
if cta_url:
cta = (
'<p style="margin:24px 0 0;">'
f'<a href="{cta_url}" '
'style="background:#2383E2;color:#fff;text-decoration:none;'
'padding:10px 20px;border-radius:8px;display:inline-block;'
'font-weight:600;">Open in FlowDeck &rarr;</a></p>'
)
return f"""<div style="font-family:-apple-system,'Segoe UI',Roboto,sans-serif;
background:#191919;color:#e0e0e0;padding:32px;">
<div style="max-width:520px;margin:0 auto;background:#252525;border:1px solid #333;
border-radius:12px;padding:24px;">
<div style="font-size:18px;font-weight:700;color:#fff;margin-bottom:8px;">FlowDeck</div>
<p style="color:#e0e0e0;line-height:1.6;white-space:pre-wrap;">{body_text}</p>
{cta}
<p style="margin-top:24px;font-size:12px;color:#999;">You received this because your
notifications preferences in FlowDeck allow it.</p>
</div></div>"""
def send_email(to_email: str, subject: str, body_text: str, cta_url: str = "") -> bool:
"""Send an email. Returns True on success, False if skipped or failed."""
if not to_email or not _configured():
return False
try:
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = settings.smtp_from
msg["To"] = to_email
msg.set_content(body_text)
msg.add_alternative(_html_body(body_text, cta_url), subtype="html")
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=15) as server:
if settings.smtp_use_tls:
server.starttls()
if settings.smtp_user:
server.login(settings.smtp_user, settings.smtp_password)
server.send_message(msg)
logger.info("Email sent to %s: %s", to_email, subject)
return True
except Exception as e: # never break the request on mail failure
logger.warning("Email send failed to %s: %s", to_email, e)
return False
def notify_user(
user_id: int,
subject: str,
body_text: str,
cta_url: str = "",
prefs_key: str = "mentions",
) -> bool:
"""Resolve a user's email + preferences and send an email notification."""
from app.db import get_conn
from app.services import notifications
with get_conn() as conn:
row = conn.execute("SELECT id, email FROM users WHERE id=?", (user_id,)).fetchone()
if not row or not row["email"]:
return False
prefs = notifications.get_user_prefs(user_id)
if not prefs.get(prefs_key, True):
return False
return send_email(row["email"], subject, body_text, cta_url)
+147
View File
@@ -0,0 +1,147 @@
"""FlowDeck — Notification service (v4.9.0 collaboration).
Creates in-app notifications (mentions, comments, page changes) and triggers
email delivery via :mod:`app.services.mailer` when the target user has opted in.
"""
from __future__ import annotations
import json
import re
from app.db import get_conn
from app.services import mailer
def create_notification(
user_id: int,
actor_id: int | None,
ntype: str,
title: str,
message: str,
resource_type: str = "page",
resource_id: int = 0,
url: str = "",
conn=None,
commit: bool = True,
) -> int | None:
"""Insert a notification row. Returns the new id (or None if skipped).
``conn`` may be supplied to join an existing transaction (caller controls
commit). Otherwise a dedicated connection is opened and committed.
"""
if not user_id:
return None
if conn is not None:
cur = conn.execute(
"""INSERT INTO notifications
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url)
VALUES (?,?,?,?,?,?,?,?)""",
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url),
)
if commit:
conn.commit()
return cur.lastrowid
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO notifications
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url)
VALUES (?,?,?,?,?,?,?,?)""",
(user_id, actor_id, ntype, title, message, resource_type, resource_id, url),
)
conn.commit()
return cur.lastrowid
_MENTION_RE = re.compile(r"(?:^|\s)@([\w\-\.]+)")
def extract_mentions(text: str) -> list[str]:
"""Return the set of @login handles mentioned in ``text`` (lowercase)."""
return list(dict.fromkeys(m.lower() for m in _MENTION_RE.findall(text or "")))
def process_mentions(
text: str,
actor_id: int,
ntype: str,
title: str,
message: str,
resource_type: str = "page",
resource_id: int = 0,
url: str = "",
conn=None,
) -> list[int]:
"""Create notifications for every user @-mentioned in ``text``.
Returns the list of mentioned user ids that were notified.
"""
handles = extract_mentions(text)
if not handles:
return []
notified = []
if conn is not None:
_do_mentions(conn, handles, actor_id, ntype, title, message,
resource_type, resource_id, url, notified)
return notified
with get_conn() as conn:
_do_mentions(conn, handles, actor_id, ntype, title, message,
resource_type, resource_id, url, notified)
conn.commit()
return notified
def _do_mentions(conn, handles, actor_id, ntype, title, message,
resource_type, resource_id, url, notified):
placeholders = ",".join("?" * len(handles))
rows = conn.execute(
f"SELECT id, login, email FROM users WHERE lower(login) IN ({placeholders})",
handles,
).fetchall()
for row in rows:
if row["id"] == actor_id:
continue
create_notification(
row["id"], actor_id, ntype, title, message,
resource_type, resource_id, url, conn=conn, commit=False,
)
notified.append(row["id"])
mailer.notify_user(
row["id"], subject=title, body_text=message, cta_url=url or "",
)
def get_user_prefs(user_id: int, conn=None) -> dict:
if conn is not None:
row = conn.execute(
"SELECT notification_prefs FROM users WHERE id=?", (user_id,)
).fetchone()
else:
with get_conn() as conn:
row = conn.execute(
"SELECT notification_prefs FROM users WHERE id=?", (user_id,)
).fetchone()
if not row or not row["notification_prefs"]:
return {"comments": True, "mentions": 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))}
def set_user_prefs(user_id: int, prefs: dict, conn=None) -> dict:
if conn is not None:
conn.execute(
"UPDATE users SET notification_prefs=? WHERE id=?",
(json.dumps(prefs), user_id),
)
conn.commit()
else:
with get_conn() as conn:
conn.execute(
"UPDATE users SET notification_prefs=? WHERE id=?",
(json.dumps(prefs), user_id),
)
conn.commit()
return prefs
+142
View File
@@ -0,0 +1,142 @@
"""FlowDeck — Bookmark cards (v5.5.0): Open Graph metadata via httpx.
Fetches a URL server-side, extracts OG/Twitter meta tags (title, description,
image, site name, favicon) and returns a safe, compact payload used to render
Notion-style bookmark cards. Robust to missing tags, non-HTML bodies and
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_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", "image")
_OG_SITE = ("og:site_name", "twitter:site", "application-name")
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 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
def _pick(props: dict, keys: tuple) -> str:
for k in keys:
v = props.get(k)
if v:
return v
return ""
def _title_of(props: dict, body: str) -> str:
t = _pick(props, _OG_TITLE)
if t:
return t
m = _TITLE_RE.search(body[:200_000])
return m.group(1).strip() if m else ""
def _site_name(url: str) -> str:
host = urlparse(url).netloc.replace("www.", "")
return host.split(".")[0].capitalize() if host else ""
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.
``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.5 bookmark-fetcher (+https://flowdeck.dracodev.net)",
"Accept": "text/html,application/xhtml+xml",
}
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
logger.debug("og fetch failed for %s: %s", src, exc)
base["title"] = urlparse(src).netloc or src
base["site_name"] = _site_name(src)
return base
ctype = (resp.headers.get("content-type") or "").lower()
if "text/html" not in ctype and "xhtml" not in ctype:
base["title"] = urlparse(src).netloc or src
base["site_name"] = _site_name(src)
return base
return parse_og(resp.text, src)
+102
View File
@@ -0,0 +1,102 @@
"""FlowDeck — Agent permission guard (v4.10.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.
"""
from __future__ import annotations
import logging
from fastapi import HTTPException
from app.db import get_conn
logger = logging.getLogger(__name__)
# Workspace roles, from least to most privileged.
READ_ROLES = {"viewer", "commenter", "editor", "admin", "owner"}
WRITE_ROLES = {"editor", "admin", "owner"}
DESTRUCTIVE_ROLES = {"admin", "owner"}
# Tools that mutate state and therefore require at least an editor role.
WRITE_TOOLS = {
"create_collection", "create_view", "create_page", "update_page",
"write_blocks", "create_document", "add_property", "add_relation",
"create_sub_item", "add_dependency", "sync_gitea", "create_gitea_issue",
"apply_template",
}
# Tools that delete / are destructive → admin/owner (or confirm mode).
DESTRUCTIVE_TOOLS = {
"delete_page", "delete_collection", "delete_document",
"delete_property", "delete_view",
}
class PermissionManager:
"""Resolves workspace role and gates agent tool calls."""
def __init__(self, user_id: int):
self.user_id = user_id
# ── Role resolution ──
def role_in_workspace(self, workspace_id: int | None) -> str:
"""Return the user's role for a workspace (owner > member role)."""
if workspace_id is None:
# No workspace → fall back to the most permissive own-content model.
return "owner"
with get_conn() as conn:
member = conn.execute(
"SELECT role FROM workspace_members WHERE workspace_id=? AND user_id=?",
(workspace_id, self.user_id),
).fetchone()
if member:
return member["role"] or "editor"
owner = conn.execute(
"SELECT id FROM workspaces WHERE id=? AND owner_id=?",
(workspace_id, self.user_id),
).fetchone()
return "owner" if owner else "viewer"
def can_read(self, workspace_id: int | None) -> bool:
return self.role_in_workspace(workspace_id) in READ_ROLES
def can_write(self, workspace_id: int | None) -> bool:
return self.role_in_workspace(workspace_id) in WRITE_ROLES
def can_destructive(self, workspace_id: int | None) -> bool:
return self.role_in_workspace(workspace_id) in DESTRUCTIVE_ROLES
# ── Gate for the engine ──
def assert_can(self, tool: str, args: dict, workspace_id: int | None,
approval_mode: str = "auto") -> None:
"""Raise HTTPException if the tool call exceeds the user's permissions.
- read tools: any authenticated user in the workspace (viewer+).
- write tools: editor+.
- destructive tools: admin/owner, or requires confirm approval mode.
"""
role = self.role_in_workspace(workspace_id)
if tool in WRITE_TOOLS and role not in WRITE_ROLES:
raise HTTPException(
status_code=403,
detail=f"Agent tool '{tool}' requires editor+ role (user is '{role}')",
)
if tool in DESTRUCTIVE_TOOLS:
if role not in DESTRUCTIVE_ROLES:
raise HTTPException(
status_code=403,
detail=f"Agent tool '{tool}' is destructive and requires admin/owner "
f"(user is '{role}')",
)
if approval_mode != "confirm":
raise HTTPException(
status_code=428, # Precondition Required
detail=f"Destructive tool '{tool}' requires approval (confirm mode)",
)
# 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")
+135
View File
@@ -0,0 +1,135 @@
"""FlowDeck — v5.2.0 Projects: normalized forge-agnostic project registry.
The ``projects`` table stores one row per repository across forges (builtin /
gitea / github). A background scheduler refreshes metadata (default branch,
language) periodically so the UI always shows up-to-date info.
"""
from __future__ import annotations
import logging
from app.config import settings
from app.db import get_conn
from app.services.forge_adapter import GiteaAdapter, normalize_repo
logger = logging.getLogger(__name__)
def register_repo(repo: dict, proj_type: str) -> int | None:
"""Upsert a forge repo into the projects table. Returns project id."""
data = normalize_repo(repo, proj_type)
if not data["name"]:
return None
with get_conn() as conn:
existing = conn.execute(
"SELECT id FROM projects WHERE proj_type=? AND owner=? AND name=?",
(proj_type, data["owner"], data["name"]),
).fetchone()
if existing:
conn.execute(
"""UPDATE projects SET forge_id=?, clone_url=?, default_branch=?,
language=?, description=?, last_synced_at=CURRENT_TIMESTAMP
WHERE id=?""",
(data["forge_id"], data["clone_url"], data["default_branch"],
data["language"], data["description"], existing["id"]),
)
conn.commit()
return existing["id"]
cur = conn.execute(
"""INSERT INTO projects
(name, proj_type, owner, forge_id, clone_url, default_branch, language, description, last_synced_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)""",
(data["name"], proj_type, data["owner"], data["forge_id"], data["clone_url"],
data["default_branch"], data["language"], data["description"]),
)
conn.commit()
return cur.lastrowid
def list_projects(proj_type: str | None = None) -> list[dict]:
with get_conn() as conn:
if proj_type:
rows = conn.execute(
"SELECT * FROM projects WHERE proj_type=? ORDER BY name",
(proj_type,),
).fetchall()
else:
rows = conn.execute("SELECT * FROM projects ORDER BY proj_type, name").fetchall()
return [dict(r) for r in rows]
def create_builtin_project(name: str, owner: str = "", description: str = "") -> dict:
"""Register a standalone (non-forge) project."""
with get_conn() as conn:
existing = conn.execute(
"SELECT id FROM projects WHERE proj_type='builtin' AND owner=? AND name=?",
(owner, name),
).fetchone()
if existing:
return {"id": existing["id"], "name": name}
cur = conn.execute(
"INSERT INTO projects (name, proj_type, owner, description) VALUES (?, 'builtin', ?, ?)",
(name, owner, description),
)
conn.commit()
return {"id": cur.lastrowid, "name": name}
# ═══════════ Periodic sync ═══════════
async def _sync_gitea(user_id: int, token: str) -> int:
from app.services.gitea_client import GiteaClient
gitea = GiteaClient(user_token=token)
adapter = GiteaAdapter(gitea)
repos = await adapter.list_repos(page=1)
count = 0
for repo in repos:
if register_repo(repo, "gitea"):
count += 1
return count
async def _sync_github(user_id: int, token: str) -> int:
from app.services.github_adapter import GitHubAdapter
adapter = GitHubAdapter(token)
repos = await adapter.list_all_repos()
count = 0
for repo in repos:
if register_repo(repo, "github"):
count += 1
return count
async def sync_all_projects() -> dict:
"""Refresh the projects table from every connected forge token."""
stats = {"gitea": 0, "github": 0, "error": 0}
with get_conn() as conn:
rows = conn.execute(
"SELECT user_id, provider, access_token FROM user_oauth_tokens "
"WHERE provider IN ('gitea','github') AND access_token != ''"
).fetchall()
tokens = [dict(r) for r in rows]
for t in tokens:
try:
if t["provider"] == "gitea":
stats["gitea"] += await _sync_gitea(t["user_id"], t["access_token"])
elif t["provider"] == "github":
stats["github"] += await _sync_github(t["user_id"], t["access_token"])
except Exception as exc:
stats["error"] += 1
logger.warning("project sync (%s user=%s) failed: %s", t["provider"], t["user_id"], exc)
logger.info("projects sync done: %s", stats)
return stats
async def project_sync_scheduler():
"""Background loop: refresh projects every interval (default hourly)."""
while True:
if settings.project_sync_enabled:
try:
await sync_all_projects()
except Exception as exc:
logger.warning("project_sync_scheduler error: %s", exc)
await __import__("asyncio").sleep(settings.project_sync_interval_hours * 3600)
+83 -5
View File
@@ -2,8 +2,8 @@
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any, Optional
from datetime import UTC, datetime
from typing import Any
# ── Property type definitions ──
@@ -106,7 +106,7 @@ SIMPLE_TYPES = ["title", "text", "number", "select", "multi_select", "status",
AUTO_TYPES = ["created_time", "created_by", "last_edited_time", "last_edited_by"]
def validate_property_value(prop_type: str, value: Any, options: Optional[list] = None) -> tuple[bool, str]:
def validate_property_value(prop_type: str, value: Any, options: list | None = None) -> tuple[bool, str]:
"""Validate a property value against its type. Returns (ok, error_message)."""
if value is None:
return True, ""
@@ -149,10 +149,88 @@ def validate_property_value(prop_type: str, value: Any, options: Optional[list]
return True, ""
def get_auto_property_value(prop_type: str, user: Optional[dict] = None) -> Any:
def parse_validation(validation) -> dict:
"""Normalize a property's ``validation_json`` into a config dict."""
if validation is None:
return {}
if isinstance(validation, dict):
cfg = dict(validation)
else:
try:
cfg = json.loads(validation) if validation else {}
except (json.JSONDecodeError, TypeError):
cfg = {}
return {
"required": bool(cfg.get("required", False)),
"unique": bool(cfg.get("unique", False)),
"min": cfg.get("min"),
"max": cfg.get("max"),
"min_length": cfg.get("min_length"),
"max_length": cfg.get("max_length"),
}
def validate_property_rule(
prop_type: str,
value: Any,
validation: dict | None = None,
*,
existing_values: list | None = None,
) -> tuple[bool, str]:
"""Validate a property value against type + validation rules.
Returns ``(ok, error_message)``. ``existing_values`` is used to enforce the
``unique`` rule (a list of the values already stored for that property).
"""
cfg = parse_validation(validation)
# Type-level validation first.
ok, msg = validate_property_value(prop_type, value)
if not ok:
return False, msg
# Empty / required
is_empty = value is None or value == "" or (isinstance(value, list) and len(value) == 0)
if is_empty:
if cfg.get("required"):
return False, "This property is required"
return True, ""
# Length bounds (string types)
if isinstance(value, str):
if cfg.get("min_length") is not None and len(value) < int(cfg["min_length"]):
return False, f"Must be at least {int(cfg['min_length'])} characters"
if cfg.get("max_length") is not None and len(value) > int(cfg["max_length"]):
return False, f"Must be at most {int(cfg['max_length'])} characters"
# Numeric bounds
if prop_type == "number" or (isinstance(value, (int, float)) and not isinstance(value, bool)):
try:
num = float(value)
except (ValueError, TypeError):
num = None
if num is not None:
if cfg.get("min") is not None and num < float(cfg["min"]):
return False, f"Must be greater than or equal to {cfg['min']}"
if cfg.get("max") is not None and num > float(cfg["max"]):
return False, f"Must be less than or equal to {cfg['max']}"
# Unique
if cfg.get("unique") and existing_values is not None:
norm = str(value).strip().lower()
for ev in existing_values:
if ev is None:
continue
if str(ev).strip().lower() == norm:
return False, "Value already exists (must be unique)"
return True, ""
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(timezone.utc).isoformat()
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")}
+294
View File
@@ -0,0 +1,294 @@
"""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.
Rooms in-memory (un seul worker uvicorn). Persistance en base (page.content)
avec debounce. Fallback polling côté client si le WS est indisponible.
"""
from __future__ import annotations
import asyncio
import json
import logging
from fastapi import WebSocket
from app.db import get_conn
logger = logging.getLogger(__name__)
COLORS = ["#2383E2", "#46A758", "#E5484D", "#F76B15", "#8E4EC6", "#12A594",
"#FFC53D", "#D6409F", "#0091FF", "#3E63DD", "#30A46C", "#FF3333"]
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)}"
def apply_op(blocks: list[dict], op: dict) -> list[dict]:
"""Apply one block op (insert/update/delete/move) — LWW par bloc."""
t = op.get("type")
if t == "insert":
blk = op.get("block") or {}
if not blk.get("id"):
blk = dict(blk)
blk["id"] = block_id()
idx = op.get("index")
if not isinstance(idx, int):
idx = len(blocks)
idx = max(0, min(idx, len(blocks)))
return blocks[:idx] + [blk] + blocks[idx:]
if t == "update":
nb = op.get("block") or {}
if not nb.get("id"):
return blocks
return [nb if b.get("id") == nb["id"] else b for b in blocks]
if t == "delete":
bid = op.get("id")
return [b for b in blocks if b.get("id") != bid]
if t == "move":
bid = op.get("id")
idx = op.get("index", 0) or 0
out = [b for b in blocks if b.get("id") != bid]
idx = max(0, min(idx, len(out)))
moved = next((b for b in blocks if b.get("id") == bid), None)
if moved is None:
return blocks
out.insert(idx, moved)
return out
return blocks
def merge_ops(blocks: list[dict], ops: list[dict]) -> list[dict]:
"""Apply a batch of ops sequentially (arrival order)."""
out = blocks
for op in ops or []:
out = apply_op(out, op)
return out
class Room:
__slots__ = ("page_id", "blocks", "title", "version", "conns",
"persist_task", "dirty")
def __init__(self, page_id: int):
self.page_id = page_id
self.blocks: list[dict] = []
self.title = ""
self.version = 0
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
class RealtimeManager:
def __init__(self):
self._rooms: dict[int, Room] = {}
def room(self, page_id: int) -> Room:
return self._rooms.setdefault(page_id, Room(page_id))
@staticmethod
def _peer(user: dict) -> dict:
uid = user.get("id") or 0
return {
"id": uid,
"login": user.get("login", ""),
"full_name": user.get("full_name", "") or user.get("login", ""),
"color": color_for(uid),
}
async def load_room(self, room: Room) -> bool:
try:
with get_conn() as conn:
row = conn.execute(
"SELECT title, content, content_format FROM pages WHERE id=? AND deleted_at IS NULL",
(room.page_id,),
).fetchone()
except Exception as e:
logger.warning("realtime load failed: %s", e)
return False
if not row:
return False
room.title = row["title"] or ""
if (row["content_format"] or "") == "blocks" and row["content"]:
try:
room.blocks = json.loads(row["content"])
except Exception:
room.blocks = []
return True
async def connect(self, ws: WebSocket, page_id: int, user: dict) -> RTConn | None:
room = self.room(page_id)
if not room.conns and not await self.load_room(room):
await ws.close(code=4404)
return None
conn = RTConn(ws, user, page_id)
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
return conn
async def disconnect(self, conn: RTConn):
room = self._rooms.get(conn.page_id)
if not room:
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
if not room.conns:
await self.flush(room)
self._rooms.pop(conn.page_id, None)
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():
room.persist_task.cancel()
await self._persist(room)
async def _persist(self, room: Room):
try:
with get_conn() as conn:
conn.execute(
"UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(room.blocks, ensure_ascii=False), room.title, room.page_id),
)
conn.commit()
room.dirty = False
except Exception as e:
logger.warning("realtime persist failed: %s", e)
def _schedule_persist(self, room: Room):
if room.persist_task and not room.persist_task.done():
return
room.dirty = True
async def _run():
try:
await asyncio.sleep(1.2)
await self._persist(room)
except asyncio.CancelledError:
pass
room.persist_task = asyncio.create_task(_run())
async def _broadcast(self, room: Room, msg: dict, exclude: RTConn | None = None):
for c in room.conns:
if c is exclude:
continue
try:
await c.ws.send_json(msg)
except Exception:
pass
async def handle(self, conn: RTConn, msg: dict):
room = self._rooms.get(conn.page_id)
if not room:
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
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
return
if t == "ping":
try:
await conn.ws.send_json({"t": "pong"})
except Exception:
pass
return
if t == "op":
op = msg.get("op") or {}
client_v = msg.get("v", 0)
room.blocks = apply_op(room.blocks, op)
room.version += 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
if stale:
try:
await conn.ws.send_json({"t": "sync",
"blocks": room.blocks,
"title": room.title,
"version": room.version})
except Exception:
pass
return
if t == "title":
fmt = (msg.get("title") or "").strip()
if fmt and fmt != room.title:
room.title = fmt
room.version += 1
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
return
if t == "sel":
await self._broadcast(room, {"t": "sel", "from": me,
"peer": self._peer(conn.user),
"block": msg.get("block"),
"offset": msg.get("offset", 0)}, exclude=conn)
return
async def room_state(self, page_id: int) -> dict:
room = self.room(page_id)
if not room.conns and not room.blocks:
await self.load_room(room)
return {"blocks": room.blocks, "title": room.title, "version": room.version}
manager = RealtimeManager()
+5 -5
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import json
import statistics
from typing import Any, Optional
from typing import Any
from app.db import get_conn
@@ -105,12 +105,12 @@ class RollupEngine:
return ROLLUP_FUNCTIONS[rollup_function](values)
def _safe_avg(values: list) -> Optional[float]:
def _safe_avg(values: list) -> float | None:
nums = [float(v) for v in values if v is not None]
return sum(nums) / len(nums) if nums else None
def _safe_stat(values: list, fn) -> Optional[float]:
def _safe_stat(values: list, fn) -> float | None:
nums = [float(v) for v in values if v is not None]
return fn(nums) if nums else None
@@ -123,12 +123,12 @@ def _numeric(gen):
pass
def _safe_range(values: list) -> Optional[float]:
def _safe_range(values: list) -> float | None:
nums = list(_numeric(v for v in values if v is not None))
return max(nums) - min(nums) if len(nums) >= 2 else None
def _percent_checked(values: list) -> Optional[float]:
def _percent_checked(values: list) -> float | None:
"""Percentage of true values (for checkbox properties)."""
if not values:
return 0.0
+186
View File
@@ -0,0 +1,186 @@
"""FlowDeck — unified search (v5.0.0).
Powers the Ctrl+K command palette. Searches editor pages and databases
(collections) with SQLite FTS5 when available, falling back to ``LIKE`` scans
otherwise. Results are scoped to the workspaces the current user can access.
"""
from __future__ import annotations
import logging
import re
from app.db import get_conn
from app.migrations import fts5_available
logger = logging.getLogger(__name__)
# ── FTS5 helpers ────────────────────────────────────────────────────────────
def _fts_terms(query: str) -> list[str]:
"""Split a user query into safe FTS5 prefix terms."""
tokens = re.findall(r"[\wÀ-ÿ]+", query, flags=re.UNICODE)
return [t.replace('"', '""') for t in tokens if t]
def _fts_match(query: str) -> str | None:
"""Build a MATCH expression, or None when the query is not FTS-safe."""
terms = _fts_terms(query)
if not terms:
return None
return " AND ".join(f'"{t}"*' for t in terms)
def _has_fts_table(conn) -> bool:
try:
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='pages_fts'"
).fetchone()
return row is not None
except Exception:
return False
def _extract_plain_text(content: str, content_format: str) -> str:
"""Return a readable one-line excerpt for a page raw ``content`` value."""
if not content:
return ""
fmt = content_format or "blocks"
if fmt == "blocks":
try:
import json as _json
blocks = _json.loads(content)
parts = []
for b in blocks if isinstance(blocks, list) else []:
if isinstance(b, dict):
text = b.get("content") or b.get("text") or ""
if isinstance(text, str) and text.strip():
parts.append(text.strip())
for child in (b.get("children") or []):
if isinstance(child, dict) and (child.get("content") or child.get("text")):
parts.append(str(child.get("content") or child.get("text")).strip())
return " ".join(parts)
except Exception:
return content
if fmt == "file":
return ""
return content
def _workspace_name(conn, workspace_id) -> str:
if not workspace_id:
return ""
try:
row = conn.execute("SELECT name FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
return row["name"] if row else ""
except Exception:
return ""
def _scope_where(user_id: int | None) -> tuple[str, list]:
"""SQL filter restricting results to the user's accessible workspaces."""
if user_id is None:
return "1=1", []
return (
"(workspace_id IS NULL OR workspace_id IN ("
" SELECT id FROM workspaces WHERE owner_id = ? "
" UNION SELECT workspace_id FROM workspace_members WHERE user_id = ?))",
[user_id, user_id],
)
# ── Search entry point ──────────────────────────────────────────────────────
def search(query: str, user_id: int | None = None, limit: int = 20) -> dict:
"""Return unified search results: ``{pages: [...], collections: [...]}``."""
q = (query or "").strip()
if not q:
return {"pages": [], "collections": []}
with get_conn() as conn:
pages = _search_pages(conn, q, user_id, limit)
collections = _search_collections(conn, q, user_id, limit)
return {"pages": pages, "collections": collections}
def _search_pages(conn, query: str, user_id: int | None, limit: int) -> list:
like = f"%{query}%"
scope, params = _scope_where(user_id)
# 1) FTS5 fast path.
if fts5_available() and _has_fts_table(conn):
match = _fts_match(query)
if match:
try:
rows = conn.execute(
f"""
SELECT p.id, p.title, p.content, p.content_format,
p.workspace_id, p.content_format
FROM pages_fts f
JOIN pages p ON p.id = f.rowid
WHERE pages_fts MATCH ? AND p.deleted_at IS NULL AND {scope}
ORDER BY rank LIMIT ?
""",
[match, *params, limit],
).fetchall()
return _page_rows_to_results(conn, rows)
except Exception as exc: # FTS syntax/edge case → fall through to LIKE
logger.debug("FTS search failed (%s); fallback to LIKE", exc)
# 2) LIKE fallback.
rows = conn.execute(
f"""
SELECT p.id, p.title, p.content, p.content_format, p.workspace_id
FROM pages p
WHERE p.deleted_at IS NULL AND {scope}
AND (p.title LIKE ? OR p.content LIKE ?)
ORDER BY p.updated_at DESC LIMIT ?
""",
[*params, like, like, limit],
).fetchall()
return _page_rows_to_results(conn, rows)
def _search_collections(conn, query: str, user_id: int | None, limit: int) -> list:
like = f"%{query}%"
scope, params = _scope_where(user_id)
rows = conn.execute(
f"""
SELECT c.id, c.name, c.description, c.icon, c.workspace_id
FROM collections c
WHERE {scope}
AND (c.name LIKE ? OR c.description LIKE ?)
ORDER BY c.updated_at DESC LIMIT ?
""",
[*params, like, like, limit],
).fetchall()
return [
{
"id": r["id"],
"type": "collection",
"title": r["name"] or "Untitled",
"subtitle": "Database" + (f" · {_workspace_name(conn, r['workspace_id'])}" if r["workspace_id"] else ""),
"icon": (r["icon"] or "📋"),
"url": f"/db/{r['id']}",
}
for r in rows
]
def _page_rows_to_results(conn, rows) -> list:
results = []
for r in rows:
title = (r["title"] or "Untitled").strip() or "Untitled"
ws = _workspace_name(conn, r["workspace_id"])
subtitle = ws or "Page"
excerpt = _extract_plain_text(r["content"], r["content_format"])
results.append({
"id": r["id"],
"type": "page",
"title": title,
"subtitle": subtitle,
"icon": "file",
"excerpt": excerpt[:160],
"url": f"/pages/{r['id']}",
})
return results
+918
View File
@@ -0,0 +1,918 @@
"""FlowDeck — Agent tool registry (v4.14.0).
The agent never re-invents FlowDeck: each tool is a thin wrapper over the same
operations the human-facing routers perform (create collection/page/property,
manage views, dependencies, Gitea issues, templates, workspaces & documents).
Every mutating tool returns an *undo snapshot* so AgentEngine can journal and
roll back each action.
Since v4.14.0 the registry also covers *documents* (Notion-style pages that
live inside a workspace, table ``pages``) and lets the agent read the list of
workspaces — fixing the case where "crée un document dans le workspace X"
used to end with no action.
"""
from __future__ import annotations
import json
import logging
import sqlite3
from dataclasses import dataclass, field
from typing import Any
from app.db import get_conn
logger = logging.getLogger(__name__)
@dataclass
class ToolResult:
status: str # success | error | reverted
tool: str
target_type: str = ""
target_id: Any = None
message: str = ""
data: dict = field(default_factory=dict)
undo: dict = field(default_factory=dict) # snapshot to restore on rollback
class Tool:
name: str = ""
description: str = ""
parameters: dict = field(default_factory=dict)
async def execute(self, args: dict, *, user_id: int | None = None) -> ToolResult: # pragma: no cover
raise NotImplementedError
# ══════════════════════════ Read tools ══════════════════════════
class SearchWorkspace(Tool):
name = "search_workspace"
description = ("Recherche full-text (par titre) dans tout FlowDeck : collections, pages de "
"collection, documents (pages éditeur) et espaces de travail.")
parameters = {
"type": "object",
"properties": {"query": {"type": "string", "description": "terme recherché"}},
"required": ["query"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
q = f"%{args.get('query', '').strip()}%"
with get_conn() as conn:
colls = conn.execute("SELECT id, name, icon FROM collections WHERE name LIKE ? ORDER BY name", (q,)).fetchall()
pages = conn.execute("SELECT id, collection_id, title FROM collection_pages WHERE title LIKE ? ORDER BY updated_at DESC LIMIT 20", (q,)).fetchall()
docs = conn.execute(
"SELECT id, title, workspace_id, content_format FROM pages "
"WHERE deleted_at IS NULL AND title LIKE ? ORDER BY updated_at DESC LIMIT 20",
(q,),
).fetchall()
workspaces = conn.execute(
"SELECT id, name FROM workspaces WHERE name LIKE ? ORDER BY name", (q,),
).fetchall()
return ToolResult(
status="success", tool=self.name, target_type="search",
data={
"collections": [dict(c) for c in colls],
"pages": [dict(p) for p in pages],
"documents": [dict(d) for d in docs],
"workspaces": [dict(w) for w in workspaces],
},
message=(f"{len(colls)} collection(s), {len(pages)} page(s), "
f"{len(docs)} document(s), {len(workspaces)} espace(s) trouvé(s)"),
)
class ReadCollection(Tool):
name = "read_collection"
description = "Lit le schéma (propriétés + vues) d'une collection."
parameters = {
"type": "object",
"properties": {"collection_id": {"type": "integer"}},
"required": ["collection_id"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid = args.get("collection_id")
with get_conn() as conn:
coll = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
if not coll:
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
props = conn.execute("SELECT id, name, prop_type, options_json FROM collection_properties WHERE collection_id=? ORDER BY position", (cid,)).fetchall()
views = conn.execute("SELECT id, name, view_type, config_json FROM collection_views WHERE collection_id=? ORDER BY position", (cid,)).fetchall()
pages = conn.execute("SELECT id, title, property_values_json FROM collection_pages WHERE collection_id=? ORDER BY position", (cid,)).fetchall()
return ToolResult(
status="success", tool=self.name, target_type="collection", target_id=cid,
data={
"collection": dict(coll),
"properties": [dict(p) for p in props],
"views": [dict(v) for v in views],
"pages": [dict(p) for p in pages],
},
)
class ReadPage(Tool):
name = "read_page"
description = "Lit une page d'une collection (propriétés + valeurs)."
parameters = {
"type": "object",
"properties": {"page_id": {"type": "integer"}},
"required": ["page_id"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
pid = args.get("page_id")
with get_conn() as conn:
page = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
if not page:
return ToolResult(status="error", tool=self.name, message=f"Page #{pid} introuvable")
deps = conn.execute("SELECT * FROM page_dependencies WHERE page_id=?", (pid,)).fetchall()
return ToolResult(
status="success", tool=self.name, target_type="page", target_id=pid,
data={"page": dict(page), "dependencies": [dict(d) for d in deps]},
)
class ReadWorkspaces(Tool):
name = "read_workspaces"
description = ("Liste les espaces de travail accessibles (id, nom, rôle) avec le nombre de "
"documents et de collections qu'ils contiennent — utile pour savoir où créer "
"ou chercher un document.")
parameters = {
"type": "object",
"properties": {"query": {"type": "string", "description": "filtre optionnel sur le nom"}},
}
async def execute(self, args, *, user_id=None) -> ToolResult:
query = (args.get("query") or "").strip()
base_sql = (
"SELECT w.id, w.name, {role} AS role, "
"(SELECT COUNT(*) FROM pages p WHERE p.workspace_id=w.id AND p.deleted_at IS NULL) AS document_count, "
"(SELECT COUNT(*) FROM collections c WHERE c.workspace_id=w.id) AS collection_count "
"FROM workspaces w {join} {where} ORDER BY w.name"
)
with get_conn() as conn:
if user_id is None:
rows = conn.execute(
base_sql.format(role="'owner'", join="", where=""), []
).fetchall()
else:
rows = conn.execute(
base_sql.format(
role="COALESCE(wm.role, CASE WHEN w.owner_id=? THEN 'owner' ELSE 'viewer' END)",
join="LEFT JOIN workspace_members wm ON wm.workspace_id=w.id AND wm.user_id=?",
where="WHERE w.owner_id=? OR wm.user_id IS NOT NULL",
),
(user_id, user_id, user_id),
).fetchall()
data = [dict(r) for r in rows]
if query:
q = query.lower()
data = [w for w in data if q in str(w.get("name", "")).lower()]
return ToolResult(
status="success", tool=self.name, target_type="workspaces",
data={"workspaces": data},
message=f"{len(data)} espace(s) de travail",
)
class ReadDocument(Tool):
name = "read_document"
description = "Lit un document (page éditeur) : titre, contenu et métadonnées."
parameters = {"type": "object", "properties": {"page_id": {"type": "integer"}}, "required": ["page_id"]}
async def execute(self, args, *, user_id=None) -> ToolResult:
pid = args.get("page_id")
with get_conn() as conn:
doc = conn.execute("SELECT * FROM pages WHERE id=? AND deleted_at IS NULL", (pid,)).fetchone()
if not doc:
return ToolResult(status="error", tool=self.name, message=f"Document #{pid} introuvable")
return ToolResult(
status="success", tool=self.name, target_type="document", target_id=pid,
data={"document": dict(doc)},
)
# ══════════════════════════ Write tools (with undo) ══════════════════════════
class CreateCollection(Tool):
name = "create_collection"
description = "Crée une collection (base de données) avec sa vue par défaut."
parameters = {
"type": "object",
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"icon": {"type": "string"},
},
"required": ["name"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
name = (args.get("name") or "").strip()
if not name:
return ToolResult(status="error", tool=self.name, message="name est requis")
description = args.get("description", "")
icon = args.get("icon", "📋")
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO collections (name, description, icon, schema_json) VALUES (?,?,?,'[]')",
(name, description, icon),
)
cid = cur.lastrowid
conn.execute(
"INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)",
(cid, "Default View", "table", json.dumps({"visible_properties": ["Title"], "sorts": [], "filters": []})),
)
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="collection", target_id=cid,
data={"collection_id": cid, "name": name},
undo={"action": "delete", "table": "collections", "id": cid},
)
class CreateView(Tool):
name = "create_view"
description = "Crée une vue (table/board/calendar/gallery/list/timeline/gantt/chart/form/map/feed) sur une collection."
parameters = {
"type": "object",
"properties": {
"collection_id": {"type": "integer"},
"view_type": {"type": "string"},
"name": {"type": "string"},
},
"required": ["collection_id", "view_type"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid, vtype = args.get("collection_id"), args.get("view_type", "table")
name = args.get("name", vtype.title())
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone()
if not coll:
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?", (cid,)).fetchone()[0]
cur = conn.execute(
"INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?,?,?,?,?)",
(cid, name, vtype, json.dumps({}), max_pos),
)
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="view", target_id=cur.lastrowid,
data={"view_id": cur.lastrowid, "collection_id": cid, "view_type": vtype},
undo={"action": "delete", "table": "collection_views", "id": cur.lastrowid},
)
class AddProperty(Tool):
name = "add_property"
description = "Ajoute une propriété typée à une collection."
parameters = {
"type": "object",
"properties": {
"collection_id": {"type": "integer"},
"name": {"type": "string"},
"prop_type": {"type": "string"},
"options": {"type": "array", "items": {"type": "string"}},
},
"required": ["collection_id", "name"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid = args.get("collection_id")
name = (args.get("name") or "").strip()
prop_type = args.get("prop_type", "text")
options = args.get("options", [])
if not name:
return ToolResult(status="error", tool=self.name, message="name est requis")
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone()
if not coll:
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?", (cid,)).fetchone()[0]
try:
cur = conn.execute(
"INSERT INTO collection_properties (collection_id, name, prop_type, options_json, position) VALUES (?,?,?,?,?)",
(cid, name, prop_type, json.dumps(options), max_pos),
)
conn.commit()
except sqlite3.IntegrityError:
return ToolResult(status="error", tool=self.name, message=f"Propriété '{name}' existe déjà")
return ToolResult(
status="success", tool=self.name, target_type="property", target_id=cur.lastrowid,
data={"property_id": cur.lastrowid, "name": name, "prop_type": prop_type},
undo={"action": "delete", "table": "collection_properties", "id": cur.lastrowid},
)
class CreatePage(Tool):
name = "create_page"
description = "Crée une page dans une collection avec les valeurs de ses propriétés."
parameters = {
"type": "object",
"properties": {
"collection_id": {"type": "integer"},
"title": {"type": "string"},
"properties": {"type": "object", "description": "map name→valeur"},
},
"required": ["collection_id", "title"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid = args.get("collection_id")
title = (args.get("title") or "").strip()
if not title:
return ToolResult(status="error", tool=self.name, message="title est requis")
with get_conn() as conn:
coll = conn.execute("SELECT id, is_locked FROM collections WHERE id=?", (cid,)).fetchone()
if not coll:
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
if coll["is_locked"]:
return ToolResult(status="error", tool=self.name, message="Collection verrouillée (is_locked)")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (cid,)).fetchone()[0]
props = self._resolve_properties(conn, cid, args.get("properties", {}))
cur = conn.execute(
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
(cid, title, max_pos, json.dumps(props, ensure_ascii=False)),
)
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="page", target_id=cur.lastrowid,
data={"page_id": cur.lastrowid, "title": title},
undo={"action": "delete", "table": "collection_pages", "id": cur.lastrowid},
)
@staticmethod
def _resolve_properties(conn, cid, values: dict) -> dict:
"""Map human property names → property ids for the JSON blob."""
props = conn.execute("SELECT id, name FROM collection_properties WHERE collection_id=?", (cid,)).fetchall()
id_by_name = {r["name"]: r["id"] for r in props}
out = {}
for k, v in (values or {}).items():
key = id_by_name.get(k, k)
if isinstance(v, list):
out[str(key)] = v
else:
out[str(key)] = v
return out
class CreateDocument(Tool):
name = "create_document"
description = ("Crée un document (page éditeur type Notion) dans un espace de travail, ou à la "
"racine. Le contenu initial est optionnel (Markdown).")
parameters = {
"type": "object",
"properties": {
"title": {"type": "string", "description": "titre du document"},
"content": {"type": "string", "description": "contenu initial en Markdown (optionnel)"},
"workspace_id": {"type": "integer", "description": "id de l'espace de travail cible (optionnel)"},
"workspace_name": {"type": "string", "description": "nom exact de l'espace de travail cible (optionnel)"},
"parent_id": {"type": "integer", "description": "document parent (sous-page) optionnel"},
},
"required": ["title"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
title = (args.get("title") or "").strip()
if not title:
return ToolResult(status="error", tool=self.name, message="title est requis")
content = (args.get("content") or "").strip()
ws_id = args.get("workspace_id")
ws_name = (args.get("workspace_name") or "").strip()
parent_id = args.get("parent_id") or None
with get_conn() as conn:
workspace = None
if ws_id is not None:
workspace = conn.execute("SELECT * FROM workspaces WHERE id=?", (ws_id,)).fetchone()
if not workspace:
return ToolResult(status="error", tool=self.name,
message=f"Workspace #{ws_id} introuvable")
elif ws_name:
workspace = conn.execute(
"SELECT * FROM workspaces WHERE lower(name)=lower(?) ORDER BY id LIMIT 1",
(ws_name,),
).fetchone()
if not workspace:
available = conn.execute("SELECT name FROM workspaces ORDER BY name").fetchall()
names = ", ".join(r["name"] for r in available) or "aucun"
return ToolResult(
status="error", tool=self.name,
message=f"Workspace « {ws_name} » introuvable. Disponibles : {names}",
)
if parent_id:
if not conn.execute("SELECT id FROM pages WHERE id=? AND deleted_at IS NULL",
(parent_id,)).fetchone():
return ToolResult(status="error", tool=self.name,
message=f"Document parent #{parent_id} introuvable")
w = dict(workspace) if workspace else None
ws_id_val = w["id"] if w else (ws_id if ws_id is not None else None)
ws_key = (w["name"] if w else "") if ws_id_val is not None else ""
if content:
fmt, store = "markdown", content
else:
fmt, store = "blocks", "[]"
cur = conn.execute(
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format,
parent_section, parent_id, sort_order)
VALUES (?,?,?,?,?,?,?,0)""",
(ws_key, ws_id_val, title, store, fmt, "Private", parent_id),
)
pid = cur.lastrowid
conn.commit()
loc = f" dans « {ws_key} »" if ws_key else ""
return ToolResult(
status="success", tool=self.name, target_type="document", target_id=pid,
data={"document_id": pid, "title": title,
"workspace_id": ws_id_val, "workspace": ws_key},
message=f"Document « {title} » créé{loc}",
undo={"action": "delete", "table": "pages", "id": pid},
)
class UpdatePage(Tool):
name = "update_page"
description = "Modifie le titre et/ou les valeurs de propriétés d'une page."
parameters = {
"type": "object",
"properties": {
"page_id": {"type": "integer"},
"title": {"type": "string"},
"properties": {"type": "object"},
},
"required": ["page_id"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
pid = args.get("page_id")
with get_conn() as conn:
page = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
if not page:
return ToolResult(status="error", tool=self.name, message=f"Page #{pid} introuvable")
coll = conn.execute("SELECT is_locked FROM collections WHERE id=?", (page["collection_id"],)).fetchone()
if coll and coll["is_locked"]:
return ToolResult(status="error", tool=self.name, message="Collection verrouillée (is_locked)")
new_title = args.get("title", page["title"])
props = json.loads(page["property_values_json"])
if args.get("properties"):
props.update(args["properties"])
conn.execute(
"UPDATE collection_pages SET title=?, property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(new_title, json.dumps(props, ensure_ascii=False), pid),
)
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="page", target_id=pid,
data={"page_id": pid, "title": new_title},
undo={"action": "update", "table": "collection_pages", "id": pid,
"snapshot": json.loads(page["property_values_json"]), "title": page["title"]},
)
class WriteBlocks(Tool):
name = "write_blocks"
description = ("Met à jour un document (page éditeur `pages`) : remplace son contenu en blocs "
"et/ou renomme son titre. Chaque bloc est un objet JSON "
"{\"type\": \"...\", \"content\": \"texte du bloc\"}. Types autorisés : "
"paragraph, heading_1, heading_2, heading_3, heading_4, bulleted_list, "
"numbered_list, to_do, quote, divider, toggle, callout, code. "
"Utilise `type: \"code\"` (avec `language`) pour un script ou du code "
"multi-lignes, `heading_1`/`heading_2`/… pour les titres. "
"Le champ contenant le texte s'appelle `content`, PAS `text`.")
parameters = {
"type": "object",
"properties": {
"page_id": {"type": "integer", "description": "id du document (page éditeur)"},
"blocks": {"type": "array",
"description": "nouveau contenu en blocs (remplace le contenu). "
"Exemple: [{\"type\": \"heading_1\", \"content\": \"Titre\"}, "
"{\"type\": \"code\", \"content\": \"Write-Host 'hi'\", \"language\": \"powershell\"}]"},
"title": {"type": "string", "description": "nouveau titre (optionnel)"},
},
"required": ["page_id"],
}
_VALID_BLOCK_TYPES = frozenset({
"paragraph", "heading_1", "heading_2", "heading_3", "heading_4",
"bulleted_list", "numbered_list", "to_do", "toggle", "quote",
"callout", "table_of_contents", "math", "columns", "divider",
"code", "table", "button", "image", "embed", "bookmark",
"video", "audio", "meeting",
})
@classmethod
def _normalize_blocks(cls, blocks: list) -> list:
"""Normalize blocks coming from the LLM so the editor can render them.
Common LLM mistakes handled:
- ``text`` instead of ``content``
- ``heading`` instead of ``heading_1`` / ``heading_2`` / …
- ``bullet`` / ``list`` instead of ``bulleted_list``
- ``numbered`` instead of ``numbered_list``
- ``check`` / ``checkbox`` instead of ``to_do``
- ``code_block`` instead of ``code``
- ``h1`` … ``h6`` shorthand
- Missing ``id`` fields (editor assigns them on load but we add them
to keep the JSON self-contained)
"""
import uuid
_TYPE_MAP = {
"heading": "heading_1", "h1": "heading_1", "h2": "heading_2",
"h3": "heading_3", "h4": "heading_4", "h5": "heading_4",
"h6": "heading_4",
"bullet": "bulleted_list", "bullets": "bulleted_list", "list": "bulleted_list",
"numbered": "numbered_list", "numbered_list": "numbered_list",
"check": "to_do", "checkbox": "to_do", "task": "to_do",
"code_block": "code",
"hr": "divider", "horizontal_rule": "divider", "horizontal rule": "divider",
}
out = []
for block in (blocks or []):
if not isinstance(block, dict):
continue
b = dict(block)
# --- normalize type ---
raw_type = (b.get("type") or "paragraph").strip().lower()
btype = _TYPE_MAP.get(raw_type, raw_type)
if btype not in cls._VALID_BLOCK_TYPES:
btype = "paragraph"
b["type"] = btype
# --- normalize content field (LLM often sends "text" instead of "content") ---
if "content" not in b and "text" in b:
b["content"] = b.pop("text")
elif "content" not in b:
# Try other common fields
for alt in ("value", "body", "source"):
if alt in b:
b["content"] = b.pop(alt)
break
else:
b.setdefault("content", "")
# --- generate id if missing ---
if not b.get("id"):
b["id"] = f"b_{uuid.uuid4().hex[:12]}"
out.append(b)
return out
async def execute(self, args, *, user_id=None) -> ToolResult:
pid = args.get("page_id")
has_blocks = "blocks" in args
new_title = (args.get("title") or "").strip()
if not has_blocks and not new_title:
return ToolResult(status="error", tool=self.name,
message="Fournir `blocks` et/ou `title`")
with get_conn() as conn:
page = conn.execute("SELECT * FROM pages WHERE id=?", (pid,)).fetchone()
if not page:
return ToolResult(status="error", tool=self.name,
message=f"Document (page éditeur) #{pid} introuvable")
snapshot = {"content": page["content"], "content_format": page["content_format"],
"title": page["title"]}
final_title = new_title or page["title"]
if has_blocks:
normalized = self._normalize_blocks(args["blocks"])
conn.execute(
"UPDATE pages SET content=?, content_format='blocks', title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(normalized, ensure_ascii=False), final_title, pid),
)
else:
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(final_title, pid))
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="document", target_id=pid,
data={"document_id": pid, "title": final_title, "blocks": len(args.get("blocks", []))},
undo={"action": "update", "table": "pages", "id": pid, "snapshot": snapshot},
)
class AddRelation(Tool):
name = "add_relation"
description = "Lie deux collections via une propriété relation (avec réciproque)."
parameters = {
"type": "object",
"properties": {
"collection_id": {"type": "integer"},
"related_collection_id": {"type": "integer"},
"name": {"type": "string"},
"reverse_name": {"type": "string"},
},
"required": ["collection_id", "related_collection_id", "name"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid = args.get("collection_id")
rel = args.get("related_collection_id")
name = (args.get("name") or "").strip()
reverse = args.get("reverse_name", "")
if not name or not rel:
return ToolResult(status="error", tool=self.name, message="name et related_collection_id requis")
with get_conn() as conn:
for c in (cid, rel):
if not conn.execute("SELECT id FROM collections WHERE id=?", (c,)).fetchone():
return ToolResult(status="error", tool=self.name, message=f"Collection #{c} introuvable")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_properties WHERE collection_id=?", (cid,)).fetchone()[0]
cur = conn.execute(
"INSERT INTO collection_properties (collection_id, name, prop_type, related_collection_id, reverse_name, position) VALUES (?,?,?,?,?,?)",
(cid, name, "relation", rel, reverse, max_pos),
)
prop_id = cur.lastrowid
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="property", target_id=prop_id,
data={"property_id": prop_id, "relation": name},
undo={"action": "delete", "table": "collection_properties", "id": prop_id},
)
class CreateSubItem(Tool):
name = "create_sub_item"
description = "Crée un sous-élément (sub-item) sous une page."
parameters = {
"type": "object",
"properties": {"collection_id": {"type": "integer"}, "page_id": {"type": "integer"}, "title": {"type": "string"}},
"required": ["collection_id", "page_id", "title"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid, pid = args.get("collection_id"), args.get("page_id")
title = (args.get("title") or "").strip()
with get_conn() as conn:
parent = conn.execute("SELECT id FROM collection_pages WHERE id=? AND collection_id=?", (pid, cid)).fetchone()
if not parent:
return ToolResult(status="error", tool=self.name, message="Page parent introuvable")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE parent_id=?", (pid,)).fetchone()[0]
cur = conn.execute(
"INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?,?,?,?,?)",
(cid, title, pid, max_pos, json.dumps({})),
)
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="page", target_id=cur.lastrowid,
data={"page_id": cur.lastrowid, "parent_id": pid},
undo={"action": "delete", "table": "collection_pages", "id": cur.lastrowid},
)
class AddDependency(Tool):
name = "add_dependency"
description = "Ajoute une dépendance (bloque) entre deux pages."
parameters = {
"type": "object",
"properties": {
"page_id": {"type": "integer"},
"dependency_id": {"type": "integer"},
"dependency_type": {"type": "string"},
},
"required": ["page_id", "dependency_id"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
pid, dep = args.get("page_id"), args.get("dependency_id")
dtype = args.get("dependency_type", "blocks")
with get_conn() as conn:
for p in (pid, dep):
if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (p,)).fetchone():
return ToolResult(status="error", tool=self.name, message=f"Page #{p} introuvable")
try:
cur = conn.execute(
"INSERT INTO page_dependencies (page_id, dependency_id, dependency_type) VALUES (?,?,?)",
(pid, dep, dtype),
)
conn.commit()
except sqlite3.IntegrityError:
return ToolResult(status="error", tool=self.name, message="Dépendance déjà existante")
return ToolResult(
status="success", tool=self.name, target_type="dependency", target_id=cur.lastrowid,
data={"dependency_id": cur.lastrowid, "page_id": pid},
undo={"action": "delete", "table": "page_dependencies", "id": cur.lastrowid},
)
class ApplyTemplate(Tool):
name = "apply_template"
description = "Applique un template de page dans une collection (crée une page)."
parameters = {
"type": "object",
"properties": {"collection_id": {"type": "integer"}, "template_id": {"type": "integer"}, "title": {"type": "string"}},
"required": ["collection_id", "template_id"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid, tid = args.get("collection_id"), args.get("template_id")
with get_conn() as conn:
tmpl = conn.execute("SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, cid)).fetchone()
if not tmpl:
return ToolResult(status="error", tool=self.name, message="Template introuvable")
max_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (cid,)).fetchone()[0]
cur = conn.execute(
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
(cid, args.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
)
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="page", target_id=cur.lastrowid,
data={"page_id": cur.lastrowid},
undo={"action": "delete", "table": "collection_pages", "id": cur.lastrowid},
)
# ══════════════════════════ Destructive tools ══════════════════════════
class DeleteDocument(Tool):
name = "delete_document"
description = "Supprime un document (page éditeur) — envoi à la corbeille (destructif, mode confirm requis)."
parameters = {"type": "object", "properties": {"page_id": {"type": "integer"}}, "required": ["page_id"]}
async def execute(self, args, *, user_id=None) -> ToolResult:
from datetime import datetime
pid = args.get("page_id")
with get_conn() as conn:
doc = conn.execute("SELECT * FROM pages WHERE id=? AND deleted_at IS NULL",
(pid,)).fetchone()
if not doc:
return ToolResult(status="error", tool=self.name,
message=f"Document #{pid} introuvable")
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?",
(datetime.utcnow().isoformat(), pid))
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="document", target_id=pid,
message=f"Document #{pid} déplacé vers la corbeille",
undo={"action": "softdelete", "table": "pages", "id": pid},
)
class DeletePage(Tool):
name = "delete_page"
description = "Supprime une page d'une collection (destructif, mode confirm requis)."
parameters = {"type": "object", "properties": {"page_id": {"type": "integer"}}, "required": ["page_id"]}
async def execute(self, args, *, user_id=None) -> ToolResult:
pid = args.get("page_id")
with get_conn() as conn:
page = conn.execute("SELECT * FROM collection_pages WHERE id=?", (pid,)).fetchone()
if not page:
return ToolResult(status="error", tool=self.name, message=f"Page #{pid} introuvable")
conn.execute("DELETE FROM collection_pages WHERE id=?", (pid,))
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="page", target_id=pid,
message="Page supprimée",
undo={"action": "insert", "table": "collection_pages", "snapshot": dict(page)},
)
class DeleteCollection(Tool):
name = "delete_collection"
description = "Supprime une collection (destructif, mode confirm requis)."
parameters = {"type": "object", "properties": {"collection_id": {"type": "integer"}}, "required": ["collection_id"]}
async def execute(self, args, *, user_id=None) -> ToolResult:
cid = args.get("collection_id")
with get_conn() as conn:
coll = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
if not coll:
return ToolResult(status="error", tool=self.name, message=f"Collection #{cid} introuvable")
conn.execute("DELETE FROM collections WHERE id=?", (cid,))
conn.commit()
return ToolResult(
status="success", tool=self.name, target_type="collection", target_id=cid,
message="Collection supprimée",
undo={"action": "insert", "table": "collections", "snapshot": dict(coll)},
)
# ══════════════════════════ Gitea tools ══════════════════════════
class ReadGiteaIssues(Tool):
name = "read_gitea_issues"
description = "Lit les issues Gitea d'un repo (état, labels, milestones)."
parameters = {
"type": "object",
"properties": {"owner": {"type": "string"}, "repo": {"type": "string"}, "state": {"type": "string"}},
"required": ["owner", "repo"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.auth.session import SessionManager
from app.services.gitea_client import GiteaClient
token = SessionManager.get_token(user_id) if user_id else None
client = GiteaClient(user_token=token)
owner, repo = args.get("owner"), args.get("repo")
try:
issues = await client.get_issues(owner, repo, state=args.get("state", "open"))
except Exception as exc: # noqa: BLE001
return ToolResult(status="error", tool=self.name, message=f"Gitea injoignable: {exc}")
return ToolResult(
status="success", tool=self.name, target_type="issues", target_id=f"{owner}/{repo}",
data={"issues": issues, "count": len(issues)},
message=f"{len(issues)} issues",
)
class SyncGitea(Tool):
name = "sync_gitea"
description = "Synchronise les issues d'un repo Gitea vers une collection."
parameters = {
"type": "object",
"properties": {"owner": {"type": "string"}, "repo": {"type": "string"}},
"required": ["owner", "repo"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.auth.session import SessionManager
from app.services.collection_adapter import GiteaBoardCompat
from app.services.gitea_client import GiteaClient
token = SessionManager.get_token(user_id) if user_id else None
client = GiteaClient(user_token=token)
owner, repo = args.get("owner"), args.get("repo")
try:
issues = await client.get_issues(owner, repo, state="all")
except Exception as exc: # noqa: BLE001
return ToolResult(status="error", tool=self.name, message=f"Gitea injoignable: {exc}")
coll_id = GiteaBoardCompat.sync_to_collection(owner, repo, issues)
return ToolResult(
status="success", tool=self.name, target_type="collection", target_id=coll_id,
data={"collection_id": coll_id, "issues": len(issues)},
message=f"{len(issues)} issues synchronisées",
undo={"action": "delete", "table": "collections", "id": coll_id} if coll_id else {},
)
class CreateGiteaIssue(Tool):
name = "create_gitea_issue"
description = "Crée une issue Gitea dans un repo."
parameters = {
"type": "object",
"properties": {"owner": {"type": "string"}, "repo": {"type": "string"}, "title": {"type": "string"}, "body": {"type": "string"}},
"required": ["owner", "repo", "title"],
}
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.auth.session import SessionManager
from app.services.gitea_client import GiteaClient
token = SessionManager.get_token(user_id) if user_id else None
client = GiteaClient(user_token=token)
try:
issue = await client.create_issue(args["owner"], args["repo"], args["title"], args.get("body", ""))
except Exception as exc: # noqa: BLE001
return ToolResult(status="error", tool=self.name, message=f"Gitea injoignable: {exc}")
return ToolResult(
status="success", tool=self.name, target_type="issue", target_id=issue.get("number"),
data={"issue": issue},
message=f"Issue #{issue.get('number')} créée",
)
# ══════════════════════════ Registry ══════════════════════════
TOOL_CLASSES = [
SearchWorkspace, ReadCollection, ReadPage, ReadWorkspaces, ReadDocument,
CreateCollection, CreateView, AddProperty, CreatePage, CreateDocument,
UpdatePage, WriteBlocks,
AddRelation, CreateSubItem, AddDependency, ApplyTemplate,
DeletePage, DeleteCollection,
ReadGiteaIssues, SyncGitea, CreateGiteaIssue,
DeleteDocument,
]
class ToolRegistry:
"""Holds tool instances and exposes their LLM schemas + execution."""
def __init__(self):
self.tools: dict[str, Tool] = {t.name: t() for t in TOOL_CLASSES}
def list(self, scope: dict | None = None) -> list[str]:
"""Tool names allowed by an agent scope (default: all)."""
allowed = (scope or {}).get("tools")
if allowed is None:
return list(self.tools.keys())
return [n for n in self.tools if n in allowed]
def schema(self, scope: dict | None = None) -> list[dict]:
"""Function-calling schema for the LLM, filtered by scope."""
return [
{"name": self.tools[n].name,
"description": self.tools[n].description,
"parameters": self.tools[n].parameters}
for n in self.list(scope)
]
async def execute(self, tool: str, args: dict, *, user_id: int | None = None) -> ToolResult:
impl = self.tools.get(tool)
if not impl:
return ToolResult(status="error", tool=tool, message=f"Outil inconnu: {tool}")
return await impl.execute(args, user_id=user_id)
+87
View File
@@ -0,0 +1,87 @@
"""FlowDeck — Global trash (v5.4.0): automatic 30-day purge.
Pages soft-deleted via ``deleted_at`` stay in the trash for the retention
window (default 30 days, matching the UI copy in ``trash.html``), then are
permanently deleted together with their child pages and orphaned versions.
The purge is run from a background scheduler in ``app.main`` so it is fully
automatic (no per-request cost).
"""
from __future__ import annotations
import logging
import re
from datetime import datetime, timedelta
from app.db import get_conn
logger = logging.getLogger(__name__)
ISO_RE = re.compile(r"^\d{4}-\d{2}-\d{2}")
def _parse_dt(value: str) -> datetime | None:
"""Parse the ISO-ish ``deleted_at`` timestamps (either 'YYYY-MM-DD...' or
SQLite 'YYYY-MM-DD HH:MM:SS'). Returns None when unparseable."""
if not value:
return None
try:
s = value[:19].replace("T", " ")
dt = datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
except ValueError:
try:
dt = datetime.fromisoformat(value)
except (ValueError, TypeError):
return None
if not ISO_RE.match(value):
return None
return dt
def purge_expired(days: int = 30) -> dict:
"""Permanently delete pages whose ``deleted_at`` is older than ``days``.
Children are re-parented to their grandparent before deletion in order
to avoid silently dropping whole sub-trees; deleted pages are removed
together with their ``page_versions`` (FK cascade).
Returns a summary of what was purged.
"""
cutoff = datetime.utcnow() - timedelta(days=days)
purged: list[int] = []
with get_conn() as conn:
rows = conn.execute(
"SELECT id, deleted_at FROM pages WHERE deleted_at IS NOT NULL"
).fetchall()
for row in rows:
dt = _parse_dt(row["deleted_at"])
if dt is None or dt >= cutoff:
continue
page_id = row["id"]
# Promote direct children to the deleted page's parent so no live
# sub-tree is orphaned (matches the permanent-delete semantics).
conn.execute(
"UPDATE pages SET parent_id=(SELECT parent_id FROM pages WHERE id=?) "
"WHERE parent_id=?",
(page_id, page_id),
)
conn.execute("DELETE FROM pages WHERE id=?", (page_id,))
purged.append(page_id)
conn.commit()
if purged:
logger.info("Trash purge: %d expired page(s) permanently deleted", len(purged))
return {"purged": purged, "count": len(purged)}
async def trash_purge_scheduler(interval_hours: int = 24):
"""Background loop purging the trash once a day (idempotent, cheap when
there is nothing expired)."""
import asyncio
# Run once shortly after startup, then on the configured interval.
await asyncio.sleep(5)
while True:
try:
purge_expired(days=30)
except Exception as exc: # pragma: no cover - defensive only
logger.warning("Trash purge failed: %s", exc)
await asyncio.sleep(interval_hours * 3600)
-1
View File
@@ -1,7 +1,6 @@
"""FlowDeck — Webhook outbound dispatcher (v2.1.0)."""
from __future__ import annotations
import json
import logging
import httpx
+290
View File
@@ -0,0 +1,290 @@
{###############################################################################
_ctx_menu.html — LE menu contextuel UNIQUE de FlowDeck
═══════════════════════════════════════════════════════════════════════════
Inclus par library.html ET local_workspace.html (les 2 seules pages du
contexte fichier). Ce partial est LE SEUL menu : son markup décrit l'UNION
des options des deux pages d'origine. Les exécutions sont déléguées par
chaque page au travers d'un store Alpine enregistré PLUS BAS (IIFE + défense
d'exécution unique → include 2 fois = store 1 fois).
Chaque item se masque quand le handler correspondant n'existe pas sur la
page (x-show="$store.fdCtx.has('key')") → menu structurellement identique,
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
############################################################################}
<script data-cfasync="false">
/* ═════════════════════════════════════════════════════════════════════
fdCtx — store Alpine UNIQUE du menu contextuel partagé.
Enregistré dans 'alpine:init' avec garde d'exécution : que la page
inclue ce partial dans base.html ou directement, le js ne s'exécute
qu'une seule fois. Chaque page, à l'ouverture du menu, positionne :
fdCtx.openMenu(evt, node, pageHash, handlers)
─────────────────────────────────────────────────────────────────── */
(function () {
if (window.__fdCtxMenuRegistered) return;
window.__fdCtxMenuRegistered = true;
document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdCtxStore) return;
if (window.Alpine) window.Alpine.__fdCtxStore = true;
Alpine.store('fdCtx', {
open: false, x: 0, y: 0, node: null, page: null,
handlers: {},
/* Tags (workspace) */
availTags: [], tagAdding: false, tagExistingOpen: false,
newTagColor: '#787774',
tagColors: ['#787774','#E03E3E','#D9730D','#DFAB01','#0F7B6C','#0B6E99','#6940A5','#AD1A72','#E16259','#D4A72C','#448361','#337EA9','#9065B0','#C94D8B'],
/* Icon picker */
iconOpen: false,
iconChoices: ['📄','📝','📕','📁','⭐','🔖','📌','✅','💡','🔥','🚀','🎯','📊','🗓️','🔗','🧩','📎','🎨','🐛','⚙️','❤️','👍','✨','🏷️','🗒️','📚'],
/* Positionne le menu à l'écran et expose les handlers de la page.
⚠️ Nom : openMenu (et PAS open) — « open » est réservé au booléen
d'état x-show. Une collision ici rendrait le menu PERMANENT. */
openMenu(ev, node, pageHash, handlers) {
handlers = handlers || {};
this.node = node;
this.page = pageHash;
this.handlers = handlers;
this.tagAdding = false;
this.tagExistingOpen = false;
this.iconOpen = false;
this.newTagColor = (node && node.tags && node.tags[0] && node.tags[0].color) || '#787774';
var cx = (ev && ev.clientX) || 0;
var cy = (ev && ev.clientY) || 0;
this.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 () {
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 (window.Alpine && window.Alpine.nextTick) window.Alpine.nextTick(place);
else setTimeout(place, 0);
},
close() {
this.open = false;
this.node = null;
this.handlers = {};
this.tagAdding = false;
this.tagExistingOpen = false;
this.iconOpen = false;
},
/* Un item est-il disponible pour CETTE page ? (union vs capacités) */
has(key) { return typeof this.handlers[key] === 'function'; },
/* Exécute l'action → handler fourni par la page courante. */
run(key) {
if (!this.node) { this.close(); return; }
var fn = this.handlers[key];
if (fn) try { fn(this.node); } catch (e) { console.error('fdCtx.run', e); }
this.close();
},
/* ── Tags ── */
avail() { return this.availTags || []; },
setAvail(a) { this.availTags = a || []; },
removeTag(id) {
var fn = this.handlers.tagRemove;
if (fn) try { fn(id); } catch (e) { console.error('fdCtx.removeTag', e); }
},
addExistingTag(t) {
var fn = this.handlers.tagExisting;
if (fn) try { fn(t); } catch (e) { console.error('fdCtx.addExistingTag', e); }
this.tagExistingOpen = false;
},
addNewTag(name, color) {
name = (name || '').trim();
if (!name) return;
var fn = this.handlers.tagAdd;
if (fn) try { fn(name, color || this.newTagColor); } catch (e) { console.error('fdCtx.addNewTag', e); }
this.tagAdding = false;
},
/* ── Icon picker ── */
setIcon(icon) {
icon = (icon || '').trim();
if (!icon) return;
var fn = this.handlers.setIcon;
if (fn) try { fn(icon); } catch (e) { console.error('fdCtx.setIcon', e); }
this.close();
},
});
});
})();
</script>
<style>
.fd-ctx-menu{position:fixed !important;top:0;left:0;min-width:230px;max-width:280px;max-height:calc(100vh - 16px);overflow-y:auto;z-index:2000;padding:4px;}
.fd-ctx-menu .menu-item{display:flex;align-items:center;gap:8px;padding:6px 10px;font-size:13px;color:var(--text-primary);cursor:pointer;border-radius:4px;transition:background .1s;white-space:nowrap;}
.fd-ctx-menu .menu-item:hover{background:var(--bg-hover);}
.fd-ctx-menu .menu-item svg{width:14px;height:14px;flex-shrink:0;}
.fd-ctx-menu .menu-danger{color:var(--danger);}
.fd-ctx-menu .menu-shortcut{margin-left:auto;font-size:11px;color:var(--text-dim);padding-left:16px;}
.fd-ctx-menu .more-sep{height:1px;background:var(--border);margin:4px 6px;}
.fd-ctx-menu .ctx-label{color:var(--text-dim);font-size:11px;cursor:default;text-transform:uppercase;letter-spacing:.04em;}
.fd-ctx-menu .ctx-label:hover{background:transparent;}
.fd-ctx-menu .ctx-empty{color:var(--text-dim);font-size:11px;cursor:default;}
.fd-ctx-menu .ctx-empty:hover{background:transparent;}
.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-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;}
.fd-ctx-menu .ctx-swatch.selected{border-color:var(--text-primary);}
.fd-ctx-menu .ctx-input{flex:1;min-width:0;font-size:12px;padding:3px 6px;background:var(--bg-primary);border:1px solid var(--border);border-radius:4px;color:var(--text-primary);outline:none;}
.fd-ctx-menu .ctx-input:focus{border-color:var(--accent);}
.fd-ctx-menu .ctx-btn{padding:3px 10px;font-size:12px;background:var(--accent);color:#fff;border:none;border-radius:4px;cursor:pointer;}
.fd-ctx-menu .ctx-icons{display:grid;grid-template-columns:repeat(7,1fr);gap:2px;padding:2px 4px 4px;}
.fd-ctx-menu .ctx-icon{width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-size:15px;background:transparent;border:none;border-radius:4px;cursor:pointer;}
.fd-ctx-menu .ctx-icon:hover{background:var(--bg-hover);}
</style>
<div class="more-menu fd-ctx-menu" x-show="$store.fdCtx.open" x-cloak
@click.outside="$store.fdCtx.close()"
@keydown.escape.window="$store.fdCtx.close()"
x-transition
:style="{ top: $store.fdCtx.y + 'px', left: $store.fdCtx.x + 'px' }">
{# ── Open ── #}
<div class="menu-item" x-show="$store.fdCtx.has('open')" @click="$store.fdCtx.run('open')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
<span style="flex:1;">Open</span>
</div>
{# ── Open in new tab ── #}
<div class="menu-item" x-show="$store.fdCtx.has('openTab')" @click="$store.fdCtx.run('openTab')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
<span style="flex:1;">Open in new tab</span><span class="menu-shortcut">Ctrl+⇧+↩</span>
</div>
{# ── Open in side peek ── #}
<div class="menu-item" x-show="$store.fdCtx.has('peek')" @click="$store.fdCtx.run('peek')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
<span style="flex:1;">Open in side peek</span><span class="menu-shortcut">Alt+Click</span>
</div>
{# ── Open folder (workspace, dossiers) ── #}
<div class="menu-item" x-show="$store.fdCtx.has('folder')" @click="$store.fdCtx.run('folder')">
<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>
<span style="flex:1;">Open folder</span>
</div>
<div class="more-sep" x-show="$store.fdCtx.has('rename') || $store.fdCtx.has('setIcon') || $store.fdCtx.has('duplicate') || $store.fdCtx.has('link') || $store.fdCtx.has('move')"></div>
{# ── Rename ── #}
<div class="menu-item" x-show="$store.fdCtx.has('rename')" @click="$store.fdCtx.run('rename')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4z"/></svg>
<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">
<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>
</div>
{# ── Duplicate ── #}
<div class="menu-item" x-show="$store.fdCtx.has('duplicate')" @click="$store.fdCtx.run('duplicate')">
<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;">Duplicate</span>
</div>
{# ── Copy link ── #}
<div class="menu-item" x-show="$store.fdCtx.has('link')" @click="$store.fdCtx.run('link')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
<span style="flex:1;">Copy link</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>
<span style="flex:1;">Move to</span><span class="menu-shortcut">Ctrl+⇧+P</span>
</div>
{# ── Favorites ── #}
<div class="menu-item" x-show="$store.fdCtx.has('fav')" @click="$store.fdCtx.run('fav')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/></svg>
<span style="flex:1;" x-text="$store.fdCtx.node && $store.fdCtx.node.favorited ? 'Remove from Favorites' : 'Add to Favorites'"></span>
</div>
{# ── Remove from Recents ── #}
<div class="menu-item" x-show="$store.fdCtx.has('recent')" @click="$store.fdCtx.run('recent')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
<span style="flex:1;">Remove from Recents</span>
</div>
{# ── Tags (workspace) ── #}
<div class="more-sep" x-show="$store.fdCtx.has('tagRemove') || $store.fdCtx.has('tagAdd')"></div>
<div x-show="$store.fdCtx.has('tagRemove') || $store.fdCtx.has('tagAdd')">
<div class="menu-item ctx-label"><span style="flex:1;">Tags</span></div>
<template x-for="t in ($store.fdCtx.node && $store.fdCtx.node.tags) || []" :key="'ct'+t.id">
<div class="menu-item" @click.stop="$store.fdCtx.removeTag(t.id)">
<span style="display:flex;align-items:center;gap:8px;flex:1;overflow:hidden;"><span class="ctx-tag-color" :style="{ background: t.color }"></span><span x-text="t.name" style="overflow:hidden;text-overflow:ellipsis;"></span></span>
<span class="ctx-x" title="Remove tag">×</span>
</div>
</template>
<div class="menu-item ctx-empty" x-show="!($store.fdCtx.node && ($store.fdCtx.node.tags||[]).length)">No tags</div>
<div class="menu-item" x-show="$store.fdCtx.avail().length" @click.stop="$store.fdCtx.tagExistingOpen = !$store.fdCtx.tagExistingOpen">
<span style="flex:1;">Add existing tag</span><span class="ctx-caret" x-text="$store.fdCtx.tagExistingOpen ? '▾' : '▸'"></span>
</div>
<div x-show="$store.fdCtx.tagExistingOpen" class="ctx-sub">
<template x-for="t in $store.fdCtx.avail()" :key="'ca'+t.id">
<div class="menu-item" @click.stop="$store.fdCtx.addExistingTag(t)">
<span style="display:flex;align-items:center;gap:8px;"><span class="ctx-tag-color" :style="{ background: t.color }"></span><span x-text="t.name"></span></span>
</div>
</template>
</div>
<div class="menu-item" @click.stop="$store.fdCtx.tagAdding = !$store.fdCtx.tagAdding">
<span style="flex:1;">+ New tag</span><span class="ctx-caret" x-text="$store.fdCtx.tagAdding ? '▾' : '▸'"></span>
</div>
<div x-show="$store.fdCtx.tagAdding" class="ctx-sub">
<div class="ctx-colors">
<template x-for="c in $store.fdCtx.tagColors" :key="'cc'+c">
<span class="ctx-swatch" :class="{ selected: $store.fdCtx.newTagColor === c }" :style="{ background: c }" @click.stop="$store.fdCtx.newTagColor = c"></span>
</template>
</div>
<div style="display:flex;gap:4px;padding:0 4px;">
<input class="ctx-input" x-ref="ctxTagInput" placeholder="Tag name…" @click.stop
@keydown.enter.prevent="$store.fdCtx.addNewTag($event.target.value, $store.fdCtx.newTagColor); $event.target.value = ''"
@keydown.escape.stop="$store.fdCtx.tagAdding = false">
<button type="button" class="ctx-btn" @click.stop="$store.fdCtx.addNewTag($refs.ctxTagInput.value, $store.fdCtx.newTagColor); $refs.ctxTagInput.value = ''">Add</button>
</div>
</div>
</div>
{# ── Delete (danger) ── #}
<div class="more-sep"></div>
<div class="menu-item menu-danger" @click="$store.fdCtx.run('delete')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6"/><path d="M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
<span style="flex:1;">Move to Trash</span><span class="menu-shortcut">Del</span>
</div>
</div>
+137
View File
@@ -0,0 +1,137 @@
<!-- ═══════════ Database Table View (Notion-style) — Vanilla JS ═══════════ -->
<div id="db-table-container" class="database-table-container">
<!-- View Selector Bar -->
<div id="db-view-bar" class="db-view-bar">
<div class="db-view-tabs">
<button id="db-view-tab-table" class="db-view-tab active">
{{ fd_icon("grid",14) }} Table
</button>
<button id="db-view-add-btn" class="db-view-add" title="Add a view">
{{ fd_icon("plus",14) }}
</button>
<div id="db-add-view-dropdown" class="db-dropdown" style="display:none">
<div class="db-dropdown-header">Add a new view</div>
<div class="db-dropdown-item">{{ fd_icon("grid",14) }} Table</div>
<div class="db-dropdown-item">{{ fd_icon("grid",14) }} Board</div>
<div class="db-dropdown-item">{{ fd_icon("list",14) }} List</div>
<div class="db-dropdown-item">{{ fd_icon("calendar",14) }} Calendar</div>
</div>
</div>
<div class="db-view-actions">
<button id="db-filter-btn" class="db-btn-icon" title="Filter">{{ fd_icon("search",14) }}</button>
<button id="db-new-btn" class="db-btn-new">
<span class="db-new-icon">{{ fd_icon("plus",12) }}</span> New
</button>
</div>
</div>
<!-- Database Table -->
<div id="db-table-wrapper" class="db-table-wrapper">
<table id="db-table" class="db-table">
<thead id="db-thead"></thead>
<tbody id="db-tbody"></tbody>
</table>
</div>
<!-- Add Property Modal -->
<div id="db-prop-modal" class="db-modal-overlay" style="display:none">
<div class="db-modal">
<div class="db-modal-header">
<h3>Add a property</h3>
<button id="db-prop-modal-close">{{ fd_icon("x",14) }}</button>
</div>
<div class="db-modal-body">
<div class="db-modal-field">
<label>Name</label>
<input id="db-prop-name-input" type="text" placeholder="Property name" />
</div>
<div class="db-modal-field">
<label>Type</label>
<select id="db-prop-type-select">
<option value="text">Text</option>
<option value="number">Number</option>
<option value="select">Select</option>
<option value="multi_select">Multi-select</option>
<option value="date">Date</option>
<option value="checkbox">Checkbox</option>
<option value="url">URL</option>
<option value="email">Email</option>
<option value="phone">Phone</option>
</select>
</div>
<div class="db-modal-actions">
<button id="db-prop-cancel" class="db-btn-cancel">Cancel</button>
<button id="db-prop-create" class="db-btn-create">Create</button>
</div>
</div>
</div>
</div>
<!-- Column Header Context Menu -->
<div id="db-col-menu" class="db-col-menu" style="display:none">
<div class="db-col-menu-header">
<span class="db-col-menu-icon" id="db-col-menu-icon">Aa</span>
<span class="db-col-menu-name" id="db-col-menu-name">Name</span>
<span class="db-col-menu-info" id="db-col-menu-info"></span>
</div>
<div class="db-col-menu-sep"></div>
<div class="db-col-menu-item" id="db-col-show-icon">
<span>😊 Show page icon</span>
<span class="db-col-menu-toggle on" id="db-col-icon-toggle">● ON</span>
</div>
<div class="db-col-menu-sep"></div>
<div class="db-col-menu-item has-sub" id="db-col-ai-autofill">
<span>✨ AI Autofill</span>
<span class="db-col-menu-hint">Now with agents</span>
<span class="db-col-menu-arrow">›</span>
</div>
<div class="db-col-menu-sub" id="db-col-ai-sub" style="display:none">
<div class="db-col-menu-item sub-item"><span>✨ Basic</span></div>
<div class="db-col-menu-subnote">Not available for this property type</div>
<div class="db-col-menu-sep"></div>
<div class="db-col-menu-item sub-item"><span>🤖 Custom Agent</span><span class="db-col-menu-tag">New</span></div>
<div class="db-col-menu-subnote">Uses advanced models to fill properties based on web search and your connected workspace.</div>
</div>
<div class="db-col-menu-sep"></div>
<div class="db-col-menu-item" id="db-col-filter"><span>≡ Filter</span></div>
<div class="db-col-menu-item has-sub" id="db-col-sort"><span>↕ Sort</span><span class="db-col-menu-arrow">›</span></div>
<div class="db-col-menu-sub" id="db-col-sort-sub" style="display:none">
<div class="db-col-menu-item sub-item"><span>↑ Sort ascending</span></div>
<div class="db-col-menu-item sub-item"><span>↓ Sort descending</span></div>
</div>
<div class="db-col-menu-item" id="db-col-group"><span>▦ Group</span></div>
<div class="db-col-menu-item has-sub" id="db-col-calc"><span>Σ Calculate</span><span class="db-col-menu-arrow">›</span></div>
<div class="db-col-menu-sub" id="db-col-calc-sub" style="display:none">
<div class="db-col-menu-item sub-item checked"><span>None</span></div>
<div class="db-col-menu-item has-sub sub-item" id="db-col-calc-count"><span>Count</span><span class="db-col-menu-arrow">›</span></div>
<div class="db-col-menu-sub" id="db-col-calc-count-sub" style="display:none">
<div class="db-col-menu-item sub-item"><span>Count all</span></div>
<div class="db-col-menu-item sub-item"><span>Count values</span></div>
<div class="db-col-menu-item sub-item"><span>Count unique values</span></div>
<div class="db-col-menu-item sub-item"><span>Count empty</span></div>
<div class="db-col-menu-item sub-item"><span>Count not empty</span></div>
</div>
<div class="db-col-menu-item has-sub sub-item" id="db-col-calc-pct"><span>Percent</span><span class="db-col-menu-arrow">›</span></div>
<div class="db-col-menu-sub" id="db-col-calc-pct-sub" style="display:none">
<div class="db-col-menu-item sub-item"><span>Percent empty</span></div>
<div class="db-col-menu-item sub-item"><span>Percent not empty</span></div>
</div>
</div>
<div class="db-col-menu-sep"></div>
<div class="db-col-menu-item" id="db-col-freeze"><span>🧊 Freeze</span></div>
<div class="db-col-menu-item" id="db-col-wrap"><span>📝 Wrap content</span></div>
<div class="db-col-menu-sep"></div>
<div class="db-col-menu-item" id="db-col-insert-left"><span>├← Insert left</span></div>
<div class="db-col-menu-item" id="db-col-insert-right"><span>→┤ Insert right</span></div>
</div>
<!-- Side Peek Panel for Database Pages -->
<div id="db-side-peek" class="db-side-peek" style="display:none">
<div class="db-side-peek-resize" id="db-side-peek-resize"></div>
<div class="db-side-peek-header">
<button id="db-side-peek-close" class="db-side-peek-close">×</button>
<button id="db-side-peek-full" class="db-side-peek-full" title="Open full page">↗</button>
</div>
<iframe id="db-side-peek-iframe" src="" style="width:100%;height:100%;border:none"></iframe>
</div>
</div>
+472
View File
@@ -0,0 +1,472 @@
<!-- ═══════════ Database Table Scripts — Vanilla JS ═══════════ -->
<style>.db-cell-invalid{outline:2px solid #e5484d;outline-offset:-1px;border-radius:4px}</style>
<script>
(function() {
'use strict';
// ── SVG Outline Icons ──
function propTypeSvg(type) {
const s = 14;
switch(type) {
case 'text': case 'title':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>';
case 'number':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="6" y1="4" x2="18" y2="20"/><line x1="18" y1="4" x2="6" y2="20"/></svg>';
case 'select':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><circle cx="4" cy="6" r="1.5"/><circle cx="4" cy="12" r="1.5"/><circle cx="4" cy="18" r="1.5"/></svg>';
case 'multi_select':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><rect x="2" y="5" width="4" height="2" rx="0.5"/><rect x="2" y="11" width="4" height="2" rx="0.5"/><rect x="2" y="17" width="4" height="2" rx="0.5"/></svg>';
case 'date':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></svg>';
case 'checkbox':
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>';
case 'url':
return '<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="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"/></svg>';
case 'email':
return '<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="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>';
case 'phone':
return '<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="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>';
default:
return '<svg width="'+s+'" height="'+s+'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7V4h16v3"/><path d="M9 20h6"/><path d="M12 4v16"/></svg>';
}
}
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function cellValue(cpage, prop) {
if (!cpage.property_values_json) return '';
let pv;
try { pv = typeof cpage.property_values_json === 'string' ? JSON.parse(cpage.property_values_json) : cpage.property_values_json; }
catch(e) { return ''; }
const val = pv[String(prop.id)];
if (val === undefined || val === null) return prop.prop_type === 'title' ? (cpage.title || '') : '';
if (Array.isArray(val)) return val.join(', ');
return String(val);
}
// ── DB Instance (one per container) ──
function DBInstance(containerEl, collectionId, initialData) {
const state = {
properties: initialData ? initialData.properties : [],
pages: initialData ? initialData.pages : [],
editingCell: null,
colMenuPropId: null,
colMenuSubOpen: null,
colMenuIconOn: true,
colWidths: {}
};
function getCsrf() { const m = document.cookie.match(/csrf_token=([^;]+)/); return m ? m[1] : ''; }
function render() {
const props = state.properties;
const pages = state.pages;
// Init col widths
for (const p of props) { if (!state.colWidths[p.id]) state.colWidths[p.id] = 200; }
// Build inner HTML
let h = '<div class="db-view-bar" style="padding:4px 0 8px">';
h += '<div class="db-view-tabs"><button class="db-view-tab active"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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"/></svg> Table</button></div>';
h += '<div class="db-view-actions"><button class="db-btn-new db-add-page-btn">+ New</button></div>';
h += '</div>';
h += '<div class="db-table-wrapper"><table class="db-table">';
// Header
h += '<thead><tr class="db-header-row">';
h += '<th class="db-th db-th-handle" style="width:32px;min-width:32px"></th>';
for (const prop of props) {
const w = state.colWidths[prop.id] || 200;
h += '<th class="db-th db-th-property" style="min-width:'+w+'px;width:'+w+'px" data-prop-id="'+prop.id+'">';
h += '<div class="db-th-content" data-prop-id="'+prop.id+'" data-prop-type="'+prop.prop_type+'" data-prop-name="'+escHtml(prop.name)+'">';
h += '<span class="db-prop-type-icon">'+propTypeSvg(prop.prop_type)+'</span>';
h += '<span class="db-prop-name db-col-title-btn">'+escHtml(prop.name)+'</span>';
h += '</div><div class="db-col-resize-handle" data-prop-id="'+prop.id+'"></div></th>';
}
h += '<th class="db-th db-th-add-prop"><button class="db-add-prop-btn db-show-prop-modal-btn">+ Add property</button></th>';
h += '<th class="db-th" style="width:72px;min-width:72px"></th>';
h += '</tr></thead>';
// Body
h += '<tbody>';
for (const cp of pages) {
h += '<tr class="db-row" data-page-id="'+cp.id+'">';
h += '<td class="db-td db-td-handle"><span class="db-drag-handle db-hover-only" style="visibility:hidden">⋮⋮</span></td>';
for (const prop of props) {
const val = cellValue(cp, prop) || '\u2014';
const isNameCol = prop.prop_type === 'title';
h += '<td class="db-td db-td-cell" style="min-width:'+(state.colWidths[prop.id]||200)+'px">';
h += '<div class="db-cell-content" data-page-id="'+cp.id+'" data-prop-id="'+prop.id+'">';
h += '<span class="db-cell-value">'+escHtml(val)+'</span>';
// OPEN button inline in first (title) column
if (isNameCol) {
h += '<button class="db-open-btn db-open-page-btn db-hover-only" data-page-id="'+cp.id+'" title="Open" style="visibility:hidden;margin-left:auto;flex-shrink:0">↗</button>';
}
h += '</div></td>';
}
h += '<td class="db-td db-td-open db-hover-only" style="visibility:hidden;white-space:nowrap"><button class="db-ai-btn db-ai-fill-btn" data-page-id="'+cp.id+'" title="Remplir les propriétés avec l\'IA" style="background:none;border:none;cursor:pointer;font-size:14px;padding:2px 4px;color:var(--text-dim)">✨</button><button class="db-open-btn db-open-page-btn" data-page-id="'+cp.id+'" title="Open">↗</button></td>';
h += '</tr>';
}
// Empty rows + New Page at bottom
const emptyNeeded = Math.max(0, 4 - pages.length);
for (let i = 0; i < emptyNeeded; i++) {
h += '<tr class="db-row db-row-empty"><td class="db-td db-td-handle"></td>';
for (let j = 0; j < props.length; j++) h += '<td class="db-td db-td-cell"><div class="db-cell-content"></div></td>';
h += '<td class="db-td db-td-open"></td></tr>';
}
h += '<tr class="db-row db-row-new"><td class="db-td db-td-handle"><span class="db-plus-icon">+</span></td>';
h += '<td class="db-td db-td-name" colspan="'+(Math.max(1,props.length)+1)+'"><button class="db-new-page-btn db-add-page-btn">New page</button></td></tr>';
h += '</tbody></table></div>';
containerEl.innerHTML = h;
// Bind events
bindEvents();
}
function bindEvents() {
// New page buttons
containerEl.querySelectorAll('.db-add-page-btn').forEach(b => b.onclick = addNewPage);
// Cell edit
containerEl.querySelectorAll('.db-cell-content').forEach(el => {
el.onclick = function(e) {
if (state.editingCell || e.target.closest('.db-open-btn')) return;
startEditCell(parseInt(this.dataset.pageId), parseInt(this.dataset.propId), this);
};
});
// Open buttons
containerEl.querySelectorAll('.db-open-page-btn').forEach(btn => {
btn.onclick = function(e) {
e.stopPropagation();
openPageInSidePeek(parseInt(this.dataset.pageId));
};
});
// AI fill properties
containerEl.querySelectorAll('.db-ai-fill-btn').forEach(btn => {
btn.onclick = function(e) {
e.stopPropagation();
aiFillRow(parseInt(this.dataset.pageId));
};
});
// Hover
containerEl.querySelectorAll('.db-row').forEach(row => {
row.onmouseenter = function() { this.querySelectorAll('.db-hover-only').forEach(el => el.style.visibility = 'visible'); };
row.onmouseleave = function() { this.querySelectorAll('.db-hover-only').forEach(el => el.style.visibility = 'hidden'); };
});
// Header context menu
containerEl.querySelectorAll('.db-th-content').forEach(el => {
el.onclick = function(e) { e.stopPropagation(); showColMenu(parseInt(this.dataset.propId), this.dataset.propType, this.dataset.propName, this); };
});
// Column resize
containerEl.querySelectorAll('.db-col-resize-handle').forEach(handle => {
handle.onpointerdown = function(e) {
e.preventDefault(); e.stopPropagation();
const propId = parseInt(this.dataset.propId);
const th = this.parentElement;
const thLeft = th.getBoundingClientRect().left;
function onMove(ev) {
const newW = Math.max(60, ev.clientX - thLeft);
state.colWidths[propId] = newW;
th.style.width = newW+'px'; th.style.minWidth = newW+'px';
const colIdx = state.properties.findIndex(p => p.id === propId);
if (colIdx >= 0) {
containerEl.querySelectorAll('.db-row').forEach(row => {
const cells = row.querySelectorAll('.db-td-cell');
if (cells[colIdx]) cells[colIdx].style.minWidth = newW+'px';
});
}
}
function onUp() { document.removeEventListener('pointermove',onMove); document.removeEventListener('pointerup',onUp); document.body.style.cursor=''; document.body.style.userSelect=''; }
document.body.style.cursor='col-resize'; document.body.style.userSelect='none';
document.addEventListener('pointermove',onMove); document.addEventListener('pointerup',onUp);
};
});
// Add property modal bind
containerEl.querySelector('.db-show-prop-modal-btn').onclick = function() { showPropModal(); };
}
function startEditCell(pageId, propId, cellEl) {
const cp = state.pages.find(p => p.id === pageId);
const prop = state.properties.find(p => p.id === propId);
if (!cp || !prop) return;
const val = cellValue(cp, prop);
const input = document.createElement('input');
input.type='text'; input.className='db-cell-input'; input.value=val;
cellEl.innerHTML=''; cellEl.appendChild(input); cellEl.classList.add('db-cell-editing'); input.focus();
state.editingCell = {pageId,propId,input,cellEl};
input.onkeydown = function(e) { if(e.key==='Enter') commitEditCell(); else if(e.key==='Escape') cancelEditCell(); };
input.onblur = function() { commitEditCell(); };
}
function commitEditCell() {
if(!state.editingCell) return;
const {pageId,propId,input,cellEl}=state.editingCell; state.editingCell=null;
const val=input.value;
const cp=state.pages.find(p=>p.id===pageId); const prop=state.properties.find(p=>p.id===propId);
if(!cp||!prop) return;
let pv; try{pv=typeof cp.property_values_json==='string'?JSON.parse(cp.property_values_json):cp.property_values_json}catch(e){pv={};}
pv[String(propId)]=val; cp.property_values_json=pv;
if(prop.prop_type==='title') cp.title=val;
cellEl.classList.remove('db-cell-editing');
const isNameCol = prop.prop_type === 'title';
cellEl.innerHTML = '<span class="db-cell-value">'+escHtml(val||'\u2014')+'</span>';
if (isNameCol) {
cellEl.innerHTML += '<button class="db-open-btn db-open-page-btn db-hover-only" data-page-id="'+pageId+'" title="Open" style="visibility:hidden;margin-left:auto;flex-shrink:0">↗</button>';
}
cellEl.onclick = function(e) { if(!state.editingCell && !e.target.closest('.db-open-btn')) startEditCell(pageId,propId,cellEl); };
fetch('/db/pages/'+pageId+'/api',{method:'PUT',headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},body:JSON.stringify({title:cp.title,properties:pv})})
.then(function(r){
if(!r.ok){
return r.json().then(function(d){ throw {status:r.status, detail:(d&&d.detail)||('Error '+r.status)}; });
}
cellEl.classList.remove('db-cell-invalid');
return r.json();
})
.catch(function(err){
// Validation failed (400) → show message + revert the cell.
const msg = (err && err.detail) ? String(err.detail) : ((err&&err.message)||'Failed to save');
if(err && err.status===400){
cellEl.classList.add('db-cell-invalid');
cellEl.title = msg;
}
if(typeof window.showToast==='function') window.showToast('⚠ '+msg,'error');
else alert('⚠ '+msg);
// Revert to the stored value.
const stored = pv[String(propId)];
cellEl.innerHTML = '<span class="db-cell-value">'+escHtml(stored||'\u2014')+'</span>';
if (prop.prop_type === 'title') {
cellEl.innerHTML += '<button class="db-open-btn db-open-page-btn db-hover-only" data-page-id="'+pageId+'" title="Open" style="visibility:hidden;margin-left:auto;flex-shrink:0">↗</button>';
}
cellEl.onclick = function(e) { if(!state.editingCell && !e.target.closest('.db-open-btn')) startEditCell(pageId,propId,cellEl); };
});
}
function cancelEditCell() {
if(!state.editingCell) return;
const {pageId,propId,cellEl}=state.editingCell; state.editingCell=null;
const cp=state.pages.find(p=>p.id===pageId); const prop=state.properties.find(p=>p.id===propId);
if(!cp||!prop) return;
const val=cellValue(cp,prop);
cellEl.classList.remove('db-cell-editing');
const isNameCol = prop.prop_type === 'title';
cellEl.innerHTML = '<span class="db-cell-value">'+escHtml(val||'\u2014')+'</span>';
if (isNameCol) {
cellEl.innerHTML += '<button class="db-open-btn db-open-page-btn db-hover-only" data-page-id="'+pageId+'" title="Open" style="visibility:hidden;margin-left:auto;flex-shrink:0">↗</button>';
}
cellEl.onclick = function(e) { if(!state.editingCell && !e.target.closest('.db-open-btn')) startEditCell(pageId,propId,cellEl); };
}
// ── v5.9.0: AI property suggestions ──
async function aiFillRow(pageId) {
const cp = state.pages.find(p => p.id === pageId);
if (!cp) return;
const props = state.properties.filter(p => p.prop_type !== 'title');
if (!props.length) {
if (typeof window.showToast === 'function') window.showToast('Aucune propriété à remplir');
return;
}
const payload = {
title: cp.title || '',
content: cp.content || '',
properties: props.map(p => ({ name: p.name, type: p.prop_type }))
};
if (typeof window.showToast === 'function') window.showToast('✨ FlowDeck AI : analyse en cours…');
try {
const r = await fetch('/api/agent/writing/properties', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
body: JSON.stringify(payload)
});
const d = await r.json();
if (!d || !d.ok) {
if (typeof window.showToast === 'function') window.showToast((d && d.error) || 'Échec de la génération');
return;
}
let pv;
try { pv = typeof cp.property_values_json === 'string' ? JSON.parse(cp.property_values_json) : (cp.property_values_json || {}); }
catch (e) { pv = {}; }
let filled = 0;
for (const p of props) {
const v = d.suggestions ? d.suggestions[p.name] : undefined;
if (v !== undefined && v !== null && v !== '') { pv[String(p.id)] = v; filled++; }
}
cp.property_values_json = pv;
await fetch('/db/pages/' + pageId + '/api', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
body: JSON.stringify({ title: cp.title, properties: pv })
});
render();
if (typeof window.showToast === 'function') window.showToast(filled ? ('✨ ' + filled + ' propriété(s) remplie(s)') : 'Aucune suggestion disponible');
} catch (e) {
if (typeof window.showToast === 'function') window.showToast('Erreur IA');
}
}
async function addNewPage() {
try {
const r = await fetch('/api/collections/'+collectionId+'/pages',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},body:JSON.stringify({title:''})});
const d = await r.json();
if(d.id) { state.pages.push({id:d.id,title:'',position:state.pages.length,icon:'file',collection_id:collectionId,property_values_json:{}}); render(); }
} catch(e) { console.error('[DB] addNewPage error:', e); }
}
// ── Property modal (inline, self-contained) ──
function showPropModal() {
// Remove any existing inline modal
var old = containerEl.querySelector('.db-prop-modal-inline');
if (old) old.remove();
var modal = document.createElement('div');
modal.className = 'db-prop-modal-inline';
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.3);z-index:1000;display:flex;align-items:center;justify-content:center';
modal.innerHTML = '<div class="db-modal" style="background:var(--bg-modal);border-radius:8px;box-shadow:0 8px 30px rgba(0,0,0,0.15);width:420px;max-width:90vw;overflow:hidden">'+
'<div class="db-modal-header"><h3>Add a property</h3><button class="db-prop-modal-close-btn" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:none;background:transparent;cursor:pointer;color:var(--text-dim);font-size:18px">×</button></div>'+
'<div class="db-modal-body" style="padding:16px">'+
'<div class="db-modal-field" style="margin-bottom:12px"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Name</label><input class="db-prop-name-inline" type="text" placeholder="Property name" style="width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:14px;box-sizing:border-box"></div>'+
'<div class="db-modal-field" style="margin-bottom:12px"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Type</label><select class="db-prop-type-inline" style="width:100%;padding:8px 10px;border:1px solid var(--border);border-radius:6px;font-size:14px;box-sizing:border-box"><option value="text">Text</option><option value="number">Number</option><option value="select">Select</option><option value="multi_select">Multi-select</option><option value="date">Date</option><option value="checkbox">Checkbox</option><option value="url">URL</option><option value="email">Email</option><option value="phone">Phone</option></select></div>'+
'<div style="display:flex;gap:16px;margin-bottom:12px">'+
'<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text)"><input class="db-prop-required-inline" type="checkbox"> Required</label>'+
'<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text)"><input class="db-prop-unique-inline" type="checkbox"> Unique</label>'+
'</div>'+
'<div style="display:flex;gap:10px;margin-bottom:12px">'+
'<div style="flex:1"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Min</label><input class="db-prop-min-inline" type="number" placeholder="—" style="width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:6px;font-size:13px;box-sizing:border-box"></div>'+
'<div style="flex:1"><label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Max</label><input class="db-prop-max-inline" type="number" placeholder="—" style="width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:6px;font-size:13px;box-sizing:border-box"></div>'+
'</div>'+
'<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:16px"><button class="db-prop-cancel-inline" style="padding:6px 14px;border-radius:6px;border:1px solid var(--border);background:var(--bg-primary);color:var(--text-secondary);font-size:13px;cursor:pointer">Cancel</button><button class="db-prop-create-inline" style="padding:6px 14px;border-radius:6px;border:none;background:var(--accent,#2383E2);color:white;font-size:13px;cursor:pointer">Create</button></div>'+
'</div></div>';
document.body.appendChild(modal);
var nameInput = modal.querySelector('.db-prop-name-inline');
nameInput.focus();
modal.querySelector('.db-prop-modal-close-btn').onclick = function(){ modal.remove(); };
modal.querySelector('.db-prop-cancel-inline').onclick = function(){ modal.remove(); };
modal.onclick = function(e){ if(e.target===modal) modal.remove(); };
modal.querySelector('.db-prop-create-inline').onclick = async function(){
var name = nameInput.value.trim();
if(!name) return;
var type = modal.querySelector('.db-prop-type-inline').value;
var validation = {};
if(modal.querySelector('.db-prop-required-inline').checked) validation.required = true;
if(modal.querySelector('.db-prop-unique-inline').checked) validation.unique = true;
var min = modal.querySelector('.db-prop-min-inline').value;
var max = modal.querySelector('.db-prop-max-inline').value;
if(min!=='') validation.min = Number(min);
if(max!=='') validation.max = Number(max);
try {
var r = await fetch('/db/'+collectionId+'/properties/api',{
method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':getCsrf()},
body:JSON.stringify({name:name,prop_type:type,validation:validation})
});
var d = await r.json();
if(d.id){ state.properties.push(d); render(); modal.remove(); }
} catch(e){ console.error('[DB] createProperty error:',e); }
};
nameInput.onkeydown = function(e){ if(e.key==='Enter') modal.querySelector('.db-prop-create-inline').click(); };
}
// ── Column context menu (inline) ──
function showColMenu(propId, propType, propName, anchor) {
// Remove any existing
var old = document.querySelector('.db-col-menu-inline');
if (old) old.remove();
var menu = document.createElement('div');
menu.className = 'db-col-menu-inline';
menu.style.cssText = 'position:fixed;min-width:240px;max-width:320px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.14);z-index:800;padding:4px 0;font-size:13px;color:var(--text-primary)';
var rect = anchor.getBoundingClientRect();
menu.style.top = (rect.bottom+4)+'px'; menu.style.left = rect.left+'px';
menu.innerHTML = '<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;font-weight:600">'+
'<span>'+propTypeSvg(propType)+'</span><span>'+escHtml(propName)+'</span><span style="font-size:11px;color:var(--text-dim)">ⓘ</span></div>'+
'<div style="height:1px;background:var(--border);margin:4px 0"></div>'+
'<div style="padding:6px 12px;cursor:pointer">≡ Filter</div>'+
'<div style="padding:6px 12px;cursor:pointer">↕ Sort</div>'+
'<div style="padding:6px 12px;cursor:pointer">▦ Group</div>'+
'<div style="height:1px;background:var(--border);margin:4px 0"></div>'+
'<div style="padding:6px 12px;cursor:pointer">├← Insert left</div>'+
'<div style="padding:6px 12px;cursor:pointer">→┤ Insert right</div>';
document.body.appendChild(menu);
setTimeout(() => document.addEventListener('click', function onDoc(e) {
if(!menu.contains(e.target)) { menu.remove(); document.removeEventListener('click',onDoc); }
}, {once:true}), 0);
}
// ── Init ──
if (!initialData) {
fetch('/api/collections/'+collectionId+'/table-data').then(r=>r.json()).then(d => {
state.properties = d.properties||[]; state.pages = d.pages||[]; render();
}).catch(e=>console.error('[DB] load error:',e));
} else { render(); }
}
// ── Global API ──
window.FlowDeckDB = {
/** Render a database table into a container element. */
renderInto: function(containerEl, collectionId, initialData) {
new DBInstance(containerEl, collectionId, initialData);
}
};
// ── Full-page init (when page_editor_collection.html is used) ──
const PAGE_COLLECTION_ID = window.__DB_COLLECTION_ID || 0;
if (PAGE_COLLECTION_ID > 0) {
const container = document.getElementById('db-table-container');
if (container) {
{% if collection_data %}
new DBInstance(container, PAGE_COLLECTION_ID, {
properties: {{ collection_data.properties | tojson }},
pages: {{ collection_data.pages | tojson }}
});
{% else %}
new DBInstance(container, PAGE_COLLECTION_ID, null);
{% endif %}
}
}
// ── Global Side Peek for Database Pages (auto-creates panel) ──
function openPageInSidePeek(pageId) {
var panel = document.getElementById('db-side-peek');
if (!panel) {
// Create panel dynamically
panel = document.createElement('div');
panel.id = 'db-side-peek';
panel.style.cssText = 'position:fixed;top:0;right:0;width:560px;height:100vh;background:var(--bg-primary);box-shadow:-4px 0 20px rgba(0,0,0,0.12);z-index:900;display:flex;flex-direction:column';
panel.innerHTML = '<div id="db-side-peek-resize" style="position:absolute;top:0;left:0;width:6px;height:100%;cursor:col-resize;z-index:2"></div>'+
'<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border-bottom:1px solid var(--border);flex-shrink:0">'+
'<button id="db-side-peek-close" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:none;background:transparent;cursor:pointer;font-size:18px;color:var(--text-dim)">×</button>'+
'<button id="db-side-peek-full" style="display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:6px;border:none;background:transparent;cursor:pointer;font-size:18px;color:var(--text-dim)" title="Open full page">↗</button>'+
'</div>'+
'<iframe id="db-side-peek-iframe" src="" style="width:100%;height:100%;border:none;flex:1"></iframe>';
document.body.appendChild(panel);
document.getElementById('db-side-peek-close').onclick = function(){ panel.style.display='none'; document.getElementById('db-side-peek-iframe').src=''; };
document.getElementById('db-side-peek-full').onclick = function(){
var ifr = document.getElementById('db-side-peek-iframe');
if(ifr&&ifr.src) window.open(ifr.src.replace('?embed=1',''),'_blank');
};
// Resize
var rh = document.getElementById('db-side-peek-resize');
rh.onpointerdown = function(e) {
e.preventDefault(); e.stopPropagation();
var sx = e.clientX, sw = panel.offsetWidth;
function mv(ev) { var w = Math.max(300, Math.min(window.innerWidth*0.9, sw-(ev.clientX-sx))); panel.style.width = w+'px'; }
function up() { document.removeEventListener('pointermove',mv); document.removeEventListener('pointerup',up); document.body.style.cursor=''; }
document.body.style.cursor='col-resize';
document.addEventListener('pointermove',mv); document.addEventListener('pointerup',up);
};
}
var iframe = document.getElementById('db-side-peek-iframe');
if (iframe) { iframe.src = '/pages/'+pageId+'?embed=1'; panel.style.display = 'flex'; }
}
function closeSidePeek() {
var panel = document.getElementById('db-side-peek');
if (panel) { panel.style.display = 'none'; }
var iframe = document.getElementById('db-side-peek-iframe');
if (iframe) { iframe.src = ''; }
}
})();
</script>
+1
View File
@@ -136,6 +136,7 @@
</div>
<div class="topbar-right header-actions">
{% include '_notification_bell.html' %}
{{ right_actions|safe if right_actions else '' }}
</div>
</header>
+131
View File
@@ -0,0 +1,131 @@
{# ── Notification bell + dropdown (v4.9.0) ──
Self-contained Alpine component. Polls unread count, opens a panel of
notifications, marks as read. Only rendered for authenticated users. #}
{% if user and user.get('id') %}
<span class="topbar-btn fd-notif-bell" x-data="fdNotifications()" x-init="init()"
@click.outside="open=false" style="position:relative;display:inline-flex;">
<button type="button" class="topbar-btn" @click="toggle()" title="Notifications"
style="padding:6px;position:relative;border:none;background:none;cursor:pointer;color:var(--text);">
{{ fd_icon("bell", 16) }}
<span x-show="unread > 0" x-cloak
class="fd-notif-badge"
x-text="unread > 99 ? '99+' : unread"
style="position:absolute;top:0;right:0;background:#E03E3E;color:#fff;
border-radius:10px;font-size:10px;line-height:1;padding:3px 5px;
font-weight:700;min-width:16px;text-align:center;transform:translate(30%,-30%);"></span>
</button>
<div x-show="open" x-cloak x-transition
class="fd-notif-panel"
style="position:absolute;top:calc(100% + 6px);right:0;width:340px;max-width:92vw;
background:var(--bg-primary,#1f1f1f);border:1px solid var(--border,#333);
border-radius:12px;box-shadow:0 12px 40px rgba(0,0,0,.45);overflow:hidden;z-index:2000;">
<div class="fd-notif-header"
style="display:flex;align-items:center;justify-content:space-between;padding:10px 14px;
border-bottom:1px solid var(--border,#333);font-weight:600;font-size:14px;">
<span>Notifications</span>
<button type="button" class="btn-sm" @click="markAllRead()" x-show="unread > 0"
style="font-size:12px;cursor:pointer;">Mark all read</button>
</div>
<div class="fd-notif-list" style="max-height:360px;overflow-y:auto;">
<template x-for="n in items" :key="n.id">
<a :href="n.url || '#'" @click.prevent="openItem(n)"
class="fd-notif-item"
style="display:flex;gap:10px;padding:10px 14px;text-decoration:none;color:var(--text);
border-bottom:1px solid var(--border,#2a2a2a);cursor:pointer;"
:style="{ background: n.is_read ? 'transparent' : 'rgba(35,131,226,.10)' }">
<div style="width:30px;height:30px;border-radius:50%;flex-shrink:0;
display:flex;align-items:center;justify-content:center;
font-weight:700;font-size:14px;color:#fff;"
:style="{ background: n.actor_color || '#3A3A3A' }">
<template x-if="n.actor_avatar">
<img :src="n.actor_avatar" style="width:30px;height:30px;border-radius:50%;object-fit:cover;">
</template>
<span x-show="!n.actor_avatar" x-text="(n.actor_name || n.actor_login || '?').charAt(0).toUpperCase()"></span>
</div>
<div style="flex:1;min-width:0;">
<div style="font-size:13px;font-weight:600;color:var(--text);" x-text="n.title"></div>
<div style="font-size:12px;color:var(--text-dim,#999);margin-top:2px;white-space:normal;
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;"
x-text="n.message"></div>
<div style="font-size:11px;color:var(--text-tertiary,#777);margin-top:4px;" x-text="timeAgo(n.created_at)"></div>
</div>
</a>
</template>
<div x-show="!loading && items.length === 0"
style="padding:24px;text-align:center;color:var(--text-dim,#999);font-size:13px;">
You're all caught up 🎉
</div>
<div x-show="loading" style="padding:24px;text-align:center;color:var(--text-dim,#999);">Loading…</div>
</div>
</div>
</span>
<script>
document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdNotificationsRegistered) return;
if (window.Alpine) window.Alpine.__fdNotificationsRegistered = true;
Alpine.data('fdNotifications', function () {
return {
open: false, items: [], unread: 0, loading: false, _timer: null,
init() {
this.load();
var self = this;
this._timer = setInterval(function () { self.refreshCount(); }, 30000);
},
toggle() { this.open = !this.open; if (this.open) this.load(); },
timeAgo(s) {
if (!s) return '';
var t = new Date((String(s).includes('Z') || String(s).includes('T') ? s : s + 'Z'));
if (isNaN(t.getTime())) t = new Date(s);
var diff = Math.floor((Date.now() - t.getTime()) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
},
csrf() {
return (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
},
refreshCount() {
var self = this;
fetch('/api/notifications/unread-count', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (d) { self.unread = d.unread || 0; })
.catch(function () {});
},
load() {
var self = this;
this.loading = true;
fetch('/api/notifications?limit=50', { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (d) {
self.items = d.notifications || [];
self.unread = d.unread || 0;
self.loading = false;
})
.catch(function () { self.loading = false; });
},
openItem(n) {
if (!n.is_read) {
var self = this;
fetch('/api/notifications/read', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.csrf() },
body: JSON.stringify({ id: n.id })
}).then(function () { self.refreshCount(); self.load(); });
}
if (n.url) window.location.href = n.url;
this.open = false;
},
markAllRead() {
var self = this;
fetch('/api/notifications/read', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.csrf() },
body: JSON.stringify({})
}).then(function () { self.refreshCount(); self.load(); });
}
};
});
});
</script>
{% endif %}
+820
View File
@@ -0,0 +1,820 @@
<div
class="page-editor-wrapper"
x-data="editorState()"
x-init="loadCoverIcon()"
>
<!-- Share/More dialogs (in content area, triggered by header buttons) -->
<!-- ═══════════ Share / Publish Dialog ═══════════ -->
<div
class="share-dialog"
x-show="shareOpen"
@click.outside="!_justOpened && (shareOpen = false)"
x-transition.opacity.scale.origin.top.right
>
<!-- Header with tabs -->
<div class="sd-header">
<button
class="sd-tab"
:class="{ active: shareTab === 'share' }"
@click="shareTab = 'share'"
>
Share
</button>
<button
class="sd-tab"
:class="{ active: shareTab === 'publish' }"
@click="shareTab = 'publish'"
>
Publish
<span class="sd-badge" x-show="pagePublished"></span>
</button>
<button class="sd-close" @click="shareOpen = false">
✕
</button>
</div>
<!-- ═══════ SHARE TAB ═══════ -->
<div class="sd-body" x-show="shareTab === 'share'" x-transition>
<!-- Invite input -->
<div class="sd-invite-wrap">
<div class="sd-invite-row">
<input
type="text"
class="sd-input"
x-ref="inviteInput"
placeholder="Add people, emails or type a name..."
x-model="inviteEmail"
@input="inviteInput()"
@focus="inviteInput()"
@keydown.enter.prevent="inviteEnter()"
@keydown.down.prevent="inviteMove(1)"
@keydown.up.prevent="inviteMove(-1)"
@keydown.escape="inviteSuggestOpen = false; inviteUsers = []"
autocomplete="off"
/>
<button class="sd-btn-primary" @click="shareInvite()">
Invite
</button>
</div>
<!-- Existing users autocomplete -->
<div
class="sd-user-suggest"
x-ref="inviteSuggest"
x-show="inviteSuggestOpen"
@click.outside="inviteSuggestOpen = false"
x-transition.opacity
>
<template x-for="(u, i) in inviteUsers" :key="u.id">
<div
class="sd-user-item"
:class="{ active: inviteSel === i }"
@mouseenter="inviteSel = i"
@click="pickInviteUser(u)"
>
<div
class="sd-user-avatar"
:style="{ background: u.avatar_color || '#3A3A3A' }"
>
<img
x-show="u.avatar_url"
:src="u.avatar_url"
:alt="u.login"
style="width:100%;height:100%;border-radius:50%;object-fit:cover;"
/>
<span
x-show="!u.avatar_url"
x-text="((u.full_name || u.login || '?').charAt(0).toUpperCase())"
></span>
</div>
<div class="sd-user-meta">
<div class="sd-user-login" x-text="u.login"></div>
<div class="sd-user-name" x-text="u.full_name"></div>
</div>
<div class="sd-user-add">Invite</div>
</div>
</template>
<div class="sd-user-empty" x-show="!inviteUsers.length">No matching users</div>
</div>
</div>
<!-- Context card (shown when independent permissions) -->
<div class="sd-context-card" x-show="false">
Share settings on this page are unlinked from parent
page
</div>
<!-- Participants list -->
<div class="sd-participants" x-show="accessList.length">
<template x-for="a in accessList" :key="a.id">
{% raw %}
<div class="sd-participant">
<div class="sd-participant-info">
<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"
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"
></div>
<div
class="sd-participant-email"
x-text="a.shared_with_email || a.user_login || ''"
></div>
</div>
</div>
<button
class="sd-perm-btn"
@click="a.permOpen = !a.permOpen"
>
<span
x-text="a.permission === 'edit' ? 'Full access' : a.permission === 'comment' ? 'Can comment' : 'Can view'"
></span>
▾
</button>
<div
class="sd-perm-menu"
x-show="a.permOpen"
@click.outside="a.permOpen = false"
>
<div
class="sd-perm-option"
:class="{ active: a.permission === 'edit' }"
@click="updateShare(a.id, 'edit'); a.permOpen=false"
>
<span>Can edit</span>
<span class="sd-perm-desc"
>Edit, suggest and comment</span
>
</div>
<div
class="sd-perm-option"
:class="{ active: a.permission === 'comment' }"
@click="updateShare(a.id, 'comment'); a.permOpen=false"
>
<span>Can comment</span>
<span class="sd-perm-desc"
>Suggest and comment only</span
>
</div>
<div
class="sd-perm-option"
:class="{ active: a.permission === 'view' }"
@click="updateShare(a.id, 'view'); a.permOpen=false"
>
<span>Can view</span>
<span class="sd-perm-desc"
>Read only</span
>
</div>
<div class="sd-perm-sep"></div>
<div
class="sd-perm-option danger"
@click="removeShare(a.id); a.permOpen=false"
>
Remove
</div>
</div>
</div>
{% endraw %}
</template>
</div>
<div class="sd-sep"></div>
<!-- General Access -->
<div class="sd-section">
<div class="sd-section-title">General access</div>
<div style="position: relative">
<button
class="sd-access-btn"
@click="accessMenuOpen = !accessMenuOpen"
>
<span
x-text="generalAccess === 'invited' ? 'Only people invited' : 'Anyone with the link'"
></span>
<span class="chevron-down">▾</span>
</button>
<div
class="sd-access-menu"
x-show="accessMenuOpen"
@click.outside="accessMenuOpen = false"
x-transition
>
<div
class="sd-perm-option"
:class="{ active: generalAccess === 'invited' }"
@click="generalAccess='invited'; accessMenuOpen=false"
>
<span>{{ fd_icon('lock',14) }} Only people invited</span>
<span class="sd-perm-desc"
>People need to be invited</span
>
</div>
<div
class="sd-perm-option"
:class="{ active: generalAccess === 'anyone' }"
@click="generalAccess='anyone'; accessMenuOpen=false"
>
<span>{{ fd_icon('globe',14) }} Anyone with the link</span>
<span class="sd-perm-desc"
>Can view the page</span
>
</div>
</div>
</div>
<!-- Public permission when link is open -->
<div
x-show="generalAccess === 'anyone'"
style="
margin-top: 6px;
display: flex;
align-items: center;
gap: 8px;
"
>
<span
style="
font-size: 13px;
color: var(--text-primary);
"
>Anyone with the link</span
>
<select class="sd-select-sm" x-model="publicPerm">
<option value="viewer">Can view</option>
<option value="commenter">Can comment</option>
<option value="editor">Can edit</option>
</select>
</div>
</div>
<div class="sd-sep"></div>
<!-- v5.5.0: Cover & Icon -->
<div class="sd-section">
<div class="sd-section-title">{{ fd_icon('image',14) }} Cover & Icon</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<button class="sd-btn-secondary" @click="toggleCover()">
<span x-show="!coverOpen">{{ fd_icon('image',14) }} Cover</span>
<span x-show="coverOpen">{{ fd_icon('x',14) }} Hide</span>
</button>
<button class="sd-btn-secondary" @click="toggleIcon()">
<span x-show="!iconOpen">{{ fd_icon('sparkles',14) }} Icon</span>
<span x-show="iconOpen">{{ fd_icon('x',14) }} Hide</span>
</button>
</div>
<div x-show="coverOpen" style="margin-top:12px;" x-transition>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:8px;">
<input type="text" class="sd-input" x-model="coverUrl" placeholder="Cover image URL" style="flex:1;min-width:200px;">
<button class="sd-btn-primary" @click="setCoverUrl()" :disabled="coverLoading" x-show="!coverLoading">Set</button>
<button class="sd-btn-primary" :disabled="coverLoading" x-show="coverLoading">Saving…</button>
<label class="sd-btn-secondary" style="display:flex;align-items:center;gap:6px;">
{{ fd_icon('upload',14) }} Upload
<input type="file" accept="image/*" @change="uploadCover($event)" style="display:none;">
</label>
<button class="sd-btn-danger" @click="removeCover()" x-show="coverUrl">Remove</button>
</div>
<div x-show="coverUrl" style="max-width:320px;border-radius:8px;overflow:hidden;border:1px solid var(--border);">
<img :src="coverUrl" style="width:100%;height:auto;display:block;">
</div>
</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">
<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;">
</div>
</div>
</div>
<div class="sd-sep"></div>
<!-- Footer -->
<div class="sd-footer">
<a href="#" class="sd-footer-link" @click.prevent
>? Learn about sharing</a
>
<button class="sd-footer-action" @click="copyPageLink">
{{ fd_icon("link",14) }} Copy link
</button>
</div>
</div>
<!-- ═══════ PUBLISH TAB ═══════ -->
<div
class="sd-body"
x-show="shareTab === 'publish'"
x-transition
>
<!-- Before publishing -->
<div x-show="!pagePublished" class="sd-publish-hero">
<div class="sd-publish-preview">
<div class="sd-preview-bar"></div>
<div class="sd-preview-title">
{{ fd_icon('globe',14) }} {{ page.title }}
</div>
</div>
<h3 class="sd-publish-heading">Publish to web</h3>
<p class="sd-publish-desc">
Make this page visible to anyone on the internet.
You can share the link with anyone.
</p>
<button
class="sd-btn-primary sd-btn-full"
@click="publishPage()"
>
Publish
</button>
<p class="sd-publish-note">
Your page will be visible to anyone with the link.
</p>
</div>
<!-- After publishing -->
<div x-show="pagePublished">
<div class="sd-url-bar">
<span
style="font-size: 13px; color: var(--text-dim)"
>{{ workspace_key or 'flowdeck' }}</span
>
<input
type="text"
class="sd-url-input"
:value="publishedUrl || pageUrl"
readonly
@click="$event.target.select()"
/>
<button
class="sd-btn-primary"
style="
height: 32px;
padding: 0 12px;
font-size: 12px;
"
@click="copyPageLink"
>
Copy link
</button>
</div>
<div class="sd-sep"></div>
<!-- Settings list -->
<div class="sd-settings">
<div class="sd-setting-row">
<span>{{ fd_icon("link",14) }} Link expires</span>
<span style="color: var(--text-dim)"
>Never ▾</span
>
</div>
<div class="sd-setting-row">
<span>{{ fd_icon('search',14) }} Search engine indexing</span>
<span style="color: var(--text-dim)"
>Off ▾</span
>
</div>
<div class="sd-setting-row">
<span>{{ fd_icon("file",14) }} Allow duplicate as template</span>
<label class="toggle-switch sm">
<input type="checkbox" checked />
<span class="toggle-slider"></span>
</label>
</div>
<div class="sd-setting-row">
<span>{{ fd_icon("edit",14) }} Allow editing</span>
<label class="toggle-switch sm">
<input
type="checkbox"
x-model="pubAllowEdit"
/>
<span class="toggle-slider"></span>
</label>
</div>
<div class="sd-setting-row">
<span>{{ fd_icon("message-square",14) }} Allow comments</span>
<label class="toggle-switch sm">
<input
type="checkbox"
x-model="pubAllowComments"
checked
/>
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="sd-sep"></div>
<button
class="sd-danger-btn"
@click="unpublishPage()"
>
Unpublish
</button>
</div>
</div>
</div>
<!-- ═══════════ Activity Popover ═══════════ -->
<div class="activity-popover"
x-show="activityOpen"
@click.outside="!_justOpened && (activityOpen = false)"
x-transition.opacity.scale.origin.top.right
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:280px;padding:12px;">
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:4px;">Edited <span x-text="timeAgo"></span></div>
<template x-if="pageCreated">
<div style="font-size:12px;color:var(--text-tertiary);">Created <span x-text="formatDate(pageCreated)"></span></div>
</template>
</div>
<!-- More menu dropdown -->
<div
class="more-menu"
x-show="moreOpen"
@click.outside="!_justOpened && (moreOpen = false)"
x-transition
>
<div class="more-menu-item" @click="toggleBacklinks">
{{ fd_icon("link-2",14) }} Linked from
</div>
<div class="more-menu-item" @click="toggleVersions">
{{ fd_icon("history",14) }} Version history
</div>
<div class="more-menu-item" @click="toggleImport">
{{ fd_icon("download",14) }} Import
</div>
<div class="more-menu-item" @click="exportOpen = !exportOpen">↗ Export <span style="margin-left:auto;font-size:10px;opacity:.6">▾</span></div>
<template x-if="exportOpen">
<div class="more-menu-sub">
<div class="more-menu-item" @click="exportPage('markdown')"> Markdown (.md)</div>
<div class="more-menu-item" @click="exportPage('html')"> HTML (.html)</div>
<div class="more-menu-item" @click="exportPage('pdf')"> PDF (.pdf)</div>
<div class="more-menu-item" @click="exportPage('site')"> Site statique (.zip)</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 danger" @click="deletePage">
{{ fd_icon("trash",14) }} Move to Trash
</div>
</div>
<!-- ═══════════ Move to Dialog ═══════════ -->
<div class="move-dialog"
x-show="moveOpen"
@click.outside="moveOpen = false"
x-transition.opacity.scale
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:320px;padding:16px;">
<h3 style="font-size:14px;margin-bottom:12px;">Move to workspace</h3>
<div x-show="moveLoading" style="color:var(--text-tertiary);text-align:center;padding:20px;">Loading...</div>
<div x-show="!moveLoading" style="max-height:240px;overflow-y:auto;">
<template x-for="ws in moveWorkspaces" :key="ws.id">
<div class="move-ws-item"
@click="doMove(ws.id)"
style="display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:6px;cursor:pointer;font-size:13px;">
<span>{{ fd_icon("folder",14) }}</span>
<span x-text="ws.name" style="flex:1;"></span>
<span style="font-size:11px;color:var(--text-tertiary);" x-text="ws.page_count + ' pages'"></span>
</div>
</template>
<div x-show="moveWorkspaces.length === 0" style="color:var(--text-tertiary);text-align:center;padding:12px;">
No workspaces available
</div>
</div>
</div>
<!-- ═══════════ Page Content ═══════════ -->
<div class="page-cover-area" x-show="coverUrl" style="position:relative;height:240px;overflow:hidden;">
<img :src="coverUrl" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:0;">
<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>
<div
class="page-title-input"
contenteditable="true"
id="_titleEl"
data-placeholder="New Page"
@input="dirty=true;save()"
@keydown.enter.prevent="focusBlock()"
@paste.prevent="pastePlain($event)"
spellcheck="false"
>
{{ page.title }}
</div>
</div>
<div
class="blocks-container"
id="_blocksCt"
@click="onCtClick($event)"
></div>
<div
id="_slashMenu"
class="slash-menu"
style="display: none; position: fixed; z-index: 999"
></div>
<!-- ═══════════ @mention autocomplete (v4.9.0) ═══════════ -->
<div id="_mentionMenu" class="mention-menu"
style="display:none;position:fixed;z-index:1200;min-width:240px;max-height:280px;overflow-y:auto;
background:var(--bg-modal,#1f1f1f);border:1px solid var(--border,#333);border-radius:10px;
box-shadow:var(--shadow-modal);padding:6px;"></div>
<!-- ═══════════ Inline comment trigger on selection (v4.9.0) ═══════════ -->
<button type="button" id="_commentSelBtn"
style="display:none;position:fixed;z-index:1100;padding:7px 12px;font-size:13px;font-weight:600;
color:#fff;background:var(--accent,#2383E2);border:none;border-radius:8px;cursor:pointer;
box-shadow:0 4px 16px rgba(0,0,0,.35);" title="Comment on selection"
@click="window.E && window.E.commentOnSelection()">
💬 Comment
</button>
<!-- ═══════════ Comments drawer (v4.9.0) ═══════════ -->
<div class="comments-drawer" x-show="commentsOpen" x-cloak x-transition
style="position:fixed;top:var(--topbar-height,44px);right:0;bottom:0;width:320px;max-width:92vw;
background:var(--bg-primary,#1c1c1c);border-left:1px solid var(--border,#333);
box-shadow:-8px 0 30px rgba(0,0,0,.25);z-index:900;display:flex;flex-direction:column;">
<div class="comments-drawer-header"
style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;
border-bottom:1px solid var(--border,#333);font-weight:600;font-size:14px;">
<span>Comments</span>
<button type="button" class="topbar-btn" @click="toggleComments()" title="Close">✕</button>
</div>
<div class="comments-drawer-list" style="flex:1;overflow-y:auto;padding:12px 16px;">
<div x-show="comments.length === 0" style="text-align:center;color:var(--text-dim,#999);padding:32px 0;">
No comments yet. Select some text and click 💬 Comment.
</div>
<template x-for="c in comments" :key="c.id">
<div class="comment-thread" style="margin-bottom:18px;"
:style="c.anchor_block_id ? 'border-left:3px solid var(--accent);padding-left:10px;' : ''">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
<div style="width:22px;height:22px;border-radius:50%;background:#3A3A3A;color:#fff;
display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;"
:style="{ background: c.author && c.author.avatar_color ? c.author.avatar_color : '#3A3A3A' }">
<span x-text="(c.author ? (c.author.full_name || c.author.login || '?') : '?').charAt(0).toUpperCase()"></span>
</div>
<span style="font-size:12px;font-weight:600;" x-text="c.author ? (c.author.full_name || c.author.login) : 'Unknown'"></span>
<span style="font-size:11px;color:var(--text-dim,#999);margin-left:auto;" x-text="fmtTime(c.created_at)"></span>
</div>
<div style="font-size:13px;white-space:pre-wrap;margin-bottom:6px;" x-text="c.body"></div>
<div style="display:flex;gap:8px;align-items:center;">
<button class="btn-sm" style="font-size:11px;" @click="resolveComment(c.id)"
x-text="c.resolved ? '↪ Reopen' : '✔ Resolve'"></button>
<button class="btn-sm" style="font-size:11px;" x-show="c.user_id === currentUserId"
@click="deleteComment(c.id)">🗑 Delete</button>
</div>
</div>
</template>
</div>
<div class="comments-drawer-input" style="border-top:1px solid var(--border,#333);padding:12px 16px;">
<textarea x-model="commentDraft" rows="2"
placeholder="Add a comment... Use @ to mention someone."
style="width:100%;background:var(--bg-secondary,#2a2a2a);border:1px solid var(--border,#333);
border-radius:8px;color:var(--text);font-size:13px;padding:8px 10px;resize:none;outline:none;"></textarea>
<div style="display:flex;justify-content:flex-end;margin-top:8px;">
<button class="btn btn-primary" style="font-size:12px;padding:6px 14px;" @click="addPageComment()">Comment</button>
</div>
</div>
</div>
<div
class="format-toolbar"
x-show="fmt.open"
:style="{top:fmt.top+'px',left:fmt.left+'px'}"
@mousedown.prevent
>
<button @click="fmtApply('bold')"><strong>B</strong></button>
<button @click="fmtApply('italic')"><em>I</em></button>
<button @click="fmtApply('underline')"><u>U</u></button>
<button @click="fmtApply('strikeThrough')"><s>S</s></button>
<button @click="fmtLink()">{{ fd_icon("link",14) }}</button>
</div>
<!-- ═══════════ Copy Link Toast ═══════════ -->
<div
class="sd-toast"
x-show="toastVisible"
x-transition
x-text="toastMsg"
></div>
<!-- ═══════════ Get Started Toolbar (bottom) ═══════════ -->
<div
class="get-started-toolbar"
x-show="blocks.length === 1 && blocks[0].type === 'paragraph' && !blocks[0].content.trim()"
>
<span class="gs-label">Get started with</span>
<button
class="gs-pill"
@click="askAI()"
>
<span class="gs-icon">{{ fd_icon("sparkles",14) }}</span> Ask AI
</button>
<button
class="gs-pill"
@click="meetingNote()"
>
<span class="gs-icon">{{ fd_icon('edit',14) }}</span> AI meeting note
</button>
<button
class="gs-pill"
@click="createDatabase()"
>
<span class="gs-icon">{{ fd_icon("folder",14) }}</span> Database
</button>
<button
class="gs-pill"
@click="createForm()"
>
<span class="gs-icon">{{ fd_icon("copy",14) }}</span> Form
</button>
<button
class="gs-pill"
@click="gsMoreOpen = !gsMoreOpen"
>
<span class="gs-icon">{{ fd_icon("file",14) }}</span> Templates
</button>
<div style="position: relative">
<button class="gs-pill" @click="gsMoreOpen = !gsMoreOpen">
<span>⋯</span>
</button>
<div
class="gs-more-dropdown"
x-show="gsMoreOpen"
@click.outside="gsMoreOpen = false"
x-transition
>
<div class="gs-more-item" @click="gsMoreOpen=false">
{{ fd_icon("bar-chart",14) }} Table
</div>
<div class="gs-more-item" @click="gsMoreOpen=false">
{{ fd_icon("copy",14) }} Board
</div>
<div class="gs-more-item" @click="gsMoreOpen=false">
{{ fd_icon('list',14) }} List
</div>
<div class="gs-more-item" @click="gsMoreOpen=false">
{{ fd_icon("calendar",14) }} Timeline
</div>
<div class="gs-more-item" @click="gsMoreOpen=false">
{{ fd_icon("calendar",14) }} Calendar
</div>
<div class="gs-more-item" @click="gsMoreOpen=false">
{{ fd_icon("image",14) }} Gallery
</div>
<div class="gs-more-sep"></div>
<div class="gs-more-item" @click="gsMoreOpen=false">
{{ fd_icon("download",14) }} Import
</div>
</div>
</div>
</div>
<div class="editor-statusbar">
<span x-show="saving">Saving...</span>
<span x-show="!saving&&dirty">Unsaved</span>
<span x-show="!saving&&!dirty&&lastSaved"
>Saved <span x-text="lastSaved"></span
></span>
<span class="statusbar-right" x-text="blocks.length+' blocks'"></span>
</div>
<!-- ═══════════ Database template picker (v5.3.0) ═══════════ -->
<div class="fd-dbtpl-overlay" id="fd-dbtpl" x-show="dbTplOpen"
style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;
align-items:flex-start;justify-content:center;padding-top:10vh;opacity:0;transition:opacity .15s ease;"
@click.self="closeDbTemplatePicker()">
<div class="fd-dbtpl" style="width:620px;max-width:94vw;max-height:76vh;display:flex;flex-direction:column;
background:var(--bg-secondary,#232323);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;
box-shadow:0 24px 70px rgba(0,0,0,.6);overflow:hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border);">
<h3 style="margin:0;font-size:16px;color:var(--text,#fff);">Create a database</h3>
<button class="flowdeck-modal-close" @click="closeDbTemplatePicker()">&times;</button>
</div>
<div style="padding:12px 20px;overflow-y:auto;">
<div x-show="dbTplLoading" style="color:var(--text-dim,#999);font-size:13px;padding:20px 0;text-align:center;">Loading templates…</div>
<div x-show="!dbTplLoading">
<!-- Blank -->
<div class="fd-dbtpl-card" @click="createDbFromTemplate(null)"
style="display:flex;align-items:center;gap:12px;padding:12px 14px;border:1px solid var(--border);border-radius:10px;margin-bottom:10px;cursor:pointer;">
<span style="font-size:22px;">📋</span>
<div><div style="font-weight:600;color:var(--text,#fff);font-size:14px;">Blank database</div>
<div style="font-size:12px;color:var(--text-dim,#999);">Start from an empty database</div></div>
</div>
<template x-for="t in dbTemplates" :key="t.id">
<div class="fd-dbtpl-card" @click="createDbFromTemplate(t.name)"
style="display:flex;align-items:center;gap:12px;padding:12px 14px;border:1px solid var(--border);border-radius:10px;margin-bottom:10px;cursor:pointer;">
<span style="font-size:22px;" x-text="t.icon||'📋'"></span>
<div style="flex:1;"><div style="font-weight:600;color:var(--text,#fff);font-size:14px;" x-text="t.name"></div>
<div style="font-size:12px;color:var(--text-dim,#999);" x-text="t.description||''"></div></div>
</div>
</template>
<div x-show="!dbTplLoading && !dbTemplates.length" style="color:var(--text-dim,#999);font-size:13px;padding:16px 0;text-align:center;">
No templates available
</div>
</div>
</div>
</div>
</div>
<!-- ═══════════ v5.4.0: Version History Popover ═══════════ -->
<div class="versions-popover"
x-show="versionsOpen"
@click.outside="versionsOpen = false"
x-transition.opacity.scale.origin.top.right
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:380px;max-height:500px;overflow:hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border);font-weight:600;font-size:14px;">
<span>{{ fd_icon('history',14) }} Version history</span>
<button class="topbar-btn" @click="versionsOpen = false">✕</button>
</div>
<div style="max-height:420px;overflow-y:auto;">
<div x-show="versionsLoading" style="padding:32px;text-align:center;color:var(--text-dim);">Loading…</div>
<template x-if="!versionsLoading && versionsList.length === 0">
<div style="padding:24px;text-align:center;color:var(--text-dim);">No versions yet</div>
</template>
<template x-for="v in versionsList" :key="v.id">
<div class="version-item"
style="padding:12px 16px;border-bottom:1px solid var(--border);cursor:pointer;"
@click="restoreVersion(v.id)">
<div style="display:flex;align-items:center;justify-content:space-between;">
<div style="font-weight:500;color:var(--text-primary);" x-text="v.note || 'Edited'"></div>
<span style="font-size:12px;color:var(--text-dim);" x-text="v.author ? v.author + ' · ' : ''"></span>
</div>
<div style="font-size:12px;color:var(--text-dim);margin-top:4px;" x-text="formatDate(v.created_at)"></div>
</div>
</template>
</div>
</div>
<!-- ═══════════ v5.4.0: Backlinks Popover ═══════════ -->
<div class="backlinks-popover"
x-show="backlinksOpen"
@click.outside="backlinksOpen = false"
x-transition.opacity.scale.origin.top.right
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:360px;max-height:500px;overflow:hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--border);font-weight:600;font-size:14px;">
<span>{{ fd_icon('link-2',14) }} Linked from</span>
<button class="topbar-btn" @click="backlinksOpen = false">✕</button>
</div>
<div style="max-height:420px;overflow-y:auto;">
<div x-show="backlinksLoading" style="padding:32px;text-align:center;color:var(--text-dim);">Loading…</div>
<template x-if="!backlinksLoading && backlinksList.length === 0">
<div style="padding:24px;text-align:center;color:var(--text-dim);">No pages link here</div>
</template>
<template x-for="b in backlinksList" :key="b.id">
<a :href="'/pages/' + b.id" style="display:block;padding:12px 16px;border-bottom:1px solid var(--border);text-decoration:none;color:inherit;" @click.prevent="backlinksOpen=false;window.location.href='/pages/'+b.id">
<div style="font-weight:500;color:var(--text-primary);" x-text="b.title"></div>
<div style="font-size:12px;color:var(--text-dim);margin-top:2px;" x-text="b.workspace || ''"></div>
</a>
</template>
</div>
</div>
<!-- ═══════════ v5.4.0: Import Modal ═══════════ -->
<div class="import-modal"
x-show="importOpen"
@click.self="importOpen = false"
x-transition.opacity
style="position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;display:flex;align-items:center;justify-content:center;padding:20px;">
<div class="import-box" style="width:560px;max-width:94vw;max-height:76vh;background:var(--bg-secondary);border:1px solid var(--border);border-radius:12px;box-shadow:0 24px 70px rgba(0,0,0,.6);overflow:hidden;display:flex;flex-direction:column;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:16px 20px;border-bottom:1px solid var(--border);">
<h3 style="margin:0;font-size:16px;color:var(--text);">{{ fd_icon('download',14) }} Import page</h3>
<button class="flowdeck-modal-close" @click="importOpen = false">&times;</button>
</div>
<div style="padding:20px;overflow-y:auto;flex:1;">
<div style="display:flex;flex-direction:column;gap:16px;">
<!-- Markdown text import -->
<div>
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:8px;color:var(--text);">Markdown text</label>
<textarea x-model="importText" rows="10" placeholder="Paste Markdown here…" style="width:100%;background:var(--bg-primary);border:1px solid var(--border);border-radius:8px;padding:12px;color:var(--text);font-family:var(--font-mono);font-size:13px;resize:vertical;outline:none;box-sizing:border-box;"></textarea>
<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 as new page</button>
</div>
</div>
<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>
<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>
</div>
</div>
+487
View File
@@ -0,0 +1,487 @@
<style>
/* ── v5.13.0 Realtime : présence + curseurs live ── */
.rt-presence{display:inline-flex;align-items:center;gap:2px;margin-right:4px;}
.rt-presence .rt-avatar{width:22px;height:22px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-size:10px;font-weight:600;color:#fff;border:2px solid var(--bg-secondary,#1f1f1f);cursor:default;user-select:none;}
.rt-presence .rt-avatar.rt-me{opacity:.85;box-shadow:0 0 0 1px var(--text-primary,#e6e6e6);}
.rt-presence .rt-count{font-size:11px;color:var(--text-tertiary,#999);margin-left:2px;}
.rt-cursors{position:fixed;inset:0;pointer-events:none;z-index:1200;}
.rt-cursor{position:fixed;transform:translate(-1px,-1px);min-width:2px;width:2px;z-index:1201;border-radius:1px;}
.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>
/* eslint-disable */
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
window.__fdRT = (function () {
const SELF = {
id: {{ (user.get('id') if user else 0) | tojson }},
login: {{ (user.get('login','') if user else '') | tojson }},
full_name: {{ (user.get('full_name','') if user else '') | tojson }},
color: {{ (user.get('avatar_color','') or '' if user else '') | tojson }},
};
const COLORS = [
"#2383E2", "#46A758", "#E5484D", "#F76B15", "#8E4EC6", "#12A594",
"#FFC53D", "#D6409F", "#0091FF", "#3E63DD", "#30A46C", "#FF3333",
];
let E = null; // editorState (window.E)
let ws = null;
let mode = 'off'; // 'ws' | 'poll'
let open = false;
let base = []; // dernier snapshot serveur des blocs
let version = 0;
let pendingOps = 0;
let needSync = false;
let peers = []; // liste des présents (hors moi)
let remoteCursors = {}; // userId -> {peer, block, offset}
let myCursor = null; // {block, offset}
let emitT = null, selT = null, titleT = null, drawT = null, retryT = null, pollT = null;
let _pid = 0;
const clone = (o) => JSON.parse(JSON.stringify(o));
function colorOf(uid) { return COLORS[Math.abs(uid || 0) % COLORS.length]; }
function initials(p) {
const n = (p && (p.full_name || p.login)) || '';
const parts = String(n).trim().split(/\s+/);
if (!parts[0]) return '?';
return ((parts[0][0] || '') + (parts.length > 1 ? (parts[1][0] || '') : '')).toUpperCase().slice(0, 2);
}
function send(obj) {
if (ws && ws.readyState === WebSocket.OPEN) {
try { ws.send(JSON.stringify(obj)); } catch (e) { /* noop */ }
}
}
/* ── ops miroir du serveur (apply_op/merge) ── */
function applyOpJS(blocks, op) {
const t = op && op.type;
if (t === 'insert') {
const blk = op.block || {};
const id = blk.id || ('rb' + Date.now().toString(36));
blk.id = id;
let idx = op.index != null ? op.index : blocks.length;
idx = Math.max(0, Math.min(idx, blocks.length));
return blocks.slice(0, idx).concat([blk]).concat(blocks.slice(idx));
}
if (t === 'update') {
const nb = op.block || {};
if (!nb.id) return blocks;
return blocks.map(b => (b.id === nb.id ? nb : b));
}
if (t === 'delete') {
const bid = op.id;
return blocks.filter(b => b.id !== bid);
}
if (t === 'move') {
const bid = op.id, idx = op.index || 0;
const out = blocks.filter(b => b.id !== bid);
const moved = blocks.find(b => b.id === bid);
if (!moved) return blocks;
const i2 = Math.max(0, Math.min(idx, out.length));
out.splice(i2, 0, moved);
return out;
}
return blocks;
}
/* Diff base → courants : update/delete/move/insert (ordre pour le serveur). */
function diffOps(cur) {
if (!base.length && !cur.length) return [];
const ops = [];
const baseById = {}, curById = {};
base.forEach(b => { baseById[b.id] = b; });
cur.forEach(b => { curById[b.id] = b; });
cur.forEach(b => {
if (baseById[b.id] !== undefined && JSON.stringify(baseById[b.id]) !== JSON.stringify(b)) {
ops.push({ type: 'update', block: clone(b) });
}
});
base.forEach(b => {
if (curById[b.id] === undefined) ops.push({ type: 'delete', id: b.id });
});
// structure : simule l'état serveur (base − supprimés) pour indices valides
let sim = base.filter(b => curById[b.id] !== undefined).map(b => clone(b));
const simById = {}; sim.forEach(b => { simById[b.id] = b; });
const finalOrder = cur.map(b => b.id);
let si = 0;
finalOrder.forEach(id => {
if (simById[id] !== undefined) {
const curPos = sim.findIndex(b => b.id === id);
if (curPos !== si) ops.push({ type: 'move', id, index: si });
const [mv] = sim.splice(curPos, 1);
sim.splice(si, 0, mv);
si++;
} else {
ops.push({ type: 'insert', index: si, block: clone(curById[id]) });
sim.splice(si, 0, curById[id]);
simById[id] = curById[id];
si++;
}
});
return ops;
}
/* ── rendu + présence ── */
function activeBlockId() {
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
return a ? a.bid : null;
}
function refocus(fid) {
if (!fid) return;
setTimeout(() => {
const el = E.getEl(fid);
if (el) { el.focus(); try { (typeof ce === 'function') && ce(el); } catch (e) { /* noop */ } }
}, 30);
}
function renderPresence() {
let host = document.querySelector('.topbar-right.header-actions');
if (!host) return;
let box = document.getElementById('rtPresence');
if (!box) {
box = document.createElement('span');
box.id = 'rtPresence';
box.className = 'rt-presence';
host.insertBefore(box, host.firstChild);
}
const shown = peers.filter(p => p.id !== (SELF.id || 0));
if (!shown.length) { box.style.display = 'none'; return; }
box.style.display = 'inline-flex';
box.innerHTML = '';
shown.forEach(p => {
const c = document.createElement('span');
c.className = 'rt-avatar';
c.title = (p.full_name || p.login) + ' est en train d\u2019éditer' + (mode === 'poll' ? ' (polling)' : '');
c.textContent = initials(p);
c.style.background = p.color || colorOf(p.id);
box.appendChild(c);
});
if (mode === 'poll') {
let off = document.getElementById('rtOffline');
if (!off) {
off = document.createElement('span');
off.id = 'rtOffline';
off.className = 'rt-offline';
off.textContent = '●';
off.title = 'Realtime indisponible — rafraîchissement toutes les 10 s';
host.insertBefore(off, box.nextSibling || null);
}
off.style.display = 'inline';
}
}
/* ── curseurs ── */
function rangeFromOffset(el, offset) {
try {
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let n = walker.nextNode(), count = 0;
while (n) {
const len = (n.nodeValue || '').length;
if (count + len >= offset) {
const r = document.createRange();
r.setStart(n, Math.min(offset - count, len));
r.collapse(true);
return r;
}
count += len;
n = walker.nextNode();
}
const r = document.createRange();
r.selectNodeContents(el);
r.collapse(false);
return r;
} catch (e) { return null; }
}
function drawCursors() {
const layer = document.getElementById('rtCursors');
if (!layer) return;
layer.innerHTML = '';
const ids = Object.keys(remoteCursors);
if (!ids.length) return;
ids.forEach(uid => {
const c = remoteCursors[uid];
if (!c || !c.block) return;
const el = E.getEl(c.block);
if (!el) return;
const r = rangeFromOffset(el, c.offset || 0);
let x, y, h;
if (r) {
const rc = r.getBoundingClientRect();
if (!rc.width && !rc.height) return; // hors viewport positionné
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
} else {
const rc = el.getBoundingClientRect();
x = rc.left; y = rc.top; h = Math.max(rc.height, 16);
}
const m = document.createElement('div');
m.className = 'rt-cursor';
m.style.left = (x - 1) + 'px';
m.style.top = (y - 1) + 'px';
m.style.height = h + 'px';
m.style.background = c.peer.color || colorOf(c.peer.id);
const nm = document.createElement('span');
nm.className = 'rt-cursor-name';
nm.textContent = initials(c.peer);
nm.style.background = m.style.background;
m.appendChild(nm);
layer.appendChild(m);
});
}
function emitCursor() {
const a = E && E.getActiveBlock ? E.getActiveBlock() : null;
let block = null, offset = 0;
if (a && a.el && a.bid) {
block = a.bid;
try { offset = (typeof cp === 'function') ? cp(a.el) : 0; } catch (e) { offset = 0; }
}
const changed = !myCursor || myCursor.block !== block || myCursor.offset !== offset;
myCursor = { block, offset };
if (!changed) return;
send({ t: 'sel', block, offset });
}
function scheduleDraw() {
clearTimeout(drawT);
drawT = setTimeout(drawCursors, 40);
}
/* ── application des changements distants ── */
function applySync(blocks, title) {
if (!E || !E.blocks) return;
const curIds = JSON.stringify((E.blocks || []).map(b => b.id));
const srvIds = JSON.stringify((blocks || []).map(b => b.id));
if (curIds === srvIds) {
base = clone(E.blocks);
E.dirty = false;
return;
}
const fid = activeBlockId();
const curById = {};
(E.blocks || []).forEach(b => { curById[b.id] = b; });
let out;
try {
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) {
tEl.textContent = title;
E.pageTitle = title;
}
E.dirty = true;
base = clone(E.blocks);
E.render();
refocus(fid);
scheduleDraw();
}
function applyRemoteOp(op) {
const fid = activeBlockId();
if (op.type === 'update') {
const nb = op.block || {};
if (nb.id === fid) {
// bloc en cours d'édition : on garde la valeur locale, le serveur a déjà
// l'op ; la prochaine frappe locale repartira (LWW).
base = applyOpJS(base, op);
return;
}
E.blocks = applyOpJS(E.blocks, op);
base = applyOpJS(base, op);
E.dirty = true;
E.render();
refocus(fid);
} else {
E.blocks = applyOpJS(E.blocks, op);
base = applyOpJS(base, op);
E.dirty = true;
E.render();
refocus(fid);
}
scheduleDraw();
}
/* ── diffusion des modifications locales ── */
function emit() {
if (mode !== 'ws' || !open || !E || !E.blocks) return;
if (needSync) { send({ t: 'sync_req' }); return; }
const cur = E.blocks.filter(b => b && b.id);
const ops = diffOps(cur);
if (!ops.length) return;
ops.forEach(op => { send({ t: 'op', op, v: version }); pendingOps++; });
base = clone(cur);
}
function onMsg(m) {
if (!m || !m.t) return;
if (m.t === 'sync') {
version = m.version || 0;
base = clone(m.blocks || []);
needSync = false;
pendingOps = 0;
if (typeof m.blocks === 'undefined') return;
applySync(m.blocks || [], m.title || '');
} else if (m.t === 'ack') {
version = m.v || 0;
if (pendingOps > 0) pendingOps--;
if (m.stale || needSync) { needSync = true; send({ t: 'sync_req' }); }
} else if (m.t === 'op') {
if (m.v) version = m.v;
if (m.from === (SELF.id || 0)) { base = applyOpJS(base, m.op); return; }
applyRemoteOp(m.op);
} else if (m.t === 'title') {
const tEl = document.getElementById('_titleEl');
if (m.from !== (SELF.id || 0) && tEl && document.activeElement !== tEl && E) {
tEl.textContent = m.title || '';
E.pageTitle = m.title || '';
}
if (m.v) version = m.v;
scheduleDraw();
} else if (m.t === 'sel') {
if (m.from === (SELF.id || 0)) return;
if (!m.block) { delete remoteCursors[m.from]; scheduleDraw(); return; }
remoteCursors[m.from] = { peer: m.peer || { id: m.from }, block: m.block, offset: m.offset || 0 };
scheduleDraw();
} else if (m.t === 'welcome') {
peers = (m.peers || []).filter(p => p.id !== (SELF.id || 0));
renderPresence();
} else if (m.t === 'peer_join') {
if (m.peer && m.peer.id !== (SELF.id || 0)) {
peers = peers.filter(p => p.id !== m.peer.id).concat([m.peer]);
renderPresence();
}
} else if (m.t === 'peer_leave') {
peers = peers.filter(p => p.id !== m.id);
delete remoteCursors[m.id];
renderPresence();
scheduleDraw();
}
}
/* ── connexion WS + fallback polling ── */
function startPolling() {
if (pollT) return;
mode = 'poll';
renderPresence();
scheduleDraw();
pollT = setInterval(poll, 10000);
}
function stopPolling() {
if (pollT) { clearInterval(pollT); pollT = null; }
const off = document.getElementById('rtOffline');
if (off) off.style.display = 'none';
}
async function poll() {
if (!E || !_pid) return;
try {
const r = await fetch('/board/api/pages/' + _pid, { credentials: 'same-origin' });
if (!r.ok) return;
const d = await r.json();
if ((d.content_format || 'blocks') !== 'blocks' || !d.content) return;
const serverBlocks = JSON.parse(d.content);
// en cas de modifs locales non persistées : on garde local (LWW au prochain save)
if (E.dirty) return;
const cur = JSON.stringify((E.blocks || []).map(b => b.id));
const srv = JSON.stringify(serverBlocks.map(b => b.id));
if (cur === srv) return;
version = version; // pas de version via polling — on adopte l'état serveur
applySync(serverBlocks, d.title || E.pageTitle);
} catch (e) { /* noop */ }
}
function connect() {
if (!E || !_pid || ws) return;
const proto = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
try {
ws = new WebSocket(proto + window.location.host + '/ws/pages/' + _pid);
} catch (e) {
startPolling();
return;
}
ws.onopen = () => {
open = true;
mode = 'ws';
stopPolling();
send({ t: 'hello' });
};
ws.onmessage = (ev) => {
let m;
try { m = JSON.parse(ev.data); } catch (e) { return; }
onMsg(m);
};
ws.onclose = () => {
open = false;
ws = null;
if (retryT) clearTimeout(retryT);
retryT = setTimeout(connect, 15000);
startPolling();
};
ws.onerror = () => { try { ws.close(); } catch (e) { /* noop */ } };
setTimeout(() => {
if (!open) { try { ws && ws.close(); } catch (e) { /* noop */ } }
}, 5000);
}
function titleInput() {
const tEl = document.getElementById('_titleEl');
if (!tEl) return;
clearTimeout(titleT);
titleT = setTimeout(() => {
send({ t: 'title', title: (tEl.textContent || '').trim() });
}, 500);
}
function wire() {
const ct = document.getElementById('_blocksCt');
if (ct) {
ct.addEventListener('input', () => {
clearTimeout(emitT);
emitT = setTimeout(emit, 350);
setTimeout(emitCursor, 80);
}, true);
ct.addEventListener('keyup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
ct.addEventListener('click', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
ct.addEventListener('mouseup', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 120); }, true);
}
const tEl = document.getElementById('_titleEl');
if (tEl) tEl.addEventListener('input', titleInput);
document.addEventListener('selectionchange', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 150); }, true);
window.addEventListener('scroll', scheduleDraw, true);
window.addEventListener('resize', scheduleDraw);
}
function start(ed) {
if (!ed) return;
if ((ed.contentFormat || 'blocks') !== 'blocks') return;
E = ed;
_pid = ed.pid || 0;
if (!_pid) return;
base = clone(ed.blocks || []);
wire();
connect();
}
// poussée immédiate des ops après une mutation programmatique (v5.10.0)
function syncNow() {
if (mode !== 'ws' || !open || !E || !E.blocks) return;
clearTimeout(emitT);
emit();
}
return { start, syncNow };
})();
</script>
File diff suppressed because it is too large Load Diff
+1
View File
@@ -27,6 +27,7 @@
<span style="width:12px;flex-shrink:0;"></span>
{% endif %}
<span class="page-icon">{% set valid_icons = ["folder","file","star","link","trash","book","home","settings","lock","globe","image","edit","calendar","users","user","search","tag","bar-chart","grid","list","align-left","zap"] %}{% if page.icon in valid_icons %}{{ fd_icon(page.icon,14) }}{% elif page.is_folder %}{{ fd_icon("folder",14) }}{% else %}{{ fd_icon("file",14) }}{% endif %}</span>
{% if page.is_shared %}<span class="page-shared-badge" title="Partagé">👥</span>{% endif %}
{% if page.is_folder %}
<span class="page-name tree-folder-link"
@click.stop="navigateToFolder({{ page.db_id }})"
+17
View File
@@ -0,0 +1,17 @@
{# FlowDeck — Agent message fragment (v4.10.0). Rendered standalone for history. #}
<div class="fd-agent-msg fd-agent-msg-{{ role }}">
<div class="fd-agent-msg-avatar">
{% if role == 'assistant' %}🤖{% else %}👤{% endif %}
</div>
<div class="fd-agent-msg-body">
<div class="fd-agent-msg-meta">{{ role }} · {{ model or '' }}</div>
<div class="fd-agent-msg-content">{{ content }}</div>
{% if tool_calls %}
<ul class="fd-agent-msg-tools">
{% for call in (tool_calls | fromjson_json) %}
<li>🛠 {{ call.name }} — <code>{{ call.arguments | tojson }}</code></li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
File diff suppressed because it is too large Load Diff
+496 -58
View File
@@ -5,7 +5,10 @@
<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=3.0.0">
<link rel="stylesheet" href="/static/css/app.css?v=5.0.0">
<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">
<style>
/* ── Mobile responsive (v4.0.2) ── */
@media (max-width: 768px) {
@@ -88,12 +91,19 @@
font-size: 13px;
}
}
/* Embed mode — hide sidebar + header for peek panels */
body.embed-mode .sidebar { display: none !important; }
body.embed-mode .topbar, body.embed-mode .unified-header { display: none !important; }
body.embed-mode .topbar-right, body.embed-mode .header-actions { display: none !important; }
body.embed-mode .app-layout { margin-left: 0 !important; padding-top: 0 !important; }
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/alpine.min.js" defer></script>
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
<script src="/static/js/sortable.min.js" defer></script>
</head>
<body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'>
<body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'{% if embed_mode %} class="embed-mode"{% endif %}>
<div class="app-layout" x-data="appState()">
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
@@ -202,7 +212,7 @@
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg>
Home
</a>
<button class="nav-icon-btn" title="Chat">
<button class="nav-icon-btn" title="Chat (Ctrl+J)" onclick="window.fdAgent&&window.fdAgent.open()">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
</button>
<button class="nav-icon-btn" title="Inbox">
@@ -215,10 +225,10 @@
<div class="sidebar-scroll">
<!-- Workspace / Private -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('workspace')">
<div class="sidebar-section" x-show="isSectionVisible('workspace')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('workspace')" @contextmenu.prevent="openSectionMenu($event, 'workspace')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.workspace }" x-text="sectionsOpen.workspace ? '▼' : '▶'"></span>
<span class="chevron" :class="{ open: sectionsOpen.workspace }">▶</span>
{% if has_active_workspace %}
<span><span style="vertical-align:sub;margin-right:4px;">{{ fd_icon("folder",16) }}</span>{{ active_ws_name if active_ws_name else "" }}{% if gitea_workspace or (workspace_key and "/" in workspace_key) %} {{ fd_icon("link",12) }}{% endif %}</span>
{% else %}
@@ -230,6 +240,7 @@
<button class="section-action-btn" title="New Page" @click.stop="window.FlowDeck.createPage()">{{ fd_icon("file",16) }}</button>
<button class="section-action-btn" title="New Folder" @click.stop="window.FlowDeck.showCreateFolderModal()">{{ fd_icon("folder",16) }}</button>
<a class="section-action-btn" href="/local-workspace" title="Open workspace page" style="text-decoration:none;">{{ fd_icon("external-link",16) }}</a>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'workspace')" @contextmenu.stop="openSectionMenu($event, 'workspace')">⋮</button>
</div>
{% endif %}
</div>
@@ -254,14 +265,16 @@
<!-- Repository (Gitea) — only visible for Gitea workspaces -->
{% if gitea_workspace %}
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('gitea')">
<div class="sidebar-section" x-show="isSectionVisible('gitea')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('gitea')" @contextmenu.prevent="openSectionMenu($event, 'gitea')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.gitea }" x-text="sectionsOpen.gitea ? '▼' : '▶'"></span>
<span>{{ fd_icon("link",14) }} Repository (Gitea)</span>
<span class="chevron" :class="{ open: sectionsOpen.gitea }">▶</span>
<span class="section-icon">{{ fd_icon("link",14) }}</span>
<span class="section-label">Repository (Gitea)</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="Refresh" @click.stop="if(window._gwData)window._gwData.refreshTree()">{{ fd_icon("refresh",16) }}</button>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'gitea')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.gitea" x-transition>
@@ -273,16 +286,18 @@
{% endif %}
<!-- Meetings -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('meetings')">
<div class="sidebar-section" x-show="isSectionVisible('meetings')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('meetings')" @contextmenu.prevent="openSectionMenu($event, 'meetings')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.meetings }" x-text="sectionsOpen.meetings ? '▼' : '▶'"></span>
<span>Meetings</span>
<span class="chevron" :class="{ open: sectionsOpen.meetings }">▶</span>
<span class="section-icon">{{ fd_icon("calendar",14) }}</span>
<span class="section-label">Meetings</span>
</div>
<div class="sidebar-section-actions">
{% if has_active_workspace %}
<button class="section-action-btn" title="Add" @click.stop="window.FlowDeck.createPage()">+</button>
{% endif %}
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'meetings')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.meetings" x-transition>
@@ -291,14 +306,16 @@
</div>
<!-- Recents -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('recents')">
<div class="sidebar-section" x-show="isSectionVisible('recents')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('recents')" @contextmenu.prevent="openSectionMenu($event, 'recents')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.recents }" x-text="sectionsOpen.recents ? '▼' : '▶'"></span>
<span>Recents</span>
<span class="chevron" :class="{ open: sectionsOpen.recents }">▶</span>
<span class="section-icon">{{ fd_icon("clock",14) }}</span>
<span class="section-label">Recents</span>
</div>
<div class="sidebar-section-actions">
<a href="/library?tab=Recents" class="library-link-btn" title="View in Library" @click.stop>{{ fd_icon("book",14) }}</a>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'recents')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.recents" x-transition>
@@ -314,14 +331,16 @@
</div>
<!-- Favorites -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('favorites')">
<div class="sidebar-section" x-show="isSectionVisible('favorites')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('favorites')" @contextmenu.prevent="openSectionMenu($event, 'favorites')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.favorites }" x-text="sectionsOpen.favorites ? '▼' : '▶'"></span>
<span>Favorites</span>
<span class="chevron" :class="{ open: sectionsOpen.favorites }">▶</span>
<span class="section-icon">{{ fd_icon("star",14) }}</span>
<span class="section-label">Favorites</span>
</div>
<div class="sidebar-section-actions">
<a href="/library?tab=Favorites" class="library-link-btn" title="View in Library" @click.stop>{{ fd_icon("book",14) }}</a>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'favorites')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.favorites" x-transition>
@@ -337,47 +356,81 @@
</div>
<!-- Agents -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('agents')">
<div class="sidebar-section-title"><span>Agents</span></div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.agents" x-transition>
<ul class="sidebar-items"><li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("bot",16) }}</span><span class="page-name text-dim">No agents configured</span></li></ul>
</div>
</div>
<!-- Shared -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('shared')">
<div class="sidebar-section" x-show="isSectionVisible('agents')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('agents')" @contextmenu.prevent="openSectionMenu($event, 'agents')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.shared }" x-text="sectionsOpen.shared ? '▼' : '▶'"></span>
<span>Shared</span>
<span class="chevron" :class="{ open: sectionsOpen.agents }">▶</span>
<span class="section-icon">{{ fd_icon("bot",14) }}</span>
<span class="section-label">Agents</span>
</div>
<div class="sidebar-section-actions">
<a href="/library?tab=Shared" class="library-link-btn" title="View in Library" @click.stop>{{ fd_icon("book",14) }}</a>
<button class="section-action-btn" title="New agent" @click.stop="agentNew()">+</button>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'agents')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.shared" x-transition>
<ul class="sidebar-items" data-section="shared">
{% for page in shared_pages %}
{{ render_tree_item(page) }}
{% endfor %}
{% if not shared_pages %}
<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("users",16) }}</span><span class="page-name text-dim">No shared pages</span></li>
{% endif %}
<div class="sidebar-section-items" x-show="sectionsOpen.agents" x-transition>
<ul class="sidebar-items" data-section="agents">
<template x-for="a in agentList" :key="a.id">
<li class="sidebar-item" @click="agentOpen(a.id)">
<span class="page-icon page-icon-svg" x-html="a.icon || '🤖'"></span>
<span class="page-name" x-text="a.name"></span>
</li>
</template>
<li class="sidebar-item empty-hint" x-show="!agentList.length" @click="agentOpen()"><span class="page-icon page-icon-svg">{{ fd_icon("bot",16) }}</span><span class="page-name text-dim">No agents — click to chat</span></li>
</ul>
</div>
</div>
<!-- Published -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('published')">
<!-- Shared -->
<div class="sidebar-section" x-show="isSectionVisible('shared')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('shared')" @contextmenu.prevent="openSectionMenu($event, 'shared')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.published }" x-text="sectionsOpen.published ? '▼' : '▶'"></span>
<span>Published</span>
<span class="chevron" :class="{ open: sectionsOpen.shared }">▶</span>
<span class="section-icon">{{ fd_icon("users",14) }}</span>
<span class="section-label">Shared</span>
</div>
<div class="sidebar-section-actions">
<a href="/library?tab=Shared" class="library-link-btn" title="View in Library" @click.stop>{{ fd_icon("book",14) }}</a>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'shared')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.shared" x-transition>
<div class="sidebar-subgroup">
<div class="sidebar-subgroup-label">Par moi</div>
<ul class="sidebar-items" data-section="shared-made">
{% for page in shared_made_pages %}
{{ render_tree_item(page) }}
{% endfor %}
{% if not shared_made_pages %}
<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("link",16) }}</span><span class="page-name text-dim">Aucun partage par moi</span></li>
{% endif %}
</ul>
</div>
<div class="sidebar-subgroup">
<div class="sidebar-subgroup-label">Avec moi</div>
<ul class="sidebar-items" data-section="shared-received">
{% for page in shared_received_pages %}
{{ render_tree_item(page) }}
{% endfor %}
{% if not shared_received_pages %}
<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("users",16) }}</span><span class="page-name text-dim">Aucun partage avec moi</span></li>
{% endif %}
</ul>
</div>
</div>
</div>
<!-- Published -->
<div class="sidebar-section" x-show="isSectionVisible('published')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('published')" @contextmenu.prevent="openSectionMenu($event, 'published')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.published }">▶</span>
<span class="section-icon">{{ fd_icon("globe",14) }}</span>
<span class="section-label">Published</span>
</div>
<div class="sidebar-section-actions">
<a href="/library?tab=Published" class="library-link-btn" title="View in Library" @click.stop>{{ fd_icon("book",14) }}</a>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'published')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.published" x-transition>
@@ -394,14 +447,16 @@
<!-- Private -->
{% if auth_method != 'local' or '/' in workspace_key %}
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('private')">
<div class="sidebar-section" x-show="isSectionVisible('private')" x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100">
<div class="sidebar-section-header" @click="toggleSection('private')" @contextmenu.prevent="openSectionMenu($event, 'private')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.private }" x-text="sectionsOpen.private ? '▼' : '▶'"></span>
<span>Private</span>
<span class="chevron" :class="{ open: sectionsOpen.private }">▶</span>
<span class="section-icon">{{ fd_icon("lock",14) }}</span>
<span class="section-label">Private</span>
</div>
<div class="sidebar-section-actions">
<a href="/library?tab=Private" class="library-link-btn" title="View in Library" @click.stop>{{ fd_icon("book",14) }}</a>
<button class="section-action-btn section-menu-btn" title="Section options" @click.stop="openSectionMenu($event, 'private')">⋮</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.private" x-transition>
@@ -417,6 +472,30 @@
</div>
{% endif %}
<!-- Customize Sidebar → shown in customize mode -->
<div class="sidebar-customize-panel" x-show="customizing" x-cloak x-transition:enter="transition ease-out duration-200" x-transition:enter-start="opacity-0 translate-y-2" x-transition:enter-end="opacity-100 translate-y-0">
<div class="scp-header">
<span class="scp-title">Customize Sidebar</span>
<button class="scp-done" @click="toggleCustomize()">Done</button>
</div>
<div class="scp-list">
<template x-for="(cfg, key) in (Object.keys(sidebarConfig).length ? sidebarConfig : {
workspace:{visible:true,order:0},meetings:{visible:true,order:1},
recents:{visible:true,order:2},favorites:{visible:true,order:3},
agents:{visible:true,order:4},shared:{visible:true,order:5},published:{visible:true,order:6}
})" :key="key">
<div class="scp-item" @click="toggleSectionVisibility(key)">
<div class="scp-item-left">
<span class="scp-item-icon" x-text="getSectionIcon(key)"></span>
<span class="scp-item-name" x-text="getSectionLabel(key)"></span>
</div>
<span class="scp-item-eye" x-show="cfg.visible !== false">👁</span>
<span class="scp-item-eye scp-item-eye-off" x-show="cfg.visible === false">👁‍🗨</span>
</div>
</template>
</div>
</div>
<!-- Notion apps / Utilities → replaced by static links footer (v4.0.2) -->
<!-- Bottom links (always visible, no section wrapper) -->
<div class="sidebar-static-links" style="padding: 4px 12px;">
@@ -449,6 +528,51 @@
</div>
</div>
</div><!-- /sidebar-scroll -->
<!-- Section options menu (⋮ dropdown) -->
<div class="section-menu-overlay" x-show="sectionMenu.visible" @click="closeSectionMenu()" @contextmenu.prevent="closeSectionMenu()"></div>
<div class="section-menu" x-show="sectionMenu.visible" x-cloak
:style="{ top: sectionMenu.y + 'px', left: Math.min(sectionMenu.x, window.innerWidth - 220) + 'px' }"
@click.outside="closeSectionMenu()">
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 5)">
<span class="smi-label">Show 5 items</span>
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 5">✓</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 10)">
<span class="smi-label">Show 10 items</span>
<span class="smi-check" x-show="!sidebarConfig[sectionMenu.section] || sidebarConfig[sectionMenu.section].show_count === 10 || sidebarConfig[sectionMenu.section].show_count == null">✓</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 15)">
<span class="smi-label">Show 15 items</span>
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 15">✓</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); setShowCount(sectionMenu.section, 20)">
<span class="smi-label">Show 20 items</span>
<span class="smi-check" x-show="sidebarConfig[sectionMenu.section] && sidebarConfig[sectionMenu.section].show_count === 20">✓</span>
</div>
<div class="section-menu-sep"></div>
<div class="section-menu-item" @click="closeSectionMenu(); moveSection(sectionMenu.section, 'up')">
<span class="smi-icon">↑</span>
<span class="smi-label">Move up</span>
</div>
<div class="section-menu-item" @click="closeSectionMenu(); moveSection(sectionMenu.section, 'down')">
<span class="smi-icon">↓</span>
<span class="smi-label">Move down</span>
</div>
<div class="section-menu-sep"></div>
<div class="section-menu-item" @click="closeSectionMenu(); toggleSectionVisibility(sectionMenu.section)">
<span class="smi-icon">👁</span>
<span class="smi-label" x-text="isSectionVisible(sectionMenu.section) ? 'Hide section' : 'Show section'"></span>
</div>
<div class="section-menu-sep"></div>
<div class="section-menu-item section-menu-customize" @click="closeSectionMenu(); toggleCustomize()">
<span class="smi-icon">⚙️</span>
<span class="smi-label">Customize sidebar</span>
</div>
</div>
<!-- Customize sidebar overlay + panel (full sidebar takeover) -->
<div class="customize-overlay" x-show="customizing" @click="toggleCustomize()" x-cloak></div>
</aside>
<!-- Uncollapse button → inside topbar, no overlap -->
@@ -586,7 +710,7 @@
</div>
<script>
<script data-cfasync="false">
// Inject CSRF token into HTMX headers
(function() {
const getCsrf = () => {
@@ -749,7 +873,7 @@
// ── Workspace context → create workspace item ──
if (ctx.wsId > 0) {
var body = { name: 'Untitled', type: 'page' };
var body = { name: '', type: 'page' };
if (ctx.isGitea) body.workspace_id = ctx.wsId;
fetch('/api/local-workspace/items', {
@@ -825,6 +949,23 @@
return {
init() {
window.appState = this;
this.loadSidebarConfig();
this.loadAgents();
},
agentList: [],
loadAgents() {
var self = this;
fetch('/api/agent').then(function(r){ return r.json(); }).then(function(d){
self.agentList = d.agents || [];
}).catch(function(){});
},
agentOpen(id) {
if(window.fdAgent && window.fdAgent.open){ window.fdAgent.open(); }
else { document.dispatchEvent(new CustomEvent('fd-agent-toggle', { detail: { agentId: id || null } })); }
},
agentNew() {
if(window.fdAgent && window.fdAgent.open){ window.fdAgent.open(); }
else { document.dispatchEvent(new CustomEvent('fd-agent-toggle')); }
},
sidebarCollapsed: false,
mobileSidebarOpen: false,
@@ -986,6 +1127,122 @@
try { localStorage.setItem('fd_sections', JSON.stringify(this.sectionsOpen)); }
catch(e) {}
},
// ── Sidebar customization ──
customizing: false,
sidebarConfig: {},
sectionMenu: { section: null, visible: false, x: 0, y: 0 },
loadSidebarConfig() {
var self = this;
try {
var raw = {{ sidebar_config|default('{}')|safe }};
if (raw && raw.config) {
self.sidebarConfig = raw.config;
} else if (typeof raw === 'object') {
self.sidebarConfig = raw;
}
} catch(e) {
self.sidebarConfig = {};
}
},
saveSidebarConfig() {
var self = this;
fetch('/api/sidebar/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': window.FlowDeck.getCsrfToken() },
body: JSON.stringify({ config: this.sidebarConfig })
}).then(function(r) {
if (!r.ok) self.toast('Failed to save sidebar config', 'error');
}).catch(function() {
self.toast('Failed to save sidebar config', 'error');
});
},
isSectionVisible(key) {
var cfg = this.sidebarConfig[key];
return cfg ? cfg.visible !== false : true;
},
getSectionOrder(key) {
var cfg = this.sidebarConfig[key];
var sections = ['workspace','gitea','meetings','recents','favorites','agents','shared','published'];
if (cfg && typeof cfg.order === 'number') return cfg.order;
return sections.indexOf(key);
},
toggleSectionVisibility(key) {
if (!this.sidebarConfig[key]) this.sidebarConfig[key] = {};
this.sidebarConfig[key].visible = !this.isSectionVisible(key);
this.sidebarConfig = Object.assign({}, this.sidebarConfig);
this.saveSidebarConfig();
},
moveSection(key, direction) {
var self = this;
var sections = Object.keys(this.sidebarConfig).length ?
Object.keys(this.sidebarConfig).sort(function(a,b) {
return (self.sidebarConfig[a] && self.sidebarConfig[a].order || 99) -
(self.sidebarConfig[b] && self.sidebarConfig[b].order || 99);
}) :
['workspace','gitea','meetings','recents','favorites','agents','shared','published'];
var idx = sections.indexOf(key);
if (idx < 0) return;
var swapIdx = direction === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= sections.length) return;
var swapKey = sections[swapIdx];
var aOrder = this.sidebarConfig[key] && this.sidebarConfig[key].order;
var bOrder = this.sidebarConfig[swapKey] && this.sidebarConfig[swapKey].order;
if (aOrder == null) aOrder = idx;
if (bOrder == null) bOrder = swapIdx;
if (!this.sidebarConfig[key]) this.sidebarConfig[key] = {};
if (!this.sidebarConfig[swapKey]) this.sidebarConfig[swapKey] = {};
this.sidebarConfig[key].order = bOrder;
this.sidebarConfig[swapKey].order = aOrder;
this.sidebarConfig = Object.assign({}, this.sidebarConfig);
this.saveSidebarConfig();
},
setShowCount(key, count) {
if (!this.sidebarConfig[key]) this.sidebarConfig[key] = {};
this.sidebarConfig[key].show_count = count;
this.sidebarConfig = Object.assign({}, this.sidebarConfig);
this.saveSidebarConfig();
},
toggleCustomize() {
this.customizing = !this.customizing;
this.sectionMenu.visible = false;
},
openSectionMenu(ev, section) {
ev.stopPropagation();
this.sectionMenu = { section: section, visible: true, x: ev.clientX, y: ev.clientY };
},
closeSectionMenu() {
this.sectionMenu.visible = false;
},
getSectionIcon(key) {
var icons = {
workspace: '📁', meetings: '📅', recents: '🕐', favorites: '⭐',
agents: '🤖', shared: '👥', published: '🌐', gitea: '🔗',
private: '🔒', library: '📚', 'my-tasks': '✅', marketplace: '🛒',
help: '❓', trash: '🗑️'
};
return icons[key] || '📋';
},
getSectionLabel(key) {
var labels = {
workspace: 'Workspace', gitea: 'Repository (Gitea)', meetings: 'Meetings',
recents: 'Recents', favorites: 'Favorites', agents: 'Agents',
shared: 'Shared', published: 'Published', private: 'Private',
library: 'Library', 'my-tasks': 'My Tasks', marketplace: 'Marketplace',
help: 'Help', trash: 'Trash'
};
return labels[key] || key;
},
workspaceMenuOpen: false,
toggleWorkspaceMenu() {
this.workspaceMenuOpen = !this.workspaceMenuOpen;
@@ -995,7 +1252,7 @@
this.sidebarCollapsed = true;
window.location.href = url;
},
openQuickFind() { this.toast('Quick Find — Ctrl+K', 'info'); },
openQuickFind() { if (window.FlowDeckPalette) window.FlowDeckPalette.open(); },
// ── Tree state ──
expandedNodes: {},
@@ -1616,5 +1873,186 @@
</div>
</div>
{% include 'agent_panel.html' %}
<!-- ═══════════════════ COMMAND PALETTE (v5.0.0) — Ctrl+K / Ctrl+P ═══════════════════ -->
<style>
.cmd-palette-overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:2100;display:flex;justify-content:center;padding-top:14vh;opacity:0;visibility:hidden;transition:opacity .12s ease,visibility .12s ease}
.cmd-palette-overlay.open{opacity:1;visibility:visible}
.cmd-palette{width:600px;max-width:92vw;max-height:60vh;display:flex;flex-direction:column;background:var(--bg-secondary,#232323);border:1px solid var(--border,rgba(255,255,255,.08));border-radius:12px;box-shadow:0 24px 70px rgba(0,0,0,.6);overflow:hidden;transform:translateY(8px);transition:transform .15s ease}
.cmd-palette-overlay.open .cmd-palette{transform:translateY(0)}
.cmd-palette-input{width:100%;box-sizing:border-box;padding:16px 18px;background:transparent;border:none;outline:none;color:var(--text,#fff);font-size:16px}
.cmd-palette-input::placeholder{color:var(--text-dim,rgba(255,255,255,.4))}
.cmd-palette-list{overflow-y:auto;border-top:1px solid var(--border,rgba(255,255,255,.06))}
.cmd-palette-group{display:flex;align-items:center;gap:8px;padding:10px 18px 4px;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--text-dim,rgba(255,255,255,.4))}
.cmd-palette-item{display:flex;align-items:center;gap:10px;padding:9px 18px;cursor:pointer;font-size:14px;color:var(--text,#fff)}
.cmd-palette-item .cpi-icon{width:20px;text-align:center;flex:none;color:var(--text-dim,rgba(255,255,255,.5))}
.cmd-palette-item .cpi-main{flex:1;min-width:0}
.cmd-palette-item .cpi-title{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cmd-palette-item .cpi-sub{font-size:12px;color:var(--text-dim,rgba(255,255,255,.45));white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cmd-palette-item .cpi-sub mark{background:none;color:var(--accent,#2383E2);font-weight:600}
.cmd-palette-item .cpi-title mark{background:none;color:var(--accent,#2383E2);font-weight:600}
.cmd-palette-item .cpi-key{margin-left:auto;flex:none;font-size:11px;color:var(--text-dim,rgba(255,255,255,.35));font-family:monospace}
.cmd-palette-item.active{background:var(--bg-hover,#2e2e2e)}
.cmd-palette-empty{padding:22px 18px;color:var(--text-dim,rgba(255,255,255,.4));font-size:13px;text-align:center}
.cmd-palette-footer{display:flex;align-items:center;gap:14px;padding:8px 16px;border-top:1px solid var(--border,rgba(255,255,255,.06));font-size:11px;color:var(--text-dim,rgba(255,255,255,.4))}
.cmd-palette-footer .cpf-kbd{display:inline-flex;align-items:center;gap:3px}
.cmd-palette-footer b{color:var(--text,rgba(255,255,255,.8));font-weight:600}
</style>
<div id="fd-command-palette" class="cmd-palette-overlay" onclick="if(event.target===this)window.FlowDeckPalette&&window.FlowDeckPalette.close()">
<div class="cmd-palette" role="dialog" aria-label="Quick find">
<input id="fd-cp-input" class="cmd-palette-input" type="text"
placeholder="Search pages, databases, or type a command…"
autocomplete="off" spellcheck="false">
<div id="fd-cp-list" class="cmd-palette-list"></div>
<div class="cmd-palette-footer">
<span class="cpf-kbd"><b>↑</b> / <b>↓</b> navigate</span>
<span class="cpf-kbd"><b>↵</b> open</span>
<span class="cpf-kbd"><b>esc</b> close</span>
</div>
</div>
</div>
<script data-cfasync="false">
(function(){
var PALETTE_ACTIONS = [
{ id:'new-page', icon:'📄', title:'New page', sub:'Create a new page in the current workspace', key:'Ctrl N', run:function(){ return window.FlowDeck && window.FlowDeck.createPage ? (window.FlowDeck.createPage(), true) : false; } },
{ id:'goto-library', icon:'📚', title:'Go to Library', sub:'Recents · Favorites · Shared · Published', key:'', run:function(){ window.location.href='/library'; return true; } },
{ id:'goto-mytasks', icon:'✅', title:'My Tasks', sub:'Aggregated tasks across databases', key:'', run:function(){ window.location.href='/my-tasks'; return true; } },
{ id:'goto-trash', icon:'🗑️', title:'Trash', sub:'Recently deleted pages', key:'', run:function(){ window.location.href='/trash'; return true; } },
{ id:'goto-settings', icon:'⚙️', title:'Settings', sub:'Account, integrations, notifications', key:'', run:function(){ window.location.href='/accounts/settings'; return true; } },
{ id:'goto-help', icon:'❓', title:'Help & shortcuts', sub:'Keyboard shortcuts and guides', key:'', run:function(){ window.location.href='/help'; return true; } }
];
var overlay, input, list;
var state = { open:false, items:[], index:0, query:'', timer:null, actions:false };
function esc(s){ return String(s==null?'':s).replace(/[&<>"']/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]; }); }
function highlight(text, q){
text = esc(text);
var terms = (q||'').split(/\s+/).filter(function(t){return t;}).map(function(t){return t.toLowerCase();});
if(!terms.length) return text;
var lower = text.toLowerCase();
var out='', i=0;
for(var k=0;k<terms.length;k++){
var term=terms[k];
if(!term) continue;
var idx = lower.indexOf(term, i);
if(idx>=0){
out += text.slice(i, idx);
out += '<mark>'+text.slice(idx, idx+term.length)+'</mark>';
i = idx + term.length;
}
}
out += text.slice(i);
return out;
}
function render(){
if(!state.open) return;
if(!state.items.length){
list.innerHTML = '<div class="cmd-palette-empty">No results for “'+esc(state.query)+'”</div>';
return;
}
var groups = [];
var current = null;
state.items.forEach(function(item, idx){
var gkey = item.group || (item.type==='collection'?'Databases':(item.type==='page'?'Pages':'Actions'));
if(gkey!==current){ current=gkey; groups.push({gkey:gkey, items:[]}); }
groups[groups.length-1].items.push({item:item, idx:idx});
});
var html='';
groups.forEach(function(g){
html += '<div class="cmd-palette-group">'+esc(g.gkey)+'</div>';
g.items.forEach(function(gi){
var it=gi.item;
var cls = gi.idx===state.index ? 'cmd-palette-item active' : 'cmd-palette-item';
var icon = it.icon || (it.type==='page'?'📄':'📋');
var sub = it.subtitle || it.excerpt || '';
var key = it.key || '';
html += '<div class="'+cls+'" data-idx="'+gi.idx+'" onmouseenter="window.FlowDeckPalette.setIndex('+gi.idx+')" onclick="window.FlowDeckPalette.choose('+gi.idx+')">'
+ '<span class="cpi-icon">'+esc(icon)+'</span>'
+ '<span class="cpi-main"><span class="cpi-title">'+highlight(it.title, state.query)+'</span>'
+ (sub ? '<div class="cpi-sub">'+highlight(sub, state.query)+'</div>' : '')
+ '</span>'
+ (key ? '<span class="cpi-key">'+esc(key)+'</span>' : '')
+ '</div>';
});
});
list.innerHTML = html;
var active = list.querySelector('.cmd-palette-item.active');
if(active) active.scrollIntoView({block:'nearest'});
}
function runSearch(){
var q = input.value.trim();
state.query = q;
state.index = 0;
if(!q){
state.items = PALETTE_ACTIONS.map(function(a){ return Object.assign({group:'Actions', type:'action'}, a); });
state.actions = true;
render();
return;
}
state.actions = false;
fetch('/api/search?q='+encodeURIComponent(q), {headers:{'X-CSRF-Token': document.body.getAttribute('hx-headers') ? (JSON.parse(document.body.getAttribute('hx-headers'))['X-CSRF-Token']||'') : ''}})
.then(function(r){ return r.json(); })
.then(function(data){
if(input.value.trim()!==q) return; // stale
var pages=(data.pages||[]).slice(0,8), cols=(data.collections||[]).slice(0,6);
var items = pages.map(function(p){ return Object.assign({group:'Pages'}, p); })
.concat(cols.map(function(c){ return Object.assign({group:'Databases'}, c); }))
.concat(PALETTE_ACTIONS.map(function(a){ return Object.assign({group:'Actions', type:'action'}, a); }));
state.items = items;
render();
})
.catch(function(){ state.items=[]; render(); });
}
function open(){
if(state.open) return;
state.open=true; overlay.classList.add('open');
document.body.style.overflow='hidden';
setTimeout(function(){ input.focus(); }, 30);
runSearch();
}
function close(){
if(!state.open) return;
state.open=false; overlay.classList.remove('open');
document.body.style.overflow='';
input.blur();
}
function toggle(){ state.open ? close() : open(); }
function setIndex(i){ if(i>=0 && i<state.items.length){ state.index=i; render(); } }
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; }
}
function onKey(e){
var tag=(document.activeElement&&document.activeElement.tagName)||'';
var mod = e.ctrlKey||e.metaKey;
if(mod && (e.key==='k'||e.key==='K'||e.key==='p'||e.key==='P')){
e.preventDefault(); toggle(); return;
}
if(!state.open) return;
if(e.key==='Escape'){ e.preventDefault(); close(); return; }
if(e.key==='ArrowDown'){ e.preventDefault(); setIndex(Math.min(state.index+1, state.items.length-1)); }
else if(e.key==='ArrowUp'){ e.preventDefault(); setIndex(Math.max(state.index-1, 0)); }
else if(e.key==='Enter'){ e.preventDefault(); choose(state.index); }
}
window.FlowDeckPalette = { open:open, close:close, toggle:toggle, setIndex:setIndex, choose:choose, getState:function(){return state;} };
document.addEventListener('DOMContentLoaded', function(){
overlay = document.getElementById('fd-command-palette');
input = document.getElementById('fd-cp-input');
list = document.getElementById('fd-cp-list');
if(!overlay) return;
input.addEventListener('input', function(){ clearTimeout(state.timer); state.timer=setTimeout(runSearch, 150); });
document.addEventListener('keydown', onKey);
});
})();
</script>
</body>
</html>
+1 -1
View File
@@ -290,7 +290,7 @@ document.addEventListener('alpine:init', () => {
// Icon
var iconSpan = document.createElement('span');
iconSpan.className = 'sidebar-icon';
iconSpan.textContent = icon;
iconSpan.innerHTML = icon; // icon = HTML SVG from getSvgIcon(), NOT text
li.appendChild(iconSpan);
// Name
var nameSpan = document.createElement('span');
+255 -101
View File
@@ -88,6 +88,19 @@
.row-title:hover{text-decoration:underline;}
.row-title input{width:100%;font-size:13px;padding:2px 4px;background:var(--bg-primary);border:1px solid var(--accent);border-radius:4px;color:var(--text-primary);outline:none;}
/* ── Tag chips (représentation + filtre) ── */
.row-tags{display:flex;align-items:center;gap:3px;flex-shrink:0;overflow:hidden;max-width:45%;}
.tag-chip{display:inline-flex;align-items:center;gap:3px;font-size:10px;line-height:1;padding:2px 6px;border-radius:999px;color:#fff;white-space:nowrap;border:none;cursor:pointer;font-family:inherit;}
.tag-chip.active{outline:2px solid var(--text-primary);outline-offset:1px;}
.lib-tag-filter{display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding:4px 16px 2px;}
.lib-tag-filter .tf-label{font-size:11px;color:var(--text-dim);margin-right:2px;}
/* ── Shared direction sub-filter pils ── */
.lib-share-directory{display:flex;align-items:center;gap:6px;padding:4px 16px;}
.lib-dir-pill{font-size:12px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:transparent;color:var(--text-secondary);cursor:pointer;transition:background 0.15s,color 0.15s,border-color 0.15s;}
.lib-dir-pill:hover{background:var(--bg-hover);color:var(--text-primary);}
.lib-dir-pill.active{background:var(--accent);border-color:var(--accent);color:#fff;}
/* ── Hover-only elements (hidden until row hover, or when checked/dragging) ── */
.hover-only{opacity:0;transition:opacity 0.12s;flex-shrink:0;}
.lib-row:hover .hover-only,.lib-row.selected .hover-only{opacity:1;}
@@ -247,7 +260,7 @@
</button>
<div class="lib-toolbar-wrap">
<button class="lib-icon-btn" :class="{active: viewOpen}" title="View options" @click.stop="viewOpen=!viewOpen; filterOpen=false; sortOpen=false">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/><circle cx="9" cy="6" r="1.5" fill="currentColor" stroke="none"/><circle cx="15" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="9" cy="18" r="1.5" fill="currentColor" stroke="none"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/><circle cx="9" cy="6" r="1.5" fill="currentColor" stroke="none"/><circle cx="15" cy="12" r="1.5" fill="currentColor" stroke="none"/><circle cx="9" cy="18" r="1.5" fill="currentColor" stroke="none"/></svg>
</button>
<div class="lib-dropdown" x-show="viewOpen" @click.outside="viewOpen=false" x-transition>
<div class="dd-label">Show columns</div>
@@ -258,6 +271,27 @@
</div>
</div>
<!-- Shared direction sub-filter (Par moi / Avec moi) -->
<div class="lib-share-directory" x-show="tab === 'shared'">
<button class="lib-dir-pill" :class="{ active: shareDir === 'all' }" @click="setShareDir('all')">Tous</button>
<button class="lib-dir-pill" :class="{ active: shareDir === 'made' }" @click="setShareDir('made')">Par moi</button>
<button class="lib-dir-pill" :class="{ active: shareDir === 'received' }" @click="setShareDir('received')">Avec moi</button>
</div>
<!-- Tag filter bar -->
<div class="lib-tag-filter" x-show="workspaceTags && workspaceTags.length > 0" x-cloak>
<span class="tf-label">Tags:</span>
<template x-for="t in workspaceTags" :key="'ltf'+t.id">
<button class="tag-chip" :class="{ active: tagFilter === t.name }"
:style="{ background: t.color }"
@click="toggleTagFilter(t.name)">
<span x-text="t.name"></span>
<span style="font-size:10px;opacity:.7" x-text="'('+(t.count||0)+')'"></span>
</button>
</template>
<button x-show="tagFilter" class="tag-chip" style="background:var(--bg-tertiary);color:var(--text-secondary);" @click="tagFilter='', _recomputeFlatItems()">clear</button>
</div>
<!-- Search bar (toggleable) -->
<div class="lib-search-bar" x-show="searchOpen" x-transition>
<input id="lib-search-input" type="text" placeholder="Search in this list..." x-model="searchQuery" @input="doSearch()">
@@ -266,6 +300,10 @@
<!-- ── Multi-select bar ── -->
<div class="lib-select-bar" x-show="selectedCount > 0" x-transition>
<span class="sel-count" x-text="selectedCount + ' selected'"></span>
<button @click="toggleSelectAll()" :title="allSelected ? 'Clear selection' : 'Select all'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" x-show="!allSelected"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" x-show="allSelected" x-cloak><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<button @click="clearSelection()" title="Clear selection">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
@@ -316,6 +354,7 @@
<div class="lib-table" id="lib-table" :class="{'show-author': showAuthor, 'show-source': showSource}">
<div class="lib-table-header">
<div class="th lib-col-name">
<input type="checkbox" style="width:14px;height:14px;accent-color:var(--accent);cursor:pointer;margin-left:2px;margin-right:6px;" :checked="allSelected" @click="toggleSelectAll()" title="Select / unselect all">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Page name
</div>
@@ -345,47 +384,7 @@
</div>
</div>
<!-- ── Row context menu ── -->
<div class="more-menu" x-show="ctxMenuItem" @click.outside="ctxMenuItem=null" x-transition
:style="'top:' + ctxMenuY + 'px;left:' + ctxMenuX + 'px;'">
<template x-if="ctxMenuItem">
<div>
<div class="menu-item" @click="openItem(ctxMenuItem); ctxMenuItem=null">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
Open
</div>
<div class="menu-item" @click="openPeek(ctxMenuItem); ctxMenuItem=null">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="15" y1="3" x2="15" y2="21"/></svg>
Open in side peek
</div>
<div class="menu-item" @click="startRename(ctxMenuItem); ctxMenuItem=null">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4z"/></svg>
Rename
</div>
<div class="menu-item" @click="toggleFavoriteItem(ctxMenuItem); ctxMenuItem=null">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/></svg>
<span x-text="ctxMenuItem.favorited ? 'Remove from Favorites' : 'Add to Favorites'"></span>
</div>
<div class="menu-item" @click="duplicateItem(ctxMenuItem); ctxMenuItem=null">
<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>
Duplicate
</div>
<div class="menu-item" @click="copyLink(ctxMenuItem); ctxMenuItem=null">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 007.54.54l3-3a5 5 0 00-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 00-7.54-.54l-3 3a5 5 0 007.07 7.07l1.71-1.71"/></svg>
Copy link
</div>
<div class="menu-item" @click="openMovePicker([ctxMenuItem.id]); ctxMenuItem=null">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
Move to
</div>
<div class="more-sep"></div>
<div class="menu-item menu-danger" @click="deleteItem(ctxMenuItem); ctxMenuItem=null">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
Move to Trash
</div>
</div>
</template>
</div>
{% include "_ctx_menu.html" %}
<!-- ── Move-to modal ── -->
<div class="move-modal-overlay" x-show="moveModalOpen" @click.self="moveModalOpen=false" x-transition>
@@ -398,7 +397,7 @@
<div class="move-modal-item" @click="confirmMove(0)">{{ fd_icon("file",14) }} <span>Root (no parent)</span></div>
<template x-for="it in moveCandidates" :key="it.id">
<div class="move-modal-item" @click="confirmMove(it.id)">
<span x-html="it.icon ? getSvgIcon(it.icon,14) : getSvgIcon('file',14)"></span> <span x-text="it.title"></span>
<span x-html="_renderIcon(it)"></span> <span x-text="it.title"></span>
</div>
</template>
</div>
@@ -407,6 +406,7 @@
<!-- ── Side Peek Panel ── -->
<div class="peek-overlay" :class="{ open: peekItem !== null }" @click.outside="closePeek()">
<div id="lib-peek-resize-handle" style="position:absolute;left:-4px;top:0;width:8px;height:100%;cursor:col-resize;z-index:501;" title="Drag to resize — click to close"></div>
<template x-if="peekItem">
<div style="display:flex;flex-direction:column;height:100%;">
<div class="peek-header">
@@ -414,7 +414,7 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
<div class="peek-title">
<span x-html="peekItem.icon ? getSvgIcon(peekItem.icon,14) : getSvgIcon('file',14)"></span>
<span x-html="_renderIcon(peekItem)"></span>
<span x-text="peekItem.title"></span>
</div>
<button @click="openItem(peekItem)" title="Open full page">
@@ -445,13 +445,8 @@
</div>
</div>
</div>
<div class="peek-body">
<div x-show="peekLoading" class="peek-loading">Loading…</div>
<div x-show="!peekLoading && peekContent" x-html="peekContent"></div>
<div x-show="!peekLoading && !peekContent" class="peek-empty">
<div style="margin-bottom:8px;">{{ fd_icon("file",32) }}</div>
<div style="font-size:14px;color:var(--text-secondary);">No preview available</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>
</div>
</div>
</template>
@@ -461,7 +456,7 @@
{% endblock %}
{% block scripts %}
<script>
<script data-cfasync="false">
function libraryPage() {
return {
tab: 'recents',
@@ -489,20 +484,20 @@ function libraryPage() {
sortOpen: false,
viewOpen: false,
sourceFilter: 'all',
shareDir: 'all',
showAuthor: false,
showSource: false,
// Tags
workspaceTags: [],
tagFilter: '',
// Sort
sortBy: '',
// More menu
moreOpen: false,
// Row context menu
ctxMenuItem: null,
ctxMenuX: 0,
ctxMenuY: 0,
// Move-to modal
moveModalOpen: false,
moveIds: [],
@@ -583,19 +578,24 @@ function libraryPage() {
} else {
html += '<span style="width:18px;flex-shrink:0;"></span>';
}
// Icon — folders get a folder icon
// Icon — monochrome, comme local-workspace (emoji custom si défini)
var isFolder = item.is_folder || item.has_children;
if (isFolder) {
html += '<span class="row-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z"/></svg></span>';
} else {
html += '<span class="row-icon">' + self._renderIcon(item.icon, 'file') + '</span>';
}
html += '<span class="row-icon">' + self._renderIcon(item) + '</span>';
// Title or rename input
if (isRenaming) {
html += '<input id="rename-inp-' + item.id + '" value="' + self._escAttr(self.renameValue) + '" onkeydown="if(event.key===\'Enter\')window._libData.commitRenameById(' + item.id + ');if(event.key===\'Escape\')window._libData.cancelRename();" onblur="window._libData.commitRenameById(' + item.id + ')" style="flex:1;min-width:80px;font-size:13px;padding:2px 4px;background:var(--bg-primary);border:1px solid var(--accent);border-radius:4px;color:var(--text-primary);outline:none;" onclick="event.stopPropagation()">';
} else {
html += '<span class="row-title" title="' + self._escAttr(item.title) + '">' + self._escHtml(item.title) + '</span>';
}
// Tags
if (item.tags && item.tags.length) {
html += '<span class="row-tags">';
for (var ti = 0; ti < item.tags.length; ti++) {
var tg = item.tags[ti];
html += '<span class="tag-chip" style="background:' + self._escAttr(tg.color || '#787774') + '" title="' + self._escAttr(tg.name) + '">' + self._escHtml(tg.name) + '</span>';
}
html += '</span>';
}
// OPEN button (side peek) — hidden for folders
if (!isFolder) {
html += '<button class="btn-open hover-only" onclick="event.stopPropagation();window._libData.openPeekById(' + item.id + ')" title="Open in side peek">';
@@ -617,12 +617,15 @@ function libraryPage() {
_escHtml(s) { var d=document.createElement('div'); d.textContent = s||''; return d.innerHTML; },
_escAttr(s) { return String(s||'').replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); },
_renderIcon(icon, fallback) {
var name = icon || fallback || 'file';
// Only allow known icon names, otherwise use fallback
var valid = ['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'];
if (valid.indexOf(name) === -1) name = fallback || 'file';
return getSvgIcon(name, 14);
_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>';
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();
if (/\.(png|jpe?g|gif|webp|svg|bmp|ico)$/.test(n)) return getSvgIcon('image', 14);
return getSvgIcon('file', 14);
},
_recomputeFlatItems() {
@@ -634,6 +637,12 @@ function libraryPage() {
return (r.title || '').toLowerCase().indexOf(q) !== -1;
});
}
if (this.tagFilter) {
var tf = this.tagFilter;
result = result.filter(function(r) {
return (r.tags || []).some(function(t) { return t.name === tf; });
});
}
// Use splice for Alpine 3.14.9 reactivity (array reference reassign fails)
this.flatItems.splice(0, this.flatItems.length, ...result);
this.selectedCount = Object.keys(this.selected).length;
@@ -696,9 +705,30 @@ function libraryPage() {
evt.preventDefault();
var item = this._findItem(id);
if (!item) return;
this.ctxMenuItem = item;
this.ctxMenuX = Math.min(evt.clientX, window.innerWidth - 220);
this.ctxMenuY = Math.min(evt.clientY, window.innerHeight - 320);
if (!(window.Alpine && Alpine.store('fdCtx'))) return;
/* MENU UNIFIÉ (partial _ctx_menu.html + store fdCtx) — handlers library
complète l'union. Open folder/tags non prévus côté library :
fdCtx les cache automatiquement via handlers absents. */
var self = this;
var handlers = {
open: function (n) { self.openItem(n); },
openTab: function (n) { self.openInNewTab(n); },
peek: function (n) { self.openPeek(n); },
rename: function (n) { self.startRename(n); },
setIcon: function (icon) { self.setItemIcon(item, icon); },
duplicate: function (n) { self.duplicateItem(n); },
link: function (n) { self.copyLink(n); },
move: function (n) { self.openMovePicker([n.id]); },
fav: function (n) { self.toggleFavoriteItem(n); },
delete: function (n) { self.deleteItem(n); },
tagExisting: function (t) { self.ctxAddExistingTag(t); },
tagAdd: function (name, color) { self.ctxAddNewTag(name, color); },
tagRemove: function (tagId) { self.ctxRemoveTag(tagId); }
};
if (this.tab === 'recents') handlers.recent = function (n) { self.removeItemFromRecents(n); };
var store = Alpine.store('fdCtx');
store.setAvail(self._ctxAvailTags(item));
store.openMenu(evt, item, 'library', handlers);
},
// ── Drag & drop (move / reparent) ──
@@ -833,6 +863,7 @@ function libraryPage() {
this.tab = t;
}
this._recomputeEmpty();
this.loadWorkspaceTags();
console.log('[Library] loading tab: ' + this.tab);
this.loadTab();
},
@@ -841,6 +872,8 @@ function libraryPage() {
if (this.tab === t) return;
if (t === 'repository' && !this.isGiteaActive) return;
this.tab = t;
this.shareDir = 'all';
this.tagFilter = '';
this.items = [];
this.selected = {};
this.selectedCount = 0;
@@ -879,6 +912,9 @@ function libraryPage() {
} else if (this.tab !== 'repository' && this.tab !== 'workspace' && this.sourceFilter && this.sourceFilter !== 'all') {
url += sep + 'source_type=' + encodeURIComponent(this.sourceFilter); sep = '&';
}
if (this.tab === 'shared' && this.shareDir && this.shareDir !== 'all') {
url += sep + 'dir=' + encodeURIComponent(this.shareDir); sep = '&';
}
var r = await fetch(url);
var d = await r.json();
// Backend already computes has_children correctly for each item (children
@@ -903,6 +939,12 @@ function libraryPage() {
this.loadTab();
},
setShareDir(v) {
if (this.shareDir === v) return;
this.shareDir = v;
this.loadTab();
},
setSort(field) {
this.sortBy = field;
this.sortOpen = false;
@@ -1053,6 +1095,106 @@ function libraryPage() {
if (window.showToast) window.showToast('Link copied', 'success');
},
openInNewTab(item) {
if (!item || !item.id) return;
window.open('/pages/' + item.id, '_blank');
},
removeItemFromRecents(item) {
if (!item) return;
var id = item.id;
this.items = this.items.filter(function(it) { return it.id !== id; });
this._recomputeFlatItems();
if (window.showToast) window.showToast('Removed from Recents', 'success');
},
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',
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': this._getCsrf()},
body: JSON.stringify({icon: icon})
});
if (!r.ok) throw new Error('icon update failed');
item.page_icon = icon;
this._renderTable();
if (window.showToast) window.showToast('Icon updated', 'success');
} catch(e) {
if (window.showToast) window.showToast('Failed to update icon', 'error');
}
},
// ── Tags ──
async loadWorkspaceTags() {
try {
var r = await fetch('/api/settings/tags/all');
var d = await r.json();
this.workspaceTags = d.tags || [];
} catch(e) { this.workspaceTags = []; }
},
toggleTagFilter(tagName) {
this.tagFilter = (this.tagFilter === tagName) ? '' : tagName;
this._recomputeFlatItems();
},
_ctxAvailTags(item) {
return (this.workspaceTags || []).filter(function(t) {
return !((item && item.tags) || []).some(function(nt) { return nt.id === t.id; });
});
},
_ctxRefreshAvail() {
var store = window.Alpine && Alpine.store('fdCtx');
if (store && store.node) store.setAvail(this._ctxAvailTags(store.node));
},
ctxAddExistingTag(tag) {
this.ctxAddNewTag(tag.name, tag.color);
},
ctxRemoveTag(tagId) {
var store = window.Alpine && Alpine.store('fdCtx');
var item = store && store.node;
if (!item) return;
var self = this;
fetch('/api/local-workspace/items/' + item.id + '/tags/' + tagId, { method: 'DELETE' })
.then(function(r) {
if (!r.ok) return;
item.tags = (item.tags || []).filter(function(t) { return t.id !== tagId; });
self.loadWorkspaceTags();
self._ctxRefreshAvail();
self._recomputeFlatItems();
});
},
async ctxAddNewTag(tagName, color) {
tagName = (tagName || '').trim();
if (!tagName) return;
var store = window.Alpine && Alpine.store('fdCtx');
var item = store && store.node;
if (!item) return;
color = color || (store && store.newTagColor) || '#787774';
try {
var r = await fetch('/api/local-workspace/items/' + item.id + '/tags', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: tagName, color: color})
});
if (r.ok) {
var d = await r.json();
var cur = item.tags || [];
if (!cur.some(function(t) { return t.id === d.tag.id; })) item.tags = cur.concat([d.tag]);
await this.loadWorkspaceTags();
this._ctxRefreshAvail();
this._recomputeFlatItems();
}
} catch(e) {}
},
async toggleFavoriteItem(item) {
if (!item) return;
var csrf = this._getCsrf();
@@ -1202,40 +1344,15 @@ function libraryPage() {
// ── Side peek ──
async openPeek(item) {
this.peekItem = item;
this.peekContent = '';
this.peekLoading = true;
this.peekFavorited = item.favorited || false;
try {
var r = await fetch('/board/api/pages/' + item.id);
var d = await r.json();
var content = d.content || '';
// Simple preview
if (d.content_format === 'blocks') {
try {
var blocks = typeof content === 'string' ? JSON.parse(content) : content;
var preview = '';
var lines = Array.isArray(blocks) ? blocks.slice(0, 30) : ((blocks && blocks.blocks) ? blocks.blocks.slice(0, 30) : []);
for (var i = 0; i < lines.length; i++) {
var b = lines[i];
var txt = b.content || b.text || (typeof b === 'string' ? b : '');
if (txt && txt.trim()) preview += '<div style="margin-bottom:6px;font-size:14px;line-height:1.5;color:var(--text-primary);">' + this._escHtml(String(txt)) + '</div>';
}
this.peekContent = preview || '<div style="color:var(--text-dim);">(empty page)</div>';
} catch(e) {
this.peekContent = '<pre style="font-size:13px;color:var(--text-primary);white-space:pre-wrap;">' + this._escHtml(String(content).substring(0, 2000)) + '</pre>';
}
} else {
this.peekContent = content ? '<div style="font-size:14px;line-height:1.6;color:var(--text-primary);white-space:pre-wrap;">' + this._escHtml(String(content).substring(0, 2000)) + '</div>' : '';
}
} catch(e) {
this.peekContent = '';
}
this.peekLoading = false;
var iframe = document.getElementById('lib-peek-iframe');
if (iframe) iframe.src = '/pages/' + item.id + '?embed=1';
},
closePeek() {
this.peekItem = null;
this.peekContent = '';
var iframe = document.getElementById('lib-peek-iframe');
if (iframe) iframe.src = '';
},
async toggleFavoritePeek() {
@@ -1303,5 +1420,42 @@ function libraryPage() {
}
};
}
// Init peek resize handle
(function() {
var panel = document.querySelector('.peek-overlay');
var handle = document.getElementById('lib-peek-resize-handle');
if (!panel || !handle) return;
var startX, startWidth, dragging = false;
handle.addEventListener('pointerdown', function(e) {
e.preventDefault();
handle.setPointerCapture(e.pointerId);
startX = e.clientX;
startWidth = panel.offsetWidth;
dragging = true;
document.body.style.userSelect = 'none';
});
handle.addEventListener('pointermove', function(e) {
if (!dragging) return;
var deltaX = startX - e.clientX;
var newWidth = Math.max(300, Math.min(window.innerWidth * 0.9, startWidth + deltaX));
panel.style.width = newWidth + 'px';
});
handle.addEventListener('pointerup', function(e) {
if (!dragging) return;
document.body.style.userSelect = '';
var moved = Math.abs(e.clientX - startX);
if (moved < 3) {
var el = document.querySelector('.peek-overlay');
if (el && el.__x) { el.__x.$data.peekItem = null; }
}
dragging = false;
try { localStorage.setItem('fd_peek_width', panel.style.width); } catch(ex) {}
handle.releasePointerCapture(e.pointerId);
});
try {
var saved = localStorage.getItem('fd_peek_width');
if (saved) panel.style.width = saved;
} catch(ex) {}
})();
</script>
{% endblock %}
File diff suppressed because it is too large Load Diff
+6 -927
View File
@@ -2,934 +2,13 @@
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% set page_icon = "file" if page.content_format != 'file' else "paperclip" %}
{% set page_title = page.title %}
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()">' ~ fd_icon("lock",14) ~ ' Share ▾</button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="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 %}
<div class="page-editor-wrapper" x-data="editorState()">
<!-- Share/More dialogs (in content area, triggered by header buttons) -->
<!-- ═══════════ Share / Publish Dialog ═══════════ -->
<div
class="share-dialog"
x-show="shareOpen"
@click.outside="!_justOpened && (shareOpen = false)"
x-transition.opacity.scale.origin.top.right
>
<!-- Header with tabs -->
<div class="sd-header">
<button
class="sd-tab"
:class="{ active: shareTab === 'share' }"
@click="shareTab = 'share'"
>
Share
</button>
<button
class="sd-tab"
:class="{ active: shareTab === 'publish' }"
@click="shareTab = 'publish'"
>
Publish
<span class="sd-badge" x-show="pagePublished"></span>
</button>
<button class="sd-close" @click="shareOpen = false">
✕
</button>
</div>
<!-- ═══════ SHARE TAB ═══════ -->
<div class="sd-body" x-show="shareTab === 'share'" x-transition>
<!-- Invite input -->
<div class="sd-invite-row">
<input
type="email"
class="sd-input"
placeholder="Add people, groups or emails..."
x-model="inviteEmail"
@keydown.enter="shareInvite()"
/>
<button class="sd-btn-primary" @click="shareInvite()">
Invite
</button>
</div>
<!-- Context card (shown when independent permissions) -->
<div class="sd-context-card" x-show="false">
Share settings on this page are unlinked from parent
page
</div>
<!-- Participants list -->
<div class="sd-participants" x-show="accessList.length">
<template x-for="a in accessList" :key="a.email">
{% raw %}
<div class="sd-participant">
<div class="sd-participant-info">
<div
class="sd-avatar"
x-text="a.email[0].toUpperCase()"
></div>
<div>
<div
class="sd-participant-name"
x-text="a.email"
></div>
<div
class="sd-participant-email"
x-text="a.email"
></div>
</div>
</div>
<button
class="sd-perm-btn"
@click="a.permOpen = !a.permOpen"
>
<span
x-text="a.permission === 'editor' ? 'Full access' : a.permission === 'commenter' ? 'Can comment' : 'Can view'"
></span>
▾
</button>
<div
class="sd-perm-menu"
x-show="a.permOpen"
@click.outside="a.permOpen = false"
>
<div
class="sd-perm-option"
:class="{ active: a.permission === 'editor' }"
@click="a.permission='editor'; a.permOpen=false"
>
<span>Can edit</span>
<span class="sd-perm-desc"
>Edit, suggest and comment</span
>
</div>
<div
class="sd-perm-option"
:class="{ active: a.permission === 'commenter' }"
@click="a.permission='commenter'; a.permOpen=false"
>
<span>Can comment</span>
<span class="sd-perm-desc"
>Suggest and comment only</span
>
</div>
<div
class="sd-perm-option"
:class="{ active: a.permission === 'viewer' }"
@click="a.permission='viewer'; a.permOpen=false"
>
<span>Can view</span>
<span class="sd-perm-desc"
>Read only</span
>
</div>
<div class="sd-perm-sep"></div>
<div
class="sd-perm-option danger"
@click="removeAccess(a.email); a.permOpen=false"
>
Remove
</div>
</div>
</div>
{% endraw %}
</template>
</div>
<div class="sd-sep"></div>
<!-- General Access -->
<div class="sd-section">
<div class="sd-section-title">General access</div>
<div style="position: relative">
<button
class="sd-access-btn"
@click="accessMenuOpen = !accessMenuOpen"
>
<span
x-text="generalAccess === 'invited' ? 'Only people invited' : 'Anyone with the link'"
></span>
<span class="chevron-down">▾</span>
</button>
<div
class="sd-access-menu"
x-show="accessMenuOpen"
@click.outside="accessMenuOpen = false"
x-transition
>
<div
class="sd-perm-option"
:class="{ active: generalAccess === 'invited' }"
@click="generalAccess='invited'; accessMenuOpen=false"
>
<span>{{ fd_icon('lock',14) }} Only people invited</span>
<span class="sd-perm-desc"
>People need to be invited</span
>
</div>
<div
class="sd-perm-option"
:class="{ active: generalAccess === 'anyone' }"
@click="generalAccess='anyone'; accessMenuOpen=false"
>
<span>{{ fd_icon('globe',14) }} Anyone with the link</span>
<span class="sd-perm-desc"
>Can view the page</span
>
</div>
</div>
</div>
<!-- Public permission when link is open -->
<div
x-show="generalAccess === 'anyone'"
style="
margin-top: 6px;
display: flex;
align-items: center;
gap: 8px;
"
>
<span
style="
font-size: 13px;
color: var(--text-primary);
"
>Anyone with the link</span
>
<select class="sd-select-sm" x-model="publicPerm">
<option value="viewer">Can view</option>
<option value="commenter">Can comment</option>
<option value="editor">Can edit</option>
</select>
</div>
</div>
<div class="sd-sep"></div>
<!-- Footer -->
<div class="sd-footer">
<a href="#" class="sd-footer-link" @click.prevent
>? Learn about sharing</a
>
<button class="sd-footer-action" @click="copyPageLink">
{{ fd_icon("link",14) }} Copy link
</button>
</div>
</div>
<!-- ═══════ PUBLISH TAB ═══════ -->
<div
class="sd-body"
x-show="shareTab === 'publish'"
x-transition
>
<!-- Before publishing -->
<div x-show="!pagePublished" class="sd-publish-hero">
<div class="sd-publish-preview">
<div class="sd-preview-bar"></div>
<div class="sd-preview-title">
{{ fd_icon('globe',14) }} {{ page.title }}
</div>
</div>
<h3 class="sd-publish-heading">Publish to web</h3>
<p class="sd-publish-desc">
Make this page visible to anyone on the internet.
You can share the link with anyone.
</p>
<button
class="sd-btn-primary sd-btn-full"
@click="publishPage()"
>
Publish
</button>
<p class="sd-publish-note">
Your page will be visible to anyone with the link.
</p>
</div>
<!-- After publishing -->
<div x-show="pagePublished">
<div class="sd-url-bar">
<span
style="font-size: 13px; color: var(--text-dim)"
>{{ workspace_key or 'flowdeck' }}</span
>
<input
type="text"
class="sd-url-input"
:value="publishedUrl || pageUrl"
readonly
@click="$event.target.select()"
/>
<button
class="sd-btn-primary"
style="
height: 32px;
padding: 0 12px;
font-size: 12px;
"
@click="copyPageLink"
>
Copy link
</button>
</div>
<div class="sd-sep"></div>
<!-- Settings list -->
<div class="sd-settings">
<div class="sd-setting-row">
<span>{{ fd_icon("link",14) }} Link expires</span>
<span style="color: var(--text-dim)"
>Never ▾</span
>
</div>
<div class="sd-setting-row">
<span>{{ fd_icon('search',14) }} Search engine indexing</span>
<span style="color: var(--text-dim)"
>Off ▾</span
>
</div>
<div class="sd-setting-row">
<span>{{ fd_icon("file",14) }} Allow duplicate as template</span>
<label class="toggle-switch sm">
<input type="checkbox" checked />
<span class="toggle-slider"></span>
</label>
</div>
<div class="sd-setting-row">
<span>{{ fd_icon("edit",14) }} Allow editing</span>
<label class="toggle-switch sm">
<input
type="checkbox"
x-model="pubAllowEdit"
/>
<span class="toggle-slider"></span>
</label>
</div>
<div class="sd-setting-row">
<span>{{ fd_icon("message-square",14) }} Allow comments</span>
<label class="toggle-switch sm">
<input
type="checkbox"
x-model="pubAllowComments"
checked
/>
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="sd-sep"></div>
<button
class="sd-danger-btn"
@click="unpublishPage()"
>
Unpublish
</button>
</div>
</div>
</div>
<!-- ═══════════ Activity Popover ═══════════ -->
<div class="activity-popover"
x-show="activityOpen"
@click.outside="!_justOpened && (activityOpen = false)"
x-transition.opacity.scale.origin.top.right
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:280px;padding:12px;">
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:4px;">Edited <span x-text="timeAgo"></span></div>
<template x-if="pageCreated">
<div style="font-size:12px;color:var(--text-tertiary);">Created <span x-text="formatDate(pageCreated)"></span></div>
</template>
</div>
<!-- More menu dropdown -->
<div
class="more-menu"
x-show="moreOpen"
@click.outside="!_justOpened && (moreOpen = false)"
x-transition
>
<div class="more-menu-item" @click="exportPage">↗ Export</div>
<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 danger" @click="deletePage">
{{ fd_icon("trash",14) }} Move to Trash
</div>
</div>
<!-- ═══════════ Move to Dialog ═══════════ -->
<div class="move-dialog"
x-show="moveOpen"
@click.outside="moveOpen = false"
x-transition.opacity.scale
style="position:absolute;top:100%;right:0;margin-top:4px;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-lg);box-shadow:var(--shadow-modal);z-index:1000;width:320px;padding:16px;">
<h3 style="font-size:14px;margin-bottom:12px;">Move to workspace</h3>
<div x-show="moveLoading" style="color:var(--text-tertiary);text-align:center;padding:20px;">Loading...</div>
<div x-show="!moveLoading" style="max-height:240px;overflow-y:auto;">
<template x-for="ws in moveWorkspaces" :key="ws.id">
<div class="move-ws-item"
@click="doMove(ws.id)"
style="display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:6px;cursor:pointer;font-size:13px;">
<span>{{ fd_icon("folder",14) }}</span>
<span x-text="ws.name" style="flex:1;"></span>
<span style="font-size:11px;color:var(--text-tertiary);" x-text="ws.page_count + ' pages'"></span>
</div>
</template>
<div x-show="moveWorkspaces.length === 0" style="color:var(--text-tertiary);text-align:center;padding:12px;">
No workspaces available
</div>
</div>
</div>
<!-- ═══════════ Page Content ═══════════ -->
<div class="page-cover-area">
<div class="page-title-block">
<span class="page-icon-emoji">{% if page.content_format == 'file' %}{{ fd_icon("paperclip",14) }}{% else %}{{ fd_icon("file",14) }}{% endif %}</span>
<div
class="page-title-input"
contenteditable="true"
id="_titleEl"
@input="dirty=true;save()"
@keydown.enter.prevent="focusBlock()"
@paste.prevent="pastePlain($event)"
spellcheck="false"
>
{{ page.title }}
</div>
</div>
</div>
<div
class="blocks-container"
id="_blocksCt"
@click="onCtClick($event)"
></div>
<div
id="_slashMenu"
class="slash-menu"
style="display: none; position: fixed; z-index: 999"
></div>
<div
class="format-toolbar"
x-show="fmt.open"
:style="{top:fmt.top+'px',left:fmt.left+'px'}"
@mousedown.prevent
>
<button @click="fmtApply('bold')"><strong>B</strong></button>
<button @click="fmtApply('italic')"><em>I</em></button>
<button @click="fmtApply('underline')"><u>U</u></button>
<button @click="fmtApply('strikeThrough')"><s>S</s></button>
<button @click="fmtLink()">{{ fd_icon("link",14) }}</button>
</div>
<!-- ═══════════ Copy Link Toast ═══════════ -->
<div
class="sd-toast"
x-show="toastVisible"
x-transition
x-text="toastMsg"
></div>
<!-- ═══════════ Get Started Toolbar (bottom) ═══════════ -->
<div
class="get-started-toolbar"
x-show="blocks.length === 1 && blocks[0].type === 'paragraph' && !blocks[0].content.trim()"
>
<span class="gs-label">Get started with</span>
<button
class="gs-pill"
@click="replaceBlock(0, 'paragraph');blocks[0].content='Ask AI to write...';render()"
>
<span class="gs-icon">{{ fd_icon("sparkles",14) }}</span> Ask AI
</button>
<button
class="gs-pill"
@click="replaceBlock(0, 'heading_1');blocks[0].content='AI Meeting Note';render()"
>
<span class="gs-icon">{{ fd_icon('edit',14) }}</span> AI meeting note
</button>
<button
class="gs-pill"
@click="replaceBlock(0, 'paragraph');blocks[0].content='Database';render()"
>
<span class="gs-icon">{{ fd_icon("folder",14) }}</span> Database
</button>
<button
class="gs-pill"
@click="replaceBlock(0, 'paragraph');blocks[0].content='Form';render()"
>
<span class="gs-icon">{{ fd_icon("copy",14) }}</span> Form
</button>
<button
class="gs-pill"
@click="replaceBlock(0, 'paragraph');blocks[0].content='Templates';render()"
>
<span class="gs-icon">{{ fd_icon("file",14) }}</span> Templates
</button>
<div style="position: relative">
<button class="gs-pill" @click="gsMoreOpen = !gsMoreOpen">
<span>⋯</span>
</button>
<div
class="gs-more-dropdown"
x-show="gsMoreOpen"
@click.outside="gsMoreOpen = false"
x-transition
>
<div class="gs-more-item" @click="gsMoreOpen=false">
{{ fd_icon("bar-chart",14) }} Table
</div>
<div class="gs-more-item" @click="gsMoreOpen=false">
{{ fd_icon("copy",14) }} Board
</div>
<div class="gs-more-item" @click="gsMoreOpen=false">
{{ fd_icon('list',14) }} List
</div>
<div class="gs-more-item" @click="gsMoreOpen=false">
{{ fd_icon("calendar",14) }} Timeline
</div>
<div class="gs-more-item" @click="gsMoreOpen=false">
{{ fd_icon("calendar",14) }} Calendar
</div>
<div class="gs-more-item" @click="gsMoreOpen=false">
{{ fd_icon("image",14) }} Gallery
</div>
<div class="gs-more-sep"></div>
<div class="gs-more-item" @click="gsMoreOpen=false">
{{ fd_icon("download",14) }} Import
</div>
</div>
</div>
</div>
<div class="editor-statusbar">
<span x-show="saving">Saving...</span>
<span x-show="!saving&&dirty">Unsaved</span>
<span x-show="!saving&&!dirty&&lastSaved"
>Saved <span x-text="lastSaved"></span
></span>
<span class="statusbar-right" x-text="blocks.length+' blocks'"></span>
</div>
</div>
{% include "_page_editor_content.html" %}
{% endblock %} {% block scripts %}
<script type="application/json" id="page-data">
{{ page_data | tojson }}
</script>
<script>
/* eslint-disable */
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.'},
{id:'to_do',name:'To-do',icon:'☐'},{id:'toggle',name:'Toggle',icon:'▶'},{id:'quote',name:'Quote',icon:'❝'},
{id:'callout',name:'Callout',icon:'lightbulb'},{id:'divider',name:'Divider',icon:'—'},{id:'code',name:'Code block',icon:'<>'},
]},{name:'MEDIA',items:[{id:'image',name:'Image',icon:'image'}]}];
let _bid=0;function genId(){return 'b'+(++_bid)+'_'+Date.now().toString(36);}
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 ce(e){const r=document.createRange();r.selectNodeContents(e);r.collapse(false);window.getSelection().removeAllRanges();window.getSelection().addRange(r);}
function cs(e){const r=document.createRange();r.selectNodeContents(e);r.collapse(true);window.getSelection().removeAllRanges();window.getSelection().addRange(r);}
function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
const SM={_o:false,_i:-1,_s:0,_q:'',_it:[],
init(){const e=document.getElementById('_slashMenu');if(!e)return;e.innerHTML='';
const inp=document.createElement('input');inp.type='text';inp.placeholder='Filter...';
inp.style.cssText='width:100%;padding:6px 10px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-size:14px;outline:none;box-sizing:border-box';
inp.oninput=()=>SM.flt();inp.onkeydown=ev=>SM._kd(ev);
const hdr=document.createElement('div');hdr.style.cssText='padding:8px;border-bottom:1px solid var(--border)';hdr.appendChild(inp);
const list=document.createElement('div');list.style.cssText='overflow-y:auto;max-height:340px';
list.addEventListener('click',ev=>{const it=ev.target.closest('.slash-item');if(it){ev.preventDefault();SM.sel(it.dataset.sid);}});
e.appendChild(hdr);e.appendChild(list);
SM._e=e;SM._in=inp;SM._li=list;
document.addEventListener('mousedown',ev=>{if(!e.contains(ev.target))SM.close();});
},
open(i,el){if(!SM._e)SM.init();SM.close();SM._i=i;SM._s=0;SM._in.value='';SM.flt();
const r=el.getBoundingClientRect();SM._e.style.display='block';
SM._e.style.top=Math.max(4,(r.bottom+4>window.innerHeight-340)?r.top-344:r.bottom+4)+'px';
SM._e.style.left=Math.min(r.left,window.innerWidth-324)+'px';
setTimeout(()=>SM._in.focus(),10);SM._o=true;},
close(){if(SM._e)SM._e.style.display='none';SM._o=false;SM._in&&(SM._in.value='');},
flt(){if(!SM._li)return;SM._it=[];const q=(SM._in.value||'').toLowerCase();SM._q=q;let h='';
for(const g of CMDS){let items=g.items;if(q)items=items.filter(c=>c.name.toLowerCase().includes(q)||c.id.includes(q));if(!items.length)continue;
h+=`<div style="padding:6px 12px 4px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px">${g.name}</div>`;
items.forEach(c=>{SM._it.push(c);
h+=`<div class="slash-item" data-sid="${c.id}" style="display:flex;align-items:center;gap:10px;padding:8px 12px;font-size:14px;color:var(--text-primary);cursor:pointer">
<span style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;font-size:16px;background:var(--bg-secondary);border-radius:4px;flex-shrink:0">${c.icon}</span>
<span style="flex:1;font-weight:500">${c.name}</span></div>`;});}
SM._li.innerHTML=h;},
_kd(e){if(e.key==='ArrowDown'){e.preventDefault();SM._s=Math.min(SM._s+1,SM._it.length-1);SM._rs();}
else if(e.key==='ArrowUp'){e.preventDefault();SM._s=Math.max(SM._s-1,0);SM._rs();}
else if(e.key==='Enter'){e.preventDefault();const c=SM._it[SM._s];if(c)SM.sel(c.id);}
else if(e.key==='Escape'){e.preventDefault();SM.close();}},
_rs(){const its=SM._li.querySelectorAll('.slash-item');its.forEach((el,i)=>{el.classList.toggle('selected',i===SM._s);});},
sel(id){if(window.E)window.E.applySlash(id);SM.close();},
get isOpen(){return SM._o;}};
function renderBlock(b,idx){
if(b.type==='divider')return `<div class="block-wrapper"><hr class="block-divider"></div>`;
if(b.type==='image')return `<div class="block-wrapper"><div class="block-content block-image" style="text-align:center;padding:12px 0;"><img src="${b.src||''}" alt="${esc(b.alt||'')}" style="max-width:100%;max-height:70vh;border-radius:8px;display:block;margin:0 auto;" onerror="this.style.display='none';this.parentElement.innerHTML='<div style=\\'padding:40px;color:var(--text-tertiary)\\'>Image not found</div>'"></div></div>`;
if(b.type==='embed'){
if(b.embed_type==='pdf')return `<div class="block-wrapper"><div class="block-content block-embed" style="padding:0;"><iframe src="${b.src||''}" style="width:100%;height:80vh;border:none;border-radius:8px;" onerror="this.style.display='none';this.nextElementSibling.style.display='block';"></iframe><div style="display:none;text-align:center;padding:32px;color:var(--text-secondary);"><svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><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"/></svg><p style="margin-top:8px;">Failed to load PDF</p><a href="${b.src||''}" target="_blank" style="color:var(--accent);">Open in new tab →</a></div></div></div>`;
if(b.embed_type==='download'){
var sz=b.file_size||0;var szStr=sz<1024?sz+' B':sz<1048576?(sz/1024).toFixed(1)+' KB':(sz/1048576).toFixed(1)+' MB';
return `<div class="block-wrapper"><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>`;
}
}
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...':'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">${esc(b.content)}</div>`;
else if(b.type==='toggle')inner=`<button class="toggle-chevron ${b.expanded?'open':''}" onclick="E._doToggleExpand('${b.id}')">▶</button><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
else if(b.type==='code')inner=`<div class="code-lang-label">${b.language||'Plain Text'}</div><pre><code data-bid="${b.id}" contenteditable="true" spellcheck="false">${esc(b.content)}</code></pre>`;
else if(b.type==='callout')inner=`<span class="callout-icon">${b.icon||'lightbulb'}</span><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
else inner=`<div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
const tag=b.type.startsWith('heading_')?'h'+b.type.slice(-1):b.type==='quote'?'blockquote':'div';
const cls=tag==='div'?`block-content ${b.type==='bulleted_list'?'block-bullet':b.type==='numbered_list'?'block-numbered':b.type==='to_do'?'block-todo':b.type==='toggle'?'block-toggle':b.type==='code'?'block-code':b.type==='callout'?'block-callout':''}`:`block-content block-${tag}`;
const style=b.type==='callout'?` style="background:${(b.style&&b.style.bgColor)||'var(--blue-bg)'}"`:'';
return `<div class="block-wrapper">
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>
<${tag}${style} class="${cls}">${inner}</${tag}>
</div>`;
}
window.E=null;
function editorState(){
return {
pid:null,pageTitle:'',blocks:[],dirty:false,saving:false,lastSaved:'',st:null,
updatedAt:'{{ page.get("updated_at", "") }}',
fmt:{open:false,top:0,left:0,idx:-1},
shareOpen:false,moreOpen:false,gsMoreOpen:false,accessMenuOpen:false,
moveOpen:false,moveWorkspaces:[],moveLoading:false,
activityOpen:false,pageCreated:'{{ page.get("created_at", "") }}',
shareTab:'share',favorited:false,
pagePublished:{{ page_published | tojson }},
generalAccess:'{{ page_share_mode }}',publicPerm:'viewer',
pubAllowEdit:false,pubAllowComments:true,
pageUrl:window.location.href,publishedUrl:'',
inviteEmail:'',invitePermission:'editor',accessList:[],
toastVisible:false,toastMsg:'',
get timeAgo() {
if (!this.updatedAt) return 'just now';
var diff = Math.floor((Date.now() - new Date(this.updatedAt + 'Z').getTime()) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
},
formatDate(d) {
if (!d) return '';
var dt = new Date(d + 'Z');
return dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) +
' at ' + dt.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
},
init(){
_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';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(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];
window.E=this;SM.init();this.render();
setTimeout(()=>{this.focusBlock();const ct=document.getElementById('_blocksCt');if(!ct)return;
ct.addEventListener('keydown',e=>this.onKd(e));
ct.addEventListener('paste',e=>{e.preventDefault();document.execCommand('insertText',false,(e.clipboardData||window.clipboardData).getData('text/plain'));this.dirty=true;this.autoSave();});
if(typeof Sortable!=='undefined')Sortable.create(ct,{draggable:'.block-wrapper',handle:'.block-handle',animation:150,ghostClass:'sortable-ghost',dragClass:'sortable-drag',onEnd:evt=>{if(evt.oldIndex===evt.newIndex)return;const[item]=this.blocks.splice(evt.oldIndex,1);this.blocks.splice(evt.newIndex,0,item);this.dirty=true;this.autoSave();this.render();}});
},100);
},
fileData:null,
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;}
const ext=name.toLowerCase().split('.').pop();
if(mime.startsWith('image/')||['png','jpg','jpeg','gif','webp','svg','bmp','ico'].includes(ext)){
this.blocks=[this.mkB('image','',{src:url,alt:name})];this.render();return;
}
if(mime==='application/pdf'||ext==='pdf'){
this.blocks=[this.mkB('embed','',{src:url,embed_type:'pdf'})];this.render();return;
}
const codeExts=['py','js','ts','jsx','tsx','html','htm','css','json','xml','yaml','yml','md','markdown','sql','sh','bash','ps1','bat','cmd','rb','go','rs','java','c','cpp','h','hpp','php','swift','kt','scala','r','toml','ini','cfg','conf','env','txt','log'];
const langMap={'py':'python','js':'javascript','ts':'typescript','jsx':'javascript','tsx':'typescript','html':'html','htm':'html','css':'css','json':'json','xml':'xml','yaml':'yaml','yml':'yaml','md':'markdown','markdown':'markdown','sql':'sql','sh':'bash','bash':'bash','ps1':'powershell','bat':'plaintext','cmd':'plaintext','rb':'ruby','go':'go','rs':'rust','java':'java','c':'c','cpp':'cpp','h':'c','hpp':'cpp','php':'php','swift':'swift','kt':'kotlin','scala':'scala','r':'r','toml':'toml','ini':'ini','cfg':'ini','conf':'ini','env':'ini','txt':'plaintext','log':'plaintext'};
if(mime.startsWith('text/')||codeExts.includes(ext)){
try{const r=await fetch(url);const text=await r.text();
if(ext==='md'||ext==='markdown'){this.blocks=this.md2b(text);}
else{this.blocks=[this.mkB('code',text,{language:langMap[ext]||'Plain Text'})];}
this.render();
}catch(e){this.blocks=[this.mkB('paragraph','Error loading file: '+e.message)];this.render();}
return;
}
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;return b;},
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)};},
focusBlock(){const b=this.blocks[0];if(!b)return;const el=this.getEl(b.id);if(el){el.focus();ce(el);}},
onCtClick(e){if(e.target===document.getElementById('_blocksCt')||e.target?.classList?.contains('blocks-empty')){if(!this.blocks.length)this.addAt(0);else{const b=this.blocks[this.blocks.length-1];const el=b&&this.getEl(b.id);if(el){el.focus();ce(el);}}}},
sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='divider'||b.type==='image'||b.type==='embed')continue;const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el)b.content=el.textContent||'';}},
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;
ct.querySelectorAll('.block-wrapper').forEach(w=>{w.onmouseenter=()=>{w.querySelector('.block-handle')?.classList.add('visible');w.querySelector('.block-actions')?.classList.add('visible');};w.onmouseleave=()=>{w.querySelector('.block-handle')?.classList.remove('visible');w.querySelector('.block-actions')?.classList.remove('visible');};});
ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/<br[^>]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());});
},
replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();b.type=type;b.content='';if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(b.id);if(el)el.focus();},60);},
addAt(idx,type,content){this.sync();const b=this.mkB(type||'paragraph',content||'');this.blocks.splice(idx,0,b);this.dirty=true;this.autoSave();this.render();setTimeout(()=>{const el=this.getEl(b.id);if(el&&type!=='divider'){el.focus();content&&ce(el);}},60);return b;},
addAfter(idx,type){return this.addAt(idx+1,type);},
removeBlock(idx){this.sync();if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}const fi=Math.min(idx,this.blocks.length-1);this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);},
toggleFavorite(){
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
const m=this.favorited?'DELETE':'POST';
fetch(`/board/api/favorites/${this.pid}`,{method:m,headers:{'X-CSRF-Token':csrf?csrf[1]:''}})
.then(r=>r.json()).then(()=>{this.favorited=!this.favorited;this.showToast(this.favorited?'Added to favorites':'Removed from favorites');
if(window.appState&&window.appState.refreshFavorites)window.appState.refreshFavorites();
}).catch(()=>{this.showToast('Failed to toggle favorite');});
},
toggleActivityOpen(){
if(!this.activityOpen){this.activityOpen=true;this._justOpened=true;setTimeout(()=>this._justOpened=false,200);}
else{this.activityOpen=false;}
},
toggleShareOpen(){
if(!this.shareOpen){this.shareOpen=true;this._justOpened=true;setTimeout(()=>this._justOpened=false,200);}
else{this.shareOpen=false;}
},
toggleMoreOpen(){
if(!this.moreOpen){this.moreOpen=true;this._justOpened=true;setTimeout(()=>this._justOpened=false,200);}
else{this.moreOpen=false;}
},
_justOpened:false,
togglePublish(){this.pagePublished=!this.pagePublished;this.saveShare();},
saveShare(){
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
fetch(`/board/api/share/${this.pid}`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({mode:this.generalAccess,published:this.pagePublished})}).catch(()=>{});
},
async copyPageLink(){
console.log('copyPageLink invoked');
const url = this.pageUrl;
const fallback = () => {
const ta = document.createElement('textarea');
ta.value = url; ta.style.position='fixed'; ta.style.opacity='0';
document.body.appendChild(ta); ta.select();
document.execCommand('copy'); document.body.removeChild(ta);
};
try {
await navigator.clipboard.writeText(url);
this.showToast('Link copied to clipboard');
} catch(e) { fallback(); this.showToast('Link copied to clipboard'); }
},
showToast(msg){
this.toastMsg = msg; this.toastVisible = true;
clearTimeout(this._toastTimer);
this._toastTimer = setTimeout(() => { this.toastVisible = false; }, 2500);
},
publishPage() {
var self = this;
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
fetch('/api/pages/' + this.pid + '/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' }
}).then(function(r){ return r.json(); }).then(function(d){
if (d.is_published) {
self.pagePublished = true;
self.publishedUrl = window.location.origin + '/p/' + d.publish_slug;
self.showToast('Published to web — ' + self.publishedUrl);
} else {
self.showToast(d.detail || 'Publish failed');
}
}).catch(function(){ self.showToast('Publish failed'); });
},
unpublishPage() {
var self = this;
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
fetch('/api/pages/' + this.pid + '/publish', {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
}).then(function(r){ return r.json(); }).then(function(d){
if (!d.is_published) {
self.pagePublished = false;
self.publishedUrl = '';
self.showToast('Unpublished');
} else {
self.showToast(d.detail || 'Unpublish failed');
}
}).catch(function(){ self.showToast('Unpublish failed'); });
},
shareInvite() {
var self = this;
if (!this.inviteEmail.trim()) return;
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({ email: this.inviteEmail.trim(), permission: this.invitePermission })
}).then(function(r){ return r.json(); }).then(function(d){
if (d.status === 'shared') { self.inviteEmail = ''; self.showToast('Invitation sent'); self.loadShares(); }
else { self.showToast(d.detail || 'Share failed'); }
}).catch(function(){ self.showToast('Share failed'); });
},
loadShares() {
var self = this;
fetch('/api/pages/' + this.pid + '/shares').then(function(r){ return r.json(); }).then(function(d){
self.accessList = d.shares || [];
}).catch(function(){ self.accessList = []; });
},
removeShare(sid) {
var self = this;
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
fetch('/api/pages/' + this.pid + '/share/' + sid, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
}).then(function(r){ return r.json(); }).then(function(d){
if (d.status === 'removed') { self.showToast('Share removed'); self.loadShares(); }
else { self.showToast(d.detail || 'Remove failed'); }
}).catch(function(){ self.showToast('Remove failed'); });
},
invitePerson(){if(!this.inviteEmail.trim())return;this.accessList.push({email:this.inviteEmail,permission:this.invitePermission,permOpen:false});this.inviteEmail='';},
removeAccess(email){this.accessList=this.accessList.filter(a=>a.email!==email);},
exportPage(){
this.moreOpen=false;
// Export blocks as Markdown
var md = '# ' + (this.pageTitle || 'Untitled') + '\n\n';
for (var i = 0; i < this.blocks.length; i++) {
var b = this.blocks[i];
md += blocksToMarkdown(b) + '\n\n';
}
var blob = new Blob([md], {type: 'text/markdown'});
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url; a.download = (this.pageTitle || 'export') + '.md';
a.click(); URL.revokeObjectURL(url);
this.showToast('Exported as Markdown');
},
duplicatePage(){this.moreOpen=false;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages?title=${encodeURIComponent(this.pageTitle+' copy')}&section=Private&project=${encodeURIComponent('{{ workspace_key }}')}`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(r=>r.json()).then(d=>{window.location.href=`/pages/${d.id}`;}).catch(()=>{window.showToast('Duplicate failed','error');});},
movePage(){
this.moreOpen=false;
this.moveOpen = true;
this.moveLoading = true;
var self = this;
fetch('/api/workspaces')
.then(function(r){ return r.json(); })
.then(function(d){
self.moveWorkspaces = d.workspaces || [];
self.moveLoading = false;
})
.catch(function(){ self.moveLoading = false; });
},
doMove(wsId) {
this.moveOpen = false;
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
var self = this;
fetch('/api/pages/' + this.pid + '/move', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
body: JSON.stringify({ workspace_id: wsId })
}).then(function(r){ return r.json(); })
.then(function(d){
if (d.status === 'ok') {
self.showToast('Moved to workspace');
setTimeout(function(){ window.location.href = '/local-workspace'; }, 800);
} else {
self.showToast(d.detail || 'Move failed');
}
})
.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');});},
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){
if(e.key==='Escape'){e.preventDefault();SM.close();return;}
if(e.key==='ArrowDown'||e.key==='ArrowUp'||e.key==='Enter'){e.preventDefault();SM._in.dispatchEvent(new KeyboardEvent('keydown',{key:e.key,bubbles:true}));return;}
if(e.key==='Backspace'&&!SM._q&&(el.textContent||'').trim()==='/'){e.preventDefault();SM.close();return;}
return;
}
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===' '){const i2=idx;setTimeout(()=>this.checkMd(i2),15);}
if(e.key==='Enter'){
if(e.shiftKey){this.dirty=true;this.autoSave();return;}
e.preventDefault();
const text=el.textContent||'',pos=cp(el),before=text.substring(0,pos),after=text.substring(pos);
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.blocks.splice(idx+1,0,this.mkB(nt,''));this.render();
setTimeout(()=>{const ne=this.getEl(this.blocks[idx+1]?.id);if(ne){ne.focus();ce(ne);}},60);
this.dirty=true;this.autoSave();return;
}
const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph';
this.sync();block.content=before;this.blocks.splice(idx+1,0,this.mkB(nt,after));this.render();
setTimeout(()=>{const ne=this.getEl(this.blocks[idx+1]?.id);if(ne){ne.focus();cs(ne);}},60);
this.dirty=true;this.autoSave();return;
}
if(e.key==='ArrowUp'){if(cp(el)===0&&idx>0){e.preventDefault();const prev=this.getEl(this.blocks[idx-1]?.id);if(prev){prev.focus();ce(prev);}}return;}
if(e.key==='ArrowDown'){if(cp(el)>=(el.textContent||'').length&&idx<this.blocks.length-1){e.preventDefault();const next=this.getEl(this.blocks[idx+1]?.id);if(next){next.focus();cs(next);}}return;}
if(e.key==='Backspace'){if(!(el.textContent||'').trim()){this.sync();if(block.type!=='paragraph'){e.preventDefault();block.type='paragraph';block.content='';this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}else if(this.blocks.length>1){e.preventDefault();this.removeBlock(idx);}}return;}
if(e.key===' '){const b2=bid;setTimeout(()=>this.checkMd(b2),15);}
if((e.ctrlKey||e.metaKey)&&!e.altKey){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();
},
checkMd(idx){
if(idx<0||idx>=this.blocks.length)return;const block=this.blocks[idx];const el=this.getEl(block.id);if(!el)return;const text=el.textContent||'';
const sc={'# ':'heading_1','## ':'heading_2','### ':'heading_3','#### ':'heading_4','- ':'bulleted_list','* ':'bulleted_list','+ ':'bulleted_list','1. ':'numbered_list','[] ':'to_do','[ ] ':'to_do','> ':'quote'};
this.sync();
for(const[pfx,type] of Object.entries(sc)){if(text===pfx||text.startsWith(pfx)){block.type=type;block.content='';if(type==='toggle')block.expanded=true;if(type==='to_do')block.checked=false;this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne&&type!=='divider')ne.focus();},60);return;}}
if(text==='---'){block.type='divider';block.content='';this.addAfter(idx,'paragraph');}
if(text==='```'){block.type='code';block.content='';block.language='Plain Text';this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}
},
applySlash(id){
const idx=SM._i;if(idx<0||idx>=this.blocks.length)return;
const block=this.blocks[idx];this.sync();block.content='';
if(id==='divider'){block.type='divider';this.addAfter(idx,'paragraph');}
else{block.type=id;if(id==='toggle')block.expanded=true;if(id==='to_do')block.checked=false;if(id==='code')block.language='Plain Text';}
this.render();this.dirty=true;this.autoSave();
setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);
},
_doToggle(bid,v){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].checked=v;this.dirty=true;this.autoSave();}},
_doToggleExpand(bid){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].expanded=!this.blocks[idx].expanded;this.dirty=true;this.autoSave();}},
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();}},
pastePlain(e){e.preventDefault();document.execCommand('insertText',false,(e.clipboardData||window.clipboardData).getData('text/plain'));this.dirty=true;this.autoSave();},
autoSave(){if(this.fileData)return;clearTimeout(this.st);this.st=setTimeout(()=>this.save(),1500);},
save(){
if(this.fileData)return;
if(this.saving)return;
this.sync();
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'New page';
this.saving=true;
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'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
.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';});
}).catch(()=>{this.saving=false;});
},
md2b(md){const bl=[];let cb=null;for(const line of md.split('\n')){if(cb){if(line.trim()==='```'){bl.push(cb);cb=null;}else cb.content+=(cb.content?'\n':'')+line;continue;}const t=line.trim();if(!t)continue;if(t.startsWith('```')){cb=this.mkB('code','',{language:t.substring(3).trim()||'Plain Text'});continue;}if(t.startsWith('# ')&&!t.startsWith('## '))bl.push(this.mkB('heading_1',t.substring(2)));else if(t.startsWith('## ')&&!t.startsWith('### '))bl.push(this.mkB('heading_2',t.substring(3)));else if(t.startsWith('### ')&&!t.startsWith('#### '))bl.push(this.mkB('heading_3',t.substring(4)));else if(t.startsWith('#### '))bl.push(this.mkB('heading_4',t.substring(5)));else if(t==='---'||t==='***')bl.push(this.mkB('divider',''));else if(/^[-*+] /.test(t))bl.push(this.mkB('bulleted_list',t.substring(2)));else if(/^\d+\. /.test(t))bl.push(this.mkB('numbered_list',t.replace(/^\d+\. /,'')));else if(t.startsWith('- [ ] ')||t.startsWith('* [ ] '))bl.push(this.mkB('to_do',t.substring(6)));else if(t.startsWith('- [x] ')||t.startsWith('* [x] '))bl.push(this.mkB('to_do',t.substring(6),{checked:true}));else if(t.startsWith('> '))bl.push(this.mkB('quote',t.substring(2)));else bl.push(this.mkB('paragraph',t));}if(cb)bl.push(cb);return bl;},
};
}
document.addEventListener('mouseup',()=>{setTimeout(()=>{const s=window.getSelection();if(!s||s.isCollapsed)return;const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;const d=ed.__x.$data;const ct=document.getElementById('_blocksCt');if(!ct?.contains(s.anchorNode))return;const bel=s.anchorNode.parentElement?.closest('[data-bid]');if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);}},80);});
function blocksToMarkdown(b) {
var c = b.content || '';
switch(b.type) {
case 'heading_1': return '# ' + c;
case 'heading_2': return '## ' + c;
case 'heading_3': return '### ' + c;
case 'heading_4': return '#### ' + c;
case 'bulleted_list': return '- ' + c;
case 'numbered_list': return '1. ' + c;
case 'to_do': return (b.checked ? '- [x] ' : '- [ ] ') + c;
case 'quote': return '> ' + c;
case 'divider': return '---';
case 'code': return '```' + (b.language || '') + '\n' + c + '\n```';
case 'toggle': return '## ' + c;
default: return c;
}
}
</script>
<script src="/static/js/katex.min.js?v=0.16.11" defer></script>
{% include "_page_editor_scripts.html" %}
{% include "_page_editor_realtime.html" %}
{% include "_database_table_scripts.html" %}
{% endblock %}
+16
View File
@@ -0,0 +1,16 @@
{% extends "base.html" %} {% block page_icon %}{{ fd_icon("file",14) }}{% endblock %} {% block
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% set page_icon = "file" %}
{% set page_title = page.title %}
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
{% include '_header.html' %}
{% endblock %} {% block content %}
{% include "_database_table.html" %}
{% endblock %} {% block scripts %}
<script>
// Initialize database table from server-rendered data
window.__DB_PAGE_ID = {{ page.id }};
window.__DB_COLLECTION_ID = {{ page.collection_id or 0 }};
</script>
{% include "_database_table_scripts.html" %}
{% endblock %}
+11
View File
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block page_title %}{{ page.title }}{% endblock %}
{# No topbar in embed mode — just the editor #}
{% block topbar %}{% endblock %}
{% block content %}
{% include '_page_editor_content.html' %}
<script>document.body.classList.add('embed-mode');</script>
{% endblock %}
{% block scripts %}
{% include '_page_editor_scripts.html' %}
{% endblock %}
+71 -1
View File
@@ -2,6 +2,7 @@
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
{% from '_icons.html' import fd_icon %}
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ title }} — FlowDeck</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
@@ -124,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>
@@ -136,7 +149,16 @@
</header>
<main class="pub-container">
<h1 class="pub-title">{{ title }}</h1>
{% if cover_url %}
<div class="pub-cover" style="position:relative;margin:-40px -24px 32px;height:240px;overflow:hidden;">
<img src="{{ cover_url }}" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;">
<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 %}
<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>
<h1 class="pub-title">{{ title }}</h1>
</div>
{% if updated_at %}
<div class="pub-meta">Last updated: {{ updated_at[:10] }}</div>
{% endif %}
@@ -149,5 +171,53 @@
Made with <a href="/">FlowDeck</a> — a Notion-style workspace
</footer>
<script src="/static/js/katex.min.js?v=0.16.11"></script>
<script>
document.addEventListener('DOMContentLoaded', function () {
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">&#8249;</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">&#8250;</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>
</body>
</html>
File diff suppressed because it is too large Load Diff
+188
View File
@@ -0,0 +1,188 @@
<!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>Bienvenue sur FlowDeck</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
<style>
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;}
*{margin:0;padding:0;box-sizing:border-box;}
body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;}
.wizard{width:560px;max-width:94vw;background:var(--bg2);border:1px solid var(--border);border-radius:16px;padding:32px;box-shadow:0 12px 40px rgba(0,0,0,.35);}
.wizard .brand{font-size:15px;font-weight:700;letter-spacing:.3px;margin-bottom:24px;display:flex;align-items:center;gap:8px;}
.wizard .brand svg{width:20px;height:20px;}
h1{font-size:22px;font-weight:700;margin-bottom:6px;}
.sub{color:var(--dim);font-size:14px;margin-bottom:26px;line-height:1.5;}
.steps{display:flex;gap:8px;margin-bottom:26px;}
.step{flex:1;height:4px;border-radius:2px;background:var(--bg3);transition:background .3s;}
.step.active{background:var(--accent);}
.step.done{background:var(--success);}
.step-label{font-size:12px;color:var(--dim);margin-bottom:8px;}
.card{background:var(--bg3);border:1px solid var(--border);border-radius:12px;padding:18px;margin-bottom:14px;cursor:pointer;transition:border-color .15s, transform .1s;}
.card:hover{border-color:var(--accent);transform:translateY(-1px);}
.card.selected{border-color:var(--accent);box-shadow:0 0 0 2px rgba(35,131,226,.25);}
.card .card-icon{font-size:22px;margin-bottom:6px;}
.card .card-title{font-size:15px;font-weight:600;margin-bottom:2px;}
.card .card-desc{font-size:13px;color:var(--dim);}
.input{width:100%;padding:11px 12px;background:var(--bg);border:1px solid var(--border);border-radius:10px;color:var(--text);font-size:14px;outline:none;margin-bottom:14px;}
.input:focus{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent);}
.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);}
.btn-ghost:hover{color:var(--text);}
.btn:disabled{opacity:.5;cursor:not-allowed;}
.actions{display:flex;justify-content:space-between;align-items:center;margin-top:8px;}
.skip{color:var(--dim);font-size:13px;cursor:pointer;text-decoration:none;}
.skip:hover{color:var(--text);}
.forge-row{display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid var(--border);}
.forge-row:last-child{border-bottom:none;}
.forge-logo{width:34px;height:34px;border-radius:9px;background:var(--bg);display:flex;align-items:center;justify-content:center;font-size:17px;}
.forge-name{font-size:14px;font-weight:600;}
.forge-status{font-size:12px;color:var(--dim);}
.badge{font-size:11px;font-weight:600;padding:2px 8px;border-radius:99px;background:rgba(46,160,67,.16);color:#2ea043;}
.badge.off{background:var(--bg3);color:var(--dim);}
.link{margin-left:auto;font-size:12px;color:var(--accent);text-decoration:none;}
.link:hover{text-decoration:underline;}
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#0F7B6C;color:#fff;padding:10px 18px;border-radius:10px;font-size:13px;box-shadow:0 8px 24px rgba(0,0,0,.4);z-index:50;display:none;}
.toast.error{background:var(--danger);}
.avatar{width:40px;height:40px;border-radius:50%;background:var(--accent);display:inline-flex;align-items:center;justify-content:center;font-weight:700;margin-right:10px;}
</style>
</head>
<body x-data="onboarding()" x-init="init()">
<div class="toast" id="toast" x-show.transition="toastMsg" x-text="toastMsg" :class="toastError ? 'error':''" x-cloak></div>
<div class="wizard">
<div class="brand">
<svg viewBox="0 0 24 24" fill="none" stroke="#2383E2" stroke-width="2"><path d="M13 2L3 14h7l-1 8 10-12h-7l1-8z"/></svg>
FlowDeck
</div>
<!-- Stepper -->
<div class="steps">
<template x-for="(s,i) in steps" :key="i">
<div class="step" :class="{ active: step===i, done: step>i }"></div>
</template>
</div>
<div class="step-label" x-text="steps[step]"></div>
<!-- Step 0: welcome -->
<section x-show="step===0">
<h1>Bienvenue 👋</h1>
<p class="sub">Configurons votre espace de travail en 3 étapes. Vous pourrez tout changer plus tard.</p>
<button class="btn btn-primary" @click="step=1">Commencer</button>
</section>
<!-- Step 1: workspace -->
<section x-show="step===1">
<h1>Créez votre espace</h1>
<p class="sub">Un workspace regroupe vos pages, bases de données et projets.</p>
<input class="input" x-model="wsName" @keydown.enter="createWorkspace()" placeholder="Ex : Équipe Frelon" maxlength="120">
<div class="actions">
<button class="btn btn-primary" @click="createWorkspace()" :disabled="!wsName.trim() || saving">Créer l'espace</button>
</div>
</section>
<!-- Step 2: connect forge -->
<section x-show="step===2">
<h1>Connectez vos dépôts</h1>
<p class="sub">Optionnel — syncronisez Gitea ou GitHub. Vous pouvez le faire plus tard dans Préférences → Intégrations.</p>
<div class="card" :class="{selected: forgeChoice==='gitea'}" @click="forgeChoice='gitea'">
<div style="display:flex;align-items:center;gap:14px;">
<div class="forge-logo">🔗</div>
<div style="flex:1;">
<div class="forge-name">Gitea</div>
<div class="forge-status">
<template x-if="!{{ 'true' if gitea_url_configured else 'false' }}">Non configuré par l'administrateur</template>
<template x-if="{{ 'true' if gitea_url_configured else 'false' }}">Disponible — Issues, Kanban, fichiers en sync</template>
</div>
</div>
<span class="badge" :class="giteaLinking ? 'off':''" x-text="giteaLinking ? 'Connexion…' : 'Choisir'"></span>
</div>
</div>
<div class="card" :class="{selected: forgeChoice==='github'}" @click="forgeChoice='github'">
<div style="display:flex;align-items:center;gap:14px;">
<div class="forge-logo">🐙</div>
<div style="flex:1;">
<div class="forge-name">GitHub</div>
<div class="forge-status">
<template x-if="!{{ 'true' if github_url_configured else 'false' }}">Non configuré par l'administrateur</template>
<template x-if="{{ 'true' if github_url_configured else 'false' }}">Disponible — dépôts privés & publics</template>
</div>
</div>
<span class="badge" :class="githubLinking ? 'off':''" x-text="githubLinking ? 'Connexion…' : 'Choisir'"></span>
</div>
</div>
<div class="actions">
<button class="btn btn-ghost" @click="skipForge()">Passer</button>
<button class="btn btn-primary" @click="connectForge()" :disabled="!forgeChoice">Connecter</button>
</div>
</section>
<!-- Step 3: first project -->
<section x-show="step===3">
<h1>Créez votre premier projet</h1>
<p class="sub">Une page d'accueil avec quelques conseils pour démarrer.</p>
<input class="input" x-model="projectTitle" @keydown.enter="createProject()" placeholder="Ex : Welcome to FlowDeck">
<div class="actions">
<button class="btn btn-ghost" @click="finish()">Passer</button>
<button class="btn btn-primary" @click="createProject()" :disabled="saving">Créer</button>
</div>
</section>
</div>
<script>
function onboarding() {
return {
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
step: 0,
wsName: '',
forgeChoice: '',
projectTitle: 'Welcome to FlowDeck',
workspaceId: null,
saving: false,
giteaLinking: false,
githubLinking: false,
toastMsg: '',
toastError: false,
init() {
const p = new URLSearchParams(location.search);
if (p.get('linked') === '1') { this.step = 2; this.toastMsg = 'Forge connectée ✅'; setTimeout(()=>this.toastMsg='', 2500); }
},
toast(msg, err) { this.toastMsg = msg; this.toastError = !!err; setTimeout(()=>this.toastMsg='', 3000); },
async createWorkspace() {
this.saving = true;
try {
const r = await fetch('/api/onboarding/workspace', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({name:this.wsName.trim()})});
const d = await r.json();
if (!r.ok) { this.toast(d.detail || 'Erreur', true); return; }
this.workspaceId = d.id;
this.step = 2;
} catch(e) { this.toast('Erreur réseau', true); }
finally { this.saving = false; }
},
connectForge() {
if (this.forgeChoice === 'gitea') { this.giteaLinking = true; window.location = '/auth/login?provider=gitea&mode=link&next=/welcome?linked=1'; }
else if (this.forgeChoice === 'github') { this.githubLinking = true; window.location = '/auth/login?provider=github&mode=link&next=/welcome?linked=1'; }
else this.skipForge();
},
skipForge() { this.forgeChoice = ''; this.step = 3; },
async createProject() {
this.saving = true;
try {
const r = await fetch('/api/onboarding/project', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({title:this.projectTitle.trim(), workspace_id:this.workspaceId})});
const d = await r.json();
if (!r.ok) { this.toast(d.detail || 'Erreur', true); return; }
this.toast('Projet créé 🎉');
setTimeout(()=> window.location = '/local-workspace?ws=' + (this.workspaceId || ''), 700);
} catch(e) { this.toast('Erreur réseau', true); }
finally { this.saving = false; }
},
finish() { window.location = '/workspaces'; }
};
}
</script>
</body>
</html>
+678
View File
@@ -0,0 +1,678 @@
# Guide des API FlowDeck — Référence d'implémentation v6.0.0
> **Statut** : référence de conception pour la mise en place de l'API publique complète (v6.0.0).
> **Dernière mise à jour** : 2026-09-04
> **Portée** : inventaire de l'API existante, conventions cibles, design CRUD par ressource, webhooks, sécurité, checklist d'implémentation.
---
## 1. Vision & architecture
FlowDeck est **API-first** (FastAPI). Deux couches d'API coexistent :
| Couche | Préfixe | Auth | Usage |
|--------|---------|------|-------|
| **API interne** (existant) | `/api`, `/workspace`, `/db`, `/board`… | Session cookie `flowdeck_session` + CSRF | Le frontend HTMX/Alpine |
| **API publique v1** (existant, lecture seule) | `/api/v1` | Bearer token | Intégrations externes minimales |
| **API publique v2** (cible v6.0.0) | `/api/v2` | Bearer token + scopes | CRUD complet, usage externe (clone Notion API) |
**Principe** : l'API v2 expose les mêmes opérations que l'API interne, mais avec une surcouche de contrôle (auth par token, scopes, pagination, filtres, erreurs normalisées). Elle est implémentée comme des wrappers sur les mêmes services/logique métier — jamais de duplication.
### Règles d'or
1. Un endpoint public = un wrapper autour de la logique existante (un seul chemin de code).
2. L'API publique ne renvoie **jamais** de HTML, de secrets, ni de colonnes internes (`password_hash`, tokens OAuth).
3. Toute mutation passe par les mêmes validations que l'UI (CSRF concerne l'UI seule ; les tokens remplacent CSRF).
4. Versionnage par préfixe d'URL (`/api/v1`, `/api/v2`) — jamais de breaking change sur une version publiée.
---
## 2. État actuel — inventaire de l'API
### 2.1 Schéma global des routeurs (`app/main.py`)
| Routeur | Fichier | Préfixe | Rôle |
|---------|---------|---------|------|
| `auth` | `routers/auth.py` | `/auth` | Login OAuth/local, register, logout, sessions |
| `dashboard` | `routers/dashboard.py` | — | Pages HTML (landing, workspace, settings…) |
| `board` | `routers/board.py` | `/board` | Kanban Gitea (legacy, compatible) |
| `notes` | `routers/notes.py` | `/notes` | Notes Markdown par projet |
| `api` | `routers/api.py` | `/api` | Pages, workspaces, tags, trash, favoris, settings |
| `webhooks` | `routers/webhooks.py` | `/api/webhook` | Réception webhooks Gitea (issues/PR/repo) |
| `collections` | `routers/collections.py` | `/db` | Databases Notion-style (CRUD) |
| `my_tasks` | `routers/my_tasks.py` | `/my-tasks` | Agrégation tâches |
| `workspace` | `routers/workspace.py` | `/workspace` | Local workspace, arborescence, webhooks sortants |
| `library` | `routers/library.py` | `/api/library` | Bibliothèque (sources local/Gitea/GitHub) |
| `admin` | `routers/admin.py` | `/api/admin` | Admin utilisateurs + audit |
| `gitea` | `routers/gitea.py` | `/api/gitea` | Projets, fichiers, commits, labels |
| `github` | `routers/github_routes.py` | `/api/github` | Intégration GitHub |
| `public_api` | `routers/public_api.py` | `/api/v1` | **API publique existante (lecture seule)** |
| `sharing` | `routers/sharing.py` | `/api` | Share/publish pages |
| `sidebar_config` | `routers/sidebar_config.py` | `/api/sidebar` | Customisation sidebar |
| `export` | `routers/export.py` | `/api/export` | Export MD/PDF/HTML/CSV |
| `notifications` | `routers/notifications.py` | `/api/notifications` | Notifications in-app |
| `collaboration` | `routers/collaboration.py` | `/api` | Commentaires inline, mentions, historique |
### 2.2 Récapitulatif des endpoints existants (par domaine)
> Liste non exhaustive côté pages HTML ; les endpoints API sont tous listés.
**Auth & users**
```
GET /auth/login, /auth/register, /auth/callback, /auth/logout, /auth/user
POST /auth/local-login, /auth/register, /auth/register/{owner}/{repo}
GET /api/users, /api/users/me, /api/users/search
PUT /api/users/me, /api/user/profile, /api/user/password
POST /api/user/token, /api/settings/avatar, /api/settings/avatar-color, /api/settings/tags
GET /api/settings/account, /api/settings/avatar/{filename}
PUT /api/settings/tags/{tag_id}, /api/settings/tags/all (GET)
DELETE /api/settings/tags/{tag_id}, /api/user/forge/{provider}
```
**Workspaces & membres**
```
GET /api/workspaces, POST /api/workspaces
GET /api/workspace/{ws_id}/members, POST /api/workspace/{ws_id}/members
PUT /api/workspace/{ws_id}/members/{user_id}, DELETE /api/workspace/{ws_id}/members/{user_id}
PUT /api/workspaces/{ws_id}, DELETE /api/workspaces/{ws_id}
POST /api/workspaces/{ws_id}/select
```
**Pages (éditeur blocs)**
```
GET /api/pages/{page_id}, PUT /api/pages/{page_id}, DELETE /api/pages/{page_id}
POST /api/pages (création), /api/pages/{page_id}/blocks, /api/pages/{page_id}/content
PUT /api/pages/{page_id}/rename, /api/pages/{page_id}/move
POST /api/pages/{page_id}/trash, /api/trash/{page_id}/restore
GET /api/trash, DELETE /api/trash/{page_id}
POST /api/pages/{page_id:int}/convert-to-database
GET /api/local-workspace/tree, /api/sidebar/workspace-tree, /api/nav/menu
GET /api/local-workspace/page-content/{page_id}, /children/{page_id}
```
**Collections (databases)**
```
GET /db/{collection_id}/api (détail), DELETE /db/{collection_id}
POST /db/inline/api (créer inline)
POST /db/{id}/linked/api, /db/{id}/toggle-inline/api, PUT /db/{id}/toggle-task/api
GET /db/{id}/sources/api, POST /db/{id}/sources/api, DELETE /db/{id}/sources/{sid}/api
GET /db/{id}/properties/api, POST /db/{id}/properties/api
PUT /properties/{prop_id}/api, DELETE /properties/{prop_id}/api
POST /db/{id}/properties/relation, /db/{id}/properties/relation/link
GET /db/{id}/views/api, POST /db/{id}/views/save-as, PUT /views/{view_id}/config
GET /property-types/api
```
**Collection pages & tâches**
```
GET /db/{id}/pages/api, POST /db/{id}/pages/api, PUT /pages/{page_id}/api, DELETE /pages/{page_id}/api
POST /db/{id}/pages/{pid}/sub-items, GET /db/{id}/pages/{pid}/sub-items
GET /db/{id}/pages/{pid}/dependencies/api, POST /db/{id}/pages/{pid}/dependencies/api
DELETE /db/{id}/pages/{pid}/dependencies/{dep_id}/api
POST /db/{id}/pages/{pid}/auto-shift/api, /db/{id}/pages/{pid}/check-deps
GET /db/{id}/pages/{pid}/status-aggregate
POST /formula/evaluate, /rollup/compute
```
**Sprints, dashboards, templates**
```
GET/POST /workspace/collections/{id}/sprints, PUT/DELETE /workspace/collections/{id}/sprints/{sid}
POST /workspace/collections/{id}/sprints/{sid}/assign, DELETE .../assign/{page_id}
GET /workspace/collections/{id}/sprints/burndown/{sid}
GET/POST/PUT/DELETE /workspace/collections/{id}/dashboards[/{did}]
GET/POST /workspace/collections/{id}/templates/page, PUT/DELETE .../templates/page/{tid}
POST .../templates/page/{tid}/apply
GET/POST /templates/database, POST /templates/database/{tid}/apply
```
**Favoris, tags, recents**
```
GET/POST /api/favorites, DELETE /api/favorites/{page_id}
GET/POST /db/{id}/pages/api… (tags par page via /api/local-workspace/items/{id}/tags)
GET /api/local-workspace/tags, /api/local-workspace/tags/search
POST /api/local-workspace/items/{id}/tags, DELETE .../tags/{tag_id}
GET /api/recents, POST /api/recents/track
```
**Partage & publication**
```
POST /api/share/{page_id}, GET /api/pages/{page_id}/shares, DELETE /api/pages/{page_id}/share/{share_id}
POST /pages/{page_id}/share, POST /pages/{page_id}/publish, DELETE /pages/{page_id}/publish
GET /public/{collection_id}, /published, /shared, /private, /p/{slug}
```
**Commentaires & notifications (v4.9.0)**
```
GET/POST /pages/{page_id}/comments, PUT/DELETE /comments/{comment_id}
POST /pages/{page_id}/mentions
GET /api/notifications, /api/notifications/unread-count
POST /api/notifications/read, /api/notifications/read-all
```
**Historique**
```
GET/POST /pages/{page_id}/history
```
**Import / Export**
```
GET /api/collections/{id}/export/csv, POST /api/collections/{id}/import/csv
GET /api/export/markdown|pdf|html (via /export et /markdown/{page_id}, /pdf/{page_id}, /html/{page_id})
```
**Forge Gitea / GitHub**
```
GET /api/workspace/projects, POST /api/workspace/projects
GET /projects/{owner}/{repo}/tree|labels|commits|private-pages|file
PUT /projects/{owner}/{repo}/file, DELETE /projects/{owner}/{repo}/file
POST /projects/{owner}/{repo}/upload, /sync-labels, /private-pages
GET/POST /issues/{owner}/{repo}, PATCH /issues/{owner}/{repo}/{issue_id}
POST /api/sync/{owner}/{repo}, /api/ai-keywords/{owner}/{repo}/extract
```
**Admin**
```
GET /api/admin/users, PUT /api/admin/users/{user_id}, DELETE /api/admin/users/{user_id}
GET /audit, /api/frontend-errors, POST /api/frontend-error
```
**Santé & divers**
```
GET /api/health, /api/stats, /api/config, PUT /api/config, /api/favorites…
POST /api/move, /col-mapping (GET/DELETE/POST), /board-config/{owner}/{repo} (GET/POST)
```
### 2.3 API publique v1 existante (`app/routers/public_api.py`)
| Méthode | Route | Description |
|---------|-------|-------------|
| POST | `/api/v1/token` | Génère un token `fd_xxx` (inséré dans `user_tokens`) |
| GET | `/api/v1/collections` | Liste des collections (id, name, description, icon, created_at) |
| GET | `/api/v1/collections/{id}` | Collection + ses pages |
| GET | `/api/v1/collections/{id}/pages` | Pages racine de la collection |
| GET | `/api/v1/pages/{page_id}` | Page détaillée |
| GET | `/api/v1/my-tasks` | Tâches (limit 50) |
**Auth v1** : header `Authorization: Bearer <token>`. Token par défaut : `fd-public-key` (fallback dev). Vérification dans la table `user_tokens` (colonne `gitea_token`).
**Limites de v1** (ce que v2 doit corriger) :
- Lecture seule — aucun POST/PUT/DELETE sur les ressources.
- Un seul token par utilisateur (`UNIQUE(gitea_user_id)`) et stocké dans une table prévue pour les tokens Gitea.
- Pas de scopes, pas d'expiration, pas de nom d'affichage, pas de révocation individuelle.
- Pas de pagination, filtres, tri.
- Pas de documentation OpenAPI en production (`docs_url` n'est actif qu'en DEBUG).
---
## 3. Conventions cibles pour l'API v2
### 3.1 Auth & tokens
**Nouvelle table `api_tokens`** (remplace l'usage détourné de `user_tokens`) :
```sql
CREATE TABLE api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'default', -- ex: "CI production"
token_hash TEXT NOT NULL UNIQUE, -- sha256 du token, jamais en clair
scopes TEXT NOT NULL DEFAULT 'read', -- 'read' | 'read,write' | 'read,write,admin'
expires_at TIMESTAMP, -- NULL = jamais
last_used_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
revoked INTEGER NOT NULL DEFAULT 0
);
```
- **Format token** : `fd_{user_id}_{urlsafe(32)}` — l'utilisateur ne le voit qu'une fois. Stockage en `sha256`.
- **Crud tokens** : `POST /api/v2/tokens`, `GET /api/v2/tokens`, `DELETE /api/v2/tokens/{id}` (révocation), `POST /api/v2/tokens/rotate`.
- **Scopes** : `read` (GET), `write` (POST/PUT/PATCH), `admin` (gestion users/admin). Dépendance FastAPI `require_scope("write")`.
- **Compat v1** : garder le fallback `fd-public-key` uniquement si `settings.public_api_insecure_ok=true` (défaut : **false** en prod).
### 3.2 Format des réponses
- JSON brut (pas d'enveloppe `{data: …}`) — style Notion API.
- Timestamps **ISO-8601 UTC** (`2026-09-04T14:30:00Z`). SQLite stocke `YYYY-MM-DD HH:MM:SS` → convertir dans le convertisseur de sortie.
- IDs : entiers SQLite (`int`). Exposés tels quels (pas de UUID) — `ponytail: garder int, passer en UUID seulement si exposition publique nécessaire`.
- Champs `*_json` stockés en SQLite (ex: `property_values_json`, `config_json`) → **déjà parsés en objets JSON** dans les réponses API.
### 3.3 Erreurs (RFC 7807 `application/problem+json`)
```json
{
"type": "https://flowdeck/api/errors/not-found",
"title": "Resource not found",
"status": 404,
"detail": "Collection 42 n'existe pas",
"instance": "/api/v2/collections/42"
}
```
Codes : `400` validation (détail = liste des champs), `401` token manquant/invalide, `403` scope insuffisant / permissions, `404` inexistant, `409` conflit (nom dupliqué, dépendance bloquante), `422` erreur de schéma (FastAPI), `429` rate limit, `500` interne.
Handler global : `@app.exception_handler(HTTPException)` + adaptation des erreurs internes → problème JSON pour tout préfixe `/api/v2`.
### 3.4 Pagination, filtres, tri
**Pagination offset** (simple, SQLite) :
```
GET /api/v2/collections?limit=30&offset=0
→ response headers: X-Total-Count: 142
```
Requête : `limit` (défaut 30, max 100), `offset` (défaut 0).
Utilise `SELECT COUNT(*)` + `LIMIT ? OFFSET ?` — `ponytail: offset OK jusqu'à ~10k lignes, passer à keyset (cursor) si besoin`.
**Filtres** (par propriété de la ressource) :
```
GET /api/v2/collections/{id}/pages?filter[status]=Done&filter[assignee]=bruno
GET /api/v2/pages?query=mot&workspace_id=3
```
Convention : `filter[<property>]=<value>` (AND implicite), `sort=<property>`, `sort=-<property>` (desc), `fields=a,b,c` (projection).
**Recherche FTS** : réserver `?query=` sur les listes → recherche plein texte (table FTS5 à créer, indexant pages + collection_pages).
### 3.5 Idempotence & mutations
- `POST` de création accepte un header optionnel `Idempotency-Key` — sur conflit, renvoyer la ressource existante créée avec cette clé (table `idempotency_keys`).
- Les `PATCH` sont partiels (seuls les champs présents sont modifiés).
### 3.6 Rate limiting par token
Étendre `RateLimitMiddleware` (actuellement 100 req/min/IP sur `/api/`, `/board/api/`, `/auth/`) :
- `/api/v2/*` : quota **par token** (défaut 300 req/min, configurable par scope), en plus du quota IP.
- Remplacer le store `defaultdict` in-memory par une clé composite `(ip|token_id)` — `ponytail: in-memory OK mono-instance, Redis si multi-workers`.
- Header de réponse : `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`.
- Exemptés : `/api/health`, `/api/v2/tokens` (auth).
---
## 4. Design des endpoints v2 — CRUD par ressource
> Pour chaque ressource : table(s) SQLite, endpoints internes existants (réutilisables), endpoints publics v2 à créer.
> **`GET /collections` → wrapper `public_list_collections` existant à enrichir** ; les mutations s'appuient sur les routeurs `collections.py` (préfixe `/db`).
### 4.1 Users
| Table | `users` |
|-------|---------|
| Colonnes clés | id, login, full_name, email, avatar_url, avatar_color, is_admin, is_active, auth_method, sidebar_config, notification_prefs |
| **Interne** | `GET/PUT /api/users/me`, `GET /api/users`, `PUT/DELETE /api/users/{id}` (admin) |
Endpoints publics v2 :
| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/v2/users/me` | Profil du user courant (sans `password_hash`) |
| PATCH | `/api/v2/users/me` | Mettre à jour profil (full_name, email, avatar_color, notification_prefs) |
| GET | `/api/v2/users` | **admin** — lister (pagination) |
| GET | `/api/v2/users/search?q=` | Recherche par login/email (pour @mentions) |
### 4.2 Workspaces & membres
| Tables | `workspaces`, `workspace_members` (role: owner/admin/editor/viewer) |
|--------|-----|
| **Interne** | `GET/POST /api/workspaces`, `PUT/DELETE /api/workspaces/{ws_id}`, `GET/POST /api/workspace/{ws_id}/members`, `PUT/DELETE …/members/{user_id}` |
Endpoints publics v2 :
| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/v2/workspaces` | Workspaces de l'utilisateur (avec rôle) |
| POST | `/api/v2/workspaces` | Créer (body: name, settings_json) |
| GET | `/api/v2/workspaces/{id}` | Détail + membres |
| PATCH | `/api/v2/workspaces/{id}` | Renommer / settings |
| DELETE | `/api/v2/workspaces/{id}` | Supprimer (owner only) |
| GET | `/api/v2/workspaces/{id}/members` | Lister membres |
| POST | `/api/v2/workspaces/{id}/members` | Inviter (user_id ou email) |
| PATCH | `/api/v2/workspaces/{id}/members/{uid}` | Changer le rôle |
| DELETE | `/api/v2/workspaces/{id}/members/{uid}` | Retirer un membre |
> ⚠️ **Dépendance v6** : créer `app/services/permission_manager.py` (la roadmap y fait référence mais le fichier n'existe pas). Centraliser : `require_workspace_role(ws_id, "editor")`, `can_read(workspace_id|collection_id|page_id, user)`.
### 4.3 Collections (databases)
| Tables | `collections` (+ `workspace_id`, `created_by`, `is_inline`, `parent_page_id`, `is_task`) |
|--------|-----|
| **Interne** | `POST /db/inline/api`, `GET/DELETE /db/{id}/api`, `POST /db/{id}/linked/api`, `POST /db/{id}/toggle-inline/api`, `PUT /db/{id}/toggle-task/api`, sources (`/db/{id}/sources/api`) |
Endpoints publics v2 :
| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/v2/collections?workspace_id=&query=` | Liste (paginée) — wrapper de v1 enrichi |
| POST | `/api/v2/collections` | Créer (name, description, icon, workspace_id, schema_json) |
| GET | `/api/v2/collections/{id}` | Détail + pages (existant v1) |
| PATCH | `/api/v2/collections/{id}` | Renommer, icon, description, schema |
| DELETE | `/api/v2/collections/{id}` | Supprimer (cascade pages/vues/props) |
| POST | `/api/v2/collections/{id}/linked` | Créer une DB liée |
| POST | `/api/v2/collections/{id}/task` | toggler `is_task` |
| GET | `/api/v2/collections/{id}/sources` | Sources / DB liées — wrapper v1 |
| POST | `/api/v2/collections/{id}/sources` | Ajouter une source |
| DELETE | `/api/v2/collections/{id}/sources/{sid}` | Retirer une source |
### 4.4 Pages de collection (collection_pages)
| Tables | `collection_pages` (property_values_json, parent_id, position, gitea_issue_*) |
|--------|-----|
| **Interne** | `GET/POST /db/{id}/pages/api`, `GET/PUT/DELETE /pages/{page_id}/api`, sub-items, dependencies, auto-shift, status-aggregate, move |
Endpoints publics v2 :
| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/v2/collections/{id}/pages?filter[status]=…&sort=…` | Lister (pagination, filtres, tri, fields) |
| POST | `/api/v2/collections/{id}/pages` | Créer (title, icon, property_values, parent_id) |
| GET | `/api/v2/pages/{id}` | Détail (propriétés parsées) |
| PATCH | `/api/v2/pages/{id}` | Titre, icon, position, property_values (merge) |
| DELETE | `/api/v2/pages/{id}` | Corbeille (soft delete via `deleted_at`) |
| POST | `/api/v2/pages/{id}/restore` | Restaurer |
| POST | `/api/v2/pages/{id}/move` | Reordonner (position / parent_id) |
| GET | `/api/v2/pages/{id}/sub-items` | Sous-tâches |
| POST | `/api/v2/pages/{id}/sub-items` | Créer sous-item |
| GET/POST/DELETE | `/api/v2/pages/{id}/dependencies[/{dep_id}]` | Dépendances |
> **Détail `property_values`** : objet JSON `{"<property_id>": <valeur typée>}` — utiliser `property_types.py` (validation/formatage) déjà en place.
### 4.5 Propriétés (`collection_properties`)
| Colonnes clés | name, prop_type (21 types), options_json, number_format, related_collection_id, reverse_name, relation_property_id, target_property_id, rollup_function, formula_expression, required, visible_in_views |
|-----|-----|
| **Interne** | `GET/POST /db/{id}/properties/api`, `PUT/DELETE /properties/{prop_id}/api`, `POST …/properties/relation`, `…/relation/link`, `POST /formula/evaluate`, `POST /rollup/compute`, `GET /property-types/api` |
Endpoints publics v2 :
| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/v2/collections/{id}/properties` | Types + configuration (wrapper) |
| POST | `/api/v2/collections/{id}/properties` | Créer (name, prop_type, options…) |
| PATCH | `/api/v2/properties/{id}` | Modifier (options, required, formula, rollup…) |
| DELETE | `/api/v2/properties/{id}` | Supprimer |
| POST | `/api/v2/properties/{id}/relation` | Créer relation bidirectionnelle |
| POST | `/api/v2/properties/evaluate-formula` | Moteur formula (debug) |
| POST | `/api/v2/properties/compute-rollup` | Moteur rollup (debug) |
### 4.6 Vues (`collection_views`) & Dashboards (`collection_dashboards`)
| **Interne** | `GET /db/{id}/views/api`, `POST /db/{id}/views/save-as`, `PUT /views/{view_id}/config`, `GET/POST/PUT/DELETE /workspace/collections/{id}/dashboards[/{did}]` |
|-----|-----|
| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/v2/collections/{id}/views` | Lister les vues (config_json parsé) |
| POST | `/api/v2/collections/{id}/views` | Créer (name, view_type, config) |
| PATCH | `/api/v2/views/{id}` | Modifier config (filtres, tri, group_by, layout) |
| DELETE | `/api/v2/views/{id}` | Supprimer |
| POST | `/api/v2/views/{id}/save-as` | Dupliquer sous un autre nom |
| GET/POST/PUT/DELETE | `/api/v2/collections/{id}/dashboards[/{did}]` | Dashboards (layout_json) |
### 4.7 Commentaires (`comments`, v4.9.0)
| Colonnes clés | target_type ('page'/'collection_page'), target_id, user_id, body, parent_id, resolved, anchor_block_id, anchor_start, anchor_end |
|-----|-----|
| **Interne** | `GET/POST /pages/{page_id}/comments`, `PUT/DELETE /comments/{comment_id}`, `POST /pages/{page_id}/mentions` |
| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/v2/pages/{id}/comments` | Lister (avec tri chronologique) |
| POST | `/api/v2/pages/{id}/comments` | Ajouter (body, anchor_block_id/start/end optionnels) |
| PATCH | `/api/v2/comments/{id}` | Éditer / résoudre (`resolved: true`) |
| DELETE | `/api/v2/comments/{id}` | Supprimer |
| POST | `/api/v2/pages/{id}/mentions` | Mentionner des users (déclenche notification + email) |
### 4.8 Notifications (`notifications`)
| ntype | 'mention' / 'comment' / 'page' — resource_type, resource_id, url, is_read |
|-----|-----|
| **Interne** | `GET /api/notifications`, `GET /unread-count`, `POST /read`, `POST /read-all` |
| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/v2/notifications?unread=1` | Lister (pagination) |
| POST | `/api/v2/notifications/{id}/read` | Marquer lue |
| POST | `/api/v2/notifications/read-all` | Tout marquer lu |
| GET | `/api/v2/notifications/unread-count` | Compteur (badge) |
| PATCH | `/api/v2/users/me/preferences` | prefs email (comments/mentions) |
### 4.9 Favoris & Tags & Recents
| Tables | `favorites`, `tags` (per-user), `page_tags`, `recents` |
|-----|-----|
| **Interne** | `GET/POST /api/favorites`, `DELETE /api/favorites/{page_id}`, `/api/settings/tags*`, `/api/local-workspace/items/{id}/tags*`, `/api/recents*` |
| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/v2/favorites` | Lister |
| POST | `/api/v2/favorites` | Ajouter `{page_id}` |
| DELETE | `/api/v2/favorites/{page_id}` | Retirer |
| GET | `/api/v2/tags?q=` | Tags de l'utilisateur |
| POST | `/api/v2/tags` | Créer (name, color) |
| PUT/DELETE | `/api/v2/tags/{id}` | Modifier / supprimer |
| POST | `/api/v2/pages/{id}/tags` | Attacher `{tag_id}` |
| DELETE | `/api/v2/pages/{id}/tags/{tag_id}` | Détacher |
| GET | `/api/v2/recents?source_type=` | Récents (limit 20) |
### 4.10 Partage & publication (`page_shares`)
| Colonnes clés | page_id, shared_with_user_id, shared_with_email, permission ('view'/'edit'), created_by + colonnes pages (is_published, publish_slug, is_shared, share_mode) |
|-----|-----|
| **Interne** | `POST /api/share/{page_id}`, `GET /api/pages/{id}/shares`, `DELETE /api/pages/{id}/share/{share_id}`, `POST /pages/{id}/publish`, `DELETE /pages/{id}/publish` |
| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/v2/pages/{id}/shares` | Liste des accès |
| POST | `/api/v2/pages/{id}/shares` | Partager `{email|user_id, permission}` |
| PATCH | `/api/v2/shares/{share_id}` | Changer permission |
| DELETE | `/api/v2/shares/{share_id}` | Révoquer |
| POST | `/api/v2/pages/{id}/publish` | Publier (`{slug}`) → page publique `/p/{slug}` |
| DELETE | `/api/v2/pages/{id}/publish` | Dé-publier |
### 4.11 Historique (`page_history`)
| Colonnes clés | page_id, user_id, change_type, snapshot_json, created_at |
|-----|-----|
| **Interne** | `GET/POST /pages/{page_id}/history` |
| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/v2/pages/{id}/history` | Liste des versions (change_type, created_at, user) |
| POST | `/api/v2/pages/{id}/history/restore` | Restaurer `{history_id}` |
### 4.12 Sprints (`sprints`, `sprint_pages`)
| **Interne** | `/workspace/collections/{id}/sprints[/{sid}]`, assign, burndown |
|-----|-----|
| Méthode | Route | Description |
|---------|-------|-------------|
| GET/POST | `/api/v2/collections/{id}/sprints` | Lister / créer |
| PATCH/DELETE | `/api/v2/sprints/{sid}` | Mettre à jour / supprimer |
| POST | `/api/v2/sprints/{sid}/assign` | Assigner `{page_id, velocity_points}` |
| DELETE | `/api/v2/sprints/{sid}/assign/{page_id}` | Retirer |
| GET | `/api/v2/sprints/{sid}/burndown` | Points (total/completed/remaining/ideal) |
### 4.13 Templates (`page_templates`, `database_templates`)
| **Interne** | `/workspace/collections/{id}/templates/page*`, `/templates/database*`, apply |
|-----|-----|
| Méthode | Route | Description |
|---------|-------|-------------|
| GET/POST | `/api/v2/collections/{id}/templates` | Lister / créer un template de page |
| PATCH/DELETE | `/api/v2/templates/{tid}` | Modifier / supprimer |
| POST | `/api/v2/templates/{tid}/apply` | Instancier (crée une page depuis le template) |
| GET | `/api/v2/templates/database` | Templates de DB (Project tracker, CRM…) |
| POST | `/api/v2/templates/database/{tid}/apply` | Créer une collection depuis le template |
### 4.14 Export & Import
| **Interne** | `GET /api/collections/{id}/export/csv`, `POST /api/collections/{id}/import/csv`, `/export`, `/markdown/{page_id}`, `/pdf/{page_id}`, `/html/{page_id}` (routers/export.py) |
|-----|-----|
| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/v2/pages/{id}/export` | `?format=markdown|pdf|html` → fichier |
| GET | `/api/v2/collections/{id}/export/csv` | CSV |
| POST | `/api/v2/collections/{id}/import/csv` | Import CSV (multipart) |
> Les exports PDF/HTML/MD existent déjà (`export.py`) — v2 ne fait que les exposer avec auth token. Générer le fichier puis le renvoyer (`FileResponse`) ou une URL signée temporaire.
### 4.15 Forges (Gitea / GitHub)
| **Interne** | `routers/gitea.py` + `services/gitea_client.py` (adapter), `routers/github_routes.py` + `services/github_adapter.py` |
|-----|-----|
| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/v2/projects` | Projets forges liés (workspace) |
| GET | `/api/v2/projects/{owner}/{repo}/tree` | Arborescence |
| GET | `/api/v2/projects/{owner}/{repo}/file?path=` | Lire un fichier |
| PUT | `/api/v2/projects/{owner}/{repo}/file` | Écrire `{path, content, message}` (commit) |
| DELETE | `/api/v2/projects/{owner}/{repo}/file` | Supprimer (commit) |
| GET | `/api/v2/projects/{owner}/{repo}/commits?path=` | Historique |
| GET | `/api/v2/projects/{owner}/{repo}/issues` | Issues (provider-agnostic via ForgeAdapter) |
| PATCH | `/api/v2/issues/{provider}/{owner}/{repo}/{id}` | Mettre à jour |
> Cible v6 (roadmap) : interface **`ForgeAdapter`** commune (Gitea + GitHub) — les endpoints v2 consomment l'adapter, pas les clients bruts.
### 4.16 Admin & Audit
| **Interne** | `routers/admin.py` (`/api/admin`), `/audit` |
|-----|-----|
| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/v2/admin/users` | **scope admin** — lister (pagination) |
| PATCH | `/api/v2/admin/users/{id}` | Activer/désactiver, rôle admin |
| DELETE | `/api/v2/admin/users/{id}` | Supprimer |
| GET | `/api/v2/admin/audit-logs` | Dernières actions (login, exports, changements rôle) |
### 4.17 Recherche
| Métier | FTS5 sur `pages` + `collection_pages` (+ tags) — table d'index à créer à la migration v6 |
|-----|-----|
| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/v2/search?query=&workspace_id=&type=page|collection|all` | Résultats groupés par type, snippets |
---
## 5. Webhooks sortants (à étendre en v2)
**Mécanisme existant** (`app/services/webhook_outbound.py`) :
- Table `webhook_subscriptions`: id, url, event, secret, active, created_at.
- Événements actuels (8) :
```python
EVENTS = [
"page.created", "page.updated", "page.deleted",
"collection.created", "collection.updated", "collection.deleted",
"comment.added", "page.moved",
]
```
- Dispatch `fire_event(event, payload)` → POST JSON avec headers `X-FlowDeck-Event` + `X-FlowDeck-Secret`.
**Endpoints v2 webhooks** (wrapper du CRUD sortant existant + gestion des abonnements) :
| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/v2/webhooks` | Abonnements de l'utilisateur |
| POST | `/api/v2/webhooks` | Créer `{url, event, secret}` |
| PATCH | `/api/v2/webhooks/{id}` | Activer/désactiver, changer url/secret |
| DELETE | `/api/v2/webhooks/{id}` | Supprimer |
| POST | `/api/v2/webhooks/{id}/test` | Événement de test `ping` |
| GET | `/api/v2/webhooks/{id}/deliveries` | Journal des livraisons (à ajouter : table `webhook_deliveries`) |
**Événements à ajouter** (parité Notion + besoins v6) :
```python
"page.created", "page.updated", "page.deleted", "page.restored",
"collection.created", "collection.updated", "collection.deleted",
"collection_page.created", "collection_page.updated", "collection_page.deleted",
"comment.added", "comment.resolved",
"mention.created", "notification.created",
"sprint.created", "sprint.updated", "sprint.completed",
"share.created", "share.revoked", "page.published", "page.unpublished",
"agent.run.completed",
```
**Payload type** :
```json
{
"event": "page.updated",
"data": { "id": 42, "workspace_id": 3, "updated_at": "2026-09-04T14:30:00Z" },
"actor": { "id": 1, "login": "bruno" },
"timestamp": "2026-09-04T14:30:00Z"
}
```
- Signature : HMAC-SHA256 du body avec `secret`, header `X-FlowDeck-Signature`.
- Retry : 3 tentatives (2s, 10s, 60s) + journal `webhook_deliveries` (status, http_code, error, duration_ms).
---
## 6. Schéma de données — résumé par ressource
> Schéma complet dans `app/db.py` (`init_db()` + migrations). Tables principales :
| Domaine | Tables |
|---------|--------|
| Auth | `users`, `login_history`, `user_tokens` (Gitea), `user_oauth_tokens`, **`api_tokens` (à créer)** |
| Kanban legacy | `boards`, `cards`, `col_mapping`, `checklists`, `checklist_items`, `notes` |
| Pages | `pages` (content_format='markdown'/'blocks', deleted_at, share_mode, is_published, publish_slug, is_shared, collection_id) |
| Databases | `collections`, `collection_pages`, `collection_views`, `collection_properties`, `collection_data_sources`, `collection_dashboards` |
| Multi-utilisateur | `workspaces`, `workspace_members`, `comments` (target_type/target_id/anchors), `page_history`, `favorites`, `recents`, `page_shares` |
| Templates | `database_templates`, `page_templates` (is_recurring, recurrence_rule) |
| Tags | `tags` (per-user), `page_tags` |
| Tâches | `page_dependencies` (blocks/related, auto_shift), `sprints`, `sprint_pages` |
| Notifications | `notifications` (+ users.notification_prefs) |
| Forges | `gitea_private_pages`, (projets via adapters) |
| Webhooks | `webhook_subscriptions`, **`webhook_deliveries` (à créer)** |
| Agent | (v4.10.0 — à créer : `agents`, `agent_conversations`, `agent_messages`, `agent_actions`, `agent_skills`, `agent_triggers`) |
**Règles** :
- FKs avec `ON DELETE CASCADE` là où la suppression parent doit purger (pages, vues, props, membres, dépendances).
- `property_values_json`, `schema_json`, `config_json`, `layout_json`, `options_json` : JSON textuel, **parse/validate côté service** (`property_types.py`).
- WAL mode + `PRAGMA foreign_keys=ON` (déjà en place dans `get_conn()`).
---
## 7. Sécurité (v2)
| Menace | Contre-mesure |
|--------|---------------|
| Token volé | Hash sha256 en DB, révocation, rotation, `expires_at`, scopes minimaux |
| Rejeu CSRF | N/A côté token (Bearer) ; l'UI garde son CSRF middleware |
| Brute force / abus | Rate limit par token + par IP (middleware existant étendu), 429 |
| Injection SQL | SQL paramétré (`?` seul, jamais de f-string dans les requêtes) |
| XSS (contenu) | Sanitisation à la sortie HTML (UI) ; l'API renvoie du JSON brut (aucun rendu) |
| Exfiltration de secrets | Projection stricte : jamais `password_hash`, `*_token`, clés OAuth dans les réponses |
| CORS | Configurer `allow_origins` pour les clients externes (settings v2) |
| Fichiers | `validate_upload()` existant : extensions whitelist + 10 MB max |
| Audit | Journaliser chaque mutation v2 (user, token_id, action, resource, ts) — table `api_audit_log` |
---
## 8. OpenAPI & documentation
- Activer `docs_url="/docs"` et `redoc_url="/redoc"` **en production** sur le routeur v2 uniquement (ou protéger par token admin).
- Générer `openapi.json` à la release et le versionner dans `docs/openapi-v2.json`.
- Tags OpenAPI par domaine (workspaces, collections, pages, properties, views, comments…).
- Exemples request/response réels dans les docstrings (FastAPI le génère automatiquement en OpenAPI).
- Garder `docs_url=None` tant que v1 est seule (comportement actuel), l'activer lors du merge v2.
---
## 9. Checklist d'implémentation v6.0.0 (ordre recommandé)
1. **Migration DB** : table `api_tokens` (+ index sur token_hash), `webhook_deliveries`, `api_audit_log`, FTS5 index de recherche.
2. **`PermissionManager`** (`app/services/permission_manager.py`) — prerequisite de toute la v2 : `can_read/can_write/can_admin` par workspace + collection + page.
3. **Conventions** : handler d'erreurs RFC 7807, convertisseur ISO-8601, helper pagination (`paginate(query, limit, offset)`), dépendances `require_scope`.
4. **Wrappers v2 read** : reprendre `public_api.py` → `/api/v2` avec pagination/filtres (collections, pages, my-tasks).
5. **Wrappers v2 write** : collections, pages, properties, views (mutation) — les plus demandés par les intégrations.
6. **Webhooks v2** : CRUD abonnements + déliveries + retry + signature HMAC + événements étendus.
7. **Reste des ressources** : sprints, templates, dashboards, favoris, tags, partage, notifications, admin.
8. **Recherche FTS** (`/api/v2/search`).
9. **OpenAPI** : activer /docs + générer openapi-v2.json + exemples.
10. **Tests** (`tests/test_public_api_v2.py`) : auth token + scopes, CRUD complet par ressource, pagination, erreurs, rate limit, webhook delivery (mock httpx). Cible : couverture ≥ 80 % sur le routeur v2.
11. **Documentation** utilisateur : page `/help` + ce guide référencé depuis le README.
## 10. Références
- Schéma DB : `app/db.py`
- Routeurs existants : `app/routers/*.py`
- Frontend : `app/templates/*.html`, `static/js/app.js`
- Webhooks sortants : `app/services/webhook_outbound.py`
- Types de propriétés : `app/services/property_types.py`
- Roadmap : `ROADMAP.md` (v6.0.0 — API publique, realtime, synced blocks, web clipper, permissions granulaires)
+555
View File
@@ -0,0 +1,555 @@
# FlowDeck — Serveur MCP : Guide complet d'intégration agents externes
> **Version :** 1.0 · **Date :** 2026-09-08 · **Cible :** FlowDeck v5.x
> **Sujet :** Exposer FlowDeck comme **serveur MCP** (Model Context Protocol) pour que des agents externes
> (Claude Desktop/Code, Cursor, OpenClaw/Hermes, n'importe quel client MCP) puissent lire et
> modifier le workspace.
> **Prérequis :** FlowDeck v5.x · FastAPI · SQLite WAL · agent interne déjà en place (`AgentEngine` + `ToolRegistry`)
---
## Table des matières
1. [Verdict : est-ce une bonne idée ?](#1-verdict)
2. [Ce que FlowDeck a déjà (l'essentiel est fait)](#2-ce-que-flowdeck-a-déjà)
3. [Positionnement : deux rôles complémentaires](#3-positionnement)
- [3.1 Vue générale des interactions](#31-vue-générale-des-interactions)
- [3.2 Workflow d'un échange avec un agent externe](#32-workflow-dun-échange-avec-un-agent-externe)
- [3.3 Les agents internes passent-ils par le MCP ?](#33-les-agents-internes-passent-ils-par-le-mcp-)
4. [Architecture cible](#4-architecture-cible)
5. [Services offerts par le serveur MCP](#5-services-offerts)
6. [Plan d'implémentation pas à pas](#6-plan-dimplémentation)
7. [Transport & configuration](#7-transport--configuration)
8. [Sécurité](#8-sécurité)
9. [Configuration des clients](#9-configuration-des-clients)
10. [Tests & vérification](#10-tests--vérification)
11. [Version & roadmap](#11-version--roadmap)
12. [Liens & références](#12-liens--références)
---
## 1. Verdict {#1-verdict}
**Oui, c'est une excellente idée — et c'est même l'évolution naturelle du projet.** Trois raisons factuelles :
1. **C'est exactement ce que fait Notion.** Le guide `docs/Guide_Complet_Notion_AI.md` (déjà dans le repo)
documente les endpoints officiels `https://mcp.notion.com/mcp` (Streamable HTTP) et `https://mcp.notion.com/sse`.
FlowDeck est un clone de Notion : l'équivalent « API publique + agents externes » de Notion, c'est un serveur MCP.
On ne copie pas une idée exotique, on complète la parité avec le modèle de référence.
2. **80 % de l'infrastructure existe déjà.** Le `ToolRegistry` (`app/services/tool_registry.py`) est déjà un
**catalogue d'outils compatible MCP** : 22 outils, chacun avec `name` + `description` + `parameters`
(JSON Schema) + `execute(args, user_id=...)`. C'est *exactement* le contrat qu'un serveur MCP expose.
Le `PermissionManager` fait déjà l'autorisation, le journal `agent_actions` fait déjà l'audit + rollback,
`public_api.py` fait déjà l'auth par token Bearer. **Il manque seulement la couche de protocole MCP.**
3. **Le coût marginal est minuscule.** Une dépendance (`mcp` SDK officiel), un module (`app/mcp_server.py`,
~150 lignes), un point d'entrée. **Aucune modification du cœur de l'app** : les outils existants sont
réutilisés tels quels, avec leurs permissions et leur audit. Le serveur MCP est un *second visage*
de l'API existante, pas une nouvelle fonctionnalité monolithique.
**Ce que ça apporte concrètement :** n'importe quel agent externe (Claude Desktop, Cursor, OpenClaw/Hermes…)
peut « voir » et piloter FlowDeck : « crée un CR de réunion dans le workspace X », « ajoute les issues du
sprint au kanban », « résume les tâches en cours », « crée une collection avec statut/sprint », etc.
Sans MCP, chaque agent externe devrait coder son propre client HTTP contre l'API — MCP standardise tout ça
(un seul schéma d'outils, découverte automatique, types JSON Schema).
---
## 2. Ce que FlowDeck a déjà {#2-ce-que-flowdeck-a-déjà}
### 2.1 ToolRegistry — 22 outils (cœur réutilisable)
Fichier : `app/services/tool_registry.py`. Chaque outil est une classe `Tool` avec :
```python
class Tool:
name: str = ""
description: str = "" # explication lisible par un LLM
parameters: dict # JSON Schema (très exactement le format MCP)
async def execute(self, args: dict, *, user_id: int | None = None) -> ToolResult
```
`ToolRegistry.schema(scope)` retourne déjà la liste `{name, description, parameters}` — le format exact
que `tools/list` MCP doit renvoyer. `ToolRegistry.execute(tool, args, user_id=...)` exécute avec
vérification d'existence.
### 2.2 PermissionManager — autorisation
Fichier : `app/services/permission_manager.py`. API :
- `can_read(workspace_id)`, `can_write(workspace_id)`, `can_destructive(workspace_id)`
- `assert_can(tool, args, workspace_id, approval_mode)` — lève une exception si refus ;
les outils destructifs (`delete_*`) exigent le rôle owner/admin **ou** `approval_mode == "confirm"`.
### 2.3 Journal d'audit + rollback
`AgentEngine._log_action(...)` (`app/services/agent_engine.py`) écrit chaque action dans `agent_actions`
(payload, résultat, snapshot undo, `executed_by`). `undo_action(action_id)` restaure.
**Le serveur MCP doit journaliser ses actions dans la même table** — l'audit reste unifié.
### 2.4 Auth par token Bearer
Fichier : `app/routers/public_api.py`. Déjà en place :
- `POST /api/v1/token` génère un token `fd_<urlsafe>` (fonction du `user_tokens` de l'utilisateur connecté)
- `verify_token(Authorization)` valide `Bearer <token>` contre la table `user_tokens`
Table `user_tokens(gitea_user_id, gitea_token)` — **attention** : `gitea_user_id` stocke en réalité
l'**id local** de `users.id` (voir `SessionManager.store_token(user_id, ...)`). Donc la résolution
token → user est directe : `SELECT gitea_user_id FROM user_tokens WHERE gitea_token = ?`.
### 2.5 Contexte agent
`ContextBuilder` (`app/services/context_builder.py`) collecte le contexte (page courante, mentions,
workspace) — utile pour la resource MCP `flowdeck://context` si on veut l'exposer.
---
## 3. Positionnement {#3-positionnement}
Deux surfaces agentiques, complémentaires — **ne pas confondre** :
| | Agent interne (existant) | Serveur MCP (à créer) |
|---|---|---|
| **Direction** | L'utilisateur parle à l'agent **dans** FlowDeck | Un agent externe parle **à** FlowDeck depuis dehors |
| **Orchestration** | `AgentEngine` (boucle ReAct, LLM) | Le **client** MCP orchestre (le serveur ne fait qu'exécuter des outils) |
| **Interface** | Panneau HTML + SSE (`/api/agent/*`) | Protocole MCP (stdio ou Streamable HTTP) |
| **Réutilise** | ToolRegistry, PermissionManager, audit | **Le même ToolRegistry, le même PermissionManager, le même audit** |
| **Valeur** | Assistant intégré à l'UI | FlowDeck pilotable par Claude/Cursor/Hermes/scripts MCP |
Le serveur MCP **ne contient pas de LLM** : il exécute les outils demandés par le client. C'est le client
(claude, cursor, openclaw…) qui décide quoi appeler. Le code de raisonnement n'est pas dupliqué.
### 3.1 Vue générale des interactions {#31-vue-générale-des-interactions}
Deux portes d'entrée distinctes, **un seul socle commun** :
```
CHEMIN A — AGENTS EXTERNES CHEMIN B — AGENT INTERNE
(serveur MCP, NOUVEAU) (UI existante, INCHANGÉE)
Claude Desktop ─┐ Utilisateur (navigateur)
Cursor ├─ MCP stdio ───┐ │
OpenClaw/Hermes ├─ MCP HTTP ────┼──┐ │ chat 🤖 + @mentions
npx mcp-remote ─┘ │ │ ▼
┌───────────▼──▼──────────────┐ ┌─────────────────────────┐
│ Serveur MCP │ │ AgentEngine (ReAct) │
│ app/mcp_server.py │ │ boucle raisonnement↔act │
│ auth token `fd_*` │ │ streaming SSE vers l'UI │
└───────────┬─────────────────┘ └────────────┬────────────┘
│ calls d'outils │ function calls
┌───────────▼──────────────────────────────────▼─────────────┐
│ ToolRegistry — 22 outils │
│ search · read · create · update · delete · gitea · … │
└───────────┬────────────────────────────────────────────────┘
│
┌───────────▼───────────────┐
│ PermissionManager │ ← mêmes ACL pour les deux chemins
│ agent_actions (audit+undo)│
└───────────┬───────────────┘
│
┌───────────▼───────────────┐
│ SQLite · API Gitea/GitHub │
└───────────────────────────┘
```
**À retenir :** les agents externes (chemin A) et l'agent interne (chemin B) ne se rencontrent
jamais « en vol » — ils **convergent** sur le même `ToolRegistry`, passent par les mêmes
`PermissionManager` et le même journal `agent_actions`. Une action faite par Claude Desktop arrive
au même endroit et avec les mêmes règles qu'une action faite par l'agent intégré au navigateur.
### 3.2 Workflow d'un échange avec un agent externe {#32-workflow-dun-échange-avec-un-agent-externe}
Exemple réel : un utilisateur demande à Claude Desktop *« crée une collection Sprint 27 avec les
issues ouvertes du dépôt bruno/flowdeck »*.
```
Claude Desktop Serveur MCP ToolRegistry / Perms / SQLite
│ │ │
│ 1. initialize (handshake) │ │
│─────────────────────────────▶│ │
│ 2. tools/list │ │
│─────────────────────────────▶│ │
│ ◀── 22 outils + schémas │ │
│ (JSON Schema exacts) │ │
│ │ │
│ 3. tools/call │ │
│ "sync_gitea" │ │
│ {owner:"bruno", │ │
│ repo:"flowdeck"} │ │
│─────────────────────────────▶│ 4. résolution user_id │
│ │ (Bearer fd_* → users.id) │
│ │ 5. PermissionManager │
│ │ assert_can(...) │
│ │ 6. execute() → GET issues │
│ │ Gitea + INSERT collection│
│ │ 7. journal agent_actions │
│ │ (payload + undo snapshot)│
│ ◀── résultat JSON │ │
│ {status:"success", │ │
│ data:{collection_id, │ │
│ issues:N}} │ │
│ │ │
│ 8. tools/call "create_page" │ (même séquence 4→7 : │
│ … contenu du CR… │ perms → execute → audit) │
```
Le client MCP orchestre : il décide seul d'enchaîner `sync_gitea` puis `create_page` (ou
`create_document`, `create_gitea_issue`…). Le serveur ne fait que **vérifier, exécuter, auditer** —
l'équivalent de ce que fait `AgentEngine` côté interne, mais sans LLM et sans streaming UI.
### 3.3 Les agents internes passent-ils par le MCP ? {#33-les-agents-internes-passent-ils-par-le-mcp-}
**Non. L'agent interne continue d'appeler directement le `ToolRegistry`** — et c'est une décision
d'architecture, pas un oubli. Le serveur MCP est la porte d'entrée des agents **externes uniquement**.
| Critère | Agent interne → ToolRegistry (direct) | Agent interne → serveur MCP (contournement) |
|---|---|---|
| Streaming SSE (raisonnement + actions live) | événements natifs | **perdu** — le MCP est atomique requête/réponse |
| Contexte (user, workspace, @mentions, page ouverte) | déjà en mémoire dans `AgentEngine` | à re-transmettre à chaque appel |
| Mode approval interactif (`confirm`) | fonctionne (dialogue dans l'UI) | impossible — pas d'humain pour confirmer |
| Surcharge | aucune | sérialisation JSON-RPC + re-auth à chaque outil |
| Bénéfice | — | **aucun** : mêmes outils, mêmes permissions, même base |
Un mauvais aiguillage (agent interne → MCP → ToolRegistry) créerait une boucle hermétique :
l'agent interne n'est pas un « agent externe », il vit **dans** FlowDeck et possède déjà tout ce
dont le MCP aurait besoin d'être re-sécurisé. Réutiliser le serveur MCP en interne reviendrait à
faire transiter des appels locaux par un protocole réseau sans protection supplémentaire.
**Ce que ça ne change pas :** les deux chemins respectent exactement les mêmes règles
(permissions, audit, rollback), donc un agent externe ne peut ni voir ni modifier plus de choses
que l'agent interne ou l'utilisateur lui-même. Et si un jour l'agent interne doit *consommer*
des serveurs MCP externes (rôle client, façon « External Agents » de Notion), c'est une
fonctionnalité séparée — documentée dans `Guide_Complet_Notion_AI.md` — qui n'entre pas en conflit
avec le serveur décrit ici.
---
## 4. Architecture cible {#4-architecture-cible}
```
AGENTS EXTERNES (clients MCP)
┌───────────────┬──────────────────┬──────────────────┐
▼ ▼ ▼ ▼
Claude Desktop Cursor CLI OpenClaw/Hermes npx mcp-remote
(stdio local) (stdio local) (stdio / HTTP) (HTTP distant)
│ │ │ │
│ ┌─────────┴──────────┐ │ │
│ ▼ ▼ ▼ ▼
│ ┌─────────────────────────────────────────────┐
│ │ app/mcp_server.py (FastMCP) │
│ │ ─ stdio : python -m app.mcp_server │
│ │ ─ HTTP : uvicorn (Streamable HTTP) │
│ │ ├─ auth MCP (token fd_* / env var) │
│ │ ├─ résolution user_id │
│ │ └─ mise en correspondance outils MCP │
│ └───────────────┬─────────────────────────────┘
│ │ (mêmes fonctions que l'app)
│ ┌───────────────▼─────────────────────────────┐
│ │ ToolRegistry (22 outils) — INCHANGÉ │
│ │ PermissionManager — INCHANGÉ │
│ │ agent_actions (audit + undo) — INCHANGÉ │
│ │ SessionManager.get_token(user_id) │
│ └───────────────┬─────────────────────────────┘
│ │
│ ┌───────────┴────────────┐
│ ▼ ▼
│ SQLite (flowdeck.db) Gitea / GitHub API
```
**Principe : zéro modification des services existants.** Le module MCP importe `app.services.tool_registry`
et appelle les mêmes classes que l'agent interne. La seule chose nouvelle est la couche protocole.
---
## 5. Services offerts {#5-services-offerts}
Le serveur expose les **22 outils existants** (déjà présents dans `ToolRegistry`), groupés par service :
### Recherche & navigation
| Outil MCP | Description |
|---|---|
| `search_workspace` | Recherche full-text (collections, pages, documents, workspaces) |
| `read_workspaces` | Liste les espaces de travail accessibles et leur contenu |
### Lecture
| Outil MCP | Description |
|---|---|
| `read_collection` | Schéma (propriétés + vues) + pages d'une collection |
| `read_page` | Page de collection + valeurs + dépendances |
| `read_document` | Document éditeur (titre, contenu, métadonnées) |
| `read_gitea_issues` | Issues Gitea d'un repo (état, labels, milestones) |
### Écriture (avec snapshot undo)
| Outil MCP | Description |
|---|---|
| `create_collection` | Crée une collection + vue par défaut |
| `create_view` | Vue (table/board/calendar/gallery/list/timeline/gantt/chart/form/map/feed) |
| `add_property` | Propriété typée |
| `create_page` | Page de collection + valeurs |
| `create_document` | Document éditeur dans un workspace (Markdown ou blocs) |
| `update_page` | Titre / propriétés d'une page |
| `write_blocks` | Contenu blocs d'un document |
| `add_relation` | Relation entre collections (avec réciproque) |
| `create_sub_item` | Sous-élément |
| `add_dependency` | Dépendance entre pages |
| `apply_template` | Page depuis un template |
### Gitea ↔ FlowDeck
| Outil MCP | Description |
|---|---|
| `sync_gitea` | Synchronise les issues d'un repo vers une collection |
| `create_gitea_issue` | Crée une issue dans un repo |
### Destructif (gated — voir §8)
| Outil MCP | Description |
|---|---|
| `delete_document` | Corbeille (soft delete) |
| `delete_page` / `delete_collection` | Suppression (dur, exige `confirm`) |
### Extensions possibles (plus tard)
- **Resource MCP** `flowdeck://workspaces` et `flowdeck://collection/{id}` (le modèle resource MCP est
naturel pour « lister mes espaces » sans appeler un outil).
- **Prompts MCP** : templates « CR de réunion », « Sprint review » réutilisant les skills de l'agent interne.
- **Notifications MCP** (logging/sampling) — à réserver pour plus tard, les outils couvrent 95 % des besoins.
---
## 6. Plan d'implémentation {#6-plan-dimplémentation}
### Étape 1 — Dépendance
Ajouter dans `requirements.txt` :
```
mcp>=1.9,<2
```
> ⚠️ **Piège version SDK** : le SDK officiel `mcp` est passé en **v2**, où `FastMCP` est renommé `MCPServer`
> (`from mcp.server.mcpserver import MCPServer`) et les API ont changé (migration : `py.sdk.modelcontextprotocol.io/v2/migration`).
> **Épingler `mcp>=1.9,<2`** : l'API FastMCP v1 est la plus documentée (la quasi-totalité des exemples
> publics) et parfaitement stable. La migration vers v2 peut se faire plus tard, sans changer les outils.
### Étape 2 — Nouveau module `app/mcp_server.py`
Squelette de référence (les 22 outils suivent le même patron ; 3 représentatifs ci-dessous) :
```python
"""FlowDeck — Serveur MCP (v1).
Réexpose le ToolRegistry existant comme serveur MCP.
Aucune modification des services : mêmes classes, mêmes permissions, même audit.
Transports : stdio (local) ou Streamable HTTP (distant) via --transport http.
"""
from __future__ import annotations
import json
import os
from typing import Any
from mcp.server.fastmcp import FastMCP
from app.db import get_conn
from app.services.tool_registry import (
ToolRegistry, SearchWorkspace, ReadCollection, CreateCollection,
CreatePage, CreateDocument, CreateGiteaIssue, # ... + les 16 autres
)
mcp = FastMCP("flowdeck")
# ── Résolution d'utilisateur ──────────────────────────────────────────────
def resolve_user_id() -> int | None:
"""stdio : env FLOWDECK_MCP_USER_ID (dev) ou FLOWDECK_MCP_TOKEN (résout via user_tokens)."""
uid = os.environ.get("FLOWDECK_MCP_USER_ID")
if uid:
return int(uid)
token = os.environ.get("FLOWDECK_MCP_TOKEN")
if token:
with get_conn() as conn:
row = conn.execute(
"SELECT gitea_user_id FROM user_tokens WHERE gitea_token=?", (token,)
).fetchone()
return row["gitea_user_id"] if row else None
return None # mode admin sans authentification (usage local uniquement)
# ── Outils : wrapper mince sur ToolRegistry ───────────────────────────────
async def _run(impl, args: dict) -> str:
result = await impl.execute(args, user_id=resolve_user_id())
return json.dumps({
"status": result.status,
"message": result.message,
"data": result.data,
"target": {"type": result.target_type, "id": result.target_id},
}, ensure_ascii=False)
@mcp.tool()
async def search_workspace(query: str) -> str:
"""Recherche full-text dans tout FlowDeck (collections, pages, documents, workspaces)."""
return await _run(SearchWorkspace(), {"query": query})
@mcp.tool()
async def read_collection(collection_id: int) -> str:
"""Lit le schéma (propriétés + vues) et les pages d'une collection."""
return await _run(ReadCollection(), {"collection_id": collection_id})
@mcp.tool()
async def create_collection(name: str, description: str = "", icon: str = "📋") -> str:
"""Crée une collection avec sa vue par défaut."""
return await _run(CreateCollection(), {"name": name, "description": description, "icon": icon})
# ... même patron pour create_page, create_document, create_view, add_property,
# update_page, write_blocks, add_relation, create_sub_item, add_dependency,
# apply_template, read_page, read_document, read_workspaces,
# read_gitea_issues, sync_gitea, create_gitea_issue,
# delete_document, delete_page, delete_collection (voir §8 pour ces 3-là)
# ── Écriture systématique dans le journal d'audit ─────────────────────────
# Chaque outil MCP DOIT journaliser dans agent_actions (comme AgentEngine._log_action)
# pour garder l'audit unifié et permettre le rollback via undo_action().
if __name__ == "__main__":
import sys
transport = sys.argv[1] if len(sys.argv) > 1 else "stdio"
mcp.run(transport=transport) # "stdio" | "streamable-http"
```
### Étape 3 — Auditer les actions MCP
Réutiliser le schéma `agent_actions` : insérer un enregistrement à chaque exécution d'outil
(`tool_name`, `payload_json`, `result_json`, `status`, `undo_snapshot_json`, `executed_by=user_id`).
Le rollback (`undo_action`) fonctionne ensuite à l'identique pour les actions faites par des agents externes.
### Étape 4 — Point d'entrée & lancement
```bash
# stdio (Claude Desktop, Cursor, OpenClaw…) — lancé par le client lui-même
FLOWDECK_MCP_USER_ID=1 python -m app.mcp_server
# HTTP (antémémoire: agents distants) — port par défaut 8123
FLOWDECK_MCP_TOKEN=fd_xxx uvicorn app.mcp_server_http:app --port 8123 # ou mcp.run(transport="streamable-http")
```
En Docker : ajouter une commande/second process au conteneur existant (même image, même volume SQLite)
ou un service compose séparé partageant le même volume — la base SQLite WAL se partage sans problème
avec le process principal (déjà le cas en dev local).
### Étape 5 — Documentation client dans le repo
Ajouter un fichier `docs/MCP_CLIENTS.md` (ou une section du présent guide, §9) avec les blocs de config
pour chaque client.
---
## 7. Transport & configuration {#7-transport--configuration}
| Transport | Usage | Lancement | Auth |
|---|---|---|---|
| **stdio** | Agents locaux (Claude Desktop, Cursor, OpenClaw) | Le client spawn `python -m app.mcp_server` | Env `FLOWDECK_MCP_USER_ID` / `FLOWDECK_MCP_TOKEN` |
| **Streamable HTTP** | Agents distants / multi-clients | `uvicorn` sur `MCP_PORT` (défaut 8123) | Header `Authorization: Bearer fd_*` |
Variables d'environnement (à ajouter à `.env` / `app/config.py`) :
```
MCP_ENABLED=true
MCP_PORT=8123
MCP_MAX_TOKENS_HISTORY=50 # contrôle de charge côté serveur (optionnel)
FLOWDECK_MCP_TOKEN= # généré via POST /api/v1/token ou manuellement
```
> **Streamable HTTP (spéc. 2025-03-26+)** remplace l'ancien SSE : le SDK FastMCP le gère nativement via
> `mcp.run(transport="streamable-http")`. C'est le protocole utilisé par Notion (`mcp.notion.com/mcp`).
---
## 8. Sécurité {#8-sécurité}
1. **Auth** — HTTP : `Bearer fd_*` validé contre `user_tokens` (pattern de `public_api.verify_token`).
stdio : variable d'env obligatoire ; refuser de démarrer sans résolution d'utilisateur en dehors du
mode local explicite (`FLOWDECK_MCP_ALLOW_ANON=1` uniquement en dev).
2. **Permissions** — chaque appel passe par `PermissionManager.assert_can(tool, args, workspace_id, ...)`
comme pour l'agent interne. Un agent externe ne voit/ne modifie **que** ce que son utilisateur peut
voir/modifier (pas de contournement MCP — même règle que Notion : *« MCP does not bypass Notion permissions »*).
3. **Outils destructifs** — `delete_document`, `delete_page`, `delete_collection` : **non exposés par défaut**
ou exigeant un paramètre explicite `confirm: true` + `approval_mode == "confirm"`. Recommandation :
activables par env `FLOWDECK_MCP_ALLOW_DESTRUCTIVE=true`, désactivé par défaut.
4. **Audit** — 100 % des actions MCP journalisées dans `agent_actions` (qui, quoi, quand, quel user,
snapshot undo) → consultable depuis l'UI agent et rollback possible.
5. **Rate limiting** — réutiliser le middleware existant (`slowapi`) côté HTTP ; côté stdio le client
contrôle le débit.
6. **Réseau** — en HTTP, ne pas exposer directement : reverse-proxy avec TLS (Traefik/Caddy/Nginx) et
restriction IP si l'usage est interne au homelab. Le token `fd_*` transite en clair sinon.
---
## 9. Configuration des clients {#9-configuration-des-clients}
### Claude Desktop — `claude_desktop_config.json`
```json
{
"mcpServers": {
"flowdeck": {
"command": "python",
"args": ["-m", "app.mcp_server"],
"cwd": "/home/bruno/workspace/flowdeck",
"env": { "FLOWDECK_MCP_USER_ID": "1" }
}
}
}
```
### Cursor — `.cursor/mcp.json` (même structure)
### OpenClaw / Hermes — `config.yaml` (client MCP natif)
```yaml
mcp:
servers:
flowdeck:
command: python
args: ["-m", "app.mcp_server"]
cwd: /home/bruno/workspace/flowdeck
env: { FLOWDECK_MCP_USER_ID: "1" }
```
### Distant (HTTP) — n'importe quel client MCP
```bash
# via mcp-remote (bridge stdio→HTTP)
npx mcp-remote http://flowdeck.lab.home:8123/mcp --header "Authorization: Bearer fd_xxx"
```
---
## 10. Tests & vérification {#10-tests--vérification}
1. **Inspector officiel** : `mcp dev app/mcp_server.py` (SDK v1) — UI web pour lister les outils,
exécuter chaque outil, vérifier schémas et erreurs.
2. **Test pytest** (`tests/test_mcp_server.py`) : démarrer le serveur en stdio, client `mcp.client.stdio`,
`tools/list` doit renvoyer ≥ 20 outils avec les bons `inputSchema` ; `call_tool("create_collection")`
crée réellement la collection (vérifier en DB), `call_tool("delete_collection")` est refusé sans `confirm`.
3. **Vérification d'audit** : après un appel MCP, une ligne `agent_actions` doit exister avec
`executed_by` = l'utilisateur résolu.
4. **CI Gitea Actions** : ajouter ces tests à la pipeline existante (pytest + ruff).
---
## 11. Version & roadmap {#11-version--roadmap}
Proposer une entrée en roadmap (le projet est à v5.x, `realtime` en v5.13.0) :
> **v5.14.0 — Serveur MCP** : exposition du ToolRegistry via MCP (stdio + Streamable HTTP),
> auth par token `fd_*`, audit unifié `agent_actions`, outils destructifs gated,
> guide client + tests.
Étapes de livraison : (1) squelette + inspector, (2) audit + rollback, (3) transport HTTP + auth,
(4) configs clients + docs, (5) CI.
---
## 12. Liens & références {#12-liens--références}
- Spec MCP : https://modelcontextprotocol.io
- SDK Python officiel : https://github.com/modelcontextprotocol/python-sdk (épingler `<2` pour FastMCP v1)
- Notion MCP (modèle de référence, déjà documenté dans ce repo) : `docs/Guide_Complet_Notion_AI.md`
- Code à réutiliser : `app/services/tool_registry.py` · `app/services/permission_manager.py` ·
`app/routers/public_api.py` (auth token) · `app/services/agent_engine.py` (`agent_actions` + undo)
- Guide agent interne existant : `docs/Flowdeck_Agent_integration.md`
+3 -1
View File
@@ -655,7 +655,9 @@ Phase 3 — Interface
Phase 4 — Personnalisation
1. Instructions custom par agent
2. Skills (table + /apply)
3. Sélecteur de modèle (GPT/Claude/Gemini/Ollama)
3. Sélecteur de modèle (GPT/Claude/Gemini/Ollama) ✅ (v4.10.1 : selects provider/modèle dans le
panneau + config admin runtime DB-backed `llm_config`, `/api/agent/providers` GET/PATCH,
`/api/agent/providers/test`)
Phase 5 — Custom Agents
1. Scope + trigger_json
+375
View File
@@ -0,0 +1,375 @@
# Guide de Référence : Fonctionnement et Architecture de Notion AI (Web)
Ce guide détaille l'ensemble des mécanismes, des flux logiques et des fonctionnalités de l'intelligence artificielle intégrée à l'application web de Notion.
> **Méthodologie :** ce document a été vérifié et mis à jour le **5 septembre 2026** à partir de la documentation officielle de Notion (Help Center, `developers.notion.com`, `notion.com/security`, pages produit). Les URLs sources sont listées en section 7. Les points qui n'ont pas pu être confirmés par la documentation officielle sont explicitement marqués d'un ⚠️.
---
## 1. Diagramme d'Intégration et Architecture des Composantes
Notion AI fonctionne comme une **couche d'orchestration cognitive** qui relie l'interface utilisateur web, le graphe de blocs internes (*Block Tree*), le moteur relationnel de bases de données et des sources externes via les connecteurs et le protocole MCP (*Model Context Protocol*).
```mermaid
flowchart TB
subgraph UI ["1. Interface Utilisateur & Points d'Entrée"]
A1["Notion Agent : Chat latéral ou flottant (Shift+Cmd/Ctrl+J)"]
A2["Onglets Home : Agent / Research / Search"]
A3["Génération In-line (Espace sur ligne vide, /AI Block, Edit with AI)"]
A4["AI Meeting Notes (/meet, Notion Calendar)"]
A5["Propriétés IA de Bases de Données (AI Autofill)"]
A6["Custom Agents (triggers, schedules, Slack)"]
end
subgraph ORCHESTRATOR ["2. Notion AI Core (Orchestrateur & Sécurité)"]
B1["Vérificateur de Droits & Permissions (RBAC)"]
B2["Gouvernance des Crédits & Modèles Premium (Admin)"]
B3["Routeur de Modèles (Auto / Claude Fable 5 / Sonnet 5 / GPT / Gemini / Grok)"]
B4["Personnalisation (Instructions, Skills, Mémoire de l'Agent)"]
end
subgraph RETRIEVAL ["3. Couche RAG & Contexte (All Sources)"]
C1["Espace de travail Notion (Pages, Blocs, Bases, Commentaires)"]
C2["AI Connectors (Slack, Drive, Jira, GitHub, Gmail, Calendar...)"]
C3["Recherche Web (contrôlée par l'admin)"]
C4["Serveurs MCP Externes (External Agents)"]
C5["Base Vectorielle d'Embeddings (recherche sémantique)"]
end
subgraph EXECUTION ["4. Moteur d'Exécution & Restitution"]
D1["Moteur d'Édition de Blocs (CRUD Block Engine, pages, bases)"]
D2["Moteur Autofill (Basic + Custom Agent)"]
D3["Module STT Audio (Transcription, Diarisation)"]
D4["Rendu du Chat avec Citations de Sources"]
end
%% Interactions UI vers Orchestrateur
A1 & A2 & A3 & A4 & A5 & A6 --> B1
B1 --> B2
B2 --> B3
B4 -.-> B3
%% Interaction Orchestrateur vers Contexte
B3 <--> RETRIEVAL
%% Exécution vers Notion
B3 --> D1
B3 --> D2
A4 <--> D3
D3 --> B3
B3 --> D4
%% Rétroaction sur l'UI
D1 -. Mise à jour de la page .-> A3
D2 -. Mise à jour des colonnes .-> A5
```
### Explication des Flux d'Interaction
1. **Isolation des Accès (RBAC) :** Toute requête passe en premier par le filtre de sécurité de Notion. Formulation officielle : *« Notion AI honors existing permissions. The LLMs and AI Models used to generate AI responses for a user cannot see or use any information to which that user does not already have access. »* L'IA n'a accès qu'aux pages, bases de données et blocs pour lesquels l'utilisateur connecté dispose d'une autorisation de lecture explicite — y compris via MCP.
2. **Assemblage Dynamique du Contexte (RAG) :** L'orchestrateur agrège les métadonnées de la page courante, l'historique du fil de discussion, les instructions personnalisées et les *skills* de l'agent, ainsi que les fragments documentaires extraits par recherche sémantique (embeddings vectoriels) dans les sources actives (workspace, connecteurs, web).
3. **Exécution Agentique :** En mode agentique, l'IA ne renvoie pas un simple texte brut : elle peut **créer et modifier de manière autonome des pages et des bases de données** dans l'espace de travail, avec les mêmes permissions que l'utilisateur connecté. Le contenu généré est inséré sous forme de **blocs éditables**.
4. **Traçabilité :** Les réponses issues d'Enterprise Search et du Research Mode **citent systématiquement leurs sources** (liens cliquables vers les pages Notion, les messages Slack, les documents connecteurs ou les pages web utilisées).
---
## 2. Points d'Accès dans l'Interface Web
L'IA s'adapte au contexte d'utilisation à travers plusieurs interfaces distinctes :
| Interface | Déclenchement | Comportement principal |
| --- | --- | --- |
| **Notion Agent (chat IA)** | `Shift + Cmd/Ctrl + J` depuis n'importe quelle page, ou onglet **Home > Agent** | Assistant agentique persistant, affichable en **panneau latéral (Sidebar)** ou en **fenêtre flottante (Floating)** (bascule depuis le chat lui-même). Interroge tout l'espace de travail, écrit, et exécute des tâches avec les permissions de l'utilisateur. |
| **Onglets Home** | Page d'accueil **Home** | Trois entrées dédiées : **Agent** (chat agentique), **Research** (recherche approfondie), **Search** (Enterprise Search). |
| **Génération In-Line** | Touche `Espace` sur une **nouvelle ligne vide** | Affiche le champ de prompt de l'IA et génère du contenu directement dans la page, à l'endroit du curseur. |
| **Bloc IA réutilisable** | Commande `/AI Block` | Insère un **bloc de prompt IA** réutilisable dans la page (le bloc rejoue son prompt à la demande). |
| **Edit with AI (menu contextuel)** | Sélection d'un fragment de texte à la souris → **`Edit with AI`** dans la barre d'outils flottante | Transformations locales : corriger la grammaire (*fix grammar*), raccourcir (*make it shorter*), changer le ton (*change the tone*), traduire (*translate*), résumer (*summarize*), brainstormer (*brainstorm*). |
| **AI Meeting Notes** | Commande `/meet` dans une page (app de bureau v4.7.0+), bouton **`Join and transcribe`** dans Notion Calendar, onglet **Meetings** de la barre latérale | Enregistreur audio à capture **locale** (aucun bot ne rejoint l'appel) : transcription en continu, diarisation, puis compte-rendu structuré. |
| **AI Autofill (bases de données)** | Configuration sur une propriété de base de données (`AI Autofill`) | Fonction de calcul dynamique appliquée à chaque entrée : résumé, traduction, extraction d'infos clés, prompt libre ou agent personnalisé. |
| **Custom Agents** | Triggers (événements), plannings (horaires), invocation depuis **Slack** | Agents personnalisés s'exécutant **en arrière-plan**, avec accès limités à certaines pages/bases et délégation à des sous-agents. |
> ⚠️ **Corrections par rapport aux versions précédentes de ce guide :** le raccourci officiel du chat est `Shift + Cmd/Ctrl + J` (et non `Ctrl + O`) ; les commandes exactes `/ai` et le bouton « Ask AI » sur page vierge n'apparaissent pas dans la documentation actuelle (la doc officielle décrit la touche `Espace` sur ligne vide, `/AI Block` et `Edit with AI`).
---
## 3. Configuration des Modes et Sélecteur de Modèles
### Les Modes Opératoires
La documentation officielle actuelle ne décrit plus trois « modes » commutables dans une même fenêtre de chat (Default / Ask / Research via `Shift + Tab`), mais **trois surfaces distinctes** :
```
┌────────────────────────────────┐
│ Onglet HOME │
└───────────────┬────────────────┘
┌──────────────────────────┼──────────────────────────┐
▼ ▼ ▼
[ Notion Agent ] [ Research Mode ] [ Enterprise Search ]
• Chat agentique par • Investigation profonde • Recherche unifiée sur
défaut multi-étapes toutes les sources
• Lecture, écriture, • Croise workspace, web • Pages Notion + apps
création de pages/bases et connecteurs connectées + web
• Mêmes permissions que • Peut durer ~10 minutes • Citations systématiques
l'utilisateur • Rapports sourcés des sources
partageables • Choix du modèle
```
* **Notion Agent (chat par défaut) :** assistant agentique qui répond, écrit et **exécute des tâches dans l'espace de travail** (création/modification autonome de pages et de bases de données), avec les mêmes permissions que l'utilisateur connecté. La documentation produit mentionne la possibilité de **relire et approuver les plans** avant exécution des tâches agentiques (⚠️ partiellement vérifié — article référencé mais non consulté directement).
* **Research Mode :** accessible via **Home > Research**. L'IA mène une recherche approfondie multi-étapes (jusqu'à ~10 minutes) en croisant le contenu de l'espace de travail, le web et les connecteurs, puis produit une **réponse sourcée** et des **rapports partageables** (plans Business et Enterprise).
* **Enterprise Search :** accessible via **Home > Search**. Recherche IA unifiée à travers le workspace **et les applications connectées** (Slack, Microsoft Teams, Google Drive, Jira, GitHub, SharePoint, OneDrive), avec réponses générées par LLM et **citations systématiques des sources** (plans Business et Enterprise). Le modèle est sélectionnable (GPT, Claude, Gemini ou Auto).
### Le Sélecteur de Modèles (Moteurs LLM)
Modèles proposés dans le Notion Agent (source : `help/notion-agent`) :
```
┌──────────────────────────────────────────────────────────────┐
│ Auto (Notion choisit automatiquement le modèle le plus │
│ adapté à la demande) │
├──────────────────────────────────────────────────────────────┤
│ Modèles premium : │
│ • Claude Fable 5 -> Tâches les plus difficiles │
├──────────────────────────────────────────────────────────────┤
│ Modèles standards : │
│ • Claude Sonnet 5 -> Anthropic, polyvalent │
│ • GPT (derniers) -> OpenAI │
│ • Gemini -> Google │
│ • Grok -> xAI │
└──────────────────────────────────────────────────────────────┘
```
* **Auto (Recommandé) :** Notion sélectionne automatiquement le modèle le plus adapté à la demande.
* **Claude Fable 5 :** modèle premium réservé aux tâches les plus difficiles.
* **Claude Sonnet 5, GPT, Gemini, Grok :** modèles standards des fournisseurs Anthropic, OpenAI, Google et xAI.
* **Gouvernance des modèles premium et des crédits :** les administrateurs gèrent l'accès aux modèles premium et la consommation de crédits Notion des membres (`Settings → Notion AI`). Une **activation manuelle est requise d'ici le 10 septembre 2026** pour continuer à utiliser les modèles premium (source : `help/manage-ai-models-and-member-credit-spend`).
> ⚠️ **Corrections :** les modèles « Opus 5 », « GPT-5.6 Sol », « Kimi K3 » et le raccourci `Shift + Tab` mentionnés dans les versions précédentes de ce guide **n'apparaissent pas dans la documentation officielle actuelle**.
---
## 4. Gestion des Sources, Connecteurs et Écosystème MCP
### 1. Sélecteur de Sources et Filtrage du Contexte
Dans le chat, un sélecteur **« All sources »** permet de contrôler le périmètre de recherche de l'IA :
```
[ Sélecteur "All sources" ]
│
┌──────────────────────────┼──────────────────────────┐
▼ ▼ ▼
[ Espace Notion ] [ Apps Connectées ] [ Recherche Web ]
• Toutes les pages • Slack, Teams, Drive • Activée/désactivée
accessibles SharePoint, Jira, par l'admin
• Filtres par pages GitHub, Linear, Gmail, • Option "Require
ou teamspaces Outlook, calendriers confirmation for
spécifiques (dont Notion Calendar) web requests"
```
* **Périmètre par défaut :** l'IA interroge tout le contenu auquel l'utilisateur a accès ; il est possible de **filtrer par pages, teamspaces, web et apps connectées**.
* **AI Connectors :** liste officielle incluant **Slack, Microsoft Teams, Google Drive, SharePoint, Jira, GitHub, Linear, Gmail, Outlook, les calendriers (dont Notion Calendar)** et d'autres (source : `help/notion-ai-connectors`).
* **Recherche Web :** contrôlée au niveau du workspace via `Settings → Notion AI` (`Enable web search for workspace`, `Require confirmation for web requests`).
* **Contrôle du périmètre Enterprise Search :** désactivation possible de la recherche web ; limitation à des apps, workspaces ou teamspaces spécifiques.
### 2. Intégration MCP (Model Context Protocol)
Notion supporte le standard ouvert **MCP** dans les deux sens :
#### a) Notion comme **serveur MCP** (piloter Notion depuis l'extérieur)
* **Endpoints officiels :** `https://mcp.notion.com/mcp` (Streamable HTTP, recommandé) et `https://mcp.notion.com/sse` (fallback SSE si le client ne gère pas Streamable HTTP). Configuration STDIO possible via `npx mcp-remote`.
* **Authentification :** OAuth interactif obligatoire. L'autorisation non interactive (workflows automatisés) n'est **pas encore supportée**.
* **Outils exposés** (liste officielle, source : `developers.notion.com/guides/mcp/mcp-supported-tools`) :
* Recherche : `notion-search`, `notion-ai-search` (recherche sémantique sur Notion + apps connectées : Slack, Mail, Calendar, Google Drive, Jira), `notion-search-skills`
* Lecture : `notion-fetch` (pages, bases, data sources, vues, id spécial `self`), `notion-query-data-sources` (SQL / rows / view — ne retourne que les lignes et propriétés lisibles par l'utilisateur connecté), `notion-query-meeting-notes`
* Écriture : `notion-create-pages`, `notion-update-page`, `notion-move-pages`, `notion-duplicate-page`, `notion-create-database`, `notion-create-folder`, `notion-update-data-source`, `notion-create-view`, `notion-update-view`
* Commentaires : `notion-create-comment`, `notion-get-comments`
* Organisation : `notion-get-users`, `notion-get-teams`
* Fichiers : `notion-create-file-upload` (≤ 20 MiB), `notion-create-attachment`, `notion-download-attachment`, `notion-get-async-task`
* Agents & skills : `notion-list-agents`, `notion-search-agents`, `notion-spawn-session`, `notion-send-message-to-session`, `notion-wait-session`, `notion-convert-page-to-skill`
* **Clients supportés (documentés avec instructions) :** Codex (OpenAI), Claude Code, Cursor, VS Code (GitHub Copilot), fx, Hermes, Devin, Pi, Antigravity ; le Help Center cite aussi Claude Desktop / Claude.ai et ChatGPT Pro. Principe officiel : *« Any AI app that supports the Model Context Protocol can connect to Notion. »*
* **Limites de débit :** ~180 requêtes/min en moyenne par utilisateur (3/s), limite partagée au niveau du workspace ; `notion-search` (mot-clé) plafonné à 30 req/min.
* **Serveur open source `notion-mcp-server` (GitHub) :** existe mais n'est **plus maintenu activement** (bearer token) ; Notion recommande le serveur hébergé.
#### b) Notion comme **client MCP** (interroger des outils externes depuis Notion)
* La documentation de sécurité confirme l'existence de la fonctionnalité **« External Agents »** (serveurs MCP externes branchés sur l'IA Notion), dotée de **contrôles et de pratiques de rétention distincts**. Le Notion Agent peut utiliser des serveurs MCP comme sources/outils.
* ⚠️ **Non vérifié :** la procédure utilisateur exacte de connexion d'un serveur MCP tiers dans Notion et la liste des connecteurs partenaires (Linear, Figma, GitHub…) — à confirmer sur `notion.com/product/agents`.
#### c) Contrôles d'Administration (plan Enterprise)
* **Liste blanche stricte :** `Settings → Connections → Permissions → AI apps → Restrict AI apps members can connect = Only from approved list`, puis `Manage approved AI apps` / `Add approved AI apps`.
* **Comportements documentés :** blocage de tout appel provenant d'un outil non approuvé (même si un token OAuth existe déjà, les tokens antérieurs ne pouvant pas être révoqués individuellement) ; bouton **`Disconnect All Users`** qui déconnecte tous les clients MCP du workspace (ré-authentification requise) ; pas de déconnexion ciblée par outil ni de visibilité par utilisateur des outils connectés (pas encore disponible) ; connexions gérables via **Okta** ; renommage/logo d'un AI app personnalisé par l'admin, journalisé dans l'audit log.
#### d) Permissions
* Formulation officielle : *« After you authorize the connection, the client can read and update content that you can access in the selected Notion workspace »* et *« Notion MCP continues to respect all existing Notion permissions »* — avec l'avertissement : *« MCP tools act with your full Notion permissions — they can access everything you can access. »*
### 3. Personnalisation Persistante de l'Agent
Le Notion Agent est personnalisable de façon durable (source : `help/notion-agent`) :
* **Nom et avatar :** l'agent peut être renommé et doté d'accessoires visuels.
* **Instructions personnalisées :** rédigées dans une page dédiée (ou une page existante) ; l'agent **mémorise les instructions** qui lui sont données et s'y conforme dans les conversations ultérieures (ex. rôle métier, directives de style, règles de mise en page récurrentes).
* **Skills :** compétences réutilisables que l'agent peut invoquer (la doc renvoie vers des articles dédiés « instructions and skills »).
> ⚠️ **Correction :** la référence à une « icône de canard jaune » des versions précédentes de ce guide n'est pas vérifiée par la documentation officielle.
---
## 5. Fonctionnalités Avancées Spécialisées
### A. Notion Agents & Custom Agents
Deux niveaux d'agents coexistent (sources : `help/notion-agent`, `help/custom-agents`, `product/agents`) :
```
┌──────────────────────────────┬──────────────────────────────────────┐
│ Notion Agent (interactif) │ Custom Agents (arrière-plan) │
├──────────────────────────────┼──────────────────────────────────────┤
│ • Chat à la demande │ • Déclenchés par des TRIGGERS │
│ (Shift+Cmd/Ctrl+J) │ (événements) ou des PLANNINGS │
│ • Agit dans tout le │ (horaires) │
│ workspace avec vos │ • Accès LIMITÉS à certaines │
│ permissions │ pages / bases de données │
│ • Sidebar ou fenêtre │ • Invocables depuis SLACK │
│ flottante │ • Délèguent à des SOUS-AGENTS │
│ • Instructions, skills, │ (hand-offs) │
│ mémoire persistante │ • Modèle configurable │
│ │ • Dupliquables (servent de base à │
│ │ de nouveaux agents) │
│ │ • Consomment des crédits Notion │
│ │ (10 $ / 1 000 crédits depuis le │
│ │ 4 mai 2026) │
└──────────────────────────────┴──────────────────────────────────────┘
```
* **Agents préconstruits :** Notion fournit des agents prêts à l'emploi (réunions, recherches, suivi de projets).
* **Comportement agentique :** l'agent peut créer/modifier pages et bases de données de manière autonome, toujours borné par les permissions de l'utilisateur connecté.
### B. AI Meeting Notes (Transcription & Synthèse Audio)
Le module de réunion transforme Notion en preneur de notes autonome (sources : `help/ai-meeting-notes`, `product/ai-meeting-notes`). **Disponibilité : plans Business et Enterprise.**
```
[ /meet ou "Join and transcribe" (Notion Calendar) ]
│
▼
[ Capture LOCALE : micro (navigateur) ou audio système + micro (desktop) ]
Aucun bot ne rejoint l'appel — extension navigateur pour les noms (Google Meet)
│
▼
[ Transcription Live (16 langues) + Diarisation (étiquettes locuteurs en anglais) ]
Consentement : notification texte + bouton 🔈 (avertissement vocal) + auto-consent
│
▼
[ Arrêt : ≥ ~300 caractères transcrits (~1 min) requis pour le résumé ]
│
▼
[ Génération du Compte-Rendu ]
• Résumé, décisions, action items
• Citations cliquables vers les horodatages audio/transcription
• Instructions : Auto | types intégrés (sales call, standup, team meeting)
| instructions personnalisées (privées par défaut, partageables)
```
1. **Démarrage et capture :** commande **`/meet`** dans n'importe quelle page via l'**application de bureau (v4.7.0+**, macOS 13+ ou dernière version Windows). La capture est **locale** : aucun bot ne rejoint Zoom/Meet/Teams. Le **navigateur** capte le **microphone uniquement** ; l'**app de bureau** capte **audio système + micro**. Une **extension navigateur** récupère les noms des intervenants sur Google Meet. Intégration **Notion Calendar** : boutons « Join and transcribe » / « View summary » et onglet **Meetings** dans la barre latérale.
2. **Consentement :** notification textuelle aux participants, bouton **🔈** qui diffuse un avertissement vocal dans les haut-parleurs, option de **consentement audio automatique** ; les administrateurs peuvent l'imposer à tout le workspace.
3. **Transcription et diarisation :** transcription en direct dans **16 langues** ; détection des changements de locuteur et étiquetage des intervenants en utilisant le contexte (ex. événement d'agenda) — **étiquetage des locuteurs disponible en anglais uniquement**.
4. **Compte-rendu :** minimum **~300 caractères transcrits (~1 minute)** pour générer un résumé. Le compte-rendu inclut résumé, décisions, action items et **citations cliquables renvoyant aux horodatages** de la transcription/audio.
5. **Instructions / gabarits :** trois niveaux — mode **Auto**, **types intégrés** (sales call, standup, team meeting…) et **instructions personnalisées** (privées par défaut, partageables avec le workspace).
6. **Import de fichiers audio :** formats **AAC, M4A, MP3, WAV** uniquement (pas de MOV/MP4/Loom). Limite : **10 heures d'enregistrement/jour/utilisateur**.
7. **Rétention et confidentialité (détaillée en section 6) :** audio capté stocké temporairement en local puis supprimé après traitement ou sous **24 h** ; en cas d'échec, upload serveur conservé **3 jours max** ; **suppression automatique planifiée des transcriptions** configurable par les owners Enterprise (les résumés et notes sont conservés ; pages en *legal hold* exclues).
8. **Sous-traitants IA :** OpenAI, Anthropic, Fireworks, Baseten Labs, X.AI, AssemblyAI.
> ⚠️ **Non vérifié par la doc officielle :** le nom exact des onglets de l'interface (Notes / Transcript / Summary) et la prise de notes manuelle pendant l'enregistrement ; l'intégration Notion Mail ↔ AI Meeting Notes.
### C. Notion AI dans les Bases de Données (AI Autofill)
Dans les bases de données, l'IA agit comme une **fonction de calcul dynamique** appliquée à chaque entrée. La documentation officielle (`help/autofill`, page « Notion AI for databases ») distingue **deux modes** :
```
┌─────────────────────────────┬───────────────────────────────────────┐
│ Basic Autofill │ Custom Agent Autofill │
├─────────────────────────────┼───────────────────────────────────────┤
│ • Actions prédéfinies : │ • Instructions LIBRES (prompt │
│ Summary | Translate | │ multi-étapes, logique │
│ Key info | Custom autofill│ conditionnelle) │
│ • Utilise UNIQUEMENT le │ • Peut activer la recherche dans le │
│ contenu de la page/ligne │ WORKSPACE et la recherche WEB │
│ concernée (pas de web, │ (avec fourniture des sources) │
│ pas d'autres pages) │ • Peut mettre à jour plusieurs │
│ • Déclencheurs : manuel, │ propriétés en une exécution │
│ à la création de page, ou │ • Déclencheurs : manuel, création, │
│ à chaque modification │ modification, ou PLANIFIÉ │
│ • INCLUS dans Business/ │ (schedule) │
│ Enterprise, SANS crédits │ • Consomme des CRÉDITS Notion │
└─────────────────────────────┴───────────────────────────────────────┘
```
* **Configuration (flux officiel) :** survoler la propriété → cliquer sur son nom → **`AI Autofill`** (ou `Set up AI Autofill`) → choisir **`Basic`** (puis l'action : Summary, Translate, Key info, Custom autofill) ou **`Custom Agent`** (puis rédiger les instructions) → choisir **quand l'exécuter** (manuellement, à la création de page, à chaque modification, ou planifié pour Custom Agent) → enregistrer.
* **Cas d'usage Basic cités officiellement :** résumés de contenu de page, extraction d'infos clés (noms, dates, action items), traduction, tag/catégorisation via options select/multi-select.
* **Astuce officielle :** pour ne remplir que les cellules vides, ajouter *« Only fill this property if it is empty »* aux instructions.
* **Exemple Custom Agent :** *« Résume la page en 2 phrases. Si un délai est mentionné, extrais-le dans la propriété Due Date ; sinon laisse vide. »*
* **Build with AI :** l'IA peut **créer** des bases de données (inclus Business/Enterprise avec quota d'usage), mais **pas modifier les bases existantes**, ni créer automatisations, formulaires, graphiques ou templates de pages.
> ⚠️ **Corrections :** les noms « AI Summary / AI Key Info / AI Custom Autofill » comme types de propriétés distincts, le bouton « Update all » et le type « AI Sentiment » des versions précédentes de ce guide ne correspondent pas à la documentation actuelle (Basic vs Custom Agent). Le coût en crédits précis par exécution Custom Agent n'est pas publié.
### D. Analyse Multimodale et Création de Contenu
* **Import audio :** AI Meeting Notes accepte l'import de fichiers audio (AAC, M4A, MP3, WAV) pour transcription et synthèse.
* **Analyse de fichiers et PDF dans le chat, création de diaporamas (`Create a slide deck`), conversion de formats (texte libre → tableaux, to-do lists, toggles) :** ⚠️ ces capacités sont décrites dans les pages produit/marketing mais **n'ont pas pu être vérifiées dans le Help Center officiel** lors de la révision du 5 septembre 2026 — à considérer comme des fonctionnalités évolutives.
---
## 6. Sécurité, Gouvernance et Confidentialité des Données
Notion applique un cadre de sécurité strict, documenté officiellement (sources : `help/notion-ai-security-practices`, `notion.com/security`, `help/ai-meeting-notes`) :
1. **Non-entraînement des modèles :** *« By default, Notion and its AI Subprocessors do not use Customer Data to train any models. »* Des accords contractuels interdisent aux sous-traitants IA (OpenAI, Anthropic, etc.) d'utiliser les données clients pour entraîner leurs modèles. Les données de clients différents ne sont **jamais mélangées** durant le traitement IA.
2. **Rétention des données LLM :** **zéro rétention par défaut pour les workspaces Enterprise** ; **≤ 30 jours** pour les plans non-Enterprise. Les LLM à rétention de données restent **désactivés par défaut** et exigent une activation admin via les réglages du workspace. La fonctionnalité *External Agents* relève de contrôles de rétention distincts.
3. **Embeddings (recherche sémantique) :** stockés dans une base vectorielle (**Turbopuffer**, auditée SOC 2 Type 2), **supprimés dans les 60 jours** suivant la suppression de la page ou du workspace. Une page/workspace supprimé est restaurable sous 30 jours, puis suppression définitive (données IA et embeddings inclus).
4. **Chiffrement :** TLS **1.2 ou supérieur** en transit vers les tiers ; chiffrement au repos des données stockées (AES-256 selon le Trust Center Notion ⚠️ non re-vérifié dans cette session).
5. **Respect strict des habilitations (RBAC) :** *« Notion AI honors existing permissions. The LLMs and AI Models used to generate AI responses for a user cannot see or use any information to which that user does not already have access. »* Cela s'applique aussi à MCP : *« MCP does not bypass Notion permissions. »*
6. **Contrôles d'administration :**
* `Settings → Notion AI` (tous plans) : `Enable web search for workspace`, `Require confirmation for web requests` ; sur Business/Enterprise, contrôle du dépassement des crédits Notion AI et gestion des modèles premium.
* **DLP (Enterprise) :** alertes sur contenu sensible (prompts IA et sorties IA) via partenaires d'intégration.
* **Meeting Notes :** opt-out workspace (`Workspace availability`), consentement audio imposable, partage automatique avec les participants internes, base de données par défaut, **suppression automatique planifiée des transcriptions** (résumés et notes conservés, pages en *legal hold* exclues) ; `Store audio locally` désactivé par défaut (les 10 derniers enregistrements conservés sur l'appareil de l'enregistreur, seul habilité à les télécharger).
* **MCP (Enterprise) :** liste blanche des AI apps, `Disconnect All Users`, gestion via Okta, audit log.
7. **Conformité internationale :** **SOC 2 Type 2** (Notion AI inclus dans le périmètre), **ISO 27001, 27701, 27017, 27018**, **HIPAA** (avec BAA et fonctionnalités Enterprise, rendu possible par les API zéro-rétention des fournisseurs LLM), **BSI C5**, alignement **RGPD/CCPA** via MSA + DPA. Rapports disponibles sur demande via le Trust Center.
8. **Sous-traitants (Subprocessors) :** Notion utilise des LLM hébergés par Notion ainsi que par **Anthropic** et **OpenAI** ; embeddings générés via une **API OpenAI zéro-rétention**. AI Meeting Notes nomme explicitement : OpenAI, Anthropic, Fireworks, Baseten Labs, X.AI LLC, AssemblyAI Inc. Tout tiers traitant des données clients est publié sur la Subprocessor Page, avec notification préalable des nouveaux sous-traitants sur abonnement et revue annuelle (attestations, pentests, questionnaires).
### Synthèse des durées de rétention audio (AI Meeting Notes)
| Scénario | Rétention |
| --- | --- |
| Audio capté (desktop/navigateur) | Envoi direct aux sous-traitants (qui ne le stockent pas) ; copie locale temporaire supprimée après traitement ou sous **24 h** |
| Échec de traitement (desktop/navigateur) | Upload serveurs Notion conservé **jusqu'à 3 jours** |
| Mobile | Audio uploadé chez Notion puis transmis aux sous-traitants ; suppression immédiate après succès, copie locale sous **1 jour** ; échec : **3 jours** (serveur) / **1 semaine** (local) |
| Fichiers uploadés par l'utilisateur | Conservés jusqu'à suppression manuelle (`Delete audio` / `Delete video`) ou suppression de la page |
| Enterprise | Suppression automatique planifiée des transcriptions (résumés conservés, *legal hold* exclu) |
---
## 7. Sources Officielles
Documentation consultée le 5 septembre 2026 :
* Notion Agent : https://www.notion.com/help/notion-agent
* Guides Notion AI for docs : https://www.notion.com/help/guides/notion-ai-for-docs
* Research Mode : https://www.notion.com/help/research-mode
* Enterprise Search : https://www.notion.com/help/enterprise-search — https://www.notion.com/product/enterprise-search
* AI Meeting Notes : https://www.notion.com/help/ai-meeting-notes — https://www.notion.com/product/ai-meeting-notes
* AI Autofill (Notion AI for databases) : https://www.notion.com/help/autofill
* Propriétés de bases de données : https://www.notion.com/help/database-properties
* Custom Agents : https://www.notion.com/help/custom-agents — https://www.notion.com/product/agents
* Connecteurs IA : https://www.notion.com/help/notion-ai-connectors
* FAQ Notion AI : https://www.notion.com/help/notion-ai-faqs
* Modèles premium & crédits : https://www.notion.com/help/manage-ai-models-and-member-credit-spend
* MCP (guide développeur) : https://developers.notion.com/guides/mcp/get-started-with-mcp — https://developers.notion.com/guides/mcp/mcp-supported-tools
* MCP (Help Center) : https://www.notion.com/help/notion-mcp
* Sécurité de Notion AI : https://www.notion.com/help/notion-ai-security-practices
* Sécurité Notion (conformité) : https://www.notion.com/security
@@ -0,0 +1,230 @@
# 📘 Guide d'Implémentation : Fonctionnalités de Partage et Collaboration (Style Notion pour Flowdesk)
> Sources officielles consultées : [Sharing & permissions](https://www.notion.com/help/sharing-and-permissions), [Comments, mentions & reactions](https://www.notion.com/help/comments-mentions-and-reminders), [Suggested edits](https://www.notion.com/help/suggested-edits), [Create & manage groups](https://www.notion.com/help/create-and-manage-groups), [Who's who in a workspace](https://www.notion.com/help/whos-who-in-a-workspace), [Collaborate in a workspace](https://www.notion.com/help/collaborate-within-a-workspace).
## 1. Vue d'ensemble des fonctionnalités cibles
Pour reproduire l'expérience de collaboration de Notion, le clone Flowdesk doit intégrer cinq piliers fonctionnels :
1. **Partage granulaire** : Invitation de membres, d'invités externes (guests, par email), partage avec des groupes, des teamspaces, ou partage public par lien / publication web.
2. **Niveaux d'accès (RBAC)** : `Full access`, `Can edit`, `Can edit content`, `Can create`, `Can comment`, `Can view` — avec sémantique précise (voir §2 et §4).
3. **Permissions au niveau des pages de base de données (Page-Level Access)** : Règles d'accès dynamiques basées sur les propriétés « Personne » ou « Créé par ».
4. **Droits sur les rôles du workspace** : membres, membres restreints, invités (guests), membres temporaires, propriétaires, admins de membres, groupes (dont groupes synchronisés via SCIM).
5. **Collaboration synchrone & asynchrone** : présence en temps réel (avatars), édition simultanée, commentaires (page, bloc, propriété), mentions (@), réactions, suggestions d'édition (suggested edits) et verrouillage de page.
---
## 2. Guide d'utilisation (Flux Utilisateur)
*L'agent IA doit reproduire ces flux interactifs :*
### 2.1 Initier le partage
1. L'utilisateur clique sur **« Partager »** en haut à droite de la page.
2. La modale propose trois actions : **inviter des personnes**, **copier le lien de la page**, **publier sur le web** (onglet `Publish`, distinct du simple partage par lien).
3. Pour inviter, l'utilisateur tape un nom (membre ou groupe) ou un email d'invité externe. Le système propose une autocomplétion en temps réel.
4. Un menu déroulant à côté de chaque nom permet de choisir le niveau d'accès, puis l'utilisateur clique sur **Inviter**.
### 2.2 Configurer l'accès général (General access)
Un sélecteur définit la visibilité par défaut de la page :
- **`Only people invited`** : seuls l'utilisateur et les personnes invitées y ont accès.
- **`Everyone at {workspace}`** : tous les membres du workspace y accèdent via la recherche ou le lien — avec option **« Hide in search »** pour masquer la page des résultats de recherche.
- **`Anyone on the web with link`** : toute personne disposant du lien peut y accéder, même sans compte Notion (connexion requise uniquement pour commenter/modifier) — avec option **« Link expires »** pour faire expirer le lien.
Pour chaque groupe d'accès général, on peut attribuer un niveau d'accès indépendant.
> **Note (sécurité Enterprise)** : les propriétaires peuvent désactiver les liens publics via `Settings → Security → Disable publishing sites, forms and public links`.
### 2.3 Demander l'accès (Request access)
- **Aucune page accessible** : en ouvrant une page, l'utilisateur voit un bouton `No access` → envoie une demande, notifie le créateur/éditeur qui peut accepter ou refuser.
- **Accès en lecture/commentaire** : `Share → dropdown de son propre niveau → Request edit access`. La demande est envoyée au créateur de la page.
### 2.4 Règles de base de données (Page-level access, optionnel)
1. Ouvrir la **base source** (pas une vue liée).
2. `Share` → section **`Page-level access`** → **`Add a new rule`**.
3. Choisir une propriété `Person` ou `Created by`, puis un niveau d'accès → **`Create rule`**.
Exemple : « Les personnes dans la propriété `Assigné à` peuvent **modifier** leur propre ligne ». Les règles s'appliquent à **toutes les vues** de la base, y compris les **vues liées**. Chaque source de données (data source) d'une base multi-sources peut avoir ses propres règles.
### 2.5 Arrêter de partager
- Glisser la page vers la section **`Private`** de la sidebar (supprime l'accès de tous les autres).
- Ou `Share` → dropdown de chaque personne/groupe/teamspace → **`Remove`**.
---
## 3. Représentation Visuelle (UI/UX) pour l'Agent IA
*Instructions de conception d'interface pour la génération de code frontend :*
### Bouton de partage
En haut à droite, dans la barre de titre de la page. Bouton secondaire avec icône de partage (ou libellé « Partager »).
### Modale de partage (Share Modal)
- Champ de saisie en haut avec placeholder « Inviter des personnes... ».
- Liste verticale des utilisateurs/groupes ayant accès. Chaque ligne : **Avatar + Nom/Email + Dropdown de permission (ex. « Peut modifier » ▼) + Icône X (suppression)**.
- Section **« General access »** avec le sélecteur principal et les options conditionnelles (case « Hide in search », expiration du lien).
- Section **« Page-level access »** (base de données uniquement) listant les règles existantes + bouton « Add a new rule ».
- **Validation en lecture seule** : la modale doit montrer à l'utilisateur courant son niveau d'accès courant, avec la possibilité de « Request edit access ».
### Indicateurs de présence (Presence Bar)
- Barre horizontale en haut de la page, alignée à droite, affichant les avatars des utilisateurs ayant accès.
- **Avatar plein** : personne actuellement sur la page.
- **Avatar estompé (opacité réduite)** : personne récemment partie.
- **Avatars mobiles** : en collaboration simultanée, les avatars se déplacent **à côté des blocs** que chacun lit/édite.
- **Au survol** : infobulle avec Nom, Email et « Dernière activité il y a X ».
- **Au clic** : la vue défile jusqu'à la position de lecture/édition de la personne ciblée.
- **Historique** : menu `•••` en haut à droite → bas du menu : « Dernière modification par X, il y a Y ».
### Système de commentaires
- **Discussion de page (top-level)** : au survol du haut de la page, bouton « Add comment ».
- **Commentaires inline** (plusieurs déclencheurs) : sélection de texte → « Comment » ; icône `⋮⋮` à gauche du bloc → « Comment » ; bouton `💬` au survol du bloc ; raccourci `Ctrl/Cmd + Shift + M` ; clic sur un `💬` existant pour répondre.
- **Panneau de commentaires (Comments pane)** : icône `💬` en haut de page (pastille rouge si non-lus). Filtrage par personne / statut (ouverts / résolus). Tri par dernier message.
- **Indicateur de résolution** : ✔️ pour résoudre, `•••` → Éditer / Supprimer, `↪️` pour rouvrir un commentaire résolu.
- **Réactions** : surligner un texte → `🙂` → emoji ; survol d'un commentaire → `🙂` → emoji.
- **Commentaires de base de données** : `💬` associé aux lignes (table/board/gallery) ; `⋮⋮`/`•••` → « Comment » ; commentaires sur les **propriétés** (survol d'une propriété → `💬`).
- **Mentions (@)** : la saisie de « @ » ouvre un menu contextuel (Popper) à trois types : **Personnes/groupes**, **Pages** (lien inline + backlink auto), **Date** (aujourd'hui/demain/hier ou date).
- **Mode Suggestion (Suggested edits)** : activé via `•••` → « Suggest edits ». Bandeau `Suggesting` en haut. Les suggestions (ajout/suppression) apparaissent dans la marge, acceptables (✔️) ou refusables (❌), réactives (emoji) et commentables.
### Verrouillage de page / base
- **Lock page** (`•••` → `Lock page`) : page en lecture seule pour tous, badge `Locked` dans le breadcrumb.
- **Lock database** : verrouille la structure (vues/propriétés) tout en permettant l'édition des données.
- **Déverrouillage** : `Locked` → `Unlock for me` (déverrouille pour soi uniquement) ou `Unlock for everyone`.
---
## 4. Guide d'Implémentation Technique (Architecture & Données)
*Spécifications pour que l'agent IA génère le backend et la logique métier :*
### A. Modèle de données (Schéma relationnel simplifié)
```sql
-- Principaux (Utilisateurs et Groupes)
CREATE TABLE principals (
id UUID PRIMARY KEY,
type VARCHAR(20), -- 'user' | 'group' | 'teamspace' | 'guest'
name VARCHAR(255),
email VARCHAR(255) UNIQUE, -- NULL pour les groupes
workspace_role VARCHAR(20), -- 'member' | 'restricted_member' | 'guest' |
-- 'temporary_member' | 'workspace_owner' |
-- 'membership_admin' | 'organization_owner'
is_scim_managed BOOLEAN DEFAULT false -- groupes synchronisés via identité externe
);
-- Appartenance utilisateur -> groupe (N:N)
CREATE TABLE group_members (
group_id UUID REFERENCES principals(id),
member_id UUID REFERENCES principals(id),
PRIMARY KEY (group_id, member_id)
);
-- Ressources (Pages, Bases de données)
CREATE TABLE resources (
id UUID PRIMARY KEY,
type VARCHAR(50), -- 'page' | 'database'
parent_id UUID, -- Héritage des permissions
workspace_id UUID,
is_locked BOOLEAN DEFAULT false -- verrou de page
);
-- Permissions (RBAC avec héritage)
CREATE TABLE permissions (
id UUID PRIMARY KEY,
resource_id UUID REFERENCES resources(id),
principal_id UUID REFERENCES principals(id),
access_level VARCHAR(20), -- 'full' | 'edit' | 'edit_content' | 'create' | 'comment' | 'view'
is_inherited BOOLEAN DEFAULT false -- true si hérité du parent / teamspace / workspace
);
-- Règles d'accès au niveau de la page de base de données (Page-Level Access)
CREATE TABLE page_level_rules (
id UUID PRIMARY KEY,
database_id UUID REFERENCES resources(id),
target_property_name VARCHAR(50), -- propriété 'Person' OU 'Created by'
access_level VARCHAR(20), -- 'edit' | 'comment' | 'view' (etc.)
data_source_id UUID NULL -- en cas de base multi-sources
);
-- Commentaires
CREATE TABLE comments (
id UUID PRIMARY KEY,
resource_id UUID REFERENCES resources(id),
block_id UUID NULL, -- NULL = discussion de page ; sinon bloc/propriété ciblé
author_id UUID REFERENCES principals(id),
content TEXT,
thread_id UUID NULL, -- pour les réponses groupées
is_resolved BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT NOW()
);
-- Réactions
CREATE TABLE reactions (
id UUID PRIMARY KEY,
target_type VARCHAR(20), -- 'comment' | 'text' | 'suggestion'
target_id UUID,
author_id UUID REFERENCES principals(id),
emoji VARCHAR(8)
);
-- Suggestions d'édition (Suggested edits)
CREATE TABLE suggestions (
id UUID PRIMARY KEY,
resource_id UUID REFERENCES resources(id),
block_id UUID,
author_id UUID REFERENCES principals(id),
operation VARCHAR(10), -- 'insert' | 'delete'
payload TEXT, -- contenu proposé / supprimé
status VARCHAR(20), -- 'open' | 'accepted' | 'rejected'
created_at TIMESTAMP DEFAULT NOW()
);
```
### B. Sémantique exacte des niveaux d'accès
| Niveau | Effets |
|---|---|
| `Full access` | Modifier tout le contenu **et** partager la page avec qui l'on veut. |
| `Can edit` | Modifier le contenu, **sans** pouvoir partager. |
| `Can edit content` | **Pages de base de données uniquement** : créer/modifier des pages de la base et leurs propriétés, sans toucher à la structure (propriétés, vues, tris, filtres). |
| `Can create` | **Pages de base de données uniquement** (Business/Enterprise) : créer de nouvelles pages, sans voir/modifier les pages existantes (soumissions de tickets, formulaires...). |
| `Can comment` | Commenter et suggérer des modifications, sans éditer ni partager. |
| `Can view` | Lecture seule. |
### C. Logique de résolution des permissions
1. Vérifier une permission **explicite** pour l'utilisateur **ou** l'un de ses groupes sur la ressource cible.
2. Si aucune permission explicite, remonter l'arbre (`parent_id`) pour vérifier les permissions **héritées** de la page parente, du teamspace ou du workspace.
3. Pour les bases de données, évaluer dynamiquement les `page_level_rules` : si l'ID de l'utilisateur correspond à la valeur de la propriété `target_property_name` de la ligne, appliquer le niveau d'accès de la règle.
4. **Principe du niveau le plus large** : Notion respecte **toujours le niveau d'accès le plus étendu** accordé à un utilisateur (permission personnelle + groupe + workspace + règle de base).
> ⚠️ **Piège à implémenter** : une règle `Can view` sur une personne est écrasée si l'utilisateur reçoit « Everyone at workspace → Full access ». L'agent doit sommer toutes les sources d'accès et retenir le maximum (ordre : `full > edit > edit_content > create > comment > view`).
5. **Sans accès à la base** mais avec une règle de page : l'utilisateur n'accède qu'aux lignes concernées via une **notification** ou une **vue liée** ; il ne peut pas créer de nouvelles pages (il faut un formulaire).
### D. Collaboration en temps réel (Stack technique recommandée)
- **Synchronisation d'état** : CRDT (**Yjs** ou **Automerge**) pour l'édition simultanée sans verrou (Notion ne verrouille pas un bloc en cours d'édition — le dernier changement l'emporte).
- **Transport** : **WebSockets** (Socket.io, Hocuspocus, ou Liveblocks) pour diffuser les mises à jour de contenu, les événements de présence et les commentaires.
- **Gestion de la présence** : registre en mémoire des `user_id` connectés à un `resource_id`, avec diffusion des avatars actifs et de leur **position de bloc** (curseur/édition) aux clients connectés, à la connexion/déconnexion et périodiquement.
### E. Endpoints API clés à générer
- `POST /api/resources/:id/share` : ajoute/met à jour une entrée dans `permissions` (invitation membre, invité, groupe).
- `GET /api/resources/:id/access-check` : retourne le niveau d'accès **effectif** (calcul d'héritage + règle la plus large).
- `POST /api/resources/:id/page-level-rules` : crée une règle d'accès par propriété Person/Created by.
- `POST /api/comments` : crée un commentaire (gestion du `block_id`/`thread_id` pour l'inline).
- `POST /api/suggestions` : crée une suggestion d'édition ; `POST /api/suggestions/:id/accept` / `reject`.
- `POST /api/access-requests` : crée une demande d'accès / d'édition.
- `POST /api/resources/:id/lock` : verrouille/déverrouille une page ou la structure d'une base.
- `WS /ws/collaboration?resourceId=:id` : canal WebSocket pour CRDT, présence et commentaires temps réel.
---
## 5. Recommandations spécifiques pour le clone Flowdesk
1. **Adaptation métier** : pour un usage support/tickets, prioriser les **Page-Level Access rules** (`Can create` pour les soumissions, `Can edit` sur la propriété « Assigné à »). C'est le mécanisme qui permet à un client de ne voir/modifier que « son » ticket sans accéder à toute la base.
2. **Sécurité** : chaque requête API (lecture/écriture) doit passer par le middleware de résolution des permissions **avant** tout accès BDD. Ne jamais faire confiance au client. Appliquer le principe du niveau le plus large côté serveur, de façon centralisée.
3. **Émuler le verrouillage** : implémenter `is_locked` au niveau ressource (et structure vs données pour les bases) pour éviter les modifications accidentelles sans révoquer les droits.
4. **Notifier intelligemment** : reproduire les règles Notion — pas de notification si la personne a la page ouverte, email uniquement si Notion est fermé, pas de notification si la personne n'a pas accès à la page mentionnée.
5. **Bibliothèques Frontend suggérées** : composants « headless » **Radix UI** ou **Headless UI** pour la modale de partage, les dropdowns, les infobulles de présence et le menu de mentions.
---
## 6. Glossaire rapide des rôles (Who's who)
- **Member** : personne de l'organisation, facturée sur les plans payants.
- **Restricted member** : accès limité aux teamspaces/pages assignés ; ne peut pas créer de teamspace.
- **Guest** : personne externe, invité page par page ; ne reçoit jamais d'accès workspace-wide et ne peut pas être ajouté à un groupe.
- **Temporary member** : consultant Marketplace avec accès à durée limitée (expiration automatique).
- **Workspace owner** : admin gérant les paramètres, les membres et la suppression du workspace.
- **Membership admin** : (Enterprise) ajoute/retire des membres sans changer les paramètres.
- **Organization owner** : (Enterprise) gère plusieurs workspaces d'une organisation.
- **Group owner** : gère la composition d'un groupe sans être admin du workspace.
+63
View File
@@ -0,0 +1,63 @@
// FlowDeck — ESLint flat config (v5.2.0).
// ESLint v9+ requires the flat config format; the legacy `.eslintrc.json`
// is no longer read. Run with: `npx eslint static/js`.
//
// Self-contained on purpose: no dependency on the `globals` npm package so the
// config works with a globally-installed ESLint (no node_modules required).
const browserGlobals = {
// Browser / DOM
window: "readonly", document: "readonly", navigator: "readonly",
location: "readonly", history: "readonly", screen: "readonly",
localStorage: "readonly", sessionStorage: "readonly", console: "readonly",
alert: "readonly", confirm: "readonly", prompt: "readonly", fetch: "readonly",
setTimeout: "readonly", clearTimeout: "readonly", setInterval: "readonly",
clearInterval: "readonly", requestAnimationFrame: "readonly",
cancelAnimationFrame: "readonly", requestIdleCallback: "readonly",
cancelIdleCallback: "readonly", queueMicrotask: "readonly",
XMLHttpRequest: "readonly", FormData: "readonly", Blob: "readonly",
File: "readonly", FileReader: "readonly", URL: "readonly",
URLSearchParams: "readonly", Image: "readonly", Event: "readonly",
CustomEvent: "readonly", EventSource: "readonly", WebSocket: "readonly",
DOMParser: "readonly", Node: "readonly", NodeList: "readonly",
Element: "readonly", HTMLElement: "readonly", getComputedStyle: "readonly",
matchMedia: "readonly", IntersectionObserver: "readonly",
MutationObserver: "readonly", ResizeObserver: "readonly",
performance: "readonly", crypto: "readonly", btoa: "readonly",
atob: "readonly", structuredClone: "readonly",
// ECMAScript built-ins
JSON: "readonly", Math: "readonly", Date: "readonly", Promise: "readonly",
Object: "readonly", Array: "readonly", String: "readonly", Number: "readonly",
Boolean: "readonly", Symbol: "readonly", Map: "readonly", Set: "readonly",
WeakMap: "readonly", WeakSet: "readonly", Error: "readonly",
TypeError: "readonly", RangeError: "readonly", RegExp: "readonly",
Proxy: "readonly", Reflect: "readonly", Intl: "readonly",
parseInt: "readonly", parseFloat: "readonly", isNaN: "readonly",
isFinite: "readonly", encodeURIComponent: "readonly",
decodeURIComponent: "readonly", encodeURI: "readonly", decodeURI: "readonly",
globalThis: "readonly", Infinity: "readonly", NaN: "readonly",
// FlowDeck front-end libraries
Alpine: "readonly", htmx: "readonly", Sortable: "readonly",
// Globals exposed on window by app.js
openModal: "readonly", closeModal: "readonly",
};
export default [
{
ignores: ["static/js/*.min.js", "static/js/vendor/**"],
},
{
files: ["static/js/**/*.js"],
languageOptions: {
ecmaVersion: 2022,
sourceType: "script",
globals: browserGlobals,
},
rules: {
"no-unused-vars": ["warn", { args: "none" }],
"no-undef": "warn",
"no-extra-semi": "warn",
"no-empty": "warn",
},
},
];
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

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