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
|
# 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
|
## v5.11.7 (2026-09-13) — v5.8.0 Calendrier & Rappels
|
||||||
|
|
||||||
> Calendrier complet (jour / semaine / mois) avec récurrences expandues
|
> 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.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
|
> **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)
|
- [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)
|
||||||
- [ ] **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
|
- [x] **Mention de page `@`** — le menu `@` (utilisateurs v4.9.0) gagne la section « Pages » : `@nom` cherche les pages et insère le chip inline
|
||||||
- [ ] **Mention de date `@`** — `@today`, `@tomorrow`, `@2026-10-01` rendus comme chips de date
|
- [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 »)
|
||||||
- [ ] **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] **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.
|
> **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
|
- [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`)
|
||||||
- [ ] **Bouton « Use template »** — duplication du contenu du template dans la nouvelle page
|
- [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
|
||||||
- [ ] **Page lock** — toggle 🔒 dans le menu « … » : page en lecture seule (édition désactivée pour tous sauf owner/admin), indicateur visuel en topbar
|
- [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
|
||||||
- [ ] **Full-width mode** — toggle pour passer la page en pleine largeur (comme Notion)
|
- [x] **Full-width mode** — toggle dans le menu « … », persisté par page (`pages.full_width`), `POST /board/api/pages/{id}/options`, classe CSS `.full-width`
|
||||||
- [ ] **Small text / typo options** — option de page : taille de police réduite, serif/mono
|
- [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)
|
## 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)
|
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)
|
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)
|
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
|
## 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
|
DB avancée, redo, drag&drop, bookmark, avancée
|
||||||
Calendrier, AI duplicate) lightbox…) (Pt.2)
|
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
|
from app.services.reminders import reminder_scheduler
|
||||||
reminder_task = asyncio.create_task(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:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
@@ -107,7 +107,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="5.11.7",
|
version="5.12.0",
|
||||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
|||||||
@@ -509,3 +509,41 @@ def _migration_v58_meeting_template(conn: sqlite3.Connection) -> None:
|
|||||||
"UPDATE database_templates SET description=? WHERE name='Meeting notes'",
|
"UPDATE database_templates SET description=? WHERE name='Meeting notes'",
|
||||||
("Notes de réunion avec participants, agenda, notes et actions.",),
|
("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",
|
"#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 ──
|
# ── Core helpers ──
|
||||||
|
|
||||||
def _issue_column(issue: dict, columns: list[str], board_id: int) -> str:
|
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: str = Query(default=""),
|
||||||
content_format: str = Query(default="")):
|
content_format: str = Query(default="")):
|
||||||
"""Update a page's title and/or content. Accepts JSON body for blocks."""
|
"""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:
|
with get_conn() as conn:
|
||||||
|
_ensure_page_editable(conn, page_id, user)
|
||||||
if title:
|
if title:
|
||||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
||||||
if content:
|
if content:
|
||||||
@@ -1002,6 +1248,7 @@ async def save_page_blocks(request: Request, page_id: int):
|
|||||||
uid = (user or {}).get("id")
|
uid = (user or {}).get("id")
|
||||||
|
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
_ensure_page_editable(conn, page_id, user)
|
||||||
if title:
|
if title:
|
||||||
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id))
|
||||||
conn.execute(
|
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}``.
|
reference to ``/pages/{page_id}`` or ``#fdblk-…`` inside ``/pages/{page_id}``.
|
||||||
"""
|
"""
|
||||||
target = f"/pages/{page_id}" if page_id else None
|
target = f"/pages/{page_id}" if page_id else None
|
||||||
|
wiki_target = f"[[fdpage:{page_id}]]" if page_id else None
|
||||||
backlinks = []
|
backlinks = []
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
@@ -1070,17 +1318,17 @@ async def page_backlinks(request: Request, page_id: int):
|
|||||||
blocks = json.loads(r["content"])
|
blocks = json.loads(r["content"])
|
||||||
for b in blocks if isinstance(blocks, list) else []:
|
for b in blocks if isinstance(blocks, list) else []:
|
||||||
for text in _block_texts(b):
|
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
|
hits = True
|
||||||
break
|
break
|
||||||
if hits:
|
if hits:
|
||||||
break
|
break
|
||||||
except (json.JSONDecodeError, TypeError):
|
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":
|
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"]:
|
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:
|
if not hits and target:
|
||||||
hits = f"/pages/{page_id}" in (r["content"] or "")
|
hits = f"/pages/{page_id}" in (r["content"] or "")
|
||||||
if hits:
|
if hits:
|
||||||
@@ -1492,7 +1740,15 @@ async def view_page(request: Request, page_id: int):
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
# Build page_data, including file metadata for uploaded files
|
# 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
|
# For file pages, extract file metadata and add to page_data
|
||||||
if page.get("content_format") == "file":
|
if page.get("content_format") == "file":
|
||||||
|
|||||||
@@ -338,7 +338,17 @@ async def view_page_root(request: Request, page_id: int):
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
# Build page_data, including file metadata for uploaded files
|
# 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
|
# For file pages, extract file metadata and add to page_data
|
||||||
if page.get("content_format") == "file":
|
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
|
import json as _json
|
||||||
try:
|
try:
|
||||||
blocks = _json.loads(page["content"])
|
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):
|
except (_json.JSONDecodeError, Exception):
|
||||||
content_html = f"<p>{page.get('content', '')}</p>"
|
content_html = f"<p>{page.get('content', '')}</p>"
|
||||||
elif page.get("content"):
|
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())
|
return "".join(ch for ch in str(block_id) if ch.isalnum())
|
||||||
|
|
||||||
|
|
||||||
def _render_blocks_public(blocks: list) -> str:
|
def _render_blocks_public(blocks: list, titles: dict | None = None) -> str:
|
||||||
"""Render FlowDeck blocks as plain HTML for public pages."""
|
"""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 = []
|
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:
|
for b in blocks:
|
||||||
t = b.get("type", "paragraph")
|
t = b.get("type", "paragraph")
|
||||||
c = b.get("content", "") or ""
|
c = _wiki(b.get("content", "") or "")
|
||||||
if t == "heading_1":
|
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>')
|
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":
|
elif t == "heading_2":
|
||||||
@@ -2208,7 +2233,7 @@ def _render_blocks_public(blocks: list) -> str:
|
|||||||
children_html = ""
|
children_html = ""
|
||||||
if b.get("children"):
|
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 = '<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>"
|
children_html += "</div>"
|
||||||
html_parts.append(
|
html_parts.append(
|
||||||
f'<details style="margin:8px 0;" open><summary style="cursor:pointer;font-weight:500;">{c}</summary>{children_html}</details>'
|
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 += (
|
cols_html += (
|
||||||
'<div style="flex:1;min-width:0;padding:10px 12px;background:rgba(255,255,255,.05);'
|
'<div style="flex:1;min-width:0;padding:10px 12px;background:rgba(255,255,255,.05);'
|
||||||
'border-radius:8px;box-sizing:border-box;">'
|
'border-radius:8px;box-sizing:border-box;">'
|
||||||
+ _render_blocks_public([child]) + "</div>"
|
+ _render_blocks_public([child], titles) + "</div>"
|
||||||
)
|
)
|
||||||
html_parts.append(
|
html_parts.append(
|
||||||
f'<div style="display:flex;gap:12px;margin:8px 0 16px;align-items:stretch;">{cols_html}</div>'
|
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>
|
||||||
<div class="more-menu-item" @click="movePage">↗ Move to</div>
|
<div class="more-menu-item" @click="movePage">↗ Move to</div>
|
||||||
<div class="more-menu-sep"></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">
|
<div class="more-menu-item danger" @click="deletePage">
|
||||||
{{ fd_icon("trash",14) }} Move to Trash
|
{{ fd_icon("trash",14) }} Move to Trash
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,11 +27,31 @@ window.__fdEditorScriptsLoaded = true;
|
|||||||
let _bid=0;function genId(){return 'b'+(++_bid)+'_'+Date.now().toString(36);}
|
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 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:'');}
|
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){
|
function splitCaret(e){
|
||||||
const s=window.getSelection(); if(!s.rangeCount||!s.rangeCount)return null; const sel=s.getRangeAt(0);
|
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;
|
const MK='\uF000FD'; const mark=document.createElement('span'); mark.textContent=MK;
|
||||||
sel.insertNode(mark);
|
sel.insertNode(mark);
|
||||||
const full=gt(e)||'';
|
const full=gtTok(e)||'';
|
||||||
const parts=full.split(MK);
|
const parts=full.split(MK);
|
||||||
mark.remove(); e.normalize();
|
mark.remove(); e.normalize();
|
||||||
return {before:parts[0]||'',after:(parts.length>1?parts[1]:'')||'',content:full.split(MK).join('')};
|
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,'<strong>$1</strong>')
|
||||||
.replace(/\*([^*]+)\*/g,'<em>$1</em>')
|
.replace(/\*([^*]+)\*/g,'<em>$1</em>')
|
||||||
.replace(/~~([^~]+)~~/g,'<s>$1</s>')
|
.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>');
|
.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=['💡','💡','📌','⚠️','ℹ️','✅','❌','🚀','🔔','📣','⭐','❤️','🔥','💬','👀','🧠','🎯','🔑','📅','💰','💡','📚','🧪','🌍'];
|
const CALLOUT_ICONS=['💡','💡','📌','⚠️','ℹ️','✅','❌','🚀','🔔','📣','⭐','❤️','🔥','💬','👀','🧠','🎯','🔑','📅','💰','💡','📚','🧪','🌍'];
|
||||||
|
|
||||||
@@ -297,6 +353,64 @@ window.__fdEditorScriptsLoaded = true;
|
|||||||
sel(id){if(window.E)window.E.applySlash(id);SM.close();},
|
sel(id){if(window.E)window.E.applySlash(id);SM.close();},
|
||||||
get isOpen(){return SM._o;}};
|
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) ═══════════
|
// ═══════════ Inline AI composer (Notion AI-style) ═══════════
|
||||||
// Space on an empty paragraph → "Edit with AI" pill → Enter → "Brewing…"
|
// Space on an empty paragraph → "Edit with AI" pill → Enter → "Brewing…"
|
||||||
// → framed result → "Insert below" → applyAIBlocks.
|
// → framed result → "Insert below" → applyAIBlocks.
|
||||||
@@ -907,6 +1021,9 @@ window.__fdEditorScriptsLoaded = true;
|
|||||||
iconValue:'',
|
iconValue:'',
|
||||||
// ── Import ──
|
// ── Import ──
|
||||||
importOpen:false,importText:'',importFile:null,
|
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 ──
|
// ── DB Templates ──
|
||||||
dbTplOpen:false,dbTplLoading:false,dbTemplates:[],
|
dbTplOpen:false,dbTplLoading:false,dbTemplates:[],
|
||||||
// ── Version history ──
|
// ── 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(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);}
|
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(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','')];
|
if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')];
|
||||||
this.loadInlineDBs();
|
this.loadInlineDBs();
|
||||||
window.E=this;
|
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);};
|
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();
|
SM.init();_tblInitDelegate();this.render();
|
||||||
|
this._applyLockMode();this._applyLayout();_wikiResolve();
|
||||||
const tEl=document.getElementById('_titleEl');
|
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(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);}
|
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);}
|
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);
|
},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,
|
fileData:null,
|
||||||
fileUrl:'',
|
fileUrl:'',
|
||||||
async loadFileContent(data){
|
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();
|
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;},
|
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;},
|
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);},
|
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)};},
|
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;}
|
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==='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;}
|
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)||'';
|
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=gt(che)||'';});
|
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=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=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(){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
|
// 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);
|
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-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());});
|
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){
|
_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(!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>';}}
|
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==='Escape'){e.preventDefault();window.AIAC.clear();return;}
|
||||||
if(e.key==='Enter'||e.key==='Backspace'){window.AIAC.clear();}
|
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(e.key===' '){
|
||||||
if(block.type==='paragraph'&&!(block.content||'').trim()&&!(el.textContent||'').trim()&&!SM.isOpen&&window.AIC){
|
if(block.type==='paragraph'&&!(block.content||'').trim()&&!(el.textContent||'').trim()&&!SM.isOpen&&window.AIC){
|
||||||
e.preventDefault(); window.AIC.open(idx); return;
|
e.preventDefault(); window.AIC.open(idx); return;
|
||||||
@@ -1704,7 +1904,7 @@ applyAIBlocks(text){
|
|||||||
if(e.shiftKey){this.dirty=true;this.autoSave();return;}
|
if(e.shiftKey){this.dirty=true;this.autoSave();return;}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const sp=splitCaret(el);
|
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()){
|
if(!before.trim()&&!after.trim()){
|
||||||
const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph';
|
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();
|
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';
|
btn.style.left=x+'px';btn.style.top=y+'px';
|
||||||
},
|
},
|
||||||
hideCommentBtn(){const btn=document.getElementById('_commentSelBtn');if(btn)btn.style.display='none';},
|
hideCommentBtn(){const btn=document.getElementById('_commentSelBtn');if(btn)btn.style.display='none';},
|
||||||
// ── @mention autocomplete ──
|
// ── @mention autocomplete (v4.9.0 users + v5.11.0 pages & dates) ──
|
||||||
mentionOpen:false,mentionQuery:'',mentionResults:[],mentionEl:null,
|
mentionOpen:false,mentionQuery:'',mentionResults:[],mentionPages:[],mentionEl:null,
|
||||||
openMention(el){
|
openMention(el){
|
||||||
const s=window.getSelection();if(!s.rangeCount)return;
|
const s=window.getSelection();if(!s.rangeCount)return;
|
||||||
const text=el.textContent||'';const pos=s.getRangeAt(0).startOffset;
|
const range=s.getRangeAt(0).cloneRange();
|
||||||
const before=text.substring(0,pos);
|
range.selectNodeContents(el);range.setEnd(s.getRangeAt(0).startContainer,s.getRangeAt(0).startOffset);
|
||||||
|
const before=range.toString();
|
||||||
const m=before.match(/@([\w\-\.]*)$/);
|
const m=before.match(/@([\w\-\.]*)$/);
|
||||||
if(!m)return;
|
if(!m){return;}
|
||||||
this.mentionQuery=m[1];this.mentionEl=el;
|
this.mentionQuery=m[1];this.mentionEl=el;
|
||||||
const r=s.getRangeAt(0).getBoundingClientRect();
|
const r=s.getRangeAt(0).getBoundingClientRect();
|
||||||
const menu=document.getElementById('_mentionMenu');
|
const menu=document.getElementById('_mentionMenu');
|
||||||
menu.style.display='block';
|
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';
|
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||'';
|
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(){
|
renderMentionMenu(){
|
||||||
const menu=document.getElementById('_mentionMenu');if(!menu)return;
|
const menu=document.getElementById('_mentionMenu');if(!menu)return;
|
||||||
let h='';const self=this;
|
let h='';const self=this;
|
||||||
(this.mentionResults||[]).forEach(function(u,idx){
|
h+='<div class="mention-group-label">People</div>';
|
||||||
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;">'
|
(this.mentionResults||[]).slice(0,6).forEach(function(u){
|
||||||
+'<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-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()))
|
+((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.innerHTML=h;
|
||||||
menu.querySelectorAll('.mention-item').forEach(function(it){
|
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){
|
insertMention(login){
|
||||||
const el=this.mentionEl;if(!el)return;
|
if(this._replaceAtMention('@'+login+' ')){
|
||||||
const s=window.getSelection();if(s.rangeCount){s.deleteFromDocument();}
|
this.closeMention();this.dirty=true;this.autoSave();
|
||||||
const text=el.textContent||'';const pos=s.rangeCount? (()=>{const r=s.getRangeAt(0);return r.startOffset;} )():text.length;
|
this.notifyNewMentions('@'+login);
|
||||||
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);
|
|
||||||
},
|
},
|
||||||
closeMention(){const menu=document.getElementById('_mentionMenu');if(menu)menu.style.display='none';this.mentionOpen=false;this.mentionEl=null;},
|
closeMention(){const menu=document.getElementById('_mentionMenu');if(menu)menu.style.display='none';this.mentionOpen=false;this.mentionEl=null;},
|
||||||
notifyNewMentions(text){
|
notifyNewMentions(text){
|
||||||
|
|||||||
+80
-29
@@ -904,43 +904,94 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new page/file in the correct workspace context.
|
* Create a new page: opens the v5.12.0 template picker (built-in + the
|
||||||
* - Local workspace active → workspace item (prompts for name)
|
* user's saved templates), then instantiates the chosen one.
|
||||||
* - Gitea workspace active → workspace item with workspace_id (prompts for name)
|
|
||||||
* - No workspace → general Notion page (no prompt, title "New page")
|
|
||||||
*/
|
*/
|
||||||
createPage: function() {
|
createPage: function() {
|
||||||
var ctx = this._getContext();
|
|
||||||
var self = this;
|
var self = this;
|
||||||
|
// Direct creation path (fallback / no-template choice).
|
||||||
// ── Workspace context → create workspace item ──
|
this._createPlainPage = function() {
|
||||||
if (ctx.wsId > 0) {
|
var ctx = self._getContext();
|
||||||
var body = { name: '', type: 'page' };
|
if (ctx.wsId > 0) {
|
||||||
if (ctx.isGitea) body.workspace_id = ctx.wsId;
|
var body = { name: '', type: 'page' };
|
||||||
|
if (ctx.isGitea) body.workspace_id = ctx.wsId;
|
||||||
fetch('/api/local-workspace/items', {
|
fetch('/api/local-workspace/items', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() },
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': self.getCsrfToken() },
|
||||||
body: JSON.stringify(body)
|
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;
|
|
||||||
})
|
})
|
||||||
.catch(function(err) { self._toast('Failed: ' + err.message, 'error'); });
|
.then(function(r) { if (!r.ok) throw new Error('Failed'); return r.json(); })
|
||||||
return;
|
.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 ──
|
_openTemplatePicker: function() {
|
||||||
var project = ctx.workspaceKey;
|
var self = this;
|
||||||
var url = '/board/api/pages?section=Private&project=' + encodeURIComponent(project);
|
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, {
|
fetch(url, {
|
||||||
method: 'POST',
|
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(r){ if (!r.ok) throw new Error('Template failed'); return r.json(); })
|
||||||
.then(function(data) { window.location.href = '/pages/' + data.id; })
|
.then(function(d){ window.location.href = '/pages/' + d.id; })
|
||||||
.catch(function(err) { self._toast('Failed to create page: ' + err.message, 'error'); });
|
.catch(function(){ self._createPlainPage(); });
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Show the global New Folder modal. */
|
/** 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:hover { background: rgba(255,255,255,.25); }
|
||||||
.fd-lightbox-nav[data-nav="prev"] { left: 24px; }
|
.fd-lightbox-nav[data-nav="prev"] { left: 24px; }
|
||||||
.fd-lightbox-nav[data-nav="next"] { right: 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