diff --git a/README.md b/README.md index b37d8e1..5f712c1 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/superpowers/plans/2026-07-08-bilin-v11-writer-toolkit.md b/docs/superpowers/plans/2026-07-08-bilin-v11-writer-toolkit.md index 629c8de..8eb9b52 100644 --- a/docs/superpowers/plans/2026-07-08-bilin-v11-writer-toolkit.md +++ b/docs/superpowers/plans/2026-07-08-bilin-v11-writer-toolkit.md @@ -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** --- diff --git a/e2e/chapter-template.spec.ts b/e2e/chapter-template.spec.ts new file mode 100644 index 0000000..66ead59 --- /dev/null +++ b/e2e/chapter-template.spec.ts @@ -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() + }) +}) diff --git a/e2e/export-submission.spec.ts b/e2e/export-submission.spec.ts new file mode 100644 index 0000000..1c07132 --- /dev/null +++ b/e2e/export-submission.spec.ts @@ -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() + }) +}) diff --git a/e2e/helpers.ts b/e2e/helpers.ts new file mode 100644 index 0000000..ca33788 --- /dev/null +++ b/e2e/helpers.ts @@ -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 = {} +): Promise { + return electron.launch({ + args: [ROOT], + env: { ...process.env, BILIN_E2E: '1', BILIN_E2E_USER_DATA: userDataDir, ...extraEnv } + }) +} + +export async function skipOnboarding(page: Page): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }) +} diff --git a/e2e/import-book.spec.ts b/e2e/import-book.spec.ts new file mode 100644 index 0000000..e2a6085 --- /dev/null +++ b/e2e/import-book.spec.ts @@ -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() + }) +}) diff --git a/e2e/inspiration-capture.spec.ts b/e2e/inspiration-capture.spec.ts new file mode 100644 index 0000000..544c415 --- /dev/null +++ b/e2e/inspiration-capture.spec.ts @@ -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() + }) +}) diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 3d06975..ab984f4 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -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'] } } }, diff --git a/package-lock.json b/package-lock.json index 74f7c79..a0154c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 0f73c69..91bd1d6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index c9bd06d..0d1e9ec 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -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" } diff --git a/public/locales/zh-CN/translation.json b/public/locales/zh-CN/translation.json index af4e6f2..39849b8 100644 --- a/public/locales/zh-CN/translation.json +++ b/public/locales/zh-CN/translation.json @@ -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": "请先创建一本书再保存灵感" } diff --git a/src/main/import-bootstrap.ts b/src/main/import-bootstrap.ts new file mode 100644 index 0000000..1a15c13 --- /dev/null +++ b/src/main/import-bootstrap.ts @@ -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) +} diff --git a/src/main/index.ts b/src/main/index.ts index 992c8ed..8e1fc20 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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() diff --git a/src/main/ipc/handlers/export.handler.ts b/src/main/ipc/handlers/export.handler.ts new file mode 100644 index 0000000..f8625f6 --- /dev/null +++ b/src/main/ipc/handlers/export.handler.ts @@ -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)) + ) +} diff --git a/src/main/ipc/handlers/import.handler.ts b/src/main/ipc/handlers/import.handler.ts new file mode 100644 index 0000000..89d30a3 --- /dev/null +++ b/src/main/ipc/handlers/import.handler.ts @@ -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 + }) + ) +} diff --git a/src/main/ipc/register.ts b/src/main/ipc/register.ts index c6c6cab..66ef419 100644 --- a/src/main/ipc/register.ts +++ b/src/main/ipc/register.ts @@ -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 } } diff --git a/src/main/lib/chapter-splitter.ts b/src/main/lib/chapter-splitter.ts new file mode 100644 index 0000000..d5d4a5b --- /dev/null +++ b/src/main/lib/chapter-splitter.ts @@ -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 +} diff --git a/src/main/lib/submission-formatter.ts b/src/main/lib/submission-formatter.ts new file mode 100644 index 0000000..c92c8cf --- /dev/null +++ b/src/main/lib/submission-formatter.ts @@ -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(//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)}` +} diff --git a/src/main/lib/submission-presets.ts b/src/main/lib/submission-presets.ts new file mode 100644 index 0000000..ad611ca --- /dev/null +++ b/src/main/lib/submission-presets.ts @@ -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') + ] +} diff --git a/src/main/services/export.service.ts b/src/main/services/export.service.ts new file mode 100644 index 0000000..f3e1251 --- /dev/null +++ b/src/main/services/export.service.ts @@ -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 { + const { canceled, filePath } = await dialog.showSaveDialog({ + defaultPath: defaultName, + filters: [{ name: 'Text', extensions: ['txt'] }] + }) + if (canceled || !filePath) return null + writeFileSync(filePath, text, 'utf8') + return filePath + } +} diff --git a/src/main/services/global-settings.ts b/src/main/services/global-settings.ts index 4d0c8a1..5a3abf5 100644 --- a/src/main/services/global-settings.ts +++ b/src/main/services/global-settings.ts @@ -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 } } diff --git a/src/main/services/import.service.ts b/src/main/services/import.service.ts new file mode 100644 index 0000000..206feb9 --- /dev/null +++ b/src/main/services/import.service.ts @@ -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 '

