"""FlowDeck — Board : pages. Découpe A28 de l'ancien app/routers/board.py (2 101 lignes, 53 routes) — un module par concern, contrat inchangé. """ from __future__ import annotations import json import logging from fastapi import APIRouter, Body, HTTPException, Query, Request from app.auth.session import SessionManager from app.db import get_conn from app.services.automations import fire_event, run_event_sync from app.services.permission_manager import PermissionManager from ._common import _block_texts, _ensure_page_editable, _record_version logger = logging.getLogger(__name__) router = APIRouter(tags=["board"], prefix="/board") # ═══════════ Pages Markdown ═══════════ @router.post("/api/pages") 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", "")) if not user or not user.get("id"): # A7 : la création de page exige une session (route sortue de la liste CSRF). raise HTTPException(401, "Authentication required") ws_key = project if project else (user.get("login", "Bruno") if user else "Bruno") page_title = title.strip() if title else "" try: 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, page_title, section, parent_val, next_order), ) conn.commit() page_id = cur.lastrowid run_event_sync(fire_event("page.created", {"page_id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id})) return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id} except Exception as e: logger.error("create_page failed: %s", e) from fastapi.responses import JSONResponse return JSONResponse({"error": "Failed to create page", "detail": str(e)}, status_code=500) @router.get("/api/pages/{page_id}") def get_page(request: Request, page_id: int): """Get a Markdown page.""" user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user or not user.get("id"): raise HTTPException(401, "Authentication required") # v6.0.0: granular page permissions — 404 (not 403) hides restricted pages. if not PermissionManager(user["id"], bool(user.get("is_admin"))).can_view_page(page_id): raise HTTPException(404, "Page not found") 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}") def update_page(request: Request, page_id: int, title: str = Query(default=""), content: str = Query(default=""), content_format: str = Query(default="")): """Update a page's title and/or content. Accepts JSON body for blocks.""" user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user or not user.get("id"): raise HTTPException(403, "Authentication required") # v6.0.0: granular page permissions — hidden pages 404 (not 403); a visible # page the caller cannot edit yields 403. pm = PermissionManager(user["id"], bool(user.get("is_admin"))) if not pm.can_view_page(page_id): raise HTTPException(404, "Page not found") if not pm.can_edit_page(page_id): raise HTTPException(403, "You don't have edit access to this page") with get_conn() as conn: _ensure_page_editable(conn, page_id, user) if title: conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id)) # 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: conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id)) conn.commit() run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title, "content_format": content_format or "markdown", "actor_id": user.get("id")})) return {"status": "ok"} @router.post("/api/pages/{page_id}/blocks") def save_page_blocks(request: Request, page_id: int, body: dict = Body(...)): """Save blocks JSON content (Notion-style block editor). v5.4.0: a version snapshot is recorded (if the block content actually changed) so the UI can browse the version history and restore any of them. v5.14.0: synced block references are tracked in page_synced_blocks. """ blocks = body.get("blocks", []) blocks_json = json.dumps(blocks) title = body.get("title", "") user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) uid = (user or {}).get("id") # No session → legacy single-user behaviour; otherwise enforce edit rights. if uid and not PermissionManager(uid).can_edit_page(page_id): raise HTTPException(403, "You don't have edit access to this page") # Extract synced block ids from the blocks def _extract_synced(blocks: list[dict]) -> set[int]: ids: set[int] = set() for b in blocks: if b.get("type") == "synced" and b.get("synced_id"): ids.add(b["synced_id"]) if isinstance(b.get("children"), list): ids |= _extract_synced(b["children"]) return ids synced_ids = _extract_synced(blocks) with get_conn() as conn: _ensure_page_editable(conn, page_id, user) if title: conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id)) # 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), ) _record_version(conn, page_id, uid, title or "", blocks_json) # Update synced block references existing = {r["synced_block_id"] for r in conn.execute( "SELECT synced_block_id FROM page_synced_blocks WHERE page_id=?", (page_id,) ).fetchall()} for sid in synced_ids: if sid not in existing: conn.execute( "INSERT OR IGNORE INTO page_synced_blocks (page_id, synced_block_id, block_index) VALUES (?, ?, 0)", (page_id, sid), ) for sid in existing - synced_ids: conn.execute( "DELETE FROM page_synced_blocks WHERE page_id=? AND synced_block_id=?", (page_id, sid), ) conn.commit() run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title or "", "content_format": "blocks", "actor_id": uid})) return {"status": "ok", "id": page_id} @router.get("/api/pages/{page_id}/backlinks") def page_backlinks(request: Request, page_id: int): """v5.4.0: pages that link to this one ("Lié depuis…"). Scans every non-deleted page's blocks (and raw markdown) for an internal reference to ``/pages/{page_id}`` or ``#fdblk-…`` inside ``/pages/{page_id}``. """ target = f"/pages/{page_id}" if page_id else None wiki_target = f"[[fdpage:{page_id}]]" if page_id else None backlinks = [] with get_conn() as conn: rows = conn.execute( "SELECT id, title, workspace, content, content_format, updated_at " "FROM pages WHERE deleted_at IS NULL AND id != ?", (page_id,), ).fetchall() for r in rows: fmt = r["content_format"] hits = False if fmt == "blocks" and r["content"]: try: blocks = json.loads(r["content"]) for b in blocks if isinstance(blocks, list) else []: for text in _block_texts(b): if target and (target in text or (wiki_target and wiki_target in text)): hits = True break if hits: break except (json.JSONDecodeError, TypeError): hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or ""))) elif fmt == "markdown": hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or ""))) elif r["content"]: hits = target and (target in json.dumps(r["content"]) or (wiki_target and wiki_target in json.dumps(r["content"]))) if not hits and target: hits = f"/pages/{page_id}" in (r["content"] or "") if hits: backlinks.append({ "id": r["id"], "title": r["title"] or "Untitled", "workspace": r["workspace"] or "", "updated_at": r["updated_at"] or "", }) backlinks.sort(key=lambda x: x.get("updated_at") or "", reverse=True) return {"backlinks": backlinks} @router.get("/api/pages/{page_id}/versions") def page_versions(request: Request, page_id: int): """v5.4.0: version history for a block-editor page.""" with get_conn() as conn: rows = conn.execute( "SELECT pv.id, pv.title, pv.note, pv.created_at, " "COALESCE(u.login, '') AS author " "FROM page_versions pv LEFT JOIN users u ON u.id=pv.user_id " "WHERE pv.page_id=? ORDER BY pv.id DESC LIMIT 100", (page_id,), ).fetchall() return {"versions": [dict(r) for r in rows]} @router.post("/api/pages/{page_id}/versions/{version_id}/restore") def restore_version(request: Request, page_id: int, version_id: int): """v5.4.0: restore a page from a version snapshot.""" with get_conn() as conn: ver = conn.execute( "SELECT * FROM page_versions WHERE id=? AND page_id=?", (version_id, page_id), ).fetchone() if not ver: raise HTTPException(404, "Version not found") conn.execute( "UPDATE pages SET content=?, title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (ver["blocks_json"], ver["title"] or "", page_id), ) conn.commit() run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "", "content_format": "blocks"})) return {"status": "ok", "restored": version_id} # ═══════════ v5.4.0: Page & collection duplication ═══════════