diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff2b9a..10287ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog - FlowDeck +## v6.5.0 (2026-09-24) — Synced blocks production (databases & vues) + +> Les synced blocks passent en production et existent enfin dans les databases : +> chaque ligne de database gagne une page contenu (éditeur complet), la lecture +> résout la source côté serveur (zéro cache périmé) et la mise à jour d'une +> source réécrit réellement toutes les pages référentes. + +### Added + +- **Page contenu par ligne de database** — migration 22 (`pages.collection_row_id INTEGER REFERENCES collection_pages(id) ON DELETE CASCADE` + index) ; `app/services/row_pages.py` : `ensure_row_page()` (création lazy, réparation si trashée, workspace hérité de la page hôte, `collection_id` hérité pour les ACL) ; sync de titre bidirectionnelle ligne ↔ page +- **Endpoint `GET /db/pages/{row_id}/open/api`** — renvoie le `page_id` de contenu d'une ligne (garde `_require_view` collection) ; le peek de toute vue DB (table/board/gallery/list/calendar) passe par là +- **Résolution serveur à chaque lecture** — `resolve_content_json()` sur les 2 routes `/pages/{id}` (dashboard + board), `GET /api/pages/{id}/content`, `GET /api/local-workspace/page-content/{id}`, page publique `/p/{slug}`, `GET /api/v2/pages/{id}` et les rooms realtime (`load_room`) +- **Rendu public des synced blocks** — branche `synced` dans `_render_blocks_public` (avant : JSON brut en `
`) + état « Deleted synced block » +- **Éditeur** — état « Deleted synced block » quand la source a été supprimée (au lieu du cache fantôme / « Loading… » éternel) +- **API v2** — `content_page_id` sur `GET /api/v2/pages/{id}` pour une ligne (lecture sans création lazy) + +### Fixed + +- **Propagation des synced blocks** — `PUT /api/synced-blocks/{id}` réécrit le contenu stocké de chaque page référente via `sync_synced_blocks_in_page()` (fonction morte depuis v5.14.0), récursif dans les `children` (colonnes/toggles), broadcast WS seulement après réécriture DB +- **Suppression d'une source** — ids des pages référentes collectés avant la cascade FK, `_synced_deleted` marqué dans le stocké (`mark_synced_block_deleted`), broadcast dédié `manager._broadcast_synced_to()` +- **Ouverture des lignes de database** — le peek appelait `/pages/{rowId}` qui lit la table `pages` : avec des ids croisés il ouvrait une page au hasard (ligne id 5 « Ask AI » → page id 5 « Données ») ; désormais résolution via l'endpoint dédié +- **Listings** — les pages contenu (`parent_section='DbRow'`) sont exclues (`AND collection_row_id IS NULL`) de l'arbre sidebar, des racines workspace, de la liste des pages du board, des projets builtins, de l'arbre Library et du `page_count` des workspaces + +### Tests + +- `tests/test_v65_synced_db.py` — **17 tests** ; suite complète **742 verts** (`pytest -n auto`) ; `ruff check app tests` OK ; `eslint static/js` 0 problème + ## v6.4.0 (2026-09-22) — Realtime editing (production) > Le realtime passe en mode production : résolution de conflits au-delà du last-write-wins (merge à trois versions) et édition à grande échelle (broadcast non bloquant, coalescence des curseurs, corrections de fuites, observabilité). diff --git a/ROADMAP.md b/ROADMAP.md index d288847..75cb859 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -800,7 +800,7 @@ Détails livrés : - [x] `GET/POST/PATCH/DELETE /api/v2/webhooks`, `POST /api/v2/webhooks/{id}/test` (ping) - [x] `GET /api/v2/webhooks/{id}/deliveries` (journal basique) -- [ ] *(reporté v6.4)* : signature HMAC `X-FlowDeck-Signature`, retry 2s/10s/60s, +20 events +- [x] *(reporté v6.4, livré plus tôt)* **v5.15.0** : signature HMAC `X-FlowDeck-Signature`, retry 2s/10s/60s, +20 events (Webhooks v2) #### Phase 7 — Forges & ressources restantes ✅ @@ -845,7 +845,36 @@ Détails livrés : - `app/templates/_page_editor_realtime.html` — envoi de `base`, adoption du bloc fusionné, toast de conflit - **26 tests** `tests/test_realtime_v64.py` (merge purs, protocole WS, convergence 2 clients, rétro-compat LWW, fuite 4404, coalescence, stats, anti-flood) ; **14 tests** `tests/test_realtime.py` préservés - `ruff check app tests` OK · `eslint static/js` 0 problème -- **Version** — 6.4.0 +- [x] **Version** — 6.4.0 + +--- + +## v6.5.0 — Synced blocks production (databases & vues) ✅ (2026-09-24) +> **Objectif** : passer les synced blocks en « production » (résolution + +> propagation fiables partout) et les faire vivre dans les databases/vues — +> le « reste en v6 » du point **v5.14.0**. **COMPLETED**. + +#### Contenu des lignes de database (le point « databases ») +- [x] **Migration 22** — `pages.collection_row_id INTEGER REFERENCES collection_pages(id) ON DELETE CASCADE` + index partiel ; chaque ligne de database gagne une **page contenu** (page `blocks` standard) portant son éditeur complet +- [x] **Service `app/services/row_pages.py`** — `ensure_row_page()` : création lazy/réparation (page trashée restaurée, `parent_section='DbRow'`), workspace hérité de la page hôte (DB full-page → page parente inline → `collections.gitea_owner/repo`), `collection_id` hérité pour que les ACL de collection s'appliquent au contenu +- [x] **Endpoint `GET /db/pages/{row_id}/open/api`** — renvoie `{page_id}` (garde `_require_view` de la collection) ; utilisé par le peek de **toute** vue (table/board/gallery/list/calendar) +- [x] **Fix bug d'ouverture de ligne** — le peek appelait `/pages/{rowId}` qui lit la table `pages` → avec des ids croisés il ouvrait une **page au hasard** (ligne id 5 « Ask AI » → page id 5 « Données ») ; désormais résolution via l'endpoint puis iframe `/pages/{pageId}` +- [x] **Titres synchronisés bidirectionnellement** — renommer la ligne (`PUT /db/pages/{id}/api`) → page contenu ; renommer dans l'éditeur (`PUT /board/api/pages/{id}`, `POST .../blocks`, `PUT /api/pages/{id}/rename`) → ligne +- [x] **Cascade** — supprimer une ligne supprime sa page contenu (FK `ON DELETE CASCADE`, refs `page_synced_blocks` en cascade) +- [x] **Exclusions des listings** — `AND collection_row_id IS NULL` sur : arbre sidebar (`_build_page_tree`), racines workspace (`_load_workspace_pages`), liste des pages du board, projets builtins, arbre Library (`_build_tree_children` + API tree), `page_count` des workspaces ; `parent_section='DbRow'` les tient hors des sections Private/Trash + +#### Résolution serveur + propagation (le point « production ») +- [x] **Résolution à chaque lecture** — `resolve_content_json()` (`app/services/synced_blocks.py`) appliqué sur : les 2 routes de rendu `/pages/{id}` (dashboard + board, dual-route), `GET /api/pages/{id}/content`, `GET /api/local-workspace/page-content/{id}`, page publique `/p/{slug}`, `GET /api/v2/pages/{id}`, et les rooms realtime (`load_room`) → le cache `_synced_content` périmé n'est plus jamais servi +- [x] **Propagation écrite réelle** — `PUT /api/synced-blocks/{id}` réécrit le contenu stocké de **chaque** page référente (`sync_synced_blocks_in_page`, jusqu'ici jamais appelé = code mort depuis v5.14.0) **avant** le broadcast WS ; récursif dans les `children` (colonnes, toggles) +- [x] **Suppression de source** — ids des pages référentes collectés **avant** la cascade FK, `mark_synced_block_deleted()` marque `_synced_deleted` dans le stocké, broadcast dédié `manager._broadcast_synced_to()` +- [x] **État « deleted »** — `resolve_synced_block()` marque `_synced_deleted` (source absente) ; rendu dédié dans l'éditeur (« Deleted synced block » au lieu d'un cache fantôme / « Loading… » éternel) +- [x] **Rendu public des synced blocks** — branche `synced` ajoutée à `_render_blocks_public` (avant : JSON brut en `
`) + état deleted
+- [x] **API v2** — `content_page_id` sur `GET /api/v2/pages/{id}` pour une ligne (sans création lazy en lecture)
+
+#### Tests
+- [x] **`tests/test_v65_synced_db.py` — 17 tests** (migration, création/idempotence/cascade/restauration de la page contenu, sync des titres, exclusions de listings, résolution lecture éditeur/content/public, propagation écrite + récursivité + deleted, synced block dans une ligne de DB de bout en bout, `content_page_id` v2)
+- [x] Suite complète **742 verts** (`pytest -n auto`) · `ruff check app tests` OK · `eslint static/js` 0 problème
+- [x] **Version** — 6.5.0 (VERSION + `app/main.py`)
---
@@ -857,7 +886,7 @@ Détails livrés :
- [x] **API publique complète** — REST API documentée (OpenAPI) ✅ (livré v6.3.0) — [📄 API Guide v2](/docs/API_GUIDE_V6.md) · [📄 OpenAPI](/docs/openapi-v2.json)
- [ ] **SSO/SAML** — enterprise authentication — [📄 Conception détaillée](/docs/V6_SSO_SAML_Enterprise_Auth.md)
- [x] **Realtime editing (production)** ✅ livré **v6.4.0** (merge 3-voix au-delà du LWW, broadcast non bloquant) ; voir **v5.13.0** pour le socle (curseurs + présence)
-- [ ] **Synced blocks (production)** — voir **v5.14.0** (bloc de base) ; reste en v6 : syncing côté databases/vues
+- [x] **Synced blocks (production)** ✅ livré **v6.5.0** (page contenu par ligne de database, résolution serveur à chaque lecture, propagation écrite réelle, état deleted, rendu public) ; socle : **v5.14.0** (bloc de base)
---
@@ -903,4 +932,4 @@ Quality DB views, Agent IA Palette → Realtime + E
DB avancée, redo, drag&drop, bookmark, avancée
Calendrier, AI duplicate) lightbox…) (Pt.2) v6.1 ✅ v6.2 ✅ v6.3 ✅
-*Dernière mise à jour: 2026-09-22 — **v6.4.0 Realtime editing (production) COMPLETED** (merge 3-voix au-delà du LWW via `realtime_merge.py`, broadcast non bloquant avec file sortante + writer, coalescence des curseurs, fix fuite de rooms 4404, anti-flood, `GET /api/realtime/stats`, 26 tests dédiés) + **v6.3.0 API publique complète v2 COMPLETED** (~100 endpoints `/api/v2`, scopes hiérarchiques `readPage not found
", status_code=404)
page = dict(row)
+ # v6.5.0: synced blocks resolve server-side at read time (fresh content
+ # even when the stored cache is stale).
+ from app.services.synced_blocks import resolve_content_json
+ page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format"))
ws = page.get("workspace", "")
parts = ws.split("/") if "/" in ws else ["", ""]
diff --git a/app/routers/collections.py b/app/routers/collections.py
index 4761a6e..c3cc453 100644
--- a/app/routers/collections.py
+++ b/app/routers/collections.py
@@ -499,6 +499,32 @@ async def get_page_api(request: Request, page_id: int):
return dict(page)
+@router.get("/pages/{page_id}/open/api")
+async def open_row_page_api(request: Request, page_id: int):
+ """v6.5.0 — content page of a database row (lazy-created).
+
+ Any DB view (table/board/gallery/list/calendar) opens a row through
+ this endpoint: it returns the shadow ``pages`` id whose full page
+ editor carries the row's block content (synced blocks included).
+ """
+ with get_conn() as conn:
+ row = conn.execute(
+ "SELECT collection_id FROM collection_pages WHERE id=?",
+ (page_id,),
+ ).fetchone()
+ if not row:
+ raise HTTPException(status_code=404, detail="Page not found")
+ coll_id = row["collection_id"]
+ # v6.0.0: granular collection permissions (same gate as the row itself).
+ _require_view(coll_id, _session_user(request))
+ from app.services.row_pages import ensure_row_page
+ try:
+ content_page_id = ensure_row_page(page_id)
+ except KeyError:
+ raise HTTPException(status_code=404, detail="Page not found") from None
+ return {"page_id": content_page_id, "row_id": page_id}
+
+
@router.put("/pages/{page_id}/api")
async def update_page_api(request: Request, page_id: int):
"""API: update a page's properties."""
@@ -552,6 +578,9 @@ async def update_page_api(request: Request, page_id: int):
WHERE id=?""",
(title, icon, cover_url, position, parent_id, property_values, page_id),
)
+ # v6.5.0: keep the row's content page title in sync (row → page).
+ from app.services.row_pages import sync_row_title_to_page
+ sync_row_title_to_page(conn, page_id)
conn.commit()
await fire_event("page.updated", {
diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py
index 71d4bc7..323416f 100644
--- a/app/routers/dashboard.py
+++ b/app/routers/dashboard.py
@@ -321,6 +321,9 @@ async def view_page_root(request: Request, page_id: int):
if not row:
return RedirectResponse("/workspaces", status_code=302)
page = dict(row)
+ # v6.5.0: synced blocks resolve server-side at read time.
+ from app.services.synced_blocks import resolve_content_json
+ page["content"] = resolve_content_json(page.get("content", ""), page.get("content_format"))
ws = page.get("workspace", "")
parts = ws.split("/") if "/" in ws else ["", ""]
@@ -885,7 +888,7 @@ async def list_workspace_projects(request: Request):
builtin = []
with get_conn() as conn:
rows = conn.execute(
- "SELECT id, title FROM pages WHERE parent_id IS NULL ORDER BY updated_at DESC LIMIT 20"
+ "SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL ORDER BY updated_at DESC LIMIT 20"
).fetchall()
for r in rows:
count = conn.execute("SELECT COUNT(*) FROM pages WHERE parent_id=?", (r["id"],)).fetchone()[0]
@@ -1081,7 +1084,9 @@ async def get_page_content(page_id: int):
fmt = row["content_format"]
if fmt == "file":
return JSONResponse({"content": "(uploaded file)", "format": fmt})
- return {"content": row["content"] or "", "format": fmt}
+ # v6.5.0: resolve synced blocks server-side (fresh content on read).
+ from app.services.synced_blocks import resolve_content_json
+ return {"content": resolve_content_json(row["content"] or "", fmt), "format": fmt}
def _file_page_disk_path(page: dict):
@@ -1185,7 +1190,7 @@ def _build_tree_children(conn, parent_id: int | None, ws_id: int, uid: int | Non
"SELECT id, title, parent_section, content_format, content, page_icon, "
"(is_shared OR share_mode != 'private' OR COALESCE(published,0)) as is_shared, "
"created_at, updated_at FROM pages "
- "WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL "
+ "WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL "
"ORDER BY created_at DESC",
(ws_id,),
).fetchall()
@@ -1364,7 +1369,7 @@ async def nav_menu(request: Request, workspace_id: int = None, parent_id: int =
else:
rows = conn.execute(
"SELECT id, title, parent_section, content_format FROM pages "
- "WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL "
+ "WHERE parent_id IS NULL AND workspace_id=? AND deleted_at IS NULL AND collection_row_id IS NULL "
"ORDER BY sort_order ASC, created_at DESC",
(ws_id,),
).fetchall()
@@ -1726,7 +1731,7 @@ async def list_workspaces(request: Request):
uid = user["id"] if user and user.get("id") else 1
with get_conn() as conn:
rows = conn.execute(
- "SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id) as page_count "
+ "SELECT w.*, (SELECT COUNT(*) FROM pages WHERE workspace_id=w.id AND collection_row_id IS NULL) as page_count "
"FROM workspaces w WHERE w.owner_id=? ORDER BY w.created_at DESC",
(uid,),
).fetchall()
@@ -2213,6 +2218,8 @@ h1{font-size:3rem;opacity:.3}404