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:
+3
-1
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user