Skip to content

Commit e18da9c

Browse files
PttCodingManclaude
andcommitted
feat: add mindmap page type rendered as a left-to-right Mermaid tree.
Pages marked `page_type = 'mindmap'` stay plain markdown but render in the viewer as an orthogonal flowchart — headings (or bullet lists as fallback) become tree nodes with right-angle `stepBefore` edges, and per-level colors pulled from the wiki CSS variables so the diagram tracks theme switches live. Backend adds `pages.page_type` via migration 9; search / public responses include it. Type toggles are metadata-only (no version bump, no base_version required), matching `is_public` semantics. Frontend adds a deterministic parser (`lib/mindmap.js`), a MindmapView component, shared Mermaid init (`lib/mermaidBootstrap.js`, also adopted by MarkdownViewer), a document/mindmap toggle on the new-page screen, and a split live-preview when editing mindmap pages. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 88a9093 commit e18da9c

18 files changed

Lines changed: 1188 additions & 37 deletions

backend/app/database.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,25 @@
2727
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
2828
);
2929
30+
-- Personal API tokens for programmatic access. Plaintext is shown once on
31+
-- creation and never stored. Only sha256(token) is persisted for lookup.
32+
-- `prefix` stores the first 8 chars of the plaintext so the UI can
33+
-- identify a specific token without revealing it. `expires_at` and
34+
-- `revoked_at` let a token be invalidated without losing the audit trail.
3035
CREATE TABLE IF NOT EXISTS api_tokens (
3136
id INTEGER PRIMARY KEY AUTOINCREMENT,
3237
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
3338
name TEXT NOT NULL,
3439
token_hash TEXT UNIQUE NOT NULL,
40+
prefix TEXT,
41+
expires_at TIMESTAMP,
42+
revoked_at TIMESTAMP,
3543
last_used TIMESTAMP,
3644
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
3745
);
3846
47+
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
48+
3949
-- SSO binding per user. A user may have zero or more identities alongside
4050
-- a local password (or instead of one, in which case users.password_hash
4151
-- is set to the sentinel '!' to disable local login while keeping the
@@ -63,6 +73,7 @@
6373
view_count INTEGER DEFAULT 0,
6474
version INTEGER NOT NULL DEFAULT 1,
6575
is_public INTEGER NOT NULL DEFAULT 0,
76+
page_type TEXT NOT NULL DEFAULT 'document',
6677
deleted_at TIMESTAMP,
6778
created_by INTEGER REFERENCES users(id),
6879
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,

backend/app/migrations.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,36 @@ async def _m006_auth_identities(db: aiosqlite.Connection) -> None:
9898
)
9999

100100

