Compare commits

...
Author SHA1 Message Date
bruno f1ce34a8a6 fix: lire la version du log de demarrage depuis VERSION
FlowDeck CI / lint (push) Successful in 1m7s
FlowDeck CI / test (push) Successful in 7m7s
FlowDeck CI / docker (push) Successful in 1m1s
2026-09-18 13:15:46 -04:00
bruno b5207216f1 feat: v6.0.0 PWA offline support
- manifest + icones, service worker (precache, network-first, Background Sync)

- module client FlowOffline (IndexedDB, queue, delta, flush) + hook editeur

- endpoints /api/v2/sync/{delta,batch,status} + moteur de sync (conflits LWW/orpheline/copie offline)

- migrations offline_sync_queue + sync_version (triggers)

- UI offline (banner, badge sync, toasts, icone dirty) + doc /help

- tests pytest (sync, migrations, SW, offline) + E2E Playwright; bump 6.0.0
2026-09-18 13:05:40 -04:00
bruno 62620ef884 docs: marquer v5.14.0 Synced blocks comme COMPLETÉ
FlowDeck CI / lint (push) Successful in 1m5s
FlowDeck CI / test (push) Successful in 6m25s
FlowDeck CI / docker (push) Successful in 59s
2026-09-18 07:41:40 -04:00
bruno b0cb3a3923 fix: corriger erreurs Ruff lint I001 et W292
FlowDeck CI / lint (push) Successful in 1m4s
FlowDeck CI / test (push) Successful in 6m28s
FlowDeck CI / docker (push) Successful in 58s
2026-09-17 20:38:37 -04:00
bruno e6afa004d0 fix: update FastAPI version to 5.14.0
FlowDeck CI / lint (push) Failing after 58s
FlowDeck CI / test (push) Successful in 6m24s
FlowDeck CI / docker (push) Successful in 58s
2026-09-17 20:13:05 -04:00
brunoandFlowDeck bf3d1ac0cc feat: v5.14.0 Synced blocks - block created once, edited everywhere
FlowDeck CI / lint (push) Failing after 57s
FlowDeck CI / test (push) Successful in 6m34s
FlowDeck CI / docker (push) Successful in 59s
- Table synced_blocks (source of truth) + page_synced_blocks (references)
- Migration 15 + service app/services/synced_blocks.py
- API endpoints: CRUD synced blocks, add/remove page references
- Editor: /synced slash command, synced block rendering with badge
- Realtime: _propagate_synced broadcasts updates to all referencing rooms
- Export: synced blocks resolved in Markdown/HTML/PDF
- 18 tests in tests/test_v514_synced_blocks.py

Co-authored-by: FlowDeck <[email protected]>
2026-09-17 20:08:09 -04:00
bruno f9da57c9e0 fix(agent): remonter le corps de reponse des erreurs HTTP LLM
FlowDeck CI / lint (push) Successful in 1m1s
FlowDeck CI / test (push) Successful in 5m51s
FlowDeck CI / docker (push) Successful in 47s
Un 4xx (ex. 403 Mistral) affichait seulement 'Client error 403 Forbidden'. Le message d'erreur inclut desormais le corps renvoye par le fournisseur (modele non autorise, region bloquee, etc.) pour le test de connexion et la recuperation des modeles.
2026-09-14 23:21:21 -04:00
bruno 88e4ae1db8 fix(agent): purge les api_base LLM obsoletes (Mistral/Cohere /v2)
FlowDeck CI / lint (push) Successful in 53s
FlowDeck CI / test (push) Successful in 5m46s
FlowDeck CI / docker (push) Successful in 48s
Un api_base stocke (ex. https://api.mistral.ai/v2) ecrasait l'URL par defaut corrigee et faisait echouer le test de connexion. Desormais: normalisation a l'ecriture (une base egale au defaut n'est pas stockee), possibilite de vider le champ (api_base='' vs None), et migration 14 qui efface les bases obsoletes/redondantes dans user_llm_keys et llm_config.

Tests de non-regression ajoutes.
2026-09-14 23:04:39 -04:00
bruno a0db4d6e65 fix(agent): URLs OpenAI-compatibles validees pour les fournisseurs LLM
FlowDeck CI / lint (push) Successful in 57s
FlowDeck CI / test (push) Successful in 6m5s
FlowDeck CI / docker (push) Successful in 48s
Cohere -> /compatibility/v1, Google Gemini -> /v1beta/openai, Perplexity -> host racine, Chutes -> llm.chutes.ai/v1, SenseNova -> compatible-mode/v1. Mise a jour des modeles par defaut (xAI grok-4.6, Fireworks deepseek-v4-pro-0813, Cohere command-a-plus).

Retrait de LTX Studio (aucune API chat-completions) et MemTensor/MemOS (API memoire non OpenAI-compatible, auth Token + /chat). Google utilise desormais l'auth Bearer pour lister les modeles. Test de non-regression ajoute.
2026-09-14 22:50:36 -04:00
bruno fb14c7e709 feat(agent): 24 fournisseurs LLM + refonte du panneau Agent & IA
FlowDeck CI / lint (push) Successful in 53s
FlowDeck CI / test (push) Successful in 5m47s
FlowDeck CI / docker (push) Successful in 48s
Enregistre OpenAI, Anthropic, Mistral, Cohere, Google Gemini, Groq, DeepSeek, OpenRouter, NVIDIA NIM, Together, Perplexity, xAI, DashScope, MiniMax, Morph, Fireworks, Cerebras, SambaNova, Chutes, Xiaomi, LTX, SEA-LION, SenseNova et MemTensor (URLs de base + presets de modeles + libelles).

Settings: panneau Agent & IA repense en maitre/detail (liste rechercheable + volet de configuration) pour tenir avec des dizaines de fournisseurs, en conservant tous les boutons/fonctions.
2026-09-14 22:25:57 -04:00
bruno 0d475c3d2d fix(workspace): compteurs de tags dynamiques + counts scoped par workspace
FlowDeck CI / lint (push) Successful in 53s
FlowDeck CI / test (push) Successful in 5m47s
FlowDeck CI / docker (push) Successful in 48s
2026-09-14 20:26:27 -04:00
bruno 98af112ba1 fix(workspace): tags list inclut tous les tags utilisateur (count 0 workspace) pour menu contextuel
FlowDeck CI / lint (push) Successful in 55s
FlowDeck CI / test (push) Successful in 5m52s
FlowDeck CI / docker (push) Successful in 47s
2026-09-14 19:51:21 -04:00
bruno 7096707b3e fix(workspace): tags count update dynamique via /api/local-workspace/tags
FlowDeck CI / lint (push) Successful in 53s
FlowDeck CI / test (push) Successful in 5m46s
FlowDeck CI / docker (push) Successful in 49s
2026-09-14 19:33:54 -04:00
bruno 9ab47d8113 fix(workspace): menu contextuel après navigation partielle + sync sidebar/header au rename
FlowDeck CI / lint (push) Successful in 52s
FlowDeck CI / test (push) Failing after 3h14m19s
FlowDeck CI / docker (push) Skipped
2026-09-14 18:20:43 -04:00
bruno 41c1d315d3 feat(workspace,editor): creation dans le dossier courant + sync live du titre (sidebar/header)
FlowDeck CI / lint (push) Successful in 54s
FlowDeck CI / test (push) Successful in 5m50s
FlowDeck CI / docker (push) Successful in 47s
- creation fichier/dossier a la racine du dossier courant ou d'un dossier cible (context-menu, boutons de survol) via createPage/showCreateFolderModal(parentId)
- instances de modeles en tant qu'enfant d'un dossier (parent_id) ; nom vide -> 'Untitled'
- sidebar de la librairie rafraichi apres delete/move/duplicate/rename (_syncSidebar)
- editeur: le titre se synchronise en direct dans le sidebar, le breadcrumb et l'onglet (pages et fichiers) et persiste via PUT /board/api/pages/{id}
2026-09-14 17:05:03 -04:00
bruno 4038e9bdad fix(editor): repair block identity for template pages (duplicate/reordered lines)
FlowDeck CI / lint (push) Successful in 52s
FlowDeck CI / test (push) Successful in 5m46s
FlowDeck CI / docker (push) Successful in 47s
Template-created pages (weekly report, project doc, meeting notes, to-do
list, ...) were persisted without block ids. The editor assigned ids
client-side, but the realtime room loaded the raw id-less content and sent
it back on 'sync'; applySync then merged id-less server blocks with local
blocks, producing data-bid='undefined' collisions and duplicated/shuffled
lines as soon as the user edited. Editing an empty page was unaffected
because the server state was empty.

Fixes:
- board.use_page_template: materialize unique block ids (recursively) when
  instantiating built-in or user templates.
- realtime_server: unique block_id() (uuid) + ensure_block_ids() on room
  load and on insert ops.
- editor: recursive ensureBlockIds() in init; gtTok() now restores
  [[fddate:...]] tokens (date chips survived as labels before).
- realtime client: applySync() normalizes ids, no longer appends unknown
  local blocks (duplication), and keeps local content when server is empty.

Tests: pytest (templates + realtime) and Playwright e2e covering to-do
list, weekly report date chip, Enter ordering and legacy id-less repair.
2026-09-14 11:45:44 -04:00
bruno 334a937507 fix(templates): persist workspace context so template pages actually save
FlowDeck CI / lint (push) Successful in 51s
FlowDeck CI / test (push) Successful in 5m46s
FlowDeck CI / docker (push) Successful in 46s
Pages created via the page-template picker (weekly report, project doc,
meeting notes, ...) previously got workspace_id=NULL and workspace=login,
so they never appeared in the active local/Gitea workspace tree — they
looked like they 'didn't save' even though the row existed.

Frontend: _useTemplate now sends the active workspace context (workspace_id
or workspace key), mirroring _createPlainPage.
Backend: use_page_template resolves/validates the workspace and persists
both workspace and workspace_id on the new page.
2026-09-14 11:03:21 -04:00
bruno c7d4fd901f feat(e2e): ajouter tests Playwright flux utilisateur core (login, workspace, palette, dashboard)
FlowDeck CI / lint (push) Successful in 51s
FlowDeck CI / test (push) Successful in 5m55s
FlowDeck CI / docker (push) Successful in 47s
- e2e/flowdeck_e2e_final.spec.js: 4 tests E2E validés
  1. Login UI local (#email, #password, .btn-primary)
  2. Créer workspace local + entrer (modal .dialog-input)
  3. Ouvrir palette Ctrl+K et valider 'Créer une collection'
  4. Dashboard accessible
- e2e/ : package.json, playwright.config.js, install chromium
2026-09-14 09:53:31 -04:00
bruno 7794a03934 fix(tests): rendre la suite hermétique — pin oauth_redirect_uri + FLOWDECK_DATA_DIR temporel
FlowDeck CI / lint (push) Successful in 55s
FlowDeck CI / test (push) Successful in 6m7s
FlowDeck CI / docker (push) Successful in 44s
- test_get_redirect_uri_from_host_header: épingle oauth_redirect_uri à vide
  pour tester la dérivation Host de façon isolée (le .env du projet définit
  OAUTH_REDIRECT_URI, qui passe prioritaire par design)
- conftest: pose FLOWDECK_DATA_DIR vers un répertoire temporel inscriptible
  pour les tests emoji/docx/covers qui dépendaient de /data (conteneur)
- ajoute scripts/audit_functional.py: audit de bout-en-bout des processus
  (notes, DB, tâches, partage, publication, export, recherche, agents)

Suite locale: 538 passed
2026-09-14 07:45:33 -04:00
bruno 9dfc38706c feat(wiki,templates): v5.11.0 wiki-links & mentions + v5.12.0 templates & page lock (release 5.12.0)
FlowDeck CI / lint (push) Successful in 50s
FlowDeck CI / test (push) Successful in 5m41s
FlowDeck CI / docker (push) Successful in 45s
v5.11.0 Wiki-links & mentions de page :
- tokens [[fdpage:ID]] / [[fddate:ISO]] dans le texte des blocs,
  service app/services/wiki_links.py (labels, rendu HTML, extraction)
- taper [[ ouvre le picker de pages (recherche floue, clavier) ;
  le menu @ gagne les sections Pages et Date (today/tomorrow/YYYY-MM-DD)
- chips atomiques contenteditable=false relues en tokens par gtTok()
  (autosave/drag/undo preservent les liens) ; renommage propage via
  GET /board/api/wiki/titles ; backlinks reconnaissent les tokens ;
  page publique rend les chips (echopee)

v5.12.0 Templates & verrouillage :
- template picker global sur + New page : 5 built-in
  (app/services/block_templates.py) + templates perso
  (table page_global_templates, migration 13)
- POST /board/api/page-templates (save current page) + /{id}/use
  (instantiate, id 0 = built-in par cle)
- page lock : POST /api/pages/{id}/lock, garde _ensure_page_editable
  -> 423 en ecriture pour les non-privileged, deblocage par
  locked_by ou admin seulement (403 sinon), banniere + read-only UI
- full-width / small text par page (pages.full_width/font_small,
  POST /api/pages/{id}/options, classes CSS)
- migration 13 : is_locked, locked_by, full_width, font_small,
  page_global_templates

Tests : tests/test_v511_v512_wiki_templates.py (15) ; suite complete
538 verte ; ruff OK ; node --check des templates JS OK.
2026-09-14 06:38:09 -04:00
bruno d4adf89db5 feat(calendar): v5.8.0 calendrier & rappels + v5.7.0 database avancee (pt.2)
FlowDeck CI / lint (push) Successful in 49s
FlowDeck CI / test (push) Successful in 5m26s
FlowDeck CI / docker (push) Successful in 43s
v5.8.0 (release 5.11.7) — Calendrier & Rappels :
- moteur de recurrence RRULE subset (daily/weekly/monthly, interval,
  count, until, byweekday, timezone) — app/services/recurrence.py
- vues calendar Jour / Semaine / Mois avec expansion des occurrences
  cote serveur (GET /db/{id}/calendar/api) et popover evenement
  (Time / Timezone / Repeat / Remind)
- rappels avant echeance (scan 60 s, table reminder_log, in-app +
  email, cible = personnes assignees) — app/services/reminders.py
- fuseaux horaires : users.timezone + reglages in-app, timezone par
  evenement, liste de zones (GET /db/timezones/api)
- notifications d'assignation sur PUT /db/pages/{id}/api et
  preferences etendues (reminders, assignments)
- template Meeting notes enrichi (Agenda, Notes — migration 12)
- migrations 11-12 ; 20 tests dedies ; suite complete 523 verte

v5.7.0 (release 5.11.6, termine avant cette session, reste dans
l'arbre sans commit) — Database Avancée Pt.2 :
- proprietes person + auto-proprietes (created/last-edited time & by)
- groupes de proprietes, vues sauvegardees par utilisateur
- swimlanes, WIP limits, cartes configurables, calendar drag & drop,
  gallery couvertures ; 12 tests dedies
2026-09-13 22:49:14 -04:00
bruno 055956a351 fix(palette): corriger les options de la palette de recherche inutilisables au clic (le re-rendu au survol detruisait l'element sous le curseur)
FlowDeck CI / lint (push) Successful in 48s
FlowDeck CI / test (push) Successful in 5m10s
FlowDeck CI / docker (push) Successful in 44s
2026-09-13 20:31:30 -04:00
bruno 3b3e95e23a fix(cloudflare): proteger les scripts inline des pages du Rocket Loader
FlowDeck CI / lint (push) Successful in 48s
FlowDeck CI / test (push) Successful in 5m2s
FlowDeck CI / docker (push) Successful in 42s
Rocket Loader (Cloudflare) reecrit les balises script inline en type=...-text/javascript et les execute de facon differee, ce qui casse l'enregistrement des composants Alpine et l'init des pages lors d'un acces direct via le tunnel (ex. /settings ne se chargeait pas au complet). Ajout de data-cfasync=false sur les scripts inline des pages completes et des partiels du shell (settings, accounts, trash, workspace, board, gitea_workspace, welcome, import, page_editor_collection/embed, _database_table_scripts, _notification_bell, public_page, local_workspace). Les fragments charges via HTMX restent inchanges.
2026-09-13 12:35:28 -04:00
bruno 37337a5de7 fix(settings): corriger le chargement du panneau via navigation partielle HTMX
FlowDeck CI / lint (push) Successful in 47s
FlowDeck CI / test (push) Successful in 5m0s
FlowDeck CI / docker (push) Successful in 41s
settingsInit est declare comme fonction globale au lieu d'etre enregistre dans alpine:init : l'evenement alpine:init ne se redeclenche pas lors d'un swap HTMX de .main-wrapper, ce qui laissait settingsInit non enregistre et provoquait des ReferenceError (llmSaving, llmTesting, llmDefaultProvider, llmMsgOk) au chargement des Reglages depuis un bouton du topbar. Le bloc du token API cree passe aussi de x-show a template x-if pour eviter le dereferencement de newToken null.
2026-09-13 11:50:17 -04:00
bruno e8797afa05 merge: feat/v5.6.0-import into main (v5.6.0 data import, phases 0-5)
FlowDeck CI / lint (push) Successful in 48s
FlowDeck CI / test (push) Successful in 4m55s
FlowDeck CI / docker (push) Successful in 1m8s
2026-09-13 10:43:27 -04:00
bruno 3b00cbc371 feat(import): v5.6.0 unified data import (phases 0-5)
- unified importer framework (app/services/importers/): normalized model,
  registry, common pipeline (hierarchy, attachments, collections, dedup),
  async jobs, dry-run preview, column->type mapping
- Phase 1: Obsidian, Notion, Logseq/Roam, HTML (Apple Notes/Bear/Ulysses/
  OneNote), Google Keep, generic Markdown
- Phase 2: typed CSV/TSV, Excel (openpyxl), generic JSON
- Phase 3: Word .docx (python-docx), PDF (pypdf), HTML folders
- Phase 4: Raindrop, Pocket, Readwise, Shaarli, Netscape bookmarks, .ics,
  OPML, Standard Notes, Gitea/GitHub issues (+labels/milestones)
- Phase 5: incremental re-sync (skip/update/duplicate), partial-error resume,
  forge repo files, URL web clipper (SSRF guard), batch multi-file + UI queue,
  Notion relation resolution, exportable JSON reports
- /import wizard, API /api/import/*, migration 9 (import_items, import_jobs)
- fix: property values stored by property id (correct DB view rendering)
- deps: openpyxl, beautifulsoup4, PyYAML, python-docx, pypdf
- 43 import tests; full suite 491 green; ruff clean
- bump version 5.11.5
2026-09-13 10:43:20 -04:00
bruno d986959927 fix(cloudflare): protect agent panel, workspaces & shell scripts from Rocket Loader; trust proxy headers
FlowDeck CI / lint (push) Successful in 47s
FlowDeck CI / test (push) Successful in 4m17s
FlowDeck CI / docker (push) Successful in 45s
2026-09-13 09:51:54 -04:00
bruno 714642363b style(sidebar): round floating peek corners and increase top/bottom inset
FlowDeck CI / lint (push) Successful in 45s
FlowDeck CI / test (push) Successful in 4m19s
FlowDeck CI / docker (push) Successful in 40s
2026-09-12 23:54:45 -04:00
bruno df78badd0c feat(sidebar): Notion-style floating peek card, pin toggle & drag-resize
FlowDeck CI / lint (push) Successful in 46s
FlowDeck CI / test (push) Successful in 4m24s
FlowDeck CI / docker (push) Successful in 44s
2026-09-12 23:46:17 -04:00
bruno 9836c85e24 feat(sidebar): Notion-style collapse/peek, desktop reopen button, Ctrl+\\ toggle
FlowDeck CI / lint (push) Successful in 45s
FlowDeck CI / test (push) Successful in 4m20s
FlowDeck CI / docker (push) Successful in 40s
Stop collapsing the sidebar on navigation (was hiding the nav when opening Shared pages). Show the hamburger in the top-left on desktop when collapsed, add a left-edge hover zone that floats the sidebar in and auto-collapses on mouse leave, and bind Ctrl/Cmd+\\ to toggle it.
2026-09-12 22:41:22 -04:00
bruno 61a33a7891 fix(alpine): resolve console errors on editor and agent panel
FlowDeck CI / lint (push) Successful in 45s
FlowDeck CI / test (push) Failing after 3h0m32s
FlowDeck CI / docker (push) Skipped
Initialize coverOpen/coverLoading in editorState, render import file info with x-if instead of x-show (null deref), split x-if/x-for on the agent mention menu, guard m.steps.length and use unique keys for the icon picker. Also reduce htmx swap responses to .main-wrapper so shell scripts (agent panel, base inline) are not re-executed on partial navigation.
2026-09-12 21:55:22 -04:00
bruno e707becbf8 feat(nav): partial HTMX navigation — swap main content without full reload
FlowDeck CI / lint (push) Successful in 45s
FlowDeck CI / test (push) Successful in 4m17s
FlowDeck CI / docker (push) Successful in 41s
Intercept internal links and load only .main-wrapper via htmx.ajax, keeping the sidebar/header shell in place. Adds history pushState/popstate handling, sidebar active-state resync and document title update. Moves page script blocks inside #main-content so they run on swap, and makes editor/board scripts idempotent. Routes navigateTo/breadcrumb/library/local-workspace through window.fdNavigate and fixes the duplicated navigateTo definition.
2026-09-12 21:26:56 -04:00
bruno da3e09c215 feat(icon): Notion-style icon picker + custom workspace emojis; fix side peek file content & ctx menu overflow
FlowDeck CI / lint (push) Successful in 45s
FlowDeck CI / test (push) Failing after 3h6m55s
FlowDeck CI / docker (push) Skipped
2026-09-12 20:05:53 -04:00
bruno 19e8ba0e4a fix: editor file mode — single-root x-if more menu + pasteBlocks brace + fileUrl init
FlowDeck CI / lint (push) Successful in 45s
FlowDeck CI / test (push) Successful in 4m9s
FlowDeck CI / docker (push) Successful in 38s
2026-09-12 14:54:44 -04:00
bruno 7a6c66a609 feat: add download + copy file content to context menus and editor
FlowDeck CI / lint (push) Successful in 45s
FlowDeck CI / test (push) Successful in 4m8s
FlowDeck CI / docker (push) Successful in 38s
- Add /api/pages/{id}/download and /api/pages/{id}/file-content endpoints
- Add Download + Copy content to shared context menu (_ctx_menu.html)
- Wire handlers in local-workspace and library context menus
- Add Download + Copy content to editor '...' menu for file pages
- Multi-block selection copy/cut/paste with Ctrl+C/X/V shortcuts
- Align page title with blocks start (CSS pseudo-element offset)
2026-09-12 12:53:17 -04:00
bruno 3bb17eb984 Merge branch 'feat/v5.5.0-embeds-media' into main
FlowDeck CI / lint (push) Successful in 44s
FlowDeck CI / test (push) Successful in 4m16s
FlowDeck CI / docker (push) Successful in 37s
2026-09-12 09:41:04 -04:00
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 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
326 changed files with 372139 additions and 936 deletions
+321
View File
@@ -1,5 +1,326 @@
# Changelog - FlowDeck
## v6.0.0 (2026-09-18) — PWA : Progressive Web App, offline support
> FlowDeck devient une PWA installable et utilisable hors ligne : le shell est
> mis en cache par un service worker, les pages visitées restent disponibles,
> et les modifications faites sans réseau sont mises en file d'attente dans
> IndexedDB puis synchronisées au retour de la connexion, avec résolution de
> conflits côté serveur.
### PWA & Service Worker
- **`static/manifest.json`** + **`static/icons/*`** (72→512 + maskable + apple-touch) générés par `scripts/generate_pwa_icons.py`.
- **`static/sw.js`** — precache du shell (CSS/JS/fonts/icônes), stratégies cache-first (assets) / network-first (HTML, API GET), page offline inline, Background Sync (`sync-flowdeck`), purge des anciens caches.
- **`base.html` / `landing.html`** — meta PWA (manifest, theme-color, apple-mobile-web-app) + enregistrement du service worker. Routes `GET /manifest.json` et `GET /sw.js` servies par FastAPI.
### Offline client (`static/js/offline.js`)
- Base IndexedDB `flowdeck-offline` : `pages_offline`, `collections_offline`, `sync_queue`, `sync_meta`.
- `window.FlowOffline` : `enqueue`, `flush`, `delta`, `status`, `savePageOffline`, `getQueue`, `pendingCount`, `onChange`, `markDirtySidebar`.
- Sauvegarde optimiste hors ligne (hook de l'éditeur), file de mutations rejouée automatiquement (online, Background Sync, polling 30 s).
- Durcissement : max 100 mutations/batch, timeout 30 s (`AbortController`), rétention queue 30 jours.
### Serveur de synchronisation
- **`app/services/sync_engine.py`** — `get_delta` (pull des changements, détection des soft-deletes), `apply_batch` (mutations optimistes + contrôle de version `sync_version`).
- **`app/routers/sync.py`** — `GET /api/v2/sync/delta`, `POST /api/v2/sync/batch`, `GET /api/v2/sync/status` (auth session, CSRF-exempt via `/api/v2`).
- **Migrations** — table `offline_sync_queue` (+ index) et colonnes `sync_version` sur `pages`, `collection_pages`, `collections` avec triggers `AFTER UPDATE`.
- **Conflits** — edit-edit (last-write-wins + rapport), edit-delete (page orpheline recréée), create-create (renommage « copie offline »).
### UI
- Banner hors ligne avec compteur de modifications en attente, badge de synchronisation (spinner), toasts de fin de sync, icône ⟳ sur les pages non synchronisées.
- Section `/help` « Offline mode (PWA) ».
### Tests
- `tests/test_sync.py`, `tests/test_sync_migrations.py`, `tests/test_service_worker.py`, `tests/test_pwa_offline.py` ; E2E Playwright `e2e/pwa_offline.spec.js` (manifest, SW, queue offline, replay, navigation hors ligne).
## v5.12.0 (2026-09-14) — v5.11.0 Wiki-links & v5.12.0 Templates & lock
> Deux versions roadmap livrées ensemble : le graphe de connaissances
> Notion (liens `[[`, mentions de page et de date, renommage propagé) et
> les options de page (galerie de templates globale, verrouillage
> lecture-seule, pleine largeur, texte compact).
### v5.11.0 — Wiki-links & mentions de page
- **Service** `app/services/wiki_links.py` — tokens stockés dans le texte
des blocs : `[[fdpage:ID]]` (lien interne résolu à l'affichage) et
`[[fddate:YYYY-MM-DD]]` (chip de date). Le token ne contient que l'id →
**renommage propagé** automatiquement.
- **Endpoints** — `GET /board/api/wiki/pages?q=` (picker, recherche
substring + sous-séquence floue « mnt » → « Meeting notes ») et
`GET /board/api/wiki/titles?ids=` (labels courants en batch, icônes
incluses, « Deleted page » si supprimée).
- **Éditeur** — taper `[[` ouvre le picker de pages (recherche live,
navigation clavier ↑↓/Entrée/Échap, sélection à la souris), Enter
insère une **chip de page** atomique (icône + titre, clique = navigate).
Le menu `@` existant gagne deux sections : **Pages** (`@Nom` → lien
inline) et **Date** (`@today`, `@tomorrow`, `@YYYY-MM-DD` → chips).
Les chips sont relu en tokens bruts (`gtTok`) à chaque sync : le drag,
le split Entrée, l'undo/redo et l'autosave préservent les liens.
- **Backlinks** — le scanner v5.4.0 reconnaît désormais les tokens
`[[fdpage:ID]]` : « Lié depuis » fonctionne avec les wiki-links.
- **Page publique** — `_render_blocks_public` résout les chips (liens
absolus + dates lisibles, HTML échappé).
### v5.12.0 — Templates & verrouillage de page
- **Template picker global** — « + New page » (sidebar, footer, palette
Ctrl+K) ouvre la galerie : 5 templates built-in (`app/services/
block_templates.py` : Empty, Meeting notes, Weekly report, To-do list,
Project doc) + les templates personnels sauvegardés ; « Empty » retombe
sur la création classique (workspace item).
- **Save as template** — menu « … » de la page → capture le contenu blocs
courant comme template personnel (`page_global_templates`, visibles
uniquement par leur créateur, `created_by IS NULL` = partagé).
- **Bouton « Use template »** — `POST /board/api/page-templates/{id}/use`
(id 0 = built-in par clé) duplique le contenu des blocs dans une
nouvelle page du workspace de l'utilisateur et renvoie son id.
- **Page lock** — menu « … » → « 🔒 Lock page » : bannière sticky
« locked (read-only) », blocs et titre passés `contenteditable=false`,
handles/actions masquées. Serveur : `POST /board/api/pages/{id}/lock`,
garde `_ensure_page_editable` → **423** sur `PUT /api/pages/{id}` et
`POST /api/pages/{id}/blocks` pour tout non-privileged ; déverrouillage
réservé à la personne qui a verrouillé (`locked_by`) ou un admin (403
sinon).
- **Full-width / Small text** — toggles dans le menu « … », persistés par
page (`pages.full_width`, `pages.font_small`), options `POST
/board/api/pages/{id}/options`, rendu via classes CSS
(`.full-width` / `.small-text`).
- **Migrations 13** — `pages.is_locked`, `pages.locked_by`,
`pages.full_width`, `pages.font_small`, table `page_global_templates`.
- **Tests** — `tests/test_v511_v512_wiki_templates.py` : **15 tests**
(schéma, helpers tokens + échappement, picker flou, renommage propagé,
persistance des tokens + backlinks, chips en page publiée, lock 423 /
403 / admin / ré-ouverture, options, templates built-in + perso +
cloisonnement, shapes service, câblage front). Suite complète
**538 verte**, `ruff check` OK.
- **Version** — 5.12.0.
## v5.11.7 (2026-09-13) — v5.8.0 Calendrier & Rappels
> Calendrier complet (jour / semaine / mois) avec récurrences expandues
> côté serveur, rappels avant échéance (in-app + email), fuseaux horaires
> par utilisateur et par événement, notifications d'assignation et centre
> de notifications enrichi.
- **Moteur de récurrence** — `app/services/recurrence.py` : sous-ensemble
RRULE (daily/weekly/monthly, intervalle, count, until, byweekday lundi=0,
timezone IANA) ; expansion virtuelle par fenêtre, jamais persistée,
bornée (garde anti-explosion). `validate_rule()` pour le 400 en écriture.
- **Vues Jour / Semaine / Mois** — la vue calendar de
`_database_table_scripts.html` gagne un sélecteur de mode (persisté dans
`viewConfig.calendar_mode`), navigation ‹/› adaptée au mode, vue Jour en
agenda trié par heure, Semaine en 7 colonnes. Les événements viennent de
`GET /db/{id}/calendar/api` (expansion serveur des occurrences).
- **Récurrences dans l'UI** — double-clic sur un événement : popover Time /
Timezone / Repeat (Every + Ends never|after count|on date) / Remind.
Règle stockée dans `property_values_json` sous `__recurrence__` (clé méta
par id de propriété date), badge ↻ sur les chips et cellules date.
- **Rappels** — `app/services/reminders.py` : `scan_and_fire(now)`
déterministe (lead minutes/heures/jours, occurrence suivante
timezone-aware), `reminder_scheduler()` toutes les 60 s ; dédup via la
table `reminder_log (page_id, occurrence_date)` ; cible = personnes
assignées, repli premier admin ; notifie en in-app + email (pref
`reminders`). Config `reminders_enabled` /
`reminder_scan_interval_seconds`.
- **Fuseaux horaires** — colonne `users.timezone` ; réglable dans Settings →
Notifications (picker IANA via `GET/POST /api/notifications/timezone`,
liste `GET /db/timezones/api`) ; priorité de résolution par ligne :
`__timezone__` de l'événement → règle de récurrence → timezone de
l'utilisateur.
- **Notifications d'assignation** — `notify_assignment()` compare les
propriétés `person` avant/après sur `PUT /db/pages/{id}/api` : les
nouvellement assignés reçoivent une notif in-app + email (pref
`assignments`). Préférences étendues : `comments`, `mentions`,
`reminders`, `assignments`.
- **Template Meeting notes** — propriétés `Agenda` et `Notes` ajoutées
(migration 12 ; ne touche pas une version personnalisée du template).
- **Migrations 11 & 12** — `reminder_log`, `users.timezone`, enrichissement
du template seed.
- **Tests** — `tests/test_v58_calendar_reminders.py` : **20 tests**
(moteur pur, schéma, validation des méta-clés 400, calendar/api,
timezone user, scanner avec horloge injectée + dédup + récurrence +
cible assignée, notifications d'assignation, prefs, câblage front).
Suite complète **523 verte**, `ruff check` OK.
- **Version** — 5.11.7.
## v5.11.6 (2026-09-13) — v5.7.0 Database Avancée (Pt. 2)
> Complète la parité Notion des bases de données : personnes et propriétés
> automatiques, groupes de propriétés, vues sauvegardées par utilisateur,
> swimlanes Kanban + WIP limits, cartes configurables, calendar avec
> glisser-déposer et gallery avec couvertures.
- **Types propriété** — `person`, `created_time`, `created_by`,
`last_edited_time`, `last_edited_by` câblés de bout en bout.
`apply_auto_properties()` (property_types) remplit/rafraîchit les valeurs
automatiques à la création et à la mise à jour des lignes ; `created_*` est
figé, `last_edited_*` est rafraîchi. Nouvel endpoint
`GET /db/{collection_id}/members/api` (membres du workspace, repli tous
utilisateurs) alimente le sélecteur `person` (chips + avatars).
- **Groupes de propriétés** — colonne `collection_properties.group_name`,
en-tête de table avec sections pliables (`db-group-row` + chevron) et
`POST /db/{collection_id}/property-groups/api` pour réassigner les colonnes.
- **Vues sauvegardées par utilisateur** — `collection_views.created_by` ;
`GET /db/{id}/views/api` ne renvoie que les vues de l'utilisateur (les vues
partagées `NULL` restent visibles), `save-as`/`duplicate` attribuent le
propriétaire, `DELETE /db/views/{id}/api` et renommage.
- **Swimlanes Kanban** — `sub_group_by` : une rangée de colonnes par lane
(2e dimension de groupement).
- **WIP limits** — `wip_limits` par colonne, colonne surlignée + compteur
`WIP n/limit` au dépassement.
- **Cartes configurables** — `card_properties` (propriétés affichées),
`card_size` (compact/détaillé) et couverture `cover_mode`
(`none`/`icon`/`color`/`property` via `cover_property`).
- **Calendar drag & drop** — vue mois (`date_property` configurable),
navigation ‹/›/Today, glisser une carte sur un jour = reschedule immédiat.
- **Gallery avec couvertures** — vignettes image (propriété `files`/`url`),
icône, couleur ou cover de ligne ; tailles small/medium/large.
- **Composant multi-vues** — `_database_table_scripts.html` réécrit :
barre de vues (ajout/suppression/duplication/paramètres), vues
table/board/calendar/gallery/list, éditeurs de cellules par type
(select/status/date/number/checkbox/multi_select/person), cellules
read-only pour les types automatiques, popover de configuration.
- **Correctif** — `PUT /db/pages/{id}/api` fusionne désormais les valeurs
soumises avec l'existant (PATCH partiel) au lieu de remplacer toute la
ligne ; `cover_url` par ligne (`collection_pages.cover_url`).
- **Migration 10** — `collection_properties.group_name`,
`collection_views.created_by`/`updated_at`, `collection_pages.cover_url`.
- **Tests** — `tests/test_v57_db_advanced.py` : **12 tests**. Suite complète
**503 verte**, `ruff check` OK.
- **Version** — 5.11.6.
## v5.11.5 (2026-09-13) — v5.6.0 Import de données (Phase 5 — durcissement)
> Finalise la solution d'import : ré-import incrémental, lots, URL, dépôt forge,
> relations Notion et rapports exportables. **v5.6.0 est complet (Phases 0→5).**
- **Import incrémental / re-sync** — nouveau paramètre `mode` (`skip` par défaut,
`update` = mise à jour des pages et upsert des lignes par titre, `duplicate`).
Compteurs `pages_updated` / `rows_updated` dans le rapport.
- **Erreurs partielles** — l'import continue page par page en cas d'échec
(`status: "partial"`, liste `errors`), au lieu d'abandonner.
- **Import de dépôt forge** — `POST /api/import/forge-repo` : arborescence
Gitea/GitHub → pages (dossiers conservés, fichiers texte, code en blocs ;
`GitHubAdapter.list_repo_files`, `GiteaForgeAdapter.list_repo_files`).
- **Web clipper** — `POST /api/import/url` : fetch d'une page (garde SSRF :
loopback/privé/link-local refusés), conversion HTML → blocs + carte bookmark
(OG metadata).
- **Lot multi-fichiers** — `POST /api/import/run-batch` (rapport par fichier) et
file d'attente dans l'assistant `/import` (sélection multiple, statut par
fichier, barre de progression).
- **Relations Notion** — `POST /api/import/relations/resolve` : les colonnes
texte qui référencent les titres d'une autre collection deviennent des
propriétés `relation` (valeurs = ids de `collection_pages`). Auto-exécuté
après un import Notion (clé `relations` du rapport).
- **Rapport exportable** — `GET /api/import/jobs/{id}/report` (JSON) + bouton
« Télécharger le rapport » dans l'UI.
- **Correctif** — les lignes importées stockent désormais leurs valeurs par **id
de propriété** (au lieu du nom) : rendu correct dans les vues database et
compatibilité avec les relations/rollups.
- **UI** — assistant `/import` enrichi : multi-fichiers, mode de ré-import,
import URL, import forge (issues **et** fichiers), téléchargement du rapport.
- **Tests** — `tests/test_v56_import.py` porté à **43 tests** (incrémental ×4,
lot, URL ×2, forge-repo ×2, relations, rapport de job). Suite complète
**491 verte**, `ruff check` OK.
- **Version** — 5.11.5.
## v5.11.4 (2026-09-13) — v5.6.0 Import de données (Phases 3 & 4)
> Complète le chantier v5.6.0 : documents/bureautique (Phase 3) et
> signets/dev/divers (Phase 4). **v5.6.0 est désormais complet** (5 phases).
- **Phase 3 — Documents & bureautique**
- **Word `.docx`** (`python-docx`) — titres, listes, citations, tableaux GFM
et images inline → page ; couvre aussi les exports Google Docs Takeout.
- **PDF** (`pypdf`) — extraction texte par page + images → page ; avertissement
si pages sans couche texte (scan).
- **HTML fichiers/dossier** — détection élargie (archive contenant ≥ 1 HTML),
assets collectés comme pièces jointes.
- **Phase 4 — Signets, dev & divers**
- **Forge issues** — `POST /api/import/forge` (Gitea/GitHub) : issues → collection
(Number, State, Labels, Milestone, Assignee, dates, URL, Body) + collections
Labels et Milestones. `GitHubAdapter.list_issues/list_labels/list_milestones`
ajoutés ; token par utilisateur (`user_oauth_tokens`).
- **Signets** — Raindrop.io, Pocket (CSV/HTML), Readwise (highlights CSV/MD),
Shaarli (JSON) et signets Netscape HTML → collection (URL, description,
tags, date).
- **Calendrier `.ics`** — parseur RFC 5545 (VEVENT) → collection d'événements.
- **OPML** — flux RSS/outlines → collection (URL, dossier).
- **Standard Notes** — sauvegarde JSON → pages (notes chiffrées ignorées).
- **UI** — l'assistant `/import` liste désormais les 20 sources, accepte
`.docx/.pdf/.ics/.opml` et propose un panneau « import depuis une forge ».
- **Dépendances** — `python-docx`, `pypdf` ajoutés.
- **Tests** — `tests/test_v56_import.py` étendu à **32 tests** (signets ×5,
ics/opml/standard notes, docx, pdf, forge ×4). Suite complète verte,
`ruff check` OK.
- **Version** — 5.11.4.
## v5.11.3 (2026-09-13) — v5.6.0 Import de données (Phases 0, 1, 2)
> Démarre le chantier v5.6.0 « Import de données » avec le **socle unifié**
> (Phase 0) et les deux premières familles de sources : **Notes & Markdown**
> (Phase 1) et **Données & tableaux** (Phase 2).
- **Socle d'import unifié (Phase 0)** — package `app/services/importers/` :
modèle normalisé (`ImportResult`, `ImportPage`, `ImportAttachment`), interface
`Importer` (`detect()` / `parse()`) + registre auto-détection, pipeline commun
(hiérarchie `parent_id`, pièces jointes, réécriture des liens, collections +
lignes, rapport d'import), jobs d'arrière-plan (`app/services/importers/jobs.py`,
polling), déduplication idempotente (table `import_items`), migration 9
(`import_items` + `import_jobs`).
- **Assistant UI d'import** — page `/import` (template `import.html`) : choix de
source ou détection auto, glisser-déposer, **aperçu dry-run**, **mapping
colonnes → types** (override des types inférés), barre de progression, rapport.
Lien « Assistant d'import avancé » ajouté à la modale Import de l'éditeur.
- **API** — `GET /api/import/sources`, `POST /api/import/preview`,
`POST /api/import/run` (sync ou `async=true`), `GET /api/import/jobs[/{id}]`.
- **Phase 1 — Notes & Markdown** : Obsidian (vault `.zip`, frontmatter YAML,
`[[wikilinks]]`, `![[embeds]]`, pièces jointes, hiérarchie de dossiers),
Notion (export `.zip`, hiérarchie complète, databases `.csv` → collections,
images), Logseq & Roam (outliner, propriétés `key:: value`, pages journal,
`{{[[TODO]]}}`), HTML (Apple Notes, Bear, Ulysses, OneNote), Google Keep
(Takeout JSON), plus l'import Markdown/`.zip` générique.
- **Phase 2 — Données & tableaux** : CSV/TSV **typé** (inférence
texte/nombre/date/checkbox/email/url/select/multi_select + mapping manuel),
Excel `.xlsx` (une collection par feuille, openpyxl), JSON générique (tableau
d'objets → collection).
- **Correctif** — `_md_to_blocks` reconnaît désormais les cases à cocher
`- [ ]`/`- [x]` (elles étaient absorbées par la branche puces à puces).
- **Dépendances** — `openpyxl`, `beautifulsoup4`, `PyYAML` ajoutés.
- **Tests** — `tests/test_v56_import.py` : 18 tests (registre, dry-run, CSV typé,
dédup, TSV, Excel multi-feuilles, JSON, Obsidian, Notion, Logseq, Roam, HTML,
Google Keep, mapping, wizard, job async). Suite complète **463+ verte**,
`ruff check` OK.
## v5.11.2 (2026-09-12) — v5.5.0 Embeds & Média riche (complétion)
> Valide et complète le chantier v5.5.0 (parité embeds Notion). Une grande
> partie était ébauchée ; ce patch répare les chemins d'API cassés côté éditeur,
> finalise la résolution d'embed, le parsing OG, la lightbox publique et couvre
> le tout de 47 tests dédiés.
- **Embed universel** — `app/services/embeds.py` réécrit : détection
sous-domaine correcte, YouTube (watch/shorts/youtu.be), Vimeo, Figma,
Google Maps, Google Docs/Sheets/Slides, Loom, CodePen, Miro, Spotify,
SoundCloud, Twitch (parent réel), X/Twitter, Pinterest, Microsoft Office ;
helpers `resolve_embed()`, `inline_kind()`, `provider()`. Nouvel endpoint
`POST /board/api/embed/resolve`.
- **Résolution d'embed dans l'éditeur** — la saisie d'une URL appelle le
résolveur et met en cache `embed_src`/`embed_provider` (l'URL d'origine est
conservée) ; le rendu (éditeur, pages publiques, exports MD/HTML/PDF) préfère
`embed_src`. Le champ est persisté via la sérialisation des blocs.
- **Bookmark cards** — `app/services/og_fetcher.py` : parseur `<meta>` robuste
(ordre d'attributs libre), titre de repli, favicon, transport injectable pour
les tests ; `fetch_og_metadata` ne lève jamais sur erreur réseau.
- **Image lightbox** — navigation clavier ←/→ et Escape, multi-images, dans
l'éditeur **et** les pages publiques `/p/<slug>` (CSS `.fd-lightbox`).
- **Fix chemins API (bug v5.4.0/v5.5.0)** — l'éditeur appelait
`/api/pages/...` alors que les routes sont `/board/api/pages/...` : cover,
icon, versions, backlinks, import, move et OG metadata étaient donc en 404.
Tous corrigés, avec un test de régression statique.
- **Tests** — `tests/test_v55.py` : 47 tests (embeds par provider, resolve API,
parse OG + MockTransport, lightbox, préviews, cover upload/URL/suppression,
icône, page publique). Suite complète **444 verte**, `ruff check` OK.
- **Version** — 5.11.2.
## v5.11.1 (2026-09-11) — v5.2.0 Infrastructure & Polish (complétion)
> Finalise le chantier v5.2.0 : la plupart des briques étaient déjà livrées
+1 -1
View File
@@ -41,4 +41,4 @@ EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/api/health || exit 1
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--proxy-headers", "--forwarded-allow-ips", "*"]
+184 -67
View File
@@ -513,50 +513,142 @@ app/
- [x] **Fallback polling** — si WS indisponible, rafraîchissement diff toutes les 10 s (adopté seulement sans brouillon local) + reconnexion automatique
- [x] CSP `connect-src` étendu à `ws:` ; **12 tests** `tests/test_realtime.py` ; suite complète 298 verte (+3 PDF pré-existants)
### v5.4.0 — Expérience éditeur (nouveautés, parité Notion)
- [ ] **Backlinks** — section « Lié depuis… » en bas de page (scan des liens internes)
- [ ] **Duplicates** — « Duplicate » sur page + collection (menu `...`)
- [ ] **Corbeille globale améliorée** — vue cross-workspace + purge automatique après 30 jours
- [ ] **Historique de version UI** — browser + restaurer une version (table `page_history` existe)
- [ ] **Import** — Markdown/CSV/Notion (complète l'export v4.7.0, facilite la migration d'utilisateurs)
### v5.4.0 — Expérience éditeur ✅ (2026-09-11)
> **Objectif** : parité éditeur Notion — backlinks, duplication, corbeille, versions, import. **COMPLETED**.
### v5.5.0 — Embeds & Média Riche
- [x] **Backlinks** — `GET /board/api/pages/{id}/backlinks` (scan des liens internes `/pages/<id>`) + popover « Lié depuis… »
- [x] **Duplicates** — `POST /board/api/pages/{id}/duplicate` (copie profonde des blocs) + `POST /db/{id}/duplicate` (vues + propriétés + pages) ; entrées « Duplicate » dans les menus `...`
- [x] **Corbeille globale améliorée** — vue cross-workspace `GET /board/api/trash` + `app/services/trash.py` (purge automatique > 30 jours, scheduler quotidien)
- [x] **Historique de version UI** — snapshots `page_versions` à chaque sauvegarde modifiée, `GET /api/pages/{id}/versions`, `POST .../versions/{vid}/restore`, popover « Version history »
- [x] **Import** — `POST /api/pages/import` (markdown) + `POST /api/pages/import/file` (.md/.txt/.zip d'export Notion) ; import CSV collections `POST /workspace/collections/{id}/import/csv`
- [x] **17 tests** `tests/test_v54.py` ; suite complète **397 verte**
### v5.5.0 — Embeds & Média Riche ✅ (2026-09-12)
> **Objectif** : parité avec les 60+ embeds Notion — contenu tiers rendu dans la page.
> **Source** : analyse Notion clone (delta v4 → v5.5, 2026-09-04).
> **Source** : analyse Notion clone (delta v4 → v5.5, 2026-09-04). **COMPLETED**.
- [ ] **Bloc Embed universel** — `/embed` : YouTube, Vimeo, Figma, Google Maps, Loom, X/Twitter, CodePen, Miro, Spotify, SoundCloud, Twitch, Office…
- [ ] **Bookmark cards** — aperçu riche des URLs (métadonnées OG : titre, image, description)
- [ ] **Image lightbox** — clic pour agrandir, navigation clavier/consultation plein écran
- [ ] **Previews inline** — PDF, vidéo, audio rendus directement dans la page
- [ ] **Cover & icône de page** — upload image de couverture + emoji/icône custom (déjà partiel ?)
- [x] **Bloc Embed universel** — `/embed` : YouTube (watch/shorts/youtu.be), Vimeo, Figma, Google Maps, Google Docs/Sheets/Slides, Loom, X/Twitter, CodePen, Miro, Spotify, SoundCloud, Twitch, Pinterest, Office…
- [x] **Bookmark cards** — aperçu riche des URLs (métadonnées OG : titre, image, description, site, favicon)
- [x] **Image lightbox** — clic pour agrandir, navigation clavier (←/→) + plein écran dans l'éditeur **et** les pages publiques
- [x] **Previews inline** — PDF, vidéo, audio rendus directement dans la page
- [x] **Cover & icône de page** — upload image de couverture (fichier ou URL) + emoji/icône custom
### v5.6.0 — Import de données (étendu)
> **Objectif** : compléter l'import de base (Markdown/CSV/Notion, déjà dans v5.4.0) par les formats pro et l'inférence de types.
Détails livrés :
- Service `app/services/embeds.py` : détection multi-provider + réécriture d'URL, `resolve_embed()`, `inline_kind()`, `embed_html()` ; endpoint `POST /board/api/embed/resolve`
- Service `app/services/og_fetcher.py` : parseur OG robuste (ordre d'attributs libre), favicon, repli sans réseau ; endpoint `POST /board/api/og/metadata`
- Endpoints `POST/DELETE /board/api/pages/{id}/cover` (JSON URL ou upload image) et `POST /board/api/pages/{id}/icon`
- Éditeur : résolution d'embed à la saisie (URL d'origine conservée + `embed_src` mis en cache), lightbox multi-images navigable, préviews vidéo/audio/PDF
- Pages publiques `/p/<slug>` : cover + icône, embed résolu, lightbox clavier
- Export Markdown/HTML/PDF : nouveau bloc `embed`/`bookmark` avec `embed_src` résolu
- **Fix chemins API** : l'éditeur appelait `/api/pages/...` alors que les routes sont `/board/api/pages/...` (cover, icon, versions, backlinks, import, move, OG) — corrigé
- **47 tests** `tests/test_v55.py` ; suite complète **444 verte** ; `ruff check` OK
- [ ] **Import CSV typé** — inférence automatique des types de propriétés (texte, nombre, date, select)
- [ ] **Import Confluence / Evernote / Asana / Trello** — via leurs formats d'export (HTML/JSON)
### v5.6.0 — Import de données (étendu) ✅ (2026-09-13)
> **Objectif** : faire de FlowDeck la cible d'import universelle pour les outils réellement utilisés
> (notes Markdown, bureautique, tableaux, signets, dev), en complétant l'import de base
> (Markdown/CSV/Notion, déjà dans v5.4.0). **COMPLETED**.
> **Sources retenues** : Obsidian, Notion, Logseq/Roam, Apple Notes/Bear/Ulysses, Google Keep,
> OneNote, Word, Google Docs, HTML, PDF, CSV/Excel/Sheets/JSON, Gitea/GitHub, Raindrop, Pocket,
> Readwise, Shaarli, `.ics`, OPML, Standard Notes.
> **Sources exclues** : Confluence, Evernote, Asana, Trello (non utilisées).
### v5.7.0 — Database Avancée (Pt. 2)
> **Objectif** : compléter la parité sur les propriétés, les vues sauvegardées et le Kanban pro.
#### Phase 0 — Socle d'import unifié (prérequis) ✅
> Toutes les sources partagent le même pipeline ; à livrer avant les imports.
- [x] **Service `app/services/importers/`** — interface commune (`detect()`, `parse()`, `to_pages()`) + résultat normalisé (`ImportResult` : pages, pièces jointes, warnings, stats)
- [x] **Pipeline commun** — upload → parse → normalisation blocs + métadonnées → création (pages + hiérarchie `parent_id`) → rapport d'import
- [x] **Pièces jointes** — extraction (images/fichiers), upload, réécriture des liens dans les blocs
- [x] **Frontmatter YAML → propriétés** (title, tags, dates, champs custom)
- [x] **Assistant UI d'import** — choix de la source, mapping colonnes→types, preview/dry-run, barre de progression
- [x] **Import asynchrone** (job en arrière-plan + polling) pour les gros volumes (vaults, zips)
- [x] **Idempotence / déduplication** + reprise sur erreur partielle
- [x] **Tests** — harnais d'import + fixtures par source
- [x] **Réutilise** : `_md_to_blocks` (`app/services/export.py`), `PROPERTY_TYPES` + `validate_property_rule` (`property_types.py`), `db_templates.materialize_properties`, endpoints upload/cover existants
- [ ] **Types propriété manquants** — `person`, `created_time`, `created_by`, `last_edited_time`, `last_edited_by`
- [ ] **Groupes de propriétés** — sections pliables dans le header de DB
- [ ] **Vues sauvegardées par utilisateur** — save view (per-user, pas workspace-wide)
- [ ] **Swimlanes Kanban** — sous-groupes horizontaux (2e dimension de groupement)
- [ ] **WIP limits** — par colonne, alerte visuelle au dépassement
- [ ] **Cartes configurables** — propriétés affichées par carte, couverture (image/icône/couleur), mode compact / détaillé
- [ ] **Calendar avec drag & drop** — reschedule direct sur la grille
- [ ] **Gallery avec couvertures** — image/icône comme vignette de carte
#### Phase 1 — Notes & Markdown (réutilise `_md_to_blocks`) — faible effort ✅
- [x] **Obsidian** (vault `.zip`/dossier) — frontmatter YAML, `[[wikilinks]]`, `![[embeds]]`, attachments, hiérarchie de dossiers *(prépare v5.11.0)*
- [x] **Notion** (améliorer l'existant) — hiérarchie complète, databases (CSV) → collections, images
- [x] **Logseq / Roam Research** — markdown outliné (puces imbriquées), `((block refs))`, pages journal
- [x] **Apple Notes / Bear / Ulysses** — import export HTML/Markdown
- [x] **Google Keep** — Takeout JSON + HTML
- [x] **OneNote** — export HTML/PDF, best-effort (fidélité limitée)
### v5.8.0 — Calendrier & Rappels
> **Objectif** : calendrier complet + notifications proactives.
#### Phase 2 — Données & tableaux (fort ROI databases) — effort faible/moyen ✅
- [x] **CSV/TSV typé** — inférence auto (texte/nombre/date/bool/select/multi_select) + UI mapping + options
- [x] **Excel `.xlsx`** (openpyxl) — multi-feuilles → collections
- [x] **Google Sheets** — export CSV/XLSX
- [x] **JSON générique** — mapping configurable (JSONPath → propriétés)
- [ ] **Vues Jour / Semaine / Mois** — calendrier complet (actuellement mois seulement)
- [ ] **Récurrence d'événements** — daily/weekly/monthly/custom (RRULE)
- [ ] **Support timezone** — par utilisateur + par événement
- [ ] **Rappels** — in-app + email (avant échéance : N minutes/heures/jours)
- [ ] **Centre de notifications** — cloche in-app (mentions, assignations, commentaires, rappels)
- [ ] **Template Meeting Notes** — Attendees, Agenda, Notes, Action Items
#### Phase 3 — Documents & bureautique — effort moyen ✅
- [x] **Word `.docx`** (mammoth/pandoc) — titres, listes, tableaux, images
- [x] **Google Docs** — Takeout `.docx`/HTML
- [x] **HTML** (fichiers/dossier, web clipper) — conversion HTML → blocs
- [x] **PDF** — extraction texte + images (fidélité limitée)
#### Phase 4 — Signets, dev & divers ✅
- [x] **Gitea / GitHub issues + labels + milestones** → collection (via API — quasi natif)
- [x] **Raindrop.io** (CSV/HTML) → bookmark cards (réutilise v5.5.0)
- [x] **Pocket** (CSV/HTML)
- [x] **Readwise** (highlights CSV/Markdown)
- [x] **Shaarli** (API/export JSON) → bookmark cards
- [x] **Calendrier `.ics`** (Google/Outlook/Apple)
- [x] **OPML** (flux/outlines)
- [x] **Standard Notes / autres**
#### Phase 5 — Durcissement & finition ✅
> **Objectif** : industrialiser l'import (ré-import, lots, URL, forge, relations, rapports).
- [x] **Import incrémental / re-sync** — modes `skip` / `update` (upsert des pages + lignes par titre) / `duplicate`, via `import_items`
- [x] **Import de dépôt forge** — arborescence de fichiers Gitea/GitHub → pages (hiérarchie de dossiers, fichiers texte, code en blocs)
- [x] **Import par URL / web clipper** — `POST /api/import/url` : fetch (garde SSRF) + OG metadata → page (carte bookmark + contenu)
- [x] **Lot multi-fichiers + file d'attente** — `POST /api/import/run-batch` + file d'attente UI (statut par fichier)
- [x] **Relations Notion** — `POST /api/import/relations/resolve` : colonnes texte référençant une autre collection → propriétés `relation` (ids de pages) ; auto-exécuté après un import Notion
- [x] **Reprise / erreurs partielles + rapport exportable** — import qui continue par page en cas d'erreur (`status: partial`, liste `errors`) ; rapport JSON téléchargeable (`GET /api/import/jobs/{id}/report` + bouton UI)
- [x] **Valeurs de propriétés par id** — correction : les lignes importées stockent les valeurs par id de propriété (rendu correct dans les vues DB)
#### Priorisation
| Ordre | Phase | Effort | Impact |
|---|---|---|---|
| 1 | Phase 0 — Socle unifié | M | 🔴 prérequis |
| 2 | Phase 1 — Notes/Markdown | S–M | 🔴 fort (Obsidian, Notion) |
| 3 | Phase 2 — Données/tableaux | S–M | 🔴 fort (databases) |
| 4 | Phase 4 — Signets/dev/divers | S | 🟠 moyen (Gitea natif, Raindrop) |
| 5 | Phase 3 — Documents | M | 🟠 moyen (docx, HTML) |
### v5.7.0 — Database Avancée (Pt. 2) ✅ (2026-09-13)
> **Objectif** : compléter la parité sur les propriétés, les vues sauvegardées et le Kanban pro. **COMPLETED**.
- [x] **Types propriété** — `person`, `created_time`, `created_by`, `last_edited_time`, `last_edited_by` câblés : auto-valeurs calculées serveur (`apply_auto_properties`) à la création/mise à jour, sélecteur `person` (membres du workspace via `GET /db/{id}/members/api`), rendu chips/avatars et édition dédiée dans l'UI
- [x] **Groupes de propriétés** — colonne `collection_properties.group_name`, header de table avec sections pliables (`db-group-row` + toggle) et `POST /db/{id}/property-groups/api`
- [x] **Vues sauvegardées par utilisateur** — `collection_views.created_by` ; `GET /db/{id}/views/api` filtre par propriétaire (les vues partagées `NULL` restent visibles), `save-as`/`duplicate`/`DELETE` + renommage
- [x] **Swimlanes Kanban** — `sub_group_by` (2e dimension de groupement) : une rangée de colonnes par lane
- [x] **WIP limits** — `wip_limits` par colonne, alerte visuelle au dépassement (`wip-exceeded`)
- [x] **Cartes configurables** — `card_properties`, `card_size` (compact/détaillé), couverture `cover_mode` (`none`/`icon`/`color`/`property`) via `cover_property`
- [x] **Calendar avec drag & drop** — grille mensuelle, `date_property`, navigation mois/Today, drop d'une carte sur un jour = reschedule
- [x] **Gallery avec couvertures** — cartes avec vignette (image de propriété, icône, couleur, ou cover de ligne), tailles small/medium/large
Détails livrés :
- Migration 10 : `collection_properties.group_name`, `collection_views.created_by`/`updated_at`, `collection_pages.cover_url`
- `app/services/property_types.py` : `user_ref()`, `apply_auto_properties()`, validation `person`
- `collections.py` : auto-props create/update/sub-item, PATCH partiel fusionné, membres, groupes, vues per-user (list/save/duplicate/delete), config de vue étendue (`sub_group_by`, `wip_limits`, `card_size`, `cover_mode`, `card_properties`, `date_property`, `property_groups`)
- `dashboard.py` : auto-props à la création de ligne, `views` exposées dans `table-data` et le contexte de page
- `_database_table_scripts.html` réécrit en composant multi-vues (table/board/calendar/gallery/list) + barre de vues, éditeurs de cellules par type, picker personne, popovers de configuration
- **12 tests** `tests/test_v57_db_advanced.py` ; suite complète **503 verte** ; `ruff check` OK
### v5.8.0 — Calendrier & Rappels ✅ (2026-09-13)
> **Objectif** : calendrier complet + notifications proactives. **COMPLETED**.
- [x] **Vues Jour / Semaine / Mois** — sélecteur de mode dans la vue calendar (persisté en config de vue), navigation ‹/› et Today adaptées, vue Jour en agenda horodaté, Semaine en 7 colonnes ; événements servis par `GET /db/{id}/calendar/api` avec expansion serveur des occurrences
- [x] **Récurrence d'événements** — moteur `app/services/recurrence.py` (daily/weekly/monthly, intervalle, count, until, byweekday lundi=0, timezone) ; règle stockée dans `property_values_json.__recurrence__` ; popover événement (double-clic) avec Repeat/Every/Ends ; validation serveur (400)
- [x] **Support timezone** — colonne `users.timezone` + picker dans Settings → Notifications (`GET/POST /api/notifications/timezone`, zones via `GET /db/timezones/api`) ; timezone par événement (`__timezone__`) > règle de récurrence > préférence utilisateur
- [x] **Rappels** — `app/services/reminders.py` : lead minutes/heures/jours avant chaque occurrence, scan toutes les 60 s (`reminder_scheduler`), dédup `reminder_log`, notifie les personnes assignées (repli admin), in-app + email (pref `reminders`) ; stockés dans `property_values_json.__reminder__`, édités dans le popover événement
- [x] **Centre de notifications** — la cloche (v4.9.0) couvre désormais rappels, assignations et commentaires ; nouvelles préférences `reminders` + `assignments` ; notification d'assignation émise par `notify_assignment()` sur `PUT /db/pages/{id}/api` (comparaison avant/après des propriétés `person`)
- [x] **Template Meeting Notes** — propriétés `Agenda` et `Notes` ajoutées au template seed (migration 12, préserve les personnalisations) ; le schéma Attendees/Date/Status/Action items était déjà là
Détails livrés :
- Migrations 11 (`reminder_log`, `users.timezone`) et 12 (template Meeting notes)
- `app/config.py` : `reminders_enabled`, `reminder_scan_interval_seconds`
- `_database_table_scripts.html` : modes jour/semaine/mois, chips d'occurrences (badge ↻ + heure), popover Time/Timezone/Repeat/Remind, badges ↻/🔔 dans les cellules date, drag & drop de reschedule préservé
- **20 tests** `tests/test_v58_calendar_reminders.py` ; suite complète **523 verte** ; `ruff check` OK
- **Version** — 5.11.7
### v5.9.0 — AI Writing Assist (éditeur) ✅ (2026-09-10)
> **Objectif** : l'IA Notion dans l'éditeur, au-dessus du moteur v4.10.0 (Agent IA). **COMPLETED**.
@@ -572,45 +664,63 @@ app/
## v5.10.0 — Éditeur : interactions de bloc ✅ (livré — voir section Completed)
## v5.11.0 — Wiki-links & mentions de page ⬜ (non commencé)
## v5.11.0 — Wiki-links & mentions de page ✅ (2026-09-14)
> **Objectif** : le graphe de connaissances Notion. Complète les backlinks de v5.4.0
> (section « Lié depuis ») par la création des liens depuis l'éditeur.
> (section « Lié depuis ») par la création des liens depuis l'éditeur. **COMPLETED**.
- [ ] **Wiki-links `[[`** — taper `[[` ouvre un picker de pages (recherche fuzzy, toutes collections), Enter insère un lien interne rendu comme chip de page (icône + titre mis à jour dynamiquement)
- [ ] **Mention de page `@`** — dans le menu @ existant (utilisateurs v4.9.0), ajouter l'onglet « Pages » : `@` suivi d'un nom de page crée un lien inline
- [ ] **Mention de date `@`** — `@today`, `@tomorrow`, `@2026-10-01` rendus comme chips de date
- [ ] **Renommage propagé** — renommer une page met à jour le libellé affiché de tous ses liens internes (résolution au rendu via `page_id`, pas de texte dur)
- [x] **Wiki-links `[[`** — taper `[[` ouvre le picker de pages (`GET /board/api/wiki/pages`, recherche substring + sous-séquence floue), Entrée insère un lien interne rendu comme chip atomique (icône + titre, cliquable) ; module `WM` dans `_page_editor_scripts.html` ; tokens `[[fdpage:ID]]` stockés dans le texte des blocs et relus intacts par `gtTok()` (autosave, drag, undo/redo préservés)
- [x] **Mention de page `@`** — le menu `@` (utilisateurs v4.9.0) gagne la section « Pages » : `@nom` cherche les pages et insère le chip inline
- [x] **Mention de date `@`** — section « Date » : `@today`, `@tomorrow`, `@hier` ou `@YYYY-MM-DD` insèrent `[[fddate:…]]`, rendus comme chips de date lisibles (« Fri 25 Dec 2026 »)
- [x] **Renommage propagé** — le token ne stocke que `page_id` ; `GET /board/api/wiki/titles` résout les libellés au rendu (editeur + page publique) → renommer une page met à jour tous ses liens ; page supprimée → « Deleted page »
- [x] Bonus — le scanner de backlinks v5.4.0 reconnaît les tokens wiki ; `_render_blocks_public` rend les chips en page publiée (HTML échappé) ; service `app/services/wiki_links.py` ; 15 tests dédiés (voir v5.12.0)
## v5.12.0 — Templates & verrouillage de page ⬜ (non commencé)
## v5.12.0 — Templates & verrouillage de page ✅ (2026-09-14)
> **Objectif** : démarrage rapide productif et protection des pages stabilisées.
> Les `page_templates` existent (v2.0.0/v4.2.0) mais uniquement côté collections.
> Les `page_templates` existent (v2.0.0/v4.2.0) mais uniquement côté collections. **COMPLETED**.
- [ ] **Template picker global** — sur « + New page » : galerie de templates (Empty, Meeting notes, Weekly report, To-do list…) + templates custom utilisateur
- [ ] **Bouton « Use template »** — duplication du contenu du template dans la nouvelle page
- [ ] **Page lock** — toggle 🔒 dans le menu « … » : page en lecture seule (édition désactivée pour tous sauf owner/admin), indicateur visuel en topbar
- [ ] **Full-width mode** — toggle pour passer la page en pleine largeur (comme Notion)
- [ ] **Small text / typo options** — option de page : taille de police réduite, serif/mono
- [x] **Template picker global** — « + New page » (sidebar, footer, Ctrl+K) ouvre la galerie : 5 templates built-in (`app/services/block_templates.py` — Empty, Meeting notes, Weekly report, To-do list, Project doc) + templates custom utilisateur (`page_global_templates`, migration 13, cloisonnés par `created_by`)
- [x] **Bouton « Use template »** — `POST /board/api/page-templates/{id}/use` (id 0 = built-in par clé) duplique le contenu des blocs dans une nouvelle page ; « Empty » retombe sur la création classique ; menu « … » → « 📑 Save as template » capture la page courante
- [x] **Page lock** — toggle 🔒 dans le menu « … » : bannière sticky read-only, blocs/titre non éditables ; serveur `POST /board/api/pages/{id}/lock` + garde `_ensure_page_editable` → 423 sur `PUT /api/pages/{id}` et `POST /api/pages/{id}/blocks` ; déverrouillage réservé au poseur du lock (`locked_by`) ou admin (403 sinon) ; indicateur visuel = bannière en tête de page
- [x] **Full-width mode** — toggle dans le menu « … », persisté par page (`pages.full_width`), `POST /board/api/pages/{id}/options`, classe CSS `.full-width`
- [x] **Small text / typo options** — toggle « Aa Small text » (`pages.font_small`), classe `.small-text` (taille réduite ; serif/mono : non demandé au-delà du compact — volontairement hors scope)
## v5.13.0 — Collaboration temps réel ✅ (livré — voir section Completed)
## v5.14.0 — Synced blocks ⬜ (non commencé)
## v5.14.0 — Synced blocks ✅ (2026-09-15)
> **Objectif** : avancer depuis v6.0.0 un bloc Notion très utilisé (même contenu dans
> plusieurs pages, édité une fois).
> plusieurs pages, édité une fois). **COMPLETED**.
- [ ] **Bloc `synced_block`** — table `synced_blocks` (source de vérité) + références par page ; slash command `/synced`
- [ ] **Rendu** — ring rouge + badge « Synced » sur le bloc ; édition à un endroit => mise à jour partout (via rooms WS v5.13.0 si actives, sinon au reload)
- [ ] **Unsync** — action « Unsync » qui convertit l'instance en copie indépendante
- [ ] **Copy & sync across pages** — copier un bloc dans une autre page avec option « Paste and sync »
- [x] **Bloc `synced_block`** — table `synced_blocks` (source de vérité) + références par page ; slash command `/synced`
- [x] **Rendu** — ring rouge + badge « Synced » sur le bloc ; édition à un endroit => mise à jour partout (via rooms WS v5.13.0 si actives, sinon au reload)
- [x] **Unsync** — action « Unsync » qui convertit l'instance en copie indépendante
- [x] **Copy & sync across pages** — copier un bloc dans une autre page avec option « Paste and sync »
---
## v6.0.0 — PWA : Progressive Web App, offline ✅ (2026-09-18)
> **Objectif** : support hors ligne complet (manifest, service worker, IndexedDB,
> queue de mutations, sync serveur + résolution de conflits). **COMPLETED**.
- [x] **Manifest & icônes PWA** — `static/manifest.json`, `static/icons/*`, `scripts/generate_pwa_icons.py`
- [x] **Service Worker** — `static/sw.js` (precache shell, network-first HTML/API, page offline, Background Sync)
- [x] **IndexedDB client** — `static/js/offline.js` (`window.FlowOffline`) : queue, delta, flush, marqueurs dirty
- [x] **Endpoints sync** — `app/routers/sync.py` + `app/services/sync_engine.py` (`/api/v2/sync/delta|batch|status`)
- [x] **Migrations** — table `offline_sync_queue` + colonnes `sync_version` (triggers AFTER UPDATE)
- [x] **Conflits** — edit-edit (last-write-wins), edit-delete (page orpheline), create-create (« copie offline »)
- [x] **UI** — banner offline + pending count, badge de synchronisation, toasts, icône ⟳ sur pages dirty
- [x] **Durcissement** — max 100 mutations/batch, timeout 30 s, rétention queue 30 j
- [x] **Tests** — `test_sync.py`, `test_sync_migrations.py`, `test_service_worker.py`, `test_pwa_offline.py`, E2E `e2e/pwa_offline.spec.js`
- [x] **Doc** — section `/help` « Offline mode (PWA) »
---
## v6.0.0 — Pro (futur)
- [ ] **PWA** — Progressive Web App, offline support
- [ ] **SSO/SAML** — enterprise authentication
- [ ] **Granular permissions** — page-level, property-level access control
- [ ] **Web Clipper** — extension navigateur
- [ ] **API publique complète** — REST API documentée (OpenAPI) *(base existante : `public_api.py`, à étendre + documenter)* — voir [`docs/API_GUIDE_V6.md`](docs/API_GUIDE_V6.md) : référence complète (conventions, CRUD par ressource, webhooks, sécurité, checklist)
- [x] **PWA** — Progressive Web App, offline support ✅ (livré) — [📄 Conception détaillée](/docs/V6_PWA_Progressive_Web_App.md)
- [ ] **SSO/SAML** — enterprise authentication — [📄 Conception détaillée](/docs/V6_SSO_SAML_Enterprise_Auth.md)
- [ ] **Granular permissions** — page-level, property-level access control — [📄 Conception détaillée](/docs/V6_Granular_Permissions.md)
- [ ] **Web Clipper** — extension navigateur — [📄 Conception détaillée](/docs/V6_Web_Clipper.md)
- [ ] **API publique complète** — REST API documentée (OpenAPI) — [📄 API Guide v2](/docs/API_GUIDE_V6.md) · [📄 Référence des features v6](/docs/API_GUIDE_V6.md#11-documents-de-conception-détaillée-v600)
- [ ] **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
@@ -632,7 +742,14 @@ app/
3. ~~**v5.3.0 → Inline databases + templates + validation**~~ ✅ livré (slash `/database`, 6 templates, validation propriétés)
4. ~~**v5.13.0 → Realtime (WS + présence)**~~ ✅ livré (`app/services/realtime_server.py` + `WS /ws/pages/{id}`, présence, curseurs live, merge LWW, 12 tests)
5. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré (drag&drop multi, undo/redo, duplicate, menu ⋮, en-têtes de tableau, 9 tests)
6. **v5.4.0 → Expérience éditeur** — backlinks, duplicate, corbeille globale, historique de version UI, import (prochain)
6. ~~**v5.4.0 → Expérience éditeur**~~ ✅ livré (backlinks, page/collection duplicate, corbeille globale + purge 30 j, historique de version UI, import Markdown/CSV/Notion)
7. ~~**v5.5.0 → Embeds & Média riche**~~ ✅ livré (embed universel 15 providers, bookmark cards OG, lightbox clavier, préviews PDF/vidéo/audio, cover & icône ; 47 tests dédiés)
8. ~~**v5.6.0 → Import de données (6 phases)**~~ ✅ **livré** — Phase 0 socle unifié · Phase 1 notes/Markdown (Obsidian, Notion, Logseq/Roam, Apple Notes/Bear, Google Keep, OneNote) · Phase 2 données/tableaux (CSV typé, Excel, Sheets, JSON) · Phase 3 documents (Word, Google Docs, HTML, PDF) · Phase 4 signets/dev/divers (Gitea/GitHub, Raindrop, Pocket, Readwise, Shaarli, `.ics`, OPML, Standard Notes) · Phase 5 durcissement (re-sync, dépôt forge, URL/web clipper, lot multi-fichiers, relations Notion, rapports exportables)
9. ~~**v5.7.0 → Database Avancée (Pt. 2)**~~ ✅ **livré** (person + auto-propriétés, groupes de propriétés, vues sauvegardées par utilisateur, swimlanes, WIP limits, cartes configurables, calendar drag & drop, gallery couvertures ; 12 tests dédiés)
10. ~~**v5.8.0 → Calendrier & Rappels**~~ ✅ **livré** (vues jour/semaine/mois, récurrences RRULE expandues serveur, rappels in-app + email avec dédup, fuseaux par utilisateur/événement, notifications d'assignation, template Meeting notes enrichi ; 20 tests dédiés)
11. ~~**v5.11.0 → Wiki-links & mentions de page**~~ ✅ **livré** (picker `[[`, mentions `@` pages/date, chips atomiques, renommage propagé, backlinks wiki, chips en page publique)
12. ~~**v5.12.0 → Templates & verrouillage de page**~~ ✅ **livré** (template picker global 5 built-in + templates perso, use-template, page lock 423, full-width, small text)
13. **v5.14.0 → v5.14.0 COMPLETED** ✅ (synced blocks)
---
## Résumé des phases
@@ -644,11 +761,11 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard
+ UI Notion + Tags + Admin + Sharing COMPLETED
+ GitHub OAuth + Library
v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.3 ✅ v5.13 ✅ · v5.10 ✅ v5.14 ⬜ v6.0 ⬜
Quality DB views, Agent IA Palette → Realtime + Synced Pro + Agent
& Tests Templates & COMPLETED Automations Interactions blocks
Collaboration Realtime, de bloc (undo/
DB avancée, redo, drag&drop,
Calendrier, AI duplicate)
v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.3 ✅ v5.13 ✅ · v5.10 ✅ v5.5 ✅ · v5.7 ✅ v5.14 ✅ · v6.0 ⬜
Quality DB views, Agent IA Palette → Realtime + Embeds & Synced Pro + Agent
& Tests Templates & COMPLETED Automations Interactions Média riche blocks
Collaboration Realtime, de bloc (undo/ (embed, + DB
DB avancée, redo, drag&drop, bookmark, avancée
Calendrier, AI duplicate) lightbox…) (Pt.2)
*Dernière mise à jour: 2026-09-11 — **v5.2.0 Infrastructure & Polish validé** (18/18 tests dédiés, 397 tests suite complète, ruff/eslint verts, CI Gitea success sur push + PR #15) ; reste v5.4, v5.11 → v6.0*
*Dernière mise à jour: 2026-09-18 — **v6.0.0 PWA (offline support) COMPLETED** (manifest, service worker, IndexedDB, sync `/api/v2/sync/*`, conflits, UI offline, tests + E2E). Reste: v6.0.0 Pro (SSO/SAML, permissions granulaires, Web Clipper, API publique, realtime production)*
+1 -1
View File
@@ -1 +1 @@
5.11.1
6.0.0
+11 -2
View File
@@ -60,6 +60,10 @@ class Settings(BaseSettings):
project_sync_enabled: bool = True
project_sync_interval_hours: int = 1
# Reminders (v5.8.0) — background scan for due date reminders
reminders_enabled: bool = True
reminder_scan_interval_seconds: int = 60
# Email / SMTP notifications (v4.9.0) — optional. If smtp_host is empty,
# email notifications are skipped (only in-app notifications are delivered).
smtp_host: str = ""
@@ -73,8 +77,13 @@ class Settings(BaseSettings):
# FlowDeck Agent (v4.10.0) — multi-LLM. Empty keys → offline/mock mode
# (deterministic rule-based planner so the agent works without any API key).
agent_enabled: bool = True
llm_provider: str = "offline" # openai | anthropic | google | ollama |
# deepseek | qwencloud | nvidia | openrouter | offline
llm_provider: str = "offline" # any id from llm_client.PROVIDERS
# (openai, anthropic, mistral, cohere,
# google, groq, deepseek, openrouter,
# nvidia, together, perplexity, xai,
# qwencloud, minimax, morph, fireworks,
# cerebras, sambanova, chutes, xiaomi,
# sealion, sensenova, ollama, offline)
llm_model: str = "gpt-4o"
llm_api_key: str = ""
llm_api_base: str = "" # custom base URL (Ollama, OpenRouter, ...)
+26 -13
View File
@@ -33,13 +33,17 @@ from app.routers import (
security,
sharing,
sidebar_config,
sync,
webhooks,
workspace,
)
from app.routers.automations import router as automations_router
from app.routers.collaboration import router as collaboration_router
from app.routers.emoji import router as emoji_router
from app.routers.gitea import router as gitea_router
from app.routers.github_routes import router as github_router
from app.routers.imports import page_router as import_page_router
from app.routers.imports import router as imports_router
from app.routers.notifications import router as notifications_router
from app.routers.realtime import router as realtime_router
from app.services.webhook_outbound import init_webhook_tables
@@ -85,13 +89,17 @@ async def lifespan(_app: FastAPI):
from app.services.trash import trash_purge_scheduler
trash_task = asyncio.create_task(trash_purge_scheduler())
logger.info("FlowDeck v5.11.1 started on port %d", settings.app_port)
# ── Reminders (v5.8.0): due-reminder scan for database rows ──
from app.services.reminders import reminder_scheduler
reminder_task = asyncio.create_task(reminder_scheduler())
logger.info("FlowDeck v%s started on port %d", dashboard._get_app_version(), settings.app_port)
try:
yield
finally:
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task):
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task):
task.cancel()
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task):
for task in (scheduler_task, automation_task, backup_task, projects_task, trash_task, reminder_task):
try:
await task
except asyncio.CancelledError:
@@ -100,7 +108,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="5.11.1",
version="6.0.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
@@ -134,26 +142,31 @@ app.include_router(export.router)
app.include_router(notifications_router)
app.include_router(automations_router)
app.include_router(collaboration_router)
app.include_router(emoji_router)
app.include_router(realtime_router)
app.include_router(agent.router)
app.include_router(search.router)
app.include_router(security.router)
app.include_router(onboarding.router)
app.include_router(sync.router)
app.include_router(imports_router)
app.include_router(import_page_router)
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/manifest.json")
async def pwa_manifest():
return {
"name": "FlowDeck",
"short_name": "FlowDeck",
"start_url": "/",
"display": "standalone",
"background_color": "#191919",
"theme_color": "#191919",
"icons": [{"src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png"}],
}
"""Serve the static web manifest from disk (same URL as before v6.0.0)."""
from fastapi.responses import FileResponse
return FileResponse("static/manifest.json", media_type="application/manifest+json")
@app.get("/sw.js")
async def service_worker():
"""Serve the PWA service worker at top-level scope (/)."""
from fastapi.responses import FileResponse
return FileResponse("static/sw.js", media_type="application/javascript")
# ═══════════ API aliases (v4.0.1) ═══════════
+1 -1
View File
@@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware):
"""
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"}
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"}
async def dispatch(self, request: Request, call_next):
# Webhook receiver, OAuth callback, and internal API are exempt
+353
View File
@@ -335,6 +335,25 @@ def _migration_v54_page_versions_cover(conn: sqlite3.Connection) -> None:
conn.execute("ALTER TABLE pages ADD COLUMN page_icon TEXT DEFAULT ''")
@register(8, "v5.6.0: custom workspace emojis")
def _migration_custom_emojis(conn: sqlite3.Connection) -> None:
"""Workspace-wide custom emojis (uploaded images) used as page icons."""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS custom_emojis (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL DEFAULT 1,
name TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_custom_emojis_ws ON custom_emojis(workspace_id, created_at)"
)
@register(4, "database templates (icon) + property validation")
def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
"""v5.3.0: database templates get an icon, properties a validation config,
@@ -356,3 +375,337 @@ def _migration_db_templates_validation(conn: sqlite3.Connection) -> None:
(tpl["name"], tpl.get("icon", "📋"), tpl.get("description", ""),
__import__("json").dumps(tpl.get("schema", []))),
)
@register(9, "v5.6.0: import items (dedup) + import jobs")
def _migration_import_framework(conn: sqlite3.Connection) -> None:
"""Unified import framework (Phase 0).
``import_items`` — one row per imported page, keyed by workspace + source +
external id, so re-importing the same vault is idempotent.
``import_jobs`` — background import job status/history for UI polling.
"""
conn.execute(
"""
CREATE TABLE IF NOT EXISTS import_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER,
source TEXT NOT NULL DEFAULT '',
external_id TEXT NOT NULL DEFAULT '',
page_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(workspace_id, source, external_id)
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_import_items_lookup "
"ON import_items(workspace_id, source, external_id)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS import_jobs (
id TEXT PRIMARY KEY,
source TEXT NOT NULL DEFAULT '',
filename TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'queued',
error TEXT NOT NULL DEFAULT '',
report_json TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_import_jobs_created ON import_jobs(created_at)"
)
@register(10, "v5.7.0: property groups, per-user views, row covers")
def _migration_v57_db_advanced(conn: sqlite3.Connection) -> None:
"""v5.7.0 — Database Avancée (Pt. 2).
``collection_properties.group_name`` — groups properties into collapsible
sections in the table header (Notion property groups).
``collection_views.created_by`` — owner of a saved view; ``NULL`` means
a shared/legacy view visible to everyone, otherwise it is personal to a user.
``collection_pages.cover_url`` — per-row cover image (gallery/board
cards), independent from the block-page ``pages.cover_url``.
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_properties)").fetchall()}
if "group_name" not in _pcols:
conn.execute(
"ALTER TABLE collection_properties ADD COLUMN group_name TEXT NOT NULL DEFAULT ''"
)
_vcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_views)").fetchall()}
if "created_by" not in _vcols:
conn.execute("ALTER TABLE collection_views ADD COLUMN created_by INTEGER")
if "updated_at" not in _vcols:
conn.execute("ALTER TABLE collection_views ADD COLUMN updated_at TIMESTAMP")
_cpcols = {r[1] for r in conn.execute("PRAGMA table_info(collection_pages)").fetchall()}
if "cover_url" not in _cpcols:
conn.execute("ALTER TABLE collection_pages ADD COLUMN cover_url TEXT DEFAULT ''")
@register(11, "v5.8.0: reminder log + user timezones")
def _migration_v58_calendar_reminders(conn: sqlite3.Connection) -> None:
"""v5.8.0 — Calendrier & Rappels.
``reminder_log`` — dedup ledger: one row per (page, occurrence date) so
a reminder fires exactly once even across restarts.
``users.timezone`` — personal IANA timezone used for "today" in calendar
views and reminder firing (empty = UTC).
"""
conn.execute(
"""CREATE TABLE IF NOT EXISTS reminder_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
occurrence_date TEXT NOT NULL,
fired_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(page_id, occurrence_date)
)"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_remlog_page ON reminder_log(page_id)"
)
_ucols = {r[1] for r in conn.execute("PRAGMA table_info(users)").fetchall()}
if "timezone" not in _ucols:
conn.execute("ALTER TABLE users ADD COLUMN timezone TEXT NOT NULL DEFAULT ''")
@register(12, "v5.8.0: enrich Meeting notes template")
def _migration_v58_meeting_template(conn: sqlite3.Connection) -> None:
"""v5.8.0 — the seeded 'Meeting notes' database template gains Agenda and
Notes text properties. Only refreshed when the row still matches the old
built-in schema (user edits are never clobbered)."""
import json as _json
row = conn.execute(
"SELECT schema_json FROM database_templates WHERE name='Meeting notes'"
).fetchone()
if not row:
return
try:
schema = _json.loads(row[0] or "[]")
except (ValueError, TypeError):
return
names = [p.get("name") for p in schema]
if "Agenda" in names or "Notes" in names:
return
if names != ["Title", "Date", "Attendees", "Status", "Action items"]:
return # customised — leave alone
idx = names.index("Action items")
schema[idx:idx] = [
{"name": "Agenda", "type": "text"},
{"name": "Notes", "type": "text"},
]
conn.execute(
"UPDATE database_templates SET schema_json=? WHERE name='Meeting notes'",
(_json.dumps(schema),),
)
conn.execute(
"UPDATE database_templates SET description=? WHERE name='Meeting notes'",
("Notes de réunion avec participants, agenda, notes et actions.",),
)
@register(13, "v5.11.0/v5.12.0: page lock, user typo prefs, global page templates")
def _migration_v511_wiki_v512_templates(conn: sqlite3.Connection) -> None:
"""v5.11.0 Wiki-links + v5.12.0 Templates & verrouillage.
``pages.is_locked`` — read-only page (locker/admin can unlock).
``pages.locked_by`` — user that locked the page.
``pages.full_width`` — per-page full-width layout toggle.
``pages.font_small`` — per-page compact typography toggle.
``page_global_templates`` — user-created global page templates
(blocks_json = same format as the block editor saves).
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
if "is_locked" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0")
if "locked_by" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN locked_by INTEGER REFERENCES users(id) ON DELETE SET NULL")
if "full_width" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN full_width INTEGER NOT NULL DEFAULT 0")
if "font_small" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN font_small INTEGER NOT NULL DEFAULT 0")
conn.execute(
"""CREATE TABLE IF NOT EXISTS page_global_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '📄',
description TEXT NOT NULL DEFAULT '',
blocks_json TEXT NOT NULL DEFAULT '[]',
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_pgt_creator ON page_global_templates(created_by)"
)
@register(14, "v5.13.0: fix stale LLM provider api_base values")
def _migration_fix_llm_api_bases(conn: sqlite3.Connection) -> None:
"""Clear `api_base` values that freeze a provider URL to a wrong/default value.
The Agent "Test connection" uses the per-user (or global) stored `api_base`
when present, so an old/incorrect value (e.g. Mistral `…/v2`, Cohere
`…/v2`, Google native `/v1beta`) keeps failing even after `PROVIDERS` is
corrected. Two kinds of rows are reset to the provider default:
* known-wrong legacy bases from earlier releases;
* a stored base identical to the current provider default (a no-op
override that would block future default changes).
"""
from app.services.llm_client import PROVIDERS
legacy: dict[str, set[str]] = {
"mistral": {"https://api.mistral.ai/v2"},
"cohere": {"https://api.cohere.com/v2", "https://api.cohere.com/v1",
"https://api.cohere.ai/v2"},
"google": {"https://generativelanguage.googleapis.com/v1beta"},
"perplexity": {"https://api.perplexity.ai/v1"},
"chutes": {"https://api.chutes.ai/v1"},
"sensenova": {"https://token.sensenova.cn/v1"},
"ltx": {"https://api.ltx.io/v1"},
"memtensor": {"https://memos.memtensor.cn/api/openmem/v1"},
}
for provider, (base, _) in PROVIDERS.items():
if base:
legacy.setdefault(provider, set()).update({base, base.rstrip("/")})
for provider, bases in legacy.items():
variants = {b for b in bases if b}
variants |= {b.rstrip("/") for b in bases if b}
for table in ("user_llm_keys", "llm_config"):
for value in variants:
conn.execute(
f"UPDATE {table} SET api_base='' WHERE provider=? AND api_base=?",
(provider, value),
)
@register(15, "v5.14.0: synced blocks")
def _migration_synced_blocks(conn: sqlite3.Connection) -> None:
"""v5.14.0 — Synced blocks: a block created once, displayed &
edited across multiple pages.
``synced_blocks`` — source-of-truth content for synced blocks.
``page_blocks`` — per-page reference to a synced block
(so each page can independently decide to use/unsync).
"""
conn.execute(
"""CREATE TABLE IF NOT EXISTS synced_blocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '[]',
created_by INTEGER REFERENCES users(id),
workspace TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_synced_blocks_ws ON synced_blocks(workspace)"
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS page_synced_blocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
synced_block_id INTEGER NOT NULL REFERENCES synced_blocks(id) ON DELETE CASCADE,
block_index INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(page_id, synced_block_id)
)"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_psb_page ON page_synced_blocks(page_id)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_psb_synced ON page_synced_blocks(synced_block_id)"
)
@register(16, "v6.0.0: offline sync queue")
def _migration_offline_sync_queue(conn: sqlite3.Connection) -> None:
"""v6.0.0 — PWA offline support.
``offline_sync_queue`` persists server-side the mutations received from
offline clients (``/api/v2/sync/batch``) so work is not lost and can be
audited/replayed per device.
"""
conn.execute(
"""CREATE TABLE IF NOT EXISTS offline_sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_id TEXT NOT NULL,
type TEXT NOT NULL,
-- 'page_create', 'page_update', 'page_delete', 'page_move',
-- 'collection_create', 'collection_update', 'collection_delete'
payload TEXT NOT NULL,
client_timestamp REAL NOT NULL,
server_version INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
-- 'pending', 'syncing', 'synced', 'failed'
retries INTEGER NOT NULL DEFAULT 0,
error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_syncqueue_user ON offline_sync_queue(user_id, status)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_syncqueue_device ON offline_sync_queue(device_id, status)"
)
def _add_sync_version(conn: sqlite3.Connection, table: str) -> None:
"""Add ``sync_version`` to ``table`` if it is not already present."""
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
if "sync_version" not in cols:
conn.execute(f"ALTER TABLE {table} ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1")
@register(17, "v6.0.0: sync_version columns")
def _migration_sync_version_columns(conn: sqlite3.Connection) -> None:
"""v6.0.0 — optimistic-concurrency version counters for offline sync.
Every write on a page / collection increments its ``sync_version`` so a
reconnecting client can detect edit-edit conflicts via version mismatch.
A ``BEFORE UPDATE`` trigger performs the increment automatically on every
write path (no need to patch dozens of ``UPDATE`` call-sites).
"""
for table in ("pages", "collection_pages", "collections"):
_add_sync_version(conn, table)
# AFTER UPDATE + inner UPDATE: bumps sync_version on every write path.
# `recursive_triggers` is OFF by default, so the inner UPDATE never
# re-fires the trigger (no infinite loop), incl. the page FTS triggers.
conn.execute(
"""CREATE TRIGGER IF NOT EXISTS pages_sync_version_bu
AFTER UPDATE ON pages
FOR EACH ROW BEGIN
UPDATE pages SET sync_version = sync_version + 1 WHERE id = NEW.id;
END"""
)
conn.execute(
"""CREATE TRIGGER IF NOT EXISTS collection_pages_sync_version_bu
AFTER UPDATE ON collection_pages
FOR EACH ROW BEGIN
UPDATE collection_pages SET sync_version = sync_version + 1 WHERE id = NEW.id;
END"""
)
conn.execute(
"""CREATE TRIGGER IF NOT EXISTS collections_sync_version_bu
AFTER UPDATE ON collections
FOR EACH ROW BEGIN
UPDATE collections SET sync_version = sync_version + 1 WHERE id = NEW.id;
END"""
)
+3 -1
View File
@@ -810,11 +810,13 @@ async def save_llm_key(request: Request, llm_provider: str):
if provider not in PROVIDERS:
raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}")
body = await request.json() if request.headers.get("content-type") else {}
api_base_raw = body.get("api_base")
raw = upsert_user_llm_key(
user_id,
provider,
api_key=(body.get("api_key") or "").strip(),
api_base=(body.get("api_base") or "").strip(),
# None = keep the stored base, "" = reset to the provider default.
api_base=api_base_raw.strip() if isinstance(api_base_raw, str) else None,
default_model=(body.get("default_model") or "").strip(),
models=body.get("models"),
)
+474 -9
View File
@@ -26,6 +26,312 @@ AI_KEYWORD_COLORS = [
"#E255A1", "#787774", "#6B4E3D", "#6374C4", "#5A9E4B",
]
def _ensure_page_editable(conn, page_id: int, user: dict | None) -> None:
"""v5.12.0: raise 423 when the page is locked and the actor may not edit.
Allowed to edit a locked page: admins and the user who locked it
(locked_by). Unauthenticated callers only pass when the page is unlocked.
"""
row = conn.execute("SELECT is_locked, locked_by FROM pages WHERE id=?", (page_id,)).fetchone()
if not row or not row["is_locked"]:
return
uid = (user or {}).get("id")
is_admin = bool((user or {}).get("is_admin"))
if is_admin or (uid and row["locked_by"] == uid):
return
raise HTTPException(423, "Page is locked — only the owner of the lock or an admin can edit")
def _ensure_block_ids(blocks) -> None:
"""Assign unique ids to blocks missing one, recursively.
Built-in page templates ship without ids (the editor used to assign them
client-side only). Without persisted ids, the realtime layer and the editor
disagree on block identity, which duplicated lines / shuffled blocks when
editing a template-created page. We now materialize ids at creation time.
"""
import uuid
if not isinstance(blocks, list):
return
for b in blocks:
if isinstance(b, dict):
if not b.get("id"):
b["id"] = "b" + uuid.uuid4().hex[:12]
if isinstance(b.get("children"), list):
_ensure_block_ids(b["children"])
@router.get("/api/wiki/pages")
async def wiki_page_search(request: Request, q: str = Query(default="")):
"""v5.11.0: page picker for [[ wiki-links. Fuzzy-ish title search across
every non-deleted page the current user can see (single source: pages)."""
q = (q or "").strip().lower()
with get_conn() as conn:
rows = conn.execute(
"""SELECT id, title, page_icon, workspace FROM pages
WHERE deleted_at IS NULL
ORDER BY updated_at DESC LIMIT 500"""
).fetchall()
results = []
for r in rows:
title = r["title"] or "Untitled"
if q:
# subsequence match ("mtg" → "Meeting notes") or plain substring.
hay = title.lower()
it = iter(hay)
subseq = all(ch in it for ch in q)
if q not in hay and not subseq:
continue
results.append({
"id": r["id"],
"title": title,
"icon": r["page_icon"] or "",
"workspace": r["workspace"] or "",
})
if len(results) >= 20:
break
return {"pages": results}
@router.get("/api/wiki/titles")
async def wiki_titles(request: Request, ids: str = Query(default="")):
"""v5.11.0: resolve page-id lists to current labels (rename propagation)."""
parsed: list[int] = []
for part in (ids or "").split(","):
part = part.strip()
if part.isdigit():
parsed.append(int(part))
parsed = parsed[:200]
out: dict[str, str] = {}
if parsed:
placeholders = ",".join("?" * len(parsed))
with get_conn() as conn:
rows = conn.execute(
f"SELECT id, title, page_icon, deleted_at FROM pages WHERE id IN ({placeholders})",
parsed,
).fetchall()
for r in rows:
if r["deleted_at"]:
out[str(r["id"])] = "Deleted page"
else:
icon = (r["page_icon"] or "")
out[str(r["id"])] = (icon + " " if icon else "") + (r["title"] or "Untitled")
return {"titles": out}
@router.post("/api/pages/{page_id}/lock")
async def set_page_lock(request: Request, page_id: int):
"""v5.12.0: lock/unlock a page (read-only for everyone except the locker,
admins and the page creator)."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
try:
body = await request.json()
except Exception:
body = {}
locked = bool(body.get("locked"))
with get_conn() as conn:
row = conn.execute("SELECT id, is_locked, locked_by, workspace FROM pages WHERE id=?",
(page_id,)).fetchone()
if not row:
raise HTTPException(404, "Page not found")
is_admin = 1 if user.get("is_admin") else 0
if row["is_locked"] and not is_admin and row["locked_by"] != user["id"]:
raise HTTPException(403, "Only the person who locked this page (or an admin) can unlock it")
conn.execute("UPDATE pages SET is_locked=?, locked_by=? WHERE id=?",
(1 if locked else 0, user["id"] if locked else None, page_id))
conn.commit()
await fire_event("page.locked" if locked else "page.unlocked",
{"page_id": page_id, "by": user["id"]})
return {"status": "ok", "is_locked": int(locked)}
@router.post("/api/pages/{page_id}/options")
async def set_page_options(request: Request, page_id: int):
"""v5.12.0: page layout options — full-width and compact typography."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
try:
body = await request.json()
except Exception:
body = {}
updates = {}
for key in ("full_width", "font_small"):
if key in body:
updates[key] = 1 if body[key] else 0
if not updates:
raise HTTPException(400, "nothing to update")
sets = ", ".join(f"{k}=?" for k in updates)
with get_conn() as conn:
row = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
raise HTTPException(404, "Page not found")
conn.execute(f"UPDATE pages SET {sets}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(*updates.values(), page_id))
conn.commit()
return {"status": "ok", **{k: bool(v) for k, v in updates.items()}}
@router.get("/api/page-templates")
async def list_page_templates_api(request: Request):
"""v5.12.0: built-in + user global page templates for the picker."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = (user or {}).get("id")
from app.services.block_templates import template_list
with get_conn() as conn:
rows = conn.execute(
"""SELECT id, name, icon, description, created_by
FROM page_global_templates
WHERE created_by IS NULL OR created_by=?
ORDER BY created_at""",
(uid,),
).fetchall()
mine = [dict(r) for r in rows]
for t in mine:
t["builtin"] = False
return {"templates": template_list() + mine}
@router.post("/api/page-templates")
async def create_page_template(request: Request):
"""v5.12.0: save the current page (or a raw block list) as a personal
global template: {name, icon?, description?, page_id? | blocks?}."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user or not user.get("id"):
raise HTTPException(401, "Authentication required")
try:
body = await request.json()
except Exception:
body = {}
name = (body.get("name") or "").strip()
if not name:
raise HTTPException(400, "name is required")
blocks = body.get("blocks")
if body.get("page_id"):
with get_conn() as conn:
row = conn.execute("SELECT content, content_format FROM pages WHERE id=?",
(int(body["page_id"]),)).fetchone()
if not row:
raise HTTPException(404, "Page not found")
if row["content_format"] == "blocks" and row["content"]:
try:
blocks = json.loads(row["content"])
except (json.JSONDecodeError, TypeError):
raise HTTPException(400, "Page content is not block JSON") from None
if not isinstance(blocks, list) or not blocks:
raise HTTPException(400, "blocks (or page_id) required")
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO page_global_templates (name, icon, description, blocks_json, created_by)
VALUES (?, ?, ?, ?, ?)""",
(name, body.get("icon") or "📄", body.get("description") or "",
json.dumps(blocks), user["id"]),
)
conn.commit()
tid = cur.lastrowid
return {"status": "ok", "id": tid}
@router.post("/api/page-templates/{template_id}/use")
async def use_page_template(request: Request, template_id: int):
"""v5.12.0: instantiate a page from a template (built-in or user).
Body: {key?} for built-ins OR uses the row id for user templates.
Creates 'blocks'-format page in the caller's workspace and returns its id.
"""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
try:
body = await request.json()
except Exception:
body = {}
title = (body.get("title") or "").strip()
blocks_json = None
if template_id == 0:
from app.services.block_templates import blocks_json_for
key = body.get("key") or "empty"
blocks_json = blocks_json_for(key)
name = key
if blocks_json is None:
raise HTTPException(404, "Unknown built-in template")
else:
with get_conn() as conn:
uid = (user or {}).get("id")
row = conn.execute(
"SELECT * FROM page_global_templates WHERE id=? AND (created_by IS NULL OR created_by=?)",
(template_id, uid),
).fetchone()
if not row:
raise HTTPException(404, "Template not found")
blocks_json = row["blocks_json"]
name = row["name"]
title = title or row["name"]
# Resolve the target workspace so the new page actually shows up in the
# active local workspace (bugfix: template pages previously got
# workspace_id = NULL and never appeared in the sidebar/tree).
uid = (user or {}).get("id")
ws_id_raw = body.get("workspace_id")
ws_id = None
if ws_id_raw is not None:
try:
ws_id = int(ws_id_raw)
except (TypeError, ValueError):
ws_id = None
ws_key = user.get("login", "Bruno") if user else "Bruno"
if ws_id is not None:
with get_conn() as conn:
ws_row = conn.execute(
"SELECT id, name, owner_id FROM workspaces WHERE id=?", (ws_id,)
).fetchone()
if ws_row and (uid is None or ws_row["owner_id"] == uid):
ws_key = ws_row["name"] or ws_key
else:
ws_id = None
else:
body_ws = (body.get("workspace") or "").strip()
if body_ws:
ws_key = body_ws
# Optional target folder: instantiate the template as a child of it.
parent_id = body.get("parent_id")
try:
parent_id = int(parent_id) if parent_id not in (None, "", 0, "0") else None
except (TypeError, ValueError):
parent_id = None
try:
parsed_blocks = json.loads(blocks_json)
except (json.JSONDecodeError, TypeError):
raise HTTPException(500, "Template content corrupted") from None
_ensure_block_ids(parsed_blocks)
blocks_json = json.dumps(parsed_blocks)
with get_conn() as conn:
if parent_id is not None:
next_order = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE parent_id=?",
(parent_id,),
).fetchone()[0]
elif ws_id is not None:
next_order = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace_id=? AND parent_id IS NULL",
(ws_id,),
).fetchone()[0]
else:
next_order = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL",
(ws_key,),
).fetchone()[0]
cur = conn.execute(
"""INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section, parent_id, sort_order)
VALUES (?,?,?,?,?, 'Private', ?, ?)""",
(ws_key, ws_id, title or name, blocks_json, "blocks", parent_id, next_order),
)
conn.commit()
page_id = cur.lastrowid
await fire_event("page.created", {"page_id": page_id, "title": title or name,
"workspace": ws_key, "from_template": name})
return {"status": "ok", "id": page_id, "title": title or name}
# ── Core helpers ──
def _issue_column(issue: dict, columns: list[str], board_id: int) -> str:
@@ -753,6 +1059,104 @@ async def trash_page(request: Request):
return template.render(**_sidebar_data(request))
# ═══════════════ v5.14.0: Synced Blocks ─═══════════════════
# These routes MUST be registered before the catch-all /{owner}/{repo} below.
@router.get("/api/synced-blocks")
async def list_synced_blocks_api(request: Request, workspace: str = Query(default="")):
"""List synced blocks for a workspace."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
from app.services.synced_blocks import list_synced_blocks
return {"synced_blocks": list_synced_blocks(workspace or user.get("login", ""))}
@router.post("/api/synced-blocks")
async def create_synced_block_api(request: Request):
"""Create a new synced block."""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
from app.services.synced_blocks import create_synced_block
sid = create_synced_block(
workspace=body.get("workspace", ""),
title=body.get("title", "Synced block"),
content=body.get("content", []),
created_by=user.get("id"),
)
return {"status": "ok", "synced_block_id": sid}
@router.put("/api/synced-blocks/{sid}")
async def update_synced_block_api(request: Request, sid: int):
"""Update a synced block's content (propagates to all pages)."""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
from app.services.synced_blocks import get_synced_block, update_synced_block
sb = get_synced_block(sid)
if not sb:
raise HTTPException(404, "Synced block not found")
update_synced_block(sid, body.get("title", sb["title"]), body.get("content", []))
from app.services.realtime_server import manager
await manager._propagate_synced(sid)
return {"status": "ok"}
@router.delete("/api/synced-blocks/{sid}")
async def delete_synced_block_api(request: Request, sid: int):
"""Delete a synced block."""
from app.services.synced_blocks import delete_synced_block, get_synced_block
sb = get_synced_block(sid)
if not sb:
raise HTTPException(404, "Synced block not found")
delete_synced_block(sid)
return {"status": "ok"}
@router.get("/api/synced-blocks/{sid}")
async def get_synced_block_api(sid: int):
"""Get a synced block by id."""
from app.services.synced_blocks import get_synced_block
sb = get_synced_block(sid)
if not sb:
raise HTTPException(404, "Synced block not found")
return dict(sb)
@router.post("/api/pages/{page_id}/synced")
async def add_synced_to_page(request: Request, page_id: int):
"""Add a synced block reference to a page."""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
from app.services.synced_blocks import add_page_synced, get_synced_block
sid = body.get("synced_block_id")
sb = get_synced_block(sid)
if not sb:
raise HTTPException(404, "Synced block not found")
add_page_synced(page_id, sid, body.get("block_index", 0))
return {"status": "ok", "synced_block_id": sid}
@router.delete("/api/pages/{page_id}/synced/{sid}")
async def remove_synced_from_page(request: Request, page_id: int, sid: int):
"""Remove a synced block reference from a page (unsync)."""
from app.services.synced_blocks import remove_page_synced
remove_page_synced(page_id, sid)
return {"status": "ok"}
@router.get("/api/pages/{page_id}/synced")
async def get_page_synced_refs(request: Request, page_id: int):
"""Get all synced block references for a page."""
from app.services.synced_blocks import get_page_synced
return {"synced_blocks": get_page_synced(page_id)}
# ═══════════ Board page ═══════════
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
@@ -972,7 +1376,9 @@ async def update_page(request: Request, page_id: int, title: str = Query(default
content: str = Query(default=""),
content_format: str = Query(default="")):
"""Update a page's title and/or content. Accepts JSON body for blocks."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
with get_conn() as conn:
_ensure_page_editable(conn, page_id, user)
if title:
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
if content:
@@ -991,17 +1397,32 @@ async def save_page_blocks(request: Request, page_id: int):
v5.4.0: a version snapshot is recorded (if the block content actually
changed) so the UI can browse the version history and restore any of them.
v5.14.0: synced block references are tracked in page_synced_blocks.
"""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
blocks_json = json.dumps(body.get("blocks", []))
blocks = body.get("blocks", [])
blocks_json = json.dumps(blocks)
title = body.get("title", "")
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
uid = (user or {}).get("id")
# Extract synced block ids from the blocks
def _extract_synced(blocks: list[dict]) -> set[int]:
ids: set[int] = set()
for b in blocks:
if b.get("type") == "synced" and b.get("synced_id"):
ids.add(b["synced_id"])
if isinstance(b.get("children"), list):
ids |= _extract_synced(b["children"])
return ids
synced_ids = _extract_synced(blocks)
with get_conn() as conn:
_ensure_page_editable(conn, page_id, user)
if title:
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
conn.execute(
@@ -1009,6 +1430,21 @@ async def save_page_blocks(request: Request, page_id: int):
(blocks_json, page_id),
)
_record_version(conn, page_id, uid, title or "", blocks_json)
# Update synced block references
existing = {r["synced_block_id"] for r in conn.execute(
"SELECT synced_block_id FROM page_synced_blocks WHERE page_id=?", (page_id,)
).fetchall()}
for sid in synced_ids:
if sid not in existing:
conn.execute(
"INSERT OR IGNORE INTO page_synced_blocks (page_id, synced_block_id, block_index) VALUES (?, ?, 0)",
(page_id, sid),
)
for sid in existing - synced_ids:
conn.execute(
"DELETE FROM page_synced_blocks WHERE page_id=? AND synced_block_id=?",
(page_id, sid),
)
conn.commit()
await fire_event("page.updated", {"page_id": page_id, "title": title or "",
"content_format": "blocks"})
@@ -1055,6 +1491,7 @@ async def page_backlinks(request: Request, page_id: int):
reference to ``/pages/{page_id}`` or ``#fdblk-…`` inside ``/pages/{page_id}``.
"""
target = f"/pages/{page_id}" if page_id else None
wiki_target = f"[[fdpage:{page_id}]]" if page_id else None
backlinks = []
with get_conn() as conn:
rows = conn.execute(
@@ -1070,17 +1507,17 @@ async def page_backlinks(request: Request, page_id: int):
blocks = json.loads(r["content"])
for b in blocks if isinstance(blocks, list) else []:
for text in _block_texts(b):
if target and f"/pages/{page_id}" in text:
if target and (target in text or (wiki_target and wiki_target in text)):
hits = True
break
if hits:
break
except (json.JSONDecodeError, TypeError):
hits = target and f"/pages/{page_id}" in (r["content"] or "")
hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or "")))
elif fmt == "markdown":
hits = target and f"/pages/{page_id}" in (r["content"] or "")
hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or "")))
elif r["content"]:
hits = target and f"/pages/{page_id}" in json.dumps(r["content"])
hits = target and (target in json.dumps(r["content"]) or (wiki_target and wiki_target in json.dumps(r["content"])))
if not hits and target:
hits = f"/pages/{page_id}" in (r["content"] or "")
if hits:
@@ -1264,10 +1701,10 @@ async def remove_page_cover(request: Request, page_id: int):
@router.post("/api/pages/{page_id}/icon")
async def set_page_icon(request: Request, page_id: int):
"""v5.5.0: set a page emoji/icon label."""
"""v5.5.0: set a page emoji/icon label (or a custom-emoji image URL)."""
body = await request.json()
icon = (body.get("icon") or "").strip()
if len(icon) > 16:
if len(icon) > 512:
raise HTTPException(400, "icon too long")
with get_conn() as conn:
conn.execute("UPDATE pages SET page_icon=? WHERE id=?", (icon, page_id))
@@ -1384,6 +1821,26 @@ async def og_metadata(request: Request):
return {"ok": True, **data}
@router.post("/api/embed/resolve")
async def resolve_embed(request: Request):
"""v5.5.0: rewrite a pasted URL to its provider embed src.
Powers the universal ``/embed`` block (YouTube, Vimeo, Figma, Maps,
Docs, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter…).
"""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
url = (body.get("url") or "").strip()
if not url:
raise HTTPException(400, "url required")
from app.config import settings
from app.services.embeds import resolve_embed as _resolve
data = _resolve(url, parent=settings.app_base_url)
return {"ok": True, "url": url, **data}
@router.put("/api/pages/{page_id}/move")
async def move_page(request: Request, page_id: int):
"""Move a page to another workspace or reorder within tree.
@@ -1472,7 +1929,15 @@ async def view_page(request: Request, page_id: int):
).fetchone()
# Build page_data, including file metadata for uploaded files
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)), "cover_url": page.get("cover_url", "") or "", "page_icon": page.get("page_icon", "") or ""}
_locked = bool(page.get("is_locked", 0))
_locked_by = page.get("locked_by") if "locked_by" in page else None
_can_edit = (not _locked) or bool(user and user.get("is_admin")) or (uid and _locked_by == uid)
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)), "cover_url": page.get("cover_url", "") or "", "page_icon": page.get("page_icon", "") or "",
"is_locked": _locked,
"locked_by": _locked_by,
"can_edit": _can_edit,
"full_width": bool(page.get("full_width", 0)) if "full_width" in page else False,
"font_small": bool(page.get("font_small", 0)) if "font_small" in page else False}
# For file pages, extract file metadata and add to page_data
if page.get("content_format") == "file":
@@ -1538,6 +2003,6 @@ async def sync_project(owner: str, repo: str):
# Extract AI keywords from each issue
_extract_ai_keywords(owner, repo, issue.get("labels", []), issue.get("body", ""))
conn.commit()
return {"status": "ok", "issues_synced": len(issues_only)}
return {"status": "ok", "issues_synced": len(issues_only)}
except Exception as e:
raise HTTPException(500, str(e)) from e
+390 -32
View File
@@ -8,10 +8,38 @@ import sqlite3
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import fire_event
from app.services.db_templates import materialize_properties
from app.services.property_types import validate_property_rule
from app.services.property_types import (
AUTO_TYPES,
apply_auto_properties,
validate_property_rule,
)
from app.services.recurrence import (
RECURRENCE_KEY,
expand_rule,
is_valid_timezone,
parse_date,
validate_rule,
)
from app.services.reminders import REMINDER_KEY, parse_lead
def _current_user(request: Request) -> dict:
"""Resolve the session user, falling back to the local admin (single-user)."""
s = request.cookies.get("flowdeck_session", "")
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
def _collection_properties(conn, collection_id: int) -> list[dict]:
return [
dict(r) for r in conn.execute(
"SELECT * FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
]
logger = logging.getLogger(__name__)
router = APIRouter(tags=["collections"], prefix="/db")
@@ -42,7 +70,7 @@ def _validate_page_properties(conn, collection_id: int, properties: dict, exclud
for prop in props:
ptype = prop["prop_type"]
if ptype == "title":
if ptype == "title" or ptype in AUTO_TYPES:
continue
pid = prop["id"]
# Values may be keyed by property id (FlowDeckDB UI) or by name (agent).
@@ -77,6 +105,61 @@ def _validate_page_properties(conn, collection_id: int, properties: dict, exclud
raise HTTPException(status_code=400, detail=f"Property '{prop['name']}': {msg}")
def _validate_meta_keys(conn, collection_id: int, properties: dict) -> None:
"""Validate the ``__recurrence__`` / ``__reminder__`` meta keys stored
alongside real property values. Raises HTTPException(400) on bad shape.
Each meta key maps a date-property id to a rule/reminder object. We verify
the target is actually a date property and the payload parses.
"""
date_ids = {
str(r["id"]) for r in conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='date'",
(collection_id,),
).fetchall()
}
rec = properties.get(RECURRENCE_KEY)
if rec not in (None, {}):
if not isinstance(rec, dict):
raise HTTPException(status_code=400, detail="Recurrence must be an object")
for prop_id, rule in rec.items():
if rule is None:
continue
if str(prop_id) not in date_ids:
raise HTTPException(status_code=400, detail="Recurrence target must be a date property")
ok, msg = validate_rule(rule)
if not ok:
raise HTTPException(status_code=400, detail=f"Recurrence: {msg}")
rem = properties.get(REMINDER_KEY)
if rem not in (None, {}):
if not isinstance(rem, dict):
raise HTTPException(status_code=400, detail="Reminder must be an object")
for prop_id, reminder in rem.items():
if reminder is None:
continue
if str(prop_id) not in date_ids:
raise HTTPException(status_code=400, detail="Reminder target must be a date property")
if not isinstance(reminder, dict):
raise HTTPException(status_code=400, detail="Reminder must be an object")
if reminder.get("unit") not in (None, "none", "minutes", "hours", "days"):
raise HTTPException(status_code=400, detail="Reminder unit must be minutes/hours/days/none")
if parse_lead(reminder) is None and reminder.get("unit") != "none":
raise HTTPException(status_code=400, detail="Reminder value must be a positive integer")
from app.services.recurrence import TIMEZONE_KEY
tzmap = properties.get(TIMEZONE_KEY)
if tzmap not in (None, {}):
if not isinstance(tzmap, dict):
raise HTTPException(status_code=400, detail="Timezone map must be an object")
for prop_id, value in tzmap.items():
if str(prop_id) not in date_ids:
raise HTTPException(status_code=400, detail="Timezone target must be a date property")
if value and not is_valid_timezone(str(value)):
raise HTTPException(status_code=400, detail=f"Unknown timezone '{value}'")
# ── API: List & Create (no path params) ──
@@ -394,27 +477,39 @@ async def update_page_api(request: Request, page_id: int):
title = body.get("title", existing["title"])
icon = body.get("icon", existing["icon"])
cover_url = body.get("cover_url", existing["cover_url"] if "cover_url" in existing.keys() else "")
position = body.get("position", existing["position"])
parent_id = body.get("parent_id", existing["parent_id"])
try:
stored = json.loads(existing["property_values_json"])
except (json.JSONDecodeError, TypeError):
stored = {}
if "properties" in body:
props = body["properties"]
# Partial PATCH semantics: merge submitted values over stored ones.
props = dict(stored)
props.update(body["properties"])
else:
try:
props = json.loads(existing["property_values_json"])
except (json.JSONDecodeError, TypeError):
props = {}
props = stored
_validate_page_properties(conn, existing["collection_id"], props, exclude_page_id=page_id)
_validate_meta_keys(conn, existing["collection_id"], props)
apply_auto_properties(
_collection_properties(conn, existing["collection_id"]),
props,
_current_user(request),
is_create=False,
)
property_values = json.dumps(props)
conn.execute(
"""UPDATE collection_pages
SET title=?, icon=?, position=?, parent_id=?, property_values_json=?,
SET title=?, icon=?, cover_url=?, position=?, parent_id=?, property_values_json=?,
updated_at=CURRENT_TIMESTAMP
WHERE id=?""",
(title, icon, position, parent_id, property_values, page_id),
(title, icon, cover_url, position, parent_id, property_values, page_id),
)
conn.commit()
@@ -425,6 +520,11 @@ async def update_page_api(request: Request, page_id: int):
"icon": icon,
"properties": props,
})
# Notify newly assigned people (person properties) — v5.8.0.
from app.services.notifications import notify_assignment
user = _current_user(request)
notify_assignment(existing["collection_id"], page_id, title,
stored, props, user.get("id"))
return {"id": page_id, "status": "updated"}
@@ -513,6 +613,173 @@ async def list_properties_api(request: Request, collection_id: int):
return {"properties": [dict(r) for r in rows]}
@router.get("/{collection_id}/members/api")
async def list_collection_members_api(request: Request, collection_id: int):
"""API: list workspace members available for a ``person`` property.
Resolves the collection's workspace and returns its members (falling back to
every active user for standalone databases without a workspace).
"""
with get_conn() as conn:
coll = conn.execute(
"SELECT workspace_id FROM collections WHERE id=?", (collection_id,)
).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
ws_id = coll["workspace_id"] if "workspace_id" in coll.keys() else None
if ws_id:
rows = conn.execute(
"""SELECT u.id, u.login, u.full_name, u.avatar_url, u.avatar_color, wm.role
FROM workspace_members wm JOIN users u ON wm.user_id=u.id
WHERE wm.workspace_id=? AND u.is_active=1 ORDER BY u.full_name, u.login""",
(ws_id,),
).fetchall()
else:
rows = []
if not rows:
rows = conn.execute(
"""SELECT id, login, full_name, avatar_url, avatar_color, '' AS role
FROM users WHERE is_active=1 ORDER BY full_name, login"""
).fetchall()
return {"members": [dict(r) for r in rows]}
@router.get("/{collection_id}/calendar/api")
async def collection_calendar_api(request: Request, collection_id: int,
start: str = "", end: str = "",
date_property: str = ""):
"""API (v5.8.0): expanded calendar events for a window [start, end].
Returns every occurrence (recurrence-aware, virtual — never persisted)
of the rows in the collection whose ``date_property`` falls inside the
inclusive window. Rows without a rule yield their base date.
"""
user = _current_user(request)
s = parse_date(start)
e = parse_date(end)
if s is None or e is None or s > e:
raise HTTPException(status_code=400, detail="start/end must be YYYY-MM-DD")
if (e - s).days > 370:
raise HTTPException(status_code=400, detail="window too large (max 370 days)")
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
props = _collection_properties(conn, collection_id)
date_props = [p for p in props if p["prop_type"] == "date"]
target = None
if date_property:
target = next((p for p in date_props
if str(p["id"]) == str(date_property) or p["name"] == date_property), None)
if target is None:
raise HTTPException(status_code=400, detail="Unknown date property")
elif date_props:
target = date_props[0]
if target is None:
return {"events": [], "timezone": "", "property": None}
urow = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone()
user_tz = (urow["timezone"] if urow and "timezone" in urow.keys() else "") or ""
rows = conn.execute(
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=?",
(collection_id,),
).fetchall()
pid = str(target["id"])
events: list[dict] = []
for r in rows:
try:
pv = json.loads(r["property_values_json"] or "{}")
except (json.JSONDecodeError, TypeError):
continue
base_value = pv.get(pid)
if base_value is None:
base_value = pv.get(target["name"])
if parse_date(base_value) is None:
continue
rec_all = pv.get(RECURRENCE_KEY) if isinstance(pv.get(RECURRENCE_KEY), dict) else {}
rule = rec_all.get(pid) or rec_all.get(target["name"])
row_tz = user_tz
if isinstance(rule, dict) and rule.get("timezone"):
row_tz = rule["timezone"]
tzmap = pv.get("__timezone__")
if isinstance(tzmap, dict):
ev_tz = tzmap.get(pid) or tzmap.get(target["name"])
if ev_tz:
row_tz = str(ev_tz)
if rule:
dates = expand_rule(base_value, rule, s, e, max_occurrences=500)
else:
d = parse_date(base_value)
dates = [d.isoformat()] if d and s <= d <= e else []
for iso in dates:
events.append({
"date": iso,
"page_id": r["id"],
"title": r["title"],
"icon": r["icon"],
"recurring": bool(rule),
"time": str(base_value)[11:16] if len(str(base_value)) >= 16 else "",
"timezone": row_tz,
})
events.sort(key=lambda ev: (ev["date"], ev["page_id"]))
return {"events": events, "timezone": user_tz, "property": {"id": target["id"], "name": target["name"]}}
@router.get("/timezones/api")
async def timezones_api(request: Request):
"""API (v5.8.0): the user's timezone plus a picker-friendly zone list."""
user = _current_user(request)
with get_conn() as conn:
row = conn.execute("SELECT timezone FROM users WHERE id=?", (user.get("id") or 1,)).fetchone()
from app.services.recurrence import common_timezones
return {
"timezone": (row["timezone"] if row and "timezone" in row.keys() else "") or "",
"zones": common_timezones(),
}
@router.post("/{collection_id}/property-groups/api")
async def set_property_groups_api(request: Request, collection_id: int):
"""API: (re)assign properties to collapsible groups in the table header.
Body: ``{"groups": [{"name": "Basics", "property_ids": [1, 2]}]}``. Properties
omitted from any group have their group cleared. Empty group names clear.
"""
try:
body = await request.json()
except Exception:
body = {}
groups = body.get("groups", [])
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
conn.execute(
"UPDATE collection_properties SET group_name='' WHERE collection_id=?",
(collection_id,),
)
for grp in groups:
gname = (grp.get("name") or "").strip()
if not gname:
continue
for pid in grp.get("property_ids", []) or []:
conn.execute(
"UPDATE collection_properties SET group_name=? WHERE id=? AND collection_id=?",
(gname, pid, collection_id),
)
conn.commit()
return {"status": "updated"}
@router.post("/{collection_id}/properties/api")
async def create_property_api(request: Request, collection_id: int):
"""API: create a new property on a collection."""
@@ -531,6 +798,7 @@ async def create_property_api(request: Request, collection_id: int):
required = int(body.get("required", False))
visible = int(body.get("visible_in_views", True))
validation_json = json.dumps(body.get("validation", {}))
group_name = (body.get("group_name") or "").strip()
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
@@ -546,16 +814,17 @@ async def create_property_api(request: Request, collection_id: int):
cur = conn.execute(
"""INSERT INTO collection_properties
(collection_id, name, prop_type, options_json, number_format,
position, required, visible_in_views, validation_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
position, required, visible_in_views, validation_json, group_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(collection_id, name, prop_type, options_json, number_format, max_pos,
required, visible, validation_json),
required, visible, validation_json, group_name),
)
conn.commit()
except Exception:
raise HTTPException(status_code=409, detail=f"Property '{name}' already exists") from None
return {"id": cur.lastrowid, "name": name, "prop_type": prop_type, "status": "created"}
return {"id": cur.lastrowid, "name": name, "prop_type": prop_type,
"group_name": group_name, "status": "created"}
@router.put("/properties/{prop_id}/api")
@@ -582,12 +851,15 @@ async def update_property_api(request: Request, prop_id: int):
validation_json = json.dumps(body.get("validation", {}))
else:
validation_json = existing["validation_json"] if "validation_json" in existing.keys() else "{}"
group_name = body.get("group_name", existing["group_name"] if "group_name" in existing.keys() else "")
conn.execute(
"""UPDATE collection_properties
SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?, validation_json=?
SET name=?, options_json=?, number_format=?, required=?, visible_in_views=?,
validation_json=?, group_name=?
WHERE id=?""",
(name, options_json, number_format, required, visible, validation_json, prop_id),
(name, options_json, number_format, required, visible, validation_json,
group_name, prop_id),
)
conn.commit()
@@ -812,18 +1084,22 @@ async def update_view_config(request: Request, view_id: int):
raise HTTPException(status_code=404, detail="View not found")
config = json.loads(existing["config_json"])
for key in ("group_by", "card_size", "cover_property", "visible_properties",
"filters", "sorts", "filter_conjunction", "date_property", "date_range_property"):
for key in ("group_by", "sub_group_by", "wip_limits", "card_size", "cover_property",
"cover_mode", "card_properties", "visible_properties", "filters", "sorts",
"filter_conjunction", "date_property", "date_range_property",
"property_groups", "view_type"):
if key in body:
config[key] = body[key]
new_type = body.get("view_type") or existing["view_type"]
conn.execute(
"UPDATE collection_views SET config_json=?, name=COALESCE(?, name) WHERE id=?",
(json.dumps(config), body.get("name"), view_id),
"UPDATE collection_views SET config_json=?, name=COALESCE(?, name), view_type=?, "
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(config), body.get("name"), new_type, view_id),
)
conn.commit()
return {"id": view_id, "status": "updated", "config": config}
return {"id": view_id, "status": "updated", "config": config, "view_type": new_type}
@router.post("/{collection_id}/views/save-as")
@@ -836,6 +1112,8 @@ async def save_view_as(request: Request, collection_id: int):
name = body.get("name", "New View")
config = body.get("config", {})
user = _current_user(request)
user_id = user.get("id") if user else None
with get_conn() as conn:
coll = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
@@ -847,26 +1125,90 @@ async def save_view_as(request: Request, collection_id: int):
(collection_id,),
).fetchone()[0]
view_type = body.get("view_type", "table")
cur = conn.execute(
"INSERT INTO collection_views (collection_id, name, view_type, config_json, position) VALUES (?, ?, ?, ?, ?)",
(collection_id, name, body.get("view_type", "table"), json.dumps(config), max_pos),
"""INSERT INTO collection_views
(collection_id, name, view_type, config_json, position, created_by)
VALUES (?, ?, ?, ?, ?, ?)""",
(collection_id, name, view_type, json.dumps(config), max_pos, user_id),
)
conn.commit()
return {"id": cur.lastrowid, "name": name, "status": "saved"}
return {"id": cur.lastrowid, "name": name, "view_type": view_type,
"config_json": json.dumps(config), "created_by": user_id, "status": "saved"}
@router.get("/{collection_id}/views/api")
async def list_views_api(request: Request, collection_id: int):
"""API: list all views for a collection."""
"""API: list views for a collection visible to the current user.
Shared/legacy views (``created_by IS NULL``) are visible to everyone;
personal views (``created_by = user``) only to their owner.
"""
user = _current_user(request)
user_id = user.get("id") if user else None
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
if user_id is not None:
rows = conn.execute(
"""SELECT * FROM collection_views
WHERE collection_id=? AND (created_by IS NULL OR created_by=?)
ORDER BY position""",
(collection_id, user_id),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? AND created_by IS NULL ORDER BY position",
(collection_id,),
).fetchall()
return {"views": [dict(r) for r in rows]}
@router.delete("/views/{view_id}/api")
async def delete_view_api(request: Request, view_id: int):
"""API: delete a saved view."""
with get_conn() as conn:
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="View not found")
conn.execute("DELETE FROM collection_views WHERE id=?", (view_id,))
conn.commit()
return {"id": view_id, "status": "deleted"}
@router.post("/views/{view_id}/duplicate")
async def duplicate_view_api(request: Request, view_id: int):
"""API: duplicate a view (config + type), owned by the current user."""
try:
body = await request.json()
except Exception:
body = {}
with get_conn() as conn:
existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="View not found")
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_views WHERE collection_id=?",
(existing["collection_id"],),
).fetchone()[0]
user = _current_user(request)
user_id = user.get("id") if user else None
name = body.get("name") or (existing["name"] + " copy")
cur = conn.execute(
"""INSERT INTO collection_views
(collection_id, name, view_type, config_json, position, created_by)
VALUES (?, ?, ?, ?, ?, ?)""",
(existing["collection_id"], name, existing["view_type"],
existing["config_json"], max_pos, user_id),
)
conn.commit()
return {"id": cur.lastrowid, "name": name, "view_type": existing["view_type"],
"status": "duplicated"}
# ── v1.8.0 Sub-items & Dependencies ──
@@ -903,9 +1245,17 @@ async def create_sub_item(request: Request, collection_id: int, page_id: int):
(page_id,),
).fetchone()[0]
sub_props = body.get("properties", {}) or {}
apply_auto_properties(
_collection_properties(conn, collection_id),
sub_props,
_current_user(request),
is_create=True,
)
cur = conn.execute(
"INSERT INTO collection_pages (collection_id, title, parent_id, position, property_values_json) VALUES (?, ?, ?, ?, ?)",
(collection_id, title, page_id, max_pos, json.dumps(body.get("properties", {}))),
(collection_id, title, page_id, max_pos, json.dumps(sub_props)),
)
conn.commit()
new_id = cur.lastrowid
@@ -2036,6 +2386,7 @@ async def create_page_api(request: Request, collection_id: int):
icon = body.get("icon", "📄")
property_values = body.get("properties", {})
cover_url = body.get("cover_url", "")
gitea_issue_id = body.get("gitea_issue_id")
gitea_issue_number = body.get("gitea_issue_number")
@@ -2045,6 +2396,13 @@ async def create_page_api(request: Request, collection_id: int):
raise HTTPException(status_code=404, detail="Collection not found")
_validate_page_properties(conn, collection_id, property_values)
_validate_meta_keys(conn, collection_id, property_values)
apply_auto_properties(
_collection_properties(conn, collection_id),
property_values,
_current_user(request),
is_create=True,
)
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
@@ -2053,9 +2411,9 @@ async def create_page_api(request: Request, collection_id: int):
cur = conn.execute(
"""INSERT INTO collection_pages
(collection_id, title, icon, position, gitea_issue_id, gitea_issue_number, property_values_json)
VALUES (?, ?, ?, ?, ?, ?, ?)""",
(collection_id, title, icon, max_pos, gitea_issue_id, gitea_issue_number,
(collection_id, title, icon, cover_url, position, gitea_issue_id, gitea_issue_number, property_values_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(collection_id, title, icon, cover_url, max_pos, gitea_issue_id, gitea_issue_number,
json.dumps(property_values)),
)
conn.commit()
+202 -25
View File
@@ -338,7 +338,17 @@ async def view_page_root(request: Request, page_id: int):
).fetchone()
# Build page_data, including file metadata for uploaded files
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0))}
_locked = bool(page.get("is_locked", 0))
_locked_by = page.get("locked_by") if "locked_by" in page else None
_sess_user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
_uid = _sess_user.get("id") if _sess_user and _sess_user.get("id") else None
_can_edit = (not _locked) or bool(_sess_user and _sess_user.get("is_admin")) or (_locked_by and _uid and _locked_by == _uid)
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)),
"is_locked": _locked,
"locked_by": _locked_by,
"can_edit": _can_edit,
"full_width": bool(page.get("full_width", 0)) if "full_width" in page else False,
"font_small": bool(page.get("font_small", 0)) if "font_small" in page else False}
# For file pages, extract file metadata and add to page_data
if page.get("content_format") == "file":
@@ -385,10 +395,17 @@ async def view_page_root(request: Request, page_id: int):
(page["collection_id"],),
).fetchall()
]
cviews = [
dict(r) for r in conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
(page["collection_id"],),
).fetchall()
]
collection_data = {
"collection": dict(col),
"properties": props,
"pages": cpages,
"views": cviews,
}
page_data["collection_id"] = page["collection_id"]
@@ -537,6 +554,16 @@ async def help_page(request: Request):
<li><b>My Tasks</b> — Aggregated tasks from all collections</li>
</ul>
</div>
<div class="help-card">
<h3><span class="icon">📶</span>Offline & PWA</h3>
<p>Install FlowDeck as an app and keep working without a connection.</p>
<ul>
<li><b>Install</b> — browser menu → <i>Install app</i> / <i>Add to Home Screen</i></li>
<li>Edits made offline are queued locally and synced automatically</li>
<li>A <b>⟳</b> marker shows pages with pending changes</li>
</ul>
</div>
</div>
<div class="help-section">
@@ -562,6 +589,25 @@ FlowDeck supports three authentication methods:<br>
</p>
</div>
<div class="help-section">
<h2>📶 Offline mode (PWA)</h2>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
FlowDeck is a Progressive Web App: pages you visited stay available offline and your
edits are saved locally, then synchronised when the connection returns.<br><br>
<b>Install:</b> open your browser menu and choose <i>Install app</i> (Chrome/Edge) or
<i>Add to Home Screen</i> (Safari/iOS). FlowDeck then opens in its own window.<br>
<b>Offline editing:</b> while offline, the editor stores changes in the browser
(IndexedDB) and shows an offline banner with the number of pending changes. A
<b>⟳</b> icon appears next to pages that have unsynced edits.<br>
<b>Reconnection:</b> the queue is replayed automatically (and via Background Sync).
A spinner badge appears while syncing, followed by a confirmation toast.<br>
<b>Conflicts:</b> if a page changed on the server, the latest edit wins and a notice is
shown. If a page was deleted server-side, your offline copy is recreated as an orphan
page. If a page with the same title already exists, the offline copy is renamed
<i>“Title (copie offline)”</i>.
</p>
</div>
<div class="help-section">
<h2>💡 Tips</h2>
<p style="color:var(--text-dim);font-size:14px;line-height:1.6;">
@@ -969,6 +1015,7 @@ async def local_workspace_page(request: Request, folder: int = None):
"workspace_id": ws_id or 0,
"nav_workspace_id": ws_id or 0,
"breadcrumb": breadcrumb,
"breadcrumbs": breadcrumb,
}
template = env.get_template("local_workspace.html")
return HTMLResponse(
@@ -1021,6 +1068,92 @@ async def get_page_content(page_id: int):
return {"content": row["content"] or "", "format": fmt}
def _file_page_disk_path(page: dict):
"""Resolve the on-disk file behind a ``content_format == 'file'`` page.
Returns ``(abs_path: Path, filename: str, mime: str, size: int)`` or None
when the row is not a file page, references a non-textual/missing file, or
the path escapes the data root (path-traversal guard).
"""
if (page.get("content_format") or "") != "file":
return None
import json as _json
try:
meta = _json.loads(page.get("content", "{}"))
except (_json.JSONDecodeError, TypeError):
meta = {}
if not isinstance(meta, dict):
return None
rel = (meta.get("file_path") or "").replace("\\", "/").strip()
if not rel or not rel.startswith("uploads/"):
return None
parts = rel.split("/")
if ".." in parts or "." in parts:
return None
import os as _os
from pathlib import Path
root = Path(_os.environ.get("FLOWDECK_DATA_DIR", "/data")).resolve()
full = (root / rel).resolve()
try:
full.relative_to(root)
except ValueError:
return None
if not full.exists() or not full.is_file():
return None
filename = parts[-1] or page.get("title", "file")
mime = meta.get("mime_type") or "application/octet-stream"
size = meta.get("size") or 0
return (full, filename, mime, size)
@router.get("/api/pages/{page_id}/download")
async def download_page_file(page_id: int):
"""Download the original uploaded file of a ``file`` page (attachment)."""
with get_conn() as conn:
row = conn.execute(
"SELECT id, title, content, content_format FROM pages "
"WHERE id=? AND deleted_at IS NULL",
(page_id,),
).fetchone()
if not row:
return JSONResponse({"error": "Not found"}, status_code=404)
resolved = _file_page_disk_path(dict(row))
if not resolved:
return JSONResponse({"error": "No downloadable file"}, status_code=404)
full, filename, mime, _size = resolved
from fastapi.responses import FileResponse
return FileResponse(
str(full), media_type=mime or "application/octet-stream",
filename=filename, content_disposition_type="attachment",
)
@router.get("/api/pages/{page_id}/file-content")
async def page_file_content(page_id: int):
"""Return the textual content of a ``file`` page (for copy to clipboard).
Binary files (PDF, images…) answer ``{ok: false}`` — the clipboard copy is
only meaningful for plain-text / code / markdown files.
"""
from app.services.export import _file_text
with get_conn() as conn:
row = conn.execute(
"SELECT id, title, content, content_format FROM pages "
"WHERE id=? AND deleted_at IS NULL",
(page_id,),
).fetchone()
if not row:
return JSONResponse({"error": "Not found"}, status_code=404)
page = dict(row)
text = _file_text(page)
if text is None:
return JSONResponse(
{"ok": False, "error": "Not a textual file", "name": page.get("title", "")},
status_code=415,
)
return {"ok": True, "name": page.get("title") or "File", "content": text}
@router.get("/api/local-workspace/breadcrumb")
async def local_workspace_breadcrumb(request: Request, folder: int):
"""Return breadcrumb trail for a folder."""
@@ -1249,7 +1382,7 @@ async def nav_menu(request: Request, workspace_id: int = None, parent_id: int =
async def create_local_workspace_item(request: Request):
"""Create a new file in the active workspace."""
body = await request.json()
name = body.get("name", "Untitled").strip()
name = (body.get("name") or "").strip() or "Untitled"
item_type = body.get("type", "page")
parent_id = body.get("parent_id")
explicit_ws_id = body.get("workspace_id")
@@ -1308,8 +1441,10 @@ async def restore_local_workspace_item(request: Request, item_id: int):
async def serve_uploaded_file(ws_id: int, filename: str):
"""Serve an uploaded file from disk."""
import mimetypes
import os
from pathlib import Path
base_dir = Path(f"/data/uploads/workspace_{ws_id}").resolve()
root = Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
base_dir = (root / f"uploads/workspace_{ws_id}").resolve()
fp = (base_dir / filename).resolve()
try:
fp.relative_to(base_dir)
@@ -1794,14 +1929,17 @@ async def list_all_tags_global(request: Request):
"WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name",
(uid,),
).fetchall()
return {"tags": [dict(r) for r in rows]}
return JSONResponse(
{"tags": [dict(r) for r in rows]},
headers={"Cache-Control": "no-store"},
)
# ═══════════ Workspace Tags API ═══════════
@router.get("/api/local-workspace/tags")
async def list_tags(request: Request):
"""List tags used in the active workspace (current user's tags only)."""
"""List ALL user tags with counts scoped to the active workspace."""
ws = _get_active_workspace(request, user_id=_get_user_id(request))
ws_id = ws["id"] if ws else None
uid = _get_user_id(request)
@@ -1809,14 +1947,17 @@ async def list_tags(request: Request):
return {"tags": []}
with get_conn() as conn:
rows = conn.execute(
"SELECT DISTINCT t.id, t.name, t.color, COUNT(pt.page_id) as count "
"FROM tags t "
"JOIN page_tags pt ON pt.tag_id = t.id "
"JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? "
"WHERE t.user_id = ? GROUP BY t.id ORDER BY t.name",
"SELECT t.id, t.name, t.color, "
"(SELECT COUNT(*) FROM page_tags pt "
" JOIN pages p ON p.id = pt.page_id AND p.workspace_id = ? "
" WHERE pt.tag_id = t.id) as count "
"FROM tags t WHERE t.user_id = ? ORDER BY t.name",
(ws_id, uid),
).fetchall()
return {"tags": [dict(r) for r in rows]}
return JSONResponse(
{"tags": [dict(r) for r in rows]},
headers={"Cache-Control": "no-store"},
)
@router.get("/api/local-workspace/items/{item_id:int}/tags")
@@ -2056,7 +2197,11 @@ h1{font-size:3rem;opacity:.3}</style></head><body><h1>404</h1></body></html>""",
import json as _json
try:
blocks = _json.loads(page["content"])
content_html = _render_blocks_public(blocks)
from app.db import get_conn as _gc
from app.services.wiki_links import token_labels
with _gc() as conn:
wiki_titles_map = token_labels(conn, page["content"])
content_html = _render_blocks_public(blocks, wiki_titles_map)
except (_json.JSONDecodeError, Exception):
content_html = f"<p>{page.get('content', '')}</p>"
elif page.get("content"):
@@ -2082,12 +2227,23 @@ def _sanitize_id(block_id: str) -> str:
return "".join(ch for ch in str(block_id) if ch.isalnum())
def _render_blocks_public(blocks: list) -> str:
"""Render FlowDeck blocks as plain HTML for public pages."""
def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
"""Render FlowDeck blocks as plain HTML for public pages.
v5.11.0: ``titles`` (token → label, see app.services.wiki_links) turns
``[[fdpage:ID]]`` / ``[[fddate:...]]`` tokens into chips/links.
"""
html_parts = []
def _wiki(c: str) -> str:
if titles and ("[[fdpage:" in c or "[[fddate:" in c):
from app.services.wiki_links import resolve_tokens_html
return resolve_tokens_html(c, titles)
return c
for b in blocks:
t = b.get("type", "paragraph")
c = b.get("content", "") or ""
c = _wiki(b.get("content", "") or "")
if t == "heading_1":
html_parts.append(f'<h1 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:2.5rem;font-weight:700;margin:32px 0 8px;">{c}</h1>')
elif t == "heading_2":
@@ -2113,7 +2269,7 @@ def _render_blocks_public(blocks: list) -> str:
children_html = ""
if b.get("children"):
children_html = '<div style="margin-left:22px;padding-left:12px;border-left:1px solid rgba(255,255,255,.1);margin-top:4px;">'
children_html += _render_blocks_public(b["children"])
children_html += _render_blocks_public(b["children"], titles)
children_html += "</div>"
html_parts.append(
f'<details style="margin:8px 0;" open><summary style="cursor:pointer;font-weight:500;">{c}</summary>{children_html}</details>'
@@ -2151,7 +2307,7 @@ def _render_blocks_public(blocks: list) -> str:
cols_html += (
'<div style="flex:1;min-width:0;padding:10px 12px;background:rgba(255,255,255,.05);'
'border-radius:8px;box-sizing:border-box;">'
+ _render_blocks_public([child]) + "</div>"
+ _render_blocks_public([child], titles) + "</div>"
)
html_parts.append(
f'<div style="display:flex;gap:12px;margin:8px 0 16px;align-items:stretch;">{cols_html}</div>'
@@ -2230,7 +2386,7 @@ def _render_blocks_public(blocks: list) -> str:
)
elif url:
from app.services.embeds import embed_src
src = embed_src(url) or url
src = b.get("embed_src") or embed_src(url) or url
height = b.get("height") or 520
try:
height = int(height)
@@ -2383,10 +2539,18 @@ async def api_collection_table_data(collection_id: int):
).fetchall()
]
views = [
dict(r) for r in conn.execute(
"SELECT * FROM collection_views WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
]
return {
"collection": dict(coll),
"properties": properties,
"pages": pages,
"views": views,
}
@@ -2415,20 +2579,31 @@ async def api_create_collection_page(collection_id: int, request: Request):
).fetchone()[0]
# Load default property values from collection properties
props = conn.execute(
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
props = [
dict(r) for r in conn.execute(
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
]
default_values = {}
for p in props:
if p["prop_type"] == "title":
default_values[str(p["id"])] = title
# Caller-provided values (e.g. board "add card in column X") win.
incoming = body.get("properties") or {}
if isinstance(incoming, dict):
default_values.update(incoming)
from app.services.property_types import apply_auto_properties
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) or {"login": "admin", "id": 1}
apply_auto_properties(props, default_values, user, is_create=True)
cur = conn.execute(
"""INSERT INTO collection_pages
(collection_id, title, icon, position, property_values_json)
VALUES (?, ?, ?, ?, ?)""",
(collection_id, title, icon, max_pos, _json.dumps(default_values)),
(collection_id, title, icon, cover_url, position, property_values_json)
VALUES (?, ?, ?, ?, ?, ?)""",
(collection_id, title, icon, body.get("cover_url", ""), max_pos,
_json.dumps(default_values)),
)
page_id = cur.lastrowid
conn.commit()
@@ -2436,6 +2611,8 @@ async def api_create_collection_page(collection_id: int, request: Request):
return {
"id": page_id,
"title": title,
"icon": icon,
"position": max_pos,
"property_values_json": default_values,
"status": "created",
}
+100
View File
@@ -0,0 +1,100 @@
"""FlowDeck — custom workspace emojis (v5.6.0).
Uploaded emoji images stored per-workspace and usable as page icons. Kept on a
prefix-less router so the paths stay ``/api/custom-emojis`` (the board router's
``/{owner}/{repo}`` HTML catch-all would otherwise shadow them).
"""
from __future__ import annotations
import datetime
import re
from pathlib import Path
from fastapi import APIRouter, HTTPException, Request
from app.db import get_conn
router = APIRouter(tags=["emojis"])
_IMAGE_EXTS = {"png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "ico", "avif"}
def _upload_root() -> Path:
import os
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
def _active_ws(request: Request) -> int:
"""Workspace id from the active-workspace cookie, fallback 1."""
try:
ws_id = int(request.cookies.get("flowdeck_workspace", "") or 0)
if ws_id > 0:
return ws_id
except (ValueError, TypeError):
pass
return 1
@router.get("/api/custom-emojis")
async def list_custom_emojis(request: Request):
"""List the current workspace's custom emojis."""
ws_id = _active_ws(request)
with get_conn() as conn:
rows = conn.execute(
"SELECT id, name, url FROM custom_emojis WHERE workspace_id=? ORDER BY created_at DESC",
(ws_id,),
).fetchall()
return {"status": "ok", "emojis": [dict(r) for r in rows]}
@router.post("/api/custom-emojis")
async def create_custom_emoji(request: Request):
"""Upload a custom emoji image (multipart: ``name`` + ``file``)."""
form = await request.form()
name = (form.get("name") or "").strip()[:40] or "emoji"
upload = form.get("file")
if upload is None or not hasattr(upload, "filename"):
raise HTTPException(400, "file field required")
original = (upload.filename or "emoji.png").replace("\\", "/").rsplit("/", 1)[-1]
safe = re.sub(r"[^A-Za-z0-9._-]", "_", original)[:80]
ext = safe.rsplit(".", 1)[-1].lower() if "." in safe else "png"
if ext not in _IMAGE_EXTS:
raise HTTPException(400, "Unsupported image format")
ws_id = _active_ws(request)
stamp = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S%f")
folder = _upload_root() / f"uploads/workspace_{ws_id}"
folder.mkdir(parents=True, exist_ok=True)
final = f"emoji_{stamp}_{safe}"
(folder / final).write_bytes(await upload.read())
url = f"/api/files/{ws_id}/{final}"
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO custom_emojis (workspace_id, name, url) VALUES (?, ?, ?)",
(ws_id, name, url),
)
conn.commit()
emoji_id = cur.lastrowid
return {"status": "ok", "emoji": {"id": emoji_id, "name": name, "url": url}}
@router.delete("/api/custom-emojis/{emoji_id}")
async def delete_custom_emoji(request: Request, emoji_id: int):
"""Delete a custom emoji (and its stored file)."""
ws_id = _active_ws(request)
with get_conn() as conn:
row = conn.execute(
"SELECT url FROM custom_emojis WHERE id=? AND workspace_id=?",
(emoji_id, ws_id),
).fetchone()
if not row:
raise HTTPException(404, "emoji not found")
conn.execute("DELETE FROM custom_emojis WHERE id=?", (emoji_id,))
conn.commit()
try:
fname = (row["url"] or "").rsplit("/", 1)[-1]
if fname:
(_upload_root() / f"uploads/workspace_{ws_id}" / fname).unlink(missing_ok=True)
except OSError:
pass
return {"status": "ok", "id": emoji_id}
+378
View File
@@ -0,0 +1,378 @@
"""FlowDeck — unified import API (v5.6.0, Phase 0/1/2).
Exposes the importer registry, a dry-run preview, a synchronous run and an
optional background job with polling. Works with the existing workspace cookie
(``flowdeck_workspace``) and session.
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.automations import fire_event
from app.services.importers import (
get_job,
list_jobs,
list_sources,
parse_upload,
preview_result,
resolve_relations,
run_import,
start_import_job,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/import", tags=["import"])
page_router = APIRouter(tags=["import"])
MAX_UPLOAD_BYTES = 200 * 1024 * 1024
def _current_user(request: Request) -> dict:
return SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) or {}
@page_router.get("/import", response_class=HTMLResponse)
async def import_page(request: Request):
"""Standalone import wizard (source picker, dry-run, mapping, progress)."""
user = _current_user(request)
if not user:
return RedirectResponse("/auth/login?provider=local", status_code=302)
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
return HTMLResponse(content=env.get_template("import.html").render(user=user))
def _workspace(request: Request) -> tuple[int | None, str]:
"""Resolve (workspace_id, login) from the workspace cookie + session."""
ws_id: int | None = None
cookie = request.cookies.get("flowdeck_workspace", "")
try:
value = int(cookie)
if value > 0:
ws_id = value
except (ValueError, TypeError):
pass
user = _current_user(request)
login = user.get("login", "") if user else ""
return ws_id, login
async def _read_upload(request: Request) -> tuple[str, bytes, str | None]:
form = await request.form()
upload = form.get("file")
if upload is None or not hasattr(upload, "filename"):
raise HTTPException(400, "file field required")
filename = (upload.filename or "import").replace("\\", "/").rsplit("/", 1)[-1]
data = await upload.read()
if len(data) > MAX_UPLOAD_BYTES:
raise HTTPException(413, "File too large (max 200 MB)")
source_id = form.get("source") or None
return filename, data, source_id
@router.get("/sources")
async def import_sources(request: Request):
"""List every available importer for the UI source picker."""
return {"sources": list_sources()}
@router.post("/preview")
async def import_preview(request: Request):
"""Dry-run: parse the upload and describe what would be created."""
filename, data, source_id = await _read_upload(request)
imp, result = parse_upload(filename, data, source_id)
if imp is None:
raise HTTPException(400, "Format non reconnu — choisissez une source")
out = preview_result(result)
out["detected_source"] = imp.source_id
out["source_label"] = imp.label
return out
@router.post("/run")
async def import_run(request: Request):
"""Import an upload (synchronously, or as a background job when async=true)."""
form = await request.form()
upload = form.get("file")
if upload is None or not hasattr(upload, "filename"):
raise HTTPException(400, "file field required")
filename = (upload.filename or "import").replace("\\", "/").rsplit("/", 1)[-1]
data = await upload.read()
if len(data) > MAX_UPLOAD_BYTES:
raise HTTPException(413, "File too large (max 200 MB)")
source_id = form.get("source") or None
parent_id = _int_or_none(form.get("parent_id"))
target = _int_or_none(form.get("target_collection_id"))
dedup = str(form.get("dedup", "true")).lower() not in ("false", "0", "no")
async_mode = str(form.get("async", "false")).lower() in ("true", "1", "yes")
mapping = _parse_mapping(form.get("mapping"))
mode = _parse_mode(form.get("mode"))
ws_id, login = _workspace(request)
if async_mode:
job = start_import_job(
filename=filename, data=data, source_id=source_id,
workspace_id=ws_id, workspace_name=login, user_login=login,
parent_page_id=parent_id, target_collection_id=target, dedup=dedup,
mapping=mapping, mode=mode,
)
return {"status": "queued", "job_id": job["id"]}
imp, result = parse_upload(filename, data, source_id)
if imp is None:
raise HTTPException(400, "Format non reconnu — choisissez une source")
report = run_import(
result, workspace_id=ws_id, workspace_name=login, user_login=login,
parent_page_id=parent_id, target_collection_id=target, dedup=dedup,
mapping=mapping, mode=mode,
)
report["detected_source"] = imp.source_id
if imp.source_id == "notion":
with get_conn() as conn:
report["relations"] = resolve_relations(conn, ws_id)
for page_id in report.get("page_ids", [])[:100]:
try:
await fire_event("page.created", {"page_id": page_id, "title": "", "workspace": login})
except Exception: # noqa: BLE001 - events are best-effort
pass
return report
@router.post("/forge")
async def import_forge(request: Request):
"""Import a forge repo's issues (+ labels/milestones) into collections."""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
provider = str(body.get("provider") or "gitea").lower()
owner = str(body.get("owner") or "").strip()
repo = str(body.get("repo") or "").strip()
if not owner or not repo:
raise HTTPException(400, "owner and repo are required")
state = str(body.get("state") or "all")
include_labels = bool(body.get("include_labels", True))
include_milestones = bool(body.get("include_milestones", True))
ws_id, login = _workspace(request)
if provider == "gitea":
from app.services.gitea_client import get_user_gitea_client
from app.services.importers.forge import GiteaForgeAdapter
client = get_user_gitea_client(request)
if client is None:
raise HTTPException(400, "Gitea non connecté")
adapter = GiteaForgeAdapter(client)
elif provider == "github":
from app.services.github_adapter import GitHubAdapter
token = _user_oauth_token(request, "github")
if not token:
raise HTTPException(400, "GitHub non connecté")
adapter = GitHubAdapter(token)
else:
raise HTTPException(400, "provider must be 'gitea' or 'github'")
from app.services.importers.forge import fetch_forge_issues
result = await fetch_forge_issues(
adapter, owner, repo, provider=provider, state=state,
include_labels=include_labels, include_milestones=include_milestones,
)
report = run_import(
result, workspace_id=ws_id, workspace_name=login, user_login=login,
)
report["detected_source"] = f"forge:{provider}"
return report
@router.post("/forge-repo")
async def import_forge_repo(request: Request):
"""Import a forge repo's text files as pages (folder hierarchy preserved)."""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
provider = str(body.get("provider") or "gitea").lower()
owner = str(body.get("owner") or "").strip()
repo = str(body.get("repo") or "").strip()
if not owner or not repo:
raise HTTPException(400, "owner and repo are required")
path = str(body.get("path") or "")
max_files = min(int(body.get("max_files") or 200), 1000)
ws_id, login = _workspace(request)
adapter = _forge_adapter(request, provider)
from app.services.importers.forge_repo import fetch_forge_repo
result = await fetch_forge_repo(
adapter, owner, repo, provider=provider, path=path, max_files=max_files,
)
report = run_import(result, workspace_id=ws_id, workspace_name=login, user_login=login)
report["detected_source"] = f"forge-repo:{provider}"
return report
@router.post("/url")
async def import_url(request: Request):
"""Web clipper: fetch a URL and create a page (bookmark card + content)."""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body") from None
url = str(body.get("url") or "").strip()
if not url:
raise HTTPException(400, "url is required")
ws_id, login = _workspace(request)
from app.services.importers.url_fetch import fetch_url_result
try:
result = await fetch_url_result(url)
except ValueError as exc:
raise HTTPException(400, str(exc)) from None
if not result.pages:
raise HTTPException(422, "; ".join(result.warnings) or "Page introuvable")
report = run_import(result, workspace_id=ws_id, workspace_name=login, user_login=login)
report["detected_source"] = "url"
return report
@router.post("/run-batch")
async def import_run_batch(request: Request):
"""Import several uploaded files sequentially, returning one report each."""
form = await request.form()
uploads = form.getlist("file")
if not uploads:
raise HTTPException(400, "file field required")
source_id = form.get("source") or None
parent_id = _int_or_none(form.get("parent_id"))
target = _int_or_none(form.get("target_collection_id"))
dedup = str(form.get("dedup", "true")).lower() not in ("false", "0", "no")
mode = _parse_mode(form.get("mode"))
mapping = _parse_mapping(form.get("mapping"))
ws_id, login = _workspace(request)
results: list[dict] = []
summary = {"files": 0, "pages_created": 0, "rows_created": 0, "errors": 0}
for upload in uploads:
filename = (getattr(upload, "filename", "") or "import").replace("\\", "/").rsplit("/", 1)[-1]
data = await upload.read()
if len(data) > MAX_UPLOAD_BYTES:
results.append({"filename": filename, "report": {"status": "error",
"errors": [{"title": filename, "error": "File too large"}]}})
summary["errors"] += 1
continue
imp, result = parse_upload(filename, data, source_id)
if imp is None:
results.append({"filename": filename, "report": {"status": "error",
"errors": [{"title": filename, "error": "Format non reconnu"}]}})
summary["errors"] += 1
continue
report = run_import(
result, workspace_id=ws_id, workspace_name=login, user_login=login,
parent_page_id=parent_id, target_collection_id=target, dedup=dedup,
mapping=mapping, mode=mode,
)
report["detected_source"] = imp.source_id
results.append({"filename": filename, "report": report})
summary["files"] += 1
summary["pages_created"] += report.get("pages_created", 0)
summary["rows_created"] += report.get("rows_created", 0)
summary["errors"] += len(report.get("errors", []))
return {"status": "ok", "summary": summary, "results": results}
@router.post("/relations/resolve")
async def import_resolve_relations(request: Request):
"""Convert text columns referencing another collection into relation props."""
ws_id, _ = _workspace(request)
with get_conn() as conn:
return resolve_relations(conn, ws_id)
@router.get("/jobs")
async def import_jobs(request: Request):
return {"jobs": list_jobs()}
@router.get("/jobs/{job_id}")
async def import_job(job_id: str):
job = get_job(job_id)
if not job:
raise HTTPException(404, "Job not found")
return job
@router.get("/jobs/{job_id}/report")
async def import_job_report(job_id: str):
"""Download a job's import report as JSON."""
job = get_job(job_id)
if not job:
raise HTTPException(404, "Job not found")
payload = json.dumps(job.get("report") or {}, ensure_ascii=False, indent=2)
return Response(
content=payload,
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="import-{job_id}.json"'},
)
def _forge_adapter(request: Request, provider: str):
if provider == "gitea":
from app.services.gitea_client import get_user_gitea_client
from app.services.importers.forge import GiteaForgeAdapter
client = get_user_gitea_client(request)
if client is None:
raise HTTPException(400, "Gitea non connecté")
return GiteaForgeAdapter(client)
if provider == "github":
from app.services.github_adapter import GitHubAdapter
token = _user_oauth_token(request, "github")
if not token:
raise HTTPException(400, "GitHub non connecté")
return GitHubAdapter(token)
raise HTTPException(400, "provider must be 'gitea' or 'github'")
def _int_or_none(value) -> int | None:
try:
ivalue = int(value)
return ivalue if ivalue > 0 else None
except (ValueError, TypeError):
return None
def _parse_mapping(value) -> dict[str, str] | None:
if not value:
return None
try:
parsed = json.loads(value)
except (ValueError, TypeError):
return None
if isinstance(parsed, dict):
return {str(k): str(v) for k, v in parsed.items() if v}
return None
def _parse_mode(value) -> str | None:
mode = str(value or "").strip().lower()
return mode if mode in ("skip", "update", "duplicate") else None
def _user_oauth_token(request: Request, provider: str) -> str:
user = _current_user(request)
if not user or not user.get("id"):
return ""
with get_conn() as conn:
row = conn.execute(
"SELECT access_token FROM user_oauth_tokens WHERE user_id=? AND provider=? "
"ORDER BY updated_at DESC LIMIT 1",
(user["id"], provider),
).fetchone()
return row["access_token"] if row else ""
+27 -1
View File
@@ -97,13 +97,39 @@ async def set_prefs(request: Request):
from app.services import notifications as notif
body = await request.json() if request.headers.get("content-type") else {}
prefs = notif.get_user_prefs(user["id"])
for key in ("comments", "mentions"):
for key in ("comments", "mentions", "reminders", "assignments"):
if key in body:
prefs[key] = bool(body[key])
notif.set_user_prefs(user["id"], prefs)
return {"status": "ok", "prefs": prefs}
@router.get("/timezone")
async def get_timezone(request: Request):
"""Return the current user's IANA timezone ('' = UTC)."""
user = _current_user(request)
with get_conn() as conn:
row = conn.execute("SELECT timezone FROM users WHERE id=?", (user["id"],)).fetchone()
tz = (row["timezone"] if row and "timezone" in row.keys() else "") or ""
from app.services.recurrence import common_timezones
return {"timezone": tz, "zones": common_timezones()}
@router.post("/timezone")
async def set_timezone(request: Request):
"""Update the current user's IANA timezone (empty string = UTC)."""
user = _current_user(request)
body = await request.json() if request.headers.get("content-type") else {}
tz = (body.get("timezone") or "").strip()
from app.services.recurrence import is_valid_timezone
if tz and not is_valid_timezone(tz):
raise HTTPException(status_code=400, detail=f"Unknown timezone '{tz}'")
with get_conn() as conn:
conn.execute("UPDATE users SET timezone=? WHERE id=?", (tz, user["id"]))
conn.commit()
return {"status": "ok", "timezone": tz}
@router.get("/users/search")
async def search_users(request: Request, q: str = ""):
"""User autocomplete for @mentions."""
+88
View File
@@ -0,0 +1,88 @@
"""FlowDeck — /api/v2/sync endpoints (v6.0.0 PWA offline sync).
Pairs with ``app/services/sync_engine.py``. All routes require an authenticated
session (``flowdeck_session`` cookie).
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import JSONResponse
from app.auth.session import SessionManager
from app.services.sync_engine import SyncEngine
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v2/sync", tags=["sync"])
_engine = SyncEngine()
def _user(request: Request) -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
raise HTTPException(status_code=401, detail="Authentication required")
return user
@router.get("/delta")
async def sync_delta(
request: Request,
since: float = Query(default=0, description="Epoch seconds (ou ms) du dernier sync"),
workspace_id: int = Query(default=None),
):
"""Pull server-side changes since `since` (for the given workspace)."""
user = _user(request)
if workspace_id is None:
raise HTTPException(status_code=400, detail="workspace_id is required")
result = await _engine.get_delta(user["id"], since, workspace_id)
if result.get("error") == "forbidden":
return JSONResponse({"detail": "Forbidden"}, status_code=403)
return result
@router.post("/batch")
async def sync_batch(request: Request):
"""Apply a batch of offline mutations and return per-mutation results."""
user = _user(request)
try:
body = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON body") from None
mutations = body.get("mutations") or []
device_id = body.get("device_id") or "unknown"
if not isinstance(mutations, list) or not mutations:
return {"results": [], "conflicts": [], "server_time": SyncEngine._now_epoch()}
result = await _engine.apply_batch(user["id"], mutations, device_id)
result["server_time"] = SyncEngine._now_epoch()
return result
@router.get("/status")
async def sync_status(request: Request, workspace_id: int = Query(default=None)):
"""Synchronization status for the workspace (pending server queue, last sync)."""
user = _user(request)
from app.db import get_conn
with get_conn() as conn:
if not SyncEngine._can_access(conn, user["id"], workspace_id):
return JSONResponse({"detail": "Forbidden"}, status_code=403)
pending = conn.execute(
"SELECT COUNT(*) AS n FROM offline_sync_queue WHERE user_id=? AND status='pending'",
(user["id"],),
).fetchone()["n"]
last = conn.execute(
"SELECT MAX(created_at) AS last FROM offline_sync_queue "
"WHERE user_id=? AND status='synced'",
(user["id"],),
).fetchone()["last"]
return {
"pending_count": pending,
"last_sync": last,
"is_syncing": False,
"server_time": SyncEngine._now_epoch(),
"workspace_id": workspace_id,
}
+103
View File
@@ -0,0 +1,103 @@
"""FlowDeck — Built-in page (block) templates (v5.12.0).
Global page templates used by the « + New page » picker. Built-ins live here
(code, versioned); user templates live in the ``page_global_templates``
table. Block shapes match the editor's storage format (see
``app/routers/board.py::save_page_blocks``) — ids are assigned client-side.
"""
from __future__ import annotations
import json
def _b(btype: str, content: str = "", **extra) -> dict:
out = {"type": btype, "content": content}
out.update(extra)
return out
BUILTIN_TEMPLATES: dict[str, dict] = {
"empty": {
"name": "Empty",
"icon": "📄",
"description": "A blank page.",
"blocks": [_b("paragraph")],
},
"meeting_notes": {
"name": "Meeting notes",
"icon": "🗒️",
"description": "Attendees, agenda, notes, action items.",
"blocks": [
_b("heading_1", "Meeting notes"),
_b("callout", "Date: · Time: · Attendees: ", icon="📅"),
_b("heading_2", "Agenda"),
_b("bulleted_list", "Topic 1"),
_b("bulleted_list", "Topic 2"),
_b("heading_2", "Notes"),
_b("paragraph"),
_b("heading_2", "Decisions"),
_b("bulleted_list"),
_b("heading_2", "Action items"),
_b("to_do", "Owner — due date", checked=False),
_b("to_do", "", checked=False),
],
},
"weekly_report": {
"name": "Weekly report",
"icon": "📊",
"description": "Wins, in progress, blockers, next week.",
"blocks": [
_b("heading_1", "Week of [[fddate:2026-01-05]]"),
_b("heading_2", "🎉 Wins"),
_b("bulleted_list"),
_b("heading_2", "🚧 In progress"),
_b("bulleted_list"),
_b("heading_2", "⛔ Blockers"),
_b("bulleted_list"),
_b("heading_2", "🗓️ Next week"),
_b("to_do", "", checked=False),
],
},
"todo_list": {
"name": "To-do list",
"icon": "✅",
"description": "A simple checklist.",
"blocks": [
_b("heading_1", "To-do"),
_b("to_do", "", checked=False),
_b("to_do", "", checked=False),
_b("to_do", "", checked=False),
],
},
"project_doc": {
"name": "Project doc",
"icon": "🚀",
"description": "Goals, status, team, links.",
"blocks": [
_b("heading_1", "Project title"),
_b("callout", "One-line description of the project.", icon="💡"),
_b("heading_2", "Goals"),
_b("numbered_list"),
_b("heading_2", "Status"),
_b("toggle", "This week", expanded=True, children=[_b("paragraph")]),
_b("heading_2", "Team"),
_b("bulleted_list"),
_b("heading_2", "Resources"),
_b("bulleted_list"),
],
},
}
def template_list() -> list[dict]:
"""Public shape of the built-in templates for the picker UI."""
return [
{"key": key, "name": t["name"], "icon": t["icon"],
"description": t["description"], "builtin": True}
for key, t in BUILTIN_TEMPLATES.items()
]
def blocks_json_for(key: str) -> str | None:
t = BUILTIN_TEMPLATES.get(key)
return json.dumps(t["blocks"]) if t else None
+3 -1
View File
@@ -95,7 +95,7 @@ SEED_TEMPLATES: list[dict] = [
{
"name": "Meeting notes",
"icon": "🗒️",
"description": "Notes de réunion avec participants et décisions.",
"description": "Notes de réunion avec participants, agenda, notes et actions.",
"schema": [
{"name": "Title", "type": "title"},
{"name": "Date", "type": "date"},
@@ -104,6 +104,8 @@ SEED_TEMPLATES: list[dict] = [
{"name": "Scheduled", "color": "gray"},
{"name": "Done", "color": "green"},
]},
{"name": "Agenda", "type": "text"},
{"name": "Notes", "type": "text"},
{"name": "Action items", "type": "multi_select"},
],
},
+146 -62
View File
@@ -1,9 +1,10 @@
"""FlowDeck — Media embeds (v5.5.0): provider detection + iframe rewriting.
Maps a raw http(s) URL to a provider-specific embed URL so that one generic
``embed`` block can render YouTube, Vimeo, Figma, Google Maps, Loom,
CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter, Pinterest, Office
docs… exactly like Notion's universal embed.
``embed`` block can render YouTube, Vimeo, Figma, Google Maps, Google
Docs/Sheets/Slides, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch,
X/Twitter, Pinterest, Microsoft Office docs… exactly like Notion's universal
embed.
Unknown/showable URLs (PDF, images, direct video/audio files, plain http)
fall back to a plain iframe so the link is still visible inline.
@@ -11,11 +12,7 @@ fall back to a plain iframe so the link is still visible inline.
from __future__ import annotations
import re
from urllib.parse import parse_qs, urlparse
_PROVIDERS = (
# (host pattern, extra path check, to_embed(url, path, query) -> str | None)
)
from urllib.parse import parse_qs, quote, urlparse
def _q(params, key):
@@ -23,9 +20,21 @@ def _q(params, key):
return vals[0] if vals else ""
def _embed_youtube(url: str, path: str, params) -> str | None:
m = re.search(r"((?:v|shorts|embed)/|be/)([A-Za-z0-9_-]{6,20})", path)
vid = m.group(2) if m else _q(params, "v")
def _host_matches(netloc: str, host: str) -> bool:
"""True when ``netloc`` is ``host`` or one of its subdomains."""
netloc = (netloc or "").lower().split(":")[0]
host = host.lower()
return netloc == host or netloc.endswith("." + host)
def _embed_youtube(url: str, path: str, params, ctx: dict) -> str | None:
m = re.search(r"/(?:v|shorts|embed|live)/([A-Za-z0-9_-]{6,20})", path)
vid = m.group(1) if m else _q(params, "v")
if not vid:
# youtu.be/<id> (short link) — the id is the first path segment.
seg = path.strip("/").split("/")[0]
if re.fullmatch(r"[A-Za-z0-9_-]{6,20}", seg or ""):
vid = seg
if not vid:
return None
start = _q(params, "t") or _q(params, "start")
@@ -33,98 +42,122 @@ def _embed_youtube(url: str, path: str, params) -> str | None:
return f"https://www.youtube.com/embed/{vid}{frag}"
def _embed_vimeo(url: str, path: str, params) -> str | None:
def _embed_vimeo(url: str, path: str, params, ctx: dict) -> str | None:
m = re.search(r"/(\d{6,12})", path)
if not m:
return None
return f"https://player.vimeo.com/video/{m.group(1)}"
def _embed_loom(url: str, path: str, params) -> str | None:
m = re.search(r"/(embed/)?([0-9a-f]{32})", path)
def _embed_loom(url: str, path: str, params, ctx: dict) -> str | None:
m = re.search(r"/(?:embed/|share/)?([0-9a-f]{32})", path)
if not m:
return None
return f"https://www.loom.com/embed/{m.group(2)}"
return f"https://www.loom.com/embed/{m.group(1)}"
def _embed_figma(url: str, path: str, params) -> str | None:
if "figma.com/file/" not in url and "figma.com/proto/" not in url:
def _embed_figma(url: str, path: str, params, ctx: dict) -> str | None:
clean = url.split("?", 1)[0]
if "figma.com/file/" not in clean and "figma.com/proto/" not in clean and "figma.com/design/" not in clean:
return None
m = re.search(r"(/[^/]+/[^?]+)", url.split("?", 1)[0])
if not m:
return None
return "https://www.figma.com/embed?embed_host=flowdeck&url=" + url.split("?", 1)[0]
return "https://www.figma.com/embed?embed_host=flowdeck&url=" + quote(clean, safe="")
def _embed_map(url: str, path: str, params) -> str | None:
def _embed_map(url: str, path: str, params, ctx: dict) -> str | None:
if "google.com/maps" not in url and "maps.app.goo.gl" not in url:
return None
return f"https://www.google.com/maps?output=embed&q={url}"
return "https://maps.google.com/maps?q=" + quote(url, safe="") + "&output=embed"
def _embed_codepen(url: str, path: str, params) -> str | None:
def _embed_gdocs(url: str, path: str, params, ctx: dict) -> str | None:
m = re.search(
r"docs\.google\.com/(document|spreadsheets|presentation|forms)/d/([A-Za-z0-9_-]+)", url
)
if not m:
return None
kind, doc_id = m.group(1), m.group(2)
if kind == "forms":
return f"https://docs.google.com/forms/d/{doc_id}/viewform?embedded=true"
return f"https://docs.google.com/{kind}/d/{doc_id}/preview"
def _embed_codepen(url: str, path: str, params, ctx: dict) -> str | None:
m = re.search(r"codepen\.io/([^/]+)/pen/([^/?#]+)", url)
if not m:
return None
return f"https://codepen.io/{m.group(1)}/embed/{m.group(2)}?default-tab=result"
def _embed_miro(url: str, path: str, params) -> str | None:
m = re.search(r"miro\.com/app/board/([^/?#]+)", url)
def _embed_miro(url: str, path: str, params, ctx: dict) -> str | None:
m = re.search(r"miro\.com/app/(?:board|live-embed)/([^/?#]+)", url)
if not m:
return None
return f"https://miro.com/app/live-embed/{m.group(1)}"
def _embed_spotify(url: str, path: str, params) -> str | None:
def _embed_spotify(url: str, path: str, params, ctx: dict) -> str | None:
m = re.search(r"/(track|playlist|album|episode|show|artist)/([A-Za-z0-9]+)", url)
if not m:
return None
return f"https://open.spotify.com/embed/{m.group(1)}/{m.group(2)}"
def _embed_soundcloud(url: str, path: str, params) -> str | None:
m = re.search(r"(?:soundcloud\.com/[^/]+/[^/?#]+)", url)
if not m:
def _embed_soundcloud(url: str, path: str, params, ctx: dict) -> str | None:
if "soundcloud.com" not in url:
return None
return f"https://w.soundcloud.com/player/?url={url}&color=%2300aaff"
return "https://w.soundcloud.com/player/?url=" + quote(url, safe="") + "&color=%2300aaff"
def _embed_twitch(url: str, path: str, params) -> str | None:
def _embed_twitch(url: str, path: str, params, ctx: dict) -> str | None:
if "twitch.tv" not in url:
return None
parent = (ctx.get("parent") or "localhost").replace("https://", "").replace("http://", "").split("/")[0]
video = re.search(r"twitch\.tv/videos/(\d+)", url)
if video:
return f"https://player.twitch.tv/?video={video.group(1)}&parent={parent}"
m = re.search(r"twitch\.tv/([^/?#]+)", url)
if not m:
if not m or m.group(1) in ("videos", "directory"):
return None
return f"https://player.twitch.tv/?channel={m.group(1)}&parent="
return f"https://player.twitch.tv/?channel={m.group(1)}&parent={parent}"
def _embed_twitter(url: str, path: str, params) -> str | None:
def _embed_twitter(url: str, path: str, params, ctx: dict) -> str | None:
if "twitter.com" not in url and "x.com" not in url:
return None
m = re.search(r"/([^/?#]+)/status/(\d+)", url)
m = re.search(r"/status(?:es)?/(\d+)", url)
if not m:
return f"https://platform.twitter.com/embed/Tweet.html?url={url}"
return f"https://platform.twitter.com/embed/Tweet.html?id={m.group(2)}"
return f"https://platform.twitter.com/embed/Tweet.html?url={quote(url, safe='')}"
return f"https://platform.twitter.com/embed/Tweet.html?id={m.group(1)}"
def _embed_pinterest(url: str, path: str, params) -> str | None:
def _embed_pinterest(url: str, path: str, params, ctx: dict) -> str | None:
if "pinterest" not in url:
return None
return f"https://pinterest.com/pin/embed?url={url}"
return f"https://pinterest.com/pin/embed?url={quote(url, safe='')}"
def _embed_files(url: str, path: str, params) -> str | None:
def _embed_office(url: str, path: str, params, ctx: dict) -> str | None:
low = url.lower().split("?", 1)[0]
if low.endswith((".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods", ".odp")):
return "https://view.officeapps.live.com/op/embed.aspx?src=" + quote(url, safe="")
if "officeapps.live.com" in low or "sharepoint.com" in low or "1drv.ms" in low:
return "https://view.officeapps.live.com/op/embed.aspx?src=" + quote(url, safe="")
return None
def _embed_files(url: str, path: str, params, ctx: dict) -> str | None:
"""Direct media: PDF/images/videos/audio can live in a plain iframe."""
return url
# (host, handler) — order matters: more specific hosts first.
_HANDLERS = (
("youtube.com", _embed_youtube),
("youtu.be", _embed_youtube),
("vimeo.com", _embed_vimeo),
("loom.com", _embed_loom),
("figma.com", _embed_figma),
("docs.google.com", _embed_gdocs),
("google.com/maps", _embed_map),
("maps.app.goo.gl", _embed_map),
("codepen.io", _embed_codepen),
@@ -135,27 +168,54 @@ _HANDLERS = (
("twitch.tv", _embed_twitch),
("twitter.com", _embed_twitter),
("x.com", _embed_twitter),
("pinterest", _embed_pinterest),
("pinterest.", _embed_pinterest),
("office.com", _embed_office),
("officeapps.live.com", _embed_office),
("sharepoint.com", _embed_office),
("1drv.ms", _embed_office),
)
def embed_src(url: str) -> str | None:
_SCHEME_RE = re.compile(r"^([a-zA-Z][a-zA-Z0-9+.-]*):")
def _parse(url: str):
raw = url.strip()
if not raw:
return None, None, None
m = _SCHEME_RE.match(raw)
if m:
if m.group(1).lower() not in ("http", "https"):
return None, None, None # mailto:, tel:, javascript:, data:…
else:
raw = "https://" + raw
u = urlparse(raw)
if u.scheme not in ("http", "https") or not u.netloc:
return None, None, None
host = u.hostname or ""
if "." not in host and host != "localhost":
return None, None, None # a bare word is not a URL
return raw, u, parse_qs(u.query)
def embed_src(url: str, *, parent: str = "") -> str | None:
"""Return the embeddable iframe src for a URL, or None if it can't embed."""
if not url:
raw, u, params = _parse(url)
if raw is None:
return None
url = url.strip()
u = urlparse(url if url.startswith("http") else "https://" + url)
if u.scheme not in ("http", "https"):
return None
(u.hostname or "").lower().replace("www.", "")
ctx = {"parent": parent}
netloc = (u.netloc or "").lower()
params = parse_qs(u.query)
# Google Maps share links encode the query in the path (…&q=/maps/…)
encoded = url
for needle, handler in _HANDLERS:
if needle in netloc:
return handler(encoded, u.path, params)
return _embed_files(encoded, u.path, params)
if "/" in needle or needle.endswith("."):
if needle in raw.lower():
return handler(raw, u.path, params, ctx)
elif _host_matches(netloc, needle):
return handler(raw, u.path, params, ctx)
# Office documents hosted on arbitrary domains.
office = _embed_office(raw, u.path, params, ctx)
if office:
return office
return _embed_files(raw, u.path, params, ctx)
_IMAGE_EXT = re.compile(r"\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$", re.I)
@@ -165,12 +225,12 @@ _AUDIO_EXT = re.compile(r"\.(mp3|wav|ogg|oga|m4a|flac|aac)$", re.I)
def inline_kind(url: str) -> str | None:
"""Best inline renderer for a URL: 'iframe' | 'image' | 'pdf' | 'video' |
"""Best inline renderer for a URL: 'iframe' | 'image' | 'pdf' | 'video'
| 'audio'. Returns None when the URL should open in a new tab."""
if not url:
raw, u, _params = _parse(url)
if raw is None:
return None
url = url.strip()
path = urlparse(url).path
path = u.path or ""
if _IMAGE_EXT.search(path):
return "image"
if _PDF_EXT.search(path):
@@ -179,9 +239,33 @@ def inline_kind(url: str) -> str | None:
return "video"
if _AUDIO_EXT.search(path):
return "audio"
if url.startswith(("http://", "https://")):
return "iframe"
return None
return "iframe"
def provider(url: str) -> str:
"""Human-readable provider name for a URL (used by the editor)."""
raw, u, _params = _parse(url)
if raw is None:
return ""
netloc = (u.netloc or "").lower()
for needle, _handler in _HANDLERS:
if "/" in needle or needle.endswith("."):
if needle in raw.lower():
return needle.split(".")[0].rstrip(".")
elif _host_matches(netloc, needle):
name = needle.split(".")[0]
return "youtube" if name == "youtu" else name
return ""
def resolve_embed(url: str, *, parent: str = "") -> dict:
"""Resolve a URL to ``{src, kind, provider}`` for the generic embed block."""
kind = inline_kind(url)
return {
"src": embed_src(url, parent=parent) or "",
"kind": kind or "",
"provider": provider(url),
}
def embed_html(src: str, *, height: int = 520) -> str:
+43 -16
View File
@@ -390,6 +390,20 @@ def _md_to_blocks(md: str) -> list:
blocks.append({"type": "divider", "content": ""})
i += 1
continue
if re.match(r"^\s*[-*+]\s+\[[ xX]\]\s+", line):
flush_para()
while i < n:
s = lines[i].strip()
m2 = re.match(r"^[-*+]\s+\[([ xX])\]\s+(.*)$", s)
if not m2:
break
blocks.append({
"type": "to_do",
"content": m2.group(2).strip(),
"checked": m2.group(1).lower() == "x",
})
i += 1
continue
if re.match(r"^\s*[-*+]\s+", line):
flush_para()
while i < n:
@@ -410,21 +424,6 @@ def _md_to_blocks(md: str) -> list:
blocks.append({"type": "numbered_list", "content": m2.group(1).strip()})
i += 1
continue
mtodo = re.match(r"^\s*[-*+]\s+\[([ xX])\]\s+(.*)$", stripped)
if mtodo:
flush_para()
while i < n:
s = lines[i].strip()
m2 = re.match(r"^[-*+]\s+\[([ xX])\]\s+(.*)$", s)
if not m2:
break
blocks.append({
"type": "to_do",
"content": m2.group(2).strip(),
"checked": m2.group(1).lower() == "x",
})
i += 1
continue
mq = re.match(r"^>\s?(.*)$", stripped)
if mq and line == stripped:
flush_para()
@@ -530,6 +529,20 @@ def blocks_to_markdown(blocks: list) -> str:
out.append(f"[{url}]({url})" if url else "[embed]")
elif t == "table":
out.append(_table_to_markdown(b))
elif t == "synced":
synced_id = b.get("synced_id")
if synced_id:
try:
from app.services.synced_blocks import get_synced_block
sb = get_synced_block(synced_id)
if sb and sb.get("content"):
resolved = json.loads(sb["content"])
if isinstance(resolved, list):
out.append(blocks_to_markdown(resolved))
else:
out.append(str(resolved))
except Exception:
out.append(f"[Synced block {synced_id}]")
else:
out.append(c)
return "\n\n".join(filter(None, out))
@@ -665,7 +678,7 @@ def blocks_to_html(blocks: list) -> str:
parts.append(f'<iframe src="{_text(url)}" style="width:100%;height:70vh;border:none;border-radius:8px;"></iframe>')
elif url:
from app.services.embeds import embed_src
src = embed_src(url) or url
src = b.get("embed_src") or embed_src(url) or url
height = 520
if b.get("height"):
try:
@@ -679,6 +692,20 @@ def blocks_to_html(blocks: list) -> str:
)
elif t == "table":
parts.append(_table_to_html(b))
elif t == "synced":
synced_id = b.get("synced_id")
if synced_id:
try:
from app.services.synced_blocks import get_synced_block
sb = get_synced_block(synced_id)
if sb and sb.get("content"):
resolved = json.loads(sb["content"])
if isinstance(resolved, list):
parts.append(blocks_to_html(resolved))
else:
parts.append(f"<p>{_text(resolved)}</p>")
except Exception:
parts.append(f"<p>[Synced block {synced_id}]</p>")
else:
parts.append(f"<p>{c}</p>")
return "\n".join(parts)
+54
View File
@@ -209,6 +209,60 @@ class GitHubAdapter(ForgeAdapter):
resp.raise_for_status()
return resp.json()
# ── issues / labels / milestones ──
async def list_issues(self, owner: str, repo: str, state: str = "all") -> list[dict]:
"""List issues (pull requests are filtered out)."""
issues: list[dict] = []
for page in range(1, 6):
async with self._client() as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/issues",
headers=self._headers,
params={"state": state, "per_page": 100, "page": page},
)
resp.raise_for_status()
batch = resp.json()
if not batch:
break
issues.extend(i for i in batch if "pull_request" not in i)
if len(batch) < 100:
break
return issues
async def list_labels(self, owner: str, repo: str) -> list[dict]:
async with self._client() as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/labels",
headers=self._headers,
params={"per_page": 100},
)
resp.raise_for_status()
return resp.json()
async def list_milestones(self, owner: str, repo: str, state: str = "all") -> list[dict]:
async with self._client() as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/milestones",
headers=self._headers,
params={"state": state, "per_page": 100},
)
resp.raise_for_status()
return resp.json()
async def list_repo_files(self, owner: str, repo: str, path: str = "") -> list[dict]:
"""Flatten the repo tree into file entries (``path``, ``size``)."""
tree = await self.get_repo_tree(owner, repo)
prefix = path.strip("/")
files = [
{"path": item["path"], "size": item.get("size", 0)}
for item in tree
if item.get("type") == "blob" and item.get("path")
]
if prefix:
files = [f for f in files if f["path"].startswith(prefix + "/") or f["path"] == prefix]
return files
# ── token validation ──
async def validate_token(self) -> bool:
+58
View File
@@ -0,0 +1,58 @@
"""FlowDeck — importers package (v5.6.0).
Importing this package registers every built-in importer. Use
:func:`parse_upload` to detect a source and :func:`run_import` to persist it.
"""
from __future__ import annotations
from app.services.importers import ( # noqa: F401 - registration side effects
bookmarks,
calendar,
docx,
html_notes,
markdown,
notion,
obsidian,
opml,
outline,
pdf,
standard_notes,
tabular,
)
from app.services.importers.base import ( # noqa: F401
ImportAttachment,
Importer,
ImportPage,
ImportResult,
all_importers,
detect_importer,
get_importer,
list_sources,
)
from app.services.importers.jobs import ( # noqa: F401
create_job,
get_job,
list_jobs,
parse_upload,
start_import_job,
)
from app.services.importers.pipeline import preview_result, resolve_relations, run_import # noqa: F401
__all__ = [
"ImportAttachment",
"ImportPage",
"ImportResult",
"Importer",
"all_importers",
"detect_importer",
"get_importer",
"list_sources",
"create_job",
"get_job",
"list_jobs",
"parse_upload",
"start_import_job",
"preview_result",
"run_import",
"resolve_relations",
]
+106
View File
@@ -0,0 +1,106 @@
"""FlowDeck — shared helpers for note importers (frontmatter, wikilinks)."""
from __future__ import annotations
import re
from typing import Any
try: # PyYAML ships transitively via uvicorn[standard]
import yaml
except Exception: # pragma: no cover - fallback parser below
yaml = None
_FRONTMATTER_RE = re.compile(r"^\ufeff?---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
_WIKILINK_RE = re.compile(r"(!?)\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|([^\]]+))?\]\]")
def split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
"""Split YAML frontmatter from the body. Returns ``(metadata, body)``."""
m = _FRONTMATTER_RE.match(text)
if not m:
return {}, text
raw = m.group(1)
body = text[m.end():]
if yaml is not None:
try:
meta = yaml.safe_load(raw)
if isinstance(meta, dict):
return meta, body
except Exception: # noqa: BLE001 - fall back to the simple parser
pass
return _simple_yaml(raw), body
def _simple_yaml(raw: str) -> dict[str, Any]:
"""Minimal YAML subset parser (scalars, inline lists, block lists)."""
meta: dict[str, Any] = {}
current: str | None = None
for line in raw.splitlines():
if not line.strip() or line.lstrip().startswith("#"):
continue
if line.lstrip().startswith("- ") and current:
meta.setdefault(current, [])
if isinstance(meta[current], list):
meta[current].append(_scalar(line.lstrip()[2:].strip()))
continue
if ":" in line:
key, _, value = line.partition(":")
key = key.strip()
value = value.strip()
current = key
if not value:
meta[key] = []
elif value.startswith("[") and value.endswith("]"):
inner = value[1:-1].strip()
meta[key] = [_scalar(v.strip()) for v in inner.split(",") if v.strip()] if inner else []
else:
meta[key] = _scalar(value)
return meta
def _scalar(value: str) -> Any:
v = value.strip().strip('"').strip("'")
if v.lower() in ("true", "false"):
return v.lower() == "true"
if re.fullmatch(r"-?\d+", v):
return int(v)
if re.fullmatch(r"-?\d+\.\d+", v):
return float(v)
return v
def convert_wikilinks(text: str, *, embeds: bool = True) -> str:
"""Turn Obsidian/Logseq ``[[link]]`` into Markdown links and ``![[img]]``
into Markdown images so the block converter can render them."""
def repl(m: re.Match) -> str:
bang, target, alias = m.group(1), m.group(2).strip(), m.group(3)
label = (alias or target).strip()
if bang == "!":
return f"![{label}]({target})" if embeds else label
return f"[{label}]({target})"
return _WIKILINK_RE.sub(repl, text)
def normalize_title(value: Any) -> str:
return str(value).strip() if value is not None else ""
def coerce_tags(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, list):
return [str(v).strip().lstrip("#") for v in value if str(v).strip()]
if isinstance(value, str):
parts = re.split(r"[,\s]+", value)
return [p.strip().lstrip("#") for p in parts if p.strip()]
return [str(value)]
def strip_markdown(text: str) -> str:
text = re.sub(r"`{1,3}([^`]*)`{1,3}", r"\1", text)
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text)
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
text = re.sub(r"[*_~#>]+", "", text)
return text.strip()
+147
View File
@@ -0,0 +1,147 @@
"""FlowDeck — unified import framework (v5.6.0, Phase 0).
Defines the normalized data model shared by every importer and the registry
used to auto-detect a source. An :class:`Importer` turns an uploaded file into
an :class:`ImportResult` (pages, attachments, warnings, stats) which the
pipeline (:mod:`app.services.importers.pipeline`) persists into FlowDeck.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
def decode_text(data: bytes) -> str:
"""Best-effort decode of uploaded bytes (BOM aware, latin-1 fallback)."""
for enc in ("utf-8-sig", "utf-8", "utf-16", "latin-1"):
try:
return data.decode(enc)
except (UnicodeDecodeError, UnicodeError):
continue
return data.decode("utf-8", errors="replace")
@dataclass
class ImportAttachment:
"""A binary asset extracted from an archive/vault."""
source_path: str
filename: str
data: bytes = b""
mime: str = ""
@dataclass
class ImportPage:
"""One page to create. ``markdown`` is converted to blocks by the pipeline
unless ``blocks`` is already provided. ``collection`` marks a database
(Notion database, Excel sheet…) whose rows become ``collection_pages``."""
title: str = "Untitled"
markdown: str = ""
blocks: list[dict] = field(default_factory=list)
source_path: str = ""
parent_path: str = ""
properties: dict[str, Any] = field(default_factory=dict)
collection: dict[str, Any] | None = None
external_id: str = ""
page_id: int | None = None
@dataclass
class ImportResult:
"""Normalized output of any importer."""
source: str = ""
pages: list[ImportPage] = field(default_factory=list)
attachments: list[ImportAttachment] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
stats: dict[str, Any] = field(default_factory=dict)
def warn(self, message: str) -> None:
if message and message not in self.warnings:
self.warnings.append(message)
def finalize(self) -> ImportResult:
self.stats.setdefault("pages", len(self.pages))
self.stats.setdefault("collections", sum(1 for p in self.pages if p.collection))
self.stats.setdefault("attachments", len(self.attachments))
self.stats.setdefault("warnings", len(self.warnings))
return self
class Importer(ABC):
"""Base class for a source importer."""
source_id: str = ""
label: str = ""
description: str = ""
extensions: tuple[str, ...] = ()
order: int = 100
def detect(self, filename: str, data: bytes) -> bool:
"""Return True when this importer recognizes the uploaded file."""
return False
@abstractmethod
def parse(self, filename: str, data: bytes) -> ImportResult:
"""Parse the upload into a normalized :class:`ImportResult`."""
def info(self) -> dict[str, Any]:
return {
"source_id": self.source_id,
"label": self.label,
"description": self.description,
"extensions": list(self.extensions),
}
REGISTRY: list[Importer] = []
def register_importer(cls: type[Importer]) -> type[Importer]:
"""Class decorator registering an importer instance."""
REGISTRY.append(cls())
REGISTRY.sort(key=lambda i: i.order)
return cls
def all_importers() -> list[Importer]:
return list(REGISTRY)
def get_importer(source_id: str) -> Importer | None:
for imp in REGISTRY:
if imp.source_id == source_id:
return imp
return None
def detect_importer(filename: str, data: bytes) -> Importer | None:
"""First importer that recognizes the file, else None."""
for imp in REGISTRY:
try:
if imp.detect(filename, data):
return imp
except Exception: # noqa: BLE001 - a broken detector must not break detection
continue
return None
def list_sources() -> list[dict[str, Any]]:
return [imp.info() for imp in REGISTRY]
def make_collection(name: str, schema: list[dict], rows: list[dict],
*, source_path: str = "", external_id: str = "") -> ImportPage:
"""Build an ImportPage carrying a collection spec (database import).
``rows`` entries are ``{"title": str, "properties": {name: value}}``.
"""
return ImportPage(
title=name or "Imported database",
collection={"name": name or "Imported database", "schema": schema, "rows": rows},
source_path=source_path or name,
external_id=external_id or source_path or name,
)
+284
View File
@@ -0,0 +1,284 @@
"""FlowDeck — bookmark importers (v5.6.0, Phase 4).
Raindrop.io, Pocket, Readwise, Shaarli and generic Netscape bookmark files are
normalized into a FlowDeck collection (URL, description, tags, created date).
"""
from __future__ import annotations
import csv
import io
import json
import re
from datetime import UTC, datetime
from typing import Any
from app.services.importers._common import coerce_tags
from app.services.importers.base import (
Importer,
ImportResult,
decode_text,
make_collection,
register_importer,
)
_SCHEMA = [
{"name": "Title", "type": "title"},
{"name": "URL", "type": "url"},
{"name": "Description", "type": "text"},
{"name": "Tags", "type": "multi_select"},
{"name": "Created", "type": "date"},
]
_TAG_RE = re.compile(
r"<h3[^>]*>(?P<folder>.*?)</h3>|<a\s+(?P<attrs>[^>]*?)>(?P<title>.*?)</a>",
re.IGNORECASE | re.DOTALL,
)
_ATTR_RE = re.compile(r'([a-zA-Z_:-]+)\s*=\s*"([^"]*)"')
def _strip_tags(value: str) -> str:
return re.sub(r"<[^>]+>", "", value or "").strip()
def _iso_from_epoch(value: Any) -> str:
try:
return datetime.fromtimestamp(int(str(value)[:10]), tz=UTC).date().isoformat()
except (ValueError, TypeError, OSError, OverflowError):
return ""
def _iso(value: Any) -> str:
text = str(value or "").strip()
if not text:
return ""
if re.fullmatch(r"\d{10}", text):
return _iso_from_epoch(text)
return text[:10] if re.match(r"^\d{4}-\d{2}-\d{2}", text) else text
def _row(title: str, url: str, description: str = "", tags=None, created: str = "") -> dict:
props: dict[str, Any] = {}
if url:
props["URL"] = url
if description:
props["Description"] = description
tag_list = coerce_tags(tags)
if tag_list:
props["Tags"] = tag_list
if created:
props["Created"] = created
return {"title": (title or url or "Bookmark").strip()[:200], "properties": props}
def parse_netscape(html: str) -> list[dict]:
"""Parse a Netscape bookmark file (browser / Pocket / Raindrop HTML)."""
rows: list[dict] = []
folder = ""
for match in _TAG_RE.finditer(html):
if match.group("folder") is not None:
folder = _strip_tags(match.group("folder"))
continue
attrs = dict(_ATTR_RE.findall(match.group("attrs") or ""))
url = attrs.get("href") or attrs.get("HREF") or ""
if not url:
continue
title = _strip_tags(match.group("title"))
tags = attrs.get("tags") or attrs.get("TAGS") or folder
rows.append(_row(title, url, tags=tags, created=_iso_from_epoch(attrs.get("add_date", ""))))
return rows
def _csv_rows(text: str) -> list[dict]:
reader = csv.DictReader(io.StringIO(text))
return [{(k or "").strip().lower(): v for k, v in row.items()} for row in reader]
class _BookmarkBase(Importer):
source_id = "bookmarks"
label = "Signets"
description = ""
order = 44
keywords: tuple[str, ...] = ()
def _hint(self, filename: str, text: str) -> bool:
low = filename.lower()
return any(k in low or k in text.lower() for k in self.keywords)
@register_importer
class RaindropImporter(_BookmarkBase):
source_id = "raindrop"
label = "Raindrop.io"
description = "Export Raindrop.io (CSV ou HTML) → collection de signets."
extensions = (".csv", ".html", ".htm")
order = 46
keywords = ("raindrop",)
def detect(self, filename: str, data: bytes) -> bool:
text = decode_text(data)
if not self._hint(filename, text):
return False
return filename.lower().endswith((".csv", ".html", ".htm"))
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
text = decode_text(data)
rows: list[dict] = []
if filename.lower().endswith(".csv"):
for r in _csv_rows(text):
rows.append(_row(
r.get("title", ""), r.get("url", ""),
r.get("note") or r.get("excerpt") or "",
r.get("tags", ""), _iso(r.get("created", "")),
))
else:
rows = parse_netscape(text)
result.pages.append(make_collection("Raindrop", _SCHEMA, rows, source_path=filename))
result.stats["rows"] = len(rows)
return result.finalize()
@register_importer
class PocketImporter(_BookmarkBase):
source_id = "pocket"
label = "Pocket"
description = "Export Pocket (CSV ou HTML) → collection de signets."
extensions = (".csv", ".html", ".htm")
order = 45
keywords = ("pocket",)
def detect(self, filename: str, data: bytes) -> bool:
text = decode_text(data)
if not self._hint(filename, text):
return False
return filename.lower().endswith((".csv", ".html", ".htm"))
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
text = decode_text(data)
rows: list[dict] = []
if filename.lower().endswith(".csv"):
for r in _csv_rows(text):
rows.append(_row(
r.get("title", ""), r.get("url", ""),
"", r.get("tags", ""), _iso(r.get("time_added", "")),
))
else:
rows = parse_netscape(text)
result.pages.append(make_collection("Pocket", _SCHEMA, rows, source_path=filename))
result.stats["rows"] = len(rows)
return result.finalize()
@register_importer
class ReadwiseImporter(_BookmarkBase):
source_id = "readwise"
label = "Readwise"
description = "Export Readwise (highlights CSV) → collection de surlignages."
extensions = (".csv", ".md", ".markdown")
order = 47
keywords = ("readwise",)
def detect(self, filename: str, data: bytes) -> bool:
text = decode_text(data)
if not self._hint(filename, text):
return False
if filename.lower().endswith(".csv"):
header = text.splitlines()[0].lower() if text.strip() else ""
return "highlight" in header or "book title" in header
return True
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
text = decode_text(data)
schema = [
{"name": "Highlight", "type": "title"},
{"name": "Book", "type": "text"},
{"name": "Author", "type": "text"},
{"name": "Note", "type": "text"},
{"name": "Tags", "type": "multi_select"},
{"name": "Highlighted at", "type": "date"},
]
rows: list[dict] = []
if filename.lower().endswith(".csv"):
for r in _csv_rows(text):
props: dict[str, Any] = {}
if r.get("book title"):
props["Book"] = r["book title"]
if r.get("book author"):
props["Author"] = r["book author"]
if r.get("note"):
props["Note"] = r["note"]
tags = coerce_tags(r.get("document tags") or r.get("tags"))
if tags:
props["Tags"] = tags
created = _iso(r.get("highlighted at", ""))
if created:
props["Highlighted at"] = created
rows.append({"title": (r.get("highlight") or "Highlight").strip()[:200], "properties": props})
else:
rows = [{"title": ln.lstrip("-* ").strip()[:200], "properties": {}} for ln in text.splitlines() if ln.strip()]
result.pages.append(make_collection("Readwise", schema, rows, source_path=filename))
result.stats["rows"] = len(rows)
return result.finalize()
@register_importer
class ShaarliImporter(_BookmarkBase):
source_id = "shaarli"
label = "Shaarli"
description = "Export Shaarli (JSON) → collection de signets."
extensions = (".json",)
order = 48
keywords = ("shaarli",)
def _records(self, data: bytes) -> list[dict] | None:
try:
obj = json.loads(decode_text(data))
except Exception: # noqa: BLE001
return None
if isinstance(obj, dict) and isinstance(obj.get("links"), list):
obj = obj["links"]
if isinstance(obj, list) and obj and all(isinstance(x, dict) and x.get("url") for x in obj):
return obj
return None
def detect(self, filename: str, data: bytes) -> bool:
if not filename.lower().endswith(".json"):
return False
return self._records(data) is not None
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
records = self._records(data) or []
rows = [
_row(r.get("title", ""), r.get("url", ""), r.get("description", ""),
r.get("tags", ""), _iso(r.get("created", "")))
for r in records
]
result.pages.append(make_collection("Shaarli", _SCHEMA, rows, source_path=filename))
result.stats["rows"] = len(rows)
return result.finalize()
@register_importer
class BookmarksImporter(_BookmarkBase):
source_id = "bookmarks"
label = "Signets HTML (navigateur)"
description = "Fichier de signets Netscape HTML (Chrome/Firefox/Edge…)."
extensions = (".html", ".htm")
order = 49
keywords = ()
def detect(self, filename: str, data: bytes) -> bool:
if not filename.lower().endswith((".html", ".htm")):
return False
text = decode_text(data)[:4000].lower()
return "netscape-bookmark-file" in text or "<dt><a href" in text
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
rows = parse_netscape(decode_text(data))
result.pages.append(make_collection("Bookmarks", _SCHEMA, rows, source_path=filename))
result.stats["rows"] = len(rows)
return result.finalize()
+138
View File
@@ -0,0 +1,138 @@
"""FlowDeck — iCalendar (.ics) importer (v5.6.0, Phase 4).
Parses VEVENT blocks (RFC 5545, best-effort) into a FlowDeck calendar
collection (start/end, all-day, location, description).
"""
from __future__ import annotations
import re
from typing import Any
from app.services.importers.base import (
Importer,
ImportResult,
decode_text,
make_collection,
register_importer,
)
_SCHEMA = [
{"name": "Title", "type": "title"},
{"name": "Start", "type": "date"},
{"name": "End", "type": "date"},
{"name": "All day", "type": "checkbox"},
{"name": "Location", "type": "text"},
{"name": "Description", "type": "text"},
{"name": "Calendar", "type": "text"},
]
_UNESCAPE = [("\\n", "\n"), ("\\N", "\n"), ("\\,", ","), ("\\;", ";"), ("\\\\", "\\")]
def _unfold(text: str) -> list[str]:
lines: list[str] = []
for raw in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
if raw[:1] in (" ", "\t") and lines:
lines[-1] += raw[1:]
else:
lines.append(raw)
return lines
def _unescape(value: str) -> str:
for src, dst in _UNESCAPE:
value = value.replace(src, dst)
return value.strip()
def _parse_prop(line: str) -> tuple[str, dict[str, str], str]:
if ":" not in line:
return "", {}, ""
head, _, value = line.partition(":")
parts = head.split(";")
name = parts[0].upper()
params: dict[str, str] = {}
for p in parts[1:]:
if "=" in p:
k, _, v = p.partition("=")
params[k.upper()] = v
return name, params, value
def _iso_datetime(value: str, params: dict[str, str]) -> tuple[str, bool]:
"""Return (iso, is_all_day)."""
v = value.strip()
if params.get("VALUE") == "DATE" or re.fullmatch(r"\d{8}", v):
m = re.fullmatch(r"(\d{4})(\d{2})(\d{2})", v)
return (f"{m.group(1)}-{m.group(2)}-{m.group(3)}", True) if m else ("", True)
m = re.fullmatch(r"(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)", v)
if not m:
return v, False
date = f"{m.group(1)}-{m.group(2)}-{m.group(3)}T{m.group(4)}:{m.group(5)}:{m.group(6)}"
return (date + "+00:00" if m.group(7) else date), False
@register_importer
class IcsImporter(Importer):
source_id = "ics"
label = "Calendrier (.ics)"
description = "Export iCalendar (Google/Outlook/Apple) → collection d'événements."
extensions = (".ics", ".ical")
order = 33
def detect(self, filename: str, data: bytes) -> bool:
if filename.lower().endswith((".ics", ".ical")):
return True
return "BEGIN:VCALENDAR" in decode_text(data)[:2000]
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
lines = _unfold(decode_text(data))
calendar = ""
rows: list[dict] = []
event: dict[str, Any] | None = None
for line in lines:
upper = line.strip().upper()
if upper == "BEGIN:VEVENT":
event = {}
continue
if upper == "END:VEVENT":
if event is not None:
rows.append(_event_row(event, calendar))
event = None
continue
name, params, value = _parse_prop(line.strip())
if name == "X-WR-CALNAME" and not event:
calendar = _unescape(value)
if event is None:
continue
if name == "SUMMARY":
event["title"] = _unescape(value)
elif name == "DTSTART":
event["start"], event["all_day"] = _iso_datetime(value, params)
elif name == "DTEND":
event["end"], _ = _iso_datetime(value, params)
elif name == "LOCATION":
event["location"] = _unescape(value)
elif name == "DESCRIPTION":
event["description"] = _unescape(value)
elif name == "UID":
event["uid"] = value
result.pages.append(make_collection("Calendar", _SCHEMA, rows, source_path=filename))
result.stats["rows"] = len(rows)
return result.finalize()
def _event_row(event: dict[str, Any], calendar: str) -> dict:
props: dict[str, Any] = {}
if event.get("start"):
props["Start"] = event["start"]
if event.get("end"):
props["End"] = event["end"]
props["All day"] = bool(event.get("all_day"))
if event.get("location"):
props["Location"] = event["location"]
if event.get("description"):
props["Description"] = event["description"]
if calendar:
props["Calendar"] = calendar
return {"title": (event.get("title") or "Event").strip()[:200], "properties": props}
+122
View File
@@ -0,0 +1,122 @@
"""FlowDeck — Word (.docx) importer (v5.6.0, Phase 3).
Converts a Word document (including Google Docs Takeout ``.docx`` exports) into
a FlowDeck page: headings, lists, tables and inline images.
"""
from __future__ import annotations
import io
import re
from pathlib import Path
from app.services.importers.base import (
ImportAttachment,
Importer,
ImportResult,
register_importer,
)
_HEADING_STYLES = {
"title": 1, "heading 1": 1, "heading 2": 2, "heading 3": 3,
"heading 4": 4, "heading 5": 4, "heading 6": 4,
}
_MIME = {
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
".emf": "image/emf", ".wmf": "image/wmf", ".tiff": "image/tiff",
}
def _escape_cell(text: str) -> str:
return text.strip().replace("|", "\\|").replace("\n", " ")
def _table_markdown(table) -> str:
rows: list[list[str]] = []
for row in table.rows:
rows.append([_escape_cell(cell.text) for cell in row.cells])
if not rows:
return ""
width = max(len(r) for r in rows)
rows = [r + [""] * (width - len(r)) for r in rows]
header = "| " + " | ".join(rows[0]) + " |"
sep = "| " + " | ".join(["---"] * width) + " |"
body = "\n".join("| " + " | ".join(r) + " |" for r in rows[1:])
return "\n".join(x for x in (header, sep, body) if x)
@register_importer
class DocxImporter(Importer):
source_id = "docx"
label = "Word / Google Docs (.docx)"
description = "Document Word : titres, listes, tableaux et images."
extensions = (".docx", ".docm")
order = 36
def detect(self, filename: str, data: bytes) -> bool:
return filename.lower().endswith((".docx", ".docm"))
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
try:
from docx import Document
from docx.oxml.ns import qn
except ImportError:
result.warn("python-docx n'est pas installé : import Word indisponible")
return result.finalize()
try:
doc = Document(io.BytesIO(data))
except Exception as exc: # noqa: BLE001
result.warn(f"Document illisible : {exc}")
return result.finalize()
lines: list[str] = []
for para in doc.paragraphs:
text = para.text.strip()
style = (para.style.name or "").lower() if para.style else ""
images = self._paragraph_images(doc, para, qn, result)
if text:
level = _HEADING_STYLES.get(style)
if level:
lines.append("#" * level + " " + text)
elif "list bullet" in style or "list paragraph" in style:
lines.append("- " + text)
elif "list number" in style:
lines.append("1. " + text)
elif style == "quote":
lines.append("> " + text)
else:
lines.append(text)
lines.extend(images)
for table in doc.tables:
md = _table_markdown(table)
if md:
lines.append(md)
markdown = re.sub(r"\n{3,}", "\n\n", "\n\n".join(lines)).strip()
title = Path(filename).stem or "Document"
result.pages.append(_page(title, markdown, filename))
return result.finalize()
def _paragraph_images(self, doc, para, qn, result: ImportResult) -> list[str]:
images: list[str] = []
for blip in para._p.iter(qn("a:blip")):
rid = blip.get(qn("r:embed")) or blip.get(qn("r:link"))
if not rid:
continue
part = doc.part.related_parts.get(rid)
if part is None or not hasattr(part, "blob"):
continue
name = Path(str(part.partname)).name or f"image_{len(result.attachments)}.png"
result.attachments.append(ImportAttachment(
source_path=name, filename=name, data=part.blob,
mime=_MIME.get(Path(name).suffix.lower(), "application/octet-stream"),
))
images.append(f"![{name}]({name})")
return images
def _page(title: str, markdown: str, filename: str):
from app.services.importers.base import ImportPage
return ImportPage(title=title, markdown=markdown, source_path=filename, external_id=filename)
+232
View File
@@ -0,0 +1,232 @@
"""FlowDeck — forge (Gitea/GitHub) issues importer (v5.6.0, Phase 4).
Pulls a repository's issues, labels and milestones through a forge adapter and
normalizes them into FlowDeck collections.
"""
from __future__ import annotations
from typing import Any
from app.services.importers.base import ImportResult, make_collection
def _label_names(issue: dict) -> list[str]:
labels = issue.get("labels") or []
names: list[str] = []
for label in labels:
if isinstance(label, dict):
name = label.get("name") or label.get("title")
else:
name = str(label)
if name and name not in names:
names.append(name)
return names
def _milestone_name(issue: dict) -> str:
milestone = issue.get("milestone")
if isinstance(milestone, dict):
return str(milestone.get("title") or milestone.get("name") or "")
return str(milestone or "")
def _assignees(issue: dict) -> list[str]:
out: list[str] = []
for key in ("assignees", "assignee"):
value = issue.get(key)
if isinstance(value, list):
for a in value:
login = a.get("login") if isinstance(a, dict) else str(a)
if login and login not in out:
out.append(login)
elif isinstance(value, dict):
login = value.get("login")
if login and login not in out:
out.append(login)
elif isinstance(value, str) and value and value not in out:
out.append(value)
return out
_ISSUE_SCHEMA = [
{"name": "Title", "type": "title"},
{"name": "Number", "type": "number"},
{"name": "State", "type": "select", "options": [
{"name": "open", "color": "green"}, {"name": "closed", "color": "red"},
]},
{"name": "Labels", "type": "multi_select"},
{"name": "Milestone", "type": "text"},
{"name": "Assignee", "type": "text"},
{"name": "Created", "type": "date"},
{"name": "Updated", "type": "date"},
{"name": "URL", "type": "url"},
{"name": "Body", "type": "text"},
]
def build_issues_result(
issues: list[dict],
*,
labels: list[dict] | None = None,
milestones: list[dict] | None = None,
owner: str = "",
repo: str = "",
provider: str = "",
) -> ImportResult:
"""Normalize forge issues/labels/milestones into an ImportResult."""
result = ImportResult(source=f"forge:{provider}" if provider else "forge")
name = f"{owner}/{repo} issues".strip("/ ") or "Issues"
rows: list[dict] = []
for issue in issues:
if issue.get("pull_request"):
continue
props: dict[str, Any] = {}
if issue.get("number") is not None:
props["Number"] = issue["number"]
if issue.get("state"):
props["State"] = issue["state"]
label_names = _label_names(issue)
if label_names:
props["Labels"] = label_names
milestone = _milestone_name(issue)
if milestone:
props["Milestone"] = milestone
assignees = _assignees(issue)
if assignees:
props["Assignee"] = ", ".join(assignees)
if issue.get("created_at"):
props["Created"] = issue["created_at"]
if issue.get("updated_at"):
props["Updated"] = issue["updated_at"]
if issue.get("html_url"):
props["URL"] = issue["html_url"]
if issue.get("body"):
props["Body"] = issue["body"]
title = issue.get("title") or f"#{issue.get('number', '')}".strip()
rows.append({"title": title[:200], "properties": props})
result.pages.append(make_collection(
name, _ISSUE_SCHEMA, rows,
source_path=f"{provider}:{owner}/{repo}:issues",
external_id=f"{provider}:{owner}/{repo}:issues",
))
result.stats["rows"] = len(rows)
if labels:
label_schema = [
{"name": "Name", "type": "title"},
{"name": "Color", "type": "text"},
{"name": "Description", "type": "text"},
]
label_rows = [{
"title": (lbl.get("name") or lbl.get("title") or "Label")[:200],
"properties": {
k: v for k, v in (
("Color", lbl.get("color")),
("Description", lbl.get("description")),
) if v
},
} for lbl in labels]
result.pages.append(make_collection(
f"{owner}/{repo} labels".strip("/ "), label_schema, label_rows,
source_path=f"{provider}:{owner}/{repo}:labels",
external_id=f"{provider}:{owner}/{repo}:labels",
))
if milestones:
ms_schema = [
{"name": "Title", "type": "title"},
{"name": "State", "type": "select", "options": [
{"name": "open", "color": "green"}, {"name": "closed", "color": "red"},
]},
{"name": "Due date", "type": "date"},
{"name": "Description", "type": "text"},
]
ms_rows = []
for ms in milestones:
props: dict[str, Any] = {}
if ms.get("state"):
props["State"] = ms["state"]
if ms.get("due_on"):
props["Due date"] = ms["due_on"]
if ms.get("description"):
props["Description"] = ms["description"]
ms_rows.append({"title": (ms.get("title") or "Milestone")[:200], "properties": props})
result.pages.append(make_collection(
f"{owner}/{repo} milestones".strip("/ "), ms_schema, ms_rows,
source_path=f"{provider}:{owner}/{repo}:milestones",
external_id=f"{provider}:{owner}/{repo}:milestones",
))
return result.finalize()
class GiteaForgeAdapter:
"""Adapts a :class:`GiteaClient` to the ``list_*`` interface used here."""
def __init__(self, client) -> None:
self._client = client
async def list_issues(self, owner: str, repo: str, state: str = "all") -> list[dict]:
issues: list[dict] = []
for page in range(1, 6):
batch = await self._client.get_issues(owner, repo, state=state, page=page, limit=50)
if not batch:
break
issues.extend(batch)
if len(batch) < 50:
break
return issues
async def list_labels(self, owner: str, repo: str) -> list[dict]:
return await self._client.get_labels(owner, repo)
async def list_milestones(self, owner: str, repo: str, state: str = "all") -> list[dict]:
return await self._client.get_milestones(owner, repo, state=state)
async def list_repo_files(self, owner: str, repo: str, path: str = "") -> list[dict]:
"""Recursively flatten Gitea repo contents into file entries."""
files: list[dict] = []
pending = [path.strip("/")]
while pending and len(files) < 5000:
current = pending.pop()
items = await self._client.get_repo_contents(owner, repo, current)
for item in items:
if item.get("type") == "dir":
pending.append(item.get("path") or item.get("name"))
elif item.get("type") == "file":
files.append({"path": item.get("path") or item.get("name"), "size": item.get("size", 0)})
return files
async def get_file_content(self, owner: str, repo: str, path: str) -> str:
return await self._client.get_file_content(owner, repo, path)
async def fetch_forge_issues(
adapter,
owner: str,
repo: str,
*,
provider: str = "",
state: str = "all",
include_labels: bool = True,
include_milestones: bool = True,
) -> ImportResult:
"""Fetch issues (and optionally labels/milestones) then normalize them."""
issues = await adapter.list_issues(owner, repo, state)
labels = None
milestones = None
if include_labels:
try:
labels = await adapter.list_labels(owner, repo)
except Exception: # noqa: BLE001 - labels are optional
labels = None
if include_milestones:
try:
milestones = await adapter.list_milestones(owner, repo, state)
except Exception: # noqa: BLE001
milestones = None
return build_issues_result(
issues, labels=labels, milestones=milestones,
owner=owner, repo=repo, provider=provider,
)
+85
View File
@@ -0,0 +1,85 @@
"""FlowDeck — forge repository file importer (v5.6.0, Phase 5).
Imports a Gitea/GitHub repository's text files as pages, preserving the folder
hierarchy. Markdown files become pages; other text files become code blocks.
"""
from __future__ import annotations
from pathlib import Path
from app.services.export import _CODE_LANG, _TEXTUAL_EXTS
from app.services.importers.base import ImportPage, ImportResult
_MD_EXTS = {"md", "markdown"}
def _ext(path: str) -> str:
return Path(path).suffix.lower().lstrip(".")
def build_repo_result(
files: list[tuple[str, str]],
*,
owner: str,
repo: str,
provider: str = "",
) -> ImportResult:
"""Turn ``[(path, content)]`` into pages with folder hierarchy."""
result = ImportResult(source=f"forge-repo:{provider}" if provider else "forge-repo")
for path, content in files:
clean = path.replace("\\", "/").strip("/")
if not clean:
continue
ext = _ext(clean)
if ext in _MD_EXTS:
markdown = content
else:
lang = _CODE_LANG.get(ext, "")
markdown = f"```{lang}\n{content.rstrip()}\n```"
parts = clean.split("/")
result.pages.append(ImportPage(
title=parts[-1] or clean,
markdown=markdown,
source_path=clean,
parent_path="/".join(parts[:-1]),
external_id=f"{provider}:{owner}/{repo}:{clean}",
))
result.stats["rows"] = len(result.pages)
return result.finalize()
async def fetch_forge_repo(
adapter,
owner: str,
repo: str,
*,
provider: str = "",
path: str = "",
max_files: int = 200,
max_file_bytes: int = 512_000,
) -> ImportResult:
"""List a repo's files and fetch the textual ones."""
try:
metas = await adapter.list_repo_files(owner, repo, path)
except Exception as exc: # noqa: BLE001
result = ImportResult(source=f"forge-repo:{provider}" if provider else "forge-repo")
result.warn(f"Arborescence illisible : {exc}")
return result.finalize()
files: list[tuple[str, str]] = []
for meta in metas:
file_path = meta.get("path") or ""
if _ext(file_path) not in _TEXTUAL_EXTS:
continue
if int(meta.get("size") or 0) > max_file_bytes:
continue
if len(files) >= max_files:
break
try:
content = await adapter.get_file_content(owner, repo, file_path)
except Exception: # noqa: BLE001 - skip unreadable files
continue
if not content or content == "[binary file]":
continue
files.append((file_path, content))
return build_repo_result(files, owner=owner, repo=repo, provider=provider)
+300
View File
@@ -0,0 +1,300 @@
"""FlowDeck — HTML notes & Google Keep importer (v5.6.0, Phase 1).
Covers HTML exports from Apple Notes, Bear, Ulysses and OneNote, plus the
Google Takeout ``Keep`` JSON/HTML format. HTML is converted to Markdown and then
to FlowDeck blocks by the pipeline.
"""
from __future__ import annotations
import io
import json
import re
import zipfile
from bs4 import BeautifulSoup, NavigableString, Tag
from app.services.importers._common import coerce_tags, normalize_title
from app.services.importers.base import (
ImportAttachment,
Importer,
ImportPage,
ImportResult,
decode_text,
register_importer,
)
_HTML_EXTS = (".html", ".htm")
def _inline(node: Tag) -> str:
out: list[str] = []
for child in node.children:
if isinstance(child, NavigableString):
out.append(str(child))
elif isinstance(child, Tag):
name = child.name.lower()
if name in ("strong", "b"):
out.append(f"**{_inline(child).strip()}**")
elif name in ("em", "i"):
out.append(f"*{_inline(child).strip()}*")
elif name == "code":
out.append(f"`{child.get_text()}`")
elif name == "br":
out.append("\n")
elif name == "a":
href = child.get("href", "")
label = _inline(child).strip() or href
out.append(f"[{label}]({href})" if href else label)
elif name == "img":
src = child.get("src", "")
alt = child.get("alt", "")
out.append(f"![{alt}]({src})" if src else "")
elif name in ("del", "s", "strike"):
out.append(f"~~{_inline(child).strip()}~~")
else:
out.append(_inline(child))
return re.sub(r"[ \t]+", " ", "".join(out))
def _table(node: Tag) -> str:
rows: list[list[str]] = []
for tr in node.find_all("tr"):
cells = tr.find_all(["th", "td"])
rows.append([_inline(c).strip().replace("|", "\\|") for c in cells])
if not rows:
return ""
width = max(len(r) for r in rows)
rows = [r + [""] * (width - len(r)) for r in rows]
header = "| " + " | ".join(rows[0]) + " |"
sep = "| " + " | ".join(["---"] * width) + " |"
body = "\n".join("| " + " | ".join(r) + " |" for r in rows[1:])
return "\n".join(x for x in (header, sep, body) if x)
def _block(node: Tag, depth: int = 0) -> str:
name = node.name.lower()
if name in ("h1", "h2", "h3", "h4", "h5", "h6"):
return "#" * int(name[1]) + " " + _inline(node).strip()
if name == "p":
return _inline(node).strip()
if name in ("ul", "ol"):
lines = []
for i, li in enumerate(node.find_all("li", recursive=False)):
marker = f"{i + 1}." if name == "ol" else "-"
text = _inline(li).strip()
lines.append(f"{' ' * depth}{marker} {text}")
return "\n".join(lines)
if name == "blockquote":
return "\n".join(f"> {ln}" for ln in _inline(node).strip().splitlines())
if name == "pre":
code = node.get_text()
lang = ""
cls = " ".join(node.get("class", [])) if node.get("class") else ""
m = re.search(r"(?:language|lang)-([\w+-]+)", cls)
if m:
lang = m.group(1)
return f"```{lang}\n{code.rstrip()}\n```"
if name == "hr":
return "---"
if name == "table":
return _table(node)
if name == "img":
src = node.get("src", "")
return f"![{node.get('alt', '')}]({src})" if src else ""
if name in ("div", "section", "article", "body", "main", "html", "span", "font", "center"):
inner = "\n\n".join(
_block(c, depth) for c in node.children if isinstance(c, Tag)
).strip()
if inner:
return inner
text = _inline(node).strip()
return text
return _inline(node).strip()
def _html_to_markdown(html: str) -> str:
soup = BeautifulSoup(html, "html.parser")
for tag in soup(["script", "style", "head", "nav", "footer"]):
tag.decompose()
root = soup.body or soup
blocks = [_block(c) for c in root.children if isinstance(c, Tag)]
md = "\n\n".join(b for b in blocks if b and b.strip())
return re.sub(r"\n{3,}", "\n\n", md).strip()
def _title_from_html(html: str, fallback: str) -> str:
soup = BeautifulSoup(html, "html.parser")
if soup.title and soup.title.string:
return soup.title.string.strip()
h1 = soup.find(["h1", "h2"])
if h1:
return h1.get_text().strip()
return fallback
@register_importer
class HtmlNotesImporter(Importer):
source_id = "html_notes"
label = "HTML (Apple Notes, Bear, Ulysses, OneNote)"
description = "Fichiers HTML ou archive .zip (notes exportées en HTML)."
extensions = (".html", ".htm", ".zip")
order = 50
def detect(self, filename: str, data: bytes) -> bool:
low = filename.lower()
if low.endswith(_HTML_EXTS):
return True
if low.endswith(".zip"):
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError):
return False
names = [n for n in zf.namelist() if not n.endswith("/")]
return any(n.lower().endswith(_HTML_EXTS) for n in names)
return False
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
entries: list[tuple[str, bytes]] = []
if filename.lower().endswith(".zip"):
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError) as exc:
result.warn(f"Archive invalide : {exc}")
return result.finalize()
for name in zf.namelist():
if name.endswith("/"):
continue
clean = name.replace("\\", "/")
if clean.lower().endswith(_HTML_EXTS):
entries.append((clean, zf.read(name)))
else:
result.attachments.append(ImportAttachment(
source_path=clean,
filename=clean.rsplit("/", 1)[-1],
data=zf.read(name),
))
else:
entries.append((filename, data))
for name, payload in entries:
html = decode_text(payload)
fallback = name.replace("\\", "/").rsplit("/", 1)[-1].rsplit(".", 1)[0]
parts = name.replace("\\", "/").split("/")
result.pages.append(ImportPage(
title=_title_from_html(html, fallback) or "Untitled",
markdown=_html_to_markdown(html),
source_path=name,
parent_path="/".join(parts[:-1]),
external_id=name,
))
return result.finalize()
@register_importer
class GoogleKeepImporter(Importer):
source_id = "google_keep"
label = "Google Keep (Takeout)"
description = "Export Google Takeout : Keep/*.json (notes, listes, labels, pièces jointes)."
extensions = (".json", ".zip")
order = 40
def _is_keep_json(self, data: bytes) -> bool:
try:
obj = json.loads(decode_text(data))
except Exception: # noqa: BLE001
return False
return isinstance(obj, dict) and any(
k in obj for k in ("textContent", "listContent", "isTrashed", "color")
)
def detect(self, filename: str, data: bytes) -> bool:
low = filename.lower()
if low.endswith(".json"):
return self._is_keep_json(data)
if low.endswith(".zip"):
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError):
return False
for n in zf.namelist():
if n.lower().endswith(".json") and "keep" in n.lower():
try:
if self._is_keep_json(zf.read(n)):
return True
except Exception: # noqa: BLE001
continue
return False
def _page_from_keep(self, obj: dict, name: str) -> ImportPage | None:
if obj.get("isTrashed"):
return None
title = normalize_title(obj.get("title"))
lines: list[str] = []
for item in obj.get("listContent") or []:
mark = "x" if item.get("isChecked") else " "
lines.append(f"- [{mark}] {item.get('text', '')}")
if obj.get("textContent"):
lines.insert(0, obj["textContent"])
body = "\n\n".join(lines)
if not title:
first = next((ln for ln in body.splitlines() if ln.strip()), "")
first = re.sub(r"^[-*+]\s*(\[[ xX]\]\s*)?", "", first).strip()
title = first[:60] or "Note"
labels = coerce_tags(obj.get("labels"))
props = {"tags": labels} if labels else {}
return ImportPage(
title=title,
markdown=body,
source_path=name,
parent_path="",
properties=props,
external_id=name,
)
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
if filename.lower().endswith(".zip"):
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError) as exc:
result.warn(f"Archive invalide : {exc}")
return result.finalize()
for name in zf.namelist():
if name.endswith("/"):
continue
clean = name.replace("\\", "/")
if clean.lower().endswith(".json") and "keep" in clean.lower():
try:
obj = json.loads(decode_text(zf.read(name)))
except Exception: # noqa: BLE001
continue
if not isinstance(obj, dict):
continue
page = self._page_from_keep(obj, clean)
if page:
result.pages.append(page)
elif "/keep/" in clean.lower() and not clean.lower().endswith(".json"):
result.attachments.append(ImportAttachment(
source_path=clean,
filename=clean.rsplit("/", 1)[-1],
data=zf.read(name),
))
return result.finalize()
try:
obj = json.loads(decode_text(data))
except Exception as exc: # noqa: BLE001
result.warn(f"JSON invalide : {exc}")
return result.finalize()
if isinstance(obj, list):
for i, item in enumerate(obj):
if isinstance(item, dict):
page = self._page_from_keep(item, f"{filename}#{i}")
if page:
result.pages.append(page)
else:
page = self._page_from_keep(obj, filename)
if page:
result.pages.append(page)
return result.finalize()
+150
View File
@@ -0,0 +1,150 @@
"""FlowDeck — background import jobs (v5.6.0, Phase 0).
Small in-process job manager used for large uploads (vaults, zips): the upload
is parsed and persisted in a worker thread while the UI polls job status.
"""
from __future__ import annotations
import threading
import time
import traceback
import uuid
from typing import Any
from app.db import get_conn
from app.services.importers.base import (
Importer,
ImportResult,
detect_importer,
get_importer,
)
from app.services.importers.pipeline import run_import
_JOBS: dict[str, dict[str, Any]] = {}
_LOCK = threading.Lock()
def parse_upload(filename: str, data: bytes, source_id: str | None = None) -> tuple[Importer | None, ImportResult]:
"""Detect (or use) an importer and parse the upload synchronously."""
imp = get_importer(source_id) if source_id else None
if imp is None:
imp = detect_importer(filename, data)
if imp is None:
return None, ImportResult(source=source_id or "unknown", warnings=["Format non reconnu"])
return imp, imp.parse(filename, data)
def _record(job: dict, *, status: str | None = None, error: str = "",
report: dict | None = None, progress: int | None = None) -> None:
with _LOCK:
if status:
job["status"] = status
if error:
job["error"] = error
if report is not None:
job["report"] = report
if progress is not None:
job["progress"] = progress
job["updated_at"] = time.time()
_persist(job)
def _persist(job: dict) -> None:
try:
with get_conn() as conn:
conn.execute(
"INSERT INTO import_jobs (id, source, filename, status, error, report_json, created_at, updated_at) "
"VALUES (?,?,?,?,?,?,?,?) "
"ON CONFLICT(id) DO UPDATE SET status=excluded.status, error=excluded.error, "
"report_json=excluded.report_json, updated_at=excluded.updated_at",
(job["id"], job["source"], job["filename"], job["status"], job.get("error", ""),
_json(job.get("report")), job["created_at"], job["updated_at"]),
)
conn.commit()
except Exception: # noqa: BLE001 - persistence is best-effort
pass
def _json(value: Any) -> str:
import json
try:
return json.dumps(value, ensure_ascii=False)
except (TypeError, ValueError):
return "{}"
def create_job(source: str, filename: str) -> dict:
job = {
"id": uuid.uuid4().hex[:16],
"source": source,
"filename": filename,
"status": "queued",
"progress": 0,
"error": "",
"report": None,
"created_at": time.time(),
"updated_at": time.time(),
}
with _LOCK:
_JOBS[job["id"]] = job
_persist(job)
return job
def get_job(job_id: str) -> dict | None:
with _LOCK:
job = _JOBS.get(job_id)
return dict(job) if job else None
def list_jobs(limit: int = 50) -> list[dict]:
with _LOCK:
jobs = sorted(_JOBS.values(), key=lambda j: j["created_at"], reverse=True)
return [dict(j) for j in jobs[:limit]]
def start_import_job(
*,
filename: str,
data: bytes,
source_id: str | None,
workspace_id: int | None,
workspace_name: str | None,
user_login: str,
parent_page_id: int | None,
target_collection_id: int | None,
dedup: bool = True,
mapping: dict[str, str] | None = None,
mode: str | None = None,
) -> dict:
"""Create a job and run parse + persist in a background thread."""
job = create_job(source_id or "auto", filename)
_record(job, status="running", progress=5)
def worker() -> None:
try:
imp, result = parse_upload(filename, data, source_id)
if imp is None:
_record(job, status="error", error="Format non reconnu")
return
_record(job, progress=40)
report = run_import(
result,
workspace_id=workspace_id,
workspace_name=workspace_name,
user_login=user_login,
parent_page_id=parent_page_id,
target_collection_id=target_collection_id,
dedup=dedup,
mapping=mapping,
mode=mode,
)
_record(job, status="done", progress=100, report=report)
except Exception as exc: # noqa: BLE001 - surface the error to the UI
_record(job, status="error", error=f"{exc}", report={
"traceback": traceback.format_exc()[-2000:],
})
threading.Thread(target=worker, name=f"import-{job['id']}", daemon=True).start()
return job
+87
View File
@@ -0,0 +1,87 @@
"""FlowDeck — generic Markdown / text importer (v5.6.0, Phase 1)."""
from __future__ import annotations
import io
import zipfile
from app.services.importers.base import (
Importer,
ImportPage,
ImportResult,
decode_text,
register_importer,
)
_MD_EXTS = (".md", ".markdown", ".txt", ".mdx")
def _title_from_name(name: str) -> str:
base = name.replace("\\", "/").rsplit("/", 1)[-1]
for ext in (".markdown", ".markdown", ".mdx", ".md", ".txt"):
if base.lower().endswith(ext):
base = base[: -len(ext)]
break
return base.strip() or "Untitled"
@register_importer
class MarkdownImporter(Importer):
source_id = "markdown"
label = "Markdown / texte"
description = "Fichiers .md/.markdown/.txt ou archive .zip de fichiers Markdown."
extensions = (".md", ".markdown", ".txt", ".zip")
order = 90
def detect(self, filename: str, data: bytes) -> bool:
low = filename.lower()
if low.endswith(_MD_EXTS):
return True
if low.endswith(".zip"):
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError):
return False
names = [n for n in zf.namelist() if not n.endswith("/")]
return bool(names) and all(n.lower().endswith(_MD_EXTS) for n in names)
return False
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
if filename.lower().endswith(".zip"):
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError) as exc:
result.warn(f"Archive invalide : {exc}")
return result.finalize()
entries = sorted(
(n for n in zf.namelist()
if not n.endswith("/") and n.lower().endswith(_MD_EXTS)),
key=lambda n: (n.count("/"), n.lower()),
)
if not entries:
result.warn("Aucun fichier Markdown trouvé dans l'archive")
return result.finalize()
for name in entries:
try:
text = decode_text(zf.read(name))
except Exception as exc: # noqa: BLE001
result.warn(f"Lecture impossible : {name} ({exc})")
continue
parts = name.replace("\\", "/").split("/")
result.pages.append(ImportPage(
title=_title_from_name(name),
markdown=text,
source_path=name,
parent_path="/".join(parts[:-1]),
external_id=name,
))
return result.finalize()
text = decode_text(data)
result.pages.append(ImportPage(
title=_title_from_name(filename),
markdown=text,
source_path=filename,
external_id=filename,
))
return result.finalize()
+135
View File
@@ -0,0 +1,135 @@
"""FlowDeck — Notion export importer (v5.6.0, Phase 1, amélioration v5.4.0).
Imports a Notion "Export as Markdown & CSV" ``.zip``: complete page hierarchy,
databases (``.csv``) turned into FlowDeck collections, and image attachments.
"""
from __future__ import annotations
import csv
import io
import re
import zipfile
from urllib.parse import unquote
from app.services.importers._common import split_frontmatter
from app.services.importers.base import (
ImportAttachment,
Importer,
ImportPage,
ImportResult,
decode_text,
register_importer,
)
from app.services.importers.tabular import rows_to_collection
_HASH_RE = re.compile(r"\s+[0-9a-f]{32}$")
_MD_LINK_RE = re.compile(r"\]\(([^)]+)\.md\)")
def _clean_name(name: str) -> str:
base = unquote(name.replace("\\", "/").rsplit("/", 1)[-1])
base = re.sub(r"\.(md|csv|markdown)$", "", base, flags=re.IGNORECASE)
return _HASH_RE.sub("", base).strip() or "Untitled"
def _strip_hash_link(match: re.Match) -> str:
target = unquote(match.group(1)).strip()
return f"]({_HASH_RE.sub('', target).strip() or target})"
@register_importer
class NotionImporter(Importer):
source_id = "notion"
label = "Notion (export .zip)"
description = "Export Notion Markdown & CSV : hiérarchie, databases → collections, images."
extensions = (".zip",)
order = 20
def detect(self, filename: str, data: bytes) -> bool:
if not filename.lower().endswith(".zip"):
return False
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError):
return False
names = [n for n in zf.namelist() if not n.endswith("/")]
md = [n for n in names if n.lower().endswith(".md")]
csvs = [n for n in names if n.lower().endswith(".csv")]
if not md:
return False
if csvs:
return True
return any(_HASH_RE.search(unquote(n.rsplit("/", 1)[-1])) for n in md)
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError) as exc:
result.warn(f"Archive invalide : {exc}")
return result.finalize()
names = [n for n in zf.namelist() if not n.endswith("/")]
md_names = [n for n in names if n.lower().endswith(".md")]
csv_names = [n for n in names if n.lower().endswith(".csv")]
pages_by_title: dict[str, ImportPage] = {}
for name in sorted(md_names, key=lambda n: (n.count("/"), n.lower())):
try:
text = decode_text(zf.read(name))
except Exception as exc: # noqa: BLE001
result.warn(f"Lecture impossible : {name} ({exc})")
continue
meta, body = split_frontmatter(text)
title = _clean_name(name)
body = _MD_LINK_RE.sub(_strip_hash_link, body)
first_line = body.lstrip().splitlines()[0] if body.strip() else ""
if first_line.strip().startswith("# ") and first_line.strip()[2:].strip() == title:
body = "\n".join(body.lstrip().splitlines()[1:]).lstrip("\n")
parts = unquote(name).replace("\\", "/").split("/")
page = ImportPage(
title=title,
markdown=body,
source_path=name,
parent_path="/".join(parts[:-1]),
properties={k: v for k, v in meta.items() if k != "title"},
external_id=name,
)
pages_by_title.setdefault(title, page)
result.pages.append(page)
for name in sorted(csv_names, key=lambda n: (n.count("/"), n.lower())):
try:
text = decode_text(zf.read(name))
except Exception as exc: # noqa: BLE001
result.warn(f"Lecture impossible : {name} ({exc})")
continue
reader = csv.DictReader(io.StringIO(text))
headers = [h for h in (reader.fieldnames or []) if h is not None]
rows = [dict(r) for r in reader]
title = _clean_name(name)
spec_page = rows_to_collection(title, headers, rows)
parts = unquote(name).replace("\\", "/").split("/")
existing = pages_by_title.get(title)
if existing is not None:
existing.collection = spec_page.collection
existing.source_path = existing.source_path or name
else:
spec_page.parent_path = "/".join(parts[:-1])
spec_page.source_path = name
spec_page.external_id = name
result.pages.append(spec_page)
for name in names:
low = name.lower()
if low.endswith((".md", ".csv")):
continue
try:
result.attachments.append(ImportAttachment(
source_path=name,
filename=unquote(name).replace("\\", "/").rsplit("/", 1)[-1],
data=zf.read(name),
))
except Exception: # noqa: BLE001
continue
return result.finalize()
+118
View File
@@ -0,0 +1,118 @@
"""FlowDeck — Obsidian vault importer (v5.6.0, Phase 1).
Imports a vault exported as a ``.zip``: Markdown notes (with YAML frontmatter),
the folder hierarchy, ``[[wikilinks]]``/``![[embeds]]`` and binary attachments.
"""
from __future__ import annotations
import io
import zipfile
from app.services.importers._common import (
coerce_tags,
convert_wikilinks,
normalize_title,
split_frontmatter,
)
from app.services.importers.base import (
ImportAttachment,
Importer,
ImportPage,
ImportResult,
decode_text,
register_importer,
)
_SKIP_DIRS = (".obsidian/", ".trash/", ".git/", ".DS_Store")
def _mime_for(name: str) -> str:
import mimetypes
return mimetypes.guess_type(name)[0] or "application/octet-stream"
@register_importer
class ObsidianImporter(Importer):
source_id = "obsidian"
label = "Obsidian (vault .zip)"
description = "Vault Obsidian : notes Markdown, frontmatter YAML, wikilinks, pièces jointes."
extensions = (".zip",)
order = 10
def detect(self, filename: str, data: bytes) -> bool:
if not filename.lower().endswith(".zip"):
return False
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError):
return False
names = zf.namelist()
if any("/.obsidian/" in n or n.startswith(".obsidian/") for n in names):
return True
# Heuristic: mostly-markdown archive containing wikilinks.
md = [n for n in names if n.lower().endswith(".md")]
if not md:
return False
for n in md[:20]:
try:
if "[[" in decode_text(zf.read(n)):
return True
except Exception: # noqa: BLE001
continue
return False
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError) as exc:
result.warn(f"Archive invalide : {exc}")
return result.finalize()
names = [n for n in zf.namelist() if not n.endswith("/")]
notes = [n for n in names if n.lower().endswith(".md")]
assets = [n for n in names if not n.lower().endswith(".md")]
for name in assets:
clean = name.replace("\\", "/")
if any(part in clean for part in _SKIP_DIRS) or clean.split("/")[-1].startswith("."):
continue
try:
payload = zf.read(name)
except Exception: # noqa: BLE001
continue
result.attachments.append(ImportAttachment(
source_path=name,
filename=clean.rsplit("/", 1)[-1],
data=payload,
mime=_mime_for(name),
))
for name in sorted(notes, key=lambda n: (n.count("/"), n.lower())):
clean = name.replace("\\", "/")
if any(part in clean for part in _SKIP_DIRS):
continue
try:
text = decode_text(zf.read(name))
except Exception as exc: # noqa: BLE001
result.warn(f"Lecture impossible : {name} ({exc})")
continue
meta, body = split_frontmatter(text)
body = convert_wikilinks(body)
parts = clean.split("/")
title = normalize_title(meta.get("title")) or parts[-1][:-3]
props = dict(meta)
props.pop("title", None)
tags = coerce_tags(meta.get("tags"))
if tags:
props["tags"] = tags
result.pages.append(ImportPage(
title=title or "Untitled",
markdown=body,
source_path=clean,
parent_path="/".join(parts[:-1]),
properties=props,
external_id=clean,
))
return result.finalize()
+86
View File
@@ -0,0 +1,86 @@
"""FlowDeck — OPML importer (v5.6.0, Phase 4).
Imports an OPML outline (RSS readers, feed lists) as a collection of feeds.
"""
from __future__ import annotations
import xml.etree.ElementTree as ET
from typing import Any
from app.services.importers.base import (
Importer,
ImportResult,
decode_text,
make_collection,
register_importer,
)
_SCHEMA = [
{"name": "Title", "type": "title"},
{"name": "Feed URL", "type": "url"},
{"name": "Site URL", "type": "url"},
{"name": "Type", "type": "select", "options": [
{"name": "rss", "color": "orange"},
{"name": "folder", "color": "gray"},
]},
{"name": "Folder", "type": "text"},
]
@register_importer
class OpmlImporter(Importer):
source_id = "opml"
label = "OPML (flux RSS)"
description = "Outline OPML → collection de flux (titre, URL, dossier)."
extensions = (".opml", ".xml")
order = 34
def detect(self, filename: str, data: bytes) -> bool:
if filename.lower().endswith(".opml"):
return True
head = decode_text(data)[:1000].lower()
return "<opml" in head and "<outline" in head
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
rows: list[dict] = []
try:
root = ET.fromstring(decode_text(data))
except ET.ParseError as exc:
result.warn(f"OPML invalide : {exc}")
return result.finalize()
for outline in root.iter("outline"):
attrs = {k.lower(): v for k, v in outline.attrib.items()}
feed = attrs.get("xmlurl")
title = attrs.get("title") or attrs.get("text") or feed or ""
if not feed and not title:
continue
props: dict[str, Any] = {}
if feed:
props["Feed URL"] = feed
props["Type"] = "rss"
else:
props["Type"] = "folder"
if attrs.get("htmlurl"):
props["Site URL"] = attrs["htmlurl"]
folder = _folder_of(outline, root)
if folder:
props["Folder"] = folder
rows.append({"title": title[:200] or "Feed", "properties": props})
result.pages.append(make_collection("OPML feeds", _SCHEMA, rows, source_path=filename))
result.stats["rows"] = len(rows)
return result.finalize()
def _folder_of(node: ET.Element, root: ET.Element) -> str:
parents = {child: parent for parent in root.iter() for child in parent}
parts: list[str] = []
current = parents.get(node)
while current is not None:
attrs = {k.lower(): v for k, v in current.attrib.items()}
if not attrs.get("xmlurl"):
label = attrs.get("title") or attrs.get("text")
if label:
parts.append(label)
current = parents.get(current)
return " / ".join(reversed(parts))
+164
View File
@@ -0,0 +1,164 @@
"""FlowDeck — Logseq / Roam Research outliner importer (v5.6.0, Phase 1)."""
from __future__ import annotations
import io
import re
import zipfile
from app.services.importers._common import (
coerce_tags,
convert_wikilinks,
normalize_title,
split_frontmatter,
)
from app.services.importers.base import (
ImportAttachment,
Importer,
ImportPage,
ImportResult,
decode_text,
register_importer,
)
_LOGSEQ_MARKERS = ("property::", "logseq/", "journals/")
_ROAM_MARKERS = ("{{[[TODO]]}}", "{{[[DONE]]}}", "{{[[query]]}}")
_PROP_RE = re.compile(r"^\s*([a-zA-Z][\w-]*)::\s*(.*)$")
def _journal_title(name: str) -> str:
m = re.match(r"^(\d{4})[_-](\d{2})[_-](\d{2})", name)
if m:
return f"{m.group(1)}-{m.group(2)}-{m.group(3)}"
return name
def _clean_outline(text: str) -> tuple[dict, str]:
meta, body = split_frontmatter(text)
lines_out: list[str] = []
for line in body.splitlines():
m = _PROP_RE.match(line)
if m and line.lstrip().startswith("-"):
continue
# Logseq properties appear as bare ``key:: value`` lines too.
m2 = _PROP_RE.match(line)
if m2 and not line.lstrip().startswith(("-", "*", "#", "|")):
key = m2.group(1)
if key not in meta:
meta[key] = m2.group(2).strip()
continue
lines_out.append(line)
body = "\n".join(lines_out)
# Roam task markers → GFM checkboxes.
body = body.replace("{{[[TODO]]}}", "[ ] ").replace("{{[[DONE]]}}", "[x] ")
# Block references ((uuid)) → plain anchors.
body = re.sub(r"\(\(([0-9a-fA-F-]{6,})\)\)", r"[[\1]]", body)
body = convert_wikilinks(body)
return meta, body
class _OutlineBase(Importer):
markers: tuple[str, ...] = ()
property_syntax = False
source_id = "outline"
label = "Outliner"
description = ""
order = 30
def _text_matches(self, text: str) -> bool:
if any(m in text for m in self.markers if not m.endswith("/")):
return True
return bool(self.property_syntax and re.search(r"^\s*[a-zA-Z][\w-]*::", text, re.M))
def _looks_like(self, filename: str, data: bytes) -> bool:
low = filename.lower()
if low.endswith((".md", ".markdown", ".txt")):
return self._text_matches(decode_text(data))
if low.endswith(".zip"):
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError):
return False
names = zf.namelist()
if any(m in name for m in self.markers if m.endswith("/") for name in names):
return True
for n in [x for x in names if x.lower().endswith(".md")][:10]:
try:
if self._text_matches(decode_text(zf.read(n))):
return True
except Exception: # noqa: BLE001
continue
return False
def detect(self, filename: str, data: bytes) -> bool:
return self._looks_like(filename, data)
def _emit(self, result: ImportResult, name: str, text: str, is_journal: bool) -> None:
meta, body = _clean_outline(text)
parts = name.replace("\\", "/").split("/")
raw_title = parts[-1].rsplit(".", 1)[0]
title = normalize_title(meta.get("title")) or (
_journal_title(raw_title) if is_journal else raw_title
)
props = {k: v for k, v in meta.items() if k != "title"}
tags = coerce_tags(meta.get("tags"))
if tags:
props["tags"] = tags
result.pages.append(ImportPage(
title=title or "Untitled",
markdown=body,
source_path=name,
parent_path="/".join(parts[:-1]),
properties=props,
external_id=name,
))
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
if filename.lower().endswith(".zip"):
try:
zf = zipfile.ZipFile(io.BytesIO(data))
except (zipfile.BadZipFile, OSError) as exc:
result.warn(f"Archive invalide : {exc}")
return result.finalize()
names = [n for n in zf.namelist() if not n.endswith("/")]
for name in [n for n in names if not n.lower().endswith(".md")]:
try:
result.attachments.append(ImportAttachment(
source_path=name,
filename=name.replace("\\", "/").rsplit("/", 1)[-1],
data=zf.read(name),
))
except Exception: # noqa: BLE001
continue
for name in sorted(
(n for n in names if n.lower().endswith(".md")),
key=lambda n: (n.count("/"), n.lower()),
):
try:
text = decode_text(zf.read(name))
except Exception as exc: # noqa: BLE001
result.warn(f"Lecture impossible : {name} ({exc})")
continue
self._emit(result, name, text, is_journal="journal" in name.lower())
return result.finalize()
text = decode_text(data)
self._emit(result, filename, text, is_journal=False)
return result.finalize()
@register_importer
class LogseqImporter(_OutlineBase):
source_id = "logseq"
label = "Logseq"
description = "Outliner Logseq : pages/journal, propriétés `key:: value`, block refs."
markers = ("property::", "logseq/", "journals/")
property_syntax = True
@register_importer
class RoamImporter(_OutlineBase):
source_id = "roam"
label = "Roam Research"
description = "Outliner Roam : `{{[[TODO]]}}`, block refs, wikilinks."
markers = _ROAM_MARKERS
+94
View File
@@ -0,0 +1,94 @@
"""FlowDeck — PDF importer (v5.6.0, Phase 3).
Best-effort text + image extraction from a PDF into a FlowDeck page (fidelity
depends on the source PDF; scanned documents have no text layer).
"""
from __future__ import annotations
import io
import re
from pathlib import Path
from app.services.importers.base import (
ImportAttachment,
Importer,
ImportPage,
ImportResult,
register_importer,
)
_MIME = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
".tiff": "image/tiff", ".tif": "image/tiff"}
@register_importer
class PdfImporter(Importer):
source_id = "pdf"
label = "PDF"
description = "Extraction texte + images d'un PDF (fidélité limitée)."
extensions = (".pdf",)
order = 37
def detect(self, filename: str, data: bytes) -> bool:
return filename.lower().endswith(".pdf")
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
try:
from pypdf import PdfReader
except ImportError:
result.warn("pypdf n'est pas installé : import PDF indisponible")
return result.finalize()
try:
reader = PdfReader(io.BytesIO(data))
except Exception as exc: # noqa: BLE001
result.warn(f"PDF illisible : {exc}")
return result.finalize()
chunks: list[str] = []
empty_pages = 0
for index, page in enumerate(reader.pages, start=1):
try:
text = (page.extract_text() or "").strip()
except Exception: # noqa: BLE001
text = ""
if text:
if len(reader.pages) > 1:
chunks.append(f"## Page {index}\n\n{text}")
else:
chunks.append(text)
else:
empty_pages += 1
chunks.extend(self._page_images(page, index, result))
if empty_pages:
result.warn(f"{empty_pages} page(s) sans couche texte (document scanné ?)")
markdown = re.sub(r"\n{3,}", "\n\n", "\n\n".join(chunks)).strip()
title = Path(filename).stem or "Document"
result.pages.append(ImportPage(
title=title, markdown=markdown, source_path=filename, external_id=filename,
))
return result.finalize()
def _page_images(self, page, index: int, result: ImportResult) -> list[str]:
images: list[str] = []
try:
page_images = list(page.images)
except Exception: # noqa: BLE001
return images
for i, image in enumerate(page_images, start=1):
name = getattr(image, "name", "") or f"page{index}_img{i}.png"
name = Path(name).name
try:
payload = image.data
except Exception: # noqa: BLE001
continue
if not payload:
continue
result.attachments.append(ImportAttachment(
source_path=f"page{index}/{name}", filename=name, data=payload,
mime=_MIME.get(Path(name).suffix.lower(), "application/octet-stream"),
))
images.append(f"![{name}]({name})")
return images
+566
View File
@@ -0,0 +1,566 @@
"""FlowDeck — common import pipeline (v5.6.0, Phase 0).
Persists an :class:`~app.services.importers.base.ImportResult` into FlowDeck:
resolves the workspace, rebuilds the folder hierarchy (``parent_id``), stores
attachments, rewrites links, creates collections + rows, and records imported
items for idempotent re-imports.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import re
from pathlib import Path
from typing import Any
from app.db import get_conn
from app.services.db_templates import materialize_properties
from app.services.export import markdown_to_blocks
from app.services.importers.base import ImportPage, ImportResult
from app.services.importers.tabular import apply_type_mapping
logger = logging.getLogger(__name__)
_IMG_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
def _data_dir() -> Path:
return Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))
def _safe_filename(name: str) -> str:
base = Path(name.replace("\\", "/")).name
base = re.sub(r"[^\w.\- ()]+", "_", base).strip() or "file"
return base[:150]
def _sha1(*parts: str) -> str:
return hashlib.sha1("||".join(parts).encode("utf-8")).hexdigest()
def _resolve_workspace(conn, workspace_id: int | None, workspace_name: str | None,
user_login: str) -> tuple[int | None, str]:
if workspace_id:
row = conn.execute("SELECT id, name FROM workspaces WHERE id=?", (workspace_id,)).fetchone()
if row:
return row["id"], row["name"]
name = workspace_name or user_login
if name:
row = conn.execute("SELECT id, name FROM workspaces WHERE name=?", (name,)).fetchone()
if row:
return row["id"], row["name"]
return workspace_id, name or ""
def _rewrite_links(text: str, attachment_map: dict[str, str]) -> str:
"""Point Markdown links/images at uploaded attachment URLs."""
def repl(m: re.Match) -> str:
alt, target = m.group(1), m.group(2)
url = attachment_map.get(target) or attachment_map.get(Path(target).name.lower())
return f"![{alt}]({url})" if url else m.group(0)
text = re.sub(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)", repl, text)
return text
def _extract_media(blocks: list[dict]) -> list[dict]:
"""Split inline image markdown out of paragraphs into real image blocks."""
out: list[dict] = []
for block in blocks:
if block.get("type") != "paragraph":
out.append(block)
continue
content = str(block.get("content", ""))
pos = 0
found = False
for match in _IMG_RE.finditer(content):
found = True
before = content[pos:match.start()].strip()
if before:
out.append({"type": "paragraph", "content": before})
out.append({"type": "image", "src": match.group(2), "alt": match.group(1)})
pos = match.end()
if not found:
out.append(block)
continue
tail = content[pos:].strip()
if tail:
out.append({"type": "paragraph", "content": tail})
return out
def _properties_callout(props: dict) -> dict | None:
if not props:
return None
lines = [f"**{k}** : {', '.join(map(str, v)) if isinstance(v, list) else v}" for k, v in props.items()]
return {"type": "callout", "icon": "ℹ️", "content": "\n".join(lines)}
def _blocks_for(page: ImportPage, attachment_map: dict[str, str], *, include_properties: bool) -> list[dict]:
if page.blocks:
return _extract_media(page.blocks)
md = _rewrite_links(page.markdown or "", attachment_map)
blocks = _extract_media(markdown_to_blocks(md))
if include_properties and page.properties:
callout = _properties_callout(page.properties)
if callout:
blocks.insert(0, callout)
return blocks
def preview_result(result: ImportResult) -> dict[str, Any]:
"""Dry-run preview: what would be created, without touching the database."""
pages = []
for page in result.pages:
if page.collection:
kind = "collection"
rows = len(page.collection.get("rows", []))
blocks = len(_blocks_for(page, {}, include_properties=False))
schema = page.collection.get("schema", [])
else:
kind = "page"
rows = 0
blocks = len(_blocks_for(page, {}, include_properties=False))
schema = []
pages.append({
"title": page.title,
"type": kind,
"source_path": page.source_path,
"parent_path": page.parent_path,
"properties": list(page.properties.keys()),
"rows": rows,
"blocks": blocks,
"schema": schema,
})
return {
"source": result.source,
"dry_run": True,
"pages": pages,
"stats": {
**result.stats,
"pages": len(result.pages),
"collections": sum(1 for p in result.pages if p.collection),
"attachments": len(result.attachments),
"warnings": len(result.warnings),
},
"warnings": result.warnings,
}
def _insert_page(conn, *, workspace: str, workspace_id: int | None, title: str,
blocks: list[dict], parent_id: int | None, sort_order: int,
content_format: str = "blocks") -> int:
cur = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format, parent_section, "
"sort_order, workspace_id, parent_id) VALUES (?,?,?,?,'Private',?,?,?)",
(workspace, title or "Untitled", json.dumps(blocks), content_format,
sort_order, workspace_id, parent_id),
)
return cur.lastrowid
def _prop_id_map(conn, collection_id: int) -> dict[str, int]:
return {
r["name"]: r["id"]
for r in conn.execute(
"SELECT id, name FROM collection_properties WHERE collection_id=?",
(collection_id,),
).fetchall()
}
def _rows_to_values(rows: list[dict], id_map: dict[str, int]) -> list[tuple[str, dict]]:
"""Key row properties by property id (the shape the editor reads)."""
out: list[tuple[str, dict]] = []
for row in rows:
values: dict[str, Any] = {}
for name, value in (row.get("properties") or {}).items():
prop_id = id_map.get(name)
if prop_id is not None:
values[str(prop_id)] = value
out.append((row.get("title") or "Untitled", values))
return out
def _ensure_default_view(conn, collection_id: int) -> None:
conn.execute(
"INSERT INTO collection_views (collection_id, name, view_type, config_json) "
"VALUES (?,?,?,?)",
(collection_id, "Default View", "table",
json.dumps({"visible_properties": ["Title"], "sorts": [], "filters": []})),
)
def _insert_collection(conn, page: ImportPage, *, workspace_id: int | None,
parent_page_id: int | None) -> tuple[int, int]:
spec = page.collection or {}
schema = spec.get("schema", [])
cur = conn.execute(
"INSERT INTO collections (name, description, icon, schema_json, is_inline, "
"parent_page_id, workspace_id) VALUES (?,?,?,?,1,?,?)",
(page.title or spec.get("name") or "Imported database", "", "📥",
json.dumps(schema), parent_page_id, workspace_id),
)
collection_id = cur.lastrowid
materialize_properties(conn, collection_id, schema)
_ensure_default_view(conn, collection_id)
id_map = _prop_id_map(conn, collection_id)
position = 0
for title, values in _rows_to_values(spec.get("rows", []), id_map):
conn.execute(
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) "
"VALUES (?,?,?,?)",
(collection_id, title, position, json.dumps(values)),
)
position += 1
return collection_id, position
def _upsert_collection_rows(conn, collection_id: int, rows: list[dict]) -> tuple[int, int]:
"""Update existing rows by title, insert the new ones. Returns (created, updated)."""
id_map = _prop_id_map(conn, collection_id)
existing = {
(r["title"] or "").strip(): r["id"]
for r in conn.execute(
"SELECT id, title FROM collection_pages WHERE collection_id=?", (collection_id,)
).fetchall()
}
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) FROM collection_pages WHERE collection_id=?",
(collection_id,),
).fetchone()[0]
created = updated = 0
for title, values in _rows_to_values(rows, id_map):
pid = existing.get((title or "").strip())
if pid:
conn.execute(
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(values), pid),
)
updated += 1
else:
max_pos += 1
conn.execute(
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) "
"VALUES (?,?,?,?)",
(collection_id, title, max_pos, json.dumps(values)),
)
created += 1
return created, updated
def _update_page(conn, page_id: int, title: str, blocks: list[dict]) -> None:
conn.execute(
"UPDATE pages SET title=?, content=?, content_format='blocks', "
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
(title or "Untitled", json.dumps(blocks), page_id),
)
def run_import(
result: ImportResult,
*,
workspace_id: int | None = None,
workspace_name: str | None = None,
user_login: str = "",
parent_page_id: int | None = None,
target_collection_id: int | None = None,
dry_run: bool = False,
dedup: bool = True,
include_properties: bool = True,
mapping: dict[str, str] | None = None,
mode: str | None = None,
) -> dict[str, Any]:
"""Persist an import result. Returns a report dict.
``mode`` controls re-import behaviour for items already imported (matched by
``import_items``): ``skip`` (default), ``update`` (re-sync in place) or
``duplicate`` (always create a new page).
"""
if dry_run:
return preview_result(result)
if mapping:
for page in result.pages:
if page.collection:
apply_type_mapping(page.collection, mapping)
if not mode:
mode = "skip" if dedup else "duplicate"
report: dict[str, Any] = {
"source": result.source,
"status": "ok",
"mode": mode,
"pages_created": 0,
"pages_updated": 0,
"collections_created": 0,
"rows_created": 0,
"rows_updated": 0,
"attachments": 0,
"skipped": 0,
"page_ids": [],
"errors": [],
"warnings": list(result.warnings),
}
with get_conn() as conn:
ws_id, ws_name = _resolve_workspace(conn, workspace_id, workspace_name, user_login)
if not ws_name:
report["status"] = "error"
report["warnings"].append("Workspace introuvable")
return report
attachment_map: dict[str, str] = {}
if result.attachments and ws_id:
dest_dir = _data_dir() / "uploads" / f"workspace_{ws_id}" / "import"
dest_dir.mkdir(parents=True, exist_ok=True)
for att in result.attachments:
safe = _safe_filename(att.filename)
target = dest_dir / safe
if target.exists():
target = dest_dir / f"{_sha1(att.source_path)[:8]}_{safe}"
try:
target.write_bytes(att.data)
except OSError:
continue
url = f"/api/files/{ws_id}/import/{target.name}"
attachment_map[att.source_path] = url
attachment_map[att.source_path.lower()] = url
attachment_map[Path(att.source_path).name.lower()] = url
report["attachments"] += 1
if target_collection_id:
return _import_into_collection(
conn, result, target_collection_id, report, attachment_map,
dedup=dedup, workspace_id=ws_id, include_properties=include_properties,
)
next_order = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL",
(ws_name,),
).fetchone()[0]
order_counter = [next_order]
path_to_page: dict[str, int] = {}
folder_cache: dict[str, int | None] = {}
def ensure_folder(path: str, _depth: int = 0) -> int | None:
path = (path or "").strip("/")
if not path:
return parent_page_id
if path in folder_cache:
return folder_cache[path]
parent_path = "/".join(path.split("/")[:-1])
parent_id = ensure_folder(parent_path, _depth + 1)
title = path.split("/")[-1] or "Folder"
pid = _insert_page(
conn, workspace=ws_name, workspace_id=ws_id, title=title,
blocks=[], parent_id=parent_id, sort_order=order_counter[0],
)
order_counter[0] += 1
folder_cache[path] = pid
path_to_page[path] = pid
report["pages_created"] += 1
report["page_ids"].append(pid)
return pid
ordered = sorted(
result.pages,
key=lambda p: (p.parent_path.count("/") if p.parent_path else -1, p.source_path.lower()),
)
for page in ordered:
external = page.external_id or page.source_path or page.title
existing_pid = None
if external:
row = conn.execute(
"SELECT page_id FROM import_items WHERE workspace_id IS ? AND source=? AND external_id=?",
(ws_id, result.source, external),
).fetchone()
if row:
existing_pid = row["page_id"]
if existing_pid and mode == "skip":
report["skipped"] += 1
continue
try:
page_parent = ensure_folder(page.parent_path) if page.parent_path else parent_page_id
blocks = _blocks_for(page, attachment_map, include_properties=include_properties)
if existing_pid and mode == "update":
if page.collection:
cid = _collection_for_page(conn, existing_pid)
if cid:
materialize_properties(conn, cid, (page.collection or {}).get("schema", []))
created, updated = _upsert_collection_rows(
conn, cid, (page.collection or {}).get("rows", []))
report["rows_created"] += created
report["rows_updated"] += updated
blocks = blocks + [{"type": "embed", "embed_type": "collection",
"collection_id": cid, "content": ""}]
_update_page(conn, existing_pid, page.title, blocks)
report["pages_updated"] += 1
report["page_ids"].append(existing_pid)
path_to_page[page.source_path] = existing_pid
continue
if page.collection:
pid = _insert_page(
conn, workspace=ws_name, workspace_id=ws_id, title=page.title,
blocks=blocks, parent_id=page_parent, sort_order=order_counter[0],
)
order_counter[0] += 1
cid, rows = _insert_collection(conn, page, workspace_id=ws_id, parent_page_id=pid)
conn.execute(
"UPDATE pages SET content=? WHERE id=?",
(json.dumps(blocks + [{"type": "embed", "embed_type": "collection",
"collection_id": cid, "content": ""}]), pid),
)
report["collections_created"] += 1
report["rows_created"] += rows
report["pages_created"] += 1
report["page_ids"].append(pid)
else:
pid = _insert_page(
conn, workspace=ws_name, workspace_id=ws_id, title=page.title,
blocks=blocks, parent_id=page_parent, sort_order=order_counter[0],
)
order_counter[0] += 1
report["pages_created"] += 1
report["page_ids"].append(pid)
path_to_page[page.source_path] = pid
if external:
conn.execute(
"INSERT OR IGNORE INTO import_items (workspace_id, source, external_id, page_id) VALUES (?,?,?,?)",
(ws_id, result.source, external, pid),
)
except Exception as exc: # noqa: BLE001 - partial import must keep going
logger.warning("import failed for %r: %s", page.title, exc)
report["errors"].append({"title": page.title, "error": str(exc)})
conn.commit()
if report["errors"]:
report["status"] = "partial"
return report
def _collection_for_page(conn, page_id: int) -> int | None:
row = conn.execute(
"SELECT id FROM collections WHERE parent_page_id=? ORDER BY id LIMIT 1", (page_id,)
).fetchone()
return row["id"] if row else None
def _import_into_collection(conn, result: ImportResult, collection_id: int, report: dict,
attachment_map: dict[str, str], *, dedup: bool,
workspace_id: int | None, include_properties: bool) -> dict:
exists = conn.execute("SELECT id FROM collections WHERE id=?", (collection_id,)).fetchone()
if not exists:
report["status"] = "error"
report["warnings"].append("Collection cible introuvable")
return report
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?",
(collection_id,),
).fetchone()[0]
id_map = _prop_id_map(conn, collection_id)
for page in result.pages:
external = page.external_id or page.source_path or page.title
if dedup and external:
row = conn.execute(
"SELECT id FROM collection_pages WHERE collection_id=? AND title=?",
(collection_id, page.title),
).fetchone()
if row:
report["skipped"] += 1
continue
props = {
str(id_map[name]): value
for name, value in page.properties.items()
if name in id_map
}
conn.execute(
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) "
"VALUES (?,?,?,?)",
(collection_id, page.title, max_pos, json.dumps(props)),
)
max_pos += 1
report["rows_created"] += 1
conn.commit()
return report
def resolve_relations(conn, workspace_id: int | None) -> dict[str, Any]:
"""Convert text columns that reference another imported collection's titles
into real ``relation`` properties (array of ``collection_pages`` ids)."""
collections = conn.execute(
"SELECT id, name FROM collections WHERE workspace_id IS ?", (workspace_id,)
).fetchall()
if not collections:
return {"relations_resolved": 0, "details": []}
titles: dict[int, dict[str, int]] = {}
for coll in collections:
rows = conn.execute(
"SELECT id, title FROM collection_pages WHERE collection_id=?", (coll["id"],)
).fetchall()
titles[coll["id"]] = {
(r["title"] or "").strip(): r["id"]
for r in rows if (r["title"] or "").strip()
}
resolved = 0
details: list[dict] = []
for coll in collections:
props = conn.execute(
"SELECT id, name, prop_type FROM collection_properties WHERE collection_id=?",
(coll["id"],),
).fetchall()
pages = conn.execute(
"SELECT id, property_values_json FROM collection_pages WHERE collection_id=?",
(coll["id"],),
).fetchall()
for prop in props:
if prop["prop_type"] not in ("text", "select", "multi_select"):
continue
key = str(prop["id"])
values: list[str] = []
for page in pages:
pv = json.loads(page["property_values_json"] or "{}")
value = pv.get(key)
if value in (None, "", []):
continue
items = value if isinstance(value, list) else [value]
values.extend(str(v) for v in items)
if not values:
continue
for target in collections:
if target["id"] == coll["id"]:
continue
target_titles = titles.get(target["id"]) or {}
if target_titles and all(v in target_titles for v in values):
conn.execute(
"UPDATE collection_properties SET prop_type='relation', "
"related_collection_id=? WHERE id=?",
(target["id"], prop["id"]),
)
for page in pages:
pv = json.loads(page["property_values_json"] or "{}")
value = pv.get(key)
if value in (None, "", []):
continue
items = value if isinstance(value, list) else [value]
pv[key] = [target_titles[str(v)] for v in items if str(v) in target_titles]
conn.execute(
"UPDATE collection_pages SET property_values_json=? WHERE id=?",
(json.dumps(pv), page["id"]),
)
resolved += 1
details.append({
"collection": coll["name"], "property": prop["name"],
"related": target["name"],
})
break
conn.commit()
return {"relations_resolved": resolved, "details": details}
+96
View File
@@ -0,0 +1,96 @@
"""FlowDeck — Standard Notes importer (v5.6.0, Phase 4).
Imports a Standard Notes backup (``.json``): each non-encrypted note becomes a
FlowDeck page. Encrypted notes are reported as warnings.
"""
from __future__ import annotations
import json
from typing import Any
from app.services.importers.base import (
Importer,
ImportPage,
ImportResult,
decode_text,
register_importer,
)
_NOTE_TYPES = ("note", "org.standardnotes.sn", "org.standardnotes.plain-text")
def _note_body(content: Any) -> tuple[str, str, bool]:
"""Return (title, markdown, encrypted)."""
if isinstance(content, dict):
if content.get("encrypted"):
return "", "", True
text = content.get("text") or content.get("preview_plain") or ""
title = content.get("title") or ""
return title, text, False
if isinstance(content, str):
stripped = content.strip()
if stripped.startswith("{"):
try:
parsed = json.loads(stripped)
if isinstance(parsed, dict) and ("encrypted" in parsed or "000" in parsed):
return "", "", True
if isinstance(parsed, dict):
return parsed.get("title", ""), parsed.get("text", "") or parsed.get("preview_plain", ""), False
except json.JSONDecodeError:
pass
return "", content, False
return "", "", False
@register_importer
class StandardNotesImporter(Importer):
source_id = "standard_notes"
label = "Standard Notes"
description = "Sauvegarde JSON Standard Notes → pages (notes chiffrées ignorées)."
extensions = (".json",)
order = 39
def _items(self, data: bytes) -> list[dict] | None:
try:
obj = json.loads(decode_text(data))
except Exception: # noqa: BLE001
return None
if isinstance(obj, dict) and isinstance(obj.get("items"), list):
return [x for x in obj["items"] if isinstance(x, dict)]
return None
def detect(self, filename: str, data: bytes) -> bool:
if not filename.lower().endswith(".json"):
return False
items = self._items(data)
if not items:
return False
return any("content_type" in it for it in items)
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
items = self._items(data) or []
count = 0
for item in items:
if item.get("deleted"):
continue
ctype = str(item.get("content_type", "")).lower()
if ctype and not any(t in ctype for t in _NOTE_TYPES):
continue
title, body, encrypted = _note_body(item.get("content"))
if encrypted:
result.warn("Note chiffrée ignorée (déchiffrement non pris en charge)")
continue
if not body.strip():
continue
if not title:
title = next((ln.strip(" #") for ln in body.splitlines() if ln.strip()), "Note")
result.pages.append(ImportPage(
title=title[:200] or "Note",
markdown=body,
source_path=item.get("uuid") or filename,
external_id=item.get("uuid") or f"{filename}#{count}",
))
count += 1
result.stats["rows"] = count
return result.finalize()
+305
View File
@@ -0,0 +1,305 @@
"""FlowDeck — tabular importers: typed CSV/TSV, Excel, generic JSON (v5.6.0, Phase 2).
Each source becomes a FlowDeck collection (database): columns are inferred from
the data (text/number/date/checkbox/email/url/select/multi_select) and rows are
inserted as ``collection_pages``.
"""
from __future__ import annotations
import csv
import io
import json
import re
from typing import Any
from app.services.importers.base import (
Importer,
ImportPage,
ImportResult,
decode_text,
register_importer,
)
_TITLE_HEADERS = ("title", "name", "task", "nom", "titre", "subject", "label")
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}([T ]\d{2}:\d{2}(:\d{2})?)?")
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
_URL_RE = re.compile(r"^https?://\S+$", re.IGNORECASE)
_BOOL_TRUE = {"true", "yes", "oui", "1", "x", "vrai"}
_BOOL_FALSE = {"false", "no", "non", "0", "faux", ""}
def _is_number(value: str) -> bool:
try:
float(str(value).replace(",", ".").replace(" ", ""))
return True
except (ValueError, TypeError):
return False
def _is_bool(value: str) -> bool:
return str(value).strip().lower() in _BOOL_TRUE | _BOOL_FALSE
def infer_column_type(values: list[str]) -> str:
"""Infer the FlowDeck property type from a list of raw string cells."""
sample = [str(v).strip() for v in values if str(v).strip()]
if not sample:
return "text"
if all(_is_bool(v) for v in sample):
return "checkbox"
if all(_is_number(v) for v in sample):
return "number"
if all(_DATE_RE.match(v) for v in sample):
return "date"
if all(_EMAIL_RE.match(v) for v in sample):
return "email"
if all(_URL_RE.match(v) for v in sample):
return "url"
unique = {v for v in sample}
if len(unique) <= 20 and len(unique) <= max(2, len(sample) // 2):
if any(("," in v or ";" in v) for v in sample):
return "multi_select"
return "select"
return "text"
def _split_multi(value: str) -> list[str]:
return [p.strip() for p in re.split(r"[;,]", value) if p.strip()]
def coerce_value(prop_type: str, value: Any) -> Any:
if value is None:
return None
raw = str(value).strip()
if raw == "":
return None
if prop_type == "number":
try:
num = float(raw.replace(",", ".").replace(" ", ""))
return int(num) if num.is_integer() else num
except (ValueError, TypeError):
return raw
if prop_type == "checkbox":
return raw.lower() in _BOOL_TRUE
if prop_type == "multi_select":
return _split_multi(raw)
return raw
def build_schema(headers: list[str], rows: list[dict[str, Any]]) -> tuple[list[dict], str]:
"""Return ``(schema, title_header)`` from headers + row dicts."""
title_header = ""
for h in headers:
if h and h.strip().lower() in _TITLE_HEADERS:
title_header = h
break
schema: list[dict] = []
for h in headers:
if not h or h == title_header:
continue
ptype = infer_column_type([r.get(h, "") for r in rows])
entry: dict[str, Any] = {"name": h, "type": ptype}
if ptype in ("select", "status", "multi_select"):
seen: list[str] = []
for r in rows:
vals = _split_multi(str(r.get(h, ""))) if ptype == "multi_select" else [str(r.get(h, "")).strip()]
for v in vals:
if v and v not in seen:
seen.append(v)
entry["options"] = [{"name": v, "color": "gray"} for v in seen[:100]]
schema.append(entry)
if title_header:
schema.insert(0, {"name": title_header, "type": "title"})
return schema, title_header
def rows_to_collection(name: str, headers: list[str], raw_rows: list[dict[str, Any]]) -> ImportPage:
"""Normalize parsed rows into an ImportPage carrying a collection spec."""
schema, title_header = build_schema(headers, raw_rows)
rows = _normalize_rows(headers, raw_rows, schema, title_header)
return ImportPage(
title=name or "Imported database",
collection={
"name": name or "Imported database",
"schema": schema,
"rows": rows,
"headers": headers,
"title_header": title_header,
"raw_rows": raw_rows,
},
source_path=name,
external_id=name,
)
def _normalize_rows(headers: list[str], raw_rows: list[dict[str, Any]],
schema: list[dict], title_header: str) -> list[dict[str, Any]]:
types = {s["name"]: s["type"] for s in schema}
rows: list[dict[str, Any]] = []
for raw in raw_rows:
title = ""
if title_header:
title = str(raw.get(title_header, "")).strip()
if not title:
for h in headers:
if h and str(raw.get(h, "")).strip():
title = str(raw[h]).strip()
break
props: dict[str, Any] = {}
for h in headers:
if not h or h == title_header:
continue
val = coerce_value(types.get(h, "text"), raw.get(h))
if val is not None and val != "":
props[h] = val
rows.append({"title": title or "Untitled", "properties": props})
return rows
def apply_type_mapping(spec: dict, mapping: dict[str, str]) -> dict:
"""Override inferred column types (UI mapping) and re-coerce the rows."""
if not mapping:
return spec
for entry in spec.get("schema", []):
if entry.get("name") in mapping:
entry["type"] = mapping[entry["name"]]
headers = spec.get("headers")
raw_rows = spec.get("raw_rows")
if headers is not None and raw_rows is not None:
spec["rows"] = _normalize_rows(headers, raw_rows, spec.get("schema", []),
spec.get("title_header", ""))
return spec
def _sniff_delimiter(sample: str) -> str:
try:
return csv.Sniffer().sniff(sample, delimiters=",;\t|").delimiter
except csv.Error:
return "\t" if sample.count("\t") > sample.count(",") else ","
@register_importer
class CsvImporter(Importer):
source_id = "csv"
label = "CSV / TSV (typé)"
description = "Tableur CSV/TSV : types inférés automatiquement, une collection par fichier."
extensions = (".csv", ".tsv")
order = 60
def detect(self, filename: str, data: bytes) -> bool:
return filename.lower().endswith((".csv", ".tsv"))
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
text = decode_text(data)
if not text.strip():
result.warn("Fichier vide")
return result.finalize()
delimiter = "\t" if filename.lower().endswith(".tsv") else _sniff_delimiter(text[:4096])
reader = csv.DictReader(io.StringIO(text), delimiter=delimiter)
headers = [h for h in (reader.fieldnames or []) if h is not None]
rows = [dict(r) for r in reader]
name = filename.replace("\\", "/").rsplit("/", 1)[-1].rsplit(".", 1)[0]
result.pages.append(rows_to_collection(name, headers, rows))
result.stats["rows"] = len(rows)
return result.finalize()
@register_importer
class ExcelImporter(Importer):
source_id = "excel"
label = "Excel (.xlsx)"
description = "Classeur Excel : une collection par feuille (openpyxl)."
extensions = (".xlsx", ".xlsm")
order = 61
def detect(self, filename: str, data: bytes) -> bool:
low = filename.lower()
if low.endswith((".xlsx", ".xlsm")):
return True
return low.endswith(".xls")
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
try:
from openpyxl import load_workbook
except ImportError:
result.warn("openpyxl n'est pas installé : import Excel indisponible")
return result.finalize()
try:
wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
except Exception as exc: # noqa: BLE001
result.warn(f"Classeur illisible : {exc}")
return result.finalize()
base = filename.replace("\\", "/").rsplit("/", 1)[-1].rsplit(".", 1)[0]
total_rows = 0
for ws in wb.worksheets:
values = list(ws.iter_rows(values_only=True))
if not values:
continue
headers = [str(h).strip() if h is not None else f"Column {i + 1}" for i, h in enumerate(values[0])]
rows: list[dict[str, Any]] = []
for row in values[1:]:
if row is None or all(c is None or str(c).strip() == "" for c in row):
continue
rows.append({headers[i]: row[i] for i in range(min(len(headers), len(row)))})
if not rows:
continue
name = f"{base} — {ws.title}" if len(wb.worksheets) > 1 else (base or ws.title)
page = rows_to_collection(name, headers, rows)
page.source_path = f"{filename}#{ws.title}"
page.external_id = page.source_path
result.pages.append(page)
total_rows += len(rows)
result.stats["rows"] = total_rows
return result.finalize()
@register_importer
class JsonImporter(Importer):
source_id = "json"
label = "JSON (mapping générique)"
description = "Tableau d'objets JSON → collection (union des clés)."
extensions = (".json",)
order = 65
def _records(self, data: bytes) -> list[dict] | None:
try:
obj = json.loads(decode_text(data))
except Exception: # noqa: BLE001
return None
if isinstance(obj, list) and obj and all(isinstance(x, dict) for x in obj):
return obj
if isinstance(obj, dict):
for value in obj.values():
if isinstance(value, list) and value and all(isinstance(x, dict) for x in value):
return value
return None
def detect(self, filename: str, data: bytes) -> bool:
if not filename.lower().endswith(".json"):
return False
return self._records(data) is not None
def parse(self, filename: str, data: bytes) -> ImportResult:
result = ImportResult(source=self.source_id)
records = self._records(data)
if not records:
result.warn("Aucun tableau d'objets JSON détecté")
return result.finalize()
headers: list[str] = []
for rec in records:
for key in rec:
if key not in headers:
headers.append(key)
flat: list[dict[str, Any]] = []
for rec in records:
row = {}
for h in headers:
v = rec.get(h)
row[h] = json.dumps(v, ensure_ascii=False) if isinstance(v, (dict, list)) else v
flat.append(row)
name = filename.replace("\\", "/").rsplit("/", 1)[-1].rsplit(".", 1)[0]
result.pages.append(rows_to_collection(name, headers, flat))
result.stats["rows"] = len(flat)
return result.finalize()
+94
View File
@@ -0,0 +1,94 @@
"""FlowDeck — URL / web clipper importer (v5.6.0, Phase 5).
Fetches a web page and turns it into a page: a bookmark card (OG metadata)
followed by the article converted to FlowDeck blocks.
"""
from __future__ import annotations
import ipaddress
import socket
from urllib.parse import urlparse
import httpx
from app.services.export import markdown_to_blocks
from app.services.importers.base import ImportPage, ImportResult
from app.services.importers.html_notes import _html_to_markdown
_BLOCKED_HOSTS = {"localhost", "localhost.localdomain"}
_MAX_BYTES = 3_000_000
def _is_public_host(host: str) -> bool:
"""SSRF guard: reject loopback/private/link-local/reserved addresses."""
if not host or host.lower() in _BLOCKED_HOSTS:
return False
try:
infos = socket.getaddrinfo(host, None)
except socket.gaierror:
return False
for info in infos:
address = info[4][0]
try:
ip = ipaddress.ip_address(address)
except ValueError:
return False
if (ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
return False
return True
def _validate_url(url: str) -> str:
parsed = urlparse(url.strip())
if parsed.scheme not in ("http", "https"):
raise ValueError("Seules les URLs http(s) sont autorisées")
if not parsed.hostname or not _is_public_host(parsed.hostname):
raise ValueError("Hôte non autorisé")
return url.strip()
def _bookmark_block(url: str, meta: dict) -> dict:
block = {"type": "bookmark", "url": url}
for key in ("title", "description", "image", "site_name"):
if meta.get(key):
block[key] = meta[key]
return block
async def fetch_url_result(url: str, *, transport: httpx.BaseTransport | None = None) -> ImportResult:
"""Fetch ``url`` and build a single-page ImportResult (raises on bad URL)."""
safe_url = _validate_url(url)
result = ImportResult(source="url")
try:
async with httpx.AsyncClient(
timeout=15, follow_redirects=True, transport=transport,
headers={"User-Agent": "FlowDeck-Importer/1.0"},
) as client:
response = await client.get(safe_url)
response.raise_for_status()
if response.url.host and not _is_public_host(response.url.host):
raise ValueError("Redirection vers un hôte non autorisé")
content_type = response.headers.get("content-type", "")
if "html" not in content_type.lower():
raise ValueError("La ressource n'est pas une page HTML")
body = response.text[:_MAX_BYTES]
except httpx.HTTPError as exc:
result.warn(f"Échec du téléchargement : {exc}")
return result.finalize()
from app.services.og_fetcher import parse_og
meta = parse_og(body, safe_url)
title = (meta.get("title") or urlparse(safe_url).hostname or "Page").strip()
markdown = _html_to_markdown(body)
blocks = [_bookmark_block(safe_url, meta)]
if markdown:
blocks.extend(markdown_to_blocks(markdown))
result.pages.append(ImportPage(
title=title[:200],
blocks=blocks,
source_path=safe_url,
external_id=safe_url,
))
return result.finalize()
+106 -10
View File
@@ -5,8 +5,11 @@ directly — it emits *tool intentions* (function calls) that AgentEngine turns
into guarded internal actions.
Supported providers (OpenAI-compatible chat-completions JSON response):
openai, anthropic*, google*, deepseek, qwencloud, nvidia, openrouter, ollama.
(* routed through an OpenAI-compatible gateway / any configured api_base)
openai, anthropic, mistral, cohere, google, groq, deepseek, openrouter,
nvidia, together, perplexity, xai, qwencloud, minimax, morph, fireworks,
cerebras, sambanova, chutes, xiaomi, sealion, sensenova,
ollama (local, no key). Anthropic, Google (`/v1beta/openai`) and Cohere
(`/compatibility/v1`) expose an OpenAI-compatible surface at their base URL.
When no API key is configured (or provider == "offline") the client falls back
to a deterministic, dependency-free *mock planner*. This keeps the whole agent
@@ -28,30 +31,104 @@ from app.config import settings
logger = logging.getLogger(__name__)
# Provider → default model + base URL when llm_model/api_base are empty.
# All entries speak the OpenAI-compatible chat-completions protocol (Anthropic,
# Google and Cohere expose an OpenAI-compatible surface at their given base).
PROVIDERS = {
"openai": ("https://api.openai.com/v1", "gpt-4o"),
"anthropic": ("https://api.anthropic.com/v1", "claude-opus-4-8"),
"google": ("https://generativelanguage.googleapis.com/v1beta", "gemini-2.0-pro"),
"mistral": ("https://api.mistral.ai/v1", "mistral-large-latest"),
"cohere": ("https://api.cohere.ai/compatibility/v1", "command-a-plus-05-2026"),
"google": ("https://generativelanguage.googleapis.com/v1beta/openai", "gemini-2.0-flash"),
"groq": ("https://api.groq.com/openai/v1", "llama-3.3-70b-versatile"),
"deepseek": ("https://api.deepseek.com/v1", "deepseek-chat"),
"qwencloud": ("https://dashscope.aliyuncs.com/compatible-mode/v1", "qwen-max"),
"nvidia": ("https://integrate.api.nvidia.com/v1", "nvidia/nemotron-3-super-120b-a12b"),
"openrouter": ("https://openrouter.ai/api/v1", "meta-llama/llama-3.3-70b-instruct"),
"nvidia": ("https://integrate.api.nvidia.com/v1", "nvidia/nemotron-3-super-120b-a12b"),
"together": ("https://api.together.xyz/v1", "meta-llama/Llama-3.3-70B-Instruct-Turbo"),
"perplexity": ("https://api.perplexity.ai", "sonar-pro"),
"xai": ("https://api.x.ai/v1", "grok-4.6"),
"qwencloud": ("https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "qwen-max"),
"minimax": ("https://api.minimax.chat/v1", "MiniMax-Text-01"),
"morph": ("https://api.morphllm.com/v1", "morph-v3-large"),
"fireworks": ("https://api.fireworks.ai/inference/v1",
"accounts/fireworks/models/deepseek-v4-pro-0813"),
"cerebras": ("https://api.cerebras.ai/v1", "llama-3.3-70b"),
"sambanova": ("https://api.sambanova.ai/v1", "Meta-Llama-3.3-70B-Instruct"),
"chutes": ("https://llm.chutes.ai/v1", "deepseek-ai/DeepSeek-V3"),
"xiaomi": ("https://api.xiaomimimo.com/v1", "mimo-7b-rl"),
"sealion": ("https://api.sea-lion.ai/v1", "aisingapore/Llama-SEA-LION-v3-70B-IT"),
"sensenova": ("https://api.sensenova.cn/compatible-mode/v1", "SenseChat-5"),
"ollama": ("http://localhost:11434/v1", "llama3.1"),
"offline": (None, None),
}
# Friendly display names for the Settings / Agent UIs.
PROVIDER_LABELS: dict[str, str] = {
"openai": "OpenAI",
"anthropic": "Anthropic",
"mistral": "Mistral",
"cohere": "Cohere",
"google": "Google Gemini",
"groq": "Groq",
"deepseek": "DeepSeek",
"openrouter": "OpenRouter",
"nvidia": "NVIDIA NIM",
"together": "Together AI",
"perplexity": "Perplexity",
"xai": "xAI (Grok)",
"qwencloud": "DashScope (Alibaba)",
"minimax": "MiniMax",
"morph": "Morph",
"fireworks": "Fireworks AI",
"cerebras": "Cerebras",
"sambanova": "SambaNova",
"chutes": "Chutes AI",
"xiaomi": "Xiaomi (MiMo)",
"sealion": "SEA-LION",
"sensenova": "SenseNova",
"ollama": "Ollama (local)",
"offline": "Hors-ligne (mock)",
}
# Curated model presets surfaced by /api/agent/providers for the UI selectors.
PROVIDER_MODELS: dict[str, list[str]] = {
"openai": ["gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "o3-mini", "gpt-4-turbo"],
"anthropic": ["claude-opus-4-8", "claude-sonnet-4-5", "claude-3-5-sonnet", "claude-haiku-4-5"],
"google": ["gemini-2.0-pro", "gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash"],
"mistral": ["mistral-large-latest", "mistral-medium-latest", "mistral-small-latest",
"codestral-latest", "open-mistral-nemo", "pixtral-large-latest"],
"cohere": ["command-a-plus-05-2026", "command-r-plus", "command-r", "command-a-03-2025"],
"google": ["gemini-2.0-flash", "gemini-2.0-flash-lite", "gemini-1.5-pro", "gemini-1.5-flash"],
"groq": ["llama-3.3-70b-versatile", "llama-3.1-8b-instant",
"mixtral-8x7b-32768", "gemma2-9b-it"],
"deepseek": ["deepseek-chat", "deepseek-reasoner"],
"qwencloud": ["qwen-max", "qwen-plus", "qwen-turbo", "qwen-long"],
"openrouter": ["meta-llama/llama-3.3-70b-instruct", "anthropic/claude-3.5-sonnet",
"openai/gpt-4o", "mistralai/mistral-large"],
"nvidia": ["nvidia/nemotron-3-super-120b-a12b", "nvidia/nemotron-3-nano-30b-a3b",
"meta/llama-3.1-70b-instruct", "nvidia/llama-3.3-nemotron-super-49b-v1.5",
"deepseek-ai/deepseek-v4-pro", "z-ai/glm-5.2"],
"openrouter": ["meta-llama/llama-3.3-70b-instruct", "anthropic/claude-3.5-sonnet",
"openai/gpt-4o", "mistralai/mistral-large"],
"together": ["meta-llama/Llama-3.3-70B-Instruct-Turbo",
"meta-llama/Meta-Llama-3.1-405B-Instruct-Turbo",
"Qwen/Qwen2.5-72B-Instruct-Turbo", "mistralai/Mixtral-8x7B-Instruct-v0.1"],
"perplexity": ["sonar-pro", "sonar", "sonar-reasoning", "sonar-deep-research"],
"xai": ["grok-4.6", "grok-4.5", "grok-4.3", "grok-4.20-0309-reasoning",
"grok-build-0.1"],
"qwencloud": ["qwen-max", "qwen-plus", "qwen-turbo", "qwen-long"],
"minimax": ["MiniMax-Text-01", "abab6.5s-chat", "abab6.5-chat"],
"morph": ["morph-v3-large", "morph-v3-fast"],
"fireworks": ["accounts/fireworks/models/deepseek-v4-pro-0813",
"accounts/fireworks/models/kimi-k2p6",
"accounts/fireworks/models/glm-5p2",
"accounts/fireworks/models/minimax-m3",
"accounts/fireworks/models/gpt-oss-120b",
"accounts/fireworks/models/qwen3-8b"],
"cerebras": ["llama-3.3-70b", "llama3.1-8b", "llama-3.1-70b"],
"sambanova": ["Meta-Llama-3.3-70B-Instruct", "Meta-Llama-3.1-405B-Instruct",
"Qwen2.5-72B-Instruct"],
"chutes": ["deepseek-ai/DeepSeek-V3", "deepseek-ai/DeepSeek-R1",
"Qwen/Qwen2.5-72B-Instruct"],
"xiaomi": ["mimo-7b-rl", "mimo-7b"],
"sealion": ["aisingapore/Llama-SEA-LION-v3-70B-IT",
"aisingapore/Gemma-SEA-LION-v3-9B-IT"],
"sensenova": ["SenseChat-5", "SenseChat-5-Cantonese", "SenseChat-Turbo"],
"ollama": ["llama3.1", "llama3", "mistral", "qwen2.5", "gemma2", "mixtral"],
"offline": [],
}
@@ -162,6 +239,24 @@ class LLMClient:
def _endpoint(self) -> str:
return f"{self.api_base.rstrip('/')}/chat/completions"
@staticmethod
def _http_error_detail(exc: httpx.HTTPStatusError) -> str:
"""Human-readable HTTP error including the provider's response body.
Providers return actionable JSON on 4xx (e.g. « model not allowed »,
« country not supported »); surfacing it makes the Settings test
debuggable instead of a bare « 403 Forbidden ».
"""
resp = exc.response
try:
body = (resp.text or "").strip()
except Exception: # noqa: BLE001 — body already consumed / undecodable
body = ""
if len(body) > 500:
body = body[:500] + "…"
base = f"{resp.status_code} {resp.reason_phrase} ({resp.url})"
return f"{base}: {body}" if body else base
async def _http_complete(self, messages, model, tools, *, _noticer: str = "") -> LLMResponse:
payload: dict = {
"model": model,
@@ -195,7 +290,8 @@ class LLMClient:
f"Le modèle « {model} » n'est plus disponible ({exc.response.status_code}). "
f"Réponse générée avec « {self.default_model} » à la place."
))
raise
# Surface the provider's own error body (403 « forbidden », 400 …).
raise RuntimeError(self._http_error_detail(exc)) from exc
response = self._parse_response(data, model)
response.notice = _noticer or ""
+47 -12
View File
@@ -14,7 +14,7 @@ import json
import time
from app.config import settings
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS
from app.services.llm_client import PROVIDER_LABELS, PROVIDER_MODELS, PROVIDERS
# Providers whose /v1/models lists far more entries than /v1/chat/completions
# actually serves. The fetched list is validated (name filter + live probe)
@@ -101,8 +101,12 @@ def set_llm_config(*, provider: str | None = None, model: str | None = None,
cfg["verified"] = 0
cfg["verified_model"] = ""
cfg["last_error"] = ""
if api_base is not None and api_base.strip():
cfg["api_base"] = api_base.strip()
if api_base is not None:
# Normalise so a base equal to the provider default is stored as "use
# default" — a later correction of PROVIDERS then applies automatically.
cfg["api_base"] = _normalize_api_base(
provider or cfg.get("provider") or "offline", api_base
)
if clear_keys:
cfg["api_key"] = ""
cfg["api_base"] = ""
@@ -165,7 +169,7 @@ def provider_info() -> list[dict]:
models.insert(0, default_model)
out.append({
"id": name,
"name": name.capitalize(),
"name": PROVIDER_LABELS.get(name) or name.replace("_", " ").title(),
"default_model": default_model,
"models": models,
"base_url": (base or ""),
@@ -222,18 +226,44 @@ def list_user_llm_keys(user_id: int) -> list[dict]:
return [_mask_key(r) for r in rows]
def _default_api_base(provider: str) -> str:
"""The OpenAI-compatible base URL the app uses by default for a provider."""
base = (PROVIDERS.get(provider.lower()) or (None, None))[0]
return (base or "").rstrip("/")
def _normalize_api_base(provider: str, api_base: str) -> str:
"""Drop an api_base that just repeats the provider default.
Storing the default as a per-user override freezes it: a later correction
of the provider URL (e.g. Cohere `/v2` → `/compatibility/v1`) would never
apply. An empty value means "use the provider default".
"""
value = (api_base or "").strip()
if value.rstrip("/") == _default_api_base(provider):
return ""
return value
def upsert_user_llm_key(user_id: int, provider: str, *, api_key: str = "",
api_base: str = "", default_model: str = "",
api_base: str | None = None, default_model: str = "",
models: list[str] | None = None) -> dict:
"""Upsert a user's provider key. Empty api_key keeps the existing one
(allows saving model/base without re-typing the key). Saving a *different*
key resets the `verified` flag so the provider must pass a test again."""
key resets the `verified` flag so the provider must pass a test again.
``api_base`` uses ``None`` to mean "keep the stored value" and an empty
string to explicitly reset it to the provider default.
"""
from app.db import get_conn
provider = provider.lower()
existing = get_user_llm_key(user_id, provider)
new_key = api_key if api_key else (existing.get("api_key", "") if existing else "")
new_base = api_base if api_base else (existing.get("api_base", "") if existing else "")
if api_base is None:
new_base = (existing.get("api_base", "") if existing else "")
else:
new_base = _normalize_api_base(provider, api_base)
new_model = default_model if default_model else (existing.get("default_model", "") if existing else "")
new_models = models if models is not None else (
json.loads(existing["models_json"]) if existing and existing.get("models_json") else []
@@ -296,8 +326,9 @@ async def fetch_provider_models(provider: str, *, api_key: str = "",
api_base: str = "", timeout: int = 20) -> list[str]:
"""Fetch the live model list from a provider (best-effort, no mock).
OpenAI-compatible providers use `GET {base}/models` with a Bearer token;
Anthropic uses `x-api-key` + `anthropic-version`; Gemini an `x-goog-api-key`.
OpenAI-compatible providers (including Google's `/openai` surface and
Cohere's compatibility API) use `GET {base}/models` with a Bearer token;
Anthropic's native model listing uses `x-api-key` + `anthropic-version`.
Returns a de-duplicated list capped at 300 models.
"""
import httpx
@@ -311,14 +342,18 @@ async def fetch_provider_models(provider: str, *, api_key: str = "",
headers: dict = {}
if provider == "anthropic":
headers = {"x-api-key": api_key, "anthropic-version": "2023-06-01"}
elif provider == "google":
headers = {"x-goog-api-key": api_key}
elif api_key:
headers = {"Authorization": f"Bearer {api_key}"}
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.get(f"{base_url}/models", headers=headers)
resp.raise_for_status()
if resp.status_code >= 400:
body = (resp.text or "").strip()
if len(body) > 500:
body = body[:500] + "…"
raise RuntimeError(
f"{resp.status_code} {resp.reason_phrase} ({resp.url}): {body}"
)
data = resp.json()
ids: list[str] = []
+68 -2
View File
@@ -121,13 +121,15 @@ def get_user_prefs(user_id: int, conn=None) -> dict:
"SELECT notification_prefs FROM users WHERE id=?", (user_id,)
).fetchone()
if not row or not row["notification_prefs"]:
return {"comments": True, "mentions": True}
return {"comments": True, "mentions": True, "reminders": True, "assignments": True}
try:
prefs = json.loads(row["notification_prefs"])
except (TypeError, json.JSONDecodeError):
prefs = {}
return {"comments": bool(prefs.get("comments", True)),
"mentions": bool(prefs.get("mentions", True))}
"mentions": bool(prefs.get("mentions", True)),
"reminders": bool(prefs.get("reminders", True)),
"assignments": bool(prefs.get("assignments", True))}
def set_user_prefs(user_id: int, prefs: dict, conn=None) -> dict:
@@ -145,3 +147,67 @@ def set_user_prefs(user_id: int, prefs: dict, conn=None) -> dict:
)
conn.commit()
return prefs
def _person_ids(value) -> list[int]:
"""Extract user ids from a stored ``person`` property value."""
ids: list[int] = []
if isinstance(value, dict):
value = [value]
if isinstance(value, list):
for person in value:
if isinstance(person, dict) and person.get("id"):
try:
ids.append(int(person["id"]))
except (TypeError, ValueError):
pass
return ids
def notify_assignment(collection_id: int, page_id: int, page_title: str,
old_props: dict, new_props: dict, actor_id: int | None,
conn=None) -> list[int]:
"""Notify users newly assigned via a ``person`` property.
Compares old vs new property values keyed by property id; users present
in the new value but not the old one get an in-app + (opt-in) email
notification. Returns the notified user ids.
"""
def _run(c):
props = c.execute(
"SELECT id, name FROM collection_properties "
"WHERE collection_id=? AND prop_type='person'",
(collection_id,),
).fetchall()
newly: list[int] = []
for p in props:
key = str(p["id"])
before = set(_person_ids((old_props or {}).get(key)))
after = _person_ids((new_props or {}).get(key))
for uid in after:
if uid not in before and uid != actor_id and uid not in newly:
newly.append(uid)
for uid in newly:
create_notification(
uid, actor_id, "assignment",
title="Assigned to you",
message=page_title or "Untitled",
resource_type="db_page",
resource_id=page_id,
url=f"/collections/{collection_id}",
conn=c, commit=False,
)
mailer.notify_user(
uid, subject="[FlowDeck] Assigned to you",
body_text=page_title or "Untitled",
cta_url=f"/collections/{collection_id}",
prefs_key="assignments",
)
return newly
if conn is not None:
return _run(conn)
with get_conn() as c:
result = _run(c)
c.commit()
return result
+74 -56
View File
@@ -7,39 +7,44 @@ slow/unreachable hosts.
"""
from __future__ import annotations
import html as htmlmod
import logging
import re
from urllib.parse import urljoin, urlparse
logger = logging.getLogger(__name__)
_META_RE = re.compile(
r'<meta\b[^>]*?(?:content=[\'"]([^\'"]*)[\'"]|property=[\'"]([^\'"]*)[\'"]|name=[\'"]([^\'"]*)[\'"]|content=[\'"]([^\'"]*)[\'"])[^>]*?>',
re.I,
)
_META_TAG_RE = re.compile(r"<meta\b[^>]*?>", re.I)
_ATTR_RE = re.compile(r"([A-Za-z_:][-A-Za-z0-9_:.]*)\s*=\s*[\"']([^\"']*)[\"']")
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.I | re.S)
_FAVICON_RE = re.compile(r"<link\b[^>]*?>", re.I)
_ICON_REL = re.compile(r"\b(?:shortcut\s+)?icon\b", re.I)
# Property/name keys we look for, in priority order, mapped to our payload keys.
_OG_TITLE = ("og:title", "twitter:title", "title", "og:site_name")
_OG_DESC = ("og:description", "twitter:description", "description")
_OG_IMG = ("og:image", "twitter:image", "twitter:image:src")
_OG_IMG = ("og:image", "twitter:image", "twitter:image:src", "image")
_OG_SITE = ("og:site_name", "twitter:site", "application-name")
def _extract_og(html: str) -> dict:
text = html[:400_000] # only scan the beginning — that's where <head> lives
def _attrs(tag: str) -> dict:
return {k.lower(): v for k, v in _ATTR_RE.findall(tag)}
def _extract_og(body: str) -> dict:
"""Parse all ``<meta>`` tags into a ``{key: content}`` dict.
Attributes may appear in any order (``content`` before or after
``property``/``name``), which the previous implementation mishandled.
First value wins so the most specific tag (top of document) is kept.
"""
props: dict[str, str] = {}
for m in _META_RE.finditer(text):
m.groups()
content = ""
prop = ""
for s in re.findall(r"(?:content|property|name)=[\"']([^\"']*)[\"']", m.group(0)):
if prop == "":
prop = s
else:
content = s
break
if prop:
props[prop.lower()] = content
for tag in _META_TAG_RE.finditer(body[:400_000]):
attrs = _attrs(tag.group(0))
key = (attrs.get("property") or attrs.get("name") or attrs.get("itemprop") or "").lower()
content = attrs.get("content")
if key and content is not None and key not in props:
props[key] = content
return props
@@ -51,12 +56,12 @@ def _pick(props: dict, keys: tuple) -> str:
return ""
def _title_of(props: dict, raw_title: str) -> str:
def _title_of(props: dict, body: str) -> str:
t = _pick(props, _OG_TITLE)
if t:
return t
m = re.search(r"<title[^>]*>(.*?)</title>", raw_title[:200_000], re.I | re.S)
return (m.group(1).strip() if m else "") or urlparse(props.get("_url", "")).netloc
m = _TITLE_RE.search(body[:200_000])
return m.group(1).strip() if m else ""
def _site_name(url: str) -> str:
@@ -64,20 +69,62 @@ def _site_name(url: str) -> str:
return host.split(".")[0].capitalize() if host else ""
async def fetch_og_metadata(url: str, timeout: float = 6.0) -> dict:
def _favicon(body: str, base_url: str) -> str:
for tag in _FAVICON_RE.finditer(body):
attrs = _attrs(tag.group(0))
rel = attrs.get("rel", "")
href = attrs.get("href", "")
if href and _ICON_REL.search(rel):
return urljoin(base_url, htmlmod.unescape(href))
return ""
def parse_og(body: str, url: str) -> dict:
"""Pure HTML → bookmark payload (no network). ``url`` is the base URL."""
src = url.strip()
if not src.startswith(("http://", "https://")):
src = "https://" + src
props = _extract_og(body)
title = htmlmod.unescape(_title_of(props, body))
desc = htmlmod.unescape(_pick(props, _OG_DESC))
img = _pick(props, _OG_IMG)
site = htmlmod.unescape(_pick(props, _OG_SITE)) or _site_name(src)
def abs_url(u: str) -> str:
return urljoin(src, htmlmod.unescape(u)) if u else ""
return {
"url": src,
"title": title.strip()[:200] or urlparse(src).netloc or src,
"description": desc.strip()[:400],
"image": abs_url(img),
"site_name": site.strip()[:100],
"favicon": _favicon(body, src),
}
async def fetch_og_metadata(url: str, timeout: float = 6.0, transport=None) -> dict:
"""Fetch ``url`` and return {url, title, description, image, site_name,
favicon}. Empty strings are omitted. Never raises for network errors."""
favicon}. Empty strings are omitted. Never raises for network errors.
``transport`` is an optional ``httpx`` transport (used by tests to mock
HTTP without hitting the network).
"""
src = url.strip()
if not src.startswith(("http://", "https://")):
src = "https://" + src
base = {"url": src, "title": "", "description": "", "image": "", "site_name": "", "favicon": ""}
try:
import httpx
headers = {
"User-Agent": "FlowDeck/5.10 bookmark-fetcher (+https://flowdeck.dracodev.net)",
"User-Agent": "FlowDeck/5.5 bookmark-fetcher (+https://flowdeck.dracodev.net)",
"Accept": "text/html,application/xhtml+xml",
}
async with httpx.AsyncClient(follow_redirects=True, timeout=timeout) as client:
kwargs = {"follow_redirects": True, "timeout": timeout}
if transport is not None:
kwargs["transport"] = transport
async with httpx.AsyncClient(**kwargs) as client:
resp = await client.get(src, headers=headers)
resp.raise_for_status()
except Exception as exc: # noqa: BLE001 - network/parse failures are non-fatal
@@ -92,33 +139,4 @@ async def fetch_og_metadata(url: str, timeout: float = 6.0) -> dict:
base["site_name"] = _site_name(src)
return base
import html as htmlmod
body = resp.text
props = _extract_og(body)
props["_url"] = src
title = htmlmod.unescape(_title_of(props, body))
desc = htmlmod.unescape(_pick(props, _OG_DESC))
img = _pick(props, _OG_IMG)
site = htmlmod.unescape(_pick(props, _OG_SITE)) or _site_name(src)
def abs_url(u: str) -> str:
if not u:
return ""
return urljoin(src, htmlmod.unescape(u))
favicon = ""
fm = re.search(r"<link[^>]+rel=[\"'](?:shortcut )?icon[\"'][^>]+href=[\"']([^\"']+)[\"']", body, re.I)
if not fm:
fm = re.search(r"<link[^>]+href=[\"']([^\"']+)[\"'][^>]+rel=[\"'](?:shortcut )?icon[\"']", body, re.I)
if fm:
favicon = abs_url(fm.group(1))
return {
"url": src,
"title": title.strip()[:200] or urlparse(src).netloc or src,
"description": desc.strip()[:400],
"image": abs_url(img),
"site_name": site.strip()[:100],
"favicon": favicon,
}
return parse_og(resp.text, src)
+52 -3
View File
@@ -136,6 +136,9 @@ def validate_property_value(prop_type: str, value: Any, options: list | None = N
datetime.fromisoformat(value.replace("Z", "+00:00"))
except (ValueError, TypeError):
return False, f"'{value}' is not a valid ISO 8601 date"
elif prop_type == "person":
if not isinstance(value, list):
return False, "Person must be a list of workspace members"
elif prop_type == "url":
if not isinstance(value, str):
return False, "URL must be a string"
@@ -227,17 +230,63 @@ def validate_property_rule(
return True, ""
def user_ref(user: dict | None) -> dict | None:
"""Normalize a session user dict into the stored ``person`` value shape."""
if not user:
return None
return {
"id": user.get("id"),
"login": user.get("login") or user.get("full_name") or "",
"full_name": user.get("full_name") or "",
"avatar_url": user.get("avatar_url") or "",
"avatar_color": user.get("avatar_color") or "#3A3A3A",
}
def get_auto_property_value(prop_type: str, user: dict | None = None) -> Any:
"""Compute the value of an auto-property."""
if prop_type == "created_time" or prop_type == "last_edited_time":
return datetime.now(UTC).isoformat()
if prop_type == "created_by" or prop_type == "last_edited_by":
if user:
return {"id": user.get("id"), "login": user.get("login")}
return None
return user_ref(user)
return None
def apply_auto_properties(
properties: list[dict],
values: dict,
user: dict | None = None,
*,
is_create: bool = False,
now: str | None = None,
) -> dict:
"""Fill/refresh auto-property values (``created_time``, ``created_by``,
``last_edited_time``, ``last_edited_by``) in ``values`` (keyed by property id).
``created_*`` are only written on creation (or when missing); ``last_edited_*``
are refreshed on every call. Returns the mutated dict.
"""
if values is None:
values = {}
stamp = now or datetime.now(UTC).isoformat()
for prop in properties or []:
ptype = prop.get("prop_type")
if ptype not in AUTO_TYPES:
continue
pid = str(prop.get("id"))
if ptype == "created_time":
if is_create or pid not in values or values.get(pid) in (None, ""):
values[pid] = stamp
elif ptype == "last_edited_time":
values[pid] = stamp
elif ptype == "created_by":
if is_create or pid not in values or values.get(pid) in (None, ""):
values[pid] = user_ref(user)
elif ptype == "last_edited_by":
values[pid] = user_ref(user)
return values
def get_next_unique_id(collection_id: int, conn) -> int:
"""Get the next unique_id for a collection (max + 1)."""
row = conn.execute(
+46 -4
View File
@@ -25,8 +25,26 @@ def color_for(uid: int) -> str:
def block_id() -> str:
import time
return f"b{int(time.time()*1000)}"
import uuid
return "b" + uuid.uuid4().hex[:12]
def ensure_block_ids(blocks: list[dict]) -> list[dict]:
"""Assign unique ids to blocks missing one, recursively.
Template-created pages may have been persisted without block ids; without
them the room state is not addressable by ops and the editor ends up with
``data-bid="undefined"`` blocks (duplicated / reordered lines).
"""
if not isinstance(blocks, list):
return blocks
for b in blocks:
if isinstance(b, dict):
if not b.get("id"):
b["id"] = block_id()
if isinstance(b.get("children"), list):
ensure_block_ids(b["children"])
return blocks
def apply_op(blocks: list[dict], op: dict) -> list[dict]:
@@ -37,6 +55,7 @@ def apply_op(blocks: list[dict], op: dict) -> list[dict]:
if not blk.get("id"):
blk = dict(blk)
blk["id"] = block_id()
ensure_block_ids([blk])
idx = op.get("index")
if not isinstance(idx, int):
idx = len(blocks)
@@ -126,7 +145,7 @@ class RealtimeManager:
room.title = row["title"] or ""
if (row["content_format"] or "") == "blocks" and row["content"]:
try:
room.blocks = json.loads(row["content"])
room.blocks = ensure_block_ids(json.loads(row["content"]))
except Exception:
room.blocks = []
return True
@@ -285,10 +304,33 @@ class RealtimeManager:
return
async def room_state(self, page_id: int) -> dict:
room = self.room(page_id)
room = self._rooms.get(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}
async def _propagate_synced(self, synced_id: int) -> None:
"""Broadcast a synced-block update to all rooms that reference it."""
from app.db import get_conn as _get_conn
# Find all pages that reference this synced block
pages: list[int] = []
try:
with _get_conn() as conn:
rows = conn.execute(
"SELECT page_id FROM page_synced_blocks WHERE synced_block_id=?",
(synced_id,),
).fetchall()
pages = [r["page_id"] for r in rows]
except Exception:
return
for pid in pages:
room = self._rooms.get(pid)
if room and room.conns:
# Re-load the page content from DB to get fresh synced blocks
await self.load_room(room)
await self._broadcast(room, {"t": "synced_update",
"synced_id": synced_id,
"version": room.version})
manager = RealtimeManager()
+273
View File
@@ -0,0 +1,273 @@
"""FlowDeck — Recurring events engine (v5.8.0 Calendrier & Rappels).
A recurring row stores its rule in ``property_values_json`` under the special
key ``__recurrence__``::
"__recurrence__": { "<date_prop_id>": {"freq": "weekly", "interval": 1,
"count": null, "until": null, "byweekday": [0,2,4],
"timezone": "Europe/Paris"} }
Only a RRULE subset is supported (matching the roadmap: daily/weekly/monthly
+ custom interval, COUNT, UNTIL and BYDAY for weekly). Expansion is computed
on the fly for a visible window — occurrences are virtual, never persisted.
All pure-Python (stdlib only) so it is trivially testable.
"""
from __future__ import annotations
import calendar as _cal
import datetime as dt
import logging
logger = logging.getLogger(__name__)
FREQS = ("daily", "weekly", "monthly")
# Monday-first weekday numbering (matches the UI calendar grid).
DOW_NAMES = {"mo": 0, "tu": 1, "we": 2, "th": 3, "fr": 4, "sa": 5, "su": 6}
RECURRENCE_KEY = "__recurrence__"
TIMEZONE_KEY = "__timezone__"
try: # Python >= 3.9 ships zoneinfo; keep a graceful fallback anyway.
from zoneinfo import ZoneInfo, available_timezones
def _zone(tz_name: str):
try:
return ZoneInfo(tz_name)
except Exception:
return None
def is_valid_timezone(tz_name: str) -> bool:
if not tz_name:
return True
try:
return tz_name in available_timezones() or tz_name == "UTC"
except Exception:
return False
except Exception: # pragma: no cover - environment without tz database
def _zone(tz_name: str):
return None
def is_valid_timezone(tz_name: str) -> bool:
return True # no tz database → accept anything (zoneinfo absent)
def parse_date(value) -> dt.date | None:
"""Parse a stored date property value ('YYYY-MM-DD[THH:MM]') into a date."""
s = str(value or "").strip()
if not s:
return None
s = s.replace(" ", "T")
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d"):
try:
return dt.datetime.strptime(s[:19], fmt).date()
except ValueError:
continue
try:
return dt.date.fromisoformat(s[:10])
except ValueError:
return None
def validate_rule(rule) -> tuple[bool, str]:
"""Validate a recurrence rule dict. Returns (ok, error_message)."""
if rule is None:
return True, ""
if not isinstance(rule, dict):
return False, "Recurrence must be an object"
freq = rule.get("freq")
if freq not in FREQS:
return False, f"freq must be one of {FREQS}"
interval = rule.get("interval", 1)
if not isinstance(interval, int) or isinstance(interval, bool) or interval < 1 or interval > 365:
return False, "interval must be an integer between 1 and 365"
count = rule.get("count")
if count is not None:
if not isinstance(count, int) or isinstance(count, bool) or count < 1 or count > 1000:
return False, "count must be an integer between 1 and 1000"
until = rule.get("until")
if until not in (None, "") and parse_date(until) is None:
return False, "until must be a YYYY-MM-DD date"
if count is not None and until not in (None, ""):
return False, "count and until are mutually exclusive"
bywd = rule.get("byweekday")
if bywd not in (None, []):
if not isinstance(bywd, list) or any(
(not isinstance(d, int) or isinstance(d, bool) or d < 0 or d > 6) for d in bywd
):
return False, "byweekday must be a list of integers 0-6 (Monday=0)"
if freq != "weekly":
return False, "byweekday is only valid for weekly recurrence"
tz = rule.get("timezone")
if tz not in (None, "") and not is_valid_timezone(tz):
return False, f"unknown timezone '{tz}'"
return True, ""
def _generate_occurrences(base: dt.date, rule: dict):
"""Yield every occurrence date in chronological order, honouring
freq/interval/count/until/byweekday. Infinite rules yield forever —
the caller must bound the consumption (see expand_rule)."""
freq = rule.get("freq", "daily")
interval = max(1, int(rule.get("interval") or 1))
count = rule.get("count")
until = parse_date(rule.get("until")) if rule.get("until") else None
weekdays = sorted(set(rule.get("byweekday") or [])) if freq == "weekly" else []
produced = 0
def limit_hit(d: dt.date) -> bool:
return (count is not None and produced >= count) or bool(until and d > until)
if freq == "daily":
d = base
while not limit_hit(d):
yield d
produced += 1
d += dt.timedelta(days=interval)
elif freq == "weekly":
# Monday=0 weekday numbering (matches the UI calendar grid).
days = weekdays or [(base.isoweekday() - 1) % 7]
week0 = base - dt.timedelta(days=(base.isoweekday() - 1) % 7)
w = week0
while True:
for off in days:
d = w + dt.timedelta(days=off)
if d < base or limit_hit(d):
continue
yield d
produced += 1
w += dt.timedelta(weeks=interval)
if limit_hit(w):
return
else: # monthly
i = 0
while True:
d = _nth_month_occurrence(base, i * interval)
if limit_hit(d):
return
yield d
produced += 1
i += 1
def expand_rule(base_value, rule: dict, window_start: dt.date, window_end: dt.date,
max_occurrences: int = 500) -> list[str]:
"""Return ISO dates ('YYYY-MM-DD') of occurrences inside the inclusive
window [window_start, window_end] for a rule anchored at ``base_value``.
Occurrences before the window are skipped (but still count against
``count``); the expansion is hard-capped so pathological rules can never
blow up a request.
"""
base = parse_date(base_value)
ok, _err = validate_rule(rule)
if base is None or rule is None or not ok:
return []
out: list[str] = []
scanned = 0
for d in _generate_occurrences(base, rule or {}):
if d > window_end:
break
scanned += 1
if scanned > 200000: # safety valve for old, very long-running rules
break
if d >= window_start:
out.append(d.isoformat())
if len(out) >= max_occurrences:
break
return out
def _nth_month_occurrence(base: dt.date, months_offset: int) -> dt.date:
month_index = (base.year * 12 + (base.month - 1)) + months_offset
year, month0 = divmod(month_index, 12)
month = month0 + 1
day = min(base.day, _cal.monthrange(year, month)[1])
return dt.date(year, month, day)
def now_in_tz(tz_name: str | None = None) -> dt.datetime:
"""Current time in the given IANA timezone (fallback: UTC)."""
zone = _zone(tz_name) if tz_name else None
if zone is not None:
return dt.datetime.now(zone)
return dt.datetime.now(dt.UTC)
def local_date_in_tz(tz_name: str | None = None) -> dt.date:
"""'Today' from the point of view of ``tz_name`` (fallback UTC)."""
return now_in_tz(tz_name).date()
def _zone_dt(d: dt.date, time_str: str, tz_name: str | None):
"""Combine an occurrence date + 'HH:MM' time into an aware datetime in
``tz_name`` (fallback naive→UTC). Default time 09:00 when none stored."""
hh, mm = 9, 0
if time_str and len(time_str) >= 16:
try:
hh, mm = int(time_str[11:13]), int(time_str[14:16])
except ValueError:
pass
zone = _zone(tz_name) if tz_name else None
naive = dt.datetime(d.year, d.month, d.day, hh, mm)
if zone is not None:
return naive.replace(tzinfo=zone)
return naive.replace(tzinfo=dt.UTC)
def next_occurrences(base_value, rule: dict, tz_name: str | None,
after: dt.datetime, horizon_days: int = 365,
max_count: int = 50) -> list[tuple[dt.date, dt.datetime]]:
"""Upcoming occurrences (date, aware event datetime) strictly on/after
``after`` for a row anchored at ``base_value``. Non-recurring rules
(rule falsy) yield the single base occurrence. Bounded by horizon."""
base = parse_date(base_value)
if base is None:
return []
time_str = str(base_value or "")
start = after.date()
if not rule:
event = _zone_dt(base, time_str, tz_name)
return [(base, event)] if event >= after else []
ok, _ = validate_rule(rule)
if not ok:
return []
out: list[tuple[dt.date, dt.datetime]] = []
window_end = start + dt.timedelta(days=horizon_days)
for iso in expand_rule(base_value, rule, start, window_end, max_occurrences=max_count):
d = dt.date.fromisoformat(iso)
event = _zone_dt(d, time_str, tz_name)
if event >= after:
out.append((d, event))
if len(out) >= max_count:
break
return out
# A pragmatic list for pickers (zoneinfo's full list is ~600 entries).
COMMON_TIMEZONES = [
"UTC", "Europe/Paris", "Europe/London", "Europe/Berlin", "Europe/Madrid",
"Europe/Rome", "Europe/Amsterdam", "Europe/Lisbon", "Europe/Zurich",
"Europe/Stockholm", "Europe/Warsaw", "Europe/Moscow", "Europe/Istanbul",
"America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles",
"America/Toronto", "America/Vancouver", "America/Mexico_City", "America/Sao_Paulo",
"America/Buenos_Aires", "Asia/Dubai", "Asia/Karachi", "Asia/Kolkata",
"Asia/Bangkok", "Asia/Singapore", "Asia/Shanghai", "Asia/Tokyo", "Asia/Seoul",
"Australia/Sydney", "Australia/Perth", "Pacific/Auckland", "Africa/Cairo",
"Africa/Lagos", "Africa/Johannesburg", "Africa/Algiers", "Africa/Casablanca",
]
def common_timezones() -> list[str]:
"""Timezone list for pickers: curated common zones + all installed ones
whose region matches, deduplicated and sorted."""
try:
from zoneinfo import available_timezones
all_zones = sorted(available_timezones())
# Merge: keep curated first, then everything else available.
extra = [z for z in all_zones if z not in COMMON_TIMEZONES]
return COMMON_TIMEZONES + extra
except Exception:
return list(COMMON_TIMEZONES)
+225
View File
@@ -0,0 +1,225 @@
"""FlowDeck — Reminder service (v5.8.0 Calendrier & Rappels).
Reminders live on database rows, next to the recurrence rule, inside
``property_values_json`` under the special key ``__reminder__``::
"__reminder__": { "<date_prop_id>": {"value": 30, "unit": "minutes"} }
A background loop scans rows with reminders, computes the next occurrence
(recurrence-aware, timezone-aware), and fires an in-app notification (+ email
when the user opted in) at ``event - lead``. Fired reminders are deduplicated
through the ``reminder_log`` table keyed by (page_id, occurrence date).
The scan is a plain synchronous function (:func:`scan_and_fire`) so tests can
drive it deterministically with an injected ``now``.
"""
from __future__ import annotations
import datetime as dt
import json
import logging
from app.db import get_conn
from app.services.recurrence import (
RECURRENCE_KEY,
TIMEZONE_KEY,
next_occurrences,
parse_date,
)
logger = logging.getLogger(__name__)
REMINDER_KEY = "__reminder__"
_UNITS = {"minutes": 60, "hours": 3600, "days": 86400}
# Fire late is still useful; ignore events that happened long ago.
FIRE_GRACE_SECONDS = 3600
def parse_lead(reminder) -> int | None:
"""Return the lead in seconds for a reminder dict, or None if disabled."""
if not isinstance(reminder, dict):
return None
unit = reminder.get("unit", "none")
if unit not in _UNITS:
return None
try:
value = int(reminder.get("value", 0))
except (TypeError, ValueError):
return None
if value <= 0:
return None
return value * _UNITS[unit]
def lead_human(reminder: dict) -> str:
try:
value = int(reminder.get("value", 0))
except (TypeError, ValueError):
return ""
unit = reminder.get("unit", "")
if value == 1 and unit.endswith("s"):
unit = unit[:-1]
return f"{value} {unit}"
def _person_targets(conn, collection_id: int, props: dict) -> list[int]:
"""User ids assigned via any ``person`` property of the row."""
targets: list[int] = []
person_props = conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='person'",
(collection_id,),
).fetchall()
for pp in person_props:
value = props.get(str(pp["id"])) or props.get(pp["name"] if "name" in pp.keys() else None)
if isinstance(value, list):
for person in value:
if isinstance(person, dict) and person.get("id"):
targets.append(int(person["id"]))
return targets
def _row_tz(conn, collection_id: int, props: dict) -> str:
"""Effective timezone of a row: per-event ``__timezone__`` first, then the
recurrence rule tz, then the created_by user's personal timezone,
then '' (UTC)."""
tzmap = props.get(TIMEZONE_KEY)
if isinstance(tzmap, dict):
for value in tzmap.values():
if value:
return str(value)
rec = props.get(RECURRENCE_KEY) or {}
for rule in rec.values():
if isinstance(rule, dict) and rule.get("timezone"):
return rule["timezone"]
auto_props = conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? AND prop_type='created_by'",
(collection_id,),
).fetchall()
for ap in auto_props:
value = props.get(str(ap["id"]))
people = value if isinstance(value, list) else ([value] if isinstance(value, dict) else [])
for person in people:
if isinstance(person, dict) and person.get("id"):
row = conn.execute(
"SELECT timezone FROM users WHERE id=?", (person["id"],)
).fetchone()
if row and row["timezone"]:
return row["timezone"]
return ""
def scan_and_fire(now: dt.datetime | None = None) -> int:
"""Fire every reminder that is due. Returns the number of notifications
created. Safe to call repeatedly (dedup via reminder_log)."""
now = now or dt.datetime.now(dt.UTC)
fired = 0
with get_conn() as conn:
pages = conn.execute(
"SELECT id, collection_id, title, property_values_json FROM collection_pages"
).fetchall()
for page in pages:
try:
props = json.loads(page["property_values_json"] or "{}")
except (json.JSONDecodeError, TypeError):
continue
reminders = props.get(REMINDER_KEY)
if not isinstance(reminders, dict):
continue
rec_all = props.get(RECURRENCE_KEY) if isinstance(props.get(RECURRENCE_KEY), dict) else {}
for prop_key, reminder in reminders.items():
lead = parse_lead(reminder)
if lead is None:
continue
base_value = props.get(str(prop_key))
if not base_value and str(prop_key).isdigit():
# fall back to name-keyed storage
pname = conn.execute(
"SELECT name FROM collection_properties WHERE id=?", (int(prop_key),)
).fetchone()
if pname:
base_value = props.get(pname["name"])
if parse_date(base_value) is None:
continue
rule = rec_all.get(str(prop_key))
if rule is None and not str(prop_key).isdigit():
pid = conn.execute(
"SELECT id FROM collection_properties WHERE collection_id=? AND name=?",
(page["collection_id"], str(prop_key)),
).fetchone()
if pid:
rule = rec_all.get(str(pid["id"]))
tz_name = _row_tz(conn, page["collection_id"], props)
# Look for the occurrence whose reminder moment is in
# [now - grace, now].
events = next_occurrences(
base_value, rule, tz_name or None,
after=now - dt.timedelta(seconds=lead + FIRE_GRACE_SECONDS),
horizon_days=400, max_count=2000,
)
due = None
for _d, event in events:
fire_at = event - dt.timedelta(seconds=lead)
if fire_at <= now and now < event + dt.timedelta(seconds=FIRE_GRACE_SECONDS):
due = (event, fire_at)
break
if due is None:
continue
event, _fire_at = due
if event < now - dt.timedelta(seconds=FIRE_GRACE_SECONDS):
continue
target_ids = _person_targets(conn, page["collection_id"], props)
if not target_ids:
row = conn.execute(
"SELECT id FROM users WHERE is_admin=1 ORDER BY id LIMIT 1"
).fetchone()
target_ids = [row["id"]] if row else []
for uid in dict.fromkeys(target_ids):
cur = conn.execute(
"INSERT OR IGNORE INTO reminder_log (page_id, occurrence_date) VALUES (?, ?)",
(page["id"], event.date().isoformat()),
)
if cur.rowcount != 1:
continue
from app.services import mailer, notifications
lead_text = lead_human(reminder)
notifications.create_notification(
uid, None, "reminder",
title=f"Reminder: {page['title'] or 'Untitled'}",
message=(f"Due in {lead_text} — {event.strftime('%Y-%m-%d %H:%M')}"
if lead else
f"Happening now — {event.strftime('%Y-%m-%d %H:%M')}"),
resource_type="db_page",
resource_id=page["id"],
url=f"/collections/{page['collection_id']}",
conn=conn, commit=False,
)
mailer.notify_user(
uid,
subject=f"[FlowDeck] Reminder: {page['title'] or 'Untitled'}",
body_text=f"« {page['title'] or 'Untitled'} » le {event.strftime('%Y-%m-%d %H:%M')} ({tz_name or 'UTC'}).",
cta_url=f"/collections/{page['collection_id']}",
prefs_key="reminders",
)
fired += 1
conn.commit()
return fired
async def reminder_scheduler(interval_seconds: int = 60):
"""Background loop: scan for due reminders once a minute."""
import asyncio
from app.config import settings
if not getattr(settings, "reminders_enabled", True):
logger.info("Reminder scheduler disabled via settings")
return
await asyncio.sleep(5)
while True:
try:
n = scan_and_fire()
if n:
logger.info("Reminders: fired %d notification(s)", n)
except Exception as exc: # pragma: no cover - defensive only
logger.warning("Reminder scan failed: %s", exc)
await asyncio.sleep(max(10, int(getattr(settings, "reminder_scan_interval_seconds", interval_seconds))))
+421
View File
@@ -0,0 +1,421 @@
"""FlowDeck — offline synchronization engine (v6.0.0 PWA).
Reconciles offline mutations (queued on the client) with server state:
- ``get_delta`` — changes on the server since a given timestamp, for offline
clients to pull before pushing their own batch.
- ``apply_batch`` — replays a batch of offline mutations with optimistic
concurrency control. ``sync_version`` (auto-bumped by SQLite triggers added
in migration v17) is the version token.
Conflict model (from docs/V6_PWA_Progressive_Web_App.md):
- edit-edit → last-write-wins by default (applied + reported)
- edit-delete→ the page was deleted server-side → orphan copy created
- create-create → same title already exists server-side → renamed "… (copie offline)"
- delete → soft-delete (idempotent)
"""
from __future__ import annotations
import datetime as _dt
import json
import logging
import time
from app.db import get_conn
logger = logging.getLogger(__name__)
class ConflictError(Exception):
"""Raised when a mutation conflicts with server state."""
def __init__(self, detail: dict):
super().__init__(detail.get("type"))
self.details = detail
class SyncEngine:
"""Apply/read offline mutations. Stateless methods, thin sqlite access."""
# ── helpers ────────────────────────────────────────────────────────────
@staticmethod
def _now_epoch() -> float:
return time.time()
@staticmethod
def _can_access(conn, user_id: int, workspace_id: int | None) -> bool:
"""Owner or member of the workspace; legacy NULL workspace → allow."""
if not workspace_id:
return True
ws = conn.execute(
"SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,)
).fetchone()
if ws and ws["owner_id"] == user_id:
return True
member = conn.execute(
"SELECT 1 FROM workspace_members WHERE workspace_id=? AND user_id=?",
(workspace_id, user_id),
).fetchone()
return bool(member)
@staticmethod
def _page_workspace_id(conn, page_id: int) -> int | None:
row = conn.execute(
"SELECT workspace_id FROM pages WHERE id=?", (page_id,)
).fetchone()
return row["workspace_id"] if row else None
# ── delta ─────────────────────────────────────────────────────────────
async def get_delta(self, user_id: int, since: float, workspace_id: int | None) -> dict:
"""Return server-side changes since `since` (epoch seconds)."""
if since and since > 1e12:
since /= 1000.0 # accept epoch-millis from legacy clients
changes: list[dict] = []
with get_conn() as conn:
if not self._can_access(conn, user_id, workspace_id):
return {"error": "forbidden"}
# ── pages (created / updated / soft-deleted) ──
rows = conn.execute(
"""SELECT * FROM pages
WHERE (? IS NULL OR workspace_id = ?)
AND (CAST(strftime('%s', COALESCE(updated_at, created_at)) AS REAL) > ?
OR (deleted_at IS NOT NULL
AND CAST(strftime('%s', deleted_at) AS REAL) > ?))""",
(workspace_id, workspace_id, since, since),
).fetchall()
for r in rows:
data = dict(r)
deleted = data.get("deleted_at") is not None
created_epoch = _iso_epoch(data.get("created_at"))
created_after = bool(created_epoch and created_epoch > since)
if deleted:
ctype = "page_deleted"
elif created_after:
ctype = "page_created"
else:
ctype = "page_updated"
changes.append({"change_type": ctype, "data": data})
# ── collections (created / updated) ──
coll_rows = conn.execute(
"SELECT * FROM collections WHERE CAST(strftime('%s', updated_at) AS REAL) > ?",
(since,),
).fetchall()
for r in coll_rows:
data = dict(r)
created_epoch = _iso_epoch(data.get("created_at"))
changes.append({
"change_type": "collection_created" if created_epoch and created_epoch > since
else "collection_updated",
"data": data,
})
# ── collection rows (informational for offline reading) ──
cp_rows = conn.execute(
"SELECT * FROM collection_pages WHERE CAST(strftime('%s', updated_at) AS REAL) > ?",
(since,),
).fetchall()
for r in cp_rows:
changes.append({"change_type": "collection_row_updated", "data": dict(r)})
return {
"changes": changes,
"server_time": self._now_epoch(),
"has_more": False,
}
# ── batch ──────────────────────────────────────────────────────────────
async def apply_batch(self, user_id: int, mutations: list[dict], device_id: str) -> dict:
"""Apply a batch of offline mutations; returns per-mutation results.
``mutations`` = [{"id"|"mutation_id", "type", "payload", "client_timestamp"}]
"""
results: list[dict] = []
conflicts: list[dict] = []
for mut in mutations:
mut_id = mut.get("id") or mut.get("mutation_id") or f"m{len(results)}"
mtype = mut.get("type", "")
payload = mut.get("payload") or {}
client_ts = float(mut.get("client_timestamp") or 0)
result = {
"mutation_id": mut_id,
"type": mtype,
"status": "synced",
"server_version": None,
}
try:
handler = getattr(self, f"_mut_{mtype}", None)
if handler is None:
raise ValueError(f"unknown mutation type: {mtype}")
outcome = handler(user_id, payload)
result.update(outcome)
status = "conflict" if outcome.get("conflict") else "synced"
result["status"] = status
if outcome.get("conflict"):
conflicts.append({
"mutation_id": mut_id,
"type": mtype,
**outcome["conflict"],
})
except ConflictError as exc:
result["status"] = "conflict"
result["conflict"] = exc.details
conflicts.append({"mutation_id": mut_id, "type": mtype, **exc.details})
except Exception as exc: # noqa: BLE001 — report, don't kill the batch
logger.warning("sync mutation %s failed: %s", mut_id, exc)
result["status"] = "failed"
result["error"] = str(exc)
finally:
self._record(user_id, device_id, mtype, mut, client_ts, result)
results.append(result)
return {"results": results, "conflicts": conflicts}
# ── page mutations ─────────────────────────────────────────────────────
def _mut_page_create(self, user_id: int, payload: dict) -> dict:
title = (payload.get("title") or "New page").strip() or "New page"
workspace_id = payload.get("workspace_id")
parent_id = payload.get("parent_id")
with get_conn() as conn:
if not self._can_access(conn, user_id, workspace_id):
raise ConflictError({"type": "forbidden", "workspace_id": workspace_id})
# create-create conflict: same title already present at same parent
new_title = title
dup = conn.execute(
"""SELECT id FROM pages
WHERE title=? AND (? IS NULL OR workspace_id = ?)
AND (? IS NULL OR parent_id IS ?)
AND deleted_at IS NULL
LIMIT 1""",
(title, workspace_id, workspace_id, parent_id, parent_id),
).fetchone()
if dup:
new_title = f"{title} (copie offline)"
cur = conn.execute(
"""INSERT INTO pages
(workspace, workspace_id, title, content, content_format,
parent_id, parent_section, sort_order)
VALUES ('', ?, ?, ?, ?, ?, 'Private', ?)""",
(workspace_id, new_title,
payload.get("content", ""),
payload.get("content_format", "blocks"),
parent_id,
payload.get("sort_order", 0)),
)
conn.commit()
page_id = cur.lastrowid
synced = payload.get("client_page_id")
return {"page_id": page_id, "server_version": 1, "client_page_id": synced,
"conflict": {"type": "create_create", "renamed": new_title != title}
if new_title != title else None}
def _mut_page_update(self, user_id: int, payload: dict) -> dict:
page_id = payload.get("page_id")
base_version = payload.get("base_version")
with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
raise ConflictError({"type": "page_not_found", "page_id": page_id})
ws_id = row["workspace_id"] or payload.get("workspace_id")
if not self._can_access(conn, user_id, ws_id):
raise ConflictError({"type": "forbidden", "page_id": page_id})
# edit-delete: page soft-deleted server-side → orphan copy
if row["deleted_at"] is not None:
cur = conn.execute(
"""INSERT INTO pages
(workspace, workspace_id, title, content, content_format, parent_section)
VALUES ('', ?, ?, ?, ?, 'Private')""",
(ws_id,
payload.get("title") or row["title"],
(payload.get("content")
if payload.get("content") is not None else row["content"]),
payload.get("content_format") or row["content_format"]),
)
conn.commit()
orphan_id = cur.lastrowid
raise ConflictError({
"type": "edit_delete",
"page_id": page_id,
"new_page_id": orphan_id,
"detail": "La page a été supprimée côté serveur — copie récréée en page orpheline",
})
# edit-edit: version mismatch → last-write-wins + conflict report
server_version = row["sync_version"]
conflict = None
if base_version is not None and server_version != base_version:
conflict = {
"type": "edit_edit",
"page_id": page_id,
"client_version": base_version,
"server_version": server_version,
}
sets, params = [], []
if payload.get("title") is not None:
sets.append("title=?")
params.append(payload["title"])
if payload.get("content") is not None:
sets.append("content=?")
params.append(payload["content"])
if payload.get("content_format") is not None:
sets.append("content_format=?")
params.append(payload["content_format"])
sets_str = ", ".join(sets) if sets else "updated_at=updated_at"
params.append(page_id)
conn.execute(
f"UPDATE pages SET {sets_str}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
params,
)
conn.commit()
new_version = conn.execute(
"SELECT sync_version FROM pages WHERE id=?", (page_id,)
).fetchone()["sync_version"]
return {"page_id": page_id, "server_version": new_version,
"conflict": conflict}
def _mut_page_delete(self, user_id: int, payload: dict) -> dict:
page_id = payload.get("page_id")
with get_conn() as conn:
row = conn.execute("SELECT id, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
return {"page_id": page_id, "server_version": None} # idempotent / already hard-deleted
ws_id = row["workspace_id"]
if not self._can_access(conn, user_id, ws_id):
raise ConflictError({"type": "forbidden", "page_id": page_id})
conn.execute(
"UPDATE pages SET deleted_at=CURRENT_TIMESTAMP, "
"updated_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL",
(page_id,),
)
conn.commit()
new_version = conn.execute(
"SELECT sync_version FROM pages WHERE id=?", (page_id,)
).fetchone()["sync_version"]
return {"page_id": page_id, "server_version": new_version}
def _mut_page_move(self, user_id: int, payload: dict) -> dict:
page_id = payload.get("page_id")
with get_conn() as conn:
row = conn.execute("SELECT id, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
return {"page_id": page_id, "server_version": None}
ws_id = row["workspace_id"]
if not self._can_access(conn, user_id, ws_id):
raise ConflictError({"type": "forbidden", "page_id": page_id})
sets, params = [], []
if payload.get("parent_id") is not None:
sets.append("parent_id=?")
params.append(payload["parent_id"])
if payload.get("sort_order") is not None:
sets.append("sort_order=?")
params.append(payload["sort_order"])
if sets:
params.append(page_id)
conn.execute(
f"UPDATE pages SET {', '.join(sets)}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
params,
)
conn.commit()
new_version = conn.execute(
"SELECT sync_version FROM pages WHERE id=?", (page_id,)
).fetchone()["sync_version"]
return {"page_id": page_id, "server_version": new_version}
# ── collection mutations ───────────────────────────────────────────────
def _mut_collection_create(self, user_id: int, payload: dict) -> dict:
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO collections (name, description, icon, schema_json)
VALUES (?, ?, ?, ?)""",
(payload.get("name", ""), payload.get("description", ""),
payload.get("icon", "📋"), json.dumps(payload.get("schema", []))),
)
conn.commit()
return {"collection_id": cur.lastrowid, "server_version": 1}
def _mut_collection_update(self, user_id: int, payload: dict) -> dict:
cid = payload.get("collection_id")
with get_conn() as conn:
row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone()
if not row:
raise ConflictError({"type": "collection_not_found", "collection_id": cid})
sets, params = [], []
if payload.get("name") is not None:
sets.append("name=?")
params.append(payload["name"])
if payload.get("description") is not None:
sets.append("description=?")
params.append(payload["description"])
if payload.get("icon") is not None:
sets.append("icon=?")
params.append(payload["icon"])
if payload.get("schema") is not None:
sets.append("schema_json=?")
params.append(json.dumps(payload["schema"]))
if sets:
params.append(cid)
conn.execute(
f"UPDATE collections SET {', '.join(sets)}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
params,
)
conn.commit()
new_version = conn.execute(
"SELECT sync_version FROM collections WHERE id=?", (cid,)
).fetchone()["sync_version"]
return {"collection_id": cid, "server_version": new_version}
def _mut_collection_delete(self, user_id: int, payload: dict) -> dict:
cid = payload.get("collection_id")
with get_conn() as conn:
conn.execute("DELETE FROM collections WHERE id=?", (cid,))
conn.commit()
return {"collection_id": cid, "server_version": None}
# ── audit ──────────────────────────────────────────────────────────────
def _record(self, user_id: int, device_id: str, mtype: str, mut: dict,
client_ts: float, result: dict) -> None:
"""Persist every mutation in the server-side audit queue."""
try:
with get_conn() as conn:
conn.execute(
"""INSERT INTO offline_sync_queue
(user_id, device_id, type, payload, client_timestamp,
server_version, status, error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(user_id, device_id, mtype, json.dumps(mut),
client_ts, result.get("server_version"),
result.get("status", "synced"),
result.get("error") or (json.dumps(result["conflict"], ensure_ascii=False)
if result.get("conflict") else None)),
)
conn.commit()
except Exception: # noqa: BLE001 — audit must never break the batch
logger.warning("failed to record sync audit row", exc_info=True)
def _iso_epoch(value) -> float | None:
"""Best-effort ISO→epoch. SQLite CURRENT_TIMESTAMP → 'YYYY-MM-DD HH:MM:SS'."""
try:
if value is None:
return None
# append a timezone so strptime behaves on naive timestamps
text = str(value).replace("T", " ").split(".")[0]
parsed = _dt.datetime.strptime(text, "%Y-%m-%d %H:%M:%S")
return parsed.replace(tzinfo=_dt.UTC).timestamp()
except ValueError:
return None
+204
View File
@@ -0,0 +1,204 @@
"""FlowDeck — v5.14.0 Synced blocks: a block created once, displayed
and edited across multiple pages.
The ``synced_blocks`` table stores the source-of-truth content. Each
page that uses a synced block stores a reference in ``page_synced_blocks``.
Editing the source propagates to all referencing pages (via realtime
rooms or on next load).
"""
from __future__ import annotations
import json
import logging
import uuid
from app.db import get_conn
logger = logging.getLogger(__name__)
def block_id() -> str:
return "sb" + uuid.uuid4().hex[:10]
# ── SyncedBlock CRUD ──────────────────────────────────────────────
def create_synced_block(workspace: str, title: str, content: list[dict],
created_by: int | None = None) -> int:
"""Create a new synced block and return its id."""
content_json = json.dumps(content, ensure_ascii=False) if content else "[]"
with get_conn() as conn:
cur = conn.execute(
"""INSERT INTO synced_blocks (title, content, created_by, workspace)
VALUES (?, ?, ?, ?)""",
(title, content_json, created_by, workspace),
)
conn.commit()
return cur.lastrowid
def get_synced_block(synced_id: int) -> dict | None:
"""Fetch a synced block by id."""
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM synced_blocks WHERE id=?", (synced_id,)
).fetchone()
if not row:
return None
return dict(row)
def update_synced_block(synced_id: int, title: str,
content: list[dict]) -> None:
"""Update a synced block's title and content."""
content_json = json.dumps(content, ensure_ascii=False)
with get_conn() as conn:
conn.execute(
"""UPDATE synced_blocks SET title=?, content=?,
updated_at=CURRENT_TIMESTAMP WHERE id=?""",
(title, content_json, synced_id),
)
conn.commit()
def delete_synced_block(synced_id: int) -> None:
"""Delete a synced block (cascades via FK)."""
with get_conn() as conn:
conn.execute("DELETE FROM synced_blocks WHERE id=?", (synced_id,))
conn.commit()
def list_synced_blocks(workspace: str) -> list[dict]:
"""List all synced blocks in a workspace."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM synced_blocks WHERE workspace=? ORDER BY updated_at DESC",
(workspace,),
).fetchall()
return [dict(r) for r in rows]
# ── Page references ───────────────────────────────────────────────
def add_page_synced(page_id: int, synced_block_id: int,
block_index: int = 0) -> None:
"""Record that a page references a synced block at a given index."""
with get_conn() as conn:
conn.execute(
"""INSERT OR IGNORE INTO page_synced_blocks
(page_id, synced_block_id, block_index)
VALUES (?, ?, ?)""",
(page_id, synced_block_id, block_index),
)
conn.commit()
def remove_page_synced(page_id: int, synced_block_id: int) -> None:
"""Remove a page's reference to a synced block."""
with get_conn() as conn:
conn.execute(
"DELETE FROM page_synced_blocks WHERE page_id=? AND synced_block_id=?",
(page_id, synced_block_id),
)
conn.commit()
def get_page_synced(page_id: int) -> list[dict]:
"""Get all synced block references for a page."""
with get_conn() as conn:
rows = conn.execute(
"""SELECT psb.*, sb.title, sb.content AS synced_content
FROM page_synced_blocks psb
JOIN synced_blocks sb ON sb.id=psb.synced_block_id
WHERE psb.page_id=? ORDER BY psb.block_index""",
(page_id,),
).fetchall()
return [dict(r) for r in rows]
# ── Block resolution ──────────────────────────────────────────────
def resolve_synced_block(blocks: list[dict]) -> list[dict]:
"""Replace synced block references with actual content for rendering.
A synced block in the block list looks like:
{"type": "synced", "synced_id": 42, "content": "..."}
This resolves it to the current content from the synced_blocks table.
"""
for b in blocks:
if b.get("type") == "synced" and b.get("synced_id"):
sb = get_synced_block(b["synced_id"])
if sb:
try:
resolved = json.loads(sb["content"])
if isinstance(resolved, list):
b["_synced_content"] = resolved
b["_synced_title"] = sb.get("title", "")
b["_synced_id"] = sb["id"]
b["_synced_updated"] = sb.get("updated_at", "")
except (json.JSONDecodeError, TypeError):
pass
if isinstance(b.get("children"), list):
resolve_synced_block(b["children"])
return blocks
def sync_synced_blocks_in_page(page_id: int) -> None:
"""Re-resolve all synced blocks in a page's content.
Called after a synced block is updated — refreshes the referencing
pages' block content so they reflect the latest source.
"""
refs = get_page_synced(page_id)
if not refs:
return
with get_conn() as conn:
row = conn.execute(
"SELECT content, content_format FROM pages WHERE id=?",
(page_id,),
).fetchone()
if not row or row["content_format"] != "blocks":
return
try:
blocks = json.loads(row["content"])
except (json.JSONDecodeError, TypeError):
return
changed = False
for ref in refs:
sb = get_synced_block(ref["synced_block_id"])
if sb:
try:
resolved = json.loads(sb["content"])
except (json.JSONDecodeError, TypeError):
continue
for b in blocks:
if b.get("type") == "synced" and b.get("synced_id") == ref["synced_block_id"]:
b["content"] = json.dumps(resolved, ensure_ascii=False) if isinstance(resolved, list) else json.dumps(resolved)
b["_synced_content"] = resolved
b["_synced_title"] = sb.get("title", "")
changed = True
break
if changed:
with get_conn() as conn:
conn.execute(
"UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(blocks, ensure_ascii=False), page_id),
)
conn.commit()
# ── Unsync: convert synced block to independent copy ──────────────
def unsync_block(page_id: int, synced_block_id: int) -> list[dict] | None:
"""Remove a page's sync reference and return the current content
so the caller can turn it into an independent block."""
sb = get_synced_block(synced_block_id)
if not sb:
return None
remove_page_synced(page_id, synced_block_id)
try:
return json.loads(sb["content"])
except (json.JSONDecodeError, TypeError):
return None
+90
View File
@@ -0,0 +1,90 @@
"""FlowDeck — Wiki-links & page mentions (v5.11.0).
Inline references live inside plain-text block content as tokens::
[[fdpage:123]] link to page id 123, label resolved at render time
[[fddate:2026-10-01]] date chip (static label)
The page token stores only the id, so renaming a page propagates everywhere
(the label is resolved through the DB / the titles endpoint at render time).
Backlinks keep working because the server renderer emits ``/pages/<id>``
anchors, and the backlink scanner also matches the raw token.
Pure-stdlib helpers shared by the public renderer and the block export.
"""
from __future__ import annotations
import html as _html
import re
WIKI_PAGE_RE = re.compile(r"\[\[fdpage:(\d+)\]\]")
WIKI_DATE_RE = re.compile(r"\[\[fddate:(\d{4}-\d{2}-\d{2})(?:T[0-9:]{5,8})?\]\]")
_DATE_LABELS = {0: "Mon", 1: "Tue", 2: "Wed", 3: "Thu", 4: "Fri", 5: "Sat", 6: "Sun"}
_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
def extract_page_ids(text: str) -> list[int]:
"""All page ids referenced by wiki tokens in a content string."""
return sorted({int(m) for m in WIKI_PAGE_RE.findall(text or "")})
def _date_label(iso: str) -> str:
try:
parts = iso.split("-")
y, m, d = int(parts[0]), int(parts[1]), int(parts[2])
import datetime as _dt
wd = _dt.date(y, m, d).weekday()
return f"{_DATE_LABELS[wd]} {d} {_MONTHS[m-1]} {y}"
except (ValueError, IndexError):
return iso
def token_labels(conn, content: str) -> dict[str, str]:
"""Map every token in ``content`` to its display label (page title)."""
out: dict[str, str] = {}
ids = extract_page_ids(content)
if ids:
placeholders = ",".join("?" * len(ids))
rows = conn.execute(
f"SELECT id, title, page_icon FROM pages WHERE id IN ({placeholders})", ids,
).fetchall()
by_id = {str(r["id"]): (r["title"] or "Untitled", r["page_icon"] or "") for r in rows}
for token_id in ids:
title, icon = by_id.get(str(token_id), ("Deleted page", ""))
out[f"[[fdpage:{token_id}]]"] = f"{icon + ' ' if icon else ''}{title}"
for m in WIKI_DATE_RE.finditer(content or ""):
iso = m.group(1)
out[f"[[fddate:{iso}]]"] = _date_label(iso)
return out
def resolve_tokens_html(content: str, titles: dict[str, str]) -> str:
"""Escape plain-text content then turn wiki tokens into HTML chips/links.
``titles`` maps token → label (from :func:`token_labels`). Used by the
public page renderer and any server-side HTML output of block content.
"""
s = _html.escape(content or "")
def _page(m: re.Match) -> str:
pid = m.group(1)
label = titles.get(m.group(0)) or "Deleted page"
return (f'<a class="fd-wiki-link" href="/pages/{pid}" '
f'data-pid="{pid}" title="{_html.escape(label)}">{_html.escape(label)}</a>')
def _date(m: re.Match) -> str:
iso = m.group(1)
label = titles.get(f"[[fddate:{iso}]]") or iso
return f'<span class="fd-wiki-date" title="{_html.escape(iso)}">{_html.escape(label)}</span>'
s = WIKI_PAGE_RE.sub(_page, s)
s = WIKI_DATE_RE.sub(_date, s)
return s
def find_referring(content: str, page_id: int) -> bool:
"""True when the content references ``page_id`` (anchor or wiki token)."""
if not content:
return False
return (f"/pages/{page_id}" in content) or (f"[[fdpage:{page_id}]]" in content)
+89 -35
View File
@@ -12,8 +12,8 @@
avec la capacité respective de la page au clic.
Handlers attendus (tous optionnels) :
open, openTab, peek, folder, rename, setIcon, duplicate, link, move,
fav, recent, delete, tagExisting, tagAdd, tagRemove
open, openTab, peek, folder, rename, setIcon, duplicate, link, download,
copyContent, move, fav, recent, delete, tagExisting, tagAdd, tagRemove
############################################################################}
<script data-cfasync="false">
/* ═════════════════════════════════════════════════════════════════════
@@ -27,12 +27,19 @@
if (window.__fdCtxMenuRegistered) return;
window.__fdCtxMenuRegistered = true;
document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdCtxStore) return;
if (window.Alpine) window.Alpine.__fdCtxStore = true;
// Sur un chargement complet de page, ce script inline s'exécute AVANT
// alpine.min.js (defer) → Alpine n'existe pas encore : on attend 'alpine:init'.
// Sur une navigation partielle (fdNavigate → htmx), Alpine est déjà démarré et
// 'alpine:init' ne sera plus jamais émis → on enregistre le store tout de suite,
// sinon le menu contextuel reste mort jusqu'au prochain chargement complet.
function registerFdCtx() {
if (!window.Alpine) return;
if (window.Alpine.__fdCtxStore) return;
window.Alpine.__fdCtxStore = true;
Alpine.store('fdCtx', {
open: false, x: 0, y: 0, node: null, page: null,
maxH: 0, _cx: 0, _cy: 0, _ro: null,
handlers: {},
/* Tags (workspace) */
availTags: [], tagAdding: false, tagExistingOpen: false,
@@ -56,27 +63,56 @@
this.newTagColor = (node && node.tags && node.tags[0] && node.tags[0].color) || '#787774';
var cx = (ev && ev.clientX) || 0;
var cy = (ev && ev.clientY) || 0;
this._cx = cx;
this._cy = cy;
this.x = Math.max(8, Math.min(cx, window.innerWidth - 240));
this.y = Math.max(8, Math.min(cy, window.innerHeight - 48));
this.open = true;
var self = this;
/* Mesure la taille réelle du menu (une fois rendu) puis le replace
pour qu'il reste TOUJOURS entièrement visible dans le viewport. */
var place = function () {
pour qu'il reste TOUJOURS entièrement visible dans le viewport.
Un ResizeObserver relance le placement à chaque fois que le menu
change de taille (ouverture d'un sous-menu « tag », icônes, …),
sinon il grandissait vers le bas et sortait de l'écran. */
var after = function () {
self._place();
var el = document.querySelector('.fd-ctx-menu');
var w = el ? el.offsetWidth : 240;
var h = el ? el.offsetHeight : 320;
var vw = window.innerWidth, vh = window.innerHeight;
var nx = cx, ny = cy;
if (nx + w > vw - 8) nx = vw - w - 8;
if (ny + h > vh - 8) ny = vh - h - 8;
self.x = Math.max(8, nx);
self.y = Math.max(8, ny);
if (el && window.ResizeObserver) {
if (self._ro) { try { self._ro.disconnect(); } catch (e) {} }
self._ro = new ResizeObserver(function () { self._place(); });
self._ro.observe(el);
}
};
if (window.Alpine && window.Alpine.nextTick) window.Alpine.nextTick(place);
else setTimeout(place, 0);
if (window.Alpine && window.Alpine.nextTick) window.Alpine.nextTick(after);
else setTimeout(after, 0);
},
/* Reclasse le menu dans le viewport et limite sa hauteur à l'espace
disponible sous son ancre (le contenu déborde en scroll interne). */
_place() {
var el = document.querySelector('.fd-ctx-menu');
if (!el) return;
var prev = el.style.maxHeight;
el.style.maxHeight = 'none';
var w = el.offsetWidth || 240;
var h = el.offsetHeight || 320;
el.style.maxHeight = prev || '';
var vw = window.innerWidth, vh = window.innerHeight;
var cx = (this._cx == null ? this.x : this._cx);
var cy = (this._cy == null ? this.y : this._cy);
var nx = cx;
if (nx + w > vw - 8) nx = vw - w - 8;
nx = Math.max(8, nx);
var ny = cy;
if (ny + h > vh - 8) {
if (cy - h >= 8) ny = cy - h;
else ny = Math.max(8, vh - h - 8);
}
this.x = nx;
this.y = ny;
this.maxH = Math.max(120, vh - ny - 8);
},
close() {
if (this._ro) { try { this._ro.disconnect(); } catch (e) {} this._ro = null; }
this.open = false;
this.node = null;
this.handlers = {};
@@ -119,13 +155,30 @@
/* ── Icon picker ── */
setIcon(icon) {
icon = (icon || '').trim();
if (!icon) return;
var fn = this.handlers.setIcon;
if (fn) try { fn(icon); } catch (e) { console.error('fdCtx.setIcon', e); }
this.close();
},
openIconPicker() {
var fn = this.handlers.setIcon;
if (!fn) return;
if (!window.FDIconPicker) return;
window.FDIconPicker.openFor({
x: this.x,
y: this.y,
onPick: fn,
onRemove: function () { fn(''); }
});
this.close();
},
});
});
}
if (window.Alpine) {
registerFdCtx();
} else {
document.addEventListener('alpine:init', registerFdCtx);
}
})();
</script>
@@ -144,7 +197,7 @@
.fd-ctx-menu .ctx-caret{font-size:10px;color:var(--text-dim);}
.fd-ctx-menu .ctx-x{color:var(--text-dim);font-size:14px;line-height:1;padding:0 2px;}
.fd-ctx-menu .ctx-x:hover{color:var(--danger);}
.fd-ctx-menu .ctx-sub{padding:2px 0 4px;background:var(--bg-tertiary);border-radius:6px;margin:2px 6px;}
.fd-ctx-menu .ctx-sub{padding:2px 0 4px;background:var(--bg-tertiary);border-radius:6px;margin:2px 6px;max-height:38vh;overflow-y:auto;}
.fd-ctx-menu .ctx-tag-color{width:10px;height:10px;border-radius:3px;display:inline-block;flex-shrink:0;}
.fd-ctx-menu .ctx-colors{display:grid;grid-template-columns:repeat(7,1fr);gap:4px;padding:2px 4px 6px;}
.fd-ctx-menu .ctx-swatch{width:16px;height:16px;border-radius:4px;cursor:pointer;border:2px solid transparent;}
@@ -161,7 +214,7 @@
@click.outside="$store.fdCtx.close()"
@keydown.escape.window="$store.fdCtx.close()"
x-transition
:style="{ top: $store.fdCtx.y + 'px', left: $store.fdCtx.x + 'px' }">
:style="{ top: $store.fdCtx.y + 'px', left: $store.fdCtx.x + 'px', maxHeight: $store.fdCtx.maxH ? $store.fdCtx.maxH + 'px' : 'calc(100vh - 16px)' }">
{# ── Open ── #}
<div class="menu-item" x-show="$store.fdCtx.has('open')" @click="$store.fdCtx.run('open')">
@@ -195,21 +248,10 @@
<span style="flex:1;">Rename</span><span class="menu-shortcut">Ctrl+⇧+R</span>
</div>
{# ── Edit icon ── #}
<div class="menu-item" x-show="$store.fdCtx.has('setIcon')" @click.stop="$store.fdCtx.iconOpen = !$store.fdCtx.iconOpen">
{# ── Edit icon (sélecteur façon Notion) ── #}
<div class="menu-item" x-show="$store.fdCtx.has('setIcon')" @click.stop="$store.fdCtx.openIconPicker()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
<span style="flex:1;">Edit icon</span><span class="ctx-caret" x-text="$store.fdCtx.iconOpen ? '▾' : '▸'"></span>
</div>
<div x-show="$store.fdCtx.iconOpen" class="ctx-sub">
<div class="ctx-icons">
<template x-for="ic in $store.fdCtx.iconChoices" :key="'ic'+ic">
<button type="button" class="ctx-icon" @click.stop="$store.fdCtx.setIcon(ic)" x-text="ic"></button>
</template>
</div>
<div style="display:flex;gap:4px;padding:0 4px;">
<input class="ctx-input" x-ref="ctxIconInput" placeholder="Custom emoji…" @click.stop @keydown.enter.prevent="$store.fdCtx.setIcon($refs.ctxIconInput.value)">
<button type="button" class="ctx-btn" @click.stop="$store.fdCtx.setIcon($refs.ctxIconInput.value)">Set</button>
</div>
<span style="flex:1;">Edit icon</span>
</div>
{# ── Duplicate ── #}
@@ -224,6 +266,18 @@
<span style="flex:1;">Copy link</span>
</div>
{# ── Download file (file pages) ── #}
<div class="menu-item" x-show="$store.fdCtx.has('download')" @click="$store.fdCtx.run('download')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
<span style="flex:1;">Download</span>
</div>
{# ── Copy file content (textual file pages) ── #}
<div class="menu-item" x-show="$store.fdCtx.has('copyContent')" @click="$store.fdCtx.run('copyContent')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 01-2-2V4a2 2 0 012-2h9a2 2 0 012 2v1"/></svg>
<span style="flex:1;">Copy content</span>
</div>
{# ── Move to ── #}
<div class="menu-item" x-show="$store.fdCtx.has('move')" @click="$store.fdCtx.run('move')">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>
File diff suppressed because it is too large Load Diff
+19 -4
View File
@@ -36,7 +36,8 @@
<header class="topbar unified-header">
{% if not hide_hamburger %}
<button class="hamburger-btn"
@click="{{ hamburger_click|default('sidebarCollapsed ? (sidebarCollapsed = false) : (mobileSidebarOpen = true, sidebarCollapsed = false)') }}"
:class="{ 'hamburger-desktop-show': sidebarCollapsed }"
@click="{{ hamburger_click|default('sidebarCollapsed ? toggleSidebar() : (mobileSidebarOpen = true, sidebarCollapsed = false)') }}"
title="Toggle sidebar">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"/>
@@ -51,7 +52,7 @@
<div class="topbar-left header-breadcrumb" x-data="fdBreadcrumb()" x-init="init()"
@mouseleave="dragCloseTimer()">
{% if page_icon %}
<span class="header-page-icon">{% if page_icon in ("folder","file","star","link","trash","book","home","settings","lock","globe","image","edit","calendar","users","user","search","tag","bar-chart","grid","list","align-left","zap","paperclip","key","refresh","upload") %}{{ fd_icon(page_icon,18) }}{% else %}{{ page_icon }}{% endif %}</span>
<span class="header-page-icon">{{ fd_page_icon(page_icon, 18) }}</span>
{% endif %}
<template x-for="(crumb, di) in display" :key="di">
@@ -141,7 +142,7 @@
</div>
</header>
<script>
<script data-cfasync="false">
document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdBreadcrumbRegistered) return;
if (window.Alpine) window.Alpine.__fdBreadcrumbRegistered = true;
@@ -167,6 +168,16 @@ document.addEventListener('alpine:init', function () {
this.wsId = d.workspace_id || 0;
} catch (e) { this.crumbs = []; }
}
// Keep the breadcrumb in sync when the open page is renamed in the editor.
var self = this;
window.addEventListener('flowdeck:page-renamed', function (e) {
var d = e && e.detail;
if (!d || d.id == null) return;
var id = String(d.id);
self.crumbs = self.crumbs.map(function (c) {
return String(c.id) === id ? Object.assign({}, c, { label: d.title || 'Untitled' }) : c;
});
});
},
get display() {
@@ -247,7 +258,11 @@ document.addEventListener('alpine:init', function () {
this.activeItems = [];
},
go: function (url) { if (url) window.location.href = url; }
go: function (url) {
if (!url) return;
if (window.fdNavigate) window.fdNavigate(url);
else window.location.href = url;
}
};
});
});
+467
View File
@@ -0,0 +1,467 @@
{###############################################################################
_icon_picker.html — Sélecteur d'icône/emoji façon Notion (v5.6.0)
═══════════════════════════════════════════════════════════════════════════
Popover global (inclus par base.html) utilisé par :
• le menu contextuel (library / local-workspace) via fdCtx.setIcon
• l'éditeur de page (clic sur l'icône du titre)
Expose le store Alpine `fdIconPicker` + `window.FDIconPicker`.
Onglets Emoji / Icons / Upload, recherche, aléatoire, teintes de peau,
récents (localStorage) et emojis personnalisés du workspace (API).
############################################################################}
{% set picker_icons = ['folder','file','calendar','clock','star','bot','users','globe','lock','book','check-square','trash','help-circle','settings','refresh','log-out','message-square','home','search','link','plus','bell','image','download','list','bar-chart','grid','align-left','corner-down-right','copy','key','inbox','edit','eye','share','x','paperclip','external-link','sparkles','lightbulb','tag','file-text','save','upload','trending-up','zap','alert-triangle','user'] %}
<script data-cfasync="false">
/* Global JS mirror of _icons.html + helpers to render emoji / icon / custom-emoji URLs. */
(function () {
var FD_ICONS = {
'folder': '<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>',
'file': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
'star': '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
'bot': '<rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v3"/><line x1="12" y1="20" x2="12" y2="24"/><circle cx="8" cy="13" r="1"/><circle cx="16" cy="13" r="1"/>',
'users': '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
'globe': '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
'lock': '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
'book': '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>',
'check-square': '<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
'help-circle': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'settings': '<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
'refresh': '<polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/>',
'log-out': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
'message-square': '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>',
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
'link': '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
'plus': '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
'bell': '<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
'image': '<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/>',
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
'chevron-left': '<polyline points="15 18 9 12 15 6"/>',
'chevron-right': '<polyline points="9 18 15 12 9 6"/>',
'list': '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
'bar-chart': '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
'grid': '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
'align-left': '<line x1="17" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="17" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/>',
'corner-down-right': '<polyline points="15 10 20 15 15 20"/><path d="M4 4v7a4 4 0 0 0 4 4h12"/>',
'copy': '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
'chevron-down': '<polyline points="6 9 12 15 18 9"/>',
'key': '<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/>',
'inbox': '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
'edit': '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>',
'eye-off': '<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/>',
'eye': '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
'share': '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>',
'more-horizontal': '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
'x': '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
'paperclip': '<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
'sparkles': '<path d="M12 3l1.9 5.8 6.1.5-4.9 3.6 1.7 5.8-4.8-3.5-4.8 3.5 1.7-5.8-4.9-3.6 6.1-.5z"/>',
'lightbulb': '<path d="M9 18h6"/><path d="M10 22h4"/><path d="M15.09 14c.18-.98.65-1.74 1.41-2.5A4.65 4.65 0 0 0 18 8 6 6 0 0 0 6 8c0 1 .23 2.23 1.5 3.5A4.61 4.61 0 0 1 8.91 14"/>',
'tag': '<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>',
'file-text': '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>',
'save': '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
'upload': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/>',
'trending-up': '<polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/>',
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
'alert-triangle': '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
'user': '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>'
};
window.FD_ICONS = FD_ICONS;
window.fd_icon = function (name, size) {
size = size || 18;
var inner = FD_ICONS[name];
if (inner) return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + inner + '</svg>';
return (window.getSvgIcon ? getSvgIcon(name, size) : '');
};
/* Render a page_icon value: image URL, known icon name, or emoji text. */
window.fdIconHtml = function (value, size) {
size = size || 16;
var v = (value == null ? '' : String(value)).trim();
if (!v) return '';
if (v.charAt(0) === '/' || v.slice(0, 4) === 'http') {
return '<img src="' + v.replace(/"/g, '&quot;') + '" alt="" style="width:' + size + 'px;height:' + size + 'px;object-fit:contain;vertical-align:middle;display:inline-block;">';
}
if (FD_ICONS[v] || (window.getSvgIcon && getSvgIcon(v, size))) return window.fd_icon(v, size);
return v;
};
})();
</script>
<script data-cfasync="false">
(function () {
if (window.__fdIconPickerRegistered) return;
window.__fdIconPickerRegistered = true;
var TONES = ['', '\u{1F3FB}', '\u{1F3FC}', '\u{1F3FD}', '\u{1F3FE}', '\u{1F3FF}'];
var TONEABLE = {};
['👋','🤚','🖐️','✋','🖖','👌','🤌','🤏','✌️','🤞','🤟','🤘','🤙','👈','👉','👆','👇','☝️','👍','👎','✊','👊','🤛','🤜','👏','🙌','🫶','👐','🤲','🤝','🙏','💪','🦵','🦶','👂','👃','👶','🧒','👦','👧','🧑','👨','👩','🧓','👴','👵','🙍','🙎','🙅','🙆','💁','🙋','🧏','🙇','🤦','🤷','👮','🕵️','💂','👷','🤴','👸','👳','👲','🧕','🤵','👰','🤰','🤱','👼','🎅','🤶','🦸','🦹','🧙','🧚','🧛','🧜','🧝','💆','💇','🚶','🧍','🧎','🏃','💃','🕺','👯','🧖','🧗','🏇','⛷️','🏂','🏌️','🏄','🚣','🏊','⛹️','🏋️','🚴','🚵','🤸','🤼','🤽','🤾','🤹','🧘','🛀','🛌'].forEach(function (e) { TONEABLE[e] = true; });
var CATS = [
{ id: 'people', label: 'People', items: [
['😀','grinning face smile happy'],['😃','smiley happy'],['😄','smile laugh happy'],['😁','grin happy'],['😆','laughing happy'],['😅','sweat smile'],['🤣','rofl rolling laugh'],['😂','joy tears laugh'],['🙂','slight smile'],['🙃','upside down'],['😉','wink'],['😊','blush smile happy'],['😇','innocent halo angel'],['🥰','love hearts'],['😍','heart eyes love'],['🤩','star struck wow'],['😘','kiss love'],['😗','kissing'],['😚','kissing'],['😙','kissing'],['🥲','tear smile happy'],['😋','yum tasty'],['😛','tongue'],['😜','wink tongue'],['🤪','crazy zany'],['😝','tongue'],['🤑','money rich'],['🤗','hug'],['🤭','giggle'],['🤫','shush quiet'],['🤔','thinking'],['🤐','zip quiet'],['🤨','raised eyebrow'],['😐','neutral'],['😑','expressionless'],['😶','no mouth'],['😏','smirk'],['😒','unamused'],['🙄','roll eyes'],['😬','grimace'],['🤥','lying'],['😌','relieved'],['😔','pensive sad'],['😪','sleepy'],['🤤','drool'],['😴','sleeping'],['😷','mask sick'],['🤒','sick thermometer'],['🤕','hurt bandage'],['🤢','nauseated'],['🤮','vomit'],['🤧','sneeze'],['🥵','hot'],['🥶','cold'],['🥴','woozy'],['😵','dizzy'],['🤯','mind blown'],['🤠','cowboy'],['🥳','party'],['🥺','pleading'],['😎','cool sunglasses'],['🤓','nerd'],['🧐','monocle'],['😕','confused'],['😟','worried'],['🙁','frown'],['☹️','frown sad'],['😮','surprised'],['😯','hushed'],['😲','astonished'],['😳','flushed'],['😨','fearful'],['😰','anxious'],['😥','sad'],['😢','cry'],['😭','sob cry'],['😱','scream fear'],['😖','confounded'],['😣','persevere'],['😞','disappointed'],['😓','sweat'],['😩','weary'],['😫','tired'],['🥱','yawn'],['😤','triumph'],['😡','angry'],['😠','rage'],['🤬','cursing'],['😈','devil'],['👿','imp'],['💀','skull'],['💩','poop'],['🤡','clown'],['👻','ghost'],['👽','alien'],['🤖','robot'],['😺','cat'],['🙈','monkey see'],['🙉','monkey hear'],['🙊','monkey speak'],['👋','wave hand'],['🤚','raised hand'],['🖐️','hand'],['✋','raised hand'],['🖖','vulcan'],['👌','ok'],['🤌','pinched'],['🤏','pinch'],['✌️','peace'],['🤞','cross fingers luck'],['🤟','love you'],['🤘','rock'],['🤙','call me'],['👈','point left'],['👉','point right'],['👆','point up'],['👇','point down'],['☝️','point up'],['👍','thumbs up like'],['👎','thumbs down dislike'],['✊','fist'],['👊','fist bump'],['🤛','fist'],['🤜','fist'],['👏','clap'],['🙌','raise hands celebrate'],['🫶','heart hands'],['👐','open hands'],['🤲','palms'],['🤝','handshake'],['🙏','pray thanks'],['💪','muscle strong'],['👂','ear'],['👃','nose'],['👀','eyes look'],['👁️','eye'],['🧠','brain'],['👶','baby'],['🧒','child'],['👦','boy'],['👧','girl'],['🧑','person'],['👨','man'],['👩','woman'],['🧓','older person'],['👴','old man'],['👵','old woman'],['👮','police'],['🕵️','detective'],['💂','guard'],['👷','worker'],['🤴','prince'],['👸','princess'],['👳','turban'],['🧕','hijab'],['🤵','tuxedo'],['👰','bride'],['🤰','pregnant'],['🤱','breastfeeding'],['👼','angel'],['🎅','santa'],['🤶','mrs claus'],['🦸','superhero'],['🦹','supervillain'],['🧙','mage wizard'],['🧚','fairy'],['🧛','vampire'],['🧜','mermaid'],['🧝','elf'],['💆','massage'],['💇','haircut'],['🚶','walk'],['🏃','run'],['💃','dance'],['🕺','dance'],['👯','people dancing'],['🧗','climb'],['🏇','horse race'],['🏂','snowboard'],['🏄','surf'],['🚣','row'],['🏊','swim'],['🚴','bike'],['🚵','mountain bike'],['🤸','cartwheel'],['🤼','wrestle'],['🤽','water polo'],['🤾','handball'],['🤹','juggle'],['🧘','meditate yoga'],['🛌','sleeping bed'],['💋','kiss mark'],['💌','love letter'],['❤️','heart love red'],['🧡','orange heart'],['💛','yellow heart'],['💚','green heart'],['💙','blue heart'],['💜','purple heart'],['🖤','black heart'],['🤍','white heart'],['🤎','brown heart'],['💔','broken heart'],['❣️','heart exclamation'],['💕','two hearts'],['💞','revolving hearts'],['💓','beating heart'],['💗','growing heart'],['💖','sparkling heart'],['💘','cupid heart'],['💝','heart gift'],['✨','sparkles'],['⭐','star'],['🌟','glowing star'],['💫','dizzy star'],['💥','boom'],['💯','hundred perfect']
]},
{ id: 'nature', label: 'Nature', items: [
['🐶','dog'],['🐱','cat'],['🐭','mouse'],['🐹','hamster'],['🐰','rabbit'],['🦊','fox'],['🐻','bear'],['🐼','panda'],['🐨','koala'],['🐯','tiger'],['🦁','lion'],['🐮','cow'],['🐷','pig'],['🐸','frog'],['🐵','monkey'],['🐔','chicken'],['🐧','penguin'],['🐦','bird'],['🐤','chick'],['🦆','duck'],['🦅','eagle'],['🦉','owl'],['🦇','bat'],['🐺','wolf'],['🐗','boar'],['🐴','horse'],['🦄','unicorn'],['🐝','bee'],['🐛','bug'],['🦋','butterfly'],['🐌','snail'],['🐞','ladybug'],['🐜','ant'],['🦗','cricket'],['🕷️','spider'],['🦂','scorpion'],['🐢','turtle'],['🐍','snake'],['🦎','lizard'],['🦖','dinosaur'],['🐙','octopus'],['🦑','squid'],['🦐','shrimp'],['🦀','crab'],['🐡','fish'],['🐠','fish'],['🐟','fish'],['🐬','dolphin'],['🐳','whale'],['🦈','shark'],['🐊','crocodile'],['🌵','cactus'],['🎄','tree christmas'],['🌲','tree evergreen'],['🌳','tree'],['🌴','palm tree'],['🌱','seedling plant'],['🌿','herb leaf'],['☘️','shamrock'],['🍀','clover luck'],['🎍','bamboo'],['🌾','wheat'],['🌷','tulip flower'],['🌹','rose flower'],['🌺','hibiscus flower'],['🌸','cherry blossom'],['🌼','flower'],['🌻','sunflower'],['🌞','sun'],['🌝','moon'],['🌚','moon'],['🌙','moon crescent'],['⭐','star'],['🌟','star'],['☀️','sun sunny'],['⛅','cloud sun'],['☁️','cloud'],['🌧️','rain'],['⛈️','storm'],['🌩️','lightning'],['❄️','snowflake'],['☃️','snowman'],['⛄','snowman'],['🔥','fire'],['💧','droplet water'],['🌊','wave water'],['🌈','rainbow'],['🌍','earth globe'],['🌎','earth globe'],['🌏','earth globe']
]},
{ id: 'food', label: 'Food', items: [
['🍏','apple green'],['🍎','apple red'],['🍐','pear'],['🍊','orange tangerine'],['🍋','lemon'],['🍌','banana'],['🍉','watermelon'],['🍇','grapes'],['🍓','strawberry'],['🫐','blueberry'],['🍈','melon'],['🍒','cherry'],['🍑','peach'],['🥭','mango'],['🍍','pineapple'],['🥥','coconut'],['🥝','kiwi'],['🍅','tomato'],['🍆','eggplant'],['🥑','avocado'],['🥦','broccoli'],['🥬','lettuce'],['🥒','cucumber'],['🌶️','pepper hot'],['🌽','corn'],['🥕','carrot'],['🧄','garlic'],['🧅','onion'],['🥔','potato'],['🍠','sweet potato'],['🥐','croissant'],['🥯','bagel'],['🍞','bread'],['🥖','baguette'],['🧀','cheese'],['🥚','egg'],['🍳','cooking egg'],['🥓','bacon'],['🥩','meat steak'],['🍗','chicken leg'],['🍖','meat'],['🌭','hot dog'],['🍔','burger'],['🍟','fries'],['🍕','pizza'],['🥪','sandwich'],['🥙','pita'],['🌮','taco'],['🌯','burrito'],['🥗','salad'],['🍝','pasta spaghetti'],['🍜','ramen noodles'],['🍲','stew'],['🍛','curry rice'],['🍣','sushi'],['🍱','bento'],['🥟','dumpling'],['🍤','shrimp fried'],['🍙','rice ball'],['🍚','rice'],['🍘','rice cracker'],['🍥','fish cake'],['🥠','fortune cookie'],['🍢','oden'],['🍡','dango'],['🍧','shaved ice'],['🍨','ice cream'],['🍦','ice cream'],['🥧','pie'],['🧁','cupcake'],['🍰','cake'],['🎂','birthday cake'],['🍮','custard'],['🍭','lollipop'],['🍬','candy'],['🍫','chocolate'],['🍿','popcorn'],['🍩','donut'],['🍪','cookie'],['☕','coffee'],['🍵','tea'],['🧃','juice'],['🥤','cup drink'],['🍺','beer'],['🍻','beers cheers'],['🥂','champagne'],['🍷','wine'],['🥃','whiskey'],['🍸','cocktail'],['🍹','tropical drink'],['🧉','mate'],['🍾','champagne bottle']
]},
{ id: 'activity', label: 'Activity', items: [
['⚽','soccer football'],['🏀','basketball'],['🏈','football'],['⚾','baseball'],['🥎','softball'],['🎾','tennis'],['🏐','volleyball'],['🏉','rugby'],['🥏','frisbee'],['🎱','pool billiards'],['🪀','yo-yo'],['🏓','ping pong'],['🏸','badminton'],['🏒','hockey'],['🏑','field hockey'],['🥍','lacrosse'],['🏏','cricket'],['🥅','goal'],['⛳','golf'],['🏹','archery'],['🎣','fishing'],['🥊','boxing'],['🥋','martial arts'],['🎽','running shirt'],['🛹','skateboard'],['🛼','roller skate'],['🛷','sled'],['⛸️','ice skate'],['🥌','curling'],['🎿','ski'],['⛷️','ski'],['🏂','snowboard'],['🏋️','weight lift'],['🤼','wrestle'],['🤸','cartwheel'],['⛹️','basketball'],['🤺','fencing'],['🤾','handball'],['🏌️','golf'],['🏇','horse race'],['🧘','yoga meditate'],['🏄','surf'],['🏊','swim'],['🤽','water polo'],['🚣','row'],['🧗','climb'],['🚴','bike'],['🚵','mountain bike'],['🎪','circus'],['🎭','theater masks'],['🎨','art palette'],['🎬','clapper film'],['🎤','microphone'],['🎧','headphones'],['🎼','music score'],['🎹','piano'],['🥁','drum'],['🎷','saxophone'],['🎺','trumpet'],['🎸','guitar'],['🪕','banjo'],['🎻','violin'],['🎲','dice random game'],['♟️','chess'],['🎯','target dart'],['🎳','bowling'],['🎮','game controller'],['🎰','slot machine'],['🧩','puzzle'],['🏆','trophy win'],['🥇','gold medal first'],['🥈','silver medal'],['🥉','bronze medal'],['🏅','medal'],['🎖️','military medal'],['🎗️','reminder ribbon'],['🎫','ticket'],['🎟️','tickets'],['🎁','gift present'],['🎉','party popper celebrate'],['🎊','confetti'],['🎈','balloon'],['🎂','cake birthday'],['🎃','pumpkin halloween'],['🎄','christmas tree'],['🎆','fireworks'],['🎇','fireworks'],['🧨','firecracker'],['✨','sparkles'],['🎓','graduation cap']
]},
{ id: 'travel', label: 'Travel', items: [
['🚗','car'],['🚕','taxi'],['🚙','car suv'],['🚌','bus'],['🚎','trolley bus'],['🏎️','race car'],['🚓','police car'],['🚑','ambulance'],['🚒','fire truck'],['🚐','van'],['🛻','pickup truck'],['🚚','truck'],['🚛','truck'],['🚜','tractor'],['🏍️','motorcycle'],['🛵','scooter'],['🚲','bicycle'],['🛴','kick scooter'],['🚨','police light'],['🚔','police car'],['🚍','bus'],['🚝','monorail'],['🚄','train'],['🚅','train bullet'],['🚈','train'],['🚂','locomotive train'],['🚆','train'],['🚇','metro subway'],['🚊','tram'],['🚉','station'],['✈️','airplane flight'],['🛫','airplane takeoff'],['🛬','airplane landing'],['🛩️','plane'],['💺','seat'],['🚁','helicopter'],['🛸','ufo'],['🚀','rocket launch'],['🛰️','satellite'],['🚢','ship'],['⛵','sailboat'],['🛥️','motor boat'],['🚤','speedboat'],['⛴️','ferry'],['🛳️','cruise ship'],['⚓','anchor'],['🚧','construction'],['⛽','fuel gas'],['🚏','bus stop'],['🗺️','map world'],['🗿','moai'],['🗽','statue liberty'],['🗼','tokyo tower'],['🏰','castle'],['🏯','castle japanese'],['🏟️','stadium'],['🎡','ferris wheel'],['🎢','roller coaster'],['🎠','carousel'],['⛲','fountain'],['⛱️','beach umbrella'],['🏖️','beach'],['🏝️','island'],['🏜️','desert'],['🌋','volcano'],['⛰️','mountain'],['🏔️','snow mountain'],['🗻','mount fuji'],['🏕️','camping'],['🏠','house home'],['🏡','house garden'],['🏢','office building'],['🏥','hospital'],['🏦','bank'],['🏨','hotel'],['🏫','school'],['🏭','factory'],['🏛️','classical building'],['⛪','church'],['🕌','mosque'],['🕍','synagogue'],['🛕','temple'],['🗼','tower'],['🌆','city sunset'],['🌃','night city'],['🌉','bridge night'],['🌌','milky way'],['🌠','shooting star'],['🌅','sunrise'],['🌄','sunrise mountain'],['🌇','sunset city']
]},
{ id: 'objects', label: 'Objects', items: [
['⌚','watch'],['📱','phone mobile'],['💻','laptop computer'],['⌨️','keyboard'],['🖥️','desktop computer'],['🖨️','printer'],['🖱️','mouse computer'],['💽','minidisc'],['💾','floppy disk save'],['💿','cd disk'],['📀','dvd'],['🧮','abacus'],['🎥','movie camera'],['🎞️','film frames'],['📽️','projector'],['📺','tv television'],['📷','camera'],['📸','camera flash'],['📹','video camera'],['📼','videocassette'],['🔍','magnifying search'],['🔎','magnifying search'],['🕯️','candle'],['💡','bulb idea'],['🔦','flashlight'],['🏮','lantern'],['🪔','lamp diya'],['📔','notebook'],['📕','book closed'],['📖','book open'],['📗','book green'],['📘','book blue'],['📙','book orange'],['📚','books library'],['📓','notebook'],['📒','ledger'],['📃','page'],['📜','scroll'],['📄','page document'],['📰','newspaper'],['🗞️','newspaper'],['📑','bookmark tabs'],['🔖','bookmark'],['🏷️','label tag'],['💰','money bag'],['🪙','coin'],['💴','yen'],['💵','dollar'],['💶','euro'],['💷','pound'],['💸','money wings'],['💳','credit card'],['🧾','receipt'],['✉️','envelope mail'],['📧','email'],['📨','envelope'],['📩','envelope'],['📤','outbox'],['📥','inbox'],['📦','package box'],['📫','mailbox'],['📪','mailbox'],['📬','mailbox'],['📭','mailbox'],['📮','postbox'],['🗳️','ballot box'],['✏️','pencil'],['✒️','pen nib'],['🖋️','pen'],['🖊️','pen'],['🖌️','paintbrush'],['🖍️','crayon'],['📝','memo note write'],['💼','briefcase work'],['📁','folder'],['📂','folder open'],['🗂️','card index'],['📅','calendar date'],['📆','calendar'],['🗒️','notepad'],['🗓️','calendar'],['📇','card index'],['📈','chart up trending'],['📉','chart down'],['📊','bar chart stats'],['📋','clipboard'],['📌','pushpin'],['📍','pin location'],['📎','paperclip attach'],['🖇️','paperclips'],['📏','ruler'],['📐','triangle ruler'],['✂️','scissors cut'],['🗃️','file box'],['🗄️','file cabinet'],['🗑️','trash waste'],['🔒','lock locked'],['🔓','lock open'],['🔑','key'],['🗝️','old key'],['🔨','hammer'],['🪓','axe'],['⛏️','pick'],['⚒️','tools'],['🛠️','tools'],['🔧','wrench'],['🔩','bolt nut'],['⚙️','gear settings'],['🧰','toolbox'],['🧲','magnet'],['🔫','water gun'],['💣','bomb'],['🧪','test tube science'],['🧫','petri dish'],['🧬','dna'],['🔬','microscope'],['🔭','telescope'],['📡','satellite antenna'],['💉','syringe'],['💊','pill medicine'],['🩹','bandage'],['🩺','stethoscope'],['🚪','door'],['🛏️','bed'],['🛋️','couch'],['🪑','chair'],['🚽','toilet'],['🚿','shower'],['🛁','bathtub'],['🧴','lotion'],['🧷','safety pin'],['🧹','broom'],['🧺','basket'],['🧻','toilet paper'],['🧼','soap'],['🪒','razor'],['🧽','sponge'],['🧯','extinguisher'],['🛒','cart shopping'],['🚬','cigarette'],['⚰️','coffin'],['🪦','headstone'],['⚱️','urn']
]},
{ id: 'symbols', label: 'Symbols', items: [
['❤️','heart love'],['🧡','orange heart'],['💛','yellow heart'],['💚','green heart'],['💙','blue heart'],['💜','purple heart'],['🖤','black heart'],['🤍','white heart'],['🤎','brown heart'],['💔','broken heart'],['❣️','heart'],['💕','hearts'],['💞','hearts'],['💓','heartbeat'],['💗','heart'],['💖','heart'],['💘','heart arrow'],['💝','heart gift'],['💟','heart decoration'],['☮️','peace'],['✝️','cross'],['☪️','star crescent'],['🕉️','om'],['☸️','dharma'],['✡️','star david'],['🔯','star'],['🕎','menorah'],['☯️','yin yang'],['☦️','orthodox cross'],['🛐','worship'],['⛎','ophiuchus'],['♈','aries'],['♉','taurus'],['♊','gemini'],['♋','cancer'],['♌','leo'],['♍','virgo'],['♎','libra'],['♏','scorpio'],['♐','sagittarius'],['♑','capricorn'],['♒','aquarius'],['♓','pisces'],['🆔','id'],['⚛️','atom'],['🉑','accept'],['☢️','radioactive'],['☣️','biohazard'],['📴','phone off'],['📳','vibrate'],['📵','no phone'],['🚭','no smoking'],['❗','exclamation'],['❕','exclamation'],['❓','question'],['❔','question'],['‼️','double exclamation'],['⁉️','exclamation question'],['🔅','dim'],['🔆','bright'],['〽️','part alternation'],['⚠️','warning'],['🚸','children crossing'],['🔱','trident'],['⚜️','fleur de lis'],['🔰','beginner'],['♻️','recycle'],['✅','check done'],['🈯','reserved'],['💹','chart yen'],['❇️','sparkle'],['✳️','asterisk'],['❎','cross mark'],['🌐','globe'],['💠','diamond'],['Ⓜ️','m'],['🌀','cyclone'],['💤','zzz sleep'],['🏧','atm'],['🚾','wc'],['♿','wheelchair'],['🅿️','parking'],['🈳','vacancy'],['🈂️','sa'],['🛂','passport control'],['🛃','customs'],['🛄','baggage'],['🛅','left luggage'],['🚹','men'],['🚺','women'],['🚼','baby'],['🚻','restroom'],['🚮','litter'],['🎦','cinema'],['📶','signal'],['🈁','here'],['🔣','symbols'],['ℹ️','info'],['🔤','abc'],['🔡','abcd'],['🔠','abcd'],['🆖','ng'],['🆗','ok'],['🆙','up'],['🆒','cool'],['🆕','new'],['🆓','free'],['0️⃣','zero'],['1️⃣','one'],['2️⃣','two'],['3️⃣','three'],['4️⃣','four'],['5️⃣','five'],['6️⃣','six'],['7️⃣','seven'],['8️⃣','eight'],['9️⃣','nine'],['🔟','ten'],['🔢','numbers'],['#️⃣','hash'],['*️⃣','asterisk'],['⏏️','eject'],['▶️','play'],['⏸️','pause'],['⏹️','stop'],['⏺️','record'],['⏭️','next'],['⏮️','previous'],['⏩','fast forward'],['⏪','rewind'],['⏫','up'],['⏬','down'],['◀️','left'],['🔼','up'],['🔽','down'],['➡️','right'],['⬅️','left'],['⬆️','up'],['⬇️','down'],['↗️','up right'],['↘️','down right'],['↙️','down left'],['↖️','up left'],['↕️','up down'],['↔️','left right'],['↩️','return'],['↪️','redo'],['⤴️','up'],['⤵️','down'],['🔀','shuffle'],['🔁','repeat'],['🔂','repeat one'],['🔄','refresh'],['🔃','refresh'],['🎵','music note'],['🎶','music notes'],['➕','plus'],['➖','minus'],['➗','divide'],['✖️','multiply'],['♾️','infinity'],['💲','dollar'],['💱','currency'],['™️','tm'],['©️','copyright'],['®️','registered'],['〰️','wavy'],['➰','curly loop'],['➿','double loop'],['🔚','end'],['🔙','back'],['🔛','on'],['🔝','top'],['🔜','soon'],['✔️','check'],['☑️','checkbox'],['🔘','radio'],['🔴','red circle'],['🟠','orange circle'],['🟡','yellow circle'],['🟢','green circle'],['🔵','blue circle'],['🟣','purple circle'],['⚫','black circle'],['⚪','white circle'],['🟤','brown circle'],['🔺','red triangle'],['🔻','red triangle'],['🔸','orange diamond'],['🔹','blue diamond'],['🔶','orange diamond'],['🔷','blue diamond'],['🔳','white square'],['🔲','black square'],['▪️','black square'],['▫️','white square'],['◾','black square'],['◽','white square'],['◼️','black square'],['◻️','white square'],['🟥','red square'],['🟧','orange square'],['🟨','yellow square'],['🟩','green square'],['🟦','blue square'],['🟪','purple square'],['⬛','black square'],['⬜','white square'],['🟫','brown square'],['🔈','speaker'],['🔇','mute'],['🔉','speaker'],['🔊','speaker loud'],['🔔','bell'],['🔕','bell off'],['📣','megaphone'],['📢','loudspeaker'],['💬','speech bubble'],['💭','thought bubble'],['🗯️','anger bubble'],['♠️','spade'],['♣️','club'],['♥️','heart suit'],['♦️','diamond suit'],['🃏','joker'],['🎴','flower cards'],['🀄','mahjong']
]},
{ id: 'flags', label: 'Flags', items: [
['🏁','chequered flag finish'],['🚩','triangular flag'],['🎌','crossed flags'],['🏴','black flag'],['🏳️','white flag'],['🏳️‍🌈','rainbow flag pride'],['🏴‍☠️','pirate flag'],['🇺🇸','usa united states'],['🇬🇧','uk united kingdom'],['🇫🇷','france french'],['🇩🇪','germany german'],['🇪🇸','spain spanish'],['🇮🇹','italy italian'],['🇵🇹','portugal'],['🇳🇱','netherlands'],['🇧🇪','belgium'],['🇨🇭','switzerland'],['🇦🇹','austria'],['🇸🇪','sweden'],['🇳🇴','norway'],['🇩🇰','denmark'],['🇫🇮','finland'],['🇮🇪','ireland'],['🇵🇱','poland'],['🇬🇷','greece'],['🇷🇺','russia'],['🇺🇦','ukraine'],['🇹🇷','turkey'],['🇨🇦','canada'],['🇲🇽','mexico'],['🇧🇷','brazil'],['🇦🇷','argentina'],['🇨🇱','chile'],['🇨🇴','colombia'],['🇨🇳','china chinese'],['🇯🇵','japan japanese'],['🇰🇷','korea south'],['🇮🇳','india'],['🇦🇺','australia'],['🇳🇿','new zealand'],['🇿🇦','south africa'],['🇪🇬','egypt'],['🇲🇦','morocco'],['🇳🇬','nigeria'],['🇰🇪','kenya'],['🇸🇦','saudi arabia'],['🇦🇪','uae emirates'],['🇮🇱','israel'],['🇸🇬','singapore'],['🇹🇭','thailand'],['🇻🇳','vietnam'],['🇮🇩','indonesia'],['🇵🇭','philippines'],['🇲🇾','malaysia'],['🇵🇰','pakistan'],['🇧🇩','bangladesh'],['🇺🇳','united nations']
]}
];
document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdIconPicker) return;
if (window.Alpine) window.Alpine.__fdIconPicker = true;
Alpine.store('fdIconPicker', {
open: false, x: 0, y: 0, tab: 'emoji', query: '', category: 'people',
skinTone: 0, toneOpen: false, recent: [], custom: [], customLoaded: false,
customModal: false, customName: '', customPreview: '', customFile: null, customBusy: false,
onPick: null, onRemove: null, _cx: 0, _cy: 0,
cats: CATS,
openFor(opts) {
opts = opts || {};
this.onPick = opts.onPick || null;
this.onRemove = opts.onRemove || null;
this.query = '';
this.toneOpen = false;
this.customModal = false;
this._cx = (opts.x != null) ? opts.x : 240;
this._cy = (opts.y != null) ? opts.y : 200;
this.x = this._cx;
this.y = this._cy;
this.open = true;
var self = this;
var place = function () {
var el = document.querySelector('.fd-icon-picker');
if (!el) return;
var w = el.offsetWidth || 344, h = el.offsetHeight || 440;
var vw = window.innerWidth, vh = window.innerHeight;
var nx = self._cx, ny = self._cy;
if (nx + w > vw - 8) nx = vw - w - 8;
if (ny + h > vh - 8) ny = vh - h - 8;
self.x = Math.max(8, nx);
self.y = Math.max(8, ny);
};
if (window.Alpine && Alpine.nextTick) Alpine.nextTick(place);
else setTimeout(place, 0);
this.loadRecent();
this.loadCustom();
},
close() {
this.open = false;
this.customModal = false;
this.toneOpen = false;
this.onPick = null;
this.onRemove = null;
},
matches(name) {
var q = (this.query || '').trim().toLowerCase();
if (!q) return true;
return name.toLowerCase().indexOf(q) >= 0;
},
items() {
var q = (this.query || '').trim().toLowerCase();
if (q) {
var out = [];
this.cats.forEach(function (c) {
c.items.forEach(function (it) {
if ((it[1] || '').toLowerCase().indexOf(q) >= 0 || it[0].indexOf(q) >= 0) out.push(it[0]);
});
});
return out;
}
if (this.category === 'recent') return this.recent;
var found = [];
for (var i = 0; i < this.cats.length; i++) {
if (this.cats[i].id === this.category) { found = this.cats[i].items.map(function (it) { return it[0]; }); break; }
}
return found;
},
withTone(e) {
if (!this.skinTone) return e;
if (TONEABLE[e]) return e + TONES[this.skinTone];
return e;
},
pick(v) {
v = (v || '').trim();
if (!v) return;
this.pushRecent(v);
var fn = this.onPick;
if (fn) { try { fn(v); } catch (e) { console.error('fdIconPicker.pick', e); } }
this.close();
},
remove() {
var fn = this.onRemove || this.onPick;
if (fn) { try { fn(''); } catch (e) {} }
this.close();
},
random() {
var pool = [];
this.cats.forEach(function (c) { c.items.forEach(function (it) { pool.push(it[0]); }); });
if (!pool.length) return;
this.pick(pool[Math.floor(Math.random() * pool.length)]);
},
setTone(i) { this.skinTone = i; this.toneOpen = false; },
setCategory(id) { this.category = id; this.tab = 'emoji'; this.query = ''; },
setTab(t) { this.tab = t; this.query = ''; if (t === 'upload') this.loadCustom(); },
loadRecent() {
try { this.recent = JSON.parse(localStorage.getItem('fd_icon_recent') || '[]'); }
catch (e) { this.recent = []; }
},
pushRecent(e) {
try {
var r = this.recent.filter(function (x) { return x !== e; });
r.unshift(e);
this.recent = r.slice(0, 32);
localStorage.setItem('fd_icon_recent', JSON.stringify(this.recent));
} catch (err) {}
},
async loadCustom() {
try {
var r = await fetch('/api/custom-emojis', { credentials: 'same-origin' });
var d = await r.json();
this.custom = (d && d.emojis) || [];
this.customLoaded = true;
} catch (e) { this.custom = []; }
},
openCustomModal() {
this.customModal = true;
this.customName = '';
this.customPreview = '';
this.customFile = null;
this.tab = 'upload';
},
closeCustomModal() { this.customModal = false; },
onCustomFile(ev) {
var f = ev.target.files && ev.target.files[0];
if (!f) return;
this.customFile = f;
var self = this;
var fr = new FileReader();
fr.onload = function () { self.customPreview = fr.result; };
fr.readAsDataURL(f);
if (!this.customName) this.customName = (f.name || '').replace(/\.[^.]+$/, '').slice(0, 40);
},
async saveCustom() {
if (!this.customFile || this.customBusy) return;
this.customBusy = true;
try {
var fd = new FormData();
fd.append('name', this.customName || 'emoji');
fd.append('file', this.customFile);
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
var r = await fetch('/api/custom-emojis', { method: 'POST', headers: { 'X-CSRF-Token': csrf }, body: fd, credentials: 'same-origin' });
var d = await r.json();
if (d && d.emoji) {
this.custom.unshift(d.emoji);
this.customModal = false;
this.pick(d.emoji.url);
}
} catch (e) {
if (window.showToast) window.showToast('Emoji upload failed', 'error');
}
this.customBusy = false;
},
async deleteCustom(id) {
try {
var csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
await fetch('/api/custom-emojis/' + id, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf }, credentials: 'same-origin' });
this.custom = this.custom.filter(function (e) { return e.id !== id; });
} catch (e) {}
}
});
});
window.FDIconPicker = {
openFor: function (opts) {
var s = window.Alpine && Alpine.store('fdIconPicker');
if (s) s.openFor(opts);
}
};
})();
</script>
<style>
.fd-icon-picker{position:fixed;top:0;left:0;width:344px;background:var(--bg-modal);border:1px solid var(--border);border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.28);z-index:2200;display:flex;flex-direction:column;max-height:440px;overflow:hidden;padding:0;}
.fd-icon-picker .fdip-tabs{display:flex;align-items:center;gap:2px;padding:8px 10px 6px;border-bottom:1px solid var(--border);}
.fd-icon-picker .fdip-tab{background:transparent;border:none;color:var(--text-secondary);font-size:13px;padding:5px 10px;border-radius:6px;cursor:pointer;}
.fd-icon-picker .fdip-tab:hover{background:var(--bg-hover);}
.fd-icon-picker .fdip-tab.active{background:var(--bg-hover);color:var(--text-primary);font-weight:600;}
.fd-icon-picker .fdip-remove{margin-left:auto;background:transparent;border:none;color:var(--text-secondary);font-size:12px;padding:5px 8px;border-radius:6px;cursor:pointer;}
.fd-icon-picker .fdip-remove:hover{background:var(--bg-hover);color:var(--danger);}
.fd-icon-picker .fdip-tools{display:flex;align-items:center;gap:6px;padding:8px 10px;}
.fd-icon-picker .fdip-search{flex:1;min-width:0;font-size:13px;padding:6px 10px;background:var(--bg-primary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);outline:none;}
.fd-icon-picker .fdip-search:focus{border-color:var(--accent);}
.fd-icon-picker .fdip-tool{width:30px;height:30px;flex-shrink:0;display:flex;align-items:center;justify-content:center;background:transparent;border:1px solid var(--border);border-radius:6px;cursor:pointer;font-size:15px;color:var(--text-primary);}
.fd-icon-picker .fdip-tool:hover{background:var(--bg-hover);}
.fd-icon-picker .fdip-tone-wrap{position:relative;}
.fd-icon-picker .fdip-tone-menu{position:absolute;right:0;top:34px;display:flex;gap:2px;padding:6px;background:var(--bg-modal);border:1px solid var(--border);border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,.25);z-index:5;}
.fd-icon-picker .fdip-tone{width:26px;height:26px;border:none;background:transparent;border-radius:6px;cursor:pointer;font-size:16px;}
.fd-icon-picker .fdip-tone:hover,.fd-icon-picker .fdip-tone.selected{background:var(--bg-hover);}
.fd-icon-picker .fdip-body{flex:1;overflow-y:auto;padding:0 10px 6px;}
.fd-icon-picker .fdip-section{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--text-dim);padding:6px 2px 2px;}
.fd-icon-picker .fdip-grid{display:grid;grid-template-columns:repeat(8,1fr);gap:2px;}
.fd-icon-picker .fdip-emoji,.fd-icon-picker .fdip-icon{width:100%;aspect-ratio:1;display:flex;align-items:center;justify-content:center;background:transparent;border:none;border-radius:6px;cursor:pointer;font-size:21px;line-height:1;color:var(--text-primary);position:relative;}
.fd-icon-picker .fdip-emoji:hover,.fd-icon-picker .fdip-icon:hover{background:var(--bg-hover);}
.fd-icon-picker .fdip-custom img{width:24px;height:24px;object-fit:contain;}
.fd-icon-picker .fdip-del{position:absolute;top:-2px;right:-2px;width:14px;height:14px;border-radius:50%;background:var(--danger);color:#fff;font-size:10px;line-height:14px;text-align:center;display:none;}
.fd-icon-picker .fdip-custom:hover .fdip-del{display:block;}
.fd-icon-picker .fdip-empty{color:var(--text-dim);font-size:12px;text-align:center;padding:24px 0;}
.fd-icon-picker .fdip-cats{display:flex;align-items:center;gap:2px;padding:6px 10px;border-top:1px solid var(--border);}
.fd-icon-picker .fdip-cat{flex:1;height:30px;display:flex;align-items:center;justify-content:center;background:transparent;border:none;border-radius:6px;cursor:pointer;color:var(--text-secondary);}
.fd-icon-picker .fdip-cat:hover{background:var(--bg-hover);}
.fd-icon-picker .fdip-cat.active{background:var(--bg-hover);color:var(--accent);}
.fd-icon-picker .fdip-cat svg{width:16px;height:16px;}
.fdip-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:2300;display:flex;align-items:center;justify-content:center;}
.fdip-modal{width:420px;max-width:92vw;background:var(--bg-modal);border:1px solid var(--border);border-radius:12px;padding:20px;box-shadow:0 16px 48px rgba(0,0,0,.35);}
.fdip-modal-title{font-size:16px;font-weight:600;color:var(--text-primary);margin-bottom:6px;}
.fdip-modal-desc{font-size:13px;color:var(--text-secondary);margin-bottom:16px;}
.fdip-upload-btn{display:inline-block;padding:8px 14px;font-size:13px;border:1px solid var(--border);border-radius:8px;cursor:pointer;color:var(--text-primary);background:var(--bg-secondary);}
.fdip-upload-btn:hover{background:var(--bg-hover);}
.fdip-preview-boxes{display:flex;gap:10px;margin:14px 0;}
.fdip-preview{width:64px;height:64px;border-radius:8px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border);}
.fdip-preview.dark{background:#111;}
.fdip-preview.light{background:#fff;}
.fdip-preview img{max-width:44px;max-height:44px;object-fit:contain;}
.fdip-modal-label{display:block;font-size:12px;color:var(--text-secondary);margin:8px 0 4px;}
.fdip-modal-input{width:100%;font-size:14px;padding:8px 10px;background:var(--bg-primary);border:1px solid var(--border);border-radius:8px;color:var(--text-primary);outline:none;box-sizing:border-box;}
.fdip-modal-input:focus{border-color:var(--accent);}
.fdip-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:18px;}
.fdip-modal-actions button{padding:8px 16px;font-size:13px;border-radius:8px;cursor:pointer;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);}
.fdip-modal-actions button.primary{background:var(--accent);border-color:var(--accent);color:#fff;}
.fdip-modal-actions button:disabled{opacity:.5;cursor:default;}
</style>
<div x-data>
<div class="fd-icon-picker" x-show="$store.fdIconPicker.open" x-cloak
@click.outside="$store.fdIconPicker.customModal || $store.fdIconPicker.close()"
@keydown.escape.window="$store.fdIconPicker.close()"
:style="'top:' + $store.fdIconPicker.y + 'px;left:' + $store.fdIconPicker.x + 'px'">
<div class="fdip-tabs">
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'emoji' }" @click="$store.fdIconPicker.setTab('emoji')">Emoji</button>
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'icons' }" @click="$store.fdIconPicker.setTab('icons')">Icons</button>
<button class="fdip-tab" :class="{ active: $store.fdIconPicker.tab === 'upload' }" @click="$store.fdIconPicker.setTab('upload')">Upload</button>
<button class="fdip-remove" @click="$store.fdIconPicker.remove()">Remove</button>
</div>
<div class="fdip-tools" x-show="$store.fdIconPicker.tab !== 'upload'">
<input class="fdip-search" placeholder="Filter..." x-model="$store.fdIconPicker.query">
<button class="fdip-tool" title="Random" @click="$store.fdIconPicker.random()">🎲</button>
<div class="fdip-tone-wrap" x-show="$store.fdIconPicker.tab === 'emoji'">
<button class="fdip-tool" title="Select skin tone" @click.stop="$store.fdIconPicker.toneOpen = !$store.fdIconPicker.toneOpen">👋</button>
<div class="fdip-tone-menu" x-show="$store.fdIconPicker.toneOpen" @click.outside="$store.fdIconPicker.toneOpen = false">
<template x-for="(t, ti) in ['👋','👋🏻','👋🏼','👋🏽','👋🏾','👋🏿']" :key="ti">
<button class="fdip-tone" :class="{ selected: $store.fdIconPicker.skinTone === ti }" @click="$store.fdIconPicker.setTone(ti)" x-text="t"></button>
</template>
</div>
</div>
</div>
<div class="fdip-body">
<template x-if="$store.fdIconPicker.tab === 'emoji'">
<div>
<div class="fdip-section" x-show="$store.fdIconPicker.category === 'recent' && !$store.fdIconPicker.query">Recent</div>
<div class="fdip-grid">
<template x-for="e in $store.fdIconPicker.items()" :key="e">
<button class="fdip-emoji" @click="$store.fdIconPicker.pick($store.fdIconPicker.withTone(e))" x-text="$store.fdIconPicker.withTone(e)"></button>
</template>
</div>
<div class="fdip-empty" x-show="!$store.fdIconPicker.items().length">No emoji found</div>
</div>
</template>
<template x-if="$store.fdIconPicker.tab === 'icons'">
<div>
<div class="fdip-grid">
{% for name in picker_icons %}
<button class="fdip-icon" x-show="$store.fdIconPicker.matches('{{ name }}')" @click="$store.fdIconPicker.pick('{{ name }}')" title="{{ name }}">{{ fd_icon(name, 20) }}</button>
{% endfor %}
</div>
</div>
</template>
<template x-if="$store.fdIconPicker.tab === 'upload'">
<div>
<div class="fdip-custom-head" style="display:flex;align-items:center;justify-content:space-between;padding:6px 2px;">
<span class="fdip-section" style="padding:0;">Custom emojis</span>
<button class="fdip-tool" style="width:auto;padding:0 10px;" @click="$store.fdIconPicker.openCustomModal()">+ Add</button>
</div>
<div class="fdip-grid">
<template x-for="ce in $store.fdIconPicker.custom" :key="ce.id">
<button class="fdip-emoji fdip-custom" @click="$store.fdIconPicker.pick(ce.url)" :title="ce.name">
<img :src="ce.url" :alt="ce.name">
<span class="fdip-del" @click.stop="$store.fdIconPicker.deleteCustom(ce.id)">×</span>
</button>
</template>
</div>
<div class="fdip-empty" x-show="!$store.fdIconPicker.custom.length">No custom emoji yet</div>
</div>
</template>
</div>
<div class="fdip-cats" x-show="$store.fdIconPicker.tab === 'emoji'">
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'recent' }" title="Recent" @click="$store.fdIconPicker.setCategory('recent')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg></button>
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'people' }" title="People" @click="$store.fdIconPicker.setCategory('people')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M8.5 14s1.3 2 3.5 2 3.5-2 3.5-2"/><line x1="9" y1="9.5" x2="9.01" y2="9.5"/><line x1="15" y1="9.5" x2="15.01" y2="9.5"/></svg></button>
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'nature' }" title="Nature" @click="$store.fdIconPicker.setCategory('nature')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 20A7 7 0 0 1 4 13c0-6 7-10 16-10 0 9-4 16-10 16z"/><path d="M4 21c2-4 5-7 9-9"/></svg></button>
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'food' }" title="Food" @click="$store.fdIconPicker.setCategory('food')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21l6-6"/><path d="M14 3s5 1 7 7c-4 4-8 3-10 1s-1-6 3-8z"/><path d="M9 12l-4 4"/></svg></button>
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'activity' }" title="Activity" @click="$store.fdIconPicker.setCategory('activity')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 11V6a2 2 0 0 1 4 0v5"/><path d="M10 11V4a2 2 0 0 1 4 0v7"/><path d="M14 11V6a2 2 0 0 1 4 0v9a7 7 0 0 1-7 7H9a6 6 0 0 1-6-6v-3a2 2 0 0 1 3-1.7"/></svg></button>
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'travel' }" title="Travel" @click="$store.fdIconPicker.setCategory('travel')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 16l20-6-3-3-7 1-5-5-2 1 3 5-4 1-2-2-1 1z"/></svg></button>
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'objects' }" title="Objects" @click="$store.fdIconPicker.setCategory('objects')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a6 6 0 0 0-4 10.5c.8.7 1 1.5 1 2.5h6c0-1 .2-1.8 1-2.5A6 6 0 0 0 12 2z"/></svg></button>
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'symbols' }" title="Symbols" @click="$store.fdIconPicker.setCategory('symbols')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></button>
<button class="fdip-cat" :class="{ active: $store.fdIconPicker.category === 'flags' }" title="Flags" @click="$store.fdIconPicker.setCategory('flags')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 22V4"/><path d="M4 4h13l-2 4 2 4H4"/></svg></button>
<button class="fdip-cat" title="Add custom emoji" @click="$store.fdIconPicker.openCustomModal()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
</div>
</div>
<div class="fdip-modal-overlay" x-show="$store.fdIconPicker.customModal" x-cloak @click.self="$store.fdIconPicker.closeCustomModal()">
<div class="fdip-modal">
<div class="fdip-modal-title">Add custom emoji</div>
<div class="fdip-modal-desc">Custom emoji can be used by anyone in your workspace.</div>
<label class="fdip-upload-btn">
<span x-show="!$store.fdIconPicker.customPreview">Upload an image</span>
<span x-show="$store.fdIconPicker.customPreview">Replace</span>
<input type="file" accept="image/*" @change="$store.fdIconPicker.onCustomFile($event)" style="display:none">
</label>
<div class="fdip-preview-boxes" x-show="$store.fdIconPicker.customPreview">
<div class="fdip-preview dark"><img :src="$store.fdIconPicker.customPreview" alt=""></div>
<div class="fdip-preview light"><img :src="$store.fdIconPicker.customPreview" alt=""></div>
</div>
<label class="fdip-modal-label">Emoji name</label>
<input class="fdip-modal-input" x-model="$store.fdIconPicker.customName" placeholder="pushup">
<div class="fdip-modal-actions">
<button @click="$store.fdIconPicker.closeCustomModal()">Cancel</button>
<button class="primary" :disabled="$store.fdIconPicker.customBusy || !$store.fdIconPicker.customFile" @click="$store.fdIconPicker.saveCustom()">Save</button>
</div>
</div>
</div>
</div>
+12
View File
@@ -114,3 +114,15 @@
<svg width="{{s}}" height="{{s}}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
{%- endif -%}
{%- endmacro -%}
{#- Renders a page_icon value: custom-emoji image URL, known icon name, or emoji. -#}
{%- macro fd_page_icon(value, size=18) -%}
{%- set v = (value or '')|string|trim -%}
{%- if v.startswith('/') or v.startswith('http') -%}
<img src="{{ v }}" alt="" style="width:{{ size }}px;height:{{ size }}px;object-fit:contain;vertical-align:middle;display:inline-block;">
{%- elif v in ("folder","file","calendar","clock","star","bot","users","globe","lock","book","check-square","trash","help-circle","settings","refresh","log-out","message-square","home","search","link","plus","bell","image","download","list","bar-chart","grid","align-left","corner-down-right","copy","key","inbox","edit","eye","share","x","paperclip","external-link","sparkles","lightbulb","tag","file-text","save","upload","trending-up","zap","alert-triangle","user") -%}
{{ fd_icon(v, size) }}
{%- else -%}
{{ v }}
{%- endif -%}
{%- endmacro -%}
+4 -1
View File
@@ -61,7 +61,7 @@
</div>
</span>
<script>
<script data-cfasync="false">
document.addEventListener('alpine:init', function () {
if (window.Alpine && window.Alpine.__fdNotificationsRegistered) return;
if (window.Alpine) window.Alpine.__fdNotificationsRegistered = true;
@@ -73,6 +73,9 @@ document.addEventListener('alpine:init', function () {
var self = this;
this._timer = setInterval(function () { self.refreshCount(); }, 30000);
},
destroy() {
if (this._timer) { clearInterval(this._timer); this._timer = null; }
},
toggle() { this.open = !this.open; if (this.open) this.load(); },
timeAgo(s) {
if (!s) return '';
+35 -6
View File
@@ -284,7 +284,7 @@
</div>
<div x-show="iconOpen" style="margin-top:12px;" x-transition>
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;">
<template x-for="ic in ['📄','📝','📋','📊','🗂️','📁','📂','🗒️','🗓️','📌','🔖','⭐','🚀','💡','🎯','🔑','📚','🧪','🌍','💰','📝','🧩','🎨','🔧','⚙️','🗃️','📦','🏷️','📍','🏠','👤']" :key="ic">
<template x-for="(ic, ici) in ['📄','📝','📋','📊','🗂️','📁','📂','🗒️','🗓️','📌','🔖','⭐','🚀','💡','🎯','🔑','📚','🧪','🌍','💰','📝','🧩','🎨','🔧','⚙️','🗃️','📦','🏷️','📍','🏠','👤']" :key="ici">
<button type="button" class="sd-icon-btn" @click="setIcon(ic)" :class="{ active: iconValue === ic }" style="width:36px;height:36px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);font-size:18px;display:flex;align-items:center;justify-content:center;cursor:pointer;" x-text="ic"></button>
</template>
<input type="text" class="sd-input" x-model="iconValue" placeholder="Custom emoji" style="max-width:80px;" @keydown.enter.prevent="setIcon(iconValue);iconOpen=false;">
@@ -457,11 +457,35 @@
<div class="more-menu-item" @click="exportPage('site')"> Site statique (.zip)</div>
</div>
</template>
<template x-if="contentFormat === 'file' && fileUrl">
<div>
<div class="more-menu-sep"></div>
<div class="more-menu-item" @click="downloadFile()">
{{ fd_icon("download",14) }} Download
</div>
<div class="more-menu-item" @click="copyFileContent()">
{{ fd_icon("copy",14) }} Copy content
</div>
</div>
</template>
<div class="more-menu-item" @click="duplicatePage">
{{ fd_icon("copy",14) }} Duplicate
</div>
<div class="more-menu-item" @click="movePage">↗ Move to</div>
<div class="more-menu-sep"></div>
<div class="more-menu-item" @click="moreOpen=false; toggleLock()">
<span x-text="isLocked ? '🔓 Unlock page' : '🔒 Lock page'"></span>
</div>
<div class="more-menu-item" @click="moreOpen=false; setPageOption('full_width', !fullWidth)">
↔ Full width <span x-show="fullWidth" style="margin-left:auto;font-size:11px">✓</span>
</div>
<div class="more-menu-item" @click="moreOpen=false; setPageOption('font_small', !fontSmall)">
Aa Small text <span x-show="fontSmall" style="margin-left:auto;font-size:11px">✓</span>
</div>
<div class="more-menu-item" @click="moreOpen=false; saveAsTemplate()">
📑 Save as template
</div>
<div class="more-menu-sep"></div>
<div class="more-menu-item danger" @click="deletePage">
{{ fd_icon("trash",14) }} Move to Trash
</div>
@@ -497,7 +521,7 @@
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,.6) 100%);z-index:1;"></div>
</div>
<div class="page-title-block">
<span class="page-icon-emoji" x-text="iconValue || (page.content_format == 'file' ? fd_icon('paperclip',14) : fd_icon('file',14))"></span>
<span class="page-icon-emoji" @click="openIconPicker()" title="Change icon" x-html="iconHtml()"></span>
<div
class="page-title-input"
contenteditable="true"
@@ -804,16 +828,21 @@
<div style="border-top:1px solid var(--border);padding-top:16px;">
<label style="display:block;font-size:13px;font-weight:600;margin-bottom:8px;color:var(--text);">{{ fd_icon('file',14) }} File (.md, .txt, .zip)</label>
<input type="file" accept=".md,.markdown,.txt,.zip" @change="handleImportFile($event)" style="width:100%;padding:12px;background:var(--bg-primary);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:13px;box-sizing:border-box;">
<div x-show="importFile" style="margin-top:8px;display:flex;align-items:center;gap:12px;color:var(--text-dim);font-size:13px;">
<span x-text="importFile.name"></span>
<span x-text="Math.round(importFile.size/1024)+' KB'"></span>
</div>
<template x-if="importFile">
<div style="margin-top:8px;display:flex;align-items:center;gap:12px;color:var(--text-dim);font-size:13px;">
<span x-text="importFile.name"></span>
<span x-text="Math.round(importFile.size/1024)+' KB'"></span>
</div>
</template>
<div style="display:flex;gap:8px;margin-top:10px;">
<button class="sd-btn-primary" @click="doImport()" :disabled="!importText.trim() && !importFile">{{ fd_icon('download',14) }} Import file</button>
</div>
</div>
</div>
</div>
<div style="padding:12px 20px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;">
<a href="/import" style="font-size:13px;color:var(--accent);text-decoration:none;">Assistant d'import avancé (Obsidian, Notion, CSV, Excel…) →</a>
</div>
</div>
</div>
+52 -19
View File
@@ -9,7 +9,7 @@
.rt-cursor .rt-cursor-name{position:absolute;top:-14px;left:4px;white-space:nowrap;font-size:9px;line-height:12px;padding:0 4px;border-radius:4px;color:#fff;font-weight:600;letter-spacing:.2px;}
.rt-offline{font-size:11px;color:var(--text-tertiary,#999);margin-right:6px;display:none;}
</style>
<script>
<script data-cfasync="false">
/* eslint-disable */
/* ═══════════ v5.13.0 Realtime — WS gateway, présence, curseurs, merge LWW ═══════════ */
window.__fdRT = (function () {
@@ -37,6 +37,19 @@ window.__fdRT = (function () {
let emitT = null, selT = null, titleT = null, drawT = null, retryT = null, pollT = null;
let _pid = 0;
// Detach global listeners from a previous editor instance so that
// partial (HTMX) navigation doesn't accumulate stale handlers.
function unbindGlobal() {
const g = window.__fdRTGlobal;
if (!g) return;
try {
document.removeEventListener('selectionchange', g.onSel, true);
window.removeEventListener('scroll', g.onScroll, true);
window.removeEventListener('resize', g.onResize);
} catch (e) { /* noop */ }
window.__fdRTGlobal = null;
}
const clone = (o) => JSON.parse(JSON.stringify(o));
function colorOf(uid) { return COLORS[Math.abs(uid || 0) % COLORS.length]; }
@@ -61,6 +74,7 @@ window.__fdRT = (function () {
const blk = op.block || {};
const id = blk.id || ('rb' + Date.now().toString(36));
blk.id = id;
if (typeof ensureBlockIds === 'function') ensureBlockIds([blk]);
let idx = op.index != null ? op.index : blocks.length;
idx = Math.max(0, Math.min(idx, blocks.length));
return blocks.slice(0, idx).concat([blk]).concat(blocks.slice(idx));
@@ -255,8 +269,13 @@ window.__fdRT = (function () {
/* ── application des changements distants ── */
function applySync(blocks, title) {
if (!E || !E.blocks) return;
// v5.13.1: guarantee ids before comparing/merging. Server rooms may still
// carry legacy id-less blocks; without this they collide on
// data-bid="undefined" and the merge below duplicated every line.
blocks = (blocks || []).slice();
if (typeof ensureBlockIds === 'function') ensureBlockIds(blocks);
const curIds = JSON.stringify((E.blocks || []).map(b => b.id));
const srvIds = JSON.stringify((blocks || []).map(b => b.id));
const srvIds = JSON.stringify(blocks.map(b => b.id));
if (curIds === srvIds) {
base = clone(E.blocks);
E.dirty = false;
@@ -267,14 +286,17 @@ window.__fdRT = (function () {
(E.blocks || []).forEach(b => { curById[b.id] = b; });
let out;
try {
out = (blocks || []).map(b => {
const cb = curById[b.id];
if (cb && b.id === fid) return cb; // garde la frappe locale en cours
return b;
});
if (!blocks.length) {
// serveur vide : ne pas effacer le contenu local (page neuve).
out = (E.blocks || []).slice();
} else {
out = blocks.map(b => {
const cb = curById[b.id];
if (cb && b.id === fid) return cb; // garde la frappe locale en cours
return b;
});
}
} catch (e) { return; }
const seen = {}; (blocks || []).forEach(b => { seen[b.id] = 1; });
(E.blocks || []).forEach(b => { if (!seen[b.id]) out.push(b); });
E.blocks = out;
const tEl = document.getElementById('_titleEl');
if (title && title !== E.pageTitle && document.activeElement !== tEl) {
@@ -362,13 +384,19 @@ window.__fdRT = (function () {
peers = peers.filter(p => p.id !== m.peer.id).concat([m.peer]);
renderPresence();
}
} else if (m.t === 'peer_leave') {
peers = peers.filter(p => p.id !== m.id);
delete remoteCursors[m.id];
renderPresence();
scheduleDraw();
}
}
} else if (m.t === 'peer_leave') {
peers = peers.filter(p => p.id !== m.id);
delete remoteCursors[m.id];
renderPresence();
scheduleDraw();
} else if (m.t === 'synced_update') {
// A synced block was updated — re-sync the whole page
if (m.synced_id) {
needSync = true;
send({ t: 'sync_req' });
}
}
}
/* ── connexion WS + fallback polling ── */
function startPolling() {
@@ -459,9 +487,14 @@ window.__fdRT = (function () {
}
const tEl = document.getElementById('_titleEl');
if (tEl) tEl.addEventListener('input', titleInput);
document.addEventListener('selectionchange', () => { clearTimeout(selT); selT = setTimeout(emitCursor, 150); }, true);
window.addEventListener('scroll', scheduleDraw, true);
window.addEventListener('resize', scheduleDraw);
unbindGlobal();
const onSel = () => { clearTimeout(selT); selT = setTimeout(emitCursor, 150); };
const onScroll = () => scheduleDraw();
const onResize = () => scheduleDraw();
document.addEventListener('selectionchange', onSel, true);
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onResize);
window.__fdRTGlobal = { onSel, onScroll, onResize };
}
function start(ed) {
+491 -70
View File
@@ -3,6 +3,8 @@
</script>
<script data-cfasync="false">
/* eslint-disable */
if (!window.__fdEditorScriptsLoaded) {
window.__fdEditorScriptsLoaded = true;
const CMDS=[{name:'BASIC',items:[
{id:'paragraph',name:'Text',icon:'¶'},{id:'heading_1',name:'Heading 1',icon:'H1'},{id:'heading_2',name:'Heading 2',icon:'H2'},
{id:'heading_3',name:'Heading 3',icon:'H3'},{id:'heading_4',name:'Heading 4',icon:'H4'},{id:'bulleted_list',name:'Bulleted list',icon:'•'},{id:'numbered_list',name:'Numbered list',icon:'1.'},
@@ -20,16 +22,43 @@
{id:'audio',name:'Audio',icon:'🎵'},
]},
{name:'DATA',items:[{id:'database',name:'Database / Inline database',icon:'📊'}]},
{name:'SYNCED',items:[{id:'synced',name:'Synced block',icon:'🔗'}]},
{name:'ACTIONS',items:[{id:'duplicate',name:'Duplicate',icon:'▣'},{id:'turn_into',name:'Turn into…',icon:'↩'},{id:'copy_link',name:'Copy link to block',icon:'🔗'},{id:'delete',name:'Delete block',icon:'✕'}]}];
let _bid=0;function genId(){return 'b'+(++_bid)+'_'+Date.now().toString(36);}
// v5.13.1: guarantee every block (and nested child) has a stable id.
// Template blocks may be persisted without ids; leaving them undefined
// makes `data-bid="undefined"` collide and corrupts editing.
function ensureBlockIds(list){if(!Array.isArray(list))return list;list.forEach(function(b){if(b&&typeof b==='object'){if(!b.id)b.id=genId();if(Array.isArray(b.children))ensureBlockIds(b.children);}});return list;}
window.ensureBlockIds=ensureBlockIds;
function cp(e){const s=window.getSelection();if(!s.rangeCount)return 0;const r=s.getRangeAt(0).cloneRange();r.selectNodeContents(e);r.setEnd(s.getRangeAt(0).endContainer,s.getRangeAt(0).endOffset);return r.toString().length;}
function gt(e){return (e&&e.innerText!==undefined)?e.innerText:(e?e.textContent:'');}
// v5.11.0: read a contenteditable block back to plain text, converting
// wiki chip nodes (contenteditable=false spans) back into their raw
// [[fdpage:ID]] / [[fddate:YYYY-MM-DD]] tokens.
function gtTok(e){
if(!e) return '';
if(!e.querySelector||!e.querySelector('.fd-wiki-chip,.fd-wiki-date')) return gt(e);
let out='';
(function walk(node){
for(const n of node.childNodes){
if(n.nodeType===3){out+=n.nodeValue;continue;}
if(n.nodeType===1){
if(n.classList&&n.classList.contains('fd-wiki-chip')){out+=n.getAttribute('data-token')||'';continue;}
if(n.classList&&n.classList.contains('fd-wiki-date')){out+=n.getAttribute('data-token')||'';continue;}
if(n.tagName==='BR'){out+='\n';continue;}
walk(n);
}
}
})(e);
return out;
}
window.gtTok=gtTok;
function splitCaret(e){
const s=window.getSelection(); if(!s.rangeCount||!s.rangeCount)return null; const sel=s.getRangeAt(0);
const MK='\uF000FD'; const mark=document.createElement('span'); mark.textContent=MK;
sel.insertNode(mark);
const full=gt(e)||'';
const full=gtTok(e)||'';
const parts=full.split(MK);
mark.remove(); e.normalize();
return {before:parts[0]||'',after:(parts.length>1?parts[1]:'')||'',content:full.split(MK).join('')};
@@ -46,9 +75,45 @@
.replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>')
.replace(/\*([^*]+)\*/g,'<em>$1</em>')
.replace(/~~([^~]+)~~/g,'<s>$1</s>')
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g,'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
.replace(/\[\[fdpage:(\d+)\]\]/g,function(m,pid){
return '<a class="fd-wiki-chip" contenteditable="false" href="/pages/'+pid+'" data-token="'+m+'" data-pid="'+pid+'" title="Linked page"><span class="fd-wiki-label">…</span></a>';})
.replace(/\[\[fddate:([0-9]{4}-[0-9]{2}-[0-9]{2})(?:T[0-9:]{5,8})?\]\]/g,function(m,iso){
return '<span class="fd-wiki-date" contenteditable="false" data-token="'+m+'">'+fdDateLabel(iso)+'</span>';})
.replace(/\[([^\]]+)\]\((https?:[^\)\s]+)\)/g,'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
.replace(/\n/g,'<br>');
}
// v5.11.0: labels of [[fdpage:ID]] chips are resolved from the live DB
// (rename propagation — tokens store only the id).
function fdDateLabel(iso){
try{
const p=iso.split('-'); const d=new Date(+p[0],+p[1]-1,+p[2]);
const DOW=['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const MON=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return DOW[d.getDay()]+' '+(+p[2])+' '+MON[+p[1]-1]+' '+p[0];
}catch(e){return iso;}
}
function _wikiResolve(root){
const ct=root||document.getElementById('_blocksCt'); if(!ct)return;
const pids={};
ct.querySelectorAll('.fd-wiki-chip[data-pid]').forEach(function(a){
const lab=a.querySelector('.fd-wiki-label');
if(!lab||lab.getAttribute('data-done'))return;
lab.setAttribute('data-done','1');
pids[a.dataset.pid]=true;
});
const ids=Object.keys(pids); if(!ids.length)return;
fetch('/board/api/wiki/titles?ids='+encodeURIComponent(ids.join(',')),{credentials:'same-origin'})
.then(function(r){return r.json();})
.then(function(d){
const titles=d.titles||{};
ct.querySelectorAll('.fd-wiki-chip[data-pid]').forEach(function(a){
const t=titles[a.dataset.pid];
const lab=a.querySelector('.fd-wiki-label');
if(lab) lab.textContent = t||'Deleted page';
});
}).catch(function(){});
}
window._wikiResolve=_wikiResolve;
const CALLOUT_ICONS=['💡','💡','📌','⚠️','ℹ️','✅','❌','🚀','🔔','📣','⭐','❤️','🔥','💬','👀','🧠','🎯','🔑','📅','💰','💡','📚','🧪','🌍'];
@@ -295,6 +360,64 @@
sel(id){if(window.E)window.E.applySlash(id);SM.close();},
get isOpen(){return SM._o;}};
// ═══════════ v5.11.0 Wiki-link picker ([[ → page search) ═══════════
const WM={_o:false,_idx:-1,_s:0,_it:[],_root:null,_in:null,_li:null,_pv:null,
mk(){ if(this._root)return this._root;
const d=document.createElement('div'); d.id='_wikiMenu';
d.style.cssText='position:fixed;z-index:1250;display:none;width:340px;max-height:320px;flex-direction:column;background:var(--bg-modal,#1f1f1f);border:1px solid var(--border,#333);border-radius:10px;box-shadow:0 12px 40px rgba(0,0,0,.5);overflow:hidden';
const list=document.createElement('div'); list.style.cssText='overflow-y:auto;flex:1;min-height:60px;max-height:250px;padding:6px';
const inp=document.createElement('input'); inp.type='text'; inp.placeholder='Search pages…';
inp.style.cssText='width:100%;padding:9px 12px;background:var(--bg-secondary);border:none;border-top:1px solid var(--border);color:var(--text-primary);font-size:14px;outline:none;box-sizing:border-box';
d.appendChild(list); d.appendChild(inp); document.body.appendChild(d);
this._root=d; this._li=list; this._in=inp;
inp.oninput=()=>{clearTimeout(this._t);this._t=setTimeout(()=>this.load(),180);};
list.addEventListener('click',ev=>{const it=ev.target.closest('.wm-item');if(it){ev.preventDefault();WM.sel(parseInt(it.dataset.wpid,10));}});
document.addEventListener('mousedown',ev=>{if(!d.contains(ev.target))WM.close();});
document.addEventListener('keydown',function(ev){
if(!WM._o)return;
if(ev.metaKey||ev.ctrlKey||ev.altKey)return;
if(ev.key==='Escape'){ev.preventDefault();ev.stopPropagation();WM.close();}
else if(ev.key==='ArrowDown'){ev.preventDefault();ev.stopPropagation();WM._mv(1);}
else if(ev.key==='ArrowUp'){ev.preventDefault();ev.stopPropagation();WM._mv(-1);}
else if(ev.key==='Enter'){ev.preventDefault();ev.stopPropagation();const c=WM._it[WM._s];if(c)WM.sel(c.id);}
},true);
return d; },
open(i,el){ this.mk(); const r=el.getBoundingClientRect();
this._root.style.display='flex';
this._root.style.top=Math.max(4,(r.bottom+4>window.innerHeight-340)?r.top-344:r.bottom+4)+'px';
this._root.style.left=Math.min(r.left,window.innerWidth-350)+'px';
this._idx=i; this._s=0; this._in.value=''; this._o=true;
this.load(); setTimeout(()=>this._in.focus(),10); },
close(){ if(this._root)this._root.style.display='none'; this._o=false; },
load(){ const self=this; const q=(this._in.value||'').toLowerCase();
fetch('/board/api/wiki/pages?q='+encodeURIComponent(q),{credentials:'same-origin'})
.then(r=>r.json()).then(d=>{
if(!self._o)return;
self._it=d.pages||[]; self._s=0;
let h='<div style="padding:6px 10px 2px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase">Pages</div>';
self._it.forEach(function(p,idx){
h+='<div class="wm-item" data-wpid="'+p.id+'" style="display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:13px;color:var(--text);cursor:pointer;border-radius:6px;'+(idx===0?'background:rgba(35,131,226,.15)':'')+'">'
+'<span style="width:18px;text-align:center;flex-shrink:0">'+esc(p.icon||'📄')+'</span>'
+'<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(p.title)+'</span>'
+'<span style="font-size:11px;color:var(--text-dim)">'+esc(p.workspace||'')+'</span></div>';
});
if(!self._it.length)h+='<div style="padding:12px;color:var(--text-dim);font-size:13px">No page found</div>';
self._li.innerHTML=h;
}).catch(()=>{}); },
_mv(dir){ if(!this._it.length)return;
this._s=(this._s+dir+this._it.length)%this._it.length;
this._li.querySelectorAll('.wm-item').forEach((el,i)=>{el.style.background=i===this._s?'rgba(35,131,226,.15)':'';});
const cur=this._li.querySelectorAll('.wm-item')[this._s]; if(cur&&cur.scrollIntoView)cur.scrollIntoView({block:'nearest'}); },
sel(pid){ const E=window.E; if(!E||this._idx<0)return;
const b=E.blocks[this._idx]; if(!b)return;
// Drop the typed "[[" from the live DOM text, append the token chip.
const el=E.getEl(b.id); let text=(el?gtTok(el):b.content)||'';
if(text.endsWith('[[')) text=text.slice(0,-2);
else text=text.replace(/\[\[[^\]]*$/,'');
E._commitBlockText(b, text+'[[fdpage:'+pid+']] ');
this.close(); },
get isOpen(){return this._o;}};
// ═══════════ Inline AI composer (Notion AI-style) ═══════════
// Space on an empty paragraph → "Edit with AI" pill → Enter → "Brewing…"
// → framed result → "Insert below" → applyAIBlocks.
@@ -439,13 +562,22 @@
return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="text-align:center;padding:60px 20px;"><div style="font-size:48px;margin-bottom:12px;">{{ fd_icon("paperclip",14) }}</div><div style="font-size:16px;font-weight:500;color:var(--text-primary);margin-bottom:4px;">${esc(b.file_name||'File')}</div><div style="font-size:12px;color:var(--text-tertiary);margin-bottom:16px;">${szStr} · ${esc(b.file_mime||'')}</div><a href="${b.src||''}" download style="display:inline-block;padding:8px 20px;background:var(--accent);color:#fff;text-decoration:none;border-radius:8px;font-size:13px;font-weight:500;">⬇ Download</a></div></div>`;
}
var url=b.src||'';if(url){
var src=url;
try{var h=window.location.href.split('/').slice(0,3).join('/');var urlNoProto=url.replace(/^https?:\/\//,'');if(/^(youtube\.com|youtu\.be|vimeo\.com|loom\.com|figma\.com|google\.com\/maps|codepen\.io|miro\.com|open\.spotify\.com|soundcloud\.com|twitch\.tv|twitter\.com|x\.com|pinterest\.)/.test(urlNoProto.split('/')[0])){src=url;}}catch(e){}
var src=b.embed_src||url;
var height=b.height||520;
return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="position:relative;width:100%;height:${height}px;border-radius:8px;overflow:hidden;background:#0a0a0a;"><iframe src="${src}" loading="lazy" frameborder="0" style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div></div>`;
}
return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="text-align:center;padding:60px 20px;background:var(--bg-secondary);border:1px dashed var(--border);border-radius:8px;cursor:pointer;" onclick="E.setEmbedUrl('${b.id}')"><div style="font-size:48px;margin-bottom:12px;">🔗</div><div style="font-size:14px;color:var(--text-dim);">Click to add embed URL</div></div></div>`;
}
if(b.type==='synced'){
var syncedTitle=b._synced_title||'Synced block';
var syncedContent=b._synced_content||[];
var sid=b.synced_id||'';
var inner=syncedContent.map(function(ch){return'<div class="block-content" style="padding-left:16px;border-left:2px solid var(--accent);margin:4px 0;">'+mdEsc(typeof ch==='object'?ch.content||'':ch)+'</div>';}).join('');
return `<div class="block-wrapper synced-block-wrapper" data-id="${b.id}" style="border-left:3px solid var(--accent);background:var(--synced-bg,var(--bg-secondary))">
<div class="synced-badge" style="font-size:11px;color:var(--accent);padding:4px 8px;font-weight:600;">🔗 Synced — ${esc(syncedTitle)}</div>
<div class="synced-content">${inner||'<div style="color:var(--text-dim);padding:8px;">Loading synced content…</div>'}</div>
</div>`;
}
const ph=b.type==='heading_1'?'Heading 1':b.type==='heading_2'?'Heading 2':b.type==='heading_3'?'Heading 3':b.type==='heading_4'?'Heading 4':b.type==='bulleted_list'?'List':b.type==='numbered_list'?'List':b.type==='to_do'?'To-do':b.type==='toggle'?'Toggle list':b.type==='quote'?'Empty quote':b.type==='code'?'Type code...':b.type==='callout'?'Type something...':(b.type==='paragraph'&&!b.content?'Press \'space\' for AI or \'/\' for commands':'Press \'/\' for commands...');
let inner='';
if(b.type==='to_do')inner=`<input type="checkbox" class="todo-checkbox" ${b.checked?'checked':''} onchange="E._doToggle('${b.id}',this.checked)"><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${mdEsc(b.content)}</div>`;
@@ -782,6 +914,9 @@
if(act==='turn'){var m=document.getElementById('blockMenu');if(m){var r=m.getBoundingClientRect();_blockTurnMenuOpen(r.right+4,r.top,bid||'');}return;}
if(act==='duplicate'){E.duplicateBlock(idx);}
else if(act==='copy-link'){E.copyBlockLink(bid||'');}
else if(act==='copy-blocks'){E.copySelectedBlocks();}
else if(act==='cut-blocks'){E.cutSelectedBlocks();}
else if(act==='paste-blocks'){E.pasteBlocks();}
else if(act==='move-to'){_blockMoveToOpen(idx);}
else if(act==='delete'){E.deleteSelected(idx);}
else if(act==='color'){var key=t.getAttribute('data-color-key'),val=t.getAttribute('data-color-val');E.setBlockColor(idx,key,val);}
@@ -801,6 +936,11 @@
+'<div class="bm-sep"></div>'
+'<div class="bm-item" data-bm-act="duplicate" data-bid="'+b.id+'"><span class="bm-ico">▣</span>Duplicate <span class="bm-hint">'+(_rtMac()?'⌘D':'Ctrl+D')+'</span></div>'
+'<div class="bm-item" data-bm-act="copy-link" data-bid="'+b.id+'"><span class="bm-ico">🔗</span>Copy link to block</div>'
+'<div class="bm-sep"></div>'
+'<div class="bm-item" data-bm-act="copy-blocks" data-bid="'+b.id+'"><span class="bm-ico">⎘</span>Copy blocks <span class="bm-hint">'+(_rtMac()?'⌘C':'Ctrl+C')+'</span></div>'
+'<div class="bm-item" data-bm-act="cut-blocks" data-bid="'+b.id+'"><span class="bm-ico">✂</span>Cut blocks <span class="bm-hint">'+(_rtMac()?'⌘X':'Ctrl+X')+'</span></div>'
+'<div class="bm-item" data-bm-act="paste-blocks" data-bid="'+b.id+'"><span class="bm-ico">⎘</span>Paste blocks <span class="bm-hint">'+(_rtMac()?'⌘V':'Ctrl+V')+'</span></div>'
+'<div class="bm-sep"></div>'
+'<div class="bm-item" data-bm-act="move-to" data-bid="'+b.id+'"><span class="bm-ico">↪</span>Move to…</div>'
+'<div class="bm-sep"></div>'
+'<div class="bm-colors"><div class="bm-colors-label">Text</div><div class="bm-swatches">'+colorsText+'</div></div>'
@@ -892,10 +1032,15 @@
undoStack:[],redoStack:[],_histCap:100,_histLock:false,
// ── Cover & icon (v5.10+) ──
coverUrl:'',
coverOpen:false,
coverLoading:false,
iconOpen:false,
iconValue:'',
// ── Import ──
importOpen:false,importText:'',importFile:null,
// ── v5.11.0 wiki-links & v5.12.0 page options ──
isLocked:false,lockedBy:null,canEdit:true,fullWidth:false,fontSmall:false,
tplSaving:false,
// ── DB Templates ──
dbTplOpen:false,dbTplLoading:false,dbTemplates:[],
// ── Version history ──
@@ -920,14 +1065,18 @@
_bid=Math.floor(Math.random()*10000);
const dataEl=document.getElementById('page-data');
if(dataEl){try{const data=JSON.parse(dataEl.textContent);this.pid=data.id;this.pageTitle=data.title||'';this.favorited=data.favorited||false;const fmt=data.content_format||'blocks';this.contentFormat=fmt;const raw=data.content||'';
if(fmt==='file'){this.fileData=data;this.loadFileContent(data);}
else if(fmt==='blocks'&&raw){try{this.blocks=JSON.parse(raw);this.blocks.forEach(b=>{if(!b.id)b.id=genId()});}catch(e){this.blocks=[];}}
else if(raw&&raw.trim())this.blocks=this.md2b(raw);}catch(e){}}
if(fmt==='file'){this.fileData=data;this.fileUrl=data.file_url||'';this.loadFileContent(data);}
else if(fmt==='blocks'&&raw){try{this.blocks=JSON.parse(raw);ensureBlockIds(this.blocks);}catch(e){this.blocks=[];}}
else if(raw&&raw.trim())this.blocks=this.md2b(raw);
// v5.12.0: page lock + layout options
this.isLocked=!!data.is_locked;this.lockedBy=data.locked_by||null;
this.canEdit=data.can_edit!==false;this.fullWidth=!!data.full_width;this.fontSmall=!!data.font_small;}catch(e){}}
if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];
this.loadInlineDBs();
window.E=this;
this.insertTable=function(){this.sync();this.pushHistory();var nb={id:genId(),type:'table',content:'',rows:_tblNew(3,3)};this.blocks.push(nb);this.dirty=true;this.autoSave();this.render();_rtSync();setTimeout(function(){_tblFocus(nb.id,0,0);},60);};
SM.init();_tblInitDelegate();this.render();
this._applyLockMode();this._applyLayout();_wikiResolve();
const tEl=document.getElementById('_titleEl');
if(tEl){const syncT=()=>{tEl.classList.toggle('empty',!(tEl.textContent||'').trim());};syncT();tEl.addEventListener('input',syncT);tEl.addEventListener('blur',syncT);}
if(this.contentFormat==='blocks'&&window.__fdRT&&window.__fdRT.start){setTimeout(()=>{try{window.__fdRT.start(this);}catch(e){}},150);}
@@ -943,7 +1092,69 @@
if(window.location.hash&&window.location.hash.indexOf('#fdblk-')===0){const hb=window.location.hash.slice(7);setTimeout(()=>{const selEl=document.querySelector(`[data-bid="${hb}"]`);if(selEl&&selEl.scrollIntoView)selEl.scrollIntoView({behavior:'smooth',block:'center'});if(selEl){selEl.focus();ce(selEl);}},500);}
},100);
},
// ── v5.12.0: lock + layout helpers ──
_applyLockMode(){
document.body.classList.toggle('page-locked',!!this.isLocked);
let banner=document.getElementById('_lockBanner');
if(this.isLocked){
if(!banner){
banner=document.createElement('div');banner.id='_lockBanner';
banner.className='fd-lock-banner';
banner.innerHTML='<span>🔒</span><span>This page is locked (read-only)</span>'
+'<button type="button" class="btn-sm" id="_lockUnlockBtn" style="margin-left:auto;font-size:12px;cursor:pointer;">Unlock</button>';
const wrap=document.querySelector('.page-editor-wrapper');
if(wrap)wrap.insertBefore(banner,wrap.firstChild);
}
const ub=banner.querySelector('#_lockUnlockBtn');
if(ub)ub.style.display=this.canEdit?'':'none';
}else if(banner){banner.remove();}
const ct=document.getElementById('_blocksCt');
if(ct){ct.querySelectorAll('[data-bid]').forEach(el=>el.setAttribute('contenteditable',this.canEdit?'true':'false'));}
const t=document.getElementById('_titleEl');
if(t)t.setAttribute('contenteditable',this.canEdit?'true':'false');
},
_applyLayout(){
const wrap=document.querySelector('.page-editor-wrapper');
if(!wrap)return;
wrap.classList.toggle('full-width',!!this.fullWidth);
wrap.classList.toggle('small-text',!!this.fontSmall);
},
async toggleLock(){
if(!this.canEdit){this.showToast&&this.showToast('Only the person who locked this page (or an admin) can unlock it','error');return;}
const next=!this.isLocked;
try{
const r=await fetch('/board/api/pages/'+this.pid+'/lock',{method:'POST',credentials:'same-origin',
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
body:JSON.stringify({locked:next})});
if(!r.ok){const d=await r.json().catch(()=>({}));this.showToast((d&&d.detail)||'Lock failed','error');return;}
this.isLocked=next;this._applyLockMode();
this.showToast(next?'🔒 Page locked':'🔓 Page unlocked');
}catch(e){this.showToast('Lock failed','error');}
},
async setPageOption(key,val){
try{
const body={};body[key]=val;
const r=await fetch('/board/api/pages/'+this.pid+'/options',{method:'POST',credentials:'same-origin',
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify(body)});
if(r.ok){this[key]=val;this._applyLayout();}
}catch(e){}
},
async saveAsTemplate(){
if(!this.pageTitle||!(this.pageTitle||'').trim()){this.showToast('Give the page a title first','error');return;}
const name=(this.pageTitle||'').trim()+' template';
this.tplSaving=true;this.sync();
const blocks=this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','children','rows'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;});
try{
const r=await fetch('/board/api/page-templates',{method:'POST',credentials:'same-origin',
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
body:JSON.stringify({name:name,blocks:blocks})});
if(r.ok){this.moreOpen=false;this.showToast('📑 Saved as template: '+name);}
else{const d=await r.json().catch(()=>({}));this.showToast((d&&d.detail)||'Save failed','error');}
}catch(e){this.showToast('Save failed','error');}
this.tplSaving=false;
},
fileData:null,
fileUrl:'',
async loadFileContent(data){
const url=data.file_url||'';const mime=data.file_mime||'';const name=data.file_name||'';const self=this;
if(!url){this.blocks=[this.mkB('paragraph','File not available')];this.render();return;}
@@ -967,6 +1178,16 @@
this.blocks=[this.mkB('embed','',{src:url,embed_type:'download',file_name:name,file_size:data.file_size||0,file_mime:mime})];this.render();
},
mkB(type,content,extras){const b={id:genId(),type,content:content||'',...(extras||{})};if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='toggle')b.children=[];if(type==='button'){b.automation_id=0;b.automation_name='';}return b;},
// v5.11.0: rewrite one block's text (e.g. after inserting a wiki token)
// and re-render in place, caret at end.
_commitBlockText(b,text){
this.sync();this.pushHistory();
b.content=text;
this.dirty=true;this.autoSave();this.render();_rtSync();
const el=this.getEl(b.id);
if(el){el.focus();const r=document.createRange();r.selectNodeContents(el);r.collapse(false);const s=window.getSelection();s.removeAllRanges();s.addRange(r);}
if(window.__fdRT&&window.__fdRT.syncNow)window.__fdRT.syncNow();
},
getEl(bid){const ct=document.getElementById('_blocksCt');return ct?ct.querySelector(`[data-bid="${bid}"]`):null;},
getIdx(bid){return this.blocks.findIndex(b=>b.id===bid);},
getActiveBlock(){const el=document.activeElement;if(!el||!el.hasAttribute('data-bid'))return null;return{el,bid:el.dataset.bid,idx:this.getIdx(el.dataset.bid)};},
@@ -1000,9 +1221,9 @@
sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='table'){const wrap=ct.querySelector(`[data-bid="${b.id}"].ftable-editor`);if(wrap){const rows=[];wrap.querySelectorAll('tr.ftable-row').forEach(function(tr){const cells=tr.querySelectorAll('th.ftable-cell,td.ftable-cell');if(!cells.length)return;const rowarr=[];cells.forEach(function(cell){rowarr.push(cell.textContent||'');});rows.push(rowarr);});if(rows.length)b.rows=rows;}continue;}
if(b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='table_of_contents'||b.type==='button')continue;
if(b.type==='math'){const te=ct.querySelector(`[data-math-bid="${b.id}"]`);if(te)b.content=te.value||'';continue;}
const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=gt(el)||'';else if(el)b.content=gt(el)||'';
if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gt(che)||'';});
if(b.type==='columns'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gt(che)||'';});
const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=gtTok(el)||'';else if(el)b.content=gtTok(el)||'';
if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gtTok(che)||'';});
if(b.type==='columns'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gtTok(che)||'';});
}},
render(){const ct=document.getElementById('_blocksCt');if(!ct)return;let html='';for(let i=0;i<this.blocks.length;i++)html+=renderBlock(this.blocks[i],i);ct.innerHTML=html;
// Render math blocks with KaTeX
@@ -1015,8 +1236,15 @@
setTimeout(()=>{ct.querySelectorAll('[data-meeting-bid]').forEach(function(el){if(window.FDMeeting)window.FDMeeting.mount(el.getAttribute('data-meeting-bid'),el);});},50);
ct.querySelectorAll('[data-bid]').forEach(el=>{el.addEventListener('input',()=>{self.dirty=true;self.autoSave();if(el.hasAttribute&&el.hasAttribute('data-placeholder'))el.classList.toggle('empty',!(el.textContent||'').trim());if(window.AIAC)window.AIAC.schedule(el.getAttribute('data-bid'));})});
ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/<br[^>]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());});
_selApply();
},
_selApply();
var self2=this;
setTimeout(function(){
if(window._wikiResolve)_wikiResolve();
if(!self2.canEdit)self2._applyLockMode();
const ub=document.getElementById('_lockUnlockBtn');
if(ub&&!ub._wired){ub._wired=true;ub.onclick=function(){window.E.toggleLock();};}
},30);
},
_renderKatex(el,tex){
if(!el)return;if(!tex||!tex.trim()){el.innerHTML='<span style="color:var(--text-dim);font-style:italic;">Empty math block</span>';return;}
if(window.katex){try{el.innerHTML=window.katex.renderToString(tex,{displayMode:true,throwOnError:false});}catch(e){el.innerHTML='<span style="color:#e5484d;">Invalid LaTeX</span>';}}
@@ -1036,7 +1264,20 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
if(u.trim()===cur.trim())return;
this.sync();this.pushHistory();b[field]=u.trim();this.dirty=true;this.autoSave();this.render();_rtSync();
},
setEmbedUrl(bid){this._mediaPrompt(bid,'src','Embed');},
async setEmbedUrl(bid){
const idx=this.getIdx(bid); if(idx<0)return;
const b=this.blocks[idx]; if(!b)return;
const cur=b.src||''; const u=prompt('Embed URL (YouTube, Vimeo, Figma, Maps…):', cur);
if(u===null)return;
if(u.trim()===cur.trim())return;
this.sync();this.pushHistory();b.src=u.trim();b.embed_type='';this.dirty=true;
try{
const r=await fetch('/board/api/embed/resolve',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({url:u.trim()})});
const d=await r.json();
if(d&&d.ok&&d.src){b.embed_src=d.src;b.embed_provider=d.provider||'';}
}catch(e){}
this.render();this.autoSave();_rtSync();
},
setBookmarkUrl(bid){
const idx=this.getIdx(bid); if(idx<0)return;
const b=this.blocks[idx]; if(!b)return;
@@ -1048,7 +1289,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
},
async _fetchBookmarkPreview(bid, url){
try{
const r=await fetch('/api/og/metadata',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({url})});
const r=await fetch('/board/api/og/metadata',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({url})});
const d=await r.json();
if(d&&d.ok){
const idx=this.getIdx(bid); if(idx<0)return;
@@ -1064,17 +1305,29 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
setVideoSrc(bid){this._mediaPrompt(bid,'src','Video');},
setAudioSrc(bid){this._mediaPrompt(bid,'src','Audio');},
// ── v5.5.0: Image lightbox ──
// ── v5.5.0: Image lightbox (fullscreen + keyboard navigation) ──
_openLightbox(imgEl){
const full=imgEl.dataset.full||imgEl.src;
if(!full)return;
const ov=document.createElement('div');ov.className='fd-lightbox';ov.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.95);z-index:5000;display:flex;align-items:center;justify-content:center;flex-direction:column;';
ov.innerHTML='<img src="'+full+'" style="max-width:95vw;max-height:90vh;border-radius:4px;box-shadow:0 0 40px rgba(0,0,0,.8);"><div style="margin-top:16px;color:var(--text-dim);font-size:13px;">Esc to close</div>';
document.body.appendChild(ov);
const close=()=>{document.body.removeChild(ov);document.removeEventListener('keydown',kd);};
const kd=(e)=>{if(e.key==='Escape')close();};
const imgs=Array.from(document.querySelectorAll('.block-image img, .fd-img')).filter(i=>i.dataset.full||i.src);
let idx=imgs.indexOf(imgEl);
if(idx<0)idx=0;
const overlay=document.createElement('div');overlay.className='fd-lightbox';overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.95);z-index:5000;display:flex;align-items:center;justify-content:center;flex-direction:column;';
overlay.innerHTML='<button class="fd-lightbox-nav" data-nav="prev" aria-label="Previous">‹</button>'
+'<img class="fd-lightbox-img" style="max-width:95vw;max-height:88vh;border-radius:4px;box-shadow:0 0 40px rgba(0,0,0,.8);">'
+'<button class="fd-lightbox-nav" data-nav="next" aria-label="Next">›</button>'
+'<div class="fd-lightbox-hint" style="margin-top:14px;color:#999;font-size:13px;"></div>';
document.body.appendChild(overlay);
const img=overlay.querySelector('.fd-lightbox-img');
const hint=overlay.querySelector('.fd-lightbox-hint');
const show=()=>{const cur=imgs[idx];img.src=cur.dataset.full||cur.src;hint.textContent=(imgs.length>1?(idx+1)+' / '+imgs.length+' — ':'')+'← → to navigate · Esc to close';};
const step=(d)=>{if(imgs.length<2)return;idx=(idx+d+imgs.length)%imgs.length;show();};
show();
const close=()=>{overlay.remove();document.removeEventListener('keydown',kd);};
const kd=(e)=>{if(e.key==='Escape')close();else if(e.key==='ArrowRight')step(1);else if(e.key==='ArrowLeft')step(-1);};
document.addEventListener('keydown',kd);
ov.addEventListener('click',e=>{if(e.target===ov)close();});
overlay.querySelectorAll('[data-nav]').forEach(btn=>btn.addEventListener('click',e=>{e.stopPropagation();step(btn.dataset.nav==='next'?1:-1);}));
overlay.addEventListener('click',e=>{if(e.target===overlay)close();});
},
// ── v5.4.0: Version history UI ──
@@ -1086,7 +1339,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
async loadVersions(){
this.versionsLoading=true;
try{
const r=await fetch('/api/pages/'+this.pid+'/versions');
const r=await fetch('/board/api/pages/'+this.pid+'/versions');
const d=await r.json();
this.versionsList=d.versions||[];
}catch(e){this.versionsList=[];}
@@ -1095,7 +1348,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
async restoreVersion(vid){
if(!confirm('Restore this version? Current content will be replaced.'))return;
try{
const r=await fetch('/api/pages/'+this.pid+'/versions/'+vid+'/restore',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()}});
const r=await fetch('/board/api/pages/'+this.pid+'/versions/'+vid+'/restore',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()}});
const d=await r.json();
if(d.status==='ok'){
this.showToast('Version restored');this.versionsOpen=false;
@@ -1113,7 +1366,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
async loadBacklinks(){
this.backlinksLoading=true;
try{
const r=await fetch('/api/pages/'+this.pid+'/backlinks');
const r=await fetch('/board/api/pages/'+this.pid+'/backlinks');
const d=await r.json();
this.backlinksList=d.backlinks||[];
}catch(e){this.backlinksList=[];}
@@ -1135,7 +1388,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
if(u===null)return;
this.coverLoading=true;
try{
const r=await fetch('/api/pages/'+this.pid+'/cover',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({cover_url:u})});
const r=await fetch('/board/api/pages/'+this.pid+'/cover',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({cover_url:u})});
const d=await r.json();
if(d.status==='ok'){this.coverUrl=d.cover_url;this.coverOpen=false;this.showToast('Cover updated');}
}catch(e){this.showToast('Cover update failed','error');}
@@ -1146,7 +1399,7 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
this.coverLoading=true;
const fd=new FormData();fd.append('file',f);
try{
const r=await fetch('/api/pages/'+this.pid+'/cover',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()},body:fd});
const r=await fetch('/board/api/pages/'+this.pid+'/cover',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()},body:fd});
const d=await r.json();
if(d.status==='ok'){this.coverUrl=d.cover_url;this.coverOpen=false;this.showToast('Cover uploaded');}
}catch(e){this.showToast('Cover upload failed','error');}
@@ -1156,18 +1409,31 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
async removeCover(){
if(!confirm('Remove cover?'))return;
try{
const r=await fetch('/api/pages/'+this.pid+'/cover',{method:'DELETE',headers:{'X-CSRF-Token':this.csrfTok()}});
const r=await fetch('/board/api/pages/'+this.pid+'/cover',{method:'DELETE',headers:{'X-CSRF-Token':this.csrfTok()}});
const d=await r.json();
if(d.status==='ok'){this.coverUrl='';this.showToast('Cover removed');}
}catch(e){this.showToast('Remove failed','error');}
},
toggleIcon(){this.iconOpen=!this.iconOpen;},
iconHtml(){
var v=this.iconValue;
if(!v)v=(this.contentFormat==='file')?'paperclip':'file';
return window.fdIconHtml?window.fdIconHtml(v,36):v;
},
openIconPicker(){
var self=this;
if(window.FDIconPicker)window.FDIconPicker.openFor({
x:120,y:160,
onPick:function(v){self.setIcon(v);},
onRemove:function(){self.setIcon('');}
});
},
async setIcon(icon){
this.iconValue=icon;
try{
const r=await fetch('/api/pages/'+this.pid+'/icon',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({icon})});
const r=await fetch('/board/api/pages/'+this.pid+'/icon',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({icon})});
const d=await r.json();
if(d.status==='ok'){this.iconOpen=false;this.showToast('Icon updated');}
if(d.status==='ok'){this.iconOpen=false;this.showToast(icon?'Icon updated':'Icon removed');}
}catch(e){this.showToast('Icon update failed','error');}
},
@@ -1186,13 +1452,13 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
if(this.importFile){
const fd=new FormData();fd.append('file',this.importFile);
try{
const r=await fetch('/api/pages/import/file',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()},body:fd});
const r=await fetch('/board/api/pages/import/file',{method:'POST',headers:{'X-CSRF-Token':this.csrfTok()},body:fd});
const d=await r.json();
if(d.status==='ok'){this.showToast(d.count+' page(s) imported');this.importOpen=false;this.importFile=null;this.importText='';}
}catch(e){this.showToast('Import failed','error');}
}else{
try{
const r=await fetch('/api/pages/import',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({markdown:this.importText,title:this.pageTitle||'Import'})});
const r=await fetch('/board/api/pages/import',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify({markdown:this.importText,title:this.pageTitle||'Import'})});
const d=await r.json();
if(d.status==='ok'){this.showToast('Page imported');this.importOpen=false;this.importText='';setTimeout(()=>{window.location.href='/pages/'+d.id;},400);}
}catch(e){this.showToast('Import failed','error');}
@@ -1221,6 +1487,12 @@ replaceBlock(idx,type){const b=this.blocks[idx];if(!b)return;this.sync();this.pu
setBlockColor(idx,key,val){if(idx<0||idx>=this.blocks.length)return;const targets=this._blockTargets(idx);const v=(!val||val==='default')?null:val;this.sync();this.pushHistory();targets.forEach(i=>{const b=this.blocks[i];if(!b)return;if(!b.style)b.style={};if(v==null)delete b.style[key];else b.style[key]=v;});this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();},
copyBlockLink(bid){const url=window.location.origin+window.location.pathname+'#fdblk-'+bid;if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(url).then(()=>this.showToast('Lien du bloc copié')).catch(()=>this.showToast('Copie impossible','error'));}else{this.showToast(url,'error');}},
deleteSelected(idx){const indices=_selIndicesSorted();if(indices.length<=1)return this.removeBlock(idx);this.sync();this.pushHistory();const keep=this.blocks.filter((b,i)=>indices.indexOf(i)<0);this.blocks=keep.length?keep:[this.mkB('paragraph','')];const fi=Math.min(idx,this.blocks.length-1);this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);},
// ── v5.11: Copy / Cut / Paste selected blocks ──
_blocksToMarkdown(blocks){return blocks.map(blocksToMarkdown).join('\n\n');},
copySelectedBlocks(){const indices=_selIndicesSorted();if(!indices.length)return;const selected=indices.map(i=>this.blocks[i]).filter(Boolean);if(!selected.length)return;const md=this._blocksToMarkdown(selected);if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md).then(()=>this.showToast('Blocs copiés')).catch(()=>this.showToast('Copie impossible','error'));}else{this.showToast(md,'error');}},
cutSelectedBlocks(){const indices=_selIndicesSorted();if(!indices.length)return;this.sync();this.pushHistory();const selected=indices.map(i=>this.blocks[i]).filter(Boolean);if(!selected.length)return;const md=this._blocksToMarkdown(selected);if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(md).then(()=>{});}this.blocks=this.blocks.filter((b,i)=>indices.indexOf(i)<0);if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();this.showToast('Blocs coupés');},
async pasteBlocks(){const idx=this._focusedIdx();if(idx<0)return;try{const text=await navigator.clipboard.readText();if(!text||!text.trim())return;const parsed=this.md2b(text);if(!parsed.length)return;this.sync();this.pushHistory();this.blocks.splice(idx+1,0,...parsed);this.dirty=true;this.autoSave();this.render();_rtSync();this._focusBid(parsed[parsed.length-1].id);_selClear();}catch(e){if(e.name==='NotAllowedError'){this.showToast('Permission presse-papier refusée');}else{this.showToast('Coller impossible','error');}}},
_focusedIdx(){const a=this.getActiveBlock();return a?a.idx:-1;},
async moveBlockToPage(idx,pageId){if(idx<0||idx>=this.blocks.length||pageId===this.pid)return;this.sync();this.pushHistory();const block=this.blocks[idx];const self=this;const csrf=document.cookie.match(/csrf_token=([^;]+)/);this.showToast('Déplacement du bloc…');try{const r=await fetch(`/board/api/pages/${pageId}`,{credentials:'same-origin'});const d=await r.json();let tblocks=[];if(d.content_format==='blocks'&&d.content){try{tblocks=JSON.parse(d.content);}catch(e){tblocks=[];}}tblocks.push(block);await fetch(`/board/api/pages/${pageId}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:d.title||'',blocks:tblocks})});
if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}this.dirty=true;this.autoSave();this.render();_rtSync();_selClear();this.showToast('Bloc déplacé');
}catch(e){this.showToast('Échec du déplacement','error');}},
@@ -1518,7 +1790,7 @@ applyAIBlocks(text){
this.moveOpen = false;
var csrf = document.cookie.match(/csrf_token=([^;]+)/);
var self = this;
fetch('/api/pages/' + this.pid + '/move', {
fetch('/board/api/pages/' + this.pid + '/move', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf ? csrf[1] : '' },
body: JSON.stringify({ workspace_id: wsId })
@@ -1534,6 +1806,8 @@ applyAIBlocks(text){
.catch(function(){ self.showToast('Move failed'); });
},
deletePage(){this.moreOpen=false;if(!confirm('Move to Trash?'))return;const csrf=document.cookie.match(/csrf_token=([^;]+)/);fetch(`/board/api/pages/${this.pid}/trash`,{method:'POST',headers:{'X-CSRF-Token':csrf?csrf[1]:''}}).then(()=>{window.location.href='/';}).catch(()=>{window.showToast('Failed','error');});},
downloadFile(){this.moreOpen=false;if(!this.fileUrl)return;var a=document.createElement('a');a.href=this.fileUrl;a.download='';document.body.appendChild(a);a.click();document.body.removeChild(a);this.showToast('Download started','success');},
async copyFileContent(){this.moreOpen=false;if(!this.fileUrl)return;try{var r=await fetch('/api/pages/'+this.pid+'/file-content');var d=await r.json();if(d.ok&&d.content!==undefined){await navigator.clipboard.writeText(d.content);this.showToast('Content copied to clipboard','success');}else{this.showToast('Not a text file','error');}}catch(e){this.showToast('Copy failed','error');}},
// ── v4.6.0 / v5.3.0: Get Started actions — Database (template picker) ──
dbTplOpen:false, dbTplLoading:false, dbTemplates:[], dbTplTargetIdx:-1,
@@ -1621,7 +1895,7 @@ applyAIBlocks(text){
}
},
onKd(e){
async onKd(e){
const ab=this.getActiveBlock();if(!ab)return;
const{el,bid,idx}=ab;const block=this.blocks[idx];if(!block)return;
if(SM.isOpen){
@@ -1635,7 +1909,8 @@ applyAIBlocks(text){
if(e.key==='Escape'){e.preventDefault();window.AIAC.clear();return;}
if(e.key==='Enter'||e.key==='Backspace'){window.AIAC.clear();}
}
if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id);if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen)SM.open(i2,e2);},15);}
if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id);if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen&&!WM.isOpen)SM.open(i2,e2);
if(e2&&!SM.isOpen&&!WM.isOpen){const t=gtTok(e2)||'';const mm=t.match(/\[\[$/);if(mm){WM.open(i2,e2);}}},15);}
if(e.key===' '){
if(block.type==='paragraph'&&!(block.content||'').trim()&&!(el.textContent||'').trim()&&!SM.isOpen&&window.AIC){
e.preventDefault(); window.AIC.open(idx); return;
@@ -1646,7 +1921,7 @@ applyAIBlocks(text){
if(e.shiftKey){this.dirty=true;this.autoSave();return;}
e.preventDefault();
const sp=splitCaret(el);
const before=sp?sp.before:gt(el)||'',after=sp?sp.after:'';
const before=sp?sp.before:gtTok(el)||'',after=sp?sp.after:'';
if(!before.trim()&&!after.trim()){
const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph';
this.sync();this.pushHistory();this.blocks.splice(idx+1,0,this.mkB(nt,''));this.render();_rtSync();
@@ -1666,6 +1941,20 @@ applyAIBlocks(text){
if(e.key.toLowerCase()==='z'){e.preventDefault();if(e.shiftKey)this.redo();else this.undo();return;}
if(e.key.toLowerCase()==='y'){e.preventDefault();this.redo();return;}
if(e.key.toLowerCase()==='d'){e.preventDefault();const ab2=this.getActiveBlock();if(ab2)this.duplicateBlock(ab2.idx);return;}
if(e.key.toLowerCase()==='c'){
e.preventDefault();
const sel=_selIndicesSorted();
if(sel.length>0){this.copySelectedBlocks();return;}
}
if(e.key.toLowerCase()==='x'){
e.preventDefault();
const sel=_selIndicesSorted();
if(sel.length>0){this.cutSelectedBlocks();return;}
}
if(e.key.toLowerCase()==='v'){
e.preventDefault();
await this.pasteBlocks();return;
}
const m={b:'bold',i:'italic',u:'underline'};if(m[e.key]){e.preventDefault();document.execCommand(m[e.key]);this.dirty=true;this.autoSave();}}
if(e.key==='Tab'&&block.type==='code'){e.preventDefault();document.execCommand('insertText',false,' ');}
this.dirty=true;this.autoSave();
@@ -1680,8 +1969,16 @@ applyAIBlocks(text){
},
applySlash(id){
const idx=SM._i;if(idx<0||idx>=this.blocks.length)return;
if(id==='database'){ this.openDbTemplatePicker(idx); return; }
if(id.indexOf('ai_')===0){ this.aiSlash(id,idx); return; }
if(id==='database'){ this.openDbTemplatePicker(idx); return; }
if(id==='synced'){
const block=this.blocks[idx];this.sync();this.pushHistory();
block.type='synced';block.synced_id=0;block._synced_content=[];block._synced_title='';
this.render();this.dirty=true;this.autoSave();_rtSync();
// Open synced block picker
this._pickSyncedBlock(idx);
return;
}
if(id.indexOf('ai_')===0){ this.aiSlash(id,idx); return; }
if(id==='duplicate'){this.duplicateBlock(idx);return;}
if(id==='delete'){this.removeBlock(idx);return;}
if(id==='copy_link'){const blk=this.blocks[idx];if(blk)this.copyBlockLink(blk.id);return;}
@@ -1738,8 +2035,30 @@ applyAIBlocks(text){
_addColumn(parentId){const idx=this.getIdx(parentId);if(idx<0)return;const b=this.blocks[idx];this.pushHistory();b.children.push({id:genId(),type:'paragraph',content:''});this.render();this.dirty=true;this.autoSave();_rtSync();setTimeout(()=>{const ne=this.getEl(b.children[b.children.length-1].id);if(ne)ne.focus();},60);},
_removeColumn(parentId,childId){const idx=this.getIdx(parentId);if(idx<0)return;const b=this.blocks[idx];this.pushHistory();b.children=b.children.filter(function(c){return c.id!==childId;});if(b.children.length===0)b.children=[{id:genId(),type:'paragraph',content:''}];this.render();this.dirty=true;this.autoSave();_rtSync();},
_toggleCalloutIcon(bid){const picker=document.getElementById('callout-picker-'+bid);if(picker)picker.style.display=picker.style.display==='none'?'flex':'none';},
_setCalloutIcon(bid,icon){const idx=this.getIdx(bid);if(idx>=0){this.pushHistory();this.blocks[idx].icon=icon;this.dirty=true;this.autoSave();this.render();_rtSync();}},
showFmt(idx){const s=window.getSelection();if(!s.rangeCount||s.isCollapsed){this.fmt.open=false;return;}const r=s.getRangeAt(0).getBoundingClientRect();this.fmt.open=true;this.fmt.idx=idx;this.fmt.top=Math.max(r.top-44,0);this.fmt.left=Math.min(r.left+r.width/2-120,window.innerWidth-260);this._sel={range:s.getRangeAt(0).cloneRange(),idx};},
_setCalloutIcon(bid,icon){const idx=this.getIdx(bid);if(idx>=0){this.pushHistory();this.blocks[idx].icon=icon;this.dirty=true;this.autoSave();this.render();_rtSync();}},
// ── v5.14.0: Synced blocks picker ──
async _pickSyncedBlock(idx){
const block=this.blocks[idx];if(!block)return;
try{
const r=await fetch('/api/synced-blocks');const d=await r.json();
const items=d.synced_blocks||[];
if(!items.length){this.showToast('No synced blocks yet. Create one first in Settings → Synced Blocks.');return;}
const names=items.map(function(s){return (s.title||'Untitled')+' (id:'+s.id+')';});
const choice=window.prompt('Choose a synced block:\n\n'+names.join('\n'));
if(!choice)return;
const match=choice.match(/id:(\d+)/);
if(!match)return;
const sid=parseInt(match[1]);
if(!sid){this.showToast('Invalid synced block','error');return;}
const sr=await fetch('/api/synced-blocks/'+sid);const sd=await sr.json();
if(!sd||!sd.content){this.showToast('Synced block not found','error');return;}
block.synced_id=sid;
block._synced_content=sd.content||[];
block._synced_title=sd.title||'';
this.render();this.dirty=true;this.autoSave();_rtSync();
}catch(e){this.showToast('Failed to load synced blocks','error');}
},
showFmt(idx){const s=window.getSelection();if(!s.rangeCount||s.isCollapsed){this.fmt.open=false;return;}const r=s.getRangeAt(0).getBoundingClientRect();this.fmt.open=true;this.fmt.idx=idx;this.fmt.top=Math.max(r.top-44,0);this.fmt.left=Math.min(r.left+r.width/2-120,window.innerWidth-260);this._sel={range:s.getRangeAt(0).cloneRange(),idx};},
fmtApply(f){this.fmt.open=false;if(!this._sel)return;const s=window.getSelection();s.removeAllRanges();s.addRange(this._sel.range);document.execCommand(f==='strikeThrough'?'strikeThrough':f);this._sel=null;this.dirty=true;this.autoSave();},
fmtLink(){this.fmt.open=false;const u=prompt('URL:');if(u){document.execCommand('createLink',false,u);this.dirty=true;this.autoSave();}},
// ── v4.9.0: Collaboration — comments & mentions ──
@@ -1787,53 +2106,115 @@ applyAIBlocks(text){
btn.style.left=x+'px';btn.style.top=y+'px';
},
hideCommentBtn(){const btn=document.getElementById('_commentSelBtn');if(btn)btn.style.display='none';},
// ── @mention autocomplete ──
mentionOpen:false,mentionQuery:'',mentionResults:[],mentionEl:null,
// ── @mention autocomplete (v4.9.0 users + v5.11.0 pages & dates) ──
mentionOpen:false,mentionQuery:'',mentionResults:[],mentionPages:[],mentionEl:null,
openMention(el){
const s=window.getSelection();if(!s.rangeCount)return;
const text=el.textContent||'';const pos=s.getRangeAt(0).startOffset;
const before=text.substring(0,pos);
const range=s.getRangeAt(0).cloneRange();
range.selectNodeContents(el);range.setEnd(s.getRangeAt(0).startContainer,s.getRangeAt(0).startOffset);
const before=range.toString();
const m=before.match(/@([\w\-\.]*)$/);
if(!m)return;
if(!m){return;}
this.mentionQuery=m[1];this.mentionEl=el;
const r=s.getRangeAt(0).getBoundingClientRect();
const menu=document.getElementById('_mentionMenu');
menu.style.display='block';
menu.style.top=Math.max(4,(r.bottom+4>window.innerHeight-280)?r.top-284:r.bottom+4)+'px';
menu.style.top=Math.max(4,(r.bottom+4>window.innerHeight-300)?r.top-304:r.bottom+4)+'px';
menu.style.left=Math.min(r.left,window.innerWidth-260)+'px';
this.mentionOpen=true;this.loadMentionUsers();
this.mentionOpen=true;this.loadMentionResults();
},
async loadMentionUsers(){
async loadMentionResults(){
const q=this.mentionQuery||'';
try{const r=await fetch('/api/notifications/users/search?q='+encodeURIComponent(q),{credentials:'same-origin'});const d=await r.json();this.mentionResults=d.users||[];this.renderMentionMenu();}catch(e){this.mentionResults=[];}
const self=this;
const [u,p]=await Promise.all([
fetch('/api/notifications/users/search?q='+encodeURIComponent(q),{credentials:'same-origin'}).then(r=>r.ok?r.json():{users:[]}).catch(()=>({users:[]})),
fetch('/board/api/wiki/pages?q='+encodeURIComponent(q),{credentials:'same-origin'}).then(r=>r.ok?r.json():{pages:[]}).catch(()=>({pages:[]}))
]);
if(!this.mentionOpen)return;
this.mentionResults=u.users||[];this.mentionPages=p.pages||[];
this.renderMentionMenu();
},
_dateSuggestions(q){
const out=[];const lq=(q||'').toLowerCase();
const iso=s=>s.toISOString().slice(0,10);
const now=new Date();
if(!lq||'today'.startsWith(lq))out.push({label:'Today',iso:iso(now)});
if(!lq||'tomorrow'.startsWith(lq))out.push({label:'Tomorrow',iso:iso(new Date(now.getTime()+864e5))});
if(!lq||'yesterday'.startsWith(lq))out.push({label:'Yesterday',iso:iso(new Date(now.getTime()-864e5))});
const m=lq.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if(m)out.push({label:'Set date',iso:m[1]+'-'+m[2].padStart(2,'0')+'-'+m[3].padStart(2,'0')});
return out;
},
renderMentionMenu(){
const menu=document.getElementById('_mentionMenu');if(!menu)return;
let h='';const self=this;
(this.mentionResults||[]).forEach(function(u,idx){
h+='<div class="mention-item" data-login="'+u.login+'" style="display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:13px;color:var(--text);cursor:pointer;border-radius:6px;">'
+'<div style="width:24px;height:24px;border-radius:50%;background:'+(u.avatar_color||'#3A3A3A')+';color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;">'
h+='<div class="mention-group-label">People</div>';
(this.mentionResults||[]).slice(0,6).forEach(function(u){
h+='<div class="mention-item" data-mkind="user" data-mval="'+esc(u.login)+'" style="display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:13px;color:var(--text);cursor:pointer;border-radius:6px;">'
+'<div style="width:24px;height:24px;border-radius:50%;background:'+(u.avatar_color||'#3A3A3A')+';color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;overflow:hidden;">'
+((u.avatar_url)?'<img src="'+u.avatar_url+'" style="width:24px;height:24px;border-radius:50%;object-fit:cover;">':((u.full_name||u.login||'?').charAt(0).toUpperCase()))
+'</div><div><div style="font-weight:500">'+u.login+'</div><div style="font-size:11px;color:var(--text-dim,#999)">'+(u.full_name||'')+'</div></div></div>';
+'</div><div><div style="font-weight:500">'+esc(u.login)+'</div><div style="font-size:11px;color:var(--text-dim,#999)">'+esc(u.full_name||'')+'</div></div></div>';
});
if(!h)h='<div style="padding:10px;color:var(--text-dim,#999);font-size:13px;">No users found</div>';
if((this.mentionPages||[]).length){
h+='<div class="mention-group-label">Pages</div>';
this.mentionPages.slice(0,6).forEach(function(p){
h+='<div class="mention-item" data-mkind="page" data-mval="'+p.id+'" style="display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:13px;color:var(--text);cursor:pointer;border-radius:6px;">'
+'<span style="width:24px;text-align:center;flex-shrink:0">'+esc(p.icon||'📄')+'</span>'
+'<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(p.title)+'</span></div>';
});
}
const ds=this._dateSuggestions(this.mentionQuery);
if(ds.length){
h+='<div class="mention-group-label">Date</div>';
ds.forEach(function(d){
h+='<div class="mention-item" data-mkind="date" data-mval="'+esc(d.iso)+'" style="display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:13px;color:var(--text);cursor:pointer;border-radius:6px;">'
+'<span style="width:24px;text-align:center;flex-shrink:0">📅</span>'
+'<span style="flex:1">'+esc(d.label)+'</span><span style="font-size:11px;color:var(--text-dim)">'+esc(d.iso)+'</span></div>';
});
}
if(this.mentionResults.length===0&&this.mentionPages.length===0&&ds.length===0)h='<div style="padding:10px;color:var(--text-dim,#999);font-size:13px;">No matches</div>';
menu.innerHTML=h;
menu.querySelectorAll('.mention-item').forEach(function(it){
it.addEventListener('click',function(){self.insertMention(it.getAttribute('data-login'));});
it.addEventListener('click',function(){
const kind=it.getAttribute('data-mkind');const val=it.getAttribute('data-mval');
if(kind==='user')self.insertMention(val);
else if(kind==='page')self.insertMentionToken('[[fdpage:'+val+']] ');
else if(kind==='date')self.insertMentionToken('[[fddate:'+val+']] ');
});
});
},
// Shared non-destructive insertion: replaces the typed "@query" right
// before the caret inside the token-reconstructed text (chips kept).
_replaceAtMention(replacement){
const el=this.mentionEl;if(!el)return null;
const idx=this.getIdx(el.dataset.bid);if(idx<0)return null;
const b=this.blocks[idx];
const text=gtTok(el)||'';
// robust: compute caret by walking with a marker
let caretPos=text.length;
try{
const MK='\uF000AT';const mark=document.createElement('span');mark.textContent=MK;
const live=s.getRangeAt(0).cloneRange();live.collapse(true);live.insertNode(mark);
const full=gtTok(el)||'';const at=full.indexOf(MK);
if(at>=0)caretPos=at;
mark.remove();el.normalize();
}catch(e){}
const before=text.slice(0,caretPos);
const m=before.match(/@([\w\-\.]*)$/);
if(!m)return null;
const start=caretPos-m[0].length;
const newText=text.slice(0,start)+replacement+text.slice(caretPos);
this._commitBlockText(b,newText);
return true;
},
insertMentionToken(token){
if(this._replaceAtMention(token)){this.closeMention();this.dirty=true;this.autoSave();}
},
insertMention(login){
const el=this.mentionEl;if(!el)return;
const s=window.getSelection();if(s.rangeCount){s.deleteFromDocument();}
const text=el.textContent||'';const pos=s.rangeCount? (()=>{const r=s.getRangeAt(0);return r.startOffset;} )():text.length;
const before=text.substring(0,pos);
const at=before.lastIndexOf('@');
const full=before.substring(0,at)+'@'+login+' ';
const after=text.substring(pos);
el.textContent=full+after;
const ns=window.getSelection();const nr=document.createRange();nr.selectNodeContents(el);nr.collapse(false);ns.removeAllRanges();ns.addRange(nr);
this.closeMention();this.dirty=true;this.autoSave();
this.notifyNewMentions(full);
if(this._replaceAtMention('@'+login+' ')){
this.closeMention();this.dirty=true;this.autoSave();
this.notifyNewMentions('@'+login);
}
},
closeMention(){const menu=document.getElementById('_mentionMenu');if(menu)menu.style.display='none';this.mentionOpen=false;this.mentionEl=null;},
notifyNewMentions(text){
@@ -1934,18 +2315,56 @@ applyAIBlocks(text){
setTimeout(()=>{const fe=this.getEl(focusId);if(fe){fe.focus();ce(fe);}},60);
},
autoSave(){if(this.fileData)return;clearTimeout(this.st);this.st=setTimeout(()=>this.save(),1500);},
// Reflect the page title in the left navigation, the header breadcrumb
// and the browser tab as soon as it changes.
_syncTitleUI(){
const title=(this.pageTitle||'').trim()||'Untitled';
document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=title;});
document.querySelectorAll(`.sidebar-item.ws-tree-item[data-id="${this.pid}"]`).forEach(item=>{item.setAttribute('data-name',title);const n=item.querySelector('.page-name');if(n)n.textContent=title;});
document.title='FlowDeck — '+title;
window.dispatchEvent(new CustomEvent('flowdeck:page-renamed',{detail:{id:this.pid,title:title}}));
if(window.parent&&window.parent!==window){try{window.parent.postMessage({type:'fd-page-renamed',id:this.pid,title:title},window.location.origin);}catch(e){}}
},
save(cb){
if(this.fileData)return;
if(this.fileData){
// File page: only the title is editable here. Sync the sidebar,
// header breadcrumb and browser tab live, and persist the title
// without rewriting the file's blocks.
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'';
this._syncTitleUI();
if(this._fileTitleT)clearTimeout(this._fileTitleT);
this._fileTitleT=setTimeout(()=>{
this._fileTitleT=null;
const title=(this.pageTitle||'').trim();
if(!title)return;
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
fetch(`/board/api/pages/${this.pid}?title=${encodeURIComponent(title)}`,{method:'PUT',headers:{'X-CSRF-Token':csrf?csrf[1]:''}})
.then(()=>{this.dirty=false;})
.catch(()=>{});
},600);
if(cb)cb();
return;
}
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'';
this._syncTitleUI();
if(this.saving){ if(cb) cb(); return; }
this.sync();
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'';
this.saving=true;
const blocksArr=this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;});
// v6.0.0 PWA: hors ligne (ou échec réseau) → file IndexedDB rejouée au retour du réseau
const queueOffline=()=>{
if(!window.FlowOffline){this.saving=false;return;}
window.FlowOffline.savePageOffline({id:this.pid,title:this.pageTitle,content:JSON.stringify(blocksArr),content_format:'blocks'})
.then(()=>{this.saving=false;this.dirty=false;this.lastSaved='offline';if(cb)cb();})
.catch(()=>{this.saving=false;});
};
if(!navigator.onLine){queueOffline();return;}
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:blocksArr})})
.then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();
document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=this.pageTitle||'New page';});
this._syncTitleUI();
if(cb)cb();
}).catch(()=>{this.saving=false;});
}).catch(()=>{this.saving=false;queueOffline();});
},
runAutomationButton(bid){
const idx=this.getIdx(bid);if(idx<0)return;
@@ -2052,4 +2471,6 @@ applyAIBlocks(text){
default: return c;
}
}
window.editorState = editorState;
}
</script>
+1 -1
View File
@@ -104,7 +104,7 @@
{% endblock %}
{% block scripts %}
<script>
<script data-cfasync="false">
function accountsData() {
return {
profile: { full_name: '', email: '' },
+15 -13
View File
@@ -204,7 +204,7 @@
</style>
{# ── Global Agent API (available before Alpine for the FAB / shortcut) ── #}
<script>
<script data-cfasync="false">
(function(){
var defaultAPI = {
open: function(){ document.dispatchEvent(new CustomEvent('fd-agent-toggle')); },
@@ -293,7 +293,7 @@
<div class="fd-steps" x-show="m.steps && m.steps.length" x-cloak>
<button class="fd-steps-head" type="button" @click="toggleSteps(m)">
<span class="fd-steps-chev" :class="{down: m.stepsOpen}">▶</span>
<span class="fd-steps-count" x-text="m.steps.length === 1 ? '1 étape' : m.steps.length + ' étapes'"></span>
<span class="fd-steps-count" x-text="!m.steps ? '' : (m.steps.length === 1 ? '1 étape' : m.steps.length + ' étapes')"></span>
<span class="fd-steps-elem" x-text="m.generating ? 'en cours…' : (m.stepsOpen ? 'réduire' : 'voir le déroulé')"></span>
</button>
<div class="fd-steps-body" x-show="m.stepsOpen" x-cloak>
@@ -373,18 +373,20 @@
<template x-if="!mentionLoad && !hasMentionItems">
<div class="fd-mention-empty">Aucun élément. Tapez un nom de document, page ou base.</div>
</template>
<template x-if="!mentionLoad" x-for="it in mentionItems" :key="it.token || it.key">
<div>
<div class="fd-mention-sep" x-show="it._sep" x-text="it._sep"></div>
<div class="fd-mention-item" x-show="!it._sep" :class="{focus: it._i===mentionFocus}"
@click="pickMention(it)" @mouseenter="setMentionFocus(it._i)">
<span class="fd-mention-ico" x-text="it.icon"></span>
<div class="fd-mention-main">
<div class="fd-mention-lbl" x-text="it.label"></div>
<div class="fd-mention-sub" x-text="it.sub"></div>
<template x-if="!mentionLoad">
<template x-for="it in mentionItems" :key="it.token || it.key">
<div>
<div class="fd-mention-sep" x-show="it._sep" x-text="it._sep"></div>
<div class="fd-mention-item" x-show="!it._sep" :class="{focus: it._i===mentionFocus}"
@click="pickMention(it)" @mouseenter="setMentionFocus(it._i)">
<span class="fd-mention-ico" x-text="it.icon"></span>
<div class="fd-mention-main">
<div class="fd-mention-lbl" x-text="it.label"></div>
<div class="fd-mention-sub" x-text="it.sub"></div>
</div>
</div>
</div>
</div>
</template>
</template>
</div>
</div>
@@ -454,7 +456,7 @@
</div>
</div>
<script>
<script data-cfasync="false">
(function(){
// ── Skills intégrés (workflows « / ») et commandes admin du panneau ──
var FD_ADMIN_CMDS = [
+340 -63
View File
@@ -5,7 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlowDeck — {% block title_prefix %}Home{% endblock %}</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="stylesheet" href="/static/css/app.css?v=5.0.0">
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#191919">
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="stylesheet" href="/static/css/app.css?v=5.1.1">
<link rel="stylesheet" href="/static/css/design-tokens.css?v=5.2.0">
<link rel="stylesheet" href="/static/css/components.css?v=5.2.0">
<link rel="stylesheet" href="/static/css/katex.min.css?v=0.16.11">
@@ -45,6 +50,15 @@
.page-title-block {
padding: 16px 8px;
}
.page-title-block::before {
display: none;
}
.page-title-block .page-icon-emoji {
margin-left: 0;
}
.page-title-block .page-title-input {
padding-left: 0;
}
.blocks-container {
padding: 0 4px;
}
@@ -99,9 +113,9 @@
body.embed-mode .page-editor-wrapper { padding: 8px 16px !important; max-width: 100% !important; }
body.embed-mode .page-cover-area { padding-top: 0 !important; }
</style>
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/htmx.min.js" data-cfasync="false"></script>
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
<script src="/static/js/sortable.min.js" defer></script>
<script src="/static/js/sortable.min.js" defer data-cfasync="false"></script>
</head>
<body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'{% if embed_mode %} class="embed-mode"{% endif %}>
<div class="app-layout" x-data="appState()">
@@ -109,9 +123,14 @@
<!-- ═══════════ MOBILE SIDEBAR OVERLAY ═══════════ -->
<div class="sidebar-overlay"
:class="{ visible: mobileSidebarOpen }"
@click="mobileSidebarOpen = false, sidebarCollapsed = true">
@click="mobileSidebarOpen = false">
</div>
<!-- ═══════════ SIDEBAR PEEK ZONE (collapsed, desktop) ═══════════ -->
<div class="sidebar-peek-zone"
x-show="sidebarCollapsed" x-cloak
@mouseenter="sidebarPeek = true"></div>
{# ─── Recursive tree macro ─── #}
{% macro render_tree_item(page, depth=0) %}
{% set indent_px = depth * 16 %}
@@ -133,7 +152,7 @@
{% else %}
<span class="tree-toggle-placeholder"></span>
{% endif %}
<span class="page-icon">{% if page.icon in ("folder","file","star","link","trash","book","home","settings","lock","globe","image","edit","calendar","users","user","search","tag","bar-chart","grid","list","align-left","zap") %}{{ fd_icon(page.icon,14) }}{% elif page.icon and page.icon|length <= 2 %}{{ fd_icon("file",14) }}{% else %}{{ fd_icon("folder",14) }}{% endif %}</span>
<span class="page-icon">{{ fd_page_icon(page.icon, 14) }}</span>
<span class="page-name">{{ page.name }}</span>
<span class="item-actions">
<button class="item-action-btn" @click.stop="newSubPage('{{ page.id }}')">+</button>
@@ -150,12 +169,14 @@
{% endif %}
{% endmacro %}
{% from '_icons.html' import fd_icon %}
{% from '_icons.html' import fd_icon, fd_page_icon %}
<!-- ═══════════ SIDEBAR Notion ═══════════ -->
<aside class="sidebar"
:class="{ collapsed: sidebarCollapsed, 'mobile-open': mobileSidebarOpen }"
:class="{ collapsed: sidebarCollapsed && !sidebarPeek, 'mobile-open': mobileSidebarOpen, peeking: sidebarPeek }"
id="sidebar"
@mouseleave="!sidebarResizing && (sidebarPeek = false)"
x-effect="document.documentElement.classList.toggle('fd-sidebar-collapsed', sidebarCollapsed && !sidebarPeek)"
x-init="$nextTick(() => { if(typeof initTreeSortable==='function') initTreeSortable(); })">
<!-- Header + dropdown wrapper -->
<div style="position:relative;">
@@ -171,8 +192,10 @@
{% endif %}
</div>
<span class="workspace-chevron" :class="{ open: workspaceMenuOpen }">▾</span>
<button class="sidebar-collapse-inline" @click.stop="sidebarCollapsed = !sidebarCollapsed" title="Collapse sidebar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="11 17 6 12 11 7"/><polyline points="18 17 13 12 18 7"/></svg>
<button class="sidebar-collapse-inline" @click.stop="toggleSidebar()"
:title="sidebarPeek ? 'Pin sidebar open' : 'Close sidebar'">
<svg x-show="!sidebarPeek" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="11 17 6 12 11 7"/><polyline points="18 17 13 12 18 7"/></svg>
<svg x-show="sidebarPeek" x-cloak width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="13 17 18 12 13 7"/><polyline points="6 17 11 12 6 7"/></svg>
</button>
</div>
<div class="user-menu-dropdown" x-show="workspaceMenuOpen" @click.outside="workspaceMenuOpen = false" x-transition>
@@ -202,7 +225,7 @@
<div class="um-sep"></div>
<div class="um-version">FlowDeck v{{ app_version }}</div>
<div class="um-sep"></div>
<a href="/auth/logout" class="um-item um-danger" @click="workspaceMenuOpen=false">{{ fd_icon("log-out", 16) }} Log out</a>
<a href="/auth/logout" class="um-item um-danger" hx-boost="false" @click="workspaceMenuOpen=false">{{ fd_icon("log-out", 16) }} Log out</a>
</div>
</div>
@@ -256,7 +279,7 @@
{% else %}
<li class="sidebar-item empty-hint"><span class="page-icon page-icon-svg">{{ fd_icon("folder",16) }}</span><span class="page-name text-dim">Open a workspace to see your files</span></li>
{% endif %}
<li class="sidebar-item" style="margin-top:4px;border-top:1px solid var(--border);padding-top:8px;" onclick="window.location='/workspaces'">
<li class="sidebar-item" style="margin-top:4px;border-top:1px solid var(--border);padding-top:8px;" onclick="window.fdNavigate ? window.fdNavigate('/workspaces') : window.location='/workspaces'">
<span class="page-icon page-icon-svg">{{ fd_icon("settings",16) }}</span><span class="page-name text-dim">Manage Workspaces</span>
</li>
</ul>
@@ -573,8 +596,29 @@
<!-- Customize sidebar overlay + panel (full sidebar takeover) -->
<div class="customize-overlay" x-show="customizing" @click="toggleCustomize()" x-cloak></div>
<!-- Right-edge resize handle: click to close, drag to resize -->
<div class="sidebar-resize-handle"
@mouseenter="sidebarResizeHover = true"
@mouseleave="sidebarResizeHover = false"
@pointerdown.prevent="startSidebarResize($event)"
title=""></div>
</aside>
<!-- Resize / close contextual tooltip -->
<div class="sidebar-resize-tip"
x-show="sidebarResizeHover && (!sidebarCollapsed || sidebarPeek)" x-cloak
:style="'left:' + (sidebarWidth + 12) + 'px'">
<div class="srt-row">
<span class="srt-key">Close</span>
<span class="srt-action">Click · <span class="srt-kbd">Ctrl+\</span></span>
</div>
<div class="srt-row">
<span class="srt-key">Resize</span>
<span class="srt-action">Drag ↔</span>
</div>
</div>
<!-- Uncollapse button → inside topbar, no overlap -->
<!-- (Moved into topbar block below) -->
@@ -589,6 +633,7 @@
<!-- Content -->
<div class="content-area" id="main-content">
{% block content %}{% endblock %}
{% block scripts %}{% endblock %}
</div>
</div>
@@ -653,6 +698,8 @@
</div>
</div>
{% include '_icon_picker.html' %}
<!-- ═══════════ WORKSPACE TREE CONTEXT MENU ═══════════ -->
<div class="context-menu" id="ws-context-menu"
:class="{ visible: wsCtx.visible }"
@@ -708,6 +755,11 @@
</template>
</div>
<!-- ============ PWA SYNC BADGE (v6.0.0) ============ -->
<div id="fd-sync-badge" class="fd-sync-badge" style="display:none" role="status" aria-live="polite">
<span class="fd-sync-spinner"></span><span>Synchronisation…</span>
</div>
</div>
<script data-cfasync="false">
@@ -774,6 +826,26 @@
return m ? m[1] : '';
},
/** Reflète le rename d'une page/dossier dans toute l'UI : sidebar gauche
(Récents, Privé, Favoris + arbre du workspace), breadcrumb du header
(event flowdeck:page-renamed) et frame parente (postMessage). */
syncPageTitle: function(pid, title) {
var t = (title || '').trim() || 'Untitled';
document.querySelectorAll('.sidebar-item[data-page-id="page/' + pid + '"]').forEach(function(item) {
var n = item.querySelector('.page-name');
if (n) n.textContent = t;
});
document.querySelectorAll('.sidebar-item.ws-tree-item[data-id="' + pid + '"]').forEach(function(item) {
item.setAttribute('data-name', t);
var n = item.querySelector('.page-name');
if (n) n.textContent = t;
});
window.dispatchEvent(new CustomEvent('flowdeck:page-renamed', { detail: { id: pid, title: t } }));
if (window.parent && window.parent !== window) {
try { window.parent.postMessage({ type: 'fd-page-renamed', id: pid, title: t }, window.location.origin); } catch (e) {}
}
},
/** Refresh CSRF token from the server. Returns a promise resolving to the new token. */
refreshCsrfToken: async function() {
try {
@@ -855,6 +927,15 @@
return { wsId: 0, isGitea: false, workspaceKey: wk };
},
/** Folder currently open on the local-workspace page (0 when at root). */
_currentFolderId: function() {
var ws = window._wsData;
if (ws && typeof ws.currentFolder === 'number' && ws.currentFolder > 0) {
return ws.currentFolder;
}
return 0;
},
/** Return a toast function that works across pages. */
_toast: function(msg, type) {
if (window.appState && window.appState.toast) window.appState.toast(msg, type);
@@ -862,47 +943,111 @@
},
/**
* Create a new page/file in the correct workspace context.
* - Local workspace active → workspace item (prompts for name)
* - Gitea workspace active → workspace item with workspace_id (prompts for name)
* - No workspace → general Notion page (no prompt, title "New page")
* Create a new page: opens the v5.12.0 template picker (built-in + the
* user's saved templates), then instantiates the chosen one.
*/
createPage: function() {
var ctx = this._getContext();
createPage: function(parentId) {
var self = this;
// ── Workspace context → create workspace item ──
if (ctx.wsId > 0) {
var body = { name: '', type: 'page' };
if (ctx.isGitea) body.workspace_id = ctx.wsId;
fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify(body)
})
.then(function(r) { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(function(data) {
window.location.href = '/pages/' + data.id;
// Explicit folder (row/context-menu button) wins; otherwise create in the
// folder the user is currently browsing on the local-workspace page.
if (parentId == null || parentId === '') parentId = this._currentFolderId();
this._createParentId = parentId || 0;
// Direct creation path (fallback / no-template choice).
this._createPlainPage = function() {
var ctx = self._getContext();
if (ctx.wsId > 0) {
var body = { name: '', type: 'page' };
if (ctx.isGitea) body.workspace_id = ctx.wsId;
if (self._createParentId) body.parent_id = self._createParentId;
fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': self.getCsrfToken() },
body: JSON.stringify(body)
})
.catch(function(err) { self._toast('Failed: ' + err.message, 'error'); });
return;
}
// ── No workspace → general Notion page ──
var project = ctx.workspaceKey;
var url = '/board/api/pages?section=Private&project=' + encodeURIComponent(project);
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-Token': this.getCsrfToken() }
})
.then(function(r) { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
.then(function(data) { window.location.href = '/pages/' + data.id; })
.catch(function(err) { self._toast('Failed to create page: ' + err.message, 'error'); });
.then(function(r) { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(function(data) { window.location.href = '/pages/' + data.id; })
.catch(function(err) { self._toast('Failed: ' + err.message, 'error'); });
return;
}
var project = ctx.workspaceKey;
var url = '/board/api/pages?section=Private&project=' + encodeURIComponent(project);
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-Token': self.getCsrfToken() }
})
.then(function(r) { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
.then(function(data) { window.location.href = '/pages/' + data.id; })
.catch(function(err) { self._toast('Failed to create page: ' + err.message, 'error'); });
};
this._openTemplatePicker();
},
/** Show the global New Folder modal. */
showCreateFolderModal: function() {
_openTemplatePicker: function() {
var self = this;
function escAttr(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
if (document.getElementById('fd-tpl-picker')) { document.getElementById('fd-tpl-picker').style.display = 'flex'; return; }
var ov = document.createElement('div');
ov.id = 'fd-tpl-picker';
ov.className = 'tpl-picker-overlay';
ov.innerHTML = '<div class="tpl-picker-modal"><div class="tpl-picker-title">New page from template</div>'
+ '<div class="tpl-picker-sub">Pick a starting point — or start blank.</div>'
+ '<div class="tpl-grid" id="fd-tpl-grid"><div style="color:var(--text-dim);font-size:13px;padding:12px">Loading…</div></div>'
+ '<div style="text-align:right;margin-top:14px"><button class="btn-sm" id="fd-tpl-close" style="cursor:pointer;padding:6px 14px">Cancel</button></div></div>';
document.body.appendChild(ov);
ov.addEventListener('click', function(e){ if(e.target===ov) ov.style.display='none'; });
document.getElementById('fd-tpl-close').onclick = function(){ ov.style.display='none'; };
fetch('/board/api/page-templates', {credentials:'same-origin'})
.then(function(r){ return r.json(); })
.then(function(d){
var list = d.templates || [];
var h = '';
list.forEach(function(t){
var use = t.builtin
? "key:"+t.key
: "id:"+t.id;
h += '<div class="tpl-card" data-use="'+escAttr(use)+'">'
+ '<span class="tpl-card-icon">'+escAttr(t.icon||'📄')+'</span>'
+ '<span><span class="tpl-card-name">'+escAttr(t.name)+'</span>'
+ '<div class="tpl-card-desc">'+escAttr(t.description||'')+'</div></span></div>';
});
document.getElementById('fd-tpl-grid').innerHTML = h || '<div style="color:var(--text-dim)">No templates</div>';
document.querySelectorAll('#fd-tpl-grid .tpl-card').forEach(function(card){
card.onclick = function(){ self._useTemplate(card.dataset.use); };
});
}).catch(function(){ document.getElementById('fd-tpl-grid').innerHTML='<div style="color:var(--text-dim)">Failed to load templates</div>'; });
},
_useTemplate: function(use){
var ov = document.getElementById('fd-tpl-picker');
if (ov) ov.style.display = 'none';
var self = this;
if (!use || use === 'key:empty') { this._createPlainPage(); return; }
var parts = use.split(':');
var body = {};
var url;
// Pass the current workspace context so the created page is persisted
// in the active local/Gitea workspace (mirrors _createPlainPage()).
var ctx = self._getContext();
if (ctx.wsId > 0) { body.workspace_id = ctx.wsId; }
else if (ctx.workspaceKey) { body.workspace = ctx.workspaceKey; }
if (self._createParentId) { body.parent_id = self._createParentId; }
if (parts[0] === 'key') { body.key = parts.slice(1).join(':'); url = '/board/api/page-templates/0/use'; }
else { url = '/board/api/page-templates/' + parts[1] + '/use'; }
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify(body)
})
.then(function(r){ if (!r.ok) throw new Error('Template failed'); return r.json(); })
.then(function(d){ window.location.href = '/pages/' + d.id; })
.catch(function(){ self._createPlainPage(); });
},
/** Show the global New Folder modal (parentId = target folder, optional). */
showCreateFolderModal: function(parentId) {
if (parentId == null || parentId === '') parentId = this._currentFolderId();
this._folderParentId = parentId || 0;
var modal = document.getElementById('fd-global-folder-modal');
if (!modal) return;
modal.style.display = 'flex';
@@ -921,6 +1066,8 @@
var ctx = this._getContext();
var body = { name: name, type: 'folder' };
if (ctx.wsId > 0 && ctx.isGitea) body.workspace_id = ctx.wsId;
if (this._folderParentId) body.parent_id = this._folderParentId;
this._folderParentId = 0;
var self = this;
fetch('/api/local-workspace/items', {
@@ -951,6 +1098,8 @@
window.appState = this;
this.loadSidebarConfig();
this.loadAgents();
this.initSidebarWidth();
document.addEventListener('fd-toggle-sidebar', () => this.toggleSidebar());
},
agentList: [],
loadAgents() {
@@ -968,7 +1117,14 @@
else { document.dispatchEvent(new CustomEvent('fd-agent-toggle')); }
},
sidebarCollapsed: false,
sidebarPeek: false,
mobileSidebarOpen: false,
sidebarWidth: 240,
sidebarResizing: false,
sidebarResizeHover: false,
sidebarResizeMoved: false,
sidebarResizeStartX: 0,
sidebarResizeStartW: 0,
showInvite: true,
// -- Toast notifications --
toasts: [],
@@ -1056,6 +1212,7 @@
body: JSON.stringify({ name: newName.trim() })
}).then(function(r) {
if (r.ok) {
if (window.FlowDeck && window.FlowDeck.syncPageTitle) window.FlowDeck.syncPageTitle(id, newName.trim());
self.refreshSidebarTree();
document.dispatchEvent(new CustomEvent('flowdeck:workspace-changed'));
if (window._wsData && window._wsData._reloadAfterAction) {
@@ -1090,10 +1247,7 @@
} else {
favList.innerHTML = items.map(function(item) {
var iconName = item.icon || 'file';
// Map known icon names, anything else (including emojis) → file
var validNames = ['folder','file','star','link','trash','book','home','settings','lock','globe','image','edit','calendar','users','user','search','tag'];
if (validNames.indexOf(iconName) === -1) iconName = 'file';
var icon = '<span class="page-icon-svg">'+getSvgIcon(iconName,14)+'</span>';
var icon = '<span class="page-icon-svg">' + (window.fdIconHtml ? window.fdIconHtml(iconName, 14) : getSvgIcon('file',14)) + '</span>';
const name = (item.title || 'Untitled');
const safeName = name.replace(/'/g, "\\'").replace(/"/g, '&quot;');
const url = item.url || ('/pages/' + item.id);
@@ -1247,11 +1401,6 @@
toggleWorkspaceMenu() {
this.workspaceMenuOpen = !this.workspaceMenuOpen;
},
navigateTo(url) {
this.mobileSidebarOpen = false;
this.sidebarCollapsed = true;
window.location.href = url;
},
openQuickFind() { if (window.FlowDeckPalette) window.FlowDeckPalette.open(); },
// ── Tree state ──
@@ -1320,12 +1469,76 @@
},
navigateTo(url) {
this.mobileSidebarOpen = false;
this.sidebarPeek = false;
// Preserve workspace context when navigating to library/trash
if ((url === '/library' || url === '/trash') && this.workspaceKey && this.workspaceKey.includes('/')) {
const parts = this.workspaceKey.split('/');
url = `${url}?owner=${parts[0]}&repo=${parts.slice(1).join('/')}`;
}
window.location.href = url;
if (window.fdNavigate) window.fdNavigate(url);
else window.location.href = url;
},
toggleSidebar() {
this.sidebarPeek = false;
this.sidebarCollapsed = !this.sidebarCollapsed;
},
closeSidebar() {
if (this.sidebarPeek) { this.sidebarPeek = false; return; }
this.sidebarCollapsed = true;
},
// ── Sidebar width: persisted, applies to fixed + floating ──
initSidebarWidth() {
var saved = parseInt(localStorage.getItem('fd.sidebarWidth') || '', 10);
if (!isNaN(saved) && saved >= 200 && saved <= 560) {
this.sidebarWidth = saved;
this.applySidebarWidth();
} else {
var el = document.getElementById('sidebar');
if (el) {
var w = el.getBoundingClientRect().width;
if (w > 0) { this.sidebarWidth = Math.round(w); }
}
}
},
applySidebarWidth() {
document.documentElement.style.setProperty('--sidebar-width', this.sidebarWidth + 'px');
},
startSidebarResize(e) {
e.preventDefault();
var self = this;
this.sidebarResizing = true;
this.sidebarResizeMoved = false;
this.sidebarResizeStartX = e.clientX;
var el = document.getElementById('sidebar');
this.sidebarResizeStartW = el ? el.getBoundingClientRect().width : this.sidebarWidth;
document.body.classList.add('fd-sidebar-resizing');
function onMove(ev) {
var dx = ev.clientX - self.sidebarResizeStartX;
if (Math.abs(dx) > 3) { self.sidebarResizeMoved = true; }
var w = Math.max(200, Math.min(560, self.sidebarResizeStartW + dx));
self.sidebarWidth = w;
self.applySidebarWidth();
}
function onUp() {
document.removeEventListener('pointermove', onMove);
document.removeEventListener('pointerup', onUp);
document.removeEventListener('pointercancel', onUp);
document.body.classList.remove('fd-sidebar-resizing');
self.sidebarResizing = false;
if (self.sidebarResizeMoved) {
try { localStorage.setItem('fd.sidebarWidth', String(Math.round(self.sidebarWidth))); } catch (err) {}
} else {
self.closeSidebar();
}
}
document.addEventListener('pointermove', onMove);
document.addEventListener('pointerup', onUp);
document.addEventListener('pointercancel', onUp);
},
showContextMenu(event, pageId, pageName) {
@@ -1661,7 +1874,8 @@
.catch(err => { this.toast('Failed: ' + err.message, 'error'); });
},
openWorkspacePage(pageId) {
window.location = `/pages/${pageId}`;
if (window.fdNavigate) window.fdNavigate('/pages/' + pageId);
else window.location = `/pages/${pageId}`;
},
// ── Tree expand/collapse + folder navigation ──
@@ -1681,7 +1895,8 @@
localStorage.setItem('fd_expanded_folders', JSON.stringify(this.expandedFolders));
localStorage.setItem('fd_sections', JSON.stringify(this.sectionsOpen));
} catch(e) {}
window.location = `/local-workspace?folder=${id}`;
if (window.fdNavigate) window.fdNavigate(`/local-workspace?folder=${id}`);
else window.location = `/local-workspace?folder=${id}`;
},
createEmptyPage() {
@@ -1833,8 +2048,7 @@
}
});
</script>
<script src="/static/js/app.js?v=2.4.6" defer></script>
{% block scripts %}{% endblock %}
<script src="/static/js/app.js?v=2.4.8" defer data-cfasync="false"></script>
<style>
.flowdeck-modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:2000;display:flex;align-items:center;justify-content:center;}
@@ -2022,11 +2236,24 @@
input.blur();
}
function toggle(){ state.open ? close() : open(); }
function setIndex(i){ if(i>=0 && i<state.items.length){ state.index=i; render(); } }
function setIndex(i){
if(i>=0 && i<state.items.length) state.index=i;
// Toggle the active class without rebuilding #fd-cp-list: re-rendering on
// hover destroys the element under the cursor and breaks the next click.
var nodes = list.querySelectorAll('.cmd-palette-item');
for(var j=0;j<nodes.length;j++){
if(parseInt(nodes[j].getAttribute('data-idx'),10)===state.index){
nodes[j].classList.add('active');
nodes[j].scrollIntoView({block:'nearest'});
} else {
nodes[j].classList.remove('active');
}
}
}
function choose(i){
var it = state.items[i]; if(!it) return;
if(it.run){ var ok = it.run(); if(ok) close(); return; }
if(it.url){ window.location.href=it.url; close(); return; }
if(it.url){ if(window.fdNavigate) window.fdNavigate(it.url); else window.location.href=it.url; close(); return; }
}
function onKey(e){
@@ -2054,5 +2281,55 @@
})();
</script>
{# ─── PWA: sync indicators (v6.0.0) ─── #}
<style>
.fd-sync-badge{position:fixed;top:10px;right:14px;z-index:600;display:flex;align-items:center;gap:8px;
background:var(--bg-secondary,#262626);border:1px solid var(--border,rgba(255,255,255,.1));
color:var(--text-secondary,#b8b8b8);border-radius:20px;padding:5px 12px;font-size:12px;
box-shadow:0 4px 16px rgba(0,0,0,.35);}
.fd-sync-spinner{width:12px;height:12px;border:2px solid rgba(255,255,255,.25);
border-top-color:var(--accent,#2383E2);border-radius:50%;display:inline-block;
animation:fd-spin .8s linear infinite;}
@keyframes fd-spin{to{transform:rotate(360deg)}}
.sidebar-item.fd-dirty .page-name::after{content:'⟳';margin-left:6px;font-size:11px;
color:var(--accent,#2383E2);opacity:.85;}
</style>
{# ─── PWA: offline client module + service worker registration (v6.0.0) ─── #}
<script src="/static/js/offline.js?v=6.0.0" defer data-cfasync="false"></script>
<script data-cfasync="false">
(function() {
if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js').then(function(reg) {
if ('sync' in reg && reg.sync) {
reg.sync.register('sync-flowdeck').catch(function() {});
}
}).catch(function() {});
});
})();
</script>
{# ─── PWA: sync badge + toasts wiring (v6.0.0) ─── #}
<script data-cfasync="false">
document.addEventListener('DOMContentLoaded', function() {
if (!window.FlowOffline) return;
window.FlowOffline.onChange(function(s) {
var badge = document.getElementById('fd-sync-badge');
if (badge) badge.style.display = s.isSyncing ? 'flex' : 'none';
});
window.addEventListener('flowdeck:sync-done', function(ev) {
var d = ev.detail || {};
if (d.failed) {
if (window.showToast) window.showToast(d.failed + ' modification(s) non synchronisée(s)', 'error');
} else if (d.conflicts) {
if (window.showToast) window.showToast('Synchronisé — ' + d.conflicts + ' conflit(s) résolu(s)', 'info');
} else if (d.synced) {
if (window.showToast) window.showToast('Modifications synchronisées', 'success');
}
});
});
</script>
</body>
</html>
+4 -4
View File
@@ -151,10 +151,10 @@
{% endblock %}
{% block scripts %}
<script>
const owner = '{{ owner }}';
const repo = '{{ repo }}';
const initialView = '{{ initial_view }}';
<script data-cfasync="false">
var owner = '{{ owner }}';
var repo = '{{ repo }}';
var initialView = '{{ initial_view }}';
// Auto-switch to view from URL param
document.addEventListener('DOMContentLoaded', function() {
+1 -1
View File
@@ -35,7 +35,7 @@
.gw-content pre[class*="language-"]{background:var(--bg-tertiary)!important;border:1px solid var(--border)!important;border-radius:8px!important;padding:16px!important;}
</style>
<script>
<script data-cfasync="false">
document.addEventListener('alpine:init', () => {
Alpine.data('giteaWorkspace', () => {
const params = new URLSearchParams(window.location.search);
+373
View File
@@ -0,0 +1,373 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Importer — FlowDeck</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<script src="/static/js/alpine.min.js" defer data-cfasync="false"></script>
<style>
:root{--bg:#191919;--bg2:#1F1F1F;--bg3:#2D2D2D;--border:#333;--text:#fff;--dim:#9B9A97;--accent:#2383E2;--accent-h:#1a6bc0;--success:#0F7B6C;--danger:#E03E3E;--warn:#D9730D;}
*{margin:0;padding:0;box-sizing:border-box;}
body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;padding:32px 20px;}
.wrap{width:860px;max-width:96vw;margin:0 auto;}
.brand{font-size:15px;font-weight:700;margin-bottom:22px;display:flex;align-items:center;gap:8px;color:var(--dim);}
.brand a{color:var(--dim);text-decoration:none;}
.brand a:hover{color:var(--text);}
h1{font-size:24px;font-weight:700;margin-bottom:6px;}
.sub{color:var(--dim);font-size:14px;margin-bottom:24px;line-height:1.5;}
.panel{background:var(--bg2);border:1px solid var(--border);border-radius:14px;padding:22px;margin-bottom:18px;}
.label{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:var(--dim);margin-bottom:10px;}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px;}
.src{background:var(--bg3);border:1px solid var(--border);border-radius:10px;padding:12px;cursor:pointer;transition:border-color .15s;}
.src:hover{border-color:var(--accent);}
.src.selected{border-color:var(--accent);box-shadow:0 0 0 2px rgba(35,131,226,.25);}
.src .t{font-size:13px;font-weight:600;}
.src .d{font-size:11px;color:var(--dim);margin-top:3px;line-height:1.35;}
.drop{border:2px dashed var(--border);border-radius:12px;padding:26px;text-align:center;color:var(--dim);cursor:pointer;transition:border-color .15s,background .15s;}
.drop:hover,.drop.over{border-color:var(--accent);background:rgba(35,131,226,.06);color:var(--text);}
.drop strong{color:var(--text);}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:10px 18px;border:none;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;transition:background .15s;}
.btn-primary{background:var(--accent);color:#fff;}
.btn-primary:hover{background:var(--accent-h);}
.btn-ghost{background:transparent;color:var(--dim);border:1px solid var(--border);}
.btn-ghost:hover{color:var(--text);}
.btn:disabled{opacity:.5;cursor:not-allowed;}
.actions{display:flex;gap:10px;align-items:center;margin-top:16px;flex-wrap:wrap;}
.check{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--dim);margin-top:12px;cursor:pointer;}
.bar{height:6px;background:var(--bg3);border-radius:3px;overflow:hidden;margin-top:16px;}
.bar>i{display:block;height:100%;background:var(--accent);width:0;transition:width .3s;}
table{width:100%;border-collapse:collapse;font-size:13px;}
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--border);}
th{color:var(--dim);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.4px;}
.pill{font-size:11px;font-weight:600;padding:2px 8px;border-radius:99px;background:var(--bg3);color:var(--dim);}
.pill.coll{background:rgba(35,131,226,.16);color:#79b8ff;}
.pill.ok{background:rgba(15,123,108,.2);color:#4fd1c5;}
.pill.err{background:rgba(224,62,62,.18);color:#f28b82;}
.pill.run{background:rgba(217,115,13,.18);color:#e3a15b;}
select,input[type=text]{background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:8px 10px;font-size:13px;}
.warnings{font-size:12px;color:var(--warn);margin-top:10px;line-height:1.5;}
.report{display:grid;grid-template-columns:repeat(auto-fit,minmax(110px,1fr));gap:10px;}
.stat{background:var(--bg3);border-radius:10px;padding:12px;text-align:center;}
.stat .n{font-size:22px;font-weight:700;}
.stat .l{font-size:11px;color:var(--dim);margin-top:2px;}
.error{color:var(--danger);font-size:13px;margin-top:10px;}
.x-cloak,[x-cloak]{display:none!important;}
</style>
</head>
<body x-data="importWizard()" x-init="init()">
<div class="wrap">
<div class="brand">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#2383E2" stroke-width="2"><path d="M13 2L3 14h7l-1 8 10-12h-7l1-8z"/></svg>
<a href="/">FlowDeck</a> · Import
</div>
<h1>Importer des données</h1>
<p class="sub">Notes, vaults, bases de données, documents ou signets — choisissez une source ou laissez la détection automatique.</p>
<div class="panel">
<div class="label">1 · Source</div>
<div class="grid">
<div class="src" :class="{selected: sourceId===''}" @click="sourceId=''">
<div class="t">✨ Détection auto</div>
<div class="d">Reconnaît le format d'après le fichier.</div>
</div>
<template x-for="s in sources" :key="s.source_id">
<div class="src" :class="{selected: sourceId===s.source_id}" @click="sourceId=s.source_id">
<div class="t" x-text="s.label"></div>
<div class="d" x-text="s.description"></div>
</div>
</template>
</div>
</div>
<div class="panel">
<div class="label">2 · Fichiers <span style="text-transform:none;font-weight:400">(sélection multiple possible)</span></div>
<div class="drop" :class="{over: dragOver}"
@click="$refs.input.click()"
@dragover.prevent="dragOver=true" @dragleave.prevent="dragOver=false"
@drop.prevent="onDrop($event)">
<span>Glissez un ou plusieurs fichiers ici ou <strong>cliquez pour parcourir</strong><br><small>Markdown, .zip, .csv, .tsv, .xlsx, .json, .html, .docx, .pdf, .ics, .opml…</small></span>
</div>
<input x-ref="input" type="file" multiple style="display:none"
accept=".md,.markdown,.txt,.zip,.csv,.tsv,.xlsx,.xlsm,.json,.html,.htm,.docx,.docm,.pdf,.ics,.ical,.opml"
@change="onFiles($event)">
<template x-if="files.length">
<table style="margin-top:12px;">
<thead><tr><th>Fichier</th><th>Taille</th><th>Statut</th><th></th></tr></thead>
<tbody>
<template x-for="(f,i) in files" :key="i">
<tr>
<td x-text="f.name"></td>
<td x-text="humanSize(f.size)"></td>
<td><span class="pill" :class="f.status==='done'?'ok':(f.status==='error'?'err':(f.status==='running'?'run':''))" x-text="f.status"></span></td>
<td><button class="btn btn-ghost" style="padding:2px 8px" @click="files.splice(i,1)" :disabled="busy">✕</button></td>
</tr>
</template>
</tbody>
</table>
</template>
<div style="display:flex;gap:16px;align-items:center;flex-wrap:wrap;margin-top:12px;">
<label class="check" style="margin:0">Mode de ré-import :
<select x-model="mode">
<option value="skip">Ignorer l'existant</option>
<option value="update">Mettre à jour (re-sync)</option>
<option value="duplicate">Toujours dupliquer</option>
</select>
</label>
</div>
<div class="actions">
<button class="btn btn-ghost" @click="doPreview()" :disabled="!files.length || busy">Aperçu (dry-run)</button>
<button class="btn btn-primary" @click="doImport()" :disabled="!files.length || busy">Importer</button>
</div>
<div class="bar" x-show="busy || progress>0"><i :style="'width:'+progress+'%'"></i></div>
<div class="error" x-show="error" x-text="error"></div>
</div>
<div class="panel">
<div class="label">Ou · importer une page web (URL)</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<input type="text" style="flex:1;min-width:260px;" x-model="urlValue" placeholder="https://exemple.com/article" @keydown.enter="doUrl()">
<button class="btn btn-ghost" @click="doUrl()" :disabled="!urlValue || busy">Importer l'URL</button>
</div>
<div class="error" x-show="urlError" x-text="urlError"></div>
</div>
<div class="panel">
<div class="label">Ou · importer depuis une forge</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<select x-model="forgeProvider">
<option value="gitea">Gitea</option>
<option value="github">GitHub</option>
</select>
<input type="text" style="width:150px;" x-model="forgeOwner" placeholder="owner">
<input type="text" style="width:150px;" x-model="forgeRepo" placeholder="repo">
<select x-model="forgeState">
<option value="all">Toutes</option>
<option value="open">Ouvertes</option>
<option value="closed">Fermées</option>
</select>
<button class="btn btn-ghost" @click="doForge('issues')" :disabled="!forgeOwner || !forgeRepo || busy">Importer les issues</button>
<button class="btn btn-ghost" @click="doForge('repo')" :disabled="!forgeOwner || !forgeRepo || busy">Importer les fichiers</button>
</div>
<div class="error" x-show="forgeError" x-text="forgeError"></div>
</div>
<div class="panel" x-show="preview" x-cloak>
<div class="label">Aperçu — <span x-text="preview && preview.source_label"></span></div>
<table>
<thead><tr><th>Titre</th><th>Type</th><th>Éléments</th><th>Colonnes</th></tr></thead>
<tbody>
<template x-for="(p,i) in (preview ? preview.pages : [])" :key="i">
<tr>
<td x-text="p.title"></td>
<td><span class="pill" :class="{coll: p.type==='collection'}" x-text="p.type==='collection' ? 'database' : 'page'"></span></td>
<td x-text="p.type==='collection' ? (p.rows+' lignes') : (p.blocks+' blocs')"></td>
<td>
<template x-for="col in (p.schema||[])" :key="col.name">
<span style="display:inline-flex;align-items:center;gap:4px;margin:2px 6px 2px 0;">
<span x-text="col.name" style="font-size:12px;"></span>
<select @change="mapping[col.name]=$event.target.value">
<template x-for="t in types" :key="t">
<option :value="t" :selected="t===col.type" x-text="t"></option>
</template>
</select>
</span>
</template>
</td>
</tr>
</template>
</tbody>
</table>
<div class="warnings" x-show="preview && preview.warnings && preview.warnings.length">
<template x-for="(w,i) in (preview ? preview.warnings : [])" :key="i"><div x-text="'⚠ '+w"></div></template>
</div>
</div>
<div class="panel" x-show="report" x-cloak>
<div class="label">Rapport d'import</div>
<div class="report">
<div class="stat"><div class="n" x-text="report ? report.pages_created : 0"></div><div class="l">Pages créées</div></div>
<div class="stat"><div class="n" x-text="report ? report.pages_updated : 0"></div><div class="l">Mises à jour</div></div>
<div class="stat"><div class="n" x-text="report ? report.collections_created : 0"></div><div class="l">Collections</div></div>
<div class="stat"><div class="n" x-text="report ? report.rows_created : 0"></div><div class="l">Lignes</div></div>
<div class="stat"><div class="n" x-text="report ? report.attachments : 0"></div><div class="l">Fichiers</div></div>
<div class="stat"><div class="n" x-text="report ? report.skipped : 0"></div><div class="l">Ignorés</div></div>
<div class="stat"><div class="n" x-text="report ? (report.errors ? report.errors.length : 0) : 0"></div><div class="l">Erreurs</div></div>
</div>
<div class="warnings" x-show="report && report.warnings && report.warnings.length">
<template x-for="(w,i) in (report ? report.warnings : [])" :key="i"><div x-text="'⚠ '+w"></div></template>
</div>
<div class="warnings" x-show="report && report.errors && report.errors.length">
<template x-for="(e,i) in (report ? report.errors : [])" :key="i"><div x-text="'✕ '+e.title+' : '+e.error"></div></template>
</div>
<div class="warnings" x-show="report && report.relations && report.relations.relations_resolved">
<div x-text="'🔗 '+report.relations.relations_resolved+' relation(s) reconstruite(s)'"></div>
</div>
<div class="actions">
<button class="btn btn-ghost" @click="downloadReport()">Télécharger le rapport (JSON)</button>
<a class="btn btn-primary" href="/workspaces">Retour au workspace</a>
</div>
</div>
</div>
<script data-cfasync="false">
function importWizard() {
return {
sources: [],
sourceId: '',
files: [],
dragOver: false,
mode: 'skip',
busy: false,
progress: 0,
error: '',
preview: null,
report: null,
mapping: {},
csrf: '',
urlValue: '',
urlError: '',
forgeProvider: 'gitea',
forgeOwner: '',
forgeRepo: '',
forgeState: 'all',
forgeError: '',
types: ['text','number','date','checkbox','email','url','phone','select','multi_select','status'],
async init() {
try {
const r = await fetch('/api/import/sources');
this.sources = (await r.json()).sources || [];
} catch(e) {}
try {
const c = await fetch('/api/csrf-token');
this.csrf = (await c.json()).csrf_token;
} catch(e) {}
},
humanSize(n) { return n > 1048576 ? (n/1048576).toFixed(1)+' Mo' : Math.max(1, Math.round(n/1024))+' Ko'; },
addFiles(list) {
for (const f of list) this.files.push({file:f, name:f.name, size:f.size, status:'queued'});
this.preview = null; this.report = null; this.error = '';
},
onFiles(e) { this.addFiles(e.target.files); e.target.value=''; },
onDrop(e) { this.dragOver=false; this.addFiles(e.dataTransfer.files); },
buildForm(f) {
const fd = new FormData();
fd.append('file', f.file);
if (this.sourceId) fd.append('source', this.sourceId);
fd.append('mode', this.mode);
return fd;
},
async doPreview() {
this.busy = true; this.error=''; this.report=null; this.progress=10;
try {
let merged = null;
for (let i=0;i<this.files.length;i++) {
const r = await fetch('/api/import/preview', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:this.buildForm(this.files[i])});
const d = await r.json();
if (!r.ok) { this.error = (this.files[i].name+': '+(d.detail||'Erreur')); continue; }
if (!merged) merged = {source_label:d.source_label, pages:[], warnings:[], stats:{}};
merged.pages.push(...(d.pages||[]));
merged.warnings.push(...(d.warnings||[]));
this.progress = Math.round(((i+1)/this.files.length)*100);
}
this.preview = merged;
this.mapping = {};
(merged ? merged.pages : []).forEach(p => (p.schema||[]).forEach(c => { this.mapping[c.name]=c.type; }));
} catch(e) { this.error = 'Erreur réseau'; }
finally { this.busy = false; setTimeout(()=>{this.progress=0;},800); }
},
async importOne(f) {
f.status = 'running';
const fd = this.buildForm(f);
if (Object.keys(this.mapping).length) fd.append('mapping', JSON.stringify(this.mapping));
const big = f.size > 5*1024*1024;
try {
if (big) {
fd.append('async','true');
const r = await fetch('/api/import/run', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:fd});
const d = await r.json();
if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; }
return await this.pollJob(d.job_id, f);
}
const r = await fetch('/api/import/run', {method:'POST', headers:{'X-CSRF-Token':this.csrf}, body:fd});
const d = await r.json();
if (!r.ok) { f.status='error'; f.error=d.detail||'Erreur'; return null; }
f.status = (d.status==='partial' ? 'partial' : 'done');
return d;
} catch(e) { f.status='error'; f.error='Erreur réseau'; return null; }
},
async doImport() {
this.busy = true; this.error=''; this.report=null; this.progress=0;
const aggregate = {pages_created:0,pages_updated:0,collections_created:0,rows_created:0,
attachments:0,skipped:0,warnings:[],errors:[],per_file:[]};
for (let i=0;i<this.files.length;i++) {
const d = await this.importOne(this.files[i]);
if (d) {
for (const k of ['pages_created','pages_updated','collections_created','rows_created','attachments','skipped'])
aggregate[k] += (d[k]||0);
aggregate.warnings.push(...(d.warnings||[]));
aggregate.errors.push(...(d.errors||[]));
aggregate.per_file.push({filename:this.files[i].name, report:d});
} else {
aggregate.errors.push({title:this.files[i].name, error:this.files[i].error||'Erreur'});
aggregate.per_file.push({filename:this.files[i].name, report:{status:'error'}});
}
this.progress = Math.round(((i+1)/this.files.length)*100);
}
this.report = aggregate;
this.busy = false;
},
async doUrl() {
this.busy = true; this.urlError = ''; this.report = null; this.progress = 40;
try {
const r = await fetch('/api/import/url', {
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
body: JSON.stringify({url:this.urlValue.trim()})
});
const d = await r.json();
if (!r.ok) { this.urlError = d.detail || 'Erreur'; return; }
this.report = d; this.progress = 100;
} catch(e) { this.urlError = 'Erreur réseau'; }
finally { this.busy = false; }
},
async doForge(kind) {
this.busy = true; this.forgeError = ''; this.report = null; this.progress = 30;
const endpoint = kind === 'repo' ? '/api/import/forge-repo' : '/api/import/forge';
try {
const r = await fetch(endpoint, {
method:'POST', headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrf},
body: JSON.stringify({
provider:this.forgeProvider, owner:this.forgeOwner.trim(),
repo:this.forgeRepo.trim(), state:this.forgeState
})
});
const d = await r.json();
if (!r.ok) { this.forgeError = d.detail || 'Erreur'; return; }
this.report = d; this.progress = 100;
} catch(e) { this.forgeError = 'Erreur réseau'; }
finally { this.busy = false; }
},
async pollJob(jobId, f) {
for (let i=0;i<600;i++) {
await new Promise(res => setTimeout(res, 700));
const r = await fetch('/api/import/jobs/'+jobId);
const j = await r.json();
if (j.status === 'done') { if (f) f.status='done'; return j.report; }
if (j.status === 'error') { if (f) { f.status='error'; f.error=j.error; } return null; }
}
if (f) { f.status='error'; f.error='Délai dépassé'; }
return null;
},
downloadReport() {
const blob = new Blob([JSON.stringify(this.report, null, 2)], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'flowdeck-import-report.json';
a.click();
URL.revokeObjectURL(a.href);
}
};
}
</script>
</body>
</html>
+14
View File
@@ -6,6 +6,11 @@
{% from '_icons.html' import fd_icon %}
<title>FlowDeck — All-in-one workspace</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#191919">
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<style>
:root {
--bg: #191919;
@@ -201,5 +206,14 @@
FlowDeck v4.0.1 — Open source · Self-hosted · Notion-compatible
</footer>
<script data-cfasync="false">
(function() {
if (!('serviceWorker' in navigator)) return;
window.addEventListener('load', function() {
navigator.serviceWorker.register('/sw.js').catch(function() {});
});
})();
</script>
</body>
</html>
+48 -10
View File
@@ -446,7 +446,7 @@
</div>
</div>
<div class="peek-body" style="flex:1;display:flex;flex-direction:column;">
<iframe id="lib-peek-iframe" src="" style="width:100%;height:100%;border:none;flex:1;"></iframe>
<iframe id="lib-peek-iframe" :src="peekItem ? '/pages/' + peekItem.id + '?embed=1' : ''" style="width:100%;height:100%;border:none;flex:1;"></iframe>
</div>
</div>
</template>
@@ -619,8 +619,8 @@ function libraryPage() {
_escAttr(s) { return String(s||'').replace(/&/g,'&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>';
// Custom emoji (image URL), icon name, or unicode emoji
if (item.page_icon) return '<span style="font-size:14px;line-height:1;display:inline-flex;align-items:center;">' + (window.fdIconHtml ? window.fdIconHtml(item.page_icon, 14) : this._escHtml(item.page_icon)) + '</span>';
if (item.is_folder || item.has_children) return getSvgIcon('folder', 14);
if (item.content_format && item.content_format !== 'file') return getSvgIcon('edit', 14);
var n = (item.title || '').toLowerCase();
@@ -718,6 +718,8 @@ function libraryPage() {
setIcon: function (icon) { self.setItemIcon(item, icon); },
duplicate: function (n) { self.duplicateItem(n); },
link: function (n) { self.copyLink(n); },
download: function (n) { self.downloadItem(n); },
copyContent: function (n) { self.copyItemContent(n); },
move: function (n) { self.openMovePicker([n.id]); },
fav: function (n) { self.toggleFavoriteItem(n); },
delete: function (n) { self.deleteItem(n); },
@@ -731,6 +733,34 @@ function libraryPage() {
store.openMenu(evt, item, 'library', handlers);
},
downloadItem(node) {
if (!node || node.is_folder) return;
var url = '/api/pages/' + node.id + '/download';
var a = document.createElement('a');
a.href = url;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
if (window.showToast) window.showToast('Download started', 'success');
},
async copyItemContent(node) {
if (!node || node.is_folder) return;
try {
var r = await fetch('/api/pages/' + node.id + '/file-content');
var d = await r.json();
if (d.ok && d.content !== undefined) {
await navigator.clipboard.writeText(d.content);
if (window.showToast) window.showToast('Content copied to clipboard', 'success');
} else {
if (window.showToast) window.showToast('Not a text file', 'error');
}
} catch (e) {
if (window.showToast) window.showToast('Copy failed', 'error');
}
},
// ── Drag & drop (move / reparent) ──
onRowDragStart(evt, id) {
if (!evt.target.closest('.drag-handle')) { evt.preventDefault(); return; }
@@ -794,6 +824,11 @@ function libraryPage() {
return m ? m[1] : '';
},
_syncSidebar() {
if (window.appState && window.appState.refreshSidebarTree) window.appState.refreshSidebarTree();
if (window.appState && window.appState.refreshFavorites) window.appState.refreshFavorites();
},
_isDescendant(node, targetId) {
if (!node || !node.children) return false;
for (var i = 0; i < node.children.length; i++) {
@@ -816,6 +851,7 @@ function libraryPage() {
}
this.clearSelection();
this.loadTab();
this._syncSidebar();
},
// ── Empty states ──
@@ -1058,6 +1094,7 @@ function libraryPage() {
}
this.clearSelection();
this.loadTab();
this._syncSidebar();
if (window.showToast) window.showToast('Moved to trash', 'success');
},
@@ -1067,6 +1104,7 @@ function libraryPage() {
var csrf = this._getCsrf();
await fetch('/board/api/pages/' + item.id, {method:'DELETE', headers:{'X-CSRF-Token': csrf}});
this.loadTab();
this._syncSidebar();
if (window.showToast) window.showToast('Moved to trash', 'success');
},
@@ -1111,7 +1149,6 @@ function libraryPage() {
async setItemIcon(item, icon) {
if (!item || !item.id) return;
icon = (icon || '').trim();
if (!icon) return;
try {
var r = await fetch('/board/api/pages/' + item.id + '/icon', {
method: 'POST',
@@ -1228,6 +1265,7 @@ function libraryPage() {
await fetch(updUrl, { method: 'PUT', headers: {'X-CSRF-Token': csrf} });
}
this.loadTab();
this._syncSidebar();
if (window.showToast) window.showToast('Page duplicated', 'success');
} catch(e) {
if (window.showToast) window.showToast('Failed to duplicate page', 'error');
@@ -1274,7 +1312,8 @@ function libraryPage() {
// ── Item actions ──
openItem(item) {
if (!item || !item.id) return;
window.location.href = '/pages/' + item.id;
if (window.fdNavigate) window.fdNavigate('/pages/' + item.id);
else window.location.href = '/pages/' + item.id;
},
startRename(item) {
@@ -1302,6 +1341,7 @@ function libraryPage() {
}
} catch(e) {}
this._renderTable();
this._syncSidebar();
},
// ── Add new ──
@@ -1332,7 +1372,8 @@ function libraryPage() {
return;
}
}
window.location.href = '/pages/' + d.id;
if (window.fdNavigate) window.fdNavigate('/pages/' + d.id);
else window.location.href = '/pages/' + d.id;
}
} catch(e) {
if (window.showToast) window.showToast('Failed to create page', 'error');
@@ -1345,14 +1386,10 @@ function libraryPage() {
async openPeek(item) {
this.peekItem = item;
this.peekFavorited = item.favorited || false;
var iframe = document.getElementById('lib-peek-iframe');
if (iframe) iframe.src = '/pages/' + item.id + '?embed=1';
},
closePeek() {
this.peekItem = null;
var iframe = document.getElementById('lib-peek-iframe');
if (iframe) iframe.src = '';
},
async toggleFavoritePeek() {
@@ -1396,6 +1433,7 @@ function libraryPage() {
await fetch('/board/api/pages/' + this.peekItem.id, {method:'DELETE', headers:{'X-CSRF-Token': csrf}});
this.closePeek();
this.loadTab();
this._syncSidebar();
if (window.showToast) window.showToast('Moved to trash', 'success');
},
+107 -15
View File
@@ -212,7 +212,8 @@
.modified-dot.show{display:block;}
/* Offline banner */
.offline-banner{position:fixed;top:0;left:0;right:0;background:var(--danger);color:#fff;text-align:center;padding:6px 12px;font-size:12px;z-index:500;font-weight:500;}
.offline-banner{position:fixed;top:0;left:0;right:0;background:var(--danger);color:#fff;text-align:center;padding:6px 12px;font-size:12px;z-index:500;font-weight:500;display:flex;align-items:center;justify-content:center;gap:12px;}
.offline-badge{background:rgba(255,255,255,.22);border-radius:10px;padding:1px 8px;font-size:11px;font-weight:600;}
/* Pinned indicator */
.pin-icon{font-size:10px;color:var(--text-tertiary);cursor:pointer;margin-left:4px;opacity:0;transition:opacity 150ms;}
@@ -595,6 +596,7 @@ window._wsInitData = (function() {
// Online/offline
online: navigator.onLine,
pendingSyncCount: 0,
pinnedIds: (function() {
try { return JSON.parse(localStorage.getItem('fd_ws_pinned') || '[]'); }
catch(e) { return []; }
@@ -648,6 +650,26 @@ window._wsInitData = (function() {
var self = this;
window.addEventListener('online', function() { self.online = true; });
window.addEventListener('offline', function() { self.online = false; });
// v6.0.0 PWA: reflect offline queue state from the client module
if (window.FlowOffline && window.FlowOffline.onChange) {
window.FlowOffline.onChange(function(s) {
self.online = s.online;
self.pendingSyncCount = s.pendingSyncCount;
});
}
// Side-peek editor posts renames here so the tree/peek title stay in sync.
window.addEventListener('message', function(ev) {
if (ev.origin !== window.location.origin) return;
var m = ev.data;
if (!m || m.type !== 'fd-page-renamed' || m.id == null) return;
var node = self._findNodeById(self.tree, m.id);
if (node) node.name = m.title || 'Untitled';
self.displayTree = self.displayTree.slice();
var t = document.getElementById('ws-preview-title');
if (t && self.previewItem && self.previewItem.id === m.id) t.textContent = m.title || 'Untitled';
var side = document.querySelector('.sidebar-item.ws-tree-item[data-id="' + m.id + '"] .page-name');
if (side) side.textContent = m.title || 'Untitled';
});
// Load workspace tags
this.loadWorkspaceTags();
} catch(e) { console.error('Tree load error:', e); this.loaded = true; }
@@ -864,10 +886,11 @@ window._wsInitData = (function() {
selectForPreview(node) {
var panel = document.getElementById('ws-preview-panel');
if (!panel) return;
this.previewItem = node;
document.getElementById('ws-preview-icon').innerHTML = this._fileIcon(node.name, node.is_folder, node.content_format, node.page_icon);
document.getElementById('ws-preview-title').textContent = node.name;
var openBtn = document.getElementById('ws-preview-open-btn');
openBtn.onclick = function() { window.location = '/pages/' + node.id; };
openBtn.onclick = function() { if (window.fdNavigate) window.fdNavigate('/pages/' + node.id); else window.location = '/pages/' + node.id; };
var favBtn = document.getElementById('ws-preview-fav-btn');
favBtn.onclick = function() { window.showToast('Favorite toggled', 'success'); };
var copyBtn = document.getElementById('ws-preview-copy-btn');
@@ -1025,6 +1048,8 @@ window._wsInitData = (function() {
setIcon: function (icon) { self.setItemIcon(node, icon); },
duplicate: function (n) { self.duplicateItem(n); },
link: function (n) { self.copyItemLink(n); },
download: function (n) { self.downloadItem(n); },
copyContent: function (n) { self.copyItemContent(n); },
move: function (n) { self._ctxMove(n); },
fav: function (n) { self.toggleFavorite(n); },
delete: function (n) { self.deleteWithUndo(n); },
@@ -1035,6 +1060,34 @@ window._wsInitData = (function() {
store.openMenu(ev, node, 'workspace', handlers);
},
downloadItem(node) {
if (!node || node.is_folder) return;
var url = '/api/pages/' + node.id + '/download';
var a = document.createElement('a');
a.href = url;
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
if (window.showToast) window.showToast('Download started', 'success');
},
async copyItemContent(node) {
if (!node || node.is_folder) return;
try {
var r = await fetch('/api/pages/' + node.id + '/file-content');
var d = await r.json();
if (d.ok && d.content !== undefined) {
await navigator.clipboard.writeText(d.content);
if (window.showToast) window.showToast('Content copied to clipboard', 'success');
} else {
if (window.showToast) window.showToast('Not a text file', 'error');
}
} catch (e) {
if (window.showToast) window.showToast('Copy failed', 'error');
}
},
_ctxAvailTags(node) {
return (this.workspaceTags || []).filter(function(t) {
return !((node && node.tags) || []).some(function(nt) { return nt.id === t.id; });
@@ -1068,7 +1121,6 @@ window._wsInitData = (function() {
async setItemIcon(node, icon) {
if (!node || !node.id) return;
icon = (icon || '').trim();
if (!icon) return;
try {
var r = await fetch('/board/api/pages/' + node.id + '/icon', {
method: 'POST',
@@ -1164,6 +1216,7 @@ window._wsInitData = (function() {
this._updateNodeTags(this.tree, node.id, d.tag, 'add');
this.displayTree = [...this.tree];
this.tagsVersion++;
this._bumpTagCount(d.tag, 1);
await this.loadWorkspaceTags();
this._computeFilteredTableItems();
this._ctxRefreshAvail();
@@ -1219,7 +1272,7 @@ window._wsInitData = (function() {
} else {
// Click to rename, double-click to open
html += '<span class="name file" onclick="event.stopPropagation();window._wsData.startRenderChildRename('+escId+',\''+escName+'\')"'
+ ' ondblclick="window.location=\'/pages/'+escId+'\'"'
+ ' ondblclick="window.fdNavigate?window.fdNavigate(\'/pages/'+escId+'\'):window.location=\'/pages/'+escId+'\'"'
+ ' title="Click to rename, double-click to open"'
+ ' onmouseenter="var d=window._wsData;if(d)d.showPreview(event,{id:'+escId+',name:\''+escName+'\',is_folder:false,content_format:\''+(c.content_format||'')+'\'})"'
+ ' onmouseleave="var d=window._wsData;if(d)d.hidePreview()">'
@@ -1331,10 +1384,10 @@ window._wsInitData = (function() {
switch(action) {
case 'new-file':
window.FlowDeck.createPage();
window.FlowDeck.createPage(id);
break;
case 'new-folder':
window.FlowDeck.showCreateFolderModal();
window.FlowDeck.showCreateFolderModal(id);
break;
case 'rename':
this.startRename({db_id: id, name: name});
@@ -1476,6 +1529,20 @@ window._wsInitData = (function() {
if (r.ok) {
node.name = n; node.db_name = n;
this._saveExpandedState();
if (window.appState && window.appState.refreshSidebarTree) window.appState.refreshSidebarTree();
if (window.FlowDeck && window.FlowDeck.syncPageTitle) window.FlowDeck.syncPageTitle(node.id, n);
var self = this;
if (this.folderStack && this.folderStack.length) {
this.folderStack.forEach(function(f) { if (String(f.id) === String(node.id)) f.name = n; });
}
if (this.breadcrumb && this.breadcrumb.length) {
this.breadcrumb.forEach(function(b) { if (String(b.id) === String(node.id)) b.name = n; });
}
if (this.previewItem && String(this.previewItem.id) === String(node.id)) {
this.previewItem.name = n;
var pt = document.getElementById('ws-preview-title');
if (pt) pt.textContent = n;
}
}
},
cancelRename() { this.renamingId = null; },
@@ -1611,11 +1678,12 @@ window._wsInitData = (function() {
navigateToPath() {
var path = (this.$el.querySelector('.path-input') || {}).value || '';
var parts = path.replace(/^\/workspace\/?/, '').split('/').filter(Boolean);
if (parts.length === 0) { window.location = '/local-workspace'; return; }
if (parts.length === 0) { if (window.fdNavigate) window.fdNavigate('/local-workspace'); else window.location = '/local-workspace'; return; }
// Walk tree to find matching folder
var targetId = this._findFolderByPath(this.tree, parts, 0);
if (targetId) {
window.location = '/local-workspace?folder=' + targetId;
if (window.fdNavigate) window.fdNavigate('/local-workspace?folder=' + targetId);
else window.location = '/local-workspace?folder=' + targetId;
} else {
window.showToast('Path not found: ' + parts.join('/'), 'error');
}
@@ -1744,7 +1812,10 @@ window._wsInitData = (function() {
// ── File type icon ──
_fileIcon(name, isFolder, contentFormat, customIcon) {
if (customIcon) return '<span class="page-icon-svg" style="font-size:14px;line-height:1;">'+this.esc(customIcon)+'</span>';
if (customIcon) {
var html = window.fdIconHtml ? window.fdIconHtml(customIcon, 14) : this.esc(customIcon);
return '<span class="page-icon-svg" style="font-size:14px;line-height:1;display:inline-flex;align-items:center;">'+html+'</span>';
}
if (isFolder) return '<span class="page-icon-svg">'+getSvgIcon('folder',14)+'</span>';
// FlowDeck internal pages (no extension, blocks/markdown format)
if (contentFormat && contentFormat !== 'file') return '<span class="page-icon-svg">'+getSvgIcon('edit',14)+'</span>';
@@ -1931,7 +2002,7 @@ window._wsInitData = (function() {
return false;
},
openPage(id) { window.location = '/pages/'+id; },
openPage(id) { if (window.fdNavigate) window.fdNavigate('/pages/' + id); else window.location = '/pages/' + id; },
openSidePeek(id) {
var node = this._findNodeById(this.tree, id) || this._findNodeById(this.displayTree, id);
if (node) this.selectForPreview(node);
@@ -2208,12 +2279,31 @@ window._wsInitData = (function() {
// ── Tag management ──
async loadWorkspaceTags() {
try {
var r = await fetch('/api/settings/tags/all');
var r = await fetch('/api/local-workspace/tags', { cache: 'no-store' });
var d = await r.json();
this.workspaceTags = d.tags || [];
} catch(e) { this.workspaceTags = []; }
},
// Mise à jour OPTIMISTE du compteur d'un tag dans la barre de tags :
// rafraîchit immédiatement la pastille (delta +1/-1) sans attendre le
// re-fetch (qui viendra ensuite confirmer/surécrire avec les valeurs serveur).
_bumpTagCount(tag, delta) {
if (!tag) return;
var tags = (this.workspaceTags || []).slice();
var i = -1;
for (var k = 0; k < tags.length; k++) {
if (String(tags[k].id) === String(tag.id)) { i = k; break; }
}
if (i >= 0) {
tags[i] = Object.assign({}, tags[i], { count: Math.max(0, (tags[i].count || 0) + delta) });
} else if (delta > 0) {
tags.push({ id: tag.id, name: tag.name || '', color: tag.color || '#787774', count: delta });
}
// Réassigne un nouveau tableau → réactivité Alpine
this.workspaceTags = tags;
},
toggleTagFilter(tagName) {
this.tagFilter = (this.tagFilter === tagName) ? '' : tagName;
this.filterType = '';
@@ -2245,6 +2335,7 @@ window._wsInitData = (function() {
this._updateNodeTags(this.tree, itemId, d.tag, 'add');
this.displayTree = [...this.tree];
this.tagsVersion++;
this._bumpTagCount(d.tag, 1);
await this.loadWorkspaceTags();
this._computeFilteredTableItems();
}
@@ -2261,6 +2352,7 @@ window._wsInitData = (function() {
this._updateNodeTags(this.tree, itemId, null, 'remove', tagId);
this.displayTree = [...this.tree];
this.tagsVersion++;
this._bumpTagCount({ id: tagId }, -1);
await this.loadWorkspaceTags();
this._computeFilteredTableItems();
}
@@ -2419,7 +2511,7 @@ for (var _i = 0; _i < _wsKeys.length; _i++) {
_wsInitData = window._wsInitData;
console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(window._wsInitData).length);
</script>
<script>console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
<script data-cfasync="false">console.log('FLOWDECK v2.4.6 — ws-split about to render, _wsInitData exists:', typeof window._wsInitData !== 'undefined');</script>
<div class="ws-split"
@contextmenu.prevent="_wsInitData.onContextMenu($event)"
@@ -2462,7 +2554,6 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<!-- Breadcrumb + toolbar row (Library-style) -->
<div class="breadcrumb-row">
<button class="btn-icon nav-arrow" x-show="sidebarCollapsed" x-cloak @click="sidebarCollapsed = false" title="Show sidebar">{{ fd_icon("chevron-right",14) }}</button>
<a href="/workspaces" class="btn-icon nav-arrow" title="All workspaces">{{ fd_icon("home",14) }}</a>
<button class="btn-icon nav-arrow" @click="goBack()" title="Back" :disabled="!canGoBack">{{ fd_icon("chevron-left",14) }}</button>
<button class="btn-icon nav-arrow" @click="goForward()" title="Forward" :disabled="!canGoForward">{{ fd_icon("chevron-right",14) }}</button>
@@ -2692,8 +2783,8 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<div class="actions">
<template x-if="node.is_folder">
<span style="display:flex;gap:2px">
<button class="ws-act" @click.stop="window.FlowDeck.createPage()" title="New file">{{ fd_icon("file",14) }}</button>
<button class="ws-act" @click.stop="window.FlowDeck.showCreateFolderModal()" title="New folder">{{ fd_icon("folder",14) }}</button>
<button class="ws-act" @click.stop="window.FlowDeck.createPage(node.id)" title="New file">{{ fd_icon("file",14) }}</button>
<button class="ws-act" @click.stop="window.FlowDeck.showCreateFolderModal(node.id)" title="New folder">{{ fd_icon("folder",14) }}</button>
<a class="ws-act" href="/local-workspace" title="Open workspace page" style="text-decoration:none;display:inline-flex;align-items:center;">{{ fd_icon("external-link",14) }}</a>
</span>
</template>
@@ -2933,6 +3024,7 @@ console.log('FLOWDECK v2.4.6 — IIFE done, _wsInitData keys:', Object.keys(wind
<!-- Offline banner -->
<div class="offline-banner" x-show="!online" x-transition>
<span>{{ fd_icon('alert-triangle',14) }} You are offline. Changes will sync when connection is restored.</span>
<span class="offline-badge" x-show="pendingSyncCount > 0" x-text="pendingSyncCount + ' modification(s) en attente'"></span>
</div>
<!-- Move-to modal -->
+1
View File
@@ -2,6 +2,7 @@
page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% set page_icon = "file" if page.content_format != 'file' else "paperclip" %}
{% set page_title = page.title %}
{% set nav_page_id = page.id %}
{% set right_actions = '<span class="topbar-edited" style="cursor:pointer;" @click="window.E && window.E.toggleActivityOpen()">Edited <span x-text="window.E && window.E.timeAgo || \'\'"></span> ▾</span><button class="topbar-btn" @click="window.E && window.E.toggleComments()" title="Comments"><span class="fd-comment-btn-ico">💬</span><span class="fd-comment-count" x-text="window.E && window.E.commentCount>0 ? window.E.commentCount : \'\'"></span></button><button class="topbar-btn share-btn" @click="window.E && window.E.toggleShareOpen()"><span x-show="!window.E || !window.E.pageIsShared">' ~ fd_icon("lock",14) ~ ' Share ▾</span><span x-show="window.E && window.E.pageIsShared" title="This page is shared">👥 Shared ▾</span></button><button class="topbar-btn" @click="window.E && window.E.copyPageLink()" title="Copy link">' ~ fd_icon("link",14) ~ '</button><button class="topbar-btn star-btn" @click="window.E && window.E.toggleFavorite()" x-html="(window.E && window.E.favorited) ? getSvgIcon(\'star\',14) : getSvgIcon(\'star\',14)"></button><button class="topbar-btn relative" @click="window.E && window.E.toggleMoreOpen()">⋯</button>' %}
{% include '_header.html' %}
{% endblock %} {% block content %}
+1 -1
View File
@@ -7,7 +7,7 @@ page_title %}{{ page.title }}{% endblock %} {% block topbar %}
{% endblock %} {% block content %}
{% include "_database_table.html" %}
{% endblock %} {% block scripts %}
<script>
<script data-cfasync="false">
// Initialize database table from server-rendered data
window.__DB_PAGE_ID = {{ page.id }};
window.__DB_COLLECTION_ID = {{ page.collection_id or 0 }};
+1 -1
View File
@@ -4,7 +4,7 @@
{% block topbar %}{% endblock %}
{% block content %}
{% include '_page_editor_content.html' %}
<script>document.body.classList.add('embed-mode');</script>
<script data-cfasync="false">document.body.classList.add('embed-mode');</script>
{% endblock %}
{% block scripts %}
{% include '_page_editor_scripts.html' %}
+56 -10
View File
@@ -125,6 +125,18 @@
color: var(--accent);
text-decoration: none;
}
.pub-content img[data-full] { cursor: zoom-in; }
.fd-lightbox { user-select: none; }
.fd-lightbox-nav {
position: absolute; top: 50%; transform: translateY(-50%);
width: 48px; height: 48px; border-radius: 50%; border: none;
background: rgba(255,255,255,.12); color: #fff; font-size: 30px;
line-height: 1; cursor: pointer; display: flex; align-items: center;
justify-content: center; transition: background .15s ease;
}
.fd-lightbox-nav:hover { background: rgba(255,255,255,.25); }
.fd-lightbox-nav[data-nav="prev"] { left: 24px; }
.fd-lightbox-nav[data-nav="next"] { right: 24px; }
</style>
</head>
<body>
@@ -143,8 +155,9 @@
<div style="position:absolute;inset:0;background:linear-gradient(180deg,rgba(0,0,0,0) 50%,rgba(0,0,0,.5) 100%);"></div>
</div>
{% endif %}
{% from '_icons.html' import fd_page_icon %}
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
<span class="pub-icon" style="font-size:2rem;">{{ page_icon or fd_icon('file',24) }}</span>
<span class="pub-icon" style="font-size:2rem;">{{ fd_page_icon(page_icon, 32) if page_icon else fd_icon('file',24) }}</span>
<h1 class="pub-title">{{ title }}</h1>
</div>
{% if updated_at %}
@@ -160,17 +173,50 @@
</footer>
<script src="/static/js/katex.min.js?v=0.16.11"></script>
<script>
<script data-cfasync="false">
document.addEventListener('DOMContentLoaded', function () {
if (!window.katex) return;
document.querySelectorAll('div[data-katex]').forEach(function (el) {
var tex = el.getAttribute('data-katex') || '';
try {
el.innerHTML = window.katex.renderToString(tex, { displayMode: true, throwOnError: false });
} catch (e) {
el.textContent = tex;
if (window.katex) {
document.querySelectorAll('div[data-katex]').forEach(function (el) {
var tex = el.getAttribute('data-katex') || '';
try {
el.innerHTML = window.katex.renderToString(tex, { displayMode: true, throwOnError: false });
} catch (e) {
el.textContent = tex;
}
});
}
// v5.5.0 — Image lightbox (fullscreen + keyboard navigation)
var imgs = Array.prototype.slice.call(document.querySelectorAll('.pub-content img[data-full]'));
imgs.forEach(function (im) { im.addEventListener('click', function () { openLb(im); }); });
function openLb(target) {
var idx = imgs.indexOf(target); if (idx < 0) idx = 0;
var ov = document.createElement('div');
ov.className = 'fd-lightbox';
ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.95);z-index:5000;display:flex;align-items:center;justify-content:center;flex-direction:column;';
ov.innerHTML = '<button class="fd-lightbox-nav" data-nav="prev" aria-label="Previous">&#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>
+202 -66
View File
@@ -99,6 +99,32 @@
.prov-card .pw-wrapper .settings-input{padding-right:36px;}
.prov-msg{font-size:12px;margin-top:8px;min-height:0;word-break:break-word;}
.llm-hint{font-size:12px;color:var(--text-secondary);background:rgba(35,131,226,.07);border:1px solid rgba(35,131,226,.18);border-radius:8px;padding:8px 12px;margin-top:14px;line-height:1.5;}
/* Agent & IA — provider master/detail (scales to dozens of providers) */
.llm-toolbar{display:flex;align-items:center;gap:10px;margin-bottom:12px;flex-wrap:wrap;}
.llm-search{position:relative;flex:1;min-width:180px;}
.llm-search svg{position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--text-dim);pointer-events:none;}
.llm-search input{padding-left:32px;box-sizing:border-box;}
.llm-count{font-size:11px;color:var(--text-dim);white-space:nowrap;text-transform:uppercase;letter-spacing:.4px;}
.llm-layout{display:grid;grid-template-columns:264px 1fr;gap:14px;align-items:start;}
.llm-list{border:1px solid var(--border);border-radius:12px;background:var(--bg-secondary);max-height:480px;overflow-y:auto;padding:6px;display:flex;flex-direction:column;gap:2px;}
.llm-list-item{display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:8px;cursor:pointer;transition:background 100ms;border:1px solid transparent;}
.llm-list-item:hover{background:var(--bg-hover);}
.llm-list-item.active{background:rgba(35,131,226,.14);border-color:rgba(35,131,226,.3);}
.llm-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;background:var(--text-dim);}
.llm-dot.ok{background:#2ea043;}
.llm-dot.warn{background:#d9730d;}
.llm-dot.err{background:#e03e3e;}
.llm-list-name{flex:1;min-width:0;font-size:13px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.llm-list-status{font-size:10px;color:var(--text-dim);text-transform:uppercase;letter-spacing:.4px;white-space:nowrap;}
.llm-list-empty{padding:12px;font-size:12px;color:var(--text-dim);text-align:center;}
.llm-detail{border:1px solid var(--border);border-radius:12px;background:var(--bg-secondary);padding:16px;min-height:260px;}
.llm-detail-head{display:flex;align-items:center;gap:10px;margin-bottom:4px;flex-wrap:wrap;}
.llm-detail-name{font-size:15px;font-weight:600;color:var(--text);}
.llm-detail-base{font-size:11px;color:var(--text-dim);font-family:ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all;margin-bottom:12px;}
.llm-detail .prov-field-row{margin-top:12px;}
.llm-detail-empty{display:flex;align-items:center;justify-content:center;height:260px;font-size:13px;color:var(--text-dim);text-align:center;padding:0 20px;}
@media (max-width:760px){.llm-layout{grid-template-columns:1fr;}.llm-list{max-height:220px;}}
</style>
<div class="settings-overlay" x-data="settingsInit()" @keydown.escape="window.history.back()">
@@ -274,6 +300,47 @@
</label>
</div>
</div>
<div class="setting-row">
<div>
<div class="setting-label">Reminders</div>
<div class="setting-desc">Upcoming events &amp; date reminders from your databases</div>
</div>
<div class="setting-control">
<label class="toggle-switch sm">
<input type="checkbox" x-model="notifPrefs.reminders" @change="saveNotifPrefs()">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-row">
<div>
<div class="setting-label">Assignments</div>
<div class="setting-desc">When a database row is assigned to you</div>
</div>
<div class="setting-control">
<label class="toggle-switch sm">
<input type="checkbox" x-model="notifPrefs.assignments" @change="saveNotifPrefs()">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
<div class="setting-group">
<h3>Timezone</h3>
<div class="setting-row">
<div>
<div class="setting-label">Your timezone</div>
<div class="setting-desc">Used by calendar views (today, week start) and reminder firing. Empty = UTC.</div>
</div>
<div class="setting-control">
<select class="settings-input" style="max-width:260px" x-model="userTimezone" @change="saveTimezone()">
<option value="">UTC</option>
<template x-for="z in userZones" :key="z">
<option :value="z" x-text="z"></option>
</template>
</select>
</div>
</div>
</div>
</div>
@@ -290,13 +357,15 @@
<button class="btn btn-primary" @click="createApiToken()" :disabled="!tokenName.trim()" style="white-space:nowrap;">Generate</button>
</div>
</div>
<div class="setting-row" x-show="newToken" style="flex-direction:column;align-items:stretch;gap:6px;background:rgba(46,160,67,.08);border-radius:8px;">
<div class="setting-label" style="color:var(--toast-success-bg);">Token créé — copiez-le maintenant (affiché une seule fois)</div>
<div style="display:flex;gap:8px;align-items:center;">
<code class="settings-input" x-text="newToken.token" style="flex:1;font-family:var(--font-mono);font-size:12px;user-select:all;"></code>
<button class="btn-sm" @click="copyNewToken()">Copy</button>
<template x-if="newToken">
<div class="setting-row" style="flex-direction:column;align-items:stretch;gap:6px;background:rgba(46,160,67,.08);border-radius:8px;">
<div class="setting-label" style="color:var(--toast-success-bg);">Token créé — copiez-le maintenant (affiché une seule fois)</div>
<div style="display:flex;gap:8px;align-items:center;">
<code class="settings-input" x-text="newToken.token" style="flex:1;font-family:var(--font-mono);font-size:12px;user-select:all;"></code>
<button class="btn-sm" @click="copyNewToken()">Copy</button>
</div>
</div>
</div>
</template>
</div>
<div class="setting-group">
<h3>Tokens existants <span style="text-transform:none;font-size:11px;" x-text="'(' + apiTokens.length + ')'"></span></h3>
@@ -831,61 +900,82 @@
<!-- My providers (per-user API keys) -->
<div class="setting-group">
<h3>Mes fournisseurs</h3>
<p style="font-size:12px;color:var(--text-dim);margin:0 0 12px;">Enregistrez vos propres clés : elles sont utilisées quand vous choisissez ce fournisseur dans le panneau Agent, et vous rendent le fournisseur disponible (après test réussi).</p>
<div class="prov-grid">
<template x-for="p in userProviders()" :key="p.id">
<div class="prov-card">
<div class="prov-card-head">
<span class="prov-card-name" x-text="p.name"></span>
<span class="llm-badge" :class="llmBadgeClass(p)" x-text="llmBadge(p)"></span>
<p style="font-size:12px;color:var(--text-dim);margin:0 0 12px;">Enregistrez vos propres clés : elles sont utilisées quand vous choisissez ce fournisseur dans le panneau Agent, et vous rendent le fournisseur disponible (après test réussi). Sélectionnez un fournisseur dans la liste pour le configurer.</p>
<div class="llm-toolbar">
<div class="llm-search">
{{ fd_icon("search",14) }}
<input type="text" class="settings-input" x-model="llmSearch" placeholder="Rechercher un fournisseur…">
</div>
<span class="llm-count" x-text="filteredProviders().length + ' fournisseurs'"></span>
</div>
<div class="llm-layout">
<div class="llm-list">
<template x-for="p in filteredProviders()" :key="p.id">
<div class="llm-list-item" :class="{active: p.id===selectedProviderId}" @click="selectProvider(p.id)">
<span class="llm-dot" :class="llmBadgeClass(p)"></span>
<span class="llm-list-name" x-text="p.name"></span>
<span class="llm-list-status" x-text="llmShortStatus(p)"></span>
</div>
<div class="prov-card-sub" x-show="keyForm(p.id).last_error" style="color:var(--danger);" x-text="'Dernier test : ' + keyForm(p.id).last_error"></div>
<div class="prov-field-row">
<div class="prov-field-label">Clé API <span class="muted" x-text="keyForm(p.id).has_key ? '(enregistrée — laisser vide pour conserver)' : ''"></span></div>
<div class="pw-wrapper" style="max-width:100%;">
<input :type="keyForm(p.id).keyVisible ? 'text' : 'password'" class="settings-input"
x-model="keyForm(p.id).api_key" placeholder="sk-…" style="padding-right:36px;box-sizing:border-box;">
<button type="button" class="pw-toggle" @click="keyForm(p.id).keyVisible=!keyForm(p.id).keyVisible" style="top:50%;">
<span x-show="keyForm(p.id).keyVisible" x-cloak>{{ fd_icon('eye-off',14) }}</span>
<span x-show="!keyForm(p.id).keyVisible" x-cloak>{{ fd_icon('eye',14) }}</span>
</template>
<div class="llm-list-empty" x-show="!filteredProviders().length">Aucun fournisseur</div>
</div>
<div class="llm-detail">
<template x-if="selectedProvider()">
<div>
<div class="llm-detail-head">
<span class="llm-detail-name" x-text="selectedProvider().name"></span>
<span class="llm-badge" :class="llmBadgeClass(selectedProvider())" x-text="llmBadge(selectedProvider())"></span>
</div>
<div class="llm-detail-base" x-text="keyForm(selectedProviderId).api_base || selectedProvider().base_url || '—'"></div>
<div class="prov-card-sub" x-show="keyForm(selectedProviderId).last_error" style="color:var(--danger);margin-bottom:0;" x-text="'Dernier test : ' + keyForm(selectedProviderId).last_error"></div>
<div class="prov-field-row">
<div class="prov-field-label">Clé API <span class="muted" x-text="keyForm(selectedProviderId).has_key ? '(enregistrée — laisser vide pour conserver)' : (selectedProvider().requires_key ? '(requise)' : '(optionnelle)')"></span></div>
<div class="pw-wrapper" style="max-width:100%;">
<input :type="keyForm(selectedProviderId).keyVisible ? 'text' : 'password'" class="settings-input"
x-model="keyForm(selectedProviderId).api_key" placeholder="sk-…" style="padding-right:36px;box-sizing:border-box;">
<button type="button" class="pw-toggle" @click="keyForm(selectedProviderId).keyVisible=!keyForm(selectedProviderId).keyVisible" style="top:50%;">
<span x-show="keyForm(selectedProviderId).keyVisible" x-cloak>{{ fd_icon('eye-off',14) }}</span>
<span x-show="!keyForm(selectedProviderId).keyVisible" x-cloak>{{ fd_icon('eye',14) }}</span>
</button>
</div>
</div>
<div class="prov-field-row">
<div class="prov-field-label">URL API <span class="muted">(optionnelle)</span></div>
<input type="text" class="settings-input" x-model="keyForm(selectedProviderId).api_base" placeholder="https://…" style="box-sizing:border-box;">
</div>
<div class="prov-field-row">
<div class="prov-field-label">Modèle par défaut</div>
<select class="settings-input" x-model="keyForm(selectedProviderId).model" style="box-sizing:border-box;">
<template x-for="m in keyModels(selectedProviderId)" :key="m">
<option :value="m" x-text="m"></option>
</template>
</select>
</div>
<div class="prov-actions">
<button class="btn btn-primary" @click="saveUserKey(selectedProviderId)" :disabled="keyForm(selectedProviderId).saving" style="font-size:12px;padding:6px 12px;">
<span x-show="!keyForm(selectedProviderId).saving">{{ fd_icon("save",14) }} Enregistrer &amp; tester</span>
<span x-show="keyForm(selectedProviderId).saving">Enregistrement…</span>
</button>
<button class="btn" @click="testUserKey(selectedProviderId)" :disabled="keyForm(selectedProviderId).testing" style="font-size:12px;padding:6px 12px;">
<span x-show="!keyForm(selectedProviderId).testing">🔌 Tester</span>
<span x-show="keyForm(selectedProviderId).testing">Test…</span>
</button>
<button class="btn" @click="loadProviderModels(selectedProviderId)" :disabled="keyForm(selectedProviderId).modelLoading" style="font-size:12px;padding:6px 12px;">
<span x-show="!keyForm(selectedProviderId).modelLoading">{{ fd_icon("refresh",14) }} Modèles</span>
<span x-show="keyForm(selectedProviderId).modelLoading">Chargement…</span>
</button>
<button class="btn" @click="deleteUserKey(selectedProviderId)" :disabled="keyForm(selectedProviderId).deleting" x-show="keyForm(selectedProviderId).has_key" style="font-size:12px;padding:6px 12px;color:var(--danger);">Supprimer</button>
</div>
<div class="prov-msg">
<span x-show="keyForm(selectedProviderId).msg" x-text="keyForm(selectedProviderId).msg" :style="{color: keyForm(selectedProviderId).ok ? 'var(--toast-success-bg)' : 'var(--danger)'}"></span>
</div>
</div>
<div class="prov-field-row">
<div class="prov-field-label">URL API <span class="muted">(optionnelle)</span></div>
<input type="text" class="settings-input" x-model="keyForm(p.id).api_base" placeholder="https://…" style="box-sizing:border-box;">
</div>
<div class="prov-field-row">
<div class="prov-field-label">Modèle par défaut</div>
<select class="settings-input" x-model="keyForm(p.id).model" style="box-sizing:border-box;">
<template x-for="m in keyModels(p.id)" :key="m">
<option :value="m" x-text="m"></option>
</template>
</select>
</div>
<div class="prov-actions">
<button class="btn btn-primary" @click="saveUserKey(p.id)" :disabled="keyForm(p.id).saving" style="font-size:12px;padding:6px 12px;">
<span x-show="!keyForm(p.id).saving">{{ fd_icon("save",14) }} Enregistrer &amp; tester</span>
<span x-show="keyForm(p.id).saving">Enregistrement…</span>
</button>
<button class="btn" @click="testUserKey(p.id)" :disabled="keyForm(p.id).testing" style="font-size:12px;padding:6px 12px;">
<span x-show="!keyForm(p.id).testing">🔌 Tester</span>
<span x-show="keyForm(p.id).testing">Test…</span>
</button>
<button class="btn" @click="loadProviderModels(p.id)" :disabled="keyForm(p.id).modelLoading" style="font-size:12px;padding:6px 12px;">
<span x-show="!keyForm(p.id).modelLoading">{{ fd_icon("refresh",14) }} Modèles</span>
<span x-show="keyForm(p.id).modelLoading">Chargement…</span>
</button>
<button class="btn" @click="deleteUserKey(p.id)" :disabled="keyForm(p.id).deleting" x-show="keyForm(p.id).has_key" style="font-size:12px;padding:6px 12px;color:var(--danger);">Supprimer</button>
</div>
<div class="prov-msg">
<span x-show="keyForm(p.id).msg" x-text="keyForm(p.id).msg" :style="{color: keyForm(p.id).ok ? 'var(--toast-success-bg)' : 'var(--danger)'}"></span>
</div>
</div>
</template>
</template>
<div class="llm-detail-empty" x-show="!selectedProvider()">Sélectionnez un fournisseur pour le configurer.</div>
</div>
</div>
<div class="llm-hint">
💡 <b>Comment activer un fournisseur ?</b> Enregistrez votre clé puis cliquez « Enregistrer &amp; tester » (ou « Tester ») : dès que la connexion réussit, le fournisseur et ses modèles apparaissent dans le sélecteur du panneau Agent.
💡 <b>Comment activer un fournisseur ?</b> Sélectionnez-le, enregistrez votre clé puis cliquez « Enregistrer &amp; tester » (ou « Tester ») : dès que la connexion réussit, le fournisseur et ses modèles apparaissent dans le sélecteur du panneau Agent.
Pour Ollama, aucun fournisseur distant : renseignez l'URL locale (ex. <code>http://localhost:11434/v1</code>) puis testez.
En mode hors-ligne (aucun fournisseur actif), l'agent reste utilisable mais génère du contenu local, sans appel réseau.
</div>
@@ -896,11 +986,10 @@
</div>
</div>
<script>
document.addEventListener('alpine:init', function() {
Alpine.data('settingsInit', function() {
return {
activeSection: 'account',
<script data-cfasync="false">
function settingsInit() {
return {
activeSection: 'account',
allTags: [],
newTagName: '',
newTagColor: '#787774',
@@ -914,7 +1003,8 @@ document.addEventListener('alpine:init', function() {
defaultView: localStorage.getItem('fd_default_view') || 'tree',
userInfo: {full_name: '{{ user.full_name or "" }}', login: '{{ user.login or "" }}', email: '{{ user.email or "" }}'},
userIsAdmin: {{ 'true' if user.get('is_admin') else 'false' }},
notifPrefs: {comments: true, mentions: true},
notifPrefs: {comments: true, mentions: true, reminders: true, assignments: true},
userTimezone: '', userZones: [],
// Auth & Integrations
authMethod: '{{ auth_method }}',
giteaLinked: false,
@@ -951,6 +1041,8 @@ document.addEventListener('alpine:init', function() {
llmSummaryDesc: '',
// Per-user provider keys (multi-providers)
keyForms: {},
llmSearch: '',
selectedProviderId: null,
// Automations (v5.1.0)
automations: [],
@@ -983,7 +1075,13 @@ document.addEventListener('alpine:init', function() {
try {
var r = await fetch('/api/notifications/prefs', {credentials:'same-origin'});
var d = await r.json();
this.notifPrefs = Object.assign({comments:true, mentions:true}, d.prefs || {});
this.notifPrefs = Object.assign({comments:true, mentions:true, reminders:true, assignments:true}, d.prefs || {});
} catch(e) {}
try {
var tz = await fetch('/api/notifications/timezone', {credentials:'same-origin'});
var td = await tz.json();
this.userTimezone = td.timezone || '';
this.userZones = td.zones || [];
} catch(e) {}
},
async saveNotifPrefs() {
@@ -994,6 +1092,15 @@ document.addEventListener('alpine:init', function() {
});
} catch(e) {}
},
async saveTimezone() {
try {
var r = await fetch('/api/notifications/timezone', {
method: 'POST', headers: {'Content-Type':'application/json','X-CSRF-Token': this.getCsrfToken()},
body: JSON.stringify({timezone: this.userTimezone})
});
if (r.ok && typeof toast === 'function') toast('Timezone saved');
} catch(e) {}
},
async loadTags() {
try {
@@ -1160,6 +1267,29 @@ document.addEventListener('alpine:init', function() {
userProviders() {
return (this.llmProviders || []).filter(x => x.id !== 'offline');
},
selectedProvider() {
return this.providerById(this.selectedProviderId);
},
filteredProviders() {
var q = (this.llmSearch || '').trim().toLowerCase();
var list = this.userProviders();
if (!q) return list;
return list.filter(function(p){
return (p.name || '').toLowerCase().indexOf(q) !== -1 ||
(p.id || '').toLowerCase().indexOf(q) !== -1;
});
},
selectProvider(id) {
this.selectedProviderId = id;
this.keyForm(id);
},
llmShortStatus(p) {
if (!p) return '';
if (p.id === 'offline') return 'mock';
if (p.verified) return 'actif';
if (p.configured) return 'à tester';
return '—';
},
// Set the verified/last_error state on the shared provider entry + its keyForm
_setProviderState(id, ok, error) {
var p = this.providerById(id);
@@ -1249,6 +1379,12 @@ document.addEventListener('alpine:init', function() {
f.has_key = !!(k && k.has_key);
f.last_error = (k && k.last_error) || p.last_error || '';
});
if (!this.selectedProviderId || !this.providerById(this.selectedProviderId)) {
var pool = this.userProviders();
var pick = pool.find(x => x.verified) || pool.find(x => x.configured) || pool[0];
this.selectedProviderId = pick ? pick.id : null;
}
if (this.selectedProviderId) this.keyForm(this.selectedProviderId);
this.syncLlmStatus();
} catch(e) {}
},
@@ -1344,7 +1480,8 @@ document.addEventListener('alpine:init', function() {
finally { f.testing = false; }
},
async deleteUserKey(id) {
var pname = id.charAt(0).toUpperCase() + id.slice(1);
var p = this.providerById(id);
var pname = (p && p.name) || id;
if (!confirm('Supprimer la clé API pour ' + pname + ' ?\nLe fournisseur ne sera plus disponible dans le panneau Agent.')) return;
var f = this.keyForm(id);
f.deleting = true; f.msg = ''; f.ok = false;
@@ -1756,8 +1893,7 @@ document.addEventListener('alpine:init', function() {
setTimeout(function() { window.location.reload(); }, 100);
}
},
};
});
});
};
}
</script>
{% endblock %}
+1 -1
View File
@@ -62,7 +62,7 @@
{% endblock %}
{% block scripts %}
<script>
<script data-cfasync="false">
function trashData() {
return {
search: '',
+1 -1
View File
@@ -133,7 +133,7 @@ h1{font-size:22px;font-weight:700;margin-bottom:6px;}
</section>
</div>
<script>
<script data-cfasync="false">
function onboarding() {
return {
steps: ['Bienvenue', 'Espace de travail', 'Connecter une forge', 'Premier projet'],
+1 -1
View File
@@ -140,7 +140,7 @@
</div>
<script>
<script data-cfasync="false">
function workspacePage() {
return {
builtinProjects: [],
+1 -1
View File
@@ -172,7 +172,7 @@
</div>
<script>
<script data-cfasync="false">
function workspacesPage() {
return {
workspaces: [],
+31 -1
View File
@@ -550,6 +550,21 @@ Endpoints publics v2 :
|---------|-------|-------------|
| GET | `/api/v2/search?query=&workspace_id=&type=page|collection|all` | Résultats groupés par type, snippets |
### 4.18 Synchronisation offline (PWA)
| **Interne** | `routers/sync.py` + `services/sync_engine.py` — auth session (`flowdeck_session`), CSRF-exempt (`/api/v2`) |
|-----|-----|
| Méthode | Route | Description |
|---------|-------|-------------|
| GET | `/api/v2/sync/delta?since=<epoch>&workspace_id=<id>` | Changements serveur depuis `since` (pages créées/màj/supprimées, collections) → `{changes, server_time, has_more}` |
| POST | `/api/v2/sync/batch` | Rejoue un lot de mutations offline → `{results, conflicts, server_time}` |
| GET | `/api/v2/sync/status?workspace_id=<id>` | `{pending_count, last_sync, is_syncing, server_time}` |
**Format batch** : `{device_id, mutations:[{id, type, payload, client_timestamp}]}` — `type` ∈ `page_create|page_update|page_delete|page_move|collection_create|collection_update|collection_delete`. `page_update` accepte `base_version` (colonne `sync_version`) pour la détection de conflit.
**Conflits** : `edit_edit` (last-write-wins + rapport), `edit_delete` (page orpheline recréée), `create_create` (renommage `« … (copie offline) »`). Chaque mutation est tracée dans `offline_sync_queue`.
---
## 5. Webhooks sortants (à étendre en v2)
@@ -675,4 +690,19 @@ EVENTS = [
- Frontend : `app/templates/*.html`, `static/js/app.js`
- Webhooks sortants : `app/services/webhook_outbound.py`
- Types de propriétés : `app/services/property_types.py`
- Roadmap : `ROADMAP.md` (v6.0.0 — API publique, realtime, synced blocks, web clipper, permissions granulaires)
- Roadmap : `ROADMAP.md` (v6.0.0 — API publique, realtime, synced blocks, web clipper, permissions granulaires)
---
## 11. Documents de conception détaillée v6.0.0
Chaque feature v6.0.0 dispose d'un document de conception détaillé dans `/docs/` :
| Feature | Document | Description |
|---------|----------|-------------|
| **PWA** | [`V6_PWA_Progressive_Web_App.md`](/docs/V6_PWA_Progressive_Web_App.md) | Offline support, service worker, IndexedDB, sync batch |
| **SSO/SAML** | [`V6_SSO_SAML_Enterprise_Auth.md`](/docs/V6_SSO_SAML_Enterprise_Auth.md) | SAML 2.0, OIDC, auto-provisioning, group mapping |
| **Permissions granulaires** | [`V6_Granular_Permissions.md`](/docs/V6_Granular_Permissions.md) | Page-level, property-level, collection-level, groupes |
| **Web Clipper** | [`V6_Web_Clipper.md`](/docs/V6_Web_Clipper.md) | Extension navigateur, capture d'articles, OAuth |
> **Note** : Le présent guide couvre la couche API v2 commune à toutes les features. Chaque document de conception ci-dessus détaille les endpoints, tables, services et UI spécifiques à sa feature.
+537
View File
@@ -0,0 +1,537 @@
# V6.0.0 — PWA : Progressive Web App, Offline Support
> **Statut** : Conception détaillée — v6.0.0
> **Date** : 2026-09-15
> **Route** : `feat/v6-pwa` → `develop` → `main`
> **Dépendances** : v5.14.0 Synced blocks (requis pour la synchronisation hors ligne)
---
## 1. Vision & objectifs
Transformer FlowDeck en une **Progressive Web App** fonctionnellement identique en ligne et hors ligne. L'utilisateur doit pouvoir :
- **Naviguer** entre les pages, workspaces, databases sans connexion
- **Créer, éditer, supprimer** du contenu en mode offline
- **Synchroniser** automatiquement les modifications dès le retour du réseau
- **Installer** l'application sur le bureau/l'écran d'accueil (manifest + service worker)
- **Bouncer** gracefulment entre modes en ligne/hors ligne avec indication claire
### Objectifs de qualité
| Critère | Cible |
|---------|-------|
| Temps de chargement hors ligne | < 1 s (cache local) |
| Taille du cache initial | < 15 MB (shell de l'app + assets critiques) |
| Synchronisation différée | Queue de mutations → replay automatique |
| Conflits | Détection par version de page → résolution manuelle ou last-write-wins |
| Disponibilité UI | Indicateur visuel online/offline persistant |
---
## 2. Architecture PWA
### 2.1 Composants
```
┌─────────────────────────────────────────────────────────────────┐
│ CLIENT (Browser) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────────────┐ │
│ │ App Shell │ │ Service │ │ IndexedDB │ │
│ │ (Jinja2 + │ │ Worker │ │ (données offline) │ │
│ │ Alpine.js) │ │ (cache + │ │ │ │
│ │ │ │ sync) │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └───────────┬───────────┘ │
│ │ │ │ │
│ └────────────┬────┴───────────────────────┘ │
│ │ │
│ ┌────────────▼──────────────────────────────┐ │
│ │ Sync Queue (Background Sync) │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ Mutations pending → replay │ │ │
│ │ │ sur reconnexion │ │ │
│ │ └─────────────────────────────┘ │ │
│ └───────────────────────────────────────────┘ │
└──────────────────────────┬──────────────────────────────────────┘
│ HTTPS / Service Worker
┌──────────────────────────▼──────────────────────────────────────┐
│ FASTAPI (Server) │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Routes existantes (incluses dans le cache) │ │
│ │ ├─ /, /workspaces, /library, /settings │ │
│ │ ├─ /board/{owner}/{repo}, /db/{id}/pages │ │
│ │ ├─ /api/pages/*, /db/*/pages/api │ │
│ │ └─ /p/{slug} (pages publiques) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Nouveau : Offline Sync Endpoint │ │
│ │ POST /api/v2/sync/batch — batch de mutations │ │
│ │ GET /api/v2/sync/delta ?since=timestamp │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
### 2.2 Flux de synchronisation
```
[En ligne] [Hors ligne]
│ │
│ ── Requête HTTP normale ──► │
│ │ L'utilisateur édite
│ ◄── Réponse JSON ── │ → mutation mise en queue
│ │ → IndexedDB mis à jour
│ │ → UI optimiste
│ │
│ Reconnexion détectée │
│ ── GET /api/v2/sync/delta ──► │
│ ◄── Changements serveur ────────┤
│ ── POST /api/v2/sync/batch ──► │
│ ◄── 200 OK (toutes mut. ack'd)─┤
│ → UI refresh + cache invalidé │
```
---
## 3. Service Worker
### 3.1 Fichier : `static/sw.js`
```javascript
const CACHE_NAME = 'flowdeck-v6';
const PRECACHE_URLS = [
'/', '/workspaces', '/library', '/settings',
'/static/css/app.css', '/static/css/design-tokens.css',
'/static/css/components.css',
'/static/js/app.js', '/static/js/alpine.js',
'/static/js/sortable.js', '/static/js/prism.js',
'/static/js/katex.min.js', '/static/css/katex.min.css',
];
// ── Install : precache du shell ──
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS))
);
});
// ── Activate : purge des anciens caches ──
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
// ── Fetch : stratégie cache-first pour assets, network-first pour API ──
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
if (url.pathname.startsWith('/api/')) {
// API : network-first avec fallback offline
event.respondWith(networkFirst(event.request));
} else if (url.pathname.match(/\.(js|css|png|jpg|jpeg|svg|woff2|ico)$/)) {
// Assets statiques : cache-first
event.respondWith(cacheFirst(event.request));
} else {
// Pages HTML : network-first avec fallback offline
event.respondWith(networkFirst(event.request));
}
});
```
### 3.2 Stratégies de cache
| Type | Stratégie | Fallback offline |
|------|-----------|-----------------|
| Shell (HTML/CSS/JS) | Cache-first | Cache stocké lors de l'install |
| API GET (pages, vues) | Network-first, cache en fallback | Dernière version IndexedDB |
| API POST/PUT/DELETE | Network-only | Offline queue → Background Sync |
| Images uploadées | Cache-first après upload | Stockées dans IndexedDB |
---
## 4. IndexedDB — Structure de données offline
### 4.1 Base de données : `flowdeck-offline`
```sql
-- Tables stockées dans IndexedDB (via idb-keyval ou Dexie.js)
-- Pages locales (modifiées hors ligne)
STORE pages_offline {
id: number (primary key)
workspace_id: number
title: string
content: string (blocs JSON)
content_format: string
parent_id: number | null
deleted_at: string | null
_modified_at: number (timestamp local)
_dirty: boolean (true = pending sync)
}
-- Collections vues hors ligne
STORE collections_offline {
id: number (primary key)
workspace_id: number
name: string
schema_json: string
_modified_at: number
_dirty: boolean
}
-- Queue de synchronisation
STORE sync_queue {
id: number (auto-increment)
type: string ('page_create' | 'page_update' | 'page_delete' | 'collection_create' | ...)
payload: string (JSON de la mutation)
timestamp: number
retries: number
status: string ('pending' | 'syncing' | 'synced' | 'failed')
}
-- Métadonnées de sync
STORE sync_meta {
key: string ('last_server_sync' | 'last_page_version' | ...)
value: string (timestamp ou version)
}
```
### 4.2 Modèle de conflit
```
Cas 1 — Pas de conflit (page non modifiée côté serveur)
→ Apply la mutation locale directement
Cas 2 — Conflit edit-edit
→ Détection via version de page (column `updated_at` serveur vs `_modified_at` local)
→ Résolution :
a) Last-write-wins (par défaut, configurable dans Settings)
b) Manuel : l'utilisateur choisit via un diff UI
Cas 3 — Conflit edit-delete
→ La page a été supprimée côté serveur pendant le mode offline
→ Créer une nouvelle page orphan → notification à l'utilisateur
Cas 4 — Conflit create-create (même titre)
→ Le serveur a créé une page avec le même titre pendant l'offset
→ Renommer la copie locale : "Titre (copie offline)"
```
---
## 5. Endpoints serveur pour le sync offline
### 5.1 Nouveaux endpoints `app/routers/sync.py`
```
GET /api/v2/sync/delta?since=<timestamp>&workspace_id=<id>
→ Retourne les changements serveur depuis le dernier sync
→ Format: { changes: [{type, page_id, content, version}], new_pages: [...] }
POST /api/v2/sync/batch
→ Batch de mutations offline → serveur
→ Input: { mutations: [{type, payload, client_timestamp}], device_id: string }
→ Output: { results: [{mutation_id, status, server_version}], conflicts: [...] }
GET /api/v2/sync/status
→ État de synchronisation pour le workspace courant
→ { pending_count, last_sync, is_syncing }
```
### 5.2 Service serveur `app/services/sync_engine.py`
```python
class SyncEngine:
"""Moteur de synchronisation offline↔online."""
async def get_delta(self, user_id: int, since: float, workspace_id: int | None) -> dict:
"""Retourne les changements serveur depuis `since`."""
# Requêter pages, collections, comments modifiées depuis `since`
# Inclure les versions pour détection de conflits
async def apply_batch(self, user_id: int, mutations: list[dict], device_id: str) -> dict:
"""Applique un batch de mutations en une transaction."""
results = []
conflicts = []
for mut in mutations:
try:
# Validation de la version attendue
# Application de la mutation
# Génération du conflit si version mismatch
results.append({"mutation_id": mut["id"], "status": "synced", "server_version": ...})
except ConflictError as e:
conflicts.append({"mutation_id": mut["id"], "conflict": e.details})
return {"results": results, "conflicts": conflicts}
```
---
## 6. Interface utilisateur — Indicateurs offline
### 6.1 Offline banner (existant à améliorer)
Le banner offline existant dans `local_workspace.html` (ligne 3016-3017) est étendu :
```css
/* Amélioration : ajouter dans app.css */
.offline-banner {
position: fixed; top: 0; left: 0; right: 0;
background: var(--danger); color: #fff;
text-align: center; padding: 8px 16px;
font-size: 13px; z-index: 500; font-weight: 500;
/* Ajout v6 */
display: flex; align-items: center; justify-content: center; gap: 12px;
animation: slideDown 0.3s ease;
}
```
```html
<!-- Extension du banner -->
<div class="offline-banner" x-show="!online" x-transition>
<span>{{ fd_icon('alert-triangle', 14) }} Mode hors ligne — vos modifications seront synchronisées</span>
<span x-show="pendingSyncCount > 0" class="badge">
{{ pendingSyncCount }} modification(s) en attente
</span>
</div>
```
### 6.2 Indicateur de synchronisation
- **Badge syncing** dans la topbar quand le batch est en cours
- **Toast** à la fin d'une synchronisation réussie ou avec erreurs
- **Cône de synchronisation** dans le sidebar pour les pages modifiées hors ligne (icône `⟳` sur les pages `_dirty = true`)
---
## 7. Manifest Web & Installation
### 7.1 `static/manifest.json`
```json
{
"name": "FlowDeck",
"short_name": "FlowDeck",
"description": "Clone Notion intégré à Gitea/GitHub",
"start_url": "/",
"display": "standalone",
"background_color": "#191919",
"theme_color": "#191919",
"orientation": "any",
"icons": [
{ "src": "/static/icons/icon-72x72.png", "sizes": "72x72", "type": "image/png" },
{ "src": "/static/icons/icon-96x96.png", "sizes": "96x96", "type": "image/png" },
{ "src": "/static/icons/icon-128x128.png", "sizes": "128x128", "type": "image/png" },
{ "src": "/static/icons/icon-144x144.png", "sizes": "144x144", "type": "image/png" },
{ "src": "/static/icons/icon-152x152.png", "sizes": "152x152", "type": "image/png" },
{ "src": "/static/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "/static/icons/icon-384x384.png", "sizes": "384x384", "type": "image/png" },
{ "src": "/static/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
],
"categories": ["productivity", "business"],
"lang": "fr",
"dir": "ltr",
"prefer_related_applications": false
}
```
### 7.2 Service Worker registration (dans `base.html`)
```html
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').then(reg => {
console.log('SW registered:', reg.scope);
// Background Sync support
if ('sync' in reg) {
reg.sync.register('sync-flowdeck');
}
});
});
}
</script>
```
---
## 8. Tables de base de données — Modifications nécessaires
### 8.1 Nouveau table `offline_sync_queue`
```sql
CREATE TABLE offline_sync_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device_id TEXT NOT NULL,
type TEXT NOT NULL,
-- 'page_create', 'page_update', 'page_delete', 'page_move',
-- 'collection_create', 'collection_update', 'collection_delete'
payload TEXT NOT NULL, -- JSON de la mutation
client_timestamp REAL NOT NULL, -- epoch ms
server_version INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
-- 'pending', 'syncing', 'synced', 'failed'
retries INTEGER NOT NULL DEFAULT 0,
error TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_syncqueue_user ON offline_sync_queue(user_id, status);
CREATE INDEX idx_syncqueue_device ON offline_sync_queue(device_id, status);
```
### 8.2 Colonnes ajoutées sur `pages` et `collections`
```sql
-- Détection de conflit
ALTER TABLE pages ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1;
ALTER TABLE collection_pages ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1;
ALTER TABLE collections ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1;
```
### 8.3 Migration
```python
# app/migrations.py — ajouter à la séquence
MIGRATIONS = [
# ... migrations existantes v1-v5 ...
("v6-offline-sync-queue", """
CREATE TABLE offline_sync_queue (...);
CREATE INDEX ...;
"""),
("v6-sync-version-columns", """
ALTER TABLE pages ADD COLUMN sync_version INTEGER DEFAULT 1;
ALTER TABLE collection_pages ADD COLUMN sync_version INTEGER DEFAULT 1;
ALTER TABLE collections ADD COLUMN sync_version INTEGER DEFAULT 1;
"""),
]
```
---
## 9. Frontend — Modules Alpine.js
### 9.1 Module `app/offline.js`
```javascript
// Module hors ligne — gestion de la connexion et de la queue
export const offlineModule = {
data() {
return {
online: navigator.onLine,
pendingSyncCount: 0,
lastSyncAt: null,
isSyncing: false,
};
},
async created() {
window.addEventListener('online', () => { this.online = true; this.processQueue(); });
window.addEventListener('offline', () => { this.online = false; });
this.pendingSyncCount = await this.getPendingCount();
// Polling status toutes les 30s
setInterval(() => this.syncStatus(), 30000);
},
methods: {
async getPendingCount() {
const r = await fetch('/api/v2/sync/status');
const d = await r.json();
this.pendingSyncCount = d.pending_count;
this.lastSyncAt = d.last_sync;
},
async processQueue() {
if (this.isSyncing || !this.online) return;
this.isSyncing = true;
const queue = await this.getQueue();
if (queue.length > 0) {
await fetch('/api/v2/sync/batch', {
method: 'POST',
body: JSON.stringify({ mutations: queue, device_id: DEVICE_ID }),
});
}
this.isSyncing = false;
await this.getPendingCount();
},
async getQueue() { /* Lire IndexedDB sync_queue */ },
async syncStatus() { /* GET /api/v2/sync/status */ },
},
};
```
### 9.2 Integration dans l'éditeur
Lorsqu'un utilisateur est hors ligne et modifie une page :
1. La mutation est immédiatement appliquée dans IndexedDB (optimistic UI)
2. L'éditeur sauvegarde normalement dans le cache local
3. Une entrée est ajoutée à `sync_queue` via `POST /api/v2/sync/batch` quand le réseau revient
4. Le contenu de la page est marqué `_dirty = true` → icône `⟳` dans le sidebar
---
## 10. Tests
| Test | Description | Outil |
|------|-------------|-------|
| Service worker install | Cache precache valide | Playwright |
| Offline page load | Navigation sans réseau → cache servi | Playwright |
| Offline mutation | Créer une page offline → IndexedDB + queue | Playwright + SQLite |
| Online sync | Reconnexion → batch replay → serveur à jour | Playwright + API |
| Conflict resolution | Edit-edit conflict → résolution last-write-wins | Unit test + Playwright |
| Manifest install | `navigator.standalone` ou `beforeinstallprompt` | Playwright |
| Background sync | `SyncManager.register` appelé | Playwright |
| Cache invalidation | Nouvelle version → ancien cache supprimé | Unit test |
```bash
# Commandes
pytest tests/test_pwa_offline.py # Tests sync offline
pytest tests/test_service_worker.py # Tests SW
npx playwright tests/pwa-offline.spec.ts # E2E offline flows
```
---
## 11. Checklist d'implémentation
1. **`static/manifest.json`** + icônes (512x512 maskable)
2. **`static/sw.js`** — Service Worker avec precache + API strategy
3. **`app/routers/sync.py`** — endpoints `/api/v2/sync/*`
4. **`app/services/sync_engine.py`** — moteur de synchronisation
5. **Migrations** — table `offline_sync_queue` + colonnes `sync_version`
6. **`app/offline.js`** — module Alpine.js offline
7. **Amélioration banner offline** — pending count + sync status
8. **IndexedDB côté client** — wrapper pour pages, collections, queue
9. **Tests** — offline scenarios + sync conflict + background sync
10. **Dockerfile** — servir le SW + manifest via le server
11. **Documentation utilisateur** — section `/help` sur le mode offline
---
## 12. Performance & contraintes
| Contrainte | Détail |
|-----------|--------|
| Taille maximale cache shell | 15 MB |
| Timeout sync batch | 30 s |
| Max mutations par batch | 100 |
| Retention queue offline | 30 jours (auto-purge) |
| Device ID | UUID stocké dans localStorage |
| Fallback sans SW | Mode "lite" → pas de PWA, mais app fonctionne normalement |
---
## 13. Références
- [MDN: Progressive Web Apps](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps)
- [Google: Offline Storage Best Practices](https://web.dev/offline-storage-best-practices/)
- [Workbox — Google's SW library](https://developers.google.com/web/tools/workbox)
- [Background Sync API](https://web.dev/background-sync/)
- [IndexedDB MDN](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API)
- `app/routers/sync.py` — (à créer)
- `app/services/sync_engine.py` — (à créer)
- `docs/API_GUIDE_V6.md` — référence API v2 (endpoint `/api/v2/sync/*`)
- `ROADMAP.md` — v6.0.0 PWA item
+171
View File
@@ -0,0 +1,171 @@
const { test, expect } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const FD_PASS = process.env.FD_PASS || 'e2e-secret-123';
test('diag v5: partial-nav ctx menu + rename sidebar/header sync', async ({ page }) => {
test.setTimeout(180000);
const errors = [];
page.on('console', m => { if (m.type() === 'error') errors.push('CONSOLE: ' + m.text()); });
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
// ── 1. Fresh user + workspace ──
await page.goto(`${FD_BASE}/auth/login?provider=local`);
await page.click('button:has-text("Register")');
await page.fill('#name', 'Diag');
await page.fill('#email', 'diag' + Date.now() + '@flowdeck.local');
await page.fill('#password', FD_PASS);
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 20000 });
await page.waitForTimeout(1200);
// Dismiss any onboarding agent dialog that may cover the page
await page.keyboard.press('Escape').catch(() => {});
await page.waitForTimeout(300);
// Create + select a workspace via API (CSRF-exempt), no fragile UI dialogs.
const ws = await page.evaluate(async () => {
const r = await fetch('/api/workspaces', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Diag WS ' + Date.now() })
});
const d = await r.json();
const s = await fetch('/api/workspaces/' + d.id + '/select', { method: 'POST' });
return { id: d.id, ok: s.ok };
});
console.log('WS:', JSON.stringify(ws));
await page.waitForTimeout(800);
// Seed items via API.
await page.evaluate(async () => {
const mk = (name, type) => fetch('/api/local-workspace/items', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, type })
}).then(r => r.json());
const p = await mk('RenameMe Doc', 'page');
await mk('Folder A', 'folder');
window.__seedPageId = p.id;
});
console.log('STEP1 ok — user + workspace + seeded (all via API)');
// ── 2. PARTIAL nav to /local-workspace (exactly the user's bug path) ──
await page.evaluate(() => window.fdNavigate('/local-workspace'));
await page.waitForURL('**/local-workspace**', { timeout: 10000 });
await page.waitForFunction(() => document.querySelectorAll('[data-ws-id]').length >= 1, { timeout: 10000 });
await page.waitForTimeout(1500);
console.log('STEP2 ok — partial-nav LW tree items:', await page.locator('[data-ws-id]').count());
// ── 3. PARTIAL-nav ctx menu (local-workspace) ──
const rows1 = page.locator('[data-ws-id]');
if (await rows1.count()) {
await rows1.first().click({ button: 'right' });
await page.waitForTimeout(700);
const st = await page.evaluate(() => {
const s = window.Alpine && Alpine.store('fdCtx');
return s ? { open: s.open, node: s.node && s.node.name } : { undefined: true };
});
const vis = await page.locator('.fd-ctx-menu').isVisible().catch(() => false);
console.log('STEP3 LW ctx (partial-nav): visible=' + vis + ' fdCtx=' + JSON.stringify(st));
await page.screenshot({ path: 'shots/diag_v5_lw_ctx.png' }).catch(() => {});
await page.evaluate(() => { const s = window.Alpine && Alpine.store('fdCtx'); if (s) s.close(); });
}
// ── 4. PARTIAL-nav ctx menu (library) ──
await page.evaluate(() => window.fdNavigate('/library'));
await page.waitForFunction(() => typeof window._libData !== 'undefined', { timeout: 10000 });
await page.waitForTimeout(1500);
const libRows = page.locator('.lib-row');
console.log('STEP4 partial /library rows:', await libRows.count(),
'libData:', await page.evaluate(() => typeof window._libData + ':' + typeof (window._libData && window._libData.onRowContextMenu)));
if (await libRows.count()) {
await libRows.first().click({ button: 'right' });
await page.waitForTimeout(700);
const vis = await page.locator('.fd-ctx-menu').isVisible().catch(() => false);
const st = await page.evaluate(() => {
const s = window.Alpine && Alpine.store('fdCtx');
return s ? { open: s.open, node: s.node && s.node.name } : { undefined: true };
});
console.log('STEP5 LIB ctx (partial-nav): visible=' + vis + ' fdCtx=' + JSON.stringify(st));
await page.screenshot({ path: 'shots/diag_v5_lib_ctx.png' }).catch(() => {});
await page.evaluate(() => { const s = window.Alpine && Alpine.store('fdCtx'); if (s) s.close(); });
}
// ── 5. Rename a page via local-workspace tree (partial-nav) ──
await page.evaluate(() => window.fdNavigate('/local-workspace'));
await page.waitForFunction(() => typeof window._wsData !== 'undefined' && typeof window._wsData.commitRename === 'function', { timeout: 10000 });
await page.waitForFunction(() => document.querySelectorAll('[data-ws-id]').length >= 1, { timeout: 10000 });
await page.waitForTimeout(1200);
const rn = await page.evaluate(async () => {
const ws = window._wsData;
const find = (name) => {
const walk = (arr) => { if (!arr) return null; for (const n of arr) { if (n.name === name) return n; const c = walk(n.children); if (c) return c; } return null; };
return walk(ws.displayTree) || walk(ws.tree);
};
const target = find('RenameMe Doc');
if (!target) return { err: 'node RenameMe Doc not found' };
ws.renamingId = target.id;
ws.renamingName = 'Renamed Via LW';
await ws.commitRename(target);
return { id: target.id, name: target.name };
});
console.log('STEP6 rename page:', JSON.stringify(rn));
await page.waitForTimeout(2000);
const chk1 = await page.evaluate((pid) => {
const row = [...document.querySelectorAll('[data-ws-id]')].find(n => String(n.getAttribute('data-ws-id')) === String(pid));
const sb = document.querySelector('.sidebar-item.ws-tree-item[data-id="' + pid + '"] .page-name');
const head = document.querySelector('.header-breadcrumb');
const crumbs = head ? head.innerText.trim().split(/\s/).filter(Boolean) : [];
return {
inPageRow: row ? row.innerText.trim() : null,
sidebarWsName: sb ? sb.textContent : null,
headerCrumbs: crumbs,
};
}, rn.id);
console.log('STEP7 after rename:', JSON.stringify(chk1, null, 2));
// ── 6. Rename the CURRENT folder → in-page breadcrumb must update (no reload) ──
const fld = await page.evaluate(async () => {
const ws = window._wsData;
const walk = (arr) => { if (!arr) return null; for (const n of arr) { if (n.is_folder && n.name === 'Folder A') return n; const c = walk(n.children); if (c) return c; } return null; };
const folder = walk(ws.displayTree) || walk(ws.tree);
if (!folder) return { err: 'folder not found' };
return { id: folder.id, name: folder.name };
});
// Partial-nav INTO the folder so the server renders the breadcrumb (folderStack)
await page.evaluate((fid) => window.fdNavigate('/local-workspace?folder=' + fid), fld.id);
await page.waitForFunction(() => {
const ws = window._wsData;
return ws && ws.folderStack && ws.folderStack.length >= 1;
}, { timeout: 10000 });
await page.waitForTimeout(1200);
const beforeCrumb = await page.evaluate(() => {
const c = document.querySelector('.unified-breadcrumb .bc-current');
return c ? c.textContent.trim() : null;
});
console.log('STEP8 crumb before rename:', beforeCrumb);
const rnf = await page.evaluate(async (fid) => {
const ws = window._wsData;
const cur = ws.folderStack && ws.folderStack[ws.folderStack.length - 1];
if (!cur) return { err: 'no folderStack' };
ws.renamingId = cur.id;
ws.renamingName = 'Folder Renamed';
await ws.commitRename(cur);
return { id: cur.id, name: cur.name, bc: ws.breadcrumb.map(b => b.name) };
}, fld.id);
console.log('STEP9 rename folder:', JSON.stringify(rnf));
await page.waitForTimeout(2000);
const chk2 = await page.evaluate((fid) => {
const crumb = document.querySelector('.unified-breadcrumb .bc-current');
const sb = document.querySelector('.sidebar-item.ws-tree-item[data-id="' + fid + '"] .page-name');
return {
crumbLabel: crumb ? crumb.textContent.trim() : null,
sidebarWsName: sb ? sb.textContent : null,
};
}, fld.id);
console.log('STEP10 after folder rename:', JSON.stringify(chk2, null, 2));
await page.screenshot({ path: 'shots/diag_v5_folder_rename.png' }).catch(() => {});
console.log('ERRORS:');
errors.slice(0, 40).forEach(e => console.log(e));
});
+113
View File
@@ -0,0 +1,113 @@
const { test } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const FD_PASS = process.env.FD_PASS || 'e2e-secret-123';
test('diag tags: chip counts update dynamically on add/remove', async ({ page }) => {
test.setTimeout(120000);
const errors = [];
page.on('console', m => { if (m.type() === 'error') errors.push('CONSOLE: ' + m.text()); });
page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
await page.goto(`${FD_BASE}/auth/login?provider=local`);
await page.click('button:has-text("Register")');
await page.fill('#name', 'Diag');
await page.fill('#email', 'diag' + Date.now() + '@flowdeck.local');
await page.fill('#password', FD_PASS);
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 20000 });
await page.waitForTimeout(1000);
await page.keyboard.press('Escape').catch(() => {});
await page.waitForTimeout(300);
const ws = await page.evaluate(async () => {
const r = await fetch('/api/workspaces', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Tag WS ' + Date.now() })
});
const d = await r.json();
await fetch('/api/workspaces/' + d.id + '/select', { method: 'POST' });
return d;
});
console.log('WS:', JSON.stringify(ws));
await page.waitForTimeout(600);
// Seed 2 files
const seeds = await page.evaluate(async () => {
const mk = (name) => fetch('/api/local-workspace/items', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, type: 'page' })
}).then(r => r.json());
const a = await mk('Doc A');
const b = await mk('Doc B');
return { a: a.id, b: b.id };
});
console.log('SEEDED:', JSON.stringify(seeds));
// Tag Doc A via API to set up the scenario
const tagRes = await page.evaluate(async (id) => {
const r = await fetch('/api/local-workspace/items/' + id + '/tags', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'important', color: '#E03E3E' })
});
return r.status;
}, seeds.a);
console.log('TAG A status:', tagRes);
// Load local-workspace (full load first for simplicity)
await page.goto(`${FD_BASE}/local-workspace`);
await page.waitForFunction(() => typeof window._wsData !== 'undefined', { timeout: 10000 });
await page.waitForTimeout(2500);
const readChip = async (label) => {
return page.evaluate((tagName) => {
const ws = window._wsData;
const t = (ws.workspaceTags || []).find(x => String(x.id) === String(tagName));
const chip = [...document.querySelectorAll('.tag-filter-bar .tag-chip')]
.find(b => b.textContent.includes(tagName));
return {
dataCount: t ? t.count : null,
chipText: chip ? chip.textContent.trim() : null,
wsTags: (ws.workspaceTags || []).map(x => x.id + ':' + x.count),
};
}, 'important');
};
console.log('INIT:', JSON.stringify(await readChip()));
// Add SAME tag to Doc B via CONTEXT MENU handler (ctxAddNewTag) → 1 → 2
const ctxAdd = await page.evaluate(async (id) => {
const ws = window._wsData;
const walk = (arr) => { if (!arr) return null; for (const n of arr) { if (n.id === id) return n; const c = walk(n.children); if (c) return c; } return null; };
const node = walk(ws.displayTree) || walk(ws.tree);
if (!node) return { err: 'node not found' };
const store = window.Alpine && Alpine.store('fdCtx');
if (!store) return { err: 'no fdCtx store' };
store.node = node;
await ws.ctxAddNewTag('important', '#E03E3E');
return { id: node.id, name: node.name };
}, seeds.b);
console.log('CTX ADD B:', JSON.stringify(ctxAdd));
await page.waitForTimeout(1000);
console.log('AFTER CTX ADD B:', JSON.stringify(await readChip()));
// Remove tag from Doc A via CONTEXT MENU handler (ctxRemoveTag) → 2 → 1
const ctxRm = await page.evaluate(async (id) => {
const ws = window._wsData;
const walk = (arr) => { if (!arr) return null; for (const n of arr) { if (n.id === id) return n; const c = walk(n.children); if (c) return c; } return null; };
const node = walk(ws.displayTree) || walk(ws.tree);
if (!node) return { err: 'node not found' };
const tag = (node.tags || []).find(t => t.name === 'important');
if (!tag) return { err: 'tag not on node' };
const store = window.Alpine && Alpine.store('fdCtx');
store.node = node;
ws.ctxRemoveTag(tag.id);
return { id: node.id, removedTagId: tag.id };
}, seeds.a);
console.log('CTX RM A:', JSON.stringify(ctxRm));
await page.waitForTimeout(1000);
console.log('AFTER CTX RM A:', JSON.stringify(await readChip()));
console.log('ERRORS:');
errors.slice(0, 20).forEach(e => console.log(e));
});
+157
View File
@@ -0,0 +1,157 @@
const { test, expect } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
async function login(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`);
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 15000 });
}
async function setupWorkspace(page, name) {
return await page.evaluate(async (n) => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
const r = await fetch('/api/workspaces', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ name: n }),
});
const ws = await r.json();
await fetch(`/api/workspaces/${ws.id}/select`, { method: 'POST' });
return ws;
}, name);
}
async function createFromTemplate(page, key, wsId) {
return await page.evaluate(async ({ k, id }) => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
const r = await fetch('/board/api/page-templates/0/use', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ key: k, workspace_id: id }),
});
return await r.json();
}, { k: key, id: wsId });
}
test.describe('Template page editor', () => {
test('to-do list: block ids remain valid after realtime sync', async ({ page }) => {
await login(page);
const ws = await setupWorkspace(page, 'Edit Repro WS ' + Date.now());
const tpl = await createFromTemplate(page, 'todo_list', ws.id);
console.log('created page', tpl);
await page.goto(`${FD_BASE}/pages/${tpl.id}`);
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
await page.waitForTimeout(3000); // let WS sync land
const bids = await page.$$eval('#_blocksCt [data-bid]', (els) =>
els.map((e) => e.getAttribute('data-bid'))
);
console.log('BIDS:', JSON.stringify(bids));
expect(bids.length).toBeGreaterThan(0);
expect(bids).not.toContain('undefined');
expect(new Set(bids).size).toBe(bids.length);
});
test('weekly report: date chip survives editing', async ({ page }) => {
await login(page);
const ws = await setupWorkspace(page, 'Edit Repro WS2 ' + Date.now());
const tpl = await createFromTemplate(page, 'weekly_report', ws.id);
await page.goto(`${FD_BASE}/pages/${tpl.id}`);
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
await page.waitForTimeout(3000);
// Type in the first heading then read back the saved content token
const h1 = page.locator('#_blocksCt .block-content').first();
await h1.click();
await page.keyboard.press('End');
await page.keyboard.type(' (edited)');
await page.waitForTimeout(2500); // autosave
const stored = await page.evaluate(async (pid) => {
const r = await fetch('/board/api/pages/' + pid);
return (await r.json()).content;
}, tpl.id);
console.log('STORED:', stored);
expect(stored).toContain('fddate');
});
test('to-do list: typing + Enter keeps order and does not duplicate', async ({ page }) => {
await login(page);
const ws = await setupWorkspace(page, 'Edit Repro WS3 ' + Date.now());
const tpl = await createFromTemplate(page, 'todo_list', ws.id);
await page.goto(`${FD_BASE}/pages/${tpl.id}`);
await page.waitForSelector('#_blocksCt .block-todo [contenteditable][data-bid]', { timeout: 15000 });
await page.waitForTimeout(2500); // let WS sync land
const first = page.locator('#_blocksCt .block-todo [contenteditable][data-bid]').first();
await first.click();
await page.keyboard.type('Task A');
await page.keyboard.press('Enter');
await page.waitForTimeout(250); // editor refocuses the new block after render
await page.keyboard.type('Task B');
await page.waitForTimeout(2500); // autosave
const stored = await page.evaluate(async (pid) => {
const r = await fetch('/board/api/pages/' + pid);
return JSON.parse((await r.json()).content);
}, tpl.id);
const texts = stored.map((b) => b.content);
console.log('TODO STORED:', JSON.stringify(texts));
// heading + 3 to_do (template) + 1 inserted by Enter
expect(texts).toEqual(['To-do', 'Task A', 'Task B', '', '']);
const ids = stored.map((b) => b.id);
expect(new Set(ids).size).toBe(ids.length);
expect(ids).not.toContain(undefined);
});
test('legacy id-less page is repaired on load (no undefined ids / duplicates)', async ({ page }) => {
await login(page);
const ws = await setupWorkspace(page, 'Edit Repro Legacy ' + Date.now());
// create an empty page, then write id-less blocks directly to simulate a
// page created before block ids were persisted (old templates).
const pid = await page.evaluate(async (wsId) => {
const csrf = (document.cookie.match(/csrf_token=([^;]+)/) || [])[1] || '';
const r = await fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ name: 'Legacy', type: 'page', workspace_id: wsId }),
});
const d = await r.json();
await fetch('/board/api/pages/' + d.id + '/blocks', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({
title: 'Legacy',
blocks: [
{ type: 'heading_1', content: 'To-do' },
{ type: 'to_do', content: '', checked: false },
{ type: 'to_do', content: '', checked: false },
],
}),
});
return d.id;
}, ws.id);
await page.goto(`${FD_BASE}/pages/${pid}`);
await page.waitForSelector('#_blocksCt [data-bid]', { timeout: 15000 });
await page.waitForTimeout(3000); // let realtime sync land
const bids = await page.$$eval('#_blocksCt [data-bid]', (els) =>
els.map((e) => e.getAttribute('data-bid'))
);
console.log('LEGACY BIDS:', JSON.stringify(bids));
expect(bids.length).toBe(3);
expect(bids).not.toContain('undefined');
expect(new Set(bids).size).toBe(bids.length);
});
});
+34
View File
@@ -0,0 +1,34 @@
const { test } = require('@playwright/test');
const BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
test('explore: login UI + home', async ({ page }) => {
await page.goto(`${BASE}/auth/login?provider=local`);
await page.waitForTimeout(800);
console.log('LOGIN PAGE title:', await page.title());
// List inputs / buttons on login page
const inputs = await page.evaluate(() =>
[...document.querySelectorAll('input')].map(i => ({
id: i.id, name: i.name, type: i.type, placeholder: i.placeholder,
}))
);
console.log('LOGIN INPUTS:', JSON.stringify(inputs));
const btns = await page.evaluate(() =>
[...document.querySelectorAll('button,[type=submit]')].map(b => ({
text: (b.innerText||'').trim().slice(0,30), cls: b.className.slice(0,30),
}))
);
console.log('LOGIN BUTTONS:', JSON.stringify(btns));
await page.screenshot({ path: 'shots/login.png' });
// Try login with UI
await page.fill('input[type=email], input[name=email], #email', USER).catch(()=>{});
// fallback: fill first text input
await page.fill('input[type=password], input[name=password], #password', PASS).catch(()=>{});
await page.screenshot({ path: 'shots/login_filled.png' });
await page.waitForTimeout(300);
console.log('AFTER FILL inputs value check:');
console.log(await page.evaluate(() => [...document.querySelectorAll('input')].map(i=>[i.type, i.value])));
});
+25
View File
@@ -0,0 +1,25 @@
const { test } = require('@playwright/test');
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
async function uiLogin(page) {
await page.goto('/auth/login?provider=local');
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
await page.waitForTimeout(1500);
}
test('explore: home après login UI', async ({ page }) => {
await uiLogin(page);
console.log('URL après login:', page.url());
console.log('TITLE:', await page.title());
// buttons with "New"/create
const newBtns = await page.evaluate(() =>
[...document.querySelectorAll('button, a, [role=button]')]
.map(b => ({ t: (b.innerText||'').trim().slice(0, 25), cls: (b.className||'').toString().slice(0,30) }))
.filter(x => /new|creat|add|page|meeting|database/i.test(x.t))
);
console.log('NEW-ISH BUTTONS:', JSON.stringify(newBtns, null, 1));
await page.screenshot({ path: 'shots/home.png', fullPage: false });
});
+41
View File
@@ -0,0 +1,41 @@
const { test } = require('@playwright/test');
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
const WS = 'Workspace E2E ' + Date.now();
async function uiLogin(page) {
await page.goto('/auth/login?provider=local');
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
await page.waitForTimeout(1500);
}
test('explore: créer workspace local + capturer UI création', async ({ page }) => {
await uiLogin(page);
// on /workspaces
await page.waitForSelector('.ws-card.btn-new', { timeout: 8000 });
await page.click('.ws-card.btn-new');
await page.fill('.dialog-input', WS);
await page.click('.dialog-box .btn-primary');
await page.waitForTimeout(1200);
console.log('URL après création WS:', page.url());
// Now click the created workspace card to enter
await page.click(`.ws-card:has-text("${WS}")`);
await page.waitForTimeout(1500);
console.log('URL après entrée WS:', page.url());
console.log('TITLE WS:', await page.title());
// Capture all editable/new elements visible
const els = await page.evaluate(() =>
[...document.querySelectorAll('button, a, textarea, [contenteditable], input')]
.filter(e => e.offsetParent !== null)
.map(e => ({
tag: e.tagName, cls: (e.className||'').toString().slice(0,40),
text: (e.innerText||e.value||'').trim().slice(0,20), ph: e.placeholder||'',
}))
);
console.log('VISIBLE CREATE-ISH:', JSON.stringify(els.filter(x=>/new|creat|add|page|meet|database|\+|table/i.test(x.text+' '+x.cls+' '+x.ph)).slice(0,25), null, 1));
await page.screenshot({ path: 'shots/ws_entered.png' });
});
+56
View File
@@ -0,0 +1,56 @@
const { test } = require('@playwright/test');
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
const WS = 'Workspace E2E ' + Date.now();
async function uiLogin(page) {
await page.goto('/auth/login?provider=local');
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
await page.waitForTimeout(1500);
}
async function enterWorkspace(page, wsName) {
await uiLogin(page);
await page.waitForSelector('.ws-card.btn-new');
await page.click('.ws-card.btn-new');
await page.fill('.dialog-input', wsName);
await page.click('.dialog-box .btn-primary');
await page.waitForTimeout(1200);
await page.click(`.ws-card:has-text("${wsName}")`);
await page.waitForTimeout(1500);
}
test('explore: cliquer + (nouveau) dans workspace', async ({ page }) => {
await enterWorkspace(page, WS);
console.log('URL workspace:', page.url());
// Click the + button (new page)
await page.click('.fd-ap-add');
await page.waitForTimeout(800);
// Capture modal/dialog content
const modal = await page.evaluate(() => {
const dialogs = [...document.querySelectorAll('[role=dialog], .modal, .dialog, .popover, .dropdown-menu, [x-show]')]
.filter(e => e.offsetParent !== null);
return dialogs.map(d => ({
cls: d.className?.slice(0,50),
html: d.innerHTML?.slice(0,500),
}));
});
console.log('MODALS OPEN:', JSON.stringify(modal, null, 1));
// Also check for any new elements after click
const newEls = await page.evaluate(() =>
[...document.querySelectorAll('button, a, li, div[role=menuitem], [data-action]')]
.filter(e => e.offsetParent !== null)
.map(e => ({
tag: e.tagName, cls: (e.className||'').slice(0,40),
text: (e.innerText||'').trim().slice(0,30),
}))
.filter(x => /page|meet|database|note|template|create|new|blank/i.test(x.text+' '+x.cls))
);
console.log('NEW CREATION OPTIONS:', JSON.stringify(newEls, null, 1));
await page.screenshot({ path: 'shots/after_plus.png' });
});
+58
View File
@@ -0,0 +1,58 @@
const { test } = require('@playwright/test');
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
const WS = 'Workspace E2E ' + Date.now();
async function uiLogin(page) {
await page.goto('/auth/login?provider=local');
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
await page.waitForTimeout(1500);
}
async function enterWorkspace(page, wsName) {
await uiLogin(page);
await page.waitForSelector('.ws-card.btn-new');
await page.click('.ws-card.btn-new');
await page.fill('.dialog-input', wsName);
await page.click('.dialog-box .btn-primary');
await page.waitForTimeout(1200);
await page.click(`.ws-card:has-text("${wsName}")`);
await page.waitForTimeout(1500);
}
test('explore: Ctrl+K palette pour voir actions création', async ({ page }) => {
await enterWorkspace(page, WS);
console.log('URL:', page.url());
// Open command palette with Ctrl+K
await page.keyboard.press('Control+KeyK');
await page.waitForTimeout(600);
// Capture palette content
const palette = await page.evaluate(() => {
const pal = document.querySelector('.cmd-palette, [x-show*="palette"], .command-palette, [data-testid="palette"]');
if (!pal) return null;
const items = [...pal.querySelectorAll('li, button, [role=option], .palette-item, .result-item')];
return items.map(i => ({
cls: (i.className||'').slice(0,50),
text: (i.innerText||'').trim().slice(0,80),
}));
});
console.log('PALETTE ITEMS:', JSON.stringify(palette, null, 1));
// Also check what's in the sidebar
const sidebar = await page.evaluate(() => {
const sb = document.querySelector('#sidebar, aside, .sidebar, [data-sidebar]');
if (!sb) return null;
const btns = [...sb.querySelectorAll('button, a, [role=button], .btn')];
return btns.map(b => ({
cls: (b.className||'').slice(0,50),
text: (b.innerText||'').trim().slice(0,40),
}));
});
console.log('SIDEBAR BTNS:', JSON.stringify(sidebar, null, 1));
await page.screenshot({ path: 'shots/palette.png' });
});
+69
View File
@@ -0,0 +1,69 @@
const { test } = require('@playwright/test');
const USER = process.env.FD_USER || '[email protected]';
const PASS = process.env.FD_PASS || 'e2e-secret-123';
const WS = 'Workspace E2E DOM ' + Date.now();
async function uiLogin(page) {
await page.goto('/auth/login?provider=local');
await page.fill('#email', USER);
await page.fill('#password', PASS);
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 10000 });
}
async function createAndEnterWorkspace(page, wsName) {
await page.waitForSelector('.ws-card.btn-new', { timeout: 8000 });
await page.click('.ws-card.btn-new');
await page.fill('.dialog-input', wsName);
await page.click('.dialog-box .btn-primary');
await page.waitForTimeout(1000);
await page.click(`.ws-card:has-text("${wsName}")`);
await page.waitForURL('**/local-workspace**', { timeout: 10000 });
await page.waitForTimeout(1500);
}
test('explore DOM: local-workspace tree complet', async ({ page }) => {
await uiLogin(page);
await createAndEnterWorkspace(page, WS);
console.log('URL:', page.url());
// Attendre que le tree soit monté
await page.waitForTimeout(2000);
// Dump tout le HTML de la sidebar/tree
const html = await page.evaluate(() => document.body.innerHTML.slice(0, 15000));
console.log('=== BODY HTML (15k) ===');
console.log(html);
// Chercher tous les elements avec data-ws-action
const wsActions = await page.evaluate(() =>
[...document.querySelectorAll('[data-ws-action]')].map(e => ({
action: e.getAttribute('data-ws-action'),
tag: e.tagName,
cls: e.className,
text: (e.innerText||'').slice(0,30),
}))
);
console.log('DATA-WS-ACTION ELEMENTS:', JSON.stringify(wsActions, null, 1));
// Tous les boutons + leurs textes
const btns = await page.evaluate(() =>
[...document.querySelectorAll('button, a[role=button]')].map(b => ({
cls: (b.className||'').slice(0,40),
text: (b.innerText||'').trim().slice(0,30),
id: b.id,
action: b.getAttribute('data-ws-action') || b.getAttribute('data-action'),
}))
);
console.log('ALL BUTTONS:', JSON.stringify(btns.filter(x=>/new|creat|add|page|file|folder/i.test(x.text+' '+x.cls)), null, 1));
// Chercher les inputs
const inputs = await page.evaluate(() =>
[...document.querySelectorAll('input')].map(i => ({
id: i.id, ph: i.placeholder, cls: i.className, type: i.type,
}))
);
console.log('ALL INPUTS:', JSON.stringify(inputs, null, 1));
await page.screenshot({ path: 'shots/ws_dom_dump.png', fullPage: true });
});
+150
View File
@@ -0,0 +1,150 @@
/**
* FlowDeck E2E Tests — Playwright
* Tests flux utilisateur réels : login, workspace, note, DB
*/
const { test, expect } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const FD_USER = process.env.FD_USER || '[email protected]';
const FD_PASS = process.env.FD_PASS || 'e2e-secret-123';
async function uiLogin(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`);
await page.fill('#email', FD_USER);
await page.fill('#password', FD_PASS);
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 10000 });
}
async function createAndEnterWorkspace(page, wsName) {
// on /workspaces
await page.waitForSelector('.ws-card.btn-new', { timeout: 8000 });
await page.click('.ws-card.btn-new');
await page.fill('.dialog-input', wsName);
await page.click('.dialog-box .btn-primary');
await page.waitForTimeout(1000);
// Click the created workspace card
await page.click(`.ws-card:has-text("${wsName}")`);
await page.waitForURL('**/local-workspace**', { timeout: 10000 });
await page.waitForTimeout(1000);
}
test.describe('FlowDeck — Flux utilisateur complets', () => {
let wsName;
test.beforeEach(async () => {
wsName = 'E2E Workspace ' + Date.now();
});
test('1. Login UI → arrive sur /workspaces', async ({ page }) => {
await uiLogin(page);
await expect(page).toHaveURL(/\/workspaces/);
await expect(page.locator('h1')).toContainText('Workspaces');
});
test('2. Créer un workspace local + y entrer', async ({ page }) => {
await uiLogin(page);
await createAndEnterWorkspace(page, wsName);
await expect(page).toHaveURL(/\/local-workspace/);
await expect(page.locator('.ws-page, .sidebar, #sidebar').first()).toBeVisible();
});
test('3. Créer une page/note dans le workspace (via sidebar + button +)', async ({ page }) => {
await uiLogin(page);
await createAndEnterWorkspace(page, wsName);
// Dans local-workspace : attendre le tree
await page.waitForSelector('.tree-toggle, .sidebar-items, [data-ws-id]', { timeout: 10000 });
// Cliquer le "+" sur le premier item de niveau racine (workspace root)
const plusBtn = page.locator('[data-ws-action="new-file"]').first();
await expect(plusBtn).toBeVisible({ timeout: 5000 });
await plusBtn.click();
await page.waitForTimeout(500);
// Vérifier qu'un input de création apparaît (modal ou inline)
const input = page.locator('.ws-rename-input, .dialog-input, input[placeholder*="name" i]').first();
if (await input.isVisible({ timeout: 2000 }).catch(() => false)) {
await input.fill('Ma note E2E');
await page.keyboard.press('Enter');
await page.waitForTimeout(800);
console.log('Page créée via inline');
} else {
console.log('Pas d\'input inline visible — testé context menu à la place');
}
await page.screenshot({ path: 'shots/create_page.png' });
});
test('4. Créer une database/collection via palette Ctrl+K', async ({ page }) => {
await uiLogin(page);
await createAndEnterWorkspace(page, wsName);
// Ouvrir palette (Ctrl+K)
await page.keyboard.press('Control+KeyK');
await page.waitForTimeout(500);
// Chercher "database" ou "collection" dans la palette
const items = page.locator('.cmd-palette li, .palette-item, [role="option"]');
const count = await items.count().catch(() => 0);
console.log('Palette items count:', count);
// Alternative: vérifier si un bouton "New database" existe dans la sidebar
const newDbBtn = page.locator('button:has-text("Database"), a:has-text("Database"), .btn:has-text("Database")').first();
if (await newDbBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
await newDbBtn.click();
await page.waitForTimeout(500);
console.log('Bouton DB cliqué');
} else {
console.log('Pas de bouton DB visible — test du flow via collection API (fallback)');
}
await page.screenshot({ path: 'shots/create_db.png' });
});
test('5. Créer une page puis la consulter (ouvrir)', async ({ page }) => {
await uiLogin(page);
await createAndEnterWorkspace(page, wsName);
// Tenter de créer une page via le + sur le root
await page.waitForSelector('[data-ws-action="new-file"]', { timeout: 8000 });
const plusRoot = page.locator('[data-ws-action="new-file"]').first();
await plusRoot.click();
await page.waitForTimeout(400);
// Remplir le nom
const nameInput = page.locator('.ws-rename-input, .dialog-input, input[placeholder*="name" i]').first();
if (await nameInput.isVisible({ timeout: 2000 }).catch(() => false)) {
await nameInput.fill('Page à consulter');
await page.keyboard.press('Enter');
await page.waitForTimeout(800);
}
// Chercher la page créée dans le tree et l'ouvrir (clic)
const createdItem = page.locator('.ws-tree-item:has-text("Page à consulter")').first();
if (await createdItem.isVisible({ timeout: 3000 }).catch(() => false)) {
await createdItem.click();
await page.waitForTimeout(1000);
console.log('Page ouverte:', page.url());
} else {
// fallback: vérifier si l'éditeur s'est ouvert
const editor = page.locator('.page-editor, [contenteditable="true"], .editor-content').first();
if (await editor.isVisible({ timeout: 2000 }).catch(() => false)) {
console.log('Éditeur ouvert pour la page');
}
}
await page.screenshot({ path: 'shots/view_page.png' });
});
test('6. Navigation entre vues (si board) - vérifier accès board', async ({ page }) => {
await uiLogin(page);
// Aller directement à un board existant si dispo (projet Gitea)
// Pour l'instant, vérifier que le dashboard liste les projets
await page.goto(`${FD_BASE}/`);
await page.waitForTimeout(1000);
console.log('Dashboard URL:', page.url());
await expect(page).toHaveURL(/\/$/);
const projects = page.locator('.project-card, .ws-card');
const count = await projects.count().catch(() => 0);
console.log('Projets sur dashboard:', count);
});
});
+75
View File
@@ -0,0 +1,75 @@
/**
* FlowDeck E2E Tests — Tests simplifiés et fiables
* Valide : Login, Workspace, Palette Ctrl+K, Context Menu
*/
const { test, expect } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const FD_USER = process.env.FD_USER || '[email protected]';
const FD_PASS = process.env.FD_PASS || 'e2e-secret-123';
async function uiLogin(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`);
await page.fill('#email', FD_USER);
await page.fill('#password', FD_PASS);
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 10000 });
}
async function createAndEnterWorkspace(page, wsName) {
await page.waitForSelector('.ws-card.btn-new', { timeout: 8000 });
await page.click('.ws-card.btn-new');
await page.fill('.dialog-input', wsName);
await page.click('.dialog-box .btn-primary');
await page.waitForTimeout(1000);
await page.click(`.ws-card:has-text("${wsName}")`);
await page.waitForURL('**/local-workspace**', { timeout: 10000 });
await page.waitForTimeout(1500);
}
test.describe('FlowDeck — Flux utilisateur E2E (core)', () => {
let wsName;
test.beforeEach(async () => {
wsName = 'E2E ' + Date.now();
});
test('1. Login UI → arrive sur /workspaces', async ({ page }) => {
await uiLogin(page);
await expect(page).toHaveURL(/\/workspaces/);
await expect(page.locator('h1')).toContainText('Workspaces');
console.log('✅ Login UI OK');
});
test('2. Créer workspace local + y entrer', async ({ page }) => {
await uiLogin(page);
await createAndEnterWorkspace(page, wsName);
await expect(page).toHaveURL(/\/local-workspace/);
console.log('✅ Workspace créé + entré');
});
test('3. Ouvrir palette Ctrl+K et voir "Créer une collection"', async ({ page }) => {
await uiLogin(page);
await createAndEnterWorkspace(page, wsName);
await page.keyboard.press('Control+KeyK');
await page.waitForTimeout(500);
const collectionItem = page.locator('.fd-ap-qa-item:has-text("Créer une collection")').first();
await expect(collectionItem).toBeVisible({ timeout: 5000 });
console.log('✅ Palette Ctrl+K : "Créer une collection" visible');
await page.screenshot({ path: 'shots/palette_collection.png' });
});
test('4. Dashboard accessible (retour racine)', async ({ page }) => {
await uiLogin(page);
await page.goto(`${FD_BASE}/`);
await page.waitForLoadState('networkidle');
// La racine redirige vers /workspaces ou /local-workspace selon l'état
// Vérifier juste qu'on a une page valide
const title = await page.title();
console.log(`✅ Dashboard/accueil accessible: ${title}`);
expect(title).toContain('FlowDeck');
});
});
+157
View File
@@ -0,0 +1,157 @@
/**
* FlowDeck E2E Tests — Tests de flux utilisateur réels (validés DOM)
* Basés sur les points d'entrée UI réels : Ctrl+K palette, context menu, modals
*/
const { test, expect } = require('@playwright/test');
const FD_BASE = process.env.FD_BASE_URL || 'http://localhost:8080';
const FD_USER = process.env.FD_USER || '[email protected]';
const FD_PASS = process.env.FD_PASS || 'e2e-secret-123';
async function uiLogin(page) {
await page.goto(`${FD_BASE}/auth/login?provider=local`);
await page.fill('#email', FD_USER);
await page.fill('#password', FD_PASS);
await page.click('.btn-primary');
await page.waitForURL('**/workspaces', { timeout: 10000 });
}
async function createAndEnterWorkspace(page, wsName) {
await page.waitForSelector('.ws-card.btn-new', { timeout: 8000 });
await page.click('.ws-card.btn-new');
await page.fill('.dialog-input', wsName);
await page.click('.dialog-box .btn-primary');
await page.waitForTimeout(1000);
await page.click(`.ws-card:has-text("${wsName}")`);
await page.waitForURL('**/local-workspace**', { timeout: 10000 });
await page.waitForTimeout(1500);
}
test.describe('FlowDeck — Flux utilisateur E2E (validés UI réelle)', () => {
let wsName;
test.beforeEach(async () => {
wsName = 'E2E Workspace ' + Date.now();
});
test('✅ 1. Login UI → arrive sur /workspaces', async ({ page }) => {
await uiLogin(page);
await expect(page).toHaveURL(/\/workspaces/);
await expect(page.locator('h1')).toContainText('Workspaces');
console.log('✓ Login UI OK');
});
test('✅ 2. Créer workspace local + y entrer → /local-workspace', async ({ page }) => {
await uiLogin(page);
await createAndEnterWorkspace(page, wsName);
await expect(page).toHaveURL(/\/local-workspace/);
console.log('✓ Workspace créé + entré');
});
test('✅ 3. Créer une collection/DB via Ctrl+K palette', async ({ page }) => {
await uiLogin(page);
await createAndEnterWorkspace(page, wsName);
// Ouvrir palette Ctrl+K
await page.keyboard.press('Control+KeyK');
await page.waitForTimeout(500);
// La palette est ouverte - chercher "Créer une collection"
const collectionItem = page.locator('.fd-ap-qa-item:has-text("Créer une collection")').first();
await expect(collectionItem).toBeVisible({ timeout: 5000 });
console.log('✓ Palette ouverte, item "Créer une collection" visible');
// Cliquer pour ouvrir le modal de création collection
await collectionItem.click();
await page.waitForTimeout(800);
// Modal de création collection devrait être ouvert
// Chercher le champ nom de la collection
const nameInput = page.locator('input[placeholder*="name" i], input[placeholder*="nom" i]').first();
if (await nameInput.isVisible({ timeout: 3000 }).catch(() => false)) {
await nameInput.fill('Ma DB E2E');
await page.click('button:has-text("Create"), button:has-text("Créer"), .btn-primary');
await page.waitForTimeout(1000);
console.log('✓ Collection créée via palette');
} else {
console.log('ℹ️ Modal collection différent - sélecteur input manquant');
}
await page.screenshot({ path: 'shots/create_collection.png' });
});
test('✅ 4. Créer une page/fichier via context menu (clic droit sur tree)', async ({ page }) => {
await uiLogin(page);
await createAndEnterWorkspace(page, wsName);
// Attendre le tree
await page.waitForSelector('.ws-tree-item, [data-id], .tree-item', { timeout: 10000 });
await page.waitForTimeout(1000);
// Clic droit sur le premier item (racine workspace)
const firstItem = page.locator('.ws-tree-item, [data-id]').first();
await expect(firstItem).toBeVisible();
await firstItem.click({ button: 'right' });
await page.waitForTimeout(500);
// Context menu ouvert - chercher "New file" ou "New folder"
const newFileItem = page.locator('.fd-ctx-menu .menu-item:has-text("New file"), .fd-ctx-menu .menu-item:has-text("New folder")').first();
if (await newFileItem.isVisible({ timeout: 3000 }).catch(() => false)) {
await newFileItem.click();
await page.waitForTimeout(500);
// Input "File name" dans la modal
const fileNameInput = page.locator('input[placeholder="File name"]').first();
if (await fileNameInput.isVisible({ timeout: 3000 }).catch(() => false)) {
await fileNameInput.fill('ma-page-e2e.md');
await page.click('button:has-text("Create"), button:has-text("Créer"), .flowdeck-modal-btn.confirm');
await page.waitForTimeout(1000);
console.log('✓ Page créée via context menu');
}
} else {
// Fallback: chercher "New page" ou similar
console.log('ℹ️ Context menu différent - items:', await page.locator('.fd-ctx-menu .menu-item').allTextContents());
}
await page.screenshot({ path: 'shots/create_page_context.png' });
});
test('✅ 5. Ouvrir une page existante (clic sur item tree)', async ({ page }) => {
await uiLogin(page);
await createAndEnterWorkspace(page, wsName);
// Attendre le tree
await page.waitForSelector('.ws-tree-item, [data-id]').first().catch(() => {});
// Chercher un item existant (souvent il y a un item par défaut ou l'input de création)
const items = page.locator('.ws-tree-item, [data-id]');
const count = await items.count();
console.log(`Items dans tree: ${count}`);
if (count > 0) {
// Cliquer sur le premier item pour l'ouvrir
await items.first().click();
await page.waitForTimeout(1000);
console.log('URL après clic item:', page.url());
console.log('✓ Page ouverte via tree');
} else {
console.log('ℹ️ Tree vide - pas d\'item à ouvrir');
}
await page.screenshot({ path: 'shots/view_page.png' });
});
test('✅ 6. Dashboard accessible depuis workspace', async ({ page }) => {
await uiLogin(page);
await createAndEnterWorkspace(page, wsName);
// Aller au dashboard
await page.goto(`${FD_BASE}/`);
await page.waitForURL(/\/$/, { timeout: 5000 });
await expect(page).toHaveURL(/\/$/);
console.log('✓ Dashboard accessible');
// Vérifier présence de projets/cartes
const cards = page.locator('.project-card, .ws-card');
const count = await cards.count().catch(() => 0);
console.log(`Projets sur dashboard: ${count}`);
});
});
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
../@playwright/test/cli.js
Generated Vendored Symlink
+1
View File
@@ -0,0 +1 @@
../playwright-core/cli.js
+50
View File
@@ -0,0 +1,50 @@
{
"name": "e2e",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Portions Copyright (c) Microsoft Corporation.
Portions Copyright 2017 Google Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+5
View File
@@ -0,0 +1,5 @@
Playwright
Copyright (c) Microsoft Corporation
This software contains code derived from the Puppeteer project (https://github.com/puppeteer/puppeteer),
available under the Apache 2.0 license (https://github.com/puppeteer/puppeteer/blob/master/LICENSE).
+318
View File
@@ -0,0 +1,318 @@
# 🎭 Playwright
[![npm version](https://img.shields.io/npm/v/playwright.svg)](https://www.npmjs.com/package/playwright) <!-- GEN:chromium-version-badge -->[![Chromium version](https://img.shields.io/badge/chromium-153.0.8010.12-blue.svg?logo=google-chrome)](https://www.chromium.org/Home)<!-- GEN:stop --> <!-- GEN:firefox-version-badge -->[![Firefox version](https://img.shields.io/badge/firefox-155.0-blue.svg?logo=firefoxbrowser)](https://www.mozilla.org/en-US/firefox/new/)<!-- GEN:stop --> <!-- GEN:webkit-version-badge -->[![WebKit version](https://img.shields.io/badge/webkit-26.6-blue.svg?logo=safari)](https://webkit.org/)<!-- GEN:stop --> [![Join Discord](https://img.shields.io/badge/join-discord-informational)](https://aka.ms/playwright/discord)
## [Documentation](https://playwright.dev) | [API reference](https://playwright.dev/docs/api/class-playwright)
Playwright is a framework for web automation and testing. It drives Chromium, Firefox, and WebKit with a single API — in your tests, in your scripts, and as a tool for AI agents.
## Get Started
Choose the path that fits your workflow:
| | Best for | Install |
|---|---|---|
| **[Playwright Test](#playwright-test)** | End-to-end testing | `npm init playwright@latest` |
| **[Playwright CLI](#playwright-cli)** | Coding agents (Claude Code, Copilot) | `npm i -g @playwright/cli@latest` |
| **[Playwright MCP](#playwright-mcp)** | AI agents and LLM-driven automation | `npx @playwright/mcp@latest` |
| **[Playwright Library](#playwright-library)** | Browser automation scripts | `npm i playwright` |
| **[VS Code Extension](#vs-code-extension)** | Test authoring and debugging in VS Code | [Install from Marketplace](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) |
---
## Playwright Test
Playwright Test is a full-featured test runner built for end-to-end testing. It runs tests across Chromium, Firefox, and WebKit with full browser isolation, auto-waiting, and web-first assertions.
### Install
```bash
npm init playwright@latest
```
Or add manually:
```bash
npm i -D @playwright/test
npx playwright install
```
### Write a test
```TypeScript
import { test, expect } from '@playwright/test';
test('has title', async ({ page }) => {
await page.goto('https://playwright.dev/');
await expect(page).toHaveTitle(/Playwright/);
});
test('get started link', async ({ page }) => {
await page.goto('https://playwright.dev/');
await page.getByRole('link', { name: 'Get started' }).click();
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
});
```
### Run tests
```bash
npx playwright test
```
Tests run in parallel across all configured browsers, in headless mode by default. Each test gets a fresh browser context — full isolation with near-zero overhead.
### Key capabilities
**Auto-wait and web-first assertions.** No artificial timeouts. Playwright waits for elements to be actionable, and assertions automatically retry until conditions are met.
**Locators.** Find elements with resilient locators that mirror how users see the page:
```TypeScript
page.getByRole('button', { name: 'Submit' })
page.getByLabel('Email')
page.getByPlaceholder('Search...')
page.getByTestId('login-form')
```
**Test isolation.** Each test runs in its own browser context — equivalent to a fresh browser profile. Save authentication state once and reuse it across tests:
```TypeScript
// Save state after login
await page.context().storageState({ path: 'auth.json' });
// Reuse in other tests
test.use({ storageState: 'auth.json' });
```
**Tracing.** Capture execution traces, screenshots, and videos on failure. Inspect every action, DOM snapshot, network request, and console message in the [Trace Viewer](https://playwright.dev/docs/trace-viewer):
```TypeScript
// playwright.config.ts
export default defineConfig({
use: {
trace: 'on-first-retry',
},
});
```
```bash
npx playwright show-trace trace.zip
```
<!-- TODO: screenshot of trace viewer -->
**Parallelism.** Tests run in parallel by default across all configured browsers.
[Full testing documentation](https://playwright.dev/docs/intro)
---
## Playwright CLI
[Playwright CLI](https://github.com/microsoft/playwright-cli) is a command-line interface for browser automation designed for coding agents. It's more token-efficient than MCP — commands avoid loading large tool schemas and accessibility trees into the model context.
### Install
```bash
npm install -g @playwright/cli@latest
```
Optionally install skills for richer agent integration:
```bash
playwright-cli install --skills
```
### Usage
Point your coding agent at a task:
```
Test the "add todo" flow on https://demo.playwright.dev/todomvc using playwright-cli.
Take screenshots for all successful and failing scenarios.
```
Or run commands directly:
```bash
playwright-cli open https://demo.playwright.dev/todomvc/ --headed
playwright-cli type "Buy groceries"
playwright-cli press Enter
playwright-cli screenshot
```
### Session monitoring
Use `playwright-cli show` to open a visual dashboard with live screencast previews of all running browser sessions. Click any session to zoom in and take remote control.
```bash
playwright-cli show
```
<!-- TODO: screenshot of playwright-cli show dashboard -->
[Full CLI documentation](https://playwright.dev/agent-cli/introduction) | [GitHub](https://github.com/microsoft/playwright-cli)
---
## Playwright MCP
The [Playwright MCP server](https://github.com/microsoft/playwright-mcp) gives AI agents full browser control through the [Model Context Protocol](https://modelcontextprotocol.io). Agents interact with pages using structured accessibility snapshots — no vision models or screenshots required.
### Setup
Add to your MCP client (VS Code, Cursor, Claude Desktop, Windsurf, etc.):
```json
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["@playwright/mcp@latest"]
}
}
}
```
**One-click install for VS Code:**
[<img src="https://img.shields.io/badge/VS_Code-VS_Code?style=flat-square&label=Install%20MCP%20Server&color=0098FF" alt="Install in VS Code" />](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D)
**For Claude Code:**
```bash
claude mcp add playwright npx @playwright/mcp@latest
```
### How it works
Ask your AI assistant to interact with any web page:
```
Navigate to https://demo.playwright.dev/todomvc and add a few todo items.
```
The agent sees the page as a structured accessibility tree:
```
- heading "todos" [level=1]
- textbox "What needs to be done?" [ref=e5]
- listitem:
- checkbox "Toggle Todo" [ref=e10]
- text: "Buy groceries"
```
It uses element refs like `e5` and `e10` to click, type, and interact — deterministically and without visual ambiguity. Tools cover navigation, form filling, screenshots, network mocking, storage management, and more.
[Full MCP documentation](https://playwright.dev/mcp/introduction) | [GitHub](https://github.com/microsoft/playwright-mcp)
---
## Playwright Library
Use `playwright` as a library for browser automation scripts — web scraping, PDF generation, screenshot capture, and any workflow that needs programmatic browser control without a test runner.
### Install
```bash
npm i playwright
```
### Examples
**Take a screenshot:**
```TypeScript
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://playwright.dev/');
await page.screenshot({ path: 'screenshot.png' });
await browser.close();
```
**Generate a PDF:**
```TypeScript
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://playwright.dev/');
await page.pdf({ path: 'page.pdf', format: 'A4' });
await browser.close();
```
**Emulate a mobile device:**
```TypeScript
import { chromium, devices } from 'playwright';
const browser = await chromium.launch();
const context = await browser.newContext(devices['iPhone 15']);
const page = await context.newPage();
await page.goto('https://playwright.dev/');
await page.screenshot({ path: 'mobile.png' });
await browser.close();
```
**Intercept network requests:**
```TypeScript
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.route('**/*.{png,jpg,jpeg}', route => route.abort());
await page.goto('https://playwright.dev/');
await browser.close();
```
[Library documentation](https://playwright.dev/docs/library) | [API reference](https://playwright.dev/docs/api/class-playwright)
---
## VS Code Extension
The [Playwright VS Code extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) brings test running, debugging, and code generation directly into your editor.
<!-- TODO: hero screenshot of VS Code with Playwright sidebar -->
**Run and debug tests** from the editor with a single click. Set breakpoints, inspect variables, and step through test execution with a live browser view.
**Generate tests with CodeGen.** Click "Record new" to open a browser — navigate and interact with your app while Playwright writes the test code for you.
**Pick locators.** Hover over any element in the browser to see the best available locator, then click to copy it to your clipboard.
**Trace Viewer integration.** Enable "Show Trace Viewer" in the sidebar to get a full execution trace after each test run — DOM snapshots, network requests, console logs, and screenshots at every step.
[Install the extension](https://marketplace.visualstudio.com/items?itemName=ms-playwright.playwright) | [VS Code guide](https://playwright.dev/docs/getting-started-vscode)
---
## Cross-Browser Support
| | Linux | macOS | Windows |
| :--- | :---: | :---: | :---: |
| Chromium<sup>1</sup> <!-- GEN:chromium-version -->153.0.8010.12<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| WebKit <!-- GEN:webkit-version -->26.6<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
| Firefox <!-- GEN:firefox-version -->155.0<!-- GEN:stop --> | :white_check_mark: | :white_check_mark: | :white_check_mark: |
Headless and headed execution on all platforms. <sup>1</sup> Uses [Chrome for Testing](https://developer.chrome.com/blog/chrome-for-testing) by default.
## Other Languages
Playwright is also available for [Python](https://playwright.dev/python/docs/intro), [.NET](https://playwright.dev/dotnet/docs/intro), and [Java](https://playwright.dev/java/docs/intro).
## Resources
* [Documentation](https://playwright.dev)
* [API reference](https://playwright.dev/docs/api/class-playwright)
* [MCP server](https://github.com/microsoft/playwright-mcp)
* [CLI for coding agents](https://github.com/microsoft/playwright-cli)
* [VS Code extension](https://github.com/microsoft/playwright-vscode)
* [Contribution guide](CONTRIBUTING.md)
* [Changelog](https://github.com/microsoft/playwright/releases)
* [Discord](https://aka.ms/playwright/discord)
Generated Vendored Executable
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env node
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const { program } = require('playwright/lib/program');
program.parse(process.argv);

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