31 Commits

Author SHA1 Message Date
akai d4136c53c7 Merge remote-tracking branch 'origin/codex/full-book-translation' into codex/readest-web-translation-base
CodeQL Advanced / Analyze (actions) (pull_request) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (pull_request) Waiting to run
CodeQL Advanced / Analyze (rust) (pull_request) Waiting to run
PR checks / rust_lint (pull_request) Waiting to run
PR checks / build_web_app (pull_request) Waiting to run
PR checks / test_web_app (1) (pull_request) Waiting to run
PR checks / test_web_app (2) (pull_request) Waiting to run
PR checks / test_extensions (pull_request) Waiting to run
PR checks / build_tauri_app (pull_request) Waiting to run
# Conflicts:
#	apps/readest-app/src/app/library/components/BookshelfItem.tsx
#	apps/readest-app/src/app/review-editor/ReviewEditorLauncher.tsx
#	apps/readest-app/src/services/reviewEditorService.ts
#	apps/readest-app/tools/epub-review-editor/MAINTENANCE.md
#	apps/readest-app/tools/epub-review-editor/README.md
#	apps/readest-app/tools/epub-review-editor/static/index.html
#	apps/readest-app/tools/epub-review-editor/version.py
#	scripts/open-readest-latest.ps1
2026-07-10 15:34:51 +08:00
akai 03c6969ce6 Merge remote-tracking branch 'origin/main' into codex/readest-web-translation-base
# Conflicts:
#	apps/readest-app/src/app/api/review-editor/launch/route.ts
#	apps/readest-app/src/app/library/components/BookshelfItem.tsx
#	apps/readest-app/src/app/reader/components/ReviewModeToggler.tsx
#	apps/readest-app/src/app/review-editor/ReviewEditorLauncher.tsx
#	apps/readest-app/src/services/reviewEditorService.ts
2026-07-10 15:03:01 +08:00
an c3a3a1d035 Merge pull request 'Fix web dev review editor launch' (#4) from web-dev-bug-fixed into main
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (rust) (push) Waiting to run
Publish Docker image / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Publish Docker image / merge (push) Blocked by required conditions
PR checks / rust_lint (push) Waiting to run
PR checks / build_web_app (push) Waiting to run
PR checks / test_web_app (1) (push) Waiting to run
PR checks / test_web_app (2) (push) Waiting to run
PR checks / test_extensions (push) Waiting to run
PR checks / build_tauri_app (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Deploy to vercel on merge / build_and_deploy (push) Waiting to run
Reviewed-on: #4
2026-07-10 05:17:32 +00:00
akai acb7dd4c93 fix: recover local Readest launcher 2026-07-10 12:52:06 +08:00
an 0d0732e579 fix: bootstrap local Readest dependencies 2026-07-10 12:44:43 +08:00
akai da756eed47 feat: secure inline review sidecar 2026-07-10 12:37:13 +08:00
an 3938c9c8bd chore: point launcher at full-book translation branch 2026-07-10 12:03:31 +08:00
an 64508fd336 feat: start reliable full-book translation workflow 2026-07-10 12:02:15 +08:00
akai 48383b3db6 fix: harden inline review web workflow 2026-07-10 11:56:25 +08:00
akai 47130f4fe3 refactor: remove legacy standalone review editor 2026-07-10 10:29:33 +08:00
akai 2c4085815e fix: improve responsive library sidebar 2026-07-10 09:20:29 +08:00
Codex a06b6183d7 Fix web dev review editor launch
CodeQL Advanced / Analyze (actions) (pull_request) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (pull_request) Waiting to run
CodeQL Advanced / Analyze (rust) (pull_request) Waiting to run
PR checks / rust_lint (pull_request) Waiting to run
PR checks / build_web_app (pull_request) Waiting to run
PR checks / test_web_app (1) (pull_request) Waiting to run
PR checks / test_web_app (2) (pull_request) Waiting to run
PR checks / test_extensions (pull_request) Waiting to run
PR checks / build_tauri_app (pull_request) Waiting to run
2026-07-10 00:42:10 +08:00
an 43ce37c61b Merge pull request 'Codex/bilingual epub filter' (#1) from codex/bilingual-epub-filter into main
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (rust) (push) Waiting to run
PR checks / rust_lint (push) Waiting to run
PR checks / build_web_app (push) Waiting to run
PR checks / test_web_app (1) (push) Waiting to run
PR checks / test_web_app (2) (push) Waiting to run
PR checks / test_extensions (push) Waiting to run
PR checks / build_tauri_app (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Deploy to vercel on merge / build_and_deploy (push) Waiting to run
Publish Docker image / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Publish Docker image / merge (push) Has been cancelled
Reviewed-on: #1
Reviewed-by: akai <1085932718@qq.com>
2026-07-09 13:30:40 +00:00
an 545c56966f Merge branch 'main' into codex/bilingual-epub-filter
Android E2E (CDP) / android-e2e (pull_request) Waiting to run
CodeQL Advanced / Analyze (actions) (pull_request) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (pull_request) Waiting to run
CodeQL Advanced / Analyze (rust) (pull_request) Waiting to run
PR checks / rust_lint (pull_request) Waiting to run
PR checks / build_web_app (pull_request) Waiting to run
PR checks / test_web_app (1) (pull_request) Waiting to run
PR checks / test_web_app (2) (pull_request) Waiting to run
PR checks / test_extensions (pull_request) Waiting to run
PR checks / build_tauri_app (pull_request) Waiting to run
2026-07-09 13:30:13 +00:00
an 2d97de18c1 Merge pull request 'Readest native inline review mode' (#3) from codex/readest-inline-review-mode into main
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (rust) (push) Waiting to run
PR checks / rust_lint (push) Waiting to run
PR checks / build_web_app (push) Waiting to run
PR checks / test_web_app (1) (push) Waiting to run
PR checks / test_web_app (2) (push) Waiting to run
PR checks / test_extensions (push) Waiting to run
PR checks / build_tauri_app (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Deploy to vercel on merge / build_and_deploy (push) Waiting to run
Publish Docker image / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Publish Docker image / merge (push) Has been cancelled
Reviewed-on: #3
2026-07-09 13:29:24 +00:00
akai 4993053d03 fix: clamp fitted library cover sizing
Android E2E (CDP) / android-e2e (pull_request) Has been cancelled
CodeQL Advanced / Analyze (actions) (pull_request) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (pull_request) Has been cancelled
CodeQL Advanced / Analyze (rust) (pull_request) Has been cancelled
PR checks / rust_lint (pull_request) Has been cancelled
PR checks / build_web_app (pull_request) Has been cancelled
PR checks / test_web_app (1) (pull_request) Has been cancelled
PR checks / test_web_app (2) (pull_request) Has been cancelled
PR checks / test_extensions (pull_request) Has been cancelled
PR checks / build_tauri_app (pull_request) Has been cancelled
2026-07-09 20:02:39 +08:00
akai 75293bd1a4 refactor: harden inline review panel layout
Android E2E (CDP) / android-e2e (pull_request) Has been cancelled
CodeQL Advanced / Analyze (actions) (pull_request) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (pull_request) Has been cancelled
CodeQL Advanced / Analyze (rust) (pull_request) Has been cancelled
PR checks / rust_lint (pull_request) Has been cancelled
PR checks / build_web_app (pull_request) Has been cancelled
PR checks / test_web_app (1) (pull_request) Has been cancelled
PR checks / test_web_app (2) (pull_request) Has been cancelled
PR checks / test_extensions (pull_request) Has been cancelled
PR checks / build_tauri_app (pull_request) Has been cancelled
2026-07-09 19:16:52 +08:00
akai 3b6d49c1ee fix: streamline inline review editing controls
CodeQL Advanced / Analyze (actions) (pull_request) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (pull_request) Has been cancelled
CodeQL Advanced / Analyze (rust) (pull_request) Has been cancelled
PR checks / rust_lint (pull_request) Has been cancelled
PR checks / build_web_app (pull_request) Has been cancelled
PR checks / test_web_app (1) (pull_request) Has been cancelled
PR checks / test_web_app (2) (pull_request) Has been cancelled
PR checks / test_extensions (pull_request) Has been cancelled
PR checks / build_tauri_app (pull_request) Has been cancelled
2026-07-09 17:32:43 +08:00
akai 783dd6c606 merge bilingual filter features into review mode 2026-07-09 16:48:19 +08:00
akai ebae9103d2 fix: enable floating review panel height resize 2026-07-09 16:30:52 +08:00
akai 0d8b2f9167 fix: restore review panel pin behavior 2026-07-09 16:04:08 +08:00
akai 987067414c fix: refine inline review panel layout 2026-07-09 15:13:14 +08:00
akai afd48837f9 feat: add inline review mode to reader 2026-07-09 14:15:36 +08:00
akai f7f10f5959 chore: add latest desktop launcher 2026-07-09 09:58:24 +08:00
Codex 14d1b35d87 Add expandable library sidebar filters
CodeQL Advanced / Analyze (actions) (pull_request) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (pull_request) Has been cancelled
CodeQL Advanced / Analyze (rust) (pull_request) Has been cancelled
PR checks / rust_lint (pull_request) Has been cancelled
PR checks / build_web_app (pull_request) Has been cancelled
PR checks / test_web_app (1) (pull_request) Has been cancelled
PR checks / test_web_app (2) (pull_request) Has been cancelled
PR checks / test_extensions (pull_request) Has been cancelled
PR checks / build_tauri_app (pull_request) Has been cancelled
2026-07-09 07:55:55 +08:00
akai fd41bcf420 feat: add native review editor desktop blocks 2026-07-09 00:50:58 +08:00
Codex 21cad7ec09 Fix web dev router without view transitions 2026-07-09 00:48:30 +08:00
Codex dc7137d331 Add bilingual action to book menu 2026-07-09 00:45:12 +08:00
Codex c4621a273e Add web bookshelf context menu 2026-07-09 00:38:04 +08:00
Codex c410b405b9 Fix web dev view transition timeout 2026-07-09 00:31:30 +08:00
Codex 94c35f999b Add bilingual EPUB filter 2026-07-08 23:00:44 +08:00
74 changed files with 8244 additions and 7081 deletions
@@ -1,25 +0,0 @@
# EPUB review editor migration
Date: 2026-07-08
This fork includes a first-stage migration of the Chinese/Japanese EPUB review editor into Readest.
Current shape:
- Bundled sidecar lives at `apps/readest-app/tools/epub-review-editor`.
- Readest route is `/review-editor`.
- Library menu entry is `Settings Menu -> Advanced Settings -> EPUB 审校器`.
- Dev launcher API is `POST /api/review-editor/launch`.
- Dev script is `pnpm epub-reviewer:dev`.
- Default runtime data is `apps/readest-app/epub_review_sessions/`; it is ignored by git. Override with `READEST_REVIEW_ROOT`.
- The automatic launcher is deliberately gated to local `dev-web`; packaged Tauri/static export needs a Rust command or packaged sidecar.
Important boundary:
- This is not a full native rewrite yet. The migrated tool still uses the proven Flask backend and static UI from the previous long-term translation project.
- Next/Tauri production static export will need a Tauri desktop command or packaged sidecar before this becomes a production desktop feature.
- Do not remove existing review-editor behavior while migrating: bookshelf, upload, bilingual review, glossary editing, GPT retranslate, full-book AI translation, soft delete, duplicate translation layer prevention, ruby preservation.
- If `/review-editor` shows `ERR_BLOCKED_BY_RESPONSE`, check both sides of cross-origin isolation: the Readest route keeps COEP `require-corp`, and the sidecar response should include local `frame-ancestors`, `Cross-Origin-Embedder-Policy: require-corp`, and `Cross-Origin-Resource-Policy: cross-origin`.
Next recommended steps:
1. Move launching from the Next dev API route into a Tauri desktop command.
2. Add per-book "Open in EPUB Reviewer" actions from Readest bookshelf/detail views.
3. Gradually port the review UI into React after the sidecar entry is stable.
+2
View File
@@ -15,3 +15,5 @@ export const SAMPLE_EPUB = path.join(
fixturesDir, fixturesDir,
'../../src/__tests__/fixtures/data/sample-alice.epub', '../../src/__tests__/fixtures/data/sample-alice.epub',
); );
export const REVIEW_EPUB = path.join(fixturesDir, 'books/readest-review-e2e.epub');
@@ -0,0 +1,31 @@
import fs from 'node:fs';
import path from 'node:path';
import { zipSync, strToU8 } from 'fflate';
const target = process.argv[2];
if (!target) throw new Error('output path is required');
const files = {
mimetype: strToU8('application/epub+zip'),
'META-INF/container.xml': strToU8(`<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>`),
'OEBPS/content.opf': strToU8(`<?xml version="1.0" encoding="UTF-8"?>
<package version="3.0" xmlns="http://www.idpf.org/2007/opf" unique-identifier="id">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="id">urn:readest:review-e2e</dc:identifier>
<dc:title>Readest Review E2E</dc:title><dc:language>zh-CN</dc:language>
</metadata>
<manifest><item id="chapter" href="chapter.xhtml" media-type="application/xhtml+xml"/></manifest>
<spine><itemref idref="chapter"/></spine>
</package>`),
'OEBPS/chapter.xhtml': strToU8(`<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml"><head><title>Chapter</title></head><body>
<p class="sourceText">これは校正テスト用の原文です。</p>
<p>这是网页审校测试译文。</p>
</body></html>`),
};
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, zipSync(files, { level: 0 }));
@@ -0,0 +1,62 @@
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test } from '../fixtures/base';
import { REVIEW_EPUB } from '../fixtures/books';
const fixtureDir = path.dirname(fileURLToPath(import.meta.url));
test.beforeAll(() => {
execFileSync(
process.execPath,
[path.join(fixtureDir, '../fixtures/create-review-epub.mjs'), REVIEW_EPUB],
{ stdio: 'inherit' },
);
});
test.afterAll(() => {
fs.rmSync(REVIEW_EPUB, { force: true });
});
test.describe('Inline review mode', () => {
test('opens the local sidecar, saves an edit, and resumes the same session', async ({
openBook,
page,
}) => {
test.skip(process.env['READEST_REVIEW_E2E'] !== '1', 'requires the local Python sidecar');
const reader = await openBook(REVIEW_EPUB);
await reader.revealHeader();
const toggle = page.getByRole('button', { name: 'Review Mode' });
await expect(toggle).toBeVisible();
await toggle.click();
const panel = page.getByRole('group', { name: '审校' });
await expect(panel).toBeVisible({ timeout: 120_000 });
await panel.getByRole('tab', { name: '整书翻译' }).click();
await expect(panel.getByRole('heading', { name: '整书翻译' })).toBeVisible();
await expect(panel.getByText(/个正文块/)).toBeVisible({ timeout: 30_000 });
await panel.getByRole('tab', { name: '逐段审校' }).click();
const editor = panel.locator('textarea').first();
await expect(editor).toHaveValue('这是网页审校测试译文。');
await editor.fill('这是已保存的网页审校测试译文。');
await panel.getByRole('button', { name: '保存', exact: true }).click();
await expect(panel.getByRole('status')).toContainText('已保存');
await panel.getByRole('button', { name: 'Close' }).click();
await expect(panel).toBeHidden();
await reader.revealHeader();
await page.getByRole('button', { name: 'Disable Review Mode' }).click();
await reader.revealHeader();
await page.getByRole('button', { name: 'Review Mode' }).click();
await expect(panel).toBeVisible({ timeout: 120_000 });
await expect(panel.locator('textarea').first()).toHaveValue('这是已保存的网页审校测试译文。');
const downloadPromise = page.waitForEvent('download');
await panel.getByRole('button', { name: '导出 EPUB' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.epub$/i);
});
});
+2 -3
View File
@@ -8,7 +8,6 @@
"build": "dotenv -e .env.tauri -- next build", "build": "dotenv -e .env.tauri -- next build",
"start": "dotenv -e .env.tauri -- next start", "start": "dotenv -e .env.tauri -- next start",
"dev-web": "dotenv -e .env.web -- next dev", "dev-web": "dotenv -e .env.web -- next dev",
"epub-reviewer:dev": "node scripts/epub-reviewer-dev.mjs",
"build-web": "dotenv -e .env.web -- next build", "build-web": "dotenv -e .env.web -- next build",
"start-web": "dotenv -e .env.web -- next start", "start-web": "dotenv -e .env.web -- next start",
"dev-web:vinext": "dotenv -e .env.web -- vinext dev", "dev-web:vinext": "dotenv -e .env.web -- vinext dev",
@@ -52,8 +51,8 @@
"copy-pdfjs-js": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build/{pdf.worker.min.mjs,pdf.min.mjs,pdf.d.mts}\" ./public/vendor/pdfjs", "copy-pdfjs-js": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build/{pdf.worker.min.mjs,pdf.min.mjs,pdf.d.mts}\" ./public/vendor/pdfjs",
"copy-pdfjs-wasm": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/wasm/*\" ./public/vendor/pdfjs", "copy-pdfjs-wasm": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/wasm/*\" ./public/vendor/pdfjs",
"copy-pdfjs-fonts": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/{cmaps,standard_fonts}/*\" ./public/vendor/pdfjs", "copy-pdfjs-fonts": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/{cmaps,standard_fonts}/*\" ./public/vendor/pdfjs",
"copy-flatten-pdfjs-annotation-layer-css": "npx postcss \"../../packages/foliate-js/vendor/pdfjs/annotation_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/annotation_layer_builder.css", "copy-flatten-pdfjs-annotation-layer-css": "pnpm exec postcss \"../../packages/foliate-js/vendor/pdfjs/annotation_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/annotation_layer_builder.css",
"copy-flatten-pdfjs-text-layer-css": "npx postcss \"../../packages/foliate-js/vendor/pdfjs/text_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/text_layer_builder.css", "copy-flatten-pdfjs-text-layer-css": "pnpm exec postcss \"../../packages/foliate-js/vendor/pdfjs/text_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/text_layer_builder.css",
"copy-flatten-pdfjs-css": "pnpm copy-flatten-pdfjs-annotation-layer-css && pnpm copy-flatten-pdfjs-text-layer-css", "copy-flatten-pdfjs-css": "pnpm copy-flatten-pdfjs-annotation-layer-css && pnpm copy-flatten-pdfjs-text-layer-css",
"copy-pdfjs": "pnpm copy-pdfjs-js && pnpm copy-pdfjs-wasm && pnpm copy-pdfjs-fonts && pnpm copy-flatten-pdfjs-css", "copy-pdfjs": "pnpm copy-pdfjs-js && pnpm copy-pdfjs-wasm && pnpm copy-pdfjs-fonts && pnpm copy-flatten-pdfjs-css",
"copy-simplecc": "cpx \"../../packages/simplecc-wasm/dist/web/*\" ./public/vendor/simplecc", "copy-simplecc": "cpx \"../../packages/simplecc-wasm/dist/web/*\" ./public/vendor/simplecc",
@@ -69,6 +69,11 @@
"Imported {{count}} annotations_one": "Imported {{count}} annotation", "Imported {{count}} annotations_one": "Imported {{count}} annotation",
"Imported {{count}} annotations_other": "Imported {{count}} annotations", "Imported {{count}} annotations_other": "Imported {{count}} annotations",
"Recently read": "Recently read", "Recently read": "Recently read",
"All Books": "All Books",
"Categories": "Categories",
"Category": "Category",
"Publication Year": "Publication Year",
"Bilingual": "Bilingual",
"Show recently read": "Show recently read", "Show recently read": "Show recently read",
"Your books will appear here": "Your books will appear here", "Your books will appear here": "Your books will appear here",
"{{count}} results_one": "{{count}} result", "{{count}} results_one": "{{count}} result",
@@ -9,7 +9,12 @@
"Apply": "应用", "Apply": "应用",
"Auto Mode": "自动主题", "Auto Mode": "自动主题",
"Behavior": "行为", "Behavior": "行为",
"Bilingual": "双语拆分",
"Book": "书籍", "Book": "书籍",
"All Books": "全部书籍",
"Categories": "分类",
"Category": "类别",
"Publication Year": "出版年份",
"Bookmark": "书签", "Bookmark": "书签",
"Cancel": "取消", "Cancel": "取消",
"Chapter": "章节", "Chapter": "章节",
@@ -9,7 +9,12 @@
"Apply": "應用", "Apply": "應用",
"Auto Mode": "自動主題", "Auto Mode": "自動主題",
"Behavior": "行為", "Behavior": "行為",
"Bilingual": "雙語拆分",
"Book": "書籍", "Book": "書籍",
"All Books": "全部書籍",
"Categories": "分類",
"Category": "類別",
"Publication Year": "出版年份",
"Bookmark": "書籤", "Bookmark": "書籤",
"Cancel": "取消", "Cancel": "取消",
"Chapter": "章節", "Chapter": "章節",
@@ -1,139 +0,0 @@
import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const appRoot = path.resolve(__dirname, '..');
const toolRoot = path.join(appRoot, 'tools', 'epub-review-editor');
const reviewRoot = path.resolve(process.env.READEST_REVIEW_ROOT || path.join(appRoot, 'epub_review_sessions'));
const defaultPort = Number(process.env.READEST_REVIEW_PORT || 5177);
const venvRoot = path.join(toolRoot, '.venv');
const venvPython =
process.platform === 'win32'
? path.join(venvRoot, 'Scripts', 'python.exe')
: path.join(venvRoot, 'bin', 'python');
const run = (command, args, options = {}) =>
spawnSync(command, args, {
cwd: options.cwd,
encoding: 'utf8',
timeout: options.timeout ?? 30_000,
windowsHide: true,
});
const pythonCandidates =
process.platform === 'win32'
? [
['py', ['-3']],
['python', []],
['python3', []],
]
: [
['python3', []],
['python', []],
];
const findPython = () => {
for (const [command, args] of pythonCandidates) {
const result = run(command, [...args, '--version'], { timeout: 10_000 });
if (result.status === 0) return { command, args };
}
throw new Error('Python 3 was not found.');
};
const requestJson = (port, pathname) =>
new Promise((resolve) => {
const req = http.get({ host: '127.0.0.1', port, path: pathname, timeout: 800 }, (res) => {
let body = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => {
try {
resolve(res.statusCode === 200 ? JSON.parse(body) : null);
} catch {
resolve(null);
}
});
});
req.on('timeout', () => {
req.destroy();
resolve(null);
});
req.on('error', () => resolve(null));
});
const findRunning = async () => {
for (let port = defaultPort; port < defaultPort + 100; port++) {
const payload = await requestJson(port, '/api/version');
if (payload?.version) return `http://localhost:${port}`;
}
return '';
};
if (!fs.existsSync(path.join(toolRoot, 'server.py'))) {
throw new Error(`Missing review editor server: ${path.join(toolRoot, 'server.py')}`);
}
const existing = await findRunning();
if (existing) {
console.log(`EPUB review editor is already running: ${existing}`);
process.exit(0);
}
if (!fs.existsSync(venvPython)) {
console.log('Creating review editor Python environment...');
const python = findPython();
const result = run(python.command, [...python.args, '-m', 'venv', venvRoot], {
cwd: appRoot,
timeout: 120_000,
});
if (result.status !== 0) {
throw new Error(result.stderr || result.stdout || 'Failed to create virtual environment.');
}
}
const flaskCheck = run(
venvPython,
['-c', 'import importlib.util, sys; sys.exit(0 if importlib.util.find_spec("flask") else 1)'],
{ cwd: toolRoot, timeout: 30_000 },
);
if (flaskCheck.status !== 0) {
console.log('Installing review editor dependencies...');
const install = run(venvPython, ['-m', 'pip', 'install', '-r', path.join(toolRoot, 'requirements.txt')], {
cwd: toolRoot,
timeout: 180_000,
});
if (install.status !== 0) {
throw new Error(install.stderr || install.stdout || 'Failed to install dependencies.');
}
}
fs.mkdirSync(reviewRoot, { recursive: true });
const child = spawn(
venvPython,
[
path.join(toolRoot, 'server.py'),
'--review-root',
reviewRoot,
'--host',
'127.0.0.1',
'--port',
String(defaultPort),
'--no-browser',
],
{
cwd: toolRoot,
stdio: 'inherit',
windowsHide: true,
},
);
child.on('exit', (code) => {
process.exit(code ?? 0);
});
+1 -1
View File
@@ -112,9 +112,9 @@ sentry = { version = "0.42", default-features = false, features = [
"rustls", "rustls",
] } ] }
tauri-plugin-sentry = "0.5" tauri-plugin-sentry = "0.5"
rand = "0.8"
[target."cfg(target_os = \"macos\")".dependencies] [target."cfg(target_os = \"macos\")".dependencies]
rand = "0.8"
cocoa = "0.25" cocoa = "0.25"
objc = "0.2.7" objc = "0.2.7"
objc-foundation = "0.1.1" objc-foundation = "0.1.1"
+4
View File
@@ -33,6 +33,8 @@ mod mobi_parser;
mod nightly_update; mod nightly_update;
mod parser_common; mod parser_common;
mod range_file; mod range_file;
#[cfg(desktop)]
mod review_editor;
mod sentry_config; mod sentry_config;
#[cfg(desktop)] #[cfg(desktop)]
mod spawn_fresh_browser; mod spawn_fresh_browser;
@@ -397,6 +399,8 @@ pub fn run() {
set_webview_info, set_webview_info,
#[cfg(desktop)] #[cfg(desktop)]
is_updater_disabled, is_updater_disabled,
#[cfg(desktop)]
review_editor::launch_epub_review_editor,
allow_paths_in_scopes, allow_paths_in_scopes,
dir_scanner::read_dir, dir_scanner::read_dir,
epub_parser::parse_epub_metadata, epub_parser::parse_epub_metadata,
@@ -0,0 +1,546 @@
use rand::RngCore;
use serde::Serialize;
use serde_json::Value;
use std::sync::Mutex;
use std::{
fs,
fs::OpenOptions,
io::{Read, Write},
net::{SocketAddr, TcpStream},
path::{Path, PathBuf},
process::Command,
time::Duration,
};
use tauri::{AppHandle, Manager};
const DEFAULT_PORT: u16 = 5177;
const PORT_SCAN_LIMIT: u16 = 100;
static LAUNCH_LOCK: Mutex<()> = Mutex::new(());
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReviewEditorLaunchResponse {
ok: bool,
url: String,
reused: bool,
review_root: String,
version: String,
session_id: Option<String>,
access_token: String,
}
struct CommandOutput {
stdout: String,
stderr: String,
}
struct PythonCommand {
program: String,
args: Vec<String>,
}
#[tauri::command]
pub async fn launch_epub_review_editor(
app: AppHandle,
epub_path: Option<String>,
) -> Result<ReviewEditorLaunchResponse, String> {
tauri::async_runtime::spawn_blocking(move || launch_epub_review_editor_sync(&app, epub_path))
.await
.map_err(|err| err.to_string())?
}
fn launch_epub_review_editor_sync(
app: &AppHandle,
epub_path: Option<String>,
) -> Result<ReviewEditorLaunchResponse, String> {
let _launch_guard = LAUNCH_LOCK
.lock()
.map_err(|_| "审校器启动锁已损坏。请重启 Readest 后重试。".to_string())?;
let tool_root = resolve_tool_root(app)?;
let server_path = tool_root.join("server.py");
if !server_path.exists() {
return Err(format!(
"没有找到审校器后端:{}",
server_path.to_string_lossy()
));
}
let review_root = std::env::var("READEST_REVIEW_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|_| {
app.path()
.app_data_dir()
.unwrap_or_else(|_| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
.join("epub_review_sessions")
});
fs::create_dir_all(&review_root).map_err(|err| {
format!(
"创建审校数据目录失败:{} ({err})",
review_root.to_string_lossy()
)
})?;
let access_token = ensure_access_token(&review_root)?;
let expected_version = read_expected_version(&tool_root);
if let Some(url) = find_running_editor(expected_version.as_deref(), &review_root, &access_token)
{
let session_id = ensure_session_from_path(&url, epub_path.as_deref(), &access_token)?;
return Ok(ReviewEditorLaunchResponse {
ok: true,
url,
reused: true,
review_root: review_root.to_string_lossy().to_string(),
version: expected_version.unwrap_or_default(),
session_id,
access_token,
});
}
let runtime_root = app
.path()
.app_data_dir()
.unwrap_or_else(|_| review_root.clone())
.join("epub-review-editor-runtime");
let venv_root = runtime_root.join(".venv");
let venv_python = venv_python_path(&venv_root);
ensure_venv(&venv_python, &venv_root)?;
ensure_dependencies(&venv_python, &tool_root)?;
let output = run_command(
&venv_python.to_string_lossy(),
&[
server_path.to_string_lossy().to_string(),
"--review-root".to_string(),
review_root.to_string_lossy().to_string(),
"--host".to_string(),
"127.0.0.1".to_string(),
"--port".to_string(),
DEFAULT_PORT.to_string(),
"--access-token".to_string(),
access_token.clone(),
"--daemon".to_string(),
],
Some(&tool_root),
)?;
let launched_url = extract_url(&output.stdout)
.or_else(|| find_running_editor(expected_version.as_deref(), &review_root, &access_token))
.ok_or_else(|| {
format!(
"审校器已启动但没有返回访问地址。\nstdout:\n{}\nstderr:\n{}",
output.stdout, output.stderr
)
})?;
let launched_url = launched_url.replace("http://localhost:", "http://127.0.0.1:");
let session_id = ensure_session_from_path(&launched_url, epub_path.as_deref(), &access_token)?;
Ok(ReviewEditorLaunchResponse {
ok: true,
url: launched_url,
reused: false,
review_root: review_root.to_string_lossy().to_string(),
version: expected_version.unwrap_or_default(),
session_id,
access_token,
})
}
fn ensure_access_token(review_root: &Path) -> Result<String, String> {
let token_path = review_root.join(".access-token");
if let Ok(existing) = fs::read_to_string(&token_path) {
let token = existing.trim();
if !token.is_empty() {
return Ok(token.to_string());
}
}
let mut bytes = [0_u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
let token = bytes
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let create_result = options
.open(&token_path)
.and_then(|mut file| file.write_all(token.as_bytes()));
match create_result {
Ok(()) => Ok(token),
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
fs::read_to_string(&token_path)
.map(|value| value.trim().to_string())
.map_err(|read_err| {
format!("Failed to read review sidecar access token: {read_err}")
})
.and_then(|value| {
if value.is_empty() {
Err("Review sidecar access token file is empty.".to_string())
} else {
Ok(value)
}
})
}
Err(err) => Err(format!(
"Failed to create review sidecar access token: {err}"
)),
}
}
fn resolve_tool_root(app: &AppHandle) -> Result<PathBuf, String> {
if let Ok(raw) = std::env::var("READEST_REVIEW_EDITOR_TOOL_ROOT") {
let path = PathBuf::from(raw);
if path.join("server.py").exists() {
return Ok(path);
}
}
if let Ok(resource_dir) = app.path().resource_dir() {
for candidate in [
resource_dir.join("tools").join("epub-review-editor"),
resource_dir
.join("apps")
.join("readest-app")
.join("tools")
.join("epub-review-editor"),
] {
if candidate.join("server.py").exists() {
return Ok(candidate);
}
}
}
#[cfg(debug_assertions)]
if let Ok(current) = std::env::current_dir() {
for ancestor in current.ancestors() {
for candidate in [
ancestor.join("tools").join("epub-review-editor"),
ancestor
.join("apps")
.join("readest-app")
.join("tools")
.join("epub-review-editor"),
] {
if candidate.join("server.py").exists() {
return Ok(candidate);
}
}
}
}
Err("没有找到已迁移的 EPUB 审校器资源目录。".to_string())
}
fn ensure_venv(venv_python: &Path, venv_root: &Path) -> Result<(), String> {
if venv_python.exists() {
return Ok(());
}
if let Some(parent) = venv_root.parent() {
fs::create_dir_all(parent).map_err(|err| {
format!(
"创建审校器运行目录失败:{} ({err})",
parent.to_string_lossy()
)
})?;
}
let python = resolve_python_command()?;
let mut args = python.args;
args.extend([
"-m".to_string(),
"venv".to_string(),
venv_root.to_string_lossy().to_string(),
]);
run_command(&python.program, &args, None)?;
Ok(())
}
fn ensure_dependencies(venv_python: &Path, tool_root: &Path) -> Result<(), String> {
let check = run_command_allow_failure(
&venv_python.to_string_lossy(),
&[
"-c".to_string(),
"import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('flask') else 1)"
.to_string(),
],
Some(tool_root),
)?;
if check.0 {
return Ok(());
}
let requirements = tool_root.join("requirements.txt");
run_command(
&venv_python.to_string_lossy(),
&[
"-m".to_string(),
"pip".to_string(),
"install".to_string(),
"-r".to_string(),
requirements.to_string_lossy().to_string(),
],
Some(tool_root),
)?;
Ok(())
}
fn venv_python_path(venv_root: &Path) -> PathBuf {
if cfg!(windows) {
venv_root.join("Scripts").join("python.exe")
} else {
venv_root.join("bin").join("python")
}
}
fn resolve_python_command() -> Result<PythonCommand, String> {
let candidates: Vec<PythonCommand> = if cfg!(windows) {
vec![
PythonCommand {
program: "py".to_string(),
args: vec!["-3".to_string()],
},
PythonCommand {
program: "python".to_string(),
args: vec![],
},
PythonCommand {
program: "python3".to_string(),
args: vec![],
},
]
} else {
vec![
PythonCommand {
program: "python3".to_string(),
args: vec![],
},
PythonCommand {
program: "python".to_string(),
args: vec![],
},
]
};
for candidate in candidates {
let mut args = candidate.args.clone();
args.push("--version".to_string());
if run_command_allow_failure(&candidate.program, &args, None)
.map(|(ok, _)| ok)
.unwrap_or(false)
{
return Ok(candidate);
}
}
Err("没有找到 Python 3。请先安装 Python 3,然后重新打开 EPUB 审校器。".to_string())
}
fn read_expected_version(tool_root: &Path) -> Option<String> {
let text = fs::read_to_string(tool_root.join("version.py")).ok()?;
let marker = "version = \"";
let start = text.find(marker)? + marker.len();
let rest = &text[start..];
Some(rest[..rest.find('"')?].to_string())
}
fn find_running_editor(
expected_version: Option<&str>,
review_root: &Path,
access_token: &str,
) -> Option<String> {
let expected_root = review_root
.canonicalize()
.unwrap_or_else(|_| review_root.to_path_buf());
for port in DEFAULT_PORT..(DEFAULT_PORT + PORT_SCAN_LIMIT) {
let Some(payload) = request_json(port, "/api/version", access_token) else {
continue;
};
let Some(version) = payload.get("version").and_then(Value::as_str) else {
continue;
};
if expected_version.map_or(true, |expected| expected == version) {
let running_root = payload
.get("review_root")
.and_then(Value::as_str)
.map(PathBuf::from);
let running_root_matches = running_root
.as_ref()
.and_then(|path| path.canonicalize().ok())
.map(|path| path == expected_root)
.unwrap_or(false);
if !running_root_matches {
continue;
}
return Some(format!("http://127.0.0.1:{port}"));
}
}
None
}
fn request_json(port: u16, path: &str, access_token: &str) -> Option<Value> {
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_millis(250)).ok()?;
let _ = stream.set_read_timeout(Some(Duration::from_millis(800)));
let _ = stream.set_write_timeout(Some(Duration::from_millis(800)));
let request = format!(
"GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer {access_token}\r\nConnection: close\r\n\r\n"
);
stream.write_all(request.as_bytes()).ok()?;
read_http_json_response(stream)
}
fn post_json(url: &str, path: &str, body: &str, access_token: &str) -> Result<Value, String> {
let port = parse_loopback_port(url)?;
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(3))
.map_err(|err| format!("连接审校器失败:{err}"))?;
let _ = stream.set_read_timeout(Some(Duration::from_secs(30)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(5)));
let request = format!(
"POST {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nAuthorization: Bearer {access_token}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(request.as_bytes())
.map_err(|err| format!("写入审校器请求失败:{err}"))?;
read_http_json_response_result(stream)
}
fn read_http_json_response(mut stream: TcpStream) -> Option<Value> {
let mut response = String::new();
stream.read_to_string(&mut response).ok()?;
if !response.starts_with("HTTP/1.1 200") && !response.starts_with("HTTP/1.0 200") {
return None;
}
let body = response.split("\r\n\r\n").nth(1)?;
serde_json::from_str(body).ok()
}
fn read_http_json_response_result(mut stream: TcpStream) -> Result<Value, String> {
let mut response = String::new();
stream
.read_to_string(&mut response)
.map_err(|err| format!("读取审校器响应失败:{err}"))?;
let status_line = response.lines().next().unwrap_or_default().to_string();
let body = response.split("\r\n\r\n").nth(1).unwrap_or("");
let payload = serde_json::from_str::<Value>(body).unwrap_or(Value::Null);
if status_line.starts_with("HTTP/1.1 200") || status_line.starts_with("HTTP/1.0 200") {
if payload.is_null() {
return Err("审校器返回了空响应。".to_string());
}
return Ok(payload);
}
let message = payload
.get("error")
.and_then(Value::as_str)
.unwrap_or(status_line.as_str());
Err(message.to_string())
}
fn parse_loopback_port(url: &str) -> Result<u16, String> {
let rest = url
.strip_prefix("http://127.0.0.1:")
.or_else(|| url.strip_prefix("http://localhost:"))
.ok_or_else(|| format!("审校器地址不是本机 HTTP 地址:{url}"))?;
let port_text = rest.split('/').next().unwrap_or(rest);
port_text
.parse::<u16>()
.map_err(|err| format!("无法解析审校器端口:{port_text} ({err})"))
}
fn ensure_session_from_path(
url: &str,
epub_path: Option<&str>,
access_token: &str,
) -> Result<Option<String>, String> {
let Some(raw_path) = epub_path.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(None);
};
let body = serde_json::json!({ "epub_path": raw_path, "activate": false }).to_string();
let payload = post_json(url, "/api/session/from-path", &body, access_token)?;
Ok(payload
.get("id")
.and_then(Value::as_str)
.map(ToOwned::to_owned))
}
fn run_command(
program: &str,
args: &[String],
cwd: Option<&Path>,
) -> Result<CommandOutput, String> {
let (ok, output) = run_command_allow_failure(program, args, cwd)?;
if ok {
Ok(output)
} else {
Err(format!(
"命令执行失败:{} {}\nstdout:\n{}\nstderr:\n{}",
program,
redact_command_args(args).join(" "),
output.stdout,
output.stderr
))
}
}
fn redact_command_args(args: &[String]) -> Vec<String> {
let mut redact_next = false;
args.iter()
.map(|arg| {
if redact_next {
redact_next = false;
return "[REDACTED]".to_string();
}
if arg == "--access-token" {
redact_next = true;
}
arg.clone()
})
.collect()
}
fn run_command_allow_failure(
program: &str,
args: &[String],
cwd: Option<&Path>,
) -> Result<(bool, CommandOutput), String> {
let mut command = Command::new(program);
command.args(args);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
command.stdin(std::process::Stdio::null());
hide_command_window(&mut command);
let output = command
.output()
.map_err(|err| format!("无法执行命令 {program}: {err}"))?;
Ok((
output.status.success(),
CommandOutput {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
},
))
}
#[cfg(windows)]
fn hide_command_window(command: &mut Command) {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
command.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
fn hide_command_window(_command: &mut Command) {}
fn extract_url(text: &str) -> Option<String> {
text.split_whitespace()
.find(|part| part.starts_with("http://") || part.starts_with("https://"))
.map(ToOwned::to_owned)
}
+6 -2
View File
@@ -19,7 +19,7 @@
"img-src": "'self' blob: data: asset: http://asset.localhost https://* https://*:* http://* http://*:*", "img-src": "'self' blob: data: asset: http://asset.localhost https://* https://*:* http://* http://*:*",
"style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://cdnjs.cloudflare.com https://storage.readest.com", "style-src": "'self' 'unsafe-inline' blob: asset: http://asset.localhost https://cdn.jsdelivr.net https://fonts.googleapis.com https://cdnjs.cloudflare.com https://storage.readest.com",
"font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://cdnjs.cloudflare.com https://storage.readest.com", "font-src": "'self' blob: data: asset: http://asset.localhost tauri: https://db.onlinewebfonts.com https://cdn.jsdelivr.net https://fonts.gstatic.com https://cdnjs.cloudflare.com https://storage.readest.com",
"frame-src": "'self' blob: asset: http://asset.localhost http://127.0.0.1:* https://*.stripe.com", "frame-src": "'self' blob: asset: http://asset.localhost https://*.stripe.com",
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' data: blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com https://*.stripe.com" "script-src": "'self' 'unsafe-inline' 'unsafe-eval' data: blob: asset: http://asset.localhost https://*.sentry.io https://*.posthog.com https://*.stripe.com"
}, },
"assetProtocol": { "assetProtocol": {
@@ -49,7 +49,11 @@
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico"
], ],
"resources": [], "resources": {
"../tools/epub-review-editor/server.py": "tools/epub-review-editor/server.py",
"../tools/epub-review-editor/version.py": "tools/epub-review-editor/version.py",
"../tools/epub-review-editor/requirements.txt": "tools/epub-review-editor/requirements.txt"
},
"windows": { "windows": {
"webviewInstallMode": { "webviewInstallMode": {
"type": "embedBootstrapper" "type": "embedBootstrapper"
@@ -1,7 +1,10 @@
{ {
"bundle": { "bundle": {
"resources": { "resources": {
"../extensions/windows-thumbnail/target/windows_thumbnail.dll": "readest_thumbnail.dll" "../extensions/windows-thumbnail/target/windows_thumbnail.dll": "readest_thumbnail.dll",
"../tools/epub-review-editor/server.py": "tools/epub-review-editor/server.py",
"../tools/epub-review-editor/version.py": "tools/epub-review-editor/version.py",
"../tools/epub-review-editor/requirements.txt": "tools/epub-review-editor/requirements.txt"
} }
} }
} }
@@ -22,6 +22,7 @@ describe('getBookContextMenuItemIds', () => {
'markFinished', 'markFinished',
'markAbandoned', 'markAbandoned',
'showDetails', 'showDetails',
'bilingual',
'showInFinder', 'showInFinder',
'searchGoodreads', 'searchGoodreads',
'upload', 'upload',
@@ -39,6 +40,7 @@ describe('getBookContextMenuItemIds', () => {
'markAbandoned', 'markAbandoned',
'clearStatus', 'clearStatus',
'showDetails', 'showDetails',
'bilingual',
'showInFinder', 'showInFinder',
'searchGoodreads', 'searchGoodreads',
'upload', 'upload',
@@ -56,6 +58,7 @@ describe('getBookContextMenuItemIds', () => {
'markAbandoned', 'markAbandoned',
'clearStatus', 'clearStatus',
'showDetails', 'showDetails',
'bilingual',
'showInFinder', 'showInFinder',
'searchGoodreads', 'searchGoodreads',
'upload', 'upload',
@@ -72,6 +75,7 @@ describe('getBookContextMenuItemIds', () => {
'markFinished', 'markFinished',
'clearStatus', 'clearStatus',
'showDetails', 'showDetails',
'bilingual',
'showInFinder', 'showInFinder',
'searchGoodreads', 'searchGoodreads',
'upload', 'upload',
@@ -88,6 +92,7 @@ describe('getBookContextMenuItemIds', () => {
'markFinished', 'markFinished',
'markAbandoned', 'markAbandoned',
'showDetails', 'showDetails',
'bilingual',
'showInFinder', 'showInFinder',
'searchGoodreads', 'searchGoodreads',
'download', 'download',
@@ -104,6 +109,7 @@ describe('getBookContextMenuItemIds', () => {
'markFinished', 'markFinished',
'markAbandoned', 'markAbandoned',
'showDetails', 'showDetails',
'bilingual',
'showInFinder', 'showInFinder',
'searchGoodreads', 'searchGoodreads',
'delete', 'delete',
@@ -29,6 +29,18 @@ const renderDropdown = () =>
</DropdownProvider>, </DropdownProvider>,
); );
const renderTwoDropdowns = () =>
render(
<DropdownProvider>
<Dropdown label='First Menu' toggleButton={<span>First</span>} showTooltip={false}>
<div>First content</div>
</Dropdown>
<Dropdown label='Second Menu' toggleButton={<span>Second</span>} showTooltip={false}>
<div>Second content</div>
</Dropdown>
</DropdownProvider>,
);
describe('Dropdown keyboard activation', () => { describe('Dropdown keyboard activation', () => {
it('opens when Enter is pressed on the toggle button', () => { it('opens when Enter is pressed on the toggle button', () => {
renderDropdown(); renderDropdown();
@@ -91,4 +103,29 @@ describe('Dropdown keyboard activation', () => {
window.removeEventListener('keydown', onWindowKeyDown); window.removeEventListener('keydown', onWindowKeyDown);
} }
}); });
it('closes when the pointer is pressed outside', () => {
renderDropdown();
const toggle = screen.getByRole('button', { name: 'Test Menu' });
fireEvent.click(toggle);
expect(toggle.getAttribute('aria-expanded')).toBe('true');
fireEvent.pointerDown(document.body);
expect(toggle.getAttribute('aria-expanded')).toBe('false');
});
it('switches directly to another dropdown on the first click', () => {
renderTwoDropdowns();
const first = screen.getByRole('button', { name: 'First Menu' });
const second = screen.getByRole('button', { name: 'Second Menu' });
fireEvent.click(first);
expect(first.getAttribute('aria-expanded')).toBe('true');
fireEvent.pointerDown(second);
fireEvent.click(second);
expect(first.getAttribute('aria-expanded')).toBe('false');
expect(second.getAttribute('aria-expanded')).toBe('true');
});
}); });
@@ -0,0 +1,176 @@
import { describe, expect, test, vi } from 'vitest';
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ appService: null }),
}));
import { __testing } from '@/app/reader/components/ReviewModeController';
import type { BookDoc } from '@/libs/document';
describe('ReviewModeController marks', () => {
test('marks bilingual source and target paragraphs by file and index', () => {
const doc = document.implementation.createHTMLDocument('chapter');
doc.body.innerHTML = [
'<p class="sourceText">原文一</p>',
'<p>译文一</p>',
'<p class="sourceText">原文二</p>',
'<p>译文二</p>',
].join('');
__testing.applyReviewMarks(
doc,
'OEBPS/Text/chapter.xhtml',
[
{
id: 'R00002',
file: 'Text/chapter.xhtml',
ja_p_index: 2,
cn_p_index: 3,
jp_html: '原文二',
jp_text: '原文二',
cn_html: '译文二',
current_html: '<span>新译文二</span><script>alert(1)</script>',
},
],
'R00002',
);
const paragraphs = Array.from(doc.querySelectorAll('p'));
expect(paragraphs[2]?.getAttribute('data-readest-review-row-id')).toBe('R00002');
expect(paragraphs[2]?.classList.contains('readest-review-source')).toBe(true);
expect(paragraphs[3]?.getAttribute('data-readest-review-row-id')).toBe('R00002');
expect(paragraphs[3]?.classList.contains('readest-review-target')).toBe(true);
expect(paragraphs[3]?.innerHTML).toBe('<span>新译文二</span>');
});
test('uses section id when foliate sections do not expose href', () => {
const bookDoc = {
sections: [{ id: 'OEBPS/Text/chapter.xhtml' }],
} as unknown as BookDoc;
expect(__testing.sectionHrefAt(bookDoc, 0)).toBe('OEBPS/Text/chapter.xhtml');
});
test('clearReviewMarks restores original target HTML', () => {
const doc = document.implementation.createHTMLDocument('chapter');
doc.body.innerHTML = '<p class="sourceText">原文</p><p><ruby>旧<rt>old</rt></ruby>译文</p>';
__testing.applyReviewMarks(
doc,
'chapter.xhtml',
[
{
id: 'R00001',
file: 'chapter.xhtml',
ja_p_index: 0,
cn_p_index: 1,
jp_html: '原文',
jp_text: '原文',
cn_html: '<ruby>旧<rt>old</rt></ruby>译文',
current_html: '<span>新译文</span>',
},
],
'',
);
__testing.clearReviewMarks(doc);
const target = doc.querySelectorAll('p')[1];
expect(target?.innerHTML).toBe('<ruby>旧<rt>old</rt></ruby>译文');
expect(target?.hasAttribute('data-readest-review-row-id')).toBe(false);
});
test('strips event handlers and dangerous links from target HTML', () => {
const doc = document.implementation.createHTMLDocument('chapter');
doc.body.innerHTML = '<p>原文</p><p>译文</p>';
__testing.applyReviewMarks(
doc,
'chapter.xhtml',
[
{
id: 'R00001',
file: 'chapter.xhtml',
ja_p_index: 0,
cn_p_index: 1,
jp_html: '原文',
jp_text: '原文',
cn_html: '译文',
current_html: '<span onclick="alert(1)"><a href="javascript:alert(1)">新译文</a></span>',
},
],
'R00001',
);
const target = doc.querySelectorAll('p')[1];
expect(target?.innerHTML).toBe('<span>新译文</span>');
});
test('updates active classes without rewriting translated paragraph HTML', () => {
const doc = document.implementation.createHTMLDocument('chapter');
doc.body.innerHTML = '<p>原文一</p><p>译文一</p><p>原文二</p><p>译文二</p>';
__testing.applyReviewMarks(
doc,
'chapter.xhtml',
[
{
id: 'R00001',
file: 'chapter.xhtml',
ja_p_index: 0,
cn_p_index: 1,
jp_html: '原文一',
jp_text: '原文一',
cn_html: '译文一',
current_html: '<span>新译文一</span>',
},
{
id: 'R00002',
file: 'chapter.xhtml',
ja_p_index: 2,
cn_p_index: 3,
jp_html: '原文二',
jp_text: '原文二',
cn_html: '译文二',
current_html: '<span>新译文二</span>',
},
],
'R00001',
);
const target = doc.querySelectorAll('p')[1]!;
target.dataset['localState'] = 'kept';
__testing.applyActiveReviewMark(doc, 'R00002');
expect(target.innerHTML).toBe('<span>新译文一</span>');
expect(target.dataset['localState']).toBe('kept');
expect(target.classList.contains('readest-review-active')).toBe(false);
expect(doc.querySelectorAll('p')[3]?.classList.contains('readest-review-active')).toBe(true);
});
test('clears the target paragraph when a saved translation is empty', () => {
const doc = document.implementation.createHTMLDocument('chapter');
doc.body.innerHTML = '<p>原文</p><p>旧译文</p>';
__testing.applyReviewMarks(
doc,
'chapter.xhtml',
[
{
id: 'R00001',
file: 'chapter.xhtml',
ja_p_index: 0,
cn_p_index: 1,
jp_html: '原文',
jp_text: '原文',
cn_html: '旧译文',
current_html: '',
},
],
'R00001',
);
expect(doc.querySelectorAll('p')[1]?.innerHTML).toBe('');
});
});
@@ -0,0 +1,71 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
const h = vi.hoisted(() => ({
ask: vi.fn(),
setBookEnabled: vi.fn(),
reviewStore: {
books: {
'book-a': { enabled: true, loading: false, hasUnsavedEdit: true },
},
setActiveBookKey: vi.fn(),
setPanelVisible: vi.fn(),
setBookLoading: vi.fn(),
setBookError: vi.fn(),
setBookEnabled: vi.fn(),
setBookData: vi.fn(),
},
}));
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ appService: { isDesktopApp: true, isMobile: false, ask: h.ask } }),
}));
vi.mock('@/hooks/useTranslation', () => ({ useTranslation: () => (value: string) => value }));
vi.mock('@/store/bookDataStore', () => ({
useBookDataStore: (selector: (state: { getBookData: () => unknown }) => unknown) =>
selector({ getBookData: () => ({ book: { format: 'EPUB' } }) }),
}));
vi.mock('@/store/readerStore', () => ({
useReaderStore: () => ({ setHoveredBookKey: vi.fn() }),
}));
vi.mock('@/store/reviewModeStore', () => {
const useReviewModeStore = (selector?: (state: typeof h.reviewStore) => unknown) =>
selector ? selector(h.reviewStore) : h.reviewStore;
Object.assign(useReviewModeStore, { getState: () => h.reviewStore });
return { useReviewModeStore };
});
vi.mock('@/services/reviewEditorService', () => ({
canLaunchInlineReviewEditor: () => true,
launchInlineReviewEditor: vi.fn(),
loadInlineReviewData: vi.fn(),
}));
vi.mock('@/utils/event', () => ({ eventDispatcher: { dispatch: vi.fn() } }));
import ReviewModeToggler from '@/app/reader/components/ReviewModeToggler';
describe('ReviewModeToggler unsaved edit guard', () => {
beforeEach(() => {
cleanup();
vi.clearAllMocks();
h.reviewStore.setBookEnabled = h.setBookEnabled;
});
test('keeps review mode enabled when discarding an unsaved edit is cancelled', async () => {
h.ask.mockResolvedValue(false);
render(<ReviewModeToggler bookKey='book-a' />);
fireEvent.click(screen.getByRole('button', { name: 'Disable Review Mode' }));
await waitFor(() => expect(h.ask).toHaveBeenCalledOnce());
expect(h.setBookEnabled).not.toHaveBeenCalled();
});
test('disables review mode after confirming the unsaved edit warning', async () => {
h.ask.mockResolvedValue(true);
render(<ReviewModeToggler bookKey='book-a' />);
fireEvent.click(screen.getByRole('button', { name: 'Disable Review Mode' }));
await waitFor(() => expect(h.setBookEnabled).toHaveBeenCalledWith('book-a', false));
});
});
@@ -18,7 +18,7 @@ describe('middleware cross-origin isolation headers', () => {
it('keeps the stricter require-corp on every other document route', () => { it('keeps the stricter require-corp on every other document route', () => {
expect(coep('/')).toBe('require-corp'); expect(coep('/')).toBe('require-corp');
expect(coep('/library')).toBe('require-corp'); expect(coep('/library')).toBe('require-corp');
expect(coep('/review-editor')).toBe('require-corp'); expect(coep('/reader')).toBe('require-corp');
// Must not be caught by a naive startsWith('/s'). // Must not be caught by a naive startsWith('/s').
expect(coep('/settings')).toBe('require-corp'); expect(coep('/settings')).toBe('require-corp');
expect(coep('/search')).toBe('require-corp'); expect(coep('/search')).toBe('require-corp');
@@ -0,0 +1,304 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { ReviewRow } from '@/services/reviewEditorService';
import {
canLaunchInlineReviewEditor,
createReviewSessionFromPath,
downloadReviewedEpub,
exportReviewedEpub,
generateReviewFeedback,
isReviewEditDirty,
launchInlineReviewEditor,
listTranslationJobs,
loadTranslationPlan,
controlTranslationJob,
loadInlineReviewData,
loadReviewGlossary,
saveReviewGptConfig,
retranslateReviewRow,
saveReviewRow,
saveReviewGlossary,
sidecarApi,
switchReviewGlossaryPath,
} from '@/services/reviewEditorService';
afterEach(() => {
vi.unstubAllGlobals();
});
const stubJsonFetch = () => {
const fetchMock = vi.fn(async () =>
Response.json({
status: 'ok',
updated_at: '2026-07-09T00:00:00Z',
current_html: '<span>净化译文</span>',
}),
);
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
};
const firstFetchUrl = (fetchMock: ReturnType<typeof vi.fn>) => {
const input = fetchMock.mock.calls[0]?.[0];
if (input instanceof URL) return input.toString();
if (typeof input === 'string') return input;
return input?.toString() || '';
};
describe('reviewEditorService', () => {
test('allows the inline review entry on desktop and local dev-web, but not hosted web', () => {
expect(canLaunchInlineReviewEditor({ isDesktopApp: true }, false)).toBe(true);
expect(canLaunchInlineReviewEditor({ isDesktopApp: false }, true)).toBe(true);
expect(canLaunchInlineReviewEditor({ isDesktopApp: false }, false)).toBe(false);
});
test('rejects a launch response when the Next route returns an HTTP error', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async () =>
Response.json({ ok: false, error: '本地 Web 审校入口未启用' }, { status: 404 }),
),
);
await expect(
launchInlineReviewEditor(
{
resolveNativeBookFilePath: async () => 'C:/books/demo.epub',
} as never,
{ format: 'EPUB' } as never,
false,
),
).rejects.toThrow('本地 Web 审校入口未启用');
});
test('uploads the current web book to the local sidecar before creating a session', async () => {
const fetchMock = vi.fn(async (input: string | URL | Request, _init?: RequestInit) => {
const url = input.toString();
if (url === '/api/review-editor/launch') {
return Response.json({
ok: true,
url: 'http://127.0.0.1:5177',
accessToken: 'sidecar-token',
});
}
return Response.json({ id: 'session-web' });
});
vi.stubGlobal('fetch', fetchMock);
const launched = await launchInlineReviewEditor(
{
resolveNativeBookFilePath: async () => null,
loadBookContent: async () => ({
file: new File(['epub-bytes'], 'demo.epub', { type: 'application/epub+zip' }),
}),
} as never,
{ format: 'EPUB', title: 'Demo book', hash: 'book-hash' } as never,
false,
);
expect(launched.sessionId).toBe('session-web');
expect(fetchMock).toHaveBeenCalledTimes(2);
const uploadCall = fetchMock.mock.calls[1] as
| [string | URL | Request, RequestInit | undefined]
| undefined;
expect(uploadCall?.[0]).toBe('http://127.0.0.1:5177/api/upload');
expect(uploadCall?.[1]?.body).toBeInstanceOf(FormData);
expect(uploadCall?.[1]?.headers).toMatchObject({
Authorization: 'Bearer sidecar-token',
});
expect((uploadCall?.[1]?.body as FormData).get('activate')).toBe('false');
expect((uploadCall?.[1]?.body as FormData).get('book_key')).toBe('book-hash');
});
test('downloads an exported EPUB in web mode with its server filename', async () => {
const click = vi.fn();
const remove = vi.fn();
const anchor = { click, remove, href: '', download: '' };
vi.spyOn(document, 'createElement').mockReturnValue(anchor as never);
vi.spyOn(document.body, 'appendChild').mockImplementation((node) => node);
const fetchMock = vi.fn(
async () => new Response(new Blob(['epub-bytes'], { type: 'application/epub+zip' })),
);
vi.stubGlobal('fetch', fetchMock);
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:reviewed-epub');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => undefined);
await downloadReviewedEpub(
'http://127.0.0.1:5177/downloads/export/reviewed.epub?session_id=session-a',
'sidecar-token',
);
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/downloads/export/reviewed.epub'),
expect.objectContaining({
headers: { Authorization: 'Bearer sidecar-token' },
}),
);
expect(anchor.href).toBe('blob:reviewed-epub');
expect(anchor.download).toBe('reviewed.epub');
expect(click).toHaveBeenCalledOnce();
expect(remove).toHaveBeenCalledOnce();
});
test('detects unsaved row fields against the selected row baseline', () => {
const row = {
current_html: '译文',
cn_html: '译文',
marked: false,
issue_type: '',
severity: '',
tags: '',
comment: '',
learn_note: '',
} as ReviewRow;
expect(
isReviewEditDirty(row, {
current_html: '译文',
marked: false,
issue_type: '',
severity: '',
tags: '',
comment: '',
learn_note: '',
}),
).toBe(false);
expect(
isReviewEditDirty(row, {
current_html: '修改后译文',
marked: false,
issue_type: '',
severity: '',
tags: '',
comment: '',
learn_note: '',
}),
).toBe(true);
expect(
isReviewEditDirty(
{ ...row, current_html: '' },
{
current_html: '',
marked: false,
issue_type: '',
severity: '',
tags: '',
comment: '',
learn_note: '',
},
),
).toBe(false);
});
test('adds session_id to sidecar API calls without mutating endpoint paths', async () => {
const fetchMock = stubJsonFetch();
await sidecarApi('http://127.0.0.1:5177', '/api/rows', {}, 'session-a', 'sidecar-token');
const url = new URL(firstFetchUrl(fetchMock));
expect(url.pathname).toBe('/api/rows');
expect(url.searchParams.get('session_id')).toBe('session-a');
const firstCall = fetchMock.mock.calls[0] as
| [string | URL | Request, RequestInit | undefined]
| undefined;
expect(firstCall?.[1]?.headers).toMatchObject({
Authorization: 'Bearer sidecar-token',
});
});
test('row operations are scoped to the current review session', async () => {
const fetchMock = stubJsonFetch();
await saveReviewRow('http://127.0.0.1:5177', 'session-a', 'R00001', {
current_html: '<span>译文</span>',
marked: true,
issue_type: '误译',
severity: '严重',
tags: '术语',
comment: '备注',
learn_note: '长期规则',
});
await retranslateReviewRow('http://127.0.0.1:5177', 'session-a', 'R00001', '');
await generateReviewFeedback('http://127.0.0.1:5177', 'session-a');
await exportReviewedEpub('http://127.0.0.1:5177', 'session-a');
expect(fetchMock).toHaveBeenCalledTimes(4);
for (const call of fetchMock.mock.calls as unknown as [
string | URL,
RequestInit | undefined,
][]) {
const input = call[0];
const url = new URL(input instanceof URL ? input.toString() : input);
expect(url.searchParams.get('session_id')).toBe('session-a');
}
});
test('inline launch creates sessions without activating sidecar global session', async () => {
const fetchMock = stubJsonFetch();
await createReviewSessionFromPath('http://127.0.0.1:5177', 'C:/books/demo.epub');
const calls = fetchMock.mock.calls as unknown as [string | URL, RequestInit][];
const body = JSON.parse(String(calls[0]?.[1]?.body || '{}'));
expect(body).toMatchObject({
epub_path: 'C:/books/demo.epub',
activate: false,
reset: false,
});
});
test('inline bootstrap and config operations are scoped to the current review session', async () => {
const fetchMock = vi.fn(async () =>
Response.json({
has_session: true,
rows: [],
entries: [],
}),
);
vi.stubGlobal('fetch', fetchMock);
await loadInlineReviewData('http://127.0.0.1:5177', 'session-a');
await saveReviewGptConfig('http://127.0.0.1:5177', 'session-a', {
base_url: 'https://api.example.test/v1',
model: 'model-a',
});
await loadReviewGlossary('http://127.0.0.1:5177', 'session-a');
await switchReviewGlossaryPath('http://127.0.0.1:5177', 'session-a', 'mingcibiao.json');
await saveReviewGlossary('http://127.0.0.1:5177', 'session-a', 'mingcibiao.json', []);
expect(fetchMock).toHaveBeenCalledTimes(7);
for (const call of fetchMock.mock.calls as unknown as [
string | URL,
RequestInit | undefined,
][]) {
const input = call[0];
const url = new URL(input instanceof URL ? input.toString() : input);
expect(url.searchParams.get('session_id')).toBe('session-a');
}
});
test('translation APIs keep bearer auth and current-book session scope', async () => {
const fetchMock = stubJsonFetch();
await loadTranslationPlan('http://127.0.0.1:5177', 'session-a', 24, 'sidecar-token');
await listTranslationJobs('http://127.0.0.1:5177', 'session-a', 'sidecar-token');
await controlTranslationJob(
'http://127.0.0.1:5177',
'session-a',
'tr_1',
'pause',
'sidecar-token',
);
expect(fetchMock).toHaveBeenCalledTimes(3);
for (const call of fetchMock.mock.calls as unknown as [
string | URL,
RequestInit | undefined,
][]) {
const url = new URL(call[0].toString());
expect(url.searchParams.get('session_id')).toBe('session-a');
expect(call[1]?.headers).toMatchObject({ Authorization: 'Bearer sidecar-token' });
}
});
});
@@ -0,0 +1,181 @@
import { beforeEach, describe, expect, test } from 'vitest';
import { useReviewModeStore } from '@/store/reviewModeStore';
beforeEach(() => {
localStorage.clear();
useReviewModeStore.setState({
activeBookKey: null,
isPanelVisible: false,
isPanelPinned: false,
panelWidth: '32%',
panelHeight: '68vh',
books: {},
});
});
describe('reviewModeStore', () => {
test('enables review mode for a book and opens the panel', () => {
useReviewModeStore.getState().setBookEnabled('book-a', true);
const state = useReviewModeStore.getState();
expect(state.activeBookKey).toBe('book-a');
expect(state.isPanelVisible).toBe(true);
expect(state.books['book-a']?.enabled).toBe(true);
});
test('selects rows without replacing loaded review data', () => {
useReviewModeStore.getState().setBookData('book-a', {
baseUrl: 'http://127.0.0.1:5177',
sessionId: 'session-a',
rows: [
{
id: 'R00001',
file: 'chapter.xhtml',
jp_html: '原文',
jp_text: '原文',
cn_html: '译文',
current_html: '译文',
},
],
});
useReviewModeStore.getState().selectRow('book-a', 'R00001');
const state = useReviewModeStore.getState();
expect(state.activeBookKey).toBe('book-a');
expect(state.isPanelVisible).toBe(true);
expect(state.books['book-a']?.baseUrl).toBe('http://127.0.0.1:5177');
expect(state.books['book-a']?.selectedRowId).toBe('R00001');
});
test('reopens the panel for the selected row without clearing its unsaved flag', () => {
useReviewModeStore.getState().setBookEnabled('book-a', true);
useReviewModeStore.getState().selectRow('book-a', 'R00001');
useReviewModeStore.getState().setBookUnsavedEdit('book-a', true);
useReviewModeStore.getState().setPanelVisible(false);
useReviewModeStore.getState().selectRow('book-a', 'R00001');
const state = useReviewModeStore.getState();
expect(state.isPanelVisible).toBe(true);
expect(state.books['book-a']?.hasUnsavedEdit).toBe(true);
});
test('updates one review row after save', () => {
useReviewModeStore.getState().setBookData('book-a', {
rows: [
{
id: 'R00001',
file: 'chapter.xhtml',
jp_html: '原文',
jp_text: '原文',
cn_html: '旧译文',
current_html: '旧译文',
},
],
});
useReviewModeStore.getState().updateRow('book-a', {
id: 'R00001',
file: 'chapter.xhtml',
jp_html: '原文',
jp_text: '原文',
cn_html: '旧译文',
current_html: '新译文',
edited: true,
});
expect(useReviewModeStore.getState().books['book-a']?.rows[0]?.current_html).toBe('新译文');
expect(useReviewModeStore.getState().books['book-a']?.rows[0]?.edited).toBe(true);
});
test('disabling the active book closes the panel', () => {
useReviewModeStore.getState().setBookEnabled('book-a', true);
useReviewModeStore.getState().setBookEnabled('book-a', false);
const state = useReviewModeStore.getState();
expect(state.activeBookKey).toBeNull();
expect(state.isPanelVisible).toBe(false);
expect(state.books['book-a']?.enabled).toBe(false);
});
test('clears an active book and closes the panel', () => {
useReviewModeStore.getState().setBookEnabled('book-a', true);
useReviewModeStore.getState().setBookData('book-a', {
baseUrl: 'http://127.0.0.1:5177',
sessionId: 'session-a',
});
useReviewModeStore.getState().clearBook('book-a');
const state = useReviewModeStore.getState();
expect(state.books['book-a']).toBeUndefined();
expect(state.activeBookKey).toBeNull();
expect(state.isPanelVisible).toBe(false);
});
test('persists only panel layout preferences', () => {
useReviewModeStore.getState().setPanelWidth('41%');
useReviewModeStore.getState().setPanelHeight('77vh');
useReviewModeStore.getState().togglePanelPin();
useReviewModeStore.getState().setBookData('book-a', {
baseUrl: 'http://127.0.0.1:5177',
sessionId: 'session-a',
rows: [
{
id: 'R00001',
file: 'chapter.xhtml',
jp_html: '原文',
jp_text: '原文',
cn_html: '译文',
current_html: '译文',
},
],
});
const stored = JSON.parse(localStorage.getItem('readest-review-panel-layout') || '{}');
expect(stored.state).toEqual({
isPanelPinned: true,
panelWidth: '41%',
panelHeight: '77vh',
});
expect(stored.state.books).toBeUndefined();
expect(stored.state.activeBookKey).toBeUndefined();
});
test('ignores invalid persisted layout values during hydration', async () => {
useReviewModeStore.setState({
activeBookKey: null,
isPanelVisible: false,
isPanelPinned: false,
panelWidth: '32%',
panelHeight: '68vh',
books: {},
});
useReviewModeStore.persist.clearStorage();
localStorage.setItem(
'readest-review-panel-layout',
JSON.stringify({
state: {
isPanelPinned: true,
panelWidth: '999%',
panelHeight: 'not-a-height',
books: {
stale: { enabled: true },
},
},
version: 0,
}),
);
await useReviewModeStore.persist.rehydrate();
const state = useReviewModeStore.getState();
expect(state.isPanelPinned).toBe(true);
expect(state.panelWidth).toBe('48%');
expect(state.panelHeight).toBe('68vh');
expect(state.books).toEqual({});
expect(state.activeBookKey).toBeNull();
});
});
@@ -1,4 +1,5 @@
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import crypto from 'node:crypto';
import fs from 'node:fs'; import fs from 'node:fs';
import http from 'node:http'; import http from 'node:http';
import path from 'node:path'; import path from 'node:path';
@@ -27,6 +28,7 @@ type LaunchResponse = {
reused?: boolean; reused?: boolean;
reviewRoot?: string; reviewRoot?: string;
version?: string; version?: string;
accessToken?: string;
error?: string; error?: string;
}; };
@@ -39,6 +41,28 @@ const appRoot = () => process.cwd();
const toolRoot = () => path.join(appRoot(), 'tools', 'epub-review-editor'); const toolRoot = () => path.join(appRoot(), 'tools', 'epub-review-editor');
const reviewRoot = () => const reviewRoot = () =>
path.resolve(process.env['READEST_REVIEW_ROOT'] || path.join(appRoot(), 'epub_review_sessions')); path.resolve(process.env['READEST_REVIEW_ROOT'] || path.join(appRoot(), 'epub_review_sessions'));
const accessTokenPath = () => path.join(reviewRoot(), '.access-token');
const ensureAccessToken = () => {
fs.mkdirSync(reviewRoot(), { recursive: true });
try {
const existing = fs.readFileSync(accessTokenPath(), 'utf8').trim();
if (existing) return existing;
} catch {
// Create the token below.
}
const token = crypto.randomBytes(32).toString('hex');
try {
fs.writeFileSync(accessTokenPath(), token, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
return token;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
const existing = fs.readFileSync(accessTokenPath(), 'utf8').trim();
if (existing) return existing;
}
throw error;
}
};
const venvRoot = () => path.join(toolRoot(), '.venv'); const venvRoot = () => path.join(toolRoot(), '.venv');
const venvPython = () => const venvPython = () =>
@@ -91,7 +115,11 @@ const isLoopbackHost = (host: string | null) => {
const normalizeLoopbackUrl = (url: string) => url.replace('http://localhost:', 'http://127.0.0.1:'); const normalizeLoopbackUrl = (url: string) => url.replace('http://localhost:', 'http://127.0.0.1:');
const requestJson = (port: number, pathname: string): Promise<Record<string, unknown> | null> => const requestJson = (
port: number,
pathname: string,
accessToken: string,
): Promise<Record<string, unknown> | null> =>
new Promise((resolve) => { new Promise((resolve) => {
const req = http.get( const req = http.get(
{ {
@@ -99,6 +127,7 @@ const requestJson = (port: number, pathname: string): Promise<Record<string, unk
port, port,
path: pathname, path: pathname,
timeout: 800, timeout: 800,
headers: { Authorization: `Bearer ${accessToken}` },
}, },
(res) => { (res) => {
let body = ''; let body = '';
@@ -126,12 +155,18 @@ const requestJson = (port: number, pathname: string): Promise<Record<string, unk
req.on('error', () => resolve(null)); req.on('error', () => resolve(null));
}); });
const findRunningEditor = async () => { const findRunningEditor = async (accessToken: string) => {
const expectedVersion = readExpectedVersion(); const expectedVersion = readExpectedVersion();
const expectedReviewRoot = path.resolve(reviewRoot());
for (let port = DEFAULT_PORT; port < DEFAULT_PORT + PORT_SCAN_LIMIT; port++) { for (let port = DEFAULT_PORT; port < DEFAULT_PORT + PORT_SCAN_LIMIT; port++) {
const payload = await requestJson(port, '/api/version'); const payload = await requestJson(port, '/api/version', accessToken);
if (!payload) continue; if (!payload) continue;
if (!expectedVersion || payload['version'] === expectedVersion) { const runningReviewRoot =
typeof payload['review_root'] === 'string' ? path.resolve(payload['review_root']) : '';
if (
(!expectedVersion || payload['version'] === expectedVersion) &&
runningReviewRoot === expectedReviewRoot
) {
return `http://127.0.0.1:${port}`; return `http://127.0.0.1:${port}`;
} }
} }
@@ -237,7 +272,8 @@ async function launchEditor(): Promise<LaunchResponse> {
}; };
} }
const runningUrl = await findRunningEditor(); const accessToken = ensureAccessToken();
const runningUrl = await findRunningEditor(accessToken);
if (runningUrl) { if (runningUrl) {
return { return {
ok: true, ok: true,
@@ -245,6 +281,7 @@ async function launchEditor(): Promise<LaunchResponse> {
reused: true, reused: true,
reviewRoot: reviewRoot(), reviewRoot: reviewRoot(),
version: readExpectedVersion(), version: readExpectedVersion(),
accessToken,
}; };
} }
@@ -263,8 +300,9 @@ async function launchEditor(): Promise<LaunchResponse> {
'127.0.0.1', '127.0.0.1',
'--port', '--port',
String(DEFAULT_PORT), String(DEFAULT_PORT),
'--access-token',
accessToken,
'--daemon', '--daemon',
'--no-browser',
], ],
{ cwd: bundledToolRoot, timeout: 45_000 }, { cwd: bundledToolRoot, timeout: 45_000 },
); );
@@ -273,7 +311,7 @@ async function launchEditor(): Promise<LaunchResponse> {
} }
const launchedUrl = normalizeLoopbackUrl( const launchedUrl = normalizeLoopbackUrl(
/https?:\/\/[^\s]+/.exec(launch.stdout)?.[0] || (await findRunningEditor()), /https?:\/\/[^\s]+/.exec(launch.stdout)?.[0] || (await findRunningEditor(accessToken)),
); );
if (!launchedUrl) { if (!launchedUrl) {
throw new Error(`审校器已启动但未返回访问地址。输出:${launch.stdout}`); throw new Error(`审校器已启动但未返回访问地址。输出:${launch.stdout}`);
@@ -285,6 +323,7 @@ async function launchEditor(): Promise<LaunchResponse> {
reused: false, reused: false,
reviewRoot: reviewRoot(), reviewRoot: reviewRoot(),
version: readExpectedVersion(), version: readExpectedVersion(),
accessToken,
}; };
} catch (error) { } catch (error) {
return { return {
+9 -7
View File
@@ -129,8 +129,16 @@ const devHmrPatchScript = `(${patchTauriHmrWebSocket.toString()})(${JSON.stringi
// fallback HTML and crash with `Unexpected token '<'`. All runtime-config // fallback HTML and crash with `Unexpected token '<'`. All runtime-config
// consumers fall back to `NEXT_PUBLIC_*` envs baked at build time on Tauri. // consumers fall back to `NEXT_PUBLIC_*` envs baked at build time on Tauri.
const shouldInjectRuntimeConfig = process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web'; const shouldInjectRuntimeConfig = process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web';
const shouldUseViewTransitions =
process.env['NODE_ENV'] !== 'development' || process.env['NEXT_PUBLIC_APP_PLATFORM'] !== 'web';
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
const app = (
<EnvProvider>
<Providers>{children}</Providers>
</EnvProvider>
);
return ( return (
<html <html
lang='en' lang='en'
@@ -144,13 +152,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<script dangerouslySetInnerHTML={{ __html: devHmrPatchScript }} /> <script dangerouslySetInnerHTML={{ __html: devHmrPatchScript }} />
) : null} ) : null}
</head> </head>
<body> <body>{shouldUseViewTransitions ? <ViewTransitions>{app}</ViewTransitions> : app}</body>
<ViewTransitions>
<EnvProvider>
<Providers>{children}</Providers>
</EnvProvider>
</ViewTransitions>
</body>
</html> </html>
); );
} }
@@ -0,0 +1,115 @@
import clsx from 'clsx';
import * as React from 'react';
import { useState } from 'react';
import { LuLanguages } from 'react-icons/lu';
import { PiX } from 'react-icons/pi';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
import { useTranslation } from '@/hooks/useTranslation';
import type { BilingualFilterMode, BilingualFilterOptions } from '@/services/bilingualEpubFilter';
interface BilingualFilterAlertProps {
bookTitle: string;
safeAreaBottom: number;
processing: boolean;
onCancel: () => void;
onFilter: (options: BilingualFilterOptions) => void;
}
const BilingualFilterAlert: React.FC<BilingualFilterAlertProps> = ({
bookTitle,
safeAreaBottom,
processing,
onCancel,
onFilter,
}) => {
const _ = useTranslation();
const divRef = useKeyDownActions({ onCancel });
const [mode, setMode] = useState<BilingualFilterMode>('auto');
const [removeUnknown, setRemoveUnknown] = useState(false);
const handleFilter = (removeLanguage: BilingualFilterOptions['removeLanguage']) => {
if (processing) return;
onFilter({ removeLanguage, mode, removeUnknown });
};
return (
<div
ref={divRef}
className='fixed bottom-0 left-0 right-0 z-50 flex justify-center px-3'
style={{ paddingBottom: `${safeAreaBottom + 16}px` }}
>
<div
className={clsx(
'border-base-content/10 bg-base-200/95 flex w-full max-w-xl flex-col gap-4 rounded-2xl border p-4 shadow-lg backdrop-blur-sm',
processing && 'pointer-events-none opacity-80',
)}
>
<div className='relative flex min-w-0 items-center justify-center gap-2 pr-8'>
<LuLanguages className='size-5 shrink-0' />
<div className='min-w-0 truncate text-center text-sm font-medium'>
{_('Bilingual EPUB')}: {bookTitle}
</div>
<button
className={clsx(
'absolute right-0 flex items-center justify-center rounded-full p-1.5',
'text-base-content/70 transition-colors hover:text-base-content',
)}
onClick={onCancel}
aria-label={_('Cancel')}
disabled={processing}
>
<PiX className='size-5' />
</button>
</div>
<div className='grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center'>
<label className='flex min-w-0 items-center gap-2'>
<span className='text-neutral-content shrink-0 text-xs'>{_('Detection')}</span>
<select
className='select select-bordered select-sm min-w-0 flex-1'
value={mode}
disabled={processing}
onChange={(event) => setMode(event.target.value as BilingualFilterMode)}
>
<option value='auto'>{_('Auto')}</option>
<option value='style'>{_('Style')}</option>
<option value='script'>{_('Script')}</option>
</select>
</label>
<label className='flex cursor-pointer items-center gap-2 justify-self-start sm:justify-self-end'>
<input
type='checkbox'
className='checkbox checkbox-sm'
checked={removeUnknown}
disabled={processing}
onChange={(event) => setRemoveUnknown(event.target.checked)}
/>
<span className='text-sm'>{_('Remove uncertain text')}</span>
</label>
</div>
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
<button
className='btn btn-primary btn-sm'
disabled={processing}
onClick={() => handleFilter('ja')}
>
{_('Keep Chinese')}
</button>
<button
className='btn btn-secondary btn-sm'
disabled={processing}
onClick={() => handleFilter('zh')}
>
{_('Keep Japanese')}
</button>
<button className='btn btn-ghost btn-sm' disabled={processing} onClick={onCancel}>
{processing ? _('Processing...') : _('Cancel')}
</button>
</div>
</div>
</div>
);
};
export default BilingualFilterAlert;
@@ -33,6 +33,10 @@ interface BookItemProps {
showBookDetailsModal: (book: Book) => void; showBookDetailsModal: (book: Book) => void;
} }
const CELL_ASPECT_RATIO = 28 / 41;
const MIN_FIT_COVER_WIDTH_RATIO = 0.72;
const MIN_FIT_COVER_HEIGHT_RATIO = 0.78;
const BookItem: React.FC<BookItemProps> = ({ const BookItem: React.FC<BookItemProps> = ({
book, book,
mode, mode,
@@ -56,61 +60,84 @@ const BookItem: React.FC<BookItemProps> = ({
setCoverAspect(null); setCoverAspect(null);
}, [book.hash, book.metadata?.coverImageUrl, book.coverImageUrl]); }, [book.hash, book.metadata?.coverImageUrl, book.coverImageUrl]);
const CELL_ASPECT_RATIO = 28 / 41; const measuredCoverAspect = coverAspect ?? CELL_ASPECT_RATIO;
const fitCoverInGrid = mode === 'grid' && coverFit === 'fit' && coverAspect !== null; const fitCoverInGrid = mode === 'grid' && coverFit === 'fit' && coverAspect !== null;
const shouldShrinkWidth = fitCoverInGrid && coverAspect! < CELL_ASPECT_RATIO; const fitCoverBaseWidthRatio =
fitCoverInGrid && measuredCoverAspect < CELL_ASPECT_RATIO
? measuredCoverAspect / CELL_ASPECT_RATIO
: 1;
const fitCoverBaseHeightRatio =
fitCoverInGrid && measuredCoverAspect > CELL_ASPECT_RATIO
? CELL_ASPECT_RATIO / measuredCoverAspect
: 1;
const fitCoverWidthRatio = fitCoverInGrid
? Math.max(MIN_FIT_COVER_WIDTH_RATIO, fitCoverBaseWidthRatio)
: 1;
const fitCoverHeightRatio = fitCoverInGrid
? Math.max(MIN_FIT_COVER_HEIGHT_RATIO, fitCoverBaseHeightRatio)
: 1;
const fitCoverIsZoomed =
fitCoverInGrid &&
(fitCoverWidthRatio > fitCoverBaseWidthRatio || fitCoverHeightRatio > fitCoverBaseHeightRatio);
const bookitemMainStyle = fitCoverInGrid const bookitemMainStyle = fitCoverInGrid
? { ? {
aspectRatio: coverAspect!, width: `${fitCoverWidthRatio * 100}%`,
...(shouldShrinkWidth ? { width: `${(coverAspect! / CELL_ASPECT_RATIO) * 100}%` } : {}), height: `${fitCoverHeightRatio * 100}%`,
} }
: undefined; : undefined;
const seriesText = formatSeries(book.metadata?.series, book.metadata?.seriesIndex); const seriesText = formatSeries(book.metadata?.series, book.metadata?.seriesIndex);
const coverNode = (
<div
className={clsx(
'bookitem-main relative flex justify-center overflow-hidden rounded',
mode === 'grid' && 'items-end',
mode === 'grid' && !fitCoverInGrid && 'h-full w-full',
mode === 'list' && 'aspect-[28/41] min-w-20 items-center',
coverFit === 'crop' && 'shadow-md',
)}
style={bookitemMainStyle}
>
<BookCover
mode={mode}
book={book}
coverFit={fitCoverIsZoomed ? 'crop' : coverFit}
showSpine={false}
imageClassName='rounded shadow-md'
onAspectRatioChange={setCoverAspect}
/>
{bookSelected && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
{isSelectMode && (
<div className='absolute bottom-1 right-1'>
{bookSelected ? (
<MdCheckCircle className='fill-blue-500' />
) : (
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
)}
</div>
)}
</div>
);
return ( return (
<div <div
role='none' role='none'
className={clsx( className={clsx(
'book-item flex', 'book-item flex',
mode === 'grid' && 'h-full flex-col justify-end', mode === 'grid' && 'h-full w-full flex-col justify-end',
mode === 'list' && 'min-h-28 flex-row gap-4 overflow-hidden', mode === 'list' && 'min-h-28 flex-row gap-4 overflow-hidden',
mode === 'list' ? 'library-list-item' : 'library-grid-item', mode === 'list' ? 'library-list-item' : 'library-grid-item',
appService?.hasContextMenu ? 'cursor-pointer' : '', appService?.hasContextMenu ? 'cursor-pointer' : '',
)} )}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div {mode === 'grid' ? (
className={clsx( <div className='flex aspect-[28/41] w-full items-end justify-center'>{coverNode}</div>
'bookitem-main relative flex justify-center overflow-hidden rounded', ) : (
!fitCoverInGrid && 'aspect-[28/41]', coverNode
coverFit === 'crop' && 'shadow-md', )}
mode === 'grid' && 'items-end',
mode === 'list' && 'min-w-20 items-center',
)}
style={bookitemMainStyle}
>
<BookCover
mode={mode}
book={book}
coverFit={coverFit}
showSpine={false}
imageClassName='rounded shadow-md'
onAspectRatioChange={setCoverAspect}
/>
{bookSelected && (
<div className='absolute inset-0 bg-black opacity-30 transition-opacity duration-300'></div>
)}
{isSelectMode && (
<div className='absolute bottom-1 right-1'>
{bookSelected ? (
<MdCheckCircle className='fill-blue-500' />
) : (
<MdCheckCircleOutline className='fill-gray-300 drop-shadow-sm' />
)}
</div>
)}
</div>
<div <div
className={clsx( className={clsx(
'flex w-full flex-col p-0', 'flex w-full flex-col p-0',
@@ -50,6 +50,7 @@ import { eventDispatcher } from '@/utils/event';
import { getLocalBookFilename } from '@/utils/book'; import { getLocalBookFilename } from '@/utils/book';
import { MIMETYPES, EXTS } from '@/libs/document'; import { MIMETYPES, EXTS } from '@/libs/document';
import { makeSafeFilename } from '@/utils/misc'; import { makeSafeFilename } from '@/utils/misc';
import { getBookCategoryValues, type LibraryCategoryFilter } from '../utils/categoryFilters';
import { useSpatialNavigation } from '../hooks/useSpatialNavigation'; import { useSpatialNavigation } from '../hooks/useSpatialNavigation';
import DeleteConfirmAlert from '@/components/DeleteConfirmAlert'; import DeleteConfirmAlert from '@/components/DeleteConfirmAlert';
@@ -63,6 +64,11 @@ import GroupingModal from './GroupingModal';
import SetStatusAlert from './SetStatusAlert'; import SetStatusAlert from './SetStatusAlert';
import RecentShelf, { RECENT_SHELF_BOOK_COUNT } from './RecentShelf'; import RecentShelf, { RECENT_SHELF_BOOK_COUNT } from './RecentShelf';
import { useOpenBook } from '../hooks/useOpenBook'; import { useOpenBook } from '../hooks/useOpenBook';
import BilingualFilterAlert from './BilingualFilterAlert';
import {
filterBilingualEpubFile,
type BilingualFilterOptions,
} from '@/services/bilingualEpubFilter';
interface BookshelfProps { interface BookshelfProps {
libraryBooks: Book[]; libraryBooks: Book[];
@@ -83,6 +89,8 @@ interface BookshelfProps {
handleLibraryNavigation: (targetGroup: string) => void; handleLibraryNavigation: (targetGroup: string) => void;
handlePushLibrary: () => Promise<void>; handlePushLibrary: () => Promise<void>;
booksTransferProgress: { [key: string]: number | null }; booksTransferProgress: { [key: string]: number | null };
categoryFilter?: LibraryCategoryFilter;
sidebarVisible?: boolean;
} }
/** /**
@@ -93,6 +101,7 @@ interface BookshelfProps {
type BookshelfListContext = { type BookshelfListContext = {
autoColumns: boolean; autoColumns: boolean;
fixedColumns: number; fixedColumns: number;
sidebarVisible: boolean;
/** /**
* The recently-read shelf, rendered in the Virtuoso header so it scrolls with * The recently-read shelf, rendered in the Virtuoso header so it scrolls with
* the shelf content (not sticky). `null` when hidden. Passed through context * the shelf content (not sticky). `null` when hidden. Passed through context
@@ -102,9 +111,12 @@ type BookshelfListContext = {
recentShelfHeader: React.ReactNode; recentShelfHeader: React.ReactNode;
}; };
const BOOKSHELF_GRID_CLASSES = const BOOKSHELF_GRID_BASE_CLASSES =
'bookshelf-items transform-wrapper grid gap-x-4 px-4 sm:gap-x-0 sm:px-2 ' + 'bookshelf-items transform-wrapper grid gap-x-4 px-4 sm:gap-x-0 sm:px-2';
const BOOKSHELF_GRID_DEFAULT_COLUMNS =
'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12'; 'grid-cols-3 sm:grid-cols-4 md:grid-cols-6 xl:grid-cols-8 2xl:grid-cols-12';
const BOOKSHELF_GRID_SIDEBAR_COLUMNS =
'grid-cols-3 sm:grid-cols-4 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-8';
const BOOKSHELF_LIST_CLASSES = 'bookshelf-items transform-wrapper flex flex-col'; const BOOKSHELF_LIST_CLASSES = 'bookshelf-items transform-wrapper flex flex-col';
@@ -115,7 +127,11 @@ const BookshelfGridList: GridComponents<BookshelfListContext>['List'] = React.fo
<div <div
ref={ref} ref={ref}
data-testid={testId} data-testid={testId}
className={clsx(BOOKSHELF_GRID_CLASSES, className)} className={clsx(
BOOKSHELF_GRID_BASE_CLASSES,
context?.sidebarVisible ? BOOKSHELF_GRID_SIDEBAR_COLUMNS : BOOKSHELF_GRID_DEFAULT_COLUMNS,
className,
)}
style={{ style={{
...style, ...style,
gridTemplateColumns: gridTemplateColumns:
@@ -170,6 +186,8 @@ const Bookshelf: React.FC<BookshelfProps> = ({
handleLibraryNavigation, handleLibraryNavigation,
handlePushLibrary, handlePushLibrary,
booksTransferProgress, booksTransferProgress,
categoryFilter = 'all',
sidebarVisible = false,
}) => { }) => {
const _ = useTranslation(); const _ = useTranslation();
const router = useRouter(); const router = useRouter();
@@ -180,6 +198,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const groupId = searchParams?.get('group') || ''; const groupId = searchParams?.get('group') || '';
const queryTerm = searchParams?.get('q') || null; const queryTerm = searchParams?.get('q') || null;
const categoryValue = searchParams?.get('categoryValue') || '';
const viewMode = searchParams?.get('view') || settings.libraryViewMode; const viewMode = searchParams?.get('view') || settings.libraryViewMode;
const storedSortBy = ensureLibrarySortByType(searchParams?.get('sort'), settings.librarySortBy); const storedSortBy = ensureLibrarySortByType(searchParams?.get('sort'), settings.librarySortBy);
const sortOrder = searchParams?.get('order') || (settings.librarySortAscending ? 'asc' : 'desc'); const sortOrder = searchParams?.get('order') || (settings.librarySortAscending ? 'asc' : 'desc');
@@ -199,6 +218,9 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const [showDeleteAlert, setShowDeleteAlert] = useState(false); const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const [showStatusAlert, setShowStatusAlert] = useState(false); const [showStatusAlert, setShowStatusAlert] = useState(false);
const [showGroupingModal, setShowGroupingModal] = useState(false); const [showGroupingModal, setShowGroupingModal] = useState(false);
const [showBilingualFilterAlert, setShowBilingualFilterAlert] = useState(false);
const [bilingualFilterBook, setBilingualFilterBook] = useState<Book | null>(null);
const [bilingualFilterProcessing, setBilingualFilterProcessing] = useState(false);
const [importBookUrl] = useState(searchParams?.get('url') || ''); const [importBookUrl] = useState(searchParams?.get('url') || '');
const abortDeletionRef = useRef(false); const abortDeletionRef = useRef(false);
@@ -241,10 +263,20 @@ const Bookshelf: React.FC<BookshelfProps> = ({
[router, searchParams], [router, searchParams],
); );
const categoryFilteredBooks = useMemo(() => {
if (categoryFilter === 'all' || !categoryValue) return libraryBooks;
return libraryBooks.filter(
(book) =>
!book.deletedAt && getBookCategoryValues(book, categoryFilter).includes(categoryValue),
);
}, [libraryBooks, categoryFilter, categoryValue]);
const filteredBooks = useMemo(() => { const filteredBooks = useMemo(() => {
const bookFilter = createBookFilter(queryTerm); const bookFilter = createBookFilter(queryTerm);
return queryTerm ? libraryBooks.filter((book) => bookFilter(book)) : libraryBooks; return queryTerm
}, [libraryBooks, queryTerm]); ? categoryFilteredBooks.filter((book) => bookFilter(book))
: categoryFilteredBooks;
}, [categoryFilteredBooks, queryTerm]);
const currentBookshelfItems = useMemo(() => { const currentBookshelfItems = useMemo(() => {
if (groupBy === LibraryGroupByType.Group) { if (groupBy === LibraryGroupByType.Group) {
@@ -452,6 +484,112 @@ const Bookshelf: React.FC<BookshelfProps> = ({
setShowStatusAlert(true); setShowStatusAlert(true);
}; };
const getSingleSelectedBook = () => {
const ids = getSelectedBooks();
if (ids.length !== 1) return;
return filteredBooks.find((book) => book.hash === ids[0]);
};
const showBilingualFilterSelection = () => {
const book = getSingleSelectedBook();
if (!book || book.format !== 'EPUB') return;
setBilingualFilterBook(null);
setShowSelectModeActions(false);
setShowBilingualFilterAlert(true);
};
const showBilingualFilterBook = useCallback(
(book: Book) => {
if (book.format !== 'EPUB') {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: _('Only EPUB books can be filtered'),
timeout: 2500,
});
return;
}
setBilingualFilterBook(book);
setShowSelectModeActions(false);
setShowBilingualFilterAlert(true);
},
[_],
);
const closeBilingualFilterSelection = () => {
if (bilingualFilterProcessing) return;
setShowBilingualFilterAlert(false);
setBilingualFilterBook(null);
if (isSelectMode) setShowSelectModeActions(true);
};
const runBilingualFilter = async (options: BilingualFilterOptions) => {
const book = bilingualFilterBook ?? getSingleSelectedBook();
if (!book || !appService) return;
if (book.format !== 'EPUB') {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: _('Only EPUB books can be filtered'),
timeout: 2500,
});
return;
}
setBilingualFilterProcessing(true);
setLoading(true);
try {
if (!(await appService.isBookAvailable(book))) {
if (!book.uploadedAt || !(await handleBookDownload(book, { queued: false }))) {
throw new Error('Book file is not available locally');
}
}
const { file } = await appService.loadBookContent(book);
try {
const result = await filterBilingualEpubFile(file, options);
const importedBook = await appService.importBook(result.file, libraryBooks);
if (!importedBook) throw new Error('Failed to import generated EPUB');
importedBook.group = book.group;
importedBook.groupId = book.groupId;
importedBook.groupName = book.groupName;
importedBook.tags = book.tags;
importedBook.updatedAt = Date.now();
await updateBooks(envConfig, [importedBook]);
handlePushLibrary();
setSelectedBooks([]);
setBilingualFilterBook(null);
setShowBilingualFilterAlert(false);
handleSetSelectMode(false);
eventDispatcher.dispatch('toast', {
type: 'success',
message: _('Created {{title}}. Removed {{count}} paragraph(s).', {
title: importedBook.title || result.title,
count: result.stats.removed,
}),
timeout: 3500,
});
} finally {
const closable = file as File & { close?: () => Promise<void> | void };
await closable.close?.();
}
} catch (error) {
console.error('Failed to filter bilingual EPUB:', error);
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to filter bilingual EPUB'),
timeout: 3000,
});
setBilingualFilterBook(null);
setShowBilingualFilterAlert(false);
if (isSelectMode) setShowSelectModeActions(true);
} finally {
setLoading(false);
setBilingualFilterProcessing(false);
}
};
const sendSelectedBook = async () => { const sendSelectedBook = async () => {
// "Send" hands the actual book file (epub/pdf/...) to the OS share // "Send" hands the actual book file (epub/pdf/...) to the OS share
// sheet (UIActivityViewController on iOS, Intent.ACTION_SEND on // sheet (UIActivityViewController on iOS, Intent.ACTION_SEND on
@@ -678,6 +816,11 @@ const Bookshelf: React.FC<BookshelfProps> = ({
); );
const selectedBooks = getSelectedBooks(); const selectedBooks = getSelectedBooks();
const selectedBilingualBook =
selectedBooks.length === 1
? filteredBooks.find((book) => book.hash === selectedBooks[0])
: null;
const bilingualFilterEnabled = !!selectedBilingualBook && selectedBilingualBook.format === 'EPUB';
const isGridMode = viewMode === 'grid'; const isGridMode = viewMode === 'grid';
const hasItems = sortedBookshelfItems.length > 0; const hasItems = sortedBookshelfItems.length > 0;
// In grid mode the Import-Books "+" tile is rendered as an extra grid cell // In grid mode the Import-Books "+" tile is rendered as an extra grid cell
@@ -716,6 +859,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
coverFit={coverFit as LibraryCoverFitType} coverFit={coverFit as LibraryCoverFitType}
autoColumns={settings.libraryAutoColumns} autoColumns={settings.libraryAutoColumns}
fixedColumns={settings.libraryColumns} fixedColumns={settings.libraryColumns}
sidebarVisible={sidebarVisible}
onOpenBook={openRecentBook} onOpenBook={openRecentBook}
handleBookUpload={handleBookUpload} handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload} handleBookDownload={handleBookDownload}
@@ -728,6 +872,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
coverFit, coverFit,
settings.libraryAutoColumns, settings.libraryAutoColumns,
settings.libraryColumns, settings.libraryColumns,
sidebarVisible,
openRecentBook, openRecentBook,
handleBookUpload, handleBookUpload,
handleBookDownload, handleBookDownload,
@@ -739,9 +884,10 @@ const Bookshelf: React.FC<BookshelfProps> = ({
() => ({ () => ({
autoColumns: settings.libraryAutoColumns, autoColumns: settings.libraryAutoColumns,
fixedColumns: settings.libraryColumns, fixedColumns: settings.libraryColumns,
sidebarVisible,
recentShelfHeader, recentShelfHeader,
}), }),
[settings.libraryAutoColumns, settings.libraryColumns, recentShelfHeader], [settings.libraryAutoColumns, settings.libraryColumns, sidebarVisible, recentShelfHeader],
); );
const renderBookshelfItem = useCallback( const renderBookshelfItem = useCallback(
@@ -791,6 +937,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
handleBookDelete={handleBookDelete} handleBookDelete={handleBookDelete}
handleSetSelectMode={handleSetSelectMode} handleSetSelectMode={handleSetSelectMode}
handleShowDetailsBook={handleShowDetailsBook} handleShowDetailsBook={handleShowDetailsBook}
handleBilingualFilterBook={showBilingualFilterBook}
handleLibraryNavigation={handleLibraryNavigation} handleLibraryNavigation={handleLibraryNavigation}
handleUpdateReadingStatus={handleUpdateReadingStatus} handleUpdateReadingStatus={handleUpdateReadingStatus}
transferProgress={ transferProgress={
@@ -816,6 +963,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
handleBookDelete, handleBookDelete,
handleSetSelectMode, handleSetSelectMode,
handleShowDetailsBook, handleShowDetailsBook,
showBilingualFilterBook,
handleLibraryNavigation, handleLibraryNavigation,
handleUpdateReadingStatus, handleUpdateReadingStatus,
], ],
@@ -889,9 +1037,20 @@ const Bookshelf: React.FC<BookshelfProps> = ({
onGroup={groupSelectedBooks} onGroup={groupSelectedBooks}
onDetails={openBookDetails} onDetails={openBookDetails}
onStatus={showStatusSelection} onStatus={showStatusSelection}
onBilingualFilter={showBilingualFilterSelection}
onSend={sendSelectedBook} onSend={sendSelectedBook}
onDelete={deleteSelectedBooks} onDelete={deleteSelectedBooks}
onCancel={() => handleSetSelectMode(false)} onCancel={() => handleSetSelectMode(false)}
bilingualFilterEnabled={bilingualFilterEnabled}
/>
)}
{showBilingualFilterAlert && (bilingualFilterBook || selectedBilingualBook) && (
<BilingualFilterAlert
bookTitle={(bilingualFilterBook || selectedBilingualBook)?.title || ''}
safeAreaBottom={safeAreaInsets?.bottom || 0}
processing={bilingualFilterProcessing}
onCancel={closeBilingualFilterSelection}
onFilter={runBilingualFilter}
/> />
)} )}
{showGroupingModal && selectedBooks.length > 0 && ( {showGroupingModal && selectedBooks.length > 0 && (
@@ -1,16 +1,19 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { useCallback } from 'react'; import { useCallback, useState } from 'react';
import { useEnv } from '@/context/EnvContext'; import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore'; import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation'; import { useTranslation } from '@/hooks/useTranslation';
import { useLongPress } from '@/hooks/useLongPress'; import { useLongPress } from '@/hooks/useLongPress';
import { Menu, type MenuItemOptions } from '@tauri-apps/api/menu'; import { Menu as TauriMenu } from '@tauri-apps/api/menu';
import { revealItemInDir } from '@tauri-apps/plugin-opener'; import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { eventDispatcher } from '@/utils/event'; import { eventDispatcher } from '@/utils/event';
import { openExternalUrl } from '@/utils/open'; import { openExternalUrl } from '@/utils/open';
import { getBookGoodreadsQuery, getGoodreadsSearchUrl } from '@/utils/goodreads'; import { getBookGoodreadsQuery, getGoodreadsSearchUrl } from '@/utils/goodreads';
import { getOSPlatform } from '@/utils/misc'; import { getOSPlatform } from '@/utils/misc';
import { throttle } from '@/utils/throttle'; import { throttle } from '@/utils/throttle';
import { Overlay } from '@/components/Overlay';
import Menu from '@/components/Menu';
import MenuItem from '@/components/MenuItem';
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings'; import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants'; import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os'; import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
@@ -24,6 +27,17 @@ import BookItem from './BookItem';
import GroupItem from './GroupItem'; import GroupItem from './GroupItem';
import { useOpenBook } from '../hooks/useOpenBook'; import { useOpenBook } from '../hooks/useOpenBook';
type WebContextMenuItem = {
text: string;
action: () => void | Promise<void>;
};
type WebContextMenuState = {
x: number;
y: number;
items: WebContextMenuItem[];
} | null;
export const generateBookshelfItems = ( export const generateBookshelfItems = (
books: Book[], books: Book[],
parentGroupName: string, parentGroupName: string,
@@ -103,6 +117,7 @@ interface BookshelfItemProps {
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>; handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleSetSelectMode: (selectMode: boolean) => void; handleSetSelectMode: (selectMode: boolean) => void;
handleShowDetailsBook: (book: Book) => void; handleShowDetailsBook: (book: Book) => void;
handleBilingualFilterBook: (book: Book) => void;
handleLibraryNavigation: (targetGroup: string) => void; handleLibraryNavigation: (targetGroup: string) => void;
handleUpdateReadingStatus: (book: Book, status: ReadingStatus | undefined) => void; handleUpdateReadingStatus: (book: Book, status: ReadingStatus | undefined) => void;
} }
@@ -121,6 +136,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
handleBookDownload, handleBookDownload,
handleSetSelectMode, handleSetSelectMode,
handleShowDetailsBook, handleShowDetailsBook,
handleBilingualFilterBook,
handleLibraryNavigation, handleLibraryNavigation,
handleUpdateReadingStatus, handleUpdateReadingStatus,
}) => { }) => {
@@ -128,6 +144,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
const { appService } = useEnv(); const { appService } = useEnv();
const { settings } = useSettingsStore(); const { settings } = useSettingsStore();
const { openBook } = useOpenBook({ setLoading, handleBookDownload }); const { openBook } = useOpenBook({ setLoading, handleBookDownload });
const [webContextMenu, setWebContextMenu] = useState<WebContextMenuState>(null);
const showBookDetailsModal = useCallback(async (book: Book) => { const showBookDetailsModal = useCallback(async (book: Book) => {
handleShowDetailsBook(book); handleShowDetailsBook(book);
@@ -157,8 +174,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
[isSelectMode, handleLibraryNavigation], [isSelectMode, handleLibraryNavigation],
); );
const bookContextMenuHandler = async (book: Book) => { const bookContextMenuHandler = async (book: Book, event?: React.MouseEvent) => {
if (!appService?.hasContextMenu) return;
const osPlatform = getOSPlatform(); const osPlatform = getOSPlatform();
const fileRevealLabel = const fileRevealLabel =
FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default; FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default;
@@ -166,7 +182,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
// in a single Menu.new({ items }) call. Appending items one-by-one with // in a single Menu.new({ items }) call. Appending items one-by-one with
// un-awaited Menu.append() promises races on the Tauri IPC boundary and // un-awaited Menu.append() promises races on the Tauri IPC boundary and
// shuffles the order on every open (issue #4389). // shuffles the order on every open (issue #4389).
const itemOptions: Record<BookContextMenuItemId, MenuItemOptions> = { const itemOptions: Record<BookContextMenuItemId, WebContextMenuItem> = {
select: { select: {
text: itemSelected ? _('Deselect Book') : _('Select Book'), text: itemSelected ? _('Deselect Book') : _('Select Book'),
action: async () => { action: async () => {
@@ -214,6 +230,12 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
showBookDetailsModal(book); showBookDetailsModal(book);
}, },
}, },
bilingual: {
text: _('Bilingual'),
action: async () => {
handleBilingualFilterBook(book);
},
},
showInFinder: { showInFinder: {
text: _(fileRevealLabel), text: _(fileRevealLabel),
action: async () => { action: async () => {
@@ -254,16 +276,22 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
}, },
}, },
}; };
const items = getBookContextMenuItemIds(book).map((id) => itemOptions[id]); const itemIds = getBookContextMenuItemIds(book).filter(
const menu = await Menu.new({ items }); (id) => appService?.hasContextMenu || id !== 'showInFinder',
await menu.popup(); );
const items = itemIds.map((id) => itemOptions[id]);
if (appService?.hasContextMenu) {
const menu = await TauriMenu.new({ items });
await menu.popup();
return;
}
if (event) setWebContextMenu({ x: event.clientX, y: event.clientY, items });
}; };
const groupContextMenuHandler = async (group: BooksGroup) => { const groupContextMenuHandler = async (group: BooksGroup, event?: React.MouseEvent) => {
if (!appService?.hasContextMenu) return;
// Single Menu.new({ items }) call keeps the order deterministic — see the // Single Menu.new({ items }) call keeps the order deterministic — see the
// note in bookContextMenuHandler about the Menu.append() IPC race (#4389). // note in bookContextMenuHandler about the Menu.append() IPC race (#4389).
const items: MenuItemOptions[] = [ const items: WebContextMenuItem[] = [
{ {
text: itemSelected ? _('Deselect Group') : _('Select Group'), text: itemSelected ? _('Deselect Group') : _('Select Group'),
action: async () => { action: async () => {
@@ -293,8 +321,12 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
}, },
}, },
]; ];
const menu = await Menu.new({ items }); if (appService?.hasContextMenu) {
await menu.popup(); const menu = await TauriMenu.new({ items });
await menu.popup();
return;
}
if (event) setWebContextMenu({ x: event.clientX, y: event.clientY, items });
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -330,14 +362,28 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
const handleContextMenu = useCallback( const handleContextMenu = useCallback(
throttle(() => { throttle((event?: React.MouseEvent) => {
if ('format' in item) { if ('format' in item) {
bookContextMenuHandler(item as Book); bookContextMenuHandler(item as Book, event);
} else { } else {
groupContextMenuHandler(item as BooksGroup); groupContextMenuHandler(item as BooksGroup, event);
} }
}, 100), }, 100),
[itemSelected, settings.localBooksDir], [
appService?.hasContextMenu,
appService?.isMobileApp,
handleBookDownload,
handleBookUpload,
handleBilingualFilterBook,
handleGroupBooks,
handleSetSelectMode,
handleUpdateReadingStatus,
item,
itemSelected,
settings.localBooksDir,
showBookDetailsModal,
toggleSelection,
],
); );
const { pressing, handlers } = useLongPress( const { pressing, handlers } = useLongPress(
@@ -348,9 +394,11 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
onTap: () => { onTap: () => {
handleOpenItem(); handleOpenItem();
}, },
onContextMenu: () => { onContextMenu: (event) => {
if (appService?.hasContextMenu) { if (appService?.hasContextMenu) {
handleContextMenu(); handleContextMenu();
} else if (!appService?.isMobileApp) {
handleContextMenu(event);
} else if (appService?.isAndroidApp) { } else if (appService?.isAndroidApp) {
handleSelectItem(); handleSelectItem();
} }
@@ -380,6 +428,29 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
return ( return (
<div className={clsx(mode === 'grid' ? 'h-full' : 'sm:hover:bg-base-300/50 px-4 sm:px-6')}> <div className={clsx(mode === 'grid' ? 'h-full' : 'sm:hover:bg-base-300/50 px-4 sm:px-6')}>
{webContextMenu && (
<>
<Overlay onDismiss={() => setWebContextMenu(null)} className='z-40' />
<Menu
className='bg-base-100 border-base-300 fixed z-50 min-w-56 rounded-lg border p-1 shadow-xl'
style={{ left: webContextMenu.x, top: webContextMenu.y }}
onCancel={() => setWebContextMenu(null)}
>
{webContextMenu.items.map((menuItem) => (
<MenuItem
key={menuItem.text}
label={menuItem.text}
noIcon
transient
onClick={() => {
setWebContextMenu(null);
void menuItem.action();
}}
/>
))}
</Menu>
</>
)}
<div <div
className={clsx( className={clsx(
'visible-focus-inset-2 group', 'visible-focus-inset-2 group',
@@ -22,6 +22,7 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showLeftArrow, setShowLeftArrow] = useState(false); const [showLeftArrow, setShowLeftArrow] = useState(false);
const [showRightArrow, setShowRightArrow] = useState(false); const [showRightArrow, setShowRightArrow] = useState(false);
const isSingleBookGrid = mode === 'grid' && group.books.length === 1;
const checkScrollArrows = () => { const checkScrollArrows = () => {
if (mode === 'list' && scrollContainerRef.current) { if (mode === 'list' && scrollContainerRef.current) {
@@ -115,7 +116,10 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
<div <div
ref={mode === 'list' ? scrollContainerRef : undefined} ref={mode === 'list' ? scrollContainerRef : undefined}
className={clsx( className={clsx(
mode === 'grid' && 'grid w-full grid-cols-2 grid-rows-2 gap-1 overflow-hidden', mode === 'grid' &&
(isSingleBookGrid
? 'flex w-full overflow-hidden'
: 'grid w-full grid-cols-2 grid-rows-2 gap-1 overflow-hidden'),
mode === 'list' && 'flex h-28 gap-2 overflow-x-auto overflow-y-hidden', mode === 'list' && 'flex h-28 gap-2 overflow-x-auto overflow-y-hidden',
mode === 'list' ? 'library-list-item' : 'library-grid-item', mode === 'list' ? 'library-list-item' : 'library-grid-item',
)} )}
@@ -137,7 +141,7 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
key={book.hash} key={book.hash}
className={clsx( className={clsx(
'relative aspect-[28/41] h-full', 'relative aspect-[28/41] h-full',
mode === 'grid' && 'w-full', mode === 'grid' && (isSingleBookGrid ? 'mx-auto' : 'w-full'),
mode === 'list' && 'flex-shrink-0', mode === 'list' && 'flex-shrink-0',
)} )}
> >
@@ -8,6 +8,7 @@ import Menu from '@/components/Menu';
interface ImportMenuProps { interface ImportMenuProps {
setIsDropdownOpen?: (open: boolean) => void; setIsDropdownOpen?: (open: boolean) => void;
menuClassName?: string;
onImportBooksFromFiles: () => void; onImportBooksFromFiles: () => void;
onImportBooksFromDirectory?: () => void; onImportBooksFromDirectory?: () => void;
onImportBookFromUrl?: () => void; onImportBookFromUrl?: () => void;
@@ -16,6 +17,7 @@ interface ImportMenuProps {
const ImportMenu: React.FC<ImportMenuProps> = ({ const ImportMenu: React.FC<ImportMenuProps> = ({
setIsDropdownOpen, setIsDropdownOpen,
menuClassName,
onImportBooksFromFiles, onImportBooksFromFiles,
onImportBooksFromDirectory, onImportBooksFromDirectory,
onImportBookFromUrl, onImportBookFromUrl,
@@ -46,7 +48,10 @@ const ImportMenu: React.FC<ImportMenuProps> = ({
return ( return (
<Menu <Menu
className={clsx('dropdown-content bg-base-100 rounded-box !relative z-[1] mt-3 p-2 shadow')} className={clsx(
'dropdown-content bg-base-100 rounded-box !relative z-[1] mt-3 p-2 shadow',
menuClassName,
)}
onCancel={() => setIsDropdownOpen?.(false)} onCancel={() => setIsDropdownOpen?.(false)}
> >
<MenuItem <MenuItem
@@ -14,6 +14,7 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore'; import { useLibraryStore } from '@/store/libraryStore';
import { useTrafficLight } from '@/hooks/useTrafficLight'; import { useTrafficLight } from '@/hooks/useTrafficLight';
import { useResponsiveSize } from '@/hooks/useResponsiveSize'; import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useIsMobileViewport } from '@/hooks/useIsMobileViewport';
import { debounce } from '@/utils/debounce'; import { debounce } from '@/utils/debounce';
import useShortcuts from '@/hooks/useShortcuts'; import useShortcuts from '@/hooks/useShortcuts';
import WindowButtons from '@/components/WindowButtons'; import WindowButtons from '@/components/WindowButtons';
@@ -33,6 +34,7 @@ interface LibraryHeaderProps {
onToggleSelectMode: () => void; onToggleSelectMode: () => void;
onSelectAll: () => void; onSelectAll: () => void;
onDeselectAll: () => void; onDeselectAll: () => void;
onToggleSidebar: () => void;
} }
const LibraryHeader: React.FC<LibraryHeaderProps> = ({ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
@@ -46,6 +48,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
onToggleSelectMode, onToggleSelectMode,
onSelectAll, onSelectAll,
onDeselectAll, onDeselectAll,
onToggleSidebar,
}) => { }) => {
const _ = useTranslation(); const _ = useTranslation();
const router = useRouter(); const router = useRouter();
@@ -59,6 +62,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const { isTrafficLightVisible } = useTrafficLight(headerRef); const { isTrafficLightVisible } = useTrafficLight(headerRef);
const iconSize18 = useResponsiveSize(18); const iconSize18 = useResponsiveSize(18);
const { safeAreaInsets: insets } = useThemeStore(); const { safeAreaInsets: insets } = useThemeStore();
const isNarrowViewport = useIsMobileViewport(641);
useShortcuts({ useShortcuts({
onToggleSelectMode, onToggleSelectMode,
@@ -92,7 +96,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
if (!insets) return null; if (!insets) return null;
const isMobile = appService?.isMobile || window.innerWidth <= 640; const isMobile = appService?.isMobile || isNarrowViewport;
return ( return (
<div <div
@@ -109,8 +113,17 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
}} }}
> >
<div className='flex w-full items-center justify-between space-x-6 sm:space-x-12'> <div className='flex w-full items-center justify-between space-x-6 sm:space-x-12'>
<div className='exclude-title-bar-mousedown relative flex w-full items-center pl-4'> <div className='exclude-title-bar-mousedown relative flex w-full items-center gap-2 pl-2 sm:pl-4'>
<div className='relative flex h-9 w-full items-center sm:h-7'> <button
type='button'
className='btn btn-ghost h-8 min-h-8 w-8 shrink-0 p-0'
aria-label={_('Toggle Sidebar')}
title={_('Toggle Sidebar')}
onClick={onToggleSidebar}
>
<MdOutlineMenu role='none' size={iconSize18} />
</button>
<div className='relative flex h-9 min-w-0 flex-1 items-center sm:h-7'>
<span className='text-base-content/50 absolute ps-3'> <span className='text-base-content/50 absolute ps-3'>
<FaSearch className='h-4 w-4' /> <FaSearch className='h-4 w-4' />
</span> </span>
@@ -0,0 +1,332 @@
import clsx from 'clsx';
import React, { useCallback, useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import {
PiBooks,
PiCalendarBlank,
PiDotsThreeCircle,
PiGear,
PiGlobeHemisphereEast,
PiHouseLine,
PiPlus,
PiSelectionAll,
PiSelectionAllFill,
PiStackSimple,
PiTag,
PiUser,
} from 'react-icons/pi';
import { MdBusiness, MdKeyboardArrowDown, MdKeyboardArrowUp, MdOutlineMenu } from 'react-icons/md';
import { IoMdCloseCircle } from 'react-icons/io';
import { FaSearch } from 'react-icons/fa';
import type { IconType } from 'react-icons';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { debounce } from '@/utils/debounce';
import { navigateToLibrary } from '@/utils/nav';
import Dropdown from '@/components/Dropdown';
import ImportMenu from './ImportMenu';
import SettingsMenu from './SettingsMenu';
import ViewMenu from './ViewMenu';
import type { Book } from '@/types/book';
import {
countCategoryValues,
ensureLibraryCategoryFilter,
type CategoryValue,
type LibraryCategoryFilter,
} from '../utils/categoryFilters';
interface LibrarySidebarProps {
books: Book[];
isSelectMode: boolean;
onPullLibrary: () => void;
onImportBooksFromFiles: () => void;
onImportBooksFromDirectory?: () => void;
onImportBookFromUrl?: () => void;
onOpenCatalogManager: () => void;
onToggleSelectMode: () => void;
onToggleSidebar: () => void;
isViewportResolved: boolean;
}
type CategoryItem = {
id: LibraryCategoryFilter;
label: string;
count: number;
Icon: IconType;
values: CategoryValue[];
};
const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
books,
isSelectMode,
onPullLibrary,
onImportBooksFromFiles,
onImportBooksFromDirectory,
onImportBookFromUrl,
onOpenCatalogManager,
onToggleSelectMode,
onToggleSidebar,
isViewportResolved,
}) => {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const iconSize18 = useResponsiveSize(18);
const [searchQuery, setSearchQuery] = useState(searchParams?.get('q') ?? '');
const activeCategory = ensureLibraryCategoryFilter(searchParams?.get('category'));
const activeCategoryValue = searchParams?.get('categoryValue') || '';
const [expandedCategories, setExpandedCategories] = useState<Set<LibraryCategoryFilter>>(
() => new Set(activeCategory !== 'all' ? [activeCategory] : ['author']),
);
const activeBooks = useMemo(() => books.filter((book) => !book.deletedAt), [books]);
const categories: CategoryItem[] = useMemo(() => {
const makeCategory = (
id: LibraryCategoryFilter,
label: string,
Icon: IconType,
): CategoryItem => {
const values = countCategoryValues(activeBooks, id);
return { id, label, count: values.length, Icon, values };
};
return [
{
id: 'all',
label: _('All Books'),
count: activeBooks.length,
Icon: PiBooks,
values: [],
},
makeCategory('author', _('Authors'), PiUser),
makeCategory('publisher', _('Publisher'), MdBusiness),
makeCategory('year', _('Publication Year'), PiCalendarBlank),
makeCategory('language', _('Language'), PiGlobeHemisphereEast),
makeCategory('format', _('Category'), PiStackSimple),
makeCategory('subject', _('Subject'), PiHouseLine),
makeCategory('tag', _('Tags'), PiTag),
];
}, [_, activeBooks]);
const updateParams = useCallback(
(updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams?.toString());
for (const [key, value] of Object.entries(updates)) {
if (!value) {
params.delete(key);
} else {
params.set(key, value);
}
}
if (params.get('category') === 'all') params.delete('category');
navigateToLibrary(router, params.toString());
},
[router, searchParams],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedUpdateQueryParam = useCallback(
debounce((value: string) => updateParams({ q: value || null }), 500),
[updateParams],
);
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newQuery = event.target.value;
setSearchQuery(newQuery);
debouncedUpdateQueryParam(newQuery);
};
const clearSearch = () => {
setSearchQuery('');
debouncedUpdateQueryParam('');
};
const toggleCategory = (category: LibraryCategoryFilter) => {
if (category === 'all') {
updateParams({ category: null, categoryValue: null, group: null });
return;
}
setExpandedCategories((prev) => {
const next = new Set(prev);
if (next.has(category)) {
next.delete(category);
} else {
next.add(category);
}
return next;
});
};
const selectCategoryValue = (category: LibraryCategoryFilter, value: string) => {
updateParams({ category, categoryValue: value, group: null });
};
return (
<aside
className={clsx(
'library-sidebar bg-base-200/95 border-base-300/70 fixed inset-y-0 start-0 z-50 h-full w-[min(82vw,248px)] shrink-0 flex-col border-r md:relative md:inset-auto md:z-auto md:w-[248px]',
isViewportResolved ? 'flex' : 'hidden md:flex',
'pt-[max(env(safe-area-inset-top),0px)]',
)}
>
<div className='exclude-title-bar-mousedown flex min-h-0 flex-1 flex-col px-3 py-3'>
<div className='mb-3 flex h-8 items-center gap-2 px-1'>
<button
type='button'
className='btn btn-ghost h-7 min-h-7 w-7 shrink-0 p-0'
aria-label={_('Toggle Sidebar')}
title={_('Toggle Sidebar')}
onClick={onToggleSidebar}
>
<MdOutlineMenu className='text-base-content/70 h-5 w-5' />
</button>
<div className='min-w-0 flex-1 truncate text-sm font-semibold'>Readest</div>
</div>
<div className='relative mb-3 flex h-8 items-center'>
<span className='text-base-content/45 absolute left-3'>
<FaSearch className='h-3.5 w-3.5' />
</span>
<input
type='text'
value={searchQuery}
placeholder={_('Search Books...')}
onChange={handleSearchChange}
spellCheck='false'
className={clsx(
'input input-sm bg-base-100 border-base-300 h-8 w-full rounded-md ps-9 pe-8',
'text-sm placeholder:text-base-content/45 focus:outline-none',
)}
/>
{searchQuery && (
<button
type='button'
onClick={clearSearch}
className='text-base-content/40 hover:text-base-content/70 absolute right-2'
aria-label={_('Clear Search')}
>
<IoMdCloseCircle className='h-4 w-4' />
</button>
)}
</div>
<nav className='min-h-0 flex-1 overflow-y-auto pb-3' aria-label={_('Categories')}>
<div className='space-y-1'>
{categories.map(({ id, label, count, Icon, values }) => {
const active = (activeCategory || 'all') === id && !activeCategoryValue;
const expanded = expandedCategories.has(id);
return (
<div key={id}>
<button
type='button'
onClick={() => toggleCategory(id)}
className={clsx(
'flex h-9 w-full items-center gap-3 rounded-md px-2 text-sm transition-colors',
active
? 'bg-base-300 text-base-content'
: 'text-base-content/85 hover:bg-base-300/55',
)}
>
<Icon className='h-4.5 w-4.5 shrink-0' />
<span className='min-w-0 flex-1 truncate text-left'>{label}</span>
{id !== 'all' &&
(expanded ? (
<MdKeyboardArrowUp className='text-base-content/60 h-4 w-4 shrink-0' />
) : (
<MdKeyboardArrowDown className='text-base-content/60 h-4 w-4 shrink-0' />
))}
<span className='bg-base-content/30 text-base-100 min-w-5 rounded-full px-1.5 text-center text-[11px] font-medium leading-5'>
{count}
</span>
</button>
{id !== 'all' && expanded && values.length > 0 && (
<div className='mt-1 space-y-1 pb-1 ps-7'>
{values.map((item) => {
const valueActive =
activeCategory === id && activeCategoryValue === item.value;
return (
<button
key={item.value}
type='button'
onClick={() => selectCategoryValue(id, item.value)}
className={clsx(
'flex h-8 w-full items-center gap-2 rounded-md px-2 text-sm transition-colors',
valueActive
? 'bg-base-300 text-base-content'
: 'text-base-content/80 hover:bg-base-300/45',
)}
>
<span
className={clsx(
'h-4 w-0.5 rounded-full',
valueActive ? 'bg-primary' : 'bg-transparent',
)}
/>
<span className='min-w-0 flex-1 truncate text-left'>{item.value}</span>
<span className='bg-base-content/30 text-base-100 min-w-5 rounded-full px-1.5 text-center text-[11px] font-medium leading-5'>
{item.count}
</span>
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
</nav>
<div className='border-base-300/70 flex items-center gap-1 border-t pt-2'>
<Dropdown
label={_('Import Books')}
className='exclude-title-bar-mousedown dropdown-top dropdown-start'
menuClassName='mb-2 mt-0'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiPlus role='none' size={iconSize18} />}
>
<ImportMenu
onImportBooksFromFiles={onImportBooksFromFiles}
onImportBooksFromDirectory={onImportBooksFromDirectory}
onImportBookFromUrl={onImportBookFromUrl}
onOpenCatalogManager={onOpenCatalogManager}
/>
</Dropdown>
<Dropdown
label={_('View Menu')}
className='exclude-title-bar-mousedown dropdown-top dropdown-center'
menuClassName='mb-2 mt-0'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiDotsThreeCircle role='none' size={iconSize18} />}
>
<ViewMenu />
</Dropdown>
<button
onClick={onToggleSelectMode}
aria-label={_('Select Books')}
title={_('Select Books')}
className='btn btn-ghost h-8 min-h-8 w-8 p-0'
>
{isSelectMode ? (
<PiSelectionAllFill className='h-[18px] w-[18px]' />
) : (
<PiSelectionAll className='h-[18px] w-[18px]' />
)}
</button>
<div className='flex-1' />
<Dropdown
label={_('Settings Menu')}
className='exclude-title-bar-mousedown dropdown-top dropdown-end'
menuClassName='mb-2 mt-0'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiGear role='none' size={iconSize18} />}
>
<SettingsMenu onPullLibrary={onPullLibrary} />
</Dropdown>
</div>
</div>
</aside>
);
};
export default LibrarySidebar;
@@ -19,6 +19,7 @@ interface RecentShelfProps {
// Mirror the bookshelf grid's column model so covers are the same size. // Mirror the bookshelf grid's column model so covers are the same size.
autoColumns: boolean; autoColumns: boolean;
fixedColumns: number; fixedColumns: number;
sidebarVisible?: boolean;
onOpenBook: (book: Book) => void; onOpenBook: (book: Book) => void;
handleBookUpload: (book: Book) => void; handleBookUpload: (book: Book) => void;
handleBookDownload: (book: Book, options?: { redownload?: boolean; queued?: boolean }) => void; handleBookDownload: (book: Book, options?: { redownload?: boolean; queued?: boolean }) => void;
@@ -110,6 +111,7 @@ const RecentShelf: React.FC<RecentShelfProps> = ({
coverFit, coverFit,
autoColumns, autoColumns,
fixedColumns, fixedColumns,
sidebarVisible = false,
onOpenBook, onOpenBook,
handleBookUpload, handleBookUpload,
handleBookDownload, handleBookDownload,
@@ -121,7 +123,9 @@ const RecentShelf: React.FC<RecentShelfProps> = ({
// `--rs-gap` mirrors the grid's `gap-x-4 sm:gap-x-0` so the width formula // `--rs-gap` mirrors the grid's `gap-x-4 sm:gap-x-0` so the width formula
// subtracts the right gap at each breakpoint. // subtracts the right gap at each breakpoint.
const colsClass = autoColumns const colsClass = autoColumns
? '[--rs-cols:3] sm:[--rs-cols:4] md:[--rs-cols:6] xl:[--rs-cols:8] 2xl:[--rs-cols:12]' ? sidebarVisible
? '[--rs-cols:3] sm:[--rs-cols:4] md:[--rs-cols:3] lg:[--rs-cols:4] xl:[--rs-cols:5] 2xl:[--rs-cols:8]'
: '[--rs-cols:3] sm:[--rs-cols:4] md:[--rs-cols:6] xl:[--rs-cols:8] 2xl:[--rs-cols:12]'
: ''; : '';
const colsStyle = autoColumns const colsStyle = autoColumns
? undefined ? undefined
@@ -162,7 +166,7 @@ const RecentShelf: React.FC<RecentShelfProps> = ({
const observer = new ResizeObserver(measure); const observer = new ResizeObserver(measure);
observer.observe(el); observer.observe(el);
return () => observer.disconnect(); return () => observer.disconnect();
}, [measure, books, autoColumns, fixedColumns, coverFit]); }, [measure, books, autoColumns, fixedColumns, sidebarVisible, coverFit]);
const scrollByPage = (direction: -1 | 1) => { const scrollByPage = (direction: -1 | 1) => {
const el = scrollerRef.current; const el = scrollerRef.current;
@@ -7,7 +7,7 @@ import {
MdCheckCircleOutline, MdCheckCircleOutline,
} from 'react-icons/md'; } from 'react-icons/md';
import { IoShareSocialOutline } from 'react-icons/io5'; import { IoShareSocialOutline } from 'react-icons/io5';
import { LuFolderPlus } from 'react-icons/lu'; import { LuFolderPlus, LuLanguages } from 'react-icons/lu';
import { useKeyDownActions } from '@/hooks/useKeyDownActions'; import { useKeyDownActions } from '@/hooks/useKeyDownActions';
import { useTranslation } from '@/hooks/useTranslation'; import { useTranslation } from '@/hooks/useTranslation';
import { isMd5 } from '@/utils/md5'; import { isMd5 } from '@/utils/md5';
@@ -25,6 +25,7 @@ interface SelectModeActionsProps {
onGroup: () => void; onGroup: () => void;
onDetails: () => void; onDetails: () => void;
onStatus: () => void; onStatus: () => void;
onBilingualFilter: () => void;
// The macOS / iPad share popover is anchored to the selected book's // The macOS / iPad share popover is anchored to the selected book's
// cover (located via its data-book-hash attribute), not to this // cover (located via its data-book-hash attribute), not to this
// button — the user's visual focus is on the cover they just tapped. // button — the user's visual focus is on the cover they just tapped.
@@ -32,6 +33,7 @@ interface SelectModeActionsProps {
onSend: () => void; onSend: () => void;
onDelete: () => void; onDelete: () => void;
onCancel: () => void; onCancel: () => void;
bilingualFilterEnabled?: boolean;
} }
const SelectModeActions: React.FC<SelectModeActionsProps> = ({ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
@@ -42,9 +44,11 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
onGroup, onGroup,
onDetails, onDetails,
onStatus, onStatus,
onBilingualFilter,
onSend, onSend,
onDelete, onDelete,
onCancel, onCancel,
bilingualFilterEnabled = false,
}) => { }) => {
const _ = useTranslation(); const _ = useTranslation();
@@ -110,13 +114,21 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
<MdInfoOutline /> <MdInfoOutline />
<div>{_('Details')}</div> <div>{_('Details')}</div>
</button> </button>
<button
onClick={onBilingualFilter}
className={clsx(
'flex flex-col items-center justify-center gap-1',
!bilingualFilterEnabled && 'btn-disabled opacity-50',
)}
>
<LuLanguages />
<div>{_('Bilingual')}</div>
</button>
{sendEnabled && ( {sendEnabled && (
<button <button
onClick={onSend} onClick={onSend}
className={clsx( className={clsx(
'flex flex-col items-center justify-center gap-1', 'flex flex-col items-center justify-center gap-1',
// Wraps to the start of the second row on narrow viewports.
'max-[500px]:col-start-1',
(!hasSingleSelection || !hasValidBooks) && 'btn-disabled opacity-50', (!hasSingleSelection || !hasValidBooks) && 'btn-disabled opacity-50',
)} )}
> >
@@ -128,12 +140,6 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
onClick={onDelete} onClick={onDelete}
className={clsx( className={clsx(
'flex flex-col items-center justify-center gap-1', 'flex flex-col items-center justify-center gap-1',
// Without Send (Linux/Windows/web), Delete needs an explicit
// col-start-2 so the wrapped row {Delete, Cancel} stays centred
// under the 4-col grid. With Send present, the layout is
// {Send, Delete, Cancel} starting at col-start-1, so Delete
// naturally lands in col-start-2 without an override.
!sendEnabled && 'max-[500px]:col-start-2',
!hasSelection && 'btn-disabled opacity-50', !hasSelection && 'btn-disabled opacity-50',
)} )}
> >
@@ -25,7 +25,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation'; import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize'; import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTransferQueue } from '@/hooks/useTransferQueue'; import { useTransferQueue } from '@/hooks/useTransferQueue';
import { navigateToLogin, navigateToProfile, navigateToReviewEditor } from '@/utils/nav'; import { navigateToLogin, navigateToProfile } from '@/utils/nav';
import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window'; import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window';
import { setAboutDialogVisible } from '@/components/AboutWindow'; import { setAboutDialogVisible } from '@/components/AboutWindow';
import { setMigrateDataDirDialogVisible } from '@/app/library/components/MigrateDataWindow'; import { setMigrateDataDirDialogVisible } from '@/app/library/components/MigrateDataWindow';
@@ -47,9 +47,14 @@ import { type AppLockDialogMode, useAppLockStore } from '@/store/appLockStore';
interface SettingsMenuProps { interface SettingsMenuProps {
onPullLibrary: (fullRefresh?: boolean, verbose?: boolean) => void; onPullLibrary: (fullRefresh?: boolean, verbose?: boolean) => void;
setIsDropdownOpen?: (isOpen: boolean) => void; setIsDropdownOpen?: (isOpen: boolean) => void;
menuClassName?: string;
} }
const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdownOpen }) => { const SettingsMenu: React.FC<SettingsMenuProps> = ({
onPullLibrary,
setIsDropdownOpen,
menuClassName,
}) => {
const _ = useTranslation(); const _ = useTranslation();
const router = useRouter(); const router = useRouter();
const { envConfig, appService } = useEnv(); const { envConfig, appService } = useEnv();
@@ -204,11 +209,6 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
setCacheManagerDialogVisible(true); setCacheManagerDialogVisible(true);
}; };
const handleOpenReviewEditor = () => {
navigateToReviewEditor(router);
setIsDropdownOpen?.(false);
};
const handleRefreshMetadata = async () => { const handleRefreshMetadata = async () => {
if (!appService || isRefreshingMetadata) return; if (!appService || isRefreshingMetadata) return;
setIsRefreshingMetadata(true); setIsRefreshingMetadata(true);
@@ -318,6 +318,7 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
className={clsx( className={clsx(
'settings-menu dropdown-content no-triangle', 'settings-menu dropdown-content no-triangle',
'z-20 mt-2 max-w-[90vw] shadow-2xl', 'z-20 mt-2 max-w-[90vw] shadow-2xl',
menuClassName,
)} )}
onCancel={() => setIsDropdownOpen?.(false)} onCancel={() => setIsDropdownOpen?.(false)}
> >
@@ -436,9 +437,6 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
<hr aria-hidden='true' className='border-base-200 my-1' /> <hr aria-hidden='true' className='border-base-200 my-1' />
<MenuItem label={_('Advanced Settings')}> <MenuItem label={_('Advanced Settings')}>
<ul className='ms-0 flex flex-col ps-0 before:hidden'> <ul className='ms-0 flex flex-col ps-0 before:hidden'>
{process.env['NODE_ENV'] === 'development' && isWebAppPlatform() && (
<MenuItem label='EPUB 审校器' onClick={handleOpenReviewEditor} />
)}
<MenuItem label={_('Backup & Restore')} onClick={handleBackupRestore} /> <MenuItem label={_('Backup & Restore')} onClick={handleBackupRestore} />
{appService?.canCustomizeRootDir && ( {appService?.canCustomizeRootDir && (
<MenuItem label={_('Change Data Location')} onClick={handleSetRootDir} /> <MenuItem label={_('Change Data Location')} onClick={handleSetRootDir} />
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import clsx from 'clsx';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { useEnv } from '@/context/EnvContext'; import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore'; import { useSettingsStore } from '@/store/settingsStore';
@@ -15,17 +16,21 @@ import { navigateToLibrary } from '@/utils/nav';
import NumberInput from '@/components/settings/NumberInput'; import NumberInput from '@/components/settings/NumberInput';
import MenuItem from '@/components/MenuItem'; import MenuItem from '@/components/MenuItem';
import Menu from '@/components/Menu'; import Menu from '@/components/Menu';
import { useIsMobileViewport } from '@/hooks/useIsMobileViewport';
interface ViewMenuProps { interface ViewMenuProps {
setIsDropdownOpen?: (isOpen: boolean) => void; setIsDropdownOpen?: (isOpen: boolean) => void;
menuClassName?: string;
} }
const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => { const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen, menuClassName }) => {
const _ = useTranslation(); const _ = useTranslation();
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { envConfig } = useEnv(); const { envConfig } = useEnv();
const { settings } = useSettingsStore(); const { settings } = useSettingsStore();
const isPhoneViewport = useIsMobileViewport();
const isCompactViewport = useIsMobileViewport(1024);
const viewMode = settings.libraryViewMode; const viewMode = settings.libraryViewMode;
const coverFit = settings.libraryCoverFit; const coverFit = settings.libraryCoverFit;
@@ -170,7 +175,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
return ( return (
<Menu <Menu
className='view-menu dropdown-content no-triangle z-20 mt-2 shadow-2xl' className={clsx('view-menu dropdown-content no-triangle z-20 mt-2 shadow-2xl', menuClassName)}
onCancel={() => setIsDropdownOpen?.(false)} onCancel={() => setIsDropdownOpen?.(false)}
> >
{/* View Mode */} {/* View Mode */}
@@ -201,11 +206,12 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
value={columns} value={columns}
disabled={viewMode === 'list'} disabled={viewMode === 'list'}
onChange={handleSetColumns} onChange={handleSetColumns}
min={window.innerWidth < 640 ? 1 : window.innerWidth < 1024 ? 2 : 3} min={isPhoneViewport ? 1 : isCompactViewport ? 2 : 3}
max={window.innerWidth < 640 ? 4 : window.innerWidth < 1024 ? 6 : 12} max={isPhoneViewport ? 4 : isCompactViewport ? 6 : 12}
/> />
} }
onClick={() => handleToggleAutoColumns()} onClick={() => handleToggleAutoColumns()}
transient
/> />
{/* Book Covers */} {/* Book Covers */}
+230 -110
View File
@@ -100,6 +100,8 @@ import {
} from './utils/libraryUtils'; } from './utils/libraryUtils';
import Spinner from '@/components/Spinner'; import Spinner from '@/components/Spinner';
import LibraryHeader from './components/LibraryHeader'; import LibraryHeader from './components/LibraryHeader';
import LibrarySidebar from './components/LibrarySidebar';
import { ensureLibraryCategoryFilter } from './utils/categoryFilters';
import Bookshelf from './components/Bookshelf'; import Bookshelf from './components/Bookshelf';
import LibraryEmptyState from './components/LibraryEmptyState'; import LibraryEmptyState from './components/LibraryEmptyState';
import GroupHeader from './components/GroupHeader'; import GroupHeader from './components/GroupHeader';
@@ -120,6 +122,7 @@ import DropIndicator from '@/components/DropIndicator';
import SettingsDialog from '@/components/settings/SettingsDialog'; import SettingsDialog from '@/components/settings/SettingsDialog';
import ModalPortal from '@/components/ModalPortal'; import ModalPortal from '@/components/ModalPortal';
import TransferQueuePanel from './components/TransferQueuePanel'; import TransferQueuePanel from './components/TransferQueuePanel';
import { Overlay } from '@/components/Overlay';
/** Skip tiny non-book artifacts during folder auto-scan (matches the manual import dialog default). */ /** Skip tiny non-book artifacts during folder auto-scan (matches the manual import dialog default). */
const AUTO_IMPORT_MIN_SIZE_BYTES = 20 * 1024; const AUTO_IMPORT_MIN_SIZE_BYTES = 20 * 1024;
@@ -156,6 +159,19 @@ const LAST_IMPORT_FOLDER_MIN_SIZE_KEY = 'readest:lastImportFolderMinSizeKB';
* dialog forces the toggle ON regardless of this value. * dialog forces the toggle ON regardless of this value.
*/ */
const LAST_IMPORT_FOLDER_READ_IN_PLACE_KEY = 'readest:lastImportFolderReadInPlace'; const LAST_IMPORT_FOLDER_READ_IN_PLACE_KEY = 'readest:lastImportFolderReadInPlace';
const LIBRARY_SIDEBAR_VISIBLE_KEY = 'readest:librarySidebarVisible';
const readLibrarySidebarVisibility = (): boolean | null => {
if (typeof window === 'undefined') return null;
try {
const stored = window.localStorage.getItem(LIBRARY_SIDEBAR_VISIBLE_KEY);
if (stored === '1') return true;
if (stored === '0') return false;
} catch {
return null;
}
return null;
};
const LibraryPageWithSearchParams = () => { const LibraryPageWithSearchParams = () => {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -216,6 +232,10 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const [isSelectNone, setIsSelectNone] = useState(false); const [isSelectNone, setIsSelectNone] = useState(false);
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null); const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
const [failedImportsModal, setFailedImportsModal] = useState<FailedImport[] | null>(null); const [failedImportsModal, setFailedImportsModal] = useState<FailedImport[] | null>(null);
const [isDesktopLibraryViewport, setDesktopLibraryViewport] = useState(true);
const [isLibrarySidebarDocked, setLibrarySidebarDockedState] = useState(true);
const [isLibrarySidebarDrawerOpen, setLibrarySidebarDrawerOpen] = useState(false);
const [isLibraryViewportResolved, setLibraryViewportResolved] = useState(false);
// "Import from folder" dialog state. Held as a small object rather // "Import from folder" dialog state. Held as a small object rather
// than a boolean because we need a default starting directory to seed // than a boolean because we need a default starting directory to seed
// the path field, and we want the dialog to remain mounted long // the path field, and we want the dialog to remain mounted long
@@ -298,6 +318,51 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
useClipUrlIngress(); useClipUrlIngress();
useTransferQueue(libraryLoaded); useTransferQueue(libraryLoaded);
const setLibrarySidebarDocked = useCallback((visible: boolean) => {
setLibrarySidebarDockedState(visible);
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(LIBRARY_SIDEBAR_VISIBLE_KEY, visible ? '1' : '0');
} catch {
// localStorage can be unavailable in constrained webviews; the in-memory
// state still gives the user a working toggle for this session.
}
}, []);
useEffect(() => {
if (typeof window === 'undefined') return;
const mediaQuery = window.matchMedia('(min-width: 768px)');
const syncSidebarMode = () => {
const isDesktop = mediaQuery.matches;
setDesktopLibraryViewport(isDesktop);
setLibrarySidebarDrawerOpen(false);
if (isDesktop) {
setLibrarySidebarDockedState(readLibrarySidebarVisibility() ?? true);
}
setLibraryViewportResolved(true);
};
syncSidebarMode();
mediaQuery.addEventListener('change', syncSidebarMode);
return () => mediaQuery.removeEventListener('change', syncSidebarMode);
}, []);
const openLibrarySidebar = useCallback(() => {
if (isDesktopLibraryViewport) {
setLibrarySidebarDocked(true);
} else {
setLibrarySidebarDrawerOpen(true);
}
}, [isDesktopLibraryViewport, setLibrarySidebarDocked]);
const closeLibrarySidebar = useCallback(() => {
if (isDesktopLibraryViewport) {
setLibrarySidebarDocked(false);
} else {
setLibrarySidebarDrawerOpen(false);
}
}, [isDesktopLibraryViewport, setLibrarySidebarDocked]);
const { pullLibrary, pushLibrary } = useBooksSync(); const { pullLibrary, pushLibrary } = useBooksSync();
// Library-scoped auto-sync for the active third-party cloud provider (WebDAV / // Library-scoped auto-sync for the active third-party cloud provider (WebDAV /
// Google Drive): keeps library.json current on import / delete / book-close, // Google Drive): keeps library.json current on import / delete / book-close,
@@ -1592,133 +1657,188 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
} }
const showBookshelf = libraryLoaded || libraryBooks.length > 0; const showBookshelf = libraryLoaded || libraryBooks.length > 0;
const categoryFilter = ensureLibraryCategoryFilter(searchParams?.get('category'));
const isLibrarySidebarVisible = isDesktopLibraryViewport
? isLibrarySidebarDocked
: isLibrarySidebarDrawerOpen;
const isLibrarySidebarDockedVisible = isDesktopLibraryViewport && isLibrarySidebarDocked;
const showLibraryHeader = !isLibrarySidebarVisible;
return ( return (
<div <div
ref={pageRef} ref={pageRef}
aria-label={_('Your Library')} aria-label={_('Your Library')}
className={clsx( className={clsx(
'library-page text-base-content full-height flex select-none flex-col overflow-hidden', 'library-page text-base-content full-height flex select-none overflow-hidden',
viewSettings?.isEink ? 'bg-base-100' : 'bg-base-200', viewSettings?.isEink ? 'bg-base-100' : 'bg-base-200',
appService?.hasRoundedWindow && isRoundedWindow && 'window-border rounded-window', appService?.hasRoundedWindow && isRoundedWindow && 'window-border rounded-window',
)} )}
> >
<div {isLibrarySidebarVisible && (
className='relative top-0 z-40 w-full' <>
role='banner' {!isDesktopLibraryViewport && (
tabIndex={-1} <Overlay className='z-40 bg-black/20' onDismiss={closeLibrarySidebar} />
aria-label={_('Library Header')}
>
<LibraryHeader
isSelectMode={isSelectMode}
isSelectAll={isSelectAll}
onPullLibrary={pullLibrary}
onImportBooksFromFiles={handleImportBooksFromFiles}
onImportBooksFromDirectory={
appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
}
onImportBookFromUrl={isTauriAppPlatform() ? () => setShowImportFromUrl(true) : undefined}
onOpenCatalogManager={handleShowOPDSDialog}
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
onSelectAll={handleSelectAll}
onDeselectAll={handleDeselectAll}
/>
<progress
aria-label={_('Library Sync Progress')}
aria-hidden={isSyncing ? 'false' : 'true'}
className={clsx(
'progress progress-success absolute bottom-0 left-0 right-0 h-1 translate-y-[2px] transition-opacity duration-200 sm:translate-y-[4px]',
isSyncing ? 'opacity-100' : 'opacity-0',
)} )}
value={syncProgress * 100} <LibrarySidebar
max='100' books={libraryBooks}
/> isSelectMode={isSelectMode}
</div> onPullLibrary={pullLibrary}
{(loading || isSyncing) && ( onImportBooksFromFiles={handleImportBooksFromFiles}
<div className='fixed inset-0 z-50 flex items-center justify-center'> onImportBooksFromDirectory={
<Spinner loading /> appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
</div> }
onImportBookFromUrl={
isTauriAppPlatform() ? () => setShowImportFromUrl(true) : undefined
}
onOpenCatalogManager={handleShowOPDSDialog}
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
onToggleSidebar={closeLibrarySidebar}
isViewportResolved={isLibraryViewportResolved}
/>
</>
)} )}
{currentGroupPath && ( <div className='flex min-w-0 flex-1 flex-col overflow-hidden'>
<div <div
className={`transition-all duration-300 ease-in-out ${ className={clsx(
currentGroupPath ? 'opacity-100' : 'max-h-0 opacity-0' 'relative top-0 z-40 w-full',
}`} isLibraryViewportResolved
? showLibraryHeader
? 'block'
: 'hidden'
: 'block md:hidden',
)}
role='banner'
tabIndex={-1}
aria-label={_('Library Header')}
> >
<div className='flex flex-wrap items-center gap-y-1 px-4 text-base'> <LibraryHeader
<button isSelectMode={isSelectMode}
onClick={() => handleNavigateToPath(undefined)} isSelectAll={isSelectAll}
className='hover:bg-base-300 text-base-content/85 rounded px-2 py-1' onPullLibrary={pullLibrary}
> onImportBooksFromFiles={handleImportBooksFromFiles}
{_('All')} onImportBooksFromDirectory={
</button> appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
{getBreadcrumbs(currentGroupPath).map((crumb, index, array) => { }
const isLast = index === array.length - 1; onImportBookFromUrl={
return ( isTauriAppPlatform() ? () => setShowImportFromUrl(true) : undefined
<React.Fragment key={index}> }
<MdChevronRight size={iconSize} className='text-neutral-content' /> onOpenCatalogManager={handleShowOPDSDialog}
{isLast ? ( onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
<span className='truncate rounded px-2 py-1'>{crumb.name}</span> onSelectAll={handleSelectAll}
) : ( onDeselectAll={handleDeselectAll}
<button onToggleSidebar={openLibrarySidebar}
onClick={() => handleNavigateToPath(crumb.path)} />
className='hover:bg-base-300 text-base-content/85 truncate rounded px-2 py-1' <progress
> aria-label={_('Library Sync Progress')}
{crumb.name} aria-hidden={isSyncing ? 'false' : 'true'}
</button> className={clsx(
)} 'progress progress-success absolute bottom-0 left-0 right-0 h-1 translate-y-[2px] transition-opacity duration-200 sm:translate-y-[4px]',
</React.Fragment> isSyncing ? 'opacity-100' : 'opacity-0',
); )}
})} value={syncProgress * 100}
</div> max='100'
/>
</div> </div>
)} {!showLibraryHeader && (
{currentSeriesAuthorGroup && ( <progress
<GroupHeader aria-label={_('Library Sync Progress')}
groupBy={currentSeriesAuthorGroup.groupBy} aria-hidden={isSyncing ? 'false' : 'true'}
groupName={currentSeriesAuthorGroup.groupName} className={clsx(
/> 'progress progress-success hidden h-1 rounded-none transition-opacity duration-200 md:block',
)} isSyncing ? 'opacity-100' : 'opacity-0',
{showBookshelf && )}
(libraryBooks.some((book) => !book.deletedAt) ? ( value={syncProgress * 100}
<div aria-label={_('Your Bookshelf')} className='flex min-h-0 flex-grow flex-col'> max='100'
<div />
ref={containerRef} )}
className={clsx( {(loading || isSyncing) && (
'scroll-container drop-zone flex min-h-0 flex-grow flex-col', <div className='fixed inset-0 z-50 flex items-center justify-center'>
isDragging && 'drag-over', <Spinner loading />
)} </div>
style={{ )}
paddingRight: `${insets.right}px`, {currentGroupPath && (
paddingLeft: `${insets.left}px`, <div
}} className={`transition-all duration-300 ease-in-out ${
> currentGroupPath ? 'opacity-100' : 'max-h-0 opacity-0'
<DropIndicator /> }`}
<Bookshelf >
libraryBooks={libraryBooks} <div className='flex flex-wrap items-center gap-y-1 px-4 py-2 text-base'>
isSelectMode={isSelectMode} <button
isSelectAll={isSelectAll} onClick={() => handleNavigateToPath(undefined)}
isSelectNone={isSelectNone} className='hover:bg-base-300 text-base-content/85 rounded px-2 py-1'
onScrollerRef={handleScrollerRef} >
handleImportBooks={handleImportBooksFromFiles} {_('All')}
handleBookUpload={handleBookUpload} </button>
handleBookDownload={handleBookDownload} {getBreadcrumbs(currentGroupPath).map((crumb, index, array) => {
handleBookDelete={handleBookDelete('both')} const isLast = index === array.length - 1;
handleBookPurge={handleBookDelete('purge')} return (
handleSetSelectMode={handleSetSelectMode} <React.Fragment key={index}>
handleShowDetailsBook={handleShowDetailsBook} <MdChevronRight size={iconSize} className='text-neutral-content' />
handleLibraryNavigation={handleLibraryNavigation} {isLast ? (
booksTransferProgress={booksTransferProgress} <span className='truncate rounded px-2 py-1'>{crumb.name}</span>
handlePushLibrary={pushLibrary} ) : (
/> <button
onClick={() => handleNavigateToPath(crumb.path)}
className='hover:bg-base-300 text-base-content/85 truncate rounded px-2 py-1'
>
{crumb.name}
</button>
)}
</React.Fragment>
);
})}
</div> </div>
</div> </div>
) : ( )}
<div className='hero drop-zone h-screen items-center justify-center'> {currentSeriesAuthorGroup && (
<DropIndicator /> <GroupHeader
<LibraryEmptyState onImport={handleImportBooksFromFiles} /> groupBy={currentSeriesAuthorGroup.groupBy}
</div> groupName={currentSeriesAuthorGroup.groupName}
))} />
)}
{showBookshelf &&
(libraryBooks.some((book) => !book.deletedAt) ? (
<div aria-label={_('Your Bookshelf')} className='flex min-h-0 flex-grow flex-col'>
<div
ref={containerRef}
className={clsx(
'scroll-container drop-zone flex min-h-0 flex-grow flex-col',
isDragging && 'drag-over',
)}
style={{
paddingRight: `${insets.right}px`,
paddingLeft: `${insets.left}px`,
}}
>
<DropIndicator />
<Bookshelf
libraryBooks={libraryBooks}
categoryFilter={categoryFilter}
isSelectMode={isSelectMode}
isSelectAll={isSelectAll}
isSelectNone={isSelectNone}
onScrollerRef={handleScrollerRef}
handleImportBooks={handleImportBooksFromFiles}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete('both')}
handleBookPurge={handleBookDelete('purge')}
handleSetSelectMode={handleSetSelectMode}
handleShowDetailsBook={handleShowDetailsBook}
handleLibraryNavigation={handleLibraryNavigation}
booksTransferProgress={booksTransferProgress}
handlePushLibrary={pushLibrary}
sidebarVisible={isLibrarySidebarDockedVisible}
/>
</div>
</div>
) : (
<div className='hero drop-zone h-screen items-center justify-center'>
<DropIndicator />
<LibraryEmptyState onImport={handleImportBooksFromFiles} />
</div>
))}
</div>
<NowPlayingBar isSelectMode={isSelectMode} /> <NowPlayingBar isSelectMode={isSelectMode} />
{showDetailsBook && ( {showDetailsBook && (
<BookDetailModal <BookDetailModal
@@ -0,0 +1,93 @@
import type { Book } from '@/types/book';
import { formatAuthors, formatLanguage, formatPublisher } from '@/utils/book';
import { parseAuthors } from './libraryUtils';
export type LibraryCategoryFilter =
| 'all'
| 'author'
| 'publisher'
| 'year'
| 'language'
| 'format'
| 'subject'
| 'tag';
const LIBRARY_CATEGORY_FILTERS: LibraryCategoryFilter[] = [
'all',
'author',
'publisher',
'year',
'language',
'format',
'subject',
'tag',
];
export type CategoryValue = {
value: string;
count: number;
};
export const ensureLibraryCategoryFilter = (
value: string | null | undefined,
): LibraryCategoryFilter =>
LIBRARY_CATEGORY_FILTERS.includes(value as LibraryCategoryFilter)
? (value as LibraryCategoryFilter)
: 'all';
const getPublishedYear = (book: Book) =>
typeof book.metadata?.published === 'string'
? book.metadata.published.match(/\d{4}/)?.[0]
: undefined;
const getSubjects = (book: Book): string[] => {
const subject = book.metadata?.subject;
if (!subject) return [];
if (Array.isArray(subject)) return subject.map((item) => `${item}`.trim()).filter(Boolean);
if (typeof subject === 'string') {
return subject
.split(/[,;]/u)
.map((item) => item.trim())
.filter(Boolean);
}
return [formatAuthors(subject)].filter(Boolean);
};
export const getBookCategoryValues = (book: Book, category: LibraryCategoryFilter): string[] => {
switch (category) {
case 'author':
return parseAuthors(formatAuthors(book.author, book.primaryLanguage));
case 'publisher': {
const publisher = formatPublisher(book.metadata?.publisher || '').trim();
return publisher ? [publisher] : [];
}
case 'year': {
const year = getPublishedYear(book);
return year ? [year] : [];
}
case 'language': {
const language = formatLanguage(book.metadata?.language).trim();
return language ? [language] : [];
}
case 'format':
return book.format ? [book.format] : [];
case 'subject':
return getSubjects(book);
case 'tag':
return book.tags?.map((tag) => tag.trim()).filter(Boolean) || [];
case 'all':
return [];
}
};
export const countCategoryValues = (books: Book[], category: LibraryCategoryFilter) => {
const counts = new Map<string, number>();
for (const book of books) {
for (const value of getBookCategoryValues(book, category)) {
counts.set(value, (counts.get(value) || 0) + 1);
}
}
return [...counts.entries()]
.map(([value, count]) => ({ value, count }))
.sort((a, b) => a.value.localeCompare(b.value, navigator.language));
};
@@ -651,6 +651,7 @@ export type BookContextMenuItemId =
| 'markAbandoned' | 'markAbandoned'
| 'clearStatus' | 'clearStatus'
| 'showDetails' | 'showDetails'
| 'bilingual'
| 'showInFinder' | 'showInFinder'
| 'searchGoodreads' | 'searchGoodreads'
| 'download' | 'download'
@@ -750,7 +751,11 @@ export const getBookContextMenuItemIds = (book: Book): BookContextMenuItemId[] =
) { ) {
ids.push('clearStatus'); ids.push('clearStatus');
} }
ids.push('showDetails', 'showInFinder', 'searchGoodreads'); ids.push('showDetails');
if (book.format?.toUpperCase() === 'EPUB') {
ids.push('bilingual');
}
ids.push('showInFinder', 'searchGoodreads');
if (book.uploadedAt && !book.downloadedAt) ids.push('download'); if (book.uploadedAt && !book.downloadedAt) ids.push('download');
if (!book.uploadedAt && book.downloadedAt) ids.push('upload'); if (!book.uploadedAt && book.downloadedAt) ids.push('upload');
// Share is offered for any local-or-uploaded book; the dialog uploads first // Share is offered for any local-or-uploaded book; the dialog uploads first
@@ -17,6 +17,7 @@ import FoliateViewer from './FoliateViewer';
import SectionInfo from './SectionInfo'; import SectionInfo from './SectionInfo';
import HeaderBar from './HeaderBar'; import HeaderBar from './HeaderBar';
import PageNavigationButtons from './PageNavigationButtons'; import PageNavigationButtons from './PageNavigationButtons';
import ReviewModeController from './ReviewModeController';
import FooterBar from './footerbar/FooterBar'; import FooterBar from './footerbar/FooterBar';
import ProgressBar from './ProgressBar'; import ProgressBar from './ProgressBar';
import Ribbon from './Ribbon'; import Ribbon from './Ribbon';
@@ -171,6 +172,7 @@ const BookCellInner: React.FC<BookCellProps> = ({
gridInsets={gridInsets} gridInsets={gridInsets}
contentInsets={contentInsets} contentInsets={contentInsets}
/> />
<ReviewModeController bookKey={bookKey} bookDoc={bookDoc} />
{viewSettings.vertical && viewSettings.scrolled && ( {viewSettings.vertical && viewSettings.scrolled && (
<> <>
{(showFooter || viewSettings.doubleBorder) && ( {(showFooter || viewSettings.doubleBorder) && (
@@ -337,7 +339,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook, onGoToLibr
return ( return (
<div <div
className={clsx('books-grid bg-base-100 relative grid h-full flex-grow')} className={clsx('books-grid bg-base-100 relative grid h-full min-w-0 flex-grow')}
style={gridStyle} style={gridStyle}
role='main' role='main'
aria-label={_('Books Content')} aria-label={_('Books Content')}
@@ -0,0 +1,361 @@
import { Download, Pause, Play, RefreshCw, Square, Sparkles } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
controlTranslationJob,
downloadReviewedEpub,
listTranslationJobs,
loadTranslationDefaults,
loadTranslationJob,
loadTranslationPlan,
loadTranslationSource,
startFullBookTranslation,
type TranslationJobSummary,
type TranslationPlanPayload,
type TranslationSourcePayload,
} from '@/services/reviewEditorService';
type TranslationSettings = {
output_mode: 'bilingual' | 'translated';
range_mode: 'all' | 'limit';
limit: number;
temperature: number;
chunk_target: number;
};
const defaultSettings: TranslationSettings = {
output_mode: 'bilingual',
range_mode: 'limit',
limit: 20,
temperature: 0.2,
chunk_target: 24,
};
const isRunningJob = (job: TranslationJobSummary | null) =>
job ? ['pending', 'running'].includes(job.status) : false;
const blocksNewJob = (job: TranslationJobSummary | null) =>
job ? ['pending', 'running', 'paused'].includes(job.status) : false;
const ActionButton = ({
label,
onClick,
disabled,
children,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
children: React.ReactNode;
}) => (
<button
type='button'
className='btn btn-ghost eink-bordered h-9 min-h-9 rounded-md px-3'
aria-label={label}
title={label}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
const FullBookTranslationPanel = ({
baseUrl,
sessionId,
accessToken,
}: {
baseUrl: string;
sessionId: string;
accessToken: string;
}) => {
const [settings, setSettings] = useState(defaultSettings);
const [source, setSource] = useState<TranslationSourcePayload | null>(null);
const [plan, setPlan] = useState<TranslationPlanPayload | null>(null);
const [job, setJob] = useState<TranslationJobSummary | null>(null);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const pollRef = useRef<number | null>(null);
const defaultsAppliedRef = useRef(false);
const stopPolling = useCallback(() => {
if (pollRef.current !== null) window.clearInterval(pollRef.current);
pollRef.current = null;
}, []);
const refreshJob = useCallback(
async (jobId: string) => {
const payload = await loadTranslationJob(baseUrl, sessionId, jobId, accessToken);
setJob(payload.job);
if (!isRunningJob(payload.job)) stopPolling();
},
[accessToken, baseUrl, sessionId, stopPolling],
);
const startPolling = useCallback(
(jobId: string) => {
stopPolling();
pollRef.current = window.setInterval(() => {
void refreshJob(jobId).catch((error) => {
stopPolling();
setMessage(error instanceof Error ? error.message : String(error));
});
}, 1200);
},
[refreshJob, stopPolling],
);
const loadOverview = useCallback(async () => {
setBusy(true);
setMessage('');
try {
const [sourcePayload, defaultsPayload, planPayload, jobsPayload] = await Promise.all([
loadTranslationSource(baseUrl, sessionId, accessToken),
loadTranslationDefaults(baseUrl, sessionId, accessToken),
loadTranslationPlan(baseUrl, sessionId, settings.chunk_target, accessToken),
listTranslationJobs(baseUrl, sessionId, accessToken),
]);
setSource(sourcePayload);
setPlan(planPayload);
if (defaultsPayload.defaults && !defaultsAppliedRef.current) {
defaultsAppliedRef.current = true;
setSettings((current) => ({
...current,
output_mode: defaultsPayload.defaults?.output_mode || current.output_mode,
range_mode: defaultsPayload.defaults?.range_mode || current.range_mode,
limit: defaultsPayload.defaults?.limit || current.limit,
temperature: defaultsPayload.defaults?.temperature ?? current.temperature,
chunk_target: defaultsPayload.defaults?.chunk_target || current.chunk_target,
}));
}
const latest = jobsPayload.jobs[0] || null;
setJob(latest);
if (latest && isRunningJob(latest)) startPolling(latest.id);
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setBusy(false);
}
}, [accessToken, baseUrl, sessionId, settings.chunk_target, startPolling]);
useEffect(() => {
void loadOverview();
return stopPolling;
}, [loadOverview, stopPolling]);
const refreshPlan = async () => {
setBusy(true);
setMessage('');
try {
setPlan(await loadTranslationPlan(baseUrl, sessionId, settings.chunk_target, accessToken));
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setBusy(false);
}
};
const start = async () => {
if (
settings.range_mode === 'all' &&
!window.confirm('整书翻译会连续调用已配置的 GPT API。确认开始吗?')
) {
return;
}
setBusy(true);
setMessage('');
try {
const payload = await startFullBookTranslation(baseUrl, sessionId, settings, accessToken);
setJob(payload.job);
startPolling(payload.job.id);
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setBusy(false);
}
};
const control = async (action: 'pause' | 'resume' | 'cancel') => {
if (!job) return;
setBusy(true);
setMessage('');
try {
const payload = await controlTranslationJob(baseUrl, sessionId, job.id, action, accessToken);
setJob(payload.job);
startPolling(job.id);
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setBusy(false);
}
};
const outputUrl = job?.output_download_url
? new URL(job.output_download_url, baseUrl).toString()
: '';
return (
<div className='grid gap-3'>
<section className='eink-bordered border-base-300 bg-base-100 grid gap-3 rounded-md border p-3'>
<div className='flex items-center justify-between gap-2'>
<div className='min-w-0'>
<h3 className='text-sm font-semibold'></h3>
<p className='text-base-content/60 text-xs'>
{source ? `${source.source_count} 个正文块` : '正在读取翻译源'}
</p>
</div>
<ActionButton label='刷新翻译状态' onClick={() => void loadOverview()} disabled={busy}>
<RefreshCw className='h-4 w-4' />
</ActionButton>
</div>
<div className='grid grid-cols-2 gap-2'>
<label className='grid gap-1 text-xs font-medium'>
<select
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
value={settings.output_mode}
onChange={(event) =>
setSettings((current) => ({
...current,
output_mode: event.target.value as TranslationSettings['output_mode'],
}))
}
>
<option value='bilingual'></option>
<option value='translated'></option>
</select>
</label>
<label className='grid gap-1 text-xs font-medium'>
<select
className='select select-bordered eink-bordered h-9 min-h-9 rounded-md text-sm'
value={settings.range_mode}
onChange={(event) =>
setSettings((current) => ({
...current,
range_mode: event.target.value as TranslationSettings['range_mode'],
}))
}
>
<option value='limit'></option>
<option value='all'></option>
</select>
</label>
{settings.range_mode === 'limit' ? (
<label className='grid gap-1 text-xs font-medium'>
<input
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
type='number'
min={1}
max={5000}
value={settings.limit}
onChange={(event) =>
setSettings((current) => ({ ...current, limit: Number(event.target.value) }))
}
/>
</label>
) : null}
<label className='grid gap-1 text-xs font-medium'>
<input
className='input input-bordered eink-bordered h-9 min-w-0 rounded-md text-sm'
type='number'
min={1}
max={120}
value={settings.chunk_target}
onChange={(event) =>
setSettings((current) => ({
...current,
chunk_target: Number(event.target.value),
}))
}
onBlur={() => void refreshPlan()}
/>
</label>
</div>
{plan ? (
<p className='text-base-content/70 text-xs'>
{plan.chunk_count} · {plan.estimated_input_tokens.toLocaleString()}
token · XHTML
</p>
) : null}
<button
type='button'
className='btn btn-primary h-auto min-h-9 rounded-md px-3 py-2 text-sm'
onClick={() => void start()}
disabled={busy || blocksNewJob(job) || !source?.source_count}
>
<Sparkles className='h-4 w-4' />
{settings.range_mode === 'all' ? '开始整书翻译' : '开始试译'}
</button>
</section>
{job ? (
<section className='eink-bordered border-base-300 bg-base-100 grid gap-3 rounded-md border p-3'>
<div className='flex items-center justify-between gap-2'>
<div className='min-w-0'>
<h3 className='text-sm font-semibold'> {job.id}</h3>
<p className='text-base-content/60 text-xs break-words'>
{job.status} · {job.progress?.current || 0}/{job.progress?.total || 0}
</p>
</div>
<span className='text-sm font-semibold'>{job.progress?.percent || 0}%</span>
</div>
<progress
className='progress progress-primary h-2 w-full'
value={job.progress?.percent || 0}
max={100}
/>
{job.progress?.message ? (
<p className='text-base-content/70 text-xs break-words'>{job.progress.message}</p>
) : null}
{job.error ? <p className='text-error text-xs break-words'>{job.error}</p> : null}
<div className='flex flex-wrap gap-2'>
{job.status === 'running' || job.status === 'pending' ? (
<ActionButton label='暂停翻译' onClick={() => void control('pause')} disabled={busy}>
<Pause className='h-4 w-4' />
</ActionButton>
) : null}
{['paused', 'failed', 'cancelled'].includes(job.status) ? (
<ActionButton
label='从断点恢复'
onClick={() => void control('resume')}
disabled={busy}
>
<Play className='h-4 w-4' />
</ActionButton>
) : null}
{!['completed', 'failed', 'cancelled'].includes(job.status) ? (
<ActionButton label='取消翻译' onClick={() => void control('cancel')} disabled={busy}>
<Square className='h-4 w-4' />
</ActionButton>
) : null}
{outputUrl ? (
<ActionButton
label='下载翻译 EPUB'
onClick={() => void downloadReviewedEpub(outputUrl, accessToken)}
>
<Download className='h-4 w-4' />
</ActionButton>
) : null}
</div>
</section>
) : null}
{message ? (
<div
className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'
role='status'
>
{message}
</div>
) : null}
</div>
);
};
export default FullBookTranslationPanel;
@@ -27,6 +27,7 @@ import QuickActionMenu from './annotator/QuickActionMenu';
import SidebarToggler from './SidebarToggler'; import SidebarToggler from './SidebarToggler';
import BookmarkToggler from './BookmarkToggler'; import BookmarkToggler from './BookmarkToggler';
import NotebookToggler from './NotebookToggler'; import NotebookToggler from './NotebookToggler';
import ReviewModeToggler from './ReviewModeToggler';
import SettingsToggler from './SettingsToggler'; import SettingsToggler from './SettingsToggler';
import TranslationToggler from './TranslationToggler'; import TranslationToggler from './TranslationToggler';
import ViewMenu from './ViewMenu'; import ViewMenu from './ViewMenu';
@@ -282,6 +283,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
<div className='header-tools-end bg-base-100 z-20 ms-auto flex h-full min-w-max items-center gap-x-4 ps-2 max-[350px]:gap-x-2'> <div className='header-tools-end bg-base-100 z-20 ms-auto flex h-full min-w-max items-center gap-x-4 ps-2 max-[350px]:gap-x-2'>
{!isHeaderCompact && <SettingsToggler bookKey={bookKey} />} {!isHeaderCompact && <SettingsToggler bookKey={bookKey} />}
<ReviewModeToggler bookKey={bookKey} />
<NotebookToggler bookKey={bookKey} /> <NotebookToggler bookKey={bookKey} />
<Dropdown <Dropdown
label={_('View Options')} label={_('View Options')}
@@ -9,6 +9,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore'; import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore'; import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore'; import { useSidebarStore } from '@/store/sidebarStore';
import { useReviewModeStore } from '@/store/reviewModeStore';
import { useGamepad } from '@/hooks/useGamepad'; import { useGamepad } from '@/hooks/useGamepad';
import { useTranslation } from '@/hooks/useTranslation'; import { useTranslation } from '@/hooks/useTranslation';
import { SystemSettings } from '@/types/settings'; import { SystemSettings } from '@/types/settings';
@@ -37,6 +38,7 @@ import Spinner from '@/components/Spinner';
import SideBar from './sidebar/SideBar'; import SideBar from './sidebar/SideBar';
import Notebook from './notebook/Notebook'; import Notebook from './notebook/Notebook';
import BooksGrid from './BooksGrid'; import BooksGrid from './BooksGrid';
import ReviewPanel from './ReviewPanel';
import SettingsDialog from '@/components/settings/SettingsDialog'; import SettingsDialog from '@/components/settings/SettingsDialog';
const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ ids, settings }) => { const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ ids, settings }) => {
@@ -50,6 +52,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
const { getConfig, getBookData, saveConfig } = useBookDataStore(); const { getConfig, getBookData, saveConfig } = useBookDataStore();
const { getView, setBookKeys, getViewSettings } = useReaderStore(); const { getView, setBookKeys, getViewSettings } = useReaderStore();
const { initViewState, getViewState, clearViewState } = useReaderStore(); const { initViewState, getViewState, clearViewState } = useReaderStore();
const clearReviewBook = useReviewModeStore((state) => state.clearBook);
const { isSettingsDialogOpen, settingsDialogBookKey } = useSettingsStore(); const { isSettingsDialogOpen, settingsDialogBookKey } = useSettingsStore();
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null); const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
const [shareDialogState, setShareDialogState] = useState<{ const [shareDialogState, setShareDialogState] = useState<{
@@ -176,6 +179,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
const saveConfigAndCloseBook = async (bookKey: string, keepTTSAlive = false) => { const saveConfigAndCloseBook = async (bookKey: string, keepTTSAlive = false) => {
console.log('Closing book', bookKey); console.log('Closing book', bookKey);
clearReviewBook(bookKey);
const viewState = getViewState(bookKey); const viewState = getViewState(bookKey);
if (viewState?.isPrimary && appService?.isDesktopApp) { if (viewState?.isPrimary && appService?.isDesktopApp) {
@@ -208,7 +212,17 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
navigateBackToLibrary(); navigateBackToLibrary();
}; };
const handleCloseReaderToLibrary = () => { const handleCloseReaderToLibrary = async () => {
const dirtyReview = Object.values(useReviewModeStore.getState().books).some(
(book) => book.hasUnsavedEdit,
);
if (
dirtyReview &&
appService &&
!(await appService.ask('审校器中有未保存的段落修改,确定返回书库吗?'))
) {
return;
}
return handleCloseBooks(true); return handleCloseBooks(true);
}; };
@@ -226,6 +240,16 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
// SPA navigation in the main window (or on web) keeps the webview alive: // SPA navigation in the main window (or on web) keeps the webview alive:
// TTS may continue headless. Non-main Tauri windows close their webview // TTS may continue headless. Non-main Tauri windows close their webview
// below, but their per-window TTS dies with the window either way. // below, but their per-window TTS dies with the window either way.
const dirtyReview = Object.values(useReviewModeStore.getState().books).some(
(book) => book.hasUnsavedEdit,
);
if (
dirtyReview &&
appService &&
!(await appService.ask('审校器中有未保存的段落修改,确定返回书库吗?'))
) {
return;
}
handleCloseBooks(true); handleCloseBooks(true);
if (isTauriAppPlatform()) { if (isTauriAppPlatform()) {
const currentWindow = getCurrentWindow(); const currentWindow = getCurrentWindow();
@@ -246,6 +270,14 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
// Header X / pane close: an SPA-side close on web and the main window. // Header X / pane close: an SPA-side close on web and the main window.
// The Tauri reader-window branches below destroy their webview, which // The Tauri reader-window branches below destroy their webview, which
// takes the per-window TTS with it either way. // takes the per-window TTS with it either way.
const review = useReviewModeStore.getState().books[bookKey];
if (
review?.hasUnsavedEdit &&
appService &&
!(await appService.ask('当前段落有未保存的修改,确定关闭这本书吗?'))
) {
return;
}
saveConfigAndCloseBook(bookKey, true); saveConfigAndCloseBook(bookKey, true);
if (sideBarBookKey === bookKey) { if (sideBarBookKey === bookKey) {
setSideBarBookKey(getNextBookKey(sideBarBookKey)); setSideBarBookKey(getNextBookKey(sideBarBookKey));
@@ -291,6 +323,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
onGoToLibrary={handleCloseBooksToLibrary} onGoToLibrary={handleCloseBooksToLibrary}
/> />
{isSettingsDialogOpen && <SettingsDialog bookKey={settingsDialogBookKey} />} {isSettingsDialogOpen && <SettingsDialog bookKey={settingsDialogBookKey} />}
<ReviewPanel />
<Notebook /> <Notebook />
{showDetailsBook && ( {showDetailsBook && (
<BookDetailModal <BookDetailModal
@@ -0,0 +1,327 @@
import DOMPurify from 'dompurify';
import { useEffect, useRef } from 'react';
import { useEnv } from '@/context/EnvContext';
import type { BookDoc } from '@/libs/document';
import type { ReviewRow } from '@/services/reviewEditorService';
import { useReaderStore } from '@/store/readerStore';
import { useReviewModeStore } from '@/store/reviewModeStore';
const STYLE_ID = 'readest-inline-review-style';
const ROW_ATTR = 'data-readest-review-row-id';
const ROLE_ATTR = 'data-readest-review-role';
const ORIGINAL_HTML_ATTR = 'data-readest-review-original-html';
const reviewHtmlPurifyOptions = {
ALLOWED_TAGS: [
'ruby',
'rb',
'rt',
'rp',
'span',
'mark',
'br',
'em',
'strong',
'b',
'i',
'u',
's',
'sub',
'sup',
],
ALLOWED_ATTR: ['class', 'title'],
ALLOW_DATA_ATTR: false,
};
const sanitizeReviewHtml = (html: string) =>
DOMPurify.sanitize(html || '', reviewHtmlPurifyOptions);
const emptyReviewRows: ReviewRow[] = [];
const normalizeFile = (value?: string) =>
(() => {
const raw = (value || '').split('#')[0] || '';
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
})()
.replaceAll('\\', '/')
.replace(/^\/+/, '')
.toLowerCase();
const sameFile = (rowFile: string, sectionHref?: string) => {
const row = normalizeFile(rowFile);
const href = normalizeFile(sectionHref);
if (!row || !href) return false;
return row === href || row.endsWith(`/${href}`) || href.endsWith(`/${row}`);
};
const sectionHrefAt = (bookDoc: BookDoc, index: number | undefined) => {
const section = bookDoc.sections[index ?? -1];
return section?.href || section?.id;
};
const clearReviewMarks = (doc: Document) => {
doc.getElementById(STYLE_ID)?.remove();
doc.querySelectorAll(`[${ROW_ATTR}]`).forEach((element) => {
const originalHtml = element.getAttribute(ORIGINAL_HTML_ATTR);
if (originalHtml !== null) {
element.innerHTML = originalHtml;
}
element.removeAttribute(ROW_ATTR);
element.removeAttribute(ROLE_ATTR);
element.removeAttribute(ORIGINAL_HTML_ATTR);
element.classList.remove(
'readest-review-source',
'readest-review-target',
'readest-review-active',
);
});
};
const ensureReviewStyle = (doc: Document) => {
if (doc.getElementById(STYLE_ID)) return;
const style = doc.createElement('style');
style.id = STYLE_ID;
style.textContent = `
[${ROW_ATTR}] {
cursor: pointer;
user-select: text;
outline-offset: 0.18em;
transition: background-color 120ms ease, outline-color 120ms ease;
}
.readest-review-source {
background: rgba(127, 127, 127, 0.12);
outline: 1px dashed rgba(127, 127, 127, 0.64);
}
.readest-review-target {
background: rgba(22, 163, 74, 0.14);
outline: 1px solid rgba(22, 163, 74, 0.72);
}
.readest-review-active {
background: rgba(37, 99, 235, 0.18) !important;
outline: 2px solid currentColor !important;
}
`;
doc.head.appendChild(style);
};
const closestReviewTarget = (target: EventTarget | null): HTMLElement | null => {
const element = target as Element | null;
if (!element || typeof element.closest !== 'function') return null;
if (element.closest('a, button, input, textarea, select, video, audio')) return null;
return element.closest(`[${ROW_ATTR}]`) as HTMLElement | null;
};
const closestReviewTargetFromNode = (node: Node | null): HTMLElement | null => {
const element =
node?.nodeType === Node.ELEMENT_NODE
? (node as Element)
: (node?.parentElement as Element | null);
if (!element || typeof element.closest !== 'function') return null;
return element.closest(`[${ROW_ATTR}]`) as HTMLElement | null;
};
const selectedReviewTarget = (doc: Document): HTMLElement | null => {
const selection = doc.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
return (
closestReviewTargetFromNode(range.commonAncestorContainer) ||
closestReviewTargetFromNode(range.startContainer) ||
closestReviewTargetFromNode(range.endContainer)
);
};
const markParagraph = (
element: Element | undefined,
row: ReviewRow,
role: 'source' | 'target',
active: boolean,
) => {
const htmlElement = element as HTMLElement | undefined;
if (!htmlElement || typeof htmlElement.setAttribute !== 'function') return;
htmlElement.setAttribute(ROW_ATTR, row.id);
htmlElement.setAttribute(ROLE_ATTR, role);
htmlElement.classList.add(role === 'source' ? 'readest-review-source' : 'readest-review-target');
htmlElement.classList.toggle('readest-review-active', active);
if (role === 'target') {
if (!htmlElement.hasAttribute(ORIGINAL_HTML_ATTR)) {
htmlElement.setAttribute(ORIGINAL_HTML_ATTR, htmlElement.innerHTML);
}
htmlElement.innerHTML = sanitizeReviewHtml(row.current_html);
}
};
const applyActiveReviewMark = (doc: Document, selectedRowId: string) => {
doc.querySelectorAll(`[${ROW_ATTR}]`).forEach((element) => {
element.classList.toggle(
'readest-review-active',
element.getAttribute(ROW_ATTR) === selectedRowId,
);
});
};
const applyReviewMarks = (
doc: Document,
sectionHref: string | undefined,
rows: ReviewRow[],
selectedRowId: string,
) => {
clearReviewMarks(doc);
const sectionRows = rows.filter((row) => sameFile(row.file, sectionHref));
if (!sectionRows.length) return;
ensureReviewStyle(doc);
const paragraphs = Array.from(doc.querySelectorAll('p'));
for (const row of sectionRows) {
const active = row.id === selectedRowId;
markParagraph(paragraphs[Number(row.ja_p_index)], row, 'source', active);
markParagraph(paragraphs[Number(row.cn_p_index)], row, 'target', active);
}
};
export const __testing = {
applyReviewMarks,
applyActiveReviewMark,
clearReviewMarks,
sectionHrefAt,
};
const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
bookKey,
bookDoc,
}) => {
const { appService } = useEnv();
const view = useReaderStore((state) => state.viewStates[bookKey]?.view);
const enabled = useReviewModeStore((state) => !!state.books[bookKey]?.enabled);
const rows = useReviewModeStore((state) => state.books[bookKey]?.rows ?? emptyReviewRows);
const selectedRowId = useReviewModeStore((state) => state.books[bookKey]?.selectedRowId || '');
const selectRow = useReviewModeStore((state) => state.selectRow);
const selectedRowIdRef = useRef(selectedRowId);
useEffect(() => {
selectedRowIdRef.current = selectedRowId;
}, [selectedRowId]);
const selectReviewRow = async (rowId: string) => {
const state = useReviewModeStore.getState();
const previousBookKey = state.activeBookKey;
const current = state.books[previousBookKey || ''];
if (
current?.hasUnsavedEdit &&
(state.activeBookKey !== bookKey || current.selectedRowId !== rowId) &&
appService &&
!(await appService.ask('当前段落有未保存的修改,确定切换到其他段落吗?'))
) {
return;
}
if (previousBookKey && current?.hasUnsavedEdit) {
state.setBookUnsavedEdit(previousBookKey, false);
}
selectRow(bookKey, rowId);
};
useEffect(() => {
if (!view) return;
const cleanupLoadedDocs = () => {
for (const { doc } of view.renderer.getContents()) {
clearReviewMarks(doc);
}
};
if (!enabled || !rows.length) {
cleanupLoadedDocs();
return;
}
const applyToLoadedDocs = () => {
for (const { doc, index } of view.renderer.getContents()) {
applyReviewMarks(doc, sectionHrefAt(bookDoc, index), rows, selectedRowIdRef.current);
}
};
const handleClick = (event: Event) => {
const target = closestReviewTarget(event.target);
if (!target) return;
const rowId = target.getAttribute(ROW_ATTR);
if (!rowId) return;
event.preventDefault();
event.stopPropagation();
if ('stopImmediatePropagation' in event) {
event.stopImmediatePropagation();
}
void selectReviewRow(rowId);
};
const handleSelectionEnd = (event: Event) => {
const doc = event.currentTarget as Document | null;
if (!doc?.getSelection) return;
window.setTimeout(() => {
const target = selectedReviewTarget(doc);
const rowId = target?.getAttribute(ROW_ATTR);
if (rowId) void selectReviewRow(rowId);
}, 0);
};
const listenedDocs = new Set<Document>();
const addDocListeners = (doc: Document) => {
if (listenedDocs.has(doc)) return;
listenedDocs.add(doc);
doc.addEventListener('click', handleClick, true);
doc.addEventListener('mouseup', handleSelectionEnd, true);
doc.addEventListener('touchend', handleSelectionEnd, true);
doc.addEventListener('keyup', handleSelectionEnd, true);
};
const removeDocListeners = (doc: Document) => {
listenedDocs.delete(doc);
doc.removeEventListener('click', handleClick, true);
doc.removeEventListener('mouseup', handleSelectionEnd, true);
doc.removeEventListener('touchend', handleSelectionEnd, true);
doc.removeEventListener('keyup', handleSelectionEnd, true);
};
const handleLoad = (event: Event) => {
const detail = (event as CustomEvent<{ doc?: Document; index?: number }>).detail;
if (!detail?.doc) return;
applyReviewMarks(
detail.doc,
sectionHrefAt(bookDoc, detail.index),
rows,
selectedRowIdRef.current,
);
addDocListeners(detail.doc);
};
for (const { doc } of view.renderer.getContents()) {
addDocListeners(doc);
}
applyToLoadedDocs();
view.addEventListener('load', handleLoad);
return () => {
view.removeEventListener('load', handleLoad);
for (const doc of listenedDocs) {
removeDocListeners(doc);
clearReviewMarks(doc);
}
};
}, [appService, bookDoc, bookKey, enabled, rows, selectRow, view]);
useEffect(() => {
if (!view || !enabled || !rows.length) return;
for (const { doc } of view.renderer.getContents()) {
applyActiveReviewMark(doc, selectedRowId);
}
}, [bookKey, enabled, rows.length, selectedRowId, view]);
return null;
};
export default ReviewModeController;
@@ -0,0 +1,129 @@
import clsx from 'clsx';
import React from 'react';
import Button from '@/components/Button';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useReviewModeStore } from '@/store/reviewModeStore';
import { eventDispatcher } from '@/utils/event';
import {
canLaunchInlineReviewEditor,
launchInlineReviewEditor,
loadInlineReviewData,
} from '@/services/reviewEditorService';
interface ReviewModeTogglerProps {
bookKey: string;
}
const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
const _ = useTranslation();
const { appService } = useEnv();
const { setHoveredBookKey } = useReaderStore();
const bookData = useBookDataStore((state) => state.getBookData(bookKey));
const reviewState = useReviewModeStore((state) => state.books[bookKey]);
const {
setActiveBookKey,
setPanelVisible,
setBookLoading,
setBookError,
setBookEnabled,
setBookData,
} = useReviewModeStore();
const enabled = !!reviewState?.enabled;
const loading = !!reviewState?.loading;
if (!canLaunchInlineReviewEditor(appService) || bookData?.book?.format !== 'EPUB') return null;
const handleToggleReviewMode = async () => {
if (appService?.isMobile) {
setHoveredBookKey('');
}
if (enabled) {
if (
reviewState?.hasUnsavedEdit &&
appService &&
!(await appService.ask('当前段落有未保存的修改,确定关闭审校模式吗?'))
) {
return;
}
setBookEnabled(bookKey, false);
return;
}
if (!bookData?.book) {
setBookError(bookKey, _('Unable to open book'));
return;
}
const activeState = useReviewModeStore.getState();
const activeReview = activeState.books[activeState.activeBookKey || ''];
if (
activeState.activeBookKey !== bookKey &&
activeReview?.hasUnsavedEdit &&
appService &&
!(await appService.ask('另一册书的当前段落有未保存修改,确定切换审校书籍吗?'))
) {
return;
}
if (activeState.activeBookKey && activeReview?.hasUnsavedEdit) {
activeState.setBookUnsavedEdit(activeState.activeBookKey, false);
}
setActiveBookKey(bookKey);
setPanelVisible(true);
setBookLoading(bookKey, true);
try {
const launch = await launchInlineReviewEditor(appService, bookData.book);
const data = await loadInlineReviewData(
launch.url,
launch.sessionId,
launch.accessToken,
);
setBookData(bookKey, {
baseUrl: launch.url,
accessToken: launch.accessToken,
sessionId: launch.sessionId,
reviewRoot: launch.reviewRoot,
version: launch.version,
session: data.session,
rows: data.rows,
gptConfig: data.gptConfig,
selectedRowId: data.rows[0]?.id || '',
});
setBookEnabled(bookKey, true);
eventDispatcher.dispatch('toast', {
type: 'info',
message: data.rows.length
? `审校模式已开启,共 ${data.rows.length}`
: '审校模式已开启,但当前 EPUB 没有识别到双语段落',
timeout: 2500,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setBookError(bookKey, message);
eventDispatcher.dispatch('toast', {
type: 'error',
message,
timeout: 3500,
});
}
};
return (
<Button
icon={<span className='text-sm font-semibold leading-none'></span>}
onClick={handleToggleReviewMode}
disabled={loading}
label={enabled ? _('Disable Review Mode') : _('Review Mode')}
className={clsx(enabled && 'bg-base-300/50')}
/>
);
};
export default ReviewModeToggler;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,124 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { PointerEvent as ReactPointerEvent, RefObject } from 'react';
type Position = {
x: number;
y: number;
};
type UseReviewPanelDragOptions = {
enabled: boolean;
panelRef: RefObject<HTMLElement | null>;
margin: number;
defaultWidthRatio: number;
topInset: number;
};
const pointerTargetIsInteractive = (target: EventTarget | null) =>
target instanceof HTMLElement &&
Boolean(target.closest('button, input, textarea, select, a, .drag-bar'));
export const useReviewPanelDrag = ({
enabled,
panelRef,
margin,
defaultWidthRatio,
topInset,
}: UseReviewPanelDragOptions) => {
const initialPosition = () => ({
x:
typeof globalThis.window === 'undefined'
? margin
: Math.max(
margin,
globalThis.window.innerWidth -
Math.round(globalThis.window.innerWidth * defaultWidthRatio) -
margin,
),
y: typeof globalThis.window === 'undefined' ? margin : topInset + margin,
});
const panelPositionRef = useRef<Position>(initialPosition());
const [panelPosition, setPanelPosition] = useState(panelPositionRef.current);
const clampPanelPosition = useCallback(
(next: Position) => {
const panel = panelRef.current;
const width = panel?.offsetWidth || Math.round(window.innerWidth * defaultWidthRatio);
const height = panel?.offsetHeight || Math.round(window.innerHeight * 0.82);
return {
x: Math.min(Math.max(margin, next.x), Math.max(margin, window.innerWidth - width - margin)),
y: Math.min(
Math.max(margin, next.y),
Math.max(margin, window.innerHeight - height - margin),
),
};
},
[defaultWidthRatio, margin, panelRef],
);
const updatePanelPosition = useCallback(
(next: Position) => {
const clamped = clampPanelPosition(next);
panelPositionRef.current = clamped;
setPanelPosition(clamped);
},
[clampPanelPosition],
);
const moveToDefaultPosition = useCallback(() => {
if (!enabled) return;
const panel = panelRef.current;
const width = panel?.offsetWidth || Math.round(window.innerWidth * defaultWidthRatio);
updatePanelPosition({
x: window.innerWidth - width - margin,
y: topInset + margin,
});
}, [defaultWidthRatio, enabled, margin, panelRef, topInset, updatePanelPosition]);
const handlePanelDragStart = useCallback(
(event: ReactPointerEvent<HTMLElement>) => {
if (!enabled || pointerTargetIsInteractive(event.target)) return;
event.preventDefault();
const startX = event.clientX;
const startY = event.clientY;
const startPosition = panelPositionRef.current;
document.body.style.userSelect = 'none';
document.documentElement.style.cursor = 'move';
const handleMove = (moveEvent: PointerEvent) => {
updatePanelPosition({
x: startPosition.x + moveEvent.clientX - startX,
y: startPosition.y + moveEvent.clientY - startY,
});
};
const handleEnd = () => {
document.body.style.userSelect = '';
document.documentElement.style.cursor = '';
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleEnd);
window.removeEventListener('pointercancel', handleEnd);
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleEnd);
window.addEventListener('pointercancel', handleEnd);
},
[enabled, updatePanelPosition],
);
useEffect(() => {
if (!enabled) return;
const handleResize = () => updatePanelPosition(panelPositionRef.current);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [enabled, updatePanelPosition]);
return {
panelPosition,
panelPositionRef,
updatePanelPosition,
moveToDefaultPosition,
handlePanelDragStart,
};
};
@@ -0,0 +1,197 @@
import { useCallback } from 'react';
import type {
KeyboardEvent as ReactKeyboardEvent,
MutableRefObject,
PointerEvent as ReactPointerEvent,
RefObject,
} from 'react';
type Position = {
x: number;
y: number;
};
type UseReviewPanelFloatingResizeOptions = {
enabled: boolean;
panelRef: RefObject<HTMLElement | null>;
panelPositionRef: MutableRefObject<Position>;
panelWidth: string;
panelHeight: string;
minWidth: number;
maxWidth: number;
minHeight: number;
maxHeight: number;
setPanelWidth: (width: string) => void;
setPanelHeight: (height: string) => void;
updatePanelPosition: (position: Position) => void;
};
const toPanelPercent = (fraction: number) => `${Math.round(fraction * 10000) / 100}%`;
const toPanelVh = (fraction: number) => `${Math.round(fraction * 10000) / 100}vh`;
export const useReviewPanelFloatingResize = ({
enabled,
panelRef,
panelPositionRef,
panelWidth,
panelHeight,
minWidth,
maxWidth,
minHeight,
maxHeight,
setPanelWidth,
setPanelHeight,
updatePanelPosition,
}: UseReviewPanelFloatingResizeOptions) => {
const resizeWidth = useCallback(
(clientX: number, rightEdge: number) => {
const fraction = Math.max(
minWidth,
Math.min(maxWidth, (rightEdge - clientX) / window.innerWidth),
);
const width = fraction * window.innerWidth;
setPanelWidth(toPanelPercent(fraction));
updatePanelPosition({ x: rightEdge - width, y: panelPositionRef.current.y });
},
[maxWidth, minWidth, panelPositionRef, setPanelWidth, updatePanelPosition],
);
const resizeHeight = useCallback(
(clientY: number, topEdge: number) => {
const fraction = Math.max(
minHeight,
Math.min(maxHeight, (clientY - topEdge) / window.innerHeight),
);
setPanelHeight(toPanelVh(fraction));
requestAnimationFrame(() => updatePanelPosition(panelPositionRef.current));
},
[maxHeight, minHeight, panelPositionRef, setPanelHeight, updatePanelPosition],
);
const handleWidthPointerDown = useCallback(
(event: ReactPointerEvent<HTMLElement>) => {
if (!enabled) return;
const panel = panelRef.current;
if (!panel) return;
event.preventDefault();
event.stopPropagation();
const rightEdge = panel.getBoundingClientRect().right;
document.body.style.pointerEvents = 'none';
document.body.style.userSelect = 'none';
document.documentElement.style.cursor = 'col-resize';
const handleMove = (moveEvent: PointerEvent) => {
resizeWidth(moveEvent.clientX, rightEdge);
};
const handleEnd = () => {
document.body.style.pointerEvents = '';
document.body.style.userSelect = '';
document.documentElement.style.cursor = '';
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleEnd);
window.removeEventListener('pointercancel', handleEnd);
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleEnd);
window.addEventListener('pointercancel', handleEnd);
},
[enabled, panelRef, resizeWidth],
);
const handleHeightPointerDown = useCallback(
(event: ReactPointerEvent<HTMLElement>) => {
if (!enabled) return;
const panel = panelRef.current;
if (!panel) return;
event.preventDefault();
event.stopPropagation();
const topEdge = panel.getBoundingClientRect().top;
document.body.style.pointerEvents = 'none';
document.body.style.userSelect = 'none';
document.documentElement.style.cursor = 'row-resize';
const handleMove = (moveEvent: PointerEvent) => {
resizeHeight(moveEvent.clientY, topEdge);
};
const handleEnd = () => {
document.body.style.pointerEvents = '';
document.body.style.userSelect = '';
document.documentElement.style.cursor = '';
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleEnd);
window.removeEventListener('pointercancel', handleEnd);
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleEnd);
window.addEventListener('pointercancel', handleEnd);
},
[enabled, panelRef, resizeHeight],
);
const handleWidthKeyDown = useCallback(
(event: ReactKeyboardEvent) => {
if (!enabled || (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight')) return;
const currentWidth = parseFloat(panelWidth) / 100;
const nextWidth =
event.key === 'ArrowLeft'
? Math.min(maxWidth, currentWidth + 0.02)
: Math.max(minWidth, currentWidth - 0.02);
const panel = panelRef.current;
const rightEdge =
panel?.getBoundingClientRect().right ||
panelPositionRef.current.x + currentWidth * window.innerWidth;
setPanelWidth(toPanelPercent(nextWidth));
updatePanelPosition({
x: rightEdge - nextWidth * window.innerWidth,
y: panelPositionRef.current.y,
});
event.preventDefault();
event.stopPropagation();
},
[
enabled,
maxWidth,
minWidth,
panelPositionRef,
panelRef,
panelWidth,
setPanelWidth,
updatePanelPosition,
],
);
const handleHeightKeyDown = useCallback(
(event: ReactKeyboardEvent) => {
if (!enabled || (event.key !== 'ArrowUp' && event.key !== 'ArrowDown')) return;
const currentHeight = parseFloat(panelHeight) / 100;
const nextHeight =
event.key === 'ArrowDown'
? Math.min(maxHeight, currentHeight + 0.03)
: Math.max(minHeight, currentHeight - 0.03);
setPanelHeight(toPanelVh(nextHeight));
requestAnimationFrame(() => updatePanelPosition(panelPositionRef.current));
event.preventDefault();
event.stopPropagation();
},
[
enabled,
maxHeight,
minHeight,
panelHeight,
panelPositionRef,
setPanelHeight,
updatePanelPosition,
],
);
return {
handleWidthPointerDown,
handleWidthKeyDown,
handleHeightPointerDown,
handleHeightKeyDown,
};
};
@@ -1,192 +0,0 @@
'use client';
import clsx from 'clsx';
import { ArrowLeft, ExternalLink, RefreshCw, Rocket, Wrench } from 'lucide-react';
import { useRouter } from 'next/navigation';
import type { ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { openExternalUrl } from '@/utils/open';
type LaunchState = 'idle' | 'launching' | 'ready' | 'failed';
type LaunchResponse = {
ok: boolean;
url?: string;
reused?: boolean;
reviewRoot?: string;
version?: string;
error?: string;
};
const LaunchButton = ({
onClick,
disabled,
children,
variant = 'primary',
}: {
onClick: () => void;
disabled?: boolean;
children: ReactNode;
variant?: 'primary' | 'ghost';
}) => (
<button
type='button'
onClick={onClick}
disabled={disabled}
className={clsx(
'btn h-10 min-h-10 rounded-md px-4 text-sm font-medium',
variant === 'primary' ? 'btn-primary' : 'btn-ghost eink-bordered',
disabled && 'opacity-60',
)}
>
{children}
</button>
);
export default function ReviewEditorLauncher() {
const router = useRouter();
const [state, setState] = useState<LaunchState>('idle');
const [payload, setPayload] = useState<LaunchResponse | null>(null);
const didAutoLaunchRef = useRef(false);
const launch = useCallback(async () => {
setState('launching');
setPayload(null);
try {
const response = await fetch('/api/review-editor/launch', {
method: 'POST',
headers: {
'x-readest-review-editor-launch': '1',
},
});
const data = (await response.json()) as LaunchResponse;
setPayload(data);
setState(response.ok && data.ok && data.url ? 'ready' : 'failed');
} catch (error) {
setPayload({
ok: false,
error: error instanceof Error ? error.message : String(error),
});
setState('failed');
}
}, []);
useEffect(() => {
if (didAutoLaunchRef.current) return;
didAutoLaunchRef.current = true;
void launch();
}, [launch]);
const editorUrl = payload?.url;
const frameTitle = useMemo(() => {
if (!payload?.version) return 'EPUB 审校器';
return `EPUB 审校器 v${payload.version}`;
}, [payload?.version]);
return (
<main className='bg-base-200 text-base-content flex h-dvh min-h-dvh flex-col overflow-hidden'>
<header className='bg-base-100/95 border-base-300 flex h-14 flex-shrink-0 items-center justify-between border-b px-3 backdrop-blur sm:px-5'>
<div className='flex min-w-0 items-center gap-2'>
<button
type='button'
aria-label='返回书架'
title='返回书架'
className='btn btn-ghost h-9 min-h-9 w-9 rounded-md p-0'
onClick={() => router.push('/library')}
>
<ArrowLeft className='h-5 w-5' />
</button>
<div className='min-w-0'>
<h1 className='truncate text-base font-semibold'>EPUB </h1>
<p className='text-base-content/60 truncate text-xs'>
Readest AI
</p>
</div>
</div>
<div className='flex items-center gap-2'>
<button
type='button'
aria-label='重新启动审校器'
title='重新启动审校器'
className='btn btn-ghost h-9 min-h-9 w-9 rounded-md p-0'
onClick={launch}
disabled={state === 'launching'}
>
<RefreshCw className={clsx('h-5 w-5', state === 'launching' && 'animate-spin')} />
</button>
{editorUrl ? (
<button
type='button'
aria-label='在新窗口打开'
title='在新窗口打开'
className='btn btn-ghost h-9 min-h-9 w-9 rounded-md p-0'
onClick={() => openExternalUrl(editorUrl)}
>
<ExternalLink className='h-5 w-5' />
</button>
) : null}
</div>
</header>
{state === 'ready' && editorUrl ? (
<iframe
src={editorUrl}
title={frameTitle}
className='bg-base-100 h-full min-h-0 w-full flex-1 border-0'
allow='clipboard-read; clipboard-write'
/>
) : (
<section className='flex min-h-0 flex-1 items-center justify-center p-5'>
<div className='bg-base-100 eink-bordered w-full max-w-2xl rounded-lg p-6 shadow-xl'>
<div className='mb-5 flex items-center gap-3'>
<div className='bg-base-200 eink-bordered flex h-11 w-11 items-center justify-center rounded-md'>
{state === 'failed' ? (
<Wrench className='h-5 w-5' />
) : (
<Rocket className='h-5 w-5' />
)}
</div>
<div>
<h2 className='text-lg font-semibold'>
{state === 'failed' ? '审校器启动失败' : '正在启动审校器'}
</h2>
<p className='text-base-content/60 text-sm'>
{state === 'failed'
? '下面是本地启动错误,修复后可重新启动。'
: '首次启动会创建 Python 环境并安装 Flask,可能需要一点时间。'}
</p>
</div>
</div>
{state === 'launching' ? (
<div className='bg-base-200 h-2 overflow-hidden rounded-full'>
<div className='bg-primary h-full w-1/2 animate-pulse rounded-full' />
</div>
) : null}
{state === 'failed' ? (
<pre className='bg-base-200 text-base-content/85 mt-4 max-h-64 overflow-auto rounded-md p-3 text-xs whitespace-pre-wrap'>
{payload?.error || '未知错误'}
</pre>
) : null}
{payload?.reviewRoot ? (
<p className='text-base-content/60 mt-4 break-all text-xs'>
{payload.reviewRoot}
</p>
) : null}
<div className='mt-6 flex flex-wrap gap-2'>
<LaunchButton onClick={launch} disabled={state === 'launching'}>
{state === 'failed' ? '重新启动' : '手动重试'}
</LaunchButton>
<LaunchButton onClick={() => router.push('/library')} variant='ghost'>
</LaunchButton>
</div>
</div>
</section>
)}
</main>
);
}
@@ -1,5 +0,0 @@
import ReviewEditorLauncher from './ReviewEditorLauncher';
export default function ReviewEditorPage() {
return <ReviewEditorLauncher />;
}
+26 -4
View File
@@ -1,7 +1,14 @@
import clsx from 'clsx'; import clsx from 'clsx';
import React, { useState, isValidElement, ReactElement, ReactNode, useRef, useId } from 'react'; import React, {
useState,
isValidElement,
ReactElement,
ReactNode,
useRef,
useId,
useEffect,
} from 'react';
import { useDropdownContext } from '@/context/DropdownContext'; import { useDropdownContext } from '@/context/DropdownContext';
import { Overlay } from './Overlay';
import MenuItem from './MenuItem'; import MenuItem from './MenuItem';
interface DropdownProps { interface DropdownProps {
@@ -74,6 +81,7 @@ const Dropdown: React.FC<DropdownProps> = ({
const dropdownId = useId(); const dropdownId = useId();
const context = useDropdownContext(); const context = useDropdownContext();
const isOpen = context ? context.openDropdownId === dropdownId : false; const isOpen = context ? context.openDropdownId === dropdownId : false;
const hasOpenDropdown = context ? context.openDropdownId !== null : isOpen;
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
@@ -108,6 +116,21 @@ const Dropdown: React.FC<DropdownProps> = ({
} }
}; };
useEffect(() => {
if (!isOpen) return;
const handlePointerDown = (event: PointerEvent) => {
if (containerRef.current?.contains(event.target as Node)) return;
if (event.target instanceof Element && event.target.closest('.dropdown-container') !== null) {
return;
}
setIsDropdownOpen(false);
};
document.addEventListener('pointerdown', handlePointerDown, true);
return () => document.removeEventListener('pointerdown', handlePointerDown, true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen]);
const childrenWithToggle = isValidElement(children) const childrenWithToggle = isValidElement(children)
? React.cloneElement(children, { ? React.cloneElement(children, {
...(typeof children.type !== 'string' && { ...(typeof children.type !== 'string' && {
@@ -120,8 +143,7 @@ const Dropdown: React.FC<DropdownProps> = ({
return ( return (
<div ref={containerRef} className={clsx('dropdown-container flex', containerClassName)}> <div ref={containerRef} className={clsx('dropdown-container flex', containerClassName)}>
{isOpen && <Overlay onDismiss={() => setIsDropdownOpen(false)} />} <div className={clsx('relative', isOpen ? 'z-[60]' : hasOpenDropdown && 'z-[70]')}>
<div className={clsx('relative', isOpen && 'z-50')}>
<button <button
tabIndex={0} tabIndex={0}
aria-haspopup='menu' aria-haspopup='menu'
+22 -2
View File
@@ -9,6 +9,7 @@ import { initReplicaSync } from '@/services/sync/replicaSync';
import { createSettingsCursorStore } from '@/services/sync/replicaCursorStore'; import { createSettingsCursorStore } from '@/services/sync/replicaCursorStore';
import { startReplicaTransferIntegration } from '@/services/sync/replicaTransferIntegration'; import { startReplicaTransferIntegration } from '@/services/sync/replicaTransferIntegration';
import { enableReplicaAutoPersist } from '@/services/sync/replicaPersist'; import { enableReplicaAutoPersist } from '@/services/sync/replicaPersist';
import { isViewTransitionTimeoutError } from '@/utils/error';
interface EnvContextType { interface EnvContextType {
envConfig: EnvConfigType; envConfig: EnvConfigType;
@@ -40,14 +41,33 @@ export const EnvProvider = ({ children }: { children: ReactNode }) => {
console.warn('replica sync init failed', err); console.warn('replica sync init failed', err);
} }
}); });
window.addEventListener('error', (e) => { const handleError = (e: ErrorEvent) => {
if (e.message === 'ResizeObserver loop limit exceeded') { if (e.message === 'ResizeObserver loop limit exceeded') {
e.stopImmediatePropagation(); e.stopImmediatePropagation();
e.preventDefault(); e.preventDefault();
return true; return true;
} }
if (isViewTransitionTimeoutError(e.error)) {
e.stopImmediatePropagation();
e.preventDefault();
return true;
}
return false; return false;
}); };
const handleUnhandledRejection = (e: PromiseRejectionEvent) => {
if (isViewTransitionTimeoutError(e.reason)) {
e.stopImmediatePropagation();
e.preventDefault();
return true;
}
return false;
};
window.addEventListener('error', handleError);
window.addEventListener('unhandledrejection', handleUnhandledRejection);
return () => {
window.removeEventListener('error', handleError);
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
};
}, [envConfig]); }, [envConfig]);
const value = useMemo(() => ({ envConfig, appService }), [envConfig, appService]); const value = useMemo(() => ({ envConfig, appService }), [envConfig, appService]);
+8 -1
View File
@@ -2,7 +2,12 @@ import { useEnv } from '@/context/EnvContext';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useTransitionRouter } from 'next-view-transitions'; import { useTransitionRouter } from 'next-view-transitions';
export const useAppRouter = () => { const isWebDevMode =
process.env['NODE_ENV'] === 'development' && process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web';
const usePlainAppRouter = () => useRouter();
const useTransitionAppRouter = () => {
const { appService } = useEnv(); const { appService } = useEnv();
const transitionRouter = useTransitionRouter(); const transitionRouter = useTransitionRouter();
const plainRouter = useRouter(); const plainRouter = useRouter();
@@ -15,3 +20,5 @@ export const useAppRouter = () => {
// seen on unsupported webviews (Sentry READEST-9). // seen on unsupported webviews (Sentry READEST-9).
return appService?.supportsViewTransitionsAPI ? transitionRouter : plainRouter; return appService?.supportsViewTransitionsAPI ? transitionRouter : plainRouter;
}; };
export const useAppRouter = isWebDevMode ? usePlainAppRouter : useTransitionAppRouter;
@@ -0,0 +1,17 @@
import { useEffect, useState } from 'react';
export const useIsMobileViewport = (breakpoint = 640) => {
const [isMobileViewport, setIsMobileViewport] = useState(false);
useEffect(() => {
if (typeof window === 'undefined') return;
const mediaQuery = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
const update = () => setIsMobileViewport(mediaQuery.matches);
update();
mediaQuery.addEventListener('change', update);
return () => mediaQuery.removeEventListener('change', update);
}, [breakpoint]);
return isMobileViewport;
};
+2 -2
View File
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
interface UseLongPressOptions { interface UseLongPressOptions {
onTap?: () => void; onTap?: () => void;
onLongPress?: () => void; onLongPress?: () => void;
onContextMenu?: () => void; onContextMenu?: (event: React.MouseEvent) => void;
onCancel?: () => void; onCancel?: () => void;
threshold?: number; threshold?: number;
moveThreshold?: number; moveThreshold?: number;
@@ -149,7 +149,7 @@ export const useLongPress = (
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setTimeout(() => { setTimeout(() => {
onContextMenu(); onContextMenu(e);
}, 100); }, 100);
} }
reset(); reset();
@@ -0,0 +1,471 @@
import { makeSafeFilename } from '@/utils/misc';
import { getBaseFilename } from '@/utils/path';
export type BilingualFilterLanguage = 'ja' | 'zh';
export type BilingualFilterMode = 'auto' | 'style' | 'script';
export interface BilingualFilterOptions {
removeLanguage: BilingualFilterLanguage;
mode?: BilingualFilterMode;
removeUnknown?: boolean;
}
export interface BilingualFilterFileStats {
path: string;
paragraphs: number;
removed: number;
kept: number;
unknown: number;
anchorsMoved: number;
byLanguage: Record<BilingualFilterLanguage | 'unknown', number>;
}
export interface BilingualFilterStats {
filesSeen: number;
htmlFiles: number;
changedFiles: number;
paragraphs: number;
removed: number;
kept: number;
unknown: number;
anchorsMoved: number;
fileStats: BilingualFilterFileStats[];
}
export interface BilingualFilterResult {
file: File;
filename: string;
title: string;
stats: BilingualFilterStats;
}
const HTML_EXTENSIONS = ['.html', '.htm', '.xhtml'];
const TEXT_BLOCK_RE = /<p\b[^>]*>.*?<\/p>/gis;
const BODY_END_RE = /<\/body\s*>/i;
const ANCHOR_RE =
/<a\b(?=[^>]*(?:\bid\s*=\s*['"][^'"]+['"]|\bname\s*=\s*['"][^'"]+['"]))[^>]*>\s*<\/a\s*>/gis;
const TAG_RE = /<[^>]+>/g;
const OPF_EXT = '.opf';
const makeEmptyFileStats = (path: string): BilingualFilterFileStats => ({
path,
paragraphs: 0,
removed: 0,
kept: 0,
unknown: 0,
anchorsMoved: 0,
byLanguage: { ja: 0, zh: 0, unknown: 0 },
});
const makeEmptyRunStats = (): BilingualFilterStats => ({
filesSeen: 0,
htmlFiles: 0,
changedFiles: 0,
paragraphs: 0,
removed: 0,
kept: 0,
unknown: 0,
anchorsMoved: 0,
fileStats: [],
});
const isHtmlPath = (path: string) => {
const lower = path.toLowerCase();
return HTML_EXTENSIONS.some((ext) => lower.endsWith(ext));
};
const decodeHtmlEntities = (text: string) => {
if (typeof document !== 'undefined') {
const textarea = document.createElement('textarea');
textarea.innerHTML = text;
return textarea.value;
}
return text
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
};
const stripTags = (fragment: string) => decodeHtmlEntities(fragment.replace(TAG_RE, '')).trim();
const getOpeningTag = (fragment: string) => fragment.match(/^<p\b[^>]*>/is)?.[0] ?? '';
const isImageOrEmptyParagraph = (fragment: string) => {
if (stripTags(fragment)) return false;
return /<(img|svg|math)\b/i.test(fragment);
};
const getStyleAttribute = (openingTag: string) =>
openingTag.match(/\bstyle\s*=\s*(["'])(.*?)\1/is)?.[2] ?? '';
const getClassAttribute = (openingTag: string) =>
openingTag.match(/\bclass\s*=\s*(["'])(.*?)\1/is)?.[2] ?? '';
const hasGrayColor = (style: string) => {
const compact = style.replace(/\s+/g, '').toLowerCase();
return (
/(^|;)color:(gray|grey)(;|$)/i.test(style) ||
compact.includes('color:#808080') ||
compact.includes('color:#888') ||
compact.includes('color:rgb(128,128,128)') ||
compact.includes('color:rgba(128,128,128,')
);
};
const isProbablyJapaneseByStyle = (fragment: string) => {
const openingTag = getOpeningTag(fragment);
const style = getStyleAttribute(openingTag);
const className = getClassAttribute(openingTag).toLowerCase();
return (
/\bopacity\s*:\s*0(?:\.40*|\.4|\.5|\.50*)\b/i.test(style) ||
hasGrayColor(style) ||
/\b(japanese|source|original|src|ja|jp)\b/.test(className)
);
};
const isProbablyChineseByStyle = (fragment: string) => {
const openingTag = getOpeningTag(fragment);
const className = getClassAttribute(openingTag).toLowerCase();
return /\b(chinese|translation|translated|target|zh|cn)\b/.test(className);
};
const scriptCounts = (text: string) => {
const counts = { hiragana: 0, katakana: 0, kana: 0, cjk: 0, ascii: 0 };
for (const ch of text) {
const code = ch.codePointAt(0) ?? 0;
if (code >= 0x3040 && code <= 0x309f) {
counts.hiragana += 1;
counts.kana += 1;
} else if (
(code >= 0x30a0 && code <= 0x30ff) ||
(code >= 0x31f0 && code <= 0x31ff) ||
(code >= 0xff66 && code <= 0xff9d)
) {
counts.katakana += 1;
counts.kana += 1;
} else if (
(code >= 0x4e00 && code <= 0x9fff) ||
(code >= 0x3400 && code <= 0x4dbf) ||
(code >= 0xf900 && code <= 0xfaff)
) {
counts.cjk += 1;
} else if (/^[a-z]$/i.test(ch)) {
counts.ascii += 1;
}
}
return counts;
};
const classifyByScript = (fragment: string): BilingualFilterLanguage | 'unknown' => {
const text = stripTags(fragment);
if (!text) return 'unknown';
const counts = scriptCounts(text);
const meaningful = counts.kana + counts.cjk + counts.ascii;
if (meaningful === 0) return 'unknown';
if (counts.kana >= 2) return 'ja';
if (counts.kana === 1 && counts.cjk <= 2) return 'ja';
if (counts.cjk >= 2 && counts.kana === 0) return 'zh';
return 'unknown';
};
const classifyParagraph = (
fragment: string,
mode: BilingualFilterMode,
): BilingualFilterLanguage | 'unknown' => {
if (isImageOrEmptyParagraph(fragment)) return 'unknown';
if (mode === 'auto' || mode === 'style') {
if (isProbablyJapaneseByStyle(fragment)) return 'ja';
if (isProbablyChineseByStyle(fragment)) return 'zh';
}
if (mode === 'style') {
return stripTags(fragment) ? 'zh' : 'unknown';
}
return classifyByScript(fragment);
};
const looksLikeStyleBilingual = (text: string) => {
const paragraphs = text.match(TEXT_BLOCK_RE) ?? [];
if (paragraphs.length < 10) return false;
const textBlocks = paragraphs.filter((paragraph) => stripTags(paragraph)).length;
if (textBlocks === 0) return false;
const styled = paragraphs.filter(isProbablyJapaneseByStyle).length;
const ratio = styled / textBlocks;
return styled >= 5 && ratio >= 0.15 && ratio <= 0.85;
};
const extractEmptyAnchors = (fragment: string) => fragment.match(ANCHOR_RE) ?? [];
const insertAnchorsIntoParagraph = (paragraph: string, anchors: string[]) => {
if (anchors.length === 0) return paragraph;
const insertion = anchors.join('');
const match = paragraph.match(/^<p\b[^>]*>/is);
if (!match) return insertion + paragraph;
const index = match[0].length;
return paragraph.slice(0, index) + insertion + paragraph.slice(index);
};
const insertPendingAnchorsBeforeBodyEnd = (text: string, anchors: string[]) => {
if (anchors.length === 0) return text;
const fallback = `<p style="display:none;">${anchors.join('')}</p>\n`;
const match = text.match(BODY_END_RE);
if (!match || match.index === undefined) return `${text}\n${fallback}`;
return text.slice(0, match.index) + fallback + text.slice(match.index);
};
const shouldRemove = (
language: BilingualFilterLanguage | 'unknown',
removeLanguage: BilingualFilterLanguage,
removeUnknown: boolean,
) => language === removeLanguage || (removeUnknown && language === 'unknown');
const filterHtmlText = (text: string, options: Required<BilingualFilterOptions>, path: string) => {
const stats = makeEmptyFileStats(path);
const output: string[] = [];
let pendingAnchors: string[] = [];
let position = 0;
let changed = false;
const mode = options.mode === 'auto' && looksLikeStyleBilingual(text) ? 'style' : options.mode;
for (const match of text.matchAll(TEXT_BLOCK_RE)) {
if (match.index === undefined) continue;
output.push(text.slice(position, match.index));
let paragraph = match[0];
const language = classifyParagraph(paragraph, mode);
stats.paragraphs += 1;
stats.byLanguage[language] += 1;
if (shouldRemove(language, options.removeLanguage, options.removeUnknown)) {
const anchors = extractEmptyAnchors(paragraph);
pendingAnchors = [...pendingAnchors, ...anchors];
stats.removed += 1;
stats.anchorsMoved += anchors.length;
changed = true;
} else {
if (language === 'unknown') stats.unknown += 1;
stats.kept += 1;
if (pendingAnchors.length > 0) {
paragraph = insertAnchorsIntoParagraph(paragraph, pendingAnchors);
pendingAnchors = [];
changed = true;
}
output.push(paragraph);
}
position = match.index + match[0].length;
}
output.push(text.slice(position));
let newText = output.join('');
if (pendingAnchors.length > 0) {
newText = insertPendingAnchorsBeforeBodyEnd(newText, pendingAnchors);
changed = true;
}
return { text: changed ? newText : text, stats };
};
const escapeXml = (value: string) =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
const decodeText = (data: Uint8Array) => {
const encodings = ['utf-8', 'shift_jis', 'gb18030'];
for (const encoding of encodings) {
try {
const decoder = new TextDecoder(encoding, { fatal: true });
return decoder.decode(data);
} catch {
// Try the next common EPUB encoding.
}
}
return new TextDecoder('utf-8').decode(data);
};
const setDocumentLanguageAndTitle = (text: string, title: string, language: string) => {
let updated = text;
if (/<title\b[^>]*>.*?<\/title>/is.test(updated)) {
updated = updated.replace(/<title\b[^>]*>.*?<\/title>/is, `<title>${escapeXml(title)}</title>`);
}
if (/\bxml:lang\s*=\s*["'][^"']*["']/i.test(updated)) {
updated = updated.replace(/\bxml:lang\s*=\s*(["'])[^"']*\1/i, `xml:lang="${language}"`);
}
if (/\blang\s*=\s*["'][^"']*["']/i.test(updated)) {
updated = updated.replace(/\blang\s*=\s*(["'])[^"']*\1/i, `lang="${language}"`);
} else {
updated = updated.replace(/<html\b/i, `<html lang="${language}"`);
}
return updated;
};
const extractOpfTitle = (text: string) => {
const match = text.match(/<dc:title\b[^>]*>(.*?)<\/dc:title>/is);
return match ? stripTags(match[1] ?? '') : '';
};
const getVariantInfo = (
sourceName: string,
removeLanguage: BilingualFilterLanguage,
metadataTitle?: string,
) => {
const sourceTitle = metadataTitle?.trim() || getBaseFilename(sourceName);
const suffix = removeLanguage === 'ja' ? '简中译本' : '日文原文版';
const language = removeLanguage === 'ja' ? 'zh-CN' : 'ja';
const title = `${sourceTitle}${suffix}`;
const filename = `${makeSafeFilename(`${sourceTitle}_${suffix}`)}.epub`;
const identifierSuffix = removeLanguage === 'ja' ? 'readest-no-ja' : 'readest-no-zh';
return { title, filename, language, identifierSuffix };
};
const patchOpfMetadata = (
text: string,
variant: ReturnType<typeof getVariantInfo>,
removeLanguage: BilingualFilterLanguage,
) => {
let updated = text;
const title = escapeXml(variant.title);
if (/<dc:title\b[^>]*>.*?<\/dc:title>/is.test(updated)) {
updated = updated.replace(
/<dc:title\b([^>]*)>.*?<\/dc:title>/is,
`<dc:title$1>${title}</dc:title>`,
);
}
if (/<dc:language\b[^>]*>.*?<\/dc:language>/is.test(updated)) {
updated = updated.replace(
/<dc:language\b([^>]*)>.*?<\/dc:language>/is,
`<dc:language$1>${variant.language}</dc:language>`,
);
}
if (/<dc:identifier\b[^>]*>.*?<\/dc:identifier>/is.test(updated)) {
updated = updated.replace(
/<dc:identifier\b([^>]*)>(.*?)<\/dc:identifier>/gis,
(_match, attrs: string, value: string) => {
const trimmed = value.trim();
const suffix = `:${variant.identifierSuffix}`;
const nextValue = trimmed.includes(suffix) ? trimmed : `${trimmed}${suffix}`;
return `<dc:identifier${attrs}>${escapeXml(nextValue)}</dc:identifier>`;
},
);
} else {
updated = updated.replace(
/<\/metadata\s*>/i,
`<dc:identifier id="readest-filter-id">readest:${removeLanguage}:${Date.now()}</dc:identifier></metadata>`,
);
}
updated = updated.replace(
/<meta\b([^>]*\bproperty\s*=\s*["']dcterms:modified["'][^>]*)>.*?<\/meta>/is,
`<meta$1>${new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')}</meta>`,
);
return updated;
};
const updateRunStats = (runStats: BilingualFilterStats, fileStats: BilingualFilterFileStats) => {
runStats.fileStats.push(fileStats);
runStats.paragraphs += fileStats.paragraphs;
runStats.removed += fileStats.removed;
runStats.kept += fileStats.kept;
runStats.unknown += fileStats.unknown;
runStats.anchorsMoved += fileStats.anchorsMoved;
};
export async function filterBilingualEpubFile(
sourceFile: File,
filterOptions: BilingualFilterOptions,
): Promise<BilingualFilterResult> {
const options: Required<BilingualFilterOptions> = {
mode: 'auto',
removeUnknown: false,
...filterOptions,
};
const {
BlobReader,
BlobWriter,
TextReader,
Uint8ArrayReader,
Uint8ArrayWriter,
ZipReader,
ZipWriter,
} = await import('@zip.js/zip.js');
const reader = new ZipReader(new BlobReader(sourceFile));
const writer = new ZipWriter(new BlobWriter('application/epub+zip'));
const runStats = makeEmptyRunStats();
try {
const entries = await reader.getEntries();
const readableEntries = entries.filter(isReadableZipEntry);
const opfEntry = readableEntries.find((entry) =>
entry.filename.toLowerCase().endsWith(OPF_EXT),
);
const opfTitle = opfEntry
? extractOpfTitle(decodeText(await opfEntry.getData(new Uint8ArrayWriter())))
: '';
const variant = getVariantInfo(sourceFile.name, options.removeLanguage, opfTitle);
const seen = new Set<string>();
const mimetype = entries.find((entry) => entry.filename.toLowerCase() === 'mimetype');
if (mimetype) {
await writer.add('mimetype', new TextReader('application/epub+zip'), { level: 0 });
seen.add(mimetype.filename);
runStats.filesSeen += 1;
}
for (const entry of readableEntries) {
if (seen.has(entry.filename)) continue;
seen.add(entry.filename);
runStats.filesSeen += 1;
const lowerName = entry.filename.toLowerCase();
const rawData = await entry.getData(new Uint8ArrayWriter());
let outData: Uint8Array | string = rawData;
if (isHtmlPath(lowerName)) {
runStats.htmlFiles += 1;
const originalText = decodeText(rawData);
const filtered = filterHtmlText(originalText, options, entry.filename);
const patchedText = setDocumentLanguageAndTitle(
filtered.text,
variant.title,
variant.language,
);
updateRunStats(runStats, filtered.stats);
if (patchedText !== originalText) runStats.changedFiles += 1;
outData = patchedText;
} else if (lowerName.endsWith(OPF_EXT)) {
const originalText = decodeText(rawData);
const patchedText = patchOpfMetadata(originalText, variant, options.removeLanguage);
if (patchedText !== originalText) runStats.changedFiles += 1;
outData = patchedText;
}
const writerReader =
typeof outData === 'string'
? new TextReader(outData)
: new Uint8ArrayReader(outData as Uint8Array);
await writer.add(entry.filename, writerReader, zipOptionsForEntry(entry));
}
const blob = await writer.close();
const file = new File([blob], variant.filename, { type: 'application/epub+zip' });
return { file, filename: variant.filename, title: variant.title, stats: runStats };
} finally {
await reader.close();
}
}
const isReadableZipEntry = (
entry: import('@zip.js/zip.js').Entry,
): entry is import('@zip.js/zip.js').FileEntry =>
!entry.directory && typeof entry.getData === 'function';
const zipOptionsForEntry = (entry: import('@zip.js/zip.js').Entry) =>
entry.filename.toLowerCase() === 'mimetype' ? { level: 0 } : {};
@@ -0,0 +1,598 @@
import { invoke } from '@tauri-apps/api/core';
import type { Book } from '@/types/book';
import type { AppService } from '@/types/system';
import { isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
export type ReviewEditorLaunchResponse = {
ok: boolean;
url?: string;
reused?: boolean;
reviewRoot?: string;
version?: string;
accessToken?: string;
sessionId?: string | null;
error?: string;
};
export type ReviewSessionSummary = {
id: string;
source_name?: string;
title?: string;
source_epub?: string;
row_count?: number;
touched_count?: number;
marked_count?: number;
feedback_md?: string;
feedback_jsonl?: string;
latest_export?: string;
};
export type ReviewSessionPayload = {
has_session: boolean;
id?: string;
title?: string;
source_name?: string;
source_epub?: string;
row_count?: number;
touched_count?: number;
marked_count?: number;
feedback_md?: string;
feedback_jsonl?: string;
review_root?: string;
error?: string;
};
export type ReviewRow = {
id: string;
file: string;
file_label?: string;
document_title?: string;
file_order?: number;
file_row_index?: number;
ja_p_index?: number;
cn_p_index?: number;
jp_html: string;
jp_text: string;
cn_html: string;
cn_text?: string;
current_html: string;
marked?: boolean;
issue_type?: string;
severity?: string;
comment?: string;
learn_note?: string;
tags?: string;
edited?: boolean;
updated_at?: string;
};
export type ReviewRowsPayload = {
rows: ReviewRow[];
};
export type ReviewGptConfig = {
configured?: boolean;
base_url?: string;
model?: string;
key_source?: string;
updated_at?: string;
translation_prompt?: string;
format_prompt?: string;
character_prompt?: string;
glossary_path?: string;
prompt_defaults?: {
translation_prompt?: string;
format_prompt?: string;
character_prompt?: string;
};
};
export type ReviewEditPayload = {
current_html: string;
marked: boolean;
issue_type: string;
severity: string;
tags: string;
comment: string;
learn_note: string;
};
export const isReviewEditDirty = (row: ReviewRow, edit: ReviewEditPayload) =>
edit.current_html !== (row.current_html ?? row.cn_html ?? '') ||
edit.marked !== Boolean(row.marked) ||
edit.issue_type !== (row.issue_type || '') ||
edit.severity !== (row.severity || '') ||
edit.tags !== (row.tags || '') ||
edit.comment !== (row.comment || '') ||
edit.learn_note !== (row.learn_note || '');
export type ReviewSaveRowPayload = {
status?: string;
row_id?: string;
updated_at?: string;
current_html?: string;
};
export type ReviewGlossaryEntry = {
source: string;
target: string;
clean_target?: string;
};
export type TranslationChunkPlan = {
id: string;
file: string;
title?: string;
first_item_id: string;
last_item_id: string;
item_count: number;
source_characters: number;
estimated_input_tokens: number;
};
export type TranslationPlanPayload = {
session_id: string;
source_count: number;
chunk_target: number;
chunk_count: number;
estimated_input_tokens: number;
chunks: TranslationChunkPlan[];
};
export type TranslationJobSummary = {
id: string;
status: 'pending' | 'running' | 'paused' | 'completed' | 'failed' | 'cancelled';
source_session_id: string;
source_name?: string;
progress?: {
current?: number;
total?: number;
percent?: number;
failed?: number;
message?: string;
};
output_epub?: string;
output_download_url?: string;
output_session_id?: string;
error?: string;
logs?: { ts?: string; level?: string; message?: string }[];
};
export type TranslationDefaultsPayload = {
gpt?: ReviewGptConfig;
defaults?: {
output_mode?: 'bilingual' | 'translated';
range_mode?: 'all' | 'limit';
limit?: number;
temperature?: number;
chunk_target?: number;
};
};
export type TranslationSourcePayload = {
source_count: number;
files?: { file: string; count: number }[];
stats?: { note?: string };
};
export type ReviewGlossaryPayload = {
path?: string;
exists?: boolean;
count?: number;
entries?: ReviewGlossaryEntry[];
updated_at?: string;
};
export type ReviewBootstrapPayload = {
session: ReviewSessionPayload;
rows: ReviewRow[];
gptConfig: ReviewGptConfig;
};
export type ReviewSessionFromPathOptions = {
activate?: boolean;
reset?: boolean;
};
const ensureBaseUrl = (baseUrl: string) => {
if (!baseUrl) throw new Error('审校器后端尚未启动');
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
};
const sidecarAccessTokens = new Map<string, string>();
const rememberSidecarAccessToken = (baseUrl: string, accessToken: string) => {
sidecarAccessTokens.set(ensureBaseUrl(baseUrl), accessToken);
};
export const canLaunchInlineReviewEditor = (
appService: Pick<AppService, 'isDesktopApp'> | null | undefined,
localWebDevelopment = process.env['NODE_ENV'] === 'development' && isWebAppPlatform(),
) => Boolean(appService?.isDesktopApp || localWebDevelopment);
export async function sidecarApi<T>(
baseUrl: string,
path: string,
options: RequestInit = {},
sessionId?: string | null,
accessToken?: string,
): Promise<T> {
const url = new URL(path, ensureBaseUrl(baseUrl));
if (sessionId) {
url.searchParams.set('session_id', sessionId);
}
const resolvedAccessToken = accessToken || sidecarAccessTokens.get(ensureBaseUrl(baseUrl));
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData;
const headers = {
...(options.body === undefined || isFormData ? {} : { 'Content-Type': 'application/json' }),
...(resolvedAccessToken ? { Authorization: `Bearer ${resolvedAccessToken}` } : {}),
...(options.headers || {}),
};
const response = await fetch(url.toString(), {
...options,
headers,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(String(data.error || `HTTP ${response.status}`));
}
return data as T;
}
export async function createReviewSessionFromPath(
baseUrl: string,
epubPath: string,
options: ReviewSessionFromPathOptions = {},
accessToken?: string,
): Promise<ReviewSessionSummary> {
return sidecarApi(
baseUrl,
'/api/session/from-path',
{
method: 'POST',
body: JSON.stringify({
epub_path: epubPath,
activate: options.activate ?? false,
reset: options.reset ?? false,
}),
},
null,
accessToken,
);
}
async function createReviewSessionFromBook(
baseUrl: string,
appService: AppService,
book: Book,
accessToken: string,
): Promise<ReviewSessionSummary> {
const { file } = await appService.loadBookContent(book);
const formData = new FormData();
formData.append('epub', file, file.name || `${book.title || 'book'}.epub`);
formData.append('activate', 'false');
formData.append('book_key', book.hash);
const response = await fetch(new URL('/api/upload', ensureBaseUrl(baseUrl)).toString(), {
method: 'POST',
body: formData,
headers: { Authorization: `Bearer ${accessToken}` },
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(String(data.error || `HTTP ${response.status}`));
}
return data as ReviewSessionSummary;
}
export async function launchInlineReviewEditor(
appService: AppService | null | undefined,
book: Book,
tauriPlatform = isTauriAppPlatform(),
): Promise<
ReviewEditorLaunchResponse & { url: string; sessionId: string | null; accessToken: string }
> {
if (!appService) throw new Error('Readest 服务尚未初始化');
if (book.format !== 'EPUB') throw new Error('审校模式目前只支持 EPUB 书籍');
const epubPath = tauriPlatform ? await appService.resolveNativeBookFilePath(book) : null;
if (tauriPlatform && !epubPath) throw new Error('无法定位当前 EPUB 的本机文件路径');
const launch = tauriPlatform
? await invoke<ReviewEditorLaunchResponse>('launch_epub_review_editor', { epubPath })
: await fetch('/api/review-editor/launch', {
method: 'POST',
headers: {
'x-readest-review-editor-launch': '1',
},
}).then((response) => response.json() as Promise<ReviewEditorLaunchResponse>);
if (!launch.ok || !launch.url) {
throw new Error(launch.error || '审校器启动失败');
}
if (!launch.accessToken) throw new Error('审校器启动响应缺少访问令牌');
rememberSidecarAccessToken(launch.url, launch.accessToken);
let sessionId = launch.sessionId || null;
if (!sessionId) {
const createdSession = epubPath
? await createReviewSessionFromPath(launch.url, epubPath, {}, launch.accessToken)
: await createReviewSessionFromBook(launch.url, appService, book, launch.accessToken);
sessionId = createdSession.id || null;
}
return { ...launch, url: launch.url, sessionId, accessToken: launch.accessToken };
}
export async function loadInlineReviewData(
baseUrl: string,
sessionId: string | null | undefined,
accessToken?: string,
): Promise<ReviewBootstrapPayload> {
if (!sessionId) throw new Error('审校器没有返回当前书籍会话');
const [session, rowsPayload, gptConfig] = await Promise.all([
sidecarApi<ReviewSessionPayload>(baseUrl, '/api/session', {}, sessionId, accessToken),
sidecarApi<ReviewRowsPayload>(baseUrl, '/api/rows', {}, sessionId, accessToken),
sidecarApi<ReviewGptConfig>(baseUrl, '/api/gpt/config', {}, sessionId, accessToken),
]);
return {
session,
rows: rowsPayload.rows || [],
gptConfig,
};
}
export async function saveReviewRow(
baseUrl: string,
sessionId: string | null | undefined,
rowId: string,
edit: ReviewEditPayload,
): Promise<ReviewSaveRowPayload> {
return sidecarApi(
baseUrl,
`/api/row/${encodeURIComponent(rowId)}`,
{
method: 'POST',
body: JSON.stringify(edit),
},
sessionId,
);
}
export async function retranslateReviewRow(
baseUrl: string,
sessionId: string | null | undefined,
rowId: string,
instruction: string,
): Promise<{
candidate_html?: string;
candidate_text?: string;
glossary_matches?: string[];
}> {
return sidecarApi(
baseUrl,
`/api/row/${encodeURIComponent(rowId)}/retranslate`,
{
method: 'POST',
body: JSON.stringify({ instruction }),
},
sessionId,
);
}
export async function saveReviewGptConfig(
baseUrl: string,
sessionId: string | null | undefined,
config: {
base_url?: string;
model?: string;
api_key?: string;
glossary_path?: string;
translation_prompt?: string;
format_prompt?: string;
character_prompt?: string;
},
): Promise<ReviewGptConfig> {
return sidecarApi(
baseUrl,
'/api/gpt/config',
{
method: 'POST',
body: JSON.stringify({
...config,
keep_existing: true,
}),
},
sessionId,
);
}
export async function loadReviewGlossary(
baseUrl: string,
sessionId?: string | null,
): Promise<ReviewGlossaryPayload> {
return sidecarApi(baseUrl, '/api/glossary', {}, sessionId);
}
export async function switchReviewGlossaryPath(
baseUrl: string,
sessionId: string | null | undefined,
path: string,
): Promise<ReviewGlossaryPayload> {
return sidecarApi(
baseUrl,
'/api/glossary',
{
method: 'POST',
body: JSON.stringify({ path }),
},
sessionId,
);
}
export async function saveReviewGlossary(
baseUrl: string,
sessionId: string | null | undefined,
path: string,
entries: ReviewGlossaryEntry[],
): Promise<ReviewGlossaryPayload> {
return sidecarApi(
baseUrl,
'/api/glossary',
{
method: 'POST',
body: JSON.stringify({ path, entries }),
},
sessionId,
);
}
export async function generateReviewFeedback(
baseUrl: string,
sessionId: string | null | undefined,
): Promise<{ feedback_md?: string; feedback_jsonl?: string }> {
return sidecarApi(baseUrl, '/api/feedback', {}, sessionId);
}
export async function exportReviewedEpub(
baseUrl: string,
sessionId: string | null | undefined,
): Promise<{ output?: string; download_url?: string }> {
return sidecarApi(
baseUrl,
'/api/export',
{
method: 'POST',
body: '{}',
},
sessionId,
);
}
export async function downloadReviewedEpub(downloadUrl: string, accessToken = ''): Promise<void> {
const url = new URL(downloadUrl);
const resolvedAccessToken = accessToken || sidecarAccessTokens.get(ensureBaseUrl(url.origin));
if (!resolvedAccessToken) throw new Error('审校器访问令牌不可用');
const filename = decodeURIComponent(url.pathname.split('/').pop() || 'reviewed.epub');
const response = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${resolvedAccessToken}` },
});
if (!response.ok) throw new Error(`EPUB 下载失败:HTTP ${response.status}`);
const blobUrl = URL.createObjectURL(await response.blob());
const anchor = document.createElement('a');
anchor.href = blobUrl;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(blobUrl);
}
export async function loadTranslationSource(
baseUrl: string,
sessionId: string,
accessToken?: string,
): Promise<TranslationSourcePayload> {
return sidecarApi(
baseUrl,
`/api/session/${encodeURIComponent(sessionId)}/translation-source`,
{},
sessionId,
accessToken,
);
}
export async function loadTranslationDefaults(
baseUrl: string,
sessionId: string,
accessToken?: string,
): Promise<TranslationDefaultsPayload> {
return sidecarApi(baseUrl, '/api/translation/defaults', {}, sessionId, accessToken);
}
export async function loadTranslationPlan(
baseUrl: string,
sessionId: string,
chunkTarget: number,
accessToken?: string,
): Promise<TranslationPlanPayload> {
return sidecarApi(
baseUrl,
`/api/session/${encodeURIComponent(sessionId)}/translation-plan`,
{ method: 'POST', body: JSON.stringify({ chunk_target: chunkTarget }) },
sessionId,
accessToken,
);
}
export async function startFullBookTranslation(
baseUrl: string,
sessionId: string,
settings: {
output_mode: 'bilingual' | 'translated';
range_mode: 'all' | 'limit';
limit: number;
temperature: number;
chunk_target: number;
},
accessToken?: string,
): Promise<{ job: TranslationJobSummary }> {
return sidecarApi(
baseUrl,
'/api/translation/start',
{ method: 'POST', body: JSON.stringify({ session_id: sessionId, ...settings }) },
sessionId,
accessToken,
);
}
export async function listTranslationJobs(
baseUrl: string,
sessionId: string,
accessToken?: string,
): Promise<{ jobs: TranslationJobSummary[]; count: number }> {
return sidecarApi(baseUrl, '/api/translation/jobs', {}, sessionId, accessToken);
}
export async function loadTranslationJob(
baseUrl: string,
sessionId: string,
jobId: string,
accessToken?: string,
): Promise<{ job: TranslationJobSummary }> {
return sidecarApi(
baseUrl,
`/api/translation/jobs/${encodeURIComponent(jobId)}`,
{},
sessionId,
accessToken,
);
}
export async function controlTranslationJob(
baseUrl: string,
sessionId: string,
jobId: string,
action: 'pause' | 'resume' | 'cancel',
accessToken?: string,
): Promise<{ job: TranslationJobSummary }> {
return sidecarApi(
baseUrl,
`/api/translation/jobs/${encodeURIComponent(jobId)}/${action}`,
{ method: 'POST', body: '{}' },
sessionId,
accessToken,
);
}
export async function probeTranslationModels(
baseUrl: string,
sessionId: string,
accessToken?: string,
): Promise<{
models: string[];
configured_model: string;
configured_model_available: boolean;
}> {
return sidecarApi(baseUrl, '/api/gpt/models', {}, sessionId, accessToken);
}
@@ -0,0 +1,225 @@
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import type {
ReviewGptConfig,
ReviewRow,
ReviewSessionPayload,
} from '@/services/reviewEditorService';
export type ReviewBookState = {
enabled: boolean;
loading: boolean;
error: string;
baseUrl: string;
accessToken: string;
sessionId: string | null;
reviewRoot?: string;
version?: string;
rows: ReviewRow[];
session: ReviewSessionPayload | null;
gptConfig: ReviewGptConfig | null;
selectedRowId: string;
hasUnsavedEdit: boolean;
};
const DEFAULT_PANEL_WIDTH = '32%';
const DEFAULT_PANEL_HEIGHT = '68vh';
const MIN_PANEL_WIDTH_PERCENT = 24;
const MAX_PANEL_WIDTH_PERCENT = 48;
const MIN_PANEL_HEIGHT_VH = 35;
const MAX_PANEL_HEIGHT_VH = 92;
type ReviewModeStore = {
activeBookKey: string | null;
isPanelVisible: boolean;
isPanelPinned: boolean;
panelWidth: string;
panelHeight: string;
books: Record<string, ReviewBookState>;
getBookState: (bookKey: string | null) => ReviewBookState | null;
setActiveBookKey: (bookKey: string | null) => void;
setPanelVisible: (visible: boolean) => void;
togglePanelPin: () => void;
setPanelWidth: (width: string) => void;
setPanelHeight: (height: string) => void;
setBookLoading: (bookKey: string, loading: boolean) => void;
setBookError: (bookKey: string, error: string) => void;
setBookEnabled: (bookKey: string, enabled: boolean) => void;
setBookData: (
bookKey: string,
data: Partial<Omit<ReviewBookState, 'enabled' | 'loading' | 'error'>>,
) => void;
selectRow: (bookKey: string, rowId: string) => void;
setBookUnsavedEdit: (bookKey: string, hasUnsavedEdit: boolean) => void;
updateRow: (bookKey: string, row: ReviewRow) => void;
clearBook: (bookKey: string) => void;
};
const emptyBookState = (): ReviewBookState => ({
enabled: false,
loading: false,
error: '',
baseUrl: '',
accessToken: '',
sessionId: null,
rows: [],
session: null,
gptConfig: null,
selectedRowId: '',
hasUnsavedEdit: false,
});
const formatSizedValue = (value: number, unit: '%' | 'vh') =>
`${Math.round(value * 100) / 100}${unit}`;
const normalizeSizedValue = (value: unknown, unit: '%' | 'vh', min: number, max: number) => {
if (typeof value !== 'string') return null;
const match = value.trim().match(new RegExp(`^(-?\\d+(?:\\.\\d+)?)${unit}$`));
if (!match) return null;
const numericValue = Number(match[1]);
if (!Number.isFinite(numericValue)) return null;
return formatSizedValue(Math.min(max, Math.max(min, numericValue)), unit);
};
const normalizePersistedLayout = (state: unknown) => {
if (!state || typeof state !== 'object') return {};
const persisted = state as Partial<ReviewModeStore>;
const panelWidth = normalizeSizedValue(
persisted.panelWidth,
'%',
MIN_PANEL_WIDTH_PERCENT,
MAX_PANEL_WIDTH_PERCENT,
);
const panelHeight = normalizeSizedValue(
persisted.panelHeight,
'vh',
MIN_PANEL_HEIGHT_VH,
MAX_PANEL_HEIGHT_VH,
);
return {
...(typeof persisted.isPanelPinned === 'boolean'
? { isPanelPinned: persisted.isPanelPinned }
: {}),
...(panelWidth ? { panelWidth } : {}),
...(panelHeight ? { panelHeight } : {}),
};
};
const withBookState = (
books: Record<string, ReviewBookState>,
bookKey: string,
patch: Partial<ReviewBookState>,
) => ({
...books,
[bookKey]: {
...(books[bookKey] || emptyBookState()),
...patch,
},
});
export const useReviewModeStore = create<ReviewModeStore>()(
persist(
(set, get) => ({
activeBookKey: null,
isPanelVisible: false,
isPanelPinned: false,
panelWidth: DEFAULT_PANEL_WIDTH,
panelHeight: DEFAULT_PANEL_HEIGHT,
books: {},
getBookState: (bookKey) => (bookKey ? get().books[bookKey] || null : null),
setActiveBookKey: (bookKey) => set({ activeBookKey: bookKey }),
setPanelVisible: (visible) => set({ isPanelVisible: visible }),
togglePanelPin: () => set((state) => ({ isPanelPinned: !state.isPanelPinned })),
setPanelWidth: (width) => set({ panelWidth: width }),
setPanelHeight: (height) => set({ panelHeight: height }),
setBookLoading: (bookKey, loading) =>
set((state) => ({
books: withBookState(
state.books,
bookKey,
loading ? { loading, error: '' } : { loading },
),
})),
setBookError: (bookKey, error) =>
set((state) => ({
books: withBookState(state.books, bookKey, { error, loading: false }),
})),
setBookEnabled: (bookKey, enabled) =>
set((state) => ({
activeBookKey: enabled
? bookKey
: state.activeBookKey === bookKey
? null
: state.activeBookKey,
isPanelVisible: enabled
? true
: state.activeBookKey === bookKey
? false
: state.isPanelVisible,
books: withBookState(state.books, bookKey, {
enabled,
...(!enabled ? { hasUnsavedEdit: false } : {}),
}),
})),
setBookData: (bookKey, data) =>
set((state) => ({
books: withBookState(state.books, bookKey, {
...data,
loading: false,
error: '',
}),
})),
selectRow: (bookKey, rowId) =>
set((state) => {
if (state.activeBookKey === bookKey && state.books[bookKey]?.selectedRowId === rowId) {
return state.isPanelVisible ? state : { isPanelVisible: true };
}
return {
activeBookKey: bookKey,
isPanelVisible: true,
books: withBookState(state.books, bookKey, {
selectedRowId: rowId,
}),
};
}),
setBookUnsavedEdit: (bookKey, hasUnsavedEdit) =>
set((state) => ({
books: withBookState(state.books, bookKey, { hasUnsavedEdit }),
})),
updateRow: (bookKey, row) =>
set((state) => {
const current = state.books[bookKey] || emptyBookState();
return {
books: withBookState(state.books, bookKey, {
rows: current.rows.map((item) => (item.id === row.id ? row : item)),
}),
};
}),
clearBook: (bookKey) =>
set((state) => {
const books = { ...state.books };
delete books[bookKey];
return {
books,
activeBookKey: state.activeBookKey === bookKey ? null : state.activeBookKey,
isPanelVisible: state.activeBookKey === bookKey ? false : state.isPanelVisible,
};
}),
}),
{
name: 'readest-review-panel-layout',
storage: createJSONStorage(() => window.localStorage),
partialize: (state) => ({
isPanelPinned: state.isPanelPinned,
panelWidth: state.panelWidth,
panelHeight: state.panelHeight,
}),
merge: (persistedState, currentState) => ({
...currentState,
...normalizePersistedLayout(persistedState),
}),
},
),
);
+13
View File
@@ -1,4 +1,17 @@
const VIEW_TRANSITION_TIMEOUT = 'Transition was aborted because of timeout in DOM update';
export const isViewTransitionTimeoutError = (error: unknown) => {
if (process.env['NODE_ENV'] !== 'development') return false;
return (
error instanceof Error &&
error.name === 'TimeoutError' &&
error.message === VIEW_TRANSITION_TIMEOUT
);
};
export const handleGlobalError = (e: Error) => { export const handleGlobalError = (e: Error) => {
if (isViewTransitionTimeoutError(e)) return;
const isChunkError = e?.message?.includes('Loading chunk'); const isChunkError = e?.message?.includes('Loading chunk');
if (!isChunkError) { if (!isChunkError) {
-4
View File
@@ -132,10 +132,6 @@ export const navigateToLibrary = (
router.replace(`/library${queryParams ? `?${queryParams}` : ''}`, navOptions); router.replace(`/library${queryParams ? `?${queryParams}` : ''}`, navOptions);
}; };
export const navigateToReviewEditor = (router: ReturnType<typeof useRouter>) => {
router.push('/review-editor');
};
// Recovery action when a reader has nothing to display — e.g. all books were // Recovery action when a reader has nothing to display — e.g. all books were
// closed, or a book failed to load in a freshly-opened reader window. // closed, or a book failed to load in a freshly-opened reader window.
// In a dedicated reader window we close the window itself, ensuring the main // In a dedicated reader window we close the window itself, ensuring the main
@@ -1,281 +1,138 @@
# EPUB 审校器长期维护手册 # Readest 内嵌 EPUB 审校维护手册
## 目标与边界 ## 目标与边界
本工具服务于中日双语 EPUB 的网页审校:长篇连续阅读、逐段修改中文译文、标记翻译问题、导出反馈与修订 EPUB。它也提供可选的书架入口 AI 翻译模块,用于对已入书架的日语 EPUB 发起 API 辅助首译,并生成中日双语或纯中文 EPUB。翻译质量规则仍由项目根目录的 `AGENTS.md``rules/translation_rules.md`、当前系列的 `series-style.md` 与术语表共同约束 当前审校器是 Readest 原生阅读功能的增强模式,不再是独立应用。用户在阅读页顶栏点击“校”,直接在 Foliate 正文中选择中文或日文段落,并通过右侧面板完成修改、标记、重翻、反馈和导出
AI 翻译模块是可选辅助首译入口,不替代长期项目里的候选词确认、复审、QA 与正式交付流程。用户在网页中留下的编辑、问题备注、长期规则建议,需要由 Codex 后续读取反馈文件后再沉淀到系列风格或候选术语中。网页术语表编辑是用户显式操作正式术语表的入口,保存时会直接写入配置路径指向的 JSON 文件。 不得恢复或重新引入以下旧入口:
## 数据模型与会话目录 - `/review-editor` 页面
- 书库设置菜单中的独立审校器入口
- 书籍右键菜单中的独立校对/翻译入口
- `tools/epub-review-editor/static/` 独立网页
- `open_editor.ps1``epub-reviewer:dev` 独立启动链路
`--review-root` 是唯一需要备份的运行数据目录。典型结构: 整书翻译是阅读页内嵌审校面板的第二个页签,可复用当前书籍 session、GPT 配置和术语表。独立审校书架、独立目录/检索页面、旧 static 网页和 `/review-editor` 仍不属于内嵌模式,不得恢复。
## 必须保留的链路
以下代码是新内嵌审校功能的依赖,不得作为旧迁移代码删除:
- `src/app/reader/components/ReviewModeController.tsx`
- `src/app/reader/components/ReviewModeToggler.tsx`
- `src/app/reader/components/ReviewPanel.tsx`
- `src/app/reader/hooks/useReviewPanelDrag.ts`
- `src/app/reader/hooks/useReviewPanelFloatingResize.ts`
- `src/services/reviewEditorService.ts`
- `src/store/reviewModeStore.ts`
- `src/app/api/review-editor/launch/route.ts`
- `src-tauri/src/review_editor.rs` 及其 command 注册
- `tools/epub-review-editor/server.py`
- `tools/epub-review-editor/version.py`
- `tools/epub-review-editor/requirements.txt`
Tauri 安装包只需要携带最后三个 sidecar 运行文件。文档、旧静态资源和独立启动器不进入安装包。
## 会话与数据
所有审校 API 调用必须携带当前书的 `session_id`,不能依赖 sidecar 的全局 active session。多窗口或多书同时审校时,不得发生串读、串写。
Next/Tauri 启动器在 review root 的 `.access-token` 中生成并复用随机令牌,启动 sidecar 时通过 `--access-token` 传入。所有 API、资源和下载请求(包括 `/api/version` 健康探测)必须携带 `Authorization: Bearer <accessToken>`;响应、日志、反馈和导出文件不得包含该令牌。CORS 预检不要求令牌,但只允许受信任的本机 origin,并显式允许 `Authorization` 请求头。
典型 session
```text ```text
epub_review_sessions/ <review-root>/
app_state.json
gpt_config.json gpt_config.json
library.json
series_configs/
<series-id>.json
translation_jobs/
<job-id>/
job.json
translations.json
translated_outputs/
*_AI翻译_*.epub
<session-id>/ <session-id>/
source.epub source.epub
extracted/ extracted/
review_state/ review_state/
epub_entries.json
rows.json rows.json
session.json
state.json state.json
session.json
gpt_config.json
feedback/ feedback/
translation_feedback.jsonl translation_feedback.jsonl
feedback_for_codex.md feedback_for_codex.md
exports/ exports/
``` ```
兼容要求: - `rows.json` 保存初始解析结果,不因用户编辑而重写。
- `state.json.edits` 保存逐段修改和标记。
- session 级 GPT 配置保存模型、提示词和术语表路径,但不保存 API Key。
- API Key 只保存在 review root 的本地配置中,任何公开响应都不得包含密钥。
- 导出前必须写回已保存的中文修改,并保持原 EPUB 图片和结构。
- `rows.json` 是初始解析结果,不应因用户编辑而重写。 ## API 契约
- `state.json.edits` 保存用户修改与标记,字段缺省时必须能回退到原始译文。
- `state.json.reading_position` 保存当前阅读位置,字段包括 `mode``scope``chapter_id``part_id``image_item_id``row_id``scroll_top``offset``updated_at`;缺省时必须回退到首页/首章。
- `session.json``app_state.json` 只保存会话定位信息。
- `gpt_config.json` 只属于本地运行环境,不写入 EPUB、不写入导出文件、不通过 API 回传密钥。
- `gpt_config.json` 可保存本地提示词设置与术语表路径,但仍不得写入 EPUB 或导出文件。
- `library.json` 是由 EPUB 元数据和会话状态生成的书架索引,可重建;字段包括书籍、系列、作者、出版社、语言、封面和更新时间。
- `series_configs/<series-id>.json` 保存同系列共享的 `glossary_path``translation_prompt``format_prompt``character_prompt`,不得保存 API Key、Base URL 或模型密钥。
- `translation_jobs/<job-id>/job.json` 保存整书 AI 翻译任务状态、进度、输出路径和日志;不得保存 API Key。
- `translated_outputs/` 保存 AI 翻译生成的 EPUB;生成后应创建独立 session,使 `/api/sessions` 和书架自然可见。
- Readest 迁移阶段的本地入口委托 `tools/epub-review-editor/open_editor.ps1``/api/review-editor/launch` 创建 `.venv`、安静检测 Flask 依赖、缺失时自动安装依赖、复用同版本已运行服务或启动 `server.py --daemon`;该入口不得硬编码用户私有路径。
## 目录与插图 内嵌模式至少依赖:
结构接口 `/api/structure` 应保持向后兼容: - `GET /api/version`
- `POST /api/session/from-path`
- `POST /api/upload`(本地 `dev-web` 使用 multipart 上传,内嵌调用必须传 `activate=false` 与稳定 `book_key`
- `/api/session/<session_id>/translation-source``/translation-plan`
- `/api/translation/defaults``/start``/jobs` 及任务控制端点
- `/api/gpt/models``/downloads/translation/...`
- `GET /api/session?session_id=...`
- `GET /api/rows?session_id=...`
- `POST /api/row/<row_id>?session_id=...`
- `POST /api/row/<row_id>/retranslate?session_id=...`
- `GET|POST /api/gpt/config?session_id=...`
- `GET|POST /api/glossary?session_id=...`
- `POST /api/feedback?session_id=...`
- `POST /api/export?session_id=...`
- 导出、反馈和 EPUB 资源下载路由
- text chapter: `kind: "text"``parts``row_count``touched_count``marked_count` 中文 HTML 清洗必须允许必要的 `ruby/rb/rt/rp/mark/span` 内联标签并移除脚本。保存响应应返回清洗后的 `current_html`,前端只能用清洗结果更新 store 和正文预览。
- image item: 优先作为 text chapter 的 `items` 子项返回,字段为 `kind: "image"``images``parts: []``row_count: 0`;没有所属文字章时才保留顶层 image chapter
- 统计字段同时保留总数和细分:`chapter_count``text_chapter_count``image_chapter_count``top_level_image_chapter_count``image_count``asset_count`。其中 `image_chapter_count` 表示目录里的图片条目数,不一定是顶层章节数
- part 标题默认使用 `part0000` 形式,真实标题可保留在 `source_title`
- 插图通过 `/api/asset/<entry>` 读取,必须继续使用 `extracted_path()` 防止路径穿越;只把 raster 图片纳入 `/api/structure``/api/asset` 白名单,不能任意读取解包目录文件
章节来源优先级: sidecar 只能监听本机 loopbackCLI 必须拒绝 `0.0.0.0``::` 等非 loopback 地址。Readest/Tauri 本机来源可获得受限 CORS;本地 `dev-web` 端口可能动态变化,应按 loopback origin 校验而非固定端口。不得使用 `Access-Control-Allow-Origin: *`。Cross-Origin Resource Policy 必须允许 Readest 页面直接读取 API 与下载资源。loopback 与 CORS 不是请求鉴权的替代品,任何有副作用的端点都不得绕过 Bearer 令牌。
1. EPUB 内部目录页的中文条目 桌面内嵌模式通过本机 EPUB 路径创建 session;本地 `dev-web` 中书籍位于 IndexedDB,不能把逻辑路径交给 Python,必须读取当前 `File` 后上传到 `/api/upload`。上传必须携带 Readest 书籍 hash 作为稳定 `book_key`,让同一本书再次开启审校时复用原 session。部署版 Web 没有本地 Node/Python/文件系统链路,当前不在支持范围内。
2. `nav.xhtml` / `toc.xhtml`
3. 正文 `<h1-6>` 标题
4. 文件名 fallback
不要把正文首段或第一句对白作为章节标题 fallback。宁可显示 `part0000`,也不要让目录被正文内容污染。 ## 前端验收标准
## 阅读与侧栏交互 - 审校模式关闭时,原阅读、书库和其他功能行为不变。
- 点击顶栏“校”能启动或复用 sidecar,并注册当前 EPUB session。
验收口径: - 只有中日双语段落可选择;点击或文本选择任一语言都打开同一条审校记录。
- 关闭审校模式后,正文高亮、事件监听和临时预览全部清理。
- 顶栏左上角必须保留“书架”入口;书架是独立页面,列出 `--review-root` 下历史打开过的审校书籍/会话 - 右侧面板不显示独立段落列表;API、提示词和术语表位于内容末尾
- 点击书架条目必须通过会话打开流程进入对应审校页面,并恢复该书上次阅读位置;从审校页进入书架或切换书籍前必须保存当前阅读位置并沿用未保存编辑保护 - 标签表示检索分类,本段备注记录当前问题,长期规则建议用于后续人工沉淀,三者不得混为一个字段
- 没有历史书籍时,书架显示空状态并提供“打开新的 EPUB”入口;开始页/书架页顶栏保持正常显示,不套用审校页沉浸隐藏 - 译文预览默认折叠;注音和注释按钮在当前选区插入可编辑标签并自动展开预览
- 书架应按 EPUB 元数据建立分类:全部书籍、同系列、作者、出版社、语言;同系列分类使用稳定 `series_id`,显示书籍数量 - 保存按钮位于中文编辑区附近,保存后正文立即反映清洗后的译文
- 书架主区域以封面网格为主,不用横向信息卡替代封面;无封面时使用稳定 fallback,不出现破图或布局跳动 - 固定时面板挤开正文,切换固定状态或调整固定宽度后恢复原阅读位置
- 鼠标悬停或键盘 focus 到封面时,封面变暗并显示书名、系列/作者/卷数、审校段数、最近更新时间,以及“校对”“翻译”“删除”按钮;点击“翻译”或“删除”不得触发“校对” - 浮动时面板不挤开正文,可横向和纵向拖动,并可调整宽度和高度
- 书架删除默认是软删除:只在 session manifest/state 中写入 `hidden` / `deleted_at`,从 `/api/sessions``/api/library` 隐藏,不删除原始 EPUB、导出 EPUB 或反馈文件;删除 active session 时必须清空 `active_session_id` - 面板变窄时文字和控件换行,所有内容仍可滚动到并完整操作
- 选中同系列分类后,必须能打开“系列设置”,设置该系列共享的术语表路径、翻译内容提示词、格式提示词和角色口吻提示词 - 移动窄屏使用抽屉或底部面板,不遮挡主要操作
- 初始进入审校时,左侧侧栏隐藏,正文占主要空间。
- 顶部“目录”只打开目录,“检索”只打开搜索/段落列表,不合并成一个常驻面板。
- 目录可展开/收起章节,插图作为所属章节的次级目录条目可点击阅读,文字 part 可点击定位。
- 点击目录章节、part 或插图条目后,侧栏不得自动收起;用户仍可点“隐藏”或按 `Escape` 关闭。
- 检索必须保留“当前范围 / 全书全局”切换;当前范围随当前章节或 part 更新,全局检索可跨章节返回并跳转。
- 阅读模式不显示每段编号、快速编辑、快速标记按钮。
- 阅读区提供默认、护眼、夜间背景切换,并允许调整字号与行距;设置可保存在浏览器本地,不要求写入 EPUB 或 session。
- 审校页顶部标题栏与阅读标题栏默认收起,鼠标移到页面上方或键盘焦点进入标题栏时再显示;开始页保持正常显示;展开后不得遮住侧栏“隐藏”或审校工具“关闭”等面板操作。
- 鼠标停留在顶栏或阅读标题栏时,沉浸顶栏不得因自动计时过早收起;前端脚本和样式发布时应带版本参数或等效缓存失效机制,避免浏览器继续执行旧入口代码。
- 阅读区“上一节 / 下一节”必须按 spine/目录内的小章节顺序移动,覆盖文字 part 与插图条目,不得只跳顶层大章节。
- 点击正文段落打开右侧精修与重翻面板。
- 右侧面板打开或关闭导致正文宽度变化时,应恢复用户原来正在看的段落位置。
- 刷新页面、重启服务或从已有会话重新打开 EPUB 时,应恢复到上次阅读的章节/part/插图、段落和滚动位置。
- 移动窄屏下,侧栏和右侧面板应以抽屉/底部面板方式显示,不遮死主要操作。
## 编辑、反馈与导出
保存本段时必须:
- sanitize 中文 HTML,但允许 `ruby/rb/rt/rp/mark/span` 等必要内联标签。
- 记录 `before_cn_html``after_cn_html`、问题类型、严重程度、标签、备注、长期规则建议。
- 更新 `feedback/translation_feedback.jsonl`
生成反馈时必须:
- 写入 `feedback_for_codex.md`
- 只汇总已编辑、已标记或有备注的段落。
- 保留日文原文、修改前、修改后,便于后续翻译规则复盘。
导出 EPUB 前必须先写回已保存的译文修改,插图文件保持原样。
## GPT 重翻
GPT 能力是可选增强,不应影响离线审校。
安全与配置:
- 支持 OpenAI 兼容的 `base_url``model``api_key`
- 支持单独设置 `translation_prompt``format_prompt``character_prompt`;缺省时必须回退到内置预设。
- API Key 优先读取 `gpt_config.json`,也支持环境变量 `OPENAI_API_KEY`
- `/api/gpt/config` 不得返回 API Key。
- `/api/gpt/config` 可以返回提示词与预设,但不得返回密钥。
- `/api/glossary` 读取和保存配置路径指向的 `mingcibiao.json`;默认路径应由当前打开 EPUB 对应的系列目录或用户保存的配置决定,不应在通用维护口径中硬编码某个具体系列。
- 未保存配置时,可在项目根目录下唯一的一级系列目录 `mingcibiao.json` 中自动推断默认术语表;若无法唯一推断,则回退到项目根目录 `mingcibiao.json` 并允许用户在网页中显式设置。
- 术语表保存应保持 JSON 对象结构:key 为源语或匹配词,value 为译名,可继续使用 `#` 作为备注分隔。
- 重翻时必须实时读取术语表,按目标段落日文纯文本、日文 HTML 与 ruby/rt 命中相关条目,不得只在提示词里泛泛要求“遵守术语表”。
- 未配置 Key 时,重翻接口应返回可读错误,不影响编辑保存。
- 重翻请求不得临时覆盖 `base_url``model` 后继续复用已保存密钥;要更换端点或模型必须先保存配置,避免把密钥发往未确认端点。
单段重翻流程:
1. 用户点击阅读段落,打开右侧面板。
2. 用户可填写额外重翻要求。
3. `/api/row/<row_id>/retranslate` 使用提示词、目标语句、实时命中的术语表条目、同文件前后一段少量上下文、角色口吻提示生成候选。
4. 候选只显示在面板里,不自动覆盖。
5. 用户点击“应用重翻”后写入编辑框,再点击保存才进入审校记录。
6. 候选事件写入 jsonl,便于后续根据精修反馈优化提示词;当前版本不自动改写提示词。
术语实时命中实现口径:
- 每次 `/api/row/<row_id>/retranslate` 请求都必须读取当前配置的 glossary 文件,不使用服务启动时的缓存作为唯一来源。
- 单段重翻的术语命中范围只包括目标段落日文纯文本和日文 HTML 中的可见文本/ruby/rb/rt 信息;当前中文译文不能反向触发新术语,避免把旧误译或幻觉带入 prompt。
- 传给 GPT 的目标日文 HTML 只保留必要内联标签和 ruby 结构,不携带 class/style/data-* 等属性,避免属性值污染术语命中或 prompt。
- 前后一段上下文只用于语气、指代和连贯性参考,不参与单段重翻的术语命中;单段重翻的 `glossary_matches` 只由目标段落自身触发。
- 正式术语表 value 如包含 `#``#` 前是指定译名,`#` 后只是备注,不写入正文。
- 未命中术语时,重翻 prompt 不得塞入无关术语兜底规则;例如只有原文出现 `ファルシオン``Falchion` 时,才可命中“大砍刀”。
- 读取旧版保存提示词时,若只剩“优先服从给定名词表”这类泛泛规则,应回退到当前内置预设,避免用户继续沿用已淘汰的全局术语设计。
- 任何术语特例都不得写入全局 prompt。Falchion / ファルシオン / 大砍刀 只能由 `glossary_matches` 动态生成;未命中时,重翻 messages 中不得出现这些字符串。
- 命中结果必须随候选事件写入 jsonl,字段至少包含 `glossary_path``glossary_matches`;未命中时记录空数组,便于排查 prompt。
Prompt 第一性原理:
- prompt 必须只携带目标段落、少量上下文、命中术语、系列风格摘要和输出格式;单段重翻不携带完整术语表。
- prompt 不得只泛泛要求遵守术语表,必须列出本段命中的具体术语;未命中的术语不得进入提示。
- prompt 必须要求只输出修订后的中文 HTML,不输出说明。
- prompt 必须要求不合并、不拆分段落,不删除段落编号或必要标签。
- prompt 必须要求保留有意义 ruby 为真实 `<ruby><rb>...</rb><rt>...</rt></ruby>`
Prompt 硬规则:
- 输出简体中文轻小说文风。
- 意义优先,不贴日语语序硬译。
- 不合并、不拆分段落。
- 有意义 ruby 必须保留为真实 `<ruby><rb>...</rb><rt>...</rt></ruby>`,不要改成括号。
- 标点统一为简中出版格式:对话引号用「」,省略号用……,破折号用——,问号用?,感叹号用!,问叹连用用?!/!?。
## 整书 AI 翻译
整书 AI 翻译从书架页进入,只处理已经上传或打开过、因此已有 session 的 EPUB。它必须作为后台任务运行,前端只负责创建任务、轮询进度、展示日志和打开输出 session。
后端接口口径:
- `GET /api/translation/defaults` 返回当前公开 GPT 配置、默认提示词、默认术语表路径、默认范围和输出模式,不返回 API Key。
- `GET /api/session/<session_id>/translation-source` 只读取指定 session,统计可翻译日文正文块和样例,不切换 active session;必须同时返回诊断统计,说明审校行数、可翻译正文块、图片、目录、空白块不是同一口径。
- `GET /api/library``GET /api/sessions` 返回按 EPUB 元数据生成的书架索引、系列列表和封面 URL。
- `GET/POST /api/series/<series_id>/config` 读写同系列翻译配置;空字段表示未设置,不能自动写入全局默认 prompt。
- `POST /api/translation/start` 创建后台任务并快速返回 `job_id`;请求不得临时覆盖 `base_url``model` 后复用已保存密钥,要更换端点或模型必须先保存配置。
- `GET /api/translation/jobs/<job_id>` 返回任务状态、进度、日志、输出 EPUB 和输出 session id,不返回 API Key。
翻译输入与提示词:
- 日语源 EPUB 需要新的源段落抽取器,不复用只识别双语灰字的 `build_rows()`
- 翻译源抽取器至少覆盖 `<p>` 与正文 `<h1-h6>`;日文上下文中的纯汉字/标点短句应进入翻译队列,纯图片、空白和目录页不进入。
- 默认范围必须是前 N 段试译,当前默认 20 段;全书翻译必须由用户显式选择。
- 每段请求只携带目标段落日文 HTML、前后一段少量上下文、该段实时命中的术语、翻译提示词、格式提示词与角色口吻提示词。
- 不得把完整术语表塞进 prompt;未命中的术语不得进入 prompt。
- 若同系列配置存在,整书翻译优先使用同系列的术语表路径和提示词;API Key、Base URL、模型仍使用全局 API 配置。
- 输出只接受当前段中文 HTML 片段,随后进行中文标点规整、HTML sanitize 与术语 ruby 括号形式修复。
输出与书架:
- `bilingual` 模式在原日文 `<p>` 后插入中文 `<p>`,并把原日文段落标为灰色且标记为 source,方便进入现有双语审校器逐段审校。
- 如果输入已经是中日双语 EPUB,翻译源抽取必须识别显式灰色/source 源段与下一段中文译文的 pair;`bilingual` 模式应替换已有中文层,不得追加第二个中文层;`translated` 模式应输出新的中文层并移除对应日文源层。
- 如果历史 session 已经出现“日文源层 + 中文层 + 旧中文灰色源层 + 新中文层”的四层坏结构,进入翻译源统计、整书翻译或 rows 重建前应自动清理为“日文源层 + 第一个中文层”,避免旧中文继续被当作日文源文。
- `translated` 模式用中文替换原日文段落;该输出会加入书架,但由于没有日中成对段落,可能没有逐段双语审校行。
- 图片、样式、OPF、spine 和非正文文件应原样保留。
- 翻译失败的单段必须写入可见占位和任务日志,不能让整本输出静默缺段。
- 输出 EPUB 创建 session 时不应自动切换用户当前 active session;只有用户点击“打开输出审校”才切换。
任务安全与恢复:
- 后台 job 不能在网络调用期间持有全局锁;只在写状态文件时短暂加锁或原子写入。
- `job.json` 和中间 `translations.json` 必须原子写入,避免前端轮询读到半截 JSON。
- 日志、状态、EPUB 元数据和反馈文件不得包含 API Key。
- 任务失败时应将状态置为 `failed` 并写清楚错误,不能让前端永久停在 running。
- 后续如加入暂停/取消/恢复,必须保持现有 `job.json` 字段向后兼容。
## 版本规则 ## 版本规则
版本号使用 `version = "0.1.0"` 格式 版本号位于 `version.py`
| 层级 | 触发条件 | - PATCHbug 或兼容性小修
| --- | --- | - MINOR:向后兼容的能力或 API 扩展
| PATCH | 修复 bug 或兼容性小修 | - MAJOR:删除入口、破坏 API 或改变持久化行为
| MINOR | 新增向后兼容能力、API 字段或可选配置 |
| MAJOR | 破坏兼容的 API、配置或行为变更 |
发版时必须同步: 发版时同步 `version.py``README.md` 和本文件。`1.0.0` 删除了旧独立 UI 和启动方式,是破坏兼容的边界收敛。`1.0.1` 修复本地 `dev-web` 上传/下载、会话隔离、译文恢复与空译文预览,并增加未保存修改提醒、动态端口 CORS 和 loopback 监听约束。`2.0.0` 为所有 sidecar 请求增加 Bearer 令牌,并强制书籍审校 API 显式携带 `session_id`,不再回退全局 active session。`2.1.0` 在当前安全契约上新增内嵌整书翻译、计划、任务控制、断点续跑和安全输出下载,翻译状态为 `untranslated``translating``translated``failed`
- `version.py`
- `static/index.html` 顶部版本徽标 fallback
- `README.md` 当前版本与版本说明
- 必要时同步 `MAINTENANCE.md` 中的维护口径
版本文档同步边界:
- 审校器自身行为、API、配置、前端交互变化,同步本文件。
- 会影响整个翻译项目的规则变化,同步项目根目录 `AGENTS.md``rules/translation_rules.md`,不要只写在审校器维护手册中。
- 对外最小包内容、启动方式或发布说明变化,同步 README。
## 回归清单 ## 回归清单
轮改动至少检查 次修改至少执行
- `python -B -m py_compile tools/epub-review-editor/server.py tools/epub-review-editor/version.py` ```powershell
- `node --check tools/epub-review-editor/static/app.js` python -B -m py_compile tools/epub-review-editor/server.py tools/epub-review-editor/version.py
- `powershell -NoProfile -ExecutionPolicy Bypass -File apps/readest-app/tools/epub-review-editor/open_editor.ps1` 能启动服务并打开书架/上传页 python tools/epub-review-editor/test_inline_review_session_scope.py
- Readest dev-web 中 `POST /api/review-editor/launch` 必须带 `x-readest-review-editor-launch: 1`,只能从本机入口调用;成功时返回 sidecar URL、版本和 review root pnpm exec vitest run src/__tests__/services/reviewEditorService.test.ts src/__tests__/store/review-mode-store.test.ts src/__tests__/components/ReviewModeController.test.ts src/__tests__/components/ReviewModeToggler.test.tsx
- 重复执行一键入口时优先复用同版本已运行服务;如需新起服务,端口探测不得允许多个服务同时监听同一端口 pnpm exec tsgo --noEmit --pretty false
- `git diff --check` $env:READEST_REVIEW_E2E='1'; pnpm exec playwright test e2e/tests/review-mode.spec.ts --project=chromium
- `/api/version` ```
- `/api/session`
- `/api/structure` 涉及 Tauri 配置或 Rust 启动器时继续执行:
- `/api/gpt/config`
- 在 Readest dev-web 的 `/review-editor` 中嵌入本地 sidecar 时,iframe 不应出现 `ERR_BLOCKED_BY_RESPONSE`;HTML 入口应带限定本机 Readest 的 `frame-ancestors`,并保留 `Cross-Origin-Embedder-Policy: require-corp``Cross-Origin-Resource-Policy: cross-origin` ```powershell
- 首个插图 `/api/asset/...` 返回 200 且浏览器可显示 pnpm fmt:check
- 非白名单或非 raster asset 不能被 `/api/asset/...` 读取 pnpm clippy:check
- 目录按钮、检索按钮、隐藏侧栏、点击段落打开右侧面板 pnpm test:rust
- 阅读段落下方没有编号和“快速编辑/快速标记” ```
- 未配置 API Key 时重翻返回清晰错误
- 配置保存后 `/api/gpt/config` 不包含密钥字段 人工验收:打开桌面端和 Web 端,在真实中日双语 EPUB 中完成开启审校、选段、编辑、注音/注释、保存、重翻、反馈、导出、固定/浮动切换、拖动和宽高调整。确认书库与设置菜单中不再出现旧独立审校器入口。
- `/api/glossary` 能读取术语表;新增、修改、删除条目后保存,再读取内容一致
- 重翻候选事件包含 `glossary_path``glossary_matches`
- 无关段落的重翻 messages 不得包含 `大砍刀``Falchion``ファルシオン`;含 `ファルシオン` 或 ruby `Falchion` 的段落必须命中“大砍刀”
- 顶栏复杂操作收敛在“审校工具”入口;未选择段落时仍可配置 AI、术语表并执行反馈与交付,段落编辑/重翻按钮禁用
- `/api/reading-position` 能保存/读取位置;刷新或重启后回到上次阅读处
- 阅读外观默认/护眼/夜间、字号、行距切换即时生效,刷新后仍保留;夜间模式下正文、原文、按钮和侧栏文本可读
- 审校页顶部栏和阅读标题栏默认不遮挡正文,鼠标移到页面上方后可操作;开始页顶栏不隐藏;目录侧栏和审校工具打开时,其顶部按钮仍可直接点击
- 鼠标停留在顶栏按钮区域时,书架/打开 EPUB/审校工具按钮能连续点击,不会在点击前自动收起;浏览器重新加载后应使用当前版本 app.js/style.css
- “上一节 / 下一节”在相邻 part 和章节内插图之间移动,不再按顶层大章节跳转
- 顶栏左上角“书架”能打开独立书架页;书架列出历史会话,空状态可进入上传页,点击书籍可进入对应审校页并恢复阅读位置
- 书架左侧分类可按全部书籍、同系列、作者、出版社、语言筛选;右侧封面网格在桌面端悬停变暗显示信息和“校对/翻译”,移动端不依赖 hover 才能操作
- `GET /api/sessions``GET /api/library` 返回 `library``series`、书籍元数据、`series_id``cover_url`,并写入可重建的 `library.json`
- `GET/POST /api/series/<series_id>/config` 能读写同系列术语表和提示词;保存后同系列其他书进入翻译页会自动套用
- 书架卡片“翻译”按钮能进入独立翻译页,且不会触发卡片的“进入审校”
- `/api/translation/defaults` 不返回 API Key,提示词和术语表路径可读取
- `/api/session/<session_id>/translation-source` 能统计日语 EPUB 可翻译正文块和诊断字段;非日语或无正文时返回清晰空状态;审校行数、源正文块数、试译 limit 不得在 UI 上混为一个“段落数”
- 翻译页默认只显示源书名和“可翻译正文块 N 个”,不保留冗余段落预览、诊断 note 或大批统计 chip 的可见入口;诊断字段可保留在 API 中供排查
- 未配置 API Key 时,`/api/translation/start` 返回清晰错误,不影响阅读、编辑、导出和单段重翻
- 启动整书翻译任务后,`/api/translation/jobs/<job_id>` 可看到 pending/running/completed/failed、总段数、已完成数、当前文件和日志
- 默认翻译范围是前 20 段试译;全书翻译必须由用户显式切换并确认
- 整书翻译每段只注入该段源文/ruby 命中的术语,不注入无关术语或全表
- 整书翻译完成后,输出 EPUB 写入 `translated_outputs/` 并自动出现在书架;中日双语输出打开后 `row_count > 0`
- 已双语 EPUB 再次执行整书翻译时,输出不得出现“旧中文译文 + 新中文译文”重复层;审校行 `jp_text` 不得变成中文旧译文
- 纯中文输出可以加入书架,且文档说明其不适合逐段双语审校
- 离开翻译页、返回书架或打开输出审校时,前端进度轮询会停止
- 书架卡片“删除”按钮能软删除会话、刷新书架、清空 active session(如删除当前书),且不会删除原始 EPUB 文件
@@ -1,51 +0,0 @@
# Readest EPUB 审校器迁移记录
## 当前定位
本目录是从长期翻译项目的 EPUB 审校器迁移进 Readest 的第一阶段基座。当前保留原 Python/Flask 后端与静态审校 UI,Readest 负责提供入口、启动本地 sidecar,并在 `/review-editor` 页面中承载审校器。
## 入口
- Readest 书架页菜单:`Settings Menu -> Advanced Settings -> EPUB 审校器`
- Next 路由:`/review-editor`
- 本地启动 API`POST /api/review-editor/launch`
- 开发脚本:`pnpm epub-reviewer:dev`
当前 Readest 页面内自动启动仅限本地 `dev-web` 开发环境。生产 Tauri 包会静态导出 Next 页面,不能依赖 Next API route 启动本地进程;正式迁移到桌面包前,需要改为 Tauri desktop command 或打包 sidecar。
Readest dev-web 页面带跨源隔离头;本地 sidecar 会为 HTML 入口返回限定本机 Readest 来源的 `frame-ancestors`,并发送 `Cross-Origin-Embedder-Policy: require-corp``Cross-Origin-Resource-Policy: cross-origin`,否则 Chromium 会把 iframe 请求拦截为 `ERR_BLOCKED_BY_RESPONSE`
## 运行数据
默认审校数据写入:
```text
apps/readest-app/epub_review_sessions/
```
可用环境变量覆盖:
```text
READEST_REVIEW_ROOT=<absolute path>
```
该目录包含书架索引、GPT 配置、翻译任务、审校 session、反馈和导出文件,必须保持在 git 之外。
## 迁移边界
当前阶段不重写审校器业务逻辑,不把 Flask API 立即拆成 Next API 或 Tauri command。这样可以先保留已验证的功能闭环:书架、上传、双语审校、术语表、GPT 重翻、整书 AI 翻译和导出。
后续原生化顺序建议:
1. 把启动 sidecar 从 Next dev API 迁到 Tauri desktop command,生产桌面包不依赖 Next API route。
2. 复用 Readest 的书库与文件选择能力,把单书“发送到审校器”入口接到 EPUB 文件路径。
3. 逐步把书架/审校/翻译 UI 拆成 React 组件,最后再替换 Flask API。
## 验收标准
- 本地 `dev-web` 环境下,`/review-editor` 能从 Readest 书架菜单进入。
- 首次进入会创建 `.venv`、安装 Flask 依赖并启动 `server.py --daemon --no-browser`
- 已运行同版本审校器时优先复用 `localhost:5177+` 端口,不重复启动。
- iframe 中能显示审校器书架/上传页。
- 审校数据目录不会进入 git。
- 原审校器 `server.py` 能通过 Python 编译检查,迁移入口 TypeScript 能通过类型检查。
@@ -1,218 +1,107 @@
# 双语 EPUB 审校编辑器 # Readest 内嵌 EPUB 审校后端
当前版本:`0.13.2` 当前版本:`2.1.0`
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件 本目录只为 Readest 阅读页内的审校模式提供本地 sidecar API。打开 EPUB 后,点击阅读页顶栏的“校”按钮即可启动或复用服务,并在原阅读界面中选择中日文段落进行审校
## 版本规则 ## 当前边界
维护版本号使用 `version = "0.1.0"` 这种语义化格式 保留的用户入口只有 Readest 阅读页内嵌审校模式。旧版独立审校器已经删除,包括
| 层级 | 触发条件 | - `/review-editor` 独立页面
| --- | --- | - 书库设置中的“EPUB 审校器”入口
| PATCH | 修复 bug 或兼容性小修 | - 书籍右键菜单中的独立校对和翻译入口
| MINOR | 新增向后兼容能力、API 字段或可选配置 | - `static/` 浏览器前端、独立启动脚本和开发脚本
| MAJOR | 破坏兼容的 API、配置或行为变更 |
本次从 `0.1.0` 升到 `0.2.0`,因为新增了独立上传、会话管理、下载接口与可选 `--host` 配置,且保持原命令行 EPUB 路径用法兼容 `server.py``version.py` `requirements.txt` 仍由桌面端打包,因为内嵌审校模式依赖它们完成 EPUB 解析、会话保存、重翻、术语表、反馈和导出。`src/app/api/review-editor/launch` 也仍用于本地 `dev-web` 启动 sidecar,不是旧独立页面
`0.2.1` 修复目录章节识别:优先读取 EPUB 内部目录页的中文标题,并把目录锚点之间的多个正文 part 合并为同一章;左侧目录增加章节展开/收起按钮 阅读页右侧面板提供“逐段审校 / 整书翻译”两个页签。整书翻译支持保持 XHTML 文件边界的计划预览、试译或全书任务、断点、暂停、恢复、取消和带令牌的输出下载;旧独立网页、独立书架和 `/review-editor` 不会恢复
`0.3.0` 新增插图阅读能力:目录中会把 EPUB spine 里的插图页作为独立章节显示,阅读模式可直接查看图片;正文子章节标题统一使用 `part0000` 这类规整编号,方便长篇审校快速定位。 ## 使用方式
`0.4.0` 新增阅读型交互与 GPT 重翻:左侧目录/检索改为按需打开的独立侧栏;阅读模式去掉每段下方操作按钮,点击段落直接打开右侧精修与重翻面板,并在面板挤开正文后恢复原阅读位置;本地可配置 OpenAI 兼容 GPT API,对单段生成重翻候选,确认后再应用到编辑框。 桌面端:
`0.4.1` 修复后端/API 安全与结构细节:章节内插图与文字按 spine 顺序 flush,结构统计补充 text/image 细分字段;内部目录识别增加目录页/链接密度判定;结构和 `/api/asset` 仅允许已识别的 raster 图片并加 `nosniff`;重翻接口不再允许单次请求临时覆盖 Base URL 或模型后复用已保存密钥。
`0.5.0` 改进长篇目录与检索:目录章节标题不再用正文首段猜测,章节内插图作为次级条目挂在所属章节下,阅读时可按原 spine 顺序穿插显示;点击目录不会自动收起侧栏,桌面端正文会为常驻侧栏让位;检索新增“当前范围 / 全书全局”切换。
`0.6.0` 新增 GPT 提示词设置:可在网页中单独配置“翻译内容提示词”“格式与标点提示词”“角色状态与口吻提示词”,并提供默认预设;单段重翻只携带目标语句、相关名词表、少量前后文和角色口吻提示,候选输出会进行中文标点规整与术语 ruby 括号形式修正。
`0.7.0` 新增实时术语表能力:GPT 重翻会从配置的 `mingcibiao.json` 实时读取命中术语并写入提示词;网页中可查看当前术语表路径、搜索术语,新增、修改、删除条目并保存,保存后的术语会立即用于下一次重翻。
`0.8.0` 新增阅读位置恢复:阅读器会自动记录当前章节/part/插图、当前可见段落和滚动位置;重新打开服务、刷新页面或切回已有会话时,会回到上次阅读的位置。
`0.9.0` 收敛重翻与审校工具入口:顶栏复杂操作统一到“审校工具”;单段重翻只携带目标段落真实命中的术语,移除无关段落里的 Falchion/大砍刀全局注入,并按第一性原理精简 prompt。
`0.10.0` 新增阅读外观与沉浸阅读:阅读区可切换默认、护眼、夜间背景,并保存字号与行距;审校页顶部栏和阅读标题栏默认收起,鼠标移到页面上方时再显示;阅读区上一节/下一节按 part 或插图小章节移动。
`0.11.0` 新增书架页面:顶栏左上角提供“书架”入口,集中列出历史打开过的审校书籍;点击书籍可直接进入对应审校页面,并继续沿用该书的上次阅读位置。
`0.11.1` 修复沉浸顶栏在鼠标停留时过早收起的问题,并给前端静态资源加入版本参数,避免浏览器缓存旧脚本导致新入口不可用。
`0.12.0` 新增书架入口的 AI 翻译模块:可从书架选择日语 EPUB,设置 OpenAI 兼容 API、提示词、术语表、翻译范围与输出模式,后台显示翻译进度;完成后生成的中日双语或纯中文 EPUB 会自动加入书架。
`0.12.1` 新增一键启动入口:项目根目录提供 `打开EPUB审校器.cmd`,会自动准备本地 Python 环境、安装依赖并以前台浏览器打开书架/上传页。
`0.12.2` 修复一键启动入口首次运行时依赖检测会被 Flask 缺失的 traceback 中断的问题;现在会先安静检测依赖,缺失时自动安装,再启动网页工具。重复打开入口时会优先复用同版本已运行服务,并修正端口探测,避免多个服务同时监听同一端口。
`0.13.0` 新增元数据书架与同系列配置:书架按 EPUB 元数据建立 `library.json`,左侧提供系列、作者、出版社、语言分类,封面悬停显示书籍信息以及“校对/翻译”入口;同系列可共享术语表路径、翻译内容提示词、格式提示词和角色口吻提示词。AI 翻译源统计改为正文块诊断,区分审校行数、可翻译日文正文块、图片、目录和空白块,并补翻日文标题与日语上下文里的短句,避免把默认试译范围误认为正文被删。
`0.13.1` 修复整书 AI 翻译重复正文块:已是中日双语的 EPUB 再翻译时会识别旧的日文源文/中文译文 pair,并替换中文层,不再把旧译文当成源文追加新译文;审校行识别也收敛为只接受显式灰色/source 标记。翻译页删除冗余段落预览和诊断提示,统一使用“正文块”口径,书架封面悬停菜单新增“删除”,可从书架软删除会话且不删除原始 EPUB。
`0.13.2` 修复迁移到 Readest dev-web 后被浏览器阻止 iframe 加载的问题:本地 sidecar 为 HTML 入口添加限定本机 Readest 的 `frame-ancestors`,并通过 `Cross-Origin-Embedder-Policy: require-corp``Cross-Origin-Resource-Policy: cross-origin` 兼容 Readest 的跨源隔离页面。
## 独立安装
这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub-review-editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖:
```powershell ```powershell
python -m venv .venv pnpm --filter @readest/readest-app tauri dev
.\.venv\Scripts\python -m pip install -r requirements.txt
``` ```
Linux/macOS Web 开发端
```bash
python3 -m venv .venv
./.venv/bin/python -m pip install -r requirements.txt
```
## 在 Readest 中启动
Readest 迁移阶段的推荐入口:
```powershell ```powershell
pnpm dev-web pnpm --filter @readest/readest-app dev-web
``` ```
然后打开: 在 Readest 中打开 EPUB,再点击阅读页顶栏“校”。桌面端通过 Tauri command `launch_epub_review_editor` 启动 sidecar,并用本机路径注册 session;本地 Web 开发端通过 `POST /api/review-editor/launch` 启动,再以 `POST /api/upload` 上传 IndexedDB 中的当前 EPUB,且使用 `activate=false` 保持多书会话隔离。启动器会在 review root 生成仅供本机使用的随机访问令牌;前端后续请求必须同时携带 `Authorization: Bearer <accessToken>` 和当前书的 `session_id`
该 Web 链路仅支持本机 `dev-web`,依赖本机 Next.js Node 进程和 Python sidecar。部署到 Cloudflare 等托管环境的 Readest Web 不支持当前内嵌审校器。
本地端到端回归可设置 `READEST_REVIEW_E2E=1` 后运行 `pnpm exec playwright test e2e/tests/review-mode.spec.ts --project=chromium`。测试会即时生成不含版权内容的最小双语 EPUB,并验证开启、保存、重开续审和导出下载。
## 审校能力
- 在 Foliate 原阅读正文中识别并选择中日双语段落
- 修改中文 HTML,并快捷插入 ruby 注音或内联注释
- 记录问题类型、严重程度、标签、本段备注和长期规则建议
- 配置 OpenAI 兼容 API、提示词和正式术语表
- 对当前段落发起 API 重翻,并手动应用候选译文
- 生成结构化反馈并导出修订后的 EPUB
- 固定面板时挤开正文并恢复阅读位置;浮动时可自由移动和调整宽高
## 数据目录
默认审校数据位于应用数据目录下的 `epub_review_sessions/`。开发环境也可以通过 `READEST_REVIEW_ROOT` 指定位置。
每个 session 的关键文件:
```text ```text
http://127.0.0.1:3000/review-editor <session-id>/
source.epub
extracted/
review_state/
rows.json
state.json
session.json
gpt_config.json
feedback/
translation_feedback.jsonl
feedback_for_codex.md
exports/
``` ```
也可以在 Readest 书架页菜单中进入: API Key 只写入本地运行配置,接口不得回传密钥,也不得写入 EPUB、反馈或导出文件。
```text sidecar 的所有 API 和下载请求都必须携带启动器返回的访问令牌,`/api/version` 健康探测也不例外。除创建 session 的 `/api/upload``/api/session/from-path` 等入口外,书籍审校接口缺少显式 `session_id` 时直接返回 `400`,不再回退全局 active session。
Advanced Settings -> EPUB 审校器
```
第一次进入会通过 `/api/review-editor/launch` 自动创建本目录下的 `.venv`、安装依赖并启动本地 sidecar。审校数据默认保存在: ## 主要 API
```text - `GET /api/version`
apps/readest-app\epub_review_sessions\ - `POST /api/session/from-path`
``` - `POST /api/upload`(本地 `dev-web`multipart`activate=false`,携带稳定 `book_key` 复用会话)
- `GET /api/session/<session_id>/translation-source?session_id=...`
- `POST /api/session/<session_id>/translation-plan?session_id=...`
- `GET /api/translation/defaults?session_id=...`
- `POST /api/translation/start?session_id=...`
- `GET /api/translation/jobs?session_id=...`
- `GET|POST /api/translation/jobs/<job_id>[/<action>]?session_id=...`
- `GET /api/gpt/models?session_id=...`
这一阶段仍是 dev-web 基座:正式 Tauri 桌面包需要后续把启动逻辑迁到 Rust command 或打包 sidecar 翻译状态为 `untranslated``translating``translated``failed`。输出下载必须继续携带 Bearer 令牌和源书 `session_id`,公开响应不返回 API Key
- `GET /api/session?session_id=...`
- `GET /api/rows?session_id=...`
- `POST /api/row/<row_id>?session_id=...`
- `POST /api/row/<row_id>/retranslate?session_id=...`
- `GET|POST /api/gpt/config?session_id=...`
- `GET|POST /api/glossary?session_id=...`
- `POST /api/feedback?session_id=...`
- `POST /api/export?session_id=...`
## 独立启动 详细维护边界和回归标准见 `MAINTENANCE.md`
如果只想脱离 Readest 单独运行本工具,可以在本目录执行: ## 版本说明
```text - `0.16.0`:新增 Readest 阅读页内嵌审校模式。
powershell -NoProfile -ExecutionPolicy Bypass -File .\open_editor.ps1 - `0.16.1``0.16.5`:完善停靠/浮动布局、宽高调整、注音注释快捷操作和响应式工程结构。
``` - `1.0.0`:删除旧独立审校页面、书库入口、静态浏览器前端及独立启动链路,只保留内嵌审校所需的共享 sidecar API。
- `1.0.1`:修复本地 `dev-web` EPUB 上传与下载、会话隔离、恢复初始译文写回、空译文预览、未保存修改提醒及动态开发端口 CORS;限制 sidecar 仅监听 loopback。
第一次运行会在 `tools/epub-review-editor/.venv` 创建本地 Python 环境并安装依赖;之后会直接启动服务并打开浏览器 - `2.0.0`:所有 sidecar API 和下载请求启用 Bearer 访问令牌;书籍审校 API 强制显式 `session_id`,移除对全局 active session 的隐式回退
- `2.1.0`:在 Readest 阅读页内嵌整书翻译页签,新增翻译计划、任务列表、断点续跑、暂停/恢复/取消、模型探测和安全输出下载;默认开发基座改为本地 `dev-web`,保留 Tauri 兼容。
不预先指定 EPUB,直接打开上传界面:
```powershell
$env:PYTHONIOENCODING='utf-8'
python '.\server.py'
```
也可以像原来一样直接打开某个 EPUB:
```powershell
$env:PYTHONIOENCODING='utf-8'
python '.\server.py' '<中日双语版.epub>' --daemon
```
默认会自动打开浏览器。如果没有自动打开,命令会打印 URL,例如:
```text
http://localhost:5177
```
局域网或服务器部署时,把监听地址改为 `0.0.0.0`,并把审校数据目录固定到一个你会备份的位置:
```powershell
python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessions
```
当前版本没有登录鉴权,不要直接暴露到公网。需要远程使用时,优先放在可信局域网、VPN、反向代理鉴权或 SSH 隧道后面。
## 浏览器上传 EPUB
- 启动时不带 EPUB 路径,会进入开始页。
- 点击“选择 EPUB 文件”,上传中日双语 EPUB 后会自动创建审校会话。
- 开始页会列出已有会话,换电脑或重启服务后可以继续打开之前的审校记录。
- 顶栏左上角“书架”会列出历史打开过的书籍,并按 EPUB 元数据生成分类;点击封面或“校对”会进入对应审校页面。
- 书架中每本书封面悬停会变暗并显示书籍信息、“校对”“翻译”和“删除”按钮;“翻译”可对日语 EPUB 发起 AI 翻译任务,输出 EPUB 会自动加入书架;“删除”只会从书架隐藏审校会话,不删除原始 EPUB。
- 上传的 EPUB、解包副本、反馈与导出文件都会保存在 `--review-root` 指定目录下。
## 能做什么
- 阅读模式下按章节连续阅读日文原文与中文译文,不需要逐句点击。
- 顶栏左上角可进入“书架”,集中查看历史打开过的书籍、审校进度和最近阅读记录;书架左侧可按全部书籍、同系列、作者、出版社和语言分类,右侧以封面网格显示书籍。
- 阅读区可切换默认、护眼、夜间背景,并调整字号与行距;设置会保存在当前浏览器中,下次打开继续沿用。
- 审校页顶部标题栏与阅读标题栏默认隐藏,鼠标移到页面上方时显示,减少长文阅读时的遮挡。
- 阅读区“上一节 / 下一节”按 part 或插图小章节移动,不再只跳顶层大章节。
- 每个审校会话会自动保存上次阅读位置,下次打开会回到上次所在章节、part、插图或段落附近。
- 左侧目录和检索默认隐藏,通过顶部“目录”“检索”两个按钮分别打开,不会把两种入口混在同一个常驻侧栏里。
- 目录优先按 EPUB 内部目录页或 nav/toc 组织章节;章节下显示内部 `part0000` 形式的 part 与次级插图条目,可展开/收起,点击 part 后只看该 part,点击章节则按原 spine 顺序连续阅读正文和插图。
- 插图页会作为所属章节的次级条目出现在目录里,点击后可在阅读模式中直接查看原 EPUB 图片;点击目录项不会自动隐藏左侧侧栏。
- 检索支持“当前范围”和“全书全局”两种范围,适合在单章精查和整本书排查术语时切换。
- 在阅读模式中点击段落即可打开右侧精修与重翻面板,直接编辑中文译文、标记问题或调用 GPT 重翻;打开/关闭面板时会尽量保持原来的阅读位置。
- 精修模式保留原有逐段编辑界面,用于较细的逐段复审。
- 可从“审校工具”统一配置 OpenAI 兼容的 `base_url``model`、API Key、重翻提示词与术语表,并对当前段落生成重翻候选;候选不会自动覆盖正文,需要点击“应用重翻”并保存。
- 可在“审校工具”的“AI 设置与术语表”里配置并读取正式术语表,实时搜索、增删改术语并保存;单段重翻会按当前段落命中术语表条目,而不是只在提示词里泛泛要求遵守术语。
- 可从书架进入“AI 翻译 EPUB”页面,对日语 EPUB 设置翻译内容提示词、格式与标点提示词、角色口吻提示词、术语表路径、翻译范围、输出为中日双语或纯中文 EPUB;同系列书籍会优先套用系列提示词和术语表配置,任务在后台运行,页面实时显示进度与日志。
- 直接编辑中文译文。
- 标记问题段落或选中文本。
- 记录问题类型、严重程度、标签、备注和可沉淀的长期规则。
- 生成反馈文件,供 Codex 后续持续优化翻译和润色质量。
- 将编辑写回 EPUB 解包内容。
- 导出一份新的“网页审校版 EPUB”。
## 重要输出
每个 EPUB 会建立独立审校 session。通过浏览器上传时,默认位于:
```text
epub_review_sessions\<timestamp>_<epub-name>_<hash>\
```
通过命令行直接指定 EPUB 时,仍沿用固定路径:
```text
epub_review_sessions\<epub-name>_<hash>\
```
关键文件:
- `feedback/translation_feedback.jsonl`:逐次保存的结构化反馈记录。
- `feedback/feedback_for_codex.md`:适合直接给 Codex 阅读的审校反馈摘要。
- `exports/*_网页审校版_*.epub`:导出的修订版 EPUB。
- `review_state/state.json`:网页编辑器当前状态。
- `extracted/`EPUB 解包后的工作副本。
- `translation_jobs/<job-id>/job.json`AI 翻译任务状态、进度和日志。
- `translated_outputs/*_AI翻译_*.epub`:AI 翻译模块生成并自动加入书架的 EPUB。
- `library.json`:按 EPUB 元数据生成的书架索引,包含书籍、系列和封面信息。
- `series_configs/*.json`:同系列共享的提示词与术语表路径配置,不包含 API Key。
在服务器上使用时,“生成反馈”和“导出审校 EPUB”会返回浏览器可访问的下载地址,不需要登录服务器找文件。
## 使用建议
- 正式长篇 EPUB 优先使用“阅读模式”:先在左侧点章节/part 连续读,看到问题再点段落快速编辑或标记。
- 同时审校多本书时,优先从顶栏左上角进入“书架”,再选择目标书籍;从书架进入会恢复该书上次阅读位置。
- 需要集中逐段复审时切到“精修模式”,左侧段落列表会跟随当前章节筛选。
- 只想记录问题时:勾选“标记为翻译问题”,写备注,保存本段。
- 想直接修正译文时:编辑中文译文后保存本段。
- 想让 Codex 学习你的偏好时:把“可沉淀为长期规则”写具体,例如“某个术语只在原文实际出现时才进入重翻提示”。
- 想单段重翻时:点击段落打开“审校工具”,先在“AI 设置与术语表”中保存 GPT 配置;提示词分为翻译内容、格式标点、角色口吻三块,可恢复预设。术语表路径会从当前项目中可唯一识别的系列 `mingcibiao.json` 推断,也可以改成其他 `mingcibiao.json`。再点“重翻本段”;满意后点“应用重翻”,最后保存本段记录。
- 想整书 AI 翻译时:先上传或打开日语 EPUB,让它出现在书架里;在书架封面悬停后点击“翻译”,保存 API 设置,并在系列设置里保存该系列术语表和提示词。默认先做前 20 段试译,确认效果后再把范围切换为全书。中日双语输出可直接进入审校;纯中文输出会入书架,但没有逐段双语审校行。
- 术语表路径默认会从当前项目中可唯一识别的系列 `mingcibiao.json` 推断;如果无法唯一推断,请在页面里填写目标术语表路径。
- 想调整术语时:打开“审校工具”的“AI 设置与术语表”,在术语表区域搜索、修改或新增条目,点击“保存术语表”;下一次重翻会重新读取已保存的最新术语。
- 审校结束后点击“生成反馈”,再在聊天里告诉 Codex 读取 `feedback_for_codex.md``translation_feedback.jsonl`
## 当前限制
- 当前解析规则针对本项目的“日文灰字 + 中文紧随其后”的双语 EPUB。
- AI 翻译模块是可选辅助首译入口,不替代正式长卷的候选词确认、复审和 QA;默认试译前 20 段,避免误触发全书 API 成本。
- 目录用于审校导航;插图可以阅读和跳转,但当前版本不提供图片内容编辑。
- GPT API Key、提示词与术语表路径仅保存在 `--review-root` 下的本地 `gpt_config.json`,接口不会回传密钥;重翻时只能使用已保存的 Base URL 和模型,避免把密钥发往未确认端点。术语表编辑会直接写入配置路径指向的 JSON 文件,服务器部署时请保护好审校目录和术语表,不要把没有鉴权的服务暴露到公网。
- 浏览器内保存的是审校工作副本;要生成 EPUB 文件,需要点击“导出审校 EPUB”。
@@ -1,134 +0,0 @@
$ErrorActionPreference = "Stop"
$toolRoot = Split-Path -Parent $PSCommandPath
$projectRoot = (Resolve-Path (Join-Path $toolRoot "..\..")).Path
$serverPath = Join-Path $toolRoot "server.py"
$reviewRoot = Join-Path $projectRoot "epub_review_sessions"
$venvRoot = Join-Path $toolRoot ".venv"
$venvPython = Join-Path $venvRoot "Scripts\python.exe"
$requirements = Join-Path $toolRoot "requirements.txt"
$versionFile = Join-Path $toolRoot "version.py"
$defaultPort = 5177
$env:PYTHONIOENCODING = "utf-8"
function Get-PythonCommand {
$pyLauncher = Get-Command py -ErrorAction SilentlyContinue
if ($pyLauncher) {
return [pscustomobject]@{ Exe = $pyLauncher.Source; Args = @("-3") }
}
$python = Get-Command python -ErrorAction SilentlyContinue
if ($python) {
return [pscustomobject]@{ Exe = $python.Source; Args = @() }
}
throw "Python was not found. Install Python 3, then run this launcher again."
}
function Invoke-PythonCommand {
param(
[Parameter(Mandatory = $true)]
[string]$Exe,
[string[]]$Args = @()
)
& $Exe @Args
return $LASTEXITCODE
}
function Test-FlaskInstalled {
& $venvPython -c "import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('flask') else 1)" 2>$null
return ($LASTEXITCODE -eq 0)
}
function Get-ExpectedVersion {
if (!(Test-Path $versionFile)) {
return ""
}
$content = Get-Content -Raw -Encoding UTF8 $versionFile
$match = [regex]::Match($content, 'version\s*=\s*"([^"]+)"')
if ($match.Success) {
return $match.Groups[1].Value
}
return ""
}
function Test-LocalPortOpen {
param(
[Parameter(Mandatory = $true)]
[int]$Port
)
$client = New-Object System.Net.Sockets.TcpClient
try {
$async = $client.BeginConnect("127.0.0.1", $Port, $null, $null)
if (!$async.AsyncWaitHandle.WaitOne(100, $false)) {
return $false
}
$client.EndConnect($async)
return $true
} catch {
return $false
} finally {
$client.Close()
}
}
function Get-RunningEditorUrl {
$expectedVersion = Get-ExpectedVersion
for ($port = $defaultPort; $port -lt ($defaultPort + 100); $port++) {
if (!(Test-LocalPortOpen -Port $port)) {
continue
}
$url = "http://127.0.0.1:$port"
try {
$versionPayload = Invoke-RestMethod -Uri "$url/api/version" -TimeoutSec 1 -ErrorAction Stop
if (!$expectedVersion -or $versionPayload.version -eq $expectedVersion) {
return "http://localhost:$port"
}
} catch {
continue
}
}
return ""
}
$runningUrl = Get-RunningEditorUrl
if ($runningUrl) {
Write-Host "EPUB review editor is already running."
Start-Process $runningUrl
Write-Host $runningUrl
Write-Host "review root: $reviewRoot"
exit 0
}
if (!(Test-Path $venvPython)) {
Write-Host "Creating local Python environment..."
$python = Get-PythonCommand
$venvArgs = @()
if ($python.Args) {
$venvArgs += $python.Args
}
$venvArgs += @("-m", "venv", $venvRoot)
if ((Invoke-PythonCommand -Exe $python.Exe -Args $venvArgs) -ne 0) {
throw "Failed to create local Python environment."
}
}
if (!(Test-FlaskInstalled)) {
Write-Host "Installing review editor dependencies..."
& $venvPython -m pip install -r $requirements
if ($LASTEXITCODE -ne 0) {
throw "Failed to install dependencies from requirements.txt."
}
if (!(Test-FlaskInstalled)) {
throw "Dependencies were installed, but Flask is still unavailable in the local Python environment."
}
}
Write-Host "Starting EPUB review editor..."
& $venvPython $serverPath --review-root $reviewRoot --daemon
if ($LASTEXITCODE -ne 0) {
throw "Failed to start EPUB review editor."
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,514 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>双语 EPUB 审校编辑器</title>
<link rel="stylesheet" href="/static/style.css?v=0.13.2">
</head>
<body>
<header class="topbar">
<button id="bookshelfBtn" class="bookshelfNavButton" type="button">书架</button>
<div class="brandBlock">
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.13.2</span></h1>
<p id="sessionMeta">正在载入……</p>
</div>
<div class="modeTabs" role="tablist" aria-label="审校模式">
<button id="readingModeBtn" class="modeButton active" type="button">阅读模式</button>
<button id="polishModeBtn" class="modeButton" type="button">精修模式</button>
</div>
<div class="toolbar">
<button id="tocPanelBtn" type="button">目录</button>
<button id="searchPanelBtn" type="button">检索</button>
<button id="openLibraryBtn" type="button">打开 EPUB</button>
<button id="reviewToolsBtn" type="button">审校工具</button>
</div>
</header>
<main class="bookshelfScreen" id="bookshelfScreen" hidden>
<div class="libraryShell">
<aside class="librarySidebar" aria-label="书架分类">
<div class="libraryBrandRow">
<button id="libraryMenuBtn" class="iconTextButton" type="button"></button>
<strong>Starrea</strong>
</div>
<label class="librarySearchLabel">
<span>搜索</span>
<input id="bookshelfSearchInput" type="search" placeholder="搜索书名、作者、系列">
</label>
<nav class="libraryFacetList" id="bookshelfFacetList"></nav>
<div class="librarySidebarFooter">
<button id="bookshelfRefreshBtn" type="button">刷新</button>
<button id="bookshelfOpenUploadBtn" class="primary" type="button">导入 EPUB</button>
</div>
</aside>
<section class="bookshelfPanel">
<div class="bookshelfHeader">
<div>
<h2 id="bookshelfTitle">全部书籍</h2>
<p id="bookshelfDescription">按 EPUB 元数据建立书架,封面悬停可直接进入审校或翻译。</p>
</div>
<div class="bookshelfActions">
<button id="seriesConfigBtn" type="button" disabled>系列设置</button>
<button id="bookshelfOpenUploadBtnInline" class="primary" type="button">打开新的 EPUB</button>
</div>
</div>
<div class="bookshelfMeta" id="bookshelfMeta">正在读取书架……</div>
<div class="bookshelfGrid" id="bookshelfList"></div>
</section>
</div>
</main>
<aside class="seriesConfigDrawer" id="seriesConfigDrawer" hidden>
<div class="seriesConfigHeader">
<div>
<h2 id="seriesConfigTitle">系列设置</h2>
<p id="seriesConfigMeta">同系列书籍会优先共用这里的提示词和术语表。</p>
</div>
<button id="closeSeriesConfigBtn" type="button">关闭</button>
</div>
<label>
术语表路径
<input id="seriesGlossaryPathInput" type="text" placeholder="mingcibiao.json 的完整路径或项目内相对路径">
</label>
<label>
翻译内容提示词
<textarea id="seriesTranslationPromptInput" rows="5"></textarea>
</label>
<label>
格式与标点提示词
<textarea id="seriesFormatPromptInput" rows="5"></textarea>
</label>
<label>
角色状态与口吻提示词
<textarea id="seriesCharacterPromptInput" rows="4"></textarea>
</label>
<div class="seriesConfigActions">
<button id="saveSeriesConfigBtn" class="primary" type="button">保存系列设置</button>
</div>
</aside>
<main class="translationScreen" id="translationScreen" hidden>
<section class="translationPanel">
<div class="translationHeader">
<div>
<h2>AI 翻译 EPUB</h2>
<p id="translationBookMeta">选择书架中的 EPUB 后设置范围、输出格式和必要提示词。</p>
</div>
<div class="translationHeaderActions">
<button id="translationBackBookshelfBtn" type="button">返回书架</button>
<button id="translationOpenReviewBtn" class="primary" type="button" disabled>打开输出审校</button>
</div>
</div>
<section class="translationGrid">
<form class="translationSettings" id="translationForm">
<section class="translationCard">
<div class="sectionTitle compactTitle">
<h2>输出与范围</h2>
<span id="translationSourceMeta">未读取源书信息</span>
</div>
<div class="translationOptions">
<label>
输出格式
<select id="translationOutputModeInput">
<option value="bilingual">中日双语 EPUB</option>
<option value="translated">纯中文 EPUB</option>
</select>
</label>
<label>
翻译范围
<select id="translationRangeModeInput">
<option value="limit">前 N 个正文块试译</option>
<option value="all">全书</option>
</select>
</label>
<label>
正文块数
<input id="translationLimitInput" type="number" min="1" max="5000" value="20">
</label>
<label>
Temperature
<input id="translationTemperatureInput" type="number" min="0" max="1" step="0.1" value="0.2">
</label>
</div>
</section>
<section class="translationCard">
<div class="sectionTitle compactTitle">
<h2>API 与术语表</h2>
<span id="translationGptStatus">未读取配置</span>
</div>
<div class="translationOptions apiOptions">
<label>
Base URL
<input id="translationBaseUrlInput" type="url" placeholder="https://api.openai.com/v1">
</label>
<label>
Model
<input id="translationModelInput" type="text" placeholder="gpt-4o-mini">
</label>
<label>
API Key
<input id="translationApiKeyInput" type="password" placeholder="留空则保留已有密钥">
</label>
<label>
术语表路径
<input id="translationGlossaryPathInput" type="text" placeholder="mingcibiao.json 的完整路径或项目内相对路径">
</label>
</div>
<div class="translationActions">
<button id="translationSaveConfigBtn" type="button">保存 API 设置</button>
</div>
</section>
<section class="translationCard">
<div class="sectionTitle compactTitle">
<h2>提示词</h2>
<button id="translationResetPromptsBtn" type="button">恢复预设</button>
</div>
<label>
翻译内容提示词
<textarea id="translationPromptInput" rows="5"></textarea>
</label>
<label>
格式与标点提示词
<textarea id="translationFormatPromptInput" rows="5"></textarea>
</label>
<label>
角色状态与口吻提示词
<textarea id="translationCharacterPromptInput" rows="4"></textarea>
</label>
<div class="translationActions">
<button id="translationStartBtn" class="primary" type="submit">开始翻译</button>
</div>
</section>
</form>
<aside class="translationProgressCard">
<div class="sectionTitle compactTitle">
<h2>翻译进度</h2>
<span id="translationJobMeta">尚未开始</span>
</div>
<div class="translationProgress">
<div class="translationProgressBar" id="translationProgressBar"></div>
</div>
<div class="translationProgressText" id="translationProgressText">等待开始翻译。</div>
<div class="translationOutput" id="translationOutput" hidden></div>
<div class="translationLogs" id="translationLogs"></div>
</aside>
</section>
</section>
</main>
<main class="startScreen" id="startScreen" hidden>
<section class="startPanel">
<div class="startHeader">
<div>
<h2>打开中日双语 EPUB</h2>
<p>上传一个“日文灰字 + 中文译文紧随其后”的双语 EPUB,审校记录会保存在本机或服务器的审校目录中。</p>
</div>
<button id="refreshSessionsBtn" type="button">刷新会话</button>
</div>
<form class="uploadBox" id="uploadForm">
<label class="fileDrop" for="epubFileInput">
<span class="fileDropTitle">选择 EPUB 文件</span>
<span class="fileDropName" id="selectedFileName">尚未选择文件</span>
<input id="epubFileInput" type="file" accept=".epub,application/epub+zip">
</label>
<button id="uploadBtn" class="primary" type="submit">上传并开始审校</button>
</form>
<section class="sessionBrowser">
<div class="sectionTitle compactTitle">
<h2>已有审校会话</h2>
<span id="sessionListMeta"></span>
</div>
<div class="sessionList" id="sessionList"></div>
</section>
</section>
</main>
<main class="layout" id="reviewLayout" hidden>
<aside class="sidebar" id="sidebar" hidden>
<div class="sideHeader">
<strong id="sideTitle">目录</strong>
<button id="closeSidebarBtn" type="button" aria-label="隐藏侧栏">隐藏</button>
</div>
<section class="tocPanel" id="tocPanel">
<div class="tocToolbar" aria-label="目录操作">
<strong>章节目录</strong>
<div>
<button id="expandTocBtn" type="button">展开全部</button>
<button id="collapseTocBtn" type="button">收起</button>
</div>
</div>
<nav class="toc" id="toc" aria-label="章节目录"></nav>
</section>
<section class="searchPanel" id="searchPanel" hidden>
<div class="searchbox">
<input id="searchInput" type="search" placeholder="搜索原文、译文、备注">
<div class="searchScope" role="group" aria-label="检索范围">
<button id="searchScopeCurrentBtn" type="button" class="active">当前范围</button>
<button id="searchScopeBookBtn" type="button">全书全局</button>
</div>
</div>
<div class="stats" id="stats"></div>
<div class="rowList" id="rowList"></div>
</section>
</aside>
<section class="readerPane" id="readerPane">
<div class="readerHeader">
<div>
<div class="chapterKicker" id="chapterKicker">目录</div>
<h2 id="readerTitle">选择章节开始阅读</h2>
</div>
<div class="readerTools">
<div class="appearanceControls" aria-label="阅读外观">
<div class="themeSwitch" role="group" aria-label="背景模式">
<button id="themeDefaultBtn" type="button" data-reader-theme="default">默认</button>
<button id="themeEyeBtn" type="button" data-reader-theme="eye">护眼</button>
<button id="themeNightBtn" type="button" data-reader-theme="night">夜间</button>
</div>
<label class="readerSlider">
<span>字号</span>
<input id="readerFontSizeInput" type="range" min="15" max="24" step="1" value="17">
<output id="readerFontSizeValue">17</output>
</label>
<label class="readerSlider">
<span>行距</span>
<input id="readerLineHeightInput" type="range" min="1.6" max="2.4" step="0.05" value="1.95">
<output id="readerLineHeightValue">1.95</output>
</label>
</div>
<button id="prevPartBtn" type="button">上一节</button>
<button id="nextPartBtn" type="button">下一节</button>
</div>
</div>
<article class="readingFlow" id="readingFlow"></article>
</section>
<section class="editorPane" id="editorPane" hidden>
<div class="emptyState" id="emptyState">选择左侧段落开始审校。</div>
<article class="editorCard" id="editorCard" hidden>
<div class="rowHeader">
<div>
<div class="rowId" id="rowId"></div>
<div class="rowPath" id="rowPath"></div>
</div>
<label class="markToggle">
<input id="markedInput" type="checkbox">
标记为翻译问题
</label>
</div>
<section class="pair">
<h2>日文原文</h2>
<div class="sourceText" id="jpText"></div>
</section>
<section class="pair">
<div class="sectionTitle">
<h2>中文译文</h2>
<div class="inlineTools">
<button id="markSelectionBtn" type="button">标记选中文本</button>
<button id="clearMarksBtn" type="button">清除本段标记</button>
<button id="resetTextBtn" type="button">恢复本段初始译文</button>
</div>
</div>
<div id="cnEditor" class="cnEditor" contenteditable="true" spellcheck="false"></div>
</section>
<section class="reviewFields">
<label>
问题类型
<select id="issueTypeInput">
<option value="">未选择</option>
<option value="误译">误译</option>
<option value="漏译">漏译</option>
<option value="术语不一致">术语不一致</option>
<option value="人名/地名问题">人名/地名问题</option>
<option value="翻译腔">翻译腔</option>
<option value="角色口吻不对">角色口吻不对</option>
<option value="语序不顺">语序不顺</option>
<option value="标点/格式">标点/格式</option>
<option value="其他">其他</option>
</select>
</label>
<label>
严重程度
<select id="severityInput">
<option value="">未选择</option>
<option value="轻微">轻微</option>
<option value="中等">中等</option>
<option value="严重">严重</option>
</select>
</label>
<label>
标签
<input id="tagsInput" type="text" placeholder="例:术语, 对话, 阿格里皮娜">
</label>
</section>
<section class="notes">
<label>
问题备注
<textarea id="commentInput" rows="4" placeholder="写下哪里翻得不好,或你希望我之后怎么处理类似句子。"></textarea>
</label>
<label>
可沉淀为长期规则
<textarea id="learnNoteInput" rows="3" placeholder="例:某个术语只在原文实际出现时才进入重翻提示。"></textarea>
</label>
</section>
<div class="actions">
<button id="prevBtn" type="button">上一段</button>
<button id="saveBtn" type="button">保存本段记录</button>
<button id="nextBtn" type="button">下一段</button>
</div>
</article>
</section>
</main>
<aside class="quickEditor" id="quickEditor" hidden aria-label="审校工具面板">
<div class="quickHeader">
<div>
<div class="rowId" id="quickRowId"></div>
<div class="rowPath" id="quickRowPath"></div>
</div>
<button id="closeQuickEditorBtn" type="button" aria-label="关闭审校工具">关闭</button>
</div>
<details class="toolSection quickSourceWrap" open>
<summary>日文原文</summary>
<section class="quickSource" id="quickJpText"></section>
</details>
<details class="toolSection" open>
<summary>当前段</summary>
<label class="quickField">
中文译文
<div id="quickCnEditor" class="cnEditor quickCnEditor" contenteditable="true" spellcheck="false"></div>
</label>
<div class="quickControls">
<label class="markToggle">
<input id="quickMarkedInput" type="checkbox">
标记问题
</label>
<select id="quickIssueTypeInput">
<option value="">问题类型</option>
<option value="误译">误译</option>
<option value="漏译">漏译</option>
<option value="术语不一致">术语不一致</option>
<option value="人名/地名问题">人名/地名问题</option>
<option value="翻译腔">翻译腔</option>
<option value="角色口吻不对">角色口吻不对</option>
<option value="语序不顺">语序不顺</option>
<option value="标点/格式">标点/格式</option>
<option value="其他">其他</option>
</select>
<select id="quickSeverityInput">
<option value="">严重程度</option>
<option value="轻微">轻微</option>
<option value="中等">中等</option>
<option value="严重">严重</option>
</select>
</div>
<label class="quickField">
标签
<input id="quickTagsInput" type="text" placeholder="例:术语, 对话">
</label>
<label class="quickField">
问题备注
<textarea id="quickCommentInput" rows="3" placeholder="哪里翻得不好,或应该如何改。"></textarea>
</label>
<label class="quickField">
可沉淀为长期规则
<textarea id="quickLearnNoteInput" rows="2" placeholder="可复用的翻译偏好或禁忌。"></textarea>
</label>
<div class="quickActions">
<button id="quickMarkSelectionBtn" type="button">标记选中文本</button>
<button id="quickSaveBtn" type="button">保存当前段</button>
<button id="quickOpenFullBtn" type="button">打开完整精修</button>
</div>
</details>
<section class="gptBox toolSection" id="gptBox">
<div class="gptConfigTitle">AI 重翻</div>
<div class="gptStatus" id="gptStatus">未读取配置</div>
<label class="quickField">
重翻要求
<textarea id="gptInstructionInput" rows="2" placeholder="可选:这次希望更口语、更忠实,或特别注意某个术语。"></textarea>
</label>
<div class="gptActions">
<button id="retranslateBtn" type="button">重翻本段</button>
<button id="applyRetranslationBtn" type="button" disabled>应用重翻</button>
</div>
<div class="gptCandidate" id="gptCandidate" hidden></div>
</section>
<details class="toolSection gptConfig" id="gptConfig" open>
<summary>AI 设置与术语表</summary>
<div class="gptConfigGroup">
<div class="gptConfigTitle">API</div>
<label>
Base URL
<input id="gptBaseUrlInput" type="url" placeholder="https://api.openai.com/v1">
</label>
<label>
Model
<input id="gptModelInput" type="text" placeholder="gpt-4o-mini">
</label>
<label>
API Key
<input id="gptApiKeyInput" type="password" placeholder="留空则保留已有密钥">
</label>
<label>
术语表路径
<input id="glossaryPathInput" type="text" placeholder="mingcibiao.json 的完整路径或项目内相对路径">
</label>
</div>
<div class="gptConfigGroup">
<div class="gptConfigTitle">提示词</div>
<label>
翻译内容提示词
<textarea id="gptTranslationPromptInput" rows="5" placeholder="设置忠实度、文风、术语优先级等内容要求。"></textarea>
</label>
<label>
格式与标点提示词
<textarea id="gptFormatPromptInput" rows="5" placeholder="设置 HTML、ruby、标点替换与输出格式规则。"></textarea>
</label>
<label>
角色状态与口吻提示词
<textarea id="gptCharacterPromptInput" rows="4" placeholder="设置角色状态、性格口吻、对话风格判断规则。"></textarea>
</label>
</div>
<div class="gptConfigGroup glossaryGroup">
<div class="gptConfigTitle">术语表</div>
<div class="glossaryMeta" id="glossaryMeta">未读取术语表</div>
<div class="glossaryTools">
<input id="glossarySearchInput" type="search" placeholder="搜索原文、译名或备注">
<button id="reloadGlossaryBtn" type="button">重新读取</button>
<button id="addGlossaryEntryBtn" type="button">新增术语</button>
<button id="saveGlossaryBtn" type="button" disabled>保存术语表</button>
</div>
<div class="glossaryList" id="glossaryList"></div>
</div>
<div class="gptConfigActions">
<button id="resetGptPromptsBtn" type="button">恢复预设提示词</button>
<button id="saveGptConfigBtn" type="button">保存 AI 设置</button>
</div>
</details>
<details class="toolSection" open>
<summary>反馈与交付</summary>
<div class="deliveryActions">
<button id="showTouchedBtn" type="button">仅看已记录</button>
<button id="feedbackBtn" type="button">生成反馈文件</button>
<button id="applyBtn" type="button">写回 EPUB 内容</button>
<button id="exportBtn" type="button">导出审校 EPUB</button>
</div>
</details>
</aside>
<div class="toast" id="toast" hidden></div>
<script src="/static/app.js?v=0.13.2"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,189 @@
import importlib.util
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
MODULE_PATH = Path(__file__).with_name("server.py")
SPEC = importlib.util.spec_from_file_location("epub_review_server_translation", MODULE_PATH)
server = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
SPEC.loader.exec_module(server)
class FullBookTranslationTests(unittest.TestCase):
def test_default_model_keeps_existing_compatibility(self):
self.assertEqual(server.DEFAULT_GPT_MODEL, "gpt-4o-mini")
def test_chunk_plan_keeps_file_boundaries(self):
items = [
{"id": "T00001", "file": "a.xhtml", "document_title": "A", "source_text": "" * 17},
{"id": "T00002", "file": "a.xhtml", "document_title": "A", "source_text": "" * 17},
{"id": "T00003", "file": "b.xhtml", "document_title": "B", "source_text": "" * 17},
]
chunks = server.build_translation_plan(items, chunk_target=2)
self.assertEqual([chunk["item_count"] for chunk in chunks], [2, 1])
self.assertEqual([chunk["file"] for chunk in chunks], ["a.xhtml", "b.xhtml"])
self.assertEqual(chunks[0]["first_item_id"], "T00001")
self.assertEqual(chunks[0]["last_item_id"], "T00002")
def test_glossary_roundtrip_keeps_legacy_object_shape(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
glossary_file = root / "mingcibiao.json"
server.write_json(root / "gpt_config.json", {"glossary_path": str(glossary_file)})
payload = server.save_glossary(
root,
[
{"source": "勇者", "target": "勇者"},
{"source": "聖剣", "target": "圣剑(Excalibur)#保留有意义注音"},
],
)
saved = server.read_json(glossary_file, {})
self.assertEqual(saved["聖剣"], "圣剑(Excalibur)#保留有意义注音")
self.assertEqual(payload["count"], 2)
self.assertTrue(all(isinstance(value, str) for value in saved.values()))
def test_job_list_never_exposes_api_key(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
job = {
"id": "tr_test",
"status": "running",
"source_session_id": "book",
"settings": {"api_key": "secret-value", "model": "gpt-5.6-sol"},
"logs": [],
}
server.write_translation_job(root, job)
public = server.list_translation_jobs(root)
self.assertNotIn("secret-value", str(public))
def test_library_translation_status_is_derived_from_jobs(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
session = root / "book_session"
server.write_json(session / "review_state" / "state.json", {"source_epub": "book.epub"})
server.write_json(
server.session_manifest_path(session),
{"id": session.name, "source_name": "book.epub", "metadata": {"title": ""}},
)
server.write_translation_job(
root,
{
"id": "tr_status",
"status": "running",
"source_session_id": session.name,
"settings": {},
"logs": [],
},
)
library = server.build_library_index(root)
self.assertEqual(library["sessions"][0]["translation_status"], "translating")
def test_failed_latest_job_is_not_reported_as_translating(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
session = root / "book_session"
server.write_json(session / "review_state" / "state.json", {"source_epub": "book.epub"})
server.write_json(
server.session_manifest_path(session),
{"id": session.name, "source_name": "book.epub", "metadata": {"title": ""}},
)
server.write_translation_job(
root,
{
"id": "tr_failed",
"status": "failed",
"source_session_id": session.name,
"settings": {},
"logs": [],
},
)
library = server.build_library_index(root)
self.assertEqual(library["sessions"][0]["translation_status"], "failed")
def test_manual_series_assignment_survives_library_rebuild(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
session = root / "book_session"
server.write_json(session / "review_state" / "state.json", {"source_epub": "book.epub"})
server.write_json(
server.session_manifest_path(session),
{
"id": session.name,
"source_name": "book.epub",
"source_epub": "book.epub",
"metadata": {"title": "第一卷", "series": "自动系列", "series_id": "auto"},
},
)
server.save_library_series_overrides(root, {"action": "create", "series_id": "manual", "title": "手动系列"})
library = server.save_library_series_overrides(
root,
{"action": "assign", "session_id": session.name, "series_id": "manual"},
)
book = next(item for item in library["sessions"] if item["id"] == session.name)
self.assertEqual(book["series_id"], "manual")
self.assertEqual(book["series"], "手动系列")
rebuilt = server.build_library_index(root)
rebuilt_book = next(item for item in rebuilt["sessions"] if item["id"] == session.name)
self.assertEqual(rebuilt_book["series_id"], "manual")
def test_models_endpoint_returns_only_public_ids(self):
with tempfile.TemporaryDirectory() as temp_dir:
token = "test-sidecar-token"
app = server.create_app(Path(temp_dir), access_token=token)
with patch.object(server, "read_scoped_gpt_config", return_value={
"api_key": "secret-value",
"base_url": "https://example.test/v1",
"model": "gpt-5.6-sol",
}), patch.object(server, "list_openai_compatible_models", return_value=["gpt-5.6-sol"]):
response = app.test_client().get(
"/api/gpt/models?session_id=test-session",
headers={"Authorization": f"Bearer {token}"},
)
self.assertEqual(response.status_code, 200)
body = response.get_json()
self.assertEqual(body["models"], ["gpt-5.6-sol"])
self.assertNotIn("secret-value", str(body))
def test_translation_routes_require_token_and_explicit_session(self):
with tempfile.TemporaryDirectory() as temp_dir:
token = "test-sidecar-token"
app = server.create_app(Path(temp_dir), access_token=token)
client = app.test_client()
missing_token = client.get("/api/translation/jobs?session_id=book")
missing_session = client.get(
"/api/translation/jobs",
headers={"Authorization": f"Bearer {token}"},
)
self.assertEqual(missing_token.status_code, 401)
self.assertEqual(missing_session.status_code, 400)
def test_translation_job_control_rejects_another_session(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
token = "test-sidecar-token"
server.write_translation_job(
root,
{
"id": "tr_owned",
"status": "paused",
"source_session_id": "book-a",
"settings": {},
"logs": [],
},
)
app = server.create_app(root, access_token=token)
response = app.test_client().post(
"/api/translation/jobs/tr_owned/resume?session_id=book-b",
headers={"Authorization": f"Bearer {token}"},
)
self.assertEqual(response.status_code, 404)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,376 @@
import importlib.util
import io
import json
import tempfile
import unittest
from pathlib import Path
SERVER_PATH = Path(__file__).with_name("server.py")
SPEC = importlib.util.spec_from_file_location("epub_review_editor_server", SERVER_PATH)
server = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
SPEC.loader.exec_module(server)
def write_json(path: Path, data):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
class InlineReviewSessionScopeTest(unittest.TestCase):
ACCESS_TOKEN = "test-sidecar-token"
def make_app(self, review_root: Path, **kwargs):
return server.create_app(review_root, access_token=self.ACCESS_TOKEN, **kwargs)
def client(self, app):
client = app.test_client()
client.environ_base["HTTP_AUTHORIZATION"] = f"Bearer {self.ACCESS_TOKEN}"
return client
def test_cli_rejects_non_loopback_host(self):
self.assertNotEqual(
server.main(["--host", "0.0.0.0", "--access-token", self.ACCESS_TOKEN]), 0
)
def test_local_dev_web_origin_on_dynamic_port_receives_cors_headers(self):
with tempfile.TemporaryDirectory() as temp_dir:
app = self.make_app(Path(temp_dir))
response = self.client(app).get(
"/api/version", headers={"Origin": "http://127.0.0.1:3017"}
)
self.assertEqual(
response.headers.get("Access-Control-Allow-Origin"),
"http://127.0.0.1:3017",
)
self.assertIn(
"Authorization", response.headers.get("Access-Control-Allow-Headers", "")
)
def test_sidecar_rejects_missing_or_incorrect_access_token(self):
with tempfile.TemporaryDirectory() as temp_dir:
app = self.make_app(Path(temp_dir))
client = app.test_client()
missing = client.get("/api/version")
incorrect = client.get(
"/api/version", headers={"Authorization": "Bearer incorrect"}
)
authorized = client.get(
"/api/version",
headers={"Authorization": f"Bearer {self.ACCESS_TOKEN}"},
)
self.assertEqual(missing.status_code, 401)
self.assertEqual(incorrect.status_code, 401)
self.assertEqual(authorized.status_code, 200)
self.assertNotIn(self.ACCESS_TOKEN, authorized.get_data(as_text=True))
def test_cors_preflight_does_not_require_access_token(self):
with tempfile.TemporaryDirectory() as temp_dir:
app = self.make_app(Path(temp_dir))
response = app.test_client().options(
"/api/rows?session_id=session-a",
headers={
"Origin": "http://localhost:3017",
"Access-Control-Request-Headers": "authorization,content-type",
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.headers.get("Access-Control-Allow-Origin"),
"http://localhost:3017",
)
def test_sidecar_has_no_legacy_standalone_page(self):
with tempfile.TemporaryDirectory() as temp_dir:
app = self.make_app(Path(temp_dir))
response = app.test_client().get("/")
self.assertEqual(response.status_code, 404)
def test_sanitize_cn_html_keeps_reader_review_inline_markup(self):
sanitized = server.sanitize_cn_html(
'<ruby><rb>文字</rb><rt>注音</rt></ruby>'
'<span class="review-annotation">(注:注释)</span>'
'<script>alert(1)</script>'
)
self.assertEqual(
sanitized,
'<ruby><rb>文字</rb><rt>注音</rt></ruby>'
'<span class="review-annotation">(注:注释)</span>',
)
def test_saved_row_restores_initial_html_after_previous_apply(self):
with tempfile.TemporaryDirectory() as temp_dir:
session_root = Path(temp_dir)
review_state = session_root / "review_state"
extracted = session_root / "extracted" / "Text"
review_state.mkdir(parents=True)
extracted.mkdir(parents=True)
row = {
"id": "R00001",
"file": "Text/chapter.xhtml",
"file_label": "chapter.xhtml",
"ja_p_index": 0,
"cn_p_index": 1,
"jp_html": "原文",
"jp_text": "原文",
"cn_html": "初始译文",
"cn_text": "初始译文",
}
write_json(review_state / "rows.json", [row])
write_json(
server.rows_meta_path(session_root),
{"parser_version": server.ROWS_PARSER_VERSION, "row_count": 1},
)
write_json(
review_state / "state.json",
{"edits": {"R00001": {"current_html": "初始译文"}}},
)
xhtml_path = extracted / "chapter.xhtml"
xhtml_path.write_text(
'<html><body><p class="sourceText">原文</p><p>先前修改</p></body></html>',
encoding="utf-8",
)
modified = server.apply_edits_to_xhtml(session_root)
self.assertEqual(modified, ["Text/chapter.xhtml"])
self.assertIn("<p>初始译文</p>", xhtml_path.read_text(encoding="utf-8"))
self.assertEqual(
json.loads((review_state / "rows.json").read_text(encoding="utf-8")),
[row],
)
def test_upload_can_create_a_session_without_changing_the_active_session(self):
with tempfile.TemporaryDirectory() as temp_dir:
review_root = Path(temp_dir)
first_epub = review_root / "first.epub"
self.write_minimal_epub(first_epub)
first_session = self.make_session(
review_root, "first", first_epub, review_root / "glossary.json"
)
app = self.make_app(review_root=review_root, initial_epub_path=first_epub)
client = self.client(app)
response = client.post(
"/api/upload",
data={
"epub": (io.BytesIO(first_epub.read_bytes()), "uploaded.epub"),
"activate": "false",
},
content_type="multipart/form-data",
)
self.assertEqual(response.status_code, 200)
uploaded_id = response.get_json()["id"]
active_session_id = client.get("/api/sessions").get_json()["active_session_id"]
self.assertNotEqual(active_session_id, uploaded_id)
self.assertIn("first", active_session_id)
scoped_session = client.get(f"/api/session?session_id={uploaded_id}")
self.assertEqual(scoped_session.status_code, 200)
self.assertEqual(scoped_session.get_json()["id"], uploaded_id)
for endpoint in (
"/api/session",
"/api/rows",
"/api/gpt/config",
"/api/glossary",
"/api/feedback",
):
unscoped = client.get(endpoint)
self.assertEqual(unscoped.status_code, 400, endpoint)
self.assertIn("session_id", unscoped.get_data(as_text=True))
def test_inline_upload_reuses_the_book_session(self):
with tempfile.TemporaryDirectory() as temp_dir:
review_root = Path(temp_dir)
epub = review_root / "book.epub"
self.write_minimal_epub(epub)
app = self.make_app(review_root=review_root)
client = self.client(app)
def upload(filename: str):
return client.post(
"/api/upload",
data={
"epub": (io.BytesIO(epub.read_bytes()), filename),
"activate": "false",
"book_key": "stable-book-hash",
},
content_type="multipart/form-data",
)
first = upload("book.epub")
second = upload("renamed-book.epub")
self.assertEqual(first.status_code, 200)
self.assertEqual(second.status_code, 200)
self.assertEqual(first.get_json()["id"], second.get_json()["id"])
@staticmethod
def write_minimal_epub(path: Path) -> None:
import zipfile
with zipfile.ZipFile(path, "w") as archive:
archive.writestr("mimetype", "application/epub+zip")
def make_session(
self,
review_root: Path,
session_id: str,
epub_path: Path,
glossary_path: Path,
) -> Path:
session_root = review_root / session_id
write_json(
session_root / "review_state" / "session.json",
{
"id": session_id,
"source_name": epub_path.name,
"source_epub": str(epub_path),
},
)
write_json(
session_root / "review_state" / "state.json",
{
"source_epub": str(epub_path),
"edits": {},
},
)
write_json(
session_root / "review_state" / "gpt_config.json",
{
"base_url": "https://session.example.test/v1",
"model": "session-model",
"glossary_path": str(glossary_path),
"translation_prompt": "session translation prompt",
"format_prompt": "session format prompt",
"character_prompt": "session character prompt",
},
)
return session_root
def test_inline_retranslate_uses_request_session_config_and_glossary(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
review_root = root / "review-root"
global_glossary = root / "global-glossary.json"
session_glossary = root / "session-glossary.json"
write_json(global_glossary, {"別語": "全局译名"})
write_json(session_glossary, {"ファルシオン": "大砍刀"})
write_json(
review_root / "gpt_config.json",
{
"api_key": "test-key",
"base_url": "https://global.example.test/v1",
"model": "global-model",
"glossary_path": str(global_glossary),
},
)
epub_path = root / "book.epub"
epub_path.write_bytes(b"not a real epub")
session_root = self.make_session(review_root, "session-a", epub_path, session_glossary)
write_json(
session_root / "review_state" / "rows_meta.json",
{"parser_version": server.ROWS_PARSER_VERSION},
)
write_json(
session_root / "review_state" / "rows.json",
[
{
"id": "R00001",
"file": "chapter.xhtml",
"file_label": "chapter.xhtml",
"document_title": "chapter",
"ja_p_index": 0,
"cn_p_index": 1,
"jp_html": "<ruby>ファルシオン<rt>Falchion</rt></ruby>",
"jp_text": "ファルシオン",
"cn_html": "旧译文",
}
],
)
captured = {}
original_call = server.call_openai_compatible_chat
def fake_call(config, messages, temperature=0.2, timeout=90):
captured["config"] = config
captured["messages"] = messages
return "<span>候选译文</span>"
server.call_openai_compatible_chat = fake_call
try:
app = self.make_app(review_root)
response = self.client(app).post(
"/api/row/R00001/retranslate?session_id=session-a",
json={"instruction": ""},
)
finally:
server.call_openai_compatible_chat = original_call
self.assertEqual(response.status_code, 200, response.get_data(as_text=True))
payload = response.get_json()
self.assertEqual(payload["model"], "session-model")
self.assertEqual(payload["glossary_path"], str(session_glossary))
self.assertEqual(payload["glossary_matches"], ["ファルシオン => 大砍刀"])
self.assertEqual(captured["config"]["base_url"], "https://session.example.test/v1")
self.assertEqual(captured["config"]["translation_prompt"], "session translation prompt")
prompt_text = "\n".join(message["content"] for message in captured["messages"])
self.assertIn("ファルシオン => 大砍刀", prompt_text)
self.assertNotIn("全局译名", prompt_text)
def test_scoped_gpt_config_saves_secret_globally_and_public_response_hides_it(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
review_root = root / "review-root"
glossary = root / "glossary.json"
write_json(glossary, {})
write_json(review_root / "gpt_config.json", {"glossary_path": str(glossary)})
epub_path = root / "book.epub"
epub_path.write_bytes(b"not a real epub")
self.make_session(review_root, "session-a", epub_path, glossary)
app = self.make_app(review_root)
response = self.client(app).post(
"/api/gpt/config?session_id=session-a",
json={
"api_key": "session-secret",
"base_url": "https://session.example.test/v1",
"model": "session-model",
"glossary_path": str(glossary),
"translation_prompt": "session translation prompt",
"format_prompt": "session format prompt",
"character_prompt": "session character prompt",
},
)
self.assertEqual(response.status_code, 200, response.get_data(as_text=True))
payload = response.get_json()
self.assertTrue(payload["configured"])
self.assertNotIn("api_key", payload)
self.assertEqual(payload["base_url"], "https://session.example.test/v1")
self.assertEqual(
json.loads((review_root / "gpt_config.json").read_text(encoding="utf-8"))["api_key"],
"session-secret",
)
scoped = json.loads(
(review_root / "session-a" / "review_state" / "gpt_config.json").read_text(
encoding="utf-8"
)
)
self.assertNotIn("api_key", scoped)
if __name__ == "__main__":
unittest.main()
@@ -1,4 +1,4 @@
version = "0.13.2" version = "2.1.0"
VERSION_RULES = { VERSION_RULES = {
"PATCH": "修复 bug 或兼容性小修", "PATCH": "修复 bug 或兼容性小修",
-1
View File
@@ -9,7 +9,6 @@
"lint:lua": "pnpm --filter @readest/readest-app lint:lua", "lint:lua": "pnpm --filter @readest/readest-app lint:lua",
"tauri": "pnpm --filter @readest/readest-app tauri", "tauri": "pnpm --filter @readest/readest-app tauri",
"dev-web": "pnpm --filter @readest/readest-app dev-web", "dev-web": "pnpm --filter @readest/readest-app dev-web",
"epub-reviewer:dev": "pnpm --filter @readest/readest-app epub-reviewer:dev",
"prepare": "husky", "prepare": "husky",
"fmt:check": "pnpm --filter @readest/readest-app fmt:check", "fmt:check": "pnpm --filter @readest/readest-app fmt:check",
"clippy:check": "pnpm --filter @readest/readest-app clippy:check", "clippy:check": "pnpm --filter @readest/readest-app clippy:check",
+2
View File
@@ -0,0 +1,2 @@
@echo off
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0open-readest-latest.ps1" %*
+265
View File
@@ -0,0 +1,265 @@
param(
[string]$Remote = "akai-tools",
[string]$Branch = "codex/readest-web-translation-base",
[switch]$Web,
[switch]$Desktop,
[switch]$SkipPull,
[switch]$CheckOnly,
[switch]$KeepRunning
)
if ($Web -and $Desktop) {
throw "-Web and -Desktop cannot be used together. Web is the default mode."
}
$ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
function Write-Step {
param([string]$Message)
Write-Host ""
Write-Host "==> $Message" -ForegroundColor Cyan
}
function Invoke-Checked {
param(
[string]$Command,
[string[]]$Arguments,
[string]$WorkingDirectory = $RepoRoot
)
Push-Location $WorkingDirectory
try {
& $Command @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$Command $($Arguments -join ' ') failed with exit code $LASTEXITCODE."
}
} finally {
Pop-Location
}
}
function Add-PathIfExists {
param([string]$PathToAdd)
if ($PathToAdd -and (Test-Path -LiteralPath $PathToAdd)) {
$script:PathParts += $PathToAdd
}
}
function Test-SubmoduleCheckout {
param([string]$RelativePath)
$fullPath = Join-Path $RepoRoot $RelativePath
if (-not (Test-Path -LiteralPath (Join-Path $fullPath ".git"))) {
return $false
}
& git -C $fullPath rev-parse --is-inside-work-tree 2>$null | Out-Null
return $LASTEXITCODE -eq 0
}
function Stop-OldReadestDev {
if ($KeepRunning) {
return
}
Write-Step "Stopping old Readest desktop/dev processes"
Get-Process -Name "Readest" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
$repoLower = $RepoRoot.ToLowerInvariant()
$currentPid = $PID
Get-CimInstance Win32_Process |
Where-Object {
$_.ProcessId -ne $currentPid -and
$_.CommandLine -and
$_.CommandLine.ToLowerInvariant().Contains($repoLower) -and
($_.CommandLine -match "tauri\s+dev|next\s+dev|@readest/readest-app")
} |
ForEach-Object {
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
}
$portConnection = Get-NetTCPConnection -State Listen -LocalPort 3000 -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $portConnection) {
return
}
$ownerPid = $portConnection.OwningProcess
$processLineage = @()
$lineagePid = $ownerPid
while ($lineagePid -and $processLineage.Count -lt 8) {
$lineageProcess = Get-CimInstance Win32_Process -Filter "ProcessId = $lineagePid" -ErrorAction SilentlyContinue
if (-not $lineageProcess) {
break
}
$processLineage += $lineageProcess
$lineagePid = $lineageProcess.ParentProcessId
}
$isReadestDev = $processLineage | Where-Object {
$_.Name -ieq "Readest.exe" -or
($_.CommandLine -and
$_.CommandLine -match "(?i)readest" -and
$_.CommandLine -match "(?i)next(.+?)dev|start-server\.js|@readest/readest-app|tauri(.+?)dev")
}
if (-not $isReadestDev) {
$owner = $processLineage | Select-Object -First 1
$ownerDescription = if ($owner.CommandLine) { $owner.CommandLine } else { "$($owner.Name) (PID $ownerPid)" }
throw "Port 3000 is occupied by an unrelated process: $ownerDescription. Stop it or launch Readest after freeing port 3000."
}
Write-Host "Stopping old Readest/Next listener on port 3000 (PID $ownerPid)." -ForegroundColor Yellow
Stop-Process -Id $ownerPid -Force -ErrorAction SilentlyContinue
for ($attempt = 0; $attempt -lt 20; $attempt++) {
Start-Sleep -Milliseconds 250
if (-not (Get-NetTCPConnection -State Listen -LocalPort 3000 -ErrorAction SilentlyContinue)) {
return
}
}
throw "Port 3000 is still occupied after stopping the old Readest/Next process. Close the old development terminal and try again."
}
function Assert-CleanWorktree {
$status = @(& git -C $RepoRoot status --porcelain --untracked-files=no --ignore-submodules=dirty)
if ($status) {
Write-Host ""
Write-Host "Local changes were found. The launcher will not pull or overwrite them:" -ForegroundColor Yellow
$status | ForEach-Object { Write-Host $_ }
throw "Commit, stash, or discard local changes before launching the latest organization version."
}
}
try {
$PathParts = @()
$runtimeRoot = Join-Path $env:USERPROFILE ".cache\codex-runtimes\codex-primary-runtime\dependencies"
Add-PathIfExists "C:\w64devkit\bin"
Add-PathIfExists (Join-Path $runtimeRoot "bin")
Add-PathIfExists (Join-Path $runtimeRoot "node\bin")
Add-PathIfExists (Join-Path $env:USERPROFILE ".cargo\bin")
$env:Path = (($PathParts + ($env:Path -split ";" | Where-Object { $_ })) -join ";")
$env:RUSTUP_TOOLCHAIN = "stable-x86_64-pc-windows-gnu"
$requiredCommands = @("git", "pnpm")
if ($Desktop) {
$requiredCommands += "cargo"
}
foreach ($command in $requiredCommands) {
if (-not (Get-Command $command -ErrorAction SilentlyContinue)) {
throw "Required command not found: $command"
}
}
Write-Step "Readest latest launcher"
Write-Host "Repository: $RepoRoot"
Write-Host "Remote: $Remote"
Write-Host "Branch: $Branch"
Write-Host "Mode: $(if ($Desktop) { 'Desktop (Tauri)' } else { 'Web (Fast Refresh)' })"
$beforeHead = (& git -C $RepoRoot rev-parse HEAD).Trim()
if (-not $SkipPull) {
Assert-CleanWorktree
Write-Step "Fetching latest code from organization remote"
Invoke-Checked "git" @("-C", $RepoRoot, "fetch", "--prune", $Remote)
$currentBranch = (& git -C $RepoRoot branch --show-current).Trim()
if ($currentBranch -ne $Branch) {
Write-Step "Switching to $Branch"
& git -C $RepoRoot show-ref --verify --quiet "refs/heads/$Branch"
if ($LASTEXITCODE -eq 0) {
Invoke-Checked "git" @("-C", $RepoRoot, "switch", $Branch)
} else {
Invoke-Checked "git" @("-C", $RepoRoot, "switch", "--track", "-c", $Branch, "$Remote/$Branch")
}
}
Write-Step "Fast-forwarding local branch"
Invoke-Checked "git" @("-C", $RepoRoot, "pull", "--ff-only", $Remote, $Branch)
} else {
Write-Step "Skipping git pull by request"
}
$afterHead = (& git -C $RepoRoot rev-parse HEAD).Trim()
Write-Step "Updating submodules"
$requiredSubmodules = @(
"packages/foliate-js",
"packages/simplecc-wasm",
"packages/js-mdict"
)
if ($Desktop) {
$requiredSubmodules += "apps/readest-app/src-tauri/plugins/tauri-plugin-turso"
}
$missingSubmodules = @($requiredSubmodules | Where-Object { -not (Test-SubmoduleCheckout $_) })
if ($missingSubmodules.Count -gt 0) {
$submoduleArgs = @(
"-C", $RepoRoot,
"-c", "http.version=HTTP/1.1",
"-c", "submodule.fetchJobs=1",
"submodule", "update", "--init", "--depth", "1", "--"
) + $missingSubmodules
Invoke-Checked "git" $submoduleArgs
} else {
Write-Host "Required submodules are already initialized."
}
$changedFiles = @()
if ($beforeHead -ne $afterHead) {
$changedFiles = (& git -C $RepoRoot diff --name-only $beforeHead $afterHead)
}
$needsInstall =
-not (Test-Path -LiteralPath (Join-Path $RepoRoot "node_modules")) -or
($changedFiles -contains "pnpm-lock.yaml") -or
($changedFiles -contains "package.json") -or
($changedFiles -contains "apps/readest-app/package.json")
if ($needsInstall) {
Write-Step "Installing/updating dependencies"
Invoke-Checked "pnpm" @("install", "--frozen-lockfile")
}
$vendorSentinel = Join-Path $RepoRoot "apps\readest-app\public\vendor\pdfjs\pdf.min.mjs"
if ($needsInstall -or -not (Test-Path -LiteralPath $vendorSentinel)) {
Write-Step "Preparing browser runtime assets"
Invoke-Checked "pnpm" @("--filter", "@readest/readest-app", "setup-vendors")
}
if ($Desktop) {
$tursoRoot = Join-Path $RepoRoot "apps\readest-app\src-tauri\plugins\tauri-plugin-turso"
$tursoOutput = Join-Path $tursoRoot "dist\index.js"
if (-not (Test-Path -LiteralPath $tursoOutput)) {
Write-Step "Building Tauri plugin dependencies"
Invoke-Checked "pnpm" @("--dir", $tursoRoot, "install", "--ignore-workspace", "--frozen-lockfile")
Invoke-Checked "pnpm" @("--dir", $tursoRoot, "build")
}
}
if ($CheckOnly) {
Write-Step "Launcher check complete"
exit 0
}
Stop-OldReadestDev
if ($Desktop) {
Write-Step "Starting Readest desktop with the latest code"
Write-Host "Close the Readest window or press Ctrl+C here to stop the dev server."
Invoke-Checked "pnpm" @("--filter", "@readest/readest-app", "tauri", "dev")
} else {
Write-Step "Starting Readest web development server"
Write-Host "Open http://localhost:3000. Saved React and CSS changes appear through Fast Refresh."
Invoke-Checked "pnpm" @("--filter", "@readest/readest-app", "dev-web")
}
} catch {
Write-Host ""
Write-Host "Readest launcher failed:" -ForegroundColor Red
Write-Host $_.Exception.Message -ForegroundColor Red
Write-Host ""
Write-Host "Press Enter to close this window."
[void][Console]::ReadLine()
exit 1
}
+28
View File
@@ -0,0 +1,28 @@
$ErrorActionPreference = "Stop"
$ScriptPath = Join-Path $PSScriptRoot "open-readest-latest.ps1"
$ScriptText = Get-Content -LiteralPath $ScriptPath -Raw
function Assert-Match {
param(
[string]$Pattern,
[string]$Message
)
if ($ScriptText -notmatch $Pattern) {
throw $Message
}
}
Assert-Match '\[string\]\$Remote\s*=\s*"akai-tools"' "Launcher must pull from akai-tools by default."
Assert-Match '\[switch\]\$Desktop' "Launcher must expose an explicit desktop mode."
Assert-Match 'if\s*\(\$Desktop\)[\s\S]*tauri[\s\S]*else[\s\S]*dev-web' "Launcher must default to dev-web while preserving Tauri."
Assert-Match '\$requiredCommands\s*=\s*@\("git",\s*"pnpm"\)' "Web mode must not require Cargo."
Assert-Match 'submodule\.fetchJobs=1' "Launcher must initialize missing submodules conservatively."
Assert-Match 'status\s+--porcelain\s+--untracked-files=no\s+--ignore-submodules=dirty' "Clean check must ignore runtime logs and dirty submodule contents."
Assert-Match 'Get-NetTCPConnection[^\r\n]*-LocalPort\s+3000' "Launcher must inspect the Tauri dev port before starting."
Assert-Match 'Stop-Process[^\r\n]*-Id\s+\$ownerPid' "Launcher must stop an old Readest/Next process that owns port 3000."
Assert-Match 'Port 3000 is occupied' "Launcher must report an actionable error for an unrelated port owner."
Assert-Match 'Port 3000 is still occupied' "Launcher must wait for the previous dev server to release the port."
Write-Host "open-readest-latest launcher checks passed."