101+
async def _m008_api_tokens_extend(db: aiosqlite.Connection) -> None:
102+
"""Extend api_tokens with prefix / expires_at / revoked_at.
103+
104+
The earlier shape only tracked the hash and a `last_used` timestamp. The
105+
three new columns let the UI show a non-revealing identifier, let tokens
106+
expire automatically (30-day default), and let users revoke without
107+
losing the audit trail. All three are nullable so existing rows (if any)
108+
keep working — they'll read as "never expires, never revoked, no prefix".
109+
"""
110+
if not await _column_exists(db, "api_tokens", "prefix"):
111+
await db.execute("ALTER TABLE api_tokens ADD COLUMN prefix TEXT")
112+
if not await _column_exists(db, "api_tokens", "expires_at"):
113+
await db.execute("ALTER TABLE api_tokens ADD COLUMN expires_at TIMESTAMP")
114+
if not await _column_exists(db, "api_tokens", "revoked_at"):
115+
await db.execute("ALTER TABLE api_tokens ADD COLUMN revoked_at TIMESTAMP")
116+
117+
118+
async def _m009_page_type(db: aiosqlite.Connection) -> None:
119+
"""Add `page_type` to pages so viewer can branch by rendering strategy.
120+
121+
Value is a free-form TEXT (not CHECK-constrained) so we can introduce new
122+
types purely in Python (Pydantic Literal does the validation). Default
123+
'document' matches the pre-existing behavior for every row on upgrade.
124+
"""
125+
if not await _column_exists(db, "pages", "page_type"):
126+
await db.execute(
127+
"ALTER TABLE pages ADD COLUMN page_type TEXT NOT NULL DEFAULT 'document'"
128+
)
129+
130+
101131
async def _m007_groups_ldap_dn(db: aiosqlite.Connection) -> None:
102132
"""Add `ldap_dn` to `groups` so LDAP-mirrored groups can be reconciled.
103133
@@ -124,6 +154,8 @@ async def _m007_groups_ldap_dn(db: aiosqlite.Connection) -> None:
124154
(5, "page_is_public", _m005_page_is_public),
125155
(6, "auth_identities", _m006_auth_identities),
126156
(7, "groups_ldap_dn", _m007_groups_ldap_dn),
157+
(8, "api_tokens_extend", _m008_api_tokens_extend),
158+
(9, "page_type", _m009_page_type),
127159
]
128160

129161

@@ -147,6 +179,7 @@ async def _m007_groups_ldap_dn(db: aiosqlite.Connection) -> None:
147179
# without this. Declared in SCHEMA_SQL but that's skipped on upgrades
148180
# where the backfill marks m006 as already applied.
149181
"CREATE INDEX IF NOT EXISTS idx_auth_identities_user ON auth_identities(user_id)",
182+
"CREATE INDEX IF NOT EXISTS idx_pages_type ON pages(page_type)",
150183
)
151184

152185

@@ -210,6 +243,10 @@ async def _detect_preexisting(db: aiosqlite.Connection) -> set[int]:
210243
applied.add(6)
211244
if await _column_exists(db, "groups", "ldap_dn"):
212245
applied.add(7)
246+
if await _column_exists(db, "api_tokens", "prefix"):
247+
applied.add(8)
248+
if await _column_exists(db, "pages", "page_type"):
249+
applied.add(9)
213250
return applied
214251

215252

backend/app/routers/pages.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -266,9 +266,9 @@ async def create_page(body: PageCreate, user=Depends(get_current_user)):
266266
candidate = await unique_slug(db, slugify(body.title, body.slug))
267267
try:
268268
cursor = await db.execute(
269-
"""INSERT INTO pages (slug, title, content_md, parent_id, sort_order, version, created_by)
270-
VALUES (?, ?, ?, ?, ?, 1, ?)""",
271-
(candidate, body.title, content, body.parent_id, body.sort_order, user["id"]),
269+
"""INSERT INTO pages (slug, title, content_md, parent_id, sort_order, version, page_type, created_by)
270+
VALUES (?, ?, ?, ?, ?, 1, ?, ?)""",
271+
(candidate, body.title, content, body.parent_id, body.sort_order, body.page_type, user["id"]),
272272
)
273273
slug = candidate
274274
page_id = cursor.lastrowid
@@ -395,23 +395,25 @@ async def update_page(slug: str, body: PageUpdate, user=Depends(get_current_user
395395
)
396396
current_is_public = bool(current.get("is_public", 0))
397397
is_public = body.is_public if body.is_public is not None else current_is_public
398+
current_page_type = current.get("page_type") or "document"
399+
page_type = body.page_type if body.page_type is not None else current_page_type
398400

399401
content_changed = body.content_md is not None and body.content_md != current["content_md"]
400402
title_changed = body.title is not None and body.title != current["title"]
401403
public_changed = body.is_public is not None and bool(body.is_public) != current_is_public
402404

403405
# Save current state as a version before updating (only if content/title actually changed).
404-
# Publicity toggles are metadata, not content, so they don't create a version.
406+
# Publicity/page_type toggles are metadata, not content, so they don't create a version.
405407
if content_changed or title_changed:
406408
await save_version(db, current["id"], current["title"], current["content_md"], user["id"])
407409

408-
# is_public changes do NOT bump version (metadata, not content)
410+
# is_public / page_type changes do NOT bump version (metadata, not content)
409411
new_version = current["version"] + 1 if (content_changed or title_changed) else current["version"]
410412

411413
await db.execute(
412414
"""UPDATE pages SET title = ?, content_md = ?, parent_id = ?, sort_order = ?,
413-
is_public = ?, version = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?""",
414-
(title, content, parent_id, sort_order, 1 if is_public else 0, new_version, slug),
415+
is_public = ?, page_type = ?, version = ?, updated_at = CURRENT_TIMESTAMP WHERE slug = ?""",
416+
(title, content, parent_id, sort_order, 1 if is_public else 0, page_type, new_version, slug),
415417
)
416418

417419
# Update search index

