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
+81
View File
@@ -0,0 +1,81 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { LandmarkType } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { ftsSync } from '../../services/fts-sync.service'
import { wrap } from '../result'
export function registerBookmarkHandlers(registry: BookRegistryService): void {
ipcMain.handle(
IPC.BOOKMARK_LIST,
(
_e,
{
bookId,
chapterId,
resolved
}: { bookId: string; chapterId?: string; resolved?: boolean }
) => wrap(() => registry.getBookmarkRepo(bookId).list(chapterId, resolved))
)
ipcMain.handle(
IPC.BOOKMARK_CREATE,
(
_e,
{
bookId,
chapterId,
position,
label,
landmarkType
}: {
bookId: string
chapterId: string
position: number
label: string
landmarkType?: LandmarkType
}
) =>
wrap(() => {
const item = registry
.getBookmarkRepo(bookId)
.create(chapterId, position, label, landmarkType)
ftsSync.upsert(registry.getDb(bookId), 'bookmark', item.id, item.label, item.label)
return item
})
)
ipcMain.handle(
IPC.BOOKMARK_UPDATE,
(
_e,
{
bookId,
id,
patch
}: {
bookId: string
id: string
patch: Partial<{ label: string; position: number; landmarkType: LandmarkType }>
}
) =>
wrap(() => {
const item = registry.getBookmarkRepo(bookId).update(id, patch)
ftsSync.upsert(registry.getDb(bookId), 'bookmark', item.id, item.label, item.label)
return item
})
)
ipcMain.handle(
IPC.BOOKMARK_RESOLVE,
(_e, { bookId, id, resolved }: { bookId: string; id: string; resolved: boolean }) =>
wrap(() => registry.getBookmarkRepo(bookId).resolve(id, resolved))
)
ipcMain.handle(IPC.BOOKMARK_DELETE, (_e, { bookId, id }: { bookId: string; id: string }) =>
wrap(() => {
registry.getBookmarkRepo(bookId).delete(id)
ftsSync.remove(registry.getDb(bookId), 'bookmark', id)
})
)
}
+12 -1
View File
@@ -2,6 +2,7 @@ import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { ChapterStatus } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { ftsSync } from '../../services/fts-sync.service'
import { wrap } from '../result'
export function registerChapterHandlers(registry: BookRegistryService): void {
@@ -40,7 +41,9 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
wrap(() => {
const repo = registry.getChapterRepo(bookId)
const sortOrder = repo.nextSortOrder(volumeId)
return repo.create(volumeId, title, sortOrder)
const chapter = repo.create(volumeId, title, sortOrder)
ftsSync.upsert(registry.getDb(bookId), 'chapter', chapter.id, chapter.title, chapter.content)
return chapter
})
)
@@ -81,6 +84,13 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
status,
cursorOffset
})
ftsSync.upsert(
registry.getDb(bookId),
'chapter',
chapter.id,
chapter.title,
chapter.content
)
registry.updateMeta(bookId, { lastChapterId: chapterId })
return chapter
})
@@ -91,6 +101,7 @@ export function registerChapterHandlers(registry: BookRegistryService): void {
(_event, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
wrap(() => {
registry.getChapterRepo(bookId).delete(chapterId)
ftsSync.remove(registry.getDb(bookId), 'chapter', chapterId)
})
)
}
@@ -0,0 +1,86 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { SettingType } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { ftsSync } from '../../services/fts-sync.service'
import { wrap } from '../result'
export function registerInspirationHandlers(registry: BookRegistryService): void {
ipcMain.handle(IPC.INSPIRATION_LIST, (_e, { bookId }: { bookId: string }) =>
wrap(() => registry.getInspirationRepo(bookId).list())
)
ipcMain.handle(
IPC.INSPIRATION_CREATE,
(
_e,
{
bookId,
title,
content,
tags
}: { bookId: string; title?: string; content?: string; tags?: string[] }
) =>
wrap(() => {
const item = registry.getInspirationRepo(bookId).create(title, content, tags)
ftsSync.upsert(registry.getDb(bookId), 'inspiration', item.id, item.title, item.content)
return item
})
)
ipcMain.handle(
IPC.INSPIRATION_UPDATE,
(
_e,
{
bookId,
id,
patch
}: {
bookId: string
id: string
patch: Partial<{ title: string; content: string; tags: string[] }>
}
) =>
wrap(() => {
const item = registry.getInspirationRepo(bookId).update(id, patch)
ftsSync.upsert(registry.getDb(bookId), 'inspiration', item.id, item.title, item.content)
return item
})
)
ipcMain.handle(IPC.INSPIRATION_DELETE, (_e, { bookId, id }: { bookId: string; id: string }) =>
wrap(() => {
registry.getInspirationRepo(bookId).delete(id)
ftsSync.remove(registry.getDb(bookId), 'inspiration', id)
})
)
ipcMain.handle(
IPC.INSPIRATION_CONVERT,
(
_e,
{
bookId,
id,
targetKind,
settingType
}: { bookId: string; id: string; targetKind: 'outline' | 'setting'; settingType?: SettingType }
) =>
wrap(() => {
const repo = registry.getInspirationRepo(bookId)
if (targetKind === 'outline') {
const item = repo.convertToOutline(id, registry.getOutlineRepo(bookId))
ftsSync.upsert(registry.getDb(bookId), 'outline', item.id, item.title, item.description)
return item
}
const item = repo.convertToSetting(
id,
settingType ?? 'custom',
registry.getSettingRepo(bookId)
)
ftsSync.upsert(registry.getDb(bookId), 'setting', item.id, item.name, item.description)
return item
})
)
}
+82
View File
@@ -0,0 +1,82 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { OutlineStatus } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { ftsSync } from '../../services/fts-sync.service'
import { wrap } from '../result'
export function registerOutlineHandlers(registry: BookRegistryService): void {
ipcMain.handle(IPC.OUTLINE_LIST, (_e, { bookId }: { bookId: string }) =>
wrap(() => registry.getOutlineRepo(bookId).listFlat())
)
ipcMain.handle(
IPC.OUTLINE_CREATE,
(
_e,
{
bookId,
parentId,
title,
sortOrder
}: { bookId: string; parentId?: string | null; title: string; sortOrder?: number }
) =>
wrap(() => {
const repo = registry.getOutlineRepo(bookId)
const order = sortOrder ?? repo.nextSortOrder(parentId ?? null)
const item = repo.create(parentId ?? null, title, order)
const db = registry.getDb(bookId)
ftsSync.upsert(db, 'outline', item.id, item.title, item.description)
return item
})
)
ipcMain.handle(
IPC.OUTLINE_UPDATE,
(
_e,
{
bookId,
id,
patch
}: {
bookId: string
id: string
patch: Partial<{
title: string
description: string
status: OutlineStatus
expectedWordCount: number | null
chapterId: string | null
sortOrder: number
}>
}
) =>
wrap(() => {
const item = registry.getOutlineRepo(bookId).update(id, patch)
const db = registry.getDb(bookId)
ftsSync.upsert(db, 'outline', item.id, item.title, item.description)
return item
})
)
ipcMain.handle(IPC.OUTLINE_DELETE, (_e, { bookId, id }: { bookId: string; id: string }) =>
wrap(() => {
registry.getOutlineRepo(bookId).delete(id)
ftsSync.remove(registry.getDb(bookId), 'outline', id)
})
)
ipcMain.handle(
IPC.OUTLINE_MOVE,
(
_e,
{
bookId,
id,
parentId,
sortOrder
}: { bookId: string; id: string; parentId?: string | null; sortOrder: number }
) => wrap(() => registry.getOutlineRepo(bookId).move(id, parentId ?? null, sortOrder))
)
}
+99
View File
@@ -0,0 +1,99 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { SearchScope } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { GlobalSettingsService } from '../../services/global-settings'
import { searchService } from '../../services/search.service'
import { wordFreqService } from '../../services/wordfreq.service'
import { wrap } from '../result'
const MAX_SEARCH_HISTORY = 10
export function registerSearchHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
): void {
ipcMain.handle(
IPC.SEARCH_QUERY,
(
_e,
{
bookId,
query,
scope,
options
}: {
bookId: string
query: string
scope?: SearchScope
options?: { regex?: boolean; caseSensitive?: boolean; matchWholeWord?: boolean }
}
) =>
wrap(() =>
searchService.query(registry.getDb(bookId), {
text: query,
scope,
regex: options?.regex,
caseSensitive: options?.caseSensitive
})
)
)
ipcMain.handle(
IPC.SEARCH_REPLACE,
(
_e,
{
bookId,
query,
replacement,
scope,
dryRun,
options
}: {
bookId: string
query: string
replacement: string
scope?: SearchScope
dryRun: boolean
options?: { regex?: boolean; caseSensitive?: boolean }
}
) =>
wrap(() =>
searchService.replace(registry.getDb(bookId), {
text: query,
replacement,
dryRun,
regex: options?.regex,
caseSensitive: options?.caseSensitive
})
)
)
ipcMain.handle(IPC.SEARCH_GET_HISTORY, () => wrap(() => settings.get().searchHistory))
ipcMain.handle(IPC.SEARCH_SAVE_HISTORY, (_e, { query }: { query: string }) =>
wrap(() => {
const trimmed = query.trim()
if (!trimmed) return settings.get().searchHistory
const current = settings.get().searchHistory.filter((q) => q !== trimmed)
const next = [trimmed, ...current].slice(0, MAX_SEARCH_HISTORY)
settings.update({ searchHistory: next })
return next
})
)
ipcMain.handle(
IPC.WORDFREQ_ANALYZE,
(
_e,
{
bookId,
scope
}: {
bookId: string
scope: { kind: 'book' | 'volume' | 'chapter'; chapterId?: string; volumeId?: string }
}
) => wrap(() => wordFreqService.analyze(registry.getDb(bookId), scope))
)
}
+58
View File
@@ -0,0 +1,58 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { SettingType } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import { ftsSync } from '../../services/fts-sync.service'
import { wrap } from '../result'
export function registerSettingHandlers(registry: BookRegistryService): void {
ipcMain.handle(
IPC.SETTING_LIST,
(_e, { bookId, type }: { bookId: string; type?: SettingType }) =>
wrap(() => registry.getSettingRepo(bookId).list(type))
)
ipcMain.handle(
IPC.SETTING_CREATE,
(_e, { bookId, type, name }: { bookId: string; type: SettingType; name: string }) =>
wrap(() => {
const item = registry.getSettingRepo(bookId).create(type, name)
ftsSync.upsert(registry.getDb(bookId), 'setting', item.id, item.name, item.description)
return item
})
)
ipcMain.handle(
IPC.SETTING_UPDATE,
(
_e,
{
bookId,
id,
patch
}: {
bookId: string
id: string
patch: Partial<{ name: string; description: string; type: SettingType; properties: Record<string, unknown> }>
}
) =>
wrap(() => {
const item = registry.getSettingRepo(bookId).update(id, patch)
ftsSync.upsert(registry.getDb(bookId), 'setting', item.id, item.name, item.description)
return item
})
)
ipcMain.handle(IPC.SETTING_DELETE, (_e, { bookId, id }: { bookId: string; id: string }) =>
wrap(() => {
registry.getSettingRepo(bookId).delete(id)
ftsSync.remove(registry.getDb(bookId), 'setting', id)
})
)
ipcMain.handle(
IPC.SETTING_SYNC_REFS,
(_e, { bookId, id, chapterIds }: { bookId: string; id: string; chapterIds: string[] }) =>
wrap(() => registry.getSettingRepo(bookId).syncChapterRefs(id, chapterIds))
)
}
+76
View File
@@ -0,0 +1,76 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { SnapshotType } from '../../../shared/types'
import { BookRegistryService } from '../../services/book-registry'
import type { SnapshotService } from '../../services/snapshot.service'
import { wrap } from '../result'
export function registerSnapshotHandlers(
registry: BookRegistryService,
snapshotService: SnapshotService
): void { ipcMain.handle(
IPC.SNAPSHOT_LIST,
(_e, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
wrap(() => registry.getSnapshotRepo(bookId).list(chapterId))
)
ipcMain.handle(
IPC.SNAPSHOT_CREATE,
(
_e,
{
bookId,
chapterId,
type,
name,
content
}: { bookId: string; chapterId: string; type: SnapshotType; name?: string; content?: string }
) =>
wrap(() => {
const repo = registry.getSnapshotRepo(bookId)
const chapter = registry.getChapterRepo(bookId).get(chapterId)
if (!chapter) throw new Error('Chapter not found')
return repo.create(chapterId, type, content ?? chapter.content, name)
})
)
ipcMain.handle(
IPC.SNAPSHOT_RESTORE,
(_e, { bookId, chapterId, snapshotId }: { bookId: string; chapterId: string; snapshotId: string }) =>
wrap(() => {
const content = registry.getSnapshotRepo(bookId).restore(snapshotId)
return registry.getChapterRepo(bookId).update(chapterId, { content })
})
)
ipcMain.handle(
IPC.SNAPSHOT_DELETE,
(_e, { bookId, snapshotId }: { bookId: string; snapshotId: string }) =>
wrap(() => {
registry.getSnapshotRepo(bookId).delete(snapshotId)
})
)
ipcMain.handle(
IPC.SNAPSHOT_START_AUTO,
(_e, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
wrap(() => {
const repo = registry.getSnapshotRepo(bookId)
const chapterRepo = registry.getChapterRepo(bookId)
snapshotService.startAutoSave(
bookId,
chapterId,
() => chapterRepo.get(chapterId)?.content ?? '',
repo
)
})
)
ipcMain.handle(
IPC.SNAPSHOT_STOP_AUTO,
(_e, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
wrap(() => {
snapshotService.stopAutoSave(bookId, chapterId)
})
)
}
+19
View File
@@ -2,13 +2,21 @@ import { join } from 'path'
import { app } from 'electron'
import { GlobalSettingsService } from '../services/global-settings'
import { BookRegistryService } from '../services/book-registry'
import { SnapshotService } from '../services/snapshot.service'
import { ShortcutManager } from '../shortcuts/manager'
import { registerSettingsHandlers } from './handlers/settings.handler'
import { registerBookHandlers } from './handlers/book.handler'
import { registerChapterHandlers } from './handlers/chapter.handler'
import { registerShortcutHandlers } from './handlers/shortcut.handler'
import { registerOutlineHandlers } from './handlers/outline.handler'
import { registerSettingHandlers } from './handlers/setting.handler'
import { registerInspirationHandlers } from './handlers/inspiration.handler'
import { registerSnapshotHandlers } from './handlers/snapshot.handler'
import { registerBookmarkHandlers } from './handlers/bookmark.handler'
import { registerSearchHandlers } from './handlers/search.handler'
let shortcutManager: ShortcutManager | null = null
let snapshotService: SnapshotService | null = null
export function registerIpc(): { settings: GlobalSettingsService; books: BookRegistryService; shortcuts: ShortcutManager } {
const userData = app.getPath('userData')
@@ -16,11 +24,18 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
const settings = new GlobalSettingsService(userData)
const books = new BookRegistryService(userData)
shortcutManager = new ShortcutManager(settings)
snapshotService = new SnapshotService(() => settings.get())
registerSettingsHandlers(settings)
registerBookHandlers(books)
registerChapterHandlers(books)
registerShortcutHandlers(shortcutManager)
registerOutlineHandlers(books)
registerSettingHandlers(books)
registerInspirationHandlers(books)
registerSnapshotHandlers(books, snapshotService!)
registerBookmarkHandlers(books)
registerSearchHandlers(books, settings)
return { settings, books, shortcuts: shortcutManager }
}
@@ -28,3 +43,7 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
export function getShortcutManager(): ShortcutManager | null {
return shortcutManager
}
export function getSnapshotService(): SnapshotService | null {
return snapshotService
}