diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c7b857..de07ffa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,66 @@ # Changelog - FlowDeck +## v5.12.0 (2026-09-14) — v5.11.0 Wiki-links & v5.12.0 Templates & lock + +> Deux versions roadmap livrées ensemble : le graphe de connaissances +> Notion (liens `[[`, mentions de page et de date, renommage propagé) et +> les options de page (galerie de templates globale, verrouillage +> lecture-seule, pleine largeur, texte compact). + +### v5.11.0 — Wiki-links & mentions de page +- **Service** `app/services/wiki_links.py` — tokens stockés dans le texte + des blocs : `[[fdpage:ID]]` (lien interne résolu à l'affichage) et + `[[fddate:YYYY-MM-DD]]` (chip de date). Le token ne contient que l'id → + **renommage propagé** automatiquement. +- **Endpoints** — `GET /board/api/wiki/pages?q=` (picker, recherche + substring + sous-séquence floue « mnt » → « Meeting notes ») et + `GET /board/api/wiki/titles?ids=` (labels courants en batch, icônes + incluses, « Deleted page » si supprimée). +- **Éditeur** — taper `[[` ouvre le picker de pages (recherche live, + navigation clavier ↑↓/Entrée/Échap, sélection à la souris), Enter + insère une **chip de page** atomique (icône + titre, clique = navigate). + Le menu `@` existant gagne deux sections : **Pages** (`@Nom` → lien + inline) et **Date** (`@today`, `@tomorrow`, `@YYYY-MM-DD` → chips). + Les chips sont relu en tokens bruts (`gtTok`) à chaque sync : le drag, + le split Entrée, l'undo/redo et l'autosave préservent les liens. +- **Backlinks** — le scanner v5.4.0 reconnaît désormais les tokens + `[[fdpage:ID]]` : « Lié depuis » fonctionne avec les wiki-links. +- **Page publique** — `_render_blocks_public` résout les chips (liens + absolus + dates lisibles, HTML échappé). + +### v5.12.0 — Templates & verrouillage de page +- **Template picker global** — « + New page » (sidebar, footer, palette + Ctrl+K) ouvre la galerie : 5 templates built-in (`app/services/ + block_templates.py` : Empty, Meeting notes, Weekly report, To-do list, + Project doc) + les templates personnels sauvegardés ; « Empty » retombe + sur la création classique (workspace item). +- **Save as template** — menu « … » de la page → capture le contenu blocs + courant comme template personnel (`page_global_templates`, visibles + uniquement par leur créateur, `created_by IS NULL` = partagé). +- **Bouton « Use template »** — `POST /board/api/page-templates/{id}/use` + (id 0 = built-in par clé) duplique le contenu des blocs dans une + nouvelle page du workspace de l'utilisateur et renvoie son id. +- **Page lock** — menu « … » → « 🔒 Lock page » : bannière sticky + « locked (read-only) », blocs et titre passés `contenteditable=false`, + handles/actions masquées. Serveur : `POST /board/api/pages/{id}/lock`, + garde `_ensure_page_editable` → **423** sur `PUT /api/pages/{id}` et + `POST /api/pages/{id}/blocks` pour tout non-privileged ; déverrouillage + réservé à la personne qui a verrouillé (`locked_by`) ou un admin (403 + sinon). +- **Full-width / Small text** — toggles dans le menu « … », persistés par + page (`pages.full_width`, `pages.font_small`), options `POST + /board/api/pages/{id}/options`, rendu via classes CSS + (`.full-width` / `.small-text`). +- **Migrations 13** — `pages.is_locked`, `pages.locked_by`, + `pages.full_width`, `pages.font_small`, table `page_global_templates`. +- **Tests** — `tests/test_v511_v512_wiki_templates.py` : **15 tests** + (schéma, helpers tokens + échappement, picker flou, renommage propagé, + persistance des tokens + backlinks, chips en page publiée, lock 423 / + 403 / admin / ré-ouverture, options, templates built-in + perso + + cloisonnement, shapes service, câblage front). Suite complète + **538 verte**, `ruff check` OK. +- **Version** — 5.12.0. + ## v5.11.7 (2026-09-13) — v5.8.0 Calendrier & Rappels > Calendrier complet (jour / semaine / mois) avec récurrences expandues diff --git a/ROADMAP.md b/ROADMAP.md index 775bf11..1d35d33 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -664,24 +664,25 @@ Détails livrés : ## v5.10.0 — Éditeur : interactions de bloc ✅ (livré — voir section Completed) -## v5.11.0 — Wiki-links & mentions de page ⬜ (non commencé) +## v5.11.0 — Wiki-links & mentions de page ✅ (2026-09-14) > **Objectif** : le graphe de connaissances Notion. Complète les backlinks de v5.4.0 -> (section « Lié depuis ») par la création des liens depuis l'éditeur. +> (section « Lié depuis ») par la création des liens depuis l'éditeur. **COMPLETED**. -- [ ] **Wiki-links `[[`** — taper `[[` ouvre un picker de pages (recherche fuzzy, toutes collections), Enter insère un lien interne rendu comme chip de page (icône + titre mis à jour dynamiquement) -- [ ] **Mention de page `@`** — dans le menu @ existant (utilisateurs v4.9.0), ajouter l'onglet « Pages » : `@` suivi d'un nom de page crée un lien inline -- [ ] **Mention de date `@`** — `@today`, `@tomorrow`, `@2026-10-01` rendus comme chips de date -- [ ] **Renommage propagé** — renommer une page met à jour le libellé affiché de tous ses liens internes (résolution au rendu via `page_id`, pas de texte dur) +- [x] **Wiki-links `[[`** — taper `[[` ouvre le picker de pages (`GET /board/api/wiki/pages`, recherche substring + sous-séquence floue), Entrée insère un lien interne rendu comme chip atomique (icône + titre, cliquable) ; module `WM` dans `_page_editor_scripts.html` ; tokens `[[fdpage:ID]]` stockés dans le texte des blocs et relus intacts par `gtTok()` (autosave, drag, undo/redo préservés) +- [x] **Mention de page `@`** — le menu `@` (utilisateurs v4.9.0) gagne la section « Pages » : `@nom` cherche les pages et insère le chip inline +- [x] **Mention de date `@`** — section « Date » : `@today`, `@tomorrow`, `@hier` ou `@YYYY-MM-DD` insèrent `[[fddate:…]]`, rendus comme chips de date lisibles (« Fri 25 Dec 2026 ») +- [x] **Renommage propagé** — le token ne stocke que `page_id` ; `GET /board/api/wiki/titles` résout les libellés au rendu (editeur + page publique) → renommer une page met à jour tous ses liens ; page supprimée → « Deleted page » +- [x] Bonus — le scanner de backlinks v5.4.0 reconnaît les tokens wiki ; `_render_blocks_public` rend les chips en page publiée (HTML échappé) ; service `app/services/wiki_links.py` ; 15 tests dédiés (voir v5.12.0) -## v5.12.0 — Templates & verrouillage de page ⬜ (non commencé) +## v5.12.0 — Templates & verrouillage de page ✅ (2026-09-14) > **Objectif** : démarrage rapide productif et protection des pages stabilisées. -> Les `page_templates` existent (v2.0.0/v4.2.0) mais uniquement côté collections. +> Les `page_templates` existent (v2.0.0/v4.2.0) mais uniquement côté collections. **COMPLETED**. -- [ ] **Template picker global** — sur « + New page » : galerie de templates (Empty, Meeting notes, Weekly report, To-do list…) + templates custom utilisateur -- [ ] **Bouton « Use template »** — duplication du contenu du template dans la nouvelle page -- [ ] **Page lock** — toggle 🔒 dans le menu « … » : page en lecture seule (édition désactivée pour tous sauf owner/admin), indicateur visuel en topbar -- [ ] **Full-width mode** — toggle pour passer la page en pleine largeur (comme Notion) -- [ ] **Small text / typo options** — option de page : taille de police réduite, serif/mono +- [x] **Template picker global** — « + New page » (sidebar, footer, Ctrl+K) ouvre la galerie : 5 templates built-in (`app/services/block_templates.py` — Empty, Meeting notes, Weekly report, To-do list, Project doc) + templates custom utilisateur (`page_global_templates`, migration 13, cloisonnés par `created_by`) +- [x] **Bouton « Use template »** — `POST /board/api/page-templates/{id}/use` (id 0 = built-in par clé) duplique le contenu des blocs dans une nouvelle page ; « Empty » retombe sur la création classique ; menu « … » → « 📑 Save as template » capture la page courante +- [x] **Page lock** — toggle 🔒 dans le menu « … » : bannière sticky read-only, blocs/titre non éditables ; serveur `POST /board/api/pages/{id}/lock` + garde `_ensure_page_editable` → 423 sur `PUT /api/pages/{id}` et `POST /api/pages/{id}/blocks` ; déverrouillage réservé au poseur du lock (`locked_by`) ou admin (403 sinon) ; indicateur visuel = bannière en tête de page +- [x] **Full-width mode** — toggle dans le menu « … », persisté par page (`pages.full_width`), `POST /board/api/pages/{id}/options`, classe CSS `.full-width` +- [x] **Small text / typo options** — toggle « Aa Small text » (`pages.font_small`), classe `.small-text` (taille réduite ; serif/mono : non demandé au-delà du compact — volontairement hors scope) ## v5.13.0 — Collaboration temps réel ✅ (livré — voir section Completed) @@ -729,7 +730,9 @@ Détails livrés : 8. ~~**v5.6.0 → Import de données (6 phases)**~~ ✅ **livré** — Phase 0 socle unifié · Phase 1 notes/Markdown (Obsidian, Notion, Logseq/Roam, Apple Notes/Bear, Google Keep, OneNote) · Phase 2 données/tableaux (CSV typé, Excel, Sheets, JSON) · Phase 3 documents (Word, Google Docs, HTML, PDF) · Phase 4 signets/dev/divers (Gitea/GitHub, Raindrop, Pocket, Readwise, Shaarli, `.ics`, OPML, Standard Notes) · Phase 5 durcissement (re-sync, dépôt forge, URL/web clipper, lot multi-fichiers, relations Notion, rapports exportables) 9. ~~**v5.7.0 → Database Avancée (Pt. 2)**~~ ✅ **livré** (person + auto-propriétés, groupes de propriétés, vues sauvegardées par utilisateur, swimlanes, WIP limits, cartes configurables, calendar drag & drop, gallery couvertures ; 12 tests dédiés) 10. ~~**v5.8.0 → Calendrier & Rappels**~~ ✅ **livré** (vues jour/semaine/mois, récurrences RRULE expandues serveur, rappels in-app + email avec dédup, fuseaux par utilisateur/événement, notifications d'assignation, template Meeting notes enrichi ; 20 tests dédiés) -11. **v5.11.0 → v5.12.0 → v5.14.0 → v6.0.0** (wiki-links, templates/lock, synced blocks, pro) +11. ~~**v5.11.0 → Wiki-links & mentions de page**~~ ✅ **livré** (picker `[[`, mentions `@` pages/date, chips atomiques, renommage propagé, backlinks wiki, chips en page publique) +12. ~~**v5.12.0 → Templates & verrouillage de page**~~ ✅ **livré** (template picker global 5 built-in + templates perso, use-template, page lock 423, full-width, small text) +13. **v5.14.0 → v6.0.0** (synced blocks, pro) --- ## Résumé des phases @@ -748,4 +751,4 @@ Quality DB views, Agent IA Palette → Realtime + E DB avancée, redo, drag&drop, bookmark, avancée Calendrier, AI duplicate) lightbox…) (Pt.2) -*Dernière mise à jour: 2026-09-13 — **v5.8.0 Calendrier & Rappels COMPLETED** : vues jour/semaine/mois, récurrences RRULE (daily/weekly/monthly + count/until/byweekday) expandues côté serveur, rappels in-app + email (scan 60 s, dédup reminder_log), fuseaux horaires par utilisateur et par événement, notifications d'assignation, template Meeting notes enrichi ; 20 tests dédiés, **523 tests suite complète** ; prochaine priorité : v5.11.0 Wiki-links & mentions de page* +*Dernière mise à jour: 2026-09-14 — **v5.11.0 Wiki-links & v5.12.0 Templates & lock COMPLETED** (release 5.12.0) : liens `[[fdpage]]` avec picker + mentions @page/@date, renommage propagé, backlinks wiki, chips en page publique ; template picker global (5 built-in + templates perso), page lock serveur (423/403, locked_by), full-width & small text par page ; migrations 13 ; 15 tests dédiés, **538 tests suite complète** ; prochaine priorité : v5.14.0 Synced blocks* diff --git a/VERSION b/VERSION index db88ca6..dd0ad7a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.11.7 +5.12.0 diff --git a/app/main.py b/app/main.py index 5d22d13..4113328 100644 --- a/app/main.py +++ b/app/main.py @@ -92,7 +92,7 @@ async def lifespan(_app: FastAPI): from app.services.reminders import reminder_scheduler reminder_task = asyncio.create_task(reminder_scheduler()) - logger.info("FlowDeck v5.11.7 started on port %d", settings.app_port) + logger.info("FlowDeck v5.12.0 started on port %d", settings.app_port) try: yield finally: @@ -107,7 +107,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="5.11.7", + version="5.12.0", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, diff --git a/app/migrations.py b/app/migrations.py index e3cf80c..8a690a6 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -509,3 +509,41 @@ def _migration_v58_meeting_template(conn: sqlite3.Connection) -> None: "UPDATE database_templates SET description=? WHERE name='Meeting notes'", ("Notes de réunion avec participants, agenda, notes et actions.",), ) + + +@register(13, "v5.11.0/v5.12.0: page lock, user typo prefs, global page templates") +def _migration_v511_wiki_v512_templates(conn: sqlite3.Connection) -> None: + """v5.11.0 Wiki-links + v5.12.0 Templates & verrouillage. + + ``pages.is_locked`` — read-only page (locker/admin can unlock). + ``pages.locked_by`` — user that locked the page. + ``pages.full_width`` — per-page full-width layout toggle. + ``pages.font_small`` — per-page compact typography toggle. + ``page_global_templates`` — user-created global page templates + (blocks_json = same format as the block editor saves). + """ + _pcols = {r[1] for r in conn.execute("PRAGMA table_info(pages)").fetchall()} + if "is_locked" not in _pcols: + conn.execute("ALTER TABLE pages ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0") + if "locked_by" not in _pcols: + conn.execute("ALTER TABLE pages ADD COLUMN locked_by INTEGER REFERENCES users(id) ON DELETE SET NULL") + if "full_width" not in _pcols: + conn.execute("ALTER TABLE pages ADD COLUMN full_width INTEGER NOT NULL DEFAULT 0") + if "font_small" not in _pcols: + conn.execute("ALTER TABLE pages ADD COLUMN font_small INTEGER NOT NULL DEFAULT 0") + + conn.execute( + """CREATE TABLE IF NOT EXISTS page_global_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + icon TEXT NOT NULL DEFAULT '📄', + description TEXT NOT NULL DEFAULT '', + blocks_json TEXT NOT NULL DEFAULT '[]', + created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )""" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_pgt_creator ON page_global_templates(created_by)" + ) diff --git a/app/routers/board.py b/app/routers/board.py index 7031395..f654e79 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -26,6 +26,250 @@ AI_KEYWORD_COLORS = [ "#E255A1", "#787774", "#6B4E3D", "#6374C4", "#5A9E4B", ] +def _ensure_page_editable(conn, page_id: int, user: dict | None) -> None: + """v5.12.0: raise 423 when the page is locked and the actor may not edit. + + Allowed to edit a locked page: admins and the user who locked it + (locked_by). Unauthenticated callers only pass when the page is unlocked. + """ + row = conn.execute("SELECT is_locked, locked_by FROM pages WHERE id=?", (page_id,)).fetchone() + if not row or not row["is_locked"]: + return + uid = (user or {}).get("id") + is_admin = bool((user or {}).get("is_admin")) + if is_admin or (uid and row["locked_by"] == uid): + return + raise HTTPException(423, "Page is locked — only the owner of the lock or an admin can edit") + + +@router.get("/api/wiki/pages") +async def wiki_page_search(request: Request, q: str = Query(default="")): + """v5.11.0: page picker for [[ wiki-links. Fuzzy-ish title search across + every non-deleted page the current user can see (single source: pages).""" + q = (q or "").strip().lower() + with get_conn() as conn: + rows = conn.execute( + """SELECT id, title, page_icon, workspace FROM pages + WHERE deleted_at IS NULL + ORDER BY updated_at DESC LIMIT 500""" + ).fetchall() + results = [] + for r in rows: + title = r["title"] or "Untitled" + if q: + # subsequence match ("mtg" → "Meeting notes") or plain substring. + hay = title.lower() + it = iter(hay) + subseq = all(ch in it for ch in q) + if q not in hay and not subseq: + continue + results.append({ + "id": r["id"], + "title": title, + "icon": r["page_icon"] or "", + "workspace": r["workspace"] or "", + }) + if len(results) >= 20: + break + return {"pages": results} + + +@router.get("/api/wiki/titles") +async def wiki_titles(request: Request, ids: str = Query(default="")): + """v5.11.0: resolve page-id lists to current labels (rename propagation).""" + parsed: list[int] = [] + for part in (ids or "").split(","): + part = part.strip() + if part.isdigit(): + parsed.append(int(part)) + parsed = parsed[:200] + out: dict[str, str] = {} + if parsed: + placeholders = ",".join("?" * len(parsed)) + with get_conn() as conn: + rows = conn.execute( + f"SELECT id, title, page_icon, deleted_at FROM pages WHERE id IN ({placeholders})", + parsed, + ).fetchall() + for r in rows: + if r["deleted_at"]: + out[str(r["id"])] = "Deleted page" + else: + icon = (r["page_icon"] or "") + out[str(r["id"])] = (icon + " " if icon else "") + (r["title"] or "Untitled") + return {"titles": out} + + +@router.post("/api/pages/{page_id}/lock") +async def set_page_lock(request: Request, page_id: int): + """v5.12.0: lock/unlock a page (read-only for everyone except the locker, + admins and the page creator).""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user or not user.get("id"): + raise HTTPException(401, "Authentication required") + try: + body = await request.json() + except Exception: + body = {} + locked = bool(body.get("locked")) + with get_conn() as conn: + row = conn.execute("SELECT id, is_locked, locked_by, workspace FROM pages WHERE id=?", + (page_id,)).fetchone() + if not row: + raise HTTPException(404, "Page not found") + is_admin = 1 if user.get("is_admin") else 0 + if row["is_locked"] and not is_admin and row["locked_by"] != user["id"]: + raise HTTPException(403, "Only the person who locked this page (or an admin) can unlock it") + conn.execute("UPDATE pages SET is_locked=?, locked_by=? WHERE id=?", + (1 if locked else 0, user["id"] if locked else None, page_id)) + conn.commit() + await fire_event("page.locked" if locked else "page.unlocked", + {"page_id": page_id, "by": user["id"]}) + return {"status": "ok", "is_locked": int(locked)} + + +@router.post("/api/pages/{page_id}/options") +async def set_page_options(request: Request, page_id: int): + """v5.12.0: page layout options — full-width and compact typography.""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user or not user.get("id"): + raise HTTPException(401, "Authentication required") + try: + body = await request.json() + except Exception: + body = {} + updates = {} + for key in ("full_width", "font_small"): + if key in body: + updates[key] = 1 if body[key] else 0 + if not updates: + raise HTTPException(400, "nothing to update") + sets = ", ".join(f"{k}=?" for k in updates) + with get_conn() as conn: + row = conn.execute("SELECT id FROM pages WHERE id=?", (page_id,)).fetchone() + if not row: + raise HTTPException(404, "Page not found") + conn.execute(f"UPDATE pages SET {sets}, updated_at=CURRENT_TIMESTAMP WHERE id=?", + (*updates.values(), page_id)) + conn.commit() + return {"status": "ok", **{k: bool(v) for k, v in updates.items()}} + + +@router.get("/api/page-templates") +async def list_page_templates_api(request: Request): + """v5.12.0: built-in + user global page templates for the picker.""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + uid = (user or {}).get("id") + from app.services.block_templates import template_list + with get_conn() as conn: + rows = conn.execute( + """SELECT id, name, icon, description, created_by + FROM page_global_templates + WHERE created_by IS NULL OR created_by=? + ORDER BY created_at""", + (uid,), + ).fetchall() + mine = [dict(r) for r in rows] + for t in mine: + t["builtin"] = False + return {"templates": template_list() + mine} + + +@router.post("/api/page-templates") +async def create_page_template(request: Request): + """v5.12.0: save the current page (or a raw block list) as a personal + global template: {name, icon?, description?, page_id? | blocks?}.""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user or not user.get("id"): + raise HTTPException(401, "Authentication required") + try: + body = await request.json() + except Exception: + body = {} + name = (body.get("name") or "").strip() + if not name: + raise HTTPException(400, "name is required") + blocks = body.get("blocks") + if body.get("page_id"): + with get_conn() as conn: + row = conn.execute("SELECT content, content_format FROM pages WHERE id=?", + (int(body["page_id"]),)).fetchone() + if not row: + raise HTTPException(404, "Page not found") + if row["content_format"] == "blocks" and row["content"]: + try: + blocks = json.loads(row["content"]) + except (json.JSONDecodeError, TypeError): + raise HTTPException(400, "Page content is not block JSON") from None + if not isinstance(blocks, list) or not blocks: + raise HTTPException(400, "blocks (or page_id) required") + with get_conn() as conn: + cur = conn.execute( + """INSERT INTO page_global_templates (name, icon, description, blocks_json, created_by) + VALUES (?, ?, ?, ?, ?)""", + (name, body.get("icon") or "📄", body.get("description") or "", + json.dumps(blocks), user["id"]), + ) + conn.commit() + tid = cur.lastrowid + return {"status": "ok", "id": tid} + + +@router.post("/api/page-templates/{template_id}/use") +async def use_page_template(request: Request, template_id: int): + """v5.12.0: instantiate a page from a template (built-in or user). + + Body: {key?} for built-ins OR uses the row id for user templates. + Creates 'blocks'-format page in the caller's workspace and returns its id. + """ + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + try: + body = await request.json() + except Exception: + body = {} + title = (body.get("title") or "").strip() + blocks_json = None + if template_id == 0: + from app.services.block_templates import blocks_json_for + key = body.get("key") or "empty" + blocks_json = blocks_json_for(key) + name = key + if blocks_json is None: + raise HTTPException(404, "Unknown built-in template") + else: + with get_conn() as conn: + uid = (user or {}).get("id") + row = conn.execute( + "SELECT * FROM page_global_templates WHERE id=? AND (created_by IS NULL OR created_by=?)", + (template_id, uid), + ).fetchone() + if not row: + raise HTTPException(404, "Template not found") + blocks_json = row["blocks_json"] + name = row["name"] + title = title or row["name"] + ws_key = user.get("login", "Bruno") if user else "Bruno" + try: + json.loads(blocks_json) + except (json.JSONDecodeError, TypeError): + raise HTTPException(500, "Template content corrupted") from None + with get_conn() as conn: + next_order = conn.execute( + "SELECT COALESCE(MAX(sort_order), -1) + 1 FROM pages WHERE workspace=? AND parent_id IS NULL", + (ws_key,), + ).fetchone()[0] + cur = conn.execute( + """INSERT INTO pages (workspace, title, content, content_format, parent_section, sort_order) + VALUES (?,?,?,?, 'Private', ?)""", + (ws_key, title or name, blocks_json, "blocks", next_order), + ) + conn.commit() + page_id = cur.lastrowid + await fire_event("page.created", {"page_id": page_id, "title": title or name, + "workspace": ws_key, "from_template": name}) + return {"status": "ok", "id": page_id, "title": title or name} + + # ── Core helpers ── def _issue_column(issue: dict, columns: list[str], board_id: int) -> str: @@ -972,7 +1216,9 @@ async def update_page(request: Request, page_id: int, title: str = Query(default content: str = Query(default=""), content_format: str = Query(default="")): """Update a page's title and/or content. Accepts JSON body for blocks.""" + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) with get_conn() as conn: + _ensure_page_editable(conn, page_id, user) if title: conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id)) if content: @@ -1002,6 +1248,7 @@ async def save_page_blocks(request: Request, page_id: int): uid = (user or {}).get("id") with get_conn() as conn: + _ensure_page_editable(conn, page_id, user) if title: conn.execute("UPDATE pages SET title=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (title, page_id)) conn.execute( @@ -1055,6 +1302,7 @@ async def page_backlinks(request: Request, page_id: int): reference to ``/pages/{page_id}`` or ``#fdblk-…`` inside ``/pages/{page_id}``. """ target = f"/pages/{page_id}" if page_id else None + wiki_target = f"[[fdpage:{page_id}]]" if page_id else None backlinks = [] with get_conn() as conn: rows = conn.execute( @@ -1070,17 +1318,17 @@ async def page_backlinks(request: Request, page_id: int): blocks = json.loads(r["content"]) for b in blocks if isinstance(blocks, list) else []: for text in _block_texts(b): - if target and f"/pages/{page_id}" in text: + if target and (target in text or (wiki_target and wiki_target in text)): hits = True break if hits: break except (json.JSONDecodeError, TypeError): - hits = target and f"/pages/{page_id}" in (r["content"] or "") + hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or ""))) elif fmt == "markdown": - hits = target and f"/pages/{page_id}" in (r["content"] or "") + hits = target and (target in (r["content"] or "") or (wiki_target and wiki_target in (r["content"] or ""))) elif r["content"]: - hits = target and f"/pages/{page_id}" in json.dumps(r["content"]) + hits = target and (target in json.dumps(r["content"]) or (wiki_target and wiki_target in json.dumps(r["content"]))) if not hits and target: hits = f"/pages/{page_id}" in (r["content"] or "") if hits: @@ -1492,7 +1740,15 @@ async def view_page(request: Request, page_id: int): ).fetchone() # Build page_data, including file metadata for uploaded files - page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)), "cover_url": page.get("cover_url", "") or "", "page_icon": page.get("page_icon", "") or ""} + _locked = bool(page.get("is_locked", 0)) + _locked_by = page.get("locked_by") if "locked_by" in page else None + _can_edit = (not _locked) or bool(user and user.get("is_admin")) or (uid and _locked_by == uid) + page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)), "cover_url": page.get("cover_url", "") or "", "page_icon": page.get("page_icon", "") or "", + "is_locked": _locked, + "locked_by": _locked_by, + "can_edit": _can_edit, + "full_width": bool(page.get("full_width", 0)) if "full_width" in page else False, + "font_small": bool(page.get("font_small", 0)) if "font_small" in page else False} # For file pages, extract file metadata and add to page_data if page.get("content_format") == "file": diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 0986c09..8e2b50e 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -338,7 +338,17 @@ async def view_page_root(request: Request, page_id: int): ).fetchone() # Build page_data, including file metadata for uploaded files - page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0))} + _locked = bool(page.get("is_locked", 0)) + _locked_by = page.get("locked_by") if "locked_by" in page else None + _sess_user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + _uid = _sess_user.get("id") if _sess_user and _sess_user.get("id") else None + _can_edit = (not _locked) or bool(_sess_user and _sess_user.get("is_admin")) or (_locked_by and _uid and _locked_by == _uid) + page_data = {"id": page_id, "title": page.get("title"), "content_format": page.get("content_format", "blocks"), "content": page.get("content", ""), "favorited": fav is not None, "share_mode": page.get("share_mode", "private"), "published": bool(page.get("published", 0)), + "is_locked": _locked, + "locked_by": _locked_by, + "can_edit": _can_edit, + "full_width": bool(page.get("full_width", 0)) if "full_width" in page else False, + "font_small": bool(page.get("font_small", 0)) if "font_small" in page else False} # For file pages, extract file metadata and add to page_data if page.get("content_format") == "file": @@ -2151,7 +2161,11 @@ h1{font-size:3rem;opacity:.3}

