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
bruno db4329eee2 feat(v4.4.0): Tasks, Sub-items & Dependencies
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Add is_task flag on collections (toggle-task API)
- New page_dependencies table with auto_shift config (blocks/blocked_by/related)
- API: GET/POST/DELETE page dependencies
- API: POST auto-shift dates based on blocking dependencies (skip_weekends option)
- 5 new tests (171 total)
2026-07-21 22:26:24 -04:00
bruno 122ccfb3c3 docs: mark v4.3.0 as completed in ROADMAP
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-21 22:24:46 -04:00
bruno 59a62ff2ad feat(v4.3.0): Database Views — 10 types complets
FlowDeck CI / test (push) Failing after 7s
FlowDeck CI / docker (push) Has been skipped
- New view types: chart (Chart.js CDN), form (HTML form POST), map (Leaflet OSM), feed (chronological), gantt (timeline + groups)
- View tabs updated to show all 11 view types (table, board, calendar, gallery, list, timeline, gantt, chart, form, map, feed)
- Chart supports bar/line/pie/doughnut/scatter via config.chart_type
- Form auto-generates fields from collection properties (text/email/select/checkbox/date/number)
- Map renders location markers from property values (lat,lng pairs)
- Gantt supports group_by for categorized timeline bars
- 7 new tests (166 total)
2026-07-21 22:24:14 -04:00
bruno 26d41cb864 docs: mark v4.2.0 as completed in ROADMAP
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-21 22:10:22 -04:00
bruno 10cbd7f52e feat(v4.2.0): Database Templates & Dashboards
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Enhance page_templates: add description, is_recurring, recurrence_rule columns
- New table collection_dashboards for widget-based dashboard layouts
- API: PUT/DELETE /workspace/collections/{id}/templates/page/{tid} — template CRUD
- API: CRUD /workspace/collections/{id}/dashboards (create/list/update/delete)
- Dashboard layout_json supports columns + widgets with view_id/x/y/w/h
- 6 new tests (159 total)
2026-07-21 22:09:48 -04:00
bruno ec96619341 docs: mark v4.1.0 as completed in ROADMAP
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
2026-07-21 21:56:31 -04:00
bruno d7c1f4c03e feat(v4.1.0): Data Sources & Linked Databases
- Add collection_data_sources table for multi-source collections
- API: GET/POST/DELETE /db/{id}/sources/api — data source management
- API: POST /db/{id}/linked/api — create linked database (copies views/properties)
- API: POST /db/{id}/toggle-inline/api — toggle full-page vs inline
- API: POST /db/inline/api — create inline database
- Linked DB inherits workspace_id (permissions) from source
- Migration: add workspace_id + created_by to collections table
- 10 new tests (153 total)
2026-07-21 21:55:45 -04:00
bruno 3fcfa8fdbb style: bouton New page → icône document+plus (même que sidebar)
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-21 21:30:55 -04:00
bruno ce2bae1f1b feat: fichier filter dropdown + fonctionnel sur toutes les vues
FlowDeck CI / test (push) Failing after 12s
FlowDeck CI / docker (push) Has been skipped
- Dropdown Filter dans la toolbar (icône filtre, à côté de Sort)
- Menu avec Folders, Pages, PDF, Images, Code, Text + Clear filter
- doFilter() applique maintenant filterType au tree view via _filterTree()
- Filter chips visibles dans toutes les vues comme indicateur
- L'enhanced table prend le relais quand un filtre est actif dans les vues non-tree

143 tests passent
2026-07-21 20:47:32 -04:00
bruno 85b4d624b8 refactor: local-workspace UI → Library-style
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
CSS:
- Ajouté: ws-container, ws-tabs-row, ws-tab, ws-icon-btn, ws-btn-primary
- Ajouté: ws-dropdown, ws-search-bar, ws-select-bar, ws-row
- Ajouté: row-name, row-icon, row-title, hover-only, ws-checkbox
- Ajouté: tree-chevron, btn-open, more-menu (Library-style)
- Conservé: ws-tree, ws-tree-item, ws-act, breadcrumb-row pour compatibilité

Navbar:
- Breadcrumb + toolbar fusionnés en une seule row propre
- Dropdowns style Library (View, Sort)
- Search toggle avec barre coulissante
- Bulk bar → ws-select-bar avec icônes SVG

Context menu:
- .ctx-menu → .more-menu (style Library)
- Toutes les refs JS mises à jour

