Compare commits

..

4 Commits

54 changed files with 8840 additions and 1625 deletions
@@ -0,0 +1,33 @@
# EPUB review editor migration
Date: 2026-07-08
This fork includes a desktop 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`.
- Desktop launcher command is `launch_epub_review_editor`.
- 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 now works in Tauri desktop through the Rust command and keeps local `dev-web` as a fallback.
- `/review-editor` is the desktop feature-block page for both 校对 and 翻译. It now uses native React blocks as the main work surface and calls the local sidecar APIs directly.
- Readest EPUB bookshelf context menus can open the reviewer in 校对 or 翻译 mode. The native file path is handed to the Tauri command, which creates/reuses a sidecar session and returns `sessionId`; the Readest page should use `session_id`, not keep `epub_path` in the URL.
- The old `static/index.html` UI remains bundled only as standalone/debug fallback. Do not treat an iframe of the old UI as the desktop migration acceptance path.
Important boundary:
- This is not a full native rewrite yet. The migrated tool still uses the proven Flask backend from the previous long-term translation project.
- The production desktop path still depends on local Python/venv/pip as a temporary sidecar runtime. Next step should package a controlled runtime or convert the hot APIs to native Rust/Tauri commands.
- 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`.
- If React blocks cannot fetch the sidecar, check restricted CORS in `server.py`; allowed origins are local Readest dev and Tauri origins only.
Next recommended steps:
1. Package the Python sidecar/runtime or replace it with native Tauri commands so desktop users do not need a system Python installation.
2. Add a launch token / origin guard for the loopback sidecar API before wider distribution.
3. Gradually port long-tail review-editor surfaces into React: glossary editing, full bookshelf classification UI, richer chapter navigation, and reading-position restore.
Desktop latest-version entrypoint:
- `scripts/open-readest-latest.ps1` and `scripts/open-readest-latest.cmd` are the stable desktop shortcut targets. They fetch `akai-tools/codex/desktop-review-editor-blocks`, fast-forward only on a clean worktree, update submodules/dependencies when needed, then start `pnpm --filter @readest/readest-app tauri dev`. Do not point the user shortcut directly at `target/debug/Readest.exe`, because that can launch stale code or miss the Next/Tauri dev server.
-2
View File
@@ -15,5 +15,3 @@ export const SAMPLE_EPUB = path.join(
fixturesDir,
'../../src/__tests__/fixtures/data/sample-alice.epub',
);
export const REVIEW_EPUB = path.join(fixturesDir, 'books/readest-review-e2e.epub');
@@ -1,31 +0,0 @@
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 }));
@@ -1,60 +0,0 @@
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 });
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);
});
});
+1
View File
@@ -8,6 +8,7 @@
"build": "dotenv -e .env.tauri -- next build",
"start": "dotenv -e .env.tauri -- next start",
"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",
"start-web": "dotenv -e .env.web -- next start",
"dev-web:vinext": "dotenv -e .env.web -- vinext dev",
@@ -0,0 +1,139 @@
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",
] }
tauri-plugin-sentry = "0.5"
rand = "0.8"
[target."cfg(target_os = \"macos\")".dependencies]
rand = "0.8"
cocoa = "0.25"
objc = "0.2.7"
objc-foundation = "0.1.1"
+14 -95
View File
@@ -1,10 +1,8 @@
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},
@@ -26,7 +24,6 @@ pub struct ReviewEditorLaunchResponse {
review_root: String,
version: String,
session_id: Option<String>,
access_token: String,
}
struct CommandOutput {
@@ -79,12 +76,10 @@ fn launch_epub_review_editor_sync(
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)?;
if let Some(url) = find_running_editor(expected_version.as_deref(), &review_root) {
let session_id = ensure_session_from_path(&url, epub_path.as_deref())?;
return Ok(ReviewEditorLaunchResponse {
ok: true,
url,
@@ -92,7 +87,6 @@ fn launch_epub_review_editor_sync(
review_root: review_root.to_string_lossy().to_string(),
version: expected_version.unwrap_or_default(),
session_id,
access_token,
});
}
@@ -117,15 +111,14 @@ fn launch_epub_review_editor_sync(
"127.0.0.1".to_string(),
"--port".to_string(),
DEFAULT_PORT.to_string(),
"--access-token".to_string(),
access_token.clone(),
"--daemon".to_string(),
"--no-browser".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))
.or_else(|| find_running_editor(expected_version.as_deref(), &review_root))
.ok_or_else(|| {
format!(
"审校器已启动但没有返回访问地址。\nstdout:\n{}\nstderr:\n{}",
@@ -134,7 +127,7 @@ fn launch_epub_review_editor_sync(
})?;
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)?;
let session_id = ensure_session_from_path(&launched_url, epub_path.as_deref())?;
Ok(ReviewEditorLaunchResponse {
ok: true,
@@ -143,57 +136,9 @@ fn launch_epub_review_editor_sync(
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);
@@ -348,16 +293,12 @@ fn read_expected_version(tool_root: &Path) -> Option<String> {
Some(rest[..rest.find('"')?].to_string())
}
fn find_running_editor(
expected_version: Option<&str>,
review_root: &Path,
access_token: &str,
) -> Option<String> {
fn find_running_editor(expected_version: Option<&str>, review_root: &Path) -> 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 {
let Some(payload) = request_json(port, "/api/version") else {
continue;
};
let Some(version) = payload.get("version").and_then(Value::as_str) else {
@@ -382,19 +323,17 @@ fn find_running_editor(
None
}
fn request_json(port: u16, path: &str, access_token: &str) -> Option<Value> {
fn request_json(port: u16, path: &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"
);
let request = format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\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> {
fn post_json(url: &str, path: &str, body: &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))
@@ -402,7 +341,7 @@ fn post_json(url: &str, path: &str, body: &str, access_token: &str) -> Result<Va
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{}",
"POST {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
@@ -454,16 +393,12 @@ fn parse_loopback_port(url: &str) -> Result<u16, String> {
.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> {
fn ensure_session_from_path(url: &str, epub_path: Option<&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)?;
let payload = post_json(url, "/api/session/from-path", &body)?;
Ok(payload
.get("id")
.and_then(Value::as_str)
@@ -482,29 +417,13 @@ fn run_command(
Err(format!(
"命令执行失败:{} {}\nstdout:\n{}\nstderr:\n{}",
program,
redact_command_args(args).join(" "),
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],
+6 -2
View File
@@ -19,7 +19,7 @@
"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",
"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 https://*.stripe.com",
"frame-src": "'self' blob: asset: http://asset.localhost http://127.0.0.1:* 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": {
@@ -52,7 +52,11 @@
"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"
"../tools/epub-review-editor/requirements.txt": "tools/epub-review-editor/requirements.txt",
"../tools/epub-review-editor/README.md": "tools/epub-review-editor/README.md",
"../tools/epub-review-editor/MAINTENANCE.md": "tools/epub-review-editor/MAINTENANCE.md",
"../tools/epub-review-editor/READEST_MIGRATION.md": "tools/epub-review-editor/READEST_MIGRATION.md",
"../tools/epub-review-editor/static/*": "tools/epub-review-editor/static/"
},
"windows": {
"webviewInstallMode": {
@@ -4,7 +4,11 @@
"../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"
"../tools/epub-review-editor/requirements.txt": "tools/epub-review-editor/requirements.txt",
"../tools/epub-review-editor/README.md": "tools/epub-review-editor/README.md",
"../tools/epub-review-editor/MAINTENANCE.md": "tools/epub-review-editor/MAINTENANCE.md",
"../tools/epub-review-editor/READEST_MIGRATION.md": "tools/epub-review-editor/READEST_MIGRATION.md",
"../tools/epub-review-editor/static/*": "tools/epub-review-editor/static/"
}
}
}
@@ -22,7 +22,8 @@ describe('getBookContextMenuItemIds', () => {
'markFinished',
'markAbandoned',
'showDetails',
'bilingual',
'reviewInEpubEditor',
'translateInEpubEditor',
'showInFinder',
'searchGoodreads',
'upload',
@@ -40,7 +41,8 @@ describe('getBookContextMenuItemIds', () => {
'markAbandoned',
'clearStatus',
'showDetails',
'bilingual',
'reviewInEpubEditor',
'translateInEpubEditor',
'showInFinder',
'searchGoodreads',
'upload',
@@ -58,7 +60,8 @@ describe('getBookContextMenuItemIds', () => {
'markAbandoned',
'clearStatus',
'showDetails',
'bilingual',
'reviewInEpubEditor',
'translateInEpubEditor',
'showInFinder',
'searchGoodreads',
'upload',
@@ -75,7 +78,8 @@ describe('getBookContextMenuItemIds', () => {
'markFinished',
'clearStatus',
'showDetails',
'bilingual',
'reviewInEpubEditor',
'translateInEpubEditor',
'showInFinder',
'searchGoodreads',
'upload',
@@ -92,7 +96,8 @@ describe('getBookContextMenuItemIds', () => {
'markFinished',
'markAbandoned',
'showDetails',
'bilingual',
'reviewInEpubEditor',
'translateInEpubEditor',
'showInFinder',
'searchGoodreads',
'download',
@@ -109,7 +114,8 @@ describe('getBookContextMenuItemIds', () => {
'markFinished',
'markAbandoned',
'showDetails',
'bilingual',
'reviewInEpubEditor',
'translateInEpubEditor',
'showInFinder',
'searchGoodreads',
'delete',
@@ -29,18 +29,6 @@ const renderDropdown = () =>
</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', () => {
it('opens when Enter is pressed on the toggle button', () => {
renderDropdown();
@@ -103,29 +91,4 @@ describe('Dropdown keyboard activation', () => {
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');
});
});
@@ -1,8 +1,4 @@
import { describe, expect, test, vi } from 'vitest';
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ appService: null }),
}));
import { describe, expect, test } from 'vitest';
import { __testing } from '@/app/reader/components/ReviewModeController';
import type { BookDoc } from '@/libs/document';
@@ -148,29 +144,4 @@ describe('ReviewModeController marks', () => {
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('');
});
});
@@ -1,71 +0,0 @@
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', () => {
expect(coep('/')).toBe('require-corp');
expect(coep('/library')).toBe('require-corp');
expect(coep('/reader')).toBe('require-corp');
expect(coep('/review-editor')).toBe('require-corp');
// Must not be caught by a naive startsWith('/s').
expect(coep('/settings')).toBe('require-corp');
expect(coep('/search')).toBe('require-corp');
@@ -1,14 +1,9 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { ReviewRow } from '@/services/reviewEditorService';
import {
canLaunchInlineReviewEditor,
createReviewSessionFromPath,
downloadReviewedEpub,
exportReviewedEpub,
generateReviewFeedback,
isReviewEditDirty,
launchInlineReviewEditor,
loadInlineReviewData,
loadReviewGlossary,
saveReviewGptConfig,
@@ -43,174 +38,14 @@ const firstFetchUrl = (fetchMock: ReturnType<typeof vi.fn>) => {
};
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',
);
await sidecarApi('http://127.0.0.1:5177', '/api/rows', {}, 'session-a');
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 () => {
@@ -48,19 +48,6 @@ describe('reviewModeStore', () => {
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: [
@@ -1,5 +1,4 @@
import { spawnSync } from 'node:child_process';
import crypto from 'node:crypto';
import { spawn, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
@@ -28,7 +27,6 @@ type LaunchResponse = {
reused?: boolean;
reviewRoot?: string;
version?: string;
accessToken?: string;
error?: string;
};
@@ -41,28 +39,6 @@ const appRoot = () => process.cwd();
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 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 venvPython = () =>
@@ -70,6 +46,8 @@ const venvPython = () =>
? path.join(venvRoot(), 'Scripts', 'python.exe')
: path.join(venvRoot(), 'bin', 'python');
const venvPythonw = () => path.join(venvRoot(), 'Scripts', 'pythonw.exe');
const run = (
command: string,
args: string[],
@@ -115,11 +93,7 @@ const isLoopbackHost = (host: string | null) => {
const normalizeLoopbackUrl = (url: string) => url.replace('http://localhost:', 'http://127.0.0.1:');
const requestJson = (
port: number,
pathname: string,
accessToken: string,
): Promise<Record<string, unknown> | null> =>
const requestJson = (port: number, pathname: string): Promise<Record<string, unknown> | null> =>
new Promise((resolve) => {
const req = http.get(
{
@@ -127,7 +101,6 @@ const requestJson = (
port,
path: pathname,
timeout: 800,
headers: { Authorization: `Bearer ${accessToken}` },
},
(res) => {
let body = '';
@@ -155,24 +128,55 @@ const requestJson = (
req.on('error', () => resolve(null));
});
const findRunningEditor = async (accessToken: string) => {
const findRunningEditor = async () => {
const expectedVersion = readExpectedVersion();
const expectedReviewRoot = path.resolve(reviewRoot());
for (let port = DEFAULT_PORT; port < DEFAULT_PORT + PORT_SCAN_LIMIT; port++) {
const payload = await requestJson(port, '/api/version', accessToken);
const payload = await requestJson(port, '/api/version');
if (!payload) continue;
const runningReviewRoot =
typeof payload['review_root'] === 'string' ? path.resolve(payload['review_root']) : '';
if (
(!expectedVersion || payload['version'] === expectedVersion) &&
runningReviewRoot === expectedReviewRoot
) {
if (!expectedVersion || payload['version'] === expectedVersion) {
return `http://127.0.0.1:${port}`;
}
}
return '';
};
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const waitForRunningEditor = async (timeoutMs = 45_000) => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const url = await findRunningEditor();
if (url) return url;
await sleep(500);
}
return '';
};
const launchDetachedEditor = (serverPath: string) => {
const command =
process.platform === 'win32' && fs.existsSync(venvPythonw()) ? venvPythonw() : venvPython();
const child = spawn(
command,
[
serverPath,
'--review-root',
reviewRoot(),
'--host',
'127.0.0.1',
'--port',
String(DEFAULT_PORT),
'--no-browser',
],
{
cwd: toolRoot(),
detached: true,
stdio: 'ignore',
windowsHide: true,
},
);
child.unref();
};
const resolvePythonCommand = (): PythonCommand => {
const candidates: PythonCommand[] =
process.platform === 'win32'
@@ -272,8 +276,7 @@ async function launchEditor(): Promise<LaunchResponse> {
};
}
const accessToken = ensureAccessToken();
const runningUrl = await findRunningEditor(accessToken);
const runningUrl = await findRunningEditor();
if (runningUrl) {
return {
ok: true,
@@ -281,7 +284,6 @@ async function launchEditor(): Promise<LaunchResponse> {
reused: true,
reviewRoot: reviewRoot(),
version: readExpectedVersion(),
accessToken,
};
}
@@ -290,31 +292,10 @@ async function launchEditor(): Promise<LaunchResponse> {
ensureDependencies();
fs.mkdirSync(reviewRoot(), { recursive: true });
const launch = run(
venvPython(),
[
serverPath,
'--review-root',
reviewRoot(),
'--host',
'127.0.0.1',
'--port',
String(DEFAULT_PORT),
'--access-token',
accessToken,
'--daemon',
],
{ cwd: bundledToolRoot, timeout: 45_000 },
);
if (!launch.ok) {
throw new Error(`启动审校器失败:${launch.stderr || launch.error || launch.stdout}`);
}
const launchedUrl = normalizeLoopbackUrl(
/https?:\/\/[^\s]+/.exec(launch.stdout)?.[0] || (await findRunningEditor(accessToken)),
);
launchDetachedEditor(serverPath);
const launchedUrl = normalizeLoopbackUrl(await waitForRunningEditor());
if (!launchedUrl) {
throw new Error(`审校器已启动但未返回访问地址。输出:${launch.stdout}`);
throw new Error('审校器已启动但未返回访问地址,请稍后重试');
}
return {
@@ -323,7 +304,6 @@ async function launchEditor(): Promise<LaunchResponse> {
reused: false,
reviewRoot: reviewRoot(),
version: readExpectedVersion(),
accessToken,
};
} catch (error) {
return {
@@ -126,7 +126,7 @@ const BookItem: React.FC<BookItemProps> = ({
role='none'
className={clsx(
'book-item flex',
mode === 'grid' && 'h-full w-full flex-col justify-end',
mode === 'grid' && 'h-full flex-col justify-end',
mode === 'list' && 'min-h-28 flex-row gap-4 overflow-hidden',
mode === 'list' ? 'library-list-item' : 'library-grid-item',
appService?.hasContextMenu ? 'cursor-pointer' : '',
@@ -90,7 +90,6 @@ interface BookshelfProps {
handlePushLibrary: () => Promise<void>;
booksTransferProgress: { [key: string]: number | null };
categoryFilter?: LibraryCategoryFilter;
sidebarVisible?: boolean;
}
/**
@@ -101,7 +100,6 @@ interface BookshelfProps {
type BookshelfListContext = {
autoColumns: boolean;
fixedColumns: number;
sidebarVisible: boolean;
/**
* The recently-read shelf, rendered in the Virtuoso header so it scrolls with
* the shelf content (not sticky). `null` when hidden. Passed through context
@@ -111,12 +109,9 @@ type BookshelfListContext = {
recentShelfHeader: React.ReactNode;
};
const BOOKSHELF_GRID_BASE_CLASSES =
'bookshelf-items transform-wrapper grid gap-x-4 px-4 sm:gap-x-0 sm:px-2';
const BOOKSHELF_GRID_DEFAULT_COLUMNS =
const BOOKSHELF_GRID_CLASSES =
'bookshelf-items transform-wrapper grid gap-x-4 px-4 sm:gap-x-0 sm:px-2 ' +
'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';
@@ -127,11 +122,7 @@ const BookshelfGridList: GridComponents<BookshelfListContext>['List'] = React.fo
<div
ref={ref}
data-testid={testId}
className={clsx(
BOOKSHELF_GRID_BASE_CLASSES,
context?.sidebarVisible ? BOOKSHELF_GRID_SIDEBAR_COLUMNS : BOOKSHELF_GRID_DEFAULT_COLUMNS,
className,
)}
className={clsx(BOOKSHELF_GRID_CLASSES, className)}
style={{
...style,
gridTemplateColumns:
@@ -187,7 +178,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({
handlePushLibrary,
booksTransferProgress,
categoryFilter = 'all',
sidebarVisible = false,
}) => {
const _ = useTranslation();
const router = useRouter();
@@ -859,7 +849,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({
coverFit={coverFit as LibraryCoverFitType}
autoColumns={settings.libraryAutoColumns}
fixedColumns={settings.libraryColumns}
sidebarVisible={sidebarVisible}
onOpenBook={openRecentBook}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
@@ -872,7 +861,6 @@ const Bookshelf: React.FC<BookshelfProps> = ({
coverFit,
settings.libraryAutoColumns,
settings.libraryColumns,
sidebarVisible,
openRecentBook,
handleBookUpload,
handleBookDownload,
@@ -884,10 +872,9 @@ const Bookshelf: React.FC<BookshelfProps> = ({
() => ({
autoColumns: settings.libraryAutoColumns,
fixedColumns: settings.libraryColumns,
sidebarVisible,
recentShelfHeader,
}),
[settings.libraryAutoColumns, settings.libraryColumns, sidebarVisible, recentShelfHeader],
[settings.libraryAutoColumns, settings.libraryColumns, recentShelfHeader],
);
const renderBookshelfItem = useCallback(
@@ -3,6 +3,7 @@ import { useCallback, useState } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useAppRouter } from '@/hooks/useAppRouter';
import { useLongPress } from '@/hooks/useLongPress';
import { Menu as TauriMenu } from '@tauri-apps/api/menu';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
@@ -18,6 +19,7 @@ import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
import { Book, BooksGroup, ReadingStatus } from '@/types/book';
import { navigateToReviewEditor } from '@/utils/nav';
import {
getBookContextMenuItemIds,
type BookContextMenuItemId,
@@ -141,9 +143,10 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
handleUpdateReadingStatus,
}) => {
const _ = useTranslation();
const router = useAppRouter();
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { openBook } = useOpenBook({ setLoading, handleBookDownload });
const { openBook, makeBookAvailable } = useOpenBook({ setLoading, handleBookDownload });
const [webContextMenu, setWebContextMenu] = useState<WebContextMenuState>(null);
const showBookDetailsModal = useCallback(async (book: Book) => {
@@ -162,6 +165,30 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
[isSelectMode, openBook, toggleSelection],
);
const openBookInReviewEditor = useCallback(
async (book: Book, mode: 'review' | 'translate') => {
const available = await makeBookAvailable(book);
if (!available) return;
const epubPath = appService?.isDesktopApp
? await appService.resolveNativeBookFilePath(book)
: null;
if (appService?.isDesktopApp && !epubPath) {
eventDispatcher.dispatch('toast', {
message: _('Book file is not available locally'),
type: 'warning',
});
return;
}
sessionStorage.setItem(
'reviewEditorLaunchContext',
JSON.stringify(epubPath ? { mode, epubPath } : { mode, bookHash: book.hash }),
);
const params = new URLSearchParams(epubPath ? { mode } : { mode, book_hash: book.hash });
navigateToReviewEditor(router, params);
},
[_, appService, makeBookAvailable, router],
);
const handleGroupClick = useCallback(
(group: BooksGroup) => {
if (isSelectMode) {
@@ -230,6 +257,18 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
showBookDetailsModal(book);
},
},
reviewInEpubEditor: {
text: '用 EPUB 审校器校对',
action: async () => {
await openBookInReviewEditor(book, 'review');
},
},
translateInEpubEditor: {
text: '用 EPUB 审校器翻译',
action: async () => {
await openBookInReviewEditor(book, 'translate');
},
},
bilingual: {
text: _('Bilingual'),
action: async () => {
@@ -22,7 +22,6 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showLeftArrow, setShowLeftArrow] = useState(false);
const [showRightArrow, setShowRightArrow] = useState(false);
const isSingleBookGrid = mode === 'grid' && group.books.length === 1;
const checkScrollArrows = () => {
if (mode === 'list' && scrollContainerRef.current) {
@@ -116,10 +115,7 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
<div
ref={mode === 'list' ? scrollContainerRef : undefined}
className={clsx(
mode === 'grid' &&
(isSingleBookGrid
? 'flex w-full overflow-hidden'
: 'grid w-full grid-cols-2 grid-rows-2 gap-1 overflow-hidden'),
mode === 'grid' && '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' ? 'library-list-item' : 'library-grid-item',
)}
@@ -141,7 +137,7 @@ const GroupItem: React.FC<GroupItemProps> = ({ mode, group, isSelectMode, groupS
key={book.hash}
className={clsx(
'relative aspect-[28/41] h-full',
mode === 'grid' && (isSingleBookGrid ? 'mx-auto' : 'w-full'),
mode === 'grid' && 'w-full',
mode === 'list' && 'flex-shrink-0',
)}
>
@@ -8,7 +8,6 @@ import Menu from '@/components/Menu';
interface ImportMenuProps {
setIsDropdownOpen?: (open: boolean) => void;
menuClassName?: string;
onImportBooksFromFiles: () => void;
onImportBooksFromDirectory?: () => void;
onImportBookFromUrl?: () => void;
@@ -17,7 +16,6 @@ interface ImportMenuProps {
const ImportMenu: React.FC<ImportMenuProps> = ({
setIsDropdownOpen,
menuClassName,
onImportBooksFromFiles,
onImportBooksFromDirectory,
onImportBookFromUrl,
@@ -48,10 +46,7 @@ const ImportMenu: React.FC<ImportMenuProps> = ({
return (
<Menu
className={clsx(
'dropdown-content bg-base-100 rounded-box !relative z-[1] mt-3 p-2 shadow',
menuClassName,
)}
className={clsx('dropdown-content bg-base-100 rounded-box !relative z-[1] mt-3 p-2 shadow')}
onCancel={() => setIsDropdownOpen?.(false)}
>
<MenuItem
@@ -14,7 +14,6 @@ import { useTranslation } from '@/hooks/useTranslation';
import { useLibraryStore } from '@/store/libraryStore';
import { useTrafficLight } from '@/hooks/useTrafficLight';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useIsMobileViewport } from '@/hooks/useIsMobileViewport';
import { debounce } from '@/utils/debounce';
import useShortcuts from '@/hooks/useShortcuts';
import WindowButtons from '@/components/WindowButtons';
@@ -34,7 +33,6 @@ interface LibraryHeaderProps {
onToggleSelectMode: () => void;
onSelectAll: () => void;
onDeselectAll: () => void;
onToggleSidebar: () => void;
}
const LibraryHeader: React.FC<LibraryHeaderProps> = ({
@@ -48,7 +46,6 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
onToggleSelectMode,
onSelectAll,
onDeselectAll,
onToggleSidebar,
}) => {
const _ = useTranslation();
const router = useRouter();
@@ -62,7 +59,6 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
const { isTrafficLightVisible } = useTrafficLight(headerRef);
const iconSize18 = useResponsiveSize(18);
const { safeAreaInsets: insets } = useThemeStore();
const isNarrowViewport = useIsMobileViewport(641);
useShortcuts({
onToggleSelectMode,
@@ -96,7 +92,7 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
if (!insets) return null;
const isMobile = appService?.isMobile || isNarrowViewport;
const isMobile = appService?.isMobile || window.innerWidth <= 640;
return (
<div
@@ -113,17 +109,8 @@ const LibraryHeader: React.FC<LibraryHeaderProps> = ({
}}
>
<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 gap-2 pl-2 sm:pl-4'>
<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'>
<div className='exclude-title-bar-mousedown relative flex w-full items-center pl-4'>
<div className='relative flex h-9 w-full items-center sm:h-7'>
<span className='text-base-content/50 absolute ps-3'>
<FaSearch className='h-4 w-4' />
</span>
@@ -44,8 +44,6 @@ interface LibrarySidebarProps {
onImportBookFromUrl?: () => void;
onOpenCatalogManager: () => void;
onToggleSelectMode: () => void;
onToggleSidebar: () => void;
isViewportResolved: boolean;
}
type CategoryItem = {
@@ -65,8 +63,6 @@ const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
onImportBookFromUrl,
onOpenCatalogManager,
onToggleSelectMode,
onToggleSidebar,
isViewportResolved,
}) => {
const _ = useTranslation();
const router = useRouter();
@@ -164,22 +160,13 @@ const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
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',
'library-sidebar bg-base-200/80 border-base-300/70 hidden h-full w-[248px] shrink-0 flex-col border-r 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>
<MdOutlineMenu className='text-base-content/70 h-5 w-5 shrink-0' />
<div className='min-w-0 flex-1 truncate text-sm font-semibold'>Readest</div>
</div>
@@ -281,7 +268,6 @@ const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
<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} />}
>
@@ -294,8 +280,7 @@ const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
</Dropdown>
<Dropdown
label={_('View Menu')}
className='exclude-title-bar-mousedown dropdown-top dropdown-center'
menuClassName='mb-2 mt-0'
className='exclude-title-bar-mousedown dropdown-top dropdown-start'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiDotsThreeCircle role='none' size={iconSize18} />}
>
@@ -316,8 +301,7 @@ const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
<div className='flex-1' />
<Dropdown
label={_('Settings Menu')}
className='exclude-title-bar-mousedown dropdown-top dropdown-end'
menuClassName='mb-2 mt-0'
className='exclude-title-bar-mousedown dropdown-top dropdown-start'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiGear role='none' size={iconSize18} />}
>
@@ -19,7 +19,6 @@ interface RecentShelfProps {
// Mirror the bookshelf grid's column model so covers are the same size.
autoColumns: boolean;
fixedColumns: number;
sidebarVisible?: boolean;
onOpenBook: (book: Book) => void;
handleBookUpload: (book: Book) => void;
handleBookDownload: (book: Book, options?: { redownload?: boolean; queued?: boolean }) => void;
@@ -111,7 +110,6 @@ const RecentShelf: React.FC<RecentShelfProps> = ({
coverFit,
autoColumns,
fixedColumns,
sidebarVisible = false,
onOpenBook,
handleBookUpload,
handleBookDownload,
@@ -123,9 +121,7 @@ const RecentShelf: React.FC<RecentShelfProps> = ({
// `--rs-gap` mirrors the grid's `gap-x-4 sm:gap-x-0` so the width formula
// subtracts the right gap at each breakpoint.
const colsClass = autoColumns
? 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]'
? '[--rs-cols:3] sm:[--rs-cols:4] md:[--rs-cols:6] xl:[--rs-cols:8] 2xl:[--rs-cols:12]'
: '';
const colsStyle = autoColumns
? undefined
@@ -166,7 +162,7 @@ const RecentShelf: React.FC<RecentShelfProps> = ({
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [measure, books, autoColumns, fixedColumns, sidebarVisible, coverFit]);
}, [measure, books, autoColumns, fixedColumns, coverFit]);
const scrollByPage = (direction: -1 | 1) => {
const el = scrollerRef.current;
@@ -25,7 +25,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useTransferQueue } from '@/hooks/useTransferQueue';
import { navigateToLogin, navigateToProfile } from '@/utils/nav';
import { navigateToLogin, navigateToProfile, navigateToReviewEditor } from '@/utils/nav';
import { tauriHandleSetAlwaysOnTop, tauriHandleToggleFullScreen } from '@/utils/window';
import { setAboutDialogVisible } from '@/components/AboutWindow';
import { setMigrateDataDirDialogVisible } from '@/app/library/components/MigrateDataWindow';
@@ -47,14 +47,9 @@ import { type AppLockDialogMode, useAppLockStore } from '@/store/appLockStore';
interface SettingsMenuProps {
onPullLibrary: (fullRefresh?: boolean, verbose?: boolean) => void;
setIsDropdownOpen?: (isOpen: boolean) => void;
menuClassName?: string;
}
const SettingsMenu: React.FC<SettingsMenuProps> = ({
onPullLibrary,
setIsDropdownOpen,
menuClassName,
}) => {
const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdownOpen }) => {
const _ = useTranslation();
const router = useRouter();
const { envConfig, appService } = useEnv();
@@ -209,6 +204,11 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({
setCacheManagerDialogVisible(true);
};
const handleOpenReviewEditor = () => {
navigateToReviewEditor(router);
setIsDropdownOpen?.(false);
};
const handleRefreshMetadata = async () => {
if (!appService || isRefreshingMetadata) return;
setIsRefreshingMetadata(true);
@@ -318,7 +318,6 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({
className={clsx(
'settings-menu dropdown-content no-triangle',
'z-20 mt-2 max-w-[90vw] shadow-2xl',
menuClassName,
)}
onCancel={() => setIsDropdownOpen?.(false)}
>
@@ -437,6 +436,10 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({
<hr aria-hidden='true' className='border-base-200 my-1' />
<MenuItem label={_('Advanced Settings')}>
<ul className='ms-0 flex flex-col ps-0 before:hidden'>
{(isTauriAppPlatform() ||
(process.env['NODE_ENV'] === 'development' && isWebAppPlatform())) && (
<MenuItem label='EPUB 审校器' onClick={handleOpenReviewEditor} />
)}
<MenuItem label={_('Backup & Restore')} onClick={handleBackupRestore} />
{appService?.canCustomizeRootDir && (
<MenuItem label={_('Change Data Location')} onClick={handleSetRootDir} />
@@ -1,5 +1,4 @@
import React from 'react';
import clsx from 'clsx';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
@@ -16,21 +15,17 @@ import { navigateToLibrary } from '@/utils/nav';
import NumberInput from '@/components/settings/NumberInput';
import MenuItem from '@/components/MenuItem';
import Menu from '@/components/Menu';
import { useIsMobileViewport } from '@/hooks/useIsMobileViewport';
interface ViewMenuProps {
setIsDropdownOpen?: (isOpen: boolean) => void;
menuClassName?: string;
}
const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen, menuClassName }) => {
const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen }) => {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const isPhoneViewport = useIsMobileViewport();
const isCompactViewport = useIsMobileViewport(1024);
const viewMode = settings.libraryViewMode;
const coverFit = settings.libraryCoverFit;
@@ -175,7 +170,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen, menuClassName })
return (
<Menu
className={clsx('view-menu dropdown-content no-triangle z-20 mt-2 shadow-2xl', menuClassName)}
className='view-menu dropdown-content no-triangle z-20 mt-2 shadow-2xl'
onCancel={() => setIsDropdownOpen?.(false)}
>
{/* View Mode */}
@@ -206,12 +201,11 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ setIsDropdownOpen, menuClassName })
value={columns}
disabled={viewMode === 'list'}
onChange={handleSetColumns}
min={isPhoneViewport ? 1 : isCompactViewport ? 2 : 3}
max={isPhoneViewport ? 4 : isCompactViewport ? 6 : 12}
min={window.innerWidth < 640 ? 1 : window.innerWidth < 1024 ? 2 : 3}
max={window.innerWidth < 640 ? 4 : window.innerWidth < 1024 ? 6 : 12}
/>
}
onClick={() => handleToggleAutoColumns()}
transient
/>
{/* Book Covers */}
+23 -113
View File
@@ -122,7 +122,6 @@ import DropIndicator from '@/components/DropIndicator';
import SettingsDialog from '@/components/settings/SettingsDialog';
import ModalPortal from '@/components/ModalPortal';
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). */
const AUTO_IMPORT_MIN_SIZE_BYTES = 20 * 1024;
@@ -159,19 +158,6 @@ const LAST_IMPORT_FOLDER_MIN_SIZE_KEY = 'readest:lastImportFolderMinSizeKB';
* dialog forces the toggle ON regardless of this value.
*/
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 searchParams = useSearchParams();
@@ -232,10 +218,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
const [isSelectNone, setIsSelectNone] = useState(false);
const [showDetailsBook, setShowDetailsBook] = useState<Book | 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
// than a boolean because we need a default starting directory to seed
// the path field, and we want the dialog to remain mounted long
@@ -318,51 +300,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
useClipUrlIngress();
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();
// Library-scoped auto-sync for the active third-party cloud provider (WebDAV /
// Google Drive): keeps library.json current on import / delete / book-close,
@@ -1658,11 +1595,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
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 (
<div
@@ -1674,39 +1606,21 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
appService?.hasRoundedWindow && isRoundedWindow && 'window-border rounded-window',
)}
>
{isLibrarySidebarVisible && (
<>
{!isDesktopLibraryViewport && (
<Overlay className='z-40 bg-black/20' onDismiss={closeLibrarySidebar} />
)}
<LibrarySidebar
books={libraryBooks}
isSelectMode={isSelectMode}
onPullLibrary={pullLibrary}
onImportBooksFromFiles={handleImportBooksFromFiles}
onImportBooksFromDirectory={
appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
}
onImportBookFromUrl={
isTauriAppPlatform() ? () => setShowImportFromUrl(true) : undefined
}
onOpenCatalogManager={handleShowOPDSDialog}
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
onToggleSidebar={closeLibrarySidebar}
isViewportResolved={isLibraryViewportResolved}
/>
</>
)}
<LibrarySidebar
books={libraryBooks}
isSelectMode={isSelectMode}
onPullLibrary={pullLibrary}
onImportBooksFromFiles={handleImportBooksFromFiles}
onImportBooksFromDirectory={
appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
}
onImportBookFromUrl={isTauriAppPlatform() ? () => setShowImportFromUrl(true) : undefined}
onOpenCatalogManager={handleShowOPDSDialog}
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
/>
<div className='flex min-w-0 flex-1 flex-col overflow-hidden'>
<div
className={clsx(
'relative top-0 z-40 w-full',
isLibraryViewportResolved
? showLibraryHeader
? 'block'
: 'hidden'
: 'block md:hidden',
)}
className='relative top-0 z-40 w-full md:hidden'
role='banner'
tabIndex={-1}
aria-label={_('Library Header')}
@@ -1726,7 +1640,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
onSelectAll={handleSelectAll}
onDeselectAll={handleDeselectAll}
onToggleSidebar={openLibrarySidebar}
/>
<progress
aria-label={_('Library Sync Progress')}
@@ -1739,18 +1652,16 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
max='100'
/>
</div>
{!showLibraryHeader && (
<progress
aria-label={_('Library Sync Progress')}
aria-hidden={isSyncing ? 'false' : 'true'}
className={clsx(
'progress progress-success hidden h-1 rounded-none transition-opacity duration-200 md:block',
isSyncing ? 'opacity-100' : 'opacity-0',
)}
value={syncProgress * 100}
max='100'
/>
)}
<progress
aria-label={_('Library Sync Progress')}
aria-hidden={isSyncing ? 'false' : 'true'}
className={clsx(
'progress progress-success hidden h-1 rounded-none transition-opacity duration-200 md:block',
isSyncing ? 'opacity-100' : 'opacity-0',
)}
value={syncProgress * 100}
max='100'
/>
{(loading || isSyncing) && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading />
@@ -1828,7 +1739,6 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
handleLibraryNavigation={handleLibraryNavigation}
booksTransferProgress={booksTransferProgress}
handlePushLibrary={pushLibrary}
sidebarVisible={isLibrarySidebarDockedVisible}
/>
</div>
</div>
@@ -651,6 +651,8 @@ export type BookContextMenuItemId =
| 'markAbandoned'
| 'clearStatus'
| 'showDetails'
| 'reviewInEpubEditor'
| 'translateInEpubEditor'
| 'bilingual'
| 'showInFinder'
| 'searchGoodreads'
@@ -753,7 +755,7 @@ export const getBookContextMenuItemIds = (book: Book): BookContextMenuItemId[] =
}
ids.push('showDetails');
if (book.format?.toUpperCase() === 'EPUB') {
ids.push('bilingual');
ids.push('bilingual', 'reviewInEpubEditor', 'translateInEpubEditor');
}
ids.push('showInFinder', 'searchGoodreads');
if (book.uploadedAt && !book.downloadedAt) ids.push('download');
@@ -212,17 +212,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
navigateBackToLibrary();
};
const handleCloseReaderToLibrary = async () => {
const dirtyReview = Object.values(useReviewModeStore.getState().books).some(
(book) => book.hasUnsavedEdit,
);
if (
dirtyReview &&
appService &&
!(await appService.ask('审校器中有未保存的段落修改,确定返回书库吗?'))
) {
return;
}
const handleCloseReaderToLibrary = () => {
return handleCloseBooks(true);
};
@@ -240,16 +230,6 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
// SPA navigation in the main window (or on web) keeps the webview alive:
// TTS may continue headless. Non-main Tauri windows close their webview
// 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);
if (isTauriAppPlatform()) {
const currentWindow = getCurrentWindow();
@@ -270,14 +250,6 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
// Header X / pane close: an SPA-side close on web and the main window.
// The Tauri reader-window branches below destroy their webview, which
// 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);
if (sideBarBookKey === bookKey) {
setSideBarBookKey(getNextBookKey(sideBarBookKey));
@@ -1,7 +1,6 @@
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';
@@ -148,7 +147,7 @@ const markParagraph = (
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 (role === 'target' && row.current_html) {
if (!htmlElement.hasAttribute(ORIGINAL_HTML_ATTR)) {
htmlElement.setAttribute(ORIGINAL_HTML_ATTR, htmlElement.innerHTML);
}
@@ -195,7 +194,6 @@ 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);
@@ -207,24 +205,6 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
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;
@@ -255,7 +235,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
if ('stopImmediatePropagation' in event) {
event.stopImmediatePropagation();
}
void selectReviewRow(rowId);
selectRow(bookKey, rowId);
};
const handleSelectionEnd = (event: Event) => {
@@ -264,7 +244,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
window.setTimeout(() => {
const target = selectedReviewTarget(doc);
const rowId = target?.getAttribute(ROW_ATTR);
if (rowId) void selectReviewRow(rowId);
if (rowId) selectRow(bookKey, rowId);
}, 0);
};
@@ -312,7 +292,7 @@ const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
clearReviewMarks(doc);
}
};
}, [appService, bookDoc, bookKey, enabled, rows, selectRow, view]);
}, [bookDoc, bookKey, enabled, rows, selectRow, view]);
useEffect(() => {
if (!view || !enabled || !rows.length) return;
@@ -4,15 +4,12 @@ import React from 'react';
import Button from '@/components/Button';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { isWebAppPlatform } from '@/services/environment';
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';
import { launchInlineReviewEditor, loadInlineReviewData } from '@/services/reviewEditorService';
interface ReviewModeTogglerProps {
bookKey: string;
@@ -35,8 +32,10 @@ const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
const enabled = !!reviewState?.enabled;
const loading = !!reviewState?.loading;
const canLaunchReviewEditor =
!!appService?.isDesktopApp || (isWebAppPlatform() && process.env['NODE_ENV'] === 'development');
if (!canLaunchInlineReviewEditor(appService) || bookData?.book?.format !== 'EPUB') return null;
if (!canLaunchReviewEditor || bookData?.book?.format !== 'EPUB') return null;
const handleToggleReviewMode = async () => {
if (appService?.isMobile) {
@@ -44,13 +43,6 @@ const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
}
if (enabled) {
if (
reviewState?.hasUnsavedEdit &&
appService &&
!(await appService.ask('当前段落有未保存的修改,确定关闭审校模式吗?'))
) {
return;
}
setBookEnabled(bookKey, false);
return;
}
@@ -60,34 +52,15 @@ const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
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,
);
const data = await loadInlineReviewData(launch.url, launch.sessionId);
setBookData(bookKey, {
baseUrl: launch.url,
accessToken: launch.accessToken,
sessionId: launch.sessionId,
reviewRoot: launch.reviewRoot,
version: launch.version,
@@ -121,7 +94,7 @@ const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
onClick={handleToggleReviewMode}
disabled={loading}
label={enabled ? _('Disable Review Mode') : _('Review Mode')}
className={clsx(enabled && 'bg-base-300/50')}
className={clsx(enabled && 'bg-base-300/50', loading && 'animate-pulse')}
/>
);
};
@@ -25,13 +25,12 @@ import { useReaderStore } from '@/store/readerStore';
import { useReviewModeStore } from '@/store/reviewModeStore';
import { useThemeStore } from '@/store/themeStore';
import { getPanelTopInset } from '@/utils/insets';
import { openExternalUrl } from '@/utils/open';
import { useReviewPanelDrag } from '../hooks/useReviewPanelDrag';
import { useReviewPanelFloatingResize } from '../hooks/useReviewPanelFloatingResize';
import {
downloadReviewedEpub,
exportReviewedEpub,
generateReviewFeedback,
isReviewEditDirty,
loadReviewGlossary,
retranslateReviewRow,
saveReviewGlossary,
@@ -489,7 +488,6 @@ const ReviewPanel: React.FC = () => {
const togglePanelPin = useReviewModeStore((state) => state.togglePanelPin);
const setPanelWidth = useReviewModeStore((state) => state.setPanelWidth);
const setPanelHeight = useReviewModeStore((state) => state.setPanelHeight);
const setBookUnsavedEdit = useReviewModeStore((state) => state.setBookUnsavedEdit);
const updateRow = useReviewModeStore((state) => state.updateRow);
const setBookData = useReviewModeStore((state) => state.setBookData);
const bookState = useReviewModeStore((state) =>
@@ -510,7 +508,6 @@ const ReviewPanel: React.FC = () => {
const [exportInfo, setExportInfo] = useState('');
const [saving, setSaving] = useState(false);
const [retranslating, setRetranslating] = useState(false);
const [exporting, setExporting] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const isMobile = useIsMobileViewport();
const panelTopInset = getPanelTopInset({
@@ -530,19 +527,6 @@ const ReviewPanel: React.FC = () => {
[bookState?.rows, bookState?.selectedRowId],
);
const canUseRowActions = Boolean(selectedRow && bookState?.baseUrl);
const hasUnsavedEdit = Boolean(selectedRow && isReviewEditDirty(selectedRow, edit));
useEffect(() => {
if (!activeBookKey) return;
setBookUnsavedEdit(activeBookKey, hasUnsavedEdit);
}, [activeBookKey, hasUnsavedEdit, setBookUnsavedEdit]);
useEffect(() => {
if (!hasUnsavedEdit) return;
const warnBeforeUnload = (event: BeforeUnloadEvent) => event.preventDefault();
window.addEventListener('beforeunload', warnBeforeUnload);
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
}, [hasUnsavedEdit]);
const bookTitle = activeBookKey
? getBookData(activeBookKey)?.book?.title || selectedRow?.document_title || '审校'
@@ -560,7 +544,7 @@ const ReviewPanel: React.FC = () => {
return;
}
setEdit({
current_html: selectedRow.current_html ?? selectedRow.cn_html ?? '',
current_html: selectedRow.current_html || selectedRow.cn_html || '',
marked: Boolean(selectedRow.marked),
issue_type: selectedRow.issue_type || '',
severity: selectedRow.severity || '',
@@ -590,17 +574,6 @@ const ReviewPanel: React.FC = () => {
togglePanelPin();
};
const closePanel = async () => {
if (
hasUnsavedEdit &&
appService &&
!(await appService.ask('当前段落有未保存的修改,确定关闭审校面板吗?'))
) {
return;
}
setPanelVisible(false);
};
const floatingPanelEnabled = !isPanelPinned && !isMobile;
const {
panelPosition,
@@ -693,7 +666,7 @@ const ReviewPanel: React.FC = () => {
setMessage('');
try {
const payload = await saveReviewRow(baseUrl, sessionId, selectedRow.id, edit);
const savedHtml = payload.current_html ?? sanitizeInlineHtml(edit.current_html);
const savedHtml = payload.current_html || sanitizeInlineHtml(edit.current_html);
const nextRow = {
...selectedRow,
...edit,
@@ -752,7 +725,6 @@ const ReviewPanel: React.FC = () => {
const exportEpub = async () => {
if (!baseUrl) return;
setExporting(true);
setExportInfo('');
setMessage('');
try {
@@ -761,14 +733,9 @@ const ReviewPanel: React.FC = () => {
? new URL(payload.download_url, baseUrl).toString()
: '';
setExportInfo(downloadUrl || payload.output || '');
if (downloadUrl) {
await downloadReviewedEpub(downloadUrl, bookState.accessToken);
}
setMessage(`已导出:${payload.output || downloadUrl}`);
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setExporting(false);
}
};
@@ -839,7 +806,7 @@ const ReviewPanel: React.FC = () => {
{isMobile && (
<Overlay
className={clsx('z-[45]', viewSettings?.isEink ? '' : 'bg-black/50 sm:bg-black/20')}
onDismiss={() => void closePanel()}
onDismiss={() => setPanelVisible(false)}
/>
)}
<aside
@@ -926,7 +893,7 @@ const ReviewPanel: React.FC = () => {
type='button'
className='btn btn-ghost h-8 min-h-8 w-8 shrink-0 rounded-full p-0'
title={_('Close')}
onClick={() => void closePanel()}
onClick={() => setPanelVisible(false)}
>
<X className='h-4 w-4' />
</button>
@@ -940,10 +907,7 @@ const ReviewPanel: React.FC = () => {
</div>
) : null}
{bookState.error ? (
<div
className='eink-bordered border-error bg-base-100 text-error rounded-md border p-3 text-sm'
role='alert'
>
<div className='eink-bordered border-error bg-base-100 text-error rounded-md border p-3 text-sm'>
{bookState.error}
</div>
) : null}
@@ -952,29 +916,22 @@ const ReviewPanel: React.FC = () => {
<PanelButton
onClick={generateFeedback}
icon={<FileText className='h-4 w-4' />}
disabled={!baseUrl || hasUnsavedEdit}
disabled={!baseUrl}
>
</PanelButton>
<PanelButton
onClick={exportEpub}
icon={<Download className='h-4 w-4' />}
disabled={!baseUrl || exporting || hasUnsavedEdit}
disabled={!baseUrl}
variant='primary'
>
{exporting ? '导出中' : '导出 EPUB'}
EPUB
</PanelButton>
</div>
{hasUnsavedEdit ? (
<p className='text-base-content/70 text-xs'></p>
) : null}
{message ? (
<div
className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'
role='status'
aria-live='polite'
>
<div className='eink-bordered border-base-300 bg-base-100 rounded-md border p-3 text-sm'>
{message}
</div>
) : null}
@@ -982,9 +939,9 @@ const ReviewPanel: React.FC = () => {
<button
type='button'
className='eink-bordered border-base-300 bg-base-100 hover:bg-base-200 rounded-md border p-3 text-start text-sm'
onClick={() => void downloadReviewedEpub(exportInfo, bookState.accessToken)}
onClick={() => openExternalUrl(exportInfo)}
>
<span className='font-semibold'></span>
<span className='font-semibold'></span>
<span className='text-base-content/60 mt-1 block break-all text-xs'>
{exportInfo}
</span>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,5 @@
import ReviewEditorLauncher from './ReviewEditorLauncher';
export default function ReviewEditorPage() {
return <ReviewEditorLauncher />;
}
+4 -26
View File
@@ -1,14 +1,7 @@
import clsx from 'clsx';
import React, {
useState,
isValidElement,
ReactElement,
ReactNode,
useRef,
useId,
useEffect,
} from 'react';
import React, { useState, isValidElement, ReactElement, ReactNode, useRef, useId } from 'react';
import { useDropdownContext } from '@/context/DropdownContext';
import { Overlay } from './Overlay';
import MenuItem from './MenuItem';
interface DropdownProps {
@@ -81,7 +74,6 @@ const Dropdown: React.FC<DropdownProps> = ({
const dropdownId = useId();
const context = useDropdownContext();
const isOpen = context ? context.openDropdownId === dropdownId : false;
const hasOpenDropdown = context ? context.openDropdownId !== null : isOpen;
const containerRef = useRef<HTMLDivElement>(null);
const [isFocused, setIsFocused] = useState(false);
@@ -116,21 +108,6 @@ 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)
? React.cloneElement(children, {
...(typeof children.type !== 'string' && {
@@ -143,7 +120,8 @@ const Dropdown: React.FC<DropdownProps> = ({
return (
<div ref={containerRef} className={clsx('dropdown-container flex', containerClassName)}>
<div className={clsx('relative', isOpen ? 'z-[60]' : hasOpenDropdown && 'z-[70]')}>
{isOpen && <Overlay onDismiss={() => setIsDropdownOpen(false)} />}
<div className={clsx('relative', isOpen && 'z-50')}>
<button
tabIndex={0}
aria-haspopup='menu'
@@ -1,7 +1,9 @@
import { useEffect, useState } from 'react';
export const useIsMobileViewport = (breakpoint = 640) => {
const [isMobileViewport, setIsMobileViewport] = useState(false);
const readViewport = () =>
typeof window === 'undefined' ? false : window.innerWidth < breakpoint;
const [isMobileViewport, setIsMobileViewport] = useState(readViewport);
useEffect(() => {
if (typeof window === 'undefined') return;
@@ -2,7 +2,7 @@ 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';
import { isTauriAppPlatform } from '@/services/environment';
export type ReviewEditorLaunchResponse = {
ok: boolean;
@@ -10,7 +10,6 @@ export type ReviewEditorLaunchResponse = {
reused?: boolean;
reviewRoot?: string;
version?: string;
accessToken?: string;
sessionId?: string | null;
error?: string;
};
@@ -28,6 +27,10 @@ export type ReviewSessionSummary = {
latest_export?: string;
};
export type ReviewSessionOpenResponse = ReviewSessionSummary & {
has_session?: boolean;
};
export type ReviewSessionPayload = {
has_session: boolean;
id?: string;
@@ -98,15 +101,6 @@ export type ReviewEditPayload = {
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;
@@ -144,35 +138,24 @@ const ensureBaseUrl = (baseUrl: string) => {
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 headers = {
...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
...(resolvedAccessToken ? { Authorization: `Bearer ${resolvedAccessToken}` } : {}),
...(options.headers || {}),
};
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData;
const headers =
options.body === undefined || isFormData
? options.headers
: {
'Content-Type': 'application/json',
...(options.headers || {}),
};
const response = await fetch(url.toString(), {
...options,
headers,
@@ -188,62 +171,64 @@ 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,
);
return sidecarApi(baseUrl, '/api/session/from-path', {
method: 'POST',
body: JSON.stringify({
epub_path: epubPath,
activate: options.activate ?? false,
reset: options.reset ?? false,
}),
});
}
async function createReviewSessionFromBook(
export async function uploadReviewSession(
baseUrl: string,
appService: AppService,
book: Book,
accessToken: string,
file: File,
options: { reset?: boolean } = {},
): 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(), {
formData.set('epub', file, file.name || 'book.epub');
if (options.reset) {
formData.set('reset', '1');
}
return sidecarApi(baseUrl, '/api/upload', {
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 openReviewSession(
baseUrl: string,
sessionId: string,
options: { activate?: boolean } = {},
): Promise<ReviewSessionOpenResponse> {
return sidecarApi(baseUrl, '/api/session/open', {
method: 'POST',
body: JSON.stringify({
session_id: sessionId,
activate: options.activate ?? false,
}),
});
}
export async function launchInlineReviewEditor(
appService: AppService | null | undefined,
book: Book,
tauriPlatform = isTauriAppPlatform(),
): Promise<
ReviewEditorLaunchResponse & { url: string; sessionId: string | null; accessToken: string }
> {
): Promise<ReviewEditorLaunchResponse & { url: string; sessionId: string | null }> {
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 epubPath = isTauriAppPlatform() ? await appService.resolveNativeBookFilePath(book) : null;
if (isTauriAppPlatform() && !epubPath) {
throw new Error('无法定位当前 EPUB 的本机文件路径');
}
const launch = tauriPlatform
? await invoke<ReviewEditorLaunchResponse>('launch_epub_review_editor', { epubPath })
const launch = epubPath
? await invoke<ReviewEditorLaunchResponse>('launch_epub_review_editor', {
epubPath,
})
: await fetch('/api/review-editor/launch', {
method: 'POST',
headers: {
@@ -254,30 +239,27 @@ export async function launchInlineReviewEditor(
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);
? await createReviewSessionFromPath(launch.url, epubPath)
: await uploadReviewSession(launch.url, (await appService.loadBookContent(book)).file);
sessionId = createdSession.id || null;
}
return { ...launch, url: launch.url, sessionId, accessToken: launch.accessToken };
return { ...launch, url: launch.url, sessionId };
}
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),
sidecarApi<ReviewSessionPayload>(baseUrl, '/api/session', {}, sessionId),
sidecarApi<ReviewRowsPayload>(baseUrl, '/api/rows', {}, sessionId),
sidecarApi<ReviewGptConfig>(baseUrl, '/api/gpt/config', {}, sessionId),
]);
return {
session,
@@ -412,25 +394,3 @@ export async function exportReviewedEpub(
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);
}
+6 -23
View File
@@ -12,7 +12,6 @@ export type ReviewBookState = {
loading: boolean;
error: string;
baseUrl: string;
accessToken: string;
sessionId: string | null;
reviewRoot?: string;
version?: string;
@@ -20,7 +19,6 @@ export type ReviewBookState = {
session: ReviewSessionPayload | null;
gptConfig: ReviewGptConfig | null;
selectedRowId: string;
hasUnsavedEdit: boolean;
};
const DEFAULT_PANEL_WIDTH = '32%';
@@ -51,7 +49,6 @@ type ReviewModeStore = {
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;
};
@@ -61,13 +58,11 @@ const emptyBookState = (): ReviewBookState => ({
loading: false,
error: '',
baseUrl: '',
accessToken: '',
sessionId: null,
rows: [],
session: null,
gptConfig: null,
selectedRowId: '',
hasUnsavedEdit: false,
});
const formatSizedValue = (value: number, unit: '%' | 'vh') =>
@@ -158,10 +153,7 @@ export const useReviewModeStore = create<ReviewModeStore>()(
: state.activeBookKey === bookKey
? false
: state.isPanelVisible,
books: withBookState(state.books, bookKey, {
enabled,
...(!enabled ? { hasUnsavedEdit: false } : {}),
}),
books: withBookState(state.books, bookKey, { enabled }),
})),
setBookData: (bookKey, data) =>
set((state) => ({
@@ -172,21 +164,12 @@ export const useReviewModeStore = create<ReviewModeStore>()(
}),
})),
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 }),
activeBookKey: bookKey,
isPanelVisible: true,
books: withBookState(state.books, bookKey, {
selectedRowId: rowId,
}),
})),
updateRow: (bookKey, row) =>
set((state) => {
+8
View File
@@ -132,6 +132,14 @@ export const navigateToLibrary = (
router.replace(`/library${queryParams ? `?${queryParams}` : ''}`, navOptions);
};
export const navigateToReviewEditor = (
router: ReturnType<typeof useRouter>,
queryParams?: string | URLSearchParams,
) => {
const query = queryParams?.toString() || '';
router.push(`/review-editor${query ? `?${query}` : ''}`);
};
// 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.
// In a dedicated reader window we close the window itself, ensuring the main
@@ -1,135 +1,293 @@
# Readest 内嵌 EPUB 审校维护手册
# EPUB 审校器长期维护手册
## 目标与边界
当前审校器是 Readest 原生阅读功能的增强模式,不再是独立应用。用户在阅读页顶栏点击“校”,直接在 Foliate 正文中选择中文或日文段落,并通过右侧面板完成修改、标记、重翻、反馈和导出
本工具服务于中日双语 EPUB 的网页审校:长篇连续阅读、逐段修改中文译文、标记翻译问题、导出反馈与修订 EPUB。它也提供可选的书架入口 AI 翻译模块,用于对已入书架的日语 EPUB 发起 API 辅助首译,并生成中日双语或纯中文 EPUB。翻译质量规则仍由项目根目录的 `AGENTS.md``rules/translation_rules.md`、当前系列的 `series-style.md` 与术语表共同约束
不得恢复或重新引入以下旧入口:
AI 翻译模块是可选辅助首译入口,不替代长期项目里的候选词确认、复审、QA 与正式交付流程。用户在网页中留下的编辑、问题备注、长期规则建议,需要由 Codex 后续读取反馈文件后再沉淀到系列风格或候选术语中。网页术语表编辑是用户显式操作正式术语表的入口,保存时会直接写入配置路径指向的 JSON 文件。
- `/review-editor` 页面
- 书库设置菜单中的独立审校器入口
- 书籍右键菜单中的独立校对/翻译入口
- `tools/epub-review-editor/static/` 独立网页
- `open_editor.ps1``epub-reviewer:dev` 独立启动链路
## 数据模型与会话目录
整书翻译、独立审校书架、独立目录/检索页面等功能不属于内嵌审校模式,不应迁入阅读页。
## 必须保留的链路
以下代码是新内嵌审校功能的依赖,不得作为旧迁移代码删除:
- `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
`--review-root` 是唯一需要备份的运行数据目录。典型结构:
```text
<review-root>/
epub_review_sessions/
app_state.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>/
source.epub
extracted/
review_state/
epub_entries.json
rows.json
state.json
session.json
gpt_config.json
state.json
feedback/
translation_feedback.jsonl
feedback_for_codex.md
exports/
```
- `rows.json` 保存初始解析结果,不因用户编辑而重写。
- `state.json.edits` 保存逐段修改和标记。
- session 级 GPT 配置保存模型、提示词和术语表路径,但不保存 API Key。
- API Key 只保存在 review root 的本地配置中,任何公开响应都不得包含密钥。
- 导出前必须写回已保存的中文修改,并保持原 EPUB 图片和结构。
兼容要求:
## API 契约
- `rows.json` 是初始解析结果,不应因用户编辑而重写。
- `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 桌面端入口委托 Tauri command `launch_epub_review_editor` 启动或复用本地 sidecar;dev-web 入口继续使用 `/api/review-editor/launch`。两者都应创建 `.venv`、安静检测 Flask 依赖、缺失时自动安装依赖、复用同版本且 `review_root` 一致的已运行服务或启动 `server.py --daemon`;该入口不得硬编码用户私有路径。桌面端从书架进入时,文件路径只交给 Tauri command 注册 sessionReadest 页面 URL 应改用 `session_id`,避免长期暴露本机 EPUB 路径。
- Readest `/review-editor` 的主路径必须是原生 React 功能块,校对和翻译功能直接调用本地 sidecar API;旧 `static/index.html` 界面只保留为独立启动、调试或应急 fallback,不再作为桌面迁移验收的主界面。若 React 直接访问 loopback sidecarsidecar 只能对本机 Readest/Tauri 来源返回受限 CORS,不得开放任意 Origin。
- Readest 原生阅读页可提供内嵌审校模式,但只能作为当前 EPUB 的阅读增强:顶栏“校”按钮启动/复用 sidecar、注册当前书 session、读取 `/api/rows``/api/gpt/config`,在 Foliate iframe 正文中只标记对应的中日双语段落;点击或选中日文/中文段落时打开同一条审校记录。右侧面板可复用旧审校器右侧栏能力(编辑中文、问题类型、严重程度、标签、备注、长期规则、GPT 配置、术语表、单段重翻、反馈生成、导出),但不显示独立段落列表;GPT 配置与术语表应放在面板底部,避免挤占逐段编辑区。中文编辑区的译文预览默认折叠;注音、注释和保存按钮应靠近“修改中文译文”标题,注音/注释操作应对当前选择位置插入可直接改写的内联标签并自动展开预览,便于保存前确认格式。桌面端右侧面板必须保留固定按钮:固定时作为阅读布局的一列停靠并压缩正文,不固定时作为浮动面板覆盖在正文上方,可横向/纵向拖动位置,并可调整宽度和高度;固定/取消固定或调整固定面板宽度后,应尽量恢复用户原先正在阅读的位置。面板内容在窄宽度下应换行和纵向堆叠,避免文字被截断。固定状态、宽度与浮动高度可作为本地布局偏好持久化,但不得把当前书审校行、session 数据或 API Key 写入该偏好。浮动模式拖动和宽高调整应使用统一的 pointer 事件逻辑,窗口尺寸或移动/桌面断点变化时要重新夹取到可视区域内。移动窄屏仍可用抽屉/底部面板。不得迁入整书翻译、书架管理等无关功能。
内嵌模式至少依赖:
## 目录与插图
- `GET /api/version`
- `POST /api/session/from-path`
- `POST /api/upload`(本地 `dev-web` 使用 multipart 上传,内嵌调用必须传 `activate=false` 与稳定 `book_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=...`
- 导出、反馈和 EPUB 资源下载路由
结构接口 `/api/structure` 应保持向后兼容:
中文 HTML 清洗必须允许必要的 `ruby/rb/rt/rp/mark/span` 内联标签并移除脚本。保存响应应返回清洗后的 `current_html`,前端只能用清洗结果更新 store 和正文预览。
- text chapter: `kind: "text"``parts``row_count``touched_count``marked_count`
- 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 令牌。
章节来源优先级:
桌面内嵌模式通过本机 EPUB 路径创建 session;本地 `dev-web` 中书籍位于 IndexedDB,不能把逻辑路径交给 Python,必须读取当前 `File` 后上传到 `/api/upload`。上传必须携带 Readest 书籍 hash 作为稳定 `book_key`,让同一本书再次开启审校时复用原 session。部署版 Web 没有本地 Node/Python/文件系统链路,当前不在支持范围内。
1. EPUB 内部目录页的中文条目
2. `nav.xhtml` / `toc.xhtml`
3. 正文 `<h1-6>` 标题
4. 文件名 fallback
## 前端验收标准
不要把正文首段或第一句对白作为章节标题 fallback。宁可显示 `part0000`,也不要让目录被正文内容污染。
- 审校模式关闭时,原阅读、书库和其他功能行为不变。
- 点击顶栏“校”能启动或复用 sidecar,并注册当前 EPUB session。
- 只有中日双语段落可选择;点击或文本选择任一语言都打开同一条审校记录。
- 关闭审校模式后,正文高亮、事件监听和临时预览全部清理。
- 右侧面板不显示独立段落列表;API、提示词和术语表位于内容末尾
- 标签表示检索分类,本段备注记录当前问题,长期规则建议用于后续人工沉淀,三者不得混为一个字段
- 译文预览默认折叠;注音和注释按钮在当前选区插入可编辑标签并自动展开预览
- 保存按钮位于中文编辑区附近,保存后正文立即反映清洗后的译文
- 固定时面板挤开正文,切换固定状态或调整固定宽度后恢复原阅读位置
- 浮动时面板不挤开正文,可横向和纵向拖动,并可调整宽度和高度
- 面板变窄时文字和控件换行,所有内容仍可滚动到并完整操作
- 移动窄屏使用抽屉或底部面板,不遮挡主要操作
## 阅读与侧栏交互
验收口径:
- 顶栏左上角必须保留“书架”入口;书架是独立页面,列出 `--review-root` 下历史打开过的审校书籍/会话
- 点击书架条目必须通过会话打开流程进入对应审校页面,并恢复该书上次阅读位置;从审校页进入书架或切换书籍前必须保存当前阅读位置并沿用未保存编辑保护
- 没有历史书籍时,书架显示空状态并提供“打开新的 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 与插图条目,不得只跳顶层大章节。
- 点击正文段落打开右侧精修与重翻面板。
- 在 Readest 原生阅读页启用“校”审校模式后,点击或选中 Foliate 正文里的日文源段或中文译文段必须打开同一条审校记录;未开启审校模式时不得改变普通阅读、翻页、标注或笔记行为。
- 右侧面板打开或关闭导致正文宽度变化时,应恢复用户原来正在看的段落位置。
- 刷新页面、重启服务或从已有会话重新打开 EPUB 时,应恢复到上次阅读的章节/part/插图、段落和滚动位置。
- 移动窄屏下,侧栏和右侧面板应以抽屉/底部面板方式显示,不遮死主要操作。
## 编辑、反馈与导出
保存本段时必须:
- sanitize 中文 HTML,但允许 `ruby/rb/rt/rp/mark/span` 等必要内联标签。
- 记录 `before_cn_html``after_cn_html`、问题类型、严重程度、标签、备注、长期规则建议。
- 更新 `feedback/translation_feedback.jsonl`
- `/api/row/<row_id>` 保存响应应返回服务端清洗后的 `current_html`,原生 Readest 面板和 Foliate 审校标记必须以后端规范化结果或同等前端 sanitize 结果更新本地 store,不能把用户输入的原始 HTML 直接写回正文 iframe。
- 原生 Readest 阅读页的 inline 审校 API 调用必须携带当前书的 `session_id``/api/session``/api/rows``/api/row/<row_id>``/api/row/<row_id>/retranslate``/api/feedback``/api/export` 与导出下载都应支持 session-scoped 调用,不能依赖 sidecar 全局 active session,以免多书格子或 `/review-editor` 页面切换会话时串写。
生成反馈时必须:
- 写入 `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.py`
版本号使用 `version = "0.1.0"` 格式
- PATCHbug 或兼容性小修
- MINOR:向后兼容的能力或 API 扩展
- MAJOR:删除入口、破坏 API 或改变持久化行为
| 层级 | 触发条件 |
| --- | --- |
| PATCH | 修复 bug 或兼容性小修 |
| 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。
发版时必须同步:
- `version.py`
- `static/index.html` 顶部版本徽标 fallback
- `README.md` 当前版本与版本说明
- 必要时同步 `MAINTENANCE.md` 中的维护口径
版本文档同步边界:
- 审校器自身行为、API、配置、前端交互变化,同步本文件。
- 会影响整个翻译项目的规则变化,同步项目根目录 `AGENTS.md``rules/translation_rules.md`,不要只写在审校器维护手册中。
- 对外最小包内容、启动方式或发布说明变化,同步 README。
## 回归清单
次修改至少执行
轮改动至少检查
```powershell
python -B -m py_compile tools/epub-review-editor/server.py tools/epub-review-editor/version.py
python tools/epub-review-editor/test_inline_review_session_scope.py
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
$env:READEST_REVIEW_E2E='1'; pnpm exec playwright test e2e/tests/review-mode.spec.ts --project=chromium
```
涉及 Tauri 配置或 Rust 启动器时继续执行:
```powershell
pnpm fmt:check
pnpm clippy:check
pnpm test:rust
```
人工验收:打开桌面端和 Web 端,在真实中日双语 EPUB 中完成开启审校、选段、编辑、注音/注释、保存、重翻、反馈、导出、固定/浮动切换、拖动和宽高调整。确认书库与设置菜单中不再出现旧独立审校器入口。
- `python -B -m py_compile tools/epub-review-editor/server.py tools/epub-review-editor/version.py`
- `node --check tools/epub-review-editor/static/app.js`
- `powershell -NoProfile -ExecutionPolicy Bypass -File apps/readest-app/tools/epub-review-editor/open_editor.ps1` 能启动服务并打开书架/上传页
- Readest 桌面端 `invoke('launch_epub_review_editor')` 能启动或复用 sidecar,成功时返回 sidecar URL、版本、review root 和可选 session iddev-web 中 `POST /api/review-editor/launch` 必须带 `x-readest-review-editor-launch: 1`,只能从本机入口调用
- Readest `/review-editor` 页面必须提供“校对”“翻译”两个原生 React 功能块,切换后直接调用 sidecar API,不得把旧静态审校器 iframe 作为主工作区;旧网页只能作为外部打开/调试 fallback
- Readest 原生阅读页顶栏“校”按钮必须能启动/复用 sidecar,当前 EPUB 的中日双语段落必须能被 Foliate iframe 内高亮,并能通过点击或文本选择打开右侧审校面板;关闭审校模式后高亮、事件监听与临时译文预览必须清理
- Readest 原生阅读页右侧审校面板必须支持保存 `/api/row/<row_id>`、重翻 `/api/row/<row_id>/retranslate`、配置 `/api/gpt/config`、读取/保存 `/api/glossary`、生成 `/api/feedback` 与导出 `/api/export`
- 校对功能块至少能加载 `/api/rows``/api/structure`,选择行,保存 `/api/row/<row_id>`,调用 `/api/row/<row_id>/retranslate`,生成反馈 `/api/feedback`,并导出 `/api/export`
- 翻译功能块至少能读取 `/api/session/<session_id>/translation-source``/api/translation/defaults`,保存 `/api/gpt/config`,保存同系列 `/api/series/<series_id>/config`,启动 `/api/translation/start`,轮询 `/api/translation/jobs/<job_id>`,完成后能打开输出 session
- Readest 书架 EPUB 右键进入校对/翻译时,Tauri command 应先创建或复用对应 session,并将 `/review-editor` 地址整理为 `session_id`,不把本机 EPUB 路径长期留在页面 URL 中
- 审校器静态页支持 `mode=review|translate&session_id=<id>` 直达已有会话,支持 `epub_path=<path>` 先创建/复用会话再进入对应功能
- 重复执行一键入口时优先复用同版本已运行服务;如需新起服务,端口探测不得允许多个服务同时监听同一端口
- `git diff --check`
- `/api/version`
- `/api/session`
- `/api/structure`
- `/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`
- 首个插图 `/api/asset/...` 返回 200 且浏览器可显示
- 非白名单或非 raster asset 不能被 `/api/asset/...` 读取
- 目录按钮、检索按钮、隐藏侧栏、点击段落打开右侧面板
- 阅读段落下方没有编号和“快速编辑/快速标记”
- 未配置 API Key 时重翻返回清晰错误
- 配置保存后 `/api/gpt/config` 不包含密钥字段
- `/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 文件
@@ -0,0 +1,58 @@
# Readest EPUB 审校器迁移记录
## 当前定位
本目录是从长期翻译项目的 EPUB 审校器迁移进 Readest 的桌面端基座。当前保留原 Python/Flask 后端作为本地 sidecarReadest 负责提供入口、启动本地 sidecar,并在 `/review-editor` 页面中用原生 React 功能块承载“校对”和“翻译”核心流程。
## 入口
- Readest 书架页菜单:`Settings Menu -> Advanced Settings -> EPUB 审校器`
- Next 路由:`/review-editor`
- 桌面启动命令:`launch_epub_review_editor`
- dev-web 启动 API`POST /api/review-editor/launch`
- 开发脚本:`pnpm epub-reviewer:dev`
Readest 桌面端通过 Tauri command 启动本地 sidecar;本地 `dev-web` 继续保留 Next API route 作为开发 fallback。
从 Readest 书架进入单本 EPUB 时,桌面端先把本机路径交给 Tauri command 创建或复用审校 session,再用 `session_id` 打开校对/翻译功能块;页面 URL 不长期保留本机 EPUB 路径。
Readest dev-web 页面带跨源隔离头;本地 sidecar 会为 HTML 入口返回限定本机 Readest 来源的 `frame-ancestors`,并发送 `Cross-Origin-Embedder-Policy: require-corp``Cross-Origin-Resource-Policy: cross-origin`,旧静态网页 fallback 因此仍可被外部打开。React 功能块直接访问 loopback APIsidecar 只对本机 Readest/Tauri 来源返回受限 CORS。
## 运行数据
默认审校数据写入:
```text
apps/readest-app/epub_review_sessions/
```
可用环境变量覆盖:
```text
READEST_REVIEW_ROOT=<absolute path>
```
该目录包含书架索引、GPT 配置、翻译任务、审校 session、反馈和导出文件,必须保持在 git 之外。
## 迁移边界
当前阶段不重写审校器业务逻辑,不把 Flask API 立即拆成 Next API 或 Tauri command。这样可以先保留已验证的功能闭环:书架、上传、双语审校、术语表、GPT 重翻、整书 AI 翻译和导出。
`static/index.html` + `static/app.js` 继续保留,用于独立启动、调试和应急 fallback;Readest 桌面迁移验收以 React 功能块为准,不再以 iframe 中显示旧 UI 为完成标准。
后续原生化顺序建议:
1. 继续收敛校对/翻译功能块,把术语表编辑、书架分类和阅读位置恢复等长尾操作逐步改成 Readest 原生 React 控件。
2. 把 Python/Flask sidecar 打包成独立可执行 sidecar,减少对用户本机 Python 的依赖。
3. 逐步把书架/审校/翻译 UI 拆成 React 组件,最后再替换 Flask API。
## 验收标准
- 桌面端与本地 `dev-web` 环境下,`/review-editor` 能从 Readest 书架菜单进入。
- `/review-editor` 提供“校对”“翻译”两个原生 React 功能块,主工作区不渲染旧静态审校器 iframe。
- Readest EPUB 书籍右键菜单能直接进入审校器校对或翻译。
- 首次进入会创建 `.venv`、安装 Flask 依赖并启动 `server.py --daemon --no-browser`
- 已运行同版本审校器时优先复用 `localhost:5177+` 端口,不重复启动。
- 校对功能块能加载行、保存译文/标记、单段重翻、生成反馈并导出 EPUB。
- 翻译功能块能读取可翻译正文块、保存 API/提示词配置、启动任务、轮询进度,并打开输出 session。
- 审校数据目录不会进入 git。
- 原审校器 `server.py` 能通过 Python 编译检查,迁移入口 TypeScript 能通过类型检查。
@@ -1,95 +1,238 @@
# Readest 内嵌 EPUB 审校后端
# 双语 EPUB 审校编辑器
当前版本:`2.0.0`
当前版本:`0.16.5`
本目录只为 Readest 阅读页内的审校模式提供本地 sidecar API。打开 EPUB 后,点击阅读页顶栏的“校”按钮即可启动或复用服务,并在原阅读界面中选择中日文段落进行审校
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件
## 当前边界
## 版本规则
保留的用户入口只有 Readest 阅读页内嵌审校模式。旧版独立审校器已经删除,包括
维护版本号使用 `version = "0.1.0"` 这种语义化格式
- `/review-editor` 独立页面
- 书库设置中的“EPUB 审校器”入口
- 书籍右键菜单中的独立校对和翻译入口
- `static/` 浏览器前端、独立启动脚本和开发脚本
| 层级 | 触发条件 |
| --- | --- |
| PATCH | 修复 bug 或兼容性小修 |
| MINOR | 新增向后兼容能力、API 字段或可选配置 |
| MAJOR | 破坏兼容的 API、配置或行为变更 |
`server.py``version.py` `requirements.txt` 仍由桌面端打包,因为内嵌审校模式依赖它们完成 EPUB 解析、会话保存、重翻、术语表、反馈和导出。`src/app/api/review-editor/launch` 也仍用于本地 `dev-web` 启动 sidecar,不是旧独立页面
本次从 `0.1.0` 升到 `0.2.0`,因为新增了独立上传、会话管理、下载接口与可选 `--host` 配置,且保持原命令行 EPUB 路径用法兼容
## 使用方式
`0.2.1` 修复目录章节识别:优先读取 EPUB 内部目录页的中文标题,并把目录锚点之间的多个正文 part 合并为同一章;左侧目录增加章节展开/收起按钮。
桌面端:
`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 的跨源隔离页面。
`0.14.0` 新增 Readest 桌面端功能块入口:Tauri command 可在桌面端启动或复用本地审校 sidecar,Readest 内提供“校对”“翻译”两个功能块,并支持从 Readest 书架 EPUB 右键直接进入校对或 AI 翻译。审校器静态页新增 `mode=review|translate``session_id``epub_path` URL 参数入口。
从 Readest 桌面书架进入时,本机 EPUB 路径由 Tauri command 交给 sidecar 创建/复用 session,随后页面会改用 `session_id` 进入对应功能块。
`0.15.0` 将 Readest `/review-editor` 迁移为原生 React 桌面功能块:校对和翻译不再以旧网页 iframe 作为主界面,而是直接调用本地 sidecar API 完成行加载、保存、重翻、反馈、导出、翻译配置、任务启动和进度轮询。旧静态网页界面保留为独立启动与调试 fallback。sidecar 为本机 Readest/Tauri 来源增加受限 CORS,以支持原生功能块直接访问 loopback API。
`0.16.0` 新增 Readest 原生阅读页审校模式:桌面端阅读界面顶栏提供“校”按钮,启用后会启动或复用本地 sidecar,并在 Foliate 原生正文里标记中日双语段落;点击或选中日文/中文段落会打开右侧审校面板,可直接修改中文译文、标记问题类型/严重程度/标签/备注/长期规则,配置 API 与术语表,执行单段重翻、生成反馈并导出修订 EPUB。
`0.16.1` 优化 Readest 原生阅读页审校面板:桌面端右侧面板改为停靠布局,打开后压缩正文而不是遮挡右侧文字;移除面板内独立段落列表,把 API 与提示词、术语表放到面板底部,并明确区分标签、本段备注和长期规则建议的用途。
`0.16.2` 恢复 Readest 原生审校面板固定按钮:固定时面板停靠并挤开正文,取消固定时变为可拖动浮动面板;固定状态切换和固定宽度调整后会恢复原阅读位置,窄面板下控件和文本改为换行/纵向堆叠,避免内容被截断。
`0.16.3` 修复 Readest 原生审校面板浮动模式交互:取消固定后面板不再占满整页高度,可在正文上方横向和纵向拖动,并支持分别调整宽度与高度。
`0.16.4` 优化 Readest 原生审校面板的中文编辑区:译文预览默认折叠,中文编辑标题右侧新增注音、注释和保存按钮;注音/注释会在当前选择位置插入可直接改写的内联标签,并自动展开预览,便于保存前确认显示效果。
`0.16.5` 工程优化 Readest 原生审校面板布局:移动/桌面断点改为响应式监听,浮动面板拖动与宽高调整逻辑抽取为可复用 hook,并持久化固定状态、面板宽度和浮动高度;审校行数据仍只保存在当前会话中,不写入浏览器布局偏好。
## 独立安装
这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub-review-editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖:
```powershell
pnpm --filter @readest/readest-app tauri dev
python -m venv .venv
.\.venv\Scripts\python -m pip install -r requirements.txt
```
Web 开发端
Linux/macOS
```bash
python3 -m venv .venv
./.venv/bin/python -m pip install -r requirements.txt
```
## 在 Readest 中启动
Readest 迁移阶段的推荐入口:
```powershell
pnpm --filter @readest/readest-app dev-web
pnpm 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
<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/
http://127.0.0.1:3000/review-editor
```
API Key 只写入本地运行配置,接口不得回传密钥,也不得写入 EPUB、反馈或导出文件。
也可以在 Readest 书架页菜单中进入:
sidecar 的所有 API 和下载请求都必须携带启动器返回的访问令牌,`/api/version` 健康探测也不例外。除创建 session 的 `/api/upload``/api/session/from-path` 等入口外,书籍审校接口缺少显式 `session_id` 时直接返回 `400`,不再回退全局 active session。
```text
Advanced Settings -> EPUB 审校器
```
## 主要 API
第一次进入会通过 `/api/review-editor/launch` 自动创建本目录下的 `.venv`、安装依赖并启动本地 sidecar。审校数据默认保存在:
- `GET /api/version`
- `POST /api/session/from-path`
- `POST /api/upload`(本地 `dev-web`multipart`activate=false`,携带稳定 `book_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=...`
```text
apps/readest-app\epub_review_sessions\
```
详细维护边界和回归标准见 `MAINTENANCE.md`
桌面端会通过 Tauri command 启动本地 sidecardev-web 仍保留 `/api/review-editor/launch` 作为开发入口。Readest `/review-editor` 页面使用 React 原生“校对”“翻译”功能块直接调用 sidecar API;旧静态网页界面仅作为独立运行和排查问题时的 fallback
## 版本说明
在 Readest 桌面端原生阅读界面中,打开 EPUB 后也可以点击顶栏“校”按钮进入内嵌审校模式。该模式不离开阅读页,只给当前书的双语段落加审校标记,并把旧审校器右侧栏能力收敛到阅读页右侧面板。
- `0.16.0`:新增 Readest 阅读页内嵌审校模式。
- `0.16.1``0.16.5`:完善停靠/浮动布局、宽高调整、注音注释快捷操作和响应式工程结构。
- `1.0.0`:删除旧独立审校页面、书库入口、静态浏览器前端及独立启动链路,只保留内嵌审校所需的共享 sidecar API。
- `1.0.1`:修复本地 `dev-web` EPUB 上传与下载、会话隔离、恢复初始译文写回、空译文预览、未保存修改提醒及动态开发端口 CORS;限制 sidecar 仅监听 loopback。
- `2.0.0`:所有 sidecar API 和下载请求启用 Bearer 访问令牌;书籍审校 API 强制显式 `session_id`,移除对全局 active session 的隐式回退。
## 独立启动
如果只想脱离 Readest 单独运行本工具,可以在本目录执行:
```text
powershell -NoProfile -ExecutionPolicy Bypass -File .\open_editor.ps1
```
第一次运行会在 `tools/epub-review-editor/.venv` 创建本地 Python 环境并安装依赖;之后会直接启动服务并打开浏览器。
不预先指定 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 重翻;打开/关闭面板时会尽量保持原来的阅读位置。
- 在 Readest 原生阅读界面中可点击顶栏“校”启用内嵌审校模式;中文和日文段落会在正文中高亮可选,点击或选中任一侧都会打开同一段的审校面板。
- 精修模式保留原有逐段编辑界面,用于较细的逐段复审。
- 可从“审校工具”统一配置 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”。
@@ -0,0 +1,134 @@
$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."
}
@@ -4,7 +4,6 @@ import argparse
import copy
import datetime as dt
import hashlib
import hmac
import html
import json
import os
@@ -20,6 +19,7 @@ import time
import urllib.error
import urllib.parse
import urllib.request
import webbrowser
import zipfile
from html.parser import HTMLParser
from pathlib import Path
@@ -36,7 +36,7 @@ except ImportError:
"MINOR": "新增向后兼容能力、API 字段或可选配置",
"MAJOR": "破坏兼容的 API、配置或行为变更",
}
version = "2.0.0"
version = "0.15.0"
P_RE = re.compile(r"(<p\b[^>]*>)(.*?)(</p>)", re.S | re.I)
@@ -140,21 +140,7 @@ def find_free_port(start: int, host: str = "127.0.0.1") -> int:
def display_host(host: str) -> str:
return "localhost" if host in {"127.0.0.1", "::1"} else host
def is_loopback_host(host: str) -> bool:
return host.strip().lower() in {"127.0.0.1", "localhost", "::1"}
def is_allowed_local_origin(origin: str) -> bool:
try:
parsed = urllib.parse.urlparse(origin)
return parsed.scheme in {"http", "https", "tauri"} and is_loopback_host(
parsed.hostname or ""
)
except ValueError:
return False
return "localhost" if host in {"127.0.0.1", "0.0.0.0", "::"} else host
def read_json(path: Path, default: Any) -> Any:
@@ -1459,20 +1445,14 @@ def validate_epub_file(path: Path) -> None:
raise ValueError("文件不是有效的 EPUB/ZIP 包")
def copy_upload_to_review_root(
file_storage: Any, review_root: Path, book_key: str = ""
) -> Path:
def copy_upload_to_review_root(file_storage: Any, review_root: Path) -> Path:
filename = safe_slug(Path(file_storage.filename or "uploaded.epub").name, max_len=140)
if not filename.lower().endswith(".epub"):
filename = f"{filename}.epub"
upload_dir = review_root / UPLOAD_DIR_NAME
upload_dir.mkdir(parents=True, exist_ok=True)
stem = safe_slug(Path(filename).stem, max_len=100)
if book_key:
safe_book_key = safe_slug(book_key, max_len=120)
target = upload_dir / f"inline_{safe_book_key}.epub"
else:
target = upload_dir / f"{dt.datetime.now().strftime('%Y%m%d_%H%M%S_%f')}_{stem}.epub"
target = upload_dir / f"{dt.datetime.now().strftime('%Y%m%d_%H%M%S')}_{stem}.epub"
try:
file_storage.save(target)
validate_epub_file(target)
@@ -2657,11 +2637,9 @@ def write_feedback_summary(session_root: Path) -> Path:
def apply_edits_to_xhtml(session_root: Path) -> list[str]:
rows = merge_rows(session_root)
state = read_json(session_root / "review_state" / "state.json", {"edits": {}})
saved_row_ids = set(state.get("edits", {})) if isinstance(state.get("edits"), dict) else set()
saved_rows = [row for row in rows if row["id"] in saved_row_ids]
edited_rows = [row for row in rows if row["edited"]]
by_file: dict[str, list[dict[str, Any]]] = {}
for row in saved_rows:
for row in edited_rows:
by_file.setdefault(row["file"], []).append(row)
modified = []
for entry_name, items in by_file.items():
@@ -3250,15 +3228,13 @@ def run_translation_job(review_root: Path, job_id: str) -> None:
write_translation_job(review_root, job)
def create_app(
review_root: Path,
initial_epub_path: Path | None = None,
reset: bool = False,
access_token: str = "",
) -> Flask:
if not access_token:
raise ValueError("Readest inline review API requires an access token.")
app = Flask(__name__, root_path=str(Path(__file__).resolve().parent), static_folder=None)
def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: bool = False) -> Flask:
app = Flask(
__name__,
root_path=str(Path(__file__).resolve().parent),
static_folder="static",
static_url_path="/static",
)
review_root.mkdir(parents=True, exist_ok=True)
translation_jobs_root(review_root).mkdir(parents=True, exist_ok=True)
app.config["REVIEW_ROOT"] = review_root
@@ -3267,47 +3243,6 @@ def create_app(
app.config["EPUB_PATH"] = None
app.config["LOCK"] = threading.Lock()
app.config["TRANSLATION_THREADS"] = {}
app.config["ACCESS_TOKEN"] = access_token
def requires_explicit_session_id(path: str) -> bool:
exact_paths = {
"/api/session",
"/api/rows",
"/api/structure",
"/api/reading-position",
"/api/gpt/config",
"/api/glossary",
"/api/glossary/entry",
"/api/glossary/search",
"/api/apply",
"/api/export",
"/api/feedback",
}
return (
path in exact_paths
or path.startswith("/api/row/")
or path.startswith("/api/asset/")
or path.startswith("/downloads/")
)
@app.before_request
def authorize_local_client():
if not (request.path.startswith("/api/") or request.path.startswith("/downloads/")):
return None
if request.method == "OPTIONS":
return None
authorization = request.headers.get("Authorization", "")
expected = f"Bearer {app.config['ACCESS_TOKEN']}"
if not hmac.compare_digest(authorization, expected):
response = jsonify({"error": "Unauthorized sidecar request"})
response.status_code = 401
response.headers["WWW-Authenticate"] = "Bearer"
return response
if requires_explicit_session_id(request.path):
session_id = str(request.args.get("session_id") or "").strip()
if not session_id:
return jsonify({"error": "缺少 session_id"}), 400
return None
def set_active_session(session_root: Path, epub_path: Path | None = None) -> None:
app.config["SESSION_ROOT"] = session_root
@@ -3338,7 +3273,7 @@ def create_app(
def session_from_request() -> tuple[Path | None, Path | None]:
session_id = str(request.args.get("session_id") or "").strip()
if not session_id:
return None, None
return active_session()
try:
session_root = session_root_from_id(review_root, session_id)
except ValueError:
@@ -3365,20 +3300,49 @@ def create_app(
@app.after_request
def add_local_embedding_headers(response):
# The Readest clients call the loopback API directly. Keep CORS limited
# to known local app origins instead of opening the sidecar to the web.
# Readest dev-web is cross-origin isolated; the local sidecar must opt in
# before Chromium will allow it inside the /review-editor iframe. The
# desktop React migration calls the same loopback APIs directly, so keep
# CORS limited to local Readest origins instead of opening the sidecar.
allowed_origins = {
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost:3001",
"http://127.0.0.1:3001",
"http://tauri.localhost",
"https://tauri.localhost",
"tauri://localhost",
}
origin = request.headers.get("Origin", "")
if is_allowed_local_origin(origin):
if origin in allowed_origins:
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Credentials"] = "false"
response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type"
response.headers["Access-Control-Allow-Headers"] = "Content-Type"
response.headers["Access-Control-Allow-Methods"] = "GET,POST,DELETE,OPTIONS"
response.headers["Vary"] = "Origin"
response.headers.setdefault("Cross-Origin-Embedder-Policy", "require-corp")
if "Cross-Origin-Resource-Policy" not in response.headers:
response.headers["Cross-Origin-Resource-Policy"] = "cross-origin"
response.headers.pop("X-Frame-Options", None)
if request.path == "/" or request.path.endswith(".html"):
frame_ancestors = (
"frame-ancestors 'self' "
"http://localhost:3000 http://127.0.0.1:3000 "
"http://localhost:3001 http://127.0.0.1:3001 "
"http://tauri.localhost https://tauri.localhost tauri://localhost"
)
existing_csp = response.headers.get("Content-Security-Policy", "").strip()
if existing_csp:
if "frame-ancestors" not in existing_csp:
response.headers["Content-Security-Policy"] = f"{existing_csp}; {frame_ancestors}"
else:
response.headers["Content-Security-Policy"] = frame_ancestors
return response
@app.route("/")
def index():
return send_from_directory(app.static_folder, "index.html")
@app.route("/api/session")
def api_session():
session_root, epub_path = session_from_request()
@@ -3459,23 +3423,15 @@ def create_app(
if file_storage is None or not file_storage.filename:
return jsonify({"error": "请选择一个 .epub 文件"}), 400
reset_upload = str(request.form.get("reset", "")).lower() in {"1", "true", "yes", "on"}
book_key = str(request.form.get("book_key", "")).strip()
activate_upload = str(request.form.get("activate", "true")).lower() in {
"1",
"true",
"yes",
"on",
}
try:
epub_path = copy_upload_to_review_root(file_storage, review_root, book_key)
epub_path = copy_upload_to_review_root(file_storage, review_root)
session_root = session_root_for(review_root, epub_path)
with app.config["LOCK"]:
if reset_upload and session_root.exists():
shutil.rmtree(session_root)
ensure_state(session_root, epub_path, reset=reset_upload)
write_session_manifest(session_root, epub_path, source_name=file_storage.filename)
if activate_upload:
set_active_session(session_root, epub_path)
set_active_session(session_root, epub_path)
summary = summarize_session(session_root)
summary["has_session"] = True
return jsonify(summary)
@@ -4068,17 +4024,13 @@ def create_app(
return app
def wait_until_ready(url: str, proc: subprocess.Popen, access_token: str, timeout: int = 15) -> bool:
def wait_until_ready(url: str, proc: subprocess.Popen, timeout: int = 15) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if proc.poll() is not None:
return False
try:
version_request = urllib.request.Request(
f"{url}/api/version",
headers={"Authorization": f"Bearer {access_token}"},
)
with urllib.request.urlopen(version_request, timeout=1) as response:
with urllib.request.urlopen(f"{url}/api/session", timeout=1) as response:
if response.status == 200:
return True
except OSError:
@@ -4117,17 +4069,20 @@ def run_daemon(args: argparse.Namespace) -> int:
args.host,
"--port",
str(port),
"--access-token",
args.access_token,
]
if epub_path is not None:
cmd.insert(2, str(epub_path))
cmd.append("--no-browser")
if args.reset:
cmd.append("--reset")
creationflags = 0
popen_kwargs: dict[str, Any] = {}
if os.name == "nt":
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
creationflags = (
subprocess.CREATE_NEW_PROCESS_GROUP
| subprocess.DETACHED_PROCESS
| subprocess.CREATE_NO_WINDOW
)
else:
popen_kwargs["start_new_session"] = True
with log_path.open("a", encoding="utf-8") as log:
@@ -4140,9 +4095,14 @@ def run_daemon(args: argparse.Namespace) -> int:
**popen_kwargs,
)
url = f"http://{display_host(args.host)}:{port}"
if not wait_until_ready(url, proc, args.access_token):
if not wait_until_ready(url, proc):
print(f"review editor failed to start; see {log_path}", file=sys.stderr)
return 1
if not args.no_browser:
try:
webbrowser.open(url)
except Exception:
pass
print(url)
if epub_path is not None:
print(f"session: {session_root}")
@@ -4153,22 +4113,19 @@ def run_daemon(args: argparse.Namespace) -> int:
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Run the local Readest inline review API.")
parser.add_argument("epub", nargs="?", help="Optional EPUB path used to initialize a review session.")
parser = argparse.ArgumentParser(description="Open a bilingual EPUB in a browser review/editor.")
parser.add_argument("epub", nargs="?", help="Optional path to a bilingual EPUB. Omit it to upload EPUBs in the browser.")
parser.add_argument("--review-root", default=DEFAULT_REVIEW_ROOT, help="Directory for review sessions.")
parser.add_argument("--host", default="127.0.0.1", help="Loopback host to bind.")
parser.add_argument("--host", default="127.0.0.1", help="Host to bind. Use 0.0.0.0 for trusted LAN/server access.")
parser.add_argument("--port", type=int, default=5177, help="Port, auto-advances if busy.")
parser.add_argument("--access-token", required=True, help=argparse.SUPPRESS)
parser.add_argument("--daemon", action="store_true", help="Start in background and print URL.")
parser.add_argument("--no-browser", action="store_true", help="Do not auto-open the browser.")
parser.add_argument("--reset", action="store_true", help="Re-extract the EPUB and discard prior review state.")
return parser
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
if not is_loopback_host(args.host):
print("Readest inline review API only supports loopback hosts.", file=sys.stderr)
return 1
epub_path = Path(args.epub).resolve() if args.epub else None
if epub_path is not None:
if not epub_path.exists():
@@ -4183,9 +4140,15 @@ def main(argv: list[str] | None = None) -> int:
return run_daemon(args)
review_root = Path(args.review_root).resolve()
app = create_app(review_root, epub_path, reset=args.reset, access_token=args.access_token)
app = create_app(review_root, epub_path, reset=args.reset)
port = find_free_port(args.port, args.host)
url = f"http://{display_host(args.host)}:{port}"
if not args.no_browser:
try:
webbrowser.open(url)
except Exception:
pass
def on_sigterm(_signum: int, _frame: Any) -> None:
sys.exit(0)
@@ -4193,14 +4156,14 @@ def main(argv: list[str] | None = None) -> int:
signal.signal(signal.SIGTERM, on_sigterm)
except Exception:
pass
print(f"Readest inline review API: {url}")
print(f"EPUB review editor: {url}")
print(f"Review root: {review_root}")
session_root = app.config.get("SESSION_ROOT")
if isinstance(session_root, Path):
print(f"Session: {session_root}")
print(f"Rows: {len(read_json(session_root / 'review_state' / 'rows.json', []))}")
else:
print("No EPUB session initialized yet.")
print("No EPUB loaded yet. Upload one in the browser.")
app.run(host=args.host, port=port, debug=False, threaded=True)
return 0
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,514 @@
<!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.16.5">
</head>
<body>
<header class="topbar">
<button id="bookshelfBtn" class="bookshelfNavButton" type="button">书架</button>
<div class="brandBlock">
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.16.5</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.16.5"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,4 @@
import importlib.util
import io
import json
import tempfile
import unittest
@@ -19,82 +18,6 @@ def write_json(path: Path, data):
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>'
@@ -108,121 +31,6 @@ class InlineReviewSessionScopeTest(unittest.TestCase):
'<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,
@@ -311,8 +119,8 @@ class InlineReviewSessionScopeTest(unittest.TestCase):
server.call_openai_compatible_chat = fake_call
try:
app = self.make_app(review_root)
response = self.client(app).post(
app = server.create_app(review_root)
response = app.test_client().post(
"/api/row/R00001/retranslate?session_id=session-a",
json={"instruction": ""},
)
@@ -341,8 +149,8 @@ class InlineReviewSessionScopeTest(unittest.TestCase):
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(
app = server.create_app(review_root)
response = app.test_client().post(
"/api/gpt/config?session_id=session-a",
json={
"api_key": "session-secret",
@@ -1,4 +1,4 @@
version = "2.0.0"
version = "0.16.5"
VERSION_RULES = {
"PATCH": "修复 bug 或兼容性小修",
+1
View File
@@ -9,6 +9,7 @@
"lint:lua": "pnpm --filter @readest/readest-app lint:lua",
"tauri": "pnpm --filter @readest/readest-app tauri",
"dev-web": "pnpm --filter @readest/readest-app dev-web",
"epub-reviewer:dev": "pnpm --filter @readest/readest-app epub-reviewer:dev",
"prepare": "husky",
"fmt:check": "pnpm --filter @readest/readest-app fmt:check",
"clippy:check": "pnpm --filter @readest/readest-app clippy:check",
+4 -44
View File
@@ -1,6 +1,6 @@
param(
[string]$Remote = "akai-tools",
[string]$Branch = "codex/readest-inline-review-mode",
[string]$Branch = "codex/desktop-review-editor-blocks",
[switch]$SkipPull,
[switch]$CheckOnly,
[switch]$KeepRunning
@@ -57,59 +57,19 @@ function Stop-OldReadestDev {
$_.ProcessId -ne $currentPid -and
$_.CommandLine -and
$_.CommandLine.ToLowerInvariant().Contains($repoLower) -and
($_.CommandLine -match "tauri\s+dev|next\s+dev|@readest/readest-app")
($_.CommandLine -match "tauri\s+dev|next\s+dev|@readest/readest-app|epub-reviewer:dev")
} |
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)
$status = (& git -C $RepoRoot status --porcelain)
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 $_ }
& git -C $RepoRoot status --short
throw "Commit, stash, or discard local changes before launching the latest organization version."
}
}
-24
View File
@@ -1,24 +0,0 @@
$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 '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."