feat(wiki,templates): v5.11.0 wiki-links & mentions + v5.12.0 templates & page lock (release 5.12.0)
v5.11.0 Wiki-links & mentions de page :
- tokens [[fdpage:ID]] / [[fddate:ISO]] dans le texte des blocs,
service app/services/wiki_links.py (labels, rendu HTML, extraction)
- taper [[ ouvre le picker de pages (recherche floue, clavier) ;
le menu @ gagne les sections Pages et Date (today/tomorrow/YYYY-MM-DD)
- chips atomiques contenteditable=false relues en tokens par gtTok()
(autosave/drag/undo preservent les liens) ; renommage propage via
GET /board/api/wiki/titles ; backlinks reconnaissent les tokens ;
page publique rend les chips (echopee)
v5.12.0 Templates & verrouillage :
- template picker global sur + New page : 5 built-in
(app/services/block_templates.py) + templates perso
(table page_global_templates, migration 13)
- POST /board/api/page-templates (save current page) + /{id}/use
(instantiate, id 0 = built-in par cle)
- page lock : POST /api/pages/{id}/lock, garde _ensure_page_editable
-> 423 en ecriture pour les non-privileged, deblocage par
locked_by ou admin seulement (403 sinon), banniere + read-only UI
- full-width / small text par page (pages.full_width/font_small,
POST /api/pages/{id}/options, classes CSS)
- migration 13 : is_locked, locked_by, full_width, font_small,
page_global_templates
Tests : tests/test_v511_v512_wiki_templates.py (15) ; suite complete
538 verte ; ruff OK ; node --check des templates JS OK.
This commit is contained in:
+261
-5
@@ -26,6 +26,250 @@ AI_KEYWORD_COLORS = [
|
||||
"#E255A1", "#787774", "#6B4E3D", "#6374C4", "#5A9E4B",
|
||||
]
|
||||
|
||||
def _ensure_page_editable(conn, page_id: int, user: dict | None) -> None:
|
||||
"""v5.12.0: raise 423 when the page is locked and the actor may not edit.
|
||||
|
||||
Allowed to edit a locked page: admins and the user who locked it
|
||||
(locked_by). Unauthenticated callers only pass when the page is unlocked.
|
||||
"""
|
||||
row = conn.execute("SELECT is_locked, locked_by FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row or not row["is_locked"]:
|
||||
return
|
||||
uid = (user or {}).get("id")
|
||||
is_admin = bool((user or {}).get("is_admin"))
|
||||
if is_admin or (uid and row["locked_by"] == uid):
|
||||
return
|
||||
raise HTTPException(423, "Page is locked — only the owner of the lock or an admin can edit")
|
||||
|
||||
|
||||
@router.get("/api/wiki/pages")
|
||||
async def wiki_page_search(request: Request, q: str = Query(default="")):
|
||||
"""v5.11.0: page picker for [[ wiki-links. Fuzzy-ish title search across
|
||||
every non-deleted page the current user can see (single source: pages)."""
|
||||
q = (q or "").strip().lower()
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT id, title, page_icon, workspace FROM pages
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY updated_at DESC LIMIT 500"""
|
||||
).fetchall()
|
||||
results = []
|
||||
for r in rows:
|
||||
title = r["title"] or "Untitled"
|
||||
if q:
|
||||
# subsequence match ("mtg" → "Meeting notes") or plain substring.
|
||||
hay = title.lower()
|
||||
it = iter(hay)
|
||||
subseq = all(ch in it for ch in q)
|
||||
if q not in hay and not subseq:
|
||||
continue
|
||||
results.append({
|
||||
"id": r["id"],
|
||||
"title": title,
|
||||
"icon": r["page_icon"] or "",
|
||||
"workspace": r["workspace"] or "",
|
||||
})
|
||||
if len(results) >= 20:
|
||||
break
|
||||
return {"pages": results}
|
||||
|
||||
|
||||
@router.get("/api/wiki/titles")
|
||||
async def wiki_titles(request: Request, ids: str = Query(default="")):
|
||||
"""v5.11.0: resolve page-id lists to current labels (rename propagation)."""
|
||||
parsed: list[int] = []
|
||||
for part in (ids or "").split(","):
|
||||
part = part.strip()
|
||||
if part.isdigit():
|
||||
parsed.append(int(part))
|
||||
parsed = parsed[:200]
|
||||
out: dict[str, str] = {}
|
||||
if parsed:
|
||||
placeholders = ",".join("?" * len(parsed))
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT id, title, page_icon, deleted_at FROM pages WHERE id IN ({placeholders})",
|
||||
parsed,
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
if r["deleted_at"]:
|
||||
out[str(r["id"])] = "Deleted page"
|
||||
else:
|
||||
icon = (r["page_icon"] or "")
|
||||
out[str(r["id"])] = (icon + " " if icon else "") + (r["title"] or "Untitled")
|
||||
return {"titles": out}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/lock")
|
||||
async def set_page_lock(request: Request, page_id: int):
|
||||
"""v5.12.0: lock/unlock a page (read-only for everyone except the locker,
|
||||
admins and the page creator)."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
locked = bool(body.get("locked"))
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id, is_locked, locked_by, workspace FROM pages WHERE id=?",
|
||||
(page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
is_admin = 1 if user.get("is_admin") else 0
|
||||
if row["is_locked"] and not is_admin and row["locked_by"] != user["id"]:
|
||||
raise HTTPException(403, "Only the person who locked this page (or an admin) can unlock it")
|
||||
conn.execute("UPDATE pages SET is_locked=?, locked_by=? WHERE id=?",
|
||||
(1 if locked else 0, user["id"] if locked else None, page_id))
|
||||
conn.commit()
|
||||
await fire_event("page.locked" if locked else "page.unlocked",
|
||||
{"page_id": page_id, "by": user["id"]})
|
||||
return {"status": "ok", "is_locked": int(locked)}
|
||||
|
||||
|
||||
@router.post("/api/pages/{page_id}/options")
|
||||
async def set_page_options(request: Request, page_id: int):
|
||||
"""v5.12.0: page layout options — full-width and compact typography."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
updates = {}
|
||||
for key in ("full_width", "font_small"):
|
||||
if key in body:
|
||||
updates[key] = 1 if body[key] else 0
|
||||
if not updates:
|
||||
raise HTTPException(400, "nothing to update")
|
||||
sets = ", ".join(f"{k}=?" for k in updates)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
conn.execute(f"UPDATE pages SET {sets}, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(*updates.values(), page_id))
|
||||
conn.commit()
|
||||
return {"status": "ok", **{k: bool(v) for k, v in updates.items()}}
|
||||
|
||||
|
||||
@router.get("/api/page-templates")
|
||||
async def list_page_templates_api(request: Request):
|
||||
"""v5.12.0: built-in + user global page templates for the picker."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
uid = (user or {}).get("id")
|
||||
from app.services.block_templates import template_list
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT id, name, icon, description, created_by
|
||||
FROM page_global_templates
|
||||
WHERE created_by IS NULL OR created_by=?
|
||||
ORDER BY created_at""",
|
||||
(uid,),
|
||||
).fetchall()
|
||||
mine = [dict(r) for r in rows]
|
||||
for t in mine:
|
||||
t["builtin"] = False
|
||||
return {"templates": template_list() + mine}
|
||||
|
||||
|
||||
@router.post("/api/page-templates")
|
||||
async def create_page_template(request: Request):
|
||||
"""v5.12.0: save the current page (or a raw block list) as a personal
|
||||
global template: {name, icon?, description?, page_id? | blocks?}."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
if not user or not user.get("id"):
|
||||
raise HTTPException(401, "Authentication required")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(400, "name is required")
|
||||
blocks = body.get("blocks")
|
||||
if body.get("page_id"):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT content, content_format FROM pages WHERE id=?",
|
||||
(int(body["page_id"]),)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Page not found")
|
||||
if row["content_format"] == "blocks" and row["content"]:
|
||||
try:
|
||||
blocks = json.loads(row["content"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raise HTTPException(400, "Page content is not block JSON") from None
|
||||
if not isinstance(blocks, list) or not blocks:
|
||||
raise HTTPException(400, "blocks (or page_id) required")
|
||||
with get_conn() as conn:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO page_global_templates (name, icon, description, blocks_json, created_by)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(name, body.get("icon") or "📄", body.get("description") or "",
|
||||
json.dumps(blocks), user["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
tid = cur.lastrowid
|
||||
return {"status": "ok", "id": tid}
|
||||
|
||||
|
||||
@router.post("/api/page-templates/{template_id}/use")
|
||||
async def use_page_template(request: Request, template_id: int):
|
||||
"""v5.12.0: instantiate a page from a template (built-in or user).
|
||||
|
||||
Body: {key?} for built-ins OR uses the row id for user templates.
|
||||
Creates 'blocks'-format page in the caller's workspace and returns its id.
|
||||
"""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
title = (body.get("title") or "").strip()
|
||||
blocks_json = None
|
||||
if template_id == 0:
|
||||
from app.services.block_templates import blocks_json_for
|
||||
key = body.get("key") or "empty"
|
||||
blocks_json = blocks_json_for(key)
|
||||
name = key
|
||||
if blocks_json is None:
|
||||
raise HTTPException(404, "Unknown built-in template")
|
||||
else:
|
||||
with get_conn() as conn:
|
||||
uid = (user or {}).get("id")
|
||||
row = conn.execute(
|
||||
"SELECT * FROM page_global_templates WHERE id=? AND (created_by IS NULL OR created_by=?)",
|
||||
(template_id, uid),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Template not found")
|
||||
blocks_json = row["blocks_json"]
|
||||
name = row["name"]
|
||||
title = title or row["name"]
|
||||
ws_key = user.get("login", "Bruno") if user else "Bruno"
|
||||
try:
|
||||
json.loads(blocks_json)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raise HTTPException(500, "Template content corrupted") from None
|
||||
with get_conn() as conn:
|
||||
next_order = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL",
|
||||
(ws_key,),
|
||||
).fetchone()[0]
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO pages (workspace, title, content, content_format, parent_section, sort_order)
|
||||
VALUES (?,?,?,?, 'Private', ?)""",
|
||||
(ws_key, title or name, blocks_json, "blocks", next_order),
|
||||
)
|
||||
conn.commit()
|
||||
page_id = cur.lastrowid
|
||||
await fire_event("page.created", {"page_id": page_id, "title": title or name,
|
||||
"workspace": ws_key, "from_template": name})
|
||||
return {"status": "ok", "id": page_id, "title": title or name}
|
||||
|
||||
|
||||
# ── Core helpers ──
|
||||
|
||||
def _issue_column(issue: dict, columns: list[str], board_id: int) -> str:
|
||||
@@ -972,7 +1216,9 @@ async def update_page(request: Request, page_id: int, title: str = Query(default
|
||||
content: str = Query(default=""),
|
||||
content_format: str = Query(default="")):
|
||||
"""Update a page's title and/or content. Accepts JSON body for blocks."""
|
||||
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
with get_conn() as conn:
|
||||
_ensure_page_editable(conn, page_id, user)
|
||||
if title:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
||||
if content:
|
||||
@@ -1002,6 +1248,7 @@ async def save_page_blocks(request: Request, page_id: int):
|
||||
uid = (user or {}).get("id")
|
||||
|
||||
with get_conn() as conn:
|
||||
_ensure_page_editable(conn, page_id, user)
|
||||
if title:
|
||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
||||
conn.execute(
|
||||
@@ -1055,6 +1302,7 @@ async def page_backlinks(request: Request, page_id: int):
|
||||
reference to ``/pages/{page_id}`` or ``#fdblk-…`` inside ``/pages/{page_id}``.
|
||||
"""
|
||||
target = f"/pages/{page_id}" if page_id else None
|
||||
wiki_target = f"[[fdpage:{page_id}]]" if page_id else None
|
||||
backlinks = []
|
||||
with get_conn() as conn:
|
||||
rows = conn.execute(
|
||||
@@ -1070,17 +1318,17 @@ async def page_backlinks(request: Request, page_id: int):
|
||||
blocks = json.loads(r["content"])
|
||||
for b in blocks if isinstance(blocks, list) else []:
|
||||
for text in _block_texts(b):
|
||||
if target and f"/pages/{page_id}" in text:
|
||||
if target and (target in text or (wiki_target and wiki_target in text)):
|
||||
hits = True
|
||||
break
|
||||
if hits:
|
||||
break
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
hits = target and f"/pages/{page_id}" in (r["content"] or "")
|
||||
hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or "")))
|
||||
elif fmt == "markdown":
|
||||
hits = target and f"/pages/{page_id}" in (r["content"] or "")
|
||||
hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or "")))
|
||||
elif r["content"]:
|
||||
hits = target and f"/pages/{page_id}" in json.dumps(r["content"])
|
||||
hits = target and (target in json.dumps(r["content"]) or (wiki_target and wiki_target in json.dumps(r["content"])))
|
||||
if not hits and target:
|
||||
hits = f"/pages/{page_id}" in (r["content"] or "")
|
||||
if hits:
|
||||
@@ -1492,7 +1740,15 @@ async def view_page(request: Request, page_id: int):
|
||||
).fetchone()
|
||||
|
||||
# Build page_data, including file metadata for uploaded files
|
||||
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)), "cover_url": page.get("cover_url", "") or "", "page_icon": page.get("page_icon", "") or ""}
|
||||
_locked = bool(page.get("is_locked", 0))
|
||||
_locked_by = page.get("locked_by") if "locked_by" in page else None
|
||||
_can_edit = (not _locked) or bool(user and user.get("is_admin")) or (uid and _locked_by == uid)
|
||||
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)), "cover_url": page.get("cover_url", "") or "", "page_icon": page.get("page_icon", "") or "",
|
||||
"is_locked": _locked,
|
||||
"locked_by": _locked_by,
|
||||
"can_edit": _can_edit,
|
||||
"full_width": bool(page.get("full_width", 0)) if "full_width" in page else False,
|
||||
"font_small": bool(page.get("font_small", 0)) if "font_small" in page else False}
|
||||
|
||||
# For file pages, extract file metadata and add to page_data
|
||||
if page.get("content_format") == "file":
|
||||
|
||||
@@ -338,7 +338,17 @@ async def view_page_root(request: Request, page_id: int):
|
||||
).fetchone()
|
||||
|
||||
# Build page_data, including file metadata for uploaded files
|
||||
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0))}
|
||||
_locked = bool(page.get("is_locked", 0))
|
||||
_locked_by = page.get("locked_by") if "locked_by" in page else None
|
||||
_sess_user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
||||
_uid = _sess_user.get("id") if _sess_user and _sess_user.get("id") else None
|
||||
_can_edit = (not _locked) or bool(_sess_user and _sess_user.get("is_admin")) or (_locked_by and _uid and _locked_by == _uid)
|
||||
page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)),
|
||||
"is_locked": _locked,
|
||||
"locked_by": _locked_by,
|
||||
"can_edit": _can_edit,
|
||||
"full_width": bool(page.get("full_width", 0)) if "full_width" in page else False,
|
||||
"font_small": bool(page.get("font_small", 0)) if "font_small" in page else False}
|
||||
|
||||
# For file pages, extract file metadata and add to page_data
|
||||
if page.get("content_format") == "file":
|
||||
@@ -2151,7 +2161,11 @@ h1{font-size:3rem;opacity:.3}</style></head><body><h1>404</h1></body></html>""",
|
||||
import json as _json
|
||||
try:
|
||||
blocks = _json.loads(page["content"])
|
||||
content_html = _render_blocks_public(blocks)
|
||||
from app.db import get_conn as _gc
|
||||
from app.services.wiki_links import token_labels
|
||||
with _gc() as conn:
|
||||
wiki_titles_map = token_labels(conn, page["content"])
|
||||
content_html = _render_blocks_public(blocks, wiki_titles_map)
|
||||
except (_json.JSONDecodeError, Exception):
|
||||
content_html = f"<p>{page.get('content', '')}</p>"
|
||||
elif page.get("content"):
|
||||
@@ -2177,12 +2191,23 @@ def _sanitize_id(block_id: str) -> str:
|
||||
return "".join(ch for ch in str(block_id) if ch.isalnum())
|
||||
|
||||
|
||||
def _render_blocks_public(blocks: list) -> str:
|
||||
"""Render FlowDeck blocks as plain HTML for public pages."""
|
||||
def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
|
||||
"""Render FlowDeck blocks as plain HTML for public pages.
|
||||
|
||||
v5.11.0: ``titles`` (token → label, see app.services.wiki_links) turns
|
||||
``[[fdpage:ID]]`` / ``[[fddate:...]]`` tokens into chips/links.
|
||||
"""
|
||||
html_parts = []
|
||||
|
||||
def _wiki(c: str) -> str:
|
||||
if titles and ("[[fdpage:" in c or "[[fddate:" in c):
|
||||
from app.services.wiki_links import resolve_tokens_html
|
||||
return resolve_tokens_html(c, titles)
|
||||
return c
|
||||
|
||||
for b in blocks:
|
||||
t = b.get("type", "paragraph")
|
||||
c = b.get("content", "") or ""
|
||||
c = _wiki(b.get("content", "") or "")
|
||||
if t == "heading_1":
|
||||
html_parts.append(f'<h1 id="h-{_sanitize_id(b.get("id",""))}" style="font-size:2.5rem;font-weight:700;margin:32px 0 8px;">{c}</h1>')
|
||||
elif t == "heading_2":
|
||||
@@ -2208,7 +2233,7 @@ def _render_blocks_public(blocks: list) -> str:
|
||||
children_html = ""
|
||||
if b.get("children"):
|
||||
children_html = '<div style="margin-left:22px;padding-left:12px;border-left:1px solid rgba(255,255,255,.1);margin-top:4px;">'
|
||||
children_html += _render_blocks_public(b["children"])
|
||||
children_html += _render_blocks_public(b["children"], titles)
|
||||
children_html += "</div>"
|
||||
html_parts.append(
|
||||
f'<details style="margin:8px 0;" open><summary style="cursor:pointer;font-weight:500;">{c}</summary>{children_html}</details>'
|
||||
@@ -2246,7 +2271,7 @@ def _render_blocks_public(blocks: list) -> str:
|
||||
cols_html += (
|
||||
'<div style="flex:1;min-width:0;padding:10px 12px;background:rgba(255,255,255,.05);'
|
||||
'border-radius:8px;box-sizing:border-box;">'
|
||||
+ _render_blocks_public([child]) + "</div>"
|
||||
+ _render_blocks_public([child], titles) + "</div>"
|
||||
)
|
||||
html_parts.append(
|
||||
f'<div style="display:flex;gap:12px;margin:8px 0 16px;align-items:stretch;">{cols_html}</div>'
|
||||
|
||||
Reference in New Issue
Block a user