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}