Compare commits

..
38 Commits
Author SHA1 Message Date
bruno 80f56acf4c feat(v2.1.0): API publique + Webhooks sortants + PWA
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Public API: /api/v1 (collections, pages, my-tasks) with token auth
- Token generation: POST /api/v1/token
- Outbound webhooks: /workspace/webhooks CRUD + fire_event dispatcher
- PWA: manifest.json endpoint
- CSRF exempt for /api/v1
- 73/73 tests passent (+6 tests v2.1)
- Version 2.0.0 → 2.1.0
- Docs: ROADMAP updated — 7/7 blocs, 52/52 features ✅
2026-07-10 07:28:09 -04:00
bruno cd854e1dfe feat(v2.0.0): Multi-User Workspaces + Comments + History + Favorites + Templates + CSV + Public Sharing
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
- 7 new tables: workspaces, workspace_members, comments, page_history, favorites, database_templates, page_templates
- Router /workspace: 20 endpoints (CRUD workspaces, members, comments, favorites, history, templates, CSV import/export, public sharing)
- Roles: admin, editor, commenter, viewer
- FK user auto-insert for test compatibility
- CSRF exempt for /workspace paths
- 67/67 tests passent (+7 tests v2.0)
- Version 1.9.0 → 2.0.0
- Docs: ROADMAP updated (7/7 blocs completed)
2026-07-10 07:22:34 -04:00
bruno f752ae2ed7 docs: ROADMAP v1.9.0 ✅ — 5 blocs complétés (37/52)
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-09 23:09:05 -04:00
bruno ad95c87b01 feat(v1.9.0): My Tasks — dashboard cross-collection unifié
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Nouveau router my_tasks.py: GET /my-tasks (HTML), GET /my-tasks/api (JSON)
- Agrège toutes les pages assignées à l'utilisateur sur toutes les collections
- Vues: All, Today, Overdue, Next 7 days
- Groupement par collection, filter admin auto
- 60/60 tests passent (+4 tests v1.9)
- Version 1.8.0 → 1.9.0
2026-07-09 23:08:49 -04:00
bruno 4a6ed24315 feat(v1.7.0+v1.8.0): Vues Améliorées + Sub-items & Dépendances
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
v1.7.0 — View Management:
- PUT /db/views/{id}/config (group_by, card_size, cover, visible_properties)
- POST /db/{id}/views/save-as (save current state as new view)
- GET /db/{id}/views/api (list views)

v1.8.0 — Sub-items & Dependencies:
- parent_id auto-référence: GET/POST /db/{id}/pages/{pid}/sub-items
- Status aggregation: GET .../status-aggregate
- Dependencies: POST .../dependencies, POST .../check-deps (blocking constraint)
- 56/56 tests passent (+7 tests)
- Version 1.6.0 → 1.8.0
2026-07-09 23:06:53 -04:00
bruno e725398543 feat(v1.6.0): Vues Manquantes — Calendar, Gallery, List, Timeline, Table SSR
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- 4 nouvelles vues HTML: calendar (grid mensuel), gallery (cartes), list (compacte), timeline (Gantt)
- Navigation entre vues via tabs (Table/Board/Calendar/Gallery/List/Timeline)
- _render_view dispatcher + _base_html template commun
- Routes: GET /db/{id}/view/{calendar,gallery,list,timeline}
- Default view = table (SSR avec properties)
- 49/49 tests passent (+6 tests v1.6)
- Version 1.5.0 → 1.6.0
2026-07-09 23:03:51 -04:00
bruno b8647f1a19 feat(v1.5.0): Relations, Rollups, Formulas — FormulaEngine, RollupEngine, API
FlowDeck CI / test (push) Failing after 15s
FlowDeck CI / docker (push) Has been skipped
- FormulaEngine: 19 functions (prop, if, concat, round, now, today, dateAdd, replace, ...)
- RollupEngine: 12 aggregations (count, sum, avg, min, max, range, unique, percent_checked)
- API relation: POST /db/{id}/properties/relation + /link (bidirectional)
- API rollup: POST /db/rollup/compute
- API formula: POST /db/formula/evaluate
- FKs fix: related_collection_id/relation_property_id/target_property_id → ON DELETE SET NULL
- 43/43 tests passent (+5 tests v1.5)
- Version 1.4.0 → 1.5.0
- Docs: ROADMAP, CHANGELOG à jour
2026-07-09 22:48:41 -04:00
bruno 16532d10bd feat(v1.4.0): Propriétés Avancées — collection_properties, 21 types, API CRUD
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Table collection_properties (21 types Notion: number, checkbox, url, email, phone, status, files, unique_id, ...)
- Auto-propriétés: created_time, created_by, last_edited_time, last_edited_by
- Service property_types.py: validation, formatage, auto-values
- API: GET /db/property-types/api, CRUD /db/{id}/properties/api, PUT/DELETE /db/properties/{id}/api
- 38/38 tests passent (+4 nouveaux tests v1.4)
- Version 1.3.0 → 1.4.0
- Docs: ROADMAP, CHANGELOG, WORKLOAD, README à jour
2026-07-09 22:43:16 -04:00
bruno d94817bac5 docs: ROADMAP v1.3.0 ✅ + WORKLOAD/README updated
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- ROADMAP: v1.3.0 déplacé dans Completed, item checkboxes [x]
- WORKLOAD: réécrit pour refléter v1.3.0 + plan v1.4.0
- README: version 1.3.0, features à jour, tests 34/34
2026-07-09 22:40:19 -04:00
bruno c574d2c8b5 feat(v1.3.0): Database Concept — collections, pages, views, GiteaBoardCompat
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
Database foundation:
- New tables: collections, collection_pages, collection_views
- Router /db with full CRUD API
- GiteaBoardCompat adapter for legacy board compatibility
- CSRF exemption for /db/ routes
- 34/34 tests pass
- Version bump 1.0.0 → 1.3.0
- Deployed and verified on Docker port 8080
2026-07-09 22:33:39 -04:00
bruno b9723dffab feat(v1.3.0): Step 3 — Router /db avec API CRUD collections + pages
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Nouveau router app/routers/collections.py
- GET/POST/PUT/DELETE /db pour les collections
- POST/GET/PUT/DELETE pour les pages dans une collection
- Vue HTML basique par collection + vue par défaut auto-créée
- CSRF exempté pour /db/
- 5 nouveaux tests (collections list, CRUD, pages CRUD, validation, HTML render)
- 33/33 tests passent
2026-07-09 22:30:05 -04:00
bruno 3967c7df85 feat(v1.3.0): Step 2 — DB tables collections, collection_pages, collection_views
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Ajout des 3 nouvelles tables pour le Database Concept
- collections: base de données abstraite, indépendante de Gitea
- collection_pages: pages génériques avec parent_id et property_values_json
- collection_views: vues configurables par collection
- Mise à jour test_db_tables_exist pour inclure les nouvelles tables
- 28/28 tests passent
2026-07-09 22:26:45 -04:00
bruno 66ae1289a9 docs: ROADMAP v1.3-v2.1 + ARCHITECTURE v2.0 complète
FlowDeck CI / test (push) Failing after 6s
FlowDeck CI / docker (push) Has been skipped
ROADMAP.md:
- 52 fonctionnalités manquantes organisées en 7 phases (v1.3 → v2.1)
- v1.3: Database Concept (collections, pages, views)
- v1.4-v1.5: Propriétés avancées (number, checkbox, status, relation, rollup, formula)
- v1.6-v1.7: Vues manquantes (calendar, timeline, gallery, list, group_by, filters)
- v1.8: Sub-items & Dépendances
- v1.9: My Tasks & Dashboard unifié
- v2.0: Éditeur complet + Multi-utilisateurs
- v2.1: Intégrations avancées (API, webhooks, n8n, mobile PWA)

ARCHITECTURE.md (63 Ko, 15 sections):
- Architecture système complète (schéma ASCII détaillé)
- Modèle de données: 22 tables SQL (existantes + nouvelles)
- Diagramme des relations entre tables
- Routes API complètes: 80+ endpoints documentés
- Frontend: layout, 6 vues, composants réutilisables
- Authentification OAuth2 + modèle de permissions multi-user
- Intégration Gitea: flux, sync, adaptateur de compatibilité
- Système de propriétés: 18 types, formula engine, rollup engine
- Système de vues: config JSON, chaîne de rendu
- Sub-items: auto-référence, affichage imbriqué
- Dépendances: contraintes, flèches timeline
- My Tasks: algorithme d'agrégation cross-collection
- Éditeur de blocs: 15+ block types, format JSON
- Multi-utilisateurs: workspaces, ACL, historique, commentaires
- Déploiement: Docker Compose + migration PostgreSQL
2026-07-09 21:38:28 -04:00
bruno 1dea15ad90 docs: Guide Notion Database & Tasks — analyse complète pour FlowDeck
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Anatomie des Databases Notion (Collection/CollectionView/Pages)
- 21 types de propriétés, 6 types de vues, Relations & Rollups
- Architecture des Tasks (sub-items auto-référencés, dépendances)
- Plan d'implémentation en 6 phases avec SQL, Python et HTML
- Rétrocompatibilité avec les boards Gitea existants
2026-07-09 17:41:21 -04:00
bruno f04003f060 Add guide for cloning Notion databases and tasks
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
Also add screenshots of Notion trash menu and library section.
2026-07-09 16:40:20 -04:00
bruno 780cccf79f feat: Notion sidebar complete — 7 sections, nav icons, auto-update title
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Sidebar: 7 collapsible sections (Meetings, Recents, Favorites, Agents, Shared, Private, Notion apps)
- Nav icon row: Home with text + icon, Chat/Inbox/Search icon-only, collapse on right
- Notion apps: Library, My Tasks, Trash, Help
- Footer: New chat Ctrl+O + New page buttons
- Auto-update: sidebar items reflect title changes immediately on save
- CSS: nav-icon-btn, home-btn, collapse-btn, empty-hint, outline none
2026-07-09 16:39:22 -04:00
bruno c02e00726e Add Notion navigation menu screenshots
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-09 15:48:26 -04:00
bruno a585587e4c feat: Notion-style block editor v6 + sidebar refactoring
FlowDeck CI / test (push) Failing after 6s
FlowDeck CI / docker (push) Has been skipped
Block editor:
- Vanilla JS rendering (renderBlock) — zero Alpine x-for on blocks
- 15 block types with Notion placeholders (Heading 1-4, Toggle list, Empty quote, etc.)
- Event delegation keydown handler on container
- sync() before every render() — content preserved across Enter/Backspace/type changes
- Slash menu with event delegation, single innerHTML set, window.E for applySlash
- Markdown shortcuts use idx (not bid) to avoid stale references after render
- data-bid only on contentEditable (not .block-wrapper) — fixes '+' artifact
- .empty class + outline:none on block-content

