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
+61
View File
@@ -1,5 +1,66 @@
# Changelog - FlowDeck
## v5.12.0 (2026-09-14) — v5.11.0 Wiki-links & v5.12.0 Templates & lock
> Deux versions roadmap livrées ensemble : le graphe de connaissances
> Notion (liens `[[`, mentions de page et de date, renommage propagé) et
> les options de page (galerie de templates globale, verrouillage
> lecture-seule, pleine largeur, texte compact).
### v5.11.0 — Wiki-links & mentions de page
- **Service** `app/services/wiki_links.py` — tokens stockés dans le texte
des blocs : `[[fdpage:ID]]` (lien interne résolu à l'affichage) et
`[[fddate:YYYY-MM-DD]]` (chip de date). Le token ne contient que l'id →
**renommage propagé** automatiquement.
- **Endpoints** — `GET /board/api/wiki/pages?q=` (picker, recherche
substring + sous-séquence floue « mnt » → « Meeting notes ») et
`GET /board/api/wiki/titles?ids=` (labels courants en batch, icônes
incluses, « Deleted page » si supprimée).
- **Éditeur** — taper `[[` ouvre le picker de pages (recherche live,
navigation clavier ↑↓/Entrée/Échap, sélection à la souris), Enter
insère une **chip de page** atomique (icône + titre, clique = navigate).
Le menu `@` existant gagne deux sections : **Pages** (`@Nom` → lien
inline) et **Date** (`@today`, `@tomorrow`, `@YYYY-MM-DD` → chips).
Les chips sont relu en tokens bruts (`gtTok`) à chaque sync : le drag,
le split Entrée, l'undo/redo et l'autosave préservent les liens.
- **Backlinks** — le scanner v5.4.0 reconnaît désormais les tokens
`[[fdpage:ID]]` : « Lié depuis » fonctionne avec les wiki-links.
- **Page publique** — `_render_blocks_public` résout les chips (liens
absolus + dates lisibles, HTML échappé).
### v5.12.0 — Templates & verrouillage de page
- **Template picker global** — « + New page » (sidebar, footer, palette
Ctrl+K) ouvre la galerie : 5 templates built-in (`app/services/
block_templates.py` : Empty, Meeting notes, Weekly report, To-do list,
Project doc) + les templates personnels sauvegardés ; « Empty » retombe
sur la création classique (workspace item).
- **Save as template** — menu « … » de la page → capture le contenu blocs
courant comme template personnel (`page_global_templates`, visibles
uniquement par leur créateur, `created_by IS NULL` = partagé).
- **Bouton « Use template »** — `POST /board/api/page-templates/{id}/use`
(id 0 = built-in par clé) duplique le contenu des blocs dans une
nouvelle page du workspace de l'utilisateur et renvoie son id.
- **Page lock** — menu « … » → « 🔒 Lock page » : bannière sticky
« locked (read-only) », blocs et titre passés `contenteditable=false`,
handles/actions masquées. Serveur : `POST /board/api/pages/{id}/lock`,
garde `_ensure_page_editable` → **423** sur `PUT /api/pages/{id}` et
`POST /api/pages/{id}/blocks` pour tout non-privileged ; déverrouillage
réservé à la personne qui a verrouillé (`locked_by`) ou un admin (403
sinon).
- **Full-width / Small text** — toggles dans le menu « … », persistés par
page (`pages.full_width`, `pages.font_small`), options `POST
/board/api/pages/{id}/options`, rendu via classes CSS
(`.full-width` / `.small-text`).
- **Migrations 13** — `pages.is_locked`, `pages.locked_by`,
`pages.full_width`, `pages.font_small`, table `page_global_templates`.
- **Tests** — `tests/test_v511_v512_wiki_templates.py` : **15 tests**
(schéma, helpers tokens + échappement, picker flou, renommage propagé,
persistance des tokens + backlinks, chips en page publiée, lock 423 /
403 / admin / ré-ouverture, options, templates built-in + perso +
cloisonnement, shapes service, câblage front). Suite complète
**538 verte**, `ruff check` OK.
- **Version** — 5.12.0.
## v5.11.7 (2026-09-13) — v5.8.0 Calendrier & Rappels
> Calendrier complet (jour / semaine / mois) avec récurrences expandues
+18 -15
View File
@@ -664,24 +664,25 @@ Détails livrés :
## v5.10.0 — Éditeur : interactions de bloc ✅ (livré — voir section Completed)
## v5.11.0 — Wiki-links & mentions de page ⬜ (non commencé)
## v5.11.0 — Wiki-links & mentions de page ✅ (2026-09-14)
> **Objectif** : le graphe de connaissances Notion. Complète les backlinks de v5.4.0
> (section « Lié depuis ») par la création des liens depuis l'éditeur.
> (section « Lié depuis ») par la création des liens depuis l'éditeur. **COMPLETED**.
- [ ] **Wiki-links `[[`** — taper `[[` ouvre un picker de pages (recherche fuzzy, toutes collections), Enter insère un lien interne rendu comme chip de page (icône + titre mis à jour dynamiquement)
- [ ] **Mention de page `@`** — dans le menu @ existant (utilisateurs v4.9.0), ajouter l'onglet « Pages » : `@` suivi d'un nom de page crée un lien inline
- [ ] **Mention de date `@`** — `@today`, `@tomorrow`, `@2026-10-01` rendus comme chips de date
- [ ] **Renommage propagé** — renommer une page met à jour le libellé affiché de tous ses liens internes (résolution au rendu via `page_id`, pas de texte dur)
- [x] **Wiki-links `[[`** — taper `[[` ouvre le picker de pages (`GET /board/api/wiki/pages`, recherche substring + sous-séquence floue), Entrée insère un lien interne rendu comme chip atomique (icône + titre, cliquable) ; module `WM` dans `_page_editor_scripts.html` ; tokens `[[fdpage:ID]]` stockés dans le texte des blocs et relus intacts par `gtTok()` (autosave, drag, undo/redo préservés)
- [x] **Mention de page `@`** — le menu `@` (utilisateurs v4.9.0) gagne la section « Pages » : `@nom` cherche les pages et insère le chip inline
- [x] **Mention de date `@`** — section « Date » : `@today`, `@tomorrow`, `@hier` ou `@YYYY-MM-DD` insèrent `[[fddate:…]]`, rendus comme chips de date lisibles (« Fri 25 Dec 2026 »)
- [x] **Renommage propagé** — le token ne stocke que `page_id` ; `GET /board/api/wiki/titles` résout les libellés au rendu (editeur + page publique) → renommer une page met à jour tous ses liens ; page supprimée → « Deleted page »
- [x] Bonus — le scanner de backlinks v5.4.0 reconnaît les tokens wiki ; `_render_blocks_public` rend les chips en page publiée (HTML échappé) ; service `app/services/wiki_links.py` ; 15 tests dédiés (voir v5.12.0)
## v5.12.0 — Templates & verrouillage de page ⬜ (non commencé)
## v5.12.0 — Templates & verrouillage de page ✅ (2026-09-14)
> **Objectif** : démarrage rapide productif et protection des pages stabilisées.
> Les `page_templates` existent (v2.0.0/v4.2.0) mais uniquement côté collections.
> Les `page_templates` existent (v2.0.0/v4.2.0) mais uniquement côté collections. **COMPLETED**.
- [ ] **Template picker global** — sur « + New page » : galerie de templates (Empty, Meeting notes, Weekly report, To-do list…) + templates custom utilisateur
- [ ] **Bouton « Use template »** — duplication du contenu du template dans la nouvelle page
- [ ] **Page lock** — toggle 🔒 dans le menu « … » : page en lecture seule (édition désactivée pour tous sauf owner/admin), indicateur visuel en topbar
- [ ] **Full-width mode** — toggle pour passer la page en pleine largeur (comme Notion)
- [ ] **Small text / typo options** — option de page : taille de police réduite, serif/mono
- [x] **Template picker global** — « + New page » (sidebar, footer, Ctrl+K) ouvre la galerie : 5 templates built-in (`app/services/block_templates.py` — Empty, Meeting notes, Weekly report, To-do list, Project doc) + templates custom utilisateur (`page_global_templates`, migration 13, cloisonnés par `created_by`)
- [x] **Bouton « Use template »** — `POST /board/api/page-templates/{id}/use` (id 0 = built-in par clé) duplique le contenu des blocs dans une nouvelle page ; « Empty » retombe sur la création classique ; menu « … » → « 📑 Save as template » capture la page courante
- [x] **Page lock** — toggle 🔒 dans le menu « … » : bannière sticky read-only, blocs/titre non éditables ; serveur `POST /board/api/pages/{id}/lock` + garde `_ensure_page_editable` → 423 sur `PUT /api/pages/{id}` et `POST /api/pages/{id}/blocks` ; déverrouillage réservé au poseur du lock (`locked_by`) ou admin (403 sinon) ; indicateur visuel = bannière en tête de page
- [x] **Full-width mode** — toggle dans le menu « … », persisté par page (`pages.full_width`), `POST /board/api/pages/{id}/options`, classe CSS `.full-width`
- [x] **Small text / typo options** — toggle « Aa Small text » (`pages.font_small`), classe `.small-text` (taille réduite ; serif/mono : non demandé au-delà du compact — volontairement hors scope)
## v5.13.0 — Collaboration temps réel ✅ (livré — voir section Completed)
@@ -729,7 +730,9 @@ Détails livrés :
8. ~~**v5.6.0 → Import de données (6 phases)**~~ ✅ **livré** — Phase 0 socle unifié · Phase 1 notes/Markdown (Obsidian, Notion, Logseq/Roam, Apple Notes/Bear, Google Keep, OneNote) · Phase 2 données/tableaux (CSV typé, Excel, Sheets, JSON) · Phase 3 documents (Word, Google Docs, HTML, PDF) · Phase 4 signets/dev/divers (Gitea/GitHub, Raindrop, Pocket, Readwise, Shaarli, `.ics`, OPML, Standard Notes) · Phase 5 durcissement (re-sync, dépôt forge, URL/web clipper, lot multi-fichiers, relations Notion, rapports exportables)
9. ~~**v5.7.0 → Database Avancée (Pt. 2)**~~ ✅ **livré** (person + auto-propriétés, groupes de propriétés, vues sauvegardées par utilisateur, swimlanes, WIP limits, cartes configurables, calendar drag & drop, gallery couvertures ; 12 tests dédiés)
10. ~~**v5.8.0 → Calendrier & Rappels**~~ ✅ **livré** (vues jour/semaine/mois, récurrences RRULE expandues serveur, rappels in-app + email avec dédup, fuseaux par utilisateur/événement, notifications d'assignation, template Meeting notes enrichi ; 20 tests dédiés)
11. **v5.11.0 → v5.12.0 → v5.14.0 → v6.0.0** (wiki-links, templates/lock, synced blocks, pro)
11. ~~**v5.11.0 → Wiki-links & mentions de page**~~ ✅ **livré** (picker `[[`, mentions `@` pages/date, chips atomiques, renommage propagé, backlinks wiki, chips en page publique)
12. ~~**v5.12.0 → Templates & verrouillage de page**~~ ✅ **livré** (template picker global 5 built-in + templates perso, use-template, page lock 423, full-width, small text)
13. **v5.14.0 → v6.0.0** (synced blocks, pro)
---
## Résumé des phases
@@ -748,4 +751,4 @@ Quality DB views, Agent IA Palette → Realtime + E
DB avancée, redo, drag&drop, bookmark, avancée
Calendrier, AI duplicate) lightbox…) (Pt.2)
*Dernière mise à jour: 2026-09-13 — **v5.8.0 Calendrier & Rappels COMPLETED** : vues jour/semaine/mois, récurrences RRULE (daily/weekly/monthly + count/until/byweekday) expandues côté serveur, rappels in-app + email (scan 60 s, dédup reminder_log), fuseaux horaires par utilisateur et par événement, notifications d'assignation, template Meeting notes enrichi ; 20 tests dédiés, **523 tests suite complète** ; prochaine priorité : v5.11.0 Wiki-links & mentions de page*
*Dernière mise à jour: 2026-09-14 — **v5.11.0 Wiki-links & v5.12.0 Templates & lock COMPLETED** (release 5.12.0) : liens `[[fdpage]]` avec picker + mentions @page/@date, renommage propagé, backlinks wiki, chips en page publique ; template picker global (5 built-in + templates perso), page lock serveur (423/403, locked_by), full-width & small text par page ; migrations 13 ; 15 tests dédiés, **538 tests suite complète** ; prochaine priorité : v5.14.0 Synced blocks*
+1 -1
View File
@@ -1 +1 @@
5.11.7
5.12.0
+2 -2
View File
@@ -92,7 +92,7 @@ async def lifespan(_app: FastAPI):
from app.services.reminders import reminder_scheduler
reminder_task = asyncio.create_task(reminder_scheduler())
logger.info("FlowDeck v5.11.7 started on port %d", settings.app_port)
logger.info("FlowDeck v5.12.0 started on port %d", settings.app_port)
try:
yield
finally:
@@ -107,7 +107,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="5.11.7",
version="5.12.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
+38
View File
@@ -509,3 +509,41 @@ def _migration_v58_meeting_template(conn: sqlite3.Connection) -> None:
"UPDATE database_templates SET description=? WHERE name='Meeting notes'",
("Notes de réunion avec participants, agenda, notes et actions.",),
)
@register(13, "v5.11.0/v5.12.0: page lock, user typo prefs, global page templates")
def _migration_v511_wiki_v512_templates(conn: sqlite3.Connection) -> None:
"""v5.11.0 Wiki-links + v5.12.0 Templates & verrouillage.
``pages.is_locked`` — read-only page (locker/admin can unlock).
``pages.locked_by`` — user that locked the page.
``pages.full_width`` — per-page full-width layout toggle.
``pages.font_small`` — per-page compact typography toggle.
``page_global_templates`` — user-created global page templates
(blocks_json = same format as the block editor saves).
"""
_pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
if "is_locked" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0")
if "locked_by" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN locked_by INTEGER REFERENCES users(id) ON DELETE SET NULL")
if "full_width" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN full_width INTEGER NOT NULL DEFAULT 0")
if "font_small" not in _pcols:
conn.execute("ALTER TABLE pages ADD COLUMN font_small INTEGER NOT NULL DEFAULT 0")
conn.execute(
"""CREATE TABLE IF NOT EXISTS page_global_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
icon TEXT NOT NULL DEFAULT '📄',
description TEXT NOT NULL DEFAULT '',
blocks_json TEXT NOT NULL DEFAULT '[]',
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_pgt_creator ON page_global_templates(created_by)"
)
+261 -5
View File
@@ -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":
+32 -7
View 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>'
+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)
+13
View File
@@ -473,6 +473,19 @@
</div>
<div class="more-menu-item" @click="movePage">↗ Move to</div>
<div class="more-menu-sep"></div>
<div class="more-menu-item" @click="moreOpen=false; toggleLock()">
<span x-text="isLocked ? '🔓 Unlock page' : '🔒 Lock page'"></span>
</div>
<div class="more-menu-item" @click="moreOpen=false; setPageOption('full_width', !fullWidth)">
↔ Full width <span x-show="fullWidth" style="margin-left:auto;font-size:11px">✓</span>
</div>
<div class="more-menu-item" @click="moreOpen=false; setPageOption('font_small', !fontSmall)">
Aa Small text <span x-show="fontSmall" style="margin-left:auto;font-size:11px">✓</span>
</div>
<div class="more-menu-item" @click="moreOpen=false; saveAsTemplate()">
📑 Save as template
</div>
<div class="more-menu-sep"></div>
<div class="more-menu-item danger" @click="deletePage">
{{ fd_icon("trash",14) }} Move to Trash
</div>
+298 -36
View File
@@ -27,11 +27,31 @@ window.__fdEditorScriptsLoaded = true;
let _bid=0;function genId(){return 'b'+(++_bid)+'_'+Date.now().toString(36);}
function cp(e){const s=window.getSelection();if(!s.rangeCount)return 0;const r=s.getRangeAt(0).cloneRange();r.selectNodeContents(e);r.setEnd(s.getRangeAt(0).endContainer,s.getRangeAt(0).endOffset);return r.toString().length;}
function gt(e){return (e&&e.innerText!==undefined)?e.innerText:(e?e.textContent:'');}
// v5.11.0: read a contenteditable block back to plain text, converting
// wiki chip nodes (contenteditable=false spans) back into their raw
// [[fdpage:ID]] / [[fddate:YYYY-MM-DD]] tokens.
function gtTok(e){
if(!e) return '';
if(!e.querySelector||!e.querySelector('.fd-wiki-chip,.fd-wiki-date')) return gt(e);
let out='';
(function walk(node){
for(const n of node.childNodes){
if(n.nodeType===3){out+=n.nodeValue;continue;}
if(n.nodeType===1){
if(n.classList&&n.classList.contains('fd-wiki-chip')){out+=n.getAttribute('data-token')||'';continue;}
if(n.tagName==='BR'){out+='\n';continue;}
walk(n);
}
}
})(e);
return out;
}
window.gtTok=gtTok;
function splitCaret(e){
const s=window.getSelection(); if(!s.rangeCount||!s.rangeCount)return null; const sel=s.getRangeAt(0);
const MK='\uF000FD'; const mark=document.createElement('span'); mark.textContent=MK;
sel.insertNode(mark);
const full=gt(e)||'';
const full=gtTok(e)||'';
const parts=full.split(MK);
mark.remove(); e.normalize();
return {before:parts[0]||'',after:(parts.length>1?parts[1]:'')||'',content:full.split(MK).join('')};
@@ -48,9 +68,45 @@ window.__fdEditorScriptsLoaded = true;
.replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>')
.replace(/\*([^*]+)\*/g,'<em>$1</em>')
.replace(/~~([^~]+)~~/g,'<s>$1</s>')
.replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g,'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
.replace(/\[\[fdpage:(\d+)\]\]/g,function(m,pid){
return '<a class="fd-wiki-chip" contenteditable="false" href="/pages/'+pid+'" data-token="'+m+'" data-pid="'+pid+'" title="Linked page"><span class="fd-wiki-label">…</span></a>';})
.replace(/\[\[fddate:([0-9]{4}-[0-9]{2}-[0-9]{2})(?:T[0-9:]{5,8})?\]\]/g,function(m,iso){
return '<span class="fd-wiki-date" contenteditable="false" data-token="'+m+'">'+fdDateLabel(iso)+'</span>';})
.replace(/\[([^\]]+)\]\((https?:[^\)\s]+)\)/g,'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
.replace(/\n/g,'<br>');
}
// v5.11.0: labels of [[fdpage:ID]] chips are resolved from the live DB
// (rename propagation — tokens store only the id).
function fdDateLabel(iso){
try{
const p=iso.split('-'); const d=new Date(+p[0],+p[1]-1,+p[2]);
const DOW=['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
const MON=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
return DOW[d.getDay()]+' '+(+p[2])+' '+MON[+p[1]-1]+' '+p[0];
}catch(e){return iso;}
}
function _wikiResolve(root){
const ct=root||document.getElementById('_blocksCt'); if(!ct)return;
const pids={};
ct.querySelectorAll('.fd-wiki-chip[data-pid]').forEach(function(a){
const lab=a.querySelector('.fd-wiki-label');
if(!lab||lab.getAttribute('data-done'))return;
lab.setAttribute('data-done','1');
pids[a.dataset.pid]=true;
});
const ids=Object.keys(pids); if(!ids.length)return;
fetch('/board/api/wiki/titles?ids='+encodeURIComponent(ids.join(',')),{credentials:'same-origin'})
.then(function(r){return r.json();})
.then(function(d){
const titles=d.titles||{};
ct.querySelectorAll('.fd-wiki-chip[data-pid]').forEach(function(a){
const t=titles[a.dataset.pid];
const lab=a.querySelector('.fd-wiki-label');
if(lab) lab.textContent = t||'Deleted page';
});
}).catch(function(){});
}
window._wikiResolve=_wikiResolve;
const CALLOUT_ICONS=['💡','💡','📌','⚠️','ℹ️','✅','❌','🚀','🔔','📣','⭐','❤️','🔥','💬','👀','🧠','🎯','🔑','📅','💰','💡','📚','🧪','🌍'];
@@ -297,6 +353,64 @@ window.__fdEditorScriptsLoaded = true;
sel(id){if(window.E)window.E.applySlash(id);SM.close();},
get isOpen(){return SM._o;}};
// ═══════════ v5.11.0 Wiki-link picker ([[ → page search) ═══════════
const WM={_o:false,_idx:-1,_s:0,_it:[],_root:null,_in:null,_li:null,_pv:null,
mk(){ if(this._root)return this._root;
const d=document.createElement('div'); d.id='_wikiMenu';
d.style.cssText='position:fixed;z-index:1250;display:none;width:340px;max-height:320px;flex-direction:column;background:var(--bg-modal,#1f1f1f);border:1px solid var(--border,#333);border-radius:10px;box-shadow:0 12px 40px rgba(0,0,0,.5);overflow:hidden';
const list=document.createElement('div'); list.style.cssText='overflow-y:auto;flex:1;min-height:60px;max-height:250px;padding:6px';
const inp=document.createElement('input'); inp.type='text'; inp.placeholder='Search pages…';
inp.style.cssText='width:100%;padding:9px 12px;background:var(--bg-secondary);border:none;border-top:1px solid var(--border);color:var(--text-primary);font-size:14px;outline:none;box-sizing:border-box';
d.appendChild(list); d.appendChild(inp); document.body.appendChild(d);
this._root=d; this._li=list; this._in=inp;
inp.oninput=()=>{clearTimeout(this._t);this._t=setTimeout(()=>this.load(),180);};
list.addEventListener('click',ev=>{const it=ev.target.closest('.wm-item');if(it){ev.preventDefault();WM.sel(parseInt(it.dataset.wpid,10));}});
document.addEventListener('mousedown',ev=>{if(!d.contains(ev.target))WM.close();});
document.addEventListener('keydown',function(ev){
if(!WM._o)return;
if(ev.metaKey||ev.ctrlKey||ev.altKey)return;
if(ev.key==='Escape'){ev.preventDefault();ev.stopPropagation();WM.close();}
else if(ev.key==='ArrowDown'){ev.preventDefault();ev.stopPropagation();WM._mv(1);}
else if(ev.key==='ArrowUp'){ev.preventDefault();ev.stopPropagation();WM._mv(-1);}
else if(ev.key==='Enter'){ev.preventDefault();ev.stopPropagation();const c=WM._it[WM._s];if(c)WM.sel(c.id);}
},true);
return d; },
open(i,el){ this.mk(); const r=el.getBoundingClientRect();
this._root.style.display='flex';
this._root.style.top=Math.max(4,(r.bottom+4>window.innerHeight-340)?r.top-344:r.bottom+4)+'px';
this._root.style.left=Math.min(r.left,window.innerWidth-350)+'px';
this._idx=i; this._s=0; this._in.value=''; this._o=true;
this.load(); setTimeout(()=>this._in.focus(),10); },
close(){ if(this._root)this._root.style.display='none'; this._o=false; },
load(){ const self=this; const q=(this._in.value||'').toLowerCase();
fetch('/board/api/wiki/pages?q='+encodeURIComponent(q),{credentials:'same-origin'})
.then(r=>r.json()).then(d=>{
if(!self._o)return;
self._it=d.pages||[]; self._s=0;
let h='<div style="padding:6px 10px 2px;font-size:11px;font-weight:600;color:var(--text-dim);text-transform:uppercase">Pages</div>';
self._it.forEach(function(p,idx){
h+='<div class="wm-item" data-wpid="'+p.id+'" style="display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:13px;color:var(--text);cursor:pointer;border-radius:6px;'+(idx===0?'background:rgba(35,131,226,.15)':'')+'">'
+'<span style="width:18px;text-align:center;flex-shrink:0">'+esc(p.icon||'📄')+'</span>'
+'<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(p.title)+'</span>'
+'<span style="font-size:11px;color:var(--text-dim)">'+esc(p.workspace||'')+'</span></div>';
});
if(!self._it.length)h+='<div style="padding:12px;color:var(--text-dim);font-size:13px">No page found</div>';
self._li.innerHTML=h;
}).catch(()=>{}); },
_mv(dir){ if(!this._it.length)return;
this._s=(this._s+dir+this._it.length)%this._it.length;
this._li.querySelectorAll('.wm-item').forEach((el,i)=>{el.style.background=i===this._s?'rgba(35,131,226,.15)':'';});
const cur=this._li.querySelectorAll('.wm-item')[this._s]; if(cur&&cur.scrollIntoView)cur.scrollIntoView({block:'nearest'}); },
sel(pid){ const E=window.E; if(!E||this._idx<0)return;
const b=E.blocks[this._idx]; if(!b)return;
// Drop the typed "[[" from the live DOM text, append the token chip.
const el=E.getEl(b.id); let text=(el?gtTok(el):b.content)||'';
if(text.endsWith('[[')) text=text.slice(0,-2);
else text=text.replace(/\[\[[^\]]*$/,'');
E._commitBlockText(b, text+'[[fdpage:'+pid+']] ');
this.close(); },
get isOpen(){return this._o;}};
// ═══════════ Inline AI composer (Notion AI-style) ═══════════
// Space on an empty paragraph → "Edit with AI" pill → Enter → "Brewing…"
// → framed result → "Insert below" → applyAIBlocks.
@@ -907,6 +1021,9 @@ window.__fdEditorScriptsLoaded = true;
iconValue:'',
// ── Import ──
importOpen:false,importText:'',importFile:null,
// ── v5.11.0 wiki-links & v5.12.0 page options ──
isLocked:false,lockedBy:null,canEdit:true,fullWidth:false,fontSmall:false,
tplSaving:false,
// ── DB Templates ──
dbTplOpen:false,dbTplLoading:false,dbTemplates:[],
// ── Version history ──
@@ -933,12 +1050,16 @@ window.__fdEditorScriptsLoaded = true;
if(dataEl){try{const data=JSON.parse(dataEl.textContent);this.pid=data.id;this.pageTitle=data.title||'';this.favorited=data.favorited||false;const fmt=data.content_format||'blocks';this.contentFormat=fmt;const raw=data.content||'';
if(fmt==='file'){this.fileData=data;this.fileUrl=data.file_url||'';this.loadFileContent(data);}
else if(fmt==='blocks'&&raw){try{this.blocks=JSON.parse(raw);this.blocks.forEach(b=>{if(!b.id)b.id=genId()});}catch(e){this.blocks=[];}}
else if(raw&&raw.trim())this.blocks=this.md2b(raw);}catch(e){}}
else if(raw&&raw.trim())this.blocks=this.md2b(raw);
// v5.12.0: page lock + layout options
this.isLocked=!!data.is_locked;this.lockedBy=data.locked_by||null;
this.canEdit=data.can_edit!==false;this.fullWidth=!!data.full_width;this.fontSmall=!!data.font_small;}catch(e){}}
if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];
this.loadInlineDBs();
window.E=this;
this.insertTable=function(){this.sync();this.pushHistory();var nb={id:genId(),type:'table',content:'',rows:_tblNew(3,3)};this.blocks.push(nb);this.dirty=true;this.autoSave();this.render();_rtSync();setTimeout(function(){_tblFocus(nb.id,0,0);},60);};
SM.init();_tblInitDelegate();this.render();
this._applyLockMode();this._applyLayout();_wikiResolve();
const tEl=document.getElementById('_titleEl');
if(tEl){const syncT=()=>{tEl.classList.toggle('empty',!(tEl.textContent||'').trim());};syncT();tEl.addEventListener('input',syncT);tEl.addEventListener('blur',syncT);}
if(this.contentFormat==='blocks'&&window.__fdRT&&window.__fdRT.start){setTimeout(()=>{try{window.__fdRT.start(this);}catch(e){}},150);}
@@ -954,6 +1075,67 @@ window.__fdEditorScriptsLoaded = true;
if(window.location.hash&&window.location.hash.indexOf('#fdblk-')===0){const hb=window.location.hash.slice(7);setTimeout(()=>{const selEl=document.querySelector(`[data-bid="${hb}"]`);if(selEl&&selEl.scrollIntoView)selEl.scrollIntoView({behavior:'smooth',block:'center'});if(selEl){selEl.focus();ce(selEl);}},500);}
},100);
},
// ── v5.12.0: lock + layout helpers ──
_applyLockMode(){
document.body.classList.toggle('page-locked',!!this.isLocked);
let banner=document.getElementById('_lockBanner');
if(this.isLocked){
if(!banner){
banner=document.createElement('div');banner.id='_lockBanner';
banner.className='fd-lock-banner';
banner.innerHTML='<span>🔒</span><span>This page is locked (read-only)</span>'
+'<button type="button" class="btn-sm" id="_lockUnlockBtn" style="margin-left:auto;font-size:12px;cursor:pointer;">Unlock</button>';
const wrap=document.querySelector('.page-editor-wrapper');
if(wrap)wrap.insertBefore(banner,wrap.firstChild);
}
const ub=banner.querySelector('#_lockUnlockBtn');
if(ub)ub.style.display=this.canEdit?'':'none';
}else if(banner){banner.remove();}
const ct=document.getElementById('_blocksCt');
if(ct){ct.querySelectorAll('[data-bid]').forEach(el=>el.setAttribute('contenteditable',this.canEdit?'true':'false'));}
const t=document.getElementById('_titleEl');
if(t)t.setAttribute('contenteditable',this.canEdit?'true':'false');
},
_applyLayout(){
const wrap=document.querySelector('.page-editor-wrapper');
if(!wrap)return;
wrap.classList.toggle('full-width',!!this.fullWidth);
wrap.classList.toggle('small-text',!!this.fontSmall);
},
async toggleLock(){
if(!this.canEdit){this.showToast&&this.showToast('Only the person who locked this page (or an admin) can unlock it','error');return;}
const next=!this.isLocked;
try{
const r=await fetch('/board/api/pages/'+this.pid+'/lock',{method:'POST',credentials:'same-origin',
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
body:JSON.stringify({locked:next})});
if(!r.ok){const d=await r.json().catch(()=>({}));this.showToast((d&&d.detail)||'Lock failed','error');return;}
this.isLocked=next;this._applyLockMode();
this.showToast(next?'🔒 Page locked':'🔓 Page unlocked');
}catch(e){this.showToast('Lock failed','error');}
},
async setPageOption(key,val){
try{
const body={};body[key]=val;
const r=await fetch('/board/api/pages/'+this.pid+'/options',{method:'POST',credentials:'same-origin',
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify(body)});
if(r.ok){this[key]=val;this._applyLayout();}
}catch(e){}
},
async saveAsTemplate(){
if(!this.pageTitle||!(this.pageTitle||'').trim()){this.showToast('Give the page a title first','error');return;}
const name=(this.pageTitle||'').trim()+' template';
this.tplSaving=true;this.sync();
const blocks=this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','children','rows'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;});
try{
const r=await fetch('/board/api/page-templates',{method:'POST',credentials:'same-origin',
headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},
body:JSON.stringify({name:name,blocks:blocks})});
if(r.ok){this.moreOpen=false;this.showToast('📑 Saved as template: '+name);}
else{const d=await r.json().catch(()=>({}));this.showToast((d&&d.detail)||'Save failed','error');}
}catch(e){this.showToast('Save failed','error');}
this.tplSaving=false;
},
fileData:null,
fileUrl:'',
async loadFileContent(data){
@@ -979,6 +1161,16 @@ window.__fdEditorScriptsLoaded = true;
this.blocks=[this.mkB('embed','',{src:url,embed_type:'download',file_name:name,file_size:data.file_size||0,file_mime:mime})];this.render();
},
mkB(type,content,extras){const b={id:genId(),type,content:content||'',...(extras||{})};if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='toggle')b.children=[];if(type==='button'){b.automation_id=0;b.automation_name='';}return b;},
// v5.11.0: rewrite one block's text (e.g. after inserting a wiki token)
// and re-render in place, caret at end.
_commitBlockText(b,text){
this.sync();this.pushHistory();
b.content=text;
this.dirty=true;this.autoSave();this.render();_rtSync();
const el=this.getEl(b.id);
if(el){el.focus();const r=document.createRange();r.selectNodeContents(el);r.collapse(false);const s=window.getSelection();s.removeAllRanges();s.addRange(r);}
if(window.__fdRT&&window.__fdRT.syncNow)window.__fdRT.syncNow();
},
getEl(bid){const ct=document.getElementById('_blocksCt');return ct?ct.querySelector(`[data-bid="${bid}"]`):null;},
getIdx(bid){return this.blocks.findIndex(b=>b.id===bid);},
getActiveBlock(){const el=document.activeElement;if(!el||!el.hasAttribute('data-bid'))return null;return{el,bid:el.dataset.bid,idx:this.getIdx(el.dataset.bid)};},
@@ -1012,9 +1204,9 @@ window.__fdEditorScriptsLoaded = true;
sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='table'){const wrap=ct.querySelector(`[data-bid="${b.id}"].ftable-editor`);if(wrap){const rows=[];wrap.querySelectorAll('tr.ftable-row').forEach(function(tr){const cells=tr.querySelectorAll('th.ftable-cell,td.ftable-cell');if(!cells.length)return;const rowarr=[];cells.forEach(function(cell){rowarr.push(cell.textContent||'');});rows.push(rowarr);});if(rows.length)b.rows=rows;}continue;}
if(b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='table_of_contents'||b.type==='button')continue;
if(b.type==='math'){const te=ct.querySelector(`[data-math-bid="${b.id}"]`);if(te)b.content=te.value||'';continue;}
const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=gt(el)||'';else if(el)b.content=gt(el)||'';
if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gt(che)||'';});
if(b.type==='columns'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gt(che)||'';});
const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=gtTok(el)||'';else if(el)b.content=gtTok(el)||'';
if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gtTok(che)||'';});
if(b.type==='columns'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gtTok(che)||'';});
}},
render(){const ct=document.getElementById('_blocksCt');if(!ct)return;let html='';for(let i=0;i<this.blocks.length;i++)html+=renderBlock(this.blocks[i],i);ct.innerHTML=html;
// Render math blocks with KaTeX
@@ -1027,8 +1219,15 @@ window.__fdEditorScriptsLoaded = true;
setTimeout(()=>{ct.querySelectorAll('[data-meeting-bid]').forEach(function(el){if(window.FDMeeting)window.FDMeeting.mount(el.getAttribute('data-meeting-bid'),el);});},50);
ct.querySelectorAll('[data-bid]').forEach(el=>{el.addEventListener('input',()=>{self.dirty=true;self.autoSave();if(el.hasAttribute&&el.hasAttribute('data-placeholder'))el.classList.toggle('empty',!(el.textContent||'').trim());if(window.AIAC)window.AIAC.schedule(el.getAttribute('data-bid'));})});
ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/<br[^>]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());});
_selApply();
},
_selApply();
var self2=this;
setTimeout(function(){
if(window._wikiResolve)_wikiResolve();
if(!self2.canEdit)self2._applyLockMode();
const ub=document.getElementById('_lockUnlockBtn');
if(ub&&!ub._wired){ub._wired=true;ub.onclick=function(){window.E.toggleLock();};}
},30);
},
_renderKatex(el,tex){
if(!el)return;if(!tex||!tex.trim()){el.innerHTML='<span style="color:var(--text-dim);font-style:italic;">Empty math block</span>';return;}
if(window.katex){try{el.innerHTML=window.katex.renderToString(tex,{displayMode:true,throwOnError:false});}catch(e){el.innerHTML='<span style="color:#e5484d;">Invalid LaTeX</span>';}}
@@ -1693,7 +1892,8 @@ applyAIBlocks(text){
if(e.key==='Escape'){e.preventDefault();window.AIAC.clear();return;}
if(e.key==='Enter'||e.key==='Backspace'){window.AIAC.clear();}
}
if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id);if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen)SM.open(i2,e2);},15);}
if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id);if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen&&!WM.isOpen)SM.open(i2,e2);
if(e2&&!SM.isOpen&&!WM.isOpen){const t=gtTok(e2)||'';const mm=t.match(/\[\[$/);if(mm){WM.open(i2,e2);}}},15);}
if(e.key===' '){
if(block.type==='paragraph'&&!(block.content||'').trim()&&!(el.textContent||'').trim()&&!SM.isOpen&&window.AIC){
e.preventDefault(); window.AIC.open(idx); return;
@@ -1704,7 +1904,7 @@ applyAIBlocks(text){
if(e.shiftKey){this.dirty=true;this.autoSave();return;}
e.preventDefault();
const sp=splitCaret(el);
const before=sp?sp.before:gt(el)||'',after=sp?sp.after:'';
const before=sp?sp.before:gtTok(el)||'',after=sp?sp.after:'';
if(!before.trim()&&!after.trim()){
const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph';
this.sync();this.pushHistory();this.blocks.splice(idx+1,0,this.mkB(nt,''));this.render();_rtSync();
@@ -1859,53 +2059,115 @@ applyAIBlocks(text){
btn.style.left=x+'px';btn.style.top=y+'px';
},
hideCommentBtn(){const btn=document.getElementById('_commentSelBtn');if(btn)btn.style.display='none';},
// ── @mention autocomplete ──
mentionOpen:false,mentionQuery:'',mentionResults:[],mentionEl:null,
// ── @mention autocomplete (v4.9.0 users + v5.11.0 pages & dates) ──
mentionOpen:false,mentionQuery:'',mentionResults:[],mentionPages:[],mentionEl:null,
openMention(el){
const s=window.getSelection();if(!s.rangeCount)return;
const text=el.textContent||'';const pos=s.getRangeAt(0).startOffset;
const before=text.substring(0,pos);
const range=s.getRangeAt(0).cloneRange();
range.selectNodeContents(el);range.setEnd(s.getRangeAt(0).startContainer,s.getRangeAt(0).startOffset);
const before=range.toString();
const m=before.match(/@([\w\-\.]*)$/);
if(!m)return;
if(!m){return;}
this.mentionQuery=m[1];this.mentionEl=el;
const r=s.getRangeAt(0).getBoundingClientRect();
const menu=document.getElementById('_mentionMenu');
menu.style.display='block';
menu.style.top=Math.max(4,(r.bottom+4>window.innerHeight-280)?r.top-284:r.bottom+4)+'px';
menu.style.top=Math.max(4,(r.bottom+4>window.innerHeight-300)?r.top-304:r.bottom+4)+'px';
menu.style.left=Math.min(r.left,window.innerWidth-260)+'px';
this.mentionOpen=true;this.loadMentionUsers();
this.mentionOpen=true;this.loadMentionResults();
},
async loadMentionUsers(){
async loadMentionResults(){
const q=this.mentionQuery||'';
try{const r=await fetch('/api/notifications/users/search?q='+encodeURIComponent(q),{credentials:'same-origin'});const d=await r.json();this.mentionResults=d.users||[];this.renderMentionMenu();}catch(e){this.mentionResults=[];}
const self=this;
const [u,p]=await Promise.all([
fetch('/api/notifications/users/search?q='+encodeURIComponent(q),{credentials:'same-origin'}).then(r=>r.ok?r.json():{users:[]}).catch(()=>({users:[]})),
fetch('/board/api/wiki/pages?q='+encodeURIComponent(q),{credentials:'same-origin'}).then(r=>r.ok?r.json():{pages:[]}).catch(()=>({pages:[]}))
]);
if(!this.mentionOpen)return;
this.mentionResults=u.users||[];this.mentionPages=p.pages||[];
this.renderMentionMenu();
},
_dateSuggestions(q){
const out=[];const lq=(q||'').toLowerCase();
const iso=s=>s.toISOString().slice(0,10);
const now=new Date();
if(!lq||'today'.startsWith(lq))out.push({label:'Today',iso:iso(now)});
if(!lq||'tomorrow'.startsWith(lq))out.push({label:'Tomorrow',iso:iso(new Date(now.getTime()+864e5))});
if(!lq||'yesterday'.startsWith(lq))out.push({label:'Yesterday',iso:iso(new Date(now.getTime()-864e5))});
const m=lq.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if(m)out.push({label:'Set date',iso:m[1]+'-'+m[2].padStart(2,'0')+'-'+m[3].padStart(2,'0')});
return out;
},
renderMentionMenu(){
const menu=document.getElementById('_mentionMenu');if(!menu)return;
let h='';const self=this;
(this.mentionResults||[]).forEach(function(u,idx){
h+='<div class="mention-item" data-login="'+u.login+'" style="display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:13px;color:var(--text);cursor:pointer;border-radius:6px;">'
+'<div style="width:24px;height:24px;border-radius:50%;background:'+(u.avatar_color||'#3A3A3A')+';color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;">'
h+='<div class="mention-group-label">People</div>';
(this.mentionResults||[]).slice(0,6).forEach(function(u){
h+='<div class="mention-item" data-mkind="user" data-mval="'+esc(u.login)+'" style="display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:13px;color:var(--text);cursor:pointer;border-radius:6px;">'
+'<div style="width:24px;height:24px;border-radius:50%;background:'+(u.avatar_color||'#3A3A3A')+';color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;overflow:hidden;">'
+((u.avatar_url)?'<img src="'+u.avatar_url+'" style="width:24px;height:24px;border-radius:50%;object-fit:cover;">':((u.full_name||u.login||'?').charAt(0).toUpperCase()))
+'</div><div><div style="font-weight:500">'+u.login+'</div><div style="font-size:11px;color:var(--text-dim,#999)">'+(u.full_name||'')+'</div></div></div>';
+'</div><div><div style="font-weight:500">'+esc(u.login)+'</div><div style="font-size:11px;color:var(--text-dim,#999)">'+esc(u.full_name||'')+'</div></div></div>';
});
if(!h)h='<div style="padding:10px;color:var(--text-dim,#999);font-size:13px;">No users found</div>';
if((this.mentionPages||[]).length){
h+='<div class="mention-group-label">Pages</div>';
this.mentionPages.slice(0,6).forEach(function(p){
h+='<div class="mention-item" data-mkind="page" data-mval="'+p.id+'" style="display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:13px;color:var(--text);cursor:pointer;border-radius:6px;">'
+'<span style="width:24px;text-align:center;flex-shrink:0">'+esc(p.icon||'📄')+'</span>'
+'<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(p.title)+'</span></div>';
});
}
const ds=this._dateSuggestions(this.mentionQuery);
if(ds.length){
h+='<div class="mention-group-label">Date</div>';
ds.forEach(function(d){
h+='<div class="mention-item" data-mkind="date" data-mval="'+esc(d.iso)+'" style="display:flex;align-items:center;gap:8px;padding:7px 10px;font-size:13px;color:var(--text);cursor:pointer;border-radius:6px;">'
+'<span style="width:24px;text-align:center;flex-shrink:0">📅</span>'
+'<span style="flex:1">'+esc(d.label)+'</span><span style="font-size:11px;color:var(--text-dim)">'+esc(d.iso)+'</span></div>';
});
}
if(this.mentionResults.length===0&&this.mentionPages.length===0&&ds.length===0)h='<div style="padding:10px;color:var(--text-dim,#999);font-size:13px;">No matches</div>';
menu.innerHTML=h;
menu.querySelectorAll('.mention-item').forEach(function(it){
it.addEventListener('click',function(){self.insertMention(it.getAttribute('data-login'));});
it.addEventListener('click',function(){
const kind=it.getAttribute('data-mkind');const val=it.getAttribute('data-mval');
if(kind==='user')self.insertMention(val);
else if(kind==='page')self.insertMentionToken('[[fdpage:'+val+']] ');
else if(kind==='date')self.insertMentionToken('[[fddate:'+val+']] ');
});
});
},
// Shared non-destructive insertion: replaces the typed "@query" right
// before the caret inside the token-reconstructed text (chips kept).
_replaceAtMention(replacement){
const el=this.mentionEl;if(!el)return null;
const idx=this.getIdx(el.dataset.bid);if(idx<0)return null;
const b=this.blocks[idx];
const text=gtTok(el)||'';
// robust: compute caret by walking with a marker
let caretPos=text.length;
try{
const MK='\uF000AT';const mark=document.createElement('span');mark.textContent=MK;
const live=s.getRangeAt(0).cloneRange();live.collapse(true);live.insertNode(mark);
const full=gtTok(el)||'';const at=full.indexOf(MK);
if(at>=0)caretPos=at;
mark.remove();el.normalize();
}catch(e){}
const before=text.slice(0,caretPos);
const m=before.match(/@([\w\-\.]*)$/);
if(!m)return null;
const start=caretPos-m[0].length;
const newText=text.slice(0,start)+replacement+text.slice(caretPos);
this._commitBlockText(b,newText);
return true;
},
insertMentionToken(token){
if(this._replaceAtMention(token)){this.closeMention();this.dirty=true;this.autoSave();}
},
insertMention(login){
const el=this.mentionEl;if(!el)return;
const s=window.getSelection();if(s.rangeCount){s.deleteFromDocument();}
const text=el.textContent||'';const pos=s.rangeCount? (()=>{const r=s.getRangeAt(0);return r.startOffset;} )():text.length;
const before=text.substring(0,pos);
const at=before.lastIndexOf('@');
const full=before.substring(0,at)+'@'+login+' ';
const after=text.substring(pos);
el.textContent=full+after;
const ns=window.getSelection();const nr=document.createRange();nr.selectNodeContents(el);nr.collapse(false);ns.removeAllRanges();ns.addRange(nr);
this.closeMention();this.dirty=true;this.autoSave();
this.notifyNewMentions(full);
if(this._replaceAtMention('@'+login+' ')){
this.closeMention();this.dirty=true;this.autoSave();
this.notifyNewMentions('@'+login);
}
},
closeMention(){const menu=document.getElementById('_mentionMenu');if(menu)menu.style.display='none';this.mentionOpen=false;this.mentionEl=null;},
notifyNewMentions(text){
+80 -29
View File
@@ -904,43 +904,94 @@
},
/**
* Create a new page/file in the correct workspace context.
* - Local workspace active → workspace item (prompts for name)
* - Gitea workspace active → workspace item with workspace_id (prompts for name)
* - No workspace → general Notion page (no prompt, title "New page")
* Create a new page: opens the v5.12.0 template picker (built-in + the
* user's saved templates), then instantiates the chosen one.
*/
createPage: function() {
var ctx = this._getContext();
var self = this;
// ── Workspace context → create workspace item ──
if (ctx.wsId > 0) {
var body = { name: '', type: 'page' };
if (ctx.isGitea) body.workspace_id = ctx.wsId;
fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
body: JSON.stringify(body)
})
.then(function(r) { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(function(data) {
window.location.href = '/pages/' + data.id;
// Direct creation path (fallback / no-template choice).
this._createPlainPage = function() {
var ctx = self._getContext();
if (ctx.wsId > 0) {
var body = { name: '', type: 'page' };
if (ctx.isGitea) body.workspace_id = ctx.wsId;
fetch('/api/local-workspace/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': self.getCsrfToken() },
body: JSON.stringify(body)
})
.catch(function(err) { self._toast('Failed: ' + err.message, 'error'); });
return;
}
.then(function(r) { if (!r.ok) throw new Error('Failed'); return r.json(); })
.then(function(data) { window.location.href = '/pages/' + data.id; })
.catch(function(err) { self._toast('Failed: ' + err.message, 'error'); });
return;
}
var project = ctx.workspaceKey;
var url = '/board/api/pages?section=Private&project=' + encodeURIComponent(project);
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-Token': self.getCsrfToken() }
})
.then(function(r) { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
.then(function(data) { window.location.href = '/pages/' + data.id; })
.catch(function(err) { self._toast('Failed to create page: ' + err.message, 'error'); });
};
this._openTemplatePicker();
},
// ── No workspace → general Notion page ──
var project = ctx.workspaceKey;
var url = '/board/api/pages?section=Private&project=' + encodeURIComponent(project);
_openTemplatePicker: function() {
var self = this;
function escAttr(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
if (document.getElementById('fd-tpl-picker')) { document.getElementById('fd-tpl-picker').style.display = 'flex'; return; }
var ov = document.createElement('div');
ov.id = 'fd-tpl-picker';
ov.className = 'tpl-picker-overlay';
ov.innerHTML = '<div class="tpl-picker-modal"><div class="tpl-picker-title">New page from template</div>'
+ '<div class="tpl-picker-sub">Pick a starting point — or start blank.</div>'
+ '<div class="tpl-grid" id="fd-tpl-grid"><div style="color:var(--text-dim);font-size:13px;padding:12px">Loading…</div></div>'
+ '<div style="text-align:right;margin-top:14px"><button class="btn-sm" id="fd-tpl-close" style="cursor:pointer;padding:6px 14px">Cancel</button></div></div>';
document.body.appendChild(ov);
ov.addEventListener('click', function(e){ if(e.target===ov) ov.style.display='none'; });
document.getElementById('fd-tpl-close').onclick = function(){ ov.style.display='none'; };
fetch('/board/api/page-templates', {credentials:'same-origin'})
.then(function(r){ return r.json(); })
.then(function(d){
var list = d.templates || [];
var h = '';
list.forEach(function(t){
var use = t.builtin
? "key:"+t.key
: "id:"+t.id;
h += '<div class="tpl-card" data-use="'+escAttr(use)+'">'
+ '<span class="tpl-card-icon">'+escAttr(t.icon||'📄')+'</span>'
+ '<span><span class="tpl-card-name">'+escAttr(t.name)+'</span>'
+ '<div class="tpl-card-desc">'+escAttr(t.description||'')+'</div></span></div>';
});
document.getElementById('fd-tpl-grid').innerHTML = h || '<div style="color:var(--text-dim)">No templates</div>';
document.querySelectorAll('#fd-tpl-grid .tpl-card').forEach(function(card){
card.onclick = function(){ self._useTemplate(card.dataset.use); };
});
}).catch(function(){ document.getElementById('fd-tpl-grid').innerHTML='<div style="color:var(--text-dim)">Failed to load templates</div>'; });
},
_useTemplate: function(use){
var ov = document.getElementById('fd-tpl-picker');
if (ov) ov.style.display = 'none';
var self = this;
if (!use || use === 'key:empty') { this._createPlainPage(); return; }
var parts = use.split(':');
var body = {};
var url;
if (parts[0] === 'key') { url = '/board/api/page-templates/0/use'; body = {key: parts.slice(1).join(':')}; }
else { url = '/board/api/page-templates/' + parts[1] + '/use'; }
fetch(url, {
method: 'POST',
headers: { 'X-CSRF-Token': this.getCsrfToken() }
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
credentials: 'same-origin',
body: JSON.stringify(body)
})
.then(function(r) { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); })
.then(function(data) { window.location.href = '/pages/' + data.id; })
.catch(function(err) { self._toast('Failed to create page: ' + err.message, 'error'); });
.then(function(r){ if (!r.ok) throw new Error('Template failed'); return r.json(); })
.then(function(d){ window.location.href = '/pages/' + d.id; })
.catch(function(){ self._createPlainPage(); });
},
/** Show the global New Folder modal. */
+101
View File
@@ -4912,3 +4912,104 @@ button.breadcrumb-current { max-width: 260px; }
.fd-lightbox-nav:hover { background: rgba(255,255,255,.25); }
.fd-lightbox-nav[data-nav="prev"] { left: 24px; }
.fd-lightbox-nav[data-nav="next"] { right: 24px; }
/* ═══════════ v5.11.0 Wiki-links & mentions ═══════════ */
.fd-wiki-chip {
display: inline-flex;
align-items: center;
gap: 4px;
background: var(--bg-hover, rgba(35, 131, 226, 0.12));
color: var(--accent, #2383e2);
border-radius: 4px;
padding: 0 4px;
text-decoration: none;
cursor: pointer;
white-space: nowrap;
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
}
.fd-wiki-chip:hover { background: rgba(35, 131, 226, 0.2); }
.fd-wiki-date {
display: inline-block;
background: var(--bg-secondary, #f1f1ef);
border-radius: 4px;
padding: 0 4px;
font-weight: 500;
}
a.fd-wiki-link { color: var(--accent, #2383e2); text-decoration: none; border-bottom: 1px solid rgba(35, 131, 226, 0.35); }
a.fd-wiki-link:hover { border-bottom-color: var(--accent, #2383e2); }
.mention-group-label {
padding: 6px 10px 2px;
font-size: 11px;
font-weight: 600;
color: var(--text-dim, #999);
text-transform: uppercase;
letter-spacing: 0.4px;
}
/* ═══════════ v5.12.0 Page lock + layout options ═══════════ */
.fd-lock-banner {
position: sticky;
top: 0;
z-index: 50;
display: flex;
align-items: center;
gap: 8px;
padding: 7px 24px;
background: rgba(217, 115, 13, 0.12);
border-bottom: 1px solid rgba(217, 115, 13, 0.35);
color: #d9730d;
font-size: 12.5px;
font-weight: 500;
}
body.page-locked .block-handle,
body.page-locked .block-add-btn,
body.page-locked .format-toolbar,
body.page-locked .get-started-toolbar { display: none !important; }
.page-editor-wrapper.full-width .page-cover-area,
.page-editor-wrapper.full-width .page-title-block,
.page-editor-wrapper.full-width .blocks-container { max-width: min(1200px, 100%); }
.page-editor-wrapper.small-text .block-content,
.page-editor-wrapper.small-text .page-title-input { font-size: 13.5px; }
.page-editor-wrapper.small-text .blocks-container { line-height: 1.5; }
/* Template picker modal (v5.12.0) */
.tpl-picker-overlay {
position: fixed;
inset: 0;
z-index: 1500;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 12vh 16px 16px;
}
.tpl-picker-modal {
width: 560px;
max-width: 94vw;
max-height: 68vh;
overflow-y: auto;
background: var(--bg-modal, #fff);
border: 1px solid var(--border, #e3e3e0);
border-radius: 14px;
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.25);
padding: 18px;
}
.tpl-picker-title { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
.tpl-picker-sub { font-size: 12px; color: var(--text-dim, #999); margin-bottom: 14px; }
.tpl-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 10px; }
.tpl-card {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 12px;
border: 1px solid var(--border, #e3e3e0);
border-radius: 10px;
cursor: pointer;
background: var(--bg-secondary, #fafafa);
}
.tpl-card:hover { border-color: var(--accent, #2383e2); background: var(--bg-hover, #f0f7ff); }
.tpl-card-icon { font-size: 20px; line-height: 1.2; }
.tpl-card-name { font-size: 13.5px; font-weight: 600; }
.tpl-card-desc { font-size: 12px; color: var(--text-dim, #999); margin-top: 2px; }
+341
View File
@@ -0,0 +1,341 @@
"""FlowDeck — v5.11.0 Wiki-links & mentions + v5.12.0 Templates & page lock.
Covers: migrations 13, the wiki token helpers, the picker / titles endpoints,
rename propagation, backlinks via tokens, the page lock (423 guard, unlock
permissions), page layout options, global page templates (list/create/use)
and the public renderer wiki chips.
"""
from __future__ import annotations
import json
import os
import secrets
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client():
db_file = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
db_path = db_file.name
db_file.close()
os.environ["DATABASE_URL"] = f"sqlite:///{db_path}"
os.environ["APP_SECRET_KEY"] = "test-secret-for-tests"
os.environ["RATE_LIMIT_ENABLED"] = "false"
from app.config import settings
settings.database_url = f"sqlite:///{db_path}"
settings.rate_limit_enabled = False
from app.db import init_db
from app.main import app
init_db()
yield TestClient(app)
try:
os.unlink(db_path)
except PermissionError:
pass
def _login(client):
from app.auth.session import SessionManager
from app.db import get_conn
with get_conn() as conn:
login = f"v511u_{secrets.token_hex(4)}"
conn.execute(
"INSERT INTO users (login, full_name, email, password_hash, is_admin) "
"VALUES (?, 'V511 User', ?, ?, 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, "is_admin": 0})
csrf = client.get("/api/csrf-token", cookies={"flowdeck_session": session}).json()["csrf_token"]
return session, csrf, uid
def _login_admin(client):
from app.auth.session import SessionManager
from app.db import get_conn
with get_conn() as conn:
login = f"v511a_{secrets.token_hex(4)}"
conn.execute(
"INSERT INTO users (login, full_name, email, password_hash, is_admin) "
"VALUES (?, 'V511 Admin', ?, ?, 1)",
(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, "is_admin": 1})
csrf = client.get("/api/csrf-token", cookies={"flowdeck_session": session}).json()["csrf_token"]
return session, csrf, uid
def _mk_page(client, session, title, blocks=None):
r = client.post("/board/api/pages", params={"title": title, "section": "Private"},
cookies={"flowdeck_session": session}, headers={"X-CSRF-Token": "x"})
assert r.status_code == 200
pid = r.json()["id"]
if blocks is not None:
r = client.post(f"/board/api/pages/{pid}/blocks",
json={"title": title, "blocks": blocks},
cookies={"flowdeck_session": session})
assert r.status_code == 200, r.text
return pid
def _blocks_b(text):
return [{"id": "b1", "type": "paragraph", "content": text}]
# ── Migration ──
def test_migration_v513_schema(client):
from app.db import get_conn
with get_conn() as conn:
pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()}
tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
version = conn.execute("SELECT MAX(version) FROM schema_version").fetchone()[0]
for col in ("is_locked", "locked_by", "full_width", "font_small"):
assert col in pcols
assert "page_global_templates" in tables
assert version >= 13
# ── wiki_links service ──
def test_wiki_token_helpers():
from app.services.wiki_links import extract_page_ids, resolve_tokens_html
assert extract_page_ids("see [[fdpage:12]] and [[fdpage:7]] plus [[fdpage:12]]") == [7, 12]
html = resolve_tokens_html(
"Hi [[fdpage:7]] on [[fddate:2026-10-01]]",
{"[[fdpage:7]]": "Project X", "[[fddate:2026-10-01]]": "Thu 1 Oct 2026"},
)
assert '<a class="fd-wiki-link" href="/pages/7"' in html and "Project X" in html
assert 'class="fd-wiki-date"' in html and "Thu 1 Oct 2026" in html
# escaping: raw HTML in label is escaped
html2 = resolve_tokens_html("[[fdpage:1]]", {"[[fdpage:1]]": "<script>x</script>"})
assert "<script>" not in html2 and "&lt;script&gt;" in html2
# ── Picker + titles endpoints ──
def test_wiki_pages_picker(client):
s, c, uid = _login(client)
_mk_page(client, s, "Project Aurora")
_mk_page(client, s, "Meeting notes")
r = client.get("/board/api/wiki/pages", params={"q": "aurora"},
cookies={"flowdeck_session": s})
assert r.status_code == 200
titles = [p["title"] for p in r.json()["pages"]]
assert "Project Aurora" in titles and "Meeting notes" not in titles
# subsequence fuzzy: "mtg" matches "Meeting notes"? m-t-g not in order → try "mnt"
r2 = client.get("/board/api/wiki/pages", params={"q": "mnt"},
cookies={"flowdeck_session": s}).json()["pages"]
assert any(p["title"] == "Meeting notes" for p in r2)
def test_wiki_titles_rename_propagation(client):
s, c, uid = _login(client)
target = _mk_page(client, s, "Old Title")
r = client.get("/board/api/wiki/titles", params={"ids": str(target)},
cookies={"flowdeck_session": s})
assert r.json()["titles"][str(target)].strip().endswith("Old Title")
# rename
client.put(f"/board/api/pages/{target}", params={"title": "New Title"},
cookies={"flowdeck_session": s})
r2 = client.get("/board/api/wiki/titles", params={"ids": f"{target},99999"},
cookies={"flowdeck_session": s})
assert "New Title" in r2.json()["titles"][str(target)]
assert r2.json()["titles"].get("99999") is None # unknown ids simply absent
# ── Tokens persist + backlinks ──
def test_wiki_token_in_blocks_and_backlink(client):
s, c, uid = _login(client)
target = _mk_page(client, s, "Linked Page")
src = _mk_page(client, s, "Source", _blocks_b(f"Check [[fdpage:{target}]] for details"))
# token survives the save
page = client.get(f"/board/api/pages/{src}").json()
assert f"[[fdpage:{target}]]" in page["content"]
# backlink scanner finds the token reference
bl = client.get(f"/board/api/pages/{target}/backlinks",
cookies={"flowdeck_session": s}).json()["backlinks"]
assert any(b["id"] == src for b in bl)
def test_public_page_renders_wiki_chips(client):
s, c, uid = _login(client)
target = _mk_page(client, s, "Target Doc")
pid = _mk_page(client, s, "Public Doc", _blocks_b(f"Hello [[fdpage:{target}]] today [[fddate:2026-12-25]]"))
r = client.post(f"/board/api/pages/{pid}/publish", cookies={"flowdeck_session": s})
assert r.status_code == 200, r.text
slug = r.json()["publish_slug"]
html = client.get(f"/p/{slug}").text
assert f'href="/pages/{target}"' in html
assert "Target Doc" in html
assert "fd-wiki-date" in html and "Fri 25 Dec 2026" in html
# ── Page lock ──
def test_page_lock_blocks_non_owner(client):
owner_s, owner_c, owner_uid = _login(client)
other_s, other_c, other_uid = _login(client)
pid = _mk_page(client, owner_s, "Locked", _blocks_b("keep me"))
r = client.post(f"/board/api/pages/{pid}/lock", json={"locked": True},
cookies={"flowdeck_session": owner_s}, headers={"X-CSRF-Token": owner_c})
assert r.status_code == 200 and r.json()["is_locked"] == 1
# non-owner edit → 423
r2 = client.post(f"/board/api/pages/{pid}/blocks",
json={"title": "hacked", "blocks": _blocks_b("hacked")},
cookies={"flowdeck_session": other_s})
assert r2.status_code == 423
# the owner can still edit
r3 = client.post(f"/board/api/pages/{pid}/blocks",
json={"title": "Locked", "blocks": _blocks_b("still fine")},
cookies={"flowdeck_session": owner_s})
assert r3.status_code == 200
# non-owner cannot unlock
r4 = client.post(f"/board/api/pages/{pid}/lock", json={"locked": False},
cookies={"flowdeck_session": other_s}, headers={"X-CSRF-Token": other_c})
assert r4.status_code == 403
# admin can force-unlock
a_s, a_c, a_uid = _login_admin(client)
r5 = client.post(f"/board/api/pages/{pid}/lock", json={"locked": False},
cookies={"flowdeck_session": a_s}, headers={"X-CSRF-Token": a_c})
assert r5.status_code == 200 and r5.json()["is_locked"] == 0
# after unlock, everyone edits again
r6 = client.post(f"/board/api/pages/{pid}/blocks",
json={"title": "Locked", "blocks": _blocks_b("open again")},
cookies={"flowdeck_session": other_s})
assert r6.status_code == 200
def test_page_lock_put_and_get_page_data(client):
s, c, uid = _login(client)
pid = _mk_page(client, s, "QL", _blocks_b("x"))
r = client.put(f"/board/api/pages/{pid}", params={"title": "T"},
cookies={"flowdeck_session": s})
assert r.status_code == 200
client.post(f"/board/api/pages/{pid}/lock", json={"locked": True},
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
other_s, other_c, _ = _login(client)
r2 = client.put(f"/board/api/pages/{pid}", params={"title": "nope"},
cookies={"flowdeck_session": other_s})
assert r2.status_code == 423
# rendered page context carries lock state
html = client.get(f"/pages/{pid}", cookies={"flowdeck_session": other_s}).text
assert '"is_locked": true' in html and '"can_edit": false' in html
def test_page_options(client):
s, c, uid = _login(client)
pid = _mk_page(client, s, "Opt")
r = client.post(f"/board/api/pages/{pid}/options",
json={"full_width": True, "font_small": False},
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
assert r.status_code == 200 and r.json()["full_width"] is True
html = client.get(f"/pages/{pid}", cookies={"flowdeck_session": s}).text
assert '"full_width": true' in html
r2 = client.post(f"/board/api/pages/{pid}/options", json={},
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
assert r2.status_code == 400
# ── Global page templates ──
def test_builtin_templates_seeded_in_api(client):
s, c, uid = _login(client)
r = client.get("/board/api/page-templates", cookies={"flowdeck_session": s})
assert r.status_code == 200
tpls = r.json()["templates"]
names = [t["name"] for t in tpls]
for expected in ("Empty", "Meeting notes", "Weekly report", "To-do list", "Project doc"):
assert expected in names
assert all(t["builtin"] for t in tpls)
def test_use_builtin_template_creates_page(client):
s, c, uid = _login(client)
r = client.post("/board/api/page-templates/0/use", json={"key": "meeting_notes"},
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
assert r.status_code == 200
pid = r.json()["id"]
page = client.get(f"/board/api/pages/{pid}").json()
assert page["content_format"] == "blocks"
blocks = json.loads(page["content"])
types = [b["type"] for b in blocks]
assert "heading_1" in types and "to_do" in types
texts = " ".join(b.get("content", "") for b in blocks)
assert "Action items" in texts
# unknown key → 404
assert client.post("/board/api/page-templates/0/use", json={"key": "nope"},
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c}).status_code == 404
def test_user_template_roundtrip(client):
s, c, uid = _login(client)
pid = _mk_page(client, s, "My Weekly", _blocks_b("body text"))
r = client.post("/board/api/page-templates",
json={"name": "My Weekly template", "page_id": pid, "icon": "⭐"},
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
assert r.status_code == 200
tid = r.json()["id"]
lst = client.get("/board/api/page-templates", cookies={"flowdeck_session": s}).json()["templates"]
mine = [t for t in lst if t["name"] == "My Weekly template"]
assert mine and mine[0]["builtin"] is False and mine[0]["icon"] == "⭐"
# instantiate
r2 = client.post(f"/board/api/page-templates/{tid}/use", json={"title": "New instance"},
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
assert r2.status_code == 200 and r2.json()["title"] == "New instance"
npage = client.get(f"/board/api/pages/{r2.json()['id']}").json()
assert "body text" in npage["content"]
# templates are personal: another user can neither list nor use it
s2, c2, _ = _login(client)
lst2 = client.get("/board/api/page-templates", cookies={"flowdeck_session": s2}).json()["templates"]
assert all(t["name"] != "My Weekly template" for t in lst2)
assert client.post(f"/board/api/page-templates/{tid}/use", cookies={"flowdeck_session": s2},
headers={"X-CSRF-Token": c2}).status_code == 404
def test_template_requires_name(client):
s, c, uid = _login(client)
r = client.post("/board/api/page-templates", json={"blocks": _blocks_b("x")},
cookies={"flowdeck_session": s}, headers={"X-CSRF-Token": c})
assert r.status_code == 400
def test_block_templates_service_shapes():
from app.services.block_templates import blocks_json_for, template_list
assert blocks_json_for("empty") is not None
assert blocks_json_for("nope") is None
meeting = json.loads(blocks_json_for("meeting_notes"))
assert any(b["type"] == "heading_2" and b["content"] == "Agenda" for b in meeting)
assert any(b["type"] == "to_do" for b in meeting)
for t in template_list():
assert set(t) >= {"key", "name", "icon", "description", "builtin"}
# ── Front-end wiring (rendered template markers) ──
def test_editor_front_end_wired(client):
import pathlib
src = pathlib.Path("app/templates/_page_editor_scripts.html").read_text(encoding="utf-8")
base = pathlib.Path("app/templates/base.html").read_text(encoding="utf-8")
assert "gtTok" in src and "fd-wiki-chip" in src # chip render + reader
assert "wiki/pages" in src and "wiki/titles" in src # picker + label resolution
assert "WM={" in src and "fdpage:" in src # [[ picker module
assert "_openTemplatePicker" in base and "page-templates" in base
assert "toggleLock" in src and "saveAsTemplate" in src
css = pathlib.Path("static/css/app.css").read_text(encoding="utf-8")
assert ".fd-wiki-chip" in css and ".fd-lock-banner" in css and ".tpl-picker-modal" in css