feat(w2): add PackService for .novel and .novel-all export/import

Introduce pack-bootstrap chunk, ZipArchive-based PackService with progress IPC,
book registry import helpers, and IT-PACK-01 / IT-ALL-02 integration tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-08 15:36:47 +08:00
parent 9f2b1ae3e0
commit a4412793f4
12 changed files with 1563 additions and 73 deletions
+3 -1
View File
@@ -51,9 +51,11 @@ function createWindow(): void { mainWindow = new BrowserWindow({
}
app.whenReady().then(async () => {
const { books } = registerIpc()
const { books, settings } = registerIpc()
const { bootstrapImportHandlers } = await import('./import-bootstrap.js')
bootstrapImportHandlers(books)
const { bootstrapPackHandlers } = await import('./pack-bootstrap.js')
bootstrapPackHandlers(books, settings)
ipcMain.handle(IPC.WINDOW_MINIMIZE, (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize()
+81
View File
@@ -0,0 +1,81 @@
import { dialog, ipcMain } from 'electron'
import { IPC } from '../../../shared/ipc-channels'
import type { PackImportStrategy } from '../../../shared/novel-pack'
import { PackService } from '../../services/pack.service'
import type { BookRegistryService } from '../../services/book-registry'
import type { GlobalSettingsService } from '../../services/global-settings'
import { wrap } from '../result'
let packService: PackService | null = null
function svc(registry: BookRegistryService, settings: GlobalSettingsService): PackService {
if (!packService) {
packService = new PackService(registry, settings, registry.getUserDataDir())
}
return packService
}
export function registerPackHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
): void {
const service = () => svc(registry, settings)
ipcMain.handle(
IPC.PACK_EXPORT_BOOK,
(_e, { bookId, destPath }: { bookId: string; destPath: string }) =>
wrap(() => service().exportBook(bookId, destPath))
)
ipcMain.handle(IPC.PACK_IMPORT_BOOK, (_e, { filePath }: { filePath: string }) =>
wrap(() => service().importBook(filePath))
)
ipcMain.handle(IPC.PACK_EXPORT_ALL, (_e, { destPath }: { destPath: string }) =>
wrap(() => service().exportAll(destPath))
)
ipcMain.handle(
IPC.PACK_IMPORT_ALL,
(_e, { filePath, strategy }: { filePath: string; strategy: PackImportStrategy }) =>
wrap(() => service().importAll(filePath, strategy))
)
ipcMain.handle(IPC.PACK_ESTIMATE_EXPORT_ALL, () =>
wrap(() => ({
bytes: service().estimateExportAllBytes(),
needsConfirm: service().needsExportAllConfirm()
}))
)
ipcMain.handle(IPC.PACK_NEEDS_CONFIRM, () =>
wrap(() => service().needsExportAllConfirm())
)
ipcMain.handle(IPC.PACK_CANCEL, () =>
wrap(() => {
service().cancel()
})
)
ipcMain.handle(IPC.PACK_PICK_FILE, (_e, { mode }: { mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' }) =>
wrap(async () => {
if (mode === 'save-novel' || mode === 'save-all') {
const ext = mode === 'save-all' ? 'novel-all' : 'novel'
const { canceled, filePath } = await dialog.showSaveDialog({
filters: [{ name: 'Bilin Pack', extensions: [ext] }],
defaultPath: mode === 'save-all' ? 'bilin-backup.novel-all' : 'book.novel'
})
if (canceled || !filePath) return null
return filePath
}
const extensions = mode === 'novel-all' ? ['novel-all'] : ['novel', 'novel-all']
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [{ name: 'Bilin Pack', extensions }]
})
if (canceled || filePaths.length === 0) return null
return filePaths[0]
})
)
}
+10
View File
@@ -0,0 +1,10 @@
import { registerPackHandlers } from './ipc/handlers/pack.handler'
import type { BookRegistryService } from './services/book-registry'
import type { GlobalSettingsService } from './services/global-settings'
export function bootstrapPackHandlers(
registry: BookRegistryService,
settings: GlobalSettingsService
): void {
registerPackHandlers(registry, settings)
}
+43 -1
View File
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'fs'
import { join } from 'path'
import { randomUUID } from 'crypto'
import { openBookDb } from '../db/connection'
@@ -50,6 +50,10 @@ export class BookRegistryService {
return this.readRegistry().books.find((b) => b.id === bookId) ?? null
}
getUserDataDir(): string {
return this.userDataDir
}
private dbPath(bookId: string): string {
return join(this.booksDir, `${bookId}.sqlite`)
}
@@ -91,6 +95,44 @@ export class BookRegistryService {
this.writeRegistry(registry)
}
private ensureUniqueName(name: string): string {
const names = new Set(this.list().map((b) => b.name))
if (!names.has(name)) return name
let i = 2
while (names.has(`${name} (${i})`)) i += 1
return `${name} (${i})`
}
importFromPack(meta: BookMeta, sqliteSourcePath: string, coverPath: string | null): BookMeta {
const id = randomUUID()
const now = new Date().toISOString()
const imported: BookMeta = {
...meta,
id,
name: this.ensureUniqueName(meta.name),
dbPath: join('books', `${id}.sqlite`),
coverPath: coverPath ?? meta.coverPath ?? null,
lastOpenedAt: now,
createdAt: meta.createdAt ?? now
}
copyFileSync(sqliteSourcePath, this.dbPath(id))
const registry = this.readRegistry()
registry.books.unshift(imported)
this.writeRegistry(registry)
return imported
}
replaceFromPack(bookId: string, meta: BookMeta, sqliteSourcePath: string): BookMeta {
copyFileSync(sqliteSourcePath, this.dbPath(bookId))
return this.updateMeta(bookId, {
name: meta.name,
category: meta.category,
targetWordCount: meta.targetWordCount,
synopsis: meta.synopsis,
status: meta.status
})
}
updateMeta(bookId: string, patch: UpdateBookMetaParams): BookMeta {
const registry = this.readRegistry()
const idx = registry.books.findIndex((b) => b.id === bookId)
+291
View File
@@ -0,0 +1,291 @@
import { app, BrowserWindow } from 'electron'
import {
copyFileSync,
createWriteStream,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync
} from 'fs'
import { IPC } from '../../shared/ipc-channels'
import { basename, join } from 'path'
import { tmpdir } from 'os'
import { randomUUID } from 'crypto'
import extract from 'extract-zip'
import { ZipArchive } from 'archiver'
import type { BookMeta } from '../../shared/types'
import {
NOVEL_ALL_FORMAT_VERSION,
NOVEL_FORMAT_VERSION,
type NovelAllManifest,
type NovelManifest,
type PackImportReport,
type PackImportStrategy,
type PackProgressEvent
} from '../../shared/novel-pack'
import type { BookRegistryService } from './book-registry'
import type { GlobalSettingsService } from './global-settings'
import { closeAllBookDbs } from '../db/connection'
const SIZE_CONFIRM_BYTES = 500 * 1024 * 1024
type ProgressFn = (event: PackProgressEvent) => void
function appVersion(): string {
try {
return app.getVersion()
} catch {
return '1.3.0'
}
}
function broadcastProgress(event: PackProgressEvent): void {
try {
const windows = BrowserWindow?.getAllWindows?.()
if (!windows) return
for (const win of windows) {
win.webContents.send(IPC.PACK_PROGRESS, event)
}
} catch {
// non-Electron test environment
}
}
export class PackService {
private abort = false
constructor(
private registry: BookRegistryService,
private settings: GlobalSettingsService,
private userDataDir: string
) {}
cancel(): void {
this.abort = true
}
resetAbort(): void {
this.abort = false
}
estimateExportAllBytes(): number {
let total = 0
for (const book of this.registry.list()) {
const dbPath = join(this.userDataDir, book.dbPath)
if (existsSync(dbPath)) total += statSync(dbPath).size
if (book.coverPath && existsSync(book.coverPath)) {
total += statSync(book.coverPath).size
}
}
const settingsPath = join(this.userDataDir, 'global_settings.json')
if (existsSync(settingsPath)) total += statSync(settingsPath).size
return total
}
needsExportAllConfirm(): boolean {
return this.estimateExportAllBytes() > SIZE_CONFIRM_BYTES
}
private resolveCoverFromAttachments(tmpDir: string): string | null {
const attachmentsDir = join(tmpDir, 'attachments')
if (!existsSync(attachmentsDir)) return null
const coverFile = readdirSync(attachmentsDir)[0]
if (!coverFile) return null
const coversDir = join(this.userDataDir, 'covers')
mkdirSync(coversDir, { recursive: true })
const ext = basename(coverFile).includes('.') ? basename(coverFile).slice(basename(coverFile).lastIndexOf('.')) : ''
const newCover = join(coversDir, `${randomUUID()}${ext}`)
copyFileSync(join(attachmentsDir, coverFile), newCover)
return newCover
}
async exportBook(bookId: string, destPath: string): Promise<void> {
const meta = this.registry.getMeta(bookId)
if (!meta) throw new Error('Book not found')
const output = createWriteStream(destPath)
const archive = new ZipArchive({ zlib: { level: 9 } })
archive.pipe(output)
const manifest: NovelManifest = {
formatVersion: NOVEL_FORMAT_VERSION,
appVersion: appVersion(),
exportedAt: new Date().toISOString(),
bookId: meta.id,
bookName: meta.name
}
archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' })
archive.append(JSON.stringify(meta, null, 2), { name: 'meta.json' })
const dbPath = join(this.userDataDir, meta.dbPath)
if (!existsSync(dbPath)) throw new Error('Book database missing')
archive.file(dbPath, { name: 'book.sqlite' })
if (meta.coverPath && existsSync(meta.coverPath)) {
archive.file(meta.coverPath, { name: `attachments/${basename(meta.coverPath)}` })
}
const done = new Promise<void>((resolve, reject) => {
output.on('close', () => resolve())
output.on('error', reject)
archive.on('error', reject)
})
void archive.finalize()
await done
}
async importBook(filePath: string): Promise<string> {
const tmpDir = mkdtempSync(join(tmpdir(), 'bilin-novel-'))
try {
await extract(filePath, { dir: tmpDir })
const manifestPath = join(tmpDir, 'manifest.json')
const metaPath = join(tmpDir, 'meta.json')
const sqlitePath = join(tmpDir, 'book.sqlite')
if (!existsSync(manifestPath) || !existsSync(metaPath) || !existsSync(sqlitePath)) {
throw new Error('Invalid .novel package')
}
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as NovelManifest
if (manifest.formatVersion !== NOVEL_FORMAT_VERSION) {
throw new Error('Unsupported .novel format version')
}
const meta = JSON.parse(readFileSync(metaPath, 'utf8')) as BookMeta
closeAllBookDbs()
const imported = this.registry.importFromPack(meta, sqlitePath, this.resolveCoverFromAttachments(tmpDir))
return imported.id
} finally {
rmSync(tmpDir, { recursive: true, force: true })
}
}
async exportAll(destPath: string, onProgress?: ProgressFn): Promise<void> {
this.resetAbort()
const books = this.registry.list()
const total = books.length + 1
let current = 0
const report = (phase: PackProgressEvent['phase'], message?: string): void => {
const event = { current, total, phase, message }
onProgress?.(event)
broadcastProgress(event)
}
const output = createWriteStream(destPath)
const archive = new ZipArchive({ zlib: { level: 9 } })
archive.pipe(output)
const manifest: NovelAllManifest = {
formatVersion: NOVEL_ALL_FORMAT_VERSION,
appVersion: appVersion(),
exportedAt: new Date().toISOString(),
bookCount: books.length
}
archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' })
const settingsPath = join(this.userDataDir, 'global_settings.json')
if (existsSync(settingsPath)) {
archive.file(settingsPath, { name: 'global_settings.json' })
} else {
archive.append(JSON.stringify(this.settings.get(), null, 2), { name: 'global_settings.json' })
}
for (const book of books) {
if (this.abort) throw new Error('Export cancelled')
current += 1
report('export', book.name)
archive.append(JSON.stringify(book, null, 2), { name: `books/${book.id}/meta.json` })
const dbPath = join(this.userDataDir, book.dbPath)
if (existsSync(dbPath)) {
archive.file(dbPath, { name: `books/${book.id}/book.sqlite` })
}
}
current = total
report('export', 'finalize')
const done = new Promise<void>((resolve, reject) => {
output.on('close', () => resolve())
output.on('error', reject)
archive.on('error', reject)
})
void archive.finalize()
await done
}
async importAll(
filePath: string,
strategy: PackImportStrategy,
onProgress?: ProgressFn
): Promise<PackImportReport> {
this.resetAbort()
const tmpDir = mkdtempSync(join(tmpdir(), 'bilin-novel-all-'))
const report: PackImportReport = { imported: 0, skipped: 0, overwritten: 0, books: [] }
try {
await extract(filePath, { dir: tmpDir })
const manifest = JSON.parse(
readFileSync(join(tmpDir, 'manifest.json'), 'utf8')
) as NovelAllManifest
if (manifest.formatVersion !== NOVEL_ALL_FORMAT_VERSION) {
throw new Error('Unsupported .novel-all format version')
}
const settingsFile = join(tmpDir, 'global_settings.json')
if (existsSync(settingsFile)) {
this.settings.update(JSON.parse(readFileSync(settingsFile, 'utf8')))
}
const booksDir = join(tmpDir, 'books')
if (!existsSync(booksDir)) return report
const bookIds = readdirSync(booksDir, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name)
const total = bookIds.length
closeAllBookDbs()
for (let i = 0; i < bookIds.length; i++) {
if (this.abort) throw new Error('Import cancelled')
const sourceId = bookIds[i]
const bookDir = join(booksDir, sourceId)
const meta = JSON.parse(readFileSync(join(bookDir, 'meta.json'), 'utf8')) as BookMeta
const sqlitePath = join(bookDir, 'book.sqlite')
onProgress?.({ current: i + 1, total, phase: 'import', message: meta.name })
broadcastProgress({ current: i + 1, total, phase: 'import', message: meta.name })
const existing = this.registry.getMeta(sourceId)
if (existing && strategy === 'skip') {
report.skipped += 1
report.books.push({
sourceBookId: sourceId,
targetBookId: null,
bookName: meta.name,
action: 'skipped'
})
continue
}
if (existing && strategy === 'overwrite') {
this.registry.replaceFromPack(existing.id, meta, sqlitePath)
report.overwritten += 1
report.books.push({
sourceBookId: sourceId,
targetBookId: existing.id,
bookName: meta.name,
action: 'overwritten'
})
continue
}
const imported = this.registry.importFromPack(meta, sqlitePath, null)
report.imported += 1
report.books.push({
sourceBookId: sourceId,
targetBookId: imported.id,
bookName: imported.name,
action: 'imported'
})
}
return report
} finally {
rmSync(tmpDir, { recursive: true, force: true })
}
}
}
+10 -1
View File
@@ -150,5 +150,14 @@ export const IPC = {
CHAPTER_TAG_REMOVE: 'chapter:tagRemove',
BOOK_PREFS_GET: 'book:prefsGet',
BOOK_PREFS_SET: 'book:prefsSet',
AI_SESSION_EXPORT: 'ai:sessionExport'
AI_SESSION_EXPORT: 'ai:sessionExport',
PACK_EXPORT_BOOK: 'pack:exportBook',
PACK_IMPORT_BOOK: 'pack:importBook',
PACK_EXPORT_ALL: 'pack:exportAll',
PACK_IMPORT_ALL: 'pack:importAll',
PACK_PICK_FILE: 'pack:pickFile',
PACK_ESTIMATE_EXPORT_ALL: 'pack:estimateExportAll',
PACK_CANCEL: 'pack:cancel',
PACK_NEEDS_CONFIRM: 'pack:needsConfirm',
PACK_PROGRESS: 'pack:progress'
} as const
+40
View File
@@ -0,0 +1,40 @@
export const NOVEL_FORMAT_VERSION = 1
export const NOVEL_ALL_FORMAT_VERSION = 1
export interface NovelManifest {
formatVersion: number
appVersion: string
exportedAt: string
bookId: string
bookName: string
}
export interface NovelAllManifest {
formatVersion: number
appVersion: string
exportedAt: string
bookCount: number
}
export type PackImportStrategy = 'skip' | 'overwrite' | 'new'
export interface PackImportBookResult {
sourceBookId: string
targetBookId: string | null
bookName: string
action: 'imported' | 'skipped' | 'overwritten'
}
export interface PackImportReport {
imported: number
skipped: number
overwritten: number
books: PackImportBookResult[]
}
export interface PackProgressEvent {
current: number
total: number
phase: 'export' | 'import' | 'estimate'
message?: string
}