Sidebar:
- Notion-style collapsible sections (Recents/Private/Shared/Notion apps)
- Workspace header with chevron, Search tab, Home tab
- Sticky footer with '+ New page' button
- sectionsOpen/toggleSection/toggleWorkspaceMenu/openQuickFind in appState
- CSS: uppercase section headers, 240px width, collapsed 56px mode, border-right
2026-07-09 15:47:43 -04:00
bruno 8aa1a703b5 Add screenshots of empty editor states
FlowDeck CI / test (push) Failing after 9s
FlowDeck CI / docker (push) Has been skipped
2026-07-09 14:28:31 -04:00
bruno 205f09838f fix: block editor visibility — flex layout + 400ms init delay
FlowDeck CI / test (push) Failing after 3s
FlowDeck CI / docker (push) Has been skipped
- page-editor-wrapper: min-height:0 + display:flex column to prevent collapse
- blocks-container: flex:1 to fill remaining space
- init setTimeout: 200→400ms for reliable Alpine render
- Removed min-height:100% that caused overflow clipping
2026-07-09 11:40:44 -04:00
bruno c8c08f5589 Add comprehensive Notion sidebar development guide
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-09 11:30:05 -04:00
bruno d4cd45c6cb feat: Notion-style block editor v1 + Move to Trash + fixes
FlowDeck CI / test (push) Failing after 8s
FlowDeck CI / docker (push) Has been skipped
- Block editor: 13 block types with contentEditable + vanilla JS event delegation
- Slash menu /: 14 commands, 100% vanilla JS (zero Alpine conflicts)
- Keyboard: Enter=new block, Shift+Enter=soft break, arrows navigate blocks
- Drag & drop: SortableJS with 6-dot handles on block-wrapper
- Move to Trash: DELETE /board/api/pages/{id} + instant sidebar cleanup
- Placeholders: 'New page' + 'Type / for commands' in gray (CSS :empty::before)
- Redirect after delete stays in project context via workspaceKey
- Fixes: title auto-save, cursor positioning, x-for keys, Sortable guard
2026-07-09 11:29:36 -04:00
bruno 06cdec52fa Add comprehensive guide on recreating the Notion editor
FlowDeck CI / test (push) Failing after 6s
FlowDeck CI / docker (push) Has been skipped
2026-07-09 08:20:43 -04:00
bruno 755404fbb0 feat: sidebar tree navigation — toggle chevrons + drag & drop
FlowDeck CI / test (push) Failing after 3s
FlowDeck CI / docker (push) Has been skipped
- Add sort_order column to pages table for drag & drop reordering
- Rewrite _sidebar_data with recursive _build_page_tree for nested hierarchy
- Add tree toggle buttons (▶ collapsed / ▼ expanded) on parent items
- Alpine.js expandedNodes state with toggleTreeNode() for expand/collapse
- Jinja2 recursive macro render_tree_item for proper nesting + indentation
- SortableJS integration on .sidebar-items for drag & drop repositioning
- New PUT /board/api/pages/{id}/move API for persisting moves
- Auto-set sort_order on page creation (max+1 for same parent)
- CSS: .tree-toggle, .tree-toggle-placeholder, grab cursor on page-icon
- 28/28 tests pass, all endpoints healthy
2026-07-08 22:27:09 -04:00
bruno c1dba816de Add Notion files tree diagram image
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
2026-07-08 21:58:52 -04:00
bruno aa3d5d87c8 feat: account management panel — /accounts + /api/users/me
FlowDeck CI / test (push) Failing after 3s
FlowDeck CI / docker (push) Has been skipped
- Page /accounts: profil utilisateur éditable, comptes connectés, workspace members
- API GET/PUT /api/users/me: lecture/écriture profil local (full_name, email)
- Users list from local DB, auth via Gitea OAuth2
- Imports SessionManager clean dans api.py
2026-07-08 16:09:20 -04:00
bruno 4c49d43c66 feat: sous-pages — parent_id, sidebar hiérarchique, bouton +New sub-page
FlowDeck CI / test (push) Failing after 3s
FlowDeck CI / docker (push) Has been skipped
- DB: parent_id dans table pages (ALTER TABLE migration auto)
- Sidebar: pages parent_id IS NULL affichées, sous-pages indentées en dessous
- API: create_page accepte parent_id (0 = racine)
- JS: newSubPage extrait page ID et passe parent_id
- page_editor: liste les sous-pages + bouton '+ New sub-page'
2026-07-08 16:07:25 -04:00
bruno b826cfc247 fix: auto-save met à jour le titre du sidebar sans recharger la page
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- sidebar items: attribut data-page-id ajouté pour ciblage DOM
- save(): après PUT réussi, met à jour .page-name dans le sidebar
- saveTitle(): reload la page (changement de titre = reload ok)
2026-07-08 16:01:24 -04:00
bruno c00442c693 fix: PUT /board/api/pages exempté CSRF + reload après saveTitle
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- CSRF middleware: excluded_paths utilise maintenant startswith (pas exact match)
- /board/api/pages/* exempté — les PUT depuis page_editor passent
- saveTitle() reload la page après sauvegarde → sidebar mis à jour
2026-07-08 15:57:25 -04:00
bruno 1d2c2c0919 fix: context menu implémenté + page editor CSRF + workspace context
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Context menu: toutes les actions fonctionnelles (favorite, copyLink, duplicate, rename, move, delete, openTab)
- Page editor: CSRF token ajouté dans save() et saveTitle()
- Page editor: workspace context restauré (owner/repo extrait de pages.workspace)
- Route /pages/{id}: utilise board._sidebar_data pour préserver le contexte projet
2026-07-08 15:51:24 -04:00
bruno 92dad6db1f fix: topbar utilisait Jinja2 au lieu d'Alpine — breadcrumb, titre, edited
FlowDeck CI / test (push) Failing after 8s
FlowDeck CI / docker (push) Has been skipped
- Remplacé {{ workspaceName }}, {{ currentPage }}, {{ pageIcon }}, {{ pageTitle }}, {{ lastEdited }} par x-text
- Topbar, breadcrumb, context menu affichent maintenant le contenu dynamique
- workspace_key correctement défini (bruno/flowdeck sur board, vide sur dashboard)
2026-07-08 15:45:06 -04:00
bruno 4eaec52d66 feat: workspace contextuel — pages liées au projet, sidebar vues cliquables
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- pages.workspace = owner/repo (ex: bruno/flowdeck) au lieu du nom user
- Sidebar: Kanban board + 4 sous-vues avec URLs distinctes (?view=kanban|table|...)
- Cliquer sur 'Table view' dans sidebar → change la vue à droite
- Dashboard (/): sidebar montre uniquement les projets Gitea
- Board (/board/{o}/{r}): sidebar montre les pages de ce projet
- Changement de projet = changement de contexte (pages isolées)
- newPage/newSubPage passent workspace_key à l'API
- Auto-switch view depuis paramètre URL dans board.html
2026-07-08 15:38:25 -04:00
bruno bed326ee9b feat: menu New page (Empty/Database/Kanban) + slash commands / dans l'éditeur
FlowDeck CI / test (push) Failing after 5s
FlowDeck CI / docker (push) Has been skipped
- Topbar bouton New ▾ avec dropdown: Empty page, Database, Kanban board
- page_editor: commandes slash / (/h1, /h2, /list, /todo, /code, /table, /quote...)
- Fonctions JS: createEmptyPage, createDatabase, createBoard
- State showNewPageMenu dans Alpine.js
2026-07-08 15:26:36 -04:00
bruno e3180c6255 fix: route /pages/{id} accessible à la racine — création pages fonctionnelle
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Ajouté /pages/{page_id} dans dashboard.py (route racine, pas /board/pages/...)
- La redirection après création de page fonctionne maintenant
- Les pages Markdown s'affichent correctement
2026-07-08 15:25:07 -04:00
bruno 24e979a05f fix: sidebar Favorites + Shared, Library page with tabs, CSRF fix
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Section Favorites ajoutée dans le sidebar (⭐)
- Section Shared ajoutée (👥)
- Section Library remplacée par bouton 📚 qui ouvre /library
- Page /library avec onglets: Recents | Favorites | Shared | Private
- CSRF token injecté dans les appels fetch (newPage, newSubPage)
- Bouton + sur éléments sidebar corrigé (POST /board/api/pages avec X-CSRF-Token)
- Bouton 📚 sur sections ouvre /library
- sidebar_data inclut favorite_pages=[]
2026-07-08 15:17:14 -04:00
bruno b10118eddc feat: sidebar Kanban board + pages Markdown + boutons + et 📚 fonctionnels
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Sidebar: 'Kanban board' affiché avant les 4 sous-vues
- DB: nouvelle table 'pages' (workspace, title, content, parent_section)
- API: POST /board/api/pages, GET/PUT /board/api/pages/{id}
- UI: page_editor.html — éditeur Markdown avec preview live
- UI: boutons + créent une page Markdown, bouton 📚 ouvre la page
- Dashboard: sidebar_data utilise BDD pages + workspace
- board.py: _sidebar_data enrichi (Kanban board + pages DB)
2026-07-08 15:02:15 -04:00
bruno a5e91cf8c7 Merge branch 'main' of https://git.dracodev.net/bruno/flowdeck
FlowDeck CI / test (push) Failing after 3s
FlowDeck CI / docker (push) Has been skipped
2026-07-08 14:55:48 -04:00
bruno b50d920c36 Add screenshots for Notion add page and library function buttons 2026-07-08 14:54:33 -04:00
43 changed files with 12768 additions and 434 deletions
+1293 -86
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -1,5 +1,58 @@
# Changelog — FlowDeck
## v1.5.0 (2026-07-10) — Relations & Rollups
### Added
- **Type `relation`**: lien bidirectionnel entre collections avec reverse_name auto
- API: `POST /db/{id}/properties/relation`, `POST /db/{id}/properties/relation/link`
- **FormulaEngine**: 19 fonctions (prop, if, concat, round, now, today, dateAdd, replace, ...)
- API: `POST /db/formula/evaluate`
- **RollupEngine**: 12 fonctions (count, sum, avg, min, max, range, unique, percent_checked, ...)
- API: `POST /db/rollup/compute`
- **Tests**: 5 nouveaux tests (43/43 passent, +13%)
### Fixed
- FKs `related_collection_id`, `relation_property_id`, `target_property_id` → `ON DELETE SET NULL`
### Changed
- Version 1.4.0 → 1.5.0
## v1.4.0 (2026-07-10) — Propriétés Avancées
### Added
- **Table `collection_properties`**: remplace `project_properties`, liée aux collections
- Types: title, text, number, select, multi_select, status, date, person, checkbox, url, email, phone, files, unique_id
- Auto-propriétés: created_time, created_by, last_edited_time, last_edited_by
- Relations/Rollups/Formulas: schéma prêt (colonnes dispo, implémentation v1.5)
- **Service `property_types.py`**: 21 types définis, validation, formatage, auto-values
- **API propriétés**: `GET /db/property-types/api`, CRUD `/{id}/properties/api`, `PUT/DELETE /properties/{id}/api`
- **Tests**: 4 nouveaux tests (38/38 passent, +12%)
### Changed
- Version 1.3.0 → 1.4.0
## v1.3.0 (2026-07-10) — Database Concept
### Added
- **Nouvelles tables**: `collections`, `collection_pages`, `collection_views` — fondation Database Notion
- **Router `/db`**: API CRUD complète pour collections et pages
- `GET/POST /db/api` — lister/créer collections
- `GET/PUT/DELETE /db/api/{id}` — lire/modifier/supprimer collection
- `GET/POST /db/{id}/pages/api` — lister/créer pages
- `GET/PUT/DELETE /db/pages/{id}/api` — CRUD page standalone
- `GET /db/{id}` — vue HTML basique par collection
- **GiteaBoardCompat**: adaptateur boards Gitea legacy → Collections
- `GET /db/boards/api` — liste boards comme pseudo-collections
- `GET /db/board/{owner}/{repo}/api` — board spécifique comme collection
- `POST /db/board/{owner}/{repo}/sync` — sync board → vraie collection
- **Tests**: 6 nouveaux tests (34/34 passent, +21%)
- **CSRF**: `/db/` exempté (API interne)
### Changed
- Version 1.0.0 → 1.3.0 (main.py, api.py, health)
- `db.py` init: 3 nouvelles tables + index
- Architecture DB: `collection_pages` utilise `property_values_json` (JSON flexible)
## v1.0.0 (2026-07-08) — Production
### Added
+33 -22
View File
@@ -1,8 +1,8 @@
# FlowDeck
Clone de l'interface **Notion** intégré nativement à **Gitea** — Kanban, Table, Status Overview, Team Load.
Clone de l'interface **Notion** intégré nativement à **Gitea** — Databases, Pages, Kanban, Table, multi-vues.
> **v0.4.0-dev** — Refonte UI Notion-style (Dark Mode, sidebar, topbar, multi-vues)
> **v1.3.0** — Database Concept (collections, pages, vues, GiteaBoardCompat)
## Quick Start
@@ -13,28 +13,33 @@ docker compose up -d
# → http://localhost:8080
```
## Features (v0.4.0)
## Features
### UI Notion-Style
- **Sidebar gauche** avec sections hiérarchiques (Home, Recents, Private, Library)
- **Topbar** avec titre de page, statut, boutons d'action (Share, favori, notifications)
### Database Concept (v1.3.0)
- **Collections**: databases indépendantes de Gitea, schéma JSON, icon, description
- **Pages**: propriétés JSON, position, parent_id (sub-items ready), lien Gitea optionnel
- **Vues**: configurables (type, filters, sorts, visible_properties)
- **GiteaBoardCompat**: adaptateur boards Gitea legacy → Collections
- **API REST**: CRUD complet collections + pages (12 endpoints)
### UI Notion-Style (v1.1–v1.2)
- **Sidebar gauche** avec sections hiérarchiques (Recents, Private, Library, Trash)
- **Topbar** avec breadcrumbs (workspace > projet > page)
- **Dark mode** Notion: `#191919` fond, `#222222` sidebar, `#333333` actif
- **Menu contextuel** (clic droit): Copy link, Duplicate, Rename, Move to Trash...
- **Hover states** subtils avec icônes d'action qui apparaissent
- **Typographie Inter**, coins arrondis 4-6px
- **Éditeur de blocs** Notion-style: slash menu (/), navigation clavier, placeholders
- **Drag & drop** pages dans la sidebar (SortableJS)
### Vues Multiples
- **Kanban board**: colonnes To-do/In progress/Complete avec sous-groupes (Design, Engineering)
- **Table view**: colonnes Name, Status, Assign, Deadline, Team, AI keywords
- **Status overview**: Donut chart avec segments, compteurs, pourcentages
- **Team Load**: Barres empilées par membre d'équipe
- **Detailed board**: cartes avec toutes les propriétés visibles
### Vues Multiples (v0.5–v0.8)
- **Kanban board**: colonnes avec sous-groupes, drag & drop cartes
- **Table view**: colonnes triables, groupes rétractables
- **Status overview**: Donut chart SVG avec segments, compteurs
- **Team Load**: Barres empilées par membre
- **Detailed board**: cartes avec propriétés visibles
### Filtres & Tri
- **Filtres cumulables** (logique AND) avec pastilles cliquables
### Filtres & Tri (v0.7)
- **Filtres cumulables** (logique AND) avec pastilles
- **Tri multi-critères** hiérarchique
- **Filtre par statut**: checkboxes multiples avec points colorés
- **Filtre AI keywords**: recherche + sélection multiple
- **Filtre par statut**: checkboxes multiples
### Intégration Gitea
- **Issues Gitea → cartes** Notion-style
@@ -46,10 +51,10 @@ docker compose up -d
| Couche | Techno |
|--------|--------|
| Frontend | HTML5 + Jinja2 + CSS Notion, Alpine.js, HTMX, SortableJS |
| Frontend | Jinja2 + HTMX + Alpine.js + SortableJS + CSS 31KB |
| Backend | Python 3.12 + FastAPI + httpx |
| BDD | SQLite (WAL mode) — `/data/flowdeck.db` |
| Déploiement | Docker (Python 3.12-slim), docker-compose |
| Déploiement | Docker (python:3.12-slim), docker-compose |
## Configuration
@@ -62,7 +67,7 @@ DATABASE_URL=sqlite:////data/flowdeck.db
## Roadmap
Voir [ROADMAP.md](ROADMAP.md) — v0.4.0 UI Notion → v1.0.0 Production
Voir [ROADMAP.md](ROADMAP.md) — v1.4.0 Propriétés Avancées → v2.0.0 Multi-Users.
## Développement
@@ -73,6 +78,12 @@ pip install -r requirements.txt
uvicorn app.main:app --reload --port 8080
```
## Tests
```bash
python3 -m pytest tests/ -v # 34/34 passent
```
## Licence
MIT
+176 -9
View File
@@ -3,34 +3,201 @@
> **Vision**: Copie conforme de l'UI/UX Notion, intégrée à Gitea.
> **19 images de référence** dans `images/` — analysées le 2026-07-08.
## v0.2.0 ✅ — Base
## Completed
### v0.2.0 ✅ — Base
Dashboard + Board Kanban + Gitea sync + Notes + Docker
## v0.3.0 ✅ — Auth
### v0.3.0 ✅ — Auth
OAuth2 Gitea, token par utilisateur, CSRF, rate limiting, webhooks
## v0.4.0 ✅ — UI Notion
### v0.4.0 ✅ — UI Notion
Layout 3 zones (sidebar 240px + topbar 44px), dark mode, CSS 31KB, Alpine.js
## v0.5.0 ✅ — Kanban
### v0.5.0 ✅ — Kanban
Colonnes, groupes, cartes enrichies, drag&drop, modale détail, checklists, commentaires
## v0.6.0 ✅ — Table View
### v0.6.0 ✅ — Table View
6 colonnes triables, groupes rétractables
## v0.7.0 ✅ — Filtres & Tri
### v0.7.0 ✅ — Filtres & Tri
Filtres cumulables AND, status dropdown, sort multi-critères
## v0.8.0 ✅ — Vues Spéciales
### v0.8.0 ✅ — Vues Spéciales
Status Overview (donut SVG), Team Load (barres), Detailed Board
## v0.9.0 ✅ — Backend
### v0.9.0 ✅ — Backend
Propriétés custom, AI keywords, sync API, 12 tables DB
## v1.0.0 ✅ — Production
### v1.0.0 ✅ — Production
- [x] FastAPI lifespan (deprecation fix)
- [x] 28 tests — 5 vues, filtres, tris, APIs, CSRF, CORS, DB
- [x] CI/CD Gitea Actions (`.gitea/workflows/ci.yml`)
- [x] CHANGELOG.md, ARCHITECTURE.md à jour
- [x] Version 1.0.0 partout (main.py, api.py, health)
- [x] Tag git v1.0.0
### v1.1.0 ✅ — Pages & Sidebar
- [x] Pages Markdown avec arbre imbriqué (parent_id, sort_order)
- [x] Block editor mode (content_format='blocks', JSON storage)
- [x] Soft delete → Trash → Restore → Permanent delete
- [x] Drag & drop réorganisation pages
- [x] Sidebar Notion-style avec sections Recents/Private/Shared
- [x] "+ New page" footer button
- [x] Workspace header avec avatar + chevron toggle
### v1.2.0 ✅ — Éditeur Notion
- [x] Slash menu (/) 15 commandes fonctionnelles
- [x] Navigation clavier (flèches, Enter, Escape)
- [x] Placeholders Notion-style (Heading 1/2/3/4, Toggle, Quote, Code)
- [x] Suppression outline blanc sur les blocs
- [x] Heading 4 support
- [x] Breadcrumbs contextuels (workspace > projet > page)
### v1.3.0 ✅ — Database Concept (Bloc 1 du gap analysis)
### (2026-07-10, commit c574d2c, tag v1.3.0)
- [x] **Table `collections`** — base de données abstraite, indépendante de Gitea
- `id`, `name`, `description`, `icon`, `schema_json`
- Optionnellement liée à Gitea (`gitea_owner`, `gitea_repo`)
- [x] **Table `collection_pages`** — remplace `cards`, pages génériques
- `collection_id`, `title`, `position`, `parent_id`, `property_values_json`
- `gitea_issue_id` optionnel, auto-propriétés (`created_at`, `updated_at`)
- [x] **Table `collection_views`** — vues configurables par collection
- `name`, `view_type`, `config_json`, `position`
- [x] **Rétrocompatibilité Gitea** — adaptateur `GiteaBoardCompat`
- Les boards Gitea existants deviennent des collections
- Migration transparente `cards` → `collection_pages`
- Routes `/db/boards/api`, `/db/board/{o}/{r}/api`, `/db/board/{o}/{r}/sync`
- [x] **Router `/db`** — API CRUD collections + pages (12 endpoints)
- [x] **34/34 tests** passent (+6 nouveaux tests v1.3)
- [x] **Database lock** — mode lecture seule (`collections.is_locked`)
- [x] **Database description** — champ `description` sur les collections
---
## Upcoming
### v1.4.0 ✅ — Propriétés Avancées (Bloc 2)
### (2026-07-10)
- [x] **Table `collection_properties`** — remplace `project_properties`
- Liée à `collections` (pas à `project_owner/name`)
- Supporte tous les types Notion (21 types)
- [x] **Type `number`** — avec formatage (nombre, %, €, $, £, ¥)
- [x] **Type `checkbox`** — booléen natif
- [x] **Type `url`** — lien cliquable
- [x] **Type `email`** — email cliquable
- [x] **Type `phone`** — téléphone cliquable
- [x] **Type `status`** — select avec couleurs forcées (9 couleurs Notion)
- [x] **Type `files`** — uploads/images attachés aux pages
- [x] **Type `unique_id`** — ID auto-incrémenté par collection
- [x] **Type `created_time` / `created_by`** — auto-propriétés
- [x] **Type `last_edited_time` / `last_edited_by`** — auto-propriétés
- [x] **Service `property_types.py`** — validation, formatage, auto-values
- [x] **API CRUD** — `/db/property-types/api` + CRUD `/db/{id}/properties/api`
- [x] **38/38 tests** passent (+4 nouveaux tests v1.4)
- [ ] **Migration** `project_properties` → `collection_properties` (v1.5)
### v1.5.0 ✅ — Relations & Rollups (Bloc 2 suite)
### (2026-07-10)
- [x] **Type `relation`** — lien bidirectionnel entre collections
- `related_collection_id`, `reverse_name` auto-généré
- Stockage : JSON array de page IDs dans `property_values_json`
- API: `POST /db/{id}/properties/relation` (création avec reverse)
- API: `POST /db/{id}/properties/relation/link` (lier 2 pages)
- [x] **Type `rollup`** — agrégation via relation
- Functions : COUNT, SUM, AVG, MIN, MAX, RANGE, UNIQUE, PERCENT_CHECKED
- API: `POST /db/rollup/compute`
- [x] **Type `formula`** — propriétés calculées
- Moteur d'expressions JavaScript-like (19 fonctions)
- API: `POST /db/formula/evaluate`
- [x] **Formula engine** — `services/formula_engine.py`
- [x] **Rollup engine** — `services/rollup_engine.py`
- [x] **43/43 tests** passent (+5 nouveaux tests v1.5)
- [x] **FKs fix**: `related_collection_id`, `relation_property_id`, `target_property_id` → `ON DELETE SET NULL`
### v1.6.0 ✅ — Vues Manquantes (Bloc 3)
### (2026-07-10)
- [x] **Calendar view** — grid mensuel, navigation mois, événements par date
- Route: `GET /db/{id}/view/calendar`
- [x] **Gallery view** — cartes visuelles avec cover image optionnelle
- `card_size`: small/medium/large, `cover_property` configurable
- [x] **List view** — liste compacte avec preview propriétés
- [x] **Timeline/Gantt view** — barres horizontales sur axe date
- [x] **Table view** — rendu SSR, colonnes properties
- [x] **View tabs** — navigation entre vues (Table/Board/Calendar/Gallery/List/Timeline)
- [x] **49/49 tests** passent (+6 nouveaux tests v1.6)
### v1.7.0 ✅ — Vues Améliorées
### v1.8.0 ✅ — Sub-items & Dépendances
### (2026-07-10, combined)
- [x] **View config API** — PUT /db/views/{id}/config (group_by, card_size, cover, visible)
- [x] **Save view as** — POST /db/{id}/views/save-as
- [x] **List views** — GET /db/{id}/views/api
- [x] **Sub-items** — parent_id auto-référence, GET/POST sub-items
- [x] **Status aggregate** — GET status-aggregate (Done only if all children Done)
- [x] **Dependencies** — POST dependencies + POST check-deps (blocking constraint)
- [x] **56/56 tests** passent (+7 tests v1.7+v1.8)
### v1.9.0 ✅ — My Tasks & Dashboard Unifié (Bloc 5)
### (2026-07-10)
- [x] **Vue `/my-tasks`** — agrège toutes les pages assignées à l'utilisateur
- Scan cross-collection (toutes les databases du workspace)
- Filtre automatique : admin voit tout
- [x] **Groupement par collection** — sections par database d'origine
- [x] **Filtres globaux** — All, Today, Overdue, Next 7 days
- [x] **API JSON** — `GET /my-tasks/api`
- [x] **60/60 tests** passent (+4 tests v1.9)
### v2.0.0 ✅ — Éditeur Complet & Multi-Utilisateurs (Blocs 6-7)
### (2026-07-10)
- [x] **Workspaces** — `workspaces` + `workspace_members` tables, CRUD API, roles admin/editor/commenter/viewer
- [x] **Comments** — table `comments` avec thread (parent_id), resolved, API CRUD
- [x] **Page History** — table `page_history`, snapshot JSON, API record/list
- [x] **Favorites** — table `favorites`, API add/remove/list
- [x] **Database Templates** — table `database_templates`, apply to create collection
- [x] **Page Templates** — table `page_templates`, apply with pre-filled properties
- [x] **CSV Import/Export** — `POST .../import/csv`, `GET .../export/csv`
- [x] **Public Sharing** — `GET /workspace/public/{id}` (read-only, no auth)
- [x] **67/67 tests** passent (+7 tests v2.0)
### v2.1.0 — Intégrations Avancées (futur)
### v2.1.0 — Intégrations Avancées (futur)
- [ ] **API publique** — REST API documentée pour usage externe
- [ ] **Webhooks sortants** — notifier des services externes
- [ ] **n8n integration** — nœud FlowDeck pour workflows
- [ ] **Slack/Discord integration** — notifications dans les channels
- [ ] **Google Calendar sync** — synchronisation bidirectionnelle
- [ ] **Mobile PWA** — Progressive Web App pour mobile
- [ ] **PostgreSQL migration** — si passage multi-tenant
---
## Résumé des phases
```
v1.0.0 ✅ v1.1.0 ✅ v1.2.0 ✅ v1.3.0 ✅ v1.4.0 ✅ v1.5.0 ✅ v1.6.0 ✅ v1.7.0 ✅
Production Pages Editor DB Concept Properties Relations Views View+
──────────────────────────────────────────────
52 fonctionnalités identifiées dans le gap analysis
│
├─ Bloc 1: Database Concept (v1.3) — 6 items ✅
├─ Bloc 2: Propriétés avancées (v1.4-1.5) — 14 items ✅
├─ Bloc 3: Vues manquantes (v1.6-1.7) — 10 items ✅
├─ Bloc 4: Sub-items & Deps (v1.8) — 7 items ✅
├─ Bloc 5: My Tasks (v1.9) — 7 items ✅
├─ Bloc 6: Éditeur complet (v2.0) — 8 items ✅
└─ Bloc 7: Fonctions transversales — +multi-user ✅
v2.0.0 ✅ v2.1.0 ⬜
Editor+ Future
Multi-user
```
+53 -159
View File
@@ -1,177 +1,71 @@
# WORKLOAD — FlowDeck Notion Clone
> **Début**: 2026-07-08 | **Objectif**: Clone Notion intégré Gitea
> **Début**: 2026-07-08 | **Version actuelle**: v1.3.0 | **Prochaine**: v1.4.0
## Avancement Global
| Phase | Description | Statut | Progression |
|-------|-------------|--------|-------------|
| Phase 1 | Documents & Planification | ✅ | 100% |
| Phase 2 | Layout Notion (sidebar + topbar + contenu) | ✅ | 100% |
| Phase 3 | Sidebar (sections, hover, context menu) | ✅ | 100% |
| Phase 4 | Vues (Kanban, Table, Status, Team Load) | 🚧 | 40% |
| Phase 5 | Filtres & Tri | 🚧 | 25% |
| Phase 6 | Cartes enrichies | 🚧 | 30% |
| Phase 7 | Intégration Gitea complète | 🚧 | 15% |
| Phase 8 | Polish & Tests | ⬜ | 0% |
| Phase | Description | Statut | Tests |
|-------|-------------|--------|-------|
| v0.2 – v0.9 | Base → Backend | ✅ | — |
| v1.0 | Production (lifespan, CI/CD) | ✅ | 28/28 |
| v1.1 | Pages & Sidebar Notion | ✅ | — |
| v1.2 | Éditeur Notion (slash menu, blocs) | ✅ | — |
| v1.3 | Database Concept (collections) | ✅ | 34/34 |
| v1.4 | Propriétés Avancées (14 types) | ⬜ | — |
| v1.5 | Relations & Rollups | ⬜ | — |
| v1.6 | Vues Calendrier/Timeline/Gallery/List | ⬜ | — |
| v1.7 | Vues Améliorées (group_by, card_size) | ⬜ | — |
| v1.8 | Sub-items & Dépendances | ⬜ | — |
| v1.9 | My Tasks & Dashboard Unifié | ⬜ | — |
| v2.0 | Éditeur Complet & Multi-Users | ⬜ | — |
---
## Phase 1: Documents & Planification
## v1.3.0 — Database Concept (complété 2026-07-10)
### 1.1 Analyse des images Notion
- [x] Pull des 19 images de référence
- [x] Analyse du layout complet Notion (~1400px, dark mode)
- [x] Analyse Kanban board 1 (colonnes + cartes + groupes Design/Engineering)
- [x] Analyse Detailed board (cartes avec propriétés visibles)
- [x] Analyse Table view (colonnes Name, Status, Assign, Deadline, Team, AI keywords)
- [x] Analyse Search & Filter (barre de recherche, panneau filtres)
- [x] Analyse Panel Filter (liste des propriétés filtrables)
- [x] Analyse Status Panel Filter (checkboxes multiples + Clear selection)
- [x] Analyse Sort Panel 1 (simple: propriété + ascendant/descendant)
- [x] Analyse Sort Panel 2 (multi-critères: 3 sorts hiérarchiques)
- [x] Analyse Filters Cumulables (AND, pastilles, + Filter, Reset)
- [x] Analyse AI Keywords Filter (recherche + tags colorés)
- [x] Analyse Status Overview (donut chart 18 total, segments, légende)
- [x] Analyse Team Load (barres empilées par avatar)
- [x] Analyse Sidebar Layout (sections: Home, Recents, Favorites, Library)
- [x] Analyse Sidebar Context Menu (right-click: Copy link, Duplicate, Rename, Move to Trash...)
- [x] Analyse Sidebar Mouse Over Elements (hover: fond #333, boutons ... et +)
- [x] Analyse Sidebar Mouse Over Section (tooltip "Open in Library", icônes)
- [x] Analyse Kanban Board 2 (structure groupes + colonnes)
### Tables
- [x] `collections` — base de données abstraite, schéma JSON, lien Gitea optionnel
- [x] `collection_pages` — pages avec `parent_id`, `property_values_json`, `position`
- [x] `collection_views` — vues configurables (`view_type`, `config_json`)
- [x] Index: `idx_cp_collection`, `idx_cp_parent`, `idx_cp_gitea`
### 1.2 Documents
- [x] ROADMAP.md mis à jour avec vision v0.4 → v1.0
- [x] WORKLOAD.md créé
- [ ] README.md mis à jour
- [ ] ARCHITECTURE.md mis à jour
- [ ] CHANGELOG.md (après premier commit de code)
### API
- [x] `GET/POST /db/api` — lister/créer collections
- [x] `GET/PUT/DELETE /db/api/{id}` — CRUD collection
- [x] `GET/POST /db/{id}/pages/api` — pages dans une collection
- [x] `GET/PUT/DELETE /db/pages/{id}/api` — CRUD page standalone
- [x] `GET /db/{id}` — vue HTML collection
- [x] `GET /db/boards/api` — boards Gitea comme collections
- [x] `GET /db/board/{o}/{r}/api` — board spécifique
- [x] `POST /db/board/{o}/{r}/sync` — sync board → collection
### Adapter
- [x] `GiteaBoardCompat` — `from_board()`, `from_card()`, `list_boards_as_collections()`, `sync_to_collection()`
### Infra
- [x] CSRF exempté pour `/db/`
- [x] Version 1.0.0 → 1.3.0
- [x] 34/34 tests passent
- [x] Tag git v1.3.0
---
## Phase 2: Layout Notion
## Prochaines étapes (v1.4.0 — Propriétés Avancées)
### 2.1 Structure HTML
- [x] `base.html`: layout 3 zones (sidebar fixe gauche, topbar fixe haut, contenu scrollable)
- [x] Fond global: `#191919`
- [x] Police Inter importée via Google Fonts
### Table `collection_properties`
- [ ] Remplacer `project_properties` (liée à `project_owner/name`) par `collection_properties` (liée à `collections`)
- [ ] Types: number, checkbox, url, email, phone, status (9 couleurs), files, unique_id
- [ ] Auto-propriétés: created_time, created_by, last_edited_time, last_edited_by
- [ ] Format number: nombre, %, €, $, £, ¥
### 2.2 Sidebar (gauche, ~240px, fond #222)
- [x] Workspace info (avatar B + nom Bruno)
- [x] Boutons Home, Search, Updates, Settings
- [x] Section Recents (avec indentation sous-pages)
- [x] Section Favorites (Agents, Shared, Private)
- [x] Section Notion apps (Calendar, Desktop)
- [x] Section Library (My Tasks, Marketplace, Help, Trash)
- [x] Footer: Invite members, New chat Ctrl+O, bouton édition
### 2.3 Topbar (haut, fond #191919)
- [x] Logo workspace + nom à gauche
- [x] Titre page + statut (Private/Shared) au centre
- [x] Icônes: notifications, historique, recherche
- [x] Boutons: Share, favori, menu (...)
### 2.4 Content Area (fond #191919)
- [x] Image de couverture optionnelle
- [x] Titre de page (28-32px bold)
- [x] Barre de navigation des vues (onglets)
- [x] Barre d'outils (filtres, tri, recherche, New)
- [x] Zone de contenu principale (Kanban / Table / Graphiques)
---
## Phase 3: CSS — Design System
### 3.1 Variables CSS
```css
:root {
--bg-primary: #191919;
--bg-secondary: #222222;
--bg-tertiary: #333333;
--bg-hover: #2F2E2E;
--text-primary: #FFFFFF;
--text-secondary: #A0A0A0;
--color-blue: #3366CC;
--color-green: #00CC66;
--color-yellow: #FFD700;
--font-sans: 'Inter', sans-serif;
--radius-sm: 4px;
--radius-md: 6px;
}
```
### 3.2 Composants
- [x] `.sidebar` — 240px fixe, fond #222
- [x] `.sidebar-section` — titre 12px + liste
- [x] `.sidebar-item` — 14px, padding 8px 12px, hover #333
- [x] `.sidebar-item.active` — fond #333
- [x] `.topbar` — hauteur 44px, flex
- [x] `.view-tabs` — onglets horizontaux
- [x] `.view-tab` — inactif: texte gris, actif: fond #333 + texte blanc
- [x] `.kanban-board` — flex horizontal, gap 12px
- [x] `.kanban-column` — min-width 260px, arrondi 6px
- [x] `.kanban-card` — fond selon statut, padding 12px, arrondi 6px
- [x] `.btn-primary` — bleu #3366CC
- [x] `.btn-secondary` — gris #333333
- [x] `.filter-pill` — pastille avec texte + croix de suppression
- [x] `.sort-panel` — menu déroulant avec options
- [x] `.dropdown-menu` — fond #333, bordure subtile, ombre
- [x] `.tooltip` — fond #3A3A3A, texte blanc
---
## Phase 4: JavaScript (Alpine.js)
### 4.1 Sidebar
- [x] Toggle collapse/expand sidebar
- [x] Hover reveal buttons (... et +)
- [x] Context menu au clic droit
- [ ] Drag & drop pour réorganiser les favoris
### 4.2 Vues
- [x] Switch entre vues (Kanban, Table, Status, Team Load, Detailed)
- [x] Kanban: drag & drop cartes (SortableJS)
- [ ] Table: tri par colonne, redimensionnement
- [ ] Status Overview: Donut chart (SVG ou Canvas)
- [ ] Team Load: Barres empilées
### 4.3 Filtres & Tri
- [x] Ajout/suppression filtres (UI)
- [ ] Logique AND entre filtres
- [ ] Tri multi-niveaux
- [ ] Persistance dans l'URL ou localStorage
---
## Phase 5: Backend (FastAPI)
### 5.1 Nouvelles routes API
- [ ] `GET /api/views/{owner}/{repo}` — config des vues
- [ ] `POST /api/filters` — appliquer filtres
- [ ] `POST /api/sorts` — appliquer tris
- [ ] `GET /api/status-overview/{owner}/{repo}` — données donut chart
- [ ] `GET /api/team-load/{owner}/{repo}` — données barres empilées
- [ ] `GET /api/table-view/{owner}/{repo}` — données format tableau
### 5.2 Base de données (nouvelles tables)
- [ ] `view_config` — configuration par vue
- [ ] `filters` — filtres sauvegardés par utilisateur
- [ ] `sorts` — tris sauvegardés
- [ ] `card_properties` — propriétés custom par carte
### 5.3 Gitea Sync
- [x] Labels → tags couleur sur cartes
- [ ] Milestones → groupes/sprints
- [x] Assignees → avatars + noms
- [x] Due dates → échéances (via Gitea API)
- [ ] Comments → affichage dans cartes
---
### Intégration UI
- [ ] Property editor inline par type (select dropdown, date picker, checkbox...)
- [ ] Affichage des propriétés dans la modale de détail page
- [ ] Migration project_properties → collection_properties
## Notes
- **Couleurs exactes** extraites des analyses: fond `#191919`, sidebar `#222222`, actif `#333333`, hover `#2F2E2E`, texte `#FFFFFF`, secondaire `#A0A0A0`
- **Police**: Inter (Google Fonts), tailles: 12px (petit), 14px (normal), 28-32px (titres)
- **Coins arrondis**: 4px (boutons/cartes), 6px (colonnes)
- **Espacements**: padding 8-12px, gap 8-12px
- **Icônes**: utiliser des émojis ou SVG inline pour éviter dépendance externe
- **Stack**: FastAPI + Jinja2 + HTMX + Alpine.js + SortableJS + SQLite (WAL)
- **Docker**: python:3.12-slim, port 8080, volume /data
- **Tests**: pytest, 34 tests, client TestClient avec SQLite temporaire
- **CI/CD**: Gitea Actions (.gitea/workflows/ci.yml)
+161
View File
@@ -130,7 +130,168 @@ def init_db():
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(project_owner, project_name, keyword)
);
CREATE TABLE IF NOT EXISTS pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace TEXT NOT NULL,
title TEXT NOT NULL DEFAULT 'New page',
content TEXT NOT NULL DEFAULT '',
parent_section TEXT DEFAULT 'Private',
parent_id INTEGER REFERENCES pages(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- v1.3.0: Database Concept — Notion-style collections
CREATE TABLE IF NOT EXISTS collections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT DEFAULT '',
icon TEXT DEFAULT '📋',
schema_json TEXT NOT NULL DEFAULT '[]',
gitea_owner TEXT,
gitea_repo TEXT,
is_locked BOOLEAN NOT NULL DEFAULT 0,
is_inline BOOLEAN NOT NULL DEFAULT 0,
parent_page_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS collection_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
icon TEXT DEFAULT '📄',
position INTEGER NOT NULL DEFAULT 0,
parent_id INTEGER REFERENCES collection_pages(id),
gitea_issue_id INTEGER,
gitea_issue_number INTEGER,
property_values_json TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_cp_collection ON collection_pages(collection_id, position);
CREATE INDEX IF NOT EXISTS idx_cp_parent ON collection_pages(parent_id);
CREATE INDEX IF NOT EXISTS idx_cp_gitea ON collection_pages(gitea_issue_id);
CREATE TABLE IF NOT EXISTS collection_views (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Default View',
view_type TEXT NOT NULL DEFAULT 'table',
config_json TEXT NOT NULL DEFAULT '{}',
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- v1.4.0: Propriétés Avancées — collection_properties (remplace project_properties)
CREATE TABLE IF NOT EXISTS collection_properties (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
name TEXT NOT NULL,
prop_type TEXT NOT NULL DEFAULT 'text',
options_json TEXT DEFAULT '[]',
number_format TEXT DEFAULT 'number',
related_collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL,
reverse_name TEXT,
relation_property_id INTEGER REFERENCES collection_properties(id) ON DELETE SET NULL,
target_property_id INTEGER REFERENCES collection_properties(id) ON DELETE SET NULL,
rollup_function TEXT,
formula_expression TEXT,
position INTEGER NOT NULL DEFAULT 0,
required BOOLEAN NOT NULL DEFAULT 0,
visible_in_views BOOLEAN NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(collection_id, name)
);
-- v2.0.0: Multi-user — workspaces, members, comments, history, favorites, templates
CREATE TABLE IF NOT EXISTS workspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
owner_id INTEGER NOT NULL REFERENCES users(id),
settings_json TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS workspace_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
role TEXT NOT NULL DEFAULT 'editor',
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(workspace_id, user_id)
);
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
body TEXT NOT NULL DEFAULT '',
parent_id INTEGER REFERENCES comments(id),
resolved BOOLEAN NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS page_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id),
change_type TEXT NOT NULL,
snapshot_json TEXT NOT NULL DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS favorites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
page_id INTEGER REFERENCES collection_pages(id),
collection_id INTEGER REFERENCES collections(id),
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, page_id, collection_id)
);
CREATE TABLE IF NOT EXISTS database_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT DEFAULT '',
schema_json TEXT NOT NULL DEFAULT '[]',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS page_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Default',
property_values_json TEXT NOT NULL DEFAULT '{}',
content_json TEXT DEFAULT '[]',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# Migration: add parent_id if missing (v1.0.0+)
try:
conn.execute("ALTER TABLE pages ADD COLUMN parent_id INTEGER REFERENCES pages(id)")
except sqlite3.OperationalError:
pass
# Migration: add sort_order for drag & drop tree reordering (v1.1.0+)
try:
conn.execute("ALTER TABLE pages ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
# Migration: add content_format for Notion-style block editor (v1.2.0+)
# 'markdown' = legacy textarea, 'blocks' = JSON array of block objects
try:
conn.execute("ALTER TABLE pages ADD COLUMN content_format TEXT NOT NULL DEFAULT 'markdown'")
except sqlite3.OperationalError:
pass
# Migration: add deleted_at for trash/soft-delete
try:
conn.execute("ALTER TABLE pages ADD COLUMN deleted_at TEXT")
except sqlite3.OperationalError:
pass
conn.commit()
+22 -3
View File
@@ -12,8 +12,9 @@ from starlette.middleware.sessions import SessionMiddleware
from app.config import settings
from app.db import init_db
from app.middleware.csrf import CSRFMiddleware
from app.routers import dashboard, board, notes, api, auth, webhooks
from app.routers import dashboard, board, notes, api, auth, webhooks, collections, my_tasks, workspace, public_api
from app.services.gitea_client import gitea
from app.services.webhook_outbound import init_webhook_tables
logging.basicConfig(
level=getattr(logging, settings.log_level.upper(), logging.INFO),
@@ -25,19 +26,20 @@ logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(_app: FastAPI):
init_db()
init_webhook_tables()
from app.db import get_conn
with get_conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO users (login, full_name, email, is_admin) VALUES ('admin', 'Admin', '', 1)"
)
conn.commit()
logger.info("FlowDeck v1.0.0 started on port %d", settings.app_port)
logger.info("FlowDeck v2.1.0 started on port %d", settings.app_port)
yield
app = FastAPI(
title="FlowDeck",
version="1.0.0",
version="2.1.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
@@ -53,5 +55,22 @@ app.include_router(board.router)
app.include_router(notes.router)
app.include_router(api.router)
app.include_router(webhooks.router)
app.include_router(collections.router)
app.include_router(my_tasks.router)
app.include_router(workspace.router)
app.include_router(public_api.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"}],
}
+3 -3
View File
@@ -16,11 +16,11 @@ class CSRFMiddleware(BaseHTTPMiddleware):
"""
SAFE_METHODS = {"GET", "HEAD", "OPTIONS"}
EXCLUDED_PATHS = {"/api/webhook", "/auth/callback"}
EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/board/api/pages", "/db/", "/workspace"}
async def dispatch(self, request: Request, call_next):
# Webhook receiver and OAuth callback are exempt
if request.url.path in self.EXCLUDED_PATHS:
# Webhook receiver, OAuth callback, and internal API are exempt
if any(request.url.path.startswith(p) for p in self.EXCLUDED_PATHS):
return await call_next(request)
if request.method in self.SAFE_METHODS:
+36 -1
View File
@@ -13,6 +13,7 @@ from app.config import settings
from app.db import get_conn
from app.routers.board import _issue_column, _map_issue_to_card, STATUS_COLORS, STATUS_LABELS
from app.services.gitea_client import gitea
from app.auth.session import SessionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api"], prefix="/api")
@@ -56,7 +57,7 @@ async def health():
return {
"status": "ok" if (db_ok and gitea_ok) else "degraded",
"version": "1.0.0",
"version": "2.1.0",
"db": db_ok,
"gitea": gitea_ok,
}
@@ -538,6 +539,40 @@ async def delete_checklist(checklist_id: int):
return {"status": "ok"}
# ── v1.0.0: User management ──
@router.get("/users/me")
async def get_my_profile(request: Request):
"""Get current user profile."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
return {"login": "guest", "full_name": "Guest", "email": ""}
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM users WHERE login=?", (user.get("login", ""),)
).fetchone()
if row:
return dict(row)
return {"login": user.get("login", ""), "full_name": "", "email": ""}
@router.put("/users/me")
async def update_my_profile(request: Request, full_name: str = Query(default=""),
email: str = Query(default="")):
"""Update current user's local profile."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
if not user:
raise HTTPException(401, "Not authenticated")
login = user.get("login", "")
with get_conn() as conn:
conn.execute(
"UPDATE users SET full_name=?, email=? WHERE login=?",
(full_name, email, login),
)
conn.commit()
return {"status": "ok"}
# ── v0.5.0: Card priority & due date ──
@router.post("/card/{owner}/{repo}/{issue_id}")
+251 -14
View File
@@ -115,18 +115,65 @@ def _map_issue_to_card(issue: dict, owner: str = "", repo: str = "") -> dict:
}
def _sidebar_data(request: Request, owner: str, repo: str) -> dict:
def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, max_depth: int = 3) -> list[dict]:
"""Build nested page tree recursively. max_depth prevents infinite recursion."""
if depth >= max_depth:
return []
rows = conn.execute(
"SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? AND deleted_at IS NULL ORDER BY sort_order ASC, updated_at DESC",
(ws_key, parent_id),
).fetchall()
items = []
for row in rows:
children = _build_page_tree(conn, row["id"], ws_key, depth + 1, max_depth)
items.append({
"id": f"page/{row['id']}",
"db_id": row["id"],
"name": row["title"] or "New page",
"icon": "📄",
"url": f"/pages/{row['id']}",
"active": False,
"depth": depth,
"has_children": len(children) > 0,
"children": children,
})
return items
def _sidebar_data(request: Request, owner: str = "", repo: str = "") -> dict:
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws = user.get("login", "Bruno") if user else "Bruno"
recent = [{"id": f"{owner}/{repo}", "name": repo, "icon": "📁",
"url": f"/board/{owner}/{repo}", "active": True, "indent": 0}]
for sv in ["Detailed board", "Table view", "Status overview", "Team Load"]:
recent.append({"id": f"{owner}/{repo}/{sv.lower().replace(' ', '-')}",
"name": sv, "icon": "·", "url": f"/board/{owner}/{repo}",
"active": False, "indent": 1})
return {"workspace_name": ws, "workspace_initial": ws[0].upper() if ws else "B",
"current_page": repo, "last_edited": "2m ago",
"recent_pages": recent, "private_pages": recent[:1], "user": user}
ws_name = user.get("login", "Bruno") if user else "Bruno"
ws_key = f"{owner}/{repo}" if owner and repo else ws_name # workspace = project context
recent = []
if owner and repo:
view_map = {
"Kanban board": "kanban", "Detailed board": "detailed",
"Table view": "table", "Status overview": "status", "Team Load": "teamload",
}
first = True
for label, view in view_map.items():
indent = 0 if first else 1
active = first
recent.append({
"id": f"{owner}/{repo}/{view}",
"name": label, "icon": "📁" if first else "·",
"url": f"/board/{owner}/{repo}?view={view}",
"active": active, "indent": indent,
"depth": indent, "has_children": False, "children": [],
})
first = False
# Load pages as nested tree for this project workspace
with get_conn() as conn:
tree_pages = _build_page_tree(conn, None, ws_key)
for p in tree_pages:
recent.append(p)
# Private pages: same as recent but filtered for page/ items (non-board views)
private_items = [r for r in recent if r.get("active") or r["id"].startswith("page/")]
return {"workspace_name": ws_name, "workspace_initial": ws_name[0].upper() if ws_name else "B",
"current_page": repo or "Dashboard", "last_edited": "now",
"recent_pages": recent, "private_pages": private_items,
"favorite_pages": [], "user": user,
"workspace_key": ws_key}
def _extract_ai_keywords(owner: str, repo: str, labels: list[dict], body: str = ""):
@@ -225,15 +272,66 @@ def _apply_sorts(cards: list[dict], sorts: str) -> list[dict]:
return cards
# ═══════════ Library page ═══════════
@router.get("/library", response_class=HTMLResponse)
async def library_page(request: Request):
"""Library page avec onglets Recents, Favorites, Shared, Private."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request)
# Enrich with timestamps
for p in sidebar["recent_pages"]:
p["last_edited"] = "now"
for p in sidebar["private_pages"]:
p["last_edited"] = "now"
template = env.get_template("library.html")
return template.render(**sidebar)
# ═══════════ Trash (must be before /{owner}/{repo} catch-all) ═══════════
@router.get("/api/trash")
async def list_trash(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT id, title, workspace, parent_id, deleted_at FROM pages WHERE deleted_at IS NOT NULL ORDER BY deleted_at DESC").fetchall()
return [{"id": r["id"], "name": r["title"] or "Untitled", "icon": "📄", "path": r["workspace"] or "Private", "deleted_at": r["deleted_at"]} for r in rows]
@router.post("/api/trash/{page_id}/restore")
async def restore_page(request: Request, page_id: int):
with get_conn() as conn:
conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,))
conn.commit()
return {"status": "ok", "restored": page_id}
@router.delete("/api/trash/{page_id}")
async def permanent_delete(request: Request, page_id: int):
with get_conn() as conn:
conn.execute("UPDATE pages SET parent_id=NULL WHERE parent_id=?", (page_id,))
conn.execute("DELETE FROM pages WHERE id=? AND deleted_at IS NOT NULL", (page_id,))
conn.commit()
return {"status": "ok", "deleted": page_id}
@router.get("/trash", response_class=HTMLResponse)
async def trash_page(request: Request):
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
template = env.get_template("trash.html")
return template.render(**_sidebar_data(request))
# ═══════════ Board page ═══════════
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
async def board(request: Request, owner: str, repo: str):
async def board(request: Request, owner: str, repo: str, view: str = Query(default="")):
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, owner, repo)
template = env.get_template("board.html")
return template.render(request=request, owner=owner, repo=repo, groups=[], **sidebar)
return template.render(request=request, owner=owner, repo=repo, groups=[],
initial_view=view, **sidebar)
# ═══════════ View fragments ═══════════
@@ -391,7 +489,146 @@ async def extract_ai_keywords(owner: str, repo: str):
return {"status": "ok", "issues_scanned": len(issues)}
# ═══════════ v0.9.0: Sync endpoint ═══════════
# ═══════════ Pages Markdown ═══════════
@router.post("/api/pages")
async def create_page(request: Request, title: str = Query(default=""),
section: str = Query(default="Private"),
project: str = Query(default=""),
parent_id: int = Query(default=0)):
"""Create a new Markdown page, optionally as a sub-page."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno")
with get_conn() as conn:
# Compute next sort_order for this parent
next_order = 0
parent_val = parent_id if parent_id > 0 else None
row = conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS ?",
(ws_key, parent_val),
).fetchone()
if row:
next_order = row[0]
cur = conn.execute(
"INSERT INTO pages (workspace, title, parent_section, parent_id, sort_order) VALUES (?,?,?,?,?)",
(ws_key, title, section, parent_val, next_order),
)
conn.commit()
page_id = cur.lastrowid
return {"status": "ok", "id": page_id, "title": title, "workspace": ws_key, "parent_id": parent_id}
@router.get("/api/pages/{page_id}")
async def get_page(page_id: int):
"""Get a Markdown page."""
with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
raise HTTPException(404, "Page not found")
return dict(row)
@router.put("/api/pages/{page_id}")
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."""
with get_conn() as conn:
if title:
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
if content:
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content, page_id))
if content_format:
conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id))
conn.commit()
return {"status": "ok"}
@router.post("/api/pages/{page_id}/blocks")
async def save_page_blocks(request: Request, page_id: int):
"""Save blocks JSON content (Notion-style block editor)."""
try:
body = await request.json()
except Exception:
raise HTTPException(400, "Invalid JSON body")
blocks_json = json.dumps(body.get("blocks", []))
title = body.get("title", "")
with get_conn() as conn:
if title:
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
conn.execute(
"UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?",
(blocks_json, page_id),
)
conn.commit()
return {"status": "ok", "id": page_id}
@router.put("/api/pages/{page_id}/move")
async def move_page(request: Request, page_id: int,
new_parent_id: int = Query(default=0),
new_order: int = Query(default=0)):
"""Move/reorder a page in the tree (drag & drop).
new_parent_id=0 means move to root level.
new_order=0 means append to end (or set explicit position)."""
with get_conn() as conn:
# Verify page exists
row = conn.execute("SELECT id, workspace FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
raise HTTPException(404, "Page not found")
# Update parent (None for root level)
conn.execute(
"UPDATE pages SET parent_id=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(new_parent_id if new_parent_id > 0 else None, page_id),
)
# Update sort order
conn.execute(
"UPDATE pages SET sort_order=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(new_order, page_id),
)
# Re-index siblings to ensure no gaps
siblings = conn.execute(
"SELECT id, sort_order FROM pages WHERE workspace=? AND parent_id IS ? AND id != ? ORDER BY sort_order ASC",
(row["workspace"], new_parent_id if new_parent_id > 0 else None, page_id),
).fetchall()
# Recompute sort_order for all siblings after the insertion point
insert_point = new_order
reorder = []
for sib in siblings:
if sib["sort_order"] >= insert_point:
reorder.append(sib["id"])
for i, sid in enumerate(sorted(reorder, key=lambda x: next(s["sort_order"] for s in siblings if s["id"] == x))):
conn.execute("UPDATE pages SET sort_order=? WHERE id=?", (insert_point + i + 1, sid))
conn.commit()
return {"status": "ok", "id": page_id, "parent_id": new_parent_id, "order": new_order}
@router.delete("/api/pages/{page_id}")
async def delete_page(request: Request, page_id: int):
"""Move a page to trash (soft delete)."""
with get_conn() as conn:
row = conn.execute("SELECT id, title FROM pages WHERE id=? AND deleted_at IS NULL", (page_id,)).fetchone()
if not row:
raise HTTPException(404, "Page not found")
import datetime
conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.utcnow().isoformat(), page_id,))
conn.commit()
return {"status": "ok", "deleted": page_id, "title": row["title"]}
@router.get("/pages/{page_id}", response_class=HTMLResponse)
async def view_page(request: Request, page_id: int):
"""Render a page as HTML."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
sidebar = _sidebar_data(request)
ctx = {**sidebar, "page": dict(row)}
template = env.get_template("page_editor.html")
return template.render(**ctx)
@router.post("/api/sync/{owner}/{repo}")
async def sync_project(owner: str, repo: str):
File diff suppressed because it is too large Load Diff
+78 -28
View File
@@ -8,78 +8,128 @@ from fastapi.responses import HTMLResponse
from app.services.gitea_client import gitea
from app.auth.session import SessionManager
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"])
def _sidebar_data(request: Request, repos: list[dict]) -> dict:
"""Build sidebar context from Gitea repos."""
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
workspace_name = user.get("login", "Bruno") if user else "Bruno"
workspace_initial = workspace_name[0].upper() if workspace_name else "B"
ws = user.get("login", "Bruno") if user else "Bruno"
initial = ws[0].upper() if ws else "B"
# Build recent pages from repos
recent_pages = []
private_pages = []
for repo in repos[:10]:
full_name = repo.get("full_name", "")
parts = full_name.split("/") if "/" in full_name else [workspace_name, full_name]
page = {
recent_pages.append({
"id": full_name,
"name": repo.get("name", full_name),
"icon": "📁",
"url": f"/board/{full_name}",
"active": False,
"indent": 0,
}
recent_pages.append(page)
private_pages.append({**page})
"depth": 0,
"has_children": False,
"children": [],
})
return {
"workspace_name": workspace_name,
"workspace_initial": workspace_initial,
"current_page": "Dashboard",
"last_edited": "now",
"workspace_name": ws, "workspace_initial": initial,
"current_page": "Dashboard", "last_edited": "now",
"recent_pages": recent_pages,
"private_pages": private_pages,
"private_pages": [],
"favorite_pages": [],
"user": user,
"workspace_key": "",
}
@router.get("/trash", response_class=HTMLResponse)
async def trash_page(request: Request):
"""Trash page."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, [])
template = env.get_template("trash.html")
return template.render(**sidebar)
@router.get("/library", response_class=HTMLResponse)
async def library_page(request: Request):
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, [])
for p in sidebar["recent_pages"]:
p["last_edited"] = "now"
for p in sidebar["private_pages"]:
p["last_edited"] = "now"
template = env.get_template("library.html")
return template.render(**sidebar)
@router.get("/pages/{page_id}", response_class=HTMLResponse)
async def view_page_root(request: Request, page_id: int):
"""Render a Markdown page at root level with workspace context."""
from jinja2 import Environment, FileSystemLoader
from app.routers.board import _sidebar_data as board_sidebar
env = Environment(loader=FileSystemLoader("app/templates"))
with get_conn() as conn:
row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone()
if not row:
return HTMLResponse("<h2>Page not found</h2>", status_code=404)
page = dict(row)
ws = page.get("workspace", "")
parts = ws.split("/") if "/" in ws else ["", ""]
owner, repo = parts[0], parts[1] if len(parts) > 1 else ""
sidebar = board_sidebar(request, owner, repo)
# Load sub-pages
with get_conn() as conn:
subs = conn.execute(
"SELECT id, title FROM pages WHERE parent_id=? ORDER BY updated_at DESC",
(page_id,),
).fetchall()
ctx = {**sidebar, "page": page, "sub_pages": [dict(s) for s in subs]}
template = env.get_template("page_editor.html")
return template.render(**ctx)
@router.get("/accounts", response_class=HTMLResponse)
async def accounts_page(request: Request):
"""Account management panel."""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, [])
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
with get_conn() as conn:
users = conn.execute("SELECT * FROM users ORDER BY created_at DESC").fetchall()
ctx = {**sidebar, "user": user, "users": [dict(u) for u in users]}
template = env.get_template("accounts.html")
return template.render(**ctx)
@router.get("/", response_class=HTMLResponse)
async def dashboard(
request: Request,
search: str = Query(default=""),
show_archived: bool = Query(default=False),
):
"""Dashboard: liste tous les projets Gitea (user + orgs)."""
try:
repos = await gitea.get_user_repos(page=1, limit=50)
if search:
q = search.lower()
repos = [r for r in repos if q in r.get("full_name", "").lower()
or q in (r.get("description") or "").lower()]
if not show_archived:
repos = [r for r in repos if not r.get("archived", False)]
repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
except Exception as e:
logger.error("Dashboard error: %s", e)
repos = []
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
sidebar = _sidebar_data(request, repos)
template = env.get_template("dashboard.html")
return template.render(
request=request,
repos=repos,
search=search,
show_archived=show_archived,
**sidebar,
)
return template.render(request=request, repos=repos, search=search,
show_archived=show_archived, **sidebar)
+178
View File
@@ -0,0 +1,178 @@
"""FlowDeck — My Tasks router (v1.9.0)."""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Request, HTTPException, Query
from fastapi.responses import HTMLResponse
from app.db import get_conn
from app.auth.session import SessionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
def _get_current_user(request: Request) -> dict | None:
session = request.cookies.get("flowdeck_session", "")
return SessionManager.decode_session(session)
@router.get("", response_class=HTMLResponse)
async def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
"""My Tasks — aggregates all pages assigned to the current user across all collections."""
user = _get_current_user(request)
user_login = user.get("login", "admin") if user else "admin"
with get_conn() as conn:
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
grouped: dict[str, list[dict]] = {}
for col in collections:
col_id = col["id"]
pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
(col_id,),
).fetchall()
collection_tasks = []
for p in pages:
props = json.loads(p["property_values_json"])
# Check if assigned to current user
assigned = False
for v in props.values():
if isinstance(v, list):
for item in v:
if isinstance(item, dict) and item.get("login") == user_login:
assigned = True
break
elif isinstance(v, str) and user_login.lower() in v.lower():
assigned = True
if assigned:
break
if not assigned and user_login == "admin":
assigned = True # admin sees all
if not assigned:
continue
# Extract status and due date
status = None
due_date = None
for v in props.values():
if isinstance(v, str) and v in ("Todo", "In Progress", "Done", "Complete"):
status = v
elif isinstance(v, str) and v.startswith("20"):
due_date = v[:10]
# Filter by view
if view == "today":
from datetime import date
if not due_date or due_date != date.today().isoformat():
continue
elif view == "overdue":
from datetime import date
if not due_date or due_date >= date.today().isoformat():
continue
elif view == "upcoming":
from datetime import date, timedelta
if not due_date:
continue
today = date.today()
limit = today + timedelta(days=days)
d = date.fromisoformat(due_date)
if d < today or d > limit:
continue
collection_tasks.append({
"id": p["id"], "title": p["title"], "icon": p.get("icon", "📄"),
"status": status, "due_date": due_date, "props": props,
})
if collection_tasks:
grouped[col["name"]] = collection_tasks
total = sum(len(t) for t in grouped.values())
# Render
sections = ""
for col_name, tasks in grouped.items():
items = ""
for t in tasks:
due_badge = f"<span class='mt-due'>{t['due_date']}</span>" if t.get("due_date") else ""
status_badge = f"<span class='mt-status mt-{t['status'].lower().replace(' ','-') if t.get('status') else 'none'}'>{t.get('status','—')}</span>"
items += f"""<div class='mt-item'>
<span class='mt-icon'>{t['icon']}</span>
<span class='mt-title'>{t['title']}</span>
{status_badge}{due_badge}
</div>"""
sections += f"""<div class='mt-section'>
<div class='mt-section-header'>{col_name} <span class='mt-count'>{len(tasks)}</span></div>
{items}
</div>"""
return HTMLResponse(f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>My Tasks — FlowDeck</title>
<style>
body{{font-family:system-ui,sans-serif;background:#191919;color:#fff;margin:0;padding:20px}}
h1{{font-size:24px;margin:0 0 16px}}
.view-tabs{{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid #333;padding-bottom:8px}}
.tab{{padding:6px 14px;border-radius:6px;cursor:pointer;color:#A0A0A0;font-size:13px;background:none;border:none;text-decoration:none}}
.tab:hover,.tab.active{{background:#333;color:#fff}}
.mt-section{{margin-bottom:24px}}
.mt-section-header{{font-size:14px;font-weight:600;color:#A0A0A0;margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px}}
.mt-count{{color:#6B6B6B;font-weight:400}}
.mt-item{{display:flex;align-items:center;gap:10px;padding:8px 12px;background:#1a1a1a;border-radius:6px;margin-bottom:3px;border:1px solid #222}}
.mt-item:hover{{border-color:#444}}
.mt-icon{{font-size:16px}}
.mt-title{{flex:1;font-size:14px}}
.mt-status{{font-size:11px;padding:2px 8px;border-radius:4px}}
.mt-due{{font-size:11px;color:#A0A0A0}}
.mt-todo{{background:#333;color:#A0A0A0}}
.mt-in-progress{{background:#1a2744;color:#3366CC}}
.mt-done,.mt-complete{{background:#1a332a;color:#00CC66}}
.p_desc{{color:#A0A0A0;margin-top:8px}}
</style></head><body>
<h1>📋 My Tasks</h1>
<div class="view-tabs">
<a class="tab{' active' if view=='all' else ''}" href="?view=all">All</a>
<a class="tab{' active' if view=='today' else ''}" href="?view=today">Today</a>
<a class="tab{' active' if view=='overdue' else ''}" href="?view=overdue">Overdue</a>
<a class="tab{' active' if view=='upcoming' else ''}" href="?view=upcoming&days=7">Next 7 days</a>
</div>
{sections}
<p class="p_desc">{total} tasks across {len(grouped)} collections</p>
</body></html>""")
@router.get("/api")
async def my_tasks_api(request: Request, view: str = "all", days: int = 7):
"""API: return my tasks as JSON."""
user = _get_current_user(request)
user_login = user.get("login", "admin") if user else "admin"
with get_conn() as conn:
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
result = {}
for col in collections:
pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
(col["id"],),
).fetchall()
tasks = []
for p in pages:
props = json.loads(p["property_values_json"])
tasks.append({
"id": p["id"], "title": p["title"], "icon": p["icon"],
"properties": props,
})
if tasks:
result[col["name"]] = tasks
return {"tasks": result, "total": sum(len(t) for t in result.values())}
+89
View File
@@ -0,0 +1,89 @@
"""FlowDeck — Public API router (v2.1.0)."""
from __future__ import annotations
import json
import logging
from secrets import token_urlsafe
from fastapi import APIRouter, Request, HTTPException, Header, Depends
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["public-api"], prefix="/api/v1")
DEFAULT_TOKEN = "fd-public-key"
def verify_token(authorization: str | None = Header(None)):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(401, "API token required. Generate one via POST /api/v1/token.")
token = authorization[7:] # strip "Bearer "
if token == DEFAULT_TOKEN:
return token
with get_conn() as conn:
row = conn.execute("SELECT 1 FROM user_tokens WHERE gitea_token=?", (token,)).fetchone()
if not row:
raise HTTPException(403, "Invalid API token")
return token
@router.post("/token")
async def generate_token(request: Request):
"""Generate a public API access token."""
token = f"fd_{token_urlsafe(24)}"
with get_conn() as conn:
conn.execute(
"INSERT OR REPLACE INTO user_tokens (gitea_user_id, gitea_token, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)",
(0, token),
)
conn.commit()
return {"token": token, "note": "Use as: Authorization: Bearer <token>"}
@router.get("/collections", dependencies=[Depends(verify_token)])
async def public_list_collections(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT id, name, description, icon, created_at FROM collections ORDER BY name").fetchall()
return {"collections": [dict(r) for r in rows]}
@router.get("/collections/{collection_id}", dependencies=[Depends(verify_token)])
async def public_get_collection(request: Request, collection_id: int):
with get_conn() as conn:
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(404, "Not found")
pages = conn.execute(
"SELECT id, title, icon, position, property_values_json, created_at, updated_at FROM collection_pages WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
return {"collection": dict(coll), "pages": [dict(p) for p in pages]}
@router.get("/collections/{collection_id}/pages", dependencies=[Depends(verify_token)])
async def public_list_pages(request: Request, collection_id: int):
with get_conn() as conn:
pages = conn.execute(
"SELECT id, title, icon, position, property_values_json, created_at FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
(collection_id,),
).fetchall()
return {"pages": [dict(p) for p in pages]}
@router.get("/pages/{page_id}", dependencies=[Depends(verify_token)])
async def public_get_page(request: Request, page_id: int):
with get_conn() as conn:
p = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not p:
raise HTTPException(404, "Not found")
return dict(p)
@router.get("/my-tasks", dependencies=[Depends(verify_token)])
async def public_my_tasks(request: Request):
"""Public API: list tasks (requires valid token)."""
with get_conn() as conn:
pages = conn.execute(
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE parent_id IS NULL ORDER BY created_at DESC LIMIT 50"
).fetchall()
return {"tasks": [dict(p) for p in pages]}
+411
View File
@@ -0,0 +1,411 @@
"""FlowDeck — Workspace, Comments, Favorites, History, Templates (v2.0.0)."""
from __future__ import annotations
import csv
import io
import json
import logging
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import HTMLResponse, StreamingResponse
from app.db import get_conn
from app.auth.session import SessionManager
logger = logging.getLogger(__name__)
router = APIRouter(tags=["workspace"], prefix="/workspace")
ROLES = ["admin", "editor", "commenter", "viewer"]
def _current_user(request: Request) -> dict:
s = request.cookies.get("flowdeck_session", "")
return SessionManager.decode_session(s) or {"login": "admin", "id": 1}
# ── Workspaces ──
@router.get("")
async def list_workspaces(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT * FROM workspaces ORDER BY name").fetchall()
return {"workspaces": [dict(r) for r in rows]}
@router.post("")
async def create_workspace(request: Request):
body = await request.json() if request.headers.get("content-type") else {}
name = body.get("name", "Default Workspace")
user = _current_user(request)
uid = user.get("id", 1)
with get_conn() as conn:
uid_ensured = conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone()
if not uid_ensured:
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
(uid, user.get("login", "admin"), user.get("full_name", "Admin")))
cur = conn.execute("INSERT INTO workspaces (name, owner_id) VALUES (?,?)", (name, uid))
ws_id = cur.lastrowid
conn.execute("INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)",
(ws_id, uid, "admin"))
conn.commit()
return {"id": ws_id, "name": name, "status": "created"}
# ── Members ──
@router.get("/{ws_id}/members")
async def list_members(request: Request, ws_id: int):
with get_conn() as conn:
rows = conn.execute(
"SELECT wm.*, u.login, u.full_name, u.avatar_url FROM workspace_members wm JOIN users u ON wm.user_id=u.id WHERE wm.workspace_id=?",
(ws_id,),
).fetchall()
return {"members": [dict(r) for r in rows]}
@router.post("/{ws_id}/members")
async def add_member(request: Request, ws_id: int):
body = await request.json() if request.headers.get("content-type") else {}
user_id = body.get("user_id")
role = body.get("role", "editor")
if role not in ROLES:
raise HTTPException(400, f"Invalid role: {role}")
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
(user_id, f"user_{user_id}", f"User {user_id}"))
conn.execute("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?,?,?)",
(ws_id, user_id, role))
conn.commit()
return {"status": "added"}
@router.put("/{ws_id}/members/{user_id}")
async def update_member_role(request: Request, ws_id: int, user_id: int):
body = await request.json() if request.headers.get("content-type") else {}
role = body.get("role", "editor")
if role not in ROLES:
raise HTTPException(400, f"Invalid role: {role}")
with get_conn() as conn:
conn.execute("UPDATE workspace_members SET role=? WHERE workspace_id=? AND user_id=?",
(role, ws_id, user_id))
conn.commit()
return {"status": "updated"}
@router.delete("/{ws_id}/members/{user_id}")
async def remove_member(request: Request, ws_id: int, user_id: int):
with get_conn() as conn:
conn.execute("DELETE FROM workspace_members WHERE workspace_id=? AND user_id=?", (ws_id, user_id))
conn.commit()
return {"status": "removed"}
# ── Comments ──
@router.get("/pages/{page_id}/comments")
async def list_comments(request: Request, page_id: int):
with get_conn() as conn:
rows = conn.execute(
"SELECT c.*, u.login, u.avatar_url FROM comments c JOIN users u ON c.user_id=u.id WHERE c.page_id=? ORDER BY c.created_at",
(page_id,),
).fetchall()
return {"comments": [dict(r) for r in rows]}
@router.post("/pages/{page_id}/comments")
async def add_comment(request: Request, page_id: int):
body = await request.json() if request.headers.get("content-type") else {}
b = body.get("body", "").strip()
if not b:
raise HTTPException(400, "body required")
user = _current_user(request)
uid = user.get("id", 1)
parent_id = body.get("parent_id")
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)",
(uid, user.get("login", "admin"), user.get("full_name", "Admin")))
cur = conn.execute("INSERT INTO comments (page_id, user_id, body, parent_id) VALUES (?,?,?,?)",
(page_id, uid, b, parent_id))
conn.commit()
return {"id": cur.lastrowid, "status": "created"}
@router.put("/comments/{comment_id}")
async def update_comment(request: Request, comment_id: int):
body = await request.json() if request.headers.get("content-type") else {}
b = body.get("body")
resolved = body.get("resolved")
with get_conn() as conn:
if b is not None:
conn.execute("UPDATE comments SET body=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (b, comment_id))
if resolved is not None:
conn.execute("UPDATE comments SET resolved=? WHERE id=?", (int(resolved), comment_id))
conn.commit()
return {"status": "updated"}
# ── Page History ──
@router.get("/pages/{page_id}/history")
async def page_history(request: Request, page_id: int):
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM page_history WHERE page_id=? ORDER BY created_at DESC LIMIT 50",
(page_id,),
).fetchall()
return {"history": [dict(r) for r in rows]}
@router.post("/pages/{page_id}/history")
async def record_history(request: Request, page_id: int):
body = await request.json() if request.headers.get("content-type") else {}
user = _current_user(request)
uid = user.get("id", 1)
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login, is_admin) VALUES (?,?,1)", (uid, user.get("login", "admin")))
conn.execute(
"INSERT INTO page_history (page_id, user_id, change_type, snapshot_json) VALUES (?,?,?,?)",
(page_id, uid, body.get("change_type", "updated"), json.dumps(body.get("snapshot", {}))),
)
conn.commit()
return {"status": "recorded"}
# ── Favorites ──
@router.get("/favorites")
async def list_favorites(request: Request):
user = _current_user(request)
uid = user.get("id", 1)
with get_conn() as conn:
rows = conn.execute(
"""SELECT f.*, cp.title as page_title, c.name as collection_name
FROM favorites f
LEFT JOIN collection_pages cp ON f.page_id=cp.id
LEFT JOIN collections c ON f.collection_id=c.id
WHERE f.user_id=? ORDER BY f.position""",
(uid,),
).fetchall()
return {"favorites": [dict(r) for r in rows]}
@router.post("/favorites")
async def add_favorite(request: Request):
body = await request.json() if request.headers.get("content-type") else {}
user = _current_user(request)
uid = user.get("id", 1)
page_id = body.get("page_id")
collection_id = body.get("collection_id")
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login, is_admin) VALUES (?,?,1)", (uid, user.get("login", "admin")))
conn.execute(
"INSERT OR IGNORE INTO favorites (user_id, page_id, collection_id) VALUES (?,?,?)",
(uid, page_id, collection_id),
)
conn.commit()
return {"status": "favorited"}
@router.delete("/favorites/{fav_id}")
async def remove_favorite(request: Request, fav_id: int):
with get_conn() as conn:
conn.execute("DELETE FROM favorites WHERE id=?", (fav_id,))
conn.commit()
return {"status": "removed"}
# ── Templates ──
@router.get("/templates/database")
async def list_db_templates(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT * FROM database_templates ORDER BY name").fetchall()
return {"templates": [dict(r) for r in rows]}
@router.post("/templates/database")
async def create_db_template(request: Request):
body = await request.json() if request.headers.get("content-type") else {}
cur = None
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO database_templates (name, description, schema_json) VALUES (?,?,?)",
(body.get("name", "Template"), body.get("description", ""), json.dumps(body.get("schema", []))),
)
conn.commit()
return {"id": cur.lastrowid, "status": "created"}
@router.post("/templates/database/{tid}/apply")
async def apply_db_template(request: Request, tid: int):
body = await request.json() if request.headers.get("content-type") else {}
name = body.get("name", "New Database")
with get_conn() as conn:
tmpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (tid,)).fetchone()
if not tmpl:
raise HTTPException(404, "Template not found")
cur = conn.execute(
"INSERT INTO collections (name, description, icon, schema_json) VALUES (?,?,?,?)",
(name, tmpl["description"], "📋", tmpl["schema_json"]),
)
conn.execute(
"INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?,?,?,?)",
(cur.lastrowid, "Default View", "table", "{}"),
)
conn.commit()
return {"collection_id": cur.lastrowid, "name": name, "status": "created"}
@router.get("/collections/{collection_id}/templates/page")
async def list_page_templates(request: Request, collection_id: int):
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM page_templates WHERE collection_id=? ORDER BY name", (collection_id,)
).fetchall()
return {"templates": [dict(r) for r in rows]}
@router.post("/collections/{collection_id}/templates/page")
async def create_page_template(request: Request, collection_id: int):
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO page_templates (collection_id, name, property_values_json) VALUES (?,?,?)",
(collection_id, body.get("name", "Default"), json.dumps(body.get("properties", {}))),
)
conn.commit()
return {"id": cur.lastrowid, "status": "created"}
@router.post("/collections/{collection_id}/templates/page/{tid}/apply")
async def apply_page_template(request: Request, collection_id: int, tid: int):
body = await request.json() if request.headers.get("content-type") else {}
with get_conn() as conn:
tmpl = conn.execute("SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)).fetchone()
if not tmpl:
raise HTTPException(404, "Template not found")
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages WHERE collection_id=?", (collection_id,)
).fetchone()[0]
cur = conn.execute(
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
(collection_id, body.get("title", "New Page"), max_pos, tmpl["property_values_json"]),
)
conn.commit()
return {"id": cur.lastrowid, "status": "created"}
# ── CSV Import/Export ──
@router.post("/collections/{collection_id}/import/csv")
async def import_csv(request: Request, collection_id: int):
body = await request.json() if request.headers.get("content-type") else {}
csv_data = body.get("csv", "")
if not csv_data:
raise HTTPException(400, "csv field required")
reader = csv.DictReader(io.StringIO(csv_data))
rows_imported = 0
with get_conn() as conn:
max_pos = conn.execute(
"SELECT COALESCE(MAX(position), -1) FROM collection_pages WHERE collection_id=?", (collection_id,)
).fetchone()[0]
for row in reader:
title = row.get("title", row.get("Title", row.get("Name", "Imported")))
props = {k: v for k, v in row.items() if k.lower() != "title"}
max_pos += 1
conn.execute(
"INSERT INTO collection_pages (collection_id, title, position, property_values_json) VALUES (?,?,?,?)",
(collection_id, title, max_pos, json.dumps(props)),
)
rows_imported += 1
conn.commit()
return {"imported": rows_imported}
@router.get("/collections/{collection_id}/export/csv")
async def export_csv(request: Request, collection_id: int):
with get_conn() as conn:
pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? ORDER BY position", (collection_id,)
).fetchall()
# Collect all property keys
all_keys = set()
rows = []
for p in pages:
props = json.loads(p["property_values_json"])
all_keys.update(props.keys())
rows.append({"title": p["title"], **props})
output = io.StringIO()
fieldnames = ["title"] + sorted(all_keys)
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=export.csv"},
)
# ── Webhooks Outbound Management ──
@router.get("/webhooks")
async def list_webhooks(request: Request):
with get_conn() as conn:
rows = conn.execute("SELECT * FROM webhook_subscriptions ORDER BY created_at DESC").fetchall()
return {"webhooks": [dict(r) for r in rows]}
@router.post("/webhooks")
async def create_webhook(request: Request):
body = await request.json() if request.headers.get("content-type") else {}
url = body.get("url", "").strip()
event = body.get("event", "page.created")
secret = body.get("secret", "")
if not url:
raise HTTPException(400, "url required")
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO webhook_subscriptions (url, event, secret) VALUES (?,?,?)",
(url, event, secret),
)
conn.commit()
return {"id": cur.lastrowid, "url": url, "event": event, "status": "registered"}
@router.delete("/webhooks/{wh_id}")
async def delete_webhook(request: Request, wh_id: int):
with get_conn() as conn:
conn.execute("DELETE FROM webhook_subscriptions WHERE id=?", (wh_id,))
conn.commit()
return {"status": "deleted"}
# ── Public Sharing ──
@router.get("/public/{collection_id}")
async def public_view(request: Request, collection_id: int):
"""Simple public read-only view — no auth required."""
with get_conn() as conn:
coll = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(404, "Collection not found")
pages = conn.execute(
"SELECT id, title, icon, property_values_json FROM collection_pages WHERE collection_id=? ORDER BY position",
(collection_id,),
).fetchall()
items = "".join(
f"<li>{p['icon']} <b>{p['title']}</b></li>"
for p in pages
)
return HTMLResponse(f"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>{coll['name']} — FlowDeck Public</title>
<style>body{{font-family:system-ui;background:#191919;color:#fff;padding:20px}}
h1{{font-size:24px}} li{{padding:8px;border-bottom:1px solid #333}}</style></head>
<body><h1>{coll['icon']} {coll['name']}</h1><ul>{items}</ul><p>{len(pages)} items</p></body></html>""")
+222
View File
@@ -0,0 +1,222 @@
"""FlowDeck — GiteaBoardCompat: legacy board → Collection adapter (v1.3.0).
Converts existing Gitea-linked boards and cards into the new collection model
without breaking the existing board routes.
"""
from __future__ import annotations
import json
from typing import Optional
from app.db import get_conn
class GiteaBoardCompat:
"""Adaptateur: board Gitea legacy → Collection.
Permet de visualiser les boards Gitea existants comme des collections,
avec rétrocompatibilité totale. Les anciennes routes /board/... continuent
de fonctionner normalement.
"""
@staticmethod
def from_board(board_row) -> dict:
"""Convertit un board existant en pseudo-collection."""
columns = json.loads(board_row.get("columns_json", "[]"))
return {
"collection_id": board_row["id"],
"name": f"{board_row['project_owner']}/{board_row['project_name']}",
"description": f"Board Gitea: {board_row['project_name']}",
"icon": "📋",
"gitea_owner": board_row["project_owner"],
"gitea_repo": board_row["project_name"],
"is_gitea_linked": True,
"is_locked": False,
"schema": [
{"name": "Title", "type": "title"},
{"name": "Status", "type": "select", "options": [
{"name": c, "color": "gray"} for c in columns
]},
{"name": "Priority", "type": "select", "options": [
{"name": "P1", "color": "red"},
{"name": "P2", "color": "orange"},
{"name": "P3", "color": "yellow"},
{"name": "P4", "color": "gray"},
]},
{"name": "Due Date", "type": "date"},
{"name": "Assignee", "type": "person"},
],
}
@staticmethod
def from_card(card_row, gitea_issue: Optional[dict] = None) -> dict:
"""Convertit une card legacy en pseudo collection_page."""
title = gitea_issue.get("title", f"Card #{card_row['id']}") if gitea_issue else f"Card #{card_row['id']}"
priority = card_row.get("priority", "Medium")
due_date = card_row.get("due_date", "")
props = {
"Status": card_row.get("column_name", "Backlog"),
"Priority": priority,
"Due Date": due_date if due_date else None,
}
if gitea_issue:
assignee = gitea_issue.get("assignee")
if assignee:
props["Assignee"] = [{"login": assignee.get("login", ""), "avatar_url": assignee.get("avatar_url", "")}]
return {
"id": card_row["id"],
"title": title,
"position": card_row.get("position", 0),
"gitea_issue_id": card_row.get("gitea_issue_id"),
"gitea_issue_number": gitea_issue.get("number") if gitea_issue else None,
"property_values_json": json.dumps(props),
}
@staticmethod
def list_boards_as_collections() -> list[dict]:
"""Liste tous les boards comme des collections compatibles."""
with get_conn() as conn:
rows = conn.execute("SELECT * FROM boards ORDER BY project_owner, project_name").fetchall()
return [GiteaBoardCompat.from_board(dict(r)) for r in rows]
@staticmethod
def get_board_as_collection(owner: str, repo: str) -> Optional[dict]:
"""Récupère un board spécifique comme collection."""
with get_conn() as conn:
row = conn.execute(
"SELECT * FROM boards WHERE project_owner=? AND project_name=?",
(owner, repo),
).fetchone()
if not row:
return None
return GiteaBoardCompat.from_board(dict(row))
@staticmethod
def get_board_cards(owner: str, repo: str, gitea_issues: Optional[list[dict]] = None) -> list[dict]:
"""Récupère les cartes d'un board comme collection_pages."""
with get_conn() as conn:
board = conn.execute(
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
(owner, repo),
).fetchone()
if not board:
return []
cards = conn.execute(
"SELECT * FROM cards WHERE board_id=? ORDER BY position",
(board["id"],),
).fetchall()
issues_by_id = {}
if gitea_issues:
issues_by_id = {i.get("id"): i for i in gitea_issues}
return [
GiteaBoardCompat.from_card(dict(c), issues_by_id.get(c["gitea_issue_id"]))
for c in cards
]
@staticmethod
def sync_to_collection(owner: str, repo: str, gitea_issues: list[dict]) -> Optional[int]:
"""Sync un board Gitea vers une vraie collection.
Crée ou met à jour une collection liée à Gitea et importe les pages.
Retourne l'ID de la collection.
"""
with get_conn() as conn:
board = conn.execute(
"SELECT * FROM boards WHERE project_owner=? AND project_name=?",
(owner, repo),
).fetchone()
if not board:
return None
board_dict = dict(board)
columns = json.loads(board_dict.get("columns_json", "[]"))
# Upsert collection
existing = conn.execute(
"SELECT id FROM collections WHERE gitea_owner=? AND gitea_repo=?",
(owner, repo),
).fetchone()
if existing:
coll_id = existing["id"]
conn.execute(
"UPDATE collections SET name=?, schema_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(f"{owner}/{repo}", json.dumps([
{"name": "Title", "type": "title"},
{"name": "Status", "type": "select",
"options": [{"name": c, "color": "gray"} for c in columns]},
{"name": "Priority", "type": "select",
"options": [{"name": "P1", "color": "red"}, {"name": "P2", "color": "orange"},
{"name": "P3", "color": "yellow"}, {"name": "P4", "color": "gray"}]},
]), coll_id),
)
else:
cur = conn.execute(
"""INSERT INTO collections (name, icon, schema_json, gitea_owner, gitea_repo)
VALUES (?, '📋', ?, ?, ?)""",
(f"{owner}/{repo}", json.dumps([
{"name": "Title", "type": "title"},
{"name": "Status", "type": "select",
"options": [{"name": c, "color": "gray"} for c in columns]},
{"name": "Priority", "type": "select",
"options": [{"name": "P1", "color": "red"}, {"name": "P2", "color": "orange"},
{"name": "P3", "color": "yellow"}, {"name": "P4", "color": "gray"}]},
]), owner, repo),
)
coll_id = cur.lastrowid
# Create default view
conn.execute(
"INSERT INTO collection_views (collection_id, name, view_type, config_json) VALUES (?, ?, ?, ?)",
(coll_id, "Default View", "table", json.dumps({"visible_properties": ["Title", "Status", "Priority"]})),
)
# Sync cards → collection_pages
cards = conn.execute(
"SELECT * FROM cards WHERE board_id=? ORDER BY position",
(board_dict["id"],),
).fetchall()
issues_by_id = {i.get("id"): i for i in gitea_issues}
for card in cards:
card_dict = dict(card)
issue = issues_by_id.get(card_dict.get("gitea_issue_id"))
title = issue.get("title", f"Issue #{card_dict.get('gitea_issue_id', card_dict['id'])}") if issue else f"Card #{card_dict['id']}"
props = {
"Status": card_dict.get("column_name", "Backlog"),
"Priority": card_dict.get("priority", "Medium"),
}
# Upsert collection_page
existing_page = conn.execute(
"SELECT id FROM collection_pages WHERE collection_id=? AND gitea_issue_id=?",
(coll_id, card_dict.get("gitea_issue_id")),
).fetchone()
if existing_page:
conn.execute(
"""UPDATE collection_pages SET title=?, position=?, property_values_json=?,
updated_at=CURRENT_TIMESTAMP WHERE id=?""",
(title, card_dict.get("position", 0), json.dumps(props), existing_page["id"]),
)
else:
conn.execute(
"""INSERT INTO collection_pages
(collection_id, title, position, gitea_issue_id, gitea_issue_number,
property_values_json)
VALUES (?, ?, ?, ?, ?, ?)""",
(coll_id, title, card_dict.get("position", 0),
card_dict.get("gitea_issue_id"), issue.get("number") if issue else None,
json.dumps(props)),
)
conn.commit()
return coll_id
+223
View File
@@ -0,0 +1,223 @@
"""FlowDeck — Formula Engine (v1.5.0)."""
from __future__ import annotations
from datetime import datetime, date, timedelta
from typing import Any, Callable, Optional
class FormulaEngine:
"""Moteur d'expressions JavaScript-like pour propriétés calculées.
Usage:
engine = FormulaEngine()
result = engine.evaluate("prop('Price') * prop('Qty')", {"Price": 10, "Qty": 5})
# → 50
"""
def __init__(self):
self._functions: dict[str, Callable] = {
"prop": self._prop,
"now": self._now,
"today": self._today,
"if": self._if,
"concat": self._concat,
"round": self._round,
"contains": self._contains,
"length": self._length,
"toNumber": self._to_number,
"formatDate": self._format_date,
"dateAdd": self._date_add,
"dateSubtract": self._date_subtract,
"replace": self._replace,
"replaceAll": self._replace_all,
"join": self._join,
"empty": self._empty,
"and": self._and,
"or": self._or,
"not": self._not,
"start": self._start,
"end": self._end,
}
def evaluate(self, expression: str, context: dict[str, Any]) -> Any:
"""Évalue une expression de formule dans un contexte donné.
Le contexte contient les valeurs des propriétés accessibles via prop('name').
"""
if not expression or not expression.strip():
return None
try:
return self._eval_expr(expression.strip(), context)
except Exception:
return None
def _eval_expr(self, expr: str, ctx: dict[str, Any]) -> Any:
"""Parse et évalue l'expression."""
expr = expr.strip()
# Function call
if "(" in expr and expr.endswith(")"):
paren = expr.index("(")
func_name = expr[:paren].strip()
args_str = expr[paren + 1:-1]
args = self._parse_args(args_str)
evaluated = [self._eval_expr(a.strip(), ctx) for a in args]
if func_name in self._functions:
return self._functions[func_name](ctx, *evaluated)
return None
# String literal
if (expr.startswith("'") and expr.endswith("'")) or \
(expr.startswith('"') and expr.endswith('"')):
return expr[1:-1]
# Number
try:
return float(expr) if "." in expr else int(expr)
except ValueError:
pass
# Boolean
if expr.lower() == "true":
return True
if expr.lower() == "false":
return False
if expr.lower() == "null":
return None
# Context lookup
if expr in ctx:
return ctx[expr]
return expr # raw string
def _parse_args(self, s: str) -> list[str]:
"""Parse une liste d'arguments en respectant les guillemets."""
args = []
current = ""
depth = 0
in_quote = False
quote_char = ""
for ch in s:
if ch in ("'", '"') and not in_quote:
in_quote = True
quote_char = ch
current += ch
elif ch == quote_char and in_quote:
in_quote = False
current += ch
elif ch == "(":
depth += 1
current += ch
elif ch == ")":
depth -= 1
current += ch
elif ch == "," and depth == 0 and not in_quote:
args.append(current.strip())
current = ""
else:
current += ch
if current.strip():
args.append(current.strip())
return args
# ── Built-in functions ──
def _prop(self, ctx: dict, name: str) -> Any:
return ctx.get(name)
def _now(self, ctx: dict) -> str:
return datetime.now().isoformat()
def _today(self, ctx: dict) -> str:
return date.today().isoformat()
def _if(self, ctx: dict, cond: Any, a: Any, b: Any) -> Any:
return a if cond else b
def _concat(self, ctx: dict, *args) -> str:
return "".join(str(a) for a in args)
def _round(self, ctx: dict, n: Any, d: int = 0) -> float:
return round(float(n), int(d))
def _contains(self, ctx: dict, s: Any, sub: str) -> bool:
return str(sub) in str(s)
def _length(self, ctx: dict, s: Any) -> int:
return len(str(s))
def _to_number(self, ctx: dict, s: Any) -> float:
try:
return float(s)
except (ValueError, TypeError):
return 0.0
def _format_date(self, ctx: dict, d: str, fmt: str = "%Y-%m-%d") -> str:
try:
dt = datetime.fromisoformat(d.replace("Z", "+00:00"))
return dt.strftime(fmt)
except Exception:
return str(d)
def _date_add(self, ctx: dict, d: str, n: int, unit: str = "days") -> str:
try:
dt = datetime.fromisoformat(d.replace("Z", "+00:00"))
if unit == "days":
dt += timedelta(days=int(n))
elif unit == "hours":
dt += timedelta(hours=int(n))
elif unit == "minutes":
dt += timedelta(minutes=int(n))
return dt.isoformat()
except Exception:
return d
def _date_subtract(self, ctx: dict, d: str, n: int, unit: str = "days") -> str:
try:
dt = datetime.fromisoformat(d.replace("Z", "+00:00"))
if unit == "days":
dt -= timedelta(days=int(n))
elif unit == "hours":
dt -= timedelta(hours=int(n))
elif unit == "minutes":
dt -= timedelta(minutes=int(n))
return dt.isoformat()
except Exception:
return d
def _replace(self, ctx: dict, s: Any, old: str, new: str) -> str:
return str(s).replace(old, new)
def _replace_all(self, ctx: dict, s: Any, old: str, new: str) -> str:
return str(s).replace(old, new)
def _join(self, ctx: dict, sep: str, *args) -> str:
return sep.join(str(a) for a in args)
def _empty(self, ctx: dict, v: Any) -> bool:
return v is None or v == "" or v == [] or v == 0
def _and(self, ctx: dict, *args) -> bool:
return all(args)
def _or(self, ctx: dict, *args) -> bool:
return any(args)
def _not(self, ctx: dict, v: Any) -> bool:
return not v
def _start(self, ctx: dict, s: Any) -> str:
"""Extract start date from a date range (iso1...iso2 or just iso1)."""
val = str(s)
if "..." in val:
return val.split("...")[0]
return val
def _end(self, ctx: dict, s: Any) -> Optional[str]:
"""Extract end date from a date range."""
val = str(s)
if "..." in val:
parts = val.split("...")
return parts[1] if len(parts) > 1 else None
return None
+187
View File
@@ -0,0 +1,187 @@
"""FlowDeck — Property types & validation (v1.4.0)."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any, Optional
# ── Property type definitions ──
PROPERTY_TYPES: dict[str, dict] = {
"title": {
"storage": "string",
"validation": "max 2000 chars",
"default": "",
},
"text": {
"storage": "string",
"default": "",
},
"number": {
"storage": "float",
"validation": "numeric",
"format_options": ["number", "percent", "dollar", "euro", "pound", "yen"],
"default": None,
},
"select": {
"storage": "string (from options_json)",
"default": None,
},
"multi_select": {
"storage": "json array of strings",
"default": [],
},
"status": {
"storage": "string (from options_json) + color",
"default": None,
"colors": ["gray", "brown", "orange", "yellow", "green", "blue", "purple", "pink", "red"],
},
"date": {
"storage": "ISO 8601 string",
"default": None,
},
"person": {
"storage": "json array of {id, login, avatar_url}",
"default": [],
},
"checkbox": {
"storage": "boolean",
"default": False,
},
"url": {
"storage": "string",
"default": "",
},
"email": {
"storage": "string",
"default": "",
},
"phone": {
"storage": "string",
"default": "",
},
"files": {
"storage": "json array of {url, name}",
"default": [],
},
"unique_id": {
"storage": "integer (auto-increment per collection)",
"default": None,
},
"relation": {
"storage": "json array of page IDs",
"default": [],
},
"rollup": {
"storage": "computed — not stored",
"default": None,
},
"formula": {
"storage": "computed — not stored",
"default": None,
},
"created_time": {
"storage": "auto — ISO 8601",
"default": None,
},
"created_by": {
"storage": "auto — {id, login}",
"default": None,
},
"last_edited_time": {
"storage": "auto — ISO 8601",
"default": None,
},
"last_edited_by": {
"storage": "auto — {id, login}",
"default": None,
},
}
# CSV-friendly subset (no relation/rollup/formula)
SIMPLE_TYPES = ["title", "text", "number", "select", "multi_select", "status",
"date", "person", "checkbox", "url", "email", "phone", "files"]
# Auto-property types (computed server-side)
AUTO_TYPES = ["created_time", "created_by", "last_edited_time", "last_edited_by"]
def validate_property_value(prop_type: str, value: Any, options: Optional[list] = None) -> tuple[bool, str]:
"""Validate a property value against its type. Returns (ok, error_message)."""
if value is None:
return True, ""
if prop_type == "number":
try:
float(value)
except (ValueError, TypeError):
return False, f"'{value}' is not a valid number"
elif prop_type == "checkbox":
if not isinstance(value, bool):
return False, "Checkbox must be true or false"
elif prop_type in ("select", "status"):
if options and value not in [o.get("name", o) if isinstance(o, dict) else o for o in options]:
return False, f"'{value}' is not a valid option"
elif prop_type == "multi_select":
if not isinstance(value, list):
return False, "Multi-select must be a list"
if options:
valid_names = {o.get("name", o) if isinstance(o, dict) else o for o in options}
for v in value:
if v not in valid_names:
return False, f"'{v}' is not a valid option"
elif prop_type == "date":
if isinstance(value, str) and value:
try:
datetime.fromisoformat(value.replace("Z", "+00:00"))
except (ValueError, TypeError):
return False, f"'{value}' is not a valid ISO 8601 date"
elif prop_type == "url":
if not isinstance(value, str):
return False, "URL must be a string"
elif prop_type == "email":
if not isinstance(value, str) or ("@" not in value if value else False):
return False, "Invalid email format"
elif prop_type == "phone":
if not isinstance(value, str):
return False, "Phone must be a string"
return True, ""
def get_auto_property_value(prop_type: str, user: Optional[dict] = None) -> Any:
"""Compute the value of an auto-property."""
if prop_type == "created_time" or prop_type == "last_edited_time":
return datetime.now(timezone.utc).isoformat()
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 None
def get_next_unique_id(collection_id: int, conn) -> int:
"""Get the next unique_id for a collection (max + 1)."""
row = conn.execute(
"""SELECT COALESCE(MAX(CAST(json_extract(property_values_json, '$.unique_id') AS INTEGER)), 0) + 1
FROM collection_pages WHERE collection_id=?""",
(collection_id,),
).fetchone()
return row[0] if row else 1
def format_number(value: float, fmt: str = "number") -> str:
"""Format a number value for display."""
if value is None:
return ""
if fmt == "percent":
return f"{value}%"
elif fmt == "dollar":
return f"${value:,.2f}"
elif fmt == "euro":
return f"€{value:,.2f}"
elif fmt == "pound":
return f"£{value:,.2f}"
elif fmt == "yen":
return f"¥{value:,.0f}"
return str(value)
+150
View File
@@ -0,0 +1,150 @@
"""FlowDeck — Rollup Engine (v1.5.0)."""
from __future__ import annotations
import json
import statistics
from typing import Any, Optional
from app.db import get_conn
ROLLUP_FUNCTIONS = {
"count": lambda values: len(values),
"count_values": lambda values: len([v for v in values if v is not None and v != ""]),
"empty": lambda values: len([v for v in values if v is None or v == ""]),
"not_empty": lambda values: len([v for v in values if v is not None and v != ""]),
"sum": lambda values: sum(float(v) for v in values if v is not None),
"average": lambda values: _safe_avg(values),
"median": lambda values: _safe_stat(values, statistics.median),
"min": lambda values: min(_numeric(v for v in values if v is not None), default=None),
"max": lambda values: max(_numeric(v for v in values if v is not None), default=None),
"range": lambda values: _safe_range(values),
"unique": lambda values: list(set(str(v) for v in values if v is not None)),
"percent_checked": lambda values: _percent_checked(values),
"percent_per_group": lambda values: _percent_per_group(values),
}
class RollupEngine:
"""Moteur d'agrégation pour les propriétés rollup."""
def compute(
self,
collection_id: int,
relation_property_id: int,
target_property_id: int,
page_id: int,
rollup_function: str,
) -> Any:
"""Calcule l'agrégation d'une propriété via une relation.
Args:
collection_id: la collection de la page source
relation_property_id: la propriété relation (dans cette collection)
target_property_id: la propriété à agréger (dans la collection liée)
page_id: la page qui porte la relation
rollup_function: nom de la fonction (count, sum, avg, etc.)
"""
if rollup_function not in ROLLUP_FUNCTIONS:
return None
with get_conn() as conn:
# Get the relation property to find the related collection
rel_prop = conn.execute(
"SELECT * FROM collection_properties WHERE id=?",
(relation_property_id,),
).fetchone()
if not rel_prop:
return None
related_collection_id = rel_prop["related_collection_id"]
if not related_collection_id:
return None
# Get the target property name
target_prop = conn.execute(
"SELECT * FROM collection_properties WHERE id=?",
(target_property_id,),
).fetchone()
if not target_prop:
return None
target_name = target_prop["name"]
# Get the related page IDs from the source page's property_values_json
source_page = conn.execute(
"SELECT property_values_json FROM collection_pages WHERE id=?",
(page_id,),
).fetchone()
if not source_page:
return None
props = json.loads(source_page["property_values_json"])
related_ids = props.get(str(relation_property_id), [])
if not isinstance(related_ids, list):
related_ids = [related_ids] if related_ids else []
if not related_ids:
return ROLLUP_FUNCTIONS[rollup_function]([])
# Get the target values from related pages
placeholders = ",".join("?" for _ in related_ids)
rows = conn.execute(
f"SELECT property_values_json FROM collection_pages WHERE id IN ({placeholders})",
related_ids,
).fetchall()
values = []
for row in rows:
page_props = json.loads(row["property_values_json"])
val = page_props.get(str(target_property_id))
if val is None:
# Try by name
val = page_props.get(target_name)
values.append(val)
return ROLLUP_FUNCTIONS[rollup_function](values)
def _safe_avg(values: list) -> Optional[float]:
nums = [float(v) for v in values if v is not None]
return sum(nums) / len(nums) if nums else None
def _safe_stat(values: list, fn) -> Optional[float]:
nums = [float(v) for v in values if v is not None]
return fn(nums) if nums else None
def _numeric(gen):
for v in gen:
try:
yield float(v)
except (ValueError, TypeError):
pass
def _safe_range(values: list) -> Optional[float]:
nums = list(_numeric(v for v in values if v is not None))
return max(nums) - min(nums) if len(nums) >= 2 else None
def _percent_checked(values: list) -> Optional[float]:
"""Percentage of true values (for checkbox properties)."""
if not values:
return 0.0
booleans = [v for v in values if isinstance(v, bool)]
if not booleans:
return 0.0
return (sum(1 for v in booleans if v) / len(booleans)) * 100
def _percent_per_group(values: list) -> dict:
"""Percentage distribution across groups."""
if not values:
return {}
counts = {}
for v in values:
key = str(v) if v is not None else "Empty"
counts[key] = counts.get(key, 0) + 1
total = sum(counts.values())
return {k: round(v / total * 100, 1) for k, v in counts.items()}
+51
View File
@@ -0,0 +1,51 @@
"""FlowDeck — Webhook outbound dispatcher (v2.1.0)."""
from __future__ import annotations
import json
import logging
import httpx
from app.db import get_conn
logger = logging.getLogger(__name__)
EVENTS = [
"page.created", "page.updated", "page.deleted",
"collection.created", "collection.updated", "collection.deleted",
"comment.added", "page.moved",
]
async def fire_event(event: str, payload: dict):
"""Fire a webhook event to all registered subscribers."""
if event not in EVENTS:
return
with get_conn() as conn:
subs = conn.execute("SELECT url, secret FROM webhook_subscriptions WHERE event=?", (event,)).fetchall()
async with httpx.AsyncClient(timeout=10) as client:
for sub in subs:
url, secret = sub["url"], sub["secret"]
headers = {"Content-Type": "application/json", "X-FlowDeck-Event": event}
if secret:
headers["X-FlowDeck-Secret"] = secret
try:
await client.post(url, json=payload, headers=headers)
except Exception:
logger.debug("Webhook delivery failed to %s", url)
def init_webhook_tables():
"""Create the webhook_subscriptions table if it doesn't exist."""
with get_conn() as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS webhook_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
event TEXT NOT NULL,
secret TEXT DEFAULT '',
active BOOLEAN NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.commit()
+131
View File
@@ -0,0 +1,131 @@
{% extends "base.html" %}
{% block page_icon %}👤{% endblock %}
{% block page_title %}Accounts{% endblock %}
{% block content %}
<div class="page-title-area">
<div class="page-title">
<span class="page-icon-lg">👤</span>
<h1>Account Management</h1>
</div>
</div>
<div style="padding: 0 24px; max-width: 900px;" x-data="accountsData()">
<!-- Current user profile -->
<div style="background:var(--bg-secondary); border-radius:8px; padding:20px; margin-bottom:24px;">
<h3 style="margin-bottom:16px;">Your Profile</h3>
<div style="display:flex; gap:16px; align-items:flex-start;">
<div class="workspace-avatar" style="width:48px; height:48px; font-size:20px;">
{{ user.login[0] if user else 'B' }}
</div>
<div style="flex:1;">
<div style="display:flex; gap:12px; margin-bottom:12px;">
<div style="flex:1;">
<label style="font-size:12px; color:var(--text-dim);">Username</label>
<input type="text" value="{{ user.login }}" disabled
style="width:100%; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px;
padding:8px; color:var(--text-dim); font-size:14px;">
</div>
<div style="flex:1;">
<label style="font-size:12px; color:var(--text-dim);">Display name</label>
<input type="text" x-model="profile.full_name"
style="width:100%; background:var(--bg-secondary); border:1px solid var(--border); border-radius:6px;
padding:8px; color:var(--text-primary); font-size:14px; outline:none;">
</div>
</div>
<div style="margin-bottom:12px;">
<label style="font-size:12px; color:var(--text-dim);">Email</label>
<input type="text" x-model="profile.email"
style="width:100%; background:var(--bg-secondary); border:1px solid var(--border); border-radius:6px;
padding:8px; color:var(--text-primary); font-size:14px; outline:none;">
</div>
<button class="btn-new" @click="saveProfile()">Save Profile</button>
<span x-show="saved" style="color:var(--green); font-size:13px; margin-left:8px;">✓ Saved</span>
</div>
</div>
</div>
<!-- Connected accounts -->
<div style="background:var(--bg-secondary); border-radius:8px; padding:20px; margin-bottom:24px;">
<h3 style="margin-bottom:12px;">Connected Accounts</h3>
<div style="display:flex; align-items:center; gap:12px; padding:12px; border:1px solid var(--border); border-radius:6px;">
<span style="font-size:20px;">🔗</span>
<div style="flex:1;">
<div style="font-weight:600;">Gitea</div>
<div style="font-size:12px; color:var(--text-dim);">
{% if user %}Connected as {{ user.login }}{% else %}Not connected{% endif %}
</div>
</div>
{% if user %}
<a href="/auth/logout" class="toolbar-btn" style="color:var(--red);">Disconnect</a>
{% else %}
<a href="/auth/login" class="btn-new">Connect Gitea</a>
{% endif %}
</div>
</div>
<!-- Users in workspace -->
<div style="background:var(--bg-secondary); border-radius:8px; padding:20px;">
<h3 style="margin-bottom:12px;">Workspace Members</h3>
<div style="font-size:13px; color:var(--text-dim); margin-bottom:12px;">
Members are managed through Gitea. New users connect via OAuth and appear here automatically.
</div>
<table class="data-table" style="width:100%;">
<thead>
<tr>
<th>User</th>
<th>Email</th>
<th>Joined</th>
<th>Role</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>
<div style="display:flex; align-items:center; gap:8px;">
<div class="card-avatar" style="width:24px; height:24px; font-size:11px;">{{ u.login[0] }}</div>
<span>{{ u.login }}</span>
</div>
</td>
<td>{{ u.email or '—' }}</td>
<td>{{ u.created_at[:10] if u.created_at else '—' }}</td>
<td>{% if u.is_admin %}Admin{% else %}Member{% endif %}</td>
</tr>
{% endfor %}
{% if not users %}
<tr><td colspan="4" style="text-align:center; color:var(--text-dim); padding:24px;">No members yet</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function accountsData() {
return {
profile: { full_name: '', email: '' },
saved: false,
async init() {
try {
const r = await fetch('/api/users/me');
const data = await r.json();
this.profile = { full_name: data.full_name || '', email: data.email || '' };
} catch(e) {}
},
saveProfile() {
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
const token = csrf ? csrf[1] : '';
fetch(`/api/users/me?full_name=${encodeURIComponent(this.profile.full_name)}&email=${encodeURIComponent(this.profile.email)}`, {
method: 'PUT', headers: { 'X-CSRF-Token': token }
}).then(() => {
this.saved = true;
setTimeout(() => this.saved = false, 3000);
});
}
};
}
</script>
{% endblock %}
+361 -101
View File
@@ -12,140 +12,220 @@
<body hx-headers='{"X-CSRF-Token":"__CSRF_PLACEHOLDER__"}'>
<div class="app-layout" x-data="appState()">
<!-- ═══════════ SIDEBAR ═══════════ -->
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed }" id="sidebar">
{# ─── Recursive tree macro ─── #}
{% macro render_tree_item(page, depth=0) %}
{% set indent_px = depth * 16 %}
<li class="sidebar-item {% if page.active %}active{% endif %}"
style="padding-left: {{ 8 + indent_px }}px;"
@click="navigateTo('{{ page.url }}')"
@contextmenu.prevent="showContextMenu($event, '{{ page.id }}', '{{ page.name }}')"
data-page-id="{{ page.id }}"
data-page-depth="{{ depth }}"
data-has-children="{{ 'true' if page.has_children else 'false' }}">
{# Toggle chevron — only visible for items with children #}
{% if page.has_children %}
<button class="tree-toggle"
@click.stop="toggleTreeNode('{{ page.id }}')"
:class="{ expanded: expandedNodes['{{ page.id }}'] }">▶</button>
{% else %}
<span class="tree-toggle-placeholder"></span>
{% endif %}
<span class="page-icon">{{ page.icon }}</span>
<span class="page-name">{{ page.name }}</span>
<span class="item-actions">
<button class="item-action-btn" @click.stop="openLibrary('{{ page.id }}')">📚</button>
<button class="item-action-btn" @click.stop="newSubPage('{{ page.id }}')">+</button>
</span>
</li>
{% if page.has_children %}
<li class="tree-children-wrapper" x-show="expandedNodes['{{ page.id }}']" x-transition>
<ul class="sidebar-items" data-parent="{{ page.id }}">
{% for child in page.children %}
{{ render_tree_item(child, depth + 1) }}
{% endfor %}
</ul>
</li>
{% endif %}
{% endmacro %}
<!-- ═══════════ SIDEBAR Notion ═══════════ -->
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed }" id="sidebar"
x-data x-init="$nextTick(() => { if(typeof initTreeSortable==='function') initTreeSortable(); })">
<!-- Workspace header -->
<div class="sidebar-workspace">
<div class="sidebar-workspace" @click="toggleWorkspaceMenu">
<div class="workspace-info">
<div class="workspace-avatar">{{ workspaceInitial }}</div>
<span class="workspace-name">{{ workspaceName }}</span>
</div>
<div class="sidebar-actions">
<button class="sidebar-action-btn" @click="sidebarCollapsed = !sidebarCollapsed" title="Toggle sidebar">
«
</button>
</div>
<span class="workspace-chevron">▾</span>
</div>
<!-- Quick actions -->
<div class="sidebar-nav">
<a href="/" class="sidebar-nav-item" :class="{ active: currentView === 'home' }">
<span class="nav-icon">🏠</span> Home
<!-- Navigation icons row -->
<div class="sidebar-nav-row">
<a href="/" class="nav-icon-btn home-btn" :class="{ active: currentView === 'home' }" title="Home">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/></svg>
Home
</a>
<button class="nav-icon-btn" title="Chat">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
</button>
<button class="nav-icon-btn" title="Inbox">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z"/></svg>
</button>
<button class="nav-icon-btn" @click="openQuickFind" title="Search">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</button>
<button class="nav-icon-btn collapse-btn" @click="sidebarCollapsed = !sidebarCollapsed" title="Collapse sidebar">
<svg width="16" height="16" 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>
</div>
<div class="sidebar-nav" style="gap: 2px; padding-right: 6px;">
<button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">💬</button>
<button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">🎤</button>
<button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">📥</button>
<button class="sidebar-action-btn" style="width:auto; padding: 0 6px;">🔍</button>
<!-- Meetings -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('meetings')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.meetings }" x-text="sectionsOpen.meetings ? '▼' : '▶'"></span>
<span>Meetings</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="Add" @click.stop="newPage('Meetings')">+</button>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.meetings" x-transition>
<ul class="sidebar-items"><li class="sidebar-item empty-hint"><span class="page-icon">📅</span><span class="page-name text-dim">No meetings</span></li></ul>
</div>
</div>
<!-- Recents -->
<div class="sidebar-section">
<div class="sidebar-section-header">
<div class="sidebar-section-header" @click="toggleSection('recents')">
<div class="sidebar-section-title">
<span class="chevron open">▶</span> Recents
<span class="chevron" :class="{ open: sectionsOpen.recents }" x-text="sectionsOpen.recents ? '▼' : '▶'"></span>
<span>Recents</span>
</div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.recents" x-transition>
<ul class="sidebar-items" data-section="recents">
{% for page in recent_pages %}
{{ render_tree_item(page) }}
{% endfor %}
{% if not recent_pages %}
<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No recent pages</span></li>
{% endif %}
</ul>
</div>
</div>
<!-- Favorites -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('favorites')">
<div class="sidebar-section-title">
<span class="chevron" :class="{ open: sectionsOpen.favorites }" x-text="sectionsOpen.favorites ? '▼' : '▶'"></span>
<span>Favorites</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="Open in Library">📚</button>
<button class="section-action-btn" title="More">⋯</button>
<button class="section-action-btn" title="Add" @click.stop="newPage('Favorites')">+</button>
</div>
</div>
<ul class="sidebar-items">
{% for page in recent_pages %}
<li class="sidebar-item {% if page.active %}active{% endif %} {% if page.indent %}indent-{{ page.indent }}{% endif %}"
@click="navigateTo('{{ page.url }}')"
@contextmenu.prevent="showContextMenu($event, '{{ page.id }}', '{{ page.name }}')">
<span class="page-icon">{{ page.icon }}</span>
<span class="page-name">{{ page.name }}</span>
<span class="item-actions">
<button class="item-action-btn" @click.stop="openInLibrary('{{ page.id }}')">📚</button>
<button class="item-action-btn" @click.stop="showMore('{{ page.id }}')">⋯</button>
</span>
</li>
{% endfor %}
{% if not recent_pages %}
<li class="sidebar-item">
<span class="page-icon">📄</span>
<span class="page-name text-dim">No recent pages</span>
</li>
{% endif %}
</ul>
<div class="sidebar-section-items" x-show="sectionsOpen.favorites" x-transition>
<ul class="sidebar-items" data-section="favorites">
{% for page in favorite_pages %}
{{ render_tree_item(page) }}
{% endfor %}
{% if not favorite_pages %}
<li class="sidebar-item empty-hint"><span class="page-icon">⭐</span><span class="page-name text-dim">No favorites yet</span></li>
{% endif %}
</ul>
</div>
</div>
<!-- Favorites: Private -->
<!-- Agents -->
<div class="sidebar-section">
<div class="sidebar-section-header">
<div class="sidebar-section-header" @click="toggleSection('agents')">
<div class="sidebar-section-title"><span>Agents</span></div>
</div>
<div class="sidebar-section-items" x-show="sectionsOpen.agents" x-transition>
<ul class="sidebar-items"><li class="sidebar-item empty-hint"><span class="page-icon">🤖</span><span class="page-name text-dim">No agents configured</span></li></ul>
</div>
</div>
<!-- Shared -->
<div class="sidebar-section">
<div class="sidebar-section-header" @click="toggleSection('shared')">
<div class="sidebar-section-title">
<span class="chevron open">▶</span> Private
<span class="chevron" :class="{ open: sectionsOpen.shared }" x-text="sectionsOpen.shared ? '▼' : '▶'"></span>
<span>Shared</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" @click="addPage('private')" title="Add page">+</button>
<button class="section-action-btn" title="Add" @click.stop="newPage('Shared')">+</button>
</div>
</div>
<ul class="sidebar-items">
{% for page in private_pages %}
<li class="sidebar-item {% if page.active %}active{% endif %} {% if page.indent %}indent-{{ page.indent }}{% endif %}"
@click="navigateTo('{{ page.url }}')"
@contextmenu.prevent="showContextMenu($event, '{{ page.id }}', '{{ page.name }}')">
<span class="page-icon">{{ page.icon }}</span>
<span class="page-name">{{ page.name }}</span>
<span class="item-actions">
<button class="item-action-btn" @click.stop="showMore('{{ page.id }}')">⋯</button>
<button class="item-action-btn" @click.stop="addSubPage('{{ page.id }}')">+</button>
</span>
</li>
{% endfor %}
</ul>
<div class="sidebar-section-items" x-show="sectionsOpen.shared" x-transition>
<ul class="sidebar-items"><li class="sidebar-item empty-hint"><span class="page-icon">👥</span><span class="page-name text-dim">No shared pages</span></li></ul>
</div>
</div>
<!-- Notion apps -->
<!-- Private -->
<div class="sidebar-section">
<div class="sidebar-section-header">
<div class="sidebar-section-header" @click="toggleSection('private')">
<div class="sidebar-section-title">
<span class="chevron open">▶</span> Notion apps
<span class="chevron" :class="{ open: sectionsOpen.private }" x-text="sectionsOpen.private ? '▼' : '▶'"></span>
<span>Private</span>
</div>
<div class="sidebar-section-actions">
<button class="section-action-btn" title="View" @click.stop="">⊞</button>
<button class="section-action-btn" title="More" @click.stop="">⋯</button>
<button class="section-action-btn" title="Add page" @click.stop="newPage('Private')">+</button>
</div>
</div>
<ul class="sidebar-items">
<li class="sidebar-item"><span class="page-icon">📅</span><span class="page-name">Notion Calendar</span></li>
<li class="sidebar-item"><span class="page-icon">🖥️</span><span class="page-name">Notion Desktop</span></li>
</ul>
<div class="sidebar-section-items" x-show="sectionsOpen.private" x-transition>
<ul class="sidebar-items" data-section="private">
{% for page in private_pages %}
{{ render_tree_item(page) }}
{% endfor %}
{% if not private_pages %}
<li class="sidebar-item empty-hint"><span class="page-icon">📄</span><span class="page-name text-dim">No pages yet</span></li>
{% endif %}
</ul>
</div>
</div>
<!-- Library -->
<!-- Notion apps / Utilities -->
<div class="sidebar-section">
<div class="sidebar-section-header">
<div class="sidebar-section-header" @click="toggleSection('apps')">
<div class="sidebar-section-title">
<span class="chevron open">▶</span> Library
<span class="chevron" :class="{ open: sectionsOpen.apps }" x-text="sectionsOpen.apps ? '▼' : '▶'"></span>
<span>Notion apps</span>
</div>
</div>
<ul class="sidebar-items">
<li class="sidebar-item"><span class="page-icon">✅</span><span class="page-name">My Tasks</span></li>
<li class="sidebar-item"><span class="page-icon">🛒</span><span class="page-name">Marketplace</span></li>
<li class="sidebar-item"><span class="page-icon">❓</span><span class="page-name">Help</span></li>
<li class="sidebar-item"><span class="page-icon">🗑️</span><span class="page-name">Trash</span></li>
</ul>
<div class="sidebar-section-items" x-show="sectionsOpen.apps" x-transition>
<ul class="sidebar-items">
<li class="sidebar-item" @click="navigateTo('/library')"><span class="page-icon">📚</span><span class="page-name">Library</span></li>
<li class="sidebar-item"><span class="page-icon">✅</span><span class="page-name">My Tasks</span></li>
<li class="sidebar-item" @click="navigateTo('/trash')"><span class="page-icon">🗑️</span><span class="page-name">Trash</span></li>
<li class="sidebar-item"><span class="page-icon">❓</span><span class="page-name">Help</span></li>
</ul>
</div>
</div>
<!-- Footer -->
<div class="sidebar-footer">
<div class="sidebar-invite" x-show="showInvite" x-transition>
<div class="sidebar-invite-title">👥 Invite members</div>
<div class="sidebar-invite-desc">Collaborate with your team.</div>
<button class="sidebar-invite-close" @click="showInvite = false">✕</button>
<div style="display:flex; gap:6px;">
<button class="sidebar-footer-btn" style="flex:1; justify-content:center; background:var(--bg-hover); border-radius:20px;">
<span style="font-size:14px;">💬</span> New chat
<span class="shortcut">Ctrl+O</span>
</button>
<button class="sidebar-footer-btn" style="width:36px; justify-content:center; background:var(--bg-hover); border-radius:8px;" @click="newPage('Private')" title="New page">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="12" y1="18" x2="12" y2="12"/><line x1="9" y1="15" x2="15" y2="15"/></svg>
</button>
</div>
<button class="sidebar-new-chat">
<span>💬 New chat</span>
<span class="shortcut">Ctrl+O</span>
</button>
<div style="display: flex; gap: 4px; margin-top: 4px;">
{% if user %}
<a href="/auth/logout" class="sidebar-new-chat" style="flex:1; justify-content:center;">🚪</a>
<a href="/auth/logout" class="sidebar-footer-btn" style="flex:1; justify-content:center;">🚪</a>
{% else %}
<a href="/auth/login" class="sidebar-new-chat" style="flex:1; justify-content:center;">🔑</a>
<a href="/auth/login" class="sidebar-footer-btn" style="flex:1; justify-content:center;">🔑</a>
{% endif %}
<button class="sidebar-new-chat" style="width:36px; justify-content:center;" @click="toggleTheme()">🌓</button>
<button class="sidebar-footer-btn" style="width:36px; justify-content:center;" @click="toggleTheme()">🌓</button>
</div>
</div>
</aside>
@@ -157,16 +237,16 @@
<header class="topbar">
<div class="topbar-left">
<div class="topbar-breadcrumb">
<span>{{ workspaceName }}</span>
<span x-text="workspaceName"></span>
<span class="text-dim">/</span>
<span class="text-dim">{{ currentPage }}</span>
<span class="text-dim" x-text="currentPage"></span>
</div>
</div>
<div class="topbar-center">
<div class="topbar-page-title">
<span class="page-icon">{{ pageIcon }}</span>
<span>{{ pageTitle }}</span>
<span class="page-icon" x-text="pageIcon"></span>
<span x-text="pageTitle"></span>
</div>
<div class="topbar-page-status">
<span>🔒</span> Private
@@ -175,13 +255,20 @@
</div>
<div class="topbar-right">
<span class="text-xs text-dim">Edited {{ lastEdited }}</span>
<span class="text-xs text-dim">Edited <span x-text="lastEdited"></span></span>
<button class="topbar-btn">🔔</button>
<button class="topbar-btn">🕒</button>
<button class="topbar-btn">🔍</button>
<button class="topbar-btn share-btn">🔗 Share ▾</button>
<button class="topbar-btn">⭐</button>
<button class="topbar-btn">⋯</button>
<button class="topbar-btn" @click="showNewPageMenu = !showNewPageMenu" style="position:relative;">
New ▾
<div class="dropdown-panel" :class="{ visible: showNewPageMenu }" style="top:100%; right:0; left:auto; min-width:200px;" @click.stop>
<div class="dropdown-option" @click="createEmptyPage()"><span class="option-icon">📄</span> Empty page</div>
<div class="dropdown-option" @click="createDatabase()"><span class="option-icon">📊</span> Database</div>
<div class="dropdown-option" @click="createBoard()"><span class="option-icon">📋</span> Kanban board</div>
</div>
</button>
</div>
</header>
@@ -240,10 +327,10 @@
</div>
<div class="context-menu-section">
<div class="context-menu-item text-dim" style="font-size:11px; cursor:default;">
Last edited by {{ workspaceName }}
Last edited by <span x-text="workspaceName"></span>
</div>
<div class="context-menu-item text-dim" style="font-size:11px; cursor:default;">
{{ lastEdited }}
<span x-text="lastEdited"></span>
</div>
<div class="context-menu-item" @click="contextAction('learnDb')">
<span class="menu-icon">❓</span> Learn about databases
@@ -290,9 +377,82 @@
pageIcon: '{% block page_icon %}📁{% endblock %}',
pageTitle: '{% block page_title %}FlowDeck{% endblock %}',
lastEdited: '{{ last_edited }}',
workspaceKey: '{{ workspace_key }}',
currentView: 'home',
contextMenu: { visible: false, x: 0, y: 0, pageId: null, pageName: '' },
contextActions: {},
showNewPageMenu: false,
// ── Sections collapsible ──
sectionsOpen: { recents: true, private: true, meetings: true, favorites: true, agents: true, shared: true, apps: true },
toggleSection(key) { this.sectionsOpen[key] = !this.sectionsOpen[key]; this.sectionsOpen = Object.assign({}, this.sectionsOpen); },
toggleWorkspaceMenu() { this.sidebarCollapsed = !this.sidebarCollapsed; },
openQuickFind() { alert('Quick Find — Ctrl+K'); },
// ── Tree state ──
expandedNodes: {},
isExpanded(id) {
return this.expandedNodes[id] || false;
},
toggleTreeNode(id) {
const current = this.expandedNodes[id] || false;
this.expandedNodes[id] = !current;
// Trigger Alpine.js reactivity by reassigning
this.expandedNodes = Object.assign({}, this.expandedNodes);
// If expanding, init SortableJS on the newly visible children
if (!current) {
this.$nextTick(() => this.initTreeSortable());
}
},
expandAll() {
document.querySelectorAll('[data-has-children="true"]').forEach(el => {
this.expandedNodes[el.dataset.pageId] = true;
});
this.expandedNodes = Object.assign({}, this.expandedNodes);
this.$nextTick(() => this.initTreeSortable());
},
// ── Drag & drop tree ──
treeSortables: [],
initTreeSortable() {
if (typeof Sortable === 'undefined') return;
// Initialize SortableJS on every .sidebar-items list
const self = this;
document.querySelectorAll('.sidebar-items').forEach(function(el) {
if (el._sortableTree) return; // Already initialized
el._sortableTree = new Sortable(el, {
group: 'pages-tree',
handle: '.page-icon',
animation: 150,
ghostClass: 'sortable-ghost',
dragClass: 'sortable-drag',
fallbackOnBody: true,
swapThreshold: 0.5,
onEnd: function(evt) {
// Get the dragged item's page ID
const item = evt.item;
const pageId = item.dataset.pageId;
if (!pageId || !pageId.startsWith('page/')) return;
const dbId = pageId.replace('page/', '');
// Determine new parent from the target list's data-parent
const targetList = evt.to;
let newParentId = 0;
const parentAttr = targetList.dataset.parent;
if (parentAttr && parentAttr.startsWith('page/')) {
newParentId = parentAttr.replace('page/', '');
}
const newIndex = evt.newIndex;
// Call API to persist the move
const csrf = self.getCsrfToken();
fetch(`/board/api/pages/${dbId}/move?new_parent_id=${newParentId}&new_order=${newIndex}`, {
method: 'PUT',
headers: { 'X-CSRF-Token': csrf }
}).catch(e => console.error('Move failed:', e));
}
});
});
},
navigateTo(url) {
window.location.href = url;
@@ -310,18 +470,118 @@
contextAction(action) {
const { pageId, pageName } = this.contextMenu;
console.log('Context action:', action, pageId, pageName);
// TODO: implement each action
if (action === 'duplicate') {
if (this.contextActions.duplicate) this.contextActions.duplicate(pageId);
const self = this;
const csrf = this.getCsrfToken();
switch(action) {
case 'favorite':
alert(`Toggled favorite for: ${pageName}`);
break;
case 'copyLink':
const url = window.location.origin + '/' + pageId;
navigator.clipboard.writeText(url).then(() => alert('Link copied!'));
break;
case 'duplicate':
fetch(`/board/api/pages?title=${encodeURIComponent(pageName + ' copy')}&section=Private&project=${encodeURIComponent(this.workspaceKey)}`, {
method: 'POST', headers: { 'X-CSRF-Token': csrf }
})
.then(r => r.json())
.then(data => { window.location.href = `/pages/${data.id}`; })
.catch(e => alert('Duplicate failed'));
break;
case 'rename':
const newName = prompt('New name:', pageName);
if (!newName) break;
const pageNum = pageId.replace('page/', '');
fetch(`/board/api/pages/${pageNum}?title=${encodeURIComponent(newName)}`, {
method: 'PUT', headers: { 'X-CSRF-Token': csrf }
}).then(() => location.reload());
break;
case 'moveTo':
const section = prompt('Move to section (Private, Recents, Favorites):', 'Private');
if (!section) break;
alert(`Moved to ${section}`);
break;
case 'delete':
if (!confirm(`Move "${pageName}" to trash?`)) break;
const delNum = pageId.replace('page/', '');
fetch(`/board/api/pages/${delNum}`, {
method: 'DELETE', headers: { 'X-CSRF-Token': csrf }
})
.then(r => { if (!r.ok) throw new Error('Delete failed'); return r.json(); })
.then(data => {
// Remove from ALL sidebar sections (Recents, Private, etc.)
const items = document.querySelectorAll(`.sidebar-item[data-page-id="${pageId}"]`);
items.forEach(item => {
const next = item.nextElementSibling;
if (next && next.classList.contains('tree-children-wrapper')) {
next.remove();
}
item.remove();
});
// Redirect if viewing deleted page — stay in project context
if (window.location.pathname.includes(`/pages/${delNum}`)) {
const wk = this.workspaceKey;
window.location.href = wk && wk.includes('/') ? `/board/${wk}` : '/';
}
})
.catch(e => alert('Delete failed: ' + e.message));
break;
case 'openTab':
window.open('/' + pageId, '_blank');
break;
case 'openPeek':
alert('Side peek not available in browser');
break;
}
this.contextMenu.visible = false;
},
openInLibrary(id) { console.log('Open in library:', id); },
openLibrary(id) {
window.location.href = '/library';
},
openInLibrary(id) { this.openLibrary(id); },
showMore(id) { console.log('Show more:', id); },
addPage(section) { console.log('Add page to:', section); },
addSubPage(id) { console.log('Add sub-page to:', id); },
addPage(section) { this.newPage(section); },
addSubPage(id) { this.newSubPage(id); },
getCsrfToken() {
const m = document.cookie.match(/csrf_token=([^;]+)/);
return m ? m[1] : '';
},
newPage(section) {
const project = this.workspaceKey || '';
fetch(`/board/api/pages?section=${encodeURIComponent(section)}&project=${encodeURIComponent(project)}`, {
method: 'POST',
headers: { 'X-CSRF-Token': this.getCsrfToken() }
})
.then(r => { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
.then(data => { window.location.href = `/pages/${data.id}`; })
.catch(err => { alert('Failed to create page: ' + err.message); });
},
newSubPage(parentId) {
const project = this.workspaceKey || '';
const pid = parentId.replace('page/', '');
fetch(`/board/api/pages?section=Private&project=${encodeURIComponent(project)}&parent_id=${pid}`, {
method: 'POST',
headers: { 'X-CSRF-Token': this.getCsrfToken() }
})
.then(r => { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
.then(data => { window.location.href = `/pages/${data.id}`; })
.catch(err => { alert('Failed to create sub-page: ' + err.message); });
},
createEmptyPage() {
this.showNewPageMenu = false;
this.newPage('Private');
},
createDatabase() {
this.showNewPageMenu = false;
alert('Database creation: use a Gitea repo as a database. Navigate to a project board from the dashboard.');
},
createBoard() {
this.showNewPageMenu = false;
window.location.href = '/board/bruno/flowdeck';
},
toggleTheme() {
const html = document.documentElement;
+9
View File
@@ -153,6 +153,15 @@
<script>
const owner = '{{ owner }}';
const repo = '{{ repo }}';
const initialView = '{{ initial_view }}';
// Auto-switch to view from URL param
document.addEventListener('DOMContentLoaded', function() {
if (initialView && initialView !== 'kanban') {
const tab = document.querySelector(`.view-tab[data-view="${initialView}"]`);
if (tab) tab.click();
}
});
function setActiveTab(el) {
document.querySelectorAll('.view-tab').forEach(t => t.classList.remove('active'));
+121
View File
@@ -0,0 +1,121 @@
{% extends "base.html" %}
{% block page_icon %}📚{% endblock %}
{% block page_title %}Library{% endblock %}
{% block content %}
<div class="page-title-area">
<div class="page-title">
<span class="page-icon-lg">📚</span>
<h1>Library</h1>
</div>
</div>
<!-- Library tabs -->
<div class="view-tabs" x-data="{ tab: 'recents' }">
<button class="view-tab" :class="{ active: tab === 'recents' }" @click="tab = 'recents'">
<span class="tab-icon">🕒</span> Recents
</button>
<button class="view-tab" :class="{ active: tab === 'favorites' }" @click="tab = 'favorites'">
<span class="tab-icon">⭐</span> Favorites
</button>
<button class="view-tab" :class="{ active: tab === 'shared' }" @click="tab = 'shared'">
<span class="tab-icon">👥</span> Shared
</button>
<button class="view-tab" :class="{ active: tab === 'private' }" @click="tab = 'private'">
<span class="tab-icon">🔒</span> Private
</button>
</div>
<!-- Toolbar -->
<div class="view-toolbar">
<div class="view-toolbar-spacer"></div>
<button class="toolbar-btn" title="Filter">☰</button>
<button class="toolbar-btn" title="Search">🔍</button>
<button class="toolbar-btn" title="Properties">⫶</button>
<button class="btn-new" @click="newPage()">New page</button>
</div>
<!-- Table -->
<div x-data="{ tab: 'recents' }" style="padding: 0 24px;">
<!-- Column headers -->
<div class="library-table-header">
<span class="lib-col lib-col-name">📄 Page name</span>
<span class="lib-col lib-col-author">👤 Created by</span>
<span class="lib-col lib-col-source">↗ Source</span>
<span class="lib-col lib-col-edited">🕒 Last edited time</span>
<span class="lib-col lib-col-visited">🕒 Last visited time</span>
</div>
{% macro page_row(page) %}
<a href="{{ page.url }}" class="library-table-row">
<span class="lib-col lib-col-name">
<span class="page-icon">{{ page.icon }}</span>
<span>{{ page.name }}</span>
</span>
<span class="lib-col lib-col-author">👤 {{ page.created_by or 'You' }}</span>
<span class="lib-col lib-col-source">{{ page.source or '🔒 Private' }}</span>
<span class="lib-col lib-col-edited">{{ page.last_edited }}</span>
<span class="lib-col lib-col-visited">{{ page.last_visited or page.last_edited }}</span>
</a>
{% endmacro %}
<!-- Recents -->
<div x-show="tab === 'recents'">
{% for page in recent_pages %}{{ page_row(page) }}{% endfor %}
{% if not recent_pages %}
<div class="lib-empty">
<div style="font-size:48px;margin-bottom:16px;">🕒</div>
<h3>No recent pages</h3>
</div>
{% endif %}
</div>
<!-- Favorites -->
<div x-show="tab === 'favorites'">
{% for page in favorite_pages %}{{ page_row(page) }}{% endfor %}
{% if not favorite_pages %}
<div class="lib-empty">
<div style="font-size:48px;margin-bottom:16px;">⭐</div>
<h3>No favorites</h3>
<p>Right-click a page in the sidebar and select "Add to Favorites".</p>
</div>
{% endif %}
</div>
<!-- Shared -->
<div x-show="tab === 'shared'">
<div class="lib-empty">
<div style="font-size:48px;margin-bottom:16px;">👥</div>
<h3>No shared pages</h3>
<p>Shared pages from your team will appear here.</p>
</div>
</div>
<!-- Private -->
<div x-show="tab === 'private'">
{% for page in private_pages %}{{ page_row(page) }}{% endfor %}
{% if not private_pages %}
<div class="lib-empty">
<div style="font-size:48px;margin-bottom:16px;">🔒</div>
<h3>No private pages</h3>
<p>Create a new page with the + button in the sidebar.</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function newPage() {
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
fetch('/board/api/pages?section=Private', {
method: 'POST',
headers: { 'X-CSRF-Token': csrf ? csrf[1] : '' }
})
.then(r => { if (!r.ok) throw new Error('CSRF error'); return r.json(); })
.then(data => { window.location.href = `/pages/${data.id}`; })
.catch(err => { alert('Failed: ' + err.message); });
}
</script>
{% endblock %}
+250
View File
@@ -0,0 +1,250 @@
{% extends "base.html" %}
{% block page_icon %}📄{% endblock %}
{% block page_title %}{{ page.title }}{% endblock %}
{% block content %}
<div class="page-editor-wrapper" x-data="editor('{{ page.id }}', '{{ page.title|e }}', '{{ page.content_format }}', '{{ page.content|e }}')" x-init="init()">
<div class="page-cover-area">
<div class="page-title-block">
<span class="page-icon-emoji">📄</span>
<div class="page-title-input" contenteditable="true" id="_titleEl"
@input="dirty=true;autoSave()"
@keydown.enter.prevent="focusBlock()"
@paste.prevent="pastePlain($event)"
spellcheck="false">{{ page.title }}</div>
</div>
</div>
<div class="blocks-container" id="_blocksCt" @click="onCtClick($event)"></div>
<div id="_slashMenu" class="slash-menu" style="display:none;position:fixed;z-index:999"></div>
<div class="format-toolbar" x-show="fmt.open" :style="{top:fmt.top+'px',left:fmt.left+'px'}" @mousedown.prevent>
<button @click="fmtApply('bold')"><strong>B</strong></button>
<button @click="fmtApply('italic')"><em>I</em></button>
<button @click="fmtApply('underline')"><u>U</u></button>
<button @click="fmtApply('strikeThrough')"><s>S</s></button>
<button @click="fmtLink()">🔗</button>
</div>
<div class="editor-statusbar">
<span x-show="saving">Saving...</span>
<span x-show="!saving&&dirty">Unsaved</span>
<span x-show="!saving&&!dirty&&lastSaved">Saved <span x-text="lastSaved"></span></span>
<span class="statusbar-right" x-text="blocks.length+' blocks'"></span>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const CMDS=[{name:'BASIC',items:[
{id:'paragraph',name:'Text',icon:'¶'},{id:'heading_1',name:'Heading 1',icon:'H1'},{id:'heading_2',name:'Heading 2',icon:'H2'},
{id:'heading_3',name:'Heading 3',icon:'H3'},{id:'heading_4',name:'Heading 4',icon:'H4'},{id:'bulleted_list',name:'Bulleted list',icon:'•'},{id:'numbered_list',name:'Numbered list',icon:'1.'},
{id:'to_do',name:'To-do',icon:'☐'},{id:'toggle',name:'Toggle',icon:'▶'},{id:'quote',name:'Quote',icon:'❝'},
{id:'callout',name:'Callout',icon:'💡'},{id:'divider',name:'Divider',icon:'—'},{id:'code',name:'Code block',icon:'<>'},
]},{name:'MEDIA',items:[{id:'image',name:'Image',icon:'🖼'}]}];
let _bid=0;function genId(){return 'b'+(++_bid)+'_'+Date.now().toString(36);}
function cp(e){const s=window.getSelection();if(!s.rangeCount)return 0;const r=s.getRangeAt(0).cloneRange();r.selectNodeContents(e);r.setEnd(s.getRangeAt(0).endContainer,s.getRangeAt(0).endOffset);return r.toString().length;}
function ce(e){const r=document.createRange();r.selectNodeContents(e);r.collapse(false);window.getSelection().removeAllRanges();window.getSelection().addRange(r);}
function cs(e){const r=document.createRange();r.selectNodeContents(e);r.collapse(true);window.getSelection().removeAllRanges();window.getSelection().addRange(r);}
function esc(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
// Slash menu — event delegation on list, single innerHTML set
const SM={_o:false,_i:-1,_s:0,_q:'',_it:[],
init(){const e=document.getElementById('_slashMenu');if(!e)return;e.innerHTML='';
const inp=document.createElement('input');inp.type='text';inp.placeholder='Filter...';
inp.style.cssText='width:100%;padding:6px 10px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:6px;color:var(--text-primary);font-size:14px;outline:none;box-sizing:border-box';
inp.oninput=()=>SM.flt();inp.onkeydown=ev=>SM._kd(ev);
const hdr=document.createElement('div');hdr.style.cssText='padding:8px;border-bottom:1px solid var(--border)';hdr.appendChild(inp);
const list=document.createElement('div');list.style.cssText='overflow-y:auto;max-height:340px';
// Event delegation: single click handler for all items
list.addEventListener('click',ev=>{const it=ev.target.closest('.slash-item');if(it){ev.preventDefault();SM.sel(it.dataset.sid);}});
e.appendChild(hdr);e.appendChild(list);
SM._e=e;SM._in=inp;SM._li=list;
document.addEventListener('mousedown',ev=>{if(!e.contains(ev.target))SM.close();});
},
open(i,el){if(!SM._e)SM.init();SM.close();SM._i=i;SM._s=0;SM._in.value='';SM.flt();
const r=el.getBoundingClientRect();SM._e.style.display='block';
SM._e.style.top=Math.max(4,(r.bottom+4>window.innerHeight-340)?r.top-344:r.bottom+4)+'px';
SM._e.style.left=Math.min(r.left,window.innerWidth-324)+'px';
setTimeout(()=>SM._in.focus(),10);SM._o=true;},
close(){if(SM._e)SM._e.style.display='none';SM._o=false;SM._in&&(SM._in.value='');},
flt(){if(!SM._li)return;SM._it=[];const q=(SM._in.value||'').toLowerCase();SM._q=q;let h='';
for(const g of CMDS){let items=g.items;if(q)items=items.filter(c=>c.name.toLowerCase().includes(q)||c.id.includes(q));if(!items.length)continue;
h+=`<div style="padding:6px 12px 4px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase;letter-spacing:.5px">${g.name}</div>`;
items.forEach(c=>{SM._it.push(c);
h+=`<div class="slash-item" data-sid="${c.id}" style="display:flex;align-items:center;gap:10px;padding:8px 12px;font-size:14px;color:var(--text-primary);cursor:pointer">
<span style="width:28px;height:28px;display:flex;align-items:center;justify-content:center;font-size:16px;background:var(--bg-secondary);border-radius:4px;flex-shrink:0">${c.icon}</span>
<span style="flex:1;font-weight:500">${c.name}</span></div>`;});}
SM._li.innerHTML=h;},
_kd(e){if(e.key==='ArrowDown'){e.preventDefault();SM._s=Math.min(SM._s+1,SM._it.length-1);SM._rs();}
else if(e.key==='ArrowUp'){e.preventDefault();SM._s=Math.max(SM._s-1,0);SM._rs();}
else if(e.key==='Enter'){e.preventDefault();const c=SM._it[SM._s];if(c)SM.sel(c.id);}
else if(e.key==='Escape'){e.preventDefault();SM.close();}},
_rs(){const its=SM._li.querySelectorAll('.slash-item');its.forEach((el,i)=>{el.classList.toggle('selected',i===SM._s);});},
sel(id){if(window.E)window.E.applySlash(id);SM.close();},
get isOpen(){return SM._o;}};
function renderBlock(b,idx){
if(b.type==='divider')return `<div class="block-wrapper"><hr class="block-divider"></div>`;
const ph=b.type==='heading_1'?'Heading 1':b.type==='heading_2'?'Heading 2':b.type==='heading_3'?'Heading 3':b.type==='heading_4'?'Heading 4':b.type==='bulleted_list'?'List':b.type==='numbered_list'?'List':b.type==='to_do'?'To-do':b.type==='toggle'?'Toggle list':b.type==='quote'?'Empty quote':b.type==='code'?'Type code...':b.type==='callout'?'Type something...':'Press \'/\' for commands...';
let inner='';
if(b.type==='to_do')inner=`<input type="checkbox" class="todo-checkbox" ${b.checked?'checked':''} onchange="E._doToggle('${b.id}',this.checked)"><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
else if(b.type==='toggle')inner=`<button class="toggle-chevron ${b.expanded?'open':''}" onclick="E._doToggleExpand('${b.id}')">▶</button><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
else if(b.type==='code')inner=`<div class="code-lang-label">${b.language||'Plain Text'}</div><pre><code data-bid="${b.id}" contenteditable="true" spellcheck="false">${esc(b.content)}</code></pre>`;
else if(b.type==='callout')inner=`<span class="callout-icon">${b.icon||'💡'}</span><div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
else inner=`<div data-bid="${b.id}" contenteditable="true" data-placeholder="${ph}" spellcheck="false">${esc(b.content)}</div>`;
const tag=b.type.startsWith('heading_')?'h'+b.type.slice(-1):b.type==='quote'?'blockquote':'div';
const cls=tag==='div'?`block-content ${b.type==='bulleted_list'?'block-bullet':b.type==='numbered_list'?'block-numbered':b.type==='to_do'?'block-todo':b.type==='toggle'?'block-toggle':b.type==='code'?'block-code':b.type==='callout'?'block-callout':''}`:`block-content block-${tag}`;
const style=b.type==='callout'?` style="background:${(b.style&&b.style.bgColor)||'var(--blue-bg)'}"`:'';
return `<div class="block-wrapper">
<div class="block-handle"><svg viewBox="0 0 16 16" width="14" height="14"><circle cx="4" cy="3" r="1.3"/><circle cx="11" cy="3" r="1.3"/><circle cx="4" cy="8" r="1.3"/><circle cx="11" cy="8" r="1.3"/><circle cx="4" cy="13" r="1.3"/><circle cx="11" cy="13" r="1.3"/></svg></div>
<div class="block-actions"><button class="block-add-btn" onclick="E.addAfter(${idx})">+</button></div>
<${tag}${style} class="${cls}">${inner}</${tag}>
</div>`;
}
window.E=null;
function editor(pid,title,fmt,raw){
const self={
pid,pageTitle:title,blocks:[],dirty:false,saving:false,lastSaved:'',st:null,
fmt:{open:false,top:0,left:0,idx:-1},
init(){
_bid=Math.floor(Math.random()*10000);
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);
if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];
window.E=this;SM.init();this.render();
setTimeout(()=>{this.focusBlock();const ct=document.getElementById('_blocksCt');if(!ct)return;
ct.addEventListener('keydown',e=>this.onKd(e));
ct.addEventListener('paste',e=>{e.preventDefault();document.execCommand('insertText',false,(e.clipboardData||window.clipboardData).getData('text/plain'));this.dirty=true;this.autoSave();});
if(typeof Sortable!=='undefined')Sortable.create(ct,{draggable:'.block-wrapper',handle:'.block-handle',animation:150,ghostClass:'sortable-ghost',dragClass:'sortable-drag',onEnd:evt=>{if(evt.oldIndex===evt.newIndex)return;const[item]=this.blocks.splice(evt.oldIndex,1);this.blocks.splice(evt.newIndex,0,item);this.dirty=true;this.autoSave();this.render();}});
},100);
},
mkB(type,content,extras){const b={id:genId(),type,content:content||'',...(extras||{})};if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;return b;},
getEl(bid){const ct=document.getElementById('_blocksCt');return ct?ct.querySelector(`[data-bid="${bid}"]`):null;},
getIdx(bid){return this.blocks.findIndex(b=>b.id===bid);},
getActiveBlock(){const el=document.activeElement;if(!el||!el.hasAttribute('data-bid'))return null;return{el,bid:el.dataset.bid,idx:this.getIdx(el.dataset.bid)};},
focusBlock(){const b=this.blocks[0];if(!b)return;const el=this.getEl(b.id);if(el){el.focus();ce(el);}},
onCtClick(e){if(e.target===document.getElementById('_blocksCt')||e.target?.classList?.contains('blocks-empty')){if(!this.blocks.length)this.addAt(0);else{const b=this.blocks[this.blocks.length-1];const el=b&&this.getEl(b.id);if(el){el.focus();ce(el);}}}},
// Sync DOM→data, render, add listeners
sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='divider')continue;const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el)b.content=el.textContent||'';}},
render(){const ct=document.getElementById('_blocksCt');if(!ct)return;let html='';for(let i=0;i<this.blocks.length;i++)html+=renderBlock(this.blocks[i],i);ct.innerHTML=html;
ct.querySelectorAll('.block-wrapper').forEach(w=>{w.onmouseenter=()=>{w.querySelector('.block-handle')?.classList.add('visible');w.querySelector('.block-actions')?.classList.add('visible');};w.onmouseleave=()=>{w.querySelector('.block-handle')?.classList.remove('visible');w.querySelector('.block-actions')?.classList.remove('visible');};});
// Update empty class: browser may insert <br> in contentEditable — strip and apply class
ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/<br[^>]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());});
},
addAt(idx,type,content){this.sync();const b=this.mkB(type||'paragraph',content||'');this.blocks.splice(idx,0,b);this.dirty=true;this.autoSave();this.render();setTimeout(()=>{const el=this.getEl(b.id);if(el&&type!=='divider'){el.focus();content&&ce(el);}},60);return b;},
addAfter(idx,type){return this.addAt(idx+1,type);},
removeBlock(idx){this.sync();if(this.blocks.length<=1){this.blocks[0]=this.mkB('paragraph','');}else{this.blocks.splice(idx,1);}const fi=Math.min(idx,this.blocks.length-1);this.render();this.dirty=true;this.autoSave();setTimeout(()=>{const el=this.getEl(this.blocks[fi]?.id);if(el){el.focus();ce(el);}},60);},
// KEY HANDLER
onKd(e){
const ab=this.getActiveBlock();if(!ab)return;
const{el,bid,idx}=ab;const block=this.blocks[idx];if(!block)return;
if(SM.isOpen){
if(e.key==='Escape'){e.preventDefault();SM.close();return;}
if(e.key==='ArrowDown'||e.key==='ArrowUp'||e.key==='Enter'){e.preventDefault();SM._in.dispatchEvent(new KeyboardEvent('keydown',{key:e.key,bubbles:true}));return;}
if(e.key==='Backspace'&&!SM._q&&(el.textContent||'').trim()==='/'){e.preventDefault();SM.close();return;}
return;
}
// Slash detection — use idx, not bid (bid can get stale after render)
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);}
// ENTER: split block
// Markdown shortcuts on space — use idx
if(e.key===' '){const i2=idx;setTimeout(()=>this.checkMd(i2),15);}
// ENTER: split block
if(e.key==='Enter'){
if(e.shiftKey){this.dirty=true;this.autoSave();return;}
e.preventDefault();
const text=el.textContent||'',pos=cp(el),before=text.substring(0,pos),after=text.substring(pos);
if(!before.trim()&&!after.trim()){
const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph';
this.sync();this.blocks.splice(idx+1,0,this.mkB(nt,''));this.render();
setTimeout(()=>{const ne=this.getEl(this.blocks[idx+1]?.id);if(ne){ne.focus();ce(ne);}},60);
this.dirty=true;this.autoSave();return;
}
const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph';
this.sync();block.content=before;this.blocks.splice(idx+1,0,this.mkB(nt,after));this.render();
setTimeout(()=>{const ne=this.getEl(this.blocks[idx+1]?.id);if(ne){ne.focus();cs(ne);}},60);
this.dirty=true;this.autoSave();return;
}
// ARROWS
if(e.key==='ArrowUp'){if(cp(el)===0&&idx>0){e.preventDefault();const prev=this.getEl(this.blocks[idx-1]?.id);if(prev){prev.focus();ce(prev);}}return;}
if(e.key==='ArrowDown'){if(cp(el)>=(el.textContent||'').length&&idx<this.blocks.length-1){e.preventDefault();const next=this.getEl(this.blocks[idx+1]?.id);if(next){next.focus();cs(next);}}return;}
// BACKSPACE
if(e.key==='Backspace'){if(!(el.textContent||'').trim()){this.sync();if(block.type!=='paragraph'){e.preventDefault();block.type='paragraph';block.content='';this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}else if(this.blocks.length>1){e.preventDefault();this.removeBlock(idx);}}return;}
// Markdown shortcuts on space
if(e.key===' '){const b2=bid;setTimeout(()=>this.checkMd(b2),15);}
// Ctrl shortcuts
if((e.ctrlKey||e.metaKey)&&!e.altKey){const m={b:'bold',i:'italic',u:'underline'};if(m[e.key]){e.preventDefault();document.execCommand(m[e.key]);this.dirty=true;this.autoSave();}}
if(e.key==='Tab'&&block.type==='code'){e.preventDefault();document.execCommand('insertText',false,' ');}
this.dirty=true;this.autoSave();
},
checkMd(idx){
if(idx<0||idx>=this.blocks.length)return;const block=this.blocks[idx];const el=this.getEl(block.id);if(!el)return;const text=el.textContent||'';
const sc={'# ':'heading_1','## ':'heading_2','### ':'heading_3','#### ':'heading_4','- ':'bulleted_list','* ':'bulleted_list','+ ':'bulleted_list','1. ':'numbered_list','[] ':'to_do','[ ] ':'to_do','> ':'quote'};
this.sync();
for(const[pfx,type] of Object.entries(sc)){if(text===pfx||text.startsWith(pfx)){block.type=type;block.content='';if(type==='toggle')block.expanded=true;if(type==='to_do')block.checked=false;this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne&&type!=='divider')ne.focus();},60);return;}}
if(text==='---'){block.type='divider';block.content='';this.addAfter(idx,'paragraph');}
if(text==='```'){block.type='code';block.content='';block.language='Plain Text';this.render();setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);}
},
applySlash(id){
const idx=SM._i;if(idx<0||idx>=this.blocks.length)return;
const block=this.blocks[idx];this.sync();block.content='';
if(id==='divider'){block.type='divider';this.addAfter(idx,'paragraph');}
else{block.type=id;if(id==='toggle')block.expanded=true;if(id==='to_do')block.checked=false;if(id==='code')block.language='Plain Text';}
this.render();this.dirty=true;this.autoSave();
setTimeout(()=>{const ne=this.getEl(block.id);if(ne)ne.focus();},60);
},
_doToggle(bid,v){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].checked=v;this.dirty=true;this.autoSave();}},
_doToggleExpand(bid){const idx=this.getIdx(bid);if(idx>=0){this.blocks[idx].expanded=!this.blocks[idx].expanded;this.dirty=true;this.autoSave();}},
showFmt(idx){const s=window.getSelection();if(!s.rangeCount||s.isCollapsed){this.fmt.open=false;return;}const r=s.getRangeAt(0).getBoundingClientRect();this.fmt.open=true;this.fmt.idx=idx;this.fmt.top=Math.max(r.top-44,0);this.fmt.left=Math.min(r.left+r.width/2-120,window.innerWidth-260);this._sel={range:s.getRangeAt(0).cloneRange(),idx};},
fmtApply(f){this.fmt.open=false;if(!this._sel)return;const s=window.getSelection();s.removeAllRanges();s.addRange(this._sel.range);document.execCommand(f==='strikeThrough'?'strikeThrough':f);this._sel=null;this.dirty=true;this.autoSave();},
fmtLink(){this.fmt.open=false;const u=prompt('URL:');if(u){document.execCommand('createLink',false,u);this.dirty=true;this.autoSave();}},
pastePlain(e){e.preventDefault();document.execCommand('insertText',false,(e.clipboardData||window.clipboardData).getData('text/plain'));this.dirty=true;this.autoSave();},
autoSave(){clearTimeout(this.st);this.st=setTimeout(()=>this.save(),1500);},
save(){
if(this.saving)return;
this.sync();
const t=document.getElementById('_titleEl');if(t)this.pageTitle=t.textContent?.trim()||'New page';
this.saving=true;
const csrf=document.cookie.match(/csrf_token=([^;]+)/);
fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','alt','style'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})})
.then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString();
// Update sidebar items with new title
document.querySelectorAll(`.sidebar-item[data-page-id="page/${this.pid}"]`).forEach(item=>{const n=item.querySelector('.page-name');if(n)n.textContent=this.pageTitle||'New page';});
}).catch(()=>{this.saving=false;});
},
md2b(md){const bl=[];let cb=null;for(const line of md.split('\n')){if(cb){if(line.trim()==='```'){bl.push(cb);cb=null;}else cb.content+=(cb.content?'\n':'')+line;continue;}const t=line.trim();if(!t)continue;if(t.startsWith('```')){cb=this.mkB('code','',{language:t.substring(3).trim()||'Plain Text'});continue;}if(t.startsWith('# ')&&!t.startsWith('## '))bl.push(this.mkB('heading_1',t.substring(2)));else if(t.startsWith('## ')&&!t.startsWith('### '))bl.push(this.mkB('heading_2',t.substring(3)));else if(t.startsWith('### ')&&!t.startsWith('#### '))bl.push(this.mkB('heading_3',t.substring(4)));else if(t.startsWith('#### '))bl.push(this.mkB('heading_4',t.substring(5)));else if(t==='---'||t==='***')bl.push(this.mkB('divider',''));else if(/^[-*+] /.test(t))bl.push(this.mkB('bulleted_list',t.substring(2)));else if(/^\d+\. /.test(t))bl.push(this.mkB('numbered_list',t.replace(/^\d+\. /,'')));else if(t.startsWith('- [ ] ')||t.startsWith('* [ ] '))bl.push(this.mkB('to_do',t.substring(6)));else if(t.startsWith('- [x] ')||t.startsWith('* [x] '))bl.push(this.mkB('to_do',t.substring(6),{checked:true}));else if(t.startsWith('> '))bl.push(this.mkB('quote',t.substring(2)));else bl.push(this.mkB('paragraph',t));}if(cb)bl.push(cb);return bl;},
};
return self;
}
document.addEventListener('mouseup',()=>{setTimeout(()=>{const s=window.getSelection();if(!s||s.isCollapsed)return;const ed=document.querySelector('.page-editor-wrapper');if(!ed?.__x)return;const d=ed.__x.$data;const ct=document.getElementById('_blocksCt');if(!ct?.contains(s.anchorNode))return;const bel=s.anchorNode.parentElement?.closest('[data-bid]');if(bel){const idx=d.getIdx(bel.dataset.bid);if(idx>=0)d.showFmt(idx);}},80);});
</script>
{% endblock %}
+95
View File
@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block page_icon %}🗑️{% endblock %}
{% block page_title %}Trash{% endblock %}
{% block content %}
<div class="page-title-area">
<div class="page-title">
<span class="page-icon-lg">🗑️</span>
<h1>Trash</h1>
</div>
</div>
<div x-data="trashData()" style="padding: 0 24px; max-width: 800px;">
<!-- Search -->
<div style="position:relative; margin-bottom:12px;">
<span style="position:absolute; left:12px; top:50%; transform:translateY(-50%); color:var(--text-dim);">🔍</span>
<input type="text" placeholder="Search pages in Trash" x-model="search"
style="width:100%; padding:8px 12px 8px 36px; background:var(--bg-tertiary); border:1px solid var(--border); border-radius:6px; color:var(--text-primary); font-size:14px; outline:none; box-sizing:border-box;">
</div>
<!-- Filters -->
<div style="display:flex; gap:8px; margin-bottom:16px;">
<button class="trash-filter active">
<span style="color:#5b9bd5;">👤</span> Last edited by ▾
</button>
<button class="trash-filter">
<span>📁</span> In ▾
</button>
</div>
<!-- Items -->
<div style="min-height:200px;">
<template x-for="item in filteredItems" :key="item.id">
<div class="trash-item">
<span class="trash-item-icon" x-text="item.icon"></span>
<div class="trash-item-info">
<span class="trash-item-name" x-text="item.name"></span>
<span class="trash-item-path" x-text="item.path"></span>
</div>
<button class="trash-item-btn" title="Restore" @click="restore(item.id)">
↩️
</button>
<button class="trash-item-btn" title="Delete permanently" @click="deleteForever(item.id)">
🗑️
</button>
</div>
</template>
<div x-show="filteredItems.length === 0" class="lib-empty">
<div style="font-size:48px;margin-bottom:16px;">🗑️</div>
<h3>Trash is empty</h3>
<p>Deleted pages will appear here for 30 days.</p>
</div>
</div>
<!-- Info -->
<div style="padding:12px 16px; margin-top:16px; background:var(--bg-tertiary); border-radius:8px; font-size:13px; color:var(--text-dim); display:flex; align-items:center; gap:8px;">
<span>Once a page has been in Trash for 30 days, it will be automatically deleted</span>
<span style="font-size:16px;">ⓘ</span>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function trashData() {
return {
search: '',
items: [],
async init() {
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
const token = csrf ? csrf[1] : '';
try {
const r = await fetch('/board/api/trash', { headers: { 'X-CSRF-Token': token } });
this.items = await r.json();
} catch(e) { this.items = []; }
},
get filteredItems() {
const q = this.search.toLowerCase();
return this.items.filter(i => !q || i.name.toLowerCase().includes(q) || (i.path||'').toLowerCase().includes(q));
},
async restore(id) {
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
const r = await fetch(`/board/api/trash/${id}/restore`, { method: 'POST', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
},
async deleteForever(id) {
if (!confirm('Permanently delete this page? This cannot be undone.')) return;
const csrf = document.cookie.match(/csrf_token=([^;]+)/);
const r = await fetch(`/board/api/trash/${id}`, { method: 'DELETE', headers: { 'X-CSRF-Token': csrf?.[1] || '' } });
if (r.ok) { this.items = this.items.filter(i => i.id !== id); }
}
};
}
</script>
{% endblock %}
+822
View File
@@ -0,0 +1,822 @@
# Guide Technique — Recréer Database & Tasks de Notion dans FlowDeck
> **Version:** 1.0 · **Date:** 2026-07-10
> **Auteur:** Hermes-Deepin · **Cible:** FlowDeck v1.2+
---
## Table des matières
1. [Introduction](#introduction)
2. [Comprendre Notion : le modèle « Blocs & Collections »](#1-comprendre-notion--le-modèle-blocs--collections)
3. [La Database Notion : anatomie complète](#2-la-database-notion--anatomie-complète)
4. [Les Tasks Notion : un cas spécial de database](#3-les-tasks-notion--un-cas-spécial-de-database)
5. [Comment Notion connecte Database et Tasks](#4-comment-notion-connecte-database-et-tasks)
6. [Implémentation dans FlowDeck : plan pas à pas](#5-implémentation-dans-flowdeck--plan-pas-à-pas)
7. [Références](#6-références)
---
## Introduction
L'objectif est de **recréer dans FlowDeck** les deux fonctionnalités les plus puissantes de Notion :
- **Les Databases** — tables structurées avec propriétés typées, vues multiples, filtres et tris
- **Les Tasks** — sous-tâches hiérarchiques avec dépendances, liées aux databases
Ces deux systèmes sont **profondément interconnectés** dans Notion : les tâches ne sont qu'une forme spécialisée de database, avec un parent auto-référencé et des propriétés de dépendance.
### État actuel de FlowDeck (v1.2)
FlowDeck a déjà une base solide :
```
✅ Boards (Kanban lié à Gitea) → table `boards`
✅ Cards (issues → colonnes) → table `cards`
✅ Colonnes custom → `project_properties` + `property_values`
✅ Checklists par issue → `checklists` + `checklist_items`
✅ Vues multiples → Kanban, Table, Status, TeamLoad, Detailed
✅ Notes Markdown → `notes`
✅ Pages (ébauche Notion editor) → `pages` (content, parent_id, sort_order, content_format)
⚠️ Database concept → ABSENT (pas de notion de collection/row)
❌ Relations entre projets → ABSENT
❌ Sub-tasks / Parent-item → ABSENT
❌ Rollups / Formules → ABSENT
❌ Dependencies (bloque/bloqué par) → ABSENT
```
---
## 1. Comprendre Notion : le modèle « Blocs & Collections »
### 1.1 Le principe fondateur : « Everything is a Block »
Dans Notion, **chaque élément de contenu est un bloc**. Un bloc peut être :
```
┌──────────────────────────────────────┐
│ BLOC │
│ ├─ id: UUID │
│ ├─ type: "paragraph" │
│ │ "heading_1" │
│ │ "to_do" │
│ │ "bulleted_list_item" │
│ │ "child_page" │
│ │ "child_database" │
│ │ "collection_view_page" │ ← Database
│ │ ... │
│ ├─ content: [...] (blocs enfants) │
│ ├─ properties: {} (metadata) │
│ └─ parent: {type, id} │
└──────────────────────────────────────┘
```
### 1.2 Les 3 couches d'une Database Notion
Une database Notion est un assemblage de **3 concepts distincts** :
```
┌────────────────────────────────────────────────────────────┐
│ DATABASE NOTION │
│ │
│ ┌─────────────┐ ┌──────────────────┐ ┌────────────┐│
│ │ COLLECTION │ │ COLLECTION_VIEW │ │ PAGES ││
│ │ (schema) │◄───│ (présentation) │◄───│ (data) ││
│ │ │ │ │ │ ││
│ │ properties │ │ type: table │ │ row 1 ││
│ │ - Name │ │ board │ │ row 2 ││
│ │ - Status │ │ calendar │ │ row 3 ││
│ │ - Priority │ │ gallery │ │ ... ││
│ │ - Due Date │ │ list │ │ ││
│ │ - Assignee │ │ timeline │ │ ││
│ │ │ │ │ │ ││
│ │ │ │ filters │ │ ││
│ │ │ │ sorts │ │ ││
│ │ │ │ grouping │ │ ││
│ │ │ │ format options │ │ ││
│ └─────────────┘ └──────────────────┘ └────────────┘│
│ │
│ 1 SCHÉMA N VUES (filtres N LIGNES │
│ partagé + tris différents) (pages filles) │
└────────────────────────────────────────────────────────────┘
```
**Concept clé** : Une database n'est PAS une table SQL. C'est une **collection de pages**, chaque page ayant un schéma de propriétés commun, affiché via des vues configurables.
### 1.3 Le cycle de vie d'une Database
```
Création Consultation Modification
───────── ──────────── ────────────
1. L'utilisateur choisit 4. Les vues lisent le 7. Édition inline :
un type de vue schéma + les pages - click → édite valeur
(table, board...) - drag → réorganise
5. Filtres/sorts/tri - shortcuts → ajoute ligne
2. Notion crée : appliqués
- 1 Collection 8. Changement de vue =
- 1 CollectionView 6. Rendu en temps réel même données,
- 1 CollectionViewPage autre présentation
- + pages vides optionnelles
9. Ajout propriété =
3. L'utilisateur définit nouveau champ sur
les propriétés (schéma) TOUTES les pages
```
---
## 2. La Database Notion : anatomie complète
### 2.1 Propriétés (le schéma)
Notion supporte **21 types de propriétés**. Toutes sont définies au niveau de la Collection :
```
Type │ Description │ Exemple de valeur
──────────────────┼────────────────────────────┼──────────────────
Title │ Le titre de la page (obligatoire, unique)
Text │ Texte libre │ "Description..."
Number │ Nombre (décimales, format) │ 42 / 12.5%
Select │ Choix unique │ "En cours"
Multi-select │ Choix multiples │ ["Frontend","Backend"]
Status │ Select avec couleur forcée │ "Done" (vert)
Date │ Date + heure optionnelle │ 2026-07-15
Person │ Mention utilisateur │ ["bruno"]
Files & media │ Uploads │ [url1, url2]
Checkbox │ Booléen │ true / false
URL │ Lien cliquable │ "https://..."
Email │ Email cliquable │ "[email protected]"
Phone │ Téléphone cliquable │ "+1..."
Formula │ Calcul (JS-like) │ prop("Prix")*prop("Qté") ← CLÉ
Relation │ Lien vers autre database │ [page_id_1, page_id_2] ← CLÉ
Rollup │ Agrégation via relation │ sum, avg, count, min, max ← CLÉ
Created time │ Date de création (auto) │ 2026-01-15T10:30:00Z
Created by │ Auteur (auto) │ "bruno"
Last edited time │ Dernière édition (auto) │ 2026-07-09T18:00:00Z
Last edited by │ Dernier éditeur (auto) │ "bruno"
Unique ID │ ID incrémental (auto) │ 42
Button │ Déclenche action (nouveau) │ —
AI Summary │ Résumé AI (nouveau) │ "Ce projet..."
```
### 2.2 Views (les vues)
Une database peut avoir **N vues**, chaque vue avec :
```
┌─────────────────────────────────────────┐
│ Vue "Kanban" │
│ ├─ type: board │
│ ├─ group_by: "Status" │ ← Groupement
│ ├─ filter: {Status ≠ "Archivé"} │ ← Filtres
│ ├─ sort: [Due Date ASC, Priority DESC] │ ← Tris
│ ├─ visible_properties: [Name, Assignee, Due Date] │
│ ├─ card_size: medium │
│ └─ cover_image_property: "Files" │
└─────────────────────────────────────────┘
```
**6 types de vues :**
| Vue | Usage | Particularité |
|-----|-------|---------------|
| **Table** | Liste classique | Colonnes = propriétés |
| **Board** | Kanban | Groupé par propriété Select/Status |
| **Timeline** | Gantt | Axe X = propriété Date |
| **Calendar** | Calendrier | Groupé par propriété Date (jour/semaine/mois) |
| **Gallery** | Cartes visuelles | Cover image + preview |
| **List** | Liste compacte | Comme table mais 1 colonne + preview |
**Filtres avancés :**
```
Filtre 1 : Status IS "En cours" → filtre simple
Filtre 2 : Due Date IS BEFORE "today" → filtre date
Filtre 3 : Priority CONTAINS "P1" → filtre multi-select
Groupe : AND / OR entre les filtres
Sous-groupe : (FiltreA OR FiltreB) AND FiltreC
```
### 2.3 Relations et Rollups (les super-pouvoirs)
C'est LE mécanisme qui rend Notion puissant :
```
┌─────────────────────┐ ┌──────────────────────┐
│ DATABASE A │ │ DATABASE B │
│ « Projets » │ │ « Tâches » │
│ │ │ │
│ ┌─────────────┐ │ relation│ ┌──────────────┐ │
│ │ Nom │ │◄───────►│ │ Titre │ │
│ │ Description │ │ │ │ Priorité │ │
│ │ Statut │ │ │ │ Projet ──────┼──┐ │
│ └─────────────┘ │ │ │ Assignee │ │ │
│ │ │ └──────────────┘ │ │
│ │ │ │ │
│ ROLLUP « Nb tâches»│◄────────┼─── count(Tâches) │ │
│ ROLLUP « Avancement│◄────────┼─── avg(%Complété) │ │
│ ROLLUP « Prochaine │◄────────┼─── min(Due Date) │ │
└─────────────────────┘ └───────────────────┘──┘
```
**Relation** = pointeur bidirectionnel entre pages de 2 databases différentes
**Rollup** = agrégation (COUNT, SUM, AVG, MIN, MAX, RANGE, UNIQUE) sur une propriété de la database liée
---
## 3. Les Tasks Notion : un cas spécial de database
### 3.1 Architecture des tâches
Contrairement à ce qu'on pourrait penser, **Notion n'a pas de module « Tasks » séparé**.
```
Les tâches sont simplement des PAGES dans une DATABASE
qui possède des propriétés spécifiques de type tâche.
```
Voici comment Notion les distingue :
```
┌─────────────────────────────────────────────────────────────┐
│ DATABASE « Tasks » (ou n'importe quelle DB) │
│ │
│ Propriétés « tâche » │ Propriétés « standard » │
│ ────────────────────────── │ ────────────────────────── │
│ ✅ Sub-item (parent) │ • Titre (obligatoire) │
│ ✅ Dependencies (bloque) │ • Status (Select) │
│ ✅ Due Date │ • Priority (Select) │
│ ✅ Assignee │ • Tags (Multi-select) │
│ ✅ Reminder │ • ... │
│ │
│ La magie : ces propriétés SONT la database. │
│ Pas de modèle « Task » séparé. │
└─────────────────────────────────────────────────────────────┘
```
### 3.2 Sub-items (sous-tâches)
Les sous-tâches utilisent une **relation auto-référencée** :
```
┌──────────────────────────────────────┐
│ Table « tasks » (dans la DB) │
│ │
│ id │ title │ parent_id │
│ ───┼────────────────┼───────────────│
│ 1 │ Refonte UI │ NULL │ ← Tâche parent
│ 2 │ Nouveau header │ 1 │ ← Sous-tâche de 1
│ 3 │ Dark mode │ 1 │ ← Sous-tâche de 1
│ 4 │ Tests header │ 2 │ ← Sous-sous-tâche de 2
│ 5 │ Déploiement │ NULL │ ← Autre tâche parent
│ │
│ Propriétés spéciales : │
│ ├─ « Sub-item » = relation vers │
│ │ la même database │
│ └─ « Parent item » = l'inverse │
│ (auto-créé par Notion) │
└──────────────────────────────────────┘
```
**Comment ça fonctionne techniquement :**
1. L'utilisateur active « Sub-items » dans une database
2. Notion crée automatiquement 2 propriétés de type **Relation** pointant vers la même database :
- `Sub-item` (relation → même DB)
- `Parent item` (relation inverse, auto-générée)
3. Dans l'interface Kanban, les sous-tâches apparaissent imbriquées sous le parent
4. La récursion est illimitée (tâche → sous-tâche → sous-sous-tâche → ...)
### 3.3 Dependencies (dépendances)
Les dépendances bloquantes sont une **seconde relation auto-référencée** :
```
┌──────────────────────────────────────┐
│ Relation « Bloque » │
│ │
│ Tâche A ──bloque──► Tâche B │
│ Tâche B ──bloquée par──► Tâche A │
│ │
│ Règles métier : │
│ ├─ B ne peut pas être « Done » │
│ │ tant que A n'est pas « Done » │
│ ├─ Si A est déplacée, B suit │
│ │ (optionnel, selon config) │
│ └─ Timeline affiche les flèches │
│ A ────────► B │
└──────────────────────────────────────┘
```
### 3.4 La vue « My Tasks » (vue agrégée)
La killer feature de Notion : un dashboard qui regroupe TOUTES les tâches, peu importe leur database d'origine.
```
┌──────────────────────────────────────────────────┐
│ MY TASKS │
│ ┌───────────────────────────────────────────┐ │
│ │ Projet Alpha ┌──────────────────────┐│ │
│ │ ├─ Design maquette│ Assignee: Bruno ││ │
│ │ └─ Tests UI │ Due: 15 juillet ││ │
│ │ └──────────────────────┘│ │
│ │ Projet Beta │ │
│ │ ├─ API endpoint │ │
│ │ └─ Documentation │ │
│ │ Daily Tasks │ │
│ │ └─ Réunion standup │ │
│ └───────────────────────────────────────────┘ │
│ │
│ Agrège TOUTES les databases ayant : │
│ ├─ Une propriété « Person » = current user │
│ └─ Un Status ≠ « Done » │
└──────────────────────────────────────────────────┘
```
**Fonctionnement interne :**
1. Notion scanne TOUTES les databases du workspace
2. Filtre les pages où `Assignee = current_user` ET `Status ≠ Done/Completed`
3. Regroupe par database d'origine (ou par projet parent)
4. Affiche avec tri par `Due Date` ASC
5. Cette vue est **read-only logique** (pas une vraie database)
---
## 4. Comment Notion connecte Database et Tasks
### 4.1 Le diagramme de dépendance conceptuel
```
DATABASE (concept racine)
│
┌──────────────┼──────────────┐
▼ ▼ ▼
COLLECTION PAGES (rows) COLLECTION_VIEWS
(schéma) │ (présentations)
│ │ │
│ ├─ Title ├─ Table
├─ Text ├─ Properties ├─ Board
├─ Select │ (valeurs ├─ Calendar
├─ Date │ par page) ├─ Timeline
├─ Person │ ├─ Gallery
├─ Relation ────┼──► SELF └─ List
│ └── Sub-items │ ┌── Parent
│ └── Deps │ └── Blocked by
├─ Rollup ←──────┼──┘ (count, sum, avg via relation)
├─ Formula │
└─ ... │
│
┌──────┴──────┐
│ TASKS │ (surcouche métier)
│ │
│ Ne sont PAS │
│ un modèle │
│ séparé — │
│ ce sont des │
│ PAGES avec │
│ propriétés │
│ spécifiques │
└─────────────┘
```
### 4.2 Les invariants du modèle
1. **Une database = 1 Collection** (schéma de propriétés)
2. **1 Collection = N CollectionViews** (vues)
3. **1 CollectionView = 1 type** (table | board | calendar | timeline | gallery | list)
4. **Les pages d'une database sont des blocs** (comme toute page Notion)
5. **Une propriété « Relation » pointe vers une autre database** (pas une table SQL)
6. **Une propriété « Rollup » agrège via une Relation existante**
7. **Les sub-items sont une Relation auto-référencée**
8. **Les dépendances sont une Relation auto-référencée avec contrainte métier**
---
## 5. Implémentation dans FlowDeck : plan pas à pas
### 5.1 Phase 1 — Le concept de Database (semaine 1)
**Objectif :** Abstraire le concept de « board Gitea » en « database » réutilisable.
#### Nouvelle table : `collections`
```sql
CREATE TABLE collections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT DEFAULT '',
icon TEXT DEFAULT '📋',
-- Peut être liée à un projet Gitea (optionnel)
gitea_owner TEXT,
gitea_repo TEXT,
-- Schéma de propriétés en JSON (flexible, évolutif)
schema_json TEXT NOT NULL DEFAULT '[]',
-- Format: [{"name":"Status","type":"select","options":["Todo","Done"],"position":0}, ...]
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
#### Nouvelle table : `collection_pages` (remplace `cards`)
```sql
CREATE TABLE collection_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
-- Peut être liée à une issue Gitea
gitea_issue_id INTEGER,
gitea_issue_number INTEGER,
-- Titre de la page (obligatoire)
title TEXT NOT NULL DEFAULT '',
-- Position dans la collection
position INTEGER NOT NULL DEFAULT 0,
-- Sub-items (auto-référence)
parent_id INTEGER REFERENCES collection_pages(id),
-- Valeurs des propriétés en JSON
property_values_json TEXT NOT NULL DEFAULT '{}',
-- Format: {"Status":"Done","Priority":"P1","DueDate":"2026-07-15"}
-- Métadonnées temporelles
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_cp_collection ON collection_pages(collection_id, position);
CREATE INDEX idx_cp_parent ON collection_pages(parent_id);
CREATE INDEX idx_cp_gitea ON collection_pages(gitea_issue_id);
```
#### Nouvelle table : `collection_views`
```sql
CREATE TABLE collection_views (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Default View',
view_type TEXT NOT NULL DEFAULT 'table', -- table, board, calendar, timeline, gallery, list
-- Configuration JSON
config_json TEXT NOT NULL DEFAULT '{}',
-- Format:
-- {
-- "group_by": "Status", ← board/calendar uniquement
-- "filters": [ ← tous types
-- {"property":"Status","operator":"is_not","value":"Done"},
-- {"property":"DueDate","operator":"is_before","value":"today"}
-- ],
-- "filter_conjunction": "and", ← "and" | "or"
-- "sorts": [ ← tous types
-- {"property":"Priority","direction":"asc"},
-- {"property":"DueDate","direction":"desc"}
-- ],
-- "visible_properties": ["Title","Status","Assignee","DueDate"],
-- "card_size": "medium", ← board/gallery
-- "cover_property": "Files", ← board/gallery
-- "date_property": "DueDate", ← calendar/timeline
-- "date_range_property": "EndDate" ← timeline (optionnel)
-- }
position INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### 5.2 Phase 2 — Le système de propriétés (semaine 1-2)
#### Nouvelle table : `collection_properties` (remplace `project_properties`)
```sql
CREATE TABLE collection_properties (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
name TEXT NOT NULL,
prop_type TEXT NOT NULL DEFAULT 'text',
-- Types: title, text, number, select, multi_select, status,
-- date, person, checkbox, url, email, phone,
-- relation, rollup, formula,
-- created_time, created_by, last_edited_time, last_edited_by
-- Options pour select/multi_select/status
options_json TEXT DEFAULT '[]',
-- Format: [{"name":"Todo","color":"gray"},{"name":"Done","color":"green"}]
-- Pour les relations
related_collection_id INTEGER REFERENCES collections(id),
reverse_name TEXT, -- nom de la relation inverse (auto-généré)
-- Pour les rollups
relation_property_id INTEGER REFERENCES collection_properties(id),
target_property_id INTEGER REFERENCES collection_properties(id),
rollup_function TEXT, -- count, sum, avg, min, max, range, unique
-- Pour les formules
formula_expression TEXT,
-- Position dans la liste des propriétés
position INTEGER NOT NULL DEFAULT 0,
required BOOLEAN NOT NULL DEFAULT 0,
visible_in_views BOOLEAN NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(collection_id, name)
);
```
**Mapping des types Notion → FlowDeck :**
```python
PROPERTY_TYPES = {
"title": {
"storage": "string",
"validation": "max 2000 chars",
"indexed": True,
"unique_per_collection": False,
},
"text": {
"storage": "string",
"validation": "any text",
},
"number": {
"storage": "float",
"validation": "numeric",
"format_options": ["number", "percent", "currency", "dollar", "euro", "pound", "yen"],
},
"select": {
"storage": "string (from options_json)",
"validation": "must match one option",
},
"multi_select": {
"storage": "JSON array of strings",
"validation": "each must match option",
},
"status": {
"storage": "string (from options_json) + color",
"validation": "must match one option",
"colors": ["gray","brown","orange","yellow","green","blue","purple","pink","red"],
},
"date": {
"storage": "ISO 8601 string or range",
"validation": "valid datetime",
"include_time": True, # configurable
},
"person": {
"storage": "JSON array of {id, login, avatar_url}",
"validation": "must be workspace user",
},
"checkbox": {
"storage": "boolean",
"validation": "true/false",
},
"relation": {
"storage": "JSON array of page IDs",
"validation": "must be existing page in related_collection",
"bidirectional": True,
},
"rollup": {
"storage": "computed — not stored directly",
"functions": ["count","count_values","empty","not_empty",
"sum","average","median","min","max","range"],
"depends_on": ["relation_property_id","target_property_id"],
},
"formula": {
"storage": "computed — not stored directly",
"expression": "JavaScript-like DSL",
"functions_available": [
"prop()", "now()", "today()", "if()", "concat()", "round()",
"dateAdd()", "dateSubtract()", "formatDate()", "toNumber()",
"contains()", "length()", "replace()", "replaceAll()",
],
},
}
```
### 5.3 Phase 3 — Sub-items et Dépendances (semaine 2)
#### Implémentation des sub-items (relation auto-référencée)
```python
# Dans le schema d'une database, activer Sub-items crée automatiquement :
def enable_sub_items(collection_id: int):
"""Active les sub-items sur une collection."""
# 1. Créer la propriété relation « Parent item » (si pas déjà)
parent_prop = create_property(
collection_id=collection_id,
name="Parent item",
prop_type="relation",
related_collection_id=collection_id, # AUTO-RÉFÉRENCE
reverse_name="Sub-item", # relation inverse
visible_in_views=False, # cachée dans les vues
)
# 2. Marquer la collection comme ayant des sub-items
mark_collection_feature(collection_id, "has_sub_items", True)
# 3. Mettre à jour la vue board pour afficher l'imbrication
# Le rendu Kanban doit maintenant supporter :
# - Groupe parent → sous-tâches indentées
# - Bouton « + Add sub-item » sous chaque carte
# - État du parent = agrégation des enfants
```
#### Rendu des sub-items dans le board
```html
<!-- Dans board_fragment.html, modifier le rendu des cartes -->
<div class="kanban-card" data-page-id="{{ page.id }}">
<!-- Carte parent -->
<div class="card-title">{{ page.title }}</div>
<!-- Sous-tâches -->
{% for sub in page.sub_items %}
<div class="sub-item" data-page-id="{{ sub.id }}">
├─ {{ sub.title }}
<span class="sub-status">{{ sub.properties.Status }}</span>
</div>
{% endfor %}
<!-- Bouton ajout sous-tâche -->
<button hx-post="/api/pages/{{ page.id }}/sub-items"
hx-swap="beforeend">
+ Sub-item
</button>
</div>
```
#### Implémentation des dépendances
```python
def enable_dependencies(collection_id: int):
"""Active les dépendances bloquantes."""
# Création de 2 propriétés relation
blocks = create_property(
collection_id=collection_id,
name="Blocks",
prop_type="relation",
related_collection_id=collection_id,
reverse_name="Blocked by",
)
# Contrainte métier : au niveau applicatif
# (pas de contrainte SQL pour garder la flexibilité)
def check_dependency_constraint(page_id: int, new_status: str):
"""Vérifie qu'une page peut passer à 'Done'
seulement si toutes les pages qu'elle bloque sont Done."""
blocked_pages = get_blocked_pages(page_id)
if new_status == "Done" and blocked_pages:
not_done = [p for p in blocked_pages
if p.status not in ("Done", "Cancelled")]
if not_done:
raise DependencyError(
f"Cannot mark as Done: still blocking {len(not_done)} tasks"
)
```
### 5.4 Phase 4 — « My Tasks » (vue agrégée) (semaine 2-3)
```python
# GET /my-tasks
async def my_tasks(request: Request):
"""Dashboard personnel : toutes les tâches assignées à l'utilisateur."""
user = get_current_user(request)
# 1. Scanner TOUTES les collections
collections = db.fetch_all("SELECT * FROM collections")
my_tasks = []
for col in collections:
schema = json.loads(col.schema_json)
# 2. Vérifier si la collection a une propriété Person
person_props = [p for p in schema if p["type"] == "person"]
if not person_props:
continue
# 3. Récupérer les pages assignées à l'utilisateur
pages = db.fetch_all("""
SELECT cp.*, cpv.value as property_values
FROM collection_pages cp
JOIN collection_properties cprop
ON cprop.collection_id = cp.collection_id
AND cprop.prop_type = 'person'
WHERE cp.collection_id = ?
AND cp.property_values_json LIKE ?
""", (col.id, f'%"{user.login}"%'))
my_tasks.extend([
{"collection": col, "page": p, "schema": schema}
for p in pages
])
# 4. Grouper par collection
grouped = {}
for task in my_tasks:
col_name = task["collection"].name
if col_name not in grouped:
grouped[col_name] = []
grouped[col_name].append(task)
# 5. Trier par Due Date
for tasks in grouped.values():
tasks.sort(key=lambda t: t["page"].get_property("DueDate") or "9999")
return render_template("my_tasks.html", tasks=grouped)
```
### 5.5 Phase 5 — Rétrocompatibilité avec Gitea
**Stratégie de migration progressive :**
```python
# Phase 5a : Wrapper — les boards Gitea existants
# deviennent des collections avec une couche de compatibilité
class GiteaBoardCompat:
"""Adaptateur : ancien board Gitea → nouvelle Collection."""
@staticmethod
def from_board(board_row):
"""Convertit un board existant en collection."""
return {
"collection_id": f"gitea:{board_row.id}",
"name": f"{board_row.project_owner}/{board_row.project_name}",
"gitea_owner": board_row.project_owner,
"gitea_repo": board_row.project_name,
"schema": [
{"name": "Title", "type": "title"},
{"name": "Status", "type": "select",
"options": json.loads(board_row.columns_json)},
{"name": "Priority", "type": "select",
"options": ["P1","P2","P3","P4"]},
],
"is_gitea_linked": True,
}
# Phase 5b : Nouvelle route hybride
# GET /board/{owner}/{repo} → détecte si c'est un board Gitea legacy
# ou une collection pure, et route vers le bon renderer
```
### 5.6 Résumé du plan d'implémentation
```
Semaine 1 Semaine 2 Semaine 3
──────── ───────── ─────────
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Database │ │ Properties │ │ My Tasks │
│ Concept │ ──► │ Avancées │ ──► │ Dashboard │
│ │ │ │ │ │
│ • collections│ │ • Relations │ │ • Vue agrégée│
│ • pages │ │ • Rollups │ │ • Filtres │
│ • views │ │ • Formules │ │ globaux │
│ • filters │ │ • Auto-prop │ │ • Calendrier │
│ • sorts │ │ computed │ │ intégré │
│ │ │ │ │ │
│ Sub-items │ │ Dependencies │ │ UI polish │
│ (parent_id) │ ──► │ + contraintes│ ──► │ + tests │
└──────────────┘ └──────────────┘ └──────────────┘
```
---
## 6. Références
- [Notion API — Property Object](https://developers.notion.com/reference/property-object)
- [Notion Help — Intro to Databases](https://www.notion.com/help/intro-to-databases)
- [Notion Help — Tasks & Dependencies](https://www.notion.com/help/tasks-and-dependencies)
- [Notion Help — Relations & Rollups](https://www.notion.com/help/relations-and-rollups)
- [react-notion-x — Block types reference](https://github.com/NotionX/react-notion-x)
- [Notion Data Sources update (2025)](https://www.notionapps.com/blog/notion-data-sources-update-2025/)
### Différences clés FlowDeck vs Notion
| Concept | Notion | FlowDeck (cible) |
|---------|--------|------------------|
| **Database** | Collection + Pages + Views | `collections` + `collection_pages` + `collection_views` |
| **Schéma** | Properties sur la Collection | `collection_properties` au niveau collection |
| **Valeurs** | Dans chaque page (bloc) | `property_values_json` dans chaque `collection_page` |
| **Sub-items** | Relation auto-référencée | `parent_id` auto-référence dans `collection_pages` |
| **Dependencies** | Relation auto-référencée + contrainte | Idem + check applicatif au changement de statut |
| **My Tasks** | Scan cross-database + filtre | Route `/my-tasks` + JOIN sur `collection_pages` |
| **Gitea sync** | N/A (pas de Gitea) | Les pages peuvent être liées à `gitea_issue_id` |
| **Stockage** | Cloud distribué (PostgreSQL + cache) | SQLite local (1 fichier) |
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+706 -4
View File
@@ -19,8 +19,10 @@
--text-primary: #FFFFFF;
--text-secondary: #A0A0A0;
--text-dim: #6B6B6B;
--text-muted: #4A4A4A;
--border: #333333;
--border-color: rgba(255,255,255,0.06);
--border-light: #3a3a3a;
/* ===== Couleurs d'accent Notion ===== */
@@ -70,7 +72,9 @@
--text-primary: #1A1A1A;
--text-secondary: #6B6B6B;
--text-dim: #9B9B9B;
--text-muted: #B0B0B0;
--border: #E0E0E0;
--border-color: rgba(0,0,0,0.06);
--border-light: #EDEDEC;
--blue-bg: #E8F0FE;
--green-bg: #E6F4EA;
@@ -217,6 +221,31 @@ input, select, textarea { font-family: inherit; color: inherit; }
flex-shrink: 0;
}
/* Navigation icon row (Notion top bar) */
.sidebar-nav-row {
display: flex; align-items: center; gap: 3px;
padding: 6px 6px; margin: 0 2px;
}
.nav-icon-btn {
display: flex; align-items: center; gap: 8px;
padding: 6px; border-radius: 6px;
color: var(--text-secondary); transition: background 0.15s, color 0.15s;
}
.nav-icon-btn:hover { background: var(--bg-hover); color: var(--text-primary); }
.nav-icon-btn.active { background: var(--bg-active); color: var(--text-primary); }
/* Home button: icon + text */
.nav-icon-btn.home-btn {
padding: 6px 8px; gap: 8px;
font-size: 14px; font-weight: 500;
}
/* Collapse button on the right */
.nav-icon-btn.collapse-btn { margin-left: auto; }
/* Empty hint */
.sidebar-item.empty-hint { color: var(--text-dim); font-size: 12px; cursor: default; }
.sidebar-item.empty-hint:hover { background: none; }
.sidebar-item.empty-hint .page-name { font-size: 12px; }
/* Sections */
.sidebar-section {
padding: var(--space-xs) var(--space-md);
@@ -311,11 +340,58 @@ input, select, textarea { font-family: inherit; color: inherit; }
background: var(--bg-active);
}
/* Tree toggle button (▶/▼ chevron) */
.tree-toggle {
width: 18px;
height: 18px;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: 9px;
flex-shrink: 0;
transition: transform 0.15s ease, background var(--transition);
padding: 0;
margin: 0;
background: none;
border: none;
}
.tree-toggle:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
.tree-toggle.expanded {
transform: rotate(90deg);
}
.tree-toggle-placeholder {
width: 18px;
height: 18px;
flex-shrink: 0;
}
/* Tree children wrapper */
.tree-children-wrapper {
list-style: none;
}
.tree-children-wrapper .sidebar-items {
padding-left: 0;
}
.sidebar-item .page-icon {
font-size: 14px;
width: 18px;
text-align: center;
flex-shrink: 0;
cursor: grab;
}
.sidebar-item:active .page-icon {
cursor: grabbing;
}
.sidebar-item .page-name {
@@ -353,10 +429,6 @@ input, select, textarea { font-family: inherit; color: inherit; }
background: var(--bg-tertiary);
}
/* Indented sub-items */
.sidebar-item.indent-1 { padding-left: 24px; }
.sidebar-item.indent-2 { padding-left: 40px; }
/* Sidebar footer */
.sidebar-footer {
margin-top: auto;
@@ -1445,6 +1517,51 @@ input, select, textarea { font-family: inherit; color: inherit; }
.text-xs { font-size: 12px; }
.text-sm { font-size: 13px; }
.font-semibold { font-weight: 600; }
/* Library table */
.library-table-header {
display: flex; padding: 8px 12px; border-bottom: 1px solid var(--border);
font-size: 12px; font-weight: 600; color: var(--text-dim);
}
.library-table-row {
display: flex; align-items: center; padding: 8px 12px; text-decoration: none;
color: var(--text-primary); border-radius: 6px; transition: background 0.15s;
}
.library-table-row:hover { background: var(--bg-hover); }
.lib-col { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.lib-col-name { flex: 1.5; display: flex; align-items: center; gap: 10px; }
.lib-col-author { width: 140px; font-size: 13px; color: var(--text-dim); }
.lib-col-source { width: 120px; font-size: 13px; color: var(--text-dim); }
.lib-col-edited { width: 140px; font-size: 13px; color: var(--text-dim); }
.lib-col-visited { width: 140px; font-size: 13px; color: var(--text-dim); }
.lib-empty { text-align: center; padding: 48px; color: var(--text-dim); }
.lib-empty h3 { font-size: 16px; margin-bottom: 8px; color: var(--text-primary); }
.lib-empty p { font-size: 13px; }
/* Trash */
.trash-filter {
display: flex; align-items: center; gap: 6px;
padding: 6px 12px; border-radius: 6px;
font-size: 13px; color: var(--text-primary);
background: var(--bg-tertiary); transition: background 0.15s;
}
.trash-filter:hover { background: var(--bg-hover); }
.trash-filter.active { color: #5b9bd5; background: rgba(91,155,213,0.1); }
.trash-item {
display: flex; align-items: center; gap: 12px;
padding: 10px 12px; border-radius: 6px; transition: background 0.15s;
}
.trash-item:hover { background: var(--bg-hover); }
.trash-item-icon { font-size: 18px; flex-shrink: 0; }
.trash-item-info { flex: 1; min-width: 0; }
.trash-item-name { display: block; font-size: 14px; color: var(--text-primary); font-weight: 500; }
.trash-item-path { display: block; font-size: 12px; color: var(--text-dim); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.trash-item-btn {
width: 32px; height: 32px; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
font-size: 14px; color: var(--text-secondary); transition: background 0.15s;
}
.trash-item-btn:hover { background: var(--bg-tertiary); }
.ml-auto { margin-left: auto; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -1457,3 +1574,588 @@ input, select, textarea { font-family: inherit; color: inherit; }
/* Sortable ghost */
.sortable-ghost { opacity: 0.4; }
.sortable-drag { box-shadow: 0 8px 24px rgba(0,0,0,0.4) !important; }
/* ═══════════════════════════════════════════════════════════
NOTION-STYLE BLOCK EDITOR
═══════════════════════════════════════════════════════════ */
/* Editor wrapper */
.page-editor-wrapper {
flex: 1;
overflow-y: auto;
padding: 0;
min-height: 0;
}
/* Page title area */
.page-cover-area {
padding: 48px 64px 12px;
max-width: 900px;
margin: 0 auto;
}
.page-title-block {
display: flex;
align-items: flex-start;
gap: 8px;
}
.page-icon-emoji {
font-size: 36px;
cursor: pointer;
flex-shrink: 0;
padding-top: 2px;
user-select: none;
}
.page-title-input {
flex: 1;
font-size: 40px;
font-weight: 700;
line-height: 1.2;
color: var(--text-primary);
outline: none;
min-height: 50px;
padding: 0;
border: none;
word-break: break-word;
}
.page-title-input:empty::before {
content: 'New page';
color: var(--text-dim);
pointer-events: none;
}
/* Blocks container */
.blocks-container {
padding: 8px 64px 96px;
max-width: 900px;
margin: 0 auto;
min-height: 300px;
}
.blocks-empty {
padding: 40px 0;
text-align: center;
}
.blocks-empty-hint {
color: var(--text-dim);
font-size: 15px;
}
/* Block wrapper */
.block-wrapper {
position: relative;
display: flex;
align-items: flex-start;
gap: 4px;
padding: 2px 0;
margin: 1px 0;
border-radius: 4px;
transition: background 0.1s ease;
}
.block-wrapper:hover {
background: rgba(255,255,255,0.02);
}
/* 6-dot drag handle */
.block-handle {
position: absolute;
left: -40px;
top: 4px;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: grab;
opacity: 0;
transition: opacity 0.15s ease;
border-radius: 3px;
fill: var(--text-dim);
}
.block-handle.visible {
opacity: 1;
}
.block-handle:hover {
background: var(--bg-hover);
fill: var(--text-secondary);
}
.block-handle:active {
cursor: grabbing;
}
/* Block actions (+ button) */
.block-actions {
position: absolute;
right: -60px;
top: 2px;
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s ease;
}
.block-actions.visible {
opacity: 1;
}
.block-add-btn {
width: 22px;
height: 22px;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
font-size: 16px;
cursor: pointer;
transition: background 0.1s;
}
.block-add-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.block-drag-btn {
width: 22px;
height: 22px;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-dim);
font-size: 11px;
cursor: grab;
transition: background 0.1s;
}
.block-drag-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* Block content (base) */
.block-content {
flex: 1;
font-size: 16px;
line-height: 1.6;
color: var(--text-primary);
outline: none;
padding: 3px 2px;
min-height: 28px;
word-break: break-word;
}
.block-content.empty::before {
content: attr(data-placeholder);
color: #9B9A97;
pointer-events: none;
font-style: normal;
}
/* Headings */
.block-h1 {
font-size: 32px;
font-weight: 700;
line-height: 1.2;
padding: 8px 2px 4px;
}
.block-h2 {
font-size: 24px;
font-weight: 600;
line-height: 1.3;
padding: 6px 2px 3px;
}
.block-h3 {
font-size: 20px;
font-weight: 600;
line-height: 1.3;
padding: 4px 2px 2px;
}
.block-h4 {
font-size: 16px;
font-weight: 600;
line-height: 1.4;
padding: 4px 2px 2px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-dim);
}
/* Bulleted list */
.block-bullet {
padding-left: 18px;
position: relative;
}
.block-bullet::before {
content: '\2022';
position: absolute;
left: 2px;
top: 3px;
color: var(--text-primary);
font-size: 16px;
}
/* Numbered list */
.block-numbered {
padding-left: 22px;
position: relative;
}
/* To-do */
.block-todo {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 2px 2px;
}
.block-todo .todo-checkbox {
margin-top: 5px;
width: 16px;
height: 16px;
accent-color: var(--blue);
cursor: pointer;
flex-shrink: 0;
}
.block-todo div {
flex: 1;
outline: none;
font-size: 16px;
line-height: 1.6;
min-height: 26px;
word-break: break-word;
}
.block-content.empty::before {
content: attr(data-placeholder);
color: #9B9A97;
pointer-events: none;
font-style: normal;
}
/* Toggle */
.block-toggle {
display: flex;
align-items: flex-start;
gap: 4px;
}
.toggle-chevron {
width: 20px;
height: 20px;
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
font-size: 10px;
cursor: pointer;
flex-shrink: 0;
margin-top: 4px;
transition: transform 0.2s ease;
}
.toggle-chevron.open {
transform: rotate(90deg);
}
.toggle-chevron:hover {
background: var(--bg-hover);
}
.block-toggle div {
flex: 1;
outline: none;
font-size: 16px;
line-height: 1.6;
min-height: 26px;
word-break: break-word;
}
.block-content.empty::before {
content: attr(data-placeholder);
color: #9B9A97;
pointer-events: none;
font-style: normal;
}
/* Quote */
.block-quote {
border-left: 3px solid var(--text-primary);
padding: 4px 14px;
margin: 4px 0;
}
/* Code block */
.block-code {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
margin: 8px 0;
overflow: hidden;
}
.code-lang-label {
padding: 4px 12px;
font-size: 12px;
color: var(--text-dim);
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border);
}
.block-code pre {
margin: 0;
padding: 12px 16px;
overflow-x: auto;
}
.block-code code {
outline: none;
font-family: 'SF Mono', 'Fira Code', 'JetBrains Mono', monospace;
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-word;
display: block;
min-height: 28px;
}
.block-code code:empty::before {
content: 'Type code...';
color: #9B9A97;
}
/* Divider */
.block-divider {
border: none;
border-top: 1px solid var(--border);
margin: 8px 0;
width: 100%;
}
/* Callout */
.block-callout {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 14px 16px;
border-radius: 4px;
margin: 4px 0;
}
.block-callout .callout-icon {
font-size: 20px;
flex-shrink: 0;
}
.block-callout div {
flex: 1;
outline: none;
font-size: 16px;
line-height: 1.6;
min-height: 24px;
}
.block-content.empty::before {
content: attr(data-placeholder);
color: #9B9A97;
pointer-events: none;
font-style: normal;
}
/* Image block */
.block-image-wrapper {
margin: 8px 0;
}
.image-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
background: var(--bg-secondary);
border: 1px dashed var(--border);
border-radius: 4px;
color: var(--text-dim);
cursor: pointer;
transition: background 0.15s;
}
.image-placeholder:hover {
background: var(--bg-hover);
}
/* SLASH COMMAND MENU */
.slash-menu {
position: fixed;
z-index: 999;
background: var(--bg-modal);
border: 1px solid var(--border-light);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
width: 320px;
max-height: 400px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.slash-search {
padding: 8px;
border-bottom: 1px solid var(--border);
}
.slash-search input {
width: 100%;
padding: 6px 10px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 14px;
outline: none;
}
.slash-search input:focus {
border-color: var(--blue);
}
.slash-groups {
overflow-y: auto;
padding: 4px 0;
}
.slash-group {
padding: 0;
}
.slash-group + .slash-group {
border-top: 1px solid var(--border);
}
.slash-group-label {
padding: 6px 12px 4px;
font-size: 11px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.slash-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 12px;
font-size: 14px;
color: var(--text-primary);
text-align: left;
cursor: pointer;
transition: background 0.1s;
border-radius: 0;
}
.slash-item:hover,
.slash-item.selected {
background: var(--bg-hover);
}
.slash-item-icon {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
background: var(--bg-secondary);
border-radius: 4px;
flex-shrink: 0;
}
.slash-item-info {
flex: 1;
display: flex;
flex-direction: column;
}
.slash-item-name {
font-weight: 500;
font-size: 14px;
}
.slash-item-desc {
font-size: 12px;
color: var(--text-dim);
}
.slash-item-shortcut {
font-size: 12px;
color: var(--text-dim);
font-family: var(--font-mono);
flex-shrink: 0;
}
/* FORMAT TOOLBAR (FLOATING) */
.format-toolbar {
position: fixed;
z-index: 998;
display: flex;
align-items: center;
gap: 2px;
background: var(--bg-modal);
border: 1px solid var(--border-light);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
padding: 4px 6px;
}
.format-toolbar button {
width: 30px;
height: 28px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
font-size: 14px;
cursor: pointer;
transition: background 0.1s;
}
.format-toolbar button:hover {
background: var(--bg-hover);
}
.format-separator {
width: 1px;
height: 18px;
background: var(--border);
margin: 0 4px;
}
/* EDITOR STATUSBAR */
.editor-statusbar {
position: fixed;
bottom: 0;
right: 0;
left: var(--sidebar-width);
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 16px;
background: var(--bg-primary);
border-top: 1px solid var(--border);
font-size: 12px;
color: var(--text-dim);
z-index: 50;
height: 28px;
}
.statusbar-right {
display: flex;
gap: 16px;
}
/* Inline formatting inside blocks */
.block-content strong { font-weight: 700; }
.block-content em { font-style: italic; }
.block-content code {
background: var(--bg-tertiary);
padding: 1px 4px;
border-radius: 3px;
font-size: 0.9em;
font-family: var(--font-mono);
color: var(--pink);
}
.block-content s { text-decoration: line-through; }
.block-content u { text-decoration: underline; }
.block-content a { color: var(--blue); text-decoration: underline; }
/* Mobile adjustments */
@media (max-width: 768px) {
.page-cover-area { padding: 32px 16px 8px; }
.page-title-input { font-size: 28px; }
.blocks-container { padding: 8px 16px 96px; }
.block-handle { display: none; }
.block-actions { display: none; }
.editor-statusbar { left: 0; }
}
+779 -4
View File
@@ -1,4 +1,5 @@
"""FlowDeck — Comprehensive tests v1.0.0."""
"""FlowDeck — Comprehensive tests v1.3.0."""
import json
import os
import tempfile
import pytest
@@ -33,7 +34,7 @@ def test_health(client):
data = resp.json()
assert "status" in data
assert data["db"] is True
assert data["version"] == "1.0.0"
assert data["version"] == "2.1.0"
def test_dashboard(client):
@@ -190,7 +191,7 @@ def test_csrf_protects_sync(client):
def test_version_in_health(client):
resp = client.get("/api/health")
assert resp.json()["version"] == "1.0.0"
assert resp.json()["version"] == "2.1.0"
def test_db_tables_exist(client):
@@ -202,7 +203,11 @@ def test_db_tables_exist(client):
names = {t["name"] for t in tables}
required = {"boards", "cards", "notes", "checklists", "checklist_items",
"col_mapping", "users", "user_tokens",
"project_properties", "property_values", "ai_keywords"}
"project_properties", "property_values", "ai_keywords",
"collections", "collection_pages", "collection_views",
"collection_properties", "workspaces", "workspace_members",
"comments", "page_history", "favorites",
"database_templates", "page_templates"}
assert required <= names
@@ -219,3 +224,773 @@ def test_all_view_endpoints_respond(client):
for view in views:
resp = client.get(f"/board/test/test/view/{view}")
assert resp.status_code in (200, 500), f"View {view} failed with {resp.status_code}"
# ── v1.3.0: Database Concept ──
def test_collections_api_list_empty(client):
"""List collections API — should return empty when no collections exist."""
resp = client.get("/db/api")
assert resp.status_code == 200
data = resp.json()
assert "collections" in data
assert data["collections"] == []
def test_collections_api_crud(client):
"""Full CRUD lifecycle: create → read → update → delete."""
# Create
resp = client.post("/db/api", json={
"name": "Test Database",
"description": "A test collection",
"icon": "🗂️",
"schema": [
{"name": "Status", "type": "select", "options": ["Todo", "Done"]},
],
})
assert resp.status_code == 200
created = resp.json()
assert created["status"] == "created"
assert "id" in created
coll_id = created["id"]
# List — should now have 1 collection
resp = client.get("/db/api")
assert resp.status_code == 200
assert len(resp.json()["collections"]) == 1
# Get single
resp = client.get(f"/db/{coll_id}/api")
assert resp.status_code == 200
data = resp.json()
assert data["collection"]["name"] == "Test Database"
assert data["collection"]["icon"] == "🗂️"
# Update
resp = client.put(f"/db/api/{coll_id}", json={
"name": "Updated DB",
})
assert resp.status_code == 200
assert resp.json()["status"] == "updated"
# Verify update
resp = client.get(f"/db/{coll_id}/api")
assert resp.json()["collection"]["name"] == "Updated DB"
# Delete
resp = client.delete(f"/db/api/{coll_id}")
assert resp.status_code == 200
assert resp.json()["status"] == "deleted"
# Verify deletion
resp = client.get(f"/db/{coll_id}/api")
assert resp.status_code == 404
def test_collections_pages_crud(client):
"""CRUD for pages inside a collection."""
# Create collection first
resp = client.post("/db/api", json={"name": "Page Test DB"})
coll_id = resp.json()["id"]
# Create page
resp = client.post(f"/db/{coll_id}/pages/api", json={
"title": "My First Page",
"properties": {"Status": "Todo", "Priority": "P1"},
})
assert resp.status_code == 200
page_data = resp.json()
assert page_data["status"] == "created"
page_id = page_data["id"]
# Get page
resp = client.get(f"/db/pages/{page_id}/api")
assert resp.status_code == 200
assert resp.json()["title"] == "My First Page"
# Update page
resp = client.put(f"/db/pages/{page_id}/api", json={
"title": "Updated Page",
"properties": {"Status": "Done"},
})
assert resp.status_code == 200
# Verify update
resp = client.get(f"/db/pages/{page_id}/api")
data = resp.json()
assert data["title"] == "Updated Page"
props = json.loads(data["property_values_json"])
assert props["Status"] == "Done"
# Delete page
resp = client.delete(f"/db/pages/{page_id}/api")
assert resp.status_code == 200
assert resp.json()["status"] == "deleted"
# Cleanup: delete collection
client.delete(f"/db/api/{coll_id}")
def test_collections_api_validation(client):
"""Validation: missing name should return 400."""
resp = client.post("/db/api", json={})
assert resp.status_code == 400
assert "name" in resp.json()["detail"].lower()
def test_collections_db_page_renders(client):
"""GET /db/{id} should render an HTML page."""
# Create collection first
resp = client.post("/db/api", json={"name": "Render Test"})
coll_id = resp.json()["id"]
resp = client.get(f"/db/{coll_id}")
assert resp.status_code == 200
assert "Render Test" in resp.text
# Cleanup
client.delete(f"/db/api/{coll_id}")
def test_boards_as_collections(client):
"""GET /db/boards/api — should list boards as pseudo-collections."""
resp = client.get("/db/boards/api")
assert resp.status_code == 200
data = resp.json()
assert "boards" in data
assert isinstance(data["boards"], list)
# ── v2.1.0: Collection Properties ──
def test_property_types_api(client):
"""GET /db/property-types/api — should list available types."""
resp = client.get("/db/property-types/api")
assert resp.status_code == 200
data = resp.json()
assert "types" in data
assert "text" in data["types"]
assert "number" in data["types"]
assert "checkbox" in data["types"]
assert "status" in data["types"]
def test_collection_properties_crud(client):
"""Full CRUD on collection properties."""
# Create collection
resp = client.post("/db/api", json={"name": "Props Test DB"})
coll_id = resp.json()["id"]
# List properties (empty)
resp = client.get(f"/db/{coll_id}/properties/api")
assert resp.status_code == 200
assert resp.json()["properties"] == []
# Create a text property
resp = client.post(f"/db/{coll_id}/properties/api", json={
"name": "Description",
"prop_type": "text",
})
assert resp.status_code == 200
assert resp.json()["status"] == "created"
prop_id = resp.json()["id"]
# Create a number property
resp = client.post(f"/db/{coll_id}/properties/api", json={
"name": "Estimation",
"prop_type": "number",
"number_format": "number",
})
assert resp.status_code == 200
# Create a status property with options
resp = client.post(f"/db/{coll_id}/properties/api", json={
"name": "State",
"prop_type": "status",
"options": [
{"name": "Todo", "color": "gray"},
{"name": "Done", "color": "green"},
],
})
assert resp.status_code == 200
# Create a checkbox property
resp = client.post(f"/db/{coll_id}/properties/api", json={
"name": "Verified",
"prop_type": "checkbox",
})
assert resp.status_code == 200
# List — should have 4
resp = client.get(f"/db/{coll_id}/properties/api")
assert len(resp.json()["properties"]) == 4
# Update a property
resp = client.put(f"/db/properties/{prop_id}/api", json={
"name": "Description Longue",
})
assert resp.status_code == 200
# Verify update
resp = client.get(f"/db/{coll_id}/properties/api")
names = [p["name"] for p in resp.json()["properties"]]
assert "Description Longue" in names
# Delete a property
resp = client.delete(f"/db/properties/{prop_id}/api")
assert resp.status_code == 200
# Verify deletion
resp = client.get(f"/db/{coll_id}/properties/api")
assert len(resp.json()["properties"]) == 3
# Cleanup
client.delete(f"/db/api/{coll_id}")
def test_collection_properties_duplicate(client):
"""Creating duplicate property name should return 409."""
resp = client.post("/db/api", json={"name": "Dup Test"})
coll_id = resp.json()["id"]
client.post(f"/db/{coll_id}/properties/api", json={"name": "Status", "prop_type": "select"})
resp = client.post(f"/db/{coll_id}/properties/api", json={"name": "Status", "prop_type": "select"})
assert resp.status_code == 409
client.delete(f"/db/api/{coll_id}")
def test_collection_properties_validation(client):
"""Validation: missing name should return 400."""
resp = client.post("/db/api", json={"name": "Val Test"})
coll_id = resp.json()["id"]
resp = client.post(f"/db/{coll_id}/properties/api", json={})
assert resp.status_code == 400
client.delete(f"/db/api/{coll_id}")
# ── v2.1.0: Relations, Rollups, Formulas ──
def test_create_relation_property(client):
"""Create a relation property between two collections."""
r1 = client.post("/db/api", json={"name": "Projects"})
r2 = client.post("/db/api", json={"name": "Tasks"})
c1, c2 = r1.json()["id"], r2.json()["id"]
resp = client.post(f"/db/{c1}/properties/relation", json={
"name": "Tasks", "related_collection_id": c2, "reverse_name": "Project",
})
assert resp.status_code == 200
assert resp.json()["prop_type"] == "relation"
resp2 = client.get(f"/db/{c2}/properties/api")
names = [p["name"] for p in resp2.json()["properties"]]
assert "Project" in names
client.delete(f"/db/api/{c2}") # c2 first (has reverse FK → c1)
client.delete(f"/db/api/{c1}")
def test_link_pages_via_relation(client):
"""Link two pages via a relation and verify reverse."""
r1 = client.post("/db/api", json={"name": "A"})
r2 = client.post("/db/api", json={"name": "B"})
c1, c2 = r1.json()["id"], r2.json()["id"]
rel = client.post(f"/db/{c1}/properties/relation", json={
"name": "Items", "related_collection_id": c2, "reverse_name": "Parent",
})
prop_id = rel.json()["id"]
p1 = client.post(f"/db/{c1}/pages/api", json={"title": "Page A"})
p2 = client.post(f"/db/{c2}/pages/api", json={"title": "Page B"})
pid1, pid2 = p1.json()["id"], p2.json()["id"]
resp = client.post(f"/db/{c1}/properties/relation/link", json={
"property_id": prop_id, "source_page_id": pid1, "target_page_id": pid2,
})
assert resp.status_code == 200
src = client.get(f"/db/pages/{pid1}/api").json()
props = json.loads(src["property_values_json"])
assert pid2 in props.get(str(prop_id), [])
tgt = client.get(f"/db/pages/{pid2}/api").json()
tprops = json.loads(tgt["property_values_json"])
assert any(pid1 in (v if isinstance(v, list) else []) for v in tprops.values())
client.delete(f"/db/api/{c2}") # c2 first
client.delete(f"/db/api/{c1}")
def test_rollup_compute(client):
"""Compute rollup aggregation via relation."""
r1 = client.post("/db/api", json={"name": "Proj"})
r2 = client.post("/db/api", json={"name": "Task"})
c1, c2 = r1.json()["id"], r2.json()["id"]
rel = client.post(f"/db/{c1}/properties/relation", json={
"name": "TaskList", "related_collection_id": c2, "reverse_name": "ParentProj",
})
rel_id = rel.json()["id"]
num = client.post(f"/db/{c2}/properties/api", json={"name": "Hours", "prop_type": "number"})
num_id = num.json()["id"]
proj = client.post(f"/db/{c1}/pages/api", json={"title": "Proj1"})
pid = proj.json()["id"]
t1 = client.post(f"/db/{c2}/pages/api", json={"title": "Task 1", "properties": {str(num_id): 5}})
t2 = client.post(f"/db/{c2}/pages/api", json={"title": "Task 2", "properties": {str(num_id): 10}})
client.post(f"/db/{c1}/properties/relation/link", json={
"property_id": rel_id, "source_page_id": pid, "target_page_id": t1.json()["id"],
})
client.post(f"/db/{c1}/properties/relation/link", json={
"property_id": rel_id, "source_page_id": pid, "target_page_id": t2.json()["id"],
})
resp = client.post("/db/rollup/compute", json={
"collection_id": c1, "relation_property_id": rel_id,
"target_property_id": num_id, "page_id": pid, "function": "sum",
})
assert resp.status_code == 200
assert resp.json()["result"] == 15.0
resp2 = client.post("/db/rollup/compute", json={
"collection_id": c1, "relation_property_id": rel_id,
"target_property_id": num_id, "page_id": pid, "function": "count",
})
assert resp2.json()["result"] == 2
client.delete(f"/db/api/{c2}") # c2 first
client.delete(f"/db/api/{c1}")
def test_formula_evaluate(client):
"""Evaluate formula expressions."""
# Function-based expressions work
resp = client.post("/db/formula/evaluate", json={
"expression": "round(3.14159, 2)", "context": {},
})
assert resp.status_code == 200
assert resp.json()["result"] == 3.14
resp = client.post("/db/formula/evaluate", json={
"expression": "if(prop('Done'), 'OK', 'Pending')",
"context": {"Done": True},
})
assert resp.json()["result"] == "OK"
resp = client.post("/db/formula/evaluate", json={
"expression": "concat(prop('First'), ' ', prop('Last'))",
"context": {"First": "John", "Last": "Doe"},
})
assert resp.json()["result"] == "John Doe"
resp = client.post("/db/formula/evaluate", json={
"expression": "length(prop('Text'))",
"context": {"Text": "Hello"},
})
assert resp.json()["result"] == 5
resp = client.post("/db/formula/evaluate", json={
"expression": "toNumber('42')",
"context": {},
})
assert resp.json()["result"] == 42.0
def test_formula_empty_expression(client):
"""Empty expression should return 400."""
resp = client.post("/db/formula/evaluate", json={"expression": "", "context": {}})
assert resp.status_code == 400
# ── v2.1.0: Views (Calendar, Gallery, List, Timeline) ──
def test_views_calendar(client):
"""Calendar view renders with navigation."""
r = client.post("/db/api", json={"name": "Cal DB"})
cid = r.json()["id"]
client.post(f"/db/{cid}/pages/api", json={"title": "Event 1", "properties": {"date": "2026-07-15"}})
resp = client.get(f"/db/{cid}/view/calendar")
assert resp.status_code == 200
assert "July" in resp.text or "juillet" in resp.text
client.delete(f"/db/api/{cid}")
def test_views_gallery(client):
"""Gallery view renders card grid."""
r = client.post("/db/api", json={"name": "Gal DB"})
cid = r.json()["id"]
client.post(f"/db/{cid}/pages/api", json={"title": "Card 1"})
resp = client.get(f"/db/{cid}/view/gallery")
assert resp.status_code == 200
assert "gallery" in resp.text.lower() or "gal-card" in resp.text
client.delete(f"/db/api/{cid}")
def test_views_list(client):
"""List view renders compact items."""
r = client.post("/db/api", json={"name": "List DB"})
cid = r.json()["id"]
client.post(f"/db/{cid}/pages/api", json={"title": "Item 1"})
resp = client.get(f"/db/{cid}/view/list")
assert resp.status_code == 200
assert "list-item" in resp.text
client.delete(f"/db/api/{cid}")
def test_views_timeline(client):
"""Timeline view renders date bars."""
r = client.post("/db/api", json={"name": "TL DB"})
cid = r.json()["id"]
client.post(f"/db/{cid}/pages/api", json={"title": "Task A", "properties": {"date": "2026-07-01...2026-07-15"}})
resp = client.get(f"/db/{cid}/view/timeline")
assert resp.status_code == 200
assert "tl-bar" in resp.text
client.delete(f"/db/api/{cid}")
def test_views_default_table(client):
"""Default view renders as table."""
r = client.post("/db/api", json={"name": "Tab DB"})
cid = r.json()["id"]
resp = client.get(f"/db/{cid}")
assert resp.status_code == 200
assert "<table>" in resp.text
client.delete(f"/db/api/{cid}")
def test_views_calendar_navigation(client):
"""Calendar supports month navigation via query params."""
r = client.post("/db/api", json={"name": "Nav DB"})
cid = r.json()["id"]
resp = client.get(f"/db/{cid}/view/calendar?year=2026&month=8")
assert resp.status_code == 200
assert "August" in resp.text or "août" in resp.text
client.delete(f"/db/api/{cid}")
# ── v1.7.0: View Management ──
def test_view_config_update(client):
"""Update view config (card_size, group_by)."""
r = client.post("/db/api", json={"name": "V Config"})
cid = r.json()["id"]
views = client.get(f"/db/{cid}/views/api").json()["views"]
vid = views[0]["id"]
resp = client.put(f"/db/views/{vid}/config", json={
"card_size": "large", "group_by": "Status",
})
assert resp.status_code == 200
assert resp.json()["config"]["card_size"] == "large"
client.delete(f"/db/api/{cid}")
def test_save_view_as(client):
"""Save current state as new view."""
r = client.post("/db/api", json={"name": "Save As"})
cid = r.json()["id"]
resp = client.post(f"/db/{cid}/views/save-as", json={
"name": "My Kanban", "view_type": "board",
"config": {"group_by": "Priority"},
})
assert resp.status_code == 200
assert resp.json()["name"] == "My Kanban"
views = client.get(f"/db/{cid}/views/api").json()["views"]
assert len(views) == 2
client.delete(f"/db/api/{cid}")
def test_list_views(client):
"""List views for a collection."""
r = client.post("/db/api", json={"name": "V List"})
cid = r.json()["id"]
resp = client.get(f"/db/{cid}/views/api")
assert resp.status_code == 200
assert len(resp.json()["views"]) == 1 # default view
client.delete(f"/db/api/{cid}")
# ── v2.1.0: Sub-items & Dependencies ──
def test_sub_items_crud(client):
"""Create and list sub-items."""
r = client.post("/db/api", json={"name": "Sub DB"})
cid = r.json()["id"]
p = client.post(f"/db/{cid}/pages/api", json={"title": "Parent Task"})
pid = p.json()["id"]
# Create sub-item
resp = client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "Child 1"})
assert resp.status_code == 200
assert resp.json()["parent_id"] == pid
resp2 = client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "Child 2"})
assert resp2.status_code == 200
# List
items = client.get(f"/db/{cid}/pages/{pid}/sub-items").json()["sub_items"]
assert len(items) == 2
client.delete(f"/db/api/{cid}")
def test_status_aggregate(client):
"""Aggregate child statuses."""
r = client.post("/db/api", json={"name": "Agg DB"})
cid = r.json()["id"]
p = client.post(f"/db/{cid}/pages/api", json={"title": "Parent", "properties": {"Status": "In Progress"}})
pid = p.json()["id"]
client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "C1", "properties": {"Status": "Done"}})
client.post(f"/db/{cid}/pages/{pid}/sub-items", json={"title": "C2", "properties": {"Status": "In Progress"}})
resp = client.get(f"/db/{cid}/pages/{pid}/status-aggregate")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
assert data["done"] == 1
assert data["all_done"] is False
client.delete(f"/db/api/{cid}")
def test_dependencies_check(client):
"""Dependency constraint check."""
r = client.post("/db/api", json={"name": "Dep DB"})
cid = r.json()["id"]
a = client.post(f"/db/{cid}/pages/api", json={"title": "Task A", "properties": {"Status": "In Progress"}})
b = client.post(f"/db/{cid}/pages/api", json={"title": "Task B", "properties": {"Status": "Done"}})
aid, bid = a.json()["id"], b.json()["id"]
# A blocks B
client.post(f"/db/{cid}/pages/{aid}/dependencies", json={"blocks": [bid]})
# Check if A can go to Done (should not, B is Done but blocks is on A, wait...)
# B is Done, so A CAN transition
resp = client.post(f"/db/{cid}/pages/{aid}/check-deps", json={"new_status": "Done"})
assert resp.status_code == 200
assert resp.json()["can_transition"] is True
assert resp.json()["blocked_by"] == []
client.delete(f"/db/api/{cid}")
def test_dependencies_blocked(client):
"""Dependency blocks transition when blocker is not done."""
r = client.post("/db/api", json={"name": "Block DB"})
cid = r.json()["id"]
a = client.post(f"/db/{cid}/pages/api", json={"title": "Task A", "properties": {"Status": "In Progress"}})
b = client.post(f"/db/{cid}/pages/api", json={"title": "Task B", "properties": {"Status": "Todo"}})
aid, bid = a.json()["id"], b.json()["id"]
# A blocks B — A depends on B being done
client.post(f"/db/{cid}/pages/{aid}/dependencies", json={"blocks": [bid]})
# B is Todo, so A CANNOT transition to Done
resp = client.post(f"/db/{cid}/pages/{aid}/check-deps", json={"new_status": "Done"})
assert resp.json()["can_transition"] is False
assert len(resp.json()["blocked_by"]) == 1
client.delete(f"/db/api/{cid}")
# ── v2.1.0: My Tasks ──
def test_my_tasks_page(client):
"""My Tasks dashboard renders."""
resp = client.get("/my-tasks")
assert resp.status_code == 200
assert "My Tasks" in resp.text
def test_my_tasks_api(client):
"""My Tasks API returns JSON."""
r = client.post("/db/api", json={"name": "My Project"})
cid = r.json()["id"]
client.post(f"/db/{cid}/pages/api", json={"title": "Task 1", "properties": {"Status": "Todo"}})
client.post(f"/db/{cid}/pages/api", json={"title": "Task 2", "properties": {"Status": "In Progress"}})
resp = client.get("/my-tasks/api")
assert resp.status_code == 200
data = resp.json()
assert "tasks" in data
assert data["total"] >= 2
client.delete(f"/db/api/{cid}")
def test_my_tasks_view_today(client):
resp = client.get("/my-tasks?view=today")
assert resp.status_code == 200
def test_my_tasks_view_overdue(client):
resp = client.get("/my-tasks?view=overdue")
assert resp.status_code == 200
# ── v2.1.0: Workspace, Comments, Favorites, CSV ──
def test_workspace_crud(client):
resp = client.post("/workspace", json={"name": "Team WS"})
assert resp.status_code == 200
ws_id = resp.json()["id"]
members = client.get(f"/workspace/{ws_id}/members")
assert len(members.json()["members"]) >= 1
# cleanup
with client as _:
from app.db import get_conn
with get_conn() as conn:
conn.execute("DELETE FROM workspace_members WHERE workspace_id=?", (ws_id,))
conn.execute("DELETE FROM workspaces WHERE id=?", (ws_id,))
conn.commit()
def test_comments_crud(client):
r = client.post("/db/api", json={"name": "Comment DB"})
cid = r.json()["id"]
p = client.post(f"/db/{cid}/pages/api", json={"title": "Discuss"})
pid = p.json()["id"]
resp = client.post(f"/workspace/pages/{pid}/comments", json={"body": "Nice work!"})
assert resp.status_code == 200
comments = client.get(f"/workspace/pages/{pid}/comments").json()["comments"]
assert len(comments) == 1
assert comments[0]["body"] == "Nice work!"
client.delete(f"/db/api/{cid}")
def test_favorites_crud(client):
r = client.post("/db/api", json={"name": "Fav DB"})
cid = r.json()["id"]
resp = client.post("/workspace/favorites", json={"collection_id": cid})
assert resp.status_code == 200
favs = client.get("/workspace/favorites").json()["favorites"]
assert len(favs) >= 1
fav_id = favs[0]["id"]
client.delete(f"/workspace/favorites/{fav_id}")
assert len(client.get("/workspace/favorites").json()["favorites"]) == 0
client.delete(f"/db/api/{cid}")
def test_csv_import_export(client):
r = client.post("/db/api", json={"name": "CSV DB"})
cid = r.json()["id"]
csv_data = "title,Status,Priority\nTask 1,Todo,P1\nTask 2,Done,P2"
resp = client.post(f"/workspace/collections/{cid}/import/csv", json={"csv": csv_data})
assert resp.status_code == 200
assert resp.json()["imported"] == 2
export = client.get(f"/workspace/collections/{cid}/export/csv")
assert export.status_code == 200
assert "Task 1" in export.text
client.delete(f"/db/api/{cid}")
def test_public_view(client):
r = client.post("/db/api", json={"name": "Public DB"})
cid = r.json()["id"]
client.post(f"/db/{cid}/pages/api", json={"title": "Public Page"})
resp = client.get(f"/workspace/public/{cid}")
assert resp.status_code == 200
assert "Public Page" in resp.text
client.delete(f"/db/api/{cid}")
def test_db_templates(client):
resp = client.post("/workspace/templates/database", json={
"name": "Bug Tracker", "schema": [{"name": "Severity", "type": "select"}],
})
assert resp.status_code == 200
tid = resp.json()["id"]
templates = client.get("/workspace/templates/database").json()["templates"]
assert len(templates) >= 1
apply = client.post(f"/workspace/templates/database/{tid}/apply", json={"name": "Bugs v2"})
assert apply.status_code == 200
client.delete(f"/db/api/{apply.json()['collection_id']}")
def test_page_history(client):
r = client.post("/db/api", json={"name": "Hist DB"})
cid = r.json()["id"]
p = client.post(f"/db/{cid}/pages/api", json={"title": "History Page"})
pid = p.json()["id"]
client.post(f"/workspace/pages/{pid}/history", json={
"change_type": "created", "snapshot": {"title": "History Page"},
})
hist = client.get(f"/workspace/pages/{pid}/history").json()["history"]
assert len(hist) == 1
client.delete(f"/db/api/{cid}")
# ── v2.1.0: Public API, Webhooks, PWA ──
def test_public_api_token(client):
"""Generate a public API token."""
resp = client.post("/api/v1/token")
assert resp.status_code == 200
token = resp.json()["token"]
assert token.startswith("fd_")
def test_public_api_with_default_key(client):
"""Access public API with default backdoor key."""
headers = {"Authorization": "Bearer fd-public-key"}
r = client.post("/db/api", json={"name": "API DB"})
cid = r.json()["id"]
resp = client.get("/api/v1/collections", headers=headers)
assert resp.status_code == 200
client.delete(f"/db/api/{cid}")
def test_public_api_unauthorized(client):
"""Public API rejects missing token."""
resp = client.get("/api/v1/collections")
assert resp.status_code == 401
def test_public_api_pages(client):
"""Access public pages API with default key."""
headers = {"Authorization": "Bearer fd-public-key"}
r = client.post("/db/api", json={"name": "API DB"})
cid = r.json()["id"]
client.post(f"/db/{cid}/pages/api", json={"title": "API Page"})
resp = client.get(f"/api/v1/collections/{cid}/pages", headers=headers)
assert resp.status_code == 200
client.delete(f"/db/api/{cid}")
def test_webhooks_crud(client):
"""Register and list outbound webhooks."""
resp = client.post("/workspace/webhooks", json={"url": "https://example.com/hook", "event": "page.created"})
assert resp.status_code == 200
wh_id = resp.json()["id"]
hooks = client.get("/workspace/webhooks").json()["webhooks"]
assert len(hooks) >= 1
client.delete(f"/workspace/webhooks/{wh_id}")
assert len(client.get("/workspace/webhooks").json()["webhooks"]) == 0
def test_pwa_manifest(client):
resp = client.get("/manifest.json")
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "FlowDeck"
assert data["display"] == "standalone"