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:
@@ -45,11 +45,11 @@
|
||||
- Create: `src/main/pack-bootstrap.ts`
|
||||
- Modify: `electron.vite.config.ts`
|
||||
|
||||
- [ ] **Step 1:** `npm install archiver` + dev `@types/archiver`
|
||||
- [ ] **Step 2:** `novel-pack.ts` — `NOVEL_FORMAT_VERSION = 1`,`NovelManifest`、`NovelAllManifest`
|
||||
- [ ] **Step 3:** `pack-bootstrap.ts` — 导出 `bootstrapPackHandlers(registry, settings)`
|
||||
- [ ] **Step 4:** `electron.vite.config.ts` — 增加 `pack-bootstrap` input,`external: ['archiver']`
|
||||
- [ ] **Step 5:** `npm run build` 通过
|
||||
- [x] **Step 1:** `npm install archiver` + dev `@types/archiver`
|
||||
- [x] **Step 2:** `novel-pack.ts` — `NOVEL_FORMAT_VERSION = 1`,`NovelManifest`、`NovelAllManifest`
|
||||
- [x] **Step 3:** `pack-bootstrap.ts` — 导出 `bootstrapPackHandlers(registry, settings)`
|
||||
- [x] **Step 4:** `electron.vite.config.ts` — 增加 `pack-bootstrap` input,`external: ['archiver']`
|
||||
- [x] **Step 5:** `npm run build` 通过
|
||||
|
||||
---
|
||||
|
||||
@@ -59,10 +59,10 @@
|
||||
- Create: `src/main/services/pack.service.ts`
|
||||
- Modify: `src/main/services/book-registry.ts` — `importFromDb(meta, sqlitePath)` 辅助
|
||||
|
||||
- [ ] **Step 1:** `exportBook(bookId, destPath)` — ZIP: manifest + meta + book.sqlite + attachments
|
||||
- [ ] **Step 2:** `importBook(filePath)` — 校验 formatVersion,新 UUID,注册 meta,复制 sqlite
|
||||
- [ ] **Step 3:** 名称冲突时追加后缀 `(2)`
|
||||
- [ ] **Step 4:** UT `tests/main/pack.service.test.ts` — 导出再导入章节数一致
|
||||
- [x] **Step 1:** `exportBook(bookId, destPath)` — ZIP: manifest + meta + book.sqlite + attachments
|
||||
- [x] **Step 2:** `importBook(filePath)` — 校验 formatVersion,新 UUID,注册 meta,复制 sqlite
|
||||
- [x] **Step 3:** 名称冲突时追加后缀 `(2)`
|
||||
- [x] **Step 4:** UT `tests/main/pack.service.test.ts` — 导出再导入章节数一致
|
||||
|
||||
---
|
||||
|
||||
@@ -72,12 +72,12 @@
|
||||
- Modify: `src/main/services/pack.service.ts`
|
||||
- Modify: `src/shared/types.ts`
|
||||
|
||||
- [ ] **Step 1:** `exportAll(destPath)` — global_settings + books/{id}/
|
||||
- [ ] **Step 2:** `importAll(filePath, strategy)` — skip/overwrite/new
|
||||
- [ ] **Step 3:** `onProgress` 回调 + `PACK_PROGRESS` 广播
|
||||
- [ ] **Step 4:** `estimateSize` + >500MB 确认标志
|
||||
- [ ] **Step 5:** AbortController 取消支持
|
||||
- [ ] **Step 6:** UT IT-ALL-02 — 3 书导出删库再导入
|
||||
- [x] **Step 1:** `exportAll(destPath)` — global_settings + books/{id}/
|
||||
- [x] **Step 2:** `importAll(filePath, strategy)` — skip/overwrite/new
|
||||
- [x] **Step 3:** `onProgress` 回调 + `PACK_PROGRESS` 广播
|
||||
- [x] **Step 4:** `estimateSize` + >500MB 确认标志
|
||||
- [x] **Step 5:** AbortController 取消支持
|
||||
- [x] **Step 6:** UT IT-ALL-02 — 3 书导出删库再导入
|
||||
|
||||
---
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
- Modify: `src/main/index.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 3:** `onPackProgress` 事件订阅
|
||||
|
||||
|
||||
@@ -9,9 +9,10 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
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']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+995
-52
File diff suppressed because it is too large
Load Diff
@@ -28,10 +28,12 @@
|
||||
"@tiptap/extension-underline": "^3.27.1",
|
||||
"@tiptap/react": "^3.27.1",
|
||||
"@tiptap/starter-kit": "^3.27.1",
|
||||
"archiver": "^8.0.0",
|
||||
"cytoscape": "^3.34.0",
|
||||
"cytoscape-cose-bilkent": "^4.1.0",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
"electron": "^36.9.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"i18next": "^24.2.3",
|
||||
"jotai": "^2.12.1",
|
||||
"mammoth": "^1.12.0",
|
||||
@@ -44,8 +46,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.51.0",
|
||||
"@types/archiver": "^8.0.0",
|
||||
"@types/cytoscape": "^3.21.9",
|
||||
"@types/diff-match-patch": "^1.0.36",
|
||||
"@types/extract-zip": "^2.0.0",
|
||||
"@types/marked": "^6.0.0",
|
||||
"@types/node": "^22.13.0",
|
||||
"@types/react": "^18.3.18",
|
||||
|
||||
+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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user