feat: v6.5.0 Synced blocks production — pages contenu par lignes de DB, résolution serveur à chaque lecture, propagation écrite réelle
This commit is contained in:
@@ -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 `<p>`) + é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é).
|
||||
|
||||
+33
-4
@@ -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 `<p>`) + é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 `read<write<admin`, RFC 7807, idempotence, audit, webhooks CRUD, OpenAPI 402 chemins). Reste: SSO/SAML, synced blocks prod (databases/vues).*
|
||||
*Dernière mise à jour: 2026-09-24 — **v6.5.0 Synced blocks production (databases & vues) COMPLETED** (page contenu par ligne de DB + ouverture correcte des lignes dans les vues, résolution serveur à chaque lecture, propagation écrite réelle, état deleted, rendu public des synced blocks ; 17 tests dédiés, suite 742) + **v6.4.0** realtime + **v6.3.0** API v2. Reste: SSO/SAML, migration de `sync.py` vers Bearer.*
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="6.4.0",
|
||||
version="6.5.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
|
||||
@@ -959,3 +959,27 @@ def _migration_sync_version_columns(conn: sqlite3.Connection) -> None:
|
||||
UPDATE collections SET sync_version = sync_version + 1 WHERE id = NEW.id;
|
||||
END"""
|
||||
)
|
||||
|
||||
|
||||
@register(22, "v6.5.0: database row content pages")
|
||||
def _migration_row_content_pages(conn: sqlite3.Connection) -> None:
|
||||
"""v6.5.0 — Synced blocks production: content for database rows.
|
||||
|
||||
A database row (``collection_pages``) gains a shadow ``pages`` row
|
||||
(``pages.collection_row_id``) that carries the Notion-style block
|
||||
content of the row: the full page editor, synced blocks, versions and
|
||||
realtime all work on it unchanged.
|
||||
|
||||
``ON DELETE CASCADE``: deleting a database row deletes its content
|
||||
page (and ``page_synced_blocks`` cascades from ``pages``).
|
||||
"""
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
if "collection_row_id" not in cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE pages ADD COLUMN collection_row_id INTEGER "
|
||||
"REFERENCES collection_pages(id) ON DELETE CASCADE"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_pages_row "
|
||||
"ON pages(collection_row_id) WHERE collection_row_id IS NOT NULL"
|
||||
)
|
||||
|
||||
@@ -802,9 +802,20 @@ async def get_page_v2(page_id: int, request: Request, authorization: str | None
|
||||
if not row2:
|
||||
raise HTTPException(404, "Page not found")
|
||||
d = row_to_dict(row2)
|
||||
# v6.5.0: resolve synced blocks server-side (fresh content).
|
||||
if (d.get("content_format") or "blocks") == "blocks" and d.get("content"):
|
||||
from app.services.synced_blocks import resolve_content_json
|
||||
d["content"] = resolve_content_json(d["content"], d["content_format"])
|
||||
return d
|
||||
d = row_to_dict(row)
|
||||
# property_values_json already parsed by row_to_dict
|
||||
# v6.5.0: expose the row's content page when it exists (no lazy
|
||||
# creation on a read-only endpoint).
|
||||
content_page_id = conn.execute(
|
||||
"SELECT id FROM pages WHERE collection_row_id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
d["content_page_id"] = content_page_id["id"] if content_page_id else None
|
||||
return d
|
||||
|
||||
@router.patch("/pages/{page_id}")
|
||||
|
||||
+37
-6
@@ -431,7 +431,7 @@ def _build_page_tree(conn, parent_id: int | None, ws_key: str, depth: int = 0, m
|
||||
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",
|
||||
"SELECT id, title, updated_at FROM pages WHERE workspace=? AND parent_id IS ? AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY sort_order ASC, updated_at DESC",
|
||||
(ws_key, parent_id),
|
||||
).fetchall()
|
||||
items = []
|
||||
@@ -499,7 +499,7 @@ def _load_workspace_pages(ws_cookie: str) -> list:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, parent_section, content_format, "
|
||||
"is_shared, share_mode, COALESCE(published,0) AS published "
|
||||
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC",
|
||||
"FROM pages WHERE workspace_id=? AND parent_id IS NULL AND deleted_at IS NULL AND collection_row_id IS NULL ORDER BY created_at DESC",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
items = []
|
||||
@@ -916,13 +916,13 @@ async def library_page(request: Request, owner: str = Query(default=""), repo: s
|
||||
if ws_key:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, updated_at FROM pages "
|
||||
"WHERE workspace=? ORDER BY updated_at DESC",
|
||||
"WHERE workspace=? AND collection_row_id IS NULL ORDER BY updated_at DESC",
|
||||
(ws_key,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT id, title, workspace, updated_at FROM pages "
|
||||
"ORDER BY updated_at DESC",
|
||||
"WHERE collection_row_id IS NULL ORDER BY updated_at DESC",
|
||||
).fetchall()
|
||||
all_pages = []
|
||||
for r in rows:
|
||||
@@ -1125,11 +1125,21 @@ async def update_synced_block_api(request: Request, sid: int):
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON body") from None
|
||||
from app.services.synced_blocks import get_synced_block, update_synced_block
|
||||
from app.services.synced_blocks import (
|
||||
get_synced_block,
|
||||
page_ids_for_synced,
|
||||
sync_synced_blocks_in_page,
|
||||
update_synced_block,
|
||||
)
|
||||
sb = get_synced_block(sid)
|
||||
if not sb:
|
||||
raise HTTPException(404, "Synced block not found")
|
||||
update_synced_block(sid, body.get("title", sb["title"]), body.get("content", []))
|
||||
# v6.5.0: rewrite every referencing page's stored content first (DB row
|
||||
# content pages included), THEN push the realtime update so open rooms
|
||||
# reload the fresh content from the DB.
|
||||
for pid in page_ids_for_synced(sid):
|
||||
sync_synced_blocks_in_page(pid)
|
||||
from app.services.realtime_server import manager
|
||||
await manager._propagate_synced(sid)
|
||||
return {"status": "ok"}
|
||||
@@ -1138,11 +1148,22 @@ async def update_synced_block_api(request: Request, sid: int):
|
||||
@router.delete("/api/synced-blocks/{sid}")
|
||||
async def delete_synced_block_api(request: Request, sid: int):
|
||||
"""Delete a synced block."""
|
||||
from app.services.synced_blocks import delete_synced_block, get_synced_block
|
||||
from app.services.synced_blocks import (
|
||||
delete_synced_block,
|
||||
get_synced_block,
|
||||
mark_synced_block_deleted,
|
||||
page_ids_for_synced,
|
||||
)
|
||||
sb = get_synced_block(sid)
|
||||
if not sb:
|
||||
raise HTTPException(404, "Synced block not found")
|
||||
# v6.5.0: collect referencing pages BEFORE the FK cascade wipes the
|
||||
# refs, rewrite their stored content (deleted state), then broadcast.
|
||||
pids = page_ids_for_synced(sid)
|
||||
delete_synced_block(sid)
|
||||
mark_synced_block_deleted(sid, pids)
|
||||
from app.services.realtime_server import manager
|
||||
await manager._broadcast_synced_to(pids, sid)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@@ -1426,6 +1447,9 @@ async def update_page(request: Request, page_id: int, title: str = Query(default
|
||||
_ensure_page_editable(conn, page_id, user)
|
||||
if title:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
||||
# v6.5.0: renaming a database row's content page updates the row.
|
||||
from app.services.row_pages import sync_page_title_to_row
|
||||
sync_page_title_to_row(conn, page_id)
|
||||
if content:
|
||||
conn.execute("UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content, page_id))
|
||||
if content_format:
|
||||
@@ -1473,6 +1497,9 @@ async def save_page_blocks(request: Request, page_id: int):
|
||||
_ensure_page_editable(conn, page_id, user)
|
||||
if title:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
||||
# v6.5.0: renaming a database row's content page updates the row.
|
||||
from app.services.row_pages import sync_page_title_to_row
|
||||
sync_page_title_to_row(conn, page_id)
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, content_format='blocks', updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(blocks_json, page_id),
|
||||
@@ -1974,6 +2001,10 @@ async def view_page(request: Request, page_id: int):
|
||||
if not row:
|
||||
return HTMLResponse("<h2>Page not found</h2>", 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 ["", ""]
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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}</style></head><body><h1>404</h1></body></html>""",
|
||||
import json as _json
|
||||
try:
|
||||
blocks = _json.loads(page["content"])
|
||||
from app.services.synced_blocks import resolve_synced_block
|
||||
blocks = resolve_synced_block(blocks)
|
||||
from app.db import get_conn as _gc
|
||||
from app.services.wiki_links import token_labels
|
||||
with _gc() as conn:
|
||||
@@ -2413,6 +2420,31 @@ def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
|
||||
f'background:#0a0a0a;"><iframe src="{src}" loading="lazy" frameborder="0" '
|
||||
f'style="position:absolute;inset:0;width:100%;height:100%;" allowfullscreen allow="autoplay; encrypted-media; picture-in-picture"></iframe></div>'
|
||||
)
|
||||
elif t == "synced":
|
||||
# v6.5.0: render synced block instances (resolved server-side).
|
||||
if b.get("_synced_deleted"):
|
||||
html_parts.append(
|
||||
'<div style="margin:8px 0;padding:8px 12px;border-left:3px solid #e05e5e;'
|
||||
'background:rgba(224,94,94,.08);border-radius:4px;font-size:13px;opacity:.8;">'
|
||||
'Deleted synced block</div>'
|
||||
)
|
||||
else:
|
||||
inner = b.get("_synced_content")
|
||||
if not isinstance(inner, list) or not inner:
|
||||
try:
|
||||
import json as _sj
|
||||
parsed = _sj.loads(b.get("content") or "[]")
|
||||
inner = parsed if isinstance(parsed, list) else []
|
||||
except (ValueError, TypeError):
|
||||
inner = []
|
||||
inner = [{"type": "paragraph", "content": str(x)} if not isinstance(x, dict) else x
|
||||
for x in inner]
|
||||
if inner:
|
||||
html_parts.append(
|
||||
'<div style="margin:8px 0;padding-left:12px;'
|
||||
'border-left:3px solid var(--accent,#4c9aff);">'
|
||||
+ _render_blocks_public(inner, titles) + '</div>'
|
||||
)
|
||||
else:
|
||||
html_parts.append(f'<p style="margin:4px 0;line-height:1.7;">{c}</p>')
|
||||
return "\n".join(html_parts)
|
||||
@@ -2430,9 +2462,11 @@ async def api_page_content(page_id: int):
|
||||
).fetchone()
|
||||
if not row:
|
||||
return JSONResponse({"error": "Not found"}, status_code=404)
|
||||
# v6.5.0: resolve synced blocks server-side (fresh content on read).
|
||||
from app.services.synced_blocks import resolve_content_json
|
||||
return {
|
||||
"title": row["title"],
|
||||
"content": row["content"],
|
||||
"content": resolve_content_json(row["content"], row["content_format"]),
|
||||
"format": row["content_format"] or "blocks",
|
||||
}
|
||||
|
||||
@@ -2449,6 +2483,9 @@ async def api_rename_page(page_id: int, request: Request):
|
||||
"UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL",
|
||||
(title, page_id),
|
||||
)
|
||||
# v6.5.0: renaming a database row's content page updates the row.
|
||||
from app.services.row_pages import sync_page_title_to_row
|
||||
sync_page_title_to_row(conn, page_id)
|
||||
conn.commit()
|
||||
return {"status": "ok", "title": title}
|
||||
|
||||
|
||||
@@ -207,7 +207,11 @@ class RealtimeManager:
|
||||
room.title = row["title"] or ""
|
||||
if (row["content_format"] or "") == "blocks" and row["content"]:
|
||||
try:
|
||||
room.blocks = ensure_block_ids(json.loads(row["content"]))
|
||||
blocks = ensure_block_ids(json.loads(row["content"]))
|
||||
# v6.5.0: rooms serve server-resolved synced blocks so a
|
||||
# sync_req never returns a stale cached copy.
|
||||
from app.services.synced_blocks import resolve_synced_block
|
||||
room.blocks = resolve_synced_block(blocks)
|
||||
except Exception:
|
||||
room.blocks = []
|
||||
return True
|
||||
@@ -503,19 +507,8 @@ class RealtimeManager:
|
||||
for r in self._rooms.values()],
|
||||
}
|
||||
|
||||
async def _propagate_synced(self, synced_id: int) -> None:
|
||||
"""Broadcast a synced-block update to all rooms that reference it."""
|
||||
from app.db import get_conn as _get_conn
|
||||
pages: list[int] = []
|
||||
try:
|
||||
with _get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT page_id FROM page_synced_blocks WHERE synced_block_id=?",
|
||||
(synced_id,),
|
||||
).fetchall()
|
||||
pages = [r["page_id"] for r in rows]
|
||||
except Exception:
|
||||
return
|
||||
async def _broadcast_synced_to(self, pages: list[int], synced_id: int) -> None:
|
||||
"""Broadcast a synced-block event to the given pages' open rooms."""
|
||||
for pid in pages:
|
||||
room = self._rooms.get(pid)
|
||||
if room and room.conns:
|
||||
@@ -524,5 +517,14 @@ class RealtimeManager:
|
||||
"synced_id": synced_id,
|
||||
"version": room.version})
|
||||
|
||||
async def _propagate_synced(self, synced_id: int) -> None:
|
||||
"""Broadcast a synced-block update to all rooms that reference it."""
|
||||
from app.services.synced_blocks import page_ids_for_synced
|
||||
try:
|
||||
pages = page_ids_for_synced(synced_id)
|
||||
except Exception:
|
||||
return
|
||||
await self._broadcast_synced_to(pages, synced_id)
|
||||
|
||||
|
||||
manager = RealtimeManager()
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""FlowDeck — v6.5.0 database row content pages (synced blocks production).
|
||||
|
||||
A database row (``collection_pages``) has no block content of its own:
|
||||
its Notion-style content lives in a shadow ``pages`` row linked via
|
||||
``pages.collection_row_id``. The full page editor (synced blocks,
|
||||
versions, realtime, permissions) is reused unchanged on that page.
|
||||
|
||||
``ensure_row_page()`` lazily creates (or heals) the shadow page and is
|
||||
the single entry point used by the row-open endpoint.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _host_workspace(conn, coll: dict) -> tuple[str, int | None]:
|
||||
"""Workspace (key, id) the shadow page should live in.
|
||||
|
||||
Resolution order: full-page database host page → inline parent page →
|
||||
the collection's own ``workspace_id`` (key left empty).
|
||||
"""
|
||||
host = conn.execute(
|
||||
"SELECT workspace, workspace_id FROM pages "
|
||||
"WHERE collection_id=? AND deleted_at IS NULL "
|
||||
"AND collection_row_id IS NULL LIMIT 1",
|
||||
(coll.get("id"),),
|
||||
).fetchone()
|
||||
if not host and coll.get("parent_page_id"):
|
||||
host = conn.execute(
|
||||
"SELECT workspace, workspace_id FROM pages WHERE id=?",
|
||||
(coll["parent_page_id"],),
|
||||
).fetchone()
|
||||
if host:
|
||||
return host["workspace"] or "", host["workspace_id"]
|
||||
if coll.get("gitea_owner") and coll.get("gitea_repo"):
|
||||
return f"{coll['gitea_owner']}/{coll['gitea_repo']}", coll.get("workspace_id")
|
||||
return "", coll.get("workspace_id")
|
||||
|
||||
|
||||
def ensure_row_page(row_id: int) -> int:
|
||||
"""Return the content page id for a database row, creating it if needed.
|
||||
|
||||
- existing live page → returned as-is;
|
||||
- soft-deleted page → restored (title resynced);
|
||||
- missing → created with the row's title and the collection workspace.
|
||||
|
||||
Raises ``KeyError`` when the row does not exist.
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT id, collection_id, title FROM collection_pages WHERE id=?",
|
||||
(row_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise KeyError(f"row {row_id} not found")
|
||||
coll_row = conn.execute(
|
||||
"SELECT id, workspace_id, parent_page_id, gitea_owner, gitea_repo "
|
||||
"FROM collections WHERE id=?",
|
||||
(row["collection_id"],),
|
||||
).fetchone()
|
||||
coll = dict(coll_row) if coll_row else {"id": row["collection_id"]}
|
||||
|
||||
existing = conn.execute(
|
||||
"SELECT id, deleted_at, title FROM pages WHERE collection_row_id=?",
|
||||
(row_id,),
|
||||
).fetchone()
|
||||
if existing:
|
||||
if existing["deleted_at"]:
|
||||
conn.execute(
|
||||
"UPDATE pages SET deleted_at=NULL, parent_section='DbRow', "
|
||||
"title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(row["title"] or existing["title"] or "Untitled", existing["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
return existing["id"]
|
||||
|
||||
ws_key, ws_id = _host_workspace(conn, coll)
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO pages
|
||||
(workspace, workspace_id, title, content, content_format,
|
||||
parent_section, collection_id, collection_row_id)
|
||||
VALUES (?, ?, ?, '', 'blocks', 'DbRow', ?, ?)""",
|
||||
(ws_key, ws_id, row["title"] or "Untitled",
|
||||
row["collection_id"], row_id),
|
||||
)
|
||||
page_id = cur.lastrowid
|
||||
conn.commit()
|
||||
logger.debug("created content page %s for row %s", page_id, row_id)
|
||||
return page_id
|
||||
|
||||
|
||||
def sync_row_title_to_page(conn, row_id: int) -> None:
|
||||
"""Row → page: copy the row's title onto its content page (if any)."""
|
||||
conn.execute(
|
||||
"""UPDATE pages SET title=(
|
||||
SELECT title FROM collection_pages WHERE id=pages.collection_row_id
|
||||
), updated_at=CURRENT_TIMESTAMP
|
||||
WHERE collection_row_id=? AND EXISTS (
|
||||
SELECT 1 FROM collection_pages cp
|
||||
WHERE cp.id=pages.collection_row_id AND cp.title<>pages.title
|
||||
)""",
|
||||
(row_id,),
|
||||
)
|
||||
|
||||
|
||||
def sync_page_title_to_row(conn, page_id: int) -> None:
|
||||
"""Page → row: a rename inside the editor propagates to the row."""
|
||||
conn.execute(
|
||||
"""UPDATE collection_pages SET title=(
|
||||
SELECT title FROM pages WHERE id=?
|
||||
), updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=(SELECT collection_row_id FROM pages WHERE id=?)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM pages p
|
||||
WHERE p.id=? AND p.collection_row_id IS NOT NULL
|
||||
AND p.title<>collection_pages.title
|
||||
)""",
|
||||
(page_id, page_id, page_id),
|
||||
)
|
||||
+131
-16
@@ -61,11 +61,17 @@ def update_synced_block(synced_id: int, title: str,
|
||||
conn.commit()
|
||||
|
||||
|
||||
def delete_synced_block(synced_id: int) -> None:
|
||||
"""Delete a synced block (cascades via FK)."""
|
||||
def delete_synced_block(synced_id: int) -> list[int]:
|
||||
"""Delete a synced block (cascades via FK).
|
||||
|
||||
v6.5.0: returns the page ids that referenced it so the caller can
|
||||
refresh/broadcast them (the FK cascade removes ``page_synced_blocks``).
|
||||
"""
|
||||
page_ids = page_ids_for_synced(synced_id)
|
||||
with get_conn() as conn:
|
||||
conn.execute("DELETE FROM synced_blocks WHERE id=?", (synced_id,))
|
||||
conn.commit()
|
||||
return page_ids
|
||||
|
||||
|
||||
def list_synced_blocks(workspace: str) -> list[dict]:
|
||||
@@ -136,18 +142,55 @@ def resolve_synced_block(blocks: list[dict]) -> list[dict]:
|
||||
b["_synced_title"] = sb.get("title", "")
|
||||
b["_synced_id"] = sb["id"]
|
||||
b["_synced_updated"] = sb.get("updated_at", "")
|
||||
b.pop("_synced_deleted", None)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
elif not b.get("_synced_deleted"):
|
||||
# v6.5.0: source deleted → instances render a clear state
|
||||
# instead of a stale cache / eternal "Loading…".
|
||||
b["_synced_deleted"] = True
|
||||
b.pop("_synced_content", None)
|
||||
b.pop("_synced_title", None)
|
||||
if isinstance(b.get("children"), list):
|
||||
resolve_synced_block(b["children"])
|
||||
return blocks
|
||||
|
||||
|
||||
def page_ids_for_synced(synced_id: int) -> list[int]:
|
||||
"""Pages currently referencing a synced block (before any cascade)."""
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT page_id FROM page_synced_blocks WHERE synced_block_id=?",
|
||||
(synced_id,),
|
||||
).fetchall()
|
||||
return [r["page_id"] for r in rows]
|
||||
|
||||
|
||||
def resolve_content_json(content: str, content_format: str | None) -> str:
|
||||
"""Resolve synced-block references inside a page's stored blocks JSON.
|
||||
|
||||
Server-side freshness guarantee (v6.5.0): every read path passes through
|
||||
this so a stale cached ``_synced_content`` is replaced by the current
|
||||
source-of-truth content. Non-blocks content is returned unchanged.
|
||||
"""
|
||||
if (content_format or "blocks") != "blocks" or not content:
|
||||
return content
|
||||
try:
|
||||
blocks = json.loads(content)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return content
|
||||
if not isinstance(blocks, list):
|
||||
return content
|
||||
return json.dumps(resolve_synced_block(blocks), ensure_ascii=False)
|
||||
|
||||
|
||||
def sync_synced_blocks_in_page(page_id: int) -> None:
|
||||
"""Re-resolve all synced blocks in a page's content.
|
||||
|
||||
Called after a synced block is updated — refreshes the referencing
|
||||
pages' block content so they reflect the latest source.
|
||||
pages' stored content so they reflect the latest source (v6.5.0: this
|
||||
used to be dead code; it is now invoked by the update/delete routes,
|
||||
recursively covering blocks nested in columns/toggles).
|
||||
"""
|
||||
refs = get_page_synced(page_id)
|
||||
if not refs:
|
||||
@@ -163,22 +206,48 @@ def sync_synced_blocks_in_page(page_id: int) -> None:
|
||||
blocks = json.loads(row["content"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return
|
||||
if not isinstance(blocks, list):
|
||||
return
|
||||
|
||||
sources: dict[int, dict] = {r["synced_block_id"]: get_synced_block(r["synced_block_id"])
|
||||
for r in refs}
|
||||
changed = False
|
||||
for ref in refs:
|
||||
sb = get_synced_block(ref["synced_block_id"])
|
||||
if sb:
|
||||
try:
|
||||
resolved = json.loads(sb["content"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
for b in blocks:
|
||||
if b.get("type") == "synced" and b.get("synced_id") == ref["synced_block_id"]:
|
||||
b["content"] = json.dumps(resolved, ensure_ascii=False) if isinstance(resolved, list) else json.dumps(resolved)
|
||||
b["_synced_content"] = resolved
|
||||
b["_synced_title"] = sb.get("title", "")
|
||||
|
||||
def _walk(bl: list[dict]) -> None:
|
||||
nonlocal changed
|
||||
for b in bl:
|
||||
if b.get("type") == "synced" and b.get("synced_id"):
|
||||
sid = b["synced_id"]
|
||||
if sid not in sources:
|
||||
sources[sid] = get_synced_block(sid)
|
||||
sb = sources.get(sid)
|
||||
if sb:
|
||||
try:
|
||||
resolved = json.loads(sb["content"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
content_str = (json.dumps(resolved, ensure_ascii=False)
|
||||
if isinstance(resolved, list)
|
||||
else json.dumps(resolved))
|
||||
title = sb.get("title", "")
|
||||
if (b.get("content") != content_str
|
||||
or b.get("_synced_content") != resolved
|
||||
or b.get("_synced_title") != title
|
||||
or b.get("_synced_deleted")):
|
||||
b["content"] = content_str
|
||||
b["_synced_content"] = resolved
|
||||
b["_synced_title"] = title
|
||||
b.pop("_synced_deleted", None)
|
||||
changed = True
|
||||
elif not b.get("_synced_deleted"):
|
||||
b["_synced_deleted"] = True
|
||||
b.pop("_synced_content", None)
|
||||
b.pop("_synced_title", None)
|
||||
changed = True
|
||||
break
|
||||
if isinstance(b.get("children"), list):
|
||||
_walk(b["children"])
|
||||
|
||||
_walk(blocks)
|
||||
|
||||
if changed:
|
||||
with get_conn() as conn:
|
||||
@@ -189,6 +258,52 @@ def sync_synced_blocks_in_page(page_id: int) -> None:
|
||||
conn.commit()
|
||||
|
||||
|
||||
def mark_synced_block_deleted(synced_id: int, page_ids: list[int]) -> None:
|
||||
"""Flag a deleted synced block's instances as ``_synced_deleted``.
|
||||
|
||||
v6.5.0 — called after ``delete_synced_block()``: the FK cascade already
|
||||
removed ``page_synced_blocks`` refs, so ``sync_synced_blocks_in_page``
|
||||
cannot be used here; this rewrites the stored blocks directly so every
|
||||
surface (editor, offline sync, export) sees the deleted state.
|
||||
"""
|
||||
for pid in page_ids:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT content, content_format FROM pages WHERE id=?",
|
||||
(pid,),
|
||||
).fetchone()
|
||||
if not row or row["content_format"] != "blocks":
|
||||
continue
|
||||
try:
|
||||
blocks = json.loads(row["content"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if not isinstance(blocks, list):
|
||||
continue
|
||||
changed = False
|
||||
|
||||
def _walk(bl: list[dict]) -> None:
|
||||
nonlocal changed
|
||||
for b in bl:
|
||||
if (b.get("type") == "synced"
|
||||
and b.get("synced_id") == synced_id
|
||||
and not b.get("_synced_deleted")):
|
||||
b["_synced_deleted"] = True
|
||||
b.pop("_synced_content", None)
|
||||
b.pop("_synced_title", None)
|
||||
changed = True
|
||||
if isinstance(b.get("children"), list):
|
||||
_walk(b["children"])
|
||||
|
||||
_walk(blocks)
|
||||
if changed:
|
||||
conn.execute(
|
||||
"UPDATE pages SET content=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(json.dumps(blocks, ensure_ascii=False), pid),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ── Unsync: convert synced block to independent copy ──────────────
|
||||
|
||||
def unsync_block(page_id: int, synced_block_id: int) -> list[dict] | None:
|
||||
|
||||
@@ -1412,7 +1412,21 @@
|
||||
};
|
||||
}
|
||||
var iframe = document.getElementById('db-side-peek-iframe');
|
||||
if (iframe) { iframe.src = '/pages/'+pageId+'?embed=1'; panel.style.display = 'flex'; }
|
||||
// v6.5.0: a database row id is NOT a pages id (raw /pages/{rowId}
|
||||
// opened an unrelated page with the same id) — resolve the row's
|
||||
// content page first (lazy-created), then load the real editor.
|
||||
resolveRowContentPage(pageId).then(function(pid){
|
||||
var fr = document.getElementById('db-side-peek-iframe');
|
||||
if (fr) { fr.src = '/pages/'+pid+'?embed=1'; panel.style.display = 'flex'; }
|
||||
});
|
||||
if (iframe) panel.style.display = 'flex';
|
||||
}
|
||||
|
||||
function resolveRowContentPage(rowId){
|
||||
return fetch('/db/pages/'+rowId+'/open/api')
|
||||
.then(function(r){ return r.ok ? r.json() : null; })
|
||||
.then(function(d){ return (d && d.page_id) ? d.page_id : rowId; })
|
||||
.catch(function(){ return rowId; });
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -570,6 +570,13 @@ window.__fdEditorScriptsLoaded = true;
|
||||
return `<div class="block-wrapper" data-id="${b.id}"><div class="block-content block-embed" style="text-align:center;padding:60px 20px;background:var(--bg-secondary);border:1px dashed var(--border);border-radius:8px;cursor:pointer;" onclick="E.setEmbedUrl('${b.id}')"><div style="font-size:48px;margin-bottom:12px;">🔗</div><div style="font-size:14px;color:var(--text-dim);">Click to add embed URL</div></div></div>`;
|
||||
}
|
||||
if(b.type==='synced'){
|
||||
if(b._synced_deleted){
|
||||
// v6.5.0: source deleted → clear state instead of stale cache.
|
||||
return `<div class="block-wrapper synced-block-wrapper" data-id="${b.id}" style="border-left:3px solid #e05e5e;background:var(--bg-secondary);opacity:.8">
|
||||
<div class="synced-badge" style="font-size:11px;color:#e05e5e;padding:4px 8px;font-weight:600;">🔗 Deleted synced block</div>
|
||||
<div style="color:var(--text-dim);padding:0 8px 8px;font-size:12px;">The original synced block was deleted — this copy no longer updates.</div>
|
||||
</div>`;
|
||||
}
|
||||
var syncedTitle=b._synced_title||'Synced block';
|
||||
var syncedContent=b._synced_content||[];
|
||||
var sid=b.synced_id||'';
|
||||
|
||||
@@ -364,7 +364,7 @@ Endpoints publics v2 :
|
||||
|---------|-------|-------------|
|
||||
| GET | `/api/v2/collections/{id}/pages?filter[status]=…&sort=…` | Lister (pagination, filtres, tri, fields) |
|
||||
| POST | `/api/v2/collections/{id}/pages` | Créer (title, icon, property_values, parent_id) |
|
||||
| GET | `/api/v2/pages/{id}` | Détail (propriétés parsées) |
|
||||
| GET | `/api/v2/pages/{id}` | Détail (propriétés parsées) — v6.5.0 : + `content_page_id` (page contenu de la ligne, `null` si absente, sans création lazy) |
|
||||
| PATCH | `/api/v2/pages/{id}` | Titre, icon, position, property_values (merge) |
|
||||
| DELETE | `/api/v2/pages/{id}` | Corbeille (soft delete via `deleted_at`) |
|
||||
| POST | `/api/v2/pages/{id}/restore` | Restaurer |
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
"""FlowDeck — v6.5.0 Synced blocks production (databases & vues).
|
||||
|
||||
Covers:
|
||||
- migration 22 (``pages.collection_row_id``) + index;
|
||||
- database row content pages: lazy creation, idempotence, cascade delete,
|
||||
title sync row ↔ page;
|
||||
- sidebar/tree/workspace listings exclude content pages;
|
||||
- server-side resolution of synced blocks on every read path
|
||||
(content API, public page) — stale cache is replaced at read time;
|
||||
- propagation: updating/deleting a source REWRITES every referencing
|
||||
page's stored content (was dead code), recursively (columns/toggles),
|
||||
including content pages of database rows;
|
||||
- API v2 exposes ``content_page_id`` for rows.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
|
||||
from app.db import get_conn
|
||||
from app.services.synced_blocks import (
|
||||
create_synced_block,
|
||||
get_page_synced,
|
||||
get_synced_block,
|
||||
update_synced_block,
|
||||
)
|
||||
|
||||
# ── Helpers (patterns from test_v514 / test_v57) ─────────────────
|
||||
|
||||
def _login(client):
|
||||
from app.auth.session import SessionManager
|
||||
|
||||
login = f"v65_{secrets.token_hex(4)}"
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO users (login, full_name, email, is_admin) "
|
||||
"VALUES (?, 'V65', ?, 0)",
|
||||
(login, f"{login}@test.com"),
|
||||
)
|
||||
uid = conn.execute("SELECT id FROM users WHERE login=?", (login,)).fetchone()["id"]
|
||||
conn.commit()
|
||||
session = SessionManager.create_session({"id": uid, "login": login})
|
||||
csrf = client.get("/api/csrf-token", cookies={"flowdeck_session": session}).json()["csrf_token"]
|
||||
return session, csrf, uid
|
||||
|
||||
|
||||
def _hdr(csrf):
|
||||
return {"X-CSRF-Token": csrf}
|
||||
|
||||
|
||||
def _create_page(client, session, csrf, title="Page"):
|
||||
r = client.post(
|
||||
f"/board/api/pages?title={title}",
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def _add_collection(client, name="DB"):
|
||||
r = client.post("/db/api", json={"name": name})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def _add_row(client, cid, title="Row A"):
|
||||
r = client.post(f"/db/{cid}/pages/api", json={"title": title})
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def _raw_page_content(page_id):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT content, content_format, title, parent_section, "
|
||||
"collection_row_id, deleted_at FROM pages WHERE id=?",
|
||||
(page_id,),
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
# ── Migration ────────────────────────────────────────────────────
|
||||
|
||||
def test_migration_v65_column_and_index(client):
|
||||
with get_conn() as conn:
|
||||
cols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
|
||||
idx = {r[1] for r in conn.execute("PRAGMA index_list(pages)").fetchall()}
|
||||
version = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
|
||||
assert "collection_row_id" in cols
|
||||
assert "idx_pages_row" in idx
|
||||
assert version >= 22
|
||||
|
||||
|
||||
# ── Row content pages ────────────────────────────────────────────
|
||||
|
||||
def test_open_row_creates_content_page(client):
|
||||
session, csrf, uid = _login(client)
|
||||
cid = _add_collection(client)
|
||||
rid = _add_row(client, cid, "My row")
|
||||
|
||||
r1 = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session})
|
||||
assert r1.status_code == 200, r1.text
|
||||
pid1 = r1.json()["page_id"]
|
||||
assert pid1 > 0
|
||||
|
||||
page = _raw_page_content(pid1)
|
||||
assert page is not None
|
||||
assert page["collection_row_id"] == rid
|
||||
assert page["title"] == "My row"
|
||||
assert page["parent_section"] == "DbRow"
|
||||
assert page["content_format"] == "blocks"
|
||||
|
||||
# Idempotent: second open returns the same page.
|
||||
r2 = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session})
|
||||
assert r2.json()["page_id"] == pid1
|
||||
|
||||
|
||||
def test_open_unknown_row_404(client):
|
||||
session, _, _ = _login(client)
|
||||
r = client.get("/db/pages/999999/open/api", cookies={"flowdeck_session": session})
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_row_title_sync_to_content_page(client):
|
||||
session, csrf, _ = _login(client)
|
||||
cid = _add_collection(client)
|
||||
rid = _add_row(client, cid, "Original")
|
||||
pid = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session}).json()["page_id"]
|
||||
|
||||
r = client.put(
|
||||
f"/db/pages/{rid}/api",
|
||||
json={"title": "Renamed row"},
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert _raw_page_content(pid)["title"] == "Renamed row"
|
||||
|
||||
|
||||
def test_content_page_title_sync_to_row(client):
|
||||
session, csrf, _ = _login(client)
|
||||
cid = _add_collection(client)
|
||||
rid = _add_row(client, cid, "Before")
|
||||
pid = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session}).json()["page_id"]
|
||||
|
||||
r = client.put(
|
||||
f"/api/pages/{pid}/rename",
|
||||
json={"title": "Renamed in editor"},
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT title FROM collection_pages WHERE id=?", (rid,)).fetchone()
|
||||
assert row["title"] == "Renamed in editor"
|
||||
|
||||
|
||||
def test_row_delete_cascades_content_page(client):
|
||||
session, csrf, _ = _login(client)
|
||||
cid = _add_collection(client)
|
||||
rid = _add_row(client, cid, "Doomed")
|
||||
pid = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session}).json()["page_id"]
|
||||
|
||||
r = client.delete(
|
||||
f"/db/pages/{rid}/api",
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert _raw_page_content(pid) is None
|
||||
|
||||
|
||||
def test_content_page_restored_from_trash(client):
|
||||
session, csrf, _ = _login(client)
|
||||
cid = _add_collection(client)
|
||||
rid = _add_row(client, cid, "Trashed")
|
||||
pid = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session}).json()["page_id"]
|
||||
|
||||
# Simulate the user trashing the content page from the editor menu.
|
||||
client.post(f"/api/pages/{pid}/trash", headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session})
|
||||
assert _raw_page_content(pid)["deleted_at"]
|
||||
|
||||
r = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session})
|
||||
assert r.json()["page_id"] == pid
|
||||
page = _raw_page_content(pid)
|
||||
assert page["deleted_at"] is None
|
||||
assert page["parent_section"] == "DbRow"
|
||||
|
||||
|
||||
# ── Listings exclusion ───────────────────────────────────────────
|
||||
|
||||
def test_sidebar_tree_excludes_content_pages(client):
|
||||
session, _, uid = _login(client)
|
||||
cid = _add_collection(client)
|
||||
rid = _add_row(client, cid, "Shadow row")
|
||||
pid = client.get(f"/db/pages/{rid}/open/api").json()["page_id"]
|
||||
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO pages (workspace, workspace_id, title, content, "
|
||||
"content_format, parent_section) VALUES ('ws/tree', NULL, "
|
||||
"'Visible page', '', 'blocks', 'Private')"
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE pages SET workspace='ws/tree' WHERE id=? OR id=?",
|
||||
(pid, pid),
|
||||
)
|
||||
conn.commit()
|
||||
from app.routers.board import _build_page_tree
|
||||
items = _build_page_tree(conn, None, "ws/tree")
|
||||
titles = {i["name"] for i in items}
|
||||
assert "Visible page" in titles
|
||||
assert "Shadow row" not in titles
|
||||
|
||||
|
||||
def test_workspace_tree_excludes_content_pages(client):
|
||||
from app.routers.dashboard import _build_tree_children
|
||||
|
||||
session, csrf, uid = _login(client)
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"INSERT INTO workspaces (name, owner_id) VALUES ('WS65', ?)", (uid,)
|
||||
)
|
||||
ws_id = cur.lastrowid
|
||||
conn.execute(
|
||||
"INSERT INTO pages (workspace, workspace_id, title, content, "
|
||||
"content_format, parent_section) VALUES ('', ?, 'Visible', '', "
|
||||
"'blocks', 'Workspace')",
|
||||
(ws_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# a content page living in the same workspace (link preserved →
|
||||
# the IS NULL filter is what must hide it)
|
||||
cid = _add_collection(client)
|
||||
rid = _add_row(client, cid, "Hidden row")
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET workspace_id=? WHERE collection_row_id=?",
|
||||
(ws_id, rid),
|
||||
)
|
||||
conn.commit()
|
||||
rows = conn.execute(
|
||||
"SELECT title FROM pages WHERE parent_id IS NULL AND workspace_id=? "
|
||||
"AND deleted_at IS NULL AND collection_row_id IS NULL",
|
||||
(ws_id,),
|
||||
).fetchall()
|
||||
items = _build_tree_children(conn, None, ws_id, uid)
|
||||
titles = {r["title"] for r in rows}
|
||||
item_titles = {i.get("title") or i.get("name") for i in items}
|
||||
assert "Visible" in titles
|
||||
assert "Hidden row" not in titles
|
||||
assert "Hidden row" not in item_titles
|
||||
|
||||
|
||||
# ── Server-side resolution on read ───────────────────────────────
|
||||
|
||||
def _page_with_stale_synced(client, session, csrf, uid):
|
||||
"""Page whose stored synced cache is stale (source updated offline)."""
|
||||
sid = create_synced_block(
|
||||
workspace="v65", title="Src",
|
||||
content=[{"type": "paragraph", "content": "VERSION_ONE"}],
|
||||
created_by=uid,
|
||||
)
|
||||
page_id = _create_page(client, session, csrf, "Reader")
|
||||
r = client.post(
|
||||
f"/board/api/pages/{page_id}/blocks",
|
||||
json={
|
||||
"blocks": [
|
||||
{"type": "paragraph", "content": "intro", "id": "b1"},
|
||||
{"type": "synced", "synced_id": sid, "id": "b2",
|
||||
"_synced_content": [{"type": "paragraph", "content": "VERSION_ONE"}],
|
||||
"_synced_title": "Src"},
|
||||
],
|
||||
"title": "Reader",
|
||||
},
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
# Source changes WITHOUT going through the API (worst case: stale cache).
|
||||
update_synced_block(sid, "Src", [{"type": "paragraph", "content": "VERSION_TWO"}])
|
||||
return sid, page_id
|
||||
|
||||
|
||||
def test_content_api_resolves_stale_synced_cache(client):
|
||||
session, csrf, uid = _login(client)
|
||||
sid, page_id = _page_with_stale_synced(client, session, csrf, uid)
|
||||
|
||||
# stored content is still stale…
|
||||
assert "VERSION_ONE" in _raw_page_content(page_id)["content"]
|
||||
|
||||
r = client.get(f"/api/pages/{page_id}/content")
|
||||
assert r.status_code == 200
|
||||
blocks = json.loads(r.json()["content"])
|
||||
synced = [b for b in blocks if b.get("type") == "synced"][0]
|
||||
assert synced["_synced_content"] == [{"type": "paragraph", "content": "VERSION_TWO"}]
|
||||
|
||||
|
||||
def test_editor_render_resolves_stale_synced_cache(client):
|
||||
session, csrf, uid = _login(client)
|
||||
sid, page_id = _page_with_stale_synced(client, session, csrf, uid)
|
||||
|
||||
r = client.get(f"/pages/{page_id}", cookies={"flowdeck_session": session})
|
||||
assert r.status_code == 200
|
||||
# page_data JSON embedded in the editor contains the fresh content
|
||||
assert "VERSION_TWO" in r.text
|
||||
|
||||
|
||||
def test_public_page_resolves_and_renders_synced(client):
|
||||
session, csrf, uid = _login(client)
|
||||
sid, page_id = _page_with_stale_synced(client, session, csrf, uid)
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE pages SET is_published=1, published=1, publish_slug='v65pub' "
|
||||
"WHERE id=?",
|
||||
(page_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
r = client.get("/p/v65pub")
|
||||
assert r.status_code == 200
|
||||
assert "VERSION_TWO" in r.text
|
||||
# no raw block JSON leaked into the public HTML
|
||||
assert '{"type"' not in r.text
|
||||
|
||||
|
||||
# ── Propagation rewrites stored content ──────────────────────────
|
||||
|
||||
def test_put_synced_rewrites_referencing_pages(client):
|
||||
session, csrf, uid = _login(client)
|
||||
sid = create_synced_block(
|
||||
workspace="v65", title="Src",
|
||||
content=[{"type": "paragraph", "content": "V1"}], created_by=uid,
|
||||
)
|
||||
page_id = _create_page(client, session, csrf, "Ref page")
|
||||
r = client.post(
|
||||
f"/board/api/pages/{page_id}/blocks",
|
||||
json={"blocks": [{"type": "synced", "synced_id": sid, "id": "b1",
|
||||
"_synced_content": [{"type": "paragraph", "content": "V1"}]}],
|
||||
"title": "Ref page"},
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
r = client.put(
|
||||
f"/board/api/synced-blocks/{sid}",
|
||||
json={"title": "Src", "content": [{"type": "paragraph", "content": "V3"}]},
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
# The page's STORED content was rewritten (not just resolved at read).
|
||||
raw = _raw_page_content(page_id)["content"]
|
||||
assert "V3" in raw
|
||||
assert "V1" not in raw
|
||||
|
||||
|
||||
def test_propagation_recurses_into_children(client):
|
||||
session, csrf, uid = _login(client)
|
||||
sid = create_synced_block(
|
||||
workspace="v65", title="Nested",
|
||||
content=[{"type": "paragraph", "content": "OLD"}], created_by=uid,
|
||||
)
|
||||
page_id = _create_page(client, session, csrf, "Columns page")
|
||||
r = client.post(
|
||||
f"/board/api/pages/{page_id}/blocks",
|
||||
json={"blocks": [
|
||||
{"type": "columns", "id": "cols", "children": [
|
||||
{"type": "column", "id": "c1", "children": [
|
||||
{"type": "synced", "synced_id": sid, "id": "n1",
|
||||
"_synced_content": [{"type": "paragraph", "content": "OLD"}]},
|
||||
]},
|
||||
]},
|
||||
], "title": "Columns page"},
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert get_page_synced(page_id), "nested synced block must be tracked"
|
||||
|
||||
r = client.put(
|
||||
f"/board/api/synced-blocks/{sid}",
|
||||
json={"title": "Nested", "content": [{"type": "paragraph", "content": "NEW"}]},
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
raw = _raw_page_content(page_id)["content"]
|
||||
assert "NEW" in raw
|
||||
assert "OLD" not in raw
|
||||
|
||||
|
||||
def test_delete_source_marks_instances_deleted(client):
|
||||
session, csrf, uid = _login(client)
|
||||
sid = create_synced_block(
|
||||
workspace="v65", title="Doomed",
|
||||
content=[{"type": "paragraph", "content": "X"}], created_by=uid,
|
||||
)
|
||||
page_id = _create_page(client, session, csrf, "Victim")
|
||||
r = client.post(
|
||||
f"/board/api/pages/{page_id}/blocks",
|
||||
json={"blocks": [{"type": "synced", "synced_id": sid, "id": "b1",
|
||||
"_synced_content": [{"type": "paragraph", "content": "X"}]}],
|
||||
"title": "Victim"},
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
r = client.delete(
|
||||
f"/board/api/synced-blocks/{sid}",
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert get_synced_block(sid) is None
|
||||
assert get_page_synced(page_id) == [] # refs cascaded away
|
||||
|
||||
raw = json.loads(_raw_page_content(page_id)["content"])
|
||||
synced = [b for b in raw if b.get("type") == "synced"][0]
|
||||
assert synced.get("_synced_deleted") is True
|
||||
assert "_synced_content" not in synced
|
||||
|
||||
# read path agrees
|
||||
r = client.get(f"/api/pages/{page_id}/content")
|
||||
blocks = json.loads(r.json()["content"])
|
||||
assert [b for b in blocks if b.get("type") == "synced"][0].get("_synced_deleted") is True
|
||||
|
||||
|
||||
# ── Synced blocks inside database rows (the v6 point) ────────────
|
||||
|
||||
def test_synced_block_lives_and_syncs_inside_a_db_row(client):
|
||||
session, csrf, uid = _login(client)
|
||||
cid = _add_collection(client)
|
||||
rid = _add_row(client, cid, "Row with content")
|
||||
pid = client.get(f"/db/pages/{rid}/open/api", cookies={"flowdeck_session": session}).json()["page_id"]
|
||||
|
||||
sid = create_synced_block(
|
||||
workspace="v65", title="Row synced",
|
||||
content=[{"type": "paragraph", "content": "ROW_V1"}], created_by=uid,
|
||||
)
|
||||
r = client.post(
|
||||
f"/board/api/pages/{pid}/blocks",
|
||||
json={"blocks": [{"type": "synced", "synced_id": sid, "id": "b1",
|
||||
"_synced_content": [{"type": "paragraph", "content": "ROW_V1"}]}],
|
||||
"title": "Row with content"},
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert any(x["synced_block_id"] == sid for x in get_page_synced(pid))
|
||||
# saving blocks with a title synced the row title too
|
||||
with get_conn() as conn:
|
||||
assert conn.execute("SELECT title FROM collection_pages WHERE id=?", (rid,)).fetchone()["title"] == "Row with content"
|
||||
|
||||
# update the source → the ROW's content page is rewritten
|
||||
r = client.put(
|
||||
f"/board/api/synced-blocks/{sid}",
|
||||
json={"title": "Row synced", "content": [{"type": "paragraph", "content": "ROW_V2"}]},
|
||||
headers=_hdr(csrf),
|
||||
cookies={"flowdeck_session": session},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert "ROW_V2" in _raw_page_content(pid)["content"]
|
||||
|
||||
|
||||
def test_api_v2_row_exposes_content_page_id(client):
|
||||
r = client.post("/auth/register", json={
|
||||
"email": "[email protected]", "password": "secret123", "name": "v65api",
|
||||
})
|
||||
assert r.status_code == 200, r.text
|
||||
tok = client.post("/api/v1/token").json()["token"]
|
||||
headers = {"Authorization": f"Bearer {tok}"}
|
||||
|
||||
cid = _add_collection(client)
|
||||
rid = _add_row(client, cid, "Api row")
|
||||
pid = client.get(f"/db/pages/{rid}/open/api").json()["page_id"]
|
||||
|
||||
r = client.get(f"/api/v2/pages/{rid}", headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json().get("content_page_id") == pid
|
||||
|
||||
# row without content page → null (no lazy creation on read)
|
||||
rid2 = _add_row(client, cid, "No content yet")
|
||||
r = client.get(f"/api/v2/pages/{rid2}", headers=headers)
|
||||
assert r.json().get("content_page_id") is None
|
||||
Reference in New Issue
Block a user