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:
@@ -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)
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user