feat(w2): ship v1.3.0 project pack UI, preload, and E2E
Wire .novel/.novel-all export-import through modals, settings backup, and home bulk export with progress IPC. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,13 @@
|
|||||||
# 笔临 (Bilin)
|
# 笔临 (Bilin)
|
||||||
|
|
||||||
长篇创作智能协作平台 — Electron 桌面客户端 v1.2.0(基础治理)
|
长篇创作智能协作平台 — Electron 桌面客户端 v1.3.0(项目包)
|
||||||
|
|
||||||
|
## 功能概览(v1.3.0)
|
||||||
|
|
||||||
|
- `.novel` 单书项目包导出/导入(含数据库、元数据、附件)
|
||||||
|
- `.novel-all` 批量导出/导入全部书籍与全局设置
|
||||||
|
- 主页「导出所有书籍」、设置页备份区、导出模态项目包选项
|
||||||
|
- 大文件导出确认与进度推送
|
||||||
|
|
||||||
## 功能概览(v1.2.0)
|
## 功能概览(v1.2.0)
|
||||||
|
|
||||||
|
|||||||
@@ -90,8 +90,8 @@
|
|||||||
- Modify: `src/preload/index.ts`、`electron-api.d.ts`
|
- Modify: `src/preload/index.ts`、`electron-api.d.ts`
|
||||||
|
|
||||||
- [x] **Step 1:** IPC 常量与 handler 注册
|
- [x] **Step 1:** IPC 常量与 handler 注册
|
||||||
- [ ] **Step 2:** preload `pack.exportBook/importBook/exportAll/importAll/pickFile`
|
- [x] **Step 2:** preload `pack.exportBook/importBook/exportAll/importAll/pickFile`
|
||||||
- [ ] **Step 3:** `onPackProgress` 事件订阅
|
- [x] **Step 3:** `onPackProgress` 事件订阅
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -102,9 +102,9 @@
|
|||||||
- Modify: `src/renderer/components/import/ImportBookModal.tsx`
|
- Modify: `src/renderer/components/import/ImportBookModal.tsx`
|
||||||
- Modify: `public/locales/*/translation.json`
|
- Modify: `public/locales/*/translation.json`
|
||||||
|
|
||||||
- [ ] **Step 1:** ExportModal 增加「笔临项目包 (.novel)」
|
- [x] **Step 1:** ExportModal 增加「笔临项目包 (.novel)」
|
||||||
- [ ] **Step 2:** ImportBookModal 文件过滤含 `.novel`/`.novel-all`
|
- [x] **Step 2:** ImportBookModal 文件过滤含 `.novel`/`.novel-all`
|
||||||
- [ ] **Step 3:** `.novel` 直接导入;`.novel-all` 打开策略选择 + 进度条
|
- [x] **Step 3:** `.novel` 直接导入;`.novel-all` 打开策略选择 + 进度条
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -115,8 +115,8 @@
|
|||||||
- Modify: `src/renderer/components/home/HomePage.tsx`
|
- Modify: `src/renderer/components/home/HomePage.tsx`
|
||||||
- Modify: `src/renderer/stores/useAppStore.ts`(可选 modal 状态)
|
- Modify: `src/renderer/stores/useAppStore.ts`(可选 modal 状态)
|
||||||
|
|
||||||
- [ ] **Step 1:** `ExportAllModal` — 选路径、大小确认、进度、取消
|
- [x] **Step 1:** `ExportAllModal` — 选路径、大小确认、进度、取消
|
||||||
- [ ] **Step 2:** HomePage「导出所有书籍」启用并打开 modal
|
- [x] **Step 2:** HomePage「导出所有书籍」启用并打开 modal
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -126,8 +126,8 @@
|
|||||||
- Create: `src/renderer/components/settings/BackupSettingsTab.tsx`
|
- Create: `src/renderer/components/settings/BackupSettingsTab.tsx`
|
||||||
- Modify: `src/renderer/components/settings/SettingsPage.tsx`
|
- Modify: `src/renderer/components/settings/SettingsPage.tsx`
|
||||||
|
|
||||||
- [ ] **Step 1:** 导出当前书 / 导出全部按钮
|
- [x] **Step 1:** 导出当前书 / 导出全部按钮
|
||||||
- [ ] **Step 2:** i18n `backup.*`
|
- [x] **Step 2:** i18n `backup.*`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -139,11 +139,11 @@
|
|||||||
- Modify: `e2e/helpers.ts`
|
- Modify: `e2e/helpers.ts`
|
||||||
- Modify: `package.json`、`README.md`、`SettingsPage.tsx`
|
- Modify: `package.json`、`README.md`、`SettingsPage.tsx`
|
||||||
|
|
||||||
- [ ] **Step 1:** E2E 单书 .novel 导出再导入
|
- [x] **Step 1:** E2E 单书 .novel 导出再导入
|
||||||
- [ ] **Step 2:** E2E-ALL-03 主页导出全部
|
- [x] **Step 2:** E2E-ALL-03 主页导出全部
|
||||||
- [ ] **Step 3:** 全量 `npm run test` + 相关 E2E
|
- [x] **Step 3:** 全量 `npm run test` + 相关 E2E
|
||||||
- [ ] **Step 4:** 版本 **v1.3.0**
|
- [x] **Step 4:** 版本 **v1.3.0**
|
||||||
- [ ] **Step 5:** 勾选本 plan 全部 checkbox
|
- [x] **Step 5:** 勾选本 plan 全部 checkbox
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,16 @@ export async function openExportModal(page: Page): Promise<void> {
|
|||||||
await expect(modal).toBeVisible({ timeout: 10_000 })
|
await expect(modal).toBeVisible({ timeout: 10_000 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function openExportAllModal(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId('home-export-all').click()
|
||||||
|
await expect(page.getByTestId('export-all-modal')).toBeVisible({ timeout: 10_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openImportBookModal(page: Page): Promise<void> {
|
||||||
|
await page.getByTestId('home-import-book').click()
|
||||||
|
await expect(page.getByTestId('import-book-modal')).toBeVisible({ timeout: 10_000 })
|
||||||
|
}
|
||||||
|
|
||||||
export async function openBookSettings(page: Page): Promise<void> {
|
export async function openBookSettings(page: Page): Promise<void> {
|
||||||
await page.getByTestId('panel-tab-book-settings').click()
|
await page.getByTestId('panel-tab-book-settings').click()
|
||||||
await expect(page.getByTestId('book-settings-tab')).toBeVisible({ timeout: 10_000 })
|
await expect(page.getByTestId('book-settings-tab')).toBeVisible({ timeout: 10_000 })
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { mkdtempSync, rmSync, existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import { launchApp, skipOnboarding, dismissCockpitIfOpen } from './helpers'
|
||||||
|
|
||||||
|
test.describe('Pack export all', () => {
|
||||||
|
let userDataDir: string
|
||||||
|
let packAllPath: string
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-pack-all-'))
|
||||||
|
packAllPath = join(userDataDir, 'backup.novel-all')
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterEach(() => {
|
||||||
|
if (userDataDir && existsSync(userDataDir)) {
|
||||||
|
try {
|
||||||
|
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('E2E-ALL-03: home export all books', async () => {
|
||||||
|
const app = await launchApp(userDataDir, { BILIN_E2E_PACK_SAVE: packAllPath })
|
||||||
|
const page = await app.firstWindow()
|
||||||
|
await skipOnboarding(page)
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /新建书籍/ }).first().click()
|
||||||
|
await page.locator('.dialog-content input').first().fill('批量导出书A')
|
||||||
|
await page.getByRole('button', { name: '创建' }).click()
|
||||||
|
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
|
||||||
|
await dismissCockpitIfOpen(page)
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /笔临|Bilin/i }).first().click()
|
||||||
|
await expect(page.locator('#home-page')).toBeVisible({ timeout: 10_000 })
|
||||||
|
|
||||||
|
await page.getByTestId('home-export-all').click()
|
||||||
|
await expect(page.getByTestId('export-all-modal')).toBeVisible()
|
||||||
|
await page.getByTestId('export-all-pick').click()
|
||||||
|
await expect(page.getByTestId('export-all-dest')).not.toHaveValue(/未选择|No destination/)
|
||||||
|
await page.getByTestId('export-all-start').click()
|
||||||
|
await expect(page.getByText(/已导出|exported/i)).toBeVisible({ timeout: 30_000 })
|
||||||
|
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { mkdtempSync, rmSync, existsSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
import { tmpdir } from 'os'
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import {
|
||||||
|
launchApp,
|
||||||
|
skipOnboarding,
|
||||||
|
createBookAndOpenEditor,
|
||||||
|
ensureChapterEditor,
|
||||||
|
openExportModal,
|
||||||
|
dismissCockpitIfOpen
|
||||||
|
} from './helpers'
|
||||||
|
|
||||||
|
test.describe('Pack export/import', () => {
|
||||||
|
let userDataDir: string
|
||||||
|
let packPath: string
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-pack-'))
|
||||||
|
packPath = join(userDataDir, 'roundtrip.novel')
|
||||||
|
})
|
||||||
|
|
||||||
|
test.afterEach(() => {
|
||||||
|
if (userDataDir && existsSync(userDataDir)) {
|
||||||
|
try {
|
||||||
|
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('E2E-PACK-01: export .novel and re-import via UI', async () => {
|
||||||
|
const app = await launchApp(userDataDir, {
|
||||||
|
BILIN_E2E_PACK_SAVE: packPath,
|
||||||
|
BILIN_E2E_PACK_IMPORT_FILE: packPath
|
||||||
|
})
|
||||||
|
const page = await app.firstWindow()
|
||||||
|
await skipOnboarding(page)
|
||||||
|
await createBookAndOpenEditor(page, 'Pack Roundtrip')
|
||||||
|
await ensureChapterEditor(page)
|
||||||
|
|
||||||
|
const editor = page.locator('.ProseMirror')
|
||||||
|
await editor.click()
|
||||||
|
await editor.pressSequentially('项目包导出导入测试正文。')
|
||||||
|
await page.keyboard.press('Control+S')
|
||||||
|
await expect(page.getByText('已保存')).toBeVisible({ timeout: 10_000 })
|
||||||
|
|
||||||
|
await openExportModal(page)
|
||||||
|
await page.getByTestId('export-kind-select').selectOption('novel')
|
||||||
|
await page.getByTestId('export-novel-btn').click()
|
||||||
|
await expect(page.getByText(/已导出/)).toBeVisible({ timeout: 15_000 })
|
||||||
|
|
||||||
|
await page.evaluate(async (dest) => {
|
||||||
|
const list = await window.electronAPI.book.list()
|
||||||
|
if (!list.ok || !list.data?.length) throw new Error('no books')
|
||||||
|
await window.electronAPI.book.delete(list.data[0].id)
|
||||||
|
}, packPath)
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /笔临|Bilin/i }).first().click()
|
||||||
|
await expect(page.locator('#home-page')).toBeVisible({ timeout: 10_000 })
|
||||||
|
|
||||||
|
await page.getByTestId('home-import-book').click()
|
||||||
|
await expect(page.getByTestId('import-book-modal')).toBeVisible()
|
||||||
|
await page.getByTestId('import-pick-file').click()
|
||||||
|
await expect(page.getByTestId('import-pack-hint')).toBeVisible({ timeout: 10_000 })
|
||||||
|
await page.getByTestId('import-execute-btn').click()
|
||||||
|
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 30_000 })
|
||||||
|
await dismissCockpitIfOpen(page)
|
||||||
|
await expect(page.locator('.chapter-item')).toHaveCount(1, { timeout: 15_000 })
|
||||||
|
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
})
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "bilin",
|
"name": "bilin",
|
||||||
"version": "1.2.0",
|
"version": "1.3.0",
|
||||||
"description": "笔临 - 长篇创作智能协作平台",
|
"description": "笔临 - 长篇创作智能协作平台",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -442,6 +442,43 @@
|
|||||||
"export.preview": "Preview",
|
"export.preview": "Preview",
|
||||||
"export.loading": "Loading…",
|
"export.loading": "Loading…",
|
||||||
"export.noPreview": "(no preview)",
|
"export.noPreview": "(no preview)",
|
||||||
|
"export.kind": "Export type",
|
||||||
|
"export.kindSubmission": "Submission format (current chapter)",
|
||||||
|
"export.kindNovel": "Bilin project pack (.novel)",
|
||||||
|
"export.novelDesc": "Export the full book database, metadata, and attachments. Restore via Import Book.",
|
||||||
|
"export.exportNovel": "Export project pack",
|
||||||
|
"import.novelHint": "Imports the entire book from a .novel pack (chapters, settings, knowledge).",
|
||||||
|
"import.novelAllHint": "Imports all books from a .novel-all pack. Choose a conflict strategy.",
|
||||||
|
"pack.exportAllTitle": "Export all books",
|
||||||
|
"pack.exportAllDesc": "Package all books and global settings into a .novel-all file.",
|
||||||
|
"pack.estimating": "Estimating size…",
|
||||||
|
"pack.estimatedSize": "Estimated size: {{size}}",
|
||||||
|
"pack.noDest": "No destination selected",
|
||||||
|
"pack.pickDest": "Choose location",
|
||||||
|
"pack.largeExportConfirm": "Export is large ({{size}}). Continue?",
|
||||||
|
"pack.exportAllStart": "Start export",
|
||||||
|
"pack.exportAllSuccess": "All books exported",
|
||||||
|
"pack.exportAllFailed": "Export failed",
|
||||||
|
"pack.exportBookSuccess": "\"{{name}}\" exported as project pack",
|
||||||
|
"pack.exportBookFailed": "Project pack export failed",
|
||||||
|
"pack.exporting": "Exporting…",
|
||||||
|
"pack.cancel": "Cancel",
|
||||||
|
"pack.progress": "Progress {{current}}/{{total}}",
|
||||||
|
"pack.importStrategy": "Import strategy",
|
||||||
|
"pack.strategyNew": "Import all as new books",
|
||||||
|
"pack.strategySkip": "Skip existing books",
|
||||||
|
"pack.strategyOverwrite": "Overwrite matching books",
|
||||||
|
"pack.importAllSuccess": "Import done: {{imported}} new, {{skipped}} skipped, {{overwritten}} overwritten",
|
||||||
|
"backup.desc": "Export books as Bilin project packs for backup, migration, or restore.",
|
||||||
|
"backup.exportCurrentBook": "Export current book",
|
||||||
|
"backup.exportBookBtn": "Export as .novel",
|
||||||
|
"backup.exportAllBooks": "Export all books",
|
||||||
|
"backup.exportAllBtn": "Open bulk export",
|
||||||
|
"backup.noBook": "Open a book in the editor first",
|
||||||
|
"backup.openBookHint": "Open a book in the editor to export a single project pack",
|
||||||
|
"backup.exportBookSuccess": "\"{{name}}\" exported",
|
||||||
|
"backup.exportBookFailed": "Export failed",
|
||||||
|
"backup.formatHint": "Bulk exports over 500 MB require extra confirmation.",
|
||||||
"submission.title": "Submission Presets",
|
"submission.title": "Submission Presets",
|
||||||
"submission.titleFormat": "Title format",
|
"submission.titleFormat": "Title format",
|
||||||
"submission.indent": "Indent paragraphs",
|
"submission.indent": "Indent paragraphs",
|
||||||
|
|||||||
@@ -442,6 +442,43 @@
|
|||||||
"export.preview": "预览",
|
"export.preview": "预览",
|
||||||
"export.loading": "加载中…",
|
"export.loading": "加载中…",
|
||||||
"export.noPreview": "(无预览)",
|
"export.noPreview": "(无预览)",
|
||||||
|
"export.kind": "导出类型",
|
||||||
|
"export.kindSubmission": "投稿格式(当前章节)",
|
||||||
|
"export.kindNovel": "笔临项目包 (.novel)",
|
||||||
|
"export.novelDesc": "导出整本书的数据库、元数据与附件,可在其他设备或备份后通过「导入书籍」恢复。",
|
||||||
|
"export.exportNovel": "导出项目包",
|
||||||
|
"import.novelHint": "将导入 .novel 项目包中的整本书(含章节、设定与知识库)。",
|
||||||
|
"import.novelAllHint": "将批量导入 .novel-all 包中的所有书籍,请选择冲突处理策略。",
|
||||||
|
"pack.exportAllTitle": "导出所有书籍",
|
||||||
|
"pack.exportAllDesc": "将所有书籍与全局设置打包为 .novel-all 文件。",
|
||||||
|
"pack.estimating": "正在估算大小…",
|
||||||
|
"pack.estimatedSize": "预计大小:{{size}}",
|
||||||
|
"pack.noDest": "未选择保存路径",
|
||||||
|
"pack.pickDest": "选择保存位置",
|
||||||
|
"pack.largeExportConfirm": "导出体积较大({{size}}),确认继续?",
|
||||||
|
"pack.exportAllStart": "开始导出",
|
||||||
|
"pack.exportAllSuccess": "全部书籍已导出",
|
||||||
|
"pack.exportAllFailed": "导出失败",
|
||||||
|
"pack.exportBookSuccess": "「{{name}}」已导出为项目包",
|
||||||
|
"pack.exportBookFailed": "项目包导出失败",
|
||||||
|
"pack.exporting": "导出中…",
|
||||||
|
"pack.cancel": "取消",
|
||||||
|
"pack.progress": "进度 {{current}}/{{total}}",
|
||||||
|
"pack.importStrategy": "导入策略",
|
||||||
|
"pack.strategyNew": "全部作为新书导入",
|
||||||
|
"pack.strategySkip": "跳过已存在的书",
|
||||||
|
"pack.strategyOverwrite": "覆盖同名书籍",
|
||||||
|
"pack.importAllSuccess": "导入完成:新增 {{imported}},跳过 {{skipped}},覆盖 {{overwritten}}",
|
||||||
|
"backup.desc": "将书籍导出为笔临项目包,便于备份、迁移或在其他设备恢复。",
|
||||||
|
"backup.exportCurrentBook": "导出当前书籍",
|
||||||
|
"backup.exportBookBtn": "导出为 .novel",
|
||||||
|
"backup.exportAllBooks": "导出全部书籍",
|
||||||
|
"backup.exportAllBtn": "打开批量导出",
|
||||||
|
"backup.noBook": "请先在编辑器中打开一本书",
|
||||||
|
"backup.openBookHint": "在编辑器中打开书籍后可导出单书项目包",
|
||||||
|
"backup.exportBookSuccess": "「{{name}}」已导出",
|
||||||
|
"backup.exportBookFailed": "导出失败",
|
||||||
|
"backup.formatHint": "超过 500 MB 的批量导出需要额外确认。",
|
||||||
"submission.title": "投稿预设",
|
"submission.title": "投稿预设",
|
||||||
"submission.titleFormat": "标题格式",
|
"submission.titleFormat": "标题格式",
|
||||||
"submission.indent": "段首空两格",
|
"submission.indent": "段首空两格",
|
||||||
|
|||||||
@@ -58,8 +58,25 @@ export function registerPackHandlers(
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
ipcMain.handle(IPC.PACK_PICK_FILE, (_e, { mode }: { mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' }) =>
|
ipcMain.handle(IPC.PACK_PICK_FILE, (_e, { mode }: { mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import' }) =>
|
||||||
wrap(async () => {
|
wrap(async () => {
|
||||||
|
if (process.env.BILIN_E2E === '1') {
|
||||||
|
if (
|
||||||
|
(mode === 'save-novel' || mode === 'save-all') &&
|
||||||
|
process.env.BILIN_E2E_PACK_SAVE
|
||||||
|
) {
|
||||||
|
return process.env.BILIN_E2E_PACK_SAVE
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(mode === 'import' || mode === 'novel' || mode === 'novel-all') &&
|
||||||
|
process.env.BILIN_E2E_PACK_IMPORT_FILE
|
||||||
|
) {
|
||||||
|
return process.env.BILIN_E2E_PACK_IMPORT_FILE
|
||||||
|
}
|
||||||
|
if (mode === 'import' && process.env.BILIN_E2E_IMPORT_FILE) {
|
||||||
|
return process.env.BILIN_E2E_IMPORT_FILE
|
||||||
|
}
|
||||||
|
}
|
||||||
if (mode === 'save-novel' || mode === 'save-all') {
|
if (mode === 'save-novel' || mode === 'save-all') {
|
||||||
const ext = mode === 'save-all' ? 'novel-all' : 'novel'
|
const ext = mode === 'save-all' ? 'novel-all' : 'novel'
|
||||||
const { canceled, filePath } = await dialog.showSaveDialog({
|
const { canceled, filePath } = await dialog.showSaveDialog({
|
||||||
@@ -69,7 +86,12 @@ export function registerPackHandlers(
|
|||||||
if (canceled || !filePath) return null
|
if (canceled || !filePath) return null
|
||||||
return filePath
|
return filePath
|
||||||
}
|
}
|
||||||
const extensions = mode === 'novel-all' ? ['novel-all'] : ['novel', 'novel-all']
|
const extensions =
|
||||||
|
mode === 'import'
|
||||||
|
? ['txt', 'md', 'docx', 'novel', 'novel-all']
|
||||||
|
: mode === 'novel-all'
|
||||||
|
? ['novel-all']
|
||||||
|
: ['novel', 'novel-all']
|
||||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||||
properties: ['openFile'],
|
properties: ['openFile'],
|
||||||
filters: [{ name: 'Bilin Pack', extensions }]
|
filters: [{ name: 'Bilin Pack', extensions }]
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ import type {
|
|||||||
ImportExecuteParams,
|
ImportExecuteParams,
|
||||||
ImportSplitMode
|
ImportSplitMode
|
||||||
} from '../shared/types'
|
} from '../shared/types'
|
||||||
|
import type {
|
||||||
|
PackImportStrategy,
|
||||||
|
PackImportReport,
|
||||||
|
PackProgressEvent
|
||||||
|
} from '../shared/novel-pack'
|
||||||
|
|
||||||
const electronAPI = {
|
const electronAPI = {
|
||||||
window: {
|
window: {
|
||||||
@@ -599,6 +604,25 @@ const electronAPI = {
|
|||||||
saveTxt: (text: string, defaultName: string): Promise<IpcResult<string | null>> =>
|
saveTxt: (text: string, defaultName: string): Promise<IpcResult<string | null>> =>
|
||||||
ipcRenderer.invoke(IPC.EXPORT_SAVE_TXT, { text, defaultName })
|
ipcRenderer.invoke(IPC.EXPORT_SAVE_TXT, { text, defaultName })
|
||||||
},
|
},
|
||||||
|
pack: {
|
||||||
|
pickFile: (
|
||||||
|
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
|
||||||
|
): Promise<IpcResult<string | null>> => ipcRenderer.invoke(IPC.PACK_PICK_FILE, { mode }),
|
||||||
|
exportBook: (bookId: string, destPath: string): Promise<IpcResult<void>> =>
|
||||||
|
ipcRenderer.invoke(IPC.PACK_EXPORT_BOOK, { bookId, destPath }),
|
||||||
|
importBook: (filePath: string): Promise<IpcResult<string>> =>
|
||||||
|
ipcRenderer.invoke(IPC.PACK_IMPORT_BOOK, { filePath }),
|
||||||
|
exportAll: (destPath: string): Promise<IpcResult<void>> =>
|
||||||
|
ipcRenderer.invoke(IPC.PACK_EXPORT_ALL, { destPath }),
|
||||||
|
importAll: (
|
||||||
|
filePath: string,
|
||||||
|
strategy: PackImportStrategy
|
||||||
|
): Promise<IpcResult<PackImportReport>> =>
|
||||||
|
ipcRenderer.invoke(IPC.PACK_IMPORT_ALL, { filePath, strategy }),
|
||||||
|
estimateExportAll: (): Promise<IpcResult<{ bytes: number; needsConfirm: boolean }>> =>
|
||||||
|
ipcRenderer.invoke(IPC.PACK_ESTIMATE_EXPORT_ALL),
|
||||||
|
cancel: (): Promise<IpcResult<void>> => ipcRenderer.invoke(IPC.PACK_CANCEL)
|
||||||
|
},
|
||||||
bridge: {
|
bridge: {
|
||||||
get: (
|
get: (
|
||||||
bookId: string,
|
bookId: string,
|
||||||
@@ -674,6 +698,13 @@ const electronAPI = {
|
|||||||
}
|
}
|
||||||
ipcRenderer.on(IPC.IMPORT_PROGRESS, handler)
|
ipcRenderer.on(IPC.IMPORT_PROGRESS, handler)
|
||||||
return () => ipcRenderer.removeListener(IPC.IMPORT_PROGRESS, handler)
|
return () => ipcRenderer.removeListener(IPC.IMPORT_PROGRESS, handler)
|
||||||
|
},
|
||||||
|
onPackProgress: (callback: (payload: PackProgressEvent) => void): (() => void) => {
|
||||||
|
const handler = (_event: IpcRendererEvent, payload: PackProgressEvent) => {
|
||||||
|
callback(payload)
|
||||||
|
}
|
||||||
|
ipcRenderer.on(IPC.PACK_PROGRESS, handler)
|
||||||
|
return () => ipcRenderer.removeListener(IPC.PACK_PROGRESS, handler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useEditStore } from '@renderer/stores/useEditStore'
|
|||||||
import { SearchModal } from '@renderer/components/search/SearchModal'
|
import { SearchModal } from '@renderer/components/search/SearchModal'
|
||||||
import { InspirationModal } from '@renderer/components/inspiration/InspirationModal'
|
import { InspirationModal } from '@renderer/components/inspiration/InspirationModal'
|
||||||
import { ImportBookModal } from '@renderer/components/import/ImportBookModal'
|
import { ImportBookModal } from '@renderer/components/import/ImportBookModal'
|
||||||
|
import { ExportAllModal } from '@renderer/components/export/ExportAllModal'
|
||||||
import { FocusModeOverlay } from '@renderer/components/focus/FocusModeOverlay'
|
import { FocusModeOverlay } from '@renderer/components/focus/FocusModeOverlay'
|
||||||
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
|
import { NamingCheckModal } from '@renderer/components/ai/NamingCheckModal'
|
||||||
import { useSearchStore } from '@renderer/stores/useSearchStore'
|
import { useSearchStore } from '@renderer/stores/useSearchStore'
|
||||||
@@ -209,6 +210,7 @@ function AppInner(): React.JSX.Element {
|
|||||||
<SearchModal />
|
<SearchModal />
|
||||||
<InspirationModal />
|
<InspirationModal />
|
||||||
<ImportBookModal />
|
<ImportBookModal />
|
||||||
|
<ExportAllModal />
|
||||||
<NamingCheckModal open={namingCheckOpen} onClose={() => setNamingCheckOpen(false)} />
|
<NamingCheckModal open={namingCheckOpen} onClose={() => setNamingCheckOpen(false)} />
|
||||||
{toast && <div className="toast">{toast}</div>}
|
{toast && <div className="toast">{toast}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { PackProgressEvent } from '@shared/novel-pack'
|
||||||
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportAllModal(): React.JSX.Element | null {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const open = useAppStore((s) => s.exportAllModalOpen)
|
||||||
|
const setOpen = useAppStore((s) => s.setExportAllModalOpen)
|
||||||
|
const showToast = useAppStore((s) => s.showToast)
|
||||||
|
|
||||||
|
const [estimating, setEstimating] = useState(false)
|
||||||
|
const [exporting, setExporting] = useState(false)
|
||||||
|
const [bytes, setBytes] = useState(0)
|
||||||
|
const [needsConfirm, setNeedsConfirm] = useState(false)
|
||||||
|
const [confirmed, setConfirmed] = useState(false)
|
||||||
|
const [progress, setProgress] = useState<PackProgressEvent | null>(null)
|
||||||
|
const [destPath, setDestPath] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
setEstimating(true)
|
||||||
|
setExporting(false)
|
||||||
|
setBytes(0)
|
||||||
|
setNeedsConfirm(false)
|
||||||
|
setConfirmed(false)
|
||||||
|
setProgress(null)
|
||||||
|
setDestPath('')
|
||||||
|
void ipcCall(() => window.electronAPI.pack.estimateExportAll())
|
||||||
|
.then((info) => {
|
||||||
|
setBytes(info.bytes)
|
||||||
|
setNeedsConfirm(info.needsConfirm)
|
||||||
|
})
|
||||||
|
.catch(() => showToast(t('pack.exportAllFailed')))
|
||||||
|
.finally(() => setEstimating(false))
|
||||||
|
}, [open, showToast, t])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const unsub = window.electronAPI.onPackProgress((payload) => {
|
||||||
|
setProgress(payload)
|
||||||
|
})
|
||||||
|
return unsub
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const handlePickDest = async (): Promise<void> => {
|
||||||
|
const path = await ipcCall(() => window.electronAPI.pack.pickFile('save-all'))
|
||||||
|
if (path) setDestPath(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExport = async (): Promise<void> => {
|
||||||
|
if (!destPath) return
|
||||||
|
if (needsConfirm && !confirmed) return
|
||||||
|
setExporting(true)
|
||||||
|
setProgress(null)
|
||||||
|
try {
|
||||||
|
await ipcCall(() => window.electronAPI.pack.exportAll(destPath))
|
||||||
|
showToast(t('pack.exportAllSuccess'))
|
||||||
|
setOpen(false)
|
||||||
|
} catch {
|
||||||
|
showToast(t('pack.exportAllFailed'))
|
||||||
|
} finally {
|
||||||
|
setExporting(false)
|
||||||
|
setProgress(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = (): void => {
|
||||||
|
if (exporting) {
|
||||||
|
void ipcCall(() => window.electronAPI.pack.cancel()).catch(() => undefined)
|
||||||
|
}
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
const progressPct =
|
||||||
|
progress && progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="modal-overlay"
|
||||||
|
data-testid="export-all-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('pack.exportAllTitle')}
|
||||||
|
>
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>{t('pack.exportAllTitle')}</h3>
|
||||||
|
<button type="button" className="modal-close" onClick={handleCancel}>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
<p className="text-muted">{t('pack.exportAllDesc')}</p>
|
||||||
|
{estimating ? (
|
||||||
|
<p>{t('pack.estimating')}</p>
|
||||||
|
) : (
|
||||||
|
<p data-testid="export-all-size">
|
||||||
|
{t('pack.estimatedSize', { size: formatFileSize(bytes) })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="import-file-row">
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
readOnly
|
||||||
|
value={destPath || t('pack.noDest')}
|
||||||
|
data-testid="export-all-dest"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
data-testid="export-all-pick"
|
||||||
|
disabled={exporting}
|
||||||
|
onClick={() => void handlePickDest()}
|
||||||
|
>
|
||||||
|
{t('pack.pickDest')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{needsConfirm && (
|
||||||
|
<label className="form-label" style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
data-testid="export-all-confirm"
|
||||||
|
checked={confirmed}
|
||||||
|
onChange={(e) => setConfirmed(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t('pack.largeExportConfirm', { size: formatFileSize(bytes) })}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{progress && (
|
||||||
|
<div className="import-progress" data-testid="export-all-progress">
|
||||||
|
<div className="import-progress-fill" style={{ width: `${progressPct}%` }} />
|
||||||
|
<span>
|
||||||
|
{progress.message ??
|
||||||
|
t('pack.progress', { current: progress.current, total: progress.total })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button type="button" className="btn" onClick={handleCancel}>
|
||||||
|
{exporting ? t('pack.cancel') : t('dialog.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
data-testid="export-all-start"
|
||||||
|
disabled={!destPath || exporting || estimating || (needsConfirm && !confirmed)}
|
||||||
|
onClick={() => void handleExport()}
|
||||||
|
>
|
||||||
|
{exporting ? t('pack.exporting') : t('pack.exportAllStart')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,12 +8,15 @@ import { useAppStore } from '@renderer/stores/useAppStore'
|
|||||||
import { useExportStore } from '@renderer/stores/useExportStore'
|
import { useExportStore } from '@renderer/stores/useExportStore'
|
||||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
|
||||||
|
type ExportKind = 'submission' | 'novel'
|
||||||
|
|
||||||
export function ExportModal(): React.JSX.Element | null {
|
export function ExportModal(): React.JSX.Element | null {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const open = useExportStore((s) => s.open)
|
const open = useExportStore((s) => s.open)
|
||||||
const close = useExportStore((s) => s.close)
|
const close = useExportStore((s) => s.close)
|
||||||
const showToast = useAppStore((s) => s.showToast)
|
const showToast = useAppStore((s) => s.showToast)
|
||||||
const bookId = useBookStore((s) => s.currentBookId)
|
const bookId = useBookStore((s) => s.currentBookId)
|
||||||
|
const bookName = useBookStore((s) => s.books.find((b) => b.id === bookId)?.name)
|
||||||
const chapters = useBookStore((s) => s.chapters)
|
const chapters = useBookStore((s) => s.chapters)
|
||||||
const target = useEditStore((s) => s.target)
|
const target = useEditStore((s) => s.target)
|
||||||
const customPresets = useSettingsStore((s) => s.submissionPresets)
|
const customPresets = useSettingsStore((s) => s.submissionPresets)
|
||||||
@@ -23,17 +26,20 @@ export function ExportModal(): React.JSX.Element | null {
|
|||||||
const chapterId = target?.kind === 'chapter' ? target.id : null
|
const chapterId = target?.kind === 'chapter' ? target.id : null
|
||||||
const chapter = chapterId ? chapters.find((c) => c.id === chapterId) : null
|
const chapter = chapterId ? chapters.find((c) => c.id === chapterId) : null
|
||||||
|
|
||||||
|
const [exportKind, setExportKind] = useState<ExportKind>('submission')
|
||||||
const [presetId, setPresetId] = useState(defaultPresetId)
|
const [presetId, setPresetId] = useState(defaultPresetId)
|
||||||
const [preview, setPreview] = useState('')
|
const [preview, setPreview] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [exportingNovel, setExportingNovel] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
|
setExportKind('submission')
|
||||||
setPresetId(defaultPresetId)
|
setPresetId(defaultPresetId)
|
||||||
}, [open, defaultPresetId])
|
}, [open, defaultPresetId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !bookId || !chapterId) {
|
if (!open || exportKind !== 'submission' || !bookId || !chapterId) {
|
||||||
setPreview('')
|
setPreview('')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -42,7 +48,7 @@ export function ExportModal(): React.JSX.Element | null {
|
|||||||
.then((text) => setPreview(text.slice(0, 500)))
|
.then((text) => setPreview(text.slice(0, 500)))
|
||||||
.catch(() => setPreview(''))
|
.catch(() => setPreview(''))
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [open, bookId, chapterId, presetId])
|
}, [open, exportKind, bookId, chapterId, presetId])
|
||||||
|
|
||||||
const handleCopy = async (): Promise<void> => {
|
const handleCopy = async (): Promise<void> => {
|
||||||
if (!bookId || !chapterId) return
|
if (!bookId || !chapterId) return
|
||||||
@@ -65,6 +71,22 @@ export function ExportModal(): React.JSX.Element | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleExportNovel = async (): Promise<void> => {
|
||||||
|
if (!bookId) return
|
||||||
|
const destPath = await ipcCall(() => window.electronAPI.pack.pickFile('save-novel'))
|
||||||
|
if (!destPath) return
|
||||||
|
setExportingNovel(true)
|
||||||
|
try {
|
||||||
|
await ipcCall(() => window.electronAPI.pack.exportBook(bookId, destPath))
|
||||||
|
showToast(t('pack.exportBookSuccess', { name: bookName ?? '' }))
|
||||||
|
close()
|
||||||
|
} catch {
|
||||||
|
showToast(t('pack.exportBookFailed'))
|
||||||
|
} finally {
|
||||||
|
setExportingNovel(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -80,53 +102,86 @@ export function ExportModal(): React.JSX.Element | null {
|
|||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h3>
|
<h3>
|
||||||
{t('export.title')}
|
{t('export.title')}
|
||||||
{chapter ? ` — ${chapter.title}` : ''}
|
{exportKind === 'submission' && chapter ? ` — ${chapter.title}` : ''}
|
||||||
|
{exportKind === 'novel' && bookName ? ` — ${bookName}` : ''}
|
||||||
</h3>
|
</h3>
|
||||||
<button type="button" className="modal-close" onClick={close}>
|
<button type="button" className="modal-close" onClick={close}>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
<label className="form-label">{t('export.submissionPresets')}</label>
|
<label className="form-label">{t('export.kind')}</label>
|
||||||
<select
|
<select
|
||||||
className="form-control form-control--wide"
|
className="form-control form-control--wide"
|
||||||
data-testid="export-preset-select"
|
data-testid="export-kind-select"
|
||||||
value={presetId}
|
value={exportKind}
|
||||||
onChange={(e) => setPresetId(e.target.value)}
|
onChange={(e) => setExportKind(e.target.value as ExportKind)}
|
||||||
>
|
>
|
||||||
{presets.map((p) => (
|
<option value="submission">{t('export.kindSubmission')}</option>
|
||||||
<option key={p.id} value={p.id}>
|
<option value="novel">{t('export.kindNovel')}</option>
|
||||||
{p.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
</select>
|
||||||
<label className="form-label">{t('export.preview')}</label>
|
{exportKind === 'submission' ? (
|
||||||
<pre className="export-preview" data-testid="export-preview">
|
<>
|
||||||
{loading ? t('export.loading') : preview || t('export.noPreview')}
|
<label className="form-label">{t('export.submissionPresets')}</label>
|
||||||
</pre>
|
<select
|
||||||
|
className="form-control form-control--wide"
|
||||||
|
data-testid="export-preset-select"
|
||||||
|
value={presetId}
|
||||||
|
onChange={(e) => setPresetId(e.target.value)}
|
||||||
|
>
|
||||||
|
{presets.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<label className="form-label">{t('export.preview')}</label>
|
||||||
|
<pre className="export-preview" data-testid="export-preview">
|
||||||
|
{loading ? t('export.loading') : preview || t('export.noPreview')}
|
||||||
|
</pre>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted" style={{ lineHeight: 1.6 }}>
|
||||||
|
{t('export.novelDesc')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button type="button" className="btn" onClick={close}>
|
<button type="button" className="btn" onClick={close}>
|
||||||
{t('dialog.cancel')}
|
{t('dialog.cancel')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
{exportKind === 'submission' ? (
|
||||||
type="button"
|
<>
|
||||||
className="btn"
|
<button
|
||||||
data-testid="export-save-txt"
|
type="button"
|
||||||
disabled={!chapterId}
|
className="btn"
|
||||||
onClick={() => void handleSaveTxt()}
|
data-testid="export-save-txt"
|
||||||
>
|
disabled={!chapterId}
|
||||||
{t('export.saveTxt')}
|
onClick={() => void handleSaveTxt()}
|
||||||
</button>
|
>
|
||||||
<button
|
{t('export.saveTxt')}
|
||||||
type="button"
|
</button>
|
||||||
className="btn btn-primary"
|
<button
|
||||||
data-testid="export-copy-clipboard"
|
type="button"
|
||||||
disabled={!chapterId}
|
className="btn btn-primary"
|
||||||
onClick={() => void handleCopy()}
|
data-testid="export-copy-clipboard"
|
||||||
>
|
disabled={!chapterId}
|
||||||
{t('export.copyClipboard')}
|
onClick={() => void handleCopy()}
|
||||||
</button>
|
>
|
||||||
|
{t('export.copyClipboard')}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
data-testid="export-novel-btn"
|
||||||
|
disabled={!bookId || exportingNovel}
|
||||||
|
onClick={() => void handleExportNovel()}
|
||||||
|
>
|
||||||
|
{exportingNovel ? t('pack.exporting') : t('export.exportNovel')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ipcCall } from '@renderer/lib/ipc-client'
|
|||||||
export function HomePage(): React.JSX.Element {
|
export function HomePage(): React.JSX.Element {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const showToast = useAppStore((s) => s.showToast)
|
const showToast = useAppStore((s) => s.showToast)
|
||||||
|
const setExportAllModalOpen = useAppStore((s) => s.setExportAllModalOpen)
|
||||||
const setView = useAppStore((s) => s.setView)
|
const setView = useAppStore((s) => s.setView)
|
||||||
const { books, loadBooks, openBook } = useBookStore()
|
const { books, loadBooks, openBook } = useBookStore()
|
||||||
const { openBookTab, openSettingsTab } = useTabStore()
|
const { openBookTab, openSettingsTab } = useTabStore()
|
||||||
@@ -63,8 +64,7 @@ export function HomePage(): React.JSX.Element {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn-large"
|
className="btn-large"
|
||||||
data-testid="home-export-all"
|
data-testid="home-export-all"
|
||||||
disabled
|
onClick={() => setExportAllModalOpen(true)}
|
||||||
title={t('home.exportAllWave2')}
|
|
||||||
>
|
>
|
||||||
{t('home.exportAll')}
|
{t('home.exportAll')}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { ImportPreviewResult, ImportSplitMode } from '@shared/types'
|
import type { ImportPreviewResult, ImportSplitMode } from '@shared/types'
|
||||||
|
import type { PackImportStrategy } from '@shared/novel-pack'
|
||||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
import { useTabStore } from '@renderer/stores/useTabStore'
|
import { useTabStore } from '@renderer/stores/useTabStore'
|
||||||
@@ -14,6 +15,13 @@ function formatFileSize(bytes: number): string {
|
|||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function detectFileKind(filePath: string): 'text' | 'novel' | 'novel-all' {
|
||||||
|
const lower = filePath.toLowerCase()
|
||||||
|
if (lower.endsWith('.novel-all')) return 'novel-all'
|
||||||
|
if (lower.endsWith('.novel')) return 'novel'
|
||||||
|
return 'text'
|
||||||
|
}
|
||||||
|
|
||||||
export function ImportBookModal(): React.JSX.Element | null {
|
export function ImportBookModal(): React.JSX.Element | null {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const open = useAppStore((s) => s.importBookModalOpen)
|
const open = useAppStore((s) => s.importBookModalOpen)
|
||||||
@@ -25,63 +33,115 @@ export function ImportBookModal(): React.JSX.Element | null {
|
|||||||
|
|
||||||
const [filePath, setFilePath] = useState('')
|
const [filePath, setFilePath] = useState('')
|
||||||
const [fileName, setFileName] = useState('')
|
const [fileName, setFileName] = useState('')
|
||||||
|
const [fileKind, setFileKind] = useState<'text' | 'novel' | 'novel-all'>('text')
|
||||||
const [bookName, setBookName] = useState('')
|
const [bookName, setBookName] = useState('')
|
||||||
const [category, setCategory] = useState('玄幻')
|
const [category, setCategory] = useState('玄幻')
|
||||||
const [splitMode, setSplitMode] = useState<ImportSplitMode>('auto')
|
const [splitMode, setSplitMode] = useState<ImportSplitMode>('auto')
|
||||||
const [customRegex, setCustomRegex] = useState('')
|
const [customRegex, setCustomRegex] = useState('')
|
||||||
|
const [packStrategy, setPackStrategy] = useState<PackImportStrategy>('new')
|
||||||
const [preview, setPreview] = useState<ImportPreviewResult | null>(null)
|
const [preview, setPreview] = useState<ImportPreviewResult | null>(null)
|
||||||
const [importing, setImporting] = useState(false)
|
const [importing, setImporting] = useState(false)
|
||||||
const [progress, setProgress] = useState<{ current: number; total: number } | null>(null)
|
const [progress, setProgress] = useState<{ current: number; total: number; message?: string } | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
setFilePath('')
|
setFilePath('')
|
||||||
setFileName('')
|
setFileName('')
|
||||||
|
setFileKind('text')
|
||||||
setBookName('')
|
setBookName('')
|
||||||
setCategory('玄幻')
|
setCategory('玄幻')
|
||||||
setSplitMode('auto')
|
setSplitMode('auto')
|
||||||
setCustomRegex('')
|
setCustomRegex('')
|
||||||
|
setPackStrategy('new')
|
||||||
setPreview(null)
|
setPreview(null)
|
||||||
setProgress(null)
|
setProgress(null)
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
if (!open) return
|
||||||
const unsub = window.electronAPI.onImportProgress((payload) => {
|
const unsubImport = window.electronAPI.onImportProgress((payload) => {
|
||||||
setProgress(payload)
|
setProgress(payload)
|
||||||
})
|
})
|
||||||
return unsub
|
const unsubPack = window.electronAPI.onPackProgress((payload) => {
|
||||||
|
setProgress(payload)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
unsubImport()
|
||||||
|
unsubPack()
|
||||||
|
}
|
||||||
}, [open])
|
}, [open])
|
||||||
|
|
||||||
const handlePickFile = async (): Promise<void> => {
|
const handlePickFile = async (): Promise<void> => {
|
||||||
const result = await ipcCall(() => window.electronAPI.import.pickFile())
|
const path = await ipcCall(() => window.electronAPI.pack.pickFile('import'))
|
||||||
if (result.canceled || !result.filePath) return
|
if (!path) return
|
||||||
setFilePath(result.filePath)
|
setFilePath(path)
|
||||||
const name = result.filePath.split(/[/\\]/).pop() ?? ''
|
const name = path.split(/[/\\]/).pop() ?? ''
|
||||||
setFileName(name)
|
setFileName(name)
|
||||||
const baseName = name.replace(/\.(txt|md|docx)$/i, '')
|
const kind = detectFileKind(path)
|
||||||
if (!bookName.trim()) setBookName(baseName)
|
setFileKind(kind)
|
||||||
|
setPreview(null)
|
||||||
|
if (kind === 'text') {
|
||||||
|
const baseName = name.replace(/\.(txt|md|docx)$/i, '')
|
||||||
|
if (!bookName.trim()) setBookName(baseName)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePreview = async (): Promise<void> => {
|
const handlePreview = async (): Promise<void> => {
|
||||||
if (!filePath) return
|
if (!filePath || fileKind !== 'text') return
|
||||||
const result = await ipcCall(() =>
|
const result = await ipcCall(() =>
|
||||||
window.electronAPI.import.preview(filePath, splitMode, customRegex || undefined)
|
window.electronAPI.import.preview(filePath, splitMode, customRegex || undefined)
|
||||||
)
|
)
|
||||||
setPreview(result)
|
setPreview(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openImportedBook = async (bookId: string, name: string): Promise<void> => {
|
||||||
|
await loadBooks()
|
||||||
|
const meta = useBookStore.getState().books.find((b) => b.id === bookId)
|
||||||
|
await openBook(bookId)
|
||||||
|
openBookTab(bookId, meta?.name ?? name)
|
||||||
|
setView('editor')
|
||||||
|
showToast(t('import.success'))
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
const handleImport = async (): Promise<void> => {
|
const handleImport = async (): Promise<void> => {
|
||||||
if (!filePath || !bookName.trim()) return
|
if (!filePath) return
|
||||||
if (preview && preview.fileSizeBytes > LARGE_FILE_BYTES) {
|
if (fileKind === 'text' && !bookName.trim()) return
|
||||||
|
|
||||||
|
if (fileKind === 'text' && preview && preview.fileSizeBytes > LARGE_FILE_BYTES) {
|
||||||
const ok = confirm(
|
const ok = confirm(
|
||||||
t('import.largeFileConfirm', { size: formatFileSize(preview.fileSizeBytes) })
|
t('import.largeFileConfirm', { size: formatFileSize(preview.fileSizeBytes) })
|
||||||
)
|
)
|
||||||
if (!ok) return
|
if (!ok) return
|
||||||
}
|
}
|
||||||
|
|
||||||
setImporting(true)
|
setImporting(true)
|
||||||
setProgress(null)
|
setProgress(null)
|
||||||
try {
|
try {
|
||||||
|
if (fileKind === 'novel') {
|
||||||
|
const bookId = await ipcCall(() => window.electronAPI.pack.importBook(filePath))
|
||||||
|
await openImportedBook(bookId, bookName.trim() || fileName)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileKind === 'novel-all') {
|
||||||
|
const report = await ipcCall(() =>
|
||||||
|
window.electronAPI.pack.importAll(filePath, packStrategy)
|
||||||
|
)
|
||||||
|
await loadBooks()
|
||||||
|
showToast(
|
||||||
|
t('pack.importAllSuccess', {
|
||||||
|
imported: report.imported,
|
||||||
|
skipped: report.skipped,
|
||||||
|
overwritten: report.overwritten
|
||||||
|
})
|
||||||
|
)
|
||||||
|
setOpen(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!preview) await handlePreview()
|
if (!preview) await handlePreview()
|
||||||
const bookId = await ipcCall(() =>
|
const bookId = await ipcCall(() =>
|
||||||
window.electronAPI.import.execute({
|
window.electronAPI.import.execute({
|
||||||
@@ -92,13 +152,7 @@ export function ImportBookModal(): React.JSX.Element | null {
|
|||||||
customRegex: customRegex || undefined
|
customRegex: customRegex || undefined
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
await loadBooks()
|
await openImportedBook(bookId, bookName.trim())
|
||||||
const meta = useBookStore.getState().books.find((b) => b.id === bookId)
|
|
||||||
await openBook(bookId)
|
|
||||||
openBookTab(bookId, meta?.name ?? bookName.trim())
|
|
||||||
setView('editor')
|
|
||||||
showToast(t('import.success'))
|
|
||||||
setOpen(false)
|
|
||||||
} catch {
|
} catch {
|
||||||
showToast(t('import.failed'))
|
showToast(t('import.failed'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -110,6 +164,7 @@ export function ImportBookModal(): React.JSX.Element | null {
|
|||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
const previewTitles = preview?.chapters.slice(0, 5).map((ch) => ch.title) ?? []
|
const previewTitles = preview?.chapters.slice(0, 5).map((ch) => ch.title) ?? []
|
||||||
|
const isPack = fileKind === 'novel' || fileKind === 'novel-all'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -144,64 +199,89 @@ export function ImportBookModal(): React.JSX.Element | null {
|
|||||||
{t('import.pickFile')}
|
{t('import.pickFile')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<label className="form-label">{t('dialog.newBook.name')}</label>
|
{fileKind === 'novel' && (
|
||||||
<input
|
<p className="text-muted" data-testid="import-pack-hint">
|
||||||
className="form-control form-control--wide"
|
{t('import.novelHint')}
|
||||||
data-testid="import-book-name"
|
</p>
|
||||||
value={bookName}
|
)}
|
||||||
onChange={(e) => setBookName(e.target.value)}
|
{fileKind === 'novel-all' && (
|
||||||
/>
|
|
||||||
<label className="form-label">{t('dialog.newBook.category')}</label>
|
|
||||||
<input
|
|
||||||
className="form-control form-control--wide"
|
|
||||||
data-testid="import-book-category"
|
|
||||||
value={category}
|
|
||||||
onChange={(e) => setCategory(e.target.value)}
|
|
||||||
/>
|
|
||||||
<label className="form-label">{t('import.splitMode')}</label>
|
|
||||||
<select
|
|
||||||
className="form-control form-control--wide"
|
|
||||||
data-testid="import-split-mode"
|
|
||||||
value={splitMode}
|
|
||||||
onChange={(e) => setSplitMode(e.target.value as ImportSplitMode)}
|
|
||||||
>
|
|
||||||
<option value="auto">{t('import.splitAuto')}</option>
|
|
||||||
<option value="paragraph">{t('import.splitParagraph')}</option>
|
|
||||||
<option value="regex">{t('import.splitRegex')}</option>
|
|
||||||
</select>
|
|
||||||
{splitMode === 'regex' && (
|
|
||||||
<>
|
<>
|
||||||
<label className="form-label">{t('import.customRegex')}</label>
|
<p className="text-muted">{t('import.novelAllHint')}</p>
|
||||||
<input
|
<label className="form-label">{t('pack.importStrategy')}</label>
|
||||||
|
<select
|
||||||
className="form-control form-control--wide"
|
className="form-control form-control--wide"
|
||||||
data-testid="import-custom-regex"
|
data-testid="import-pack-strategy"
|
||||||
value={customRegex}
|
value={packStrategy}
|
||||||
onChange={(e) => setCustomRegex(e.target.value)}
|
onChange={(e) => setPackStrategy(e.target.value as PackImportStrategy)}
|
||||||
placeholder="^第.+章"
|
>
|
||||||
/>
|
<option value="new">{t('pack.strategyNew')}</option>
|
||||||
|
<option value="skip">{t('pack.strategySkip')}</option>
|
||||||
|
<option value="overwrite">{t('pack.strategyOverwrite')}</option>
|
||||||
|
</select>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<button
|
{fileKind === 'text' && (
|
||||||
type="button"
|
<>
|
||||||
className="btn"
|
<label className="form-label">{t('dialog.newBook.name')}</label>
|
||||||
data-testid="import-preview-btn"
|
<input
|
||||||
disabled={!filePath}
|
className="form-control form-control--wide"
|
||||||
onClick={() => void handlePreview()}
|
data-testid="import-book-name"
|
||||||
>
|
value={bookName}
|
||||||
{t('import.preview')}
|
onChange={(e) => setBookName(e.target.value)}
|
||||||
</button>
|
/>
|
||||||
{preview && (
|
<label className="form-label">{t('dialog.newBook.category')}</label>
|
||||||
<div className="import-preview" data-testid="import-preview">
|
<input
|
||||||
<p>
|
className="form-control form-control--wide"
|
||||||
{t('import.chapterCount', { count: preview.chapters.length })}
|
data-testid="import-book-category"
|
||||||
{preview.warnings.length > 0 && ` (${preview.warnings.join(', ')})`}
|
value={category}
|
||||||
</p>
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
<ul className="import-preview-list">
|
/>
|
||||||
{previewTitles.map((title, i) => (
|
<label className="form-label">{t('import.splitMode')}</label>
|
||||||
<li key={i}>{title}</li>
|
<select
|
||||||
))}
|
className="form-control form-control--wide"
|
||||||
</ul>
|
data-testid="import-split-mode"
|
||||||
</div>
|
value={splitMode}
|
||||||
|
onChange={(e) => setSplitMode(e.target.value as ImportSplitMode)}
|
||||||
|
>
|
||||||
|
<option value="auto">{t('import.splitAuto')}</option>
|
||||||
|
<option value="paragraph">{t('import.splitParagraph')}</option>
|
||||||
|
<option value="regex">{t('import.splitRegex')}</option>
|
||||||
|
</select>
|
||||||
|
{splitMode === 'regex' && (
|
||||||
|
<>
|
||||||
|
<label className="form-label">{t('import.customRegex')}</label>
|
||||||
|
<input
|
||||||
|
className="form-control form-control--wide"
|
||||||
|
data-testid="import-custom-regex"
|
||||||
|
value={customRegex}
|
||||||
|
onChange={(e) => setCustomRegex(e.target.value)}
|
||||||
|
placeholder="^第.+章"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
data-testid="import-preview-btn"
|
||||||
|
disabled={!filePath}
|
||||||
|
onClick={() => void handlePreview()}
|
||||||
|
>
|
||||||
|
{t('import.preview')}
|
||||||
|
</button>
|
||||||
|
{preview && (
|
||||||
|
<div className="import-preview" data-testid="import-preview">
|
||||||
|
<p>
|
||||||
|
{t('import.chapterCount', { count: preview.chapters.length })}
|
||||||
|
{preview.warnings.length > 0 && ` (${preview.warnings.join(', ')})`}
|
||||||
|
</p>
|
||||||
|
<ul className="import-preview-list">
|
||||||
|
{previewTitles.map((title, i) => (
|
||||||
|
<li key={i}>{title}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{progress && (
|
{progress && (
|
||||||
<div className="import-progress" data-testid="import-progress-bar">
|
<div className="import-progress" data-testid="import-progress-bar">
|
||||||
@@ -209,7 +289,10 @@ export function ImportBookModal(): React.JSX.Element | null {
|
|||||||
className="import-progress-fill"
|
className="import-progress-fill"
|
||||||
style={{ width: `${(progress.current / progress.total) * 100}%` }}
|
style={{ width: `${(progress.current / progress.total) * 100}%` }}
|
||||||
/>
|
/>
|
||||||
<span>{t('import.progress', { current: progress.current, total: progress.total })}</span>
|
<span>
|
||||||
|
{progress.message ??
|
||||||
|
t('import.progress', { current: progress.current, total: progress.total })}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -221,7 +304,7 @@ export function ImportBookModal(): React.JSX.Element | null {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary"
|
className="btn btn-primary"
|
||||||
data-testid="import-execute-btn"
|
data-testid="import-execute-btn"
|
||||||
disabled={!filePath || !bookName.trim() || importing}
|
disabled={!filePath || (!isPack && !bookName.trim()) || importing}
|
||||||
onClick={() => void handleImport()}
|
onClick={() => void handleImport()}
|
||||||
>
|
>
|
||||||
{importing ? t('import.importing') : t('import.start')}
|
{importing ? t('import.importing') : t('import.start')}
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import type { PackProgressEvent } from '@shared/novel-pack'
|
||||||
|
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||||
|
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||||
|
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||||
|
|
||||||
|
export function BackupSettingsTab(): React.JSX.Element {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const showToast = useAppStore((s) => s.showToast)
|
||||||
|
const setExportAllModalOpen = useAppStore((s) => s.setExportAllModalOpen)
|
||||||
|
const bookId = useBookStore((s) => s.currentBookId)
|
||||||
|
const bookName = useBookStore((s) => s.books.find((b) => b.id === bookId)?.name)
|
||||||
|
|
||||||
|
const [exportingBook, setExportingBook] = useState(false)
|
||||||
|
const [progress, setProgress] = useState<PackProgressEvent | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const unsub = window.electronAPI.onPackProgress((payload) => {
|
||||||
|
setProgress(payload)
|
||||||
|
})
|
||||||
|
return unsub
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleExportBook = async (): Promise<void> => {
|
||||||
|
if (!bookId) {
|
||||||
|
showToast(t('backup.noBook'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const destPath = await ipcCall(() => window.electronAPI.pack.pickFile('save-novel'))
|
||||||
|
if (!destPath) return
|
||||||
|
setExportingBook(true)
|
||||||
|
setProgress(null)
|
||||||
|
try {
|
||||||
|
await ipcCall(() => window.electronAPI.pack.exportBook(bookId, destPath))
|
||||||
|
showToast(t('backup.exportBookSuccess', { name: bookName ?? '' }))
|
||||||
|
} catch {
|
||||||
|
showToast(t('backup.exportBookFailed'))
|
||||||
|
} finally {
|
||||||
|
setExportingBook(false)
|
||||||
|
setProgress(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressPct =
|
||||||
|
progress && progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="backup-settings-tab">
|
||||||
|
<p style={{ color: 'var(--text-secondary)', marginBottom: 16, lineHeight: 1.6 }}>
|
||||||
|
{t('backup.desc')}
|
||||||
|
</p>
|
||||||
|
<div className="setting-item" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
|
||||||
|
<span>{t('backup.exportCurrentBook')}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
data-testid="backup-export-book"
|
||||||
|
disabled={!bookId || exportingBook}
|
||||||
|
onClick={() => void handleExportBook()}
|
||||||
|
>
|
||||||
|
{exportingBook ? t('pack.exporting') : t('backup.exportBookBtn')}
|
||||||
|
</button>
|
||||||
|
{!bookId && <small style={{ color: 'var(--text-muted)' }}>{t('backup.openBookHint')}</small>}
|
||||||
|
</div>
|
||||||
|
<div className="setting-item" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 8 }}>
|
||||||
|
<span>{t('backup.exportAllBooks')}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
data-testid="backup-export-all"
|
||||||
|
onClick={() => setExportAllModalOpen(true)}
|
||||||
|
>
|
||||||
|
{t('backup.exportAllBtn')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{progress && (
|
||||||
|
<div className="import-progress" data-testid="backup-progress" style={{ marginTop: 16 }}>
|
||||||
|
<div className="import-progress-fill" style={{ width: `${progressPct}%` }} />
|
||||||
|
<span>
|
||||||
|
{progress.message ??
|
||||||
|
t('pack.progress', { current: progress.current, total: progress.total })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p style={{ color: 'var(--text-muted)', fontSize: 12, marginTop: 24 }}>
|
||||||
|
{t('backup.formatHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
|||||||
import { ShortcutEditor } from '@renderer/components/settings/ShortcutEditor'
|
import { ShortcutEditor } from '@renderer/components/settings/ShortcutEditor'
|
||||||
import { AiSettingsPage } from '@renderer/components/settings/AiSettingsPage'
|
import { AiSettingsPage } from '@renderer/components/settings/AiSettingsPage'
|
||||||
import { TemplateSettingsTab } from '@renderer/components/settings/TemplateSettingsTab'
|
import { TemplateSettingsTab } from '@renderer/components/settings/TemplateSettingsTab'
|
||||||
|
import { BackupSettingsTab } from '@renderer/components/settings/BackupSettingsTab'
|
||||||
import { SubmissionPresetTab } from '@renderer/components/settings/SubmissionPresetTab'
|
import { SubmissionPresetTab } from '@renderer/components/settings/SubmissionPresetTab'
|
||||||
|
|
||||||
const THEMES: { id: ThemeId; key: string }[] = [
|
const THEMES: { id: ThemeId; key: string }[] = [
|
||||||
@@ -21,7 +22,7 @@ export function SettingsPage(): React.JSX.Element {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const settings = useSettingsStore()
|
const settings = useSettingsStore()
|
||||||
const [section, setSection] = useState<
|
const [section, setSection] = useState<
|
||||||
'general' | 'ai' | 'templates' | 'submission' | 'shortcuts' | 'about'
|
'general' | 'ai' | 'templates' | 'submission' | 'backup' | 'shortcuts' | 'about'
|
||||||
>('general')
|
>('general')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -62,6 +63,15 @@ export function SettingsPage(): React.JSX.Element {
|
|||||||
>
|
>
|
||||||
{t('settings.submission')}
|
{t('settings.submission')}
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
className={`settings-nav-item ${section === 'backup' ? 'active' : ''}`}
|
||||||
|
onClick={() => setSection('backup')}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
data-testid="settings-nav-backup"
|
||||||
|
>
|
||||||
|
{t('settings.backup')}
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className={`settings-nav-item ${section === 'shortcuts' ? 'active' : ''}`}
|
className={`settings-nav-item ${section === 'shortcuts' ? 'active' : ''}`}
|
||||||
onClick={() => setSection('shortcuts')}
|
onClick={() => setSection('shortcuts')}
|
||||||
@@ -249,10 +259,11 @@ export function SettingsPage(): React.JSX.Element {
|
|||||||
{section === 'ai' && <AiSettingsPage />}
|
{section === 'ai' && <AiSettingsPage />}
|
||||||
{section === 'templates' && <TemplateSettingsTab />}
|
{section === 'templates' && <TemplateSettingsTab />}
|
||||||
{section === 'submission' && <SubmissionPresetTab />}
|
{section === 'submission' && <SubmissionPresetTab />}
|
||||||
|
{section === 'backup' && <BackupSettingsTab />}
|
||||||
{section === 'shortcuts' && <ShortcutEditor />}
|
{section === 'shortcuts' && <ShortcutEditor />}
|
||||||
{section === 'about' && (
|
{section === 'about' && (
|
||||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||||
{t('app.name')} v1.2.0
|
{t('app.name')} v1.3.0
|
||||||
<br />
|
<br />
|
||||||
{t('app.tagline')}
|
{t('app.tagline')}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface AppState {
|
|||||||
landmarksModalOpen: boolean
|
landmarksModalOpen: boolean
|
||||||
inspirationModalOpen: boolean
|
inspirationModalOpen: boolean
|
||||||
importBookModalOpen: boolean
|
importBookModalOpen: boolean
|
||||||
|
exportAllModalOpen: boolean
|
||||||
focusMode: boolean
|
focusMode: boolean
|
||||||
namingCheckOpen: boolean
|
namingCheckOpen: boolean
|
||||||
toast: string | null
|
toast: string | null
|
||||||
@@ -20,6 +21,7 @@ interface AppState {
|
|||||||
setLandmarksModalOpen: (open: boolean) => void
|
setLandmarksModalOpen: (open: boolean) => void
|
||||||
setInspirationModalOpen: (open: boolean) => void
|
setInspirationModalOpen: (open: boolean) => void
|
||||||
setImportBookModalOpen: (open: boolean) => void
|
setImportBookModalOpen: (open: boolean) => void
|
||||||
|
setExportAllModalOpen: (open: boolean) => void
|
||||||
setFocusMode: (on: boolean) => void
|
setFocusMode: (on: boolean) => void
|
||||||
setNamingCheckOpen: (open: boolean) => void
|
setNamingCheckOpen: (open: boolean) => void
|
||||||
showToast: (message: string) => void
|
showToast: (message: string) => void
|
||||||
@@ -34,6 +36,7 @@ export const useAppStore = create<AppState>((set) => ({
|
|||||||
landmarksModalOpen: false,
|
landmarksModalOpen: false,
|
||||||
inspirationModalOpen: false,
|
inspirationModalOpen: false,
|
||||||
importBookModalOpen: false,
|
importBookModalOpen: false,
|
||||||
|
exportAllModalOpen: false,
|
||||||
focusMode: false,
|
focusMode: false,
|
||||||
namingCheckOpen: false,
|
namingCheckOpen: false,
|
||||||
toast: null,
|
toast: null,
|
||||||
@@ -44,6 +47,7 @@ export const useAppStore = create<AppState>((set) => ({
|
|||||||
setLandmarksModalOpen: (landmarksModalOpen) => set({ landmarksModalOpen }),
|
setLandmarksModalOpen: (landmarksModalOpen) => set({ landmarksModalOpen }),
|
||||||
setInspirationModalOpen: (inspirationModalOpen) => set({ inspirationModalOpen }),
|
setInspirationModalOpen: (inspirationModalOpen) => set({ inspirationModalOpen }),
|
||||||
setImportBookModalOpen: (importBookModalOpen) => set({ importBookModalOpen }),
|
setImportBookModalOpen: (importBookModalOpen) => set({ importBookModalOpen }),
|
||||||
|
setExportAllModalOpen: (exportAllModalOpen) => set({ exportAllModalOpen }),
|
||||||
setFocusMode: (focusMode) => set({ focusMode }),
|
setFocusMode: (focusMode) => set({ focusMode }),
|
||||||
setNamingCheckOpen: (namingCheckOpen) => set({ namingCheckOpen }),
|
setNamingCheckOpen: (namingCheckOpen) => set({ namingCheckOpen }),
|
||||||
showToast: (toast) => {
|
showToast: (toast) => {
|
||||||
|
|||||||
Vendored
+13
@@ -59,6 +59,7 @@ import type {
|
|||||||
ImportExecuteParams,
|
ImportExecuteParams,
|
||||||
ImportSplitMode
|
ImportSplitMode
|
||||||
} from './types'
|
} from './types'
|
||||||
|
import type { PackImportStrategy, PackImportReport, PackProgressEvent } from './novel-pack'
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
window: {
|
window: {
|
||||||
@@ -445,6 +446,17 @@ export interface ElectronAPI {
|
|||||||
copyClipboard: (text: string) => Promise<IpcResult<void>>
|
copyClipboard: (text: string) => Promise<IpcResult<void>>
|
||||||
saveTxt: (text: string, defaultName: string) => Promise<IpcResult<string | null>>
|
saveTxt: (text: string, defaultName: string) => Promise<IpcResult<string | null>>
|
||||||
}
|
}
|
||||||
|
pack: {
|
||||||
|
pickFile: (
|
||||||
|
mode: 'novel' | 'novel-all' | 'save-novel' | 'save-all' | 'import'
|
||||||
|
) => Promise<IpcResult<string | null>>
|
||||||
|
exportBook: (bookId: string, destPath: string) => Promise<IpcResult<void>>
|
||||||
|
importBook: (filePath: string) => Promise<IpcResult<string>>
|
||||||
|
exportAll: (destPath: string) => Promise<IpcResult<void>>
|
||||||
|
importAll: (filePath: string, strategy: PackImportStrategy) => Promise<IpcResult<PackImportReport>>
|
||||||
|
estimateExportAll: () => Promise<IpcResult<{ bytes: number; needsConfirm: boolean }>>
|
||||||
|
cancel: () => Promise<IpcResult<void>>
|
||||||
|
}
|
||||||
bridge: {
|
bridge: {
|
||||||
get: (
|
get: (
|
||||||
bookId: string,
|
bookId: string,
|
||||||
@@ -471,6 +483,7 @@ export interface ElectronAPI {
|
|||||||
onPomodoroTick: (callback: (state: PomodoroState) => void) => () => void
|
onPomodoroTick: (callback: (state: PomodoroState) => void) => () => void
|
||||||
onGoalNotification: (callback: (payload: GoalNotificationPayload) => void) => () => void
|
onGoalNotification: (callback: (payload: GoalNotificationPayload) => void) => () => void
|
||||||
onImportProgress: (callback: (payload: { current: number; total: number }) => void) => () => void
|
onImportProgress: (callback: (payload: { current: number; total: number }) => void) => () => void
|
||||||
|
onPackProgress: (callback: (payload: PackProgressEvent) => void) => () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
Reference in New Issue
Block a user