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
+163 -1
View File
@@ -2,14 +2,25 @@ import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'
import { IPC } from '../shared/ipc-channels'
import type {
ActionId,
Bookmark,
BookMeta,
BookOpenResult,
Chapter,
CreateBookParams,
GlobalSettings,
InspirationEntry,
IpcResult,
LandmarkType,
OutlineItem,
OutlineStatus,
SearchResult,
SettingEntry,
SettingType,
Snapshot,
SnapshotType,
UpdateChapterParams,
Volume
Volume,
WordFreqResult
} from '../shared/types'
const electronAPI = {
@@ -58,6 +69,157 @@ const electronAPI = {
delete: (bookId: string, chapterId: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.CHAPTER_DELETE, { bookId, chapterId })
},
outline: {
list: (bookId: string): Promise<IpcResult<OutlineItem[]>> =>
ipcRenderer.invoke(IPC.OUTLINE_LIST, { bookId }),
create: (
bookId: string,
title: string,
parentId?: string | null,
sortOrder?: number
): Promise<IpcResult<OutlineItem>> =>
ipcRenderer.invoke(IPC.OUTLINE_CREATE, { bookId, parentId, title, sortOrder }),
update: (
bookId: string,
id: string,
patch: Partial<{
title: string
description: string
status: OutlineStatus
expectedWordCount: number | null
chapterId: string | null
sortOrder: number
}>
): Promise<IpcResult<OutlineItem>> =>
ipcRenderer.invoke(IPC.OUTLINE_UPDATE, { bookId, id, patch }),
delete: (bookId: string, id: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.OUTLINE_DELETE, { bookId, id }),
move: (
bookId: string,
id: string,
parentId: string | null,
sortOrder: number
): Promise<IpcResult<OutlineItem>> =>
ipcRenderer.invoke(IPC.OUTLINE_MOVE, { bookId, id, parentId, sortOrder })
},
setting: {
list: (bookId: string, type?: SettingType): Promise<IpcResult<SettingEntry[]>> =>
ipcRenderer.invoke(IPC.SETTING_LIST, { bookId, type }),
create: (bookId: string, type: SettingType, name: string): Promise<IpcResult<SettingEntry>> =>
ipcRenderer.invoke(IPC.SETTING_CREATE, { bookId, type, name }),
update: (
bookId: string,
id: string,
patch: Partial<{ name: string; description: string; type: SettingType; properties: Record<string, unknown> }>
): Promise<IpcResult<SettingEntry>> =>
ipcRenderer.invoke(IPC.SETTING_UPDATE, { bookId, id, patch }),
delete: (bookId: string, id: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.SETTING_DELETE, { bookId, id }),
syncRefs: (bookId: string, id: string, chapterIds: string[]): Promise<IpcResult<SettingEntry>> =>
ipcRenderer.invoke(IPC.SETTING_SYNC_REFS, { bookId, id, chapterIds })
},
inspiration: {
list: (bookId: string): Promise<IpcResult<InspirationEntry[]>> =>
ipcRenderer.invoke(IPC.INSPIRATION_LIST, { bookId }),
create: (
bookId: string,
title?: string,
content?: string,
tags?: string[]
): Promise<IpcResult<InspirationEntry>> =>
ipcRenderer.invoke(IPC.INSPIRATION_CREATE, { bookId, title, content, tags }),
update: (
bookId: string,
id: string,
patch: Partial<{ title: string; content: string; tags: string[] }>
): Promise<IpcResult<InspirationEntry>> =>
ipcRenderer.invoke(IPC.INSPIRATION_UPDATE, { bookId, id, patch }),
delete: (bookId: string, id: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.INSPIRATION_DELETE, { bookId, id }),
convert: (
bookId: string,
id: string,
targetKind: 'outline' | 'setting',
settingType?: SettingType
): Promise<IpcResult<OutlineItem | SettingEntry>> =>
ipcRenderer.invoke(IPC.INSPIRATION_CONVERT, { bookId, id, targetKind, settingType })
},
snapshot: {
list: (bookId: string, chapterId: string): Promise<IpcResult<Snapshot[]>> =>
ipcRenderer.invoke(IPC.SNAPSHOT_LIST, { bookId, chapterId }),
create: (
bookId: string,
chapterId: string,
type: SnapshotType,
name?: string,
content?: string
): Promise<IpcResult<Snapshot>> =>
ipcRenderer.invoke(IPC.SNAPSHOT_CREATE, { bookId, chapterId, type, name, content }),
restore: (
bookId: string,
chapterId: string,
snapshotId: string
): Promise<IpcResult<Chapter>> =>
ipcRenderer.invoke(IPC.SNAPSHOT_RESTORE, { bookId, chapterId, snapshotId }),
delete: (bookId: string, snapshotId: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.SNAPSHOT_DELETE, { bookId, snapshotId }),
startAutoSave: (bookId: string, chapterId: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.SNAPSHOT_START_AUTO, { bookId, chapterId }),
stopAutoSave: (bookId: string, chapterId: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.SNAPSHOT_STOP_AUTO, { bookId, chapterId })
},
bookmark: {
list: (
bookId: string,
chapterId?: string,
resolved?: boolean
): Promise<IpcResult<Bookmark[]>> =>
ipcRenderer.invoke(IPC.BOOKMARK_LIST, { bookId, chapterId, resolved }),
create: (
bookId: string,
chapterId: string,
position: number,
label: string,
landmarkType?: LandmarkType
): Promise<IpcResult<Bookmark>> =>
ipcRenderer.invoke(IPC.BOOKMARK_CREATE, { bookId, chapterId, position, label, landmarkType }),
update: (
bookId: string,
id: string,
patch: Partial<{ label: string; position: number; landmarkType: LandmarkType }>
): Promise<IpcResult<Bookmark>> =>
ipcRenderer.invoke(IPC.BOOKMARK_UPDATE, { bookId, id, patch }),
resolve: (bookId: string, id: string, resolved: boolean): Promise<IpcResult<Bookmark>> =>
ipcRenderer.invoke(IPC.BOOKMARK_RESOLVE, { bookId, id, resolved }),
delete: (bookId: string, id: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.BOOKMARK_DELETE, { bookId, id })
},
search: {
query: (
bookId: string,
query: string,
options?: { regex?: boolean; caseSensitive?: boolean; matchWholeWord?: boolean }
): Promise<IpcResult<SearchResult[]>> =>
ipcRenderer.invoke(IPC.SEARCH_QUERY, { bookId, query, options }),
replace: (
bookId: string,
query: string,
replacement: string,
dryRun: boolean,
options?: { regex?: boolean; caseSensitive?: boolean }
): Promise<IpcResult<{ count: number; previews: SearchResult[] }>> =>
ipcRenderer.invoke(IPC.SEARCH_REPLACE, { bookId, query, replacement, dryRun, options }),
getHistory: (): Promise<IpcResult<string[]>> => ipcRenderer.invoke(IPC.SEARCH_GET_HISTORY),
saveHistory: (query: string): Promise<IpcResult<string[]>> =>
ipcRenderer.invoke(IPC.SEARCH_SAVE_HISTORY, { query })
},
wordfreq: {
analyze: (
bookId: string,
scope: { kind: 'book' | 'volume' | 'chapter'; chapterId?: string; volumeId?: string }
): Promise<IpcResult<WordFreqResult>> =>
ipcRenderer.invoke(IPC.WORDFREQ_ANALYZE, { bookId, scope })
},
shortcut: {
getAll: (): Promise<IpcResult<Record<ActionId, string>>> =>
ipcRenderer.invoke(IPC.SHORTCUT_GET_ALL),