feat(wiki,templates): v5.11.0 wiki-links & mentions + v5.12.0 templates & page lock (release 5.12.0)
FlowDeck CI / lint (push) Successful in 50s
FlowDeck CI / test (push) Successful in 5m41s
FlowDeck CI / docker (push) Successful in 45s

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:
2026-09-14 06:38:09 -04:00
parent d4adf89db5
commit 9dfc38706c
14 changed files with 1439 additions and 95 deletions
+103
View File
@@ -0,0 +1,103 @@
"""FlowDeck — Built-in page (block) templates (v5.12.0).
Global page templates used by the « + New page » picker. Built-ins live here
(code, versioned); user templates live in the ``page_global_templates``
table. Block shapes match the editor's storage format (see
``app/routers/board.py::save_page_blocks``) — ids are assigned client-side.
"""
from __future__ import annotations
import json
def _b(btype: str, content: str = "", **extra) -> dict:
out = {"type": btype, "content": content}
out.update(extra)
return out
BUILTIN_TEMPLATES: dict[str, dict] = {
"empty": {
"name": "Empty",
"icon": "📄",
"description": "A blank page.",
"blocks": [_b("paragraph")],
},
"meeting_notes": {
"name": "Meeting notes",
"icon": "🗒️",
"description": "Attendees, agenda, notes, action items.",
"blocks": [
_b("heading_1", "Meeting notes"),
_b("callout", "Date: · Time: · Attendees: ", icon="📅"),
_b("heading_2", "Agenda"),
_b("bulleted_list", "Topic 1"),
_b("bulleted_list", "Topic 2"),
_b("heading_2", "Notes"),
_b("paragraph"),
_b("heading_2", "Decisions"),
_b("bulleted_list"),
_b("heading_2", "Action items"),
_b("to_do", "Owner — due date", checked=False),
_b("to_do", "", checked=False),
],
},
"weekly_report": {
"name": "Weekly report",
"icon": "📊",
"description": "Wins, in progress, blockers, next week.",
"blocks": [
_b("heading_1", "Week of [[fddate:2026-01-05]]"),
_b("heading_2", "🎉 Wins"),
_b("bulleted_list"),
_b("heading_2", "🚧 In progress"),
_b("bulleted_list"),
_b("heading_2", "⛔ Blockers"),
_b("bulleted_list"),
_b("heading_2", "🗓️ Next week"),
_b("to_do", "", checked=False),
],
},
"todo_list": {
"name": "To-do list",
"icon": "✅",
"description": "A simple checklist.",
"blocks": [
_b("heading_1", "To-do"),
_b("to_do", "", checked=False),
_b("to_do", "", checked=False),
_b("to_do", "", checked=False),
],
},
"project_doc": {
"name": "Project doc",
"icon": "🚀",
"description": "Goals, status, team, links.",
"blocks": [
_b("heading_1", "Project title"),
_b("callout", "One-line description of the project.", icon="💡"),
_b("heading_2", "Goals"),
_b("numbered_list"),
_b("heading_2", "Status"),
_b("toggle", "This week", expanded=True, children=[_b("paragraph")]),
_b("heading_2", "Team"),
_b("bulleted_list"),
_b("heading_2", "Resources"),
_b("bulleted_list"),
],
},
}
def template_list() -> list[dict]:
"""Public shape of the built-in templates for the picker UI."""
return [
{"key": key, "name": t["name"], "icon": t["icon"],
"description": t["description"], "builtin": True}
for key, t in BUILTIN_TEMPLATES.items()
]
def blocks_json_for(key: str) -> str | None:
t = BUILTIN_TEMPLATES.get(key)
return json.dumps(t["blocks"]) if t else None
+90
View File
@@ -0,0 +1,90 @@
"""FlowDeck — Wiki-links & page mentions (v5.11.0).
Inline references live inside plain-text block content as tokens::
[[fdpage:123]] link to page id 123, label resolved at render time
[[fddate:2026-10-01]] date chip (static label)
The page token stores only the id, so renaming a page propagates everywhere
(the label is resolved through the DB / the titles endpoint at render time).
Backlinks keep working because the server renderer emits ``/pages/<id>``
anchors, and the backlink scanner also matches the raw token.
Pure-stdlib helpers shared by the public renderer and the block export.
"""
from __future__ import annotations
import html as _html
import re
WIKI_PAGE_RE = re.compile(r"\[\[fdpage:(\d+)\]\]")
WIKI_DATE_RE = re.compile(r"\[\[fddate:(\d{4}-\d{2}-\d{2})(?:T[0-9:]{5,8})?\]\]")
_DATE_LABELS = {0: "Mon", 1: "Tue", 2: "Wed", 3: "Thu", 4: "Fri", 5: "Sat", 6: "Sun"}
_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
def extract_page_ids(text: str) -> list[int]:
"""All page ids referenced by wiki tokens in a content string."""
return sorted({int(m) for m in WIKI_PAGE_RE.findall(text or "")})
def _date_label(iso: str) -> str:
try:
parts = iso.split("-")
y, m, d = int(parts[0]), int(parts[1]), int(parts[2])
import datetime as _dt
wd = _dt.date(y, m, d).weekday()
return f"{_DATE_LABELS[wd]} {d} {_MONTHS[m-1]} {y}"
except (ValueError, IndexError):
return iso
def token_labels(conn, content: str) -> dict[str, str]:
"""Map every token in ``content`` to its display label (page title)."""
out: dict[str, str] = {}
ids = extract_page_ids(content)
if ids:
placeholders = ",".join("?" * len(ids))
rows = conn.execute(
f"SELECT id, title, page_icon FROM pages WHERE id IN ({placeholders})", ids,
).fetchall()
by_id = {str(r["id"]): (r["title"] or "Untitled", r["page_icon"] or "") for r in rows}
for token_id in ids:
title, icon = by_id.get(str(token_id), ("Deleted page", ""))
out[f"[[fdpage:{token_id}]]"] = f"{icon + ' ' if icon else ''}{title}"
for m in WIKI_DATE_RE.finditer(content or ""):
iso = m.group(1)
out[f"[[fddate:{iso}]]"] = _date_label(iso)
return out
def resolve_tokens_html(content: str, titles: dict[str, str]) -> str:
"""Escape plain-text content then turn wiki tokens into HTML chips/links.
``titles`` maps token → label (from :func:`token_labels`). Used by the
public page renderer and any server-side HTML output of block content.
"""
s = _html.escape(content or "")
def _page(m: re.Match) -> str:
pid = m.group(1)
label = titles.get(m.group(0)) or "Deleted page"
return (f'<a class="fd-wiki-link" href="/pages/{pid}" '
f'data-pid="{pid}" title="{_html.escape(label)}">{_html.escape(label)}</a>')
def _date(m: re.Match) -> str:
iso = m.group(1)
label = titles.get(f"[[fddate:{iso}]]") or iso
return f'<span class="fd-wiki-date" title="{_html.escape(iso)}">{_html.escape(label)}</span>'
s = WIKI_PAGE_RE.sub(_page, s)
s = WIKI_DATE_RE.sub(_date, s)
return s
def find_referring(content: str, page_id: int) -> bool:
"""True when the content references ``page_id`` (anchor or wiki token)."""
if not content:
return False
return (f"/pages/{page_id}" in content) or (f"[[fdpage:{page_id}]]" in content)