' + return trimmed + .split(/\n{2,}/) + .map((p) => { + const safe = p + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
') + return '

' + safe + '

' + }) + .join('') +} + +async function readHtmlFromFile(filePath: string): Promise { + 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 { + 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 { + 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 + } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index f6e3238..4e76ef5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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> => ipcRenderer.invoke(IPC.ANALYTICS_SUMMARY, { bookId }) }, + import: { + pickFile: (): Promise> => + ipcRenderer.invoke(IPC.IMPORT_PICK_FILE), + preview: ( + filePath: string, + splitMode: ImportSplitMode, + customRegex?: string + ): Promise> => + ipcRenderer.invoke(IPC.IMPORT_PREVIEW, { filePath, splitMode, customRegex }), + execute: (params: ImportExecuteParams): Promise> => + ipcRenderer.invoke(IPC.IMPORT_EXECUTE, params) + }, + export: { + formatChapter: ( + bookId: string, + chapterId: string, + presetId: string + ): Promise> => + ipcRenderer.invoke(IPC.EXPORT_FORMAT_CHAPTER, { bookId, chapterId, presetId }), + copyClipboard: (text: string): Promise> => + ipcRenderer.invoke(IPC.EXPORT_COPY_CLIPBOARD, { text }), + saveTxt: (text: string, defaultName: string): Promise> => + 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) } } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index dabfd49..3007482 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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 && } + {toast &&
{toast}
} ) diff --git a/src/renderer/components/chapter/TemplateQuickCreateModal.tsx b/src/renderer/components/chapter/TemplateQuickCreateModal.tsx new file mode 100644 index 0000000..0823965 --- /dev/null +++ b/src/renderer/components/chapter/TemplateQuickCreateModal.tsx @@ -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(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 => { + 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 ( + + ) +} diff --git a/src/renderer/components/export/ExportModal.tsx b/src/renderer/components/export/ExportModal.tsx new file mode 100644 index 0000000..a3ad15a --- /dev/null +++ b/src/renderer/components/export/ExportModal.tsx @@ -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 => { + 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 => { + 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 ( + + ) +} diff --git a/src/renderer/components/home/HomePage.tsx b/src/renderer/components/home/HomePage.tsx index b53edc4..7673fbd 100644 --- a/src/renderer/components/home/HomePage.tsx +++ b/src/renderer/components/home/HomePage.tsx @@ -51,7 +51,12 @@ export function HomePage(): React.JSX.Element { - + +
+
+ + +
+ + setBookName(e.target.value)} + /> + + setCategory(e.target.value)} + /> + + + {splitMode === 'regex' && ( + <> + + setCustomRegex(e.target.value)} + placeholder="^第.+章" + /> + + )} + + {preview && ( +
+

+ {t('import.chapterCount', { count: preview.chapters.length })} + {preview.warnings.length > 0 && ` (${preview.warnings.join(', ')})`} +

+
    + {previewTitles.map((title, i) => ( +
  • {title}
  • + ))} +
+
+ )} + {progress && ( +
+
+ {t('import.progress', { current: progress.current, total: progress.total })} +
+ )} +
+
+ + +
+
+ + ) +} diff --git a/src/renderer/components/inspiration/InspirationModal.tsx b/src/renderer/components/inspiration/InspirationModal.tsx index 9a54774..d3dd100 100644 --- a/src/renderer/components/inspiration/InspirationModal.tsx +++ b/src/renderer/components/inspiration/InspirationModal.tsx @@ -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('') const [title, setTitle] = useState('') const [content, setContent] = useState('') const [tags, setTags] = useState('') const contentRef = useRef(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 => { + 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 (
+ {!currentBookId && sortedBooks.length > 0 && ( + <> + + + + )} + {!currentBookId && sortedBooks.length === 0 && ( +

{t('inspiration.noBooks')}

+ )} void handleSave()} > {t('inspiration.save')} diff --git a/src/renderer/components/layout/EditorLayout.tsx b/src/renderer/components/layout/EditorLayout.tsx index 2fa0abf..0bdf296 100644 --- a/src/renderer/components/layout/EditorLayout.tsx +++ b/src/renderer/components/layout/EditorLayout.tsx @@ -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(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 { + @@ -427,6 +439,16 @@ export function EditorLayout(): React.JSX.Element { ) : ( <>
+
) } diff --git a/src/renderer/components/settings/SettingsPage.tsx b/src/renderer/components/settings/SettingsPage.tsx index 79ae4d5..7272f3f 100644 --- a/src/renderer/components/settings/SettingsPage.tsx +++ b/src/renderer/components/settings/SettingsPage.tsx @@ -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 (
@@ -40,6 +44,24 @@ export function SettingsPage(): React.JSX.Element { > {t('settings.ai')}
+
setSection('templates')} + role="button" + tabIndex={0} + data-testid="settings-nav-templates" + > + {t('settings.templates')} +
+
setSection('submission')} + role="button" + tabIndex={0} + data-testid="settings-nav-submission" + > + {t('settings.submission')} +
setSection('shortcuts')} @@ -225,10 +247,12 @@ export function SettingsPage(): React.JSX.Element { )} {section === 'ai' && } + {section === 'templates' && } + {section === 'submission' && } {section === 'shortcuts' && } {section === 'about' && (

- {t('app.name')} v1.0.0 + {t('app.name')} v1.1.0
{t('app.tagline')}

diff --git a/src/renderer/components/settings/SubmissionPresetTab.tsx b/src/renderer/components/settings/SubmissionPresetTab.tsx new file mode 100644 index 0000000..89ea714 --- /dev/null +++ b/src/renderer/components/settings/SubmissionPresetTab.tsx @@ -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(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): 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 = { submissionPresets: next } + if (defaultPresetId === id) { + Object.assign(patch, { defaultSubmissionPresetId: BUILTIN_ID }) + } + void update(patch) + if (editingId === id) setEditingId(null) + } + + return ( +
+

{t('submission.title')}

+
+ {allPresets.map((preset) => ( +
+
+ {preset.name} + {preset.id === BUILTIN_ID && ( + {t('template.builtin')} + )} +
+
+ {t('submission.titleFormat')}: {preset.titleFormat} + + {preset.indentParagraphs ? t('submission.indentOn') : t('submission.indentOff')} + +
+ {preset.id !== BUILTIN_ID && ( +
+ + +
+ )} +
+ ))} +
+ + + {editing && ( +
+ + handleSaveEdit(editing.id, { name: e.target.value })} + /> + + handleSaveEdit(editing.id, { titleFormat: e.target.value })} + /> + + + + + +
+ )} +
+ ) +} diff --git a/src/renderer/components/settings/TemplateSettingsTab.tsx b/src/renderer/components/settings/TemplateSettingsTab.tsx new file mode 100644 index 0000000..155c28f --- /dev/null +++ b/src/renderer/components/settings/TemplateSettingsTab.tsx @@ -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(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): 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 ( +
+

{t('template.title')}

+
+ {allTemplates.map((tpl) => ( +
+
+ {tpl.name} + {tpl.builtin ? ( + {t('template.builtin')} + ) : ( + {t('template.custom')} + )} +
+ {!tpl.builtin && ( +
+ + +
+ )} +
{tpl.body}
+
+ ))} +
+ + + {editing && ( +
+ + handleSaveEdit(editing.id, { name: e.target.value })} + /> + +