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
@@ -45,11 +45,11 @@
- Create: `src/main/pack-bootstrap.ts` - Create: `src/main/pack-bootstrap.ts`
- Modify: `electron.vite.config.ts` - Modify: `electron.vite.config.ts`
- [ ] **Step 1:** `npm install archiver` + dev `@types/archiver` - [x] **Step 1:** `npm install archiver` + dev `@types/archiver`
- [ ] **Step 2:** `novel-pack.ts``NOVEL_FORMAT_VERSION = 1``NovelManifest``NovelAllManifest` - [x] **Step 2:** `novel-pack.ts``NOVEL_FORMAT_VERSION = 1``NovelManifest``NovelAllManifest`
- [ ] **Step 3:** `pack-bootstrap.ts` — 导出 `bootstrapPackHandlers(registry, settings)` - [x] **Step 3:** `pack-bootstrap.ts` — 导出 `bootstrapPackHandlers(registry, settings)`
- [ ] **Step 4:** `electron.vite.config.ts` — 增加 `pack-bootstrap` input`external: ['archiver']` - [x] **Step 4:** `electron.vite.config.ts` — 增加 `pack-bootstrap` input`external: ['archiver']`
- [ ] **Step 5:** `npm run build` 通过 - [x] **Step 5:** `npm run build` 通过
--- ---
@@ -59,10 +59,10 @@
- Create: `src/main/services/pack.service.ts` - Create: `src/main/services/pack.service.ts`
- Modify: `src/main/services/book-registry.ts``importFromDb(meta, sqlitePath)` 辅助 - Modify: `src/main/services/book-registry.ts``importFromDb(meta, sqlitePath)` 辅助
- [ ] **Step 1:** `exportBook(bookId, destPath)` — ZIP: manifest + meta + book.sqlite + attachments - [x] **Step 1:** `exportBook(bookId, destPath)` — ZIP: manifest + meta + book.sqlite + attachments
- [ ] **Step 2:** `importBook(filePath)` — 校验 formatVersion,新 UUID,注册 meta,复制 sqlite - [x] **Step 2:** `importBook(filePath)` — 校验 formatVersion,新 UUID,注册 meta,复制 sqlite
- [ ] **Step 3:** 名称冲突时追加后缀 `(2)` - [x] **Step 3:** 名称冲突时追加后缀 `(2)`
- [ ] **Step 4:** UT `tests/main/pack.service.test.ts` — 导出再导入章节数一致 - [x] **Step 4:** UT `tests/main/pack.service.test.ts` — 导出再导入章节数一致
--- ---
@@ -72,12 +72,12 @@
- Modify: `src/main/services/pack.service.ts` - Modify: `src/main/services/pack.service.ts`
- Modify: `src/shared/types.ts` - Modify: `src/shared/types.ts`
- [ ] **Step 1:** `exportAll(destPath)` — global_settings + books/{id}/ - [x] **Step 1:** `exportAll(destPath)` — global_settings + books/{id}/
- [ ] **Step 2:** `importAll(filePath, strategy)` — skip/overwrite/new - [x] **Step 2:** `importAll(filePath, strategy)` — skip/overwrite/new
- [ ] **Step 3:** `onProgress` 回调 + `PACK_PROGRESS` 广播 - [x] **Step 3:** `onProgress` 回调 + `PACK_PROGRESS` 广播
- [ ] **Step 4:** `estimateSize` + >500MB 确认标志 - [x] **Step 4:** `estimateSize` + >500MB 确认标志
- [ ] **Step 5:** AbortController 取消支持 - [x] **Step 5:** AbortController 取消支持
- [ ] **Step 6:** UT IT-ALL-02 — 3 书导出删库再导入 - [x] **Step 6:** UT IT-ALL-02 — 3 书导出删库再导入
--- ---
@@ -89,7 +89,7 @@
- Modify: `src/main/index.ts` - Modify: `src/main/index.ts`
- Modify: `src/preload/index.ts``electron-api.d.ts` - Modify: `src/preload/index.ts``electron-api.d.ts`
- [ ] **Step 1:** IPC 常量与 handler 注册 - [x] **Step 1:** IPC 常量与 handler 注册
- [ ] **Step 2:** preload `pack.exportBook/importBook/exportAll/importAll/pickFile` - [ ] **Step 2:** preload `pack.exportBook/importBook/exportAll/importAll/pickFile`
- [ ] **Step 3:** `onPackProgress` 事件订阅 - [ ] **Step 3:** `onPackProgress` 事件订阅
+3 -2
View File
@@ -9,9 +9,10 @@ export default defineConfig({
rollupOptions: { rollupOptions: {
input: { input: {
index: resolve(__dirname, 'src/main/index.ts'), index: resolve(__dirname, 'src/main/index.ts'),
'import-bootstrap': resolve(__dirname, 'src/main/import-bootstrap.ts') 'import-bootstrap': resolve(__dirname, 'src/main/import-bootstrap.ts'),
'pack-bootstrap': resolve(__dirname, 'src/main/pack-bootstrap.ts')
}, },
external: ['mammoth', 'marked'] external: ['mammoth', 'marked', 'archiver', 'extract-zip']
} }
} }
}, },
+995 -52
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -28,10 +28,12 @@
"@tiptap/extension-underline": "^3.27.1", "@tiptap/extension-underline": "^3.27.1",
"@tiptap/react": "^3.27.1", "@tiptap/react": "^3.27.1",
"@tiptap/starter-kit": "^3.27.1", "@tiptap/starter-kit": "^3.27.1",
"archiver": "^8.0.0",
"cytoscape": "^3.34.0", "cytoscape": "^3.34.0",
"cytoscape-cose-bilkent": "^4.1.0", "cytoscape-cose-bilkent": "^4.1.0",
"diff-match-patch": "^1.0.5", "diff-match-patch": "^1.0.5",
"electron": "^36.9.0", "electron": "^36.9.0",
"extract-zip": "^2.0.1",
"i18next": "^24.2.3", "i18next": "^24.2.3",
"jotai": "^2.12.1", "jotai": "^2.12.1",
"mammoth": "^1.12.0", "mammoth": "^1.12.0",
@@ -44,8 +46,10 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.51.0", "@playwright/test": "^1.51.0",
"@types/archiver": "^8.0.0",
"@types/cytoscape": "^3.21.9", "@types/cytoscape": "^3.21.9",
"@types/diff-match-patch": "^1.0.36", "@types/diff-match-patch": "^1.0.36",
"@types/extract-zip": "^2.0.0",
"@types/marked": "^6.0.0", "@types/marked": "^6.0.0",
"@types/node": "^22.13.0", "@types/node": "^22.13.0",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
+3 -1
View File
@@ -51,9 +51,11 @@ function createWindow(): void { mainWindow = new BrowserWindow({
} }
app.whenReady().then(async () => { app.whenReady().then(async () => {
const { books } = registerIpc() const { books, settings } = registerIpc()
const { bootstrapImportHandlers } = await import('./import-bootstrap.js') const { bootstrapImportHandlers } = await import('./import-bootstrap.js')
bootstrapImportHandlers(books) bootstrapImportHandlers(books)
const { bootstrapPackHandlers } = await import('./pack-bootstrap.js')
bootstrapPackHandlers(books, settings)
ipcMain.handle(IPC.WINDOW_MINIMIZE, (event) => { ipcMain.handle(IPC.WINDOW_MINIMIZE, (event) => {
BrowserWindow.fromWebContents(event.sender)?.minimize() 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 { join } from 'path'
import { randomUUID } from 'crypto' import { randomUUID } from 'crypto'
import { openBookDb } from '../db/connection' import { openBookDb } from '../db/connection'
@@ -50,6 +50,10 @@ export class BookRegistryService {
return this.readRegistry().books.find((b) => b.id === bookId) ?? null return this.readRegistry().books.find((b) => b.id === bookId) ?? null
} }
getUserDataDir(): string {
return this.userDataDir
}
private dbPath(bookId: string): string { private dbPath(bookId: string): string {
return join(this.booksDir, `${bookId}.sqlite`) return join(this.booksDir, `${bookId}.sqlite`)
} }
@@ -91,6 +95,44 @@ export class BookRegistryService {
this.writeRegistry(registry) 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 { updateMeta(bookId: string, patch: UpdateBookMetaParams): BookMeta {
const registry = this.readRegistry() const registry = this.readRegistry()
const idx = registry.books.findIndex((b) => b.id === bookId) 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', CHAPTER_TAG_REMOVE: 'chapter:tagRemove',
BOOK_PREFS_GET: 'book:prefsGet', BOOK_PREFS_GET: 'book:prefsGet',
BOOK_PREFS_SET: 'book:prefsSet', 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 } 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
}
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, rmSync, existsSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
import { BookRegistryService } from '../../src/main/services/book-registry'
import { GlobalSettingsService } from '../../src/main/services/global-settings'
import { PackService } from '../../src/main/services/pack.service'
import { closeAllBookDbs } from '../../src/main/db/connection'
describe('PackService', () => {
let userData: string
let registry: BookRegistryService
let settings: GlobalSettingsService
let pack: PackService
beforeEach(() => {
userData = mkdtempSync(join(tmpdir(), 'bilin-pack-'))
registry = new BookRegistryService(userData)
settings = new GlobalSettingsService(userData)
pack = new PackService(registry, settings, userData)
})
afterEach(() => {
closeAllBookDbs()
rmSync(userData, { recursive: true, force: true })
})
it('IT-PACK-01: export and import single .novel preserves chapters', async () => {
const book = registry.create({ name: '打包测试', category: '玄幻', createSampleChapter: true })
registry.open(book.id)
const chapterCount = registry.getChapterRepo(book.id).list().length
expect(chapterCount).toBeGreaterThan(0)
const novelPath = join(userData, 'export.novel')
await pack.exportBook(book.id, novelPath)
expect(existsSync(novelPath)).toBe(true)
registry.delete(book.id)
const importedId = await pack.importBook(novelPath)
const imported = registry.open(importedId)
expect(imported.chapters.length).toBe(chapterCount)
expect(imported.meta.name).toContain('打包测试')
})
it('IT-ALL-02: export all three books and restore after wipe', async () => {
const ids: string[] = []
for (let i = 0; i < 3; i++) {
const b = registry.create({
name: `${i + 1}`,
category: '玄幻',
createSampleChapter: true
})
ids.push(b.id)
}
const archivePath = join(userData, 'all.novel-all')
await pack.exportAll(archivePath)
expect(existsSync(archivePath)).toBe(true)
for (const id of ids) registry.delete(id)
expect(registry.list()).toHaveLength(0)
const report = await pack.importAll(archivePath, 'new')
expect(report.imported).toBe(3)
expect(registry.list().length).toBe(3)
})
})