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
+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
}
}