backend/app/routers/public.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ async def get_public_page(slug: str, request: Request):
4040

4141
db = await get_db()
4242
rows = await db.execute_fetchall(
43-
"""SELECT p.slug, p.title, p.content_md, p.updated_at,
43+
"""SELECT p.slug, p.title, p.content_md, p.page_type, p.updated_at,
4444
CASE WHEN u.display_name IS NOT NULL AND u.display_name != ''
4545
THEN u.display_name ELSE u.username END AS author_name
4646
FROM pages p

backend/app/routers/search.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ async def _search_fts(db, fts_query, tag, readable_json, per_page, offset):
7979
total = count_rows[0]["cnt"]
8080

8181
search_sql = """
82-
SELECT DISTINCT p.id, p.slug, p.title, p.content_md, p.updated_at, p.view_count
82+
SELECT DISTINCT p.id, p.slug, p.title, p.content_md, p.updated_at, p.view_count, p.page_type
8383
FROM search_index
8484
JOIN pages p ON CAST(search_index.page_id AS INTEGER) = p.id
8585
JOIN page_tags pt ON pt.page_id = p.id
@@ -104,7 +104,7 @@ async def _search_fts(db, fts_query, tag, readable_json, per_page, offset):
104104
total = count_rows[0]["cnt"]
105105

106106
search_sql = """
107-
SELECT p.id, p.slug, p.title, p.content_md, p.updated_at, p.view_count
107+
SELECT p.id, p.slug, p.title, p.content_md, p.updated_at, p.view_count, p.page_type
108108
FROM search_index
109109
JOIN pages p ON CAST(search_index.page_id AS INTEGER) = p.id
110110
WHERE search_index MATCH ? AND p.deleted_at IS NULL
@@ -150,7 +150,7 @@ async def _search_like(db, words, tag, readable_json, per_page, offset):
150150
total = count_rows[0]["cnt"]
151151

152152
search_sql = f"""
153-
SELECT DISTINCT p.id, p.slug, p.title, p.content_md, p.updated_at, p.view_count
153+
SELECT DISTINCT p.id, p.slug, p.title, p.content_md, p.updated_at, p.view_count, p.page_type
154154
FROM pages p
155155
JOIN page_tags pt ON pt.page_id = p.id
156156
JOIN tags t ON t.id = pt.tag_id
@@ -175,7 +175,7 @@ async def _search_like(db, words, tag, readable_json, per_page, offset):
175175
total = count_rows[0]["cnt"]
176176

177177
search_sql = f"""
178-
SELECT p.id, p.slug, p.title, p.content_md, p.updated_at, p.view_count
178+
SELECT p.id, p.slug, p.title, p.content_md, p.updated_at, p.view_count, p.page_type
179179
FROM pages p
180180
WHERE {like_clauses} AND p.deleted_at IS NULL
181181
AND p.id IN (SELECT value FROM json_each(?))
@@ -234,6 +234,7 @@ async def search_pages(
234234
"snippet": make_snippet(row["content_md"], q),
235235
"updated_at": row["updated_at"],
236236
"view_count": row["view_count"],
237+
"page_type": row["page_type"],
237238
})
238239

239240
return {"results": results, "total": total, "page": page, "per_page": per_page}

backend/app/schemas.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
from pydantic import BaseModel
2-
from typing import Optional
2+
from typing import Literal, Optional
33
from datetime import datetime
44

55

6+
# ── Page types ──
7+
# Free-form TEXT in SQLite; Pydantic Literal is the source of truth for validation.
8+
# Add new types here (plus a frontend renderer) — no migration required.
9+
PageType = Literal["document", "mindmap"]
10+
11+
612
# ── Auth ──
713
class LoginRequest(BaseModel):
814
username: str
@@ -26,6 +32,7 @@ class PageCreate(BaseModel):
2632
sort_order: int = 0
2733
template_id: Optional[int] = None
2834
slug: Optional[str] = None
35+
page_type: PageType = "document"
2936

3037

3138
class PageUpdate(BaseModel):
@@ -34,6 +41,7 @@ class PageUpdate(BaseModel):
3441
parent_id: Optional[int] = None
3542
sort_order: Optional[int] = None
3643
is_public: Optional[bool] = None
44+
page_type: Optional[PageType] = None
3745
base_version: Optional[int] = None # for optimistic locking
3846

