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