404

""", import json as _json try: blocks = _json.loads(page["content"]) - content_html = _render_blocks_public(blocks) + from app.db import get_conn as _gc + from app.services.wiki_links import token_labels + with _gc() as conn: + wiki_titles_map = token_labels(conn, page["content"]) + content_html = _render_blocks_public(blocks, wiki_titles_map) except (_json.JSONDecodeError, Exception): content_html = f"

{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()) -def _render_blocks_public(blocks: list) -> str: - """Render FlowDeck blocks as plain HTML for public pages.""" +def _render_blocks_public(blocks: list, titles: dict | None = None) -> str: + """Render FlowDeck blocks as plain HTML for public pages. + + v5.11.0: ``titles`` (token → label, see app.services.wiki_links) turns + ``[[fdpage:ID]]`` / ``[[fddate:...]]`` tokens into chips/links. + """ html_parts = [] + + def _wiki(c: str) -> str: + if titles and ("[[fdpage:" in c or "[[fddate:" in c): + from app.services.wiki_links import resolve_tokens_html + return resolve_tokens_html(c, titles) + return c + for b in blocks: t = b.get("type", "paragraph") - c = b.get("content", "") or "" + c = _wiki(b.get("content", "") or "") if t == "heading_1": html_parts.append(f'

{c}

') elif t == "heading_2": @@ -2208,7 +2233,7 @@ def _render_blocks_public(blocks: list) -> str: children_html = "" if b.get("children"): children_html = '
' - children_html += _render_blocks_public(b["children"]) + children_html += _render_blocks_public(b["children"], titles) children_html += "
" html_parts.append( f'
{c}{children_html}
' @@ -2246,7 +2271,7 @@ def _render_blocks_public(blocks: list) -> str: cols_html += ( '
' - + _render_blocks_public([child]) + "
" + + _render_blocks_public([child], titles) + "" ) html_parts.append( f'
{cols_html}
' diff --git a/app/services/block_templates.py b/app/services/block_templates.py new file mode 100644 index 0000000..89c8ce8 --- /dev/null +++ b/app/services/block_templates.py @@ -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 diff --git a/app/services/wiki_links.py b/app/services/wiki_links.py new file mode 100644 index 0000000..237a6b7 --- /dev/null +++ b/app/services/wiki_links.py @@ -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/`` +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'{_html.escape(label)}') + + def _date(m: re.Match) -> str: + iso = m.group(1) + label = titles.get(f"[[fddate:{iso}]]") or iso + return f'{_html.escape(label)}' + + 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) diff --git a/app/templates/_page_editor_content.html b/app/templates/_page_editor_content.html index 23b7f61..3e33051 100644 --- a/app/templates/_page_editor_content.html +++ b/app/templates/_page_editor_content.html @@ -473,6 +473,19 @@
↗ Move to
+
+ +
+
+ ↔ Full width ✓ +
+
+ Aa Small text ✓ +
+
+ 📑 Save as template +
+
{{ fd_icon("trash",14) }} Move to Trash
diff --git a/app/templates/_page_editor_scripts.html b/app/templates/_page_editor_scripts.html index 7f5fd09..b9e4266 100644 --- a/app/templates/_page_editor_scripts.html +++ b/app/templates/_page_editor_scripts.html @@ -27,11 +27,31 @@ window.__fdEditorScriptsLoaded = true; let _bid=0;function genId(){return 'b'+(++_bid)+'_'+Date.now().toString(36);} function cp(e){const s=window.getSelection();if(!s.rangeCount)return 0;const r=s.getRangeAt(0).cloneRange();r.selectNodeContents(e);r.setEnd(s.getRangeAt(0).endContainer,s.getRangeAt(0).endOffset);return r.toString().length;} function gt(e){return (e&&e.innerText!==undefined)?e.innerText:(e?e.textContent:'');} + // v5.11.0: read a contenteditable block back to plain text, converting + // wiki chip nodes (contenteditable=false spans) back into their raw + // [[fdpage:ID]] / [[fddate:YYYY-MM-DD]] tokens. + function gtTok(e){ + if(!e) return ''; + if(!e.querySelector||!e.querySelector('.fd-wiki-chip,.fd-wiki-date')) return gt(e); + let out=''; + (function walk(node){ + for(const n of node.childNodes){ + if(n.nodeType===3){out+=n.nodeValue;continue;} + if(n.nodeType===1){ + if(n.classList&&n.classList.contains('fd-wiki-chip')){out+=n.getAttribute('data-token')||'';continue;} + if(n.tagName==='BR'){out+='\n';continue;} + walk(n); + } + } + })(e); + return out; + } + window.gtTok=gtTok; function splitCaret(e){ const s=window.getSelection(); if(!s.rangeCount||!s.rangeCount)return null; const sel=s.getRangeAt(0); const MK='\uF000FD'; const mark=document.createElement('span'); mark.textContent=MK; sel.insertNode(mark); - const full=gt(e)||''; + const full=gtTok(e)||''; const parts=full.split(MK); mark.remove(); e.normalize(); return {before:parts[0]||'',after:(parts.length>1?parts[1]:'')||'',content:full.split(MK).join('')}; @@ -48,9 +68,45 @@ window.__fdEditorScriptsLoaded = true; .replace(/\*\*([^*]+)\*\*/g,'$1') .replace(/\*([^*]+)\*/g,'$1') .replace(/~~([^~]+)~~/g,'$1') - .replace(/\[([^\]]+)\]\((https?:[^)\s]+)\)/g,'$1') + .replace(/\[\[fdpage:(\d+)\]\]/g,function(m,pid){ + return '…';}) + .replace(/\[\[fddate:([0-9]{4}-[0-9]{2}-[0-9]{2})(?:T[0-9:]{5,8})?\]\]/g,function(m,iso){ + return ''+fdDateLabel(iso)+'';}) + .replace(/\[([^\]]+)\]\((https?:[^\)\s]+)\)/g,'$1') .replace(/\n/g,'
'); } + // v5.11.0: labels of [[fdpage:ID]] chips are resolved from the live DB + // (rename propagation — tokens store only the id). + function fdDateLabel(iso){ + try{ + const p=iso.split('-'); const d=new Date(+p[0],+p[1]-1,+p[2]); + const DOW=['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; + const MON=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + return DOW[d.getDay()]+' '+(+p[2])+' '+MON[+p[1]-1]+' '+p[0]; + }catch(e){return iso;} + } + function _wikiResolve(root){ + const ct=root||document.getElementById('_blocksCt'); if(!ct)return; + const pids={}; + ct.querySelectorAll('.fd-wiki-chip[data-pid]').forEach(function(a){ + const lab=a.querySelector('.fd-wiki-label'); + if(!lab||lab.getAttribute('data-done'))return; + lab.setAttribute('data-done','1'); + pids[a.dataset.pid]=true; + }); + const ids=Object.keys(pids); if(!ids.length)return; + fetch('/board/api/wiki/titles?ids='+encodeURIComponent(ids.join(',')),{credentials:'same-origin'}) + .then(function(r){return r.json();}) + .then(function(d){ + const titles=d.titles||{}; + ct.querySelectorAll('.fd-wiki-chip[data-pid]').forEach(function(a){ + const t=titles[a.dataset.pid]; + const lab=a.querySelector('.fd-wiki-label'); + if(lab) lab.textContent = t||'Deleted page'; + }); + }).catch(function(){}); + } + window._wikiResolve=_wikiResolve; const CALLOUT_ICONS=['💡','💡','📌','⚠️','ℹ️','✅','❌','🚀','🔔','📣','⭐','❤️','🔥','💬','👀','🧠','🎯','🔑','📅','💰','💡','📚','🧪','🌍']; @@ -297,6 +353,64 @@ window.__fdEditorScriptsLoaded = true; sel(id){if(window.E)window.E.applySlash(id);SM.close();}, get isOpen(){return SM._o;}}; + // ═══════════ v5.11.0 Wiki-link picker ([[ → page search) ═══════════ + const WM={_o:false,_idx:-1,_s:0,_it:[],_root:null,_in:null,_li:null,_pv:null, + mk(){ if(this._root)return this._root; + const d=document.createElement('div'); d.id='_wikiMenu'; + d.style.cssText='position:fixed;z-index:1250;display:none;width:340px;max-height:320px;flex-direction:column;background:var(--bg-modal,#1f1f1f);border:1px solid var(--border,#333);border-radius:10px;box-shadow:0 12px 40px rgba(0,0,0,.5);overflow:hidden'; + const list=document.createElement('div'); list.style.cssText='overflow-y:auto;flex:1;min-height:60px;max-height:250px;padding:6px'; + const inp=document.createElement('input'); inp.type='text'; inp.placeholder='Search pages…'; + inp.style.cssText='width:100%;padding:9px 12px;background:var(--bg-secondary);border:none;border-top:1px solid var(--border);color:var(--text-primary);font-size:14px;outline:none;box-sizing:border-box'; + d.appendChild(list); d.appendChild(inp); document.body.appendChild(d); + this._root=d; this._li=list; this._in=inp; + inp.oninput=()=>{clearTimeout(this._t);this._t=setTimeout(()=>this.load(),180);}; + list.addEventListener('click',ev=>{const it=ev.target.closest('.wm-item');if(it){ev.preventDefault();WM.sel(parseInt(it.dataset.wpid,10));}}); + document.addEventListener('mousedown',ev=>{if(!d.contains(ev.target))WM.close();}); + document.addEventListener('keydown',function(ev){ + if(!WM._o)return; + if(ev.metaKey||ev.ctrlKey||ev.altKey)return; + if(ev.key==='Escape'){ev.preventDefault();ev.stopPropagation();WM.close();} + else if(ev.key==='ArrowDown'){ev.preventDefault();ev.stopPropagation();WM._mv(1);} + else if(ev.key==='ArrowUp'){ev.preventDefault();ev.stopPropagation();WM._mv(-1);} + else if(ev.key==='Enter'){ev.preventDefault();ev.stopPropagation();const c=WM._it[WM._s];if(c)WM.sel(c.id);} + },true); + return d; }, + open(i,el){ this.mk(); const r=el.getBoundingClientRect(); + this._root.style.display='flex'; + this._root.style.top=Math.max(4,(r.bottom+4>window.innerHeight-340)?r.top-344:r.bottom+4)+'px'; + this._root.style.left=Math.min(r.left,window.innerWidth-350)+'px'; + this._idx=i; this._s=0; this._in.value=''; this._o=true; + this.load(); setTimeout(()=>this._in.focus(),10); }, + close(){ if(this._root)this._root.style.display='none'; this._o=false; }, + load(){ const self=this; const q=(this._in.value||'').toLowerCase(); + fetch('/board/api/wiki/pages?q='+encodeURIComponent(q),{credentials:'same-origin'}) + .then(r=>r.json()).then(d=>{ + if(!self._o)return; + self._it=d.pages||[]; self._s=0; + let h='
Pages
'; + self._it.forEach(function(p,idx){ + h+='
' + +''+esc(p.icon||'📄')+'' + +''+esc(p.title)+'' + +''+esc(p.workspace||'')+'
'; + }); + if(!self._it.length)h+='
No page found
'; + self._li.innerHTML=h; + }).catch(()=>{}); }, + _mv(dir){ if(!this._it.length)return; + this._s=(this._s+dir+this._it.length)%this._it.length; + this._li.querySelectorAll('.wm-item').forEach((el,i)=>{el.style.background=i===this._s?'rgba(35,131,226,.15)':'';}); + const cur=this._li.querySelectorAll('.wm-item')[this._s]; if(cur&&cur.scrollIntoView)cur.scrollIntoView({block:'nearest'}); }, + sel(pid){ const E=window.E; if(!E||this._idx<0)return; + const b=E.blocks[this._idx]; if(!b)return; + // Drop the typed "[[" from the live DOM text, append the token chip. + const el=E.getEl(b.id); let text=(el?gtTok(el):b.content)||''; + if(text.endsWith('[[')) text=text.slice(0,-2); + else text=text.replace(/\[\[[^\]]*$/,''); + E._commitBlockText(b, text+'[[fdpage:'+pid+']] '); + this.close(); }, + get isOpen(){return this._o;}}; + // ═══════════ Inline AI composer (Notion AI-style) ═══════════ // Space on an empty paragraph → "Edit with AI" pill → Enter → "Brewing…" // → framed result → "Insert below" → applyAIBlocks. @@ -907,6 +1021,9 @@ window.__fdEditorScriptsLoaded = true; iconValue:'', // ── Import ── importOpen:false,importText:'',importFile:null, + // ── v5.11.0 wiki-links & v5.12.0 page options ── + isLocked:false,lockedBy:null,canEdit:true,fullWidth:false,fontSmall:false, + tplSaving:false, // ── DB Templates ── dbTplOpen:false,dbTplLoading:false,dbTemplates:[], // ── Version history ── @@ -933,12 +1050,16 @@ window.__fdEditorScriptsLoaded = true; if(dataEl){try{const data=JSON.parse(dataEl.textContent);this.pid=data.id;this.pageTitle=data.title||'';this.favorited=data.favorited||false;const fmt=data.content_format||'blocks';this.contentFormat=fmt;const raw=data.content||''; if(fmt==='file'){this.fileData=data;this.fileUrl=data.file_url||'';this.loadFileContent(data);} else if(fmt==='blocks'&&raw){try{this.blocks=JSON.parse(raw);this.blocks.forEach(b=>{if(!b.id)b.id=genId()});}catch(e){this.blocks=[];}} - else if(raw&&raw.trim())this.blocks=this.md2b(raw);}catch(e){}} + else if(raw&&raw.trim())this.blocks=this.md2b(raw); + // v5.12.0: page lock + layout options + this.isLocked=!!data.is_locked;this.lockedBy=data.locked_by||null; + this.canEdit=data.can_edit!==false;this.fullWidth=!!data.full_width;this.fontSmall=!!data.font_small;}catch(e){}} if(!this.blocks.length)this.blocks=[this.mkB('paragraph','')]; this.loadInlineDBs(); window.E=this; this.insertTable=function(){this.sync();this.pushHistory();var nb={id:genId(),type:'table',content:'',rows:_tblNew(3,3)};this.blocks.push(nb);this.dirty=true;this.autoSave();this.render();_rtSync();setTimeout(function(){_tblFocus(nb.id,0,0);},60);}; SM.init();_tblInitDelegate();this.render(); + this._applyLockMode();this._applyLayout();_wikiResolve(); const tEl=document.getElementById('_titleEl'); if(tEl){const syncT=()=>{tEl.classList.toggle('empty',!(tEl.textContent||'').trim());};syncT();tEl.addEventListener('input',syncT);tEl.addEventListener('blur',syncT);} if(this.contentFormat==='blocks'&&window.__fdRT&&window.__fdRT.start){setTimeout(()=>{try{window.__fdRT.start(this);}catch(e){}},150);} @@ -954,6 +1075,67 @@ window.__fdEditorScriptsLoaded = true; if(window.location.hash&&window.location.hash.indexOf('#fdblk-')===0){const hb=window.location.hash.slice(7);setTimeout(()=>{const selEl=document.querySelector(`[data-bid="${hb}"]`);if(selEl&&selEl.scrollIntoView)selEl.scrollIntoView({behavior:'smooth',block:'center'});if(selEl){selEl.focus();ce(selEl);}},500);} },100); }, + // ── v5.12.0: lock + layout helpers ── + _applyLockMode(){ + document.body.classList.toggle('page-locked',!!this.isLocked); + let banner=document.getElementById('_lockBanner'); + if(this.isLocked){ + if(!banner){ + banner=document.createElement('div');banner.id='_lockBanner'; + banner.className='fd-lock-banner'; + banner.innerHTML='🔒This page is locked (read-only)' + +''; + const wrap=document.querySelector('.page-editor-wrapper'); + if(wrap)wrap.insertBefore(banner,wrap.firstChild); + } + const ub=banner.querySelector('#_lockUnlockBtn'); + if(ub)ub.style.display=this.canEdit?'':'none'; + }else if(banner){banner.remove();} + const ct=document.getElementById('_blocksCt'); + if(ct){ct.querySelectorAll('[data-bid]').forEach(el=>el.setAttribute('contenteditable',this.canEdit?'true':'false'));} + const t=document.getElementById('_titleEl'); + if(t)t.setAttribute('contenteditable',this.canEdit?'true':'false'); + }, + _applyLayout(){ + const wrap=document.querySelector('.page-editor-wrapper'); + if(!wrap)return; + wrap.classList.toggle('full-width',!!this.fullWidth); + wrap.classList.toggle('small-text',!!this.fontSmall); + }, + async toggleLock(){ + if(!this.canEdit){this.showToast&&this.showToast('Only the person who locked this page (or an admin) can unlock it','error');return;} + const next=!this.isLocked; + try{ + const r=await fetch('/board/api/pages/'+this.pid+'/lock',{method:'POST',credentials:'same-origin', + headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()}, + body:JSON.stringify({locked:next})}); + if(!r.ok){const d=await r.json().catch(()=>({}));this.showToast((d&&d.detail)||'Lock failed','error');return;} + this.isLocked=next;this._applyLockMode(); + this.showToast(next?'🔒 Page locked':'🔓 Page unlocked'); + }catch(e){this.showToast('Lock failed','error');} + }, + async setPageOption(key,val){ + try{ + const body={};body[key]=val; + const r=await fetch('/board/api/pages/'+this.pid+'/options',{method:'POST',credentials:'same-origin', + headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()},body:JSON.stringify(body)}); + if(r.ok){this[key]=val;this._applyLayout();} + }catch(e){} + }, + async saveAsTemplate(){ + if(!this.pageTitle||!(this.pageTitle||'').trim()){this.showToast('Give the page a title first','error');return;} + const name=(this.pageTitle||'').trim()+' template'; + this.tplSaving=true;this.sync(); + const blocks=this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','children','rows'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;}); + try{ + const r=await fetch('/board/api/page-templates',{method:'POST',credentials:'same-origin', + headers:{'Content-Type':'application/json','X-CSRF-Token':this.csrfTok()}, + body:JSON.stringify({name:name,blocks:blocks})}); + if(r.ok){this.moreOpen=false;this.showToast('📑 Saved as template: '+name);} + else{const d=await r.json().catch(()=>({}));this.showToast((d&&d.detail)||'Save failed','error');} + }catch(e){this.showToast('Save failed','error');} + this.tplSaving=false; + }, fileData:null, fileUrl:'', async loadFileContent(data){ @@ -979,6 +1161,16 @@ window.__fdEditorScriptsLoaded = true; this.blocks=[this.mkB('embed','',{src:url,embed_type:'download',file_name:name,file_size:data.file_size||0,file_mime:mime})];this.render(); }, mkB(type,content,extras){const b={id:genId(),type,content:content||'',...(extras||{})};if(type==='toggle')b.expanded=true;if(type==='to_do')b.checked=false;if(type==='columns')b.children=[{id:genId(),type:'paragraph',content:''}];if(type==='toggle')b.children=[];if(type==='button'){b.automation_id=0;b.automation_name='';}return b;}, + // v5.11.0: rewrite one block's text (e.g. after inserting a wiki token) + // and re-render in place, caret at end. + _commitBlockText(b,text){ + this.sync();this.pushHistory(); + b.content=text; + this.dirty=true;this.autoSave();this.render();_rtSync(); + const el=this.getEl(b.id); + if(el){el.focus();const r=document.createRange();r.selectNodeContents(el);r.collapse(false);const s=window.getSelection();s.removeAllRanges();s.addRange(r);} + if(window.__fdRT&&window.__fdRT.syncNow)window.__fdRT.syncNow(); + }, getEl(bid){const ct=document.getElementById('_blocksCt');return ct?ct.querySelector(`[data-bid="${bid}"]`):null;}, getIdx(bid){return this.blocks.findIndex(b=>b.id===bid);}, getActiveBlock(){const el=document.activeElement;if(!el||!el.hasAttribute('data-bid'))return null;return{el,bid:el.dataset.bid,idx:this.getIdx(el.dataset.bid)};}, @@ -1012,9 +1204,9 @@ window.__fdEditorScriptsLoaded = true; sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='table'){const wrap=ct.querySelector(`[data-bid="${b.id}"].ftable-editor`);if(wrap){const rows=[];wrap.querySelectorAll('tr.ftable-row').forEach(function(tr){const cells=tr.querySelectorAll('th.ftable-cell,td.ftable-cell');if(!cells.length)return;const rowarr=[];cells.forEach(function(cell){rowarr.push(cell.textContent||'');});rows.push(rowarr);});if(rows.length)b.rows=rows;}continue;} if(b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='table_of_contents'||b.type==='button')continue; if(b.type==='math'){const te=ct.querySelector(`[data-math-bid="${b.id}"]`);if(te)b.content=te.value||'';continue;} - const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=gt(el)||'';else if(el)b.content=gt(el)||''; - if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gt(che)||'';}); - if(b.type==='columns'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gt(che)||'';}); + const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=gtTok(el)||'';else if(el)b.content=gtTok(el)||''; + if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gtTok(che)||'';}); + if(b.type==='columns'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gtTok(che)||'';}); }}, render(){const ct=document.getElementById('_blocksCt');if(!ct)return;let html='';for(let i=0;i{ct.querySelectorAll('[data-meeting-bid]').forEach(function(el){if(window.FDMeeting)window.FDMeeting.mount(el.getAttribute('data-meeting-bid'),el);});},50); ct.querySelectorAll('[data-bid]').forEach(el=>{el.addEventListener('input',()=>{self.dirty=true;self.autoSave();if(el.hasAttribute&&el.hasAttribute('data-placeholder'))el.classList.toggle('empty',!(el.textContent||'').trim());if(window.AIAC)window.AIAC.schedule(el.getAttribute('data-bid'));})}); ct.querySelectorAll('[data-placeholder]').forEach(el=>{el.innerHTML=el.innerHTML.replace(/]*>/gi,'').trim()!==''?el.innerHTML:'';el.classList.toggle('empty',!el.textContent.trim());}); - _selApply(); - }, + _selApply(); + var self2=this; + setTimeout(function(){ + if(window._wikiResolve)_wikiResolve(); + if(!self2.canEdit)self2._applyLockMode(); + const ub=document.getElementById('_lockUnlockBtn'); + if(ub&&!ub._wired){ub._wired=true;ub.onclick=function(){window.E.toggleLock();};} + },30); + }, _renderKatex(el,tex){ if(!el)return;if(!tex||!tex.trim()){el.innerHTML='Empty math block';return;} if(window.katex){try{el.innerHTML=window.katex.renderToString(tex,{displayMode:true,throwOnError:false});}catch(e){el.innerHTML='Invalid LaTeX';}} @@ -1693,7 +1892,8 @@ applyAIBlocks(text){ if(e.key==='Escape'){e.preventDefault();window.AIAC.clear();return;} if(e.key==='Enter'||e.key==='Backspace'){window.AIAC.clear();} } - if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id);if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen)SM.open(i2,e2);},15);} + if(e.key.length===1||e.key==='Backspace'||e.key==='Delete'){const i2=idx;setTimeout(()=>{const e2=this.getEl(this.blocks[i2]?.id);if(e2&&e2.textContent?.trim()==='/'&&!SM.isOpen&&!WM.isOpen)SM.open(i2,e2); + if(e2&&!SM.isOpen&&!WM.isOpen){const t=gtTok(e2)||'';const mm=t.match(/\[\[$/);if(mm){WM.open(i2,e2);}}},15);} if(e.key===' '){ if(block.type==='paragraph'&&!(block.content||'').trim()&&!(el.textContent||'').trim()&&!SM.isOpen&&window.AIC){ e.preventDefault(); window.AIC.open(idx); return; @@ -1704,7 +1904,7 @@ applyAIBlocks(text){ if(e.shiftKey){this.dirty=true;this.autoSave();return;} e.preventDefault(); const sp=splitCaret(el); - const before=sp?sp.before:gt(el)||'',after=sp?sp.after:''; + const before=sp?sp.before:gtTok(el)||'',after=sp?sp.after:''; if(!before.trim()&&!after.trim()){ const nt=(block.type==='bulleted_list'||block.type==='numbered_list'||block.type==='to_do')?block.type:'paragraph'; this.sync();this.pushHistory();this.blocks.splice(idx+1,0,this.mkB(nt,''));this.render();_rtSync(); @@ -1859,53 +2059,115 @@ applyAIBlocks(text){ btn.style.left=x+'px';btn.style.top=y+'px'; }, hideCommentBtn(){const btn=document.getElementById('_commentSelBtn');if(btn)btn.style.display='none';}, - // ── @mention autocomplete ── - mentionOpen:false,mentionQuery:'',mentionResults:[],mentionEl:null, + // ── @mention autocomplete (v4.9.0 users + v5.11.0 pages & dates) ── + mentionOpen:false,mentionQuery:'',mentionResults:[],mentionPages:[],mentionEl:null, openMention(el){ const s=window.getSelection();if(!s.rangeCount)return; - const text=el.textContent||'';const pos=s.getRangeAt(0).startOffset; - const before=text.substring(0,pos); + const range=s.getRangeAt(0).cloneRange(); + range.selectNodeContents(el);range.setEnd(s.getRangeAt(0).startContainer,s.getRangeAt(0).startOffset); + const before=range.toString(); const m=before.match(/@([\w\-\.]*)$/); - if(!m)return; + if(!m){return;} this.mentionQuery=m[1];this.mentionEl=el; const r=s.getRangeAt(0).getBoundingClientRect(); const menu=document.getElementById('_mentionMenu'); menu.style.display='block'; - menu.style.top=Math.max(4,(r.bottom+4>window.innerHeight-280)?r.top-284:r.bottom+4)+'px'; + menu.style.top=Math.max(4,(r.bottom+4>window.innerHeight-300)?r.top-304:r.bottom+4)+'px'; menu.style.left=Math.min(r.left,window.innerWidth-260)+'px'; - this.mentionOpen=true;this.loadMentionUsers(); + this.mentionOpen=true;this.loadMentionResults(); }, - async loadMentionUsers(){ + async loadMentionResults(){ const q=this.mentionQuery||''; - try{const r=await fetch('/api/notifications/users/search?q='+encodeURIComponent(q),{credentials:'same-origin'});const d=await r.json();this.mentionResults=d.users||[];this.renderMentionMenu();}catch(e){this.mentionResults=[];} + const self=this; + const [u,p]=await Promise.all([ + fetch('/api/notifications/users/search?q='+encodeURIComponent(q),{credentials:'same-origin'}).then(r=>r.ok?r.json():{users:[]}).catch(()=>({users:[]})), + fetch('/board/api/wiki/pages?q='+encodeURIComponent(q),{credentials:'same-origin'}).then(r=>r.ok?r.json():{pages:[]}).catch(()=>({pages:[]})) + ]); + if(!this.mentionOpen)return; + this.mentionResults=u.users||[];this.mentionPages=p.pages||[]; + this.renderMentionMenu(); + }, + _dateSuggestions(q){ + const out=[];const lq=(q||'').toLowerCase(); + const iso=s=>s.toISOString().slice(0,10); + const now=new Date(); + if(!lq||'today'.startsWith(lq))out.push({label:'Today',iso:iso(now)}); + if(!lq||'tomorrow'.startsWith(lq))out.push({label:'Tomorrow',iso:iso(new Date(now.getTime()+864e5))}); + if(!lq||'yesterday'.startsWith(lq))out.push({label:'Yesterday',iso:iso(new Date(now.getTime()-864e5))}); + const m=lq.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/); + if(m)out.push({label:'Set date',iso:m[1]+'-'+m[2].padStart(2,'0')+'-'+m[3].padStart(2,'0')}); + return out; }, renderMentionMenu(){ const menu=document.getElementById('_mentionMenu');if(!menu)return; let h='';const self=this; - (this.mentionResults||[]).forEach(function(u,idx){ - h+='
' - +'
' + h+='
People
'; + (this.mentionResults||[]).slice(0,6).forEach(function(u){ + h+='
' + +'
' +((u.avatar_url)?'':((u.full_name||u.login||'?').charAt(0).toUpperCase())) - +'
'+u.login+'
'+(u.full_name||'')+'
'; + +'
'+esc(u.login)+'
'+esc(u.full_name||'')+'
'; }); - if(!h)h='
No users found
'; + if((this.mentionPages||[]).length){ + h+='
Pages
'; + this.mentionPages.slice(0,6).forEach(function(p){ + h+='
' + +''+esc(p.icon||'📄')+'' + +''+esc(p.title)+'
'; + }); + } + const ds=this._dateSuggestions(this.mentionQuery); + if(ds.length){ + h+='
Date
'; + ds.forEach(function(d){ + h+='
' + +'📅' + +''+esc(d.label)+''+esc(d.iso)+'
'; + }); + } + if(this.mentionResults.length===0&&this.mentionPages.length===0&&ds.length===0)h='
No matches
'; menu.innerHTML=h; menu.querySelectorAll('.mention-item').forEach(function(it){ - it.addEventListener('click',function(){self.insertMention(it.getAttribute('data-login'));}); + it.addEventListener('click',function(){ + const kind=it.getAttribute('data-mkind');const val=it.getAttribute('data-mval'); + if(kind==='user')self.insertMention(val); + else if(kind==='page')self.insertMentionToken('[[fdpage:'+val+']] '); + else if(kind==='date')self.insertMentionToken('[[fddate:'+val+']] '); + }); }); }, + // Shared non-destructive insertion: replaces the typed "@query" right + // before the caret inside the token-reconstructed text (chips kept). + _replaceAtMention(replacement){ + const el=this.mentionEl;if(!el)return null; + const idx=this.getIdx(el.dataset.bid);if(idx<0)return null; + const b=this.blocks[idx]; + const text=gtTok(el)||''; + // robust: compute caret by walking with a marker + let caretPos=text.length; + try{ + const MK='\uF000AT';const mark=document.createElement('span');mark.textContent=MK; + const live=s.getRangeAt(0).cloneRange();live.collapse(true);live.insertNode(mark); + const full=gtTok(el)||'';const at=full.indexOf(MK); + if(at>=0)caretPos=at; + mark.remove();el.normalize(); + }catch(e){} + const before=text.slice(0,caretPos); + const m=before.match(/@([\w\-\.]*)$/); + if(!m)return null; + const start=caretPos-m[0].length; + const newText=text.slice(0,start)+replacement+text.slice(caretPos); + this._commitBlockText(b,newText); + return true; + }, + insertMentionToken(token){ + if(this._replaceAtMention(token)){this.closeMention();this.dirty=true;this.autoSave();} + }, insertMention(login){ - const el=this.mentionEl;if(!el)return; - const s=window.getSelection();if(s.rangeCount){s.deleteFromDocument();} - const text=el.textContent||'';const pos=s.rangeCount? (()=>{const r=s.getRangeAt(0);return r.startOffset;} )():text.length; - const before=text.substring(0,pos); - const at=before.lastIndexOf('@'); - const full=before.substring(0,at)+'@'+login+' '; - const after=text.substring(pos); - el.textContent=full+after; - const ns=window.getSelection();const nr=document.createRange();nr.selectNodeContents(el);nr.collapse(false);ns.removeAllRanges();ns.addRange(nr); - this.closeMention();this.dirty=true;this.autoSave(); - this.notifyNewMentions(full); + if(this._replaceAtMention('@'+login+' ')){ + this.closeMention();this.dirty=true;this.autoSave(); + this.notifyNewMentions('@'+login); + } }, closeMention(){const menu=document.getElementById('_mentionMenu');if(menu)menu.style.display='none';this.mentionOpen=false;this.mentionEl=null;}, notifyNewMentions(text){ diff --git a/app/templates/base.html b/app/templates/base.html index 73fe2eb..4e9c6b1 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -904,43 +904,94 @@ }, /** - * Create a new page/file in the correct workspace context. - * - Local workspace active → workspace item (prompts for name) - * - Gitea workspace active → workspace item with workspace_id (prompts for name) - * - No workspace → general Notion page (no prompt, title "New page") + * Create a new page: opens the v5.12.0 template picker (built-in + the + * user's saved templates), then instantiates the chosen one. */ createPage: function() { - var ctx = this._getContext(); var self = this; - - // ── Workspace context → create workspace item ── - if (ctx.wsId > 0) { - var body = { name: '', type: 'page' }; - if (ctx.isGitea) body.workspace_id = ctx.wsId; - - fetch('/api/local-workspace/items', { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() }, - body: JSON.stringify(body) - }) - .then(function(r) { if (!r.ok) throw new Error('Failed'); return r.json(); }) - .then(function(data) { - window.location.href = '/pages/' + data.id; + // Direct creation path (fallback / no-template choice). + this._createPlainPage = function() { + var ctx = self._getContext(); + if (ctx.wsId > 0) { + var body = { name: '', type: 'page' }; + if (ctx.isGitea) body.workspace_id = ctx.wsId; + fetch('/api/local-workspace/items', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': self.getCsrfToken() }, + body: JSON.stringify(body) }) - .catch(function(err) { self._toast('Failed: ' + err.message, 'error'); }); - return; - } + .then(function(r) { if (!r.ok) throw new Error('Failed'); return r.json(); }) + .then(function(data) { window.location.href = '/pages/' + data.id; }) + .catch(function(err) { self._toast('Failed: ' + err.message, 'error'); }); + return; + } + var project = ctx.workspaceKey; + var url = '/board/api/pages?section=Private&project=' + encodeURIComponent(project); + fetch(url, { + method: 'POST', + headers: { 'X-CSRF-Token': self.getCsrfToken() } + }) + .then(function(r) { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); }) + .then(function(data) { window.location.href = '/pages/' + data.id; }) + .catch(function(err) { self._toast('Failed to create page: ' + err.message, 'error'); }); + }; + this._openTemplatePicker(); + }, - // ── No workspace → general Notion page ── - var project = ctx.workspaceKey; - var url = '/board/api/pages?section=Private&project=' + encodeURIComponent(project); + _openTemplatePicker: function() { + var self = this; + function escAttr(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } + 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 = '
New page from template
' + + '
Pick a starting point — or start blank.
' + + '
Loading…
' + + '
'; + 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 += '
' + + ''+escAttr(t.icon||'📄')+'' + + ''+escAttr(t.name)+'' + + '
'+escAttr(t.description||'')+'
'; + }); + document.getElementById('fd-tpl-grid').innerHTML = h || '
No templates
'; + 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='
Failed to load templates
'; }); + }, + + _useTemplate: function(use){ + var ov = document.getElementById('fd-tpl-picker'); + if (ov) ov.style.display = 'none'; + var self = this; + if (!use || use === 'key:empty') { this._createPlainPage(); return; } + var parts = use.split(':'); + var body = {}; + var url; + if (parts[0] === 'key') { url = '/board/api/page-templates/0/use'; body = {key: parts.slice(1).join(':')}; } + else { url = '/board/api/page-templates/' + parts[1] + '/use'; } fetch(url, { method: 'POST', - headers: { 'X-CSRF-Token': this.getCsrfToken() } + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': this.getCsrfToken() }, + credentials: 'same-origin', + body: JSON.stringify(body) }) - .then(function(r) { if (!r.ok) throw new Error('CSRF or API error'); return r.json(); }) - .then(function(data) { window.location.href = '/pages/' + data.id; }) - .catch(function(err) { self._toast('Failed to create page: ' + err.message, 'error'); }); + .then(function(r){ if (!r.ok) throw new Error('Template failed'); return r.json(); }) + .then(function(d){ window.location.href = '/pages/' + d.id; }) + .catch(function(){ self._createPlainPage(); }); }, /** Show the global New Folder modal. */ diff --git a/static/css/app.css b/static/css/app.css index 631df75..934ad03 100644 --- a/static/css/app.css +++ b/static/css/app.css @@ -4912,3 +4912,104 @@ button.breadcrumb-current { max-width: 260px; } .fd-lightbox-nav:hover { background: rgba(255,255,255,.25); } .fd-lightbox-nav[data-nav="prev"] { left: 24px; } .fd-lightbox-nav[data-nav="next"] { right: 24px; } + +/* ═══════════ v5.11.0 Wiki-links & mentions ═══════════ */ +.fd-wiki-chip { + display: inline-flex; + align-items: center; + gap: 4px; + background: var(--bg-hover, rgba(35, 131, 226, 0.12)); + color: var(--accent, #2383e2); + border-radius: 4px; + padding: 0 4px; + text-decoration: none; + cursor: pointer; + white-space: nowrap; + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; +} +.fd-wiki-chip:hover { background: rgba(35, 131, 226, 0.2); } +.fd-wiki-date { + display: inline-block; + background: var(--bg-secondary, #f1f1ef); + border-radius: 4px; + padding: 0 4px; + font-weight: 500; +} +a.fd-wiki-link { color: var(--accent, #2383e2); text-decoration: none; border-bottom: 1px solid rgba(35, 131, 226, 0.35); } +a.fd-wiki-link:hover { border-bottom-color: var(--accent, #2383e2); } +.mention-group-label { + padding: 6px 10px 2px; + font-size: 11px; + font-weight: 600; + color: var(--text-dim, #999); + text-transform: uppercase; + letter-spacing: 0.4px; +} + +/* ═══════════ v5.12.0 Page lock + layout options ═══════════ */ +.fd-lock-banner { + position: sticky; + top: 0; + z-index: 50; + display: flex; + align-items: center; + gap: 8px; + padding: 7px 24px; + background: rgba(217, 115, 13, 0.12); + border-bottom: 1px solid rgba(217, 115, 13, 0.35); + color: #d9730d; + font-size: 12.5px; + font-weight: 500; +} +body.page-locked .block-handle, +body.page-locked .block-add-btn, +body.page-locked .format-toolbar, +body.page-locked .get-started-toolbar { display: none !important; } +.page-editor-wrapper.full-width .page-cover-area, +.page-editor-wrapper.full-width .page-title-block, +.page-editor-wrapper.full-width .blocks-container { max-width: min(1200px, 100%); } +.page-editor-wrapper.small-text .block-content, +.page-editor-wrapper.small-text .page-title-input { font-size: 13.5px; } +.page-editor-wrapper.small-text .blocks-container { line-height: 1.5; } + +/* Template picker modal (v5.12.0) */ +.tpl-picker-overlay { + position: fixed; + inset: 0; + z-index: 1500; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: flex-start; + justify-content: center; + padding: 12vh 16px 16px; +} +.tpl-picker-modal { + width: 560px; + max-width: 94vw; + max-height: 68vh; + overflow-y: auto; + background: var(--bg-modal, #fff); + border: 1px solid var(--border, #e3e3e0); + border-radius: 14px; + box-shadow: 0 18px 60px rgba(0, 0, 0, 0.25); + padding: 18px; +} +.tpl-picker-title { font-size: 15px; font-weight: 600; margin-bottom: 4px; } +.tpl-picker-sub { font-size: 12px; color: var(--text-dim, #999); margin-bottom: 14px; } +.tpl-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 10px; } +.tpl-card { + display: flex; + gap: 10px; + align-items: flex-start; + padding: 12px; + border: 1px solid var(--border, #e3e3e0); + border-radius: 10px; + cursor: pointer; + background: var(--bg-secondary, #fafafa); +} +.tpl-card:hover { border-color: var(--accent, #2383e2); background: var(--bg-hover, #f0f7ff); } +.tpl-card-icon { font-size: 20px; line-height: 1.2; } +.tpl-card-name { font-size: 13.5px; font-weight: 600; } +.tpl-card-desc { font-size: 12px; color: var(--text-dim, #999); margin-top: 2px; } diff --git a/tests/test_v511_v512_wiki_templates.py b/tests/test_v511_v512_wiki_templates.py new file mode 100644 index 0000000..86e02ca --- /dev/null +++ b/tests/test_v511_v512_wiki_templates.py @@ -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 'x"}) + assert "