feat(w5): ship v2.0.0 platform sync, undo persistence, and updates

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-08 18:24:51 +08:00
parent cb6b4c3731
commit fe421ffc55
60 changed files with 2207 additions and 36 deletions
+7 -1
View File
@@ -10,8 +10,9 @@ import schemaV8 from './schema-v8.sql?raw'
import schemaV9 from './schema-v9.sql?raw'
import schemaV10 from './schema-v10.sql?raw'
import schemaV11 from './schema-v11.sql?raw'
const CURRENT_VERSION = 10
const CURRENT_VERSION = 11
function tryAlter(db: SqliteDb, sql: string): void {
try {
@@ -105,4 +106,9 @@ export function migrate(db: SqliteDb): void {
db.exec(schemaV10)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(10, 'W4 timeline')
}
if (current < 11) {
db.exec(schemaV11)
db.prepare('INSERT INTO schema_version (version, description) VALUES (?, ?)').run(11, 'W5 undo stack')
}
}
+61
View File
@@ -0,0 +1,61 @@
import type { UndoOperation, UndoStackEntry } from '../../../shared/undo'
import type { SqliteDb } from '../types'
const MAX_ENTRIES = 50
function mapRow(row: Record<string, unknown>): UndoStackEntry {
let operation: UndoOperation
try {
operation = JSON.parse(row.operation as string) as UndoOperation
} catch {
operation = { type: 'doc', content: '' }
}
return {
id: row.id as number,
chapterId: row.chapter_id as string,
operation,
timestamp: row.timestamp as string
}
}
export class UndoStackRepository {
constructor(private db: SqliteDb) {}
push(chapterId: string, operation: UndoOperation): UndoStackEntry {
const json = JSON.stringify(operation)
const result = this.db
.prepare('INSERT INTO undo_stack (chapter_id, operation) VALUES (?, ?)')
.run(chapterId, json)
const id = Number(result.lastInsertRowid)
this.trim(chapterId)
return this.get(id)!
}
list(chapterId: string, limit = MAX_ENTRIES): UndoStackEntry[] {
return (
this.db
.prepare(
'SELECT * FROM undo_stack WHERE chapter_id = ? ORDER BY id DESC LIMIT ?'
)
.all(chapterId, limit) as Record<string, unknown>[]
).map(mapRow)
}
get(id: number): UndoStackEntry | null {
const row = this.db.prepare('SELECT * FROM undo_stack WHERE id = ?').get(id) as
| Record<string, unknown>
| undefined
return row ? mapRow(row) : null
}
private trim(chapterId: string): void {
const rows = this.db
.prepare(
'SELECT id FROM undo_stack WHERE chapter_id = ? ORDER BY id DESC LIMIT -1 OFFSET ?'
)
.all(chapterId, MAX_ENTRIES) as Array<{ id: number }>
for (const row of rows) {
this.db.prepare('DELETE FROM undo_stack WHERE id = ?').run(row.id)
}
}
}
+9
View File
@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS undo_stack (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chapter_id TEXT NOT NULL,
operation TEXT NOT NULL,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (chapter_id) REFERENCES chapters(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_undo_stack_chapter ON undo_stack(chapter_id, id DESC);
+2
View File
@@ -58,6 +58,8 @@ app.whenReady().then(async () => {
bootstrapPackHandlers(books, settings)
const { bootstrapExportMatrixHandlers } = await import('./export-matrix-bootstrap.js')
bootstrapExportMatrixHandlers(books, settings)
const { bootstrapSyncHandlers } = await import('./sync-bootstrap.js')
bootstrapSyncHandlers(books, settings)
ipcMain.handle(IPC.WINDOW_MINIMIZE, (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize()
+33
View File
@@ -0,0 +1,33 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { LogService } from '../../services/log.service'
import { extensionRegistry } from '../../services/extension-registry'
import { wrap } from '../result'
export function registerLogHandlers(logs: LogService): void {
ipcMain.handle(IPC.LOG_HAD_CRASH, () => wrap(() => logs.hadCrashOnLastRun()))
ipcMain.handle(IPC.LOG_CLEAR_CRASH, () =>
wrap(() => {
logs.clearCrashFlag()
})
)
ipcMain.handle(IPC.LOG_SEND_REPORT, (_e, { note }: { note?: string }) =>
wrap(() => logs.sendReportStub(note))
)
ipcMain.handle(IPC.LOG_WRITE_ERROR, (_e, { message }: { message: string }) =>
wrap(() => {
logs.logManualError(message)
})
)
}
export function registerExtensionHandlers(): void {
ipcMain.handle(IPC.EXTENSION_LIST, () => wrap(() => extensionRegistry.list()))
ipcMain.handle(IPC.EXTENSION_VALIDATE, (_e, { manifest }: { manifest: unknown }) =>
wrap(() => extensionRegistry.validateManifest(manifest))
)
ipcMain.handle(IPC.MCP_TEST_CONNECTION, (_e, { url }: { url: string }) =>
wrap(() => ({ ok: true, message: `MCP stub: ${url || 'no url'}` }))
)
}
+41
View File
@@ -0,0 +1,41 @@
import { dialog, ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { SyncConfigureInput, SyncRunInput } from '../../../shared/sync'
import { SyncService } from '../../services/sync.service'
import type { BookRegistryService } from '../../services/book-registry'
import type { GlobalSettingsService } from '../../services/global-settings'
import { wrap } from '../result'
let syncService: SyncService | null = null
function svc(registry: BookRegistryService, settings: GlobalSettingsService): SyncService {
if (!syncService) {
syncService = new SyncService(registry, settings, registry.getUserDataDir())
}
return syncService
}
export function registerSyncHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
): void {
const service = () => svc(registry, settings)
ipcMain.handle(IPC.SYNC_STATUS, () => wrap(() => service().getStatus()))
ipcMain.handle(IPC.SYNC_CONFIGURE, (_e, input: SyncConfigureInput) =>
wrap(() => service().configure(input))
)
ipcMain.handle(IPC.SYNC_RUN, (_e, input: SyncRunInput) =>
wrap(() => service().run(input.password, input.conflictResolution))
)
ipcMain.handle(IPC.SYNC_PICK_FOLDER, async () =>
wrap(async () => {
const result = await dialog.showOpenDialog({ properties: ['openDirectory', 'createDirectory'] })
if (result.canceled || !result.filePaths[0]) return null
return result.filePaths[0]
})
)
}
+36
View File
@@ -0,0 +1,36 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { UndoOperation } from '../../../shared/undo'
import { UndoStackService } from '../../services/undo-stack.service'
import type { BookRegistryService } from '../../services/book-registry'
import { wrap } from '../result'
export function registerUndoHandlers(registry: BookRegistryService): void {
ipcMain.handle(
IPC.UNDO_PUSH,
(
_e,
{
bookId,
chapterId,
operation
}: { bookId: string; chapterId: string; operation: UndoOperation }
) => wrap(() => new UndoStackService(registry.getDb(bookId)).push(chapterId, operation))
)
ipcMain.handle(
IPC.UNDO_LIST,
(_e, { bookId, chapterId }: { bookId: string; chapterId: string }) =>
wrap(() => new UndoStackService(registry.getDb(bookId)).list(chapterId))
)
ipcMain.handle(
IPC.UNDO_APPLY,
(_e, { bookId, entryId }: { bookId: string; entryId: number }) =>
wrap(() => {
const op = new UndoStackService(registry.getDb(bookId)).apply(entryId)
if (!op) throw new Error('Undo entry not found')
return op
})
)
}
+21
View File
@@ -0,0 +1,21 @@
import { ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import { UpdateService } from '../../services/update.service'
import { wrap } from '../result'
let updateService: UpdateService | null = null
function service(): UpdateService {
if (!updateService) updateService = new UpdateService()
return updateService
}
export function registerUpdateHandlers(): void {
ipcMain.handle(IPC.UPDATE_CHECK, () => wrap(() => service().check()))
ipcMain.handle(IPC.UPDATE_DOWNLOAD, () => wrap(() => service().download()))
ipcMain.handle(IPC.UPDATE_INSTALL, () =>
wrap(() => {
service().install()
})
)
}
+21 -2
View File
@@ -39,6 +39,10 @@ import { registerExportHandlers } from './handlers/export.handler'
import { registerTimelineHandlers } from './handlers/timeline.handler'
import { registerArcHandlers } from './handlers/arc.handler'
import { registerComplianceHandlers } from './handlers/compliance.handler'
import { registerUndoHandlers } from './handlers/undo.handler'
import { registerLogHandlers, registerExtensionHandlers } from './handlers/log.handler'
import { registerUpdateHandlers } from './handlers/update.handler'
import { LogService } from '../services/log.service'
import { AiClientService } from '../services/ai-client.service'
import { NetworkMonitorService } from '../services/network-monitor.service'
@@ -46,10 +50,21 @@ let shortcutManager: ShortcutManager | null = null
let snapshotService: SnapshotService | null = null
let networkMonitor: NetworkMonitorService | null = null
export function registerIpc(): { settings: GlobalSettingsService; books: BookRegistryService; shortcuts: ShortcutManager } {
export function registerIpc(): {
settings: GlobalSettingsService
books: BookRegistryService
shortcuts: ShortcutManager
logs: LogService
} {
const userData = app.getPath('userData')
const settings = new GlobalSettingsService(userData)
const logs = new LogService(userData)
if (!settings.get().onboardingCompleted) {
const locale = app.getLocale().toLowerCase()
const language = locale.startsWith('en') ? 'en' : 'zh-CN'
settings.update({ language })
}
const books = new BookRegistryService(userData)
const writingLogRepo = new WritingLogRepository(userData)
const writingLogs = new WritingLogService(writingLogRepo, settings)
@@ -99,8 +114,12 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
registerTimelineHandlers(books)
registerArcHandlers(books)
registerComplianceHandlers(settings)
registerUndoHandlers(books)
registerLogHandlers(logs)
registerExtensionHandlers()
registerUpdateHandlers()
return { settings, books, shortcuts: shortcutManager }
return { settings, books, shortcuts: shortcutManager, logs }
}
export function getShortcutManager(): ShortcutManager | null {
+40
View File
@@ -0,0 +1,40 @@
import type { BilinExtension, ExtensionManifestEntry } from '../../shared/extension-api'
export class ExtensionRegistry {
private extensions = new Map<string, ExtensionManifestEntry>()
register(manifest: BilinExtension, sourcePath?: string): ExtensionManifestEntry {
const errors: string[] = []
if (!manifest.name?.trim()) errors.push('name required')
if (!manifest.version?.trim()) errors.push('version required')
if (!manifest.main?.trim()) errors.push('main required')
const entry: ExtensionManifestEntry = {
id: manifest.name,
manifest,
sourcePath,
valid: errors.length === 0,
errors
}
this.extensions.set(entry.id, entry)
return entry
}
list(): ExtensionManifestEntry[] {
return [...this.extensions.values()]
}
validateManifest(raw: unknown): ExtensionManifestEntry {
const manifest = raw as BilinExtension
return this.register(manifest)
}
registerImporter(_id: string, _extensions: string[]): void {
/* stub */
}
registerExporter(_id: string, _extensions: string[]): void {
/* stub */
}
}
export const extensionRegistry = new ExtensionRegistry()
+8 -1
View File
@@ -41,7 +41,10 @@ function defaults(): GlobalSettings {
defaultSubmissionPresetId: 'builtin-webnovel',
enableComplianceCheck: false,
complianceWords: [],
customSlashCommands: []
customSlashCommands: [],
syncConfig: null,
extensionDevMode: false,
mcpConnectorUrl: ''
}
}
@@ -76,6 +79,10 @@ export class GlobalSettingsService {
enableComplianceCheck: raw.enableComplianceCheck ?? false,
complianceWords: raw.complianceWords ?? [],
customSlashCommands: raw.customSlashCommands ?? [],
syncConfig: raw.syncConfig ?? null,
syncPasswordVerifier: raw.syncPasswordVerifier,
extensionDevMode: raw.extensionDevMode ?? false,
mcpConnectorUrl: raw.mcpConnectorUrl ?? '',
shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts },
aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig }
}
+80
View File
@@ -0,0 +1,80 @@
import { app } from 'electron'
import log from 'electron-log'
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from 'fs'
import { join } from 'path'
const CRASH_FLAG = 'crash.pending'
const LOG_RETENTION_DAYS = 7
export class LogService {
private logDir: string
private logFile: string
private crashFlagPath: string
constructor(userDataDir: string) {
this.logDir = join(userDataDir, 'logs')
this.logFile = join(this.logDir, 'bilin.log')
this.crashFlagPath = join(userDataDir, CRASH_FLAG)
if (!existsSync(this.logDir)) mkdirSync(this.logDir, { recursive: true })
log.transports.file.resolvePathFn = () => this.logFile
log.transports.file.maxSize = 5 * 1024 * 1024
log.transports.console.level = process.env.BILIN_E2E === '1' ? 'error' : 'info'
this.pruneOldLogs()
this.installGlobalHandlers()
}
getLogger(): typeof log {
return log
}
hadCrashOnLastRun(): boolean {
return existsSync(this.crashFlagPath)
}
clearCrashFlag(): void {
if (existsSync(this.crashFlagPath)) rmSync(this.crashFlagPath)
}
markUncleanExit(): void {
writeFileSync(this.crashFlagPath, new Date().toISOString(), 'utf-8')
}
sendReportStub(note?: string): { ok: true; loggedAt: string } {
const msg = note ? `User report: ${note}` : 'User report submitted (stub)'
log.error(msg)
this.clearCrashFlag()
return { ok: true, loggedAt: new Date().toISOString() }
}
logManualError(message: string, detail?: unknown): void {
log.error(message, detail)
}
private installGlobalHandlers(): void {
process.on('uncaughtException', (err) => {
log.error('uncaughtException', err)
this.markUncleanExit()
})
process.on('unhandledRejection', (reason) => {
log.error('unhandledRejection', reason)
})
app?.on?.('before-quit', () => {
this.clearCrashFlag()
})
}
private pruneOldLogs(): void {
if (!existsSync(this.logDir)) return
const cutoff = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000
for (const name of readdirSync(this.logDir)) {
const full = join(this.logDir, name)
try {
if (statSync(full).mtimeMs < cutoff) rmSync(full, { force: true })
} catch {
/* ignore */
}
}
}
}
+47
View File
@@ -0,0 +1,47 @@
import { createCipheriv, createDecipheriv, createHash, pbkdf2Sync, randomBytes } from 'crypto'
import { readFileSync, writeFileSync } from 'fs'
const ALGO = 'aes-256-gcm'
const KEY_LEN = 32
const IV_LEN = 12
const TAG_LEN = 16
const PBKDF2_ITERATIONS = 120_000
export function hashSyncPassword(password: string, salt: string): string {
return createHash('sha256').update(`${salt}:${password}`).digest('hex')
}
export function deriveKey(password: string, salt: Buffer): Buffer {
return pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, KEY_LEN, 'sha256')
}
export function generateSalt(): string {
return randomBytes(16).toString('hex')
}
export function encryptFile(inputPath: string, outputPath: string, password: string, saltHex: string): void {
const salt = Buffer.from(saltHex, 'hex')
const key = deriveKey(password, salt)
const iv = randomBytes(IV_LEN)
const plain = readFileSync(inputPath)
const cipher = createCipheriv(ALGO, key, iv)
const encrypted = Buffer.concat([cipher.update(plain), cipher.final()])
const tag = cipher.getAuthTag()
const out = Buffer.concat([salt, iv, tag, encrypted])
writeFileSync(outputPath, out)
}
export function decryptFile(inputPath: string, outputPath: string, password: string): void {
const buf = readFileSync(inputPath)
const salt = buf.subarray(0, 16)
const iv = buf.subarray(16, 16 + IV_LEN)
const tag = buf.subarray(16 + IV_LEN, 16 + IV_LEN + TAG_LEN)
const data = buf.subarray(16 + IV_LEN + TAG_LEN)
const key = deriveKey(password, salt)
const decipher = createDecipheriv(ALGO, key, iv)
decipher.setAuthTag(tag)
const plain = Buffer.concat([decipher.update(data), decipher.final()])
writeFileSync(outputPath, plain)
}
export const SYNC_BUNDLE_NAME = 'bilin-sync.novel-all.enc'
+243
View File
@@ -0,0 +1,243 @@
import { app, BrowserWindow } from 'electron'
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
statSync,
writeFileSync
} from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { IPC } from '../../shared/ipc-channels'
import type { SyncConfig, SyncProgressEvent, SyncRunResult } from '../../shared/sync'
import type { BookRegistryService } from './book-registry'
import type { GlobalSettingsService } from './global-settings'
import { PackService } from './pack.service'
import { decryptFile, encryptFile, generateSalt, hashSyncPassword, SYNC_BUNDLE_NAME } from './sync-crypto'
function broadcastProgress(event: SyncProgressEvent): void {
try {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(IPC.SYNC_PROGRESS, event)
}
} catch {
/* test env */
}
}
export class SyncService {
private scheduleTimer: ReturnType<typeof setInterval> | null = null
private sessionPassword: string | null = null
constructor(
private registry: BookRegistryService,
private settings: GlobalSettingsService,
private userDataDir: string
) {}
getStatus(): SyncConfig | null {
return this.settings.get().syncConfig ?? null
}
configure(input: { config: SyncConfig; password: string }): SyncConfig {
if (input.password.length < 8) {
throw new Error('同步密码至少 8 位')
}
const salt = input.config.syncSalt ?? generateSalt()
const next: SyncConfig = {
...input.config,
syncSalt: salt,
passwordConfigured: true,
lastSyncStatus: 'idle'
}
this.sessionPassword = input.password
this.settings.update({
syncConfig: next,
syncPasswordVerifier: hashSyncPassword(input.password, salt)
})
this.applySchedule()
return next
}
setSessionPassword(password: string): void {
const cfg = this.settings.get().syncConfig
const salt = cfg?.syncSalt
const verifier = this.settings.get().syncPasswordVerifier
if (!salt || !verifier) throw new Error('尚未配置同步')
if (hashSyncPassword(password, salt) !== verifier) {
throw new Error('同步密码错误')
}
this.sessionPassword = password
}
async run(
password: string,
conflictResolution?: 'local' | 'remote' | 'cancel'
): Promise<SyncRunResult> {
this.setSessionPassword(password)
const cfg = this.settings.get().syncConfig
if (!cfg?.passwordConfigured || !cfg.syncSalt) {
throw new Error('请先完成同步配置')
}
const tempDir = mkdtempSync(join(tmpdir(), 'bilin-sync-'))
const plainPath = join(tempDir, 'bundle.novel-all')
const encPath = join(tempDir, SYNC_BUNDLE_NAME)
try {
broadcastProgress({ phase: 'export', message: '正在打包书籍…', percent: 10 })
const pack = new PackService(this.registry, this.settings, this.userDataDir)
await pack.exportAll(plainPath, (p) =>
broadcastProgress({
phase: 'export',
message: p.message,
percent: 10 + Math.round((p.current / Math.max(p.total, 1)) * 30)
})
)
const remotePath = this.remoteBundlePath(cfg)
const remoteExists = await this.remoteExists(cfg, remotePath)
const remoteMtime = await this.remoteMtime(cfg, remotePath)
const localEditedAt = Date.now()
const lastSyncAt = cfg.lastSyncAt ? Date.parse(cfg.lastSyncAt) : 0
const remoteNewer = remoteExists && remoteMtime > lastSyncAt
const localNewer = localEditedAt > lastSyncAt
if (remoteExists && remoteNewer && localNewer && !conflictResolution) {
this.patchStatus('conflict')
return {
status: 'conflict',
syncedAt: new Date().toISOString(),
remoteNewer: true,
localNewer: true
}
}
if (conflictResolution === 'cancel') {
return { status: 'idle', syncedAt: new Date().toISOString() }
}
if (conflictResolution === 'remote' && remoteExists) {
broadcastProgress({ phase: 'download', message: '正在下载远程备份…', percent: 60 })
const downloaded = join(tempDir, 'remote.enc')
await this.fetchRemote(cfg, remotePath, downloaded)
broadcastProgress({ phase: 'decrypt', message: '正在解密…', percent: 75 })
const decrypted = join(tempDir, 'restored.novel-all')
decryptFile(downloaded, decrypted, password)
broadcastProgress({ phase: 'import', message: '正在导入…', percent: 90 })
await pack.importAll(decrypted, 'merge')
} else {
broadcastProgress({ phase: 'encrypt', message: '正在加密…', percent: 50 })
encryptFile(plainPath, encPath, password, cfg.syncSalt)
broadcastProgress({ phase: 'upload', message: '正在同步…', percent: 70 })
await this.pushRemote(cfg, remotePath, encPath)
}
const syncedAt = new Date().toISOString()
this.patchStatus('ok', syncedAt)
broadcastProgress({ phase: 'done', message: '同步完成', percent: 100 })
return { status: 'ok', syncedAt }
} catch (err) {
this.patchStatus('error')
throw err
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
}
applySchedule(): void {
if (this.scheduleTimer) clearInterval(this.scheduleTimer)
const cfg = this.settings.get().syncConfig
if (!cfg || cfg.schedule === 'manual' || !this.sessionPassword) return
const ms =
cfg.schedule === 'hourly'
? 60 * 60 * 1000
: cfg.schedule === 'daily'
? 24 * 60 * 60 * 1000
: 7 * 24 * 60 * 60 * 1000
this.scheduleTimer = setInterval(() => {
if (!this.sessionPassword) return
void this.run(this.sessionPassword).catch(() => this.patchStatus('error'))
}, ms)
}
private patchStatus(status: SyncConfig['lastSyncStatus'], lastSyncAt?: string): void {
const current = this.settings.get().syncConfig
if (!current) return
this.settings.update({
syncConfig: {
...current,
lastSyncStatus: status,
lastSyncAt: lastSyncAt ?? current.lastSyncAt ?? null
}
})
}
private remoteBundlePath(cfg: SyncConfig): string {
if (cfg.mode === 'local-folder') {
if (!cfg.localFolderPath) throw new Error('未配置本地同步目录')
return join(cfg.localFolderPath, SYNC_BUNDLE_NAME)
}
if (!cfg.webdavUrl) throw new Error('未配置 WebDAV 地址')
const base = cfg.webdavUrl.replace(/\/$/, '')
return `${base}/${SYNC_BUNDLE_NAME}`
}
private async remoteExists(cfg: SyncConfig, remotePath: string): Promise<boolean> {
if (cfg.mode === 'local-folder') return existsSync(remotePath)
try {
const res = await fetch(remotePath, { method: 'HEAD' })
return res.ok
} catch {
return false
}
}
private async remoteMtime(cfg: SyncConfig, remotePath: string): Promise<number> {
if (cfg.mode === 'local-folder') {
return existsSync(remotePath) ? statSync(remotePath).mtimeMs : 0
}
try {
const res = await fetch(remotePath, { method: 'HEAD' })
const lm = res.headers.get('last-modified')
return lm ? Date.parse(lm) : 0
} catch {
return 0
}
}
private async pushRemote(cfg: SyncConfig, remotePath: string, localEncPath: string): Promise<void> {
if (cfg.mode === 'local-folder') {
mkdirSync(cfg.localFolderPath!, { recursive: true })
copyFileSync(localEncPath, remotePath)
return
}
const body = readFileSync(localEncPath)
const headers: Record<string, string> = { 'Content-Type': 'application/octet-stream' }
if (cfg.webdavUser && this.sessionPassword) {
const token = Buffer.from(`${cfg.webdavUser}:${this.sessionPassword}`).toString('base64')
headers.Authorization = `Basic ${token}`
}
const res = await fetch(remotePath, { method: 'PUT', headers, body })
if (!res.ok) throw new Error(`WebDAV 上传失败: ${res.status}`)
}
private async fetchRemote(cfg: SyncConfig, remotePath: string, destPath: string): Promise<void> {
if (cfg.mode === 'local-folder') {
copyFileSync(remotePath, destPath)
return
}
const headers: Record<string, string> = {}
if (cfg.webdavUser && this.sessionPassword) {
const token = Buffer.from(`${cfg.webdavUser}:${this.sessionPassword}`).toString('base64')
headers.Authorization = `Basic ${token}`
}
const res = await fetch(remotePath, { headers })
if (!res.ok) throw new Error(`WebDAV 下载失败: ${res.status}`)
const buf = Buffer.from(await res.arrayBuffer())
writeFileSync(destPath, buf)
}
}
+20
View File
@@ -0,0 +1,20 @@
import type { UndoOperation, UndoStackEntry } from '../../shared/undo'
import { UndoStackRepository } from '../db/repositories/undo.repo'
import type { SqliteDb } from '../db/types'
export class UndoStackService {
constructor(private db: SqliteDb) {}
push(chapterId: string, operation: UndoOperation): UndoStackEntry {
return new UndoStackRepository(this.db).push(chapterId, operation)
}
list(chapterId: string): UndoStackEntry[] {
return new UndoStackRepository(this.db).list(chapterId)
}
apply(entryId: number): UndoOperation | null {
const entry = new UndoStackRepository(this.db).get(entryId)
return entry?.operation ?? null
}
}
+98
View File
@@ -0,0 +1,98 @@
import { app, BrowserWindow } from 'electron'
import electronUpdater from 'electron-updater'
import { IPC } from '../../shared/ipc-channels'
const { autoUpdater } = electronUpdater
export interface UpdateInfoPayload {
version: string
releaseNotes?: string
}
export interface UpdateProgressPayload {
percent: number
transferred: number
total: number
}
export class UpdateService {
private disabled: boolean
private mockVersion: string | null = null
constructor() {
if (process.env.BILIN_MOCK_UPDATE === '1') {
this.disabled = false
this.mockVersion = process.env.BILIN_MOCK_UPDATE_VERSION ?? '9.9.9'
return
}
this.disabled =
process.env.BILIN_E2E === '1' ||
!app.isPackaged ||
process.env.NODE_ENV === 'development'
autoUpdater.autoDownload = false
autoUpdater.autoInstallOnAppQuit = true
autoUpdater.on('update-available', (info) => {
this.broadcast(IPC.UPDATE_AVAILABLE, {
version: info.version,
releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined
})
})
autoUpdater.on('download-progress', (p) => {
this.broadcast(IPC.UPDATE_PROGRESS, {
percent: p.percent,
transferred: p.transferred,
total: p.total
})
})
autoUpdater.on('update-downloaded', (info) => {
this.broadcast(IPC.UPDATE_DOWNLOADED, { version: info.version })
})
autoUpdater.on('error', (err) => {
this.broadcast(IPC.UPDATE_ERROR, { message: err.message })
})
}
async check(): Promise<UpdateInfoPayload | null> {
if (this.disabled) return null
if (this.mockVersion) {
const payload = { version: this.mockVersion, releaseNotes: 'Mock update' }
this.broadcast(IPC.UPDATE_AVAILABLE, payload)
return payload
}
const result = await autoUpdater.checkForUpdates()
const info = result?.updateInfo
if (!info) return null
return {
version: info.version,
releaseNotes: typeof info.releaseNotes === 'string' ? info.releaseNotes : undefined
}
}
async download(): Promise<void> {
if (this.disabled) return
if (this.mockVersion) {
this.broadcast(IPC.UPDATE_PROGRESS, { percent: 50, transferred: 50, total: 100 })
this.broadcast(IPC.UPDATE_DOWNLOADED, { version: this.mockVersion })
return
}
await autoUpdater.downloadUpdate()
}
install(): void {
if (this.disabled || this.mockVersion) return
autoUpdater.quitAndInstall()
}
private broadcast(channel: string, payload: unknown): void {
try {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(channel, payload)
}
} catch {
/* test env */
}
}
}
+10
View File
@@ -0,0 +1,10 @@
import { registerSyncHandlers } from './ipc/handlers/sync.handler'
import type { BookRegistryService } from './services/book-registry'
import type { GlobalSettingsService } from './services/global-settings'
export function bootstrapSyncHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
): void {
registerSyncHandlers(registry, settings)
}
+90
View File
@@ -69,6 +69,15 @@ import type { ExportMatrixParams } from '../shared/export-matrix'
import type { TimelineEntry, TimelineConflict, TimelineUpsertManualInput } from '../shared/timeline'
import type { CharacterArcNode } from '../shared/arc'
import type { ComplianceScanResult } from '../shared/compliance'
import type { UndoOperation, UndoStackEntry } from '../shared/undo'
import type {
SyncConfig,
SyncConfigureInput,
SyncProgressEvent,
SyncRunInput,
SyncRunResult
} from '../shared/sync'
import type { ExtensionManifestEntry } from '../shared/extension-api'
const electronAPI = {
window: {
@@ -635,6 +644,49 @@ const electronAPI = {
checkText: (text: string): Promise<IpcResult<ComplianceScanResult>> =>
ipcRenderer.invoke(IPC.COMPLIANCE_CHECK_TEXT, { text })
},
undo: {
push: (
bookId: string,
chapterId: string,
operation: UndoOperation
): Promise<IpcResult<UndoStackEntry>> =>
ipcRenderer.invoke(IPC.UNDO_PUSH, { bookId, chapterId, operation }),
list: (bookId: string, chapterId: string): Promise<IpcResult<UndoStackEntry[]>> =>
ipcRenderer.invoke(IPC.UNDO_LIST, { bookId, chapterId }),
apply: (bookId: string, entryId: number): Promise<IpcResult<UndoOperation>> =>
ipcRenderer.invoke(IPC.UNDO_APPLY, { bookId, entryId })
},
sync: {
status: (): Promise<IpcResult<SyncConfig | null>> => ipcRenderer.invoke(IPC.SYNC_STATUS),
configure: (input: SyncConfigureInput): Promise<IpcResult<SyncConfig>> =>
ipcRenderer.invoke(IPC.SYNC_CONFIGURE, input),
run: (input: SyncRunInput): Promise<IpcResult<SyncRunResult>> =>
ipcRenderer.invoke(IPC.SYNC_RUN, input),
pickFolder: (): Promise<IpcResult<string | null>> => ipcRenderer.invoke(IPC.SYNC_PICK_FOLDER)
},
update: {
check: (): Promise<IpcResult<{ version: string; releaseNotes?: string } | null>> =>
ipcRenderer.invoke(IPC.UPDATE_CHECK),
download: (): Promise<IpcResult<void>> => ipcRenderer.invoke(IPC.UPDATE_DOWNLOAD),
install: (): Promise<IpcResult<void>> => ipcRenderer.invoke(IPC.UPDATE_INSTALL)
},
log: {
hadCrash: (): Promise<IpcResult<boolean>> => ipcRenderer.invoke(IPC.LOG_HAD_CRASH),
clearCrash: (): Promise<IpcResult<void>> => ipcRenderer.invoke(IPC.LOG_CLEAR_CRASH),
sendReport: (note?: string): Promise<IpcResult<{ ok: true; loggedAt: string }>> =>
ipcRenderer.invoke(IPC.LOG_SEND_REPORT, { note }),
writeError: (message: string): Promise<IpcResult<void>> =>
ipcRenderer.invoke(IPC.LOG_WRITE_ERROR, { message })
},
extension: {
list: (): Promise<IpcResult<ExtensionManifestEntry[]>> => ipcRenderer.invoke(IPC.EXTENSION_LIST),
validate: (manifest: unknown): Promise<IpcResult<ExtensionManifestEntry>> =>
ipcRenderer.invoke(IPC.EXTENSION_VALIDATE, { manifest })
},
mcp: {
testConnection: (url: string): Promise<IpcResult<{ ok: boolean; message: string }>> =>
ipcRenderer.invoke(IPC.MCP_TEST_CONNECTION, { url })
},
pack: {
pickFile: (
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
@@ -736,6 +788,44 @@ const electronAPI = {
}
ipcRenderer.on(IPC.PACK_PROGRESS, handler)
return () => ipcRenderer.removeListener(IPC.PACK_PROGRESS, handler)
},
onSyncProgress: (callback: (payload: SyncProgressEvent) => void): (() => void) => {
const handler = (_event: IpcRendererEvent, payload: SyncProgressEvent) => {
callback(payload)
}
ipcRenderer.on(IPC.SYNC_PROGRESS, handler)
return () => ipcRenderer.removeListener(IPC.SYNC_PROGRESS, handler)
},
onUpdateAvailable: (
callback: (payload: { version: string; releaseNotes?: string }) => void
): (() => void) => {
const handler = (
_event: IpcRendererEvent,
payload: { version: string; releaseNotes?: string }
) => {
callback(payload)
}
ipcRenderer.on(IPC.UPDATE_AVAILABLE, handler)
return () => ipcRenderer.removeListener(IPC.UPDATE_AVAILABLE, handler)
},
onUpdateProgress: (
callback: (payload: { percent: number; transferred: number; total: number }) => void
): (() => void) => {
const handler = (
_event: IpcRendererEvent,
payload: { percent: number; transferred: number; total: number }
) => {
callback(payload)
}
ipcRenderer.on(IPC.UPDATE_PROGRESS, handler)
return () => ipcRenderer.removeListener(IPC.UPDATE_PROGRESS, handler)
},
onUpdateDownloaded: (callback: (payload: { version: string }) => void): (() => void) => {
const handler = (_event: IpcRendererEvent, payload: { version: string }) => {
callback(payload)
}
ipcRenderer.on(IPC.UPDATE_DOWNLOADED, handler)
return () => ipcRenderer.removeListener(IPC.UPDATE_DOWNLOADED, handler)
}
}
+11
View File
@@ -6,6 +6,7 @@ import './styles/themes.css'
import './styles/globals.css'
import './styles/layout.css'
import { TopBar } from '@renderer/components/layout/TopBar'
import { UpdateBanner } from '@renderer/components/settings/UpdateBanner'
import { HomePage } from '@renderer/components/home/HomePage'
import { EditorLayout } from '@renderer/components/layout/EditorLayout'
import { SettingsPage } from '@renderer/components/settings/SettingsPage'
@@ -31,6 +32,7 @@ import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
import { isSpeaking, speakText, stopSpeaking } from '@renderer/lib/tts-controller'
import { useAiStore } from '@renderer/stores/useAiStore'
import { useExportStore } from '@renderer/stores/useExportStore'
import { CrashReportDialog } from '@renderer/components/settings/CrashReportDialog'
import { ipcCall } from '@renderer/lib/ipc-client'
function AppInner(): React.JSX.Element {
@@ -48,6 +50,13 @@ function AppInner(): React.JSX.Element {
const { activeTabId, openTabs } = useTabStore()
const openBook = useBookStore((s) => s.openBook)
const [showOnboarding, setShowOnboarding] = useState(false)
const [crashDialogOpen, setCrashDialogOpen] = useState(false)
useEffect(() => {
void ipcCall(() => window.electronAPI.log.hadCrash()).then((had) => {
if (had) setCrashDialogOpen(true)
})
}, [])
useEffect(() => {
void (async () => {
@@ -202,6 +211,7 @@ function AppInner(): React.JSX.Element {
return (
<div id="app" className={focusMode ? 'focus-mode' : undefined} data-testid="app-root">
<TopBar />
<UpdateBanner />
<div id="main-area">
{view === 'home' && <HomePage />}
{view === 'editor' && <EditorLayout />}
@@ -215,6 +225,7 @@ function AppInner(): React.JSX.Element {
<ImportBookModal />
<ExportAllModal />
<TimelineModal />
<CrashReportDialog open={crashDialogOpen} onClose={() => setCrashDialogOpen(false)} />
<NamingCheckModal open={namingCheckOpen} onClose={() => setNamingCheckOpen(false)} />
{toast && <div className="toast">{toast}</div>}
</div>
@@ -87,6 +87,13 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
const updateInspirationLocal = useBookStore((s) => s.updateInspirationLocal)
const settings = useBookStore((s) => s.settings)
const timerRef = useRef<ReturnType<typeof setTimeout>>()
const undoTimerRef = useRef<ReturnType<typeof setTimeout>>()
const lastPushedContentRef = useRef<string | null>(null)
const persistentUndoRef = useRef<{ entries: import('@shared/undo').UndoStackEntry[]; index: number }>({
entries: [],
index: -1
})
const editorRef = useRef<ReturnType<typeof useEditor>>(null)
const targetKeyRef = useRef<string | null>(null)
const [mentionOpen, setMentionOpen] = useState(false)
const [mentionQuery, setMentionQuery] = useState('')
@@ -139,6 +146,26 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
}
const text = ed.getText()
const chapterCount = countPlain(text)
if (isChapter && bookId && target?.kind === 'chapter') {
clearTimeout(undoTimerRef.current)
undoTimerRef.current = setTimeout(() => {
const html = ed.getHTML()
const prev = lastPushedContentRef.current
if (prev !== null && prev !== html) {
void ipcCall(() =>
window.electronAPI.undo.push(bookId, target.id, { type: 'doc', content: prev })
).then(() =>
ipcCall(() => window.electronAPI.undo.list(bookId, target.id)).then((entries) => {
persistentUndoRef.current = {
entries,
index: entries.length > 0 ? entries.length - 1 : -1
}
})
)
}
lastPushedContentRef.current = html
}, 1500)
}
if (isChapter && target?.kind === 'chapter') {
const ch = chapters.find((c) => c.id === target.id)
const volId = ch?.volumeId
@@ -185,12 +212,26 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
const pos = coords?.pos ?? view.state.selection.from
view.dispatch(view.state.tr.insertText(text, pos))
return true
},
handleKeyDown: (_view, event) => {
if (!(event.ctrlKey || event.metaKey) || event.key !== 'z' || event.shiftKey) return false
const ed = editorRef.current
if (!ed || ed.can().undo()) return false
const slot = persistentUndoRef.current
const entry = slot.entries[slot.index]
if (!entry) return false
slot.index -= 1
ed.commands.setContent(entry.operation.content)
event.preventDefault()
return true
}
}
},
[target?.kind, target?.id, i18n.language]
[target?.kind, target?.id, i18n.language, isChapter, bookId]
)
editorRef.current = editor
const save = useCallback(async () => {
if (!bookId || !target || !editor) return
setSaving(true)
@@ -356,6 +397,15 @@ export function TipTapEditor({ target, bookId }: TipTapEditorProps): React.JSX.E
targetKeyRef.current = key
editor.commands.setContent(content || '')
lastPushedContentRef.current = content || ''
if (bookId && target.kind === 'chapter') {
void ipcCall(() => window.electronAPI.undo.list(bookId, target.id)).then((entries) => {
persistentUndoRef.current = {
entries,
index: entries.length > 0 ? entries.length - 1 : -1
}
})
}
if (target.kind === 'chapter') {
const ch = chapters.find((c) => c.id === target.id)
const pos = Math.min(ch?.cursorOffset ?? 0, editor.state.doc.content.size)
@@ -148,6 +148,38 @@ export function AiSettingsPage(): React.JSX.Element {
</p>
)}
<div className="setting-item" style={{ flexDirection: 'column', alignItems: 'flex-start' }}>
<span>{t('ai.settings.builtinSkills')}</span>
<ul className="builtin-skill-list" data-testid="builtin-skills-list">
<li>{t('ai.settings.skillContinue')}</li>
<li>{t('ai.settings.skillPolish')}</li>
<li>{t('ai.settings.skillProofread')}</li>
</ul>
</div>
<div className="setting-item" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
<span>{t('ai.settings.mcpConnector')}</span>
<input
className="form-control form-control--wide"
data-testid="mcp-connector-url"
value={settings.mcpConnectorUrl ?? ''}
onChange={(e) => void settings.update({ mcpConnectorUrl: e.target.value })}
placeholder="https://mcp.example.com"
/>
<button
type="button"
className="btn"
data-testid="mcp-test-connection"
onClick={() =>
void ipcCall(() =>
window.electronAPI.mcp.testConnection(settings.mcpConnectorUrl ?? '')
).then((r) => showToast(r.message))
}
>
{t('ai.settings.mcpTest')}
</button>
</div>
<div className="setting-item" style={{ flexDirection: 'column', alignItems: 'flex-start' }}>
<span>{t('ai.settings.customSlashCommands')}</span>
<div className="custom-slash-list" data-testid="custom-slash-commands">
@@ -0,0 +1,44 @@
import { useTranslation } from 'react-i18next'
import { ipcCall } from '@renderer/lib/ipc-client'
interface CrashReportDialogProps {
open: boolean
onClose: () => void
}
export function CrashReportDialog({ open, onClose }: CrashReportDialogProps): React.JSX.Element | null {
const { t } = useTranslation()
if (!open) return null
const handleSend = async (): Promise<void> => {
await ipcCall(() => window.electronAPI.log.sendReport())
await ipcCall(() => window.electronAPI.log.clearCrash())
onClose()
}
const handleDismiss = async (): Promise<void> => {
await ipcCall(() => window.electronAPI.log.clearCrash())
onClose()
}
return (
<div className="modal-overlay" data-testid="crash-report-modal" role="dialog" aria-modal="true">
<div className="modal-content">
<div className="modal-header">
<h3>{t('crash.title')}</h3>
</div>
<div className="modal-body">
<p>{t('crash.desc')}</p>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-primary" data-testid="crash-send-report" onClick={() => void handleSend()}>
{t('crash.send')}
</button>
<button type="button" className="btn" onClick={() => void handleDismiss()}>
{t('crash.dismiss')}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next'
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
export function ExtensionsSettingsTab(): React.JSX.Element {
const { t } = useTranslation()
const settings = useSettingsStore()
return (
<div data-testid="extensions-settings-tab">
<h4>{t('extensions.title')}</h4>
<p className="text-muted" style={{ marginBottom: 12 }}>
{t('extensions.desc')}
</p>
<div className="setting-item">
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
data-testid="extensions-dev-mode"
checked={settings.extensionDevMode === true}
onChange={(e) => void settings.update({ extensionDevMode: e.target.checked })}
/>
{t('extensions.devMode')}
</label>
</div>
<p className="text-muted" style={{ fontSize: 12 }}>
{t('extensions.stubHint')}
</p>
</div>
)
}
@@ -6,6 +6,8 @@ import { ShortcutEditor } from '@renderer/components/settings/ShortcutEditor'
import { AiSettingsPage } from '@renderer/components/settings/AiSettingsPage'
import { TemplateSettingsTab } from '@renderer/components/settings/TemplateSettingsTab'
import { BackupSettingsTab } from '@renderer/components/settings/BackupSettingsTab'
import { SyncSettingsTab } from '@renderer/components/settings/SyncSettingsTab'
import { ExtensionsSettingsTab } from '@renderer/components/settings/ExtensionsSettingsTab'
import { SubmissionPresetTab } from '@renderer/components/settings/SubmissionPresetTab'
const THEMES: { id: ThemeId; key: string }[] = [
@@ -22,7 +24,7 @@ export function SettingsPage(): React.JSX.Element {
const { t } = useTranslation()
const settings = useSettingsStore()
const [section, setSection] = useState<
'general' | 'ai' | 'templates' | 'submission' | 'backup' | 'shortcuts' | 'about'
'general' | 'ai' | 'templates' | 'submission' | 'backup' | 'extensions' | 'shortcuts' | 'about'
>('general')
return (
@@ -72,6 +74,15 @@ export function SettingsPage(): React.JSX.Element {
>
{t('settings.backup')}
</div>
<div
className={`settings-nav-item ${section === 'extensions' ? 'active' : ''}`}
onClick={() => setSection('extensions')}
role="button"
tabIndex={0}
data-testid="settings-nav-extensions"
>
{t('settings.extensions')}
</div>
<div
className={`settings-nav-item ${section === 'shortcuts' ? 'active' : ''}`}
onClick={() => setSection('shortcuts')}
@@ -305,11 +316,18 @@ export function SettingsPage(): React.JSX.Element {
{section === 'ai' && <AiSettingsPage />}
{section === 'templates' && <TemplateSettingsTab />}
{section === 'submission' && <SubmissionPresetTab />}
{section === 'backup' && <BackupSettingsTab />}
{section === 'backup' && (
<>
<BackupSettingsTab />
<hr style={{ margin: '24px 0', borderColor: 'var(--border-color)' }} />
<SyncSettingsTab />
</>
)}
{section === 'extensions' && <ExtensionsSettingsTab />}
{section === 'shortcuts' && <ShortcutEditor />}
{section === 'about' && (
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
{t('app.name')} v1.5.0
{t('app.name')} v2.0.0
<br />
{t('app.tagline')}
</p>
@@ -0,0 +1,43 @@
import { useTranslation } from 'react-i18next'
interface SyncConflictModalProps {
open: boolean
onClose: () => void
onChoose: (choice: 'local' | 'remote' | 'cancel') => void
}
export function SyncConflictModal({
open,
onClose,
onChoose
}: SyncConflictModalProps): React.JSX.Element | null {
const { t } = useTranslation()
if (!open) return null
return (
<div className="modal-overlay" data-testid="sync-conflict-modal" role="dialog" aria-modal="true">
<div className="modal-content">
<div className="modal-header">
<h3>{t('sync.conflictTitle')}</h3>
<button type="button" className="modal-close" onClick={onClose}>
</button>
</div>
<div className="modal-body">
<p>{t('sync.conflictDesc')}</p>
</div>
<div className="modal-footer">
<button type="button" className="btn" onClick={() => onChoose('remote')}>
{t('sync.useRemote')}
</button>
<button type="button" className="btn btn-primary" onClick={() => onChoose('local')}>
{t('sync.useLocal')}
</button>
<button type="button" className="btn" onClick={() => onChoose('cancel')}>
{t('dialog.cancel')}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,205 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { SyncConfig, SyncMode, SyncProgressEvent, SyncSchedule } from '@shared/sync'
import { useAppStore } from '@renderer/stores/useAppStore'
import { ipcCall } from '@renderer/lib/ipc-client'
import { SyncConflictModal } from '@renderer/components/settings/SyncConflictModal'
export function SyncSettingsTab(): React.JSX.Element {
const { t } = useTranslation()
const showToast = useAppStore((s) => s.showToast)
const [mode, setMode] = useState<SyncMode>('local-folder')
const [schedule, setSchedule] = useState<SyncSchedule>('manual')
const [localFolderPath, setLocalFolderPath] = useState('')
const [webdavUrl, setWebdavUrl] = useState('')
const [webdavUser, setWebdavUser] = useState('')
const [password, setPassword] = useState('')
const [syncing, setSyncing] = useState(false)
const [progress, setProgress] = useState<SyncProgressEvent | null>(null)
const [status, setStatus] = useState<SyncConfig | null>(null)
const [conflictOpen, setConflictOpen] = useState(false)
useEffect(() => {
void ipcCall(() => window.electronAPI.sync.status()).then((cfg) => {
if (!cfg) return
setStatus(cfg)
setMode(cfg.mode)
setSchedule(cfg.schedule)
setLocalFolderPath(cfg.localFolderPath ?? '')
setWebdavUrl(cfg.webdavUrl ?? '')
setWebdavUser(cfg.webdavUser ?? '')
})
const unsub = window.electronAPI.onSyncProgress(setProgress)
return unsub
}, [])
const buildConfig = (): SyncConfig => ({
mode,
schedule,
localFolderPath: localFolderPath || undefined,
webdavUrl: webdavUrl || undefined,
webdavUser: webdavUser || undefined,
syncSalt: status?.syncSalt,
passwordConfigured: status?.passwordConfigured,
lastSyncAt: status?.lastSyncAt,
lastSyncStatus: status?.lastSyncStatus
})
const handleSaveConfig = async (): Promise<void> => {
if (password.length < 8) {
showToast(t('sync.passwordHint'))
return
}
const cfg = await ipcCall(() =>
window.electronAPI.sync.configure({ config: buildConfig(), password })
)
setStatus(cfg)
showToast(t('sync.configSaved'))
}
const handlePickFolder = async (): Promise<void> => {
const path = await ipcCall(() => window.electronAPI.sync.pickFolder())
if (path) setLocalFolderPath(path)
}
const runSync = async (conflictResolution?: 'local' | 'remote' | 'cancel'): Promise<void> => {
if (password.length < 8 && !status?.passwordConfigured) {
showToast(t('sync.passwordHint'))
return
}
setSyncing(true)
setProgress(null)
try {
const result = await ipcCall(() =>
window.electronAPI.sync.run({ password, conflictResolution })
)
if (result.status === 'conflict') {
setConflictOpen(true)
return
}
setStatus((s) => (s ? { ...s, lastSyncStatus: result.status, lastSyncAt: result.syncedAt } : s))
showToast(t('sync.done'))
} catch (err) {
showToast(err instanceof Error ? err.message : String(err))
} finally {
setSyncing(false)
setProgress(null)
}
}
return (
<div className="sync-settings" data-testid="sync-settings-tab">
<h4>{t('sync.title')}</h4>
<p className="text-muted" style={{ marginBottom: 12 }}>
{t('sync.desc')}
</p>
<div className="setting-item">
<span>{t('sync.mode')}</span>
<select
className="form-control"
data-testid="sync-mode"
value={mode}
onChange={(e) => setMode(e.target.value as SyncMode)}
>
<option value="local-folder">{t('sync.modeLocal')}</option>
<option value="webdav">{t('sync.modeWebdav')}</option>
</select>
</div>
{mode === 'local-folder' ? (
<div className="setting-item" style={{ gap: 8 }}>
<input
className="form-control form-control--wide"
data-testid="sync-local-path"
value={localFolderPath}
onChange={(e) => setLocalFolderPath(e.target.value)}
placeholder={t('sync.localPathPlaceholder')}
/>
<button type="button" className="btn" onClick={() => void handlePickFolder()}>
{t('sync.pickFolder')}
</button>
</div>
) : (
<>
<div className="setting-item">
<input
className="form-control form-control--wide"
data-testid="sync-webdav-url"
value={webdavUrl}
onChange={(e) => setWebdavUrl(e.target.value)}
placeholder={t('sync.webdavUrl')}
/>
</div>
<div className="setting-item">
<input
className="form-control form-control--wide"
data-testid="sync-webdav-user"
value={webdavUser}
onChange={(e) => setWebdavUser(e.target.value)}
placeholder={t('sync.webdavUser')}
/>
</div>
</>
)}
<div className="setting-item">
<span>{t('sync.schedule')}</span>
<select
className="form-control"
data-testid="sync-schedule"
value={schedule}
onChange={(e) => setSchedule(e.target.value as SyncSchedule)}
>
<option value="manual">{t('sync.scheduleManual')}</option>
<option value="hourly">{t('sync.scheduleHourly')}</option>
<option value="daily">{t('sync.scheduleDaily')}</option>
<option value="weekly">{t('sync.scheduleWeekly')}</option>
</select>
</div>
<div className="setting-item">
<input
type="password"
className="form-control form-control--wide"
data-testid="sync-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t('sync.passwordPlaceholder')}
/>
</div>
<div className="setting-item" style={{ gap: 8 }}>
<button type="button" className="btn" data-testid="sync-save-config" onClick={() => void handleSaveConfig()}>
{t('sync.saveConfig')}
</button>
<button
type="button"
className="btn btn-primary"
data-testid="sync-run-now"
disabled={syncing}
onClick={() => void runSync()}
>
{syncing ? t('sync.running') : t('sync.runNow')}
</button>
</div>
{status?.lastSyncAt && (
<p className="text-muted" data-testid="sync-last-status" style={{ fontSize: 12 }}>
{t('sync.lastSync', {
time: new Date(status.lastSyncAt).toLocaleString(),
status: status.lastSyncStatus ?? 'idle'
})}
</p>
)}
{progress && (
<div className="import-progress" data-testid="sync-progress">
<div className="import-progress-fill" style={{ width: `${progress.percent ?? 0}%` }} />
<span>{progress.message}</span>
</div>
)}
<SyncConflictModal
open={conflictOpen}
onClose={() => setConflictOpen(false)}
onChoose={(choice) => {
setConflictOpen(false)
void runSync(choice)
}}
/>
</div>
)
}
@@ -0,0 +1,59 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ipcCall } from '@renderer/lib/ipc-client'
export function UpdateBanner(): React.JSX.Element | null {
const { t } = useTranslation()
const [version, setVersion] = useState<string | null>(null)
const [downloading, setDownloading] = useState(false)
const [ready, setReady] = useState(false)
const [percent, setPercent] = useState(0)
useEffect(() => {
const unsubAvail = window.electronAPI.onUpdateAvailable((p) => setVersion(p.version))
const unsubProg = window.electronAPI.onUpdateProgress((p) => setPercent(Math.round(p.percent)))
const unsubDone = window.electronAPI.onUpdateDownloaded(() => {
setDownloading(false)
setReady(true)
})
void ipcCall(() => window.electronAPI.update.check()).then((info) => {
if (info?.version) setVersion(info.version)
})
return () => {
unsubAvail()
unsubProg()
unsubDone()
}
}, [])
if (!version) return null
return (
<div className="update-banner" data-testid="update-banner">
<span>{t('update.available', { version })}</span>
{!ready ? (
<button
type="button"
className="btn btn-primary"
data-testid="update-download"
disabled={downloading}
onClick={() => {
setDownloading(true)
void ipcCall(() => window.electronAPI.update.download())
}}
>
{downloading ? t('update.downloading', { percent }) : t('update.download')}
</button>
) : (
<button
type="button"
className="btn btn-primary"
data-testid="update-install"
onClick={() => void ipcCall(() => window.electronAPI.update.install())}
>
{t('update.install')}
</button>
)}
</div>
)
}
+2
View File
@@ -38,6 +38,8 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
enableComplianceCheck: false,
complianceWords: [] as string[],
customSlashCommands: [] as { id: string; name: string; template: string }[],
mcpConnectorUrl: '',
extensionDevMode: false,
loaded: false,
load: async () => {
const data = await ipcCall(() => window.electronAPI.settings.get())
+22
View File
@@ -2574,3 +2574,25 @@
gap: 8px;
align-items: center;
}
.update-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 6px 12px;
background: var(--accent-muted, rgba(59, 130, 246, 0.12));
border-bottom: 1px solid var(--border-color);
font-size: 13px;
}
.sync-settings h4 {
margin: 0 0 8px;
}
.builtin-skill-list {
margin: 8px 0 0;
padding-left: 20px;
color: var(--text-muted);
font-size: 13px;
}
+50
View File
@@ -64,6 +64,15 @@ import type { ExportMatrixParams } from './export-matrix'
import type { TimelineEntry, TimelineConflict, TimelineUpsertManualInput } from './timeline'
import type { CharacterArcNode } from './arc'
import type { ComplianceScanResult } from './compliance'
import type { UndoOperation, UndoStackEntry } from './undo'
import type {
SyncConfig,
SyncConfigureInput,
SyncProgressEvent,
SyncRunInput,
SyncRunResult
} from './sync'
import type { ExtensionManifestEntry } from './extension-api'
export interface ElectronAPI {
window: {
@@ -467,6 +476,39 @@ export interface ElectronAPI {
setWords: (words: string[]) => Promise<IpcResult<void>>
checkText: (text: string) => Promise<IpcResult<ComplianceScanResult>>
}
undo: {
push: (
bookId: string,
chapterId: string,
operation: UndoOperation
) => Promise<IpcResult<UndoStackEntry>>
list: (bookId: string, chapterId: string) => Promise<IpcResult<UndoStackEntry[]>>
apply: (bookId: string, entryId: number) => Promise<IpcResult<UndoOperation>>
}
sync: {
status: () => Promise<IpcResult<SyncConfig | null>>
configure: (input: SyncConfigureInput) => Promise<IpcResult<SyncConfig>>
run: (input: SyncRunInput) => Promise<IpcResult<SyncRunResult>>
pickFolder: () => Promise<IpcResult<string | null>>
}
update: {
check: () => Promise<IpcResult<{ version: string; releaseNotes?: string } | null>>
download: () => Promise<IpcResult<void>>
install: () => Promise<IpcResult<void>>
}
log: {
hadCrash: () => Promise<IpcResult<boolean>>
clearCrash: () => Promise<IpcResult<void>>
sendReport: (note?: string) => Promise<IpcResult<{ ok: true; loggedAt: string }>>
writeError: (message: string) => Promise<IpcResult<void>>
}
extension: {
list: () => Promise<IpcResult<ExtensionManifestEntry[]>>
validate: (manifest: unknown) => Promise<IpcResult<ExtensionManifestEntry>>
}
mcp: {
testConnection: (url: string) => Promise<IpcResult<{ ok: boolean; message: string }>>
}
pack: {
pickFile: (
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
@@ -505,6 +547,14 @@ export interface ElectronAPI {
onGoalNotification: (callback: (payload: GoalNotificationPayload) => void) => () => void
onImportProgress: (callback: (payload: { current: number; total: number }) => void) => () => void
onPackProgress: (callback: (payload: PackProgressEvent) => void) => () => void
onSyncProgress: (callback: (payload: SyncProgressEvent) => void) => () => void
onUpdateAvailable: (
callback: (payload: { version: string; releaseNotes?: string }) => void
) => () => void
onUpdateProgress: (
callback: (payload: { percent: number; transferred: number; total: number }) => void
) => () => void
onUpdateDownloaded: (callback: (payload: { version: string }) => void) => () => void
}
declare global {
+64
View File
@@ -0,0 +1,64 @@
export interface SidePanelConfig {
id: string
title: string
icon?: string
}
export interface ImporterConfig {
id: string
extensions: string[]
label: string
}
export interface ExporterConfig {
id: string
extensions: string[]
label: string
}
export interface AIBackendConfig {
id: string
label: string
}
export interface SkillConfig {
id: string
name: string
description?: string
}
export interface EditorExtensionConfig {
id: string
name: string
}
export interface MenuItemConfig {
id: string
label: string
location: 'toolbar' | 'context'
}
export interface BilinExtension {
name: string
version: string
description: string
main: string
permissions: string[]
contributes: {
sidePanels?: SidePanelConfig[]
importers?: ImporterConfig[]
exporters?: ExporterConfig[]
aiBackends?: AIBackendConfig[]
skills?: SkillConfig[]
editorExtensions?: EditorExtensionConfig[]
menuItems?: MenuItemConfig[]
}
}
export interface ExtensionManifestEntry {
id: string
manifest: BilinExtension
sourcePath?: string
valid: boolean
errors: string[]
}
+23 -1
View File
@@ -168,5 +168,27 @@ export const IPC = {
ARC_GET_FOR_CHARACTER: 'arc:getForCharacter',
COMPLIANCE_GET_WORDS: 'compliance:getWords',
COMPLIANCE_SET_WORDS: 'compliance:setWords',
COMPLIANCE_CHECK_TEXT: 'compliance:checkText'
COMPLIANCE_CHECK_TEXT: 'compliance:checkText',
UNDO_PUSH: 'undo:push',
UNDO_LIST: 'undo:list',
UNDO_APPLY: 'undo:apply',
SYNC_CONFIGURE: 'sync:configure',
SYNC_RUN: 'sync:run',
SYNC_STATUS: 'sync:status',
SYNC_PICK_FOLDER: 'sync:pickFolder',
SYNC_PROGRESS: 'sync:progress',
UPDATE_CHECK: 'update:check',
UPDATE_DOWNLOAD: 'update:download',
UPDATE_INSTALL: 'update:install',
UPDATE_AVAILABLE: 'update:available',
UPDATE_PROGRESS: 'update:progress',
UPDATE_DOWNLOADED: 'update:downloaded',
UPDATE_ERROR: 'update:error',
LOG_HAD_CRASH: 'log:hadCrash',
LOG_CLEAR_CRASH: 'log:clearCrash',
LOG_SEND_REPORT: 'log:sendReport',
LOG_WRITE_ERROR: 'log:writeError',
EXTENSION_LIST: 'extension:list',
EXTENSION_VALIDATE: 'extension:validate',
MCP_TEST_CONNECTION: 'mcp:testConnection'
} as const
+38
View File
@@ -0,0 +1,38 @@
export type SyncMode = 'webdav' | 'local-folder'
export type SyncSchedule = 'manual' | 'hourly' | 'daily' | 'weekly'
export type SyncStatus = 'ok' | 'error' | 'syncing' | 'conflict' | 'idle'
export interface SyncConfig {
mode: SyncMode
webdavUrl?: string
webdavUser?: string
localFolderPath?: string
schedule: SyncSchedule
lastSyncAt?: string | null
lastSyncStatus?: SyncStatus
syncSalt?: string
passwordConfigured?: boolean
}
export interface SyncProgressEvent {
phase: 'export' | 'encrypt' | 'upload' | 'download' | 'decrypt' | 'import' | 'done'
message?: string
percent?: number
}
export interface SyncRunResult {
status: SyncStatus
syncedAt: string
remoteNewer?: boolean
localNewer?: boolean
}
export interface SyncConfigureInput {
config: SyncConfig
password: string
}
export interface SyncRunInput {
password: string
conflictResolution?: 'local' | 'remote' | 'cancel'
}
+7
View File
@@ -1,3 +1,5 @@
import type { SyncConfig } from './sync'
export type ThemeId =
| 'default'
| 'bamboo'
@@ -295,6 +297,11 @@ export interface GlobalSettings {
enableComplianceCheck?: boolean
complianceWords?: string[]
customSlashCommands?: CustomSlashCommand[]
syncConfig?: SyncConfig | null
syncPasswordVerifier?: string
extensionDevMode?: boolean
mcpConnectorUrl?: string
pendingCrashAck?: boolean
}
export interface CustomSlashCommand {
+11
View File
@@ -0,0 +1,11 @@
export interface UndoOperation {
type: 'doc'
content: string
}
export interface UndoStackEntry {
id: number
chapterId: string
operation: UndoOperation
timestamp: string
}