feat: deliver P2 v0.2.0 with unified editor, search, and snapshots

Implement schema v2 migration, outline/setting/inspiration CRUD, unified TipTap editing, reference panel with mentions, FTS global search, chapter version history, writing landmarks, word frequency analysis, light theme contrast fixes, and full unit/E2E test coverage.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 14:13:27 +08:00
parent 91c93954df
commit aac51bf183
72 changed files with 5790 additions and 203 deletions
+30 -7
View File
@@ -1,7 +1,25 @@
import type { SqliteDb } from './types'
import schemaV1 from './schema-v1.sql?raw'
import schemaV2 from './schema-v2.sql?raw'
const CURRENT_VERSION = 1
const CURRENT_VERSION = 2
function tryAlter(db: SqliteDb, sql: string): void {
try {
db.exec(sql)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (!msg.includes('duplicate column')) throw err
}
}
function applyV2(db: SqliteDb): void {
tryAlter(db, "ALTER TABLE chapters ADD COLUMN publish_status TEXT NOT NULL DEFAULT 'draft'")
tryAlter(db, 'ALTER TABLE chapters ADD COLUMN pov_character_id TEXT DEFAULT NULL')
tryAlter(db, "ALTER TABLE chapters ADD COLUMN summary TEXT DEFAULT ''")
tryAlter(db, 'ALTER TABLE chapters ADD COLUMN story_time TEXT DEFAULT NULL')
db.exec(schemaV2)
}
export function migrate(db: SqliteDb): void {
db.exec(`CREATE TABLE IF NOT EXISTS schema_version (
@@ -11,12 +29,17 @@ export function migrate(db: SqliteDb): void {
)`)
const row = db.prepare('SELECT MAX(version) AS v FROM schema_version').get() as { v: number | null }
const current = row?.v ?? 0
let current = row?.v ?? 0
if (current >= CURRENT_VERSION) return
db.exec(schemaV1)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(
CURRENT_VERSION,
'initial schema'
)
if (current < 1) {
db.exec(schemaV1)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(1, 'initial schema')
current = 1
}
if (current < 2) {
applyV2(db)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(2, 'P2 schema')
}
}
+92
View File
@@ -0,0 +1,92 @@
import { randomUUID } from 'crypto'
import type { Bookmark, LandmarkType } from '../../../shared/types'
import type { SqliteDb } from '../types'
function mapRow(row: Record<string, unknown>): Bookmark {
return {
id: row.id as string,
chapterId: row.chapter_id as string,
position: row.position as number,
label: row.label as string,
landmarkType: row.landmark_type as LandmarkType,
resolved: Boolean(row.resolved),
createdAt: row.created_at as string
}
}
export class BookmarkRepository {
constructor(private db: SqliteDb) {}
create(
chapterId: string,
position: number,
label: string,
landmarkType: LandmarkType = 'todo'
): Bookmark {
const id = randomUUID()
this.db
.prepare(
`INSERT INTO bookmarks (id, chapter_id, position, label, landmark_type)
VALUES (?, ?, ?, ?, ?)`
)
.run(id, chapterId, position, label, landmarkType)
return this.get(id)!
}
get(id: string): Bookmark | null {
const row = this.db.prepare('SELECT * FROM bookmarks WHERE id = ?').get(id) as
| Record<string, unknown>
| undefined
return row ? mapRow(row) : null
}
list(chapterId?: string, resolved?: boolean): Bookmark[] {
let sql = 'SELECT * FROM bookmarks WHERE 1=1'
const params: unknown[] = []
if (chapterId !== undefined) {
sql += ' AND chapter_id = ?'
params.push(chapterId)
}
if (resolved !== undefined) {
sql += ' AND resolved = ?'
params.push(resolved ? 1 : 0)
}
sql += ' ORDER BY created_at DESC'
return (this.db.prepare(sql).all(...params) as Record<string, unknown>[]).map(mapRow)
}
listByChapter(chapterId: string): Bookmark[] {
return this.list(chapterId)
}
update(
id: string,
patch: Partial<{ label: string; position: number; landmarkType: LandmarkType }>
): Bookmark {
const existing = this.get(id)
if (!existing) throw new Error('Bookmark not found')
this.db
.prepare(
`UPDATE bookmarks SET label = ?, position = ?, landmark_type = ? WHERE id = ?`
)
.run(
patch.label ?? existing.label,
patch.position ?? existing.position,
patch.landmarkType ?? existing.landmarkType,
id
)
return this.get(id)!
}
resolve(id: string, resolved: boolean): Bookmark {
const existing = this.get(id)
if (!existing) throw new Error('Bookmark not found')
this.db.prepare('UPDATE bookmarks SET resolved = ? WHERE id = ?').run(resolved ? 1 : 0, id)
return this.get(id)!
}
delete(id: string): void {
this.db.prepare('DELETE FROM bookmarks WHERE id = ?').run(id)
}
}
@@ -0,0 +1,91 @@
import { randomUUID } from 'crypto'
import type { InspirationEntry, SettingType } from '../../../shared/types'
import type { SqliteDb } from '../types'
import { stripHtml } from '../../services/word-count'
import type { OutlineRepository } from './outline.repo'
import type { SettingRepository } from './setting.repo'
function mapRow(row: Record<string, unknown>): InspirationEntry {
let tags: string[] = []
try {
tags = JSON.parse((row.tags as string) || '[]') as string[]
} catch {
tags = []
}
return {
id: row.id as string,
title: row.title as string,
content: row.content as string,
tags,
createdAt: row.created_at as string,
updatedAt: row.updated_at as string
}
}
export class InspirationRepository {
constructor(private db: SqliteDb) {}
create(title = '', content = '', tags: string[] = []): InspirationEntry {
const id = randomUUID()
this.db
.prepare(`INSERT INTO inspiration (id, title, content, tags) VALUES (?, ?, ?, ?)`)
.run(id, title, content, JSON.stringify(tags))
return this.get(id)!
}
get(id: string): InspirationEntry | null {
const row = this.db.prepare('SELECT * FROM inspiration WHERE id = ?').get(id) as
| Record<string, unknown>
| undefined
return row ? mapRow(row) : null
}
list(): InspirationEntry[] {
return (
this.db.prepare('SELECT * FROM inspiration ORDER BY updated_at DESC').all() as Record<string, unknown>[]
).map(mapRow)
}
update(
id: string,
patch: Partial<{ title: string; content: string; tags: string[] }>
): InspirationEntry {
const existing = this.get(id)
if (!existing) throw new Error('Inspiration not found')
this.db
.prepare(
`UPDATE inspiration SET title = ?, content = ?, tags = ?, updated_at = datetime('now') WHERE id = ?`
)
.run(
patch.title ?? existing.title,
patch.content ?? existing.content,
JSON.stringify(patch.tags ?? existing.tags),
id
)
return this.get(id)!
}
delete(id: string): void {
this.db.prepare('DELETE FROM inspiration WHERE id = ?').run(id)
}
convertToOutline(id: string, outlineRepo: OutlineRepository) {
const item = this.get(id)
if (!item) throw new Error('Inspiration not found')
const outline = outlineRepo.create(null, item.title || '未命名灵感', outlineRepo.nextSortOrder(null))
outlineRepo.update(outline.id, { description: stripHtml(item.content) })
this.delete(id)
return outlineRepo.get(outline.id)!
}
convertToSetting(id: string, type: SettingType, settingRepo: SettingRepository) {
const item = this.get(id)
if (!item) throw new Error('Inspiration not found')
const setting = settingRepo.create(type, item.title || '未命名设定', stripHtml(item.content))
this.delete(id)
return setting
}
}
+137
View File
@@ -0,0 +1,137 @@
import { randomUUID } from 'crypto'
import type { OutlineItem, OutlineStatus } from '../../../shared/types'
import type { SqliteDb } from '../types'
function mapRow(row: Record<string, unknown>): OutlineItem {
return {
id: row.id as string,
parentId: (row.parent_id as string | null) ?? null,
title: row.title as string,
description: (row.description as string) ?? '',
status: row.status as OutlineStatus,
expectedWordCount: (row.expected_word_count as number | null) ?? null,
chapterId: (row.chapter_id as string | null) ?? null,
sortOrder: row.sort_order as number,
createdAt: row.created_at as string,
updatedAt: row.updated_at as string
}
}
function buildTree(items: OutlineItem[]): OutlineItem[] {
const byParent = new Map<string | null, OutlineItem[]>()
for (const item of items) {
const key = item.parentId
if (!byParent.has(key)) byParent.set(key, [])
byParent.get(key)!.push({ ...item, children: [] })
}
const attach = (parentId: string | null): OutlineItem[] => {
const nodes = byParent.get(parentId) ?? []
nodes.sort((a, b) => a.sortOrder - b.sortOrder)
for (const node of nodes) {
node.children = attach(node.id)
}
return nodes
}
return attach(null)
}
export class OutlineRepository {
constructor(private db: SqliteDb) {}
create(parentId: string | null, title: string, sortOrder: number): OutlineItem {
const id = randomUUID()
this.db
.prepare(
`INSERT INTO outline_items (id, parent_id, title, sort_order)
VALUES (?, ?, ?, ?)`
)
.run(id, parentId, title, sortOrder)
return this.get(id)!
}
get(id: string): OutlineItem | null {
const row = this.db.prepare('SELECT * FROM outline_items WHERE id = ?').get(id) as
| Record<string, unknown>
| undefined
return row ? mapRow(row) : null
}
listFlat(): OutlineItem[] {
return (
this.db.prepare('SELECT * FROM outline_items ORDER BY sort_order').all() as Record<string, unknown>[]
).map(mapRow)
}
listTree(): OutlineItem[] {
return buildTree(this.listFlat())
}
update(
id: string,
patch: Partial<{
title: string
description: string
status: OutlineStatus
expectedWordCount: number | null
chapterId: string | null
sortOrder: number
}>
): OutlineItem {
const existing = this.get(id)
if (!existing) throw new Error('Outline item not found')
this.db
.prepare(
`UPDATE outline_items SET
title = ?, description = ?, status = ?,
expected_word_count = ?, chapter_id = ?, sort_order = ?,
updated_at = datetime('now')
WHERE id = ?`
)
.run(
patch.title ?? existing.title,
patch.description ?? existing.description,
patch.status ?? existing.status,
patch.expectedWordCount !== undefined ? patch.expectedWordCount : existing.expectedWordCount,
patch.chapterId !== undefined ? patch.chapterId : existing.chapterId,
patch.sortOrder ?? existing.sortOrder,
id
)
return this.get(id)!
}
delete(id: string): void {
this.db.prepare('DELETE FROM outline_items WHERE id = ?').run(id)
}
move(id: string, parentId: string | null, sortOrder: number): OutlineItem {
const existing = this.get(id)
if (!existing) throw new Error('Outline item not found')
this.db
.prepare(
`UPDATE outline_items SET parent_id = ?, sort_order = ?, updated_at = datetime('now') WHERE id = ?`
)
.run(parentId, sortOrder, id)
return this.get(id)!
}
listUncovered(): OutlineItem[] {
return (
this.db
.prepare('SELECT * FROM outline_items WHERE chapter_id IS NULL ORDER BY sort_order')
.all() as Record<string, unknown>[]
).map(mapRow)
}
nextSortOrder(parentId: string | null): number {
const row = parentId
? (this.db
.prepare('SELECT MAX(sort_order) AS maxOrder FROM outline_items WHERE parent_id = ?')
.get(parentId) as { maxOrder: number | null })
: (this.db
.prepare('SELECT MAX(sort_order) AS maxOrder FROM outline_items WHERE parent_id IS NULL')
.get() as { maxOrder: number | null })
return (row?.maxOrder ?? -1) + 1
}
}
+103
View File
@@ -0,0 +1,103 @@
import { randomUUID } from 'crypto'
import type { SettingEntry, SettingType } from '../../../shared/types'
import type { SqliteDb } from '../types'
function mapRow(row: Record<string, unknown>, chapterIds: string[]): SettingEntry {
let properties: Record<string, unknown> = {}
try {
properties = JSON.parse((row.properties as string) || '{}') as Record<string, unknown>
} catch {
properties = {}
}
return {
id: row.id as string,
type: row.type as SettingType,
name: row.name as string,
description: (row.description as string) ?? '',
properties,
chapterIds,
createdAt: row.created_at as string,
updatedAt: row.updated_at as string
}
}
export class SettingRepository {
constructor(private db: SqliteDb) {}
private loadChapterIds(settingId: string): string[] {
return (
this.db
.prepare('SELECT chapter_id FROM setting_chapter_refs WHERE setting_id = ?')
.all(settingId) as { chapter_id: string }[]
).map((r) => r.chapter_id)
}
create(type: SettingType, name: string, description = '', properties: Record<string, unknown> = {}): SettingEntry {
const id = randomUUID()
this.db
.prepare(
`INSERT INTO settings (id, type, name, description, properties)
VALUES (?, ?, ?, ?, ?)`
)
.run(id, type, name, description, JSON.stringify(properties))
return this.get(id)!
}
get(id: string): SettingEntry | null {
const row = this.db.prepare('SELECT * FROM settings WHERE id = ?').get(id) as
| Record<string, unknown>
| undefined
return row ? mapRow(row, this.loadChapterIds(id)) : null
}
list(type?: SettingType): SettingEntry[] {
const rows = type
? (this.db.prepare('SELECT * FROM settings WHERE type = ? ORDER BY name').all(type) as Record<
string,
unknown
>[])
: (this.db.prepare('SELECT * FROM settings ORDER BY type, name').all() as Record<string, unknown>[])
return rows.map((row) => mapRow(row, this.loadChapterIds(row.id as string)))
}
update(
id: string,
patch: Partial<{ name: string; description: string; type: SettingType; properties: Record<string, unknown> }>
): SettingEntry {
const existing = this.get(id)
if (!existing) throw new Error('Setting not found')
this.db
.prepare(
`UPDATE settings SET
type = ?, name = ?, description = ?, properties = ?,
updated_at = datetime('now')
WHERE id = ?`
)
.run(
patch.type ?? existing.type,
patch.name ?? existing.name,
patch.description ?? existing.description,
JSON.stringify(patch.properties ?? existing.properties),
id
)
return this.get(id)!
}
delete(id: string): void {
this.db.prepare('DELETE FROM settings WHERE id = ?').run(id)
}
syncChapterRefs(settingId: string, chapterIds: string[]): SettingEntry {
if (!this.get(settingId)) throw new Error('Setting not found')
this.db.prepare('DELETE FROM setting_chapter_refs WHERE setting_id = ?').run(settingId)
const insert = this.db.prepare(
'INSERT INTO setting_chapter_refs (setting_id, chapter_id) VALUES (?, ?)'
)
for (const chapterId of chapterIds) {
insert.run(settingId, chapterId)
}
return this.get(settingId)!
}
}
+81
View File
@@ -0,0 +1,81 @@
import { randomUUID } from 'crypto'
import type { Snapshot, SnapshotType } from '../../../shared/types'
import type { SqliteDb } from '../types'
function mapRow(row: Record<string, unknown>): Snapshot {
return {
id: row.id as string,
chapterId: row.chapter_id as string,
type: row.type as SnapshotType,
name: (row.name as string) ?? '',
content: row.content as string,
createdAt: row.created_at as string
}
}
export class SnapshotRepository {
constructor(private db: SqliteDb) {}
create(chapterId: string, type: SnapshotType, content: string, name = ''): Snapshot {
const id = randomUUID()
this.db
.prepare(`INSERT INTO snapshots (id, chapter_id, type, name, content) VALUES (?, ?, ?, ?, ?)`)
.run(id, chapterId, type, name, content)
return this.get(id)!
}
get(id: string): Snapshot | null {
const row = this.db.prepare('SELECT * FROM snapshots WHERE id = ?').get(id) as
| Record<string, unknown>
| undefined
return row ? mapRow(row) : null
}
list(chapterId: string): Snapshot[] {
return (
this.db
.prepare('SELECT * FROM snapshots WHERE chapter_id = ? ORDER BY created_at DESC')
.all(chapterId) as Record<string, unknown>[]
).map(mapRow)
}
delete(id: string): void {
this.db.prepare('DELETE FROM snapshots WHERE id = ?').run(id)
}
pruneAuto(chapterId: string, max: number): void {
const autos = (
this.db
.prepare(
`SELECT id FROM snapshots WHERE chapter_id = ? AND type = 'auto' ORDER BY created_at ASC`
)
.all(chapterId) as { id: string }[]
)
const excess = autos.length - max
if (excess <= 0) return
for (let i = 0; i < excess; i++) {
this.delete(autos[i].id)
}
}
prunePersist(chapterId: string, max: number): void {
const items = (
this.db
.prepare(
`SELECT id FROM snapshots WHERE chapter_id = ? AND type = 'persist' ORDER BY created_at ASC`
)
.all(chapterId) as { id: string }[]
)
const excess = items.length - max
if (excess <= 0) return
for (let i = 0; i < excess; i++) {
this.delete(items[i].id)
}
}
restore(snapshotId: string): string {
const snap = this.get(snapshotId)
if (!snap) throw new Error('Snapshot not found')
return snap.content
}
}
+70
View File
@@ -0,0 +1,70 @@
CREATE TABLE IF NOT EXISTS outline_items (
id TEXT PRIMARY KEY,
parent_id TEXT,
title TEXT NOT NULL,
description TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
expected_word_count INTEGER DEFAULT NULL,
chapter_id TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (parent_id) REFERENCES outline_items(id) ON DELETE CASCADE,
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS settings (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT DEFAULT '',
properties TEXT DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS setting_chapter_refs (
setting_id TEXT NOT NULL,
chapter_id TEXT NOT NULL,
PRIMARY KEY (setting_id, chapter_id),
FOREIGN KEY (setting_id) REFERENCES settings(id) ON DELETE CASCADE,
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS inspiration (
id TEXT PRIMARY KEY,
title TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
tags TEXT DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS snapshots (
id TEXT PRIMARY KEY,
chapter_id TEXT NOT NULL,
type TEXT NOT NULL,
name TEXT DEFAULT '',
content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS bookmarks (
id TEXT PRIMARY KEY,
chapter_id TEXT NOT NULL,
position INTEGER NOT NULL,
label TEXT NOT NULL,
landmark_type TEXT NOT NULL DEFAULT 'todo',
resolved INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
CREATE VIRTUAL TABLE IF NOT EXISTS search_fts USING fts5(
source_kind,
source_id,
title,
body,
tokenize='unicode61'
);