3947

@@ -52,6 +60,7 @@ class PageResponse(BaseModel):
5260
view_count: int = 0
5361
version: int = 1
5462
is_public: bool = False
63+
page_type: PageType = "document"
5564
created_by: Optional[int] = None
5665
author_name: Optional[str] = None
5766
created_at: Optional[str] = None
@@ -63,6 +72,7 @@ class PublicPageResponse(BaseModel):
6372
slug: str
6473
title: str
6574
content_md: str
75+
page_type: PageType = "document"
6676
updated_at: Optional[str] = None
6777
author_name: Optional[str] = None
6878
diagrams: dict[str, str] = {}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Regression test for m009 (page_type column + index).
2+
3+
Simulates the upgrade path: start from a pages table that lacks page_type,
4+
run migrations, check the column, index, and default value all land.
5+
"""
6+
import os
7+
import tempfile
8+
9+
import aiosqlite
10+
import pytest
11+
12+
from app.migrations import run_migrations
13+
14+
15+
@pytest.mark.asyncio
16+
async def test_m009_adds_page_type_to_legacy_db():
17+
fd, path = tempfile.mkstemp(suffix=".db")
18+
os.close(fd)
19+
try:
20+
db = await aiosqlite.connect(path)
21+
db.row_factory = aiosqlite.Row
22+
try:
23+
# Shape: a database where migrations 1-8 are already recorded in
24+
# the ledger (normal upgrade case). _ensure_indexes runs after
25+
# every migration pass and references users / groups /
26+
# auth_identities, so we stub just enough schema for those
27+
# CREATE INDEX IF NOT EXISTS statements to succeed.
28+
await db.execute(
29+
"""
30+
CREATE TABLE schema_migrations (
31+
version INTEGER PRIMARY KEY,
32+
name TEXT NOT NULL,
33+
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
34+
)
35+
"""
36+
)
37+
for v in range(1, 9):
38+
await db.execute(
39+
"INSERT INTO schema_migrations (version, name) VALUES (?, ?)",
40+
(v, f"legacy_{v}"),
41+
)
42+
await db.execute(
43+
"""
44+
CREATE TABLE pages (
45+
id INTEGER PRIMARY KEY AUTOINCREMENT,
46+
slug TEXT UNIQUE NOT NULL,
47+
title TEXT NOT NULL,
48+
content_md TEXT NOT NULL DEFAULT '',
49+
version INTEGER NOT NULL DEFAULT 1,
50+
is_public INTEGER NOT NULL DEFAULT 0,
51+
deleted_at TIMESTAMP
52+
)
53+
"""
54+
)
55+
await db.execute(
56+
"CREATE TABLE users (id INTEGER PRIMARY KEY, deleted_at TIMESTAMP)"
57+
)
58+
await db.execute(
59+
"CREATE TABLE groups (id INTEGER PRIMARY KEY, ldap_dn TEXT)"
60+
)
61+
await db.execute(
62+
"CREATE TABLE auth_identities (id INTEGER PRIMARY KEY, user_id INTEGER)"
63+
)
64+
await db.execute(
65+
"INSERT INTO pages (slug, title, content_md) VALUES (?, ?, ?)",
66+
("legacy", "Legacy page", "body"),
67+
)
68+
await db.commit()
69+
70+
applied = await run_migrations(db)
71+
72+
# m009 should have run exactly once.
73+
assert 9 in applied
74+
75+
# Column exists and backfills to 'document'.
76+
cols = {r["name"] for r in await db.execute_fetchall("PRAGMA table_info(pages)")}
77+
assert "page_type" in cols
78+
rows = await db.execute_fetchall("SELECT page_type FROM pages WHERE slug = 'legacy'")
79+
assert rows[0]["page_type"] == "document"
80+
81+
# Index was created by _ensure_indexes regardless of fresh/upgrade.
82+
idx_rows = await db.execute_fetchall(
83+
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='pages'"
84+
)
85+
idx_names = {r["name"] for r in idx_rows}
86+
assert "idx_pages_type" in idx_names
87+
88+
# Re-running is a no-op (idempotent ledger).
89+
applied_again = await run_migrations(db)
90+
assert 9 not in applied_again
91+
finally:
92+
await db.close()
93+
finally:
94+
if os.path.exists(path):
95+
os.remove(path)

0 commit comments

Comments
 (0)