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`
|
- 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` 事件订阅
|
||||||
|
|
||||||
|
|||||||
@@ -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']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+995
-52
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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 { 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)
|
||||||
|
|||||||
@@ -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',
|
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
|
||||||
|
|||||||
@@ -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