feat: ship v1.1.0 writer toolkit with templates, import, export, and inspiration capture
Implement chapter templates, txt/md/docx import, submission export presets, and global inspiration shortcut. Split import handlers into a separate main bundle and fix EditorLayout setTarget loop that broke E2E. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
# 笔临 (Bilin)
|
||||
|
||||
长篇创作智能协作平台 — Electron 桌面客户端 v1.0.0(P7 角色关系图谱 + P9 个人写作分析)
|
||||
长篇创作智能协作平台 — Electron 桌面客户端 v1.1.0(作家日更工具包)
|
||||
|
||||
## 功能概览(v1.1.0)
|
||||
|
||||
- 章节模板与快速新建章(§10)
|
||||
- 导入 txt / md / docx 并自动分章(§11.1)
|
||||
- 投稿预设与单章导出(复制剪贴板 / 存 txt)(§20.2)
|
||||
- 全局灵感捕获增强:任意界面快捷键、未开书可选书、保存仅 toast(§20.6)
|
||||
|
||||
## 功能概览(v1.0.0)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 笔临 v1.1 作家日更工具包 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
||||
|
||||
**Goal:** 交付 v1.1.0:§10 章节模板 + 快速新建、§11.1 txt/md/docx 导入、§20.2 投稿预设单章导出、§20.6 全局灵感捕获增强。
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
- Modify: `src/main/services/global-settings.ts`
|
||||
- Modify: `src/renderer/stores/useSettingsStore.ts`
|
||||
|
||||
- [ ] **Step 1: 扩展 types**
|
||||
- [x] **Step 1: 扩展 types**
|
||||
|
||||
```typescript
|
||||
export interface ChapterTemplate {
|
||||
@@ -109,7 +109,7 @@ submissionPresets?: SubmissionPreset[]
|
||||
defaultSubmissionPresetId?: string
|
||||
```
|
||||
|
||||
- [ ] **Step 2: builtin-templates.ts**
|
||||
- [x] **Step 2: builtin-templates.ts**
|
||||
|
||||
```typescript
|
||||
import type { ChapterTemplate, SubmissionPreset } from './types'
|
||||
@@ -149,13 +149,13 @@ export function mergeSubmissionPresets(custom: SubmissionPreset[] = []): Submiss
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: global-settings defaults**
|
||||
- [x] **Step 3: global-settings defaults**
|
||||
|
||||
在 `defaults()` 追加 `chapterTemplates: []`, `submissionPresets: []`, `defaultSubmissionPresetId: 'builtin-webnovel'`。
|
||||
|
||||
- [ ] **Step 4: useSettingsStore 初始字段对齐**
|
||||
- [x] **Step 4: useSettingsStore 初始字段对齐**
|
||||
|
||||
- [ ] **Step 5: build**
|
||||
- [x] **Step 5: build**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
@@ -169,7 +169,7 @@ npm run build
|
||||
- Create: `src/renderer/lib/chapter-template.ts`
|
||||
- Create: `tests/main/chapter-template.test.ts`
|
||||
|
||||
- [ ] **Step 1: 实现 replaceChapterTemplate**
|
||||
- [x] **Step 1: 实现 replaceChapterTemplate**
|
||||
|
||||
```typescript
|
||||
export interface TemplateContext {
|
||||
@@ -204,7 +204,7 @@ export function plainTextToEditorHtml(text: string): string {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: UT-TEMPL-01~03**
|
||||
- [x] **Step 2: UT-TEMPL-01~03**
|
||||
|
||||
```typescript
|
||||
it('UT-TEMPL-01: replaces chapterNumber and chapterTitle', () => {
|
||||
@@ -217,7 +217,7 @@ it('UT-TEMPL-01: replaces chapterNumber and chapterTitle', () => {
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 运行**
|
||||
- [x] **Step 3: 运行**
|
||||
|
||||
```bash
|
||||
npm run test -- tests/main/chapter-template.test.ts
|
||||
@@ -231,7 +231,7 @@ npm run test -- tests/main/chapter-template.test.ts
|
||||
- Create: `src/renderer/components/chapter/TemplateQuickCreateModal.tsx`
|
||||
- Modify: `src/renderer/components/layout/EditorLayout.tsx`
|
||||
|
||||
- [ ] **Step 1: TemplateQuickCreateModal**
|
||||
- [x] **Step 1: TemplateQuickCreateModal**
|
||||
|
||||
- `data-testid="chapter-quick-new"` 打开模态
|
||||
- 从 `useSettingsStore` + `mergeChapterTemplates` 展示模板卡片
|
||||
@@ -239,11 +239,11 @@ npm run test -- tests/main/chapter-template.test.ts
|
||||
- 若模板 `defaultStatus` 设置则 `chapter.update({ status })`
|
||||
- `switchTarget` 到新章
|
||||
|
||||
- [ ] **Step 2: EditorLayout**
|
||||
- [x] **Step 2: EditorLayout**
|
||||
|
||||
在章节面板底部保留原「+ 新章」按钮,新增「模板新建」按钮 `data-testid="chapter-quick-new"`。
|
||||
|
||||
- [ ] **Step 3: build**
|
||||
- [x] **Step 3: build**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
@@ -257,18 +257,18 @@ npm run build
|
||||
- Create: `src/renderer/components/settings/TemplateSettingsTab.tsx`
|
||||
- Modify: `src/renderer/components/settings/SettingsPage.tsx`
|
||||
|
||||
- [ ] **Step 1: TemplateSettingsTab**
|
||||
- [x] **Step 1: TemplateSettingsTab**
|
||||
|
||||
- `data-testid="settings-chapter-templates"`
|
||||
- 内置模板只读展示
|
||||
- 自定义:`setting-template-add` 新建;编辑 name/body;删除(uuid id)
|
||||
- 保存:`settings.update({ chapterTemplates: customOnly })`
|
||||
|
||||
- [ ] **Step 2: SettingsPage 导航**
|
||||
- [x] **Step 2: SettingsPage 导航**
|
||||
|
||||
新增 section `'templates'`,侧栏项 `settings.templates`。
|
||||
|
||||
- [ ] **Step 3: build**
|
||||
- [x] **Step 3: build**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
@@ -282,7 +282,7 @@ npm run build
|
||||
- Create: `src/main/lib/submission-formatter.ts`
|
||||
- Create: `tests/main/submission-formatter.test.ts`
|
||||
|
||||
- [ ] **Step 1: 实现**
|
||||
- [x] **Step 1: 实现**
|
||||
|
||||
```typescript
|
||||
import { stripHtml } from '../services/word-count'
|
||||
@@ -312,9 +312,9 @@ export function formatChapterForSubmission(input: FormatChapterInput): string {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: UT-EXPORT-01 / UT-EXPORT-02**
|
||||
- [x] **Step 2: UT-EXPORT-01 / UT-EXPORT-02**
|
||||
|
||||
- [ ] **Step 3: 运行**
|
||||
- [x] **Step 3: 运行**
|
||||
|
||||
```bash
|
||||
npm run test -- tests/main/submission-formatter.test.ts
|
||||
@@ -332,7 +332,7 @@ npm run test -- tests/main/submission-formatter.test.ts
|
||||
- Modify: `src/preload/index.ts`
|
||||
- Modify: `src/shared/electron-api.d.ts`
|
||||
|
||||
- [ ] **Step 1: ExportService**
|
||||
- [x] **Step 1: ExportService**
|
||||
|
||||
```typescript
|
||||
export class ExportService {
|
||||
@@ -371,7 +371,7 @@ export class ExportService {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: export.handler + register + preload**
|
||||
- [x] **Step 2: export.handler + register + preload**
|
||||
|
||||
```typescript
|
||||
EXPORT_FORMAT_CHAPTER: 'export:formatChapter',
|
||||
@@ -379,7 +379,7 @@ EXPORT_COPY_CLIPBOARD: 'export:copyClipboard',
|
||||
EXPORT_SAVE_TXT: 'export:saveTxt',
|
||||
```
|
||||
|
||||
- [ ] **Step 3: build**
|
||||
- [x] **Step 3: build**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
@@ -396,19 +396,19 @@ npm run build
|
||||
- Modify: `src/renderer/components/layout/EditorLayout.tsx`
|
||||
- Modify: `src/renderer/App.tsx`
|
||||
|
||||
- [ ] **Step 1: ExportModal**
|
||||
- [x] **Step 1: ExportModal**
|
||||
|
||||
- `data-testid="export-modal"`
|
||||
- `export-preset-select` 下拉(mergeSubmissionPresets)
|
||||
- 预览区:调用 `export.formatChapter` 显示前 500 字
|
||||
- `export-copy-clipboard` / `export-save-txt`
|
||||
|
||||
- [ ] **Step 2: SubmissionPresetTab**
|
||||
- [x] **Step 2: SubmissionPresetTab**
|
||||
|
||||
- `data-testid="settings-submission-presets"`
|
||||
- CRUD 自定义预设(不可删 builtin-webnovel)
|
||||
|
||||
- [ ] **Step 3: EditorLayout 工具栏 + App exportBook 快捷键**
|
||||
- [x] **Step 3: EditorLayout 工具栏 + App exportBook 快捷键**
|
||||
|
||||
```typescript
|
||||
if (action === 'exportBook') {
|
||||
@@ -420,7 +420,7 @@ if (action === 'exportBook') {
|
||||
|
||||
创建 `useExportStore`(open/close,类似 useGraphStore)。
|
||||
|
||||
- [ ] **Step 4: build**
|
||||
- [x] **Step 4: build**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
@@ -435,7 +435,7 @@ npm run build
|
||||
- Modify: `src/renderer/App.tsx`
|
||||
- Modify: `src/renderer/styles/layout.css`
|
||||
|
||||
- [ ] **Step 1: App.tsx 移除 view 限制**
|
||||
- [x] **Step 1: App.tsx 移除 view 限制**
|
||||
|
||||
```typescript
|
||||
if (action === 'captureInspiration') {
|
||||
@@ -444,13 +444,13 @@ if (action === 'captureInspiration') {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: InspirationModal**
|
||||
- [x] **Step 2: InspirationModal**
|
||||
|
||||
- 无 `bookId` 时显示 `inspiration-book-select`(`useBookStore.books` 按 `lastOpenedAt` 排序)
|
||||
- `handleSave` 删除 `switchTarget` / `setSidebarPanel`;改为 `showToast(t('inspiration.saved'))`
|
||||
- 添加 `inspiration-modal--capture` class
|
||||
|
||||
- [ ] **Step 3: E2E 预备 build**
|
||||
- [x] **Step 3: E2E 预备 build**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
@@ -464,7 +464,7 @@ npm run build
|
||||
- Create: `src/main/lib/chapter-splitter.ts`
|
||||
- Create: `tests/main/chapter-splitter.test.ts`
|
||||
|
||||
- [ ] **Step 1: splitPlainText**
|
||||
- [x] **Step 1: splitPlainText**
|
||||
|
||||
```typescript
|
||||
export function splitPlainText(
|
||||
@@ -478,9 +478,9 @@ export function splitPlainText(
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: UT-IMPORT-01** — 样本文本 3 章标题
|
||||
- [x] **Step 2: UT-IMPORT-01** — 样本文本 3 章标题
|
||||
|
||||
- [ ] **Step 3: 运行**
|
||||
- [x] **Step 3: 运行**
|
||||
|
||||
```bash
|
||||
npm run test -- tests/main/chapter-splitter.test.ts
|
||||
@@ -498,14 +498,14 @@ npm run test -- tests/main/chapter-splitter.test.ts
|
||||
- Modify: `src/preload/index.ts`
|
||||
- Create: `tests/main/import.service.test.ts`
|
||||
|
||||
- [ ] **Step 1: 安装依赖**
|
||||
- [x] **Step 1: 安装依赖**
|
||||
|
||||
```bash
|
||||
npm install mammoth marked --legacy-peer-deps
|
||||
npm install -D @types/marked --legacy-peer-deps
|
||||
```
|
||||
|
||||
- [ ] **Step 2: ImportService**
|
||||
- [x] **Step 2: ImportService**
|
||||
|
||||
```typescript
|
||||
export class ImportService {
|
||||
@@ -543,14 +543,14 @@ export class ImportService {
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: import.handler**
|
||||
- [x] **Step 3: import.handler**
|
||||
|
||||
- `IMPORT_PICK_FILE` / `IMPORT_PREVIEW` / `IMPORT_EXECUTE`
|
||||
- progress:`BrowserWindow.getAllWindows()[0]?.webContents.send(IPC.IMPORT_PROGRESS, { current, total })`
|
||||
|
||||
- [ ] **Step 4: IT-IMPORT-01**
|
||||
- [x] **Step 4: IT-IMPORT-01**
|
||||
|
||||
- [ ] **Step 5: 运行**
|
||||
- [x] **Step 5: 运行**
|
||||
|
||||
```bash
|
||||
npm run test -- tests/main/import.service.test.ts
|
||||
@@ -565,7 +565,7 @@ npm run test -- tests/main/import.service.test.ts
|
||||
- Modify: `src/renderer/components/home/HomePage.tsx`
|
||||
- Modify: `src/renderer/App.tsx`(挂载 ImportBookModal)
|
||||
|
||||
- [ ] **Step 1: ImportBookModal**
|
||||
- [x] **Step 1: ImportBookModal**
|
||||
|
||||
- `data-testid="import-book-modal"`
|
||||
- 浏览 → `import.pickFile`
|
||||
@@ -575,13 +575,13 @@ npm run test -- tests/main/import.service.test.ts
|
||||
- 执行 → 监听 `onImportProgress` → `import-progress-bar`
|
||||
- 成功 → `openBook(bookId)` + 切 editor
|
||||
|
||||
- [ ] **Step 2: HomePage 替换 comingSoon**
|
||||
- [x] **Step 2: HomePage 替换 comingSoon**
|
||||
|
||||
```typescript
|
||||
onClick={() => setImportOpen(true)}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: build**
|
||||
- [x] **Step 3: build**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
@@ -603,9 +603,9 @@ npm run build
|
||||
- Modify: `README.md`
|
||||
- Modify: `src/renderer/components/settings/SettingsPage.tsx`(关于页版本)
|
||||
|
||||
- [ ] **Step 1: i18n 键**(spec §8 全部)
|
||||
- [x] **Step 1: i18n 键**(spec §8 全部)
|
||||
|
||||
- [ ] **Step 2: CSS**
|
||||
- [x] **Step 2: CSS**
|
||||
|
||||
```css
|
||||
.template-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; }
|
||||
@@ -614,7 +614,7 @@ npm run build
|
||||
.inspiration-modal--capture { z-index: 10000; }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: E2E**
|
||||
- [x] **Step 3: E2E**
|
||||
|
||||
```typescript
|
||||
// E2E-TEMPL-01: 模板新建章 → ProseMirror 含模板正文
|
||||
@@ -623,7 +623,7 @@ npm run build
|
||||
// E2E-INSP-01: 主页 Ctrl+Shift+I → toast,仍在 home
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 全量验证**
|
||||
- [x] **Step 4: 全量验证**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
@@ -631,11 +631,11 @@ npm run test
|
||||
npm run test:e2e -- e2e/chapter-template.spec.ts e2e/import-book.spec.ts e2e/export-submission.spec.ts e2e/inspiration-capture.spec.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 版本 1.1.0**
|
||||
- [x] **Step 5: 版本 1.1.0**
|
||||
|
||||
`package.json` / `README.md` / Settings 关于页 → `v1.1.0`
|
||||
|
||||
- [ ] **Step 6: 勾选本 plan 全部 checkbox**
|
||||
- [x] **Step 6: 勾选本 plan 全部 checkbox**
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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 } from './helpers'
|
||||
|
||||
test.describe('Chapter template', () => {
|
||||
let userDataDir: string
|
||||
|
||||
test.beforeEach(() => {
|
||||
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-templ-'))
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
if (userDataDir && existsSync(userDataDir)) {
|
||||
try {
|
||||
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('E2E-TEMPL-01: quick create from template fills editor', async () => {
|
||||
const app = await launchApp(userDataDir)
|
||||
const page = await app.firstWindow()
|
||||
await skipOnboarding(page)
|
||||
await createBookAndOpenEditor(page, '模板测试书')
|
||||
|
||||
await page.getByTestId('chapter-quick-new').click()
|
||||
await expect(page.getByTestId('template-quick-create-modal')).toBeVisible()
|
||||
await page.getByTestId('template-chapter-title').fill('觉醒之路')
|
||||
await page.getByTestId('template-create-btn').click()
|
||||
await expect(page.getByTestId('template-quick-create-modal')).toBeHidden({ timeout: 10_000 })
|
||||
|
||||
const editor = page.locator('.ProseMirror')
|
||||
await expect(editor).toBeVisible({ timeout: 10_000 })
|
||||
await expect(editor).toContainText('觉醒之路', { timeout: 10_000 })
|
||||
await expect(editor).toContainText('第')
|
||||
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
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
|
||||
} from './helpers'
|
||||
|
||||
test.describe('Export submission', () => {
|
||||
let userDataDir: string
|
||||
|
||||
test.beforeEach(() => {
|
||||
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-export-'))
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
if (userDataDir && existsSync(userDataDir)) {
|
||||
try {
|
||||
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('E2E-EXPORT-01: copy submission format includes chapter heading', async () => {
|
||||
const app = await launchApp(userDataDir)
|
||||
const page = await app.firstWindow()
|
||||
await skipOnboarding(page)
|
||||
await createBookAndOpenEditor(page, '导出测试书')
|
||||
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 expect(page.getByTestId('export-preview')).toContainText(/# 第\d+章/, { timeout: 10_000 })
|
||||
await page.getByTestId('export-copy-clipboard').click()
|
||||
await expect(page.getByText('已复制到剪贴板')).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import path from 'path'
|
||||
import { _electron as electron, type ElectronApplication, type Page, expect } from '@playwright/test'
|
||||
|
||||
export const ROOT = path.join(import.meta.dirname, '..')
|
||||
|
||||
export async function launchApp(
|
||||
userDataDir: string,
|
||||
extraEnv: Record<string, string> = {}
|
||||
): Promise<ElectronApplication> {
|
||||
return electron.launch({
|
||||
args: [ROOT],
|
||||
env: { ...process.env, BILIN_E2E: '1', BILIN_E2E_USER_DATA: userDataDir, ...extraEnv }
|
||||
})
|
||||
}
|
||||
|
||||
export async function skipOnboarding(page: Page): Promise<void> {
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await expect(page.locator('#app')).toBeVisible({ timeout: 20_000 })
|
||||
if (await page.getByText('欢迎使用笔临').isVisible()) {
|
||||
await page.getByRole('button', { name: '跳过' }).click()
|
||||
}
|
||||
}
|
||||
|
||||
export async function dismissCockpitIfOpen(page: Page): Promise<void> {
|
||||
const cockpit = page.locator('[data-testid="cockpit-modal"]')
|
||||
if (await cockpit.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await page.locator('[data-testid="cockpit-modal"] .modal-close').click()
|
||||
await expect(cockpit).toBeHidden({ timeout: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBookAndOpenEditor(page: Page, name: string): Promise<void> {
|
||||
await page.getByRole('button', { name: /新建书籍/ }).first().click()
|
||||
await page.locator('.dialog-content input').first().fill(name)
|
||||
await page.getByRole('button', { name: '创建' }).click()
|
||||
await expect(page.locator('#editor-layout')).toBeVisible({ timeout: 15_000 })
|
||||
await dismissCockpitIfOpen(page)
|
||||
}
|
||||
|
||||
export async function ensureChapterEditor(page: Page): Promise<void> {
|
||||
const editor = page.locator('.ProseMirror')
|
||||
if (await editor.isVisible().catch(() => false)) return
|
||||
await page.getByRole('button', { name: '+ 新章' }).click()
|
||||
await expect(editor).toBeVisible({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
export async function openInspirationModal(page: Page): Promise<void> {
|
||||
await page.keyboard.press('Control+Shift+I')
|
||||
const modal = page.getByTestId('inspiration-modal')
|
||||
if (!(await modal.isVisible().catch(() => false))) {
|
||||
await page.evaluate(() => window.dispatchEvent(new Event('bilin:e2e-capture-inspiration')))
|
||||
}
|
||||
await expect(modal).toBeVisible({ timeout: 10_000 })
|
||||
}
|
||||
|
||||
export async function openExportModal(page: Page): Promise<void> {
|
||||
await page.keyboard.press('Control+Shift+E')
|
||||
const modal = page.getByTestId('export-modal')
|
||||
if (!(await modal.isVisible().catch(() => false))) {
|
||||
await page.getByTestId('open-export').click()
|
||||
}
|
||||
await expect(modal).toBeVisible({ timeout: 10_000 })
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { mkdtempSync, rmSync, existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { launchApp, skipOnboarding, ROOT, dismissCockpitIfOpen } from './helpers'
|
||||
|
||||
const FIXTURE = join(ROOT, 'tests/fixtures/import-three-chapters.txt')
|
||||
|
||||
test.describe('Import book', () => {
|
||||
let userDataDir: string
|
||||
|
||||
test.beforeEach(() => {
|
||||
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-import-'))
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
if (userDataDir && existsSync(userDataDir)) {
|
||||
try {
|
||||
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('E2E-IMPORT-01: import txt creates book with multiple chapters', async () => {
|
||||
const app = await launchApp(userDataDir, { BILIN_E2E_IMPORT_FILE: FIXTURE })
|
||||
const page = await app.firstWindow()
|
||||
await skipOnboarding(page)
|
||||
|
||||
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-file-path')).not.toHaveValue(/未选择|No file/)
|
||||
await page.getByTestId('import-book-name').fill('导入E2E书')
|
||||
await page.getByTestId('import-preview-btn').click()
|
||||
await expect(page.getByTestId('import-preview')).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(3, { timeout: 15_000 })
|
||||
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { mkdtempSync, rmSync, existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { test, expect } from '@playwright/test'
|
||||
import {
|
||||
launchApp,
|
||||
skipOnboarding,
|
||||
createBookAndOpenEditor,
|
||||
openInspirationModal
|
||||
} from './helpers'
|
||||
|
||||
test.describe('Inspiration capture', () => {
|
||||
let userDataDir: string
|
||||
|
||||
test.beforeEach(() => {
|
||||
userDataDir = mkdtempSync(join(tmpdir(), 'bilin-e2e-insp-'))
|
||||
})
|
||||
|
||||
test.afterEach(() => {
|
||||
if (userDataDir && existsSync(userDataDir)) {
|
||||
try {
|
||||
rmSync(userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 })
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('E2E-INSP-01: home Ctrl+Shift+I saves idea with toast, stays on home', async () => {
|
||||
const app = await launchApp(userDataDir)
|
||||
const page = await app.firstWindow()
|
||||
await skipOnboarding(page)
|
||||
await createBookAndOpenEditor(page, '灵感测试书')
|
||||
await page.locator('.tab.active').locator('span').last().click()
|
||||
await expect(page.locator('#home-page')).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await openInspirationModal(page)
|
||||
await page.getByTestId('inspiration-content-input').fill('主页捕获的灵感内容')
|
||||
await page.getByTestId('inspiration-save-btn').click()
|
||||
await expect(page.getByText('灵感已保存')).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.locator('#home-page')).toBeVisible()
|
||||
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -8,8 +8,10 @@ export default defineConfig({
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
index: resolve(__dirname, 'src/main/index.ts')
|
||||
}
|
||||
index: resolve(__dirname, 'src/main/index.ts'),
|
||||
'import-bootstrap': resolve(__dirname, 'src/main/import-bootstrap.ts')
|
||||
},
|
||||
external: ['mammoth', 'marked']
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+211
-9
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bilin",
|
||||
"version": "0.9.0",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bilin",
|
||||
"version": "0.9.0",
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.6",
|
||||
@@ -24,6 +24,8 @@
|
||||
"electron": "^36.9.0",
|
||||
"i18next": "^24.2.3",
|
||||
"jotai": "^2.12.1",
|
||||
"mammoth": "^1.12.0",
|
||||
"marked": "^15.0.12",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.4.1",
|
||||
@@ -34,6 +36,7 @@
|
||||
"@playwright/test": "^1.51.0",
|
||||
"@types/cytoscape": "^3.21.9",
|
||||
"@types/diff-match-patch": "^1.0.36",
|
||||
"@types/marked": "^6.0.0",
|
||||
"@types/node": "^22.13.0",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
@@ -3024,6 +3027,17 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/marked": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/@types/marked/-/marked-6.0.0.tgz",
|
||||
"integrity": "sha512-jmjpa4BwUsmhxcfsgUit/7A9KbrC48Q0q8KvnY107ogcjGgTFDlIL3RpihNpx2Mu1hM4mdFQjoVc4O6JoGKHsA==",
|
||||
"deprecated": "This is a stub types definition. marked provides its own type definitions, so you do not need this installed.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"marked": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
@@ -3575,7 +3589,6 @@
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -4213,9 +4226,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
|
||||
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cose-base": {
|
||||
"version": "1.0.3",
|
||||
@@ -4438,6 +4449,12 @@
|
||||
"integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dingbat-to-unicode": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
|
||||
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/dir-compare": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dir-compare/-/dir-compare-4.2.0.tgz",
|
||||
@@ -4554,6 +4571,15 @@
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/duck": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmmirror.com/duck/-/duck-0.1.12.tgz",
|
||||
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
|
||||
"license": "BSD",
|
||||
"dependencies": {
|
||||
"underscore": "^1.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -5600,6 +5626,12 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/imurmurhash": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||
@@ -5643,7 +5675,6 @@
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
@@ -5709,6 +5740,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/isbinaryfile": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-5.0.7.tgz",
|
||||
@@ -5880,6 +5917,48 @@
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip/node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip/node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -5902,6 +5981,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/linkifyjs": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
|
||||
@@ -5944,6 +6032,17 @@
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lop": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmmirror.com/lop/-/lop-0.4.2.tgz",
|
||||
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"duck": "^0.1.12",
|
||||
"option": "~0.2.1",
|
||||
"underscore": "^1.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
@@ -6060,6 +6159,81 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/mammoth": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.0.tgz",
|
||||
"integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@xmldom/xmldom": "^0.8.6",
|
||||
"argparse": "~1.0.3",
|
||||
"base64-js": "^1.5.1",
|
||||
"bluebird": "~3.4.0",
|
||||
"dingbat-to-unicode": "^1.0.1",
|
||||
"jszip": "^3.7.1",
|
||||
"lop": "^0.4.2",
|
||||
"path-is-absolute": "^1.0.0",
|
||||
"underscore": "^1.13.1",
|
||||
"xmlbuilder": "^10.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"mammoth": "bin/mammoth"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mammoth/node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.13",
|
||||
"resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
|
||||
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mammoth/node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/mammoth/node_modules/bluebird": {
|
||||
"version": "3.4.7",
|
||||
"resolved": "https://registry.npmmirror.com/bluebird/-/bluebird-3.4.7.tgz",
|
||||
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mammoth/node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/mammoth/node_modules/xmlbuilder": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
|
||||
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "15.0.12",
|
||||
"resolved": "https://registry.npmmirror.com/marked/-/marked-15.0.12.tgz",
|
||||
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/matcher": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz",
|
||||
@@ -6523,6 +6697,12 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/option": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/option/-/option-0.2.4.tgz",
|
||||
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/ora": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz",
|
||||
@@ -6601,11 +6781,16 @@
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -6789,6 +6974,12 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||
@@ -7457,6 +7648,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -7936,6 +8133,12 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/underscore": {
|
||||
"version": "1.13.8",
|
||||
"resolved": "https://registry.npmmirror.com/underscore/-/underscore-1.13.8.tgz",
|
||||
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
|
||||
@@ -8082,7 +8285,6 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bilin",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "笔临 - 长篇创作智能协作平台",
|
||||
"main": "./out/main/index.js",
|
||||
"type": "module",
|
||||
@@ -34,6 +34,8 @@
|
||||
"electron": "^36.9.0",
|
||||
"i18next": "^24.2.3",
|
||||
"jotai": "^2.12.1",
|
||||
"mammoth": "^1.12.0",
|
||||
"marked": "^15.0.12",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-i18next": "^15.4.1",
|
||||
@@ -44,6 +46,7 @@
|
||||
"@playwright/test": "^1.51.0",
|
||||
"@types/cytoscape": "^3.21.9",
|
||||
"@types/diff-match-patch": "^1.0.36",
|
||||
"@types/marked": "^6.0.0",
|
||||
"@types/node": "^22.13.0",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
|
||||
@@ -400,5 +400,62 @@
|
||||
"landmark.created": "Landmark inserted",
|
||||
"landmark.chapterOnly": "Insert landmarks in a chapter",
|
||||
"wordfreq.hint": "Top 100 words in current scope",
|
||||
"wordfreq.aiNaming": "AI naming suggestions"
|
||||
"wordfreq.aiNaming": "AI naming suggestions",
|
||||
"settings.templates": "Chapter Templates",
|
||||
"settings.submission": "Submission Presets",
|
||||
"dialog.edit": "Edit",
|
||||
"dialog.delete": "Delete",
|
||||
"template.title": "Chapter Templates",
|
||||
"template.quickNew": "New from Template",
|
||||
"template.builtin": "Built-in",
|
||||
"template.custom": "Custom",
|
||||
"template.selectTemplate": "Select template",
|
||||
"template.chapterTitle": "Chapter title",
|
||||
"template.chapterTitlePlaceholder": "Enter chapter title",
|
||||
"template.createChapter": "Create chapter",
|
||||
"template.add": "Add template",
|
||||
"template.newTemplate": "New template",
|
||||
"template.name": "Template name",
|
||||
"template.body": "Template body",
|
||||
"import.title": "Import Book",
|
||||
"import.pickFile": "Browse file",
|
||||
"import.noFile": "No file selected",
|
||||
"import.splitMode": "Split mode",
|
||||
"import.splitAuto": "Auto-detect chapters",
|
||||
"import.splitParagraph": "Split by blank lines",
|
||||
"import.splitRegex": "Custom regex",
|
||||
"import.customRegex": "Regex pattern",
|
||||
"import.preview": "Preview chapters",
|
||||
"import.chapterCount": "{{count}} chapter(s)",
|
||||
"import.largeFileConfirm": "Large file ({{size}}). Import may take 30s–2min. Continue?",
|
||||
"import.progress": "Importing {{current}}/{{total}}",
|
||||
"import.start": "Start import",
|
||||
"import.importing": "Importing…",
|
||||
"import.success": "Import complete",
|
||||
"import.failed": "Import failed",
|
||||
"export.title": "Export Chapter",
|
||||
"export.submissionPresets": "Submission preset",
|
||||
"export.copyClipboard": "Copy to clipboard",
|
||||
"export.saveTxt": "Save as text",
|
||||
"export.copied": "Copied to clipboard",
|
||||
"export.saved": "Saved to {{path}}",
|
||||
"export.preview": "Preview",
|
||||
"export.loading": "Loading…",
|
||||
"export.noPreview": "(no preview)",
|
||||
"submission.title": "Submission Presets",
|
||||
"submission.titleFormat": "Title format",
|
||||
"submission.indent": "Indent paragraphs",
|
||||
"submission.indentOn": "Indent: on",
|
||||
"submission.indentOff": "Indent: off",
|
||||
"submission.includeAuthorsNote": "Include author's note",
|
||||
"submission.paragraphGap": "Paragraph spacing",
|
||||
"submission.gapSingle": "Single spacing",
|
||||
"submission.gapDouble": "Double spacing",
|
||||
"submission.add": "Add preset",
|
||||
"submission.newPreset": "New preset",
|
||||
"submission.name": "Preset name",
|
||||
"submission.setDefault": "Set as default",
|
||||
"inspiration.selectBook": "Link to book",
|
||||
"inspiration.saved": "Idea saved",
|
||||
"inspiration.noBooks": "Create a book first to save ideas"
|
||||
}
|
||||
|
||||
@@ -400,5 +400,62 @@
|
||||
"landmark.created": "地标已插入",
|
||||
"landmark.chapterOnly": "请在章节中插入地标",
|
||||
"wordfreq.hint": "当前范围词频 Top 100",
|
||||
"wordfreq.aiNaming": "AI 命名建议"
|
||||
"wordfreq.aiNaming": "AI 命名建议",
|
||||
"settings.templates": "章节模板",
|
||||
"settings.submission": "投稿预设",
|
||||
"dialog.edit": "编辑",
|
||||
"dialog.delete": "删除",
|
||||
"template.title": "章节模板",
|
||||
"template.quickNew": "模板新建章",
|
||||
"template.builtin": "内置",
|
||||
"template.custom": "自定义",
|
||||
"template.selectTemplate": "选择模板",
|
||||
"template.chapterTitle": "章节标题",
|
||||
"template.chapterTitlePlaceholder": "输入本章标题",
|
||||
"template.createChapter": "创建章节",
|
||||
"template.add": "新建模板",
|
||||
"template.newTemplate": "新模板",
|
||||
"template.name": "模板名称",
|
||||
"template.body": "模板正文",
|
||||
"import.title": "导入书籍",
|
||||
"import.pickFile": "浏览文件",
|
||||
"import.noFile": "未选择文件",
|
||||
"import.splitMode": "分章规则",
|
||||
"import.splitAuto": "自动识别章节",
|
||||
"import.splitParagraph": "双换行分段",
|
||||
"import.splitRegex": "自定义正则",
|
||||
"import.customRegex": "正则表达式",
|
||||
"import.preview": "预览分章",
|
||||
"import.chapterCount": "共 {{count}} 章",
|
||||
"import.largeFileConfirm": "文件较大({{size}}),导入可能需要 30 秒至 2 分钟,是否继续?",
|
||||
"import.progress": "正在导入 {{current}}/{{total}}",
|
||||
"import.start": "开始导入",
|
||||
"import.importing": "导入中…",
|
||||
"import.success": "导入成功",
|
||||
"import.failed": "导入失败",
|
||||
"export.title": "导出章节",
|
||||
"export.submissionPresets": "投稿预设",
|
||||
"export.copyClipboard": "复制到剪贴板",
|
||||
"export.saveTxt": "另存为文本",
|
||||
"export.copied": "已复制到剪贴板",
|
||||
"export.saved": "已保存至 {{path}}",
|
||||
"export.preview": "预览",
|
||||
"export.loading": "加载中…",
|
||||
"export.noPreview": "(无预览)",
|
||||
"submission.title": "投稿预设",
|
||||
"submission.titleFormat": "标题格式",
|
||||
"submission.indent": "段首空两格",
|
||||
"submission.indentOn": "段首缩进:开",
|
||||
"submission.indentOff": "段首缩进:关",
|
||||
"submission.includeAuthorsNote": "包含作者的话",
|
||||
"submission.paragraphGap": "段落间距",
|
||||
"submission.gapSingle": "单倍行距",
|
||||
"submission.gapDouble": "双倍行距",
|
||||
"submission.add": "新建预设",
|
||||
"submission.newPreset": "新投稿预设",
|
||||
"submission.name": "预设名称",
|
||||
"submission.setDefault": "设为默认预设",
|
||||
"inspiration.selectBook": "关联书籍",
|
||||
"inspiration.saved": "灵感已保存",
|
||||
"inspiration.noBooks": "请先创建一本书再保存灵感"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { BookRegistryService } from './services/book-registry'
|
||||
import { registerImportHandlers } from './ipc/handlers/import.handler'
|
||||
|
||||
export { registerImportHandlers }
|
||||
|
||||
export function bootstrapImportHandlers(registry: BookRegistryService): void {
|
||||
registerImportHandlers(registry)
|
||||
}
|
||||
+4
-2
@@ -50,8 +50,10 @@ function createWindow(): void { mainWindow = new BrowserWindow({
|
||||
getNetworkMonitor()?.start(() => mainWindow)
|
||||
}
|
||||
|
||||
app.whenReady().then(() => {
|
||||
registerIpc()
|
||||
app.whenReady().then(async () => {
|
||||
const { books } = registerIpc()
|
||||
const { bootstrapImportHandlers } = await import('./import-bootstrap.js')
|
||||
bootstrapImportHandlers(books)
|
||||
|
||||
ipcMain.handle(IPC.WINDOW_MINIMIZE, (event) => {
|
||||
BrowserWindow.fromWebContents(event.sender)?.minimize()
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import { IPC } from '../../../shared/ipc-channels'
|
||||
import type { ImportExecuteParams, ImportSplitMode } from '../../../shared/types'
|
||||
import { ExportService } from '../../services/export.service'
|
||||
import type { BookRegistryService } from '../../services/book-registry'
|
||||
import type { GlobalSettingsService } from '../../services/global-settings'
|
||||
import { wrap } from '../result'
|
||||
|
||||
export function registerExportHandlers(
|
||||
registry: BookRegistryService,
|
||||
settings: GlobalSettingsService
|
||||
): void {
|
||||
const svc = new ExportService(registry, settings)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.EXPORT_FORMAT_CHAPTER,
|
||||
(
|
||||
_e,
|
||||
{
|
||||
bookId,
|
||||
chapterId,
|
||||
presetId
|
||||
}: { bookId: string; chapterId: string; presetId: string }
|
||||
) => wrap(() => svc.formatChapter(bookId, chapterId, presetId))
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.EXPORT_COPY_CLIPBOARD, (_e, { text }: { text: string }) =>
|
||||
wrap(() => {
|
||||
svc.copyToClipboard(text)
|
||||
})
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.EXPORT_SAVE_TXT,
|
||||
(_e, { text, defaultName }: { text: string; defaultName: string }) =>
|
||||
wrap(() => svc.saveTxt(text, defaultName))
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import { IPC } from '../../../shared/ipc-channels'
|
||||
import type { ImportExecuteParams, ImportSplitMode } from '../../../shared/types'
|
||||
import { ImportService } from '../../services/import.service'
|
||||
import type { BookRegistryService } from '../../services/book-registry'
|
||||
import { wrap } from '../result'
|
||||
|
||||
export function registerImportHandlers(registry: BookRegistryService): void {
|
||||
const svc = new ImportService(registry)
|
||||
|
||||
ipcMain.handle(IPC.IMPORT_PICK_FILE, () => wrap(() => svc.pickFile()))
|
||||
|
||||
ipcMain.handle(
|
||||
IPC.IMPORT_PREVIEW,
|
||||
(
|
||||
_e,
|
||||
{
|
||||
filePath,
|
||||
splitMode,
|
||||
customRegex
|
||||
}: { filePath: string; splitMode: ImportSplitMode; customRegex?: string }
|
||||
) => wrap(() => svc.preview(filePath, splitMode, customRegex))
|
||||
)
|
||||
|
||||
ipcMain.handle(IPC.IMPORT_EXECUTE, (_e, params: ImportExecuteParams) =>
|
||||
wrap(async () => {
|
||||
const bookId = await svc.execute(params, (current, total) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
win.webContents.send(IPC.IMPORT_PROGRESS, { current, total })
|
||||
}
|
||||
})
|
||||
return bookId
|
||||
})
|
||||
)
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import { registerAchievementHandlers } from './handlers/achievement.handler'
|
||||
import { registerGraphHandlers } from './handlers/graph.handler'
|
||||
import { registerAnalyticsHandlers } from './handlers/analytics.handler'
|
||||
import { registerBridgeHandlers } from './handlers/bridge.handler'
|
||||
import { registerExportHandlers } from './handlers/export.handler'
|
||||
import { AiClientService } from '../services/ai-client.service'
|
||||
import { NetworkMonitorService } from '../services/network-monitor.service'
|
||||
|
||||
@@ -89,6 +90,7 @@ export function registerIpc(): { settings: GlobalSettingsService; books: BookReg
|
||||
registerGraphHandlers(books)
|
||||
registerAnalyticsHandlers(books, writingLogs, writingSessionRepo, pomodoroDailyRepo)
|
||||
registerBridgeHandlers(books, aiClient)
|
||||
registerExportHandlers(books, settings)
|
||||
|
||||
return { settings, books, shortcuts: shortcutManager }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { ImportSplitMode } from '../../shared/types'
|
||||
|
||||
export interface SplitChapterPart {
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
|
||||
const CH_DI = '\u7b2c'
|
||||
const CH_ZHANG = '\u7ae0'
|
||||
const MARKDOWN_HEADING = /^#\s*.+/gm
|
||||
const CHINESE_CHAPTER = new RegExp(
|
||||
'^' + CH_DI + '[\\d\\u4e00-\\u9fff]+' + CH_ZHANG + '[^\\n]*',
|
||||
'gm'
|
||||
)
|
||||
const DEFAULT_TITLE = CH_DI + '\u4e00' + CH_ZHANG
|
||||
|
||||
function splitByRegex(text: string, regex: RegExp): SplitChapterPart[] {
|
||||
const matches = [...text.matchAll(regex)]
|
||||
if (matches.length === 0) return []
|
||||
|
||||
const parts: SplitChapterPart[] = []
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
const match = matches[i]!
|
||||
const title = match[0].replace(/^#\s*/, '').trim()
|
||||
const start = (match.index ?? 0) + match[0].length
|
||||
const end = i + 1 < matches.length ? matches[i + 1]!.index! : text.length
|
||||
const body = text.slice(start, end).trim()
|
||||
parts.push({ title: title.slice(0, 50), body })
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
function splitByParagraph(text: string): SplitChapterPart[] {
|
||||
const blocks = text.split(/\n\s*\n/).filter((b) => b.trim())
|
||||
return blocks.map((block) => {
|
||||
const lines = block.trim().split('\n')
|
||||
const title = (lines[0] ?? 'Untitled').trim().slice(0, 50)
|
||||
const body = (lines.slice(1).join('\n').trim() || lines[0]) ?? ''
|
||||
return { title, body: body || title }
|
||||
})
|
||||
}
|
||||
|
||||
export function splitPlainText(
|
||||
text: string,
|
||||
mode: ImportSplitMode,
|
||||
customRegex?: string
|
||||
): SplitChapterPart[] {
|
||||
const normalized = text.replace(/\r\n/g, '\n').trim()
|
||||
if (!normalized) return []
|
||||
|
||||
if (mode === 'paragraph') {
|
||||
return splitByParagraph(normalized)
|
||||
}
|
||||
|
||||
if (mode === 'regex') {
|
||||
if (!customRegex?.trim()) return []
|
||||
try {
|
||||
const regex = new RegExp(customRegex, 'gm')
|
||||
return splitByRegex(normalized, regex)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
let parts = splitByRegex(normalized, MARKDOWN_HEADING)
|
||||
if (parts.length === 0) parts = splitByRegex(normalized, CHINESE_CHAPTER)
|
||||
if (parts.length === 0) {
|
||||
return [{ title: DEFAULT_TITLE, body: normalized }]
|
||||
}
|
||||
|
||||
const firstMatch = normalized.match(MARKDOWN_HEADING) ?? normalized.match(CHINESE_CHAPTER)
|
||||
if (firstMatch && (firstMatch.index ?? 0) > 0) {
|
||||
const preamble = normalized.slice(0, firstMatch.index).trim()
|
||||
if (preamble && parts[0]) {
|
||||
parts[0].body = preamble + '\n\n' + parts[0].body
|
||||
}
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { stripHtml } from '../services/word-count'
|
||||
import type { SubmissionPreset } from '../../shared/types'
|
||||
|
||||
export interface FormatChapterInput {
|
||||
chapterNumber: number
|
||||
chapterTitle: string
|
||||
contentHtml: string
|
||||
preset: SubmissionPreset
|
||||
}
|
||||
|
||||
export function formatChapterForSubmission(input: FormatChapterInput): string {
|
||||
let body = input.contentHtml
|
||||
.replace(/<\/p>/gi, '\n')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
body = stripHtml(body)
|
||||
if (!input.preset.includeAuthorsNote) {
|
||||
body = body.split(/\n\u2014\u2014\n/)[0] ?? body
|
||||
}
|
||||
const paragraphs = body.split(/\n+/).filter((p) => p.trim())
|
||||
const indented = input.preset.indentParagraphs
|
||||
? paragraphs.map((p) => '\u3000\u3000' + p.trim())
|
||||
: paragraphs.map((p) => p.trim())
|
||||
const gap = input.preset.paragraphGap === 'double' ? '\n\n' : '\n'
|
||||
const title = input.preset.titleFormat
|
||||
.replace('{chapterNumber}', String(input.chapterNumber))
|
||||
.replace('{chapterTitle}', input.chapterTitle)
|
||||
return `${title}\n\n${indented.join(gap)}`
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { SubmissionPreset } from '../../shared/types'
|
||||
|
||||
export const MAIN_BUILTIN_SUBMISSION_PRESET: SubmissionPreset = {
|
||||
id: 'builtin-webnovel',
|
||||
name: 'builtin-webnovel',
|
||||
titleFormat: '# \u7b2c{chapterNumber}\u7ae0 {chapterTitle}',
|
||||
indentParagraphs: true,
|
||||
includeAuthorsNote: true,
|
||||
paragraphGap: 'double'
|
||||
}
|
||||
|
||||
export function mergeSubmissionPresetsForMain(
|
||||
custom: SubmissionPreset[] = []
|
||||
): SubmissionPreset[] {
|
||||
return [
|
||||
MAIN_BUILTIN_SUBMISSION_PRESET,
|
||||
...custom.filter((p) => p.id !== 'builtin-webnovel')
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { clipboard, dialog } from 'electron'
|
||||
import { writeFileSync } from 'fs'
|
||||
import {
|
||||
mergeSubmissionPresetsForMain,
|
||||
MAIN_BUILTIN_SUBMISSION_PRESET
|
||||
} from '../lib/submission-presets'
|
||||
import { ChapterRepository } from '../db/repositories/chapter.repo'
|
||||
import { formatChapterForSubmission } from '../lib/submission-formatter'
|
||||
import type { BookRegistryService } from './book-registry'
|
||||
import type { GlobalSettingsService } from './global-settings'
|
||||
|
||||
export class ExportService {
|
||||
constructor(
|
||||
private registry: BookRegistryService,
|
||||
private settings: GlobalSettingsService
|
||||
) {}
|
||||
|
||||
formatChapter(bookId: string, chapterId: string, presetId: string): string {
|
||||
const db = this.registry.getDb(bookId)
|
||||
const chapterRepo = new ChapterRepository(db)
|
||||
const chapter = chapterRepo.get(chapterId)
|
||||
if (!chapter) throw new Error('Chapter not found')
|
||||
|
||||
const presets = mergeSubmissionPresetsForMain(this.settings.get().submissionPresets)
|
||||
const preset =
|
||||
presets.find((p) => p.id === presetId) ?? MAIN_BUILTIN_SUBMISSION_PRESET
|
||||
|
||||
const volumeChapters = chapterRepo
|
||||
.list()
|
||||
.filter((c) => c.volumeId === chapter.volumeId)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
const chapterNumber = volumeChapters.findIndex((c) => c.id === chapterId) + 1
|
||||
|
||||
return formatChapterForSubmission({
|
||||
chapterNumber,
|
||||
chapterTitle: chapter.title,
|
||||
contentHtml: chapter.content,
|
||||
preset
|
||||
})
|
||||
}
|
||||
|
||||
copyToClipboard(text: string): void {
|
||||
clipboard.writeText(text)
|
||||
}
|
||||
|
||||
async saveTxt(text: string, defaultName: string): Promise<string | null> {
|
||||
const { canceled, filePath } = await dialog.showSaveDialog({
|
||||
defaultPath: defaultName,
|
||||
filters: [{ name: 'Text', extensions: ['txt'] }]
|
||||
})
|
||||
if (canceled || !filePath) return null
|
||||
writeFileSync(filePath, text, 'utf8')
|
||||
return filePath
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,10 @@ function defaults(): GlobalSettings {
|
||||
knowledgeExtractConfidenceThreshold: 0.8,
|
||||
pomodoroDurationMinutes: 25,
|
||||
enableSystemNotifications: false,
|
||||
enableGoalNotifications: true
|
||||
enableGoalNotifications: true,
|
||||
chapterTemplates: [],
|
||||
submissionPresets: [],
|
||||
defaultSubmissionPresetId: 'builtin-webnovel'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +67,9 @@ export class GlobalSettingsService {
|
||||
enableSystemNotifications: raw.enableSystemNotifications ?? false,
|
||||
enableGoalNotifications: raw.enableGoalNotifications ?? true,
|
||||
dailyGoalNotifiedDate: raw.dailyGoalNotifiedDate,
|
||||
chapterTemplates: raw.chapterTemplates ?? [],
|
||||
submissionPresets: raw.submissionPresets ?? [],
|
||||
defaultSubmissionPresetId: raw.defaultSubmissionPresetId ?? 'builtin-webnovel',
|
||||
shortcuts: { ...DEFAULT_SHORTCUTS, ...raw.shortcuts },
|
||||
aiConfig: { ...DEFAULT_AI_CONFIG, ...raw.aiConfig }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { basename } from 'node:path'
|
||||
import { readFileSync, statSync } from 'node:fs'
|
||||
import { dialog } from 'electron'
|
||||
import type {
|
||||
ImportExecuteParams,
|
||||
ImportPreviewResult,
|
||||
ImportSplitMode
|
||||
} from '../../shared/types'
|
||||
import { splitPlainText } from '../lib/chapter-splitter'
|
||||
import { ChapterRepository } from '../db/repositories/chapter.repo'
|
||||
import { VolumeRepository } from '../db/repositories/volume.repo'
|
||||
import { countWords, stripHtml } from './word-count'
|
||||
import type { BookRegistryService } from './book-registry'
|
||||
|
||||
function plainTextToEditorHtml(text: string): string {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return '<p></p>'
|
||||
return trimmed
|
||||
.split(/\n{2,}/)
|
||||
.map((p) => {
|
||||
const safe = p
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>')
|
||||
return '<p>' + safe + '</p>'
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
async function readHtmlFromFile(filePath: string): Promise<string> {
|
||||
const lower = filePath.toLowerCase()
|
||||
if (lower.endsWith('.docx')) {
|
||||
const mod = await import('mammoth')
|
||||
return (await mod.default.convertToHtml({ path: filePath })).value
|
||||
}
|
||||
const raw = readFileSync(filePath, 'utf8')
|
||||
if (lower.endsWith('.md')) {
|
||||
const mod = await import('marked')
|
||||
return mod.marked.parse(raw) as string
|
||||
}
|
||||
return plainTextToEditorHtml(raw)
|
||||
}
|
||||
|
||||
export class ImportService {
|
||||
constructor(private registry: BookRegistryService) {}
|
||||
|
||||
async pickFile(): Promise<{ canceled: boolean; filePath?: string }> {
|
||||
if (process.env.BILIN_E2E === '1' && process.env.BILIN_E2E_IMPORT_FILE) {
|
||||
return { canceled: false, filePath: process.env.BILIN_E2E_IMPORT_FILE }
|
||||
}
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog({
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'Import', extensions: ['txt', 'md', 'docx'] }]
|
||||
})
|
||||
if (canceled || filePaths.length === 0) return { canceled: true }
|
||||
return { canceled: false, filePath: filePaths[0] }
|
||||
}
|
||||
|
||||
async preview(
|
||||
filePath: string,
|
||||
splitMode: ImportSplitMode,
|
||||
customRegex?: string
|
||||
): Promise<ImportPreviewResult> {
|
||||
const stat = statSync(filePath)
|
||||
const html = await readHtmlFromFile(filePath)
|
||||
const plain = stripHtml(html)
|
||||
const parts = splitPlainText(plain, splitMode, customRegex)
|
||||
const warnings: string[] = []
|
||||
if (parts.length === 0) warnings.push('No chapters detected')
|
||||
|
||||
const chapters = parts.map((p) => {
|
||||
const contentHtml = plainTextToEditorHtml(p.body)
|
||||
return {
|
||||
title: p.title || 'Untitled',
|
||||
contentHtml,
|
||||
wordCount: countWords(contentHtml)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
fileName: basename(filePath),
|
||||
fileSizeBytes: stat.size,
|
||||
chapters,
|
||||
warnings
|
||||
}
|
||||
}
|
||||
|
||||
async execute(
|
||||
params: ImportExecuteParams,
|
||||
onProgress?: (current: number, total: number) => void
|
||||
): Promise<string> {
|
||||
const preview = await this.preview(params.filePath, params.splitMode, params.customRegex)
|
||||
if (preview.chapters.length === 0) throw new Error('No chapters to import')
|
||||
|
||||
const meta = this.registry.create({
|
||||
name: params.bookName,
|
||||
category: params.category,
|
||||
createSampleChapter: false
|
||||
})
|
||||
|
||||
try {
|
||||
const db = this.registry.getDb(meta.id)
|
||||
const volId = new VolumeRepository(db).list()[0]!.id
|
||||
const chapterRepo = new ChapterRepository(db)
|
||||
|
||||
for (let i = 0; i < preview.chapters.length; i++) {
|
||||
const ch = preview.chapters[i]!
|
||||
chapterRepo.create(volId, ch.title, i, ch.contentHtml)
|
||||
onProgress?.(i + 1, preview.chapters.length)
|
||||
}
|
||||
|
||||
return meta.id
|
||||
} catch (e) {
|
||||
this.registry.delete(meta.id)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
-1
@@ -53,7 +53,10 @@ import type {
|
||||
WritingAchievement,
|
||||
CharacterGraphData,
|
||||
CharacterRelationship,
|
||||
WritingAnalyticsSummary
|
||||
WritingAnalyticsSummary,
|
||||
ImportPreviewResult,
|
||||
ImportExecuteParams,
|
||||
ImportSplitMode
|
||||
} from '../shared/types'
|
||||
|
||||
const electronAPI = {
|
||||
@@ -543,6 +546,30 @@ const electronAPI = {
|
||||
getSummary: (bookId: string): Promise<IpcResult<WritingAnalyticsSummary>> =>
|
||||
ipcRenderer.invoke(IPC.ANALYTICS_SUMMARY, { bookId })
|
||||
},
|
||||
import: {
|
||||
pickFile: (): Promise<IpcResult<{ canceled: boolean; filePath?: string }>> =>
|
||||
ipcRenderer.invoke(IPC.IMPORT_PICK_FILE),
|
||||
preview: (
|
||||
filePath: string,
|
||||
splitMode: ImportSplitMode,
|
||||
customRegex?: string
|
||||
): Promise<IpcResult<ImportPreviewResult>> =>
|
||||
ipcRenderer.invoke(IPC.IMPORT_PREVIEW, { filePath, splitMode, customRegex }),
|
||||
execute: (params: ImportExecuteParams): Promise<IpcResult<string>> =>
|
||||
ipcRenderer.invoke(IPC.IMPORT_EXECUTE, params)
|
||||
},
|
||||
export: {
|
||||
formatChapter: (
|
||||
bookId: string,
|
||||
chapterId: string,
|
||||
presetId: string
|
||||
): Promise<IpcResult<string>> =>
|
||||
ipcRenderer.invoke(IPC.EXPORT_FORMAT_CHAPTER, { bookId, chapterId, presetId }),
|
||||
copyClipboard: (text: string): Promise<IpcResult<void>> =>
|
||||
ipcRenderer.invoke(IPC.EXPORT_COPY_CLIPBOARD, { text }),
|
||||
saveTxt: (text: string, defaultName: string): Promise<IpcResult<string | null>> =>
|
||||
ipcRenderer.invoke(IPC.EXPORT_SAVE_TXT, { text, defaultName })
|
||||
},
|
||||
bridge: {
|
||||
get: (
|
||||
bookId: string,
|
||||
@@ -609,6 +636,15 @@ const electronAPI = {
|
||||
}
|
||||
ipcRenderer.on(IPC.GOAL_NOTIFICATION, handler)
|
||||
return () => ipcRenderer.removeListener(IPC.GOAL_NOTIFICATION, handler)
|
||||
},
|
||||
onImportProgress: (
|
||||
callback: (payload: { current: number; total: number }) => void
|
||||
): (() => void) => {
|
||||
const handler = (_event: IpcRendererEvent, payload: { current: number; total: number }) => {
|
||||
callback(payload)
|
||||
}
|
||||
ipcRenderer.on(IPC.IMPORT_PROGRESS, handler)
|
||||
return () => ipcRenderer.removeListener(IPC.IMPORT_PROGRESS, handler)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-4
@@ -17,11 +17,13 @@ import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { SearchModal } from '@renderer/components/search/SearchModal'
|
||||
import { InspirationModal } from '@renderer/components/inspiration/InspirationModal'
|
||||
import { ImportBookModal } from '@renderer/components/import/ImportBookModal'
|
||||
import { useSearchStore } from '@renderer/stores/useSearchStore'
|
||||
import { useReferenceStore } from '@renderer/stores/useReferenceStore'
|
||||
import { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
||||
import { insertLandmarkInEditor } from '@renderer/lib/editor-commands'
|
||||
import { useAiStore } from '@renderer/stores/useAiStore'
|
||||
import { useExportStore } from '@renderer/stores/useExportStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
function AppInner(): React.JSX.Element {
|
||||
@@ -57,8 +59,6 @@ function AppInner(): React.JSX.Element {
|
||||
const openSearch = (): void => useSearchStore.getState().setOpen(true)
|
||||
const openLandmarks = (): void => useAppStore.getState().setLandmarksModalOpen(true)
|
||||
const openInspiration = (): void => {
|
||||
if (useAppStore.getState().view !== 'editor') return
|
||||
if (!useBookStore.getState().currentBookId) return
|
||||
useAppStore.getState().setInspirationModalOpen(true)
|
||||
}
|
||||
const onInsertLandmark = (e: Event): void => {
|
||||
@@ -148,11 +148,16 @@ function AppInner(): React.JSX.Element {
|
||||
return
|
||||
}
|
||||
if (action === 'captureInspiration') {
|
||||
if (view !== 'editor') return
|
||||
if (!useBookStore.getState().currentBookId) return
|
||||
useAppStore.getState().setInspirationModalOpen(true)
|
||||
return
|
||||
}
|
||||
if (action === 'exportBook') {
|
||||
if (view !== 'editor') return
|
||||
const target = useEditStore.getState().target
|
||||
if (target?.kind !== 'chapter') return
|
||||
useExportStore.getState().openModal()
|
||||
return
|
||||
}
|
||||
showToast(t('feature.comingSoon'))
|
||||
})
|
||||
return unsub
|
||||
@@ -173,6 +178,7 @@ function AppInner(): React.JSX.Element {
|
||||
{showOnboarding && <OnboardingWizard onComplete={handleOnboardingComplete} />}
|
||||
<SearchModal />
|
||||
<InspirationModal />
|
||||
<ImportBookModal />
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { mergeChapterTemplates } from '@shared/builtin-templates'
|
||||
import {
|
||||
bodyPlainToChapterHtml,
|
||||
replaceChapterTemplate,
|
||||
type TemplateContext
|
||||
} from '@shared/chapter-template'
|
||||
import type { ChapterTemplate } from '@shared/types'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
import { flushEditorSave } from '@renderer/components/editor/TipTapEditor'
|
||||
|
||||
interface TemplateQuickCreateModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function TemplateQuickCreateModal({
|
||||
open,
|
||||
onClose
|
||||
}: TemplateQuickCreateModalProps): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const penName = useSettingsStore((s) => s.penName)
|
||||
const customTemplates = useSettingsStore((s) => s.chapterTemplates)
|
||||
const {
|
||||
currentBookId,
|
||||
activeVolumeId,
|
||||
volumes,
|
||||
chapters,
|
||||
refreshChapters,
|
||||
setSelectedChapter
|
||||
} = useBookStore()
|
||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||
|
||||
const templates = useMemo(
|
||||
() => mergeChapterTemplates(customTemplates ?? []),
|
||||
[customTemplates]
|
||||
)
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string>(templates[0]?.id ?? '')
|
||||
const [chapterTitle, setChapterTitle] = useState('')
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setSelectedId(templates[0]?.id ?? '')
|
||||
setChapterTitle('')
|
||||
}, [open, templates])
|
||||
|
||||
const selectedTemplate = templates.find((tpl) => tpl.id === selectedId) ?? templates[0]
|
||||
|
||||
const buildContext = (title: string): TemplateContext => {
|
||||
const volChapters = chapters
|
||||
.filter((c) => c.volumeId === activeVolumeId)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
const chapterNumber = volChapters.length + 1
|
||||
const volumeName = volumes.find((v) => v.id === activeVolumeId)?.name ?? ''
|
||||
const previousChapterTitle =
|
||||
volChapters.length > 0 ? (volChapters[volChapters.length - 1]?.title ?? '') : ''
|
||||
return {
|
||||
chapterNumber,
|
||||
chapterTitle: title,
|
||||
penName,
|
||||
date: new Date().toLocaleDateString('zh-CN'),
|
||||
volumeName,
|
||||
previousChapterTitle,
|
||||
outlineItem: '',
|
||||
characterList: ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async (): Promise<void> => {
|
||||
if (!currentBookId || !activeVolumeId || !selectedTemplate || !chapterTitle.trim()) return
|
||||
setCreating(true)
|
||||
try {
|
||||
await flushEditorSave()
|
||||
const title = chapterTitle.trim()
|
||||
const ctx = buildContext(title)
|
||||
const bodyPlain = replaceChapterTemplate(selectedTemplate.body, ctx)
|
||||
const contentHtml = bodyPlainToChapterHtml(bodyPlain)
|
||||
|
||||
const ch = await ipcCall(() =>
|
||||
window.electronAPI.chapter.create(currentBookId, activeVolumeId, title)
|
||||
)
|
||||
const updated = await ipcCall(() =>
|
||||
window.electronAPI.chapter.update({
|
||||
bookId: currentBookId,
|
||||
chapterId: ch.id,
|
||||
content: contentHtml,
|
||||
...(selectedTemplate.defaultStatus ? { status: selectedTemplate.defaultStatus } : {})
|
||||
})
|
||||
)
|
||||
await refreshChapters()
|
||||
setSelectedChapter(updated.id)
|
||||
await switchTarget({ kind: 'chapter', id: updated.id })
|
||||
onClose()
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
id="templateModal"
|
||||
data-testid="template-quick-create-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('template.quickNew')}
|
||||
>
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h3>{t('template.quickNew')}</h3>
|
||||
<button type="button" className="modal-close" onClick={onClose}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<label className="form-label">{t('template.selectTemplate')}</label>
|
||||
<div className="template-grid">
|
||||
{templates.map((tpl: ChapterTemplate) => (
|
||||
<button
|
||||
key={tpl.id}
|
||||
type="button"
|
||||
className={`template-card ${tpl.id === selectedId ? 'active' : ''}`}
|
||||
data-testid={`template-card-${tpl.id}`}
|
||||
onClick={() => setSelectedId(tpl.id)}
|
||||
>
|
||||
<span className="template-card-name">{tpl.name}</span>
|
||||
{tpl.builtin && <span className="template-badge">{t('template.builtin')}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<label className="form-label">{t('template.chapterTitle')}</label>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
data-testid="template-chapter-title"
|
||||
value={chapterTitle}
|
||||
onChange={(e) => setChapterTitle(e.target.value)}
|
||||
placeholder={t('template.chapterTitlePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" onClick={onClose}>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="template-create-btn"
|
||||
disabled={!chapterTitle.trim() || creating}
|
||||
onClick={() => void handleCreate()}
|
||||
>
|
||||
{t('template.createChapter')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { mergeSubmissionPresets } from '@shared/builtin-templates'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useExportStore } from '@renderer/stores/useExportStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
export function ExportModal(): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const open = useExportStore((s) => s.open)
|
||||
const close = useExportStore((s) => s.close)
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const chapters = useBookStore((s) => s.chapters)
|
||||
const target = useEditStore((s) => s.target)
|
||||
const customPresets = useSettingsStore((s) => s.submissionPresets)
|
||||
const defaultPresetId = useSettingsStore((s) => s.defaultSubmissionPresetId ?? 'builtin-webnovel')
|
||||
|
||||
const presets = useMemo(() => mergeSubmissionPresets(customPresets ?? []), [customPresets])
|
||||
const chapterId = target?.kind === 'chapter' ? target.id : null
|
||||
const chapter = chapterId ? chapters.find((c) => c.id === chapterId) : null
|
||||
|
||||
const [presetId, setPresetId] = useState(defaultPresetId)
|
||||
const [preview, setPreview] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setPresetId(defaultPresetId)
|
||||
}, [open, defaultPresetId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !bookId || !chapterId) {
|
||||
setPreview('')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
void ipcCall(() => window.electronAPI.export.formatChapter(bookId, chapterId, presetId))
|
||||
.then((text) => setPreview(text.slice(0, 500)))
|
||||
.catch(() => setPreview(''))
|
||||
.finally(() => setLoading(false))
|
||||
}, [open, bookId, chapterId, presetId])
|
||||
|
||||
const handleCopy = async (): Promise<void> => {
|
||||
if (!bookId || !chapterId) return
|
||||
const text = await ipcCall(() =>
|
||||
window.electronAPI.export.formatChapter(bookId, chapterId, presetId)
|
||||
)
|
||||
await ipcCall(() => window.electronAPI.export.copyClipboard(text))
|
||||
showToast(t('export.copied'))
|
||||
}
|
||||
|
||||
const handleSaveTxt = async (): Promise<void> => {
|
||||
if (!bookId || !chapterId || !chapter) return
|
||||
const text = await ipcCall(() =>
|
||||
window.electronAPI.export.formatChapter(bookId, chapterId, presetId)
|
||||
)
|
||||
const defaultName = `${chapter.title}.txt`
|
||||
const saved = await ipcCall(() => window.electronAPI.export.saveTxt(text, defaultName))
|
||||
if (saved) {
|
||||
showToast(t('export.saved', { path: saved }))
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
id="exportModal"
|
||||
data-testid="export-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('export.title')}
|
||||
>
|
||||
<div className="modal-content">
|
||||
<div className="modal-header">
|
||||
<h3>
|
||||
{t('export.title')}
|
||||
{chapter ? ` — ${chapter.title}` : ''}
|
||||
</h3>
|
||||
<button type="button" className="modal-close" onClick={close}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<label className="form-label">{t('export.submissionPresets')}</label>
|
||||
<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>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" onClick={close}>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="export-save-txt"
|
||||
disabled={!chapterId}
|
||||
onClick={() => void handleSaveTxt()}
|
||||
>
|
||||
{t('export.saveTxt')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="export-copy-clipboard"
|
||||
disabled={!chapterId}
|
||||
onClick={() => void handleCopy()}
|
||||
>
|
||||
{t('export.copyClipboard')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -51,7 +51,12 @@ export function HomePage(): React.JSX.Element {
|
||||
<button type="button" className="btn-large primary" onClick={() => setShowDialog(true)}>
|
||||
+ {t('home.newBook')}
|
||||
</button>
|
||||
<button type="button" className="btn-large" onClick={() => showToast(t('feature.comingSoon'))}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-large"
|
||||
data-testid="home-import-book"
|
||||
onClick={() => useAppStore.getState().setImportBookModalOpen(true)}
|
||||
>
|
||||
{t('home.importBook')}
|
||||
</button>
|
||||
<button type="button" className="btn-large" onClick={() => showToast(t('feature.comingSoon'))}>
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ImportPreviewResult, ImportSplitMode } from '@shared/types'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useTabStore } from '@renderer/stores/useTabStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
const LARGE_FILE_BYTES = 20 * 1024 * 1024
|
||||
|
||||
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 ImportBookModal(): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const open = useAppStore((s) => s.importBookModalOpen)
|
||||
const setOpen = useAppStore((s) => s.setImportBookModalOpen)
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const setView = useAppStore((s) => s.setView)
|
||||
const { loadBooks, openBook } = useBookStore()
|
||||
const { openBookTab } = useTabStore()
|
||||
|
||||
const [filePath, setFilePath] = useState('')
|
||||
const [fileName, setFileName] = useState('')
|
||||
const [bookName, setBookName] = useState('')
|
||||
const [category, setCategory] = useState('玄幻')
|
||||
const [splitMode, setSplitMode] = useState<ImportSplitMode>('auto')
|
||||
const [customRegex, setCustomRegex] = useState('')
|
||||
const [preview, setPreview] = useState<ImportPreviewResult | null>(null)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [progress, setProgress] = useState<{ current: number; total: number } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setFilePath('')
|
||||
setFileName('')
|
||||
setBookName('')
|
||||
setCategory('玄幻')
|
||||
setSplitMode('auto')
|
||||
setCustomRegex('')
|
||||
setPreview(null)
|
||||
setProgress(null)
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const unsub = window.electronAPI.onImportProgress((payload) => {
|
||||
setProgress(payload)
|
||||
})
|
||||
return unsub
|
||||
}, [open])
|
||||
|
||||
const handlePickFile = async (): Promise<void> => {
|
||||
const result = await ipcCall(() => window.electronAPI.import.pickFile())
|
||||
if (result.canceled || !result.filePath) return
|
||||
setFilePath(result.filePath)
|
||||
const name = result.filePath.split(/[/\\]/).pop() ?? ''
|
||||
setFileName(name)
|
||||
const baseName = name.replace(/\.(txt|md|docx)$/i, '')
|
||||
if (!bookName.trim()) setBookName(baseName)
|
||||
}
|
||||
|
||||
const handlePreview = async (): Promise<void> => {
|
||||
if (!filePath) return
|
||||
const result = await ipcCall(() =>
|
||||
window.electronAPI.import.preview(filePath, splitMode, customRegex || undefined)
|
||||
)
|
||||
setPreview(result)
|
||||
}
|
||||
|
||||
const handleImport = async (): Promise<void> => {
|
||||
if (!filePath || !bookName.trim()) return
|
||||
if (preview && preview.fileSizeBytes > LARGE_FILE_BYTES) {
|
||||
const ok = confirm(
|
||||
t('import.largeFileConfirm', { size: formatFileSize(preview.fileSizeBytes) })
|
||||
)
|
||||
if (!ok) return
|
||||
}
|
||||
setImporting(true)
|
||||
setProgress(null)
|
||||
try {
|
||||
if (!preview) await handlePreview()
|
||||
const bookId = await ipcCall(() =>
|
||||
window.electronAPI.import.execute({
|
||||
filePath,
|
||||
bookName: bookName.trim(),
|
||||
category,
|
||||
splitMode,
|
||||
customRegex: customRegex || undefined
|
||||
})
|
||||
)
|
||||
await loadBooks()
|
||||
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 {
|
||||
showToast(t('import.failed'))
|
||||
} finally {
|
||||
setImporting(false)
|
||||
setProgress(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null
|
||||
|
||||
const previewTitles = preview?.chapters.slice(0, 5).map((ch) => ch.title) ?? []
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
id="importModal"
|
||||
data-testid="import-book-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('import.title')}
|
||||
>
|
||||
<div className="modal-content modal-content--wide">
|
||||
<div className="modal-header">
|
||||
<h3>{t('import.title')}</h3>
|
||||
<button type="button" className="modal-close" onClick={() => setOpen(false)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="import-file-row">
|
||||
<input
|
||||
className="form-control"
|
||||
readOnly
|
||||
value={fileName || t('import.noFile')}
|
||||
data-testid="import-file-path"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
data-testid="import-pick-file"
|
||||
onClick={() => void handlePickFile()}
|
||||
>
|
||||
{t('import.pickFile')}
|
||||
</button>
|
||||
</div>
|
||||
<label className="form-label">{t('dialog.newBook.name')}</label>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
data-testid="import-book-name"
|
||||
value={bookName}
|
||||
onChange={(e) => setBookName(e.target.value)}
|
||||
/>
|
||||
<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>
|
||||
<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 && (
|
||||
<div className="import-progress" data-testid="import-progress-bar">
|
||||
<div
|
||||
className="import-progress-fill"
|
||||
style={{ width: `${(progress.current / progress.total) * 100}%` }}
|
||||
/>
|
||||
<span>{t('import.progress', { current: progress.current, total: progress.total })}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button type="button" className="btn" onClick={() => setOpen(false)}>
|
||||
{t('dialog.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="import-execute-btn"
|
||||
disabled={!filePath || !bookName.trim() || importing}
|
||||
onClick={() => void handleImport()}
|
||||
>
|
||||
{importing ? t('import.importing') : t('import.start')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAppStore } from '@renderer/stores/useAppStore'
|
||||
import { useBookStore } from '@renderer/stores/useBookStore'
|
||||
import { useEditStore } from '@renderer/stores/useEditStore'
|
||||
import { ipcCall } from '@renderer/lib/ipc-client'
|
||||
|
||||
type SpeechRecognitionCtor = new () => SpeechRecognition
|
||||
@@ -19,24 +18,34 @@ export function InspirationModal(): React.JSX.Element | null {
|
||||
const { t } = useTranslation()
|
||||
const open = useAppStore((s) => s.inspirationModalOpen)
|
||||
const setOpen = useAppStore((s) => s.setInspirationModalOpen)
|
||||
const setSidebarPanel = useAppStore((s) => s.setSidebarPanel)
|
||||
const bookId = useBookStore((s) => s.currentBookId)
|
||||
const showToast = useAppStore((s) => s.showToast)
|
||||
const currentBookId = useBookStore((s) => s.currentBookId)
|
||||
const books = useBookStore((s) => s.books)
|
||||
const addInspirationLocal = useBookStore((s) => s.addInspirationLocal)
|
||||
const switchTarget = useEditStore((s) => s.switchTarget)
|
||||
|
||||
const sortedBooks = useMemo(
|
||||
() => [...books].sort((a, b) => new Date(b.lastOpenedAt).getTime() - new Date(a.lastOpenedAt).getTime()),
|
||||
[books]
|
||||
)
|
||||
|
||||
const [selectedBookId, setSelectedBookId] = useState<string>('')
|
||||
const [title, setTitle] = useState('')
|
||||
const [content, setContent] = useState('')
|
||||
const [tags, setTags] = useState('')
|
||||
const contentRef = useRef<HTMLTextAreaElement>(null)
|
||||
const speechAvailable = getSpeechRecognition() !== null
|
||||
|
||||
const effectiveBookId = currentBookId ?? (selectedBookId || sortedBooks[0]?.id || '')
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setTitle('')
|
||||
setContent('')
|
||||
setTags('')
|
||||
setSelectedBookId(currentBookId ?? sortedBooks[0]?.id ?? '')
|
||||
const timer = setTimeout(() => contentRef.current?.focus(), 100)
|
||||
return () => clearTimeout(timer)
|
||||
}, [open])
|
||||
}, [open, currentBookId, sortedBooks])
|
||||
|
||||
const handleVoice = (): void => {
|
||||
const SpeechRecognition = getSpeechRecognition()
|
||||
@@ -51,6 +60,7 @@ export function InspirationModal(): React.JSX.Element | null {
|
||||
}
|
||||
|
||||
const handleSave = async (): Promise<void> => {
|
||||
const bookId = effectiveBookId
|
||||
if (!bookId || !content.trim()) return
|
||||
const tagList = tags
|
||||
.split(/[,,]/)
|
||||
@@ -64,9 +74,10 @@ export function InspirationModal(): React.JSX.Element | null {
|
||||
tagList
|
||||
)
|
||||
)
|
||||
addInspirationLocal(item)
|
||||
setSidebarPanel('inspiration')
|
||||
await switchTarget({ kind: 'inspiration', id: item.id })
|
||||
if (bookId === currentBookId) {
|
||||
addInspirationLocal(item)
|
||||
}
|
||||
showToast(t('inspiration.saved'))
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
@@ -74,7 +85,7 @@ export function InspirationModal(): React.JSX.Element | null {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
className="modal-overlay inspiration-modal--capture"
|
||||
id="inspirationModal"
|
||||
data-testid="inspiration-modal"
|
||||
role="dialog"
|
||||
@@ -89,6 +100,26 @@ export function InspirationModal(): React.JSX.Element | null {
|
||||
</button>
|
||||
</div>
|
||||
<div className="modal-body inspiration-capture">
|
||||
{!currentBookId && sortedBooks.length > 0 && (
|
||||
<>
|
||||
<label className="form-label">{t('inspiration.selectBook')}</label>
|
||||
<select
|
||||
className="form-control form-control--wide"
|
||||
data-testid="inspiration-book-select"
|
||||
value={effectiveBookId}
|
||||
onChange={(e) => setSelectedBookId(e.target.value)}
|
||||
>
|
||||
{sortedBooks.map((book) => (
|
||||
<option key={book.id} value={book.id}>
|
||||
{book.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
{!currentBookId && sortedBooks.length === 0 && (
|
||||
<p className="import-hint">{t('inspiration.noBooks')}</p>
|
||||
)}
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
data-testid="inspiration-title-input"
|
||||
@@ -132,7 +163,7 @@ export function InspirationModal(): React.JSX.Element | null {
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="inspiration-save-btn"
|
||||
disabled={!content.trim()}
|
||||
disabled={!content.trim() || !effectiveBookId}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{t('inspiration.save')}
|
||||
|
||||
@@ -25,6 +25,9 @@ import { LandmarkModal } from '@renderer/components/landmark/LandmarkModal'
|
||||
import { CockpitModal } from '@renderer/components/cockpit/CockpitModal'
|
||||
import { CharacterGraphModal } from '@renderer/components/graph/CharacterGraphModal'
|
||||
import { ChapterBridgeModal } from '@renderer/components/bridge/ChapterBridgeModal'
|
||||
import { TemplateQuickCreateModal } from '@renderer/components/chapter/TemplateQuickCreateModal'
|
||||
import { ExportModal } from '@renderer/components/export/ExportModal'
|
||||
import { useExportStore } from '@renderer/stores/useExportStore'
|
||||
import {
|
||||
editorDirtyAtom,
|
||||
editorSavingAtom,
|
||||
@@ -117,6 +120,7 @@ export function EditorLayout(): React.JSX.Element {
|
||||
const documentTitle = useDocumentTitle()
|
||||
|
||||
const [bridgeOpen, setBridgeOpen] = useState(false)
|
||||
const [templateModalOpen, setTemplateModalOpen] = useState(false)
|
||||
const [cockpitSummary, setCockpitSummary] = useState<CockpitSummary | null>(null)
|
||||
|
||||
const bookMeta = useBookStore.getState().books.find((b) => b.id === currentBookId)
|
||||
@@ -148,10 +152,10 @@ export function EditorLayout(): React.JSX.Element {
|
||||
}, [currentBookId, pomodoroRunning, pomodoroBookId, pomodoroCancel])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedChapterId && (!target || target.kind === 'chapter')) {
|
||||
setTarget({ kind: 'chapter', id: selectedChapterId })
|
||||
}
|
||||
}, [selectedChapterId, setTarget, target])
|
||||
if (!selectedChapterId) return
|
||||
if (target?.kind === 'chapter' && target.id === selectedChapterId) return
|
||||
setTarget({ kind: 'chapter', id: selectedChapterId })
|
||||
}, [selectedChapterId, setTarget, target?.kind, target?.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentBookId || target?.kind !== 'chapter') return
|
||||
@@ -405,6 +409,14 @@ export function EditorLayout(): React.JSX.Element {
|
||||
<button type="button" onClick={() => void handleNewChapter()}>
|
||||
+ {t('editor.newChapter')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chapter-quick-new"
|
||||
style={{ marginTop: 6 }}
|
||||
onClick={() => setTemplateModalOpen(true)}
|
||||
>
|
||||
+ {t('template.quickNew')}
|
||||
</button>
|
||||
<button type="button" style={{ marginTop: 6 }} onClick={() => void handleNewVolume()}>
|
||||
+ {t('editor.newVolume')}
|
||||
</button>
|
||||
@@ -427,6 +439,16 @@ export function EditorLayout(): React.JSX.Element {
|
||||
) : (
|
||||
<>
|
||||
<div id="editor-toolbar">
|
||||
<button
|
||||
type="button"
|
||||
className="tool-btn"
|
||||
title={t('export.title')}
|
||||
data-testid="open-export"
|
||||
disabled={!isChapterTarget}
|
||||
onClick={() => useExportStore.getState().openModal()}
|
||||
>
|
||||
📦
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="tool-btn"
|
||||
@@ -572,6 +594,11 @@ export function EditorLayout(): React.JSX.Element {
|
||||
clearBridgeChapter()
|
||||
}}
|
||||
/>
|
||||
<TemplateQuickCreateModal
|
||||
open={templateModalOpen}
|
||||
onClose={() => setTemplateModalOpen(false)}
|
||||
/>
|
||||
<ExportModal />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { ThemeId, Language, PomodoroDuration } from '@shared/types'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
import { ShortcutEditor } from '@renderer/components/settings/ShortcutEditor'
|
||||
import { AiSettingsPage } from '@renderer/components/settings/AiSettingsPage'
|
||||
import { TemplateSettingsTab } from '@renderer/components/settings/TemplateSettingsTab'
|
||||
import { SubmissionPresetTab } from '@renderer/components/settings/SubmissionPresetTab'
|
||||
|
||||
const THEMES: { id: ThemeId; key: string }[] = [
|
||||
{ id: 'default', key: 'theme.default' },
|
||||
@@ -18,7 +20,9 @@ const THEMES: { id: ThemeId; key: string }[] = [
|
||||
export function SettingsPage(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const settings = useSettingsStore()
|
||||
const [section, setSection] = useState<'general' | 'ai' | 'shortcuts' | 'about'>('general')
|
||||
const [section, setSection] = useState<
|
||||
'general' | 'ai' | 'templates' | 'submission' | 'shortcuts' | 'about'
|
||||
>('general')
|
||||
|
||||
return (
|
||||
<div id="settings-page">
|
||||
@@ -40,6 +44,24 @@ export function SettingsPage(): React.JSX.Element {
|
||||
>
|
||||
{t('settings.ai')}
|
||||
</div>
|
||||
<div
|
||||
className={`settings-nav-item ${section === 'templates' ? 'active' : ''}`}
|
||||
onClick={() => setSection('templates')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid="settings-nav-templates"
|
||||
>
|
||||
{t('settings.templates')}
|
||||
</div>
|
||||
<div
|
||||
className={`settings-nav-item ${section === 'submission' ? 'active' : ''}`}
|
||||
onClick={() => setSection('submission')}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid="settings-nav-submission"
|
||||
>
|
||||
{t('settings.submission')}
|
||||
</div>
|
||||
<div
|
||||
className={`settings-nav-item ${section === 'shortcuts' ? 'active' : ''}`}
|
||||
onClick={() => setSection('shortcuts')}
|
||||
@@ -225,10 +247,12 @@ export function SettingsPage(): React.JSX.Element {
|
||||
</>
|
||||
)}
|
||||
{section === 'ai' && <AiSettingsPage />}
|
||||
{section === 'templates' && <TemplateSettingsTab />}
|
||||
{section === 'submission' && <SubmissionPresetTab />}
|
||||
{section === 'shortcuts' && <ShortcutEditor />}
|
||||
{section === 'about' && (
|
||||
<p style={{ color: 'var(--text-secondary)', lineHeight: 1.8 }}>
|
||||
{t('app.name')} v1.0.0
|
||||
{t('app.name')} v1.1.0
|
||||
<br />
|
||||
{t('app.tagline')}
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { mergeSubmissionPresets } from '@shared/builtin-templates'
|
||||
import type { GlobalSettings, SubmissionPreset } from '@shared/types'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
|
||||
const BUILTIN_ID = 'builtin-webnovel'
|
||||
|
||||
export function SubmissionPresetTab(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const customPresets = useSettingsStore((s) => s.submissionPresets ?? [])
|
||||
const defaultPresetId = useSettingsStore((s) => s.defaultSubmissionPresetId)
|
||||
const update = useSettingsStore((s) => s.update)
|
||||
const allPresets = useMemo(() => mergeSubmissionPresets(customPresets), [customPresets])
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
const editing = customPresets.find((p) => p.id === editingId) ?? null
|
||||
|
||||
const handleAdd = (): void => {
|
||||
const preset: SubmissionPreset = {
|
||||
id: uuid(),
|
||||
name: t('submission.newPreset'),
|
||||
titleFormat: '# 第{chapterNumber}章 {chapterTitle}',
|
||||
indentParagraphs: true,
|
||||
includeAuthorsNote: true,
|
||||
paragraphGap: 'double'
|
||||
}
|
||||
void update({ submissionPresets: [...customPresets, preset] })
|
||||
setEditingId(preset.id)
|
||||
}
|
||||
|
||||
const handleSaveEdit = (id: string, patch: Partial<SubmissionPreset>): void => {
|
||||
void update({
|
||||
submissionPresets: customPresets.map((p) => (p.id === id ? { ...p, ...patch } : p))
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = (id: string): void => {
|
||||
if (id === BUILTIN_ID) return
|
||||
const next = customPresets.filter((p) => p.id !== id)
|
||||
const patch: Partial<GlobalSettings> = { submissionPresets: next }
|
||||
if (defaultPresetId === id) {
|
||||
Object.assign(patch, { defaultSubmissionPresetId: BUILTIN_ID })
|
||||
}
|
||||
void update(patch)
|
||||
if (editingId === id) setEditingId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="settings-submission-presets">
|
||||
<h3 className="settings-subheading">{t('submission.title')}</h3>
|
||||
<div className="template-settings-list">
|
||||
{allPresets.map((preset) => (
|
||||
<div key={preset.id} className="template-settings-item">
|
||||
<div className="template-settings-header">
|
||||
<strong>{preset.name}</strong>
|
||||
{preset.id === BUILTIN_ID && (
|
||||
<span className="template-badge">{t('template.builtin')}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="submission-preset-meta">
|
||||
<span>{t('submission.titleFormat')}: {preset.titleFormat}</span>
|
||||
<span>
|
||||
{preset.indentParagraphs ? t('submission.indentOn') : t('submission.indentOff')}
|
||||
</span>
|
||||
</div>
|
||||
{preset.id !== BUILTIN_ID && (
|
||||
<div className="template-settings-actions">
|
||||
<button type="button" className="btn btn-sm" onClick={() => setEditingId(preset.id)}>
|
||||
{t('dialog.edit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => handleDelete(preset.id)}
|
||||
>
|
||||
{t('dialog.delete')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="setting-submission-add"
|
||||
style={{ marginTop: 12 }}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
+ {t('submission.add')}
|
||||
</button>
|
||||
|
||||
{editing && (
|
||||
<div className="template-edit-panel">
|
||||
<label className="form-label">{t('submission.name')}</label>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
value={editing.name}
|
||||
onChange={(e) => handleSaveEdit(editing.id, { name: e.target.value })}
|
||||
/>
|
||||
<label className="form-label">{t('submission.titleFormat')}</label>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
value={editing.titleFormat}
|
||||
onChange={(e) => handleSaveEdit(editing.id, { titleFormat: e.target.value })}
|
||||
/>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing.indentParagraphs}
|
||||
onChange={(e) => handleSaveEdit(editing.id, { indentParagraphs: e.target.checked })}
|
||||
/>
|
||||
{t('submission.indent')}
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editing.includeAuthorsNote}
|
||||
onChange={(e) =>
|
||||
handleSaveEdit(editing.id, { includeAuthorsNote: e.target.checked })
|
||||
}
|
||||
/>
|
||||
{t('submission.includeAuthorsNote')}
|
||||
</label>
|
||||
<label className="form-label">{t('submission.paragraphGap')}</label>
|
||||
<select
|
||||
className="form-control"
|
||||
value={editing.paragraphGap}
|
||||
onChange={(e) =>
|
||||
handleSaveEdit(editing.id, {
|
||||
paragraphGap: e.target.value as SubmissionPreset['paragraphGap']
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="single">{t('submission.gapSingle')}</option>
|
||||
<option value="double">{t('submission.gapDouble')}</option>
|
||||
</select>
|
||||
<label className="form-label" style={{ marginTop: 8 }}>
|
||||
<input
|
||||
type="radio"
|
||||
name="defaultPreset"
|
||||
checked={defaultPresetId === editing.id}
|
||||
onChange={() => void update({ defaultSubmissionPresetId: editing.id })}
|
||||
/>{' '}
|
||||
{t('submission.setDefault')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { mergeChapterTemplates } from '@shared/builtin-templates'
|
||||
import type { ChapterTemplate } from '@shared/types'
|
||||
import { useSettingsStore } from '@renderer/stores/useSettingsStore'
|
||||
|
||||
export function TemplateSettingsTab(): React.JSX.Element {
|
||||
const { t } = useTranslation()
|
||||
const customTemplates = useSettingsStore((s) => s.chapterTemplates ?? [])
|
||||
const update = useSettingsStore((s) => s.update)
|
||||
const allTemplates = useMemo(() => mergeChapterTemplates(customTemplates), [customTemplates])
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
const editing = customTemplates.find((tpl) => tpl.id === editingId) ?? null
|
||||
|
||||
const handleAdd = (): void => {
|
||||
const tpl: ChapterTemplate = {
|
||||
id: uuid(),
|
||||
name: t('template.newTemplate'),
|
||||
body: '## 第{chapterNumber}章 {chapterTitle}\n\n'
|
||||
}
|
||||
void update({ chapterTemplates: [...customTemplates, tpl] })
|
||||
setEditingId(tpl.id)
|
||||
}
|
||||
|
||||
const handleSaveEdit = (id: string, patch: Partial<ChapterTemplate>): void => {
|
||||
void update({
|
||||
chapterTemplates: customTemplates.map((tpl) => (tpl.id === id ? { ...tpl, ...patch } : tpl))
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = (id: string): void => {
|
||||
void update({ chapterTemplates: customTemplates.filter((tpl) => tpl.id !== id) })
|
||||
if (editingId === id) setEditingId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="settings-chapter-templates">
|
||||
<h3 className="settings-subheading">{t('template.title')}</h3>
|
||||
<div className="template-settings-list">
|
||||
{allTemplates.map((tpl) => (
|
||||
<div key={tpl.id} className="template-settings-item">
|
||||
<div className="template-settings-header">
|
||||
<strong>{tpl.name}</strong>
|
||||
{tpl.builtin ? (
|
||||
<span className="template-badge">{t('template.builtin')}</span>
|
||||
) : (
|
||||
<span className="template-badge template-badge--custom">{t('template.custom')}</span>
|
||||
)}
|
||||
</div>
|
||||
{!tpl.builtin && (
|
||||
<div className="template-settings-actions">
|
||||
<button type="button" className="btn btn-sm" onClick={() => setEditingId(tpl.id)}>
|
||||
{t('dialog.edit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => handleDelete(tpl.id)}
|
||||
>
|
||||
{t('dialog.delete')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<pre className="template-body-preview">{tpl.body}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
data-testid="setting-template-add"
|
||||
style={{ marginTop: 12 }}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
+ {t('template.add')}
|
||||
</button>
|
||||
|
||||
{editing && (
|
||||
<div className="template-edit-panel">
|
||||
<label className="form-label">{t('template.name')}</label>
|
||||
<input
|
||||
className="form-control form-control--wide"
|
||||
value={editing.name}
|
||||
onChange={(e) => handleSaveEdit(editing.id, { name: e.target.value })}
|
||||
/>
|
||||
<label className="form-label">{t('template.body')}</label>
|
||||
<textarea
|
||||
className="form-control form-control--wide"
|
||||
rows={6}
|
||||
value={editing.body}
|
||||
onChange={(e) => handleSaveEdit(editing.id, { body: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ interface AppState {
|
||||
versionModalOpen: boolean
|
||||
landmarksModalOpen: boolean
|
||||
inspirationModalOpen: boolean
|
||||
importBookModalOpen: boolean
|
||||
toast: string | null
|
||||
setView: (view: AppView) => void
|
||||
setSidebarPanel: (panel: AppState['sidebarPanel']) => void
|
||||
@@ -16,6 +17,7 @@ interface AppState {
|
||||
setVersionModalOpen: (open: boolean) => void
|
||||
setLandmarksModalOpen: (open: boolean) => void
|
||||
setInspirationModalOpen: (open: boolean) => void
|
||||
setImportBookModalOpen: (open: boolean) => void
|
||||
showToast: (message: string) => void
|
||||
clearToast: () => void
|
||||
}
|
||||
@@ -27,6 +29,7 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
versionModalOpen: false,
|
||||
landmarksModalOpen: false,
|
||||
inspirationModalOpen: false,
|
||||
importBookModalOpen: false,
|
||||
toast: null,
|
||||
setView: (view) => set({ view }),
|
||||
setSidebarPanel: (sidebarPanel) => set({ sidebarPanel }),
|
||||
@@ -34,6 +37,7 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
setVersionModalOpen: (versionModalOpen) => set({ versionModalOpen }),
|
||||
setLandmarksModalOpen: (landmarksModalOpen) => set({ landmarksModalOpen }),
|
||||
setInspirationModalOpen: (inspirationModalOpen) => set({ inspirationModalOpen }),
|
||||
setImportBookModalOpen: (importBookModalOpen) => set({ importBookModalOpen }),
|
||||
showToast: (toast) => {
|
||||
set({ toast })
|
||||
setTimeout(() => set({ toast: null }), 2500)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface ExportState {
|
||||
open: boolean
|
||||
openModal: () => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export const useExportStore = create<ExportState>((set) => ({
|
||||
open: false,
|
||||
openModal: () => set({ open: true }),
|
||||
close: () => set({ open: false })
|
||||
}))
|
||||
@@ -29,6 +29,9 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
pomodoroDurationMinutes: 25,
|
||||
enableSystemNotifications: false,
|
||||
enableGoalNotifications: true,
|
||||
chapterTemplates: [],
|
||||
submissionPresets: [],
|
||||
defaultSubmissionPresetId: 'builtin-webnovel',
|
||||
loaded: false,
|
||||
load: async () => {
|
||||
const data = await ipcCall(() => window.electronAPI.settings.get())
|
||||
|
||||
@@ -1987,3 +1987,144 @@
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.template-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.template-card.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.template-badge {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.template-badge--custom {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.template-settings-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.template-settings-item {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.template-body-preview {
|
||||
font-size: 11px;
|
||||
white-space: pre-wrap;
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.template-edit-panel {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.settings-subheading {
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.import-file-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.import-file-row .form-control {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.import-preview-list {
|
||||
font-size: 12px;
|
||||
max-height: 120px;
|
||||
overflow: auto;
|
||||
margin: 8px 0 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
.import-progress {
|
||||
margin-top: 12px;
|
||||
height: 20px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.import-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
transition: width 0.2s;
|
||||
}
|
||||
|
||||
.import-progress span {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.export-preview {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 11px;
|
||||
white-space: pre-wrap;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.inspiration-modal--capture {
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.submission-preset-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.import-hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"chapterTemplates": [
|
||||
{
|
||||
"id": "builtin-generic",
|
||||
"name": "通用模板",
|
||||
"body": "## 第{chapterNumber}章 {chapterTitle}\n\n",
|
||||
"builtin": true
|
||||
},
|
||||
{
|
||||
"id": "builtin-webnovel",
|
||||
"name": "网文常用",
|
||||
"body": "## 第{chapterNumber}章 {chapterTitle}\n\n\n\n——\n作者的话:\n",
|
||||
"builtin": true
|
||||
}
|
||||
],
|
||||
"submissionPresets": [
|
||||
{
|
||||
"id": "builtin-webnovel",
|
||||
"name": "通用网文",
|
||||
"titleFormat": "# 第{chapterNumber}章 {chapterTitle}",
|
||||
"indentParagraphs": true,
|
||||
"includeAuthorsNote": true,
|
||||
"paragraphGap": "double"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ChapterTemplate, SubmissionPreset } from './types'
|
||||
import builtinData from './builtin-templates.json'
|
||||
|
||||
export const BUILTIN_CHAPTER_TEMPLATES: ChapterTemplate[] = builtinData.chapterTemplates
|
||||
|
||||
export const BUILTIN_SUBMISSION_PRESETS: SubmissionPreset[] = builtinData.submissionPresets
|
||||
|
||||
export function mergeChapterTemplates(custom: ChapterTemplate[] = []): ChapterTemplate[] {
|
||||
return [...BUILTIN_CHAPTER_TEMPLATES, ...custom.filter((t) => !t.builtin)]
|
||||
}
|
||||
|
||||
export function mergeSubmissionPresets(custom: SubmissionPreset[] = []): SubmissionPreset[] {
|
||||
const customFiltered = custom.filter((p) => p.id !== 'builtin-webnovel')
|
||||
return [...BUILTIN_SUBMISSION_PRESETS, ...customFiltered]
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export interface TemplateContext {
|
||||
chapterNumber: number
|
||||
chapterTitle: string
|
||||
penName: string
|
||||
date: string
|
||||
volumeName: string
|
||||
previousChapterTitle: string
|
||||
outlineItem: string
|
||||
characterList: string
|
||||
}
|
||||
|
||||
const VARS = [
|
||||
'chapterNumber',
|
||||
'chapterTitle',
|
||||
'penName',
|
||||
'date',
|
||||
'volumeName',
|
||||
'previousChapterTitle',
|
||||
'outlineItem',
|
||||
'characterList'
|
||||
] as const
|
||||
|
||||
export function replaceChapterTemplate(body: string, ctx: TemplateContext): string {
|
||||
let out = body
|
||||
for (const key of VARS) {
|
||||
out = out.split(`{${key}}`).join(String(ctx[key]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function plainTextToEditorHtml(text: string): string {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return '<p></p>'
|
||||
return trimmed
|
||||
.split(/\n{2,}/)
|
||||
.map((p) => `<p>${escapeHtml(p).replace(/\n/g, '<br>')}</p>`)
|
||||
.join('')
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
export function bodyPlainToChapterHtml(bodyPlain: string): string {
|
||||
return plainTextToEditorHtml(bodyPlain)
|
||||
}
|
||||
Vendored
+23
-1
@@ -52,7 +52,10 @@ import type {
|
||||
PomodoroDuration,
|
||||
CharacterGraphData,
|
||||
CharacterRelationship,
|
||||
WritingAnalyticsSummary
|
||||
WritingAnalyticsSummary,
|
||||
ImportPreviewResult,
|
||||
ImportExecuteParams,
|
||||
ImportSplitMode
|
||||
} from './types'
|
||||
|
||||
export interface ElectronAPI {
|
||||
@@ -410,6 +413,24 @@ export interface ElectronAPI {
|
||||
analytics: {
|
||||
getSummary: (bookId: string) => Promise<IpcResult<WritingAnalyticsSummary>>
|
||||
}
|
||||
import: {
|
||||
pickFile: () => Promise<IpcResult<{ canceled: boolean; filePath?: string }>>
|
||||
preview: (
|
||||
filePath: string,
|
||||
splitMode: ImportSplitMode,
|
||||
customRegex?: string
|
||||
) => Promise<IpcResult<ImportPreviewResult>>
|
||||
execute: (params: ImportExecuteParams) => Promise<IpcResult<string>>
|
||||
}
|
||||
export: {
|
||||
formatChapter: (
|
||||
bookId: string,
|
||||
chapterId: string,
|
||||
presetId: string
|
||||
) => Promise<IpcResult<string>>
|
||||
copyClipboard: (text: string) => Promise<IpcResult<void>>
|
||||
saveTxt: (text: string, defaultName: string) => Promise<IpcResult<string | null>>
|
||||
}
|
||||
bridge: {
|
||||
get: (
|
||||
bookId: string,
|
||||
@@ -435,6 +456,7 @@ export interface ElectronAPI {
|
||||
onInteractiveStreamChunk: (callback: (payload: InteractiveStreamChunkEvent) => void) => () => void
|
||||
onPomodoroTick: (callback: (state: PomodoroState) => void) => () => void
|
||||
onGoalNotification: (callback: (payload: GoalNotificationPayload) => void) => () => void
|
||||
onImportProgress: (callback: (payload: { current: number; total: number }) => void) => () => void
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -130,6 +130,13 @@ export const IPC = {
|
||||
GRAPH_UPSERT_RELATIONSHIP: 'graph:upsertRelationship',
|
||||
GRAPH_DELETE_RELATIONSHIP: 'graph:deleteRelationship',
|
||||
ANALYTICS_SUMMARY: 'analytics:summary',
|
||||
IMPORT_PICK_FILE: 'import:pickFile',
|
||||
IMPORT_PREVIEW: 'import:preview',
|
||||
IMPORT_EXECUTE: 'import:execute',
|
||||
IMPORT_PROGRESS: 'import:progress',
|
||||
EXPORT_FORMAT_CHAPTER: 'export:formatChapter',
|
||||
EXPORT_COPY_CLIPBOARD: 'export:copyClipboard',
|
||||
EXPORT_SAVE_TXT: 'export:saveTxt',
|
||||
COCKPIT_SUMMARY: 'cockpit:summary',
|
||||
COCKPIT_MARK_SEEN: 'cockpit:markSeen',
|
||||
COCKPIT_SHOULD_SHOW: 'cockpit:shouldShowOnOpen',
|
||||
|
||||
@@ -198,6 +198,46 @@ export interface WritingAnalyticsSummary {
|
||||
volumeDistribution: { volumeId: string; title: string; words: number }[]
|
||||
}
|
||||
|
||||
export interface ChapterTemplate {
|
||||
id: string
|
||||
name: string
|
||||
body: string
|
||||
defaultStatus?: ChapterStatus
|
||||
builtin?: boolean
|
||||
}
|
||||
|
||||
export interface SubmissionPreset {
|
||||
id: string
|
||||
name: string
|
||||
titleFormat: string
|
||||
indentParagraphs: boolean
|
||||
includeAuthorsNote: boolean
|
||||
paragraphGap: 'single' | 'double'
|
||||
}
|
||||
|
||||
export type ImportSplitMode = 'auto' | 'paragraph' | 'regex'
|
||||
|
||||
export interface ImportChapterPreview {
|
||||
title: string
|
||||
contentHtml: string
|
||||
wordCount: number
|
||||
}
|
||||
|
||||
export interface ImportPreviewResult {
|
||||
fileName: string
|
||||
fileSizeBytes: number
|
||||
chapters: ImportChapterPreview[]
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface ImportExecuteParams {
|
||||
filePath: string
|
||||
bookName: string
|
||||
category: string
|
||||
splitMode: ImportSplitMode
|
||||
customRegex?: string
|
||||
}
|
||||
|
||||
export interface ChapterBridgeResult {
|
||||
previousChapterId: string | null
|
||||
previousTail: string
|
||||
@@ -246,6 +286,9 @@ export interface GlobalSettings {
|
||||
enableSystemNotifications?: boolean
|
||||
enableGoalNotifications?: boolean
|
||||
dailyGoalNotifiedDate?: string
|
||||
chapterTemplates?: ChapterTemplate[]
|
||||
submissionPresets?: SubmissionPreset[]
|
||||
defaultSubmissionPresetId?: string
|
||||
}
|
||||
|
||||
export interface BookMeta {
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# 第一章 开端
|
||||
|
||||
这是第一章的正文内容。
|
||||
|
||||
# 第二章 发展
|
||||
|
||||
这是第二章的正文内容。
|
||||
|
||||
# 第三章 结局
|
||||
|
||||
这是第三章的正文内容。
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { splitPlainText } from '../../src/main/lib/chapter-splitter'
|
||||
|
||||
const SAMPLE = `前言内容
|
||||
|
||||
# 第一章 开端
|
||||
|
||||
第一段正文。
|
||||
|
||||
# 第二章 发展
|
||||
|
||||
第二段正文。
|
||||
|
||||
# 第三章 结局
|
||||
|
||||
第三段正文。`
|
||||
|
||||
describe('chapter-splitter', () => {
|
||||
it('UT-IMPORT-01: auto split finds 3 chapters', () => {
|
||||
const parts = splitPlainText(SAMPLE, 'auto')
|
||||
expect(parts).toHaveLength(3)
|
||||
expect(parts[0]!.title).toContain('第一章')
|
||||
expect(parts[2]!.title).toContain('第三章')
|
||||
})
|
||||
|
||||
it('paragraph mode splits by blank lines', () => {
|
||||
const text = '标题一\n\n正文一\n\n标题二\n\n正文二'
|
||||
const parts = splitPlainText(text, 'paragraph')
|
||||
expect(parts.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { replaceChapterTemplate, plainTextToEditorHtml } from '../../src/shared/chapter-template'
|
||||
|
||||
const baseCtx = {
|
||||
chapterNumber: 1,
|
||||
chapterTitle: '测试',
|
||||
penName: '笔名',
|
||||
date: '2026-07-08',
|
||||
volumeName: '第一卷',
|
||||
previousChapterTitle: '上一章',
|
||||
outlineItem: '',
|
||||
characterList: '甲,乙'
|
||||
}
|
||||
|
||||
describe('chapter-template', () => {
|
||||
it('UT-TEMPL-01: replaces chapterNumber and chapterTitle', () => {
|
||||
const result = replaceChapterTemplate('第{chapterNumber}章 {chapterTitle}', {
|
||||
...baseCtx,
|
||||
chapterNumber: 5,
|
||||
chapterTitle: '觉醒'
|
||||
})
|
||||
expect(result).toBe('第5章 觉醒')
|
||||
})
|
||||
|
||||
it('UT-TEMPL-02: replaces volumeName', () => {
|
||||
const result = replaceChapterTemplate('卷:{volumeName}', {
|
||||
...baseCtx,
|
||||
volumeName: '第一卷 觉醒'
|
||||
})
|
||||
expect(result).toBe('卷:第一卷 觉醒')
|
||||
})
|
||||
|
||||
it('UT-TEMPL-03: replaces characterList', () => {
|
||||
const result = replaceChapterTemplate('出场:{characterList}', baseCtx)
|
||||
expect(result).toBe('出场:甲,乙')
|
||||
})
|
||||
|
||||
it('plainTextToEditorHtml wraps paragraphs', () => {
|
||||
const html = plainTextToEditorHtml('第一段\n\n第二段')
|
||||
expect(html).toContain('<p>')
|
||||
expect(html).toContain('第一段')
|
||||
expect(html).toContain('第二段')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { readFileSync, mkdtempSync, rmSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { BookRegistryService } from '../../src/main/services/book-registry'
|
||||
import { splitPlainText } from '../../src/main/lib/chapter-splitter'
|
||||
import { VolumeRepository } from '../../src/main/db/repositories/volume.repo'
|
||||
import { ChapterRepository } from '../../src/main/db/repositories/chapter.repo'
|
||||
import { closeAllBookDbs } from '../../src/main/db/connection'
|
||||
|
||||
const FIXTURE = join(import.meta.dirname, '../fixtures/import-three-chapters.txt')
|
||||
|
||||
describe('Import integration', () => {
|
||||
let dir: string
|
||||
let registry: BookRegistryService
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'bilin-import-'))
|
||||
registry = new BookRegistryService(dir)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
closeAllBookDbs()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('IT-IMPORT-01: split + create book chapters', () => {
|
||||
const parts = splitPlainText(readFileSync(FIXTURE, 'utf8'), 'auto')
|
||||
expect(parts).toHaveLength(3)
|
||||
|
||||
const meta = registry.create({
|
||||
name: '导入测试书',
|
||||
category: '玄幻',
|
||||
createSampleChapter: false
|
||||
})
|
||||
const db = registry.getDb(meta.id)
|
||||
const volId = new VolumeRepository(db).list()[0]!.id
|
||||
const chapterRepo = new ChapterRepository(db)
|
||||
parts.forEach((p, i) => {
|
||||
chapterRepo.create(volId, p.title, i, `<p>${p.body}</p>`)
|
||||
})
|
||||
|
||||
const opened = registry.open(meta.id)
|
||||
expect(opened.chapters).toHaveLength(3)
|
||||
expect(opened.chapters[0]!.title).toContain('第一章')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { formatChapterForSubmission } from '../../src/main/lib/submission-formatter'
|
||||
import { BUILTIN_SUBMISSION_PRESETS } from '../../src/shared/builtin-templates'
|
||||
|
||||
const preset = BUILTIN_SUBMISSION_PRESETS[0]!
|
||||
|
||||
describe('submission-formatter', () => {
|
||||
it('UT-EXPORT-01: indentParagraphs adds fullwidth spaces', () => {
|
||||
const text = formatChapterForSubmission({
|
||||
chapterNumber: 1,
|
||||
chapterTitle: '测试',
|
||||
contentHtml: '<p>第一段</p><p>第二段</p>',
|
||||
preset
|
||||
})
|
||||
expect(text).toContain(' 第一段')
|
||||
expect(text).toContain(' 第二段')
|
||||
})
|
||||
|
||||
it('UT-EXPORT-02: title format', () => {
|
||||
const text = formatChapterForSubmission({
|
||||
chapterNumber: 1,
|
||||
chapterTitle: '觉醒',
|
||||
contentHtml: '<p>正文</p>',
|
||||
preset
|
||||
})
|
||||
expect(text.startsWith('# 第1章 觉醒')).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user