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