143 tests passent
2026-07-21 15:53:36 -04:00
bruno ef8d4e6bca fix: page not found → redirect workspaces (dashboard.py manquait)
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-21 14:07:48 -04:00
bruno 27352c84e5 feat: 404 pages → redirect to /workspaces
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- main.py: @app.exception_handler(404) → RedirectResponse(/workspaces)
- dashboard.py: page_detail 404 → RedirectResponse(/workspaces)
- tests: test_styled_404_page → 302 redirect
- API paths (containing /api) still get JSON 404, not redirect
- 143 tests passent
2026-07-21 13:51:31 -04:00
bruno bf329a4ba0 fix: PDF viewer — URL encode filename + error fallback
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- dashboard.py: quote(filename) dans file_url (espaces/caractères spéciaux)
- page_editor.html: iframe PDF → onerror fallback avec lien Open in new tab
- 143 tests passent
2026-07-21 13:41:34 -04:00
bruno b505cfc156 fix: sidebar workspace section → + external-link to /local-workspace
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
Maintenant au bon endroit: base.html sidebar-section-actions (L229-233)
Pas dans local_workspace.html tree (où il n'était pas visible).
143 tests passent
2026-07-21 13:24:44 -04:00
bruno 3079d53cab fix: sidebar root external-link → href static (pas Alpine :href)
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
Alpine ne résolvait pas wsId dans le scope x-for.
Maintenant: href='/local-workspace' (route qui utilise le cookie ws actif).
+ hard refresh nécessaire (Ctrl+Shift+R) pour vider cache ancien JS.
143 tests passent
2026-07-21 11:59:05 -04:00
bruno d3bada54f0 feat: sidebar tree — add external-link icon to /local-workspace
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- local_workspace.html: hover actions (folder) → + external-link → /local-workspace
- Ajouté dans JS renderChildren ET template x-for
- 143 tests passent
2026-07-21 11:54:12 -04:00
bruno eac6e5aed3 fix: 2 context menu width fixes
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
- base.html showContextMenu: edge detection (menu never overflows right/bottom)
- app.css .more-menu: min-width:200px → unset + width:max-content
- 143 tests passent
2026-07-21 11:25:36 -04:00
bruno 55853db625 fix: library empty state → outline SVG + 19 new JS getSvgIcon icons
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- library.html L342: x-text→x-html + getSvgIcon(emptyIcon,48)
- base.html getSvgIcon: +19 icons: link, lock, globe, edit, clock, users,
  book, settings, tag, bar-chart, grid, align-left, calendar, bot, refresh,
  inbox, help-circle, check-square, plus
- 143 tests passent
2026-07-21 11:15:52 -04:00
bruno fd1a4319b4 fix: library row icons → outline SVG + context menu width
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- library.html L591: item.icon (emoji) → self._renderIcon('file')
- library.html: +_renderIcon() helper with validNames filter
- app.css: context-menu min-width:200px → width:max-content + max-width:240px
- 143 tests passent
2026-07-21 11:02:25 -04:00
bruno b818bc908f fix: _header page_icon rendered word → SVG icon
FlowDeck CI / test (push) Failing after 6s
FlowDeck CI / docker (push) Has been skipped
_header.html L54: {{ page_icon }} affichait le mot 'home'/'file'/'paperclip'
au lieu de l'icône SVG. Maintenant détecte les noms connus → fd_icon().
Fallback pour les valeurs non-reconnues: affiche tel quel.
143 tests passent
2026-07-21 10:22:33 -04:00
bruno 8a94c3b676 fix: Alpine x-text error — fd_icon SVG double-quotes broke JS expression
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
page_editor.html L5: le bouton star utilisait x-text avec fd_icon('star',14)
contenant des guillemets doubles SVG → Expression Error.
→ x-html + getSvgIcon('star',14) (JS, pas de guillemets dans l'expression)
→ share/lock/link: utilisent déjà la concaténation ~ (safe)
143 tests passent
2026-07-21 10:11:21 -04:00
bruno 53d31572e6 fix: sidebar workspace tree + pages DB — emojis → outlined icons
Root cause: 3 sources d'icônes colorées dans le sidebar:
1. _workspace_tree_macro.html L28: {{ page.icon }} raw → filtré via valid_icons
2. db.py: DEFAULT '📄' → 'file'
3. board.py _file_icon(): 15 émojis → 'file'/'edit'/'image'
4. board.py/dashboard.py: JSON icon: 📄/📁 → file/folder
5. base.html L931: JS favorites icon → validNames filter
6. base.html render_tree_item: valid_icons list étendue + fallback

Migration: nouvelles pages utilisent 'file'/'folder'. Pages existantes
avec émojis → les templates utilisent le fallback automatiquement.
143 tests passent
2026-07-21 09:28:28 -04:00
bruno 3fcc12ad8c feat: sweep complet — tous les émojis → outline SVG icons
Templates modifiés (22 fichiers):
- _icons.html: +14 new icons (paperclip, external-link, sparkles, lightbulb, tag,
  file-text, save, upload, trending-up, zap, alert-triangle, user, eye-off)
- base.html: +8 JS icons, 🦎🐙🔑🔗👁📋🔲📑❓⚠️→SVG
- page_editor.html: 43→0 émojis (📎📄🔒🔗⭐📋📁✨📝🗄📑📊📅🗓🖼📥🌐✏💬💡 etc)
- settings.html: 30→0 (⚙️📋🏷🧩👥💾🙈👁🔒🌙☀️🌳📊📝⚠🦎✅🐙🔗✏🗑)
- local_workspace.html: 25→0 (📄📁✏🗑⚠📋➕)
- gitea_workspace.html: 25→0 (🔗📄📤🔄⚙📁📄🔒✏🗑💾)
- board.html: 13→0 (📁📊📋☰📈👥✕✓🗑⚡🔍)
- workspace.html: 11→0 (🏠🔑⚙📁🔗📂🐙)
- landing.html: 8→0 (📚🚀📝📊🦎🌐🔒⚡)
- trash.html, table_view, dashboard, accounts, card_detail, public_page,
  workspaces, team_load, notes, _header, detailed_board, card, board_fragment
- Ajouté import _icons.html aux fragments standalone
- 143 tests passent
2026-07-21 08:27:10 -04:00
bruno b88a972531 fix: 4 colored JS icons in /local-workspace hover → outline SVG
- 📄 New file → getSvgIcon('file',14)
- 📁 New folder → getSvgIcon('folder',14)
- ✏️ Rename → getSvgIcon('edit',14)
- 🗑 Delete → getSvgIcon('trash',14)
- 143 tests passent
2026-07-21 08:10:30 -04:00
bruno 3d10400f8f fix: workspace cards edit/delete + remaining workspaces emojis
- ✏️→edit SVG (card rename button)
- 🔗→link SVG (Gitea section title, Connect button)
- 🔍→removed (search placeholder)
- 🔒→lock SVG (private project indicator)
- 143 tests passent
2026-07-21 08:03:31 -04:00
bruno 9e45ea8870 feat: version dynamique via fichier VERSION
- Fichier VERSION à la racine (4.0.3)
- _get_app_version() avec cache dans dashboard.py
  - Lecture depuis VERSION (dev) ou /app/VERSION (Docker)
  - Fallback '0.0.0' si fichier absent
- board.py: import _get_app_version depuis dashboard
- Les deux _sidebar_data() remplacent '4.0.3' par _get_app_version()
- Déploiement: changer VERSION → mettre à jour sans toucher le code
- 143 tests passent
2026-07-21 07:58:24 -04:00
bruno d94dd3da99 fix: workspaces topbar 🔑⚙ → outline SVG key+settings
- _icons.html: +key icon (Lucide style)
- getSvgIcon JS: +key (matching SVG)
- workspaces.html: 🔑 Login → fd_icon('key'), ⚙ Settings → fd_icon('settings')
- 143 tests passent
2026-07-21 07:55:46 -04:00
bruno aa434e9fe9 fix: derniers émojis — workspaces 🏠, hover actions, sidebar context menu
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
- workspaces.html: 🏠→home SVG (page_icon, header)
- local_workspace.html: ✏️🗑 ✕→SVG (rename, delete, clear buttons, rename modal)
- base.html: ✏️🗑️📁📄→SVG (context menus Rename/Delete/New File/Folder)
  + pageIcon default '📁'→'folder', modal title '📁 New Folder'→SVG
- 143 tests passent
2026-07-20 23:13:01 -04:00
bruno 45911438f0 fix: Home button pointe vers workspace par défaut au lieu de /workspaces
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Si local_workspaces existe: redirige vers /local-workspace?ws=<premier id>
- Sinon (fallback): redirige vers /workspaces
- 143 tests passent
2026-07-20 22:48:56 -04:00
bruno 5ac8dd10b2 fix: ajout '..' parent folder dans vues List, Details, Cards, Content
FlowDeck CI / test (push) Failing after 16s
FlowDeck CI / docker (push) Has been skipped
- List (compact): rangée '..' avec icône folder avant les données
- Details (enhanced): rangée '..' avec toutes les colonnes
- Cards (title grid): carte '..' dans la grille
- Content (content list): élément '..' avec preview 'Parent folder'
- x-show='currentFolder > 0' pour n'afficher que dans un sous-dossier
- 143 tests passent
2026-07-20 22:47:35 -04:00
bruno eca4067c82 fix: view button 'list List' duplicate + 📁 Folder dans colonne Type
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- View button: supprimé x-text='viewMode' (double affichage 'listList')
- Col. Type: 📁 Folder → Folder (details, compact, enhanced table, preview panel)
- _fileTypeLabel: déjà clean (Page/PDF/Code/Text/... sans emoji)
- 143 tests passent
2026-07-20 22:11:52 -04:00
bruno fff502a700 fix: /local-workspace — final emoji sweep + view menu icons
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- _fileIcon(): 📜→getSvgIcon('file') (JS/TS + code languages)
- Breadcrumb: ☰🏠←→📁✏→ SVG (chevron-right, home, chevron-left/right, folder, edit)
- View menu: +5 outline icons (folder, list, bar-chart, grid, align-left)
- View button: 🌳→viewMode text only
- Filter chips: 📄→getSvgIcon('file')
- Parent folder: 📂→folder SVG
- Context menu: 📄📂✏📋→file, folder, edit, copy SVG
- Drop overlay: 📥→download SVG
- _icons.html: +chevron-left, chevron-right, list, bar-chart, grid, align-left, corner-down-right, copy
- 143 tests passent
2026-07-20 21:56:46 -04:00
bruno fbd4b836f7 fix: /local-workspace SVG rendering + ALL emoji icons converted
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- BUG: x-text→x-html pour _fileIcon() (SVG s'affichait en texte brut)
- _fileIcon(): 11 types de fichiers → SVG (🐍📕🖼️📜🌐🎨🗃️💻⚡📦📄)
- title-card: x-text '📁' → x-html getSvgIcon('folder',24)
- preview empty states (PDF): 📕 → getSvgIcon('file')
- toolbar: 🔍📄📁🗑 → SVG icons
- filter chips: 📁📝📕🖼️📜 → getSvgIcon()
- view menu: 🌳📋📊🏷️📝 → text-only (clean)
- _icons.html: +image, +download
- getSvgIcon JS: +image, +download, +search, +home
- 143 tests passent
2026-07-20 21:39:40 -04:00
bruno b835dd6b5e fix: WORKSPSACES section + remaining emoji icons replaced
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- base.html: séparateur entre version et Log out, _local_workspaces_for_user
- board.py: _local_workspaces_for_user helper, app_version, fix emoji icons
- dashboard.py: _local_workspaces_for_user helper
- library.html: 📚📁📄🕒⭐👥🌐🔒📦 → SVG + icon map
- workspaces.html: 📁🔍🗑📝 → SVG
- local_workspace.html: page_icon, empty states, icon functions → SVG
- settings.html: 👤🔔 → SVG nav icons
- _icons.html: +'bell' icon
- CSS: .nav-icon-inline, .empty-state-icon
- 143 tests passent
2026-07-20 20:58:02 -04:00
bruno 5372f4f22f feat: outline SVG icons + version + workspaces in user menu
FlowDeck CI / test (push) Failing after 12s
FlowDeck CI / docker (push) Has been skipped
- _icons.html: 25 outline SVG icons (Feather/Lucide style, stroke=currentColor)
- base.html: toutes les icônes emoji remplacées par {{ fd_icon() }}
  - Sidebar sections, empty states, user menu, footer links, context menu
  - JS getSvgIcon() helper pour rendu dynamique
- User menu: ajout version (FlowDeck v4.0.3) + liste workspaces locaux
  - Entre Switch workspace et Log out, avec ✓ sur le workspace actif
- dashboard.py: _sidebar_data enrichi (app_version, local_workspaces)
- CSS: .page-icon-svg, .um-version, .um-section-label, .um-item svg
- Dark + light theme compatible (stroke:currentColor)
- 143 tests passent
2026-07-20 20:18:50 -04:00
bruno 39b485622d feat: CSRF token auto-refresh après expiration session
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- app/main.py: GET /api/csrf-token → retourne un token frais JSON + cookie
- app/middleware/csrf.py: /api/csrf-token ajouté aux EXCLUDED_PATHS
- app/templates/base.html:
  - FlowDeck.refreshCsrfToken() → appelle /api/csrf-token
  - FlowDeck.csrfFetch() → wrapper fetch avec auto-refresh sur 403 CSRF
  - Si un POST/PUT/DELETE reçoit 403 'CSRF', refresh automatique + retry
- ROADMAP: item CSRF token refresh marqué ✅
- 143 tests passent
2026-07-20 19:32:43 -04:00
bruno aba771400d docs: fusionner ROADMAP v3.0 (docs/) → ROADMAP racine + supprimer ancien
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- docs/ROADMAP.md: 16 éléments utiles manquants détectés et ajoutés
- Nouvelle section v5.2.0 Infrastructure & Polish (design tokens, API tokens,
  sessions, onboarding wizard, migrations Alembic, backup, linting, CI/CD)
- Duplication v5.2.0 corrigée → v5.3.0 Database Avancée
- docs/ROADMAP.md supprimé (redondant, tout est dans le ROADMAP racine)
2026-07-20 16:34:02 -04:00
bruno 2ca27d3398 docs: NOTION_DATABASE_TASKS_GUIDE.md v2.0 + ROADMAP Database/Views/Tasks
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
- NOTION_DATABASE_TASKS_GUIDE.md: réécriture complète (822→~750 lignes)
  - 16 sections: anatomie DB, 22 propriétés, relations/rollups, formules
  - 10 Database Views détaillées, filtres/tris/groupes
  - Data Sources & Linked Databases, Templates, Dashboards
  - Tasks, Sub-items, Dependencies, Sprints, My Tasks
  - Modèle de données complet (tables existantes + 5 nouvelles)
  - Plan d'implémentation en 5 phases (v4.1–v4.5)
- ROADMAP: v4.1–v4.5 redéfini = Database/Views/Tasks
  - v4.1: Data Sources & Linked
  - v4.2: Templates & Dashboards
  - v4.3: 10 Database Views complètes
  - v4.4: Tasks, Sub-items & Dependencies
  - v4.5: Sprints & My Tasks
  - Versions suivantes renumérotées (v4.6→v6.0)
- Sources: Guide_Complet_Notion_database.md + 15 URLs docs officielles Notion
2026-07-20 15:58:52 -04:00
bruno 9422753e8b Add Notion databases and views guide
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-20 15:48:11 -04:00
bruno 3a94b06af4 docs: ROADMAP — v4.7.0 FlowDeck Agent (IA native complète)
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Remplace l'entrée 'AI Assistant' minimale par FlowDeck Agent détaillé
- 6 sections: Concept, Fonctionnalités, Architecture, Tables, Cas d'usage, Migration
- 10+ outils actionnables, boucle ReAct, SSE streaming, multi-LLM
- 5 phases de migration, 6 nouvelles tables DB
- Références: Flowdeck_Agent_integration.md + Guide_Complet_Notion_Agent_2026.md
- Résumé des phases mis à jour
2026-07-20 14:56:27 -04:00
bruno b0d222b81a Add Notion Agent and FlowDeck integration docs
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
Document Notion Agent (2026) concepts and a FlowDeck Agent design
that orchestrates tool calls through existing FastAPI routers, plus
a chat UI screenshot.
2026-07-20 14:38:42 -04:00
bruno 0ea447ee6f docs: ARCHITECTURE.md v4.0 — mise à jour complète
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Version 3.0 → 4.0, date 2026-07-20
- Templates: reflète la structure actuelle (base, _header, _workspace_tree_macro, etc.)
- Routers: ajoutés (local_workspace, library, private, public_api, admin, workspace)
- Data layer: nouvelles tables v4.0 (page_shares, recents, tags, gitea_private_pages)
- Nouvelle section 5: Système d'authentification (3 types de comptes, sidebar behavior, cookie flowdeck_workspace)
- Layout diagram: sidebar Notion complet avec toutes les sections
- Section 16: Versions à venir (v4.1.0 → v5.0.0)
- Nettoyage références obsolètes
2026-07-20 13:27:13 -04:00
bruno 63773cb904 fix: 4 correctifs UX — workspace +, library empty, settings spacing
FlowDeck CI / test (push) Failing after 3s
FlowDeck CI / docker (push) Has been skipped
- Workspaces: bouton bleu '+' maintenant centré, 48px, min-height 140px (plus coupé)
- Library no-workspace: 'Open a Workspace...' avec lien Go to Workspaces au lieu de Loading
- Library: boutons '+ Add new' et 'New page' cachés quand pas de workspace
- Settings: espacement bouton Upload photo (margin-bottom, margin-top augmentés)
- 143 tests passent
2026-07-20 11:21:14 -04:00
bruno ec3ad6b1de fix: aligner Search à droite dans la nav sidebar
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
2026-07-20 11:07:35 -04:00
bruno cefc7eb356 fix: admin password permanent + Help/My Tasks content rendering
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- main.py: lifespan insère toujours le hash de 'FlowDeck2026!' pour admin
- dashboard.py + my_tasks.py: content_html passe via {% block content %} (Jinja)
- Avant: content_html ignoré car base.html utilise des blocs, pas des variables
- 143 tests passent
2026-07-20 11:02:37 -04:00
bruno de62628cb7 merge: develop → main (help page)
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-20 10:47:30 -04:00
bruno 4806097bc5 feat: Help page complète — 6 cartes, raccourcis, auth, tips
- Grille 2 colonnes de cartes: Getting Started, Pages, Workspaces, Gitea, Sharing, Library
- Section Keyboard Shortcuts avec style kbd + hover
- Section Authentication avec badges colorés (Local/Gitea/GitHub)
- Section Tips & Tricks
- Style Notion dark élégant, responsive
2026-07-20 10:47:30 -04:00
bruno 1fc4c21e2a merge: develop → main (my-tasks + help)
FlowDeck CI / test (push) Failing after 3s
FlowDeck CI / docker (push) Has been skipped
2026-07-20 10:46:29 -04:00
bruno 0d9c1bf9ee feat: My Tasks dans base.html (sidebar visible) + Help page complète
- /my-tasks: wrappé dans base.html pour garder le sidebar visible
- /help: page d'aide complète avec 5 sections (démarrage, pages, workspaces, intégrations, raccourcis)
- Style élégant Notion dark avec cartes, grille 2 colonnes, badges
- 143 tests passent
2026-07-20 10:46:24 -04:00
bruno a49a9bae9b merge: develop → main (sidebar links + /help)
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-20 10:12:59 -04:00
bruno d5ba29714c feat: remplacer section Notion apps par liens statiques Library/My Tasks/Trash/Help
- Section 'Notion apps' retirée
- Liens statiques en bas du sidebar (toujours visibles): Library, My Tasks, Trash, Help
- Nouvelle route /help avec page d'aide (raccourcis, guide rapide)
- Trash visible pour tous (plus de condition sur auth_method)
- 143 tests passent
2026-07-20 10:12:55 -04:00
bruno feb29cbff3 merge: develop → main (fix: sidebar create buttons guard)
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-20 09:12:08 -04:00
bruno 79bc1bf1fd fix: cacher tous les boutons create du sidebar quand aucun workspace actif
- Meetings '+' button → gardé avec has_active_workspace
- Sidebar footer 'New page' → gardé avec has_active_workspace
- Context menu New File/New Folder → déjà gardé car l'arbre est caché sans workspace
- Workspace section déjà gardée (commit précédent)
- 143 tests passent
2026-07-20 09:12:03 -04:00
bruno 63c9a5fced merge: develop → main (fix: sidebar no-workspace guard)
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-20 08:59:06 -04:00
bruno cbebd1f537 merge: fix/no-workspace-sidebar → develop 2026-07-20 08:59:06 -04:00
bruno b2486a6e11 fix: sidebar workspace section — hide create buttons quand aucun workspace actif
- Ajout flag has_active_workspace dans _sidebar_data (dashboard.py + board.py)
- Template base.html: quand has_active_workspace=False:
  - Titre section → '📁 No workspace open'
  - Boutons New File/New Folder cachés
  - Message 'Open a workspace to see your files'
- Guard JS newPageInWorkspace()/newFolderInWorkspace(): toast + redirect si pas de workspace
- 143 tests passent
2026-07-20 08:59:00 -04:00
bruno 0e99dc2251 docs: ROADMAP v4.0.2 marqué complété
FlowDeck CI / test (push) Failing after 8s
FlowDeck CI / docker (push) Has been skipped
2026-07-20 08:08:06 -04:00
bruno 68f7f97b0d merge: feat/quality → develop (v4.0.2) 2026-07-20 08:07:28 -04:00
bruno 8514386a9b merge: develop → main (v4.0.2 — quality & robustness)
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-20 08:07:28 -04:00
bruno c49e152e7c docs: ROADMAP v4.0.1 marqué complété
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-20 07:55:39 -04:00
bruno 8e6ed1e251 merge: develop → main (v4.0.1 — onboarding & polish)
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
2026-07-20 07:55:18 -04:00
228 changed files with 36060 additions and 3244 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
+215 -64
View File
@@ -1,7 +1,8 @@
# Architecture FlowDeck — Document Complet v3.0
# Architecture FlowDeck — Document Complet v4.0
> **Version:** 3.0 · **Date:** 2026-07-14 · **Auteur:** Hermes-Deepin
> **Version:** 4.0 · **Date:** 2026-07-20 · **Auteur:** Hermes-Deepin
> **Cible:** Clone Notion intégré à Gitea/GitHub — multi-comptes, multi-intégrations
> **Version déployée:** v4.0.x (production : https://flowdeck.dracodev.net)
---
@@ -47,34 +48,31 @@ FlowDeck est un **clone de Notion** intégré à Gitea. Il recrée l'expérience
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Templates Jinja2 (SSR) │ │
│ │ ├─ dashboard.html — Projets + sidebar │ │
│ │ ├─ board.html — Kanban (wrapper) │ │
│ │ ├─ board_fragment.html— Cartes Kanban │ │
│ │ ├─ table_view.html — Vue table │ │
│ │ ├─ calendar_view.html — Vue calendrier (v1.6) │ │
│ │ ├─ timeline_view.html — Vue Gantt (v1.6) │ │
│ │ ├─ gallery_view.html — Vue galerie (v1.6) │ │
│ │ ├─ list_view.html — Vue liste (v1.6) │ │
│ │ ├─ status_overview.html — Donut SVG │ │
│ │ ├─ team_load.html — Barres stacked │ │
│ │ ├─ detailed_board.html— Board détaillé │ │
│ │ ├─ card_detail.html — Modale détail carte │ │
│ │ ├─ card.html — Carte individuelle │ │
│ │ ├─ page_editor.html — Éditeur de blocs │ │
│ │ ├─ my_tasks.html — Dashboard unifié (v1.9) │ │
│ │ ├─ library.html — Bibliothèque de pages │ │
│ │ ├─ trash.html — Corbeille │ │
│ │ ├─ accounts.html — Gestion comptes │ │
│ │ ├─ notes.html — Notes Markdown │ │
│ │ └─ base.html — Layout commun │ │
│ │ ├─ base.html — Layout Notion commun (sidebar) │ │
│ │ ├─ _header.html — Topbar + breadcrumbs │ │
│ │ ├─ _workspace_tree_macro.html — Macro arbre sidebar │ │
│ │ ├─ landing.html — Landing page non-auth │ │
│ │ ├─ workspaces.html — Page Workspaces (locale/Gitea) │ │
│ │ ├─ library.html — Bibliothèque multi-onglets │ │
│ │ ├─ local_workspace.html — Workspace local (explorer) │ │
│ │ ├─ page_editor.html — Éditeur de blocs │ │
│ │ ├─ trash.html — Corbeille (local only) │ │
│ │ ├─ settings.html — Settings / My Account / Admin │ │
│ │ ├─ accounts.html — Gestion comptes admin │ │
│ │ ├─ board.html — Kanban (wrapper) │ │
│ │ ├─ board_fragment.html — Cartes Kanban │ │
│ │ ├─ table_view.html — Vue table │ │
│ │ ├─ login.html — Login/Register (local + OAuth) │ │
│ │ ├─ public_page.html — Page publiée (/p/slug) │ │
│ │ └─ (legacy: dashboard, detailed_board, card_detail, etc.) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ JavaScript (CDN, pas de build step) │ │
│ │ ├─ HTMX 1.9 — AJAX sans JS │ │
│ │ JavaScript (servi localement, pas de CDN externe) │ │
│ │ ├─ Alpine.js 3.14 — Interactivité légère │ │
│ │ ├─ HTMX 1.9 — AJAX sans JS (legacy) │ │
│ │ ├─ SortableJS 1.15 — Drag & drop │ │
│ │ └─ highlight.js — Syntax highlighting (v2.0) │ │
│ │ └─ Prism.js — Syntax highlighting │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
@@ -95,17 +93,27 @@ FlowDeck est un **clone de Notion** intégré à Gitea. Il recrée l'expérience
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ ROUTERS │ │
│ │ ├─ dashboard.py — / Dashboard │ │
│ │ ├─ board.py — /board/{o}/{r} Board + vues │ │
│ │ │ — /board/api/... APIs board │ │
│ │ ├─ api.py — /api/... CRUD + sync │ │
│ │ ├─ notes.py — /notes/{o}/{r} Notes │ │
│ │ ├─ auth.py — /auth/... OAuth2 │ │
│ │ ├─ webhooks.py — /webhooks/... Gitea hooks │ │
│ │ ├─ my_tasks.py — /my-tasks (v1.9) │ │
│ │ ├─ pages.py — /pages/... Pages CRUD │ │
│ │ ├─ collections.py — /db/... Collections │ │
│ │ └─ editor.py — /api/editor/... Block editor │ │
│ │ ├─ main.py — FastAPI app, lifespan, CORS, 404 │ │
│ │ ├─ dashboard.py — /, /workspaces, /library, /help │ │
│ │ │ — /trash, /settings, /accounts │ │
│ │ │ — /p/{slug} (pages publiques) │ │
│ │ │ — _sidebar_data() — données sidebar │ │
│ │ ├─ board.py — /board/{o}/{r} Board + vues │ │
│ │ │ — /board/api/pages CRUD pages │ │
│ │ │ — /board/api/trash Trash │ │
│ │ ├─ api.py — /api/... CRUD + sync │ │
│ │ ├─ auth.py — /auth/... OAuth2 + local │ │
│ │ ├─ local_workspace.py— /api/local-workspace CRUD + upload │ │
│ │ ├─ my_tasks.py — /my-tasks Dashboard │ │
│ │ ├─ library.py — /api/library/* API bibliothèque│ │
│ │ ├─ pages.py — /pages/... Pages CRUD │ │
│ │ ├─ collections.py — /db/... Collections │ │
│ │ ├─ editor.py — /api/editor/... Block editor │ │
│ │ ├─ private.py — /api/private/* Section privée │ │
│ │ ├─ public_api.py — /api/public/* Public API │ │
│ │ ├─ workspace.py — Workspaces API + Gitea projets │ │
│ │ ├─ webhooks.py — /webhooks/... Gitea hooks │ │
│ │ └─ admin.py — /api/admin/* Admin users │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
@@ -138,6 +146,11 @@ FlowDeck est un **clone de Notion** intégré à Gitea. Il recrée l'expérience
│ │ │ ├─ property_values (legacy → collection_pages) │
│ │ │ ├─ ai_keywords │ │ │
│ │ │ ├─ pages │ │ │
│ │ │ ├─ page_shares (v4.0) │ │ │
│ │ │ ├─ recents (v4.0) │ │ │
│ │ │ ├─ tags (v4.0) │ │ │
│ │ │ ├─ page_tags (v4.0) │ │ │
│ │ │ ├─ gitea_private_pages (v4.0) │ │ │
│ │ │ ├─ users │ │ │
│ │ │ ├─ user_tokens │ │ │
│ │ │ ├─ workspaces (v2.0) │ │ │
@@ -685,42 +698,146 @@ POST /favorites → Ajouter favori
DELETE /favorites/{id} → Retirer favori
```
### 4.6 Routes Sidebar & Library (v4.0)
```
GET /library → Bibliothèque dynamique
GET /api/library/recents → Pages récentes
GET /api/library/favorites → Favoris
GET /api/library/shared → Pages partagées
GET /api/library/published → Pages publiées
GET /api/library/private → Pages privées
GET /api/library/workspace → Pages du workspace
GET /api/local-workspace/items → Arborescence workspace
POST /api/local-workspace/items → Créer page/dossier
PUT /api/local-workspace/items/{id} → Renommer
DELETE /api/local-workspace/items/{id} → Soft delete
POST /api/local-workspace/upload → Upload fichier
GET /api/files/{ws_id}/{filename} → Servir fichier uploadé
GET /help → Page d'aide complète
GET /api/public/pages/{slug} → Page publique
```
### 4.7 Routes Share & Publish (v4.0)
```
POST /api/pages/{id}/share → Partager une page
DELETE /api/pages/{id}/share/{share_id} → Retirer partage
GET /api/pages/{id}/shares → Liste partages
POST /api/pages/{id}/publish → Publier page
DELETE /api/pages/{id}/publish → Dépublier
```
---
## 5. Frontend
## 5. Système d'authentification (v4.0)
FlowDeck supporte **3 méthodes d'authentification** et la **connexion d'intégrations multiples** à un même compte local.
### 5.1 Types de comptes
| Type | Auth | Workspaces | Intégrations |
|------|------|------------|--------------|
| **Local pur** | email + password | Locaux uniquement | Aucune |
| **Local + intégrations** | email + password | Locaux + distants | Gitea et/ou GitHub |
| **OAuth pur** | Gitea ou GitHub | Distants uniquement | Non-déconnectable |
### 5.2 Comportement du sidebar selon le type de compte
```
LOCAL PUR:
├─ 📁 [nom workspace local]
├─ 📅 Meetings
├─ 🕒 Recents
├─ ⭐ Favorites
├─ 🤖 Agents
├─ 👥 Shared ← visible si pages partagées
├─ 🌐 Published ← visible si pages publiées
├─ (Private caché)
└─ 📚 Library / ✅ My Tasks / 🗑️ Trash / ❓ Help
LOCAL + GITEA/GITHUB:
├─ 🔗 [owner/repo] ← workspace distant actif
├─ 📅 Meetings
├─ 🕒 Recents
├─ ⭐ Favorites
├─ 🤖 Agents
├─ 👥 Shared
├─ 🌐 Published
├─ 🔒 Private ← visible (fichiers locaux liés au projet distant)
└─ 📚 / ✅ / 🗑️ / ❓
OAUTH PUR (GITEA/GITHUB):
├─ 🔗 [owner/repo] ← identité = compte Gitea/GitHub
├─ 🕒 Recents
├─ ⭐ Favorites
├─ 👥 Shared
├─ 🌐 Published
├─ (Private caché)
└─ 📚 / ✅ / ❓ (Trash caché — pas de workspace local)
```
### 5.3 Règles d'affichage
- **Section Private** : visible UNIQUEMENT si compte local + workspace distant actif
- **Trash** : visible si `auth_method == 'local'` ou si des pages locales existent
- **Boutons New File/New Folder** : cachés si `has_active_workspace == False`
- **Message "No workspace open"** : affiché dans la section Workspace si aucun workspace actif
- **Header sidebar** : toujours le compte local si auth_method=local, même avec intégrations
### 5.4 cookie `flowdeck_workspace`
- `""` → aucun workspace actif
- `"42"` → workspace local ID 42
- `"gitea:owner:repo"` → workspace distant Gitea
- Nettoyage automatique si cookie invalide (workspace supprimé ou autre user)
---
## 6. Frontend
### 5.1 Layout
```
```ascii
┌──────────────────────────────────────────────────────────────┐
│ TOPBAR (44px, sticky) │
│ ┌──────────┬──────────────────────────────────┬────────────┐ │
│ │ Logo │ Breadcrumbs: WS > Collection │ Share... │ │
│ │ FlowDeck │ (ou WS > Collection > Page) │ Settings │ │
│ └──────────┴──────────────────────────────────┴────────────┘ │
│ TOPBAR (unified-header) │
│ ┌──────┬───────────────────────────────────────┬───────────┐ │
│ │ «» │ Breadcrumbs: 🏠 Workspaces │ Share ⚙ │ │
│ └──────┴───────────────────────────────────────┴───────────┘ │
├────────────┬─────────────────────────────────────────────────┤
│ SIDEBAR │ CONTENU PRINCIPAL │
│ (240px) │ │
│ │ ┌─────────────────────────────────────────────┐│
│ Workspace │ │ DATABASE VIEW ││
│ header │ │ ┌──────┬──────┬──────┬──────┬──────────┐ ││
│ │ │ │Title │Statut│Priori│Assign│Due Date │ ││
│ 🔍 Search │ │ ├──────┼──────┼──────┼──────┼──────────┤ ││
│ │ │ │Tâche1│Todo │ P1 │Bruno │15 juil │ ││
│ ▼ Recents │ │ │Tâche2│Doing │ P2 │ — │20 juil │ ││
│ · Proj A │ │ │Tâche3│Done │ P3 │Marie │10 juil │ ││
│ · Proj B │ │ └──────┴──────┴──────┴──────┴──────────┘ ││
│ │ │ [+ New] [Filter] [Sort] [Search] [...] ││
│ ▼ Private │ └─────────────────────────────────────────────┘│
│ · Notes │ │
│ │ ┌─ Barre d'outils ────────────────────────────┐│
│ ▼ Shared │ │ [Table▼] [Kanban] [Calendar] [Gallery] ... ││
│ · ... │ │ [Group▼] [Filter▼] [Sort▼] [Properties▼] ││
│ │ ┌─ Help Page ────────────────────────────────┐│
│ ┌────────┐ │ │ ┌──────────────┐ ┌──────────────┐ ││
│ │ Header │ │ │ │ 🚀 Getting │ │ 📝 Pages │ (...) ││
│ │ [D] │ │ │ │ Started │ │ & Editor │ ││
│ │ Draco │ │ │ └──────────────┘ └──────────────┘ ││
│ └────────┘ │ └─────────────────────────────────────────────┘│
│ │ │
│ 🏠 💬 📨 🔍│ ┌─ Library ─────────────────────────────────┐│
│ │ │ [Recents|Favorites|Shared|Published|...] ││
│ 📁 Worksp. │ │ ┌──────┬──────────┬──────────┬───────────┐ ││
│ 📄 New Pg │ │ │Name │Created by│Source │Last edited│ ││
│ 📁 New Fl │ │ ├──────┼──────────┼──────────┼───────────┤ ││
│ 📄 page 1 │ │ │doc A │Draco │Local │10 min ago │ ││
│ 📄 page 2 │ │ │doc B │bruno │Gitea │2 hours ago│ ││
│ │ │ └──────┴──────────┴──────────┴───────────┘ ││
│ 📅 Meeting │ └─────────────────────────────────────────────┘│
│ 🕒 Recents │ │
│ ⭐ Favoris │ ┌─ Workspaces ───────────────────────────────┐│
│ 🤖 Agents │ │ ┌──────────┐ ┌──────────┐ ┌───┐ ┌───┐ ││
│ 👥 Shared │ │ │Project A │ │Project B │ │ + │ │ + │ ││
│ 🌐 Publ. │ │ │3 pages │ │12 pages │ │ │ │ │ ││
│ │ │ └──────────┘ └──────────┘ └───┘ └───┘ ││
│ ══════════ │ └─────────────────────────────────────────────┘│
│ 📚 Library │ │
│ ✅ My Task │ ┌─ Settings ─────────────────────────────────┐│
│ 🗑️ Trash │ │ My Account | Admin | Integrations ││
│ ❓ Help │ │ [Upload photo] [Full Name] [Email] [...] ││
│ │ └─────────────────────────────────────────────┘│
│ ────────── │ │
│ [+New page]│ ┌─ Contenu de la vue ─────────────────────────┐│
│ (sticky │ │ (table / board / calendar / gallery / ...) ││
│ footer) │ └─────────────────────────────────────────────┘│
│ 💬 [+📄] │ │
└────────────┴─────────────────────────────────────────────────┘
```
@@ -1553,7 +1670,41 @@ docker compose restart flowdeck
- [Notion Help — Databases](https://www.notion.com/help/intro-to-databases)
- [Notion Help — Tasks & Dependencies](https://www.notion.com/help/tasks-and-dependencies)
- [Notion Help — Relations & Rollups](https://www.notion.com/help/relations-and-rollups)
- [Notion Data Sources (2025)](https://www.notionapps.com/blog/notion-data-sources-update-2025/)
- [react-notion-x](https://github.com/NotionX/react-notion-x)
- [FlowDeck — NOTION_DATABASE_TASKS_GUIDE.md](docs/NOTION_DATABASE_TASKS_GUIDE.md)
- [FlowDeck — ROADMAP.md](ROADMAP.md)
---
## 16. Versions à venir
### v4.1.0 — Content Blocks enrichis (priorité standard)
- **Callout boxes** — Blocs d'alerte (info, warning, tip, danger)
- **Table of Contents** — Auto-généré depuis les headings
- **LaTeX / KaTeX** — Formules mathématiques
- **Toggle lists** — Listes pliables/dépliables
- **Multi-columns** — Mise en page 2-3 colonnes
### v4.2.0 — Export (priorité standard)
- Export **Markdown** avec images
- Export **PDF** (via WeasyPrint ou headless Chrome)
- Export **HTML** standalone (page auto-suffisante)
### v4.3.0 — Collaboration (priorité basse)
- **Commentaires inline** sur les pages
- **@mentions** pour notifier des utilisateurs
- **Notifications email** (changements, mentions)
- **Permissions par workspace** (viewer, editor, admin)
### v5.0.0 — Plateforme avancée (futur)
- **AI Assistants** — Génération de contenu, résumés, suggestions
- **Embeds** — Vidéos, PDF, Figma, Google Docs
- **Automatisations** — Règles déclenchées sur événements (Notion-style)
- **Base de données avancée** — Relations inter-collections, rollups
- **Kanban flexible** — Colonnes custom, WIP limits
- **API publique REST** — Tokens d'accès pour intégrations tierces
- **Volume Docker persistant** — `/data` monté pour survie des données
- **PostgreSQL** — Migration optionnelle pour scaling
+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"]
+453 -74
View File
@@ -177,113 +177,492 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
## 🔜 Prochaines versions
### v4.0.1 — Onboarding & Polish 🎯 (priorité MAX)
> **Objectif** : rendre l'app utilisable dès la première visite, sans friction.
### v4.0.1 — Onboarding & Polish ✅ (2026-07-18)
> **Objectif** : rendre l'app utilisable dès la première visite, sans friction. **COMPLETED**.
- [ ] **Landing page `/` pour visiteurs non-auth** — page d'accueil (pas de redirect loop)
- [ ] **Page `/auth/register` GET** — formulaire d'inscription dédié (pas juste un tab)
- [ ] **Redirection `/` intelligente** — si pas connecté → login, si connecté sans Gitea → local workspace
- [ ] **Empty states cohérents** — onboarding dans le local workspace vide
- [ ] **Page 404 stylisée** — thème Notion sombre (actuellement HTML brut)
- [ ] **Unifier les routes API** — `/board/api/pages` ↔ `/api/pages` (cohérence)
- [ ] **Page Settings accessible** → lien dans le menu utilisateur déjà présent, vérifier fonctionnel
- [x] **Landing page `/` pour visiteurs non-auth** — template `landing.html`, design Notion dark
- [x] **Page `/auth/register` GET** — formulaire d'inscription dédié (tab register actif)
- [x] **Redirection `/` intelligente** — non-auth → landing, auth sans Gitea → local workspace, auth+Gitea → dashboard
- [x] **Empty states** — déjà présents dans local workspace (« Welcome to your Workspace »)
- [x] **Page 404 stylisée** — thème Notion sombre, JSON pour `/api/*`, HTML pour le reste
- [x] **Unifier les routes API** — `/api/pages` GET/POST → 307 redirect vers `/board/api/pages`
- [x] **Page Settings** — lien déjà présent dans le menu utilisateur (Settings → `/accounts/settings`)
**Effort estimé** : 2-3 sessions | **Impact** : 🔴 Critique pour adoption
### v4.0.2 — Qualité & Robustesse
### v4.0.2 — Qualité & Robustesse ✅ (2026-07-18)
> **Objectif** : zéro crash, zéro regression, DX impeccable.
- [ ] **Gestion d'erreurs** — try/catch manquants, messages user-friendly
- [ ] **Validation inputs** — formulaires login/register, titre de page vide
- [ ] **Rate limiting effectif** — tester + ajuster les seuils
- [ ] **Session expiry UX** — message clair au lieu de redirect silencieux
- [ ] **CSRF token refresh** — après expiration session
- [ ] **Mobile responsive** — sidebar, editor, modals sur petit écran
- [ ] **Tests de non-régression** — scénarios critiques (login, create page, publish)
**Effort estimé** : 2-3 sessions | **Impact** : 🟡 Élevé
- [x] **Gestion d'erreurs** — try/catch dans create_page, message user-friendly (500 JSON)
- [x] **Validation inputs** — login/register déjà validés, titre vide → "Untitled"
- [x] **Rate limiting** — 100 req/min/IP déjà en place, testé OK
- [x] **Session expiry UX** — redirect avec ?expired=1, bannière sur la page login
- [x] **CSRF token refresh** — auto-refresh sur 403, endpoint `/api/csrf-token`
- [x] **Mobile responsive** — sidebar slide-in, modales centrées, landing adaptative
- [x] **Tests de non-régression** — +10 tests (landing, register, 404, validation, duplicate, expiry)
- [x] **143 tests passent**
---
### v4.1.0 — Content Blocks enrichis (Notion Parity Tier 2)
### v4.1.0 — Database: Data Sources & Linked Databases ✅ (2026-07-21)
> **Objectif** : Collections multi-sources, databases liées (linked). **COMPLETED**.
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 1](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
- [x] **Table `collection_data_sources`** — multi-sources par collection (FK `collection_id` + `source_collection_id`, flag `is_linked`)
- [x] **API sources** — `GET /db/{id}/sources/api` (liste), `POST /db/{id}/sources/api` (ajouter), `DELETE /db/{id}/sources/{sid}/api` (retirer)
- [x] **API linked DB** — `POST /db/{id}/linked/api` (crée une collection liée : copie vues + propriétés + data source `is_linked=1`)
- [x] **Full-page vs Inline** — `POST /db/{id}/toggle-inline/api` (toggle), `POST /db/inline/api` (créer inline avec `parent_page_id`)
- [x] **Règle permissions** — linked DB hérite du `workspace_id` de la source → mêmes ACLs
- [x] **Migration** — ajout colonnes `workspace_id`, `created_by` sur `collections`
- [x] **153 tests passent** (+10 nouveaux)
### v4.2.0 — Database: Templates & Dashboards ✅ (2026-07-21)
> **Objectif** : Templates réutilisables + dashboards combinant vues/widgets. **COMPLETED**.
- [x] **Table `page_templates` enrichie** — colonnes `description`, `is_recurring`, `recurrence_rule` (daily/weekly/monthly/yearly)
- [x] **Table `collection_dashboards`** — layout widgets (grille configurable : `columns`, `widgets` avec `view_id`, `x`, `y`, `width`, `height`)
- [x] **API templates CRUD complet** — `PUT /workspace/collections/{id}/templates/page/{tid}` (update), `DELETE` (delete)
- [x] **API dashboards CRUD** — `GET/POST/PUT/DELETE /workspace/collections/{id}/dashboards`
- [x] **159 tests passent** (+6 nouveaux)
### v4.3.0 — Database Views complètes (10 types) ✅ (2026-07-21)
> **Objectif** : Tous les types de vues Notion avec layouts. **COMPLETED**.
- [x] **Chart** — barres, courbes, camemberts, donuts, scatter (Chart.js CDN)
- [x] **Form** — formulaire HTML auto-généré depuis les propriétés → POST création page
- [x] **Map** — Leaflet/OSM pour propriété Place (lat,lng parsing)
- [x] **Feed** — vue chronologique type fil d'actualité (newest first)
- [x] **Timeline/Gantt** — barres horizontales avec group_by et plages de dates
- [x] **View tabs** — 11 onglets de navigation (table, board, calendar, gallery, list, timeline, gantt, chart, form, map, feed)
- [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 ✅ (2026-07-21)
> **Objectif** : Système complet de tâches Notion-style. **COMPLETED**.
- [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 ✅ (2026-07-21)
> **Objectif** : Sprints agiles + vue My Tasks cross-databases. **COMPLETED**.
- [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 ✅ (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.2.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.3.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.4.0 — Embeds & Rich Media
- [ ] **Embeds** — YouTube, Figma, Google Maps, Twitter, etc.
- [ ] **Image lightbox** — clic pour agrandir, navigation
- [ ] **Bookmark cards** — aperçu riche des liens (OG metadata)
- [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)
### v4.5.0 — Kanban Adaptable
- [ ] Colonnes configurables par utilisateur
- [ ] Cartes configurables (choisir propriétés affichées)
- [ ] Couverture de carte (image, icône, couleur)
- [ ] WIP limits par colonne
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.6.0 — Calendar & Meetings
- [ ] Vue Calendrier drag & drop
- [ ] Récurrence d'événements
- [ ] Template Meeting Notes
### 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.0 — AI Assistant
- [ ] Intégration LLM pour écriture/résumé/traduction
- [ ] Slash AI commands (`/ai write`, `/ai summarize`)
- [ ] Auto-complétion
### 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.8.0 — Command Palette & Recherche
- [ ] **Command palette** — Ctrl+K / Ctrl+P recherche universelle
- [ ] **Quick actions** — navigation, création, commandes
### 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.9.0 — Automations
- [ ] **Database automations** — if-this-then-that
- [ ] **Buttons** — cliquables déclenchant actions
### 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.10.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)
### 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).
**Documentation :**
[`docs/Flowdeck_Agent_integration.md`](docs/Flowdeck_Agent_integration.md) (702 lignes) ·
[`docs/Guide_Complet_Notion_Agent_2026.md`](docs/Guide_Complet_Notion_Agent_2026.md) (464 lignes)
#### Concept
Là où un assistant IA classique répond (`prompt → réponse`), FlowDeck Agent **réalise** :
`objectif → planification → collecte contexte → actions API → résultat`
| Notion AI | FlowDeck Agent |
|-----------|----------------|
| Répond, résume, réécrit | Planifie, recherche, **agit** |
| Bloc de texte | Crée collections, pages, propriétés, vues |
| Sans état | Historique conversation + snapshots rollback |
| Aucune action DB | Modifie le workspace via API FastAPI existantes |
#### Fonctionnalités clés
- [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)
```
app/
├── routers/agent.py — /api/agent/* (run, conversations, tools)
├── services/agent_engine.py — Orchestrateur ReAct + boucle tool calls
├── services/llm_client.py — Abstraction multi-fournisseur LLM
├── services/tool_registry.py — 10+ outils wrappant les API existantes
├── services/context_builder.py — Collecte contexte workspace
└── templates/
├── agent_panel.html — Panneau conversation Alpine.js
└── agent_message.html — Fragment message (streaming)
```
#### Nouvelles tables
| Table | Rôle |
|-------|------|
| `agents` | Config agent (nom, instructions, modèle, tools scope) |
| `agent_conversations` | Sessions (titre, agent, workspace, statut) |
| `agent_messages` | Messages (role, content, tool_calls, artifacts) |
| `agent_actions` | Journal d'audit (tool, args, result, undo_snapshot) |
| `agent_skills` | Skills réutilisables (nom, prompt template, outils) |
| `agent_triggers` | Déclencheurs (cron, webhook, event, agent cible) |
#### Cas d'usage FlowDeck
- **Créer un CRM complet** — agent crée collection, propriétés, vues, exemples
- **Résumer les notes de réunion** — lit les pages, synthétise, crée une page résumé
- **Générer des OKR** — crée la database OKR avec relations et vues
- **Analyser un repo Gitea** — lit les issues, crée un dashboard de suivi
- **Rédiger un rapport hebdomadaire** — agrège les pages modifiées, génère le rapport
#### 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 ✅ (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
#### Limitations (v1)
- Pas de modification concurrente (lock optimiste DB)
- Pas d'appels API externes non-FlowDeck (sandbox strict)
- Budget tokens max par conversation (500k tokens)
- Timeout 5 minutes par run
### v5.0.0 — Command Palette & Recherche ✅ (2026-09-06)
> **Objectif** : `Ctrl+K` / `Ctrl+P` palette de commandes universelle + recherche full-text. **COMPLETED**.
- [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.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**
- [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**
- [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**
- [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**
- [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] **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
**✅ 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.0.0 — Pro (futur)
## 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 »
---
## v6.0.0 — Pro (futur)
- [ ] **PWA** — Progressive Web App, offline support
- [ ] **SSO/SAML** — enterprise authentication
- [ ] **Granular permissions** — page-level, property-level access control
- [ ] **Web Clipper** — extension navigateur
- [ ] **API publique** — 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
```
v0.x–v1.x ✅ v2.x ✅ v3.0.0 ✅ v4.0.0 ✅ v4.0.1 ⬜
v0.x–v1.x ✅ v2.x ✅ v3.0.0 ✅ v4.0.0 ✅ v4.0.1 ✅
Base + Kanban Éditeur + Gitea UX Pro MVP Onboarding
+ DB + Auth + Workspaces + Multi-select Accounts & Polish
+ UI Notion + Tags + Admin + Sharing PRIORITÉ
+ GitHub OAuth + Library MAX
+ UI Notion + Tags + Admin + Sharing COMPLETED
+ GitHub OAuth + Library
v4.0.2 ⬜ v4.1.0 ⬜ v4.2.0 ⬜ v4.3.0 ⬜ v4.4–4.10 ⬜ v5.0.0 ⬜
Quality Blocks Export Collab Notion Pro
& Tests enrichis PDF/MD @mentions Parity (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-18 — Roadmap restructuré, focus MVP, stratégie de branches Gitea*
*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
View File
@@ -0,0 +1 @@
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:":
+361 -1
View File
@@ -192,7 +192,7 @@ def init_db():
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
icon TEXT DEFAULT '📄',
icon TEXT DEFAULT 'file',
position INTEGER NOT NULL DEFAULT 0,
parent_id INTEGER REFERENCES collection_pages(id),
gitea_issue_id INTEGER,
@@ -462,6 +462,366 @@ def init_db():
""")
conn.commit()
# v4.1.0: Data Sources & Linked Databases
conn.execute("""
CREATE TABLE IF NOT EXISTS collection_data_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
source_collection_id INTEGER NOT NULL REFERENCES collections(id),
source_name TEXT DEFAULT '',
is_linked BOOLEAN NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(collection_id, source_collection_id)
)
""")
# Add workspace_id to collections if missing (v4.1.0 migration)
try:
conn.execute("ALTER TABLE collections ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)")
except sqlite3.OperationalError:
pass
# Add created_by to collections if missing
try:
conn.execute("ALTER TABLE collections ADD COLUMN created_by INTEGER REFERENCES users(id)")
except sqlite3.OperationalError:
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:
conn.execute("ALTER TABLE page_templates ADD COLUMN description TEXT DEFAULT ''")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE page_templates ADD COLUMN is_recurring BOOLEAN NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE page_templates ADD COLUMN recurrence_rule TEXT DEFAULT ''")
except sqlite3.OperationalError:
pass
# Dashboard: combinaison de vues/widgets sur une page
conn.execute("""
CREATE TABLE IF NOT EXISTS collection_dashboards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Dashboard',
layout_json TEXT NOT NULL DEFAULT '{"columns":1,"widgets":[]}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
# v4.4.0: Tasks & Dependencies
# Add is_task flag to collections
try:
conn.execute("ALTER TABLE collections ADD COLUMN is_task BOOLEAN NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
# Dependencies table: bloque/bloqué par with auto-shift config
conn.execute("""
CREATE TABLE IF NOT EXISTS page_dependencies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
dependency_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
dependency_type TEXT NOT NULL DEFAULT 'blocks',
auto_shift TEXT DEFAULT 'overlap',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(page_id, dependency_id, dependency_type)
)
""")
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():
+97 -12
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(
@@ -31,18 +56,51 @@ async def lifespan(_app: FastAPI):
init_db()
init_webhook_tables()
from app.db import get_conn
from app.password_utils import hash_password
admin_hash = hash_password("FlowDeck2026!")
with get_conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO users (login, full_name, email, is_admin) VALUES ('admin', 'Admin', '', 1)"
"INSERT OR IGNORE INTO users (login, full_name, email, password_hash, is_admin) VALUES ('admin', 'Admin', '', ?, 1)",
(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,
@@ -58,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)
@@ -69,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")
@@ -89,6 +159,21 @@ async def pwa_manifest():
# ═══════════ API aliases (v4.0.1) ═══════════
@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."""
import secrets
from fastapi.responses import JSONResponse
token = secrets.token_hex(32)
response = JSONResponse({"csrf_token": token})
response.set_cookie(
"csrf_token", token,
httponly=False, samesite="lax", max_age=86400, path="/",
)
return response
@app.get("/api/pages")
async def api_pages_alias(request: Request):
"""Alias /api/pages → /board/api/pages for API path consistency."""
@@ -135,10 +220,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;backgrou
@app.exception_handler(404)
async def not_found_handler(request: Request, exc):
"""Styled 404 page matching the FlowDeck dark theme."""
from fastapi.responses import HTMLResponse
# API routes should get JSON, not HTML
if request.url.path.startswith("/api/") or request.url.path.startswith("/board/api/"):
"""Redirect 404 HTML pages to /workspaces. API routes still get JSON."""
# Preserve JSON 404 for all API-like paths (including /db/xxx/api)
if "/api" in request.url.path:
from fastapi.responses import JSONResponse
return JSONResponse({"detail": "Not found"}, status_code=404)
return HTMLResponse(NOT_FOUND_HTML, status_code=404)
from fastapi.responses import RedirectResponse
return RedirectResponse("/workspaces", status_code=302)
+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"}
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]}
+619 -70
View File
@@ -3,13 +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")
@@ -62,7 +66,7 @@ def _map_issue_to_card(issue: dict, owner: str = "", repo: str = "") -> dict:
"bug": "🐛", "feature": "✨", "enhancement": "⚡", "documentation": "📄",
"design": "🎨", "testing": "🧪", "refactor": "🔧", "security": "🔒",
}
icon = "📄"
icon = "file"
for lbl in labels:
for kw, emoji in icon_map.items():
if kw in lbl.get("name", "").lower():
@@ -141,27 +145,41 @@ def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, m
def _file_icon(name: str, content_format: str = "") -> str:
"""Map file extension to emoji icon."""
"""Map file extension to icon name (SVG-safe)."""
import re
# FlowDeck internal pages (no extension)
if content_format and content_format != 'file':
return '📝'
return 'edit'
n = name.lower()
if re.search(r'\.(png|jpe?g|gif|webp|svg|bmp|ico)$', n): return '🖼️'
if n.endswith('.pdf'): return '📕'
if re.search(r'\.(md|markdown)$', n): return '📝'
if n.endswith('.py'): return '🐍'
if re.search(r'\.(js|jsx|ts|tsx)$', n): return '📜'
if re.search(r'\.(html?|xml)$', n): return '🌐'
if n.endswith('.css'): return '🎨'
if n.endswith('.json'): return '📋'
if n.endswith('.sql'): return '🗃️'
if re.search(r'\.(sh|bash|zsh)$', n): return '💻'
if n.endswith('.ps1'): return '⚡'
if re.search(r'\.(rs|go|java|rb|php|c|cpp|h|swift|kt|scala|r)$', n): return '📜'
if re.search(r'\.(txt|log)$', n): return '📄'
if re.search(r'\.(zip|tar|gz|rar|7z)$', n): return '📦'
return '📄'
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'
def _load_workspace_pages(ws_cookie: str) -> list:
@@ -172,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 = []
@@ -180,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,
})
@@ -197,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 = []
@@ -205,17 +229,116 @@ 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,
})
return children
def _local_workspaces_for_user(user: dict | None, workspace_id: int = 0) -> list[dict]:
"""Return list of local workspaces for a user."""
if not user:
return []
try:
from app.db import get_conn
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name FROM workspaces WHERE owner_id = ? ORDER BY name",
(user["id"],)
).fetchall()
return [{"id": r["id"], "name": r["name"]} for r in rows]
except Exception:
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"
@@ -229,17 +352,21 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
gitea_workspace = False
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]
gitea_repo = parts[2]
active_ws_name = f"{gitea_owner}/{gitea_repo}"
gitea_workspace = True
workspace_pages = [] # loaded client-side
# Get local workspace ID for mirror
has_active_workspace = True
# Get local workspace ID for mirror and load its tree
if user:
try:
with get_conn() as conn:
@@ -249,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:
@@ -262,9 +390,9 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
if row:
active_ws_name = row["name"]
workspace_pages = _load_workspace_pages(ws_cookie)
has_active_workspace = True
except (ValueError, Exception):
pass
local_ws_id = 0
recent = []
if owner and repo:
view_map = {
@@ -277,7 +405,7 @@ def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
active = first
recent.append({
"id": f"{owner}/{repo}/{view}",
"name": label, "icon": "📁" if first else "·",
"name": label, "icon": "folder" if first else "",
"url": f"/board/{owner}/{repo}?view={view}",
"active": active, "indent": indent,
"depth": indent, "has_children": False, "children": [],
@@ -314,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"
@@ -372,11 +477,17 @@ 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,
"gitea_linked": gitea_linked,
"github_linked": github_linked}
"github_linked": github_linked,
"has_active_workspace": has_active_workspace,
"app_version": _get_app_version(),
"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 = ""):
@@ -506,7 +617,7 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
"icon": "📄",
"url": f"/pages/{page['id']}",
"created_by": "You",
"source": page.get("workspace") or "🔒 Private",
"source": page.get("workspace") or "Private",
"last_edited": page.get("updated_at", "now"),
"last_visited": page.get("updated_at", "now"),
})
@@ -514,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)
@@ -567,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)
@@ -580,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")
@@ -723,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}
@@ -777,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)}
@@ -791,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
@@ -809,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)
@@ -839,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))
@@ -858,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)
@@ -878,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()
@@ -903,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}
@@ -918,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:
@@ -945,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":
@@ -977,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"})
@@ -1011,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"}
File diff suppressed because it is too large Load Diff
+590 -69
View File
File diff suppressed because it is too large Load Diff
+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"
+63 -94
View File
@@ -4,11 +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")
@@ -38,121 +39,89 @@ async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7)
collection_tasks = []
for p in pages:
props = json.loads(p["property_values_json"])
# Check if assigned to current user
assigned = False
for v in props.values():
if isinstance(v, list):
for item in v:
if isinstance(item, dict) and item.get("login") == user_login:
assigned = True
break
elif isinstance(v, str) and user_login.lower() in v.lower():
assigned = True
if assigned:
break
if not assigned and user_login == "admin":
assigned = True # admin sees all
if not assigned:
props = {}
try:
props = json.loads(p["property_values_json"] or "{}")
except (json.JSONDecodeError, TypeError):
pass
due_date = props.get("due_date", "")
status = props.get("status", "—")
assignee = props.get("assignee", "")
if view != "all" and assignee and assignee != user_login:
continue
# Extract status and due date
status = None
due_date = None
for v in props.values():
if isinstance(v, str) and v in ("Todo", "In Progress", "Done", "Complete"):
status = v
elif isinstance(v, str) and v.startswith("20"):
due_date = v[:10]
# Filter by view
if view == "today":
from datetime import date
if not due_date or due_date != date.today().isoformat():
continue
elif view == "overdue":
from datetime import date
if not due_date or due_date >= date.today().isoformat():
continue
elif view == "upcoming":
from datetime import date, timedelta
if not due_date:
continue
today = date.today()
limit = today + timedelta(days=days)
d = date.fromisoformat(due_date)
if d < today or d > limit:
continue
collection_tasks.append({
"id": p["id"], "title": p["title"], "icon": p.get("icon", "📄"),
"status": status, "due_date": due_date, "props": props,
"id": p["id"],
"title": p["title"] or "Untitled",
"icon": p["icon"] or "📋",
"status": status,
"due_date": due_date,
})
if collection_tasks:
grouped[col["name"]] = collection_tasks
total = sum(len(t) for t in grouped.values())
# Render
# Build content HTML
sections = ""
for col_name, tasks in grouped.items():
items = ""
for t in tasks:
due_badge = f"<span class='mt-due'>{t['due_date']}</span>" if t.get("due_date") else ""
status_badge = f"<span class='mt-status mt-{t['status'].lower().replace(' ','-') if t.get('status') else 'none'}'>{t.get('status','—')}</span>"
items += f"""<div class='mt-item'>
<span class='mt-icon'>{t['icon']}</span>
<span class='mt-title'>{t['title']}</span>
{status_badge}{due_badge}
</div>"""
status_cls = t['status'].lower().replace(' ', '-') if t.get('status') else 'none'
status_badge = f"<span class='mt-status mt-{status_cls}'>{t.get('status', '—')}</span>"
items += f"<div class='mt-item'><span class='mt-icon'>{t['icon']}</span><span class='mt-title'>{t['title']}</span>{status_badge}{due_badge}</div>"
sections += f"""<div class='mt-section'>
<div class='mt-section-header'>{col_name} <span class='mt-count'>{len(tasks)}</span></div>
{items}
</div>"""
sections += f"<div class='mt-section'><div class='mt-section-header'>{col_name} <span class='mt-count'>{len(tasks)}</span></div>{items}</div>"
return HTMLResponse(f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>My Tasks — FlowDeck</title>
<style>
body{{font-family:system-ui,sans-serif;background:#191919;color:#fff;margin:0;padding:20px}}
h1{{font-size:24px;margin:0 0 16px}}
.view-tabs{{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid #333;padding-bottom:8px}}
.tab{{padding:6px 14px;border-radius:6px;cursor:pointer;color:#A0A0A0;font-size:13px;background:none;border:none;text-decoration:none}}
.tab:hover,.tab.active{{background:#333;color:#fff}}
.mt-section{{margin-bottom:24px}}
.mt-section-header{{font-size:14px;font-weight:600;color:#A0A0A0;margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px}}
.mt-count{{color:#6B6B6B;font-weight:400}}
.mt-item{{display:flex;align-items:center;gap:10px;padding:8px 12px;background:#1a1a1a;border-radius:6px;margin-bottom:3px;border:1px solid #222}}
.mt-item:hover{{border-color:#444}}
.mt-icon{{font-size:16px}}
.mt-title{{flex:1;font-size:14px}}
.mt-status{{font-size:11px;padding:2px 8px;border-radius:4px}}
.mt-due{{font-size:11px;color:#A0A0A0}}
.mt-todo{{background:#333;color:#A0A0A0}}
.mt-in-progress{{background:#1a2744;color:#3366CC}}
.mt-done,.mt-complete{{background:#1a332a;color:#00CC66}}
.p_desc{{color:#A0A0A0;margin-top:8px}}
</style></head><body>
<h1>📋 My Tasks</h1>
<div class="view-tabs">
<a class="tab{' active' if view=='all' else ''}" href="?view=all">All</a>
<a class="tab{' active' if view=='today' else ''}" href="?view=today">Today</a>
<a class="tab{' active' if view=='overdue' else ''}" href="?view=overdue">Overdue</a>
<a class="tab{' active' if view=='upcoming' else ''}" href="?view=upcoming&days=7">Next 7 days</a>
content_html = f"""<div style="max-width:800px;margin:0 auto;padding:40px 24px;">
<h1 style="font-size:24px;margin:0 0 8px;">📋 My Tasks</h1>
<p style="color:var(--text-dim);font-size:14px;margin-bottom:24px;">{total} tasks across {len(grouped)} collections</p>
<div class="view-tabs" style="display:flex;gap:4px;margin-bottom:24px;border-bottom:1px solid var(--border);padding-bottom:12px;">
<a class="tab{' active' if view=='all' else ''}" href="/my-tasks?view=all" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">All</a>
<a class="tab{' active' if view=='today' else ''}" href="/my-tasks?view=today" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Today</a>
<a class="tab{' active' if view=='overdue' else ''}" href="/my-tasks?view=overdue" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Overdue</a>
<a class="tab{' active' if view=='upcoming' else ''}" href="/my-tasks?view=upcoming&days=7" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Next 7 days</a>
</div>
<style>
.tab.active{{background:var(--accent);color:#fff!important;}}
.tab:hover{{background:var(--bg-hover);color:var(--text);}}
.mt-section{{margin-bottom:24px;}}
.mt-section-header{{font-size:13px;font-weight:600;color:var(--text-dim);margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px;}}
.mt-count{{color:var(--text-dim);font-weight:400;opacity:.5;}}
.mt-item{{display:flex;align-items:center;gap:10px;padding:10px 12px;background:var(--bg-card);border-radius:8px;margin-bottom:3px;border:1px solid var(--border);}}
.mt-item:hover{{border-color:var(--accent);}}
.mt-icon{{font-size:16px;}}
.mt-title{{flex:1;font-size:14px;}}
.mt-status{{font-size:11px;padding:2px 8px;border-radius:4px;}}
.mt-due{{font-size:11px;color:var(--text-dim);}}
.mt-todo,.mt-none{{background:rgba(255,255,255,.05);color:var(--text-dim);}}
.mt-in-progress{{background:rgba(35,131,226,.15);color:#2C8CEB;}}
.mt-done,.mt-completed,.mt-complete{{background:rgba(0,200,100,.15);color:#00CC66;}}
</style>
{sections}
<p class="p_desc">{total} tasks across {len(grouped)} collections</p>
</body></html>""")
</div>"""
from app.routers.dashboard import _sidebar_data
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, [])
block_tpl = env.from_string(
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
)
return block_tpl.render(
**sidebar,
request=request,
content_html=content_html,
page_title="My Tasks",
title_prefix="My Tasks",
page_icon="📋",
)
@router.get("/api")
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}")
+299 -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,9 +290,298 @@ 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"}
@router.put("/collections/{collection_id}/templates/page/{tid}")
async def update_page_template(request: Request, collection_id: int, tid: int):
"""Update a page template — name, properties, content, recurrence."""
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
tmpl = conn.execute(
"SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)
).fetchone()
if not tmpl:
raise HTTPException(404, "Template not found")
name = body.get("name", tmpl["name"])
tmpl_dict = dict(tmpl)
description = body.get("description", tmpl_dict.get("description", ""))
property_values_json = json.dumps(body.get("properties", json.loads(tmpl["property_values_json"])))
content_json = json.dumps(body.get("content", json.loads(tmpl_dict.get("content_json", "[]"))))
is_recurring = int(body.get("is_recurring", tmpl_dict.get("is_recurring", 0)))
recurrence_rule = body.get("recurrence_rule", tmpl_dict.get("recurrence_rule", ""))
conn.execute(
"""UPDATE page_templates SET name=?, description=?, property_values_json=?,
content_json=?, is_recurring=?, recurrence_rule=? WHERE id=?""",
(name, description, property_values_json, content_json, is_recurring, recurrence_rule, tid),
)
conn.commit()
return {"id": tid, "status": "updated"}
@router.delete("/collections/{collection_id}/templates/page/{tid}")
async def delete_page_template(request: Request, collection_id: int, tid: int):
"""Delete a page template."""
with get_conn() as conn:
tmpl = conn.execute(
"SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)
).fetchone()
if not tmpl:
raise HTTPException(404, "Template not found")
conn.execute("DELETE FROM page_templates WHERE id=?", (tid,))
conn.commit()
return {"id": tid, "status": "deleted"}
# ── v4.2.0: Dashboards ──
@router.get("/collections/{collection_id}/dashboards")
async def list_dashboards(request: Request, collection_id: int):
"""List all dashboards for a collection."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM collection_dashboards WHERE collection_id=? ORDER BY name", (collection_id,)
).fetchall()
return {"dashboards": [dict(r) for r in rows]}
@router.post("/collections/{collection_id}/dashboards")
async def create_dashboard(request: Request, collection_id: int):
"""Create a new dashboard for a collection."""
body = await request.json() if request.headers.get("content-type") else {}
name = body.get("name", "Dashboard").strip()
layout = json.dumps(body.get("layout", {"columns": 1, "widgets": []}))
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(404, "Collection not found")
cur = conn.execute(
"INSERT INTO collection_dashboards (collection_id, name, layout_json) VALUES (?,?,?)",
(collection_id, name, layout),
)
conn.commit()
return {"id": cur.lastrowid, "name": name, "status": "created"}
@router.put("/collections/{collection_id}/dashboards/{did}")
async def update_dashboard(request: Request, collection_id: int, did: int):
"""Update a dashboard — name or layout (widgets grid)."""
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
dash = conn.execute(
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (did, collection_id)
).fetchone()
if not dash:
raise HTTPException(404, "Dashboard not found")
name = body.get("name", dash["name"])
layout = json.dumps(body.get("layout", json.loads(dash["layout_json"])))
conn.execute(
"UPDATE collection_dashboards SET name=?, layout_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(name, layout, did),
)
conn.commit()
return {"id": did, "status": "updated"}
@router.delete("/collections/{collection_id}/dashboards/{did}")
async def delete_dashboard(request: Request, collection_id: int, did: int):
"""Delete a dashboard."""
with get_conn() as conn:
dash = conn.execute(
"SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (did, collection_id)
).fetchone()
if not dash:
raise HTTPException(404, "Dashboard not found")
conn.execute("DELETE FROM collection_dashboards WHERE id=?", (did,))
conn.commit()
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>
+6 -5
View File
@@ -12,7 +12,7 @@
#}
{% set ns = namespace(items=[]) %}
{% if not hide_home %}
{% set ns.items = ns.items + [{"label": "Home", "url": "/workspaces", "id": none, "icon": "🏠", "menu": false, "home": true}] %}
{% set ns.items = ns.items + [{"label": "Home", "url": "/workspaces", "id": none, "icon": "home", "menu": false, "home": true}] %}
{% endif %}
{% if breadcrumb_items %}
{% for item in breadcrumb_items %}
@@ -51,7 +51,7 @@
<div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()"
@mouseleave="dragCloseTimer()">
{% if page_icon %}
<span class="header-page-icon">{{ page_icon }}</span>
<span class="header-page-icon">{% if page_icon in ("folder","file","star","link","trash","book","home","settings","lock","globe","image","edit","calendar","users","user","search","tag","bar-chart","grid","list","align-left","zap","paperclip","key","refresh","upload") %}{{ fd_icon(page_icon,18) }}{% else %}{{ page_icon }}{% endif %}</span>
{% endif %}
<template x-for="(crumb, di) in display" :key="di">
@@ -67,7 +67,7 @@
@mouseleave="dragCloseTimer()">
<template x-for="h in crumb.hidden" :key="h.id">
<div class="fd-nav-item" @click.stop="go(h.url); closeAll()">
<span class="fd-nav-ico" x-text="h.icon || '📄'"></span>
<span class="fd-nav-ico" :title="h.icon || 'file'"></span>
<span class="fd-nav-label" x-text="h.label"></span>
</div>
</template>
@@ -110,7 +110,7 @@
<div class="fd-nav-item"
@mouseenter="openSub(item)"
@click.stop="go(item.url); closeAll()">
<span class="fd-nav-ico" x-text="item.icon || '📄'"></span>
<span class="fd-nav-ico" :title="item.icon || 'file'"></span>
<span class="fd-nav-label" x-text="item.name"></span>
<span class="fd-nav-arrow" x-show="item.has_children">&rsaquo;</span>
<div class="fd-nav-menu fd-nav-submenu" x-show="subOpenId === item.id" x-cloak
@@ -118,7 +118,7 @@
@mouseleave="dragCloseTimer()">
<template x-for="s in subItems" :key="s.id">
<div class="fd-nav-item" @click.stop="go(s.url); closeAll()">
<span class="fd-nav-ico" x-text="s.icon || '📄'"></span>
<span class="fd-nav-ico" :title="s.icon || 'file'"></span>
<span class="fd-nav-label" x-text="s.name"></span>
<span class="fd-nav-arrow" x-show="s.has_children">&rsaquo;</span>
</div>
@@ -136,6 +136,7 @@
</div>
<div class="topbar-right header-actions">
{% include '_notification_bell.html' %}
{{ right_actions|safe if right_actions else '' }}
</div>
</header>
+116
View File
@@ -0,0 +1,116 @@
{# FlowDeck — Outline SVG Icon Set (v4.0+)
Usage: {{ fd_icon('folder') }} {{ fd_icon('star', 16) }}
All icons use stroke="currentColor" for theme compatibility.
#}
{%- macro fd_icon(name, size=18) -%}
{%- set s = size|string -%}
{%- if name == 'folder' -%}
<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 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
{%- elif name == 'file' -%}
<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="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>
{%- elif name == 'calendar' -%}
<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>
{%- elif name == 'clock' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
{%- elif name == 'star' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
{%- elif name == 'bot' -%}
<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="7" width="18" height="13" rx="2"/><path d="M8 7V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v3"/><line x1="12" y1="20" x2="12" y2="24"/><circle cx="8" cy="13" r="1"/><circle cx="16" cy="13" r="1"/></svg>
{%- elif name == 'users' -%}
<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="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
{%- elif name == 'globe' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
{%- elif name == 'lock' -%}
<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="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
{%- elif name == 'book' -%}
<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 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
{%- elif name == 'check-square' -%}
<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>
{%- elif name == 'trash' -%}
<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="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
{%- elif name == 'help-circle' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
{%- elif name == 'settings' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
{%- elif name == 'refresh' -%}
<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="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
{%- elif name == 'log-out' -%}
<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="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
{%- elif name == 'message-square' -%}
<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="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
{%- elif name == 'home' -%}
<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="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
{%- elif name == 'search' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
{%- elif name == 'link' -%}
<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>
{%- elif name == 'plus' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
{%- elif name == 'bell' -%}
<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="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
{%- elif name == 'image' -%}
<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="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
{%- elif name == 'download' -%}
<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="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
{%- elif name == 'chevron-left' -%}
<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="15 18 9 12 15 6"/></svg>
{%- elif name == 'chevron-right' -%}
<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 18 15 12 9 6"/></svg>
{%- elif name == 'list' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
{%- elif name == 'bar-chart' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
{%- elif name == 'grid' -%}
<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="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>
{%- elif name == 'align-left' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="17" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/></svg>
{%- elif name == 'corner-down-right' -%}
<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="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/></svg>
{%- elif name == 'copy' -%}
<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="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
{%- elif name == 'chevron-down' -%}
<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="6 9 12 15 18 9"/></svg>
{%- elif name == 'key' -%}
<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="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
{%- elif name == 'inbox' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
{%- elif name == 'edit' -%}
<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="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
{%- elif name == 'copy' -%}
<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="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
{%- elif name == 'eye-off' -%}
<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="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
{%- elif name == 'eye' -%}
<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="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{%- elif name == 'share' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
{%- elif name == 'more-horizontal' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
{%- elif name == 'x' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
{%- elif name == 'paperclip' -%}
<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="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
{%- elif name == 'external-link' -%}
<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="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
{%- elif name == 'sparkles' -%}
<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="M12 3l1.9 5.8 6.1.5-4.9 3.6 1.7 5.8-4.8-3.5-4.8 3.5 1.7-5.8-4.9-3.6 6.1-.5z"/></svg>
{%- elif name == 'lightbulb' -%}
<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="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/></svg>
{%- elif name == 'tag' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>
{%- elif name == 'file-text' -%}
<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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
{%- elif name == 'save' -%}
<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="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
{%- elif name == 'upload' -%}
<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="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
{%- elif name == 'trending-up' -%}
<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="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
{%- elif name == 'zap' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>
{%- elif name == 'alert-triangle' -%}
<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.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
{%- elif name == 'user' -%}
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
{%- endif -%}
{%- endmacro -%}
+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
+3 -1
View File
@@ -1,3 +1,4 @@
{% from '_icons.html' import fd_icon %}
{% macro render_workspace_tree(pages, depth=0) %}
{% for page in pages %}
{% set safe_name = page.name | replace("'", "\\'") %}
@@ -25,7 +26,8 @@
{% else %}
<span style="width:12px;flex-shrink:0;"></span>
{% endif %}
<span class="page-icon">{{ page.icon }}</span>
<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 }})"
+3 -3
View File
@@ -1,12 +1,12 @@
{% extends "base.html" %}
{% block page_icon %}👤{% endblock %}
{% block page_icon %}{{ fd_icon("user",18) }}{% endblock %}
{% block page_title %}Accounts{% endblock %}
{% block title_prefix %}Accounts{% endblock %}
{% block content %}
<div class="page-title-area">
<div class="page-title">
<span class="page-icon-lg">👤</span>
<span class="page-icon-lg">{{ fd_icon("user",24) }}</span>
<h1>Account Management</h1>
</div>
</div>
@@ -50,7 +50,7 @@
<div style="background:var(--bg-secondary); border-radius:8px; padding:20px; margin-bottom:24px;">
<h3 style="margin-bottom:12px;">Connected Accounts</h3>
<div style="display:flex; align-items:center; gap:12px; padding:12px; border:1px solid var(--border); border-radius:6px;">
<span style="font-size:20px;">🔗</span>
<span style="font-size:20px;">{{ fd_icon("link",20) }}</span>
<div style="flex:1;">
<div style="font-weight:600;">Gitea</div>
<div style="font-size:12px; color:var(--text-dim);">
+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
+701 -121
View File
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block page_icon %}📁{% endblock %}
{% block page_icon %}{{ fd_icon("folder",18) }}{% endblock %}
{% block page_title %}Kanban board new{% endblock %}
{% block title_prefix %}Kanban board new{% endblock %}
@@ -16,7 +16,7 @@
<!-- Page title -->
<div class="page-title-area">
<div class="page-title">
<span class="page-icon-lg">📁</span>
<span class="page-icon-lg">{{ fd_icon("folder",24) }}</span>
<h1>{{ repo }}</h1>
</div>
</div>
@@ -41,31 +41,31 @@
hx-get="/board/{{ owner }}/{{ repo }}/view/kanban"
hx-target="#view-content" hx-swap="innerHTML"
@click="setActiveTab($el)">
<span class="tab-icon">📊</span> Kanban board
<span class="tab-icon">{{ fd_icon("bar-chart",14) }}</span> Kanban board
</button>
<button class="view-tab" data-view="detailed"
hx-get="/board/{{ owner }}/{{ repo }}/view/detailed"
hx-target="#view-content" hx-swap="innerHTML"
@click="setActiveTab($el)">
<span class="tab-icon">📋</span> Detailed board
<span class="tab-icon">{{ fd_icon("list",14) }}</span> Detailed board
</button>
<button class="view-tab" data-view="table"
hx-get="/board/{{ owner }}/{{ repo }}/view/table"
hx-target="#view-content" hx-swap="innerHTML"
@click="setActiveTab($el)">
<span class="tab-icon">☰</span> Table view
<span class="tab-icon">{{ fd_icon("align-left",14) }}</span> Table view
</button>
<button class="view-tab" data-view="status"
hx-get="/board/{{ owner }}/{{ repo }}/view/status"
hx-target="#view-content" hx-swap="innerHTML"
@click="setActiveTab($el)">
<span class="tab-icon">📈</span> Status overview
<span class="tab-icon">{{ fd_icon("trending-up",14) }}</span> Status overview
</button>
<button class="view-tab" data-view="teamload"
hx-get="/board/{{ owner }}/{{ repo }}/view/teamload"
hx-target="#view-content" hx-swap="innerHTML"
@click="setActiveTab($el)">
<span class="tab-icon">👥</span> Team Load
<span class="tab-icon">{{ fd_icon("users",14) }}</span> Team Load
</button>
</div>
@@ -111,7 +111,7 @@
<span class="sort-direction" @click="toggleDir(i)">
<span x-text="s.dir === 'asc' ? 'Ascending ↑' : 'Descending ↓'"></span>
</span>
<button class="sort-delete" @click="removeSort(i)" title="Delete">🗑</button>
<button class="sort-delete" @click="removeSort(i)" title="Delete">{{ fd_icon("trash",14) }}</button>
</div>
</template>
<button class="dropdown-option" @click="addSort()">+ Add sort</button>
@@ -119,8 +119,8 @@
</div>
</div>
</div>
<button class="toolbar-btn" title="Properties">⚡</button>
<button class="toolbar-btn" title="Search">🔍</button>
<button class="toolbar-btn" title="Properties">{{ fd_icon("zap",14) }}</button>
<button class="toolbar-btn" title="Search">{{ fd_icon("search",14) }}</button>
<button class="toolbar-btn">⋯</button>
<button class="btn-new" @click="showNewIssue()">New ▾</button>
</div>
+1 -1
View File
@@ -36,7 +36,7 @@
<span class="card-tag" style="background: {{ card.tag_color }}20; color: {{ card.tag_color }};">{{ card.tag }}</span>
{% endif %}
{% if card.due_date %}
<div class="card-date"><span>📅</span> {{ card.due_date }}</div>
<div class="card-date"><span>{{ fd_icon("calendar",14) }}</span> {{ card.due_date }}</div>
{% endif %}
</div>
</div>
+1 -1
View File
@@ -21,7 +21,7 @@
{% endif %}
{% if card.due_date %}
<div class="card-date">
<span>📅</span> {{ card.due_date }}
<span>{{ fd_icon("calendar",14) }}</span> {{ card.due_date }}
</div>
{% endif %}
</div>
+2 -2
View File
@@ -23,13 +23,13 @@
</div>
<!-- Assignee -->
<div class="filter-pill" style="cursor:pointer;">
<span>👤</span>
<span>{{ fd_icon("user",14) }}</span>
{% if issue.assignee %}{{ issue.assignee }}{% else %}Unassigned{% endif %}
<span style="font-size:10px;">▾</span>
</div>
<!-- Deadline -->
<div class="filter-pill" style="cursor:pointer;">
<span>📅</span>
<span>{{ fd_icon("calendar",14) }}</span>
{% if issue.due_date %}{{ issue.due_date }}{% else %}No deadline{% endif %}
</div>
<!-- Labels -->
+4 -4
View File
@@ -1,5 +1,5 @@
{% extends "base.html" %}
{% block page_icon %}🏠{% endblock %}
{% block page_icon %}{{ fd_icon("home",18) }}{% endblock %}
{% block page_title %}Home{% endblock %}
{% block title_prefix %}Home{% endblock %}
@@ -42,7 +42,7 @@
border-radius: 6px; transition: background 0.15s; cursor: pointer; color: var(--text-primary); text-decoration: none;"
onmouseover="this.style.background='var(--bg-hover)'"
onmouseout="this.style.background='transparent'">
<span style="font-size:18px;">📁</span>
<span style="font-size:18px;">{{ fd_icon("folder",20) }}</span>
<div style="flex:1; min-width:0;">
<div style="font-weight:500; font-size:14px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">
{{ repo.name }}
@@ -57,7 +57,7 @@
<span style="font-size:12px; color: var(--text-dim);">🟡 {{ repo.language }}</span>
{% endif %}
{% if repo.private %}
<span style="font-size:12px; color: var(--text-dim);">🔒</span>
<span style="font-size:12px; color: var(--text-dim);">{{ fd_icon("lock",14) }}</span>
{% endif %}
<span style="font-size:12px; color: var(--text-dim);">→</span>
</div>
@@ -66,7 +66,7 @@
{% if not repos %}
<div style="text-align:center; padding:48px; color:var(--text-dim);">
<div style="font-size:48px; margin-bottom:16px;">📭</div>
<div style="margin-bottom:16px;">{{ fd_icon("inbox",48) }}</div>
<h3 style="color:var(--text-secondary);">Aucun projet trouvé</h3>
<p style="margin-top:8px;">
{% if search %}
+1 -1
View File
@@ -39,7 +39,7 @@
{% endif %}
{% if card.due_date %}
<div class="card-date">
<span>📅</span>
<span>{{ fd_icon("calendar",14) }}</span>
<span style="font-size:12px;">{{ card.due_date }}</span>
</div>
{% endif %}
+20 -20
View File
@@ -1,12 +1,12 @@
{% extends "base.html" %}
{% block page_title %}{{ workspace_name }}{% endblock %}
{% block title_prefix %}{{ workspace_name }}{% endblock %}
{% block page_icon %}🔗{% endblock %}
{% block page_icon %}{{ fd_icon("link",18) }}{% endblock %}
{% block topbar %}
{% set breadcrumb_items = [{"label": owner ~ "/" ~ repo, "url": None}] %}
{% set page_icon = "🔗" %}
{% set right_actions = '<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">📄+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">📤</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">🔄</button><a href="/accounts/settings" class="topbar-btn" title="Settings">⚙</a>' %}
{% set page_icon = "link" %}
{% set right_actions = '<button class="page-action-btn" @click="showNewFile=!showNewFile" title="New file">' ~ fd_icon("file",14) ~ '+</button><button class="page-action-btn" @click="triggerUpload()" title="Upload file">' ~ fd_icon("upload",14) ~ '</button><input type="file" id="gitea-upload-input" style="display:none" @change="doUpload($event)" multiple><button class="page-action-btn" @click="refreshTree()" title="Refresh">' ~ fd_icon("refresh",14) ~ '</button><a href="/accounts/settings" class="topbar-btn" title="Settings">' ~ fd_icon("settings",16) ~ '</a>' %}
{% include '_header.html' %}
{% endblock %}
@@ -164,7 +164,7 @@ document.addEventListener('alpine:init', () => {
if (!r.ok) {
// If API fails (e.g. no Gitea token), set a message
var container = document.getElementById('sidebar-gitea-items');
if (container) container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon">🔗</span><span class="page-name text-dim">Connect Gitea to browse files</span></li>';
if (container) container.innerHTML = '<li class="sidebar-item empty-hint"><span class="page-icon">'+getSvgIcon('link',14)+'</span><span class="page-name text-dim">Connect Gitea to browse files</span></li>';
return;
}
var d = await r.json();
@@ -180,7 +180,7 @@ document.addEventListener('alpine:init', () => {
var html = '';
for (var i = 0; i < items.length; i++) {
var item = items[i];
var icon = item.type === 'folder' ? '📁' : '📄';
var icon = item.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
html += '<li class="sidebar-item" data-gitea-path="' + item.path + '" data-gitea-type="' + (item.type === 'folder' ? 'folder' : 'file') + '" data-gitea-sha="' + (item.sha || '') + '" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
html += '<span class="page-icon">' + icon + '</span>';
html += '<span class="page-name">' + item.name + '</span>';
@@ -189,7 +189,7 @@ document.addEventListener('alpine:init', () => {
// Add Private Pages link
html += '<li style="border-top:1px solid var(--border);margin:8px 0;"></li>';
html += '<li class="sidebar-item" id="gitea-private-link" style="padding-left:12px;cursor:pointer;display:flex;align-items:center;gap:4px;">';
html += '<span class="page-icon">🔒</span><span class="page-name">Private Pages</span></li>';
html += '<span class="page-icon">'+getSvgIcon('lock',14)+'</span><span class="page-name">Private Pages</span></li>';
container.innerHTML = html;
// Re-attach event listeners
this.setupSidebarClicks();
@@ -270,7 +270,7 @@ document.addEventListener('alpine:init', () => {
ul.className = 'sidebar-items';
ul.style.paddingLeft = '20px';
children.forEach(function(child) {
var icon = child.type === 'folder' ? '📁' : '📄';
var icon = child.type === 'folder' ? getSvgIcon('folder',14) : getSvgIcon('file',14);
var li = document.createElement('li');
li.className = 'sidebar-item';
li.setAttribute('data-gitea-path', child.path);
@@ -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');
@@ -669,8 +669,8 @@ document.addEventListener('alpine:init', () => {
<strong x-text="filePath || ''"></strong>
<span x-text="formatSize(fileSize)"></span>
<span style="flex:1;"></span>
<button class="btn" @click="openEditor()">✏️ Edit</button>
<button class="btn btn-danger" @click="deleteCurrentFile()">🗑 Delete</button>
<button class="btn" @click="openEditor()">{{ fd_icon("edit",14) }} Edit</button>
<button class="btn btn-danger" @click="deleteCurrentFile()">{{ fd_icon("trash",14) }} Delete</button>
</div>
<div class="gw-content">
<!-- Skeleton loading -->
@@ -710,7 +710,7 @@ document.addEventListener('alpine:init', () => {
<option :value="msg">
</template>
</datalist>
<button class="btn-primary" @click="saveFile()">💾 Commit</button>
<button class="btn-primary" @click="saveFile()">{{ fd_icon("save",14) }} Commit</button>
</div>
</div>
</template>
@@ -734,9 +734,9 @@ document.addEventListener('alpine:init', () => {
<div x-show="!treeLoading">
<template x-if="treeItems.length === 0">
<div class="empty-state">
<h2>📁 Empty</h2>
<h2>{{ fd_icon("folder",16) }} Empty</h2>
<p>No files in this folder</p>
<button class="btn btn-primary" @click="showNewFile=!showNewFile">📄+ New file</button>
<button class="btn btn-primary" @click="showNewFile=!showNewFile">{{ fd_icon("file",14) }}+ New file</button>
</div>
</template>
@@ -746,7 +746,7 @@ document.addEventListener('alpine:init', () => {
@click="item.type==='folder' ? drillDown(item.path) : openFile(item.path, item.sha)"
@contextmenu.prevent="openGwContext($event, item)"
style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;">
<span x-text="item.type==='folder' ? '📁' : '📄'" style="font-size:16px;"></span>
<span x-html="item.type==='folder' ? getSvgIcon('folder',16) : getSvgIcon('file',16)" style="font-size:16px;"></span>
<span x-text="item.name" style="flex:1;font-size:14px;" :style="{ fontWeight: item.type==='folder' ? '500' : '400' }"></span>
<span x-text="item.type==='folder' ? '' : formatSize(item.size)" style="font-size:12px;color:var(--text-tertiary);"></span>
</div>
@@ -759,8 +759,8 @@ document.addEventListener('alpine:init', () => {
:style="'left:' + gwCtx.x + 'px; top:' + gwCtx.y + 'px'"
@click.outside="gwCtx.visible = false"
style="display:none;position:fixed;background:var(--bg-modal);border:1px solid var(--border);border-radius:var(--radius-md);box-shadow:var(--shadow-popover);z-index:1100;min-width:160px;padding:4px;">
<div class="ctx-item" @click="gwRename()">✏️ Rename</div>
<div class="ctx-item ctx-danger" @click="gwDelete()">🗑 Delete</div>
<div class="ctx-item" @click="gwRename()">{{ fd_icon("edit",14) }} Rename</div>
<div class="ctx-item ctx-danger" @click="gwDelete()">{{ fd_icon("trash",14) }} Delete</div>
</div>
</div>
@@ -769,7 +769,7 @@ document.addEventListener('alpine:init', () => {
<input type="text" x-model="newFilePath" placeholder="path/to/file.md" class="settings-input" style="margin-bottom:8px;">
<textarea x-model="newFileContent" placeholder="File content..." class="settings-input" style="height:120px;margin-bottom:8px;"></textarea>
<input type="text" x-model="newFileMsg" placeholder="Commit message" class="settings-input" style="margin-bottom:8px;">
<button class="btn btn-primary" @click="createNewFile()">💾 Create file</button>
<button class="btn btn-primary" @click="createNewFile()">{{ fd_icon("save",14) }} Create file</button>
</div>
</div>
</template>
@@ -778,7 +778,7 @@ document.addEventListener('alpine:init', () => {
<template x-if="showPrivate && !editingPrivate">
<div class="gw-content">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<h3>🔒 Private Pages</h3>
<h3>{{ fd_icon("lock",14) }} Private Pages</h3>
<button class="btn btn-primary" onclick="window.FlowDeck.createPage()">+ New Page</button>
</div>
<template x-if="privatePages.length === 0">
@@ -793,7 +793,7 @@ document.addEventListener('alpine:init', () => {
<div style="font-weight:500;" x-text="pp.title"></div>
<div style="font-size:12px;color:var(--text-dim);" x-text="pp.updated_at ? new Date(pp.updated_at+'Z').toLocaleString() : ''"></div>
</div>
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">🗑</button>
<button class="btn btn-danger" style="padding:4px 8px;font-size:11px;" @click.stop="deletePrivate(pp.id)">{{ fd_icon('trash',14) }}</button>
</div>
</template>
</div>
@@ -805,7 +805,7 @@ document.addEventListener('alpine:init', () => {
<div class="gw-file-toolbar">
<strong>Editing: <span x-text="editingPrivateTitle || 'Untitled'"></span></strong>
<span style="flex:1;"></span>
<button class="btn" @click="savePrivate()">💾 Save</button>
<button class="btn" @click="savePrivate()">{{ fd_icon("save",14) }} Save</button>
<button class="btn" @click="editingPrivate=null;editingPrivateTitle='';editingPrivateContent='';">Cancel</button>
</div>
<div class="gw-content">
+9 -8
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% from '_icons.html' import fd_icon %}
<title>FlowDeck — All-in-one workspace</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<style>
@@ -142,7 +143,7 @@
<nav class="landing-nav">
<a href="/" class="landing-logo">
<span>📚</span> FlowDeck
<span>{{ fd_icon("book",20) }}</span> FlowDeck
</a>
<div class="landing-nav-actions">
<a href="/auth/login?provider=local" class="btn btn-secondary">Log in</a>
@@ -158,39 +159,39 @@
All on your own server.
</p>
<div class="hero-actions">
<a href="/auth/register" class="btn btn-primary">🚀 Get started — it's free</a>
<a href="/auth/register" class="btn btn-primary">Get started — it's free</a>
<a href="/auth/login?provider=local" class="btn btn-secondary">I already have an account</a>
</div>
</section>
<div class="features">
<div class="feature-card">
<div class="feature-icon">📝</div>
<div class="feature-icon">{{ fd_icon("edit",24) }}</div>
<h3>Block Editor</h3>
<p>Notion-style editor with slash commands, markdown shortcuts, headings, code blocks, to-dos, and more.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📊</div>
<div class="feature-icon">{{ fd_icon("bar-chart",24) }}</div>
<h3>Kanban & Tables</h3>
<p>Visual project management with drag-drop boards, table views, and database collections.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🦎</div>
<div class="feature-icon">{{ fd_icon("link",24) }}</div>
<h3>Gitea & GitHub</h3>
<p>Browse repos, edit files, commit changes — all from your workspace sidebar.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🌐</div>
<div class="feature-icon">{{ fd_icon("globe",24) }}</div>
<h3>Publish to Web</h3>
<p>One-click publish any page to a public URL. Share with anyone, no account needed.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🔒</div>
<div class="feature-icon">{{ fd_icon("lock",24) }}</div>
<h3>Self-Hosted</h3>
<p>Your data stays on your server. No vendor lock-in, full control, Docker single-container deploy.</p>
</div>
<div class="feature-card">
<div class="feature-icon">⚡</div>
<div class="feature-icon">{{ fd_icon("zap",24) }}</div>
<h3>Fast & Light</h3>
<p>Alpine.js + FastAPI + SQLite. No React bloat, no Node.js needed. Sub-100ms page loads.</p>
</div>
+282 -106
View File
@@ -1,10 +1,10 @@
{% extends "base.html" %}
{% block page_icon %}📚{% endblock %}
{% block page_icon %}{{ fd_icon("book",18) }}{% endblock %}
{% block page_title %}Library{% endblock %}
{% block title_prefix %}Library{% endblock %}
{% block topbar %}
{% set page_icon = "📚" %}
{% set page_icon = "book" %}
{% set page_title = "Library" %}
{% include '_header.html' %}
{% endblock %}
@@ -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;}
@@ -186,7 +199,7 @@
<div class="lib-header">
<h1>Library</h1>
<div class="lib-header-actions">
<button class="lib-btn-primary" onclick="window.FlowDeck.createPage()">
<button class="lib-btn-primary" x-show="!noWorkspace" onclick="window.FlowDeck.createPage()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New page
</button>
@@ -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>
@@ -302,14 +340,21 @@
</div>
<!-- ── Loading ── -->
<div class="lib-loading" id="lib-loading">
<p>Loading…</p>
<div class="lib-loading" id="lib-loading" x-show="loading">
<p x-show="!noWorkspace">Loading…</p>
<div x-show="noWorkspace" style="padding:40px;text-align:center;">
<div style="margin-bottom:16px;">{{ fd_icon("folder",32) }}</div>
<h3 style="font-size:18px;margin:0 0 8px;">No workspace open</h3>
<p style="color:var(--text-dim);font-size:14px;margin:0 0 20px;">Open a workspace to browse and manage your library items.</p>
<a href="/workspaces" class="btn btn-primary" style="display:inline-block;text-decoration:none;padding:8px 20px;">Go to Workspaces</a>
</div>
</div>
<!-- ── Table ── -->
<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>
@@ -325,7 +370,7 @@
</div>
<!-- "+ Add new" (root level) -->
<div class="add-new-row" x-show="!loading && tab !== 'favorites' && tab !== 'shared' && tab !== 'published' && tab !== 'repository'"
<div class="add-new-row" x-show="!loading && !noWorkspace && tab !== 'favorites' && tab !== 'shared' && tab !== 'published' && tab !== 'repository'"
onclick="window.FlowDeck.createPage()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add new
@@ -333,53 +378,13 @@
<!-- ── Empty state ── -->
<div class="lib-empty" id="lib-empty">
<div class="empty-icon" x-text="emptyIcon"></div>
<div class="empty-icon" x-html="getSvgIcon(emptyIcon,48)"></div>
<h3 x-text="emptyTitle"></h3>
<p x-text="emptyText"></p>
</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>
@@ -389,10 +394,10 @@
<input type="text" placeholder="Search pages..." x-model="moveSearchQuery" @input="doMoveSearch()">
</div>
<div class="move-modal-list">
<div class="move-modal-item" @click="confirmMove(0)">📄 <span>Root (no parent)</span></div>
<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-text="it.icon || '📄'"></span> <span x-text="it.title"></span>
<span x-html="_renderIcon(it)"></span> <span x-text="it.title"></span>
</div>
</template>
</div>
@@ -401,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">
@@ -408,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-text="peekItem.icon || '📄'"></span>
<span x-html="_renderIcon(peekItem)"></span>
<span x-text="peekItem.title"></span>
</div>
<button @click="openItem(peekItem)" title="Open full page">
@@ -439,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="font-size:48px;margin-bottom:8px;">📄</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>
@@ -455,7 +456,7 @@
{% endblock %}
{% block scripts %}
<script>
<script data-cfasync="false">
function libraryPage() {
return {
tab: 'recents',
@@ -483,24 +484,27 @@ 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: [],
moveSearchQuery: '',
// No workspace guard
noWorkspace: false,
moveCandidates: [],
_moveAllCandidates: [],
@@ -574,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">' + (item.icon || '\ud83d\udcc4') + '</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">';
@@ -608,6 +617,16 @@ 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(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() {
var result = [];
@@ -618,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;
@@ -680,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) ──
@@ -773,13 +819,13 @@ function libraryPage() {
},
// ── Empty states ──
emptyIcon: '📄',
emptyIcon: 'file',
emptyTitle: 'No items',
emptyText: '',
_recomputeEmpty() {
var icons = {recents:'🕒',favorites:'⭐',shared:'👥',published:'🌐',private:'🔒',workspace:'📁',repository:'📦'};
this.emptyIcon = icons[this.tab] || '📄';
var icons = {recents:'clock',favorites:'star',shared:'users',published:'globe',private:'lock',workspace:'folder',repository:'folder'};
this.emptyIcon = icons[this.tab] || 'file';
var titles = {recents:'No recent pages',favorites:'No favorites',shared:'No shared pages',
published:'No published pages',private:'No private pages',workspace:'No workspace pages',
repository:'Empty repository'};
@@ -804,6 +850,12 @@ function libraryPage() {
this.isGiteaActive = (giteaEl ? giteaEl.textContent : '0') === '1';
this.giteaOwner = (document.getElementById('lib-gitea-owner') || {}).textContent || '';
this.giteaRepo = (document.getElementById('lib-gitea-repo') || {}).textContent || '';
// Detect no workspace: no local wsId AND no Gitea active
if (!this.workspaceId && !this.isGiteaActive) {
this.noWorkspace = true;
this.loading = false; // don't show loading spinner
return;
}
console.log('[Library] workspaceId=' + this.workspaceId + ' isGitea=' + this.isGiteaActive);
var params = new URLSearchParams(window.location.search);
var t = params.get('tab');
@@ -811,6 +863,7 @@ function libraryPage() {
this.tab = t;
}
this._recomputeEmpty();
this.loadWorkspaceTags();
console.log('[Library] loading tab: ' + this.tab);
this.loadTab();
},
@@ -819,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;
@@ -857,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
@@ -881,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;
@@ -1031,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();
@@ -1180,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() {
@@ -1281,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
+1 -1
View File
@@ -15,7 +15,7 @@
<form hx-post="/notes/{{ owner }}/{{ repo }}" hx-target=".notes-editor" hx-swap="outerHTML">
<textarea name="content" class="notes-textarea" placeholder="Notes markdown pour ce projet...">{{ content }}</textarea>
<div class="notes-actions">
<button type="submit" class="btn btn-primary">💾 Sauvegarder</button>
<button type="submit" class="btn btn-primary">{{ fd_icon("save",14) }} Sauvegarder</button>
</div>
</form>
+8 -929
View File
@@ -1,935 +1,14 @@
{% extends "base.html" %} {% block page_icon %}{% if page.content_format == 'file' %}📎{% else %}📄{% endif %}{% endblock %} {% block
{% extends "base.html" %} {% block page_icon %}{% if page.content_format == 'file' %}{{ fd_icon("paperclip",14) }}{% else %}{{ fd_icon("file",14) }}{% endif %}{% endblock %} {% block
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% set page_icon = "📄" if page.content_format != 'file' else "📎" %}
{% 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()">🔒 Share ▾</button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">🔗</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-text="(window.E && window.E.favorited) ? \'⭐\' : \'☆\'"></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>🔒 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>🌐 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">
🔗 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">
🌐 {{ 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>🔗 Link expires</span>
<span style="color: var(--text-dim)"
>Never ▾</span
>
</div>
<div class="sd-setting-row">
<span>🔍 Search engine indexing</span>
<span style="color: var(--text-dim)"
>Off ▾</span
>
</div>
<div class="sd-setting-row">
<span>📄 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>✏️ 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>💬 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">
📋 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">
🗑️ 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>📁</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' %}📎{% else %}📄{% 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()">🔗</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">✨</span> Ask AI
</button>
<button
class="gs-pill"
@click="replaceBlock(0, 'heading_1');blocks[0].content='AI Meeting Note';render()"
>
<span class="gs-icon">📝</span> AI meeting note
</button>
<button
class="gs-pill"
@click="replaceBlock(0, 'paragraph');blocks[0].content='Database';render()"
>
<span class="gs-icon">🗄️</span> Database
</button>
<button
class="gs-pill"
@click="replaceBlock(0, 'paragraph');blocks[0].content='Form';render()"
>
<span class="gs-icon">📋</span> Form
</button>
<button
class="gs-pill"
@click="replaceBlock(0, 'paragraph');blocks[0].content='Templates';render()"
>
<span class="gs-icon">📑</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">
📊 Table
</div>
<div class="gs-more-item" @click="gsMoreOpen=false">
📋 Board
</div>
<div class="gs-more-item" @click="gsMoreOpen=false">
📝 List
</div>
<div class="gs-more-item" @click="gsMoreOpen=false">
📅 Timeline
</div>
<div class="gs-more-item" @click="gsMoreOpen=false">
🗓️ Calendar
</div>
<div class="gs-more-item" @click="gsMoreOpen=false">
🖼️ Gallery
</div>
<div class="gs-more-sep"></div>
<div class="gs-more-item" @click="gsMoreOpen=false">
📥 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:'💡'},{id:'divider',name:'Divider',icon:'—'},{id:'code',name:'Code block',icon:'<>'},
]},{name:'MEDIA',items:[{id:'image',name:'Image',icon:'🖼'}]}];
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;"></iframe></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;">📎</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||'💡'}</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 %}
+73 -3
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,19 +125,40 @@
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>
<header class="pub-header">
<a href="/" class="pub-brand">
<span>📚</span> FlowDeck
<span>{{ fd_icon("book",20) }}</span> FlowDeck
</a>
<span class="pub-badge">🌐 Published</span>
<span class="pub-badge">{{ fd_icon("globe",14) }} Published</span>
</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>

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