13 Commits

Author SHA1 Message Date
akai 783dd6c606 merge bilingual filter features into review mode 2026-07-09 16:48:19 +08:00
akai ebae9103d2 fix: enable floating review panel height resize 2026-07-09 16:30:52 +08:00
akai 0d8b2f9167 fix: restore review panel pin behavior 2026-07-09 16:04:08 +08:00
akai 987067414c fix: refine inline review panel layout 2026-07-09 15:13:14 +08:00
akai afd48837f9 feat: add inline review mode to reader 2026-07-09 14:15:36 +08:00
akai f7f10f5959 chore: add latest desktop launcher 2026-07-09 09:58:24 +08:00
Codex 14d1b35d87 Add expandable library sidebar filters
CodeQL Advanced / Analyze (actions) (pull_request) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (pull_request) Waiting to run
CodeQL Advanced / Analyze (rust) (pull_request) Waiting to run
PR checks / rust_lint (pull_request) Waiting to run
PR checks / build_web_app (pull_request) Waiting to run
PR checks / test_web_app (1) (pull_request) Waiting to run
PR checks / test_web_app (2) (pull_request) Waiting to run
PR checks / test_extensions (pull_request) Waiting to run
PR checks / build_tauri_app (pull_request) Waiting to run
2026-07-09 07:55:55 +08:00
akai fd41bcf420 feat: add native review editor desktop blocks 2026-07-09 00:50:58 +08:00
Codex 21cad7ec09 Fix web dev router without view transitions 2026-07-09 00:48:30 +08:00
Codex dc7137d331 Add bilingual action to book menu 2026-07-09 00:45:12 +08:00
Codex c4621a273e Add web bookshelf context menu 2026-07-09 00:38:04 +08:00
Codex c410b405b9 Fix web dev view transition timeout 2026-07-09 00:31:30 +08:00
Codex 94c35f999b Add bilingual EPUB filter 2026-07-08 23:00:44 +08:00
47 changed files with 6857 additions and 253 deletions
@@ -2,24 +2,32 @@
Date: 2026-07-08
This fork includes a first-stage migration of the Chinese/Japanese EPUB review editor into Readest.
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 is deliberately gated to local `dev-web`; packaged Tauri/static export needs a Rust command or packaged sidecar.
- 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 and static UI from the previous long-term translation project.
- Next/Tauri production static export will need a Tauri desktop command or packaged sidecar before this becomes a production desktop feature.
- 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. Move launching from the Next dev API route into a Tauri desktop command.
2. Add per-book "Open in EPUB Reviewer" actions from Readest bookshelf/detail views.
3. Gradually port the review UI into React after the sidecar entry is stable.
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.
@@ -69,6 +69,11 @@
"Imported {{count}} annotations_one": "Imported {{count}} annotation",
"Imported {{count}} annotations_other": "Imported {{count}} annotations",
"Recently read": "Recently read",
"All Books": "All Books",
"Categories": "Categories",
"Category": "Category",
"Publication Year": "Publication Year",
"Bilingual": "Bilingual",
"Show recently read": "Show recently read",
"Your books will appear here": "Your books will appear here",
"{{count}} results_one": "{{count}} result",
@@ -9,7 +9,12 @@
"Apply": "应用",
"Auto Mode": "自动主题",
"Behavior": "行为",
"Bilingual": "双语拆分",
"Book": "书籍",
"All Books": "全部书籍",
"Categories": "分类",
"Category": "类别",
"Publication Year": "出版年份",
"Bookmark": "书签",
"Cancel": "取消",
"Chapter": "章节",
@@ -9,7 +9,12 @@
"Apply": "應用",
"Auto Mode": "自動主題",
"Behavior": "行為",
"Bilingual": "雙語拆分",
"Book": "書籍",
"All Books": "全部書籍",
"Categories": "分類",
"Category": "類別",
"Publication Year": "出版年份",
"Bookmark": "書籤",
"Cancel": "取消",
"Chapter": "章節",
+4
View File
@@ -33,6 +33,8 @@ mod mobi_parser;
mod nightly_update;
mod parser_common;
mod range_file;
#[cfg(desktop)]
mod review_editor;
mod sentry_config;
#[cfg(desktop)]
mod spawn_fresh_browser;
@@ -397,6 +399,8 @@ pub fn run() {
set_webview_info,
#[cfg(desktop)]
is_updater_disabled,
#[cfg(desktop)]
review_editor::launch_epub_review_editor,
allow_paths_in_scopes,
dir_scanner::read_dir,
epub_parser::parse_epub_metadata,
@@ -0,0 +1,465 @@
use serde::Serialize;
use serde_json::Value;
use std::sync::Mutex;
use std::{
fs,
io::{Read, Write},
net::{SocketAddr, TcpStream},
path::{Path, PathBuf},
process::Command,
time::Duration,
};
use tauri::{AppHandle, Manager};
const DEFAULT_PORT: u16 = 5177;
const PORT_SCAN_LIMIT: u16 = 100;
static LAUNCH_LOCK: Mutex<()> = Mutex::new(());
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ReviewEditorLaunchResponse {
ok: bool,
url: String,
reused: bool,
review_root: String,
version: String,
session_id: Option<String>,
}
struct CommandOutput {
stdout: String,
stderr: String,
}
struct PythonCommand {
program: String,
args: Vec<String>,
}
#[tauri::command]
pub async fn launch_epub_review_editor(
app: AppHandle,
epub_path: Option<String>,
) -> Result<ReviewEditorLaunchResponse, String> {
tauri::async_runtime::spawn_blocking(move || launch_epub_review_editor_sync(&app, epub_path))
.await
.map_err(|err| err.to_string())?
}
fn launch_epub_review_editor_sync(
app: &AppHandle,
epub_path: Option<String>,
) -> Result<ReviewEditorLaunchResponse, String> {
let _launch_guard = LAUNCH_LOCK
.lock()
.map_err(|_| "审校器启动锁已损坏。请重启 Readest 后重试。".to_string())?;
let tool_root = resolve_tool_root(app)?;
let server_path = tool_root.join("server.py");
if !server_path.exists() {
return Err(format!(
"没有找到审校器后端:{}",
server_path.to_string_lossy()
));
}
let review_root = std::env::var("READEST_REVIEW_ROOT")
.map(PathBuf::from)
.unwrap_or_else(|_| {
app.path()
.app_data_dir()
.unwrap_or_else(|_| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
.join("epub_review_sessions")
});
fs::create_dir_all(&review_root).map_err(|err| {
format!(
"创建审校数据目录失败:{} ({err})",
review_root.to_string_lossy()
)
})?;
let expected_version = read_expected_version(&tool_root);
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,
reused: true,
review_root: review_root.to_string_lossy().to_string(),
version: expected_version.unwrap_or_default(),
session_id,
});
}
let runtime_root = app
.path()
.app_data_dir()
.unwrap_or_else(|_| review_root.clone())
.join("epub-review-editor-runtime");
let venv_root = runtime_root.join(".venv");
let venv_python = venv_python_path(&venv_root);
ensure_venv(&venv_python, &venv_root)?;
ensure_dependencies(&venv_python, &tool_root)?;
let output = run_command(
&venv_python.to_string_lossy(),
&[
server_path.to_string_lossy().to_string(),
"--review-root".to_string(),
review_root.to_string_lossy().to_string(),
"--host".to_string(),
"127.0.0.1".to_string(),
"--port".to_string(),
DEFAULT_PORT.to_string(),
"--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))
.ok_or_else(|| {
format!(
"审校器已启动但没有返回访问地址。\nstdout:\n{}\nstderr:\n{}",
output.stdout, output.stderr
)
})?;
let launched_url = launched_url.replace("http://localhost:", "http://127.0.0.1:");
let session_id = ensure_session_from_path(&launched_url, epub_path.as_deref())?;
Ok(ReviewEditorLaunchResponse {
ok: true,
url: launched_url,
reused: false,
review_root: review_root.to_string_lossy().to_string(),
version: expected_version.unwrap_or_default(),
session_id,
})
}
fn resolve_tool_root(app: &AppHandle) -> Result<PathBuf, String> {
if let Ok(raw) = std::env::var("READEST_REVIEW_EDITOR_TOOL_ROOT") {
let path = PathBuf::from(raw);
if path.join("server.py").exists() {
return Ok(path);
}
}
if let Ok(resource_dir) = app.path().resource_dir() {
for candidate in [
resource_dir.join("tools").join("epub-review-editor"),
resource_dir
.join("apps")
.join("readest-app")
.join("tools")
.join("epub-review-editor"),
] {
if candidate.join("server.py").exists() {
return Ok(candidate);
}
}
}
#[cfg(debug_assertions)]
if let Ok(current) = std::env::current_dir() {
for ancestor in current.ancestors() {
for candidate in [
ancestor.join("tools").join("epub-review-editor"),
ancestor
.join("apps")
.join("readest-app")
.join("tools")
.join("epub-review-editor"),
] {
if candidate.join("server.py").exists() {
return Ok(candidate);
}
}
}
}
Err("没有找到已迁移的 EPUB 审校器资源目录。".to_string())
}
fn ensure_venv(venv_python: &Path, venv_root: &Path) -> Result<(), String> {
if venv_python.exists() {
return Ok(());
}
if let Some(parent) = venv_root.parent() {
fs::create_dir_all(parent).map_err(|err| {
format!(
"创建审校器运行目录失败:{} ({err})",
parent.to_string_lossy()
)
})?;
}
let python = resolve_python_command()?;
let mut args = python.args;
args.extend([
"-m".to_string(),
"venv".to_string(),
venv_root.to_string_lossy().to_string(),
]);
run_command(&python.program, &args, None)?;
Ok(())
}
fn ensure_dependencies(venv_python: &Path, tool_root: &Path) -> Result<(), String> {
let check = run_command_allow_failure(
&venv_python.to_string_lossy(),
&[
"-c".to_string(),
"import importlib.util, sys; sys.exit(0 if importlib.util.find_spec('flask') else 1)"
.to_string(),
],
Some(tool_root),
)?;
if check.0 {
return Ok(());
}
let requirements = tool_root.join("requirements.txt");
run_command(
&venv_python.to_string_lossy(),
&[
"-m".to_string(),
"pip".to_string(),
"install".to_string(),
"-r".to_string(),
requirements.to_string_lossy().to_string(),
],
Some(tool_root),
)?;
Ok(())
}
fn venv_python_path(venv_root: &Path) -> PathBuf {
if cfg!(windows) {
venv_root.join("Scripts").join("python.exe")
} else {
venv_root.join("bin").join("python")
}
}
fn resolve_python_command() -> Result<PythonCommand, String> {
let candidates: Vec<PythonCommand> = if cfg!(windows) {
vec![
PythonCommand {
program: "py".to_string(),
args: vec!["-3".to_string()],
},
PythonCommand {
program: "python".to_string(),
args: vec![],
},
PythonCommand {
program: "python3".to_string(),
args: vec![],
},
]
} else {
vec![
PythonCommand {
program: "python3".to_string(),
args: vec![],
},
PythonCommand {
program: "python".to_string(),
args: vec![],
},
]
};
for candidate in candidates {
let mut args = candidate.args.clone();
args.push("--version".to_string());
if run_command_allow_failure(&candidate.program, &args, None)
.map(|(ok, _)| ok)
.unwrap_or(false)
{
return Ok(candidate);
}
}
Err("没有找到 Python 3。请先安装 Python 3,然后重新打开 EPUB 审校器。".to_string())
}
fn read_expected_version(tool_root: &Path) -> Option<String> {
let text = fs::read_to_string(tool_root.join("version.py")).ok()?;
let marker = "version = \"";
let start = text.find(marker)? + marker.len();
let rest = &text[start..];
Some(rest[..rest.find('"')?].to_string())
}
fn find_running_editor(expected_version: Option<&str>, review_root: &Path) -> 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") else {
continue;
};
let Some(version) = payload.get("version").and_then(Value::as_str) else {
continue;
};
if expected_version.map_or(true, |expected| expected == version) {
let running_root = payload
.get("review_root")
.and_then(Value::as_str)
.map(PathBuf::from);
let running_root_matches = running_root
.as_ref()
.and_then(|path| path.canonicalize().ok())
.map(|path| path == expected_root)
.unwrap_or(false);
if !running_root_matches {
continue;
}
return Some(format!("http://127.0.0.1:{port}"));
}
}
None
}
fn request_json(port: u16, path: &str) -> 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\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) -> Result<Value, String> {
let port = parse_loopback_port(url)?;
let addr = SocketAddr::from(([127, 0, 0, 1], port));
let mut stream = TcpStream::connect_timeout(&addr, Duration::from_secs(3))
.map_err(|err| format!("连接审校器失败:{err}"))?;
let _ = stream.set_read_timeout(Some(Duration::from_secs(30)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(5)));
let request = format!(
"POST {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(request.as_bytes())
.map_err(|err| format!("写入审校器请求失败:{err}"))?;
read_http_json_response_result(stream)
}
fn read_http_json_response(mut stream: TcpStream) -> Option<Value> {
let mut response = String::new();
stream.read_to_string(&mut response).ok()?;
if !response.starts_with("HTTP/1.1 200") && !response.starts_with("HTTP/1.0 200") {
return None;
}
let body = response.split("\r\n\r\n").nth(1)?;
serde_json::from_str(body).ok()
}
fn read_http_json_response_result(mut stream: TcpStream) -> Result<Value, String> {
let mut response = String::new();
stream
.read_to_string(&mut response)
.map_err(|err| format!("读取审校器响应失败:{err}"))?;
let status_line = response.lines().next().unwrap_or_default().to_string();
let body = response.split("\r\n\r\n").nth(1).unwrap_or("");
let payload = serde_json::from_str::<Value>(body).unwrap_or(Value::Null);
if status_line.starts_with("HTTP/1.1 200") || status_line.starts_with("HTTP/1.0 200") {
if payload.is_null() {
return Err("审校器返回了空响应。".to_string());
}
return Ok(payload);
}
let message = payload
.get("error")
.and_then(Value::as_str)
.unwrap_or(status_line.as_str());
Err(message.to_string())
}
fn parse_loopback_port(url: &str) -> Result<u16, String> {
let rest = url
.strip_prefix("http://127.0.0.1:")
.or_else(|| url.strip_prefix("http://localhost:"))
.ok_or_else(|| format!("审校器地址不是本机 HTTP 地址:{url}"))?;
let port_text = rest.split('/').next().unwrap_or(rest);
port_text
.parse::<u16>()
.map_err(|err| format!("无法解析审校器端口:{port_text} ({err})"))
}
fn ensure_session_from_path(url: &str, epub_path: Option<&str>) -> 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)?;
Ok(payload
.get("id")
.and_then(Value::as_str)
.map(ToOwned::to_owned))
}
fn run_command(
program: &str,
args: &[String],
cwd: Option<&Path>,
) -> Result<CommandOutput, String> {
let (ok, output) = run_command_allow_failure(program, args, cwd)?;
if ok {
Ok(output)
} else {
Err(format!(
"命令执行失败:{} {}\nstdout:\n{}\nstderr:\n{}",
program,
args.join(" "),
output.stdout,
output.stderr
))
}
}
fn run_command_allow_failure(
program: &str,
args: &[String],
cwd: Option<&Path>,
) -> Result<(bool, CommandOutput), String> {
let mut command = Command::new(program);
command.args(args);
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
command.stdin(std::process::Stdio::null());
hide_command_window(&mut command);
let output = command
.output()
.map_err(|err| format!("无法执行命令 {program}: {err}"))?;
Ok((
output.status.success(),
CommandOutput {
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
},
))
}
#[cfg(windows)]
fn hide_command_window(command: &mut Command) {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
command.creation_flags(CREATE_NO_WINDOW);
}
#[cfg(not(windows))]
fn hide_command_window(_command: &mut Command) {}
fn extract_url(text: &str) -> Option<String> {
text.split_whitespace()
.find(|part| part.starts_with("http://") || part.starts_with("https://"))
.map(ToOwned::to_owned)
}
+9 -1
View File
@@ -49,7 +49,15 @@
"icons/icon.icns",
"icons/icon.ico"
],
"resources": [],
"resources": {
"../tools/epub-review-editor/server.py": "tools/epub-review-editor/server.py",
"../tools/epub-review-editor/version.py": "tools/epub-review-editor/version.py",
"../tools/epub-review-editor/requirements.txt": "tools/epub-review-editor/requirements.txt",
"../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": {
"type": "embedBootstrapper"
@@ -1,7 +1,14 @@
{
"bundle": {
"resources": {
"../extensions/windows-thumbnail/target/windows_thumbnail.dll": "readest_thumbnail.dll"
"../extensions/windows-thumbnail/target/windows_thumbnail.dll": "readest_thumbnail.dll",
"../tools/epub-review-editor/server.py": "tools/epub-review-editor/server.py",
"../tools/epub-review-editor/version.py": "tools/epub-review-editor/version.py",
"../tools/epub-review-editor/requirements.txt": "tools/epub-review-editor/requirements.txt",
"../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,6 +22,8 @@ describe('getBookContextMenuItemIds', () => {
'markFinished',
'markAbandoned',
'showDetails',
'reviewInEpubEditor',
'translateInEpubEditor',
'showInFinder',
'searchGoodreads',
'upload',
@@ -39,6 +41,8 @@ describe('getBookContextMenuItemIds', () => {
'markAbandoned',
'clearStatus',
'showDetails',
'reviewInEpubEditor',
'translateInEpubEditor',
'showInFinder',
'searchGoodreads',
'upload',
@@ -56,6 +60,8 @@ describe('getBookContextMenuItemIds', () => {
'markAbandoned',
'clearStatus',
'showDetails',
'reviewInEpubEditor',
'translateInEpubEditor',
'showInFinder',
'searchGoodreads',
'upload',
@@ -72,6 +78,8 @@ describe('getBookContextMenuItemIds', () => {
'markFinished',
'clearStatus',
'showDetails',
'reviewInEpubEditor',
'translateInEpubEditor',
'showInFinder',
'searchGoodreads',
'upload',
@@ -88,6 +96,8 @@ describe('getBookContextMenuItemIds', () => {
'markFinished',
'markAbandoned',
'showDetails',
'reviewInEpubEditor',
'translateInEpubEditor',
'showInFinder',
'searchGoodreads',
'download',
@@ -104,6 +114,8 @@ describe('getBookContextMenuItemIds', () => {
'markFinished',
'markAbandoned',
'showDetails',
'reviewInEpubEditor',
'translateInEpubEditor',
'showInFinder',
'searchGoodreads',
'delete',
@@ -0,0 +1,147 @@
import { describe, expect, test } from 'vitest';
import { __testing } from '@/app/reader/components/ReviewModeController';
import type { BookDoc } from '@/libs/document';
describe('ReviewModeController marks', () => {
test('marks bilingual source and target paragraphs by file and index', () => {
const doc = document.implementation.createHTMLDocument('chapter');
doc.body.innerHTML = [
'<p class="sourceText">原文一</p>',
'<p>译文一</p>',
'<p class="sourceText">原文二</p>',
'<p>译文二</p>',
].join('');
__testing.applyReviewMarks(
doc,
'OEBPS/Text/chapter.xhtml',
[
{
id: 'R00002',
file: 'Text/chapter.xhtml',
ja_p_index: 2,
cn_p_index: 3,
jp_html: '原文二',
jp_text: '原文二',
cn_html: '译文二',
current_html: '<span>新译文二</span><script>alert(1)</script>',
},
],
'R00002',
);
const paragraphs = Array.from(doc.querySelectorAll('p'));
expect(paragraphs[2]?.getAttribute('data-readest-review-row-id')).toBe('R00002');
expect(paragraphs[2]?.classList.contains('readest-review-source')).toBe(true);
expect(paragraphs[3]?.getAttribute('data-readest-review-row-id')).toBe('R00002');
expect(paragraphs[3]?.classList.contains('readest-review-target')).toBe(true);
expect(paragraphs[3]?.innerHTML).toBe('<span>新译文二</span>');
});
test('uses section id when foliate sections do not expose href', () => {
const bookDoc = {
sections: [{ id: 'OEBPS/Text/chapter.xhtml' }],
} as unknown as BookDoc;
expect(__testing.sectionHrefAt(bookDoc, 0)).toBe('OEBPS/Text/chapter.xhtml');
});
test('clearReviewMarks restores original target HTML', () => {
const doc = document.implementation.createHTMLDocument('chapter');
doc.body.innerHTML = '<p class="sourceText">原文</p><p><ruby>旧<rt>old</rt></ruby>译文</p>';
__testing.applyReviewMarks(
doc,
'chapter.xhtml',
[
{
id: 'R00001',
file: 'chapter.xhtml',
ja_p_index: 0,
cn_p_index: 1,
jp_html: '原文',
jp_text: '原文',
cn_html: '<ruby>旧<rt>old</rt></ruby>译文',
current_html: '<span>新译文</span>',
},
],
'',
);
__testing.clearReviewMarks(doc);
const target = doc.querySelectorAll('p')[1];
expect(target?.innerHTML).toBe('<ruby>旧<rt>old</rt></ruby>译文');
expect(target?.hasAttribute('data-readest-review-row-id')).toBe(false);
});
test('strips event handlers and dangerous links from target HTML', () => {
const doc = document.implementation.createHTMLDocument('chapter');
doc.body.innerHTML = '<p>原文</p><p>译文</p>';
__testing.applyReviewMarks(
doc,
'chapter.xhtml',
[
{
id: 'R00001',
file: 'chapter.xhtml',
ja_p_index: 0,
cn_p_index: 1,
jp_html: '原文',
jp_text: '原文',
cn_html: '译文',
current_html: '<span onclick="alert(1)"><a href="javascript:alert(1)">新译文</a></span>',
},
],
'R00001',
);
const target = doc.querySelectorAll('p')[1];
expect(target?.innerHTML).toBe('<span>新译文</span>');
});
test('updates active classes without rewriting translated paragraph HTML', () => {
const doc = document.implementation.createHTMLDocument('chapter');
doc.body.innerHTML = '<p>原文一</p><p>译文一</p><p>原文二</p><p>译文二</p>';
__testing.applyReviewMarks(
doc,
'chapter.xhtml',
[
{
id: 'R00001',
file: 'chapter.xhtml',
ja_p_index: 0,
cn_p_index: 1,
jp_html: '原文一',
jp_text: '原文一',
cn_html: '译文一',
current_html: '<span>新译文一</span>',
},
{
id: 'R00002',
file: 'chapter.xhtml',
ja_p_index: 2,
cn_p_index: 3,
jp_html: '原文二',
jp_text: '原文二',
cn_html: '译文二',
current_html: '<span>新译文二</span>',
},
],
'R00001',
);
const target = doc.querySelectorAll('p')[1]!;
target.dataset['localState'] = 'kept';
__testing.applyActiveReviewMark(doc, 'R00002');
expect(target.innerHTML).toBe('<span>新译文一</span>');
expect(target.dataset['localState']).toBe('kept');
expect(target.classList.contains('readest-review-active')).toBe(false);
expect(doc.querySelectorAll('p')[3]?.classList.contains('readest-review-active')).toBe(true);
});
});
@@ -0,0 +1,121 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
createReviewSessionFromPath,
exportReviewedEpub,
generateReviewFeedback,
loadInlineReviewData,
loadReviewGlossary,
saveReviewGptConfig,
retranslateReviewRow,
saveReviewRow,
saveReviewGlossary,
sidecarApi,
switchReviewGlossaryPath,
} from '@/services/reviewEditorService';
afterEach(() => {
vi.unstubAllGlobals();
});
const stubJsonFetch = () => {
const fetchMock = vi.fn(async () =>
Response.json({
status: 'ok',
updated_at: '2026-07-09T00:00:00Z',
current_html: '<span>净化译文</span>',
}),
);
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
};
const firstFetchUrl = (fetchMock: ReturnType<typeof vi.fn>) => {
const input = fetchMock.mock.calls[0]?.[0];
if (input instanceof URL) return input.toString();
if (typeof input === 'string') return input;
return input?.toString() || '';
};
describe('reviewEditorService', () => {
test('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');
const url = new URL(firstFetchUrl(fetchMock));
expect(url.pathname).toBe('/api/rows');
expect(url.searchParams.get('session_id')).toBe('session-a');
});
test('row operations are scoped to the current review session', async () => {
const fetchMock = stubJsonFetch();
await saveReviewRow('http://127.0.0.1:5177', 'session-a', 'R00001', {
current_html: '<span>译文</span>',
marked: true,
issue_type: '误译',
severity: '严重',
tags: '术语',
comment: '备注',
learn_note: '长期规则',
});
await retranslateReviewRow('http://127.0.0.1:5177', 'session-a', 'R00001', '');
await generateReviewFeedback('http://127.0.0.1:5177', 'session-a');
await exportReviewedEpub('http://127.0.0.1:5177', 'session-a');
expect(fetchMock).toHaveBeenCalledTimes(4);
for (const call of fetchMock.mock.calls as unknown as [
string | URL,
RequestInit | undefined,
][]) {
const input = call[0];
const url = new URL(input instanceof URL ? input.toString() : input);
expect(url.searchParams.get('session_id')).toBe('session-a');
}
});
test('inline launch creates sessions without activating sidecar global session', async () => {
const fetchMock = stubJsonFetch();
await createReviewSessionFromPath('http://127.0.0.1:5177', 'C:/books/demo.epub');
const calls = fetchMock.mock.calls as unknown as [string | URL, RequestInit][];
const body = JSON.parse(String(calls[0]?.[1]?.body || '{}'));
expect(body).toMatchObject({
epub_path: 'C:/books/demo.epub',
activate: false,
reset: false,
});
});
test('inline bootstrap and config operations are scoped to the current review session', async () => {
const fetchMock = vi.fn(async () =>
Response.json({
has_session: true,
rows: [],
entries: [],
}),
);
vi.stubGlobal('fetch', fetchMock);
await loadInlineReviewData('http://127.0.0.1:5177', 'session-a');
await saveReviewGptConfig('http://127.0.0.1:5177', 'session-a', {
base_url: 'https://api.example.test/v1',
model: 'model-a',
});
await loadReviewGlossary('http://127.0.0.1:5177', 'session-a');
await switchReviewGlossaryPath('http://127.0.0.1:5177', 'session-a', 'mingcibiao.json');
await saveReviewGlossary('http://127.0.0.1:5177', 'session-a', 'mingcibiao.json', []);
expect(fetchMock).toHaveBeenCalledTimes(7);
for (const call of fetchMock.mock.calls as unknown as [
string | URL,
RequestInit | undefined,
][]) {
const input = call[0];
const url = new URL(input instanceof URL ? input.toString() : input);
expect(url.searchParams.get('session_id')).toBe('session-a');
}
});
});
@@ -0,0 +1,102 @@
import { beforeEach, describe, expect, test } from 'vitest';
import { useReviewModeStore } from '@/store/reviewModeStore';
beforeEach(() => {
useReviewModeStore.setState({
activeBookKey: null,
isPanelVisible: false,
isPanelPinned: false,
panelWidth: '32%',
panelHeight: '68vh',
books: {},
});
});
describe('reviewModeStore', () => {
test('enables review mode for a book and opens the panel', () => {
useReviewModeStore.getState().setBookEnabled('book-a', true);
const state = useReviewModeStore.getState();
expect(state.activeBookKey).toBe('book-a');
expect(state.isPanelVisible).toBe(true);
expect(state.books['book-a']?.enabled).toBe(true);
});
test('selects rows without replacing loaded review data', () => {
useReviewModeStore.getState().setBookData('book-a', {
baseUrl: 'http://127.0.0.1:5177',
sessionId: 'session-a',
rows: [
{
id: 'R00001',
file: 'chapter.xhtml',
jp_html: '原文',
jp_text: '原文',
cn_html: '译文',
current_html: '译文',
},
],
});
useReviewModeStore.getState().selectRow('book-a', 'R00001');
const state = useReviewModeStore.getState();
expect(state.activeBookKey).toBe('book-a');
expect(state.isPanelVisible).toBe(true);
expect(state.books['book-a']?.baseUrl).toBe('http://127.0.0.1:5177');
expect(state.books['book-a']?.selectedRowId).toBe('R00001');
});
test('updates one review row after save', () => {
useReviewModeStore.getState().setBookData('book-a', {
rows: [
{
id: 'R00001',
file: 'chapter.xhtml',
jp_html: '原文',
jp_text: '原文',
cn_html: '旧译文',
current_html: '旧译文',
},
],
});
useReviewModeStore.getState().updateRow('book-a', {
id: 'R00001',
file: 'chapter.xhtml',
jp_html: '原文',
jp_text: '原文',
cn_html: '旧译文',
current_html: '新译文',
edited: true,
});
expect(useReviewModeStore.getState().books['book-a']?.rows[0]?.current_html).toBe('新译文');
expect(useReviewModeStore.getState().books['book-a']?.rows[0]?.edited).toBe(true);
});
test('disabling the active book closes the panel', () => {
useReviewModeStore.getState().setBookEnabled('book-a', true);
useReviewModeStore.getState().setBookEnabled('book-a', false);
const state = useReviewModeStore.getState();
expect(state.activeBookKey).toBeNull();
expect(state.isPanelVisible).toBe(false);
expect(state.books['book-a']?.enabled).toBe(false);
});
test('clears an active book and closes the panel', () => {
useReviewModeStore.getState().setBookEnabled('book-a', true);
useReviewModeStore.getState().setBookData('book-a', {
baseUrl: 'http://127.0.0.1:5177',
sessionId: 'session-a',
});
useReviewModeStore.getState().clearBook('book-a');
const state = useReviewModeStore.getState();
expect(state.books['book-a']).toBeUndefined();
expect(state.activeBookKey).toBeNull();
expect(state.isPanelVisible).toBe(false);
});
});
+9 -7
View File
@@ -129,8 +129,16 @@ const devHmrPatchScript = `(${patchTauriHmrWebSocket.toString()})(${JSON.stringi
// fallback HTML and crash with `Unexpected token '<'`. All runtime-config
// consumers fall back to `NEXT_PUBLIC_*` envs baked at build time on Tauri.
const shouldInjectRuntimeConfig = process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web';
const shouldUseViewTransitions =
process.env['NODE_ENV'] !== 'development' || process.env['NEXT_PUBLIC_APP_PLATFORM'] !== 'web';
export default function RootLayout({ children }: { children: React.ReactNode }) {
const app = (
<EnvProvider>
<Providers>{children}</Providers>
</EnvProvider>
);
return (
<html
lang='en'
@@ -144,13 +152,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<script dangerouslySetInnerHTML={{ __html: devHmrPatchScript }} />
) : null}
</head>
<body>
<ViewTransitions>
<EnvProvider>
<Providers>{children}</Providers>
</EnvProvider>
</ViewTransitions>
</body>
<body>{shouldUseViewTransitions ? <ViewTransitions>{app}</ViewTransitions> : app}</body>
</html>
);
}
@@ -0,0 +1,115 @@
import clsx from 'clsx';
import * as React from 'react';
import { useState } from 'react';
import { LuLanguages } from 'react-icons/lu';
import { PiX } from 'react-icons/pi';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
import { useTranslation } from '@/hooks/useTranslation';
import type { BilingualFilterMode, BilingualFilterOptions } from '@/services/bilingualEpubFilter';
interface BilingualFilterAlertProps {
bookTitle: string;
safeAreaBottom: number;
processing: boolean;
onCancel: () => void;
onFilter: (options: BilingualFilterOptions) => void;
}
const BilingualFilterAlert: React.FC<BilingualFilterAlertProps> = ({
bookTitle,
safeAreaBottom,
processing,
onCancel,
onFilter,
}) => {
const _ = useTranslation();
const divRef = useKeyDownActions({ onCancel });
const [mode, setMode] = useState<BilingualFilterMode>('auto');
const [removeUnknown, setRemoveUnknown] = useState(false);
const handleFilter = (removeLanguage: BilingualFilterOptions['removeLanguage']) => {
if (processing) return;
onFilter({ removeLanguage, mode, removeUnknown });
};
return (
<div
ref={divRef}
className='fixed bottom-0 left-0 right-0 z-50 flex justify-center px-3'
style={{ paddingBottom: `${safeAreaBottom + 16}px` }}
>
<div
className={clsx(
'border-base-content/10 bg-base-200/95 flex w-full max-w-xl flex-col gap-4 rounded-2xl border p-4 shadow-lg backdrop-blur-sm',
processing && 'pointer-events-none opacity-80',
)}
>
<div className='relative flex min-w-0 items-center justify-center gap-2 pr-8'>
<LuLanguages className='size-5 shrink-0' />
<div className='min-w-0 truncate text-center text-sm font-medium'>
{_('Bilingual EPUB')}: {bookTitle}
</div>
<button
className={clsx(
'absolute right-0 flex items-center justify-center rounded-full p-1.5',
'text-base-content/70 transition-colors hover:text-base-content',
)}
onClick={onCancel}
aria-label={_('Cancel')}
disabled={processing}
>
<PiX className='size-5' />
</button>
</div>
<div className='grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center'>
<label className='flex min-w-0 items-center gap-2'>
<span className='text-neutral-content shrink-0 text-xs'>{_('Detection')}</span>
<select
className='select select-bordered select-sm min-w-0 flex-1'
value={mode}
disabled={processing}
onChange={(event) => setMode(event.target.value as BilingualFilterMode)}
>
<option value='auto'>{_('Auto')}</option>
<option value='style'>{_('Style')}</option>
<option value='script'>{_('Script')}</option>
</select>
</label>
<label className='flex cursor-pointer items-center gap-2 justify-self-start sm:justify-self-end'>
<input
type='checkbox'
className='checkbox checkbox-sm'
checked={removeUnknown}
disabled={processing}
onChange={(event) => setRemoveUnknown(event.target.checked)}
/>
<span className='text-sm'>{_('Remove uncertain text')}</span>
</label>
</div>
<div className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
<button
className='btn btn-primary btn-sm'
disabled={processing}
onClick={() => handleFilter('ja')}
>
{_('Keep Chinese')}
</button>
<button
className='btn btn-secondary btn-sm'
disabled={processing}
onClick={() => handleFilter('zh')}
>
{_('Keep Japanese')}
</button>
<button className='btn btn-ghost btn-sm' disabled={processing} onClick={onCancel}>
{processing ? _('Processing...') : _('Cancel')}
</button>
</div>
</div>
</div>
);
};
export default BilingualFilterAlert;
@@ -50,6 +50,7 @@ import { eventDispatcher } from '@/utils/event';
import { getLocalBookFilename } from '@/utils/book';
import { MIMETYPES, EXTS } from '@/libs/document';
import { makeSafeFilename } from '@/utils/misc';
import { getBookCategoryValues, type LibraryCategoryFilter } from '../utils/categoryFilters';
import { useSpatialNavigation } from '../hooks/useSpatialNavigation';
import DeleteConfirmAlert from '@/components/DeleteConfirmAlert';
@@ -63,6 +64,11 @@ import GroupingModal from './GroupingModal';
import SetStatusAlert from './SetStatusAlert';
import RecentShelf, { RECENT_SHELF_BOOK_COUNT } from './RecentShelf';
import { useOpenBook } from '../hooks/useOpenBook';
import BilingualFilterAlert from './BilingualFilterAlert';
import {
filterBilingualEpubFile,
type BilingualFilterOptions,
} from '@/services/bilingualEpubFilter';
interface BookshelfProps {
libraryBooks: Book[];
@@ -83,6 +89,7 @@ interface BookshelfProps {
handleLibraryNavigation: (targetGroup: string) => void;
handlePushLibrary: () => Promise<void>;
booksTransferProgress: { [key: string]: number | null };
categoryFilter?: LibraryCategoryFilter;
}
/**
@@ -170,6 +177,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
handleLibraryNavigation,
handlePushLibrary,
booksTransferProgress,
categoryFilter = 'all',
}) => {
const _ = useTranslation();
const router = useRouter();
@@ -180,6 +188,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const groupId = searchParams?.get('group') || '';
const queryTerm = searchParams?.get('q') || null;
const categoryValue = searchParams?.get('categoryValue') || '';
const viewMode = searchParams?.get('view') || settings.libraryViewMode;
const storedSortBy = ensureLibrarySortByType(searchParams?.get('sort'), settings.librarySortBy);
const sortOrder = searchParams?.get('order') || (settings.librarySortAscending ? 'asc' : 'desc');
@@ -199,6 +208,9 @@ const Bookshelf: React.FC<BookshelfProps> = ({
const [showDeleteAlert, setShowDeleteAlert] = useState(false);
const [showStatusAlert, setShowStatusAlert] = useState(false);
const [showGroupingModal, setShowGroupingModal] = useState(false);
const [showBilingualFilterAlert, setShowBilingualFilterAlert] = useState(false);
const [bilingualFilterBook, setBilingualFilterBook] = useState<Book | null>(null);
const [bilingualFilterProcessing, setBilingualFilterProcessing] = useState(false);
const [importBookUrl] = useState(searchParams?.get('url') || '');
const abortDeletionRef = useRef(false);
@@ -241,10 +253,20 @@ const Bookshelf: React.FC<BookshelfProps> = ({
[router, searchParams],
);
const categoryFilteredBooks = useMemo(() => {
if (categoryFilter === 'all' || !categoryValue) return libraryBooks;
return libraryBooks.filter(
(book) =>
!book.deletedAt && getBookCategoryValues(book, categoryFilter).includes(categoryValue),
);
}, [libraryBooks, categoryFilter, categoryValue]);
const filteredBooks = useMemo(() => {
const bookFilter = createBookFilter(queryTerm);
return queryTerm ? libraryBooks.filter((book) => bookFilter(book)) : libraryBooks;
}, [libraryBooks, queryTerm]);
return queryTerm
? categoryFilteredBooks.filter((book) => bookFilter(book))
: categoryFilteredBooks;
}, [categoryFilteredBooks, queryTerm]);
const currentBookshelfItems = useMemo(() => {
if (groupBy === LibraryGroupByType.Group) {
@@ -452,6 +474,112 @@ const Bookshelf: React.FC<BookshelfProps> = ({
setShowStatusAlert(true);
};
const getSingleSelectedBook = () => {
const ids = getSelectedBooks();
if (ids.length !== 1) return;
return filteredBooks.find((book) => book.hash === ids[0]);
};
const showBilingualFilterSelection = () => {
const book = getSingleSelectedBook();
if (!book || book.format !== 'EPUB') return;
setBilingualFilterBook(null);
setShowSelectModeActions(false);
setShowBilingualFilterAlert(true);
};
const showBilingualFilterBook = useCallback(
(book: Book) => {
if (book.format !== 'EPUB') {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: _('Only EPUB books can be filtered'),
timeout: 2500,
});
return;
}
setBilingualFilterBook(book);
setShowSelectModeActions(false);
setShowBilingualFilterAlert(true);
},
[_],
);
const closeBilingualFilterSelection = () => {
if (bilingualFilterProcessing) return;
setShowBilingualFilterAlert(false);
setBilingualFilterBook(null);
if (isSelectMode) setShowSelectModeActions(true);
};
const runBilingualFilter = async (options: BilingualFilterOptions) => {
const book = bilingualFilterBook ?? getSingleSelectedBook();
if (!book || !appService) return;
if (book.format !== 'EPUB') {
eventDispatcher.dispatch('toast', {
type: 'warning',
message: _('Only EPUB books can be filtered'),
timeout: 2500,
});
return;
}
setBilingualFilterProcessing(true);
setLoading(true);
try {
if (!(await appService.isBookAvailable(book))) {
if (!book.uploadedAt || !(await handleBookDownload(book, { queued: false }))) {
throw new Error('Book file is not available locally');
}
}
const { file } = await appService.loadBookContent(book);
try {
const result = await filterBilingualEpubFile(file, options);
const importedBook = await appService.importBook(result.file, libraryBooks);
if (!importedBook) throw new Error('Failed to import generated EPUB');
importedBook.group = book.group;
importedBook.groupId = book.groupId;
importedBook.groupName = book.groupName;
importedBook.tags = book.tags;
importedBook.updatedAt = Date.now();
await updateBooks(envConfig, [importedBook]);
handlePushLibrary();
setSelectedBooks([]);
setBilingualFilterBook(null);
setShowBilingualFilterAlert(false);
handleSetSelectMode(false);
eventDispatcher.dispatch('toast', {
type: 'success',
message: _('Created {{title}}. Removed {{count}} paragraph(s).', {
title: importedBook.title || result.title,
count: result.stats.removed,
}),
timeout: 3500,
});
} finally {
const closable = file as File & { close?: () => Promise<void> | void };
await closable.close?.();
}
} catch (error) {
console.error('Failed to filter bilingual EPUB:', error);
eventDispatcher.dispatch('toast', {
type: 'error',
message: _('Failed to filter bilingual EPUB'),
timeout: 3000,
});
setBilingualFilterBook(null);
setShowBilingualFilterAlert(false);
if (isSelectMode) setShowSelectModeActions(true);
} finally {
setLoading(false);
setBilingualFilterProcessing(false);
}
};
const sendSelectedBook = async () => {
// "Send" hands the actual book file (epub/pdf/...) to the OS share
// sheet (UIActivityViewController on iOS, Intent.ACTION_SEND on
@@ -678,6 +806,11 @@ const Bookshelf: React.FC<BookshelfProps> = ({
);
const selectedBooks = getSelectedBooks();
const selectedBilingualBook =
selectedBooks.length === 1
? filteredBooks.find((book) => book.hash === selectedBooks[0])
: null;
const bilingualFilterEnabled = !!selectedBilingualBook && selectedBilingualBook.format === 'EPUB';
const isGridMode = viewMode === 'grid';
const hasItems = sortedBookshelfItems.length > 0;
// In grid mode the Import-Books "+" tile is rendered as an extra grid cell
@@ -791,6 +924,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
handleBookDelete={handleBookDelete}
handleSetSelectMode={handleSetSelectMode}
handleShowDetailsBook={handleShowDetailsBook}
handleBilingualFilterBook={showBilingualFilterBook}
handleLibraryNavigation={handleLibraryNavigation}
handleUpdateReadingStatus={handleUpdateReadingStatus}
transferProgress={
@@ -816,6 +950,7 @@ const Bookshelf: React.FC<BookshelfProps> = ({
handleBookDelete,
handleSetSelectMode,
handleShowDetailsBook,
showBilingualFilterBook,
handleLibraryNavigation,
handleUpdateReadingStatus,
],
@@ -889,9 +1024,20 @@ const Bookshelf: React.FC<BookshelfProps> = ({
onGroup={groupSelectedBooks}
onDetails={openBookDetails}
onStatus={showStatusSelection}
onBilingualFilter={showBilingualFilterSelection}
onSend={sendSelectedBook}
onDelete={deleteSelectedBooks}
onCancel={() => handleSetSelectMode(false)}
bilingualFilterEnabled={bilingualFilterEnabled}
/>
)}
{showBilingualFilterAlert && (bilingualFilterBook || selectedBilingualBook) && (
<BilingualFilterAlert
bookTitle={(bilingualFilterBook || selectedBilingualBook)?.title || ''}
safeAreaBottom={safeAreaInsets?.bottom || 0}
processing={bilingualFilterProcessing}
onCancel={closeBilingualFilterSelection}
onFilter={runBilingualFilter}
/>
)}
{showGroupingModal && selectedBooks.length > 0 && (
@@ -1,20 +1,25 @@
import clsx from 'clsx';
import { useCallback } from 'react';
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, type MenuItemOptions } from '@tauri-apps/api/menu';
import { Menu as TauriMenu } from '@tauri-apps/api/menu';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import { eventDispatcher } from '@/utils/event';
import { openExternalUrl } from '@/utils/open';
import { getBookGoodreadsQuery, getGoodreadsSearchUrl } from '@/utils/goodreads';
import { getOSPlatform } from '@/utils/misc';
import { throttle } from '@/utils/throttle';
import { Overlay } from '@/components/Overlay';
import Menu from '@/components/Menu';
import MenuItem from '@/components/MenuItem';
import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
import { 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,
@@ -24,6 +29,17 @@ import BookItem from './BookItem';
import GroupItem from './GroupItem';
import { useOpenBook } from '../hooks/useOpenBook';
type WebContextMenuItem = {
text: string;
action: () => void | Promise<void>;
};
type WebContextMenuState = {
x: number;
y: number;
items: WebContextMenuItem[];
} | null;
export const generateBookshelfItems = (
books: Book[],
parentGroupName: string,
@@ -103,6 +119,7 @@ interface BookshelfItemProps {
handleBookDelete: (book: Book, syncBooks?: boolean) => Promise<boolean>;
handleSetSelectMode: (selectMode: boolean) => void;
handleShowDetailsBook: (book: Book) => void;
handleBilingualFilterBook: (book: Book) => void;
handleLibraryNavigation: (targetGroup: string) => void;
handleUpdateReadingStatus: (book: Book, status: ReadingStatus | undefined) => void;
}
@@ -121,13 +138,16 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
handleBookDownload,
handleSetSelectMode,
handleShowDetailsBook,
handleBilingualFilterBook,
handleLibraryNavigation,
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) => {
handleShowDetailsBook(book);
@@ -145,6 +165,25 @@ 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 = await appService?.resolveNativeBookFilePath(book);
if (!epubPath) {
eventDispatcher.dispatch('toast', {
message: _('Book file is not available locally'),
type: 'warning',
});
return;
}
sessionStorage.setItem('reviewEditorLaunchContext', JSON.stringify({ mode, epubPath }));
const params = new URLSearchParams({ mode });
navigateToReviewEditor(router, params);
},
[_, appService, makeBookAvailable, router],
);
const handleGroupClick = useCallback(
(group: BooksGroup) => {
if (isSelectMode) {
@@ -157,8 +196,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
[isSelectMode, handleLibraryNavigation],
);
const bookContextMenuHandler = async (book: Book) => {
if (!appService?.hasContextMenu) return;
const bookContextMenuHandler = async (book: Book, event?: React.MouseEvent) => {
const osPlatform = getOSPlatform();
const fileRevealLabel =
FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default;
@@ -166,7 +204,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
// in a single Menu.new({ items }) call. Appending items one-by-one with
// un-awaited Menu.append() promises races on the Tauri IPC boundary and
// shuffles the order on every open (issue #4389).
const itemOptions: Record<BookContextMenuItemId, MenuItemOptions> = {
const itemOptions: Record<BookContextMenuItemId, WebContextMenuItem> = {
select: {
text: itemSelected ? _('Deselect Book') : _('Select Book'),
action: async () => {
@@ -214,6 +252,24 @@ 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 () => {
handleBilingualFilterBook(book);
},
},
showInFinder: {
text: _(fileRevealLabel),
action: async () => {
@@ -254,16 +310,22 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
},
},
};
const items = getBookContextMenuItemIds(book).map((id) => itemOptions[id]);
const menu = await Menu.new({ items });
await menu.popup();
const itemIds = getBookContextMenuItemIds(book).filter(
(id) => appService?.hasContextMenu || id !== 'showInFinder',
);
const items = itemIds.map((id) => itemOptions[id]);
if (appService?.hasContextMenu) {
const menu = await TauriMenu.new({ items });
await menu.popup();
return;
}
if (event) setWebContextMenu({ x: event.clientX, y: event.clientY, items });
};
const groupContextMenuHandler = async (group: BooksGroup) => {
if (!appService?.hasContextMenu) return;
const groupContextMenuHandler = async (group: BooksGroup, event?: React.MouseEvent) => {
// Single Menu.new({ items }) call keeps the order deterministic — see the
// note in bookContextMenuHandler about the Menu.append() IPC race (#4389).
const items: MenuItemOptions[] = [
const items: WebContextMenuItem[] = [
{
text: itemSelected ? _('Deselect Group') : _('Select Group'),
action: async () => {
@@ -293,8 +355,12 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
},
},
];
const menu = await Menu.new({ items });
await menu.popup();
if (appService?.hasContextMenu) {
const menu = await TauriMenu.new({ items });
await menu.popup();
return;
}
if (event) setWebContextMenu({ x: event.clientX, y: event.clientY, items });
};
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -330,14 +396,28 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleContextMenu = useCallback(
throttle(() => {
throttle((event?: React.MouseEvent) => {
if ('format' in item) {
bookContextMenuHandler(item as Book);
bookContextMenuHandler(item as Book, event);
} else {
groupContextMenuHandler(item as BooksGroup);
groupContextMenuHandler(item as BooksGroup, event);
}
}, 100),
[itemSelected, settings.localBooksDir],
[
appService?.hasContextMenu,
appService?.isMobileApp,
handleBookDownload,
handleBookUpload,
handleBilingualFilterBook,
handleGroupBooks,
handleSetSelectMode,
handleUpdateReadingStatus,
item,
itemSelected,
settings.localBooksDir,
showBookDetailsModal,
toggleSelection,
],
);
const { pressing, handlers } = useLongPress(
@@ -348,9 +428,11 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
onTap: () => {
handleOpenItem();
},
onContextMenu: () => {
onContextMenu: (event) => {
if (appService?.hasContextMenu) {
handleContextMenu();
} else if (!appService?.isMobileApp) {
handleContextMenu(event);
} else if (appService?.isAndroidApp) {
handleSelectItem();
}
@@ -380,6 +462,29 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
return (
<div className={clsx(mode === 'grid' ? 'h-full' : 'sm:hover:bg-base-300/50 px-4 sm:px-6')}>
{webContextMenu && (
<>
<Overlay onDismiss={() => setWebContextMenu(null)} className='z-40' />
<Menu
className='bg-base-100 border-base-300 fixed z-50 min-w-56 rounded-lg border p-1 shadow-xl'
style={{ left: webContextMenu.x, top: webContextMenu.y }}
onCancel={() => setWebContextMenu(null)}
>
{webContextMenu.items.map((menuItem) => (
<MenuItem
key={menuItem.text}
label={menuItem.text}
noIcon
transient
onClick={() => {
setWebContextMenu(null);
void menuItem.action();
}}
/>
))}
</Menu>
</>
)}
<div
className={clsx(
'visible-focus-inset-2 group',
@@ -0,0 +1,316 @@
import clsx from 'clsx';
import React, { useCallback, useMemo, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import {
PiBooks,
PiCalendarBlank,
PiDotsThreeCircle,
PiGear,
PiGlobeHemisphereEast,
PiHouseLine,
PiPlus,
PiSelectionAll,
PiSelectionAllFill,
PiStackSimple,
PiTag,
PiUser,
} from 'react-icons/pi';
import { MdBusiness, MdKeyboardArrowDown, MdKeyboardArrowUp, MdOutlineMenu } from 'react-icons/md';
import { IoMdCloseCircle } from 'react-icons/io';
import { FaSearch } from 'react-icons/fa';
import type { IconType } from 'react-icons';
import { useTranslation } from '@/hooks/useTranslation';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { debounce } from '@/utils/debounce';
import { navigateToLibrary } from '@/utils/nav';
import Dropdown from '@/components/Dropdown';
import ImportMenu from './ImportMenu';
import SettingsMenu from './SettingsMenu';
import ViewMenu from './ViewMenu';
import type { Book } from '@/types/book';
import {
countCategoryValues,
ensureLibraryCategoryFilter,
type CategoryValue,
type LibraryCategoryFilter,
} from '../utils/categoryFilters';
interface LibrarySidebarProps {
books: Book[];
isSelectMode: boolean;
onPullLibrary: () => void;
onImportBooksFromFiles: () => void;
onImportBooksFromDirectory?: () => void;
onImportBookFromUrl?: () => void;
onOpenCatalogManager: () => void;
onToggleSelectMode: () => void;
}
type CategoryItem = {
id: LibraryCategoryFilter;
label: string;
count: number;
Icon: IconType;
values: CategoryValue[];
};
const LibrarySidebar: React.FC<LibrarySidebarProps> = ({
books,
isSelectMode,
onPullLibrary,
onImportBooksFromFiles,
onImportBooksFromDirectory,
onImportBookFromUrl,
onOpenCatalogManager,
onToggleSelectMode,
}) => {
const _ = useTranslation();
const router = useRouter();
const searchParams = useSearchParams();
const iconSize18 = useResponsiveSize(18);
const [searchQuery, setSearchQuery] = useState(searchParams?.get('q') ?? '');
const activeCategory = ensureLibraryCategoryFilter(searchParams?.get('category'));
const activeCategoryValue = searchParams?.get('categoryValue') || '';
const [expandedCategories, setExpandedCategories] = useState<Set<LibraryCategoryFilter>>(
() => new Set(activeCategory !== 'all' ? [activeCategory] : ['author']),
);
const activeBooks = useMemo(() => books.filter((book) => !book.deletedAt), [books]);
const categories: CategoryItem[] = useMemo(() => {
const makeCategory = (
id: LibraryCategoryFilter,
label: string,
Icon: IconType,
): CategoryItem => {
const values = countCategoryValues(activeBooks, id);
return { id, label, count: values.length, Icon, values };
};
return [
{
id: 'all',
label: _('All Books'),
count: activeBooks.length,
Icon: PiBooks,
values: [],
},
makeCategory('author', _('Authors'), PiUser),
makeCategory('publisher', _('Publisher'), MdBusiness),
makeCategory('year', _('Publication Year'), PiCalendarBlank),
makeCategory('language', _('Language'), PiGlobeHemisphereEast),
makeCategory('format', _('Category'), PiStackSimple),
makeCategory('subject', _('Subject'), PiHouseLine),
makeCategory('tag', _('Tags'), PiTag),
];
}, [_, activeBooks]);
const updateParams = useCallback(
(updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams?.toString());
for (const [key, value] of Object.entries(updates)) {
if (!value) {
params.delete(key);
} else {
params.set(key, value);
}
}
if (params.get('category') === 'all') params.delete('category');
navigateToLibrary(router, params.toString());
},
[router, searchParams],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const debouncedUpdateQueryParam = useCallback(
debounce((value: string) => updateParams({ q: value || null }), 500),
[updateParams],
);
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const newQuery = event.target.value;
setSearchQuery(newQuery);
debouncedUpdateQueryParam(newQuery);
};
const clearSearch = () => {
setSearchQuery('');
debouncedUpdateQueryParam('');
};
const toggleCategory = (category: LibraryCategoryFilter) => {
if (category === 'all') {
updateParams({ category: null, categoryValue: null, group: null });
return;
}
setExpandedCategories((prev) => {
const next = new Set(prev);
if (next.has(category)) {
next.delete(category);
} else {
next.add(category);
}
return next;
});
};
const selectCategoryValue = (category: LibraryCategoryFilter, value: string) => {
updateParams({ category, categoryValue: value, group: null });
};
return (
<aside
className={clsx(
'library-sidebar bg-base-200/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'>
<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>
<div className='relative mb-3 flex h-8 items-center'>
<span className='text-base-content/45 absolute left-3'>
<FaSearch className='h-3.5 w-3.5' />
</span>
<input
type='text'
value={searchQuery}
placeholder={_('Search Books...')}
onChange={handleSearchChange}
spellCheck='false'
className={clsx(
'input input-sm bg-base-100 border-base-300 h-8 w-full rounded-md ps-9 pe-8',
'text-sm placeholder:text-base-content/45 focus:outline-none',
)}
/>
{searchQuery && (
<button
type='button'
onClick={clearSearch}
className='text-base-content/40 hover:text-base-content/70 absolute right-2'
aria-label={_('Clear Search')}
>
<IoMdCloseCircle className='h-4 w-4' />
</button>
)}
</div>
<nav className='min-h-0 flex-1 overflow-y-auto pb-3' aria-label={_('Categories')}>
<div className='space-y-1'>
{categories.map(({ id, label, count, Icon, values }) => {
const active = (activeCategory || 'all') === id && !activeCategoryValue;
const expanded = expandedCategories.has(id);
return (
<div key={id}>
<button
type='button'
onClick={() => toggleCategory(id)}
className={clsx(
'flex h-9 w-full items-center gap-3 rounded-md px-2 text-sm transition-colors',
active
? 'bg-base-300 text-base-content'
: 'text-base-content/85 hover:bg-base-300/55',
)}
>
<Icon className='h-4.5 w-4.5 shrink-0' />
<span className='min-w-0 flex-1 truncate text-left'>{label}</span>
{id !== 'all' &&
(expanded ? (
<MdKeyboardArrowUp className='text-base-content/60 h-4 w-4 shrink-0' />
) : (
<MdKeyboardArrowDown className='text-base-content/60 h-4 w-4 shrink-0' />
))}
<span className='bg-base-content/30 text-base-100 min-w-5 rounded-full px-1.5 text-center text-[11px] font-medium leading-5'>
{count}
</span>
</button>
{id !== 'all' && expanded && values.length > 0 && (
<div className='mt-1 space-y-1 pb-1 ps-7'>
{values.map((item) => {
const valueActive =
activeCategory === id && activeCategoryValue === item.value;
return (
<button
key={item.value}
type='button'
onClick={() => selectCategoryValue(id, item.value)}
className={clsx(
'flex h-8 w-full items-center gap-2 rounded-md px-2 text-sm transition-colors',
valueActive
? 'bg-base-300 text-base-content'
: 'text-base-content/80 hover:bg-base-300/45',
)}
>
<span
className={clsx(
'h-4 w-0.5 rounded-full',
valueActive ? 'bg-primary' : 'bg-transparent',
)}
/>
<span className='min-w-0 flex-1 truncate text-left'>{item.value}</span>
<span className='bg-base-content/30 text-base-100 min-w-5 rounded-full px-1.5 text-center text-[11px] font-medium leading-5'>
{item.count}
</span>
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
</nav>
<div className='border-base-300/70 flex items-center gap-1 border-t pt-2'>
<Dropdown
label={_('Import Books')}
className='exclude-title-bar-mousedown dropdown-top dropdown-start'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiPlus role='none' size={iconSize18} />}
>
<ImportMenu
onImportBooksFromFiles={onImportBooksFromFiles}
onImportBooksFromDirectory={onImportBooksFromDirectory}
onImportBookFromUrl={onImportBookFromUrl}
onOpenCatalogManager={onOpenCatalogManager}
/>
</Dropdown>
<Dropdown
label={_('View Menu')}
className='exclude-title-bar-mousedown dropdown-top dropdown-start'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiDotsThreeCircle role='none' size={iconSize18} />}
>
<ViewMenu />
</Dropdown>
<button
onClick={onToggleSelectMode}
aria-label={_('Select Books')}
title={_('Select Books')}
className='btn btn-ghost h-8 min-h-8 w-8 p-0'
>
{isSelectMode ? (
<PiSelectionAllFill className='h-[18px] w-[18px]' />
) : (
<PiSelectionAll className='h-[18px] w-[18px]' />
)}
</button>
<div className='flex-1' />
<Dropdown
label={_('Settings Menu')}
className='exclude-title-bar-mousedown dropdown-top dropdown-start'
buttonClassName='btn btn-ghost h-8 min-h-8 w-8 p-0'
toggleButton={<PiGear role='none' size={iconSize18} />}
>
<SettingsMenu onPullLibrary={onPullLibrary} />
</Dropdown>
</div>
</div>
</aside>
);
};
export default LibrarySidebar;
@@ -7,7 +7,7 @@ import {
MdCheckCircleOutline,
} from 'react-icons/md';
import { IoShareSocialOutline } from 'react-icons/io5';
import { LuFolderPlus } from 'react-icons/lu';
import { LuFolderPlus, LuLanguages } from 'react-icons/lu';
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
import { useTranslation } from '@/hooks/useTranslation';
import { isMd5 } from '@/utils/md5';
@@ -25,6 +25,7 @@ interface SelectModeActionsProps {
onGroup: () => void;
onDetails: () => void;
onStatus: () => void;
onBilingualFilter: () => void;
// The macOS / iPad share popover is anchored to the selected book's
// cover (located via its data-book-hash attribute), not to this
// button — the user's visual focus is on the cover they just tapped.
@@ -32,6 +33,7 @@ interface SelectModeActionsProps {
onSend: () => void;
onDelete: () => void;
onCancel: () => void;
bilingualFilterEnabled?: boolean;
}
const SelectModeActions: React.FC<SelectModeActionsProps> = ({
@@ -42,9 +44,11 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
onGroup,
onDetails,
onStatus,
onBilingualFilter,
onSend,
onDelete,
onCancel,
bilingualFilterEnabled = false,
}) => {
const _ = useTranslation();
@@ -110,13 +114,21 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
<MdInfoOutline />
<div>{_('Details')}</div>
</button>
<button
onClick={onBilingualFilter}
className={clsx(
'flex flex-col items-center justify-center gap-1',
!bilingualFilterEnabled && 'btn-disabled opacity-50',
)}
>
<LuLanguages />
<div>{_('Bilingual')}</div>
</button>
{sendEnabled && (
<button
onClick={onSend}
className={clsx(
'flex flex-col items-center justify-center gap-1',
// Wraps to the start of the second row on narrow viewports.
'max-[500px]:col-start-1',
(!hasSingleSelection || !hasValidBooks) && 'btn-disabled opacity-50',
)}
>
@@ -128,12 +140,6 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
onClick={onDelete}
className={clsx(
'flex flex-col items-center justify-center gap-1',
// Without Send (Linux/Windows/web), Delete needs an explicit
// col-start-2 so the wrapped row {Delete, Cancel} stays centred
// under the 4-col grid. With Send present, the layout is
// {Send, Delete, Cancel} starting at col-start-1, so Delete
// naturally lands in col-start-2 without an override.
!sendEnabled && 'max-[500px]:col-start-2',
!hasSelection && 'btn-disabled opacity-50',
)}
>
@@ -436,7 +436,8 @@ const SettingsMenu: React.FC<SettingsMenuProps> = ({ onPullLibrary, setIsDropdow
<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'>
{process.env['NODE_ENV'] === 'development' && isWebAppPlatform() && (
{(isTauriAppPlatform() ||
(process.env['NODE_ENV'] === 'development' && isWebAppPlatform())) && (
<MenuItem label='EPUB 审校器' onClick={handleOpenReviewEditor} />
)}
<MenuItem label={_('Backup & Restore')} onClick={handleBackupRestore} />
+135 -105
View File
@@ -100,6 +100,8 @@ import {
} from './utils/libraryUtils';
import Spinner from '@/components/Spinner';
import LibraryHeader from './components/LibraryHeader';
import LibrarySidebar from './components/LibrarySidebar';
import { ensureLibraryCategoryFilter } from './utils/categoryFilters';
import Bookshelf from './components/Bookshelf';
import LibraryEmptyState from './components/LibraryEmptyState';
import GroupHeader from './components/GroupHeader';
@@ -1592,133 +1594,161 @@ const LibraryPageContent = ({ searchParams }: { searchParams: ReadonlyURLSearchP
}
const showBookshelf = libraryLoaded || libraryBooks.length > 0;
const categoryFilter = ensureLibraryCategoryFilter(searchParams?.get('category'));
return (
<div
ref={pageRef}
aria-label={_('Your Library')}
className={clsx(
'library-page text-base-content full-height flex select-none flex-col overflow-hidden',
'library-page text-base-content full-height flex select-none overflow-hidden',
viewSettings?.isEink ? 'bg-base-100' : 'bg-base-200',
appService?.hasRoundedWindow && isRoundedWindow && 'window-border rounded-window',
)}
>
<div
className='relative top-0 z-40 w-full'
role='banner'
tabIndex={-1}
aria-label={_('Library Header')}
>
<LibraryHeader
isSelectMode={isSelectMode}
isSelectAll={isSelectAll}
onPullLibrary={pullLibrary}
onImportBooksFromFiles={handleImportBooksFromFiles}
onImportBooksFromDirectory={
appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
}
onImportBookFromUrl={isTauriAppPlatform() ? () => setShowImportFromUrl(true) : undefined}
onOpenCatalogManager={handleShowOPDSDialog}
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
onSelectAll={handleSelectAll}
onDeselectAll={handleDeselectAll}
/>
<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='relative top-0 z-40 w-full md:hidden'
role='banner'
tabIndex={-1}
aria-label={_('Library Header')}
>
<LibraryHeader
isSelectMode={isSelectMode}
isSelectAll={isSelectAll}
onPullLibrary={pullLibrary}
onImportBooksFromFiles={handleImportBooksFromFiles}
onImportBooksFromDirectory={
appService?.canReadExternalDir ? handleImportBooksFromDirectory : undefined
}
onImportBookFromUrl={
isTauriAppPlatform() ? () => setShowImportFromUrl(true) : undefined
}
onOpenCatalogManager={handleShowOPDSDialog}
onToggleSelectMode={() => handleSetSelectMode(!isSelectMode)}
onSelectAll={handleSelectAll}
onDeselectAll={handleDeselectAll}
/>
<progress
aria-label={_('Library Sync Progress')}
aria-hidden={isSyncing ? 'false' : 'true'}
className={clsx(
'progress progress-success absolute bottom-0 left-0 right-0 h-1 translate-y-[2px] transition-opacity duration-200 sm:translate-y-[4px]',
isSyncing ? 'opacity-100' : 'opacity-0',
)}
value={syncProgress * 100}
max='100'
/>
</div>
<progress
aria-label={_('Library Sync Progress')}
aria-hidden={isSyncing ? 'false' : 'true'}
className={clsx(
'progress progress-success absolute bottom-0 left-0 right-0 h-1 translate-y-[2px] transition-opacity duration-200 sm:translate-y-[4px]',
'progress progress-success hidden h-1 rounded-none transition-opacity duration-200 md:block',
isSyncing ? 'opacity-100' : 'opacity-0',
)}
value={syncProgress * 100}
max='100'
/>
</div>
{(loading || isSyncing) && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading />
</div>
)}
{currentGroupPath && (
<div
className={`transition-all duration-300 ease-in-out ${
currentGroupPath ? 'opacity-100' : 'max-h-0 opacity-0'
}`}
>
<div className='flex flex-wrap items-center gap-y-1 px-4 text-base'>
<button
onClick={() => handleNavigateToPath(undefined)}
className='hover:bg-base-300 text-base-content/85 rounded px-2 py-1'
>
{_('All')}
</button>
{getBreadcrumbs(currentGroupPath).map((crumb, index, array) => {
const isLast = index === array.length - 1;
return (
<React.Fragment key={index}>
<MdChevronRight size={iconSize} className='text-neutral-content' />
{isLast ? (
<span className='truncate rounded px-2 py-1'>{crumb.name}</span>
) : (
<button
onClick={() => handleNavigateToPath(crumb.path)}
className='hover:bg-base-300 text-base-content/85 truncate rounded px-2 py-1'
>
{crumb.name}
</button>
)}
</React.Fragment>
);
})}
{(loading || isSyncing) && (
<div className='fixed inset-0 z-50 flex items-center justify-center'>
<Spinner loading />
</div>
</div>
)}
{currentSeriesAuthorGroup && (
<GroupHeader
groupBy={currentSeriesAuthorGroup.groupBy}
groupName={currentSeriesAuthorGroup.groupName}
/>
)}
{showBookshelf &&
(libraryBooks.some((book) => !book.deletedAt) ? (
<div aria-label={_('Your Bookshelf')} className='flex min-h-0 flex-grow flex-col'>
<div
ref={containerRef}
className={clsx(
'scroll-container drop-zone flex min-h-0 flex-grow flex-col',
isDragging && 'drag-over',
)}
style={{
paddingRight: `${insets.right}px`,
paddingLeft: `${insets.left}px`,
}}
>
<DropIndicator />
<Bookshelf
libraryBooks={libraryBooks}
isSelectMode={isSelectMode}
isSelectAll={isSelectAll}
isSelectNone={isSelectNone}
onScrollerRef={handleScrollerRef}
handleImportBooks={handleImportBooksFromFiles}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete('both')}
handleBookPurge={handleBookDelete('purge')}
handleSetSelectMode={handleSetSelectMode}
handleShowDetailsBook={handleShowDetailsBook}
handleLibraryNavigation={handleLibraryNavigation}
booksTransferProgress={booksTransferProgress}
handlePushLibrary={pushLibrary}
/>
)}
{currentGroupPath && (
<div
className={`transition-all duration-300 ease-in-out ${
currentGroupPath ? 'opacity-100' : 'max-h-0 opacity-0'
}`}
>
<div className='flex flex-wrap items-center gap-y-1 px-4 py-2 text-base'>
<button
onClick={() => handleNavigateToPath(undefined)}
className='hover:bg-base-300 text-base-content/85 rounded px-2 py-1'
>
{_('All')}
</button>
{getBreadcrumbs(currentGroupPath).map((crumb, index, array) => {
const isLast = index === array.length - 1;
return (
<React.Fragment key={index}>
<MdChevronRight size={iconSize} className='text-neutral-content' />
{isLast ? (
<span className='truncate rounded px-2 py-1'>{crumb.name}</span>
) : (
<button
onClick={() => handleNavigateToPath(crumb.path)}
className='hover:bg-base-300 text-base-content/85 truncate rounded px-2 py-1'
>
{crumb.name}
</button>
)}
</React.Fragment>
);
})}
</div>
</div>
) : (
<div className='hero drop-zone h-screen items-center justify-center'>
<DropIndicator />
<LibraryEmptyState onImport={handleImportBooksFromFiles} />
</div>
))}
)}
{currentSeriesAuthorGroup && (
<GroupHeader
groupBy={currentSeriesAuthorGroup.groupBy}
groupName={currentSeriesAuthorGroup.groupName}
/>
)}
{showBookshelf &&
(libraryBooks.some((book) => !book.deletedAt) ? (
<div aria-label={_('Your Bookshelf')} className='flex min-h-0 flex-grow flex-col'>
<div
ref={containerRef}
className={clsx(
'scroll-container drop-zone flex min-h-0 flex-grow flex-col',
isDragging && 'drag-over',
)}
style={{
paddingRight: `${insets.right}px`,
paddingLeft: `${insets.left}px`,
}}
>
<DropIndicator />
<Bookshelf
libraryBooks={libraryBooks}
categoryFilter={categoryFilter}
isSelectMode={isSelectMode}
isSelectAll={isSelectAll}
isSelectNone={isSelectNone}
onScrollerRef={handleScrollerRef}
handleImportBooks={handleImportBooksFromFiles}
handleBookUpload={handleBookUpload}
handleBookDownload={handleBookDownload}
handleBookDelete={handleBookDelete('both')}
handleBookPurge={handleBookDelete('purge')}
handleSetSelectMode={handleSetSelectMode}
handleShowDetailsBook={handleShowDetailsBook}
handleLibraryNavigation={handleLibraryNavigation}
booksTransferProgress={booksTransferProgress}
handlePushLibrary={pushLibrary}
/>
</div>
</div>
) : (
<div className='hero drop-zone h-screen items-center justify-center'>
<DropIndicator />
<LibraryEmptyState onImport={handleImportBooksFromFiles} />
</div>
))}
</div>
<NowPlayingBar isSelectMode={isSelectMode} />
{showDetailsBook && (
<BookDetailModal
@@ -0,0 +1,93 @@
import type { Book } from '@/types/book';
import { formatAuthors, formatLanguage, formatPublisher } from '@/utils/book';
import { parseAuthors } from './libraryUtils';
export type LibraryCategoryFilter =
| 'all'
| 'author'
| 'publisher'
| 'year'
| 'language'
| 'format'
| 'subject'
| 'tag';
const LIBRARY_CATEGORY_FILTERS: LibraryCategoryFilter[] = [
'all',
'author',
'publisher',
'year',
'language',
'format',
'subject',
'tag',
];
export type CategoryValue = {
value: string;
count: number;
};
export const ensureLibraryCategoryFilter = (
value: string | null | undefined,
): LibraryCategoryFilter =>
LIBRARY_CATEGORY_FILTERS.includes(value as LibraryCategoryFilter)
? (value as LibraryCategoryFilter)
: 'all';
const getPublishedYear = (book: Book) =>
typeof book.metadata?.published === 'string'
? book.metadata.published.match(/\d{4}/)?.[0]
: undefined;
const getSubjects = (book: Book): string[] => {
const subject = book.metadata?.subject;
if (!subject) return [];
if (Array.isArray(subject)) return subject.map((item) => `${item}`.trim()).filter(Boolean);
if (typeof subject === 'string') {
return subject
.split(/[,;]/u)
.map((item) => item.trim())
.filter(Boolean);
}
return [formatAuthors(subject)].filter(Boolean);
};
export const getBookCategoryValues = (book: Book, category: LibraryCategoryFilter): string[] => {
switch (category) {
case 'author':
return parseAuthors(formatAuthors(book.author, book.primaryLanguage));
case 'publisher': {
const publisher = formatPublisher(book.metadata?.publisher || '').trim();
return publisher ? [publisher] : [];
}
case 'year': {
const year = getPublishedYear(book);
return year ? [year] : [];
}
case 'language': {
const language = formatLanguage(book.metadata?.language).trim();
return language ? [language] : [];
}
case 'format':
return book.format ? [book.format] : [];
case 'subject':
return getSubjects(book);
case 'tag':
return book.tags?.map((tag) => tag.trim()).filter(Boolean) || [];
case 'all':
return [];
}
};
export const countCategoryValues = (books: Book[], category: LibraryCategoryFilter) => {
const counts = new Map<string, number>();
for (const book of books) {
for (const value of getBookCategoryValues(book, category)) {
counts.set(value, (counts.get(value) || 0) + 1);
}
}
return [...counts.entries()]
.map(([value, count]) => ({ value, count }))
.sort((a, b) => a.value.localeCompare(b.value, navigator.language));
};
@@ -651,6 +651,9 @@ export type BookContextMenuItemId =
| 'markAbandoned'
| 'clearStatus'
| 'showDetails'
| 'reviewInEpubEditor'
| 'translateInEpubEditor'
| 'bilingual'
| 'showInFinder'
| 'searchGoodreads'
| 'download'
@@ -750,7 +753,11 @@ export const getBookContextMenuItemIds = (book: Book): BookContextMenuItemId[] =
) {
ids.push('clearStatus');
}
ids.push('showDetails', 'showInFinder', 'searchGoodreads');
ids.push('showDetails');
if (book.format?.toUpperCase() === 'EPUB') {
ids.push('bilingual', 'reviewInEpubEditor', 'translateInEpubEditor');
}
ids.push('showInFinder', 'searchGoodreads');
if (book.uploadedAt && !book.downloadedAt) ids.push('download');
if (!book.uploadedAt && book.downloadedAt) ids.push('upload');
// Share is offered for any local-or-uploaded book; the dialog uploads first
@@ -17,6 +17,7 @@ import FoliateViewer from './FoliateViewer';
import SectionInfo from './SectionInfo';
import HeaderBar from './HeaderBar';
import PageNavigationButtons from './PageNavigationButtons';
import ReviewModeController from './ReviewModeController';
import FooterBar from './footerbar/FooterBar';
import ProgressBar from './ProgressBar';
import Ribbon from './Ribbon';
@@ -171,6 +172,7 @@ const BookCellInner: React.FC<BookCellProps> = ({
gridInsets={gridInsets}
contentInsets={contentInsets}
/>
<ReviewModeController bookKey={bookKey} bookDoc={bookDoc} />
{viewSettings.vertical && viewSettings.scrolled && (
<>
{(showFooter || viewSettings.doubleBorder) && (
@@ -337,7 +339,7 @@ const BooksGrid: React.FC<BooksGridProps> = ({ bookKeys, onCloseBook, onGoToLibr
return (
<div
className={clsx('books-grid bg-base-100 relative grid h-full flex-grow')}
className={clsx('books-grid bg-base-100 relative grid h-full min-w-0 flex-grow')}
style={gridStyle}
role='main'
aria-label={_('Books Content')}
@@ -27,6 +27,7 @@ import QuickActionMenu from './annotator/QuickActionMenu';
import SidebarToggler from './SidebarToggler';
import BookmarkToggler from './BookmarkToggler';
import NotebookToggler from './NotebookToggler';
import ReviewModeToggler from './ReviewModeToggler';
import SettingsToggler from './SettingsToggler';
import TranslationToggler from './TranslationToggler';
import ViewMenu from './ViewMenu';
@@ -282,6 +283,7 @@ const HeaderBar: React.FC<HeaderBarProps> = ({
<div className='header-tools-end bg-base-100 z-20 ms-auto flex h-full min-w-max items-center gap-x-4 ps-2 max-[350px]:gap-x-2'>
{!isHeaderCompact && <SettingsToggler bookKey={bookKey} />}
<ReviewModeToggler bookKey={bookKey} />
<NotebookToggler bookKey={bookKey} />
<Dropdown
label={_('View Options')}
@@ -9,6 +9,7 @@ import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { useReviewModeStore } from '@/store/reviewModeStore';
import { useGamepad } from '@/hooks/useGamepad';
import { useTranslation } from '@/hooks/useTranslation';
import { SystemSettings } from '@/types/settings';
@@ -37,6 +38,7 @@ import Spinner from '@/components/Spinner';
import SideBar from './sidebar/SideBar';
import Notebook from './notebook/Notebook';
import BooksGrid from './BooksGrid';
import ReviewPanel from './ReviewPanel';
import SettingsDialog from '@/components/settings/SettingsDialog';
const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ ids, settings }) => {
@@ -50,6 +52,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
const { getConfig, getBookData, saveConfig } = useBookDataStore();
const { getView, setBookKeys, getViewSettings } = useReaderStore();
const { initViewState, getViewState, clearViewState } = useReaderStore();
const clearReviewBook = useReviewModeStore((state) => state.clearBook);
const { isSettingsDialogOpen, settingsDialogBookKey } = useSettingsStore();
const [showDetailsBook, setShowDetailsBook] = useState<Book | null>(null);
const [shareDialogState, setShareDialogState] = useState<{
@@ -176,6 +179,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
const saveConfigAndCloseBook = async (bookKey: string, keepTTSAlive = false) => {
console.log('Closing book', bookKey);
clearReviewBook(bookKey);
const viewState = getViewState(bookKey);
if (viewState?.isPrimary && appService?.isDesktopApp) {
@@ -291,6 +295,7 @@ const ReaderContent: React.FC<{ ids?: string; settings: SystemSettings }> = ({ i
onGoToLibrary={handleCloseBooksToLibrary}
/>
{isSettingsDialogOpen && <SettingsDialog bookKey={settingsDialogBookKey} />}
<ReviewPanel />
<Notebook />
{showDetailsBook && (
<BookDetailModal
@@ -0,0 +1,307 @@
import DOMPurify from 'dompurify';
import { useEffect, useRef } from 'react';
import type { BookDoc } from '@/libs/document';
import type { ReviewRow } from '@/services/reviewEditorService';
import { useReaderStore } from '@/store/readerStore';
import { useReviewModeStore } from '@/store/reviewModeStore';
const STYLE_ID = 'readest-inline-review-style';
const ROW_ATTR = 'data-readest-review-row-id';
const ROLE_ATTR = 'data-readest-review-role';
const ORIGINAL_HTML_ATTR = 'data-readest-review-original-html';
const reviewHtmlPurifyOptions = {
ALLOWED_TAGS: [
'ruby',
'rb',
'rt',
'rp',
'span',
'mark',
'br',
'em',
'strong',
'b',
'i',
'u',
's',
'sub',
'sup',
],
ALLOWED_ATTR: ['class', 'title'],
ALLOW_DATA_ATTR: false,
};
const sanitizeReviewHtml = (html: string) =>
DOMPurify.sanitize(html || '', reviewHtmlPurifyOptions);
const emptyReviewRows: ReviewRow[] = [];
const normalizeFile = (value?: string) =>
(() => {
const raw = (value || '').split('#')[0] || '';
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
})()
.replaceAll('\\', '/')
.replace(/^\/+/, '')
.toLowerCase();
const sameFile = (rowFile: string, sectionHref?: string) => {
const row = normalizeFile(rowFile);
const href = normalizeFile(sectionHref);
if (!row || !href) return false;
return row === href || row.endsWith(`/${href}`) || href.endsWith(`/${row}`);
};
const sectionHrefAt = (bookDoc: BookDoc, index: number | undefined) => {
const section = bookDoc.sections[index ?? -1];
return section?.href || section?.id;
};
const clearReviewMarks = (doc: Document) => {
doc.getElementById(STYLE_ID)?.remove();
doc.querySelectorAll(`[${ROW_ATTR}]`).forEach((element) => {
const originalHtml = element.getAttribute(ORIGINAL_HTML_ATTR);
if (originalHtml !== null) {
element.innerHTML = originalHtml;
}
element.removeAttribute(ROW_ATTR);
element.removeAttribute(ROLE_ATTR);
element.removeAttribute(ORIGINAL_HTML_ATTR);
element.classList.remove(
'readest-review-source',
'readest-review-target',
'readest-review-active',
);
});
};
const ensureReviewStyle = (doc: Document) => {
if (doc.getElementById(STYLE_ID)) return;
const style = doc.createElement('style');
style.id = STYLE_ID;
style.textContent = `
[${ROW_ATTR}] {
cursor: pointer;
user-select: text;
outline-offset: 0.18em;
transition: background-color 120ms ease, outline-color 120ms ease;
}
.readest-review-source {
background: rgba(127, 127, 127, 0.12);
outline: 1px dashed rgba(127, 127, 127, 0.64);
}
.readest-review-target {
background: rgba(22, 163, 74, 0.14);
outline: 1px solid rgba(22, 163, 74, 0.72);
}
.readest-review-active {
background: rgba(37, 99, 235, 0.18) !important;
outline: 2px solid currentColor !important;
}
`;
doc.head.appendChild(style);
};
const closestReviewTarget = (target: EventTarget | null): HTMLElement | null => {
const element = target as Element | null;
if (!element || typeof element.closest !== 'function') return null;
if (element.closest('a, button, input, textarea, select, video, audio')) return null;
return element.closest(`[${ROW_ATTR}]`) as HTMLElement | null;
};
const closestReviewTargetFromNode = (node: Node | null): HTMLElement | null => {
const element =
node?.nodeType === Node.ELEMENT_NODE
? (node as Element)
: (node?.parentElement as Element | null);
if (!element || typeof element.closest !== 'function') return null;
return element.closest(`[${ROW_ATTR}]`) as HTMLElement | null;
};
const selectedReviewTarget = (doc: Document): HTMLElement | null => {
const selection = doc.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null;
const range = selection.getRangeAt(0);
return (
closestReviewTargetFromNode(range.commonAncestorContainer) ||
closestReviewTargetFromNode(range.startContainer) ||
closestReviewTargetFromNode(range.endContainer)
);
};
const markParagraph = (
element: Element | undefined,
row: ReviewRow,
role: 'source' | 'target',
active: boolean,
) => {
const htmlElement = element as HTMLElement | undefined;
if (!htmlElement || typeof htmlElement.setAttribute !== 'function') return;
htmlElement.setAttribute(ROW_ATTR, row.id);
htmlElement.setAttribute(ROLE_ATTR, role);
htmlElement.classList.add(role === 'source' ? 'readest-review-source' : 'readest-review-target');
htmlElement.classList.toggle('readest-review-active', active);
if (role === 'target' && row.current_html) {
if (!htmlElement.hasAttribute(ORIGINAL_HTML_ATTR)) {
htmlElement.setAttribute(ORIGINAL_HTML_ATTR, htmlElement.innerHTML);
}
htmlElement.innerHTML = sanitizeReviewHtml(row.current_html);
}
};
const applyActiveReviewMark = (doc: Document, selectedRowId: string) => {
doc.querySelectorAll(`[${ROW_ATTR}]`).forEach((element) => {
element.classList.toggle(
'readest-review-active',
element.getAttribute(ROW_ATTR) === selectedRowId,
);
});
};
const applyReviewMarks = (
doc: Document,
sectionHref: string | undefined,
rows: ReviewRow[],
selectedRowId: string,
) => {
clearReviewMarks(doc);
const sectionRows = rows.filter((row) => sameFile(row.file, sectionHref));
if (!sectionRows.length) return;
ensureReviewStyle(doc);
const paragraphs = Array.from(doc.querySelectorAll('p'));
for (const row of sectionRows) {
const active = row.id === selectedRowId;
markParagraph(paragraphs[Number(row.ja_p_index)], row, 'source', active);
markParagraph(paragraphs[Number(row.cn_p_index)], row, 'target', active);
}
};
export const __testing = {
applyReviewMarks,
applyActiveReviewMark,
clearReviewMarks,
sectionHrefAt,
};
const ReviewModeController: React.FC<{ bookKey: string; bookDoc: BookDoc }> = ({
bookKey,
bookDoc,
}) => {
const view = useReaderStore((state) => state.viewStates[bookKey]?.view);
const enabled = useReviewModeStore((state) => !!state.books[bookKey]?.enabled);
const rows = useReviewModeStore((state) => state.books[bookKey]?.rows ?? emptyReviewRows);
const selectedRowId = useReviewModeStore((state) => state.books[bookKey]?.selectedRowId || '');
const selectRow = useReviewModeStore((state) => state.selectRow);
const selectedRowIdRef = useRef(selectedRowId);
useEffect(() => {
selectedRowIdRef.current = selectedRowId;
}, [selectedRowId]);
useEffect(() => {
if (!view) return;
const cleanupLoadedDocs = () => {
for (const { doc } of view.renderer.getContents()) {
clearReviewMarks(doc);
}
};
if (!enabled || !rows.length) {
cleanupLoadedDocs();
return;
}
const applyToLoadedDocs = () => {
for (const { doc, index } of view.renderer.getContents()) {
applyReviewMarks(doc, sectionHrefAt(bookDoc, index), rows, selectedRowIdRef.current);
}
};
const handleClick = (event: Event) => {
const target = closestReviewTarget(event.target);
if (!target) return;
const rowId = target.getAttribute(ROW_ATTR);
if (!rowId) return;
event.preventDefault();
event.stopPropagation();
if ('stopImmediatePropagation' in event) {
event.stopImmediatePropagation();
}
selectRow(bookKey, rowId);
};
const handleSelectionEnd = (event: Event) => {
const doc = event.currentTarget as Document | null;
if (!doc?.getSelection) return;
window.setTimeout(() => {
const target = selectedReviewTarget(doc);
const rowId = target?.getAttribute(ROW_ATTR);
if (rowId) selectRow(bookKey, rowId);
}, 0);
};
const listenedDocs = new Set<Document>();
const addDocListeners = (doc: Document) => {
if (listenedDocs.has(doc)) return;
listenedDocs.add(doc);
doc.addEventListener('click', handleClick, true);
doc.addEventListener('mouseup', handleSelectionEnd, true);
doc.addEventListener('touchend', handleSelectionEnd, true);
doc.addEventListener('keyup', handleSelectionEnd, true);
};
const removeDocListeners = (doc: Document) => {
listenedDocs.delete(doc);
doc.removeEventListener('click', handleClick, true);
doc.removeEventListener('mouseup', handleSelectionEnd, true);
doc.removeEventListener('touchend', handleSelectionEnd, true);
doc.removeEventListener('keyup', handleSelectionEnd, true);
};
const handleLoad = (event: Event) => {
const detail = (event as CustomEvent<{ doc?: Document; index?: number }>).detail;
if (!detail?.doc) return;
applyReviewMarks(
detail.doc,
sectionHrefAt(bookDoc, detail.index),
rows,
selectedRowIdRef.current,
);
addDocListeners(detail.doc);
};
for (const { doc } of view.renderer.getContents()) {
addDocListeners(doc);
}
applyToLoadedDocs();
view.addEventListener('load', handleLoad);
return () => {
view.removeEventListener('load', handleLoad);
for (const doc of listenedDocs) {
removeDocListeners(doc);
clearReviewMarks(doc);
}
};
}, [bookDoc, bookKey, enabled, rows, selectRow, view]);
useEffect(() => {
if (!view || !enabled || !rows.length) return;
for (const { doc } of view.renderer.getContents()) {
applyActiveReviewMark(doc, selectedRowId);
}
}, [bookKey, enabled, rows.length, selectedRowId, view]);
return null;
};
export default ReviewModeController;
@@ -0,0 +1,99 @@
import clsx from 'clsx';
import React from 'react';
import Button from '@/components/Button';
import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { useBookDataStore } from '@/store/bookDataStore';
import { useReaderStore } from '@/store/readerStore';
import { useReviewModeStore } from '@/store/reviewModeStore';
import { eventDispatcher } from '@/utils/event';
import { launchInlineReviewEditor, loadInlineReviewData } from '@/services/reviewEditorService';
interface ReviewModeTogglerProps {
bookKey: string;
}
const ReviewModeToggler: React.FC<ReviewModeTogglerProps> = ({ bookKey }) => {
const _ = useTranslation();
const { appService } = useEnv();
const { setHoveredBookKey } = useReaderStore();
const bookData = useBookDataStore((state) => state.getBookData(bookKey));
const reviewState = useReviewModeStore((state) => state.books[bookKey]);
const {
setActiveBookKey,
setPanelVisible,
setBookLoading,
setBookError,
setBookEnabled,
setBookData,
} = useReviewModeStore();
const enabled = !!reviewState?.enabled;
const loading = !!reviewState?.loading;
if (!appService?.isDesktopApp || bookData?.book?.format !== 'EPUB') return null;
const handleToggleReviewMode = async () => {
if (appService?.isMobile) {
setHoveredBookKey('');
}
if (enabled) {
setBookEnabled(bookKey, false);
return;
}
if (!bookData?.book) {
setBookError(bookKey, _('Unable to open book'));
return;
}
setActiveBookKey(bookKey);
setPanelVisible(true);
setBookLoading(bookKey, true);
try {
const launch = await launchInlineReviewEditor(appService, bookData.book);
const data = await loadInlineReviewData(launch.url, launch.sessionId);
setBookData(bookKey, {
baseUrl: launch.url,
sessionId: launch.sessionId,
reviewRoot: launch.reviewRoot,
version: launch.version,
session: data.session,
rows: data.rows,
gptConfig: data.gptConfig,
selectedRowId: data.rows[0]?.id || '',
});
setBookEnabled(bookKey, true);
eventDispatcher.dispatch('toast', {
type: 'info',
message: data.rows.length
? `审校模式已开启,共 ${data.rows.length}`
: '审校模式已开启,但当前 EPUB 没有识别到双语段落',
timeout: 2500,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setBookError(bookKey, message);
eventDispatcher.dispatch('toast', {
type: 'error',
message,
timeout: 3500,
});
}
};
return (
<Button
icon={<span className='text-sm font-semibold leading-none'></span>}
onClick={handleToggleReviewMode}
disabled={loading}
label={enabled ? _('Disable Review Mode') : _('Review Mode')}
className={clsx(enabled && 'bg-base-300/50', loading && 'animate-pulse')}
/>
);
};
export default ReviewModeToggler;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+22 -2
View File
@@ -9,6 +9,7 @@ import { initReplicaSync } from '@/services/sync/replicaSync';
import { createSettingsCursorStore } from '@/services/sync/replicaCursorStore';
import { startReplicaTransferIntegration } from '@/services/sync/replicaTransferIntegration';
import { enableReplicaAutoPersist } from '@/services/sync/replicaPersist';
import { isViewTransitionTimeoutError } from '@/utils/error';
interface EnvContextType {
envConfig: EnvConfigType;
@@ -40,14 +41,33 @@ export const EnvProvider = ({ children }: { children: ReactNode }) => {
console.warn('replica sync init failed', err);
}
});
window.addEventListener('error', (e) => {
const handleError = (e: ErrorEvent) => {
if (e.message === 'ResizeObserver loop limit exceeded') {
e.stopImmediatePropagation();
e.preventDefault();
return true;
}
if (isViewTransitionTimeoutError(e.error)) {
e.stopImmediatePropagation();
e.preventDefault();
return true;
}
return false;
});
};
const handleUnhandledRejection = (e: PromiseRejectionEvent) => {
if (isViewTransitionTimeoutError(e.reason)) {
e.stopImmediatePropagation();
e.preventDefault();
return true;
}
return false;
};
window.addEventListener('error', handleError);
window.addEventListener('unhandledrejection', handleUnhandledRejection);
return () => {
window.removeEventListener('error', handleError);
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
};
}, [envConfig]);
const value = useMemo(() => ({ envConfig, appService }), [envConfig, appService]);
+8 -1
View File
@@ -2,7 +2,12 @@ import { useEnv } from '@/context/EnvContext';
import { useRouter } from 'next/navigation';
import { useTransitionRouter } from 'next-view-transitions';
export const useAppRouter = () => {
const isWebDevMode =
process.env['NODE_ENV'] === 'development' && process.env['NEXT_PUBLIC_APP_PLATFORM'] === 'web';
const usePlainAppRouter = () => useRouter();
const useTransitionAppRouter = () => {
const { appService } = useEnv();
const transitionRouter = useTransitionRouter();
const plainRouter = useRouter();
@@ -15,3 +20,5 @@ export const useAppRouter = () => {
// seen on unsupported webviews (Sentry READEST-9).
return appService?.supportsViewTransitionsAPI ? transitionRouter : plainRouter;
};
export const useAppRouter = isWebDevMode ? usePlainAppRouter : useTransitionAppRouter;
+2 -2
View File
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
interface UseLongPressOptions {
onTap?: () => void;
onLongPress?: () => void;
onContextMenu?: () => void;
onContextMenu?: (event: React.MouseEvent) => void;
onCancel?: () => void;
threshold?: number;
moveThreshold?: number;
@@ -149,7 +149,7 @@ export const useLongPress = (
e.preventDefault();
e.stopPropagation();
setTimeout(() => {
onContextMenu();
onContextMenu(e);
}, 100);
}
reset();
@@ -0,0 +1,471 @@
import { makeSafeFilename } from '@/utils/misc';
import { getBaseFilename } from '@/utils/path';
export type BilingualFilterLanguage = 'ja' | 'zh';
export type BilingualFilterMode = 'auto' | 'style' | 'script';
export interface BilingualFilterOptions {
removeLanguage: BilingualFilterLanguage;
mode?: BilingualFilterMode;
removeUnknown?: boolean;
}
export interface BilingualFilterFileStats {
path: string;
paragraphs: number;
removed: number;
kept: number;
unknown: number;
anchorsMoved: number;
byLanguage: Record<BilingualFilterLanguage | 'unknown', number>;
}
export interface BilingualFilterStats {
filesSeen: number;
htmlFiles: number;
changedFiles: number;
paragraphs: number;
removed: number;
kept: number;
unknown: number;
anchorsMoved: number;
fileStats: BilingualFilterFileStats[];
}
export interface BilingualFilterResult {
file: File;
filename: string;
title: string;
stats: BilingualFilterStats;
}
const HTML_EXTENSIONS = ['.html', '.htm', '.xhtml'];
const TEXT_BLOCK_RE = /<p\b[^>]*>.*?<\/p>/gis;
const BODY_END_RE = /<\/body\s*>/i;
const ANCHOR_RE =
/<a\b(?=[^>]*(?:\bid\s*=\s*['"][^'"]+['"]|\bname\s*=\s*['"][^'"]+['"]))[^>]*>\s*<\/a\s*>/gis;
const TAG_RE = /<[^>]+>/g;
const OPF_EXT = '.opf';
const makeEmptyFileStats = (path: string): BilingualFilterFileStats => ({
path,
paragraphs: 0,
removed: 0,
kept: 0,
unknown: 0,
anchorsMoved: 0,
byLanguage: { ja: 0, zh: 0, unknown: 0 },
});
const makeEmptyRunStats = (): BilingualFilterStats => ({
filesSeen: 0,
htmlFiles: 0,
changedFiles: 0,
paragraphs: 0,
removed: 0,
kept: 0,
unknown: 0,
anchorsMoved: 0,
fileStats: [],
});
const isHtmlPath = (path: string) => {
const lower = path.toLowerCase();
return HTML_EXTENSIONS.some((ext) => lower.endsWith(ext));
};
const decodeHtmlEntities = (text: string) => {
if (typeof document !== 'undefined') {
const textarea = document.createElement('textarea');
textarea.innerHTML = text;
return textarea.value;
}
return text
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
};
const stripTags = (fragment: string) => decodeHtmlEntities(fragment.replace(TAG_RE, '')).trim();
const getOpeningTag = (fragment: string) => fragment.match(/^<p\b[^>]*>/is)?.[0] ?? '';
const isImageOrEmptyParagraph = (fragment: string) => {
if (stripTags(fragment)) return false;
return /<(img|svg|math)\b/i.test(fragment);
};
const getStyleAttribute = (openingTag: string) =>
openingTag.match(/\bstyle\s*=\s*(["'])(.*?)\1/is)?.[2] ?? '';
const getClassAttribute = (openingTag: string) =>
openingTag.match(/\bclass\s*=\s*(["'])(.*?)\1/is)?.[2] ?? '';
const hasGrayColor = (style: string) => {
const compact = style.replace(/\s+/g, '').toLowerCase();
return (
/(^|;)color:(gray|grey)(;|$)/i.test(style) ||
compact.includes('color:#808080') ||
compact.includes('color:#888') ||
compact.includes('color:rgb(128,128,128)') ||
compact.includes('color:rgba(128,128,128,')
);
};
const isProbablyJapaneseByStyle = (fragment: string) => {
const openingTag = getOpeningTag(fragment);
const style = getStyleAttribute(openingTag);
const className = getClassAttribute(openingTag).toLowerCase();
return (
/\bopacity\s*:\s*0(?:\.40*|\.4|\.5|\.50*)\b/i.test(style) ||
hasGrayColor(style) ||
/\b(japanese|source|original|src|ja|jp)\b/.test(className)
);
};
const isProbablyChineseByStyle = (fragment: string) => {
const openingTag = getOpeningTag(fragment);
const className = getClassAttribute(openingTag).toLowerCase();
return /\b(chinese|translation|translated|target|zh|cn)\b/.test(className);
};
const scriptCounts = (text: string) => {
const counts = { hiragana: 0, katakana: 0, kana: 0, cjk: 0, ascii: 0 };
for (const ch of text) {
const code = ch.codePointAt(0) ?? 0;
if (code >= 0x3040 && code <= 0x309f) {
counts.hiragana += 1;
counts.kana += 1;
} else if (
(code >= 0x30a0 && code <= 0x30ff) ||
(code >= 0x31f0 && code <= 0x31ff) ||
(code >= 0xff66 && code <= 0xff9d)
) {
counts.katakana += 1;
counts.kana += 1;
} else if (
(code >= 0x4e00 && code <= 0x9fff) ||
(code >= 0x3400 && code <= 0x4dbf) ||
(code >= 0xf900 && code <= 0xfaff)
) {
counts.cjk += 1;
} else if (/^[a-z]$/i.test(ch)) {
counts.ascii += 1;
}
}
return counts;
};
const classifyByScript = (fragment: string): BilingualFilterLanguage | 'unknown' => {
const text = stripTags(fragment);
if (!text) return 'unknown';
const counts = scriptCounts(text);
const meaningful = counts.kana + counts.cjk + counts.ascii;
if (meaningful === 0) return 'unknown';
if (counts.kana >= 2) return 'ja';
if (counts.kana === 1 && counts.cjk <= 2) return 'ja';
if (counts.cjk >= 2 && counts.kana === 0) return 'zh';
return 'unknown';
};
const classifyParagraph = (
fragment: string,
mode: BilingualFilterMode,
): BilingualFilterLanguage | 'unknown' => {
if (isImageOrEmptyParagraph(fragment)) return 'unknown';
if (mode === 'auto' || mode === 'style') {
if (isProbablyJapaneseByStyle(fragment)) return 'ja';
if (isProbablyChineseByStyle(fragment)) return 'zh';
}
if (mode === 'style') {
return stripTags(fragment) ? 'zh' : 'unknown';
}
return classifyByScript(fragment);
};
const looksLikeStyleBilingual = (text: string) => {
const paragraphs = text.match(TEXT_BLOCK_RE) ?? [];
if (paragraphs.length < 10) return false;
const textBlocks = paragraphs.filter((paragraph) => stripTags(paragraph)).length;
if (textBlocks === 0) return false;
const styled = paragraphs.filter(isProbablyJapaneseByStyle).length;
const ratio = styled / textBlocks;
return styled >= 5 && ratio >= 0.15 && ratio <= 0.85;
};
const extractEmptyAnchors = (fragment: string) => fragment.match(ANCHOR_RE) ?? [];
const insertAnchorsIntoParagraph = (paragraph: string, anchors: string[]) => {
if (anchors.length === 0) return paragraph;
const insertion = anchors.join('');
const match = paragraph.match(/^<p\b[^>]*>/is);
if (!match) return insertion + paragraph;
const index = match[0].length;
return paragraph.slice(0, index) + insertion + paragraph.slice(index);
};
const insertPendingAnchorsBeforeBodyEnd = (text: string, anchors: string[]) => {
if (anchors.length === 0) return text;
const fallback = `<p style="display:none;">${anchors.join('')}</p>\n`;
const match = text.match(BODY_END_RE);
if (!match || match.index === undefined) return `${text}\n${fallback}`;
return text.slice(0, match.index) + fallback + text.slice(match.index);
};
const shouldRemove = (
language: BilingualFilterLanguage | 'unknown',
removeLanguage: BilingualFilterLanguage,
removeUnknown: boolean,
) => language === removeLanguage || (removeUnknown && language === 'unknown');
const filterHtmlText = (text: string, options: Required<BilingualFilterOptions>, path: string) => {
const stats = makeEmptyFileStats(path);
const output: string[] = [];
let pendingAnchors: string[] = [];
let position = 0;
let changed = false;
const mode = options.mode === 'auto' && looksLikeStyleBilingual(text) ? 'style' : options.mode;
for (const match of text.matchAll(TEXT_BLOCK_RE)) {
if (match.index === undefined) continue;
output.push(text.slice(position, match.index));
let paragraph = match[0];
const language = classifyParagraph(paragraph, mode);
stats.paragraphs += 1;
stats.byLanguage[language] += 1;
if (shouldRemove(language, options.removeLanguage, options.removeUnknown)) {
const anchors = extractEmptyAnchors(paragraph);
pendingAnchors = [...pendingAnchors, ...anchors];
stats.removed += 1;
stats.anchorsMoved += anchors.length;
changed = true;
} else {
if (language === 'unknown') stats.unknown += 1;
stats.kept += 1;
if (pendingAnchors.length > 0) {
paragraph = insertAnchorsIntoParagraph(paragraph, pendingAnchors);
pendingAnchors = [];
changed = true;
}
output.push(paragraph);
}
position = match.index + match[0].length;
}
output.push(text.slice(position));
let newText = output.join('');
if (pendingAnchors.length > 0) {
newText = insertPendingAnchorsBeforeBodyEnd(newText, pendingAnchors);
changed = true;
}
return { text: changed ? newText : text, stats };
};
const escapeXml = (value: string) =>
value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
const decodeText = (data: Uint8Array) => {
const encodings = ['utf-8', 'shift_jis', 'gb18030'];
for (const encoding of encodings) {
try {
const decoder = new TextDecoder(encoding, { fatal: true });
return decoder.decode(data);
} catch {
// Try the next common EPUB encoding.
}
}
return new TextDecoder('utf-8').decode(data);
};
const setDocumentLanguageAndTitle = (text: string, title: string, language: string) => {
let updated = text;
if (/<title\b[^>]*>.*?<\/title>/is.test(updated)) {
updated = updated.replace(/<title\b[^>]*>.*?<\/title>/is, `<title>${escapeXml(title)}</title>`);
}
if (/\bxml:lang\s*=\s*["'][^"']*["']/i.test(updated)) {
updated = updated.replace(/\bxml:lang\s*=\s*(["'])[^"']*\1/i, `xml:lang="${language}"`);
}
if (/\blang\s*=\s*["'][^"']*["']/i.test(updated)) {
updated = updated.replace(/\blang\s*=\s*(["'])[^"']*\1/i, `lang="${language}"`);
} else {
updated = updated.replace(/<html\b/i, `<html lang="${language}"`);
}
return updated;
};
const extractOpfTitle = (text: string) => {
const match = text.match(/<dc:title\b[^>]*>(.*?)<\/dc:title>/is);
return match ? stripTags(match[1] ?? '') : '';
};
const getVariantInfo = (
sourceName: string,
removeLanguage: BilingualFilterLanguage,
metadataTitle?: string,
) => {
const sourceTitle = metadataTitle?.trim() || getBaseFilename(sourceName);
const suffix = removeLanguage === 'ja' ? '简中译本' : '日文原文版';
const language = removeLanguage === 'ja' ? 'zh-CN' : 'ja';
const title = `${sourceTitle}${suffix}`;
const filename = `${makeSafeFilename(`${sourceTitle}_${suffix}`)}.epub`;
const identifierSuffix = removeLanguage === 'ja' ? 'readest-no-ja' : 'readest-no-zh';
return { title, filename, language, identifierSuffix };
};
const patchOpfMetadata = (
text: string,
variant: ReturnType<typeof getVariantInfo>,
removeLanguage: BilingualFilterLanguage,
) => {
let updated = text;
const title = escapeXml(variant.title);
if (/<dc:title\b[^>]*>.*?<\/dc:title>/is.test(updated)) {
updated = updated.replace(
/<dc:title\b([^>]*)>.*?<\/dc:title>/is,
`<dc:title$1>${title}</dc:title>`,
);
}
if (/<dc:language\b[^>]*>.*?<\/dc:language>/is.test(updated)) {
updated = updated.replace(
/<dc:language\b([^>]*)>.*?<\/dc:language>/is,
`<dc:language$1>${variant.language}</dc:language>`,
);
}
if (/<dc:identifier\b[^>]*>.*?<\/dc:identifier>/is.test(updated)) {
updated = updated.replace(
/<dc:identifier\b([^>]*)>(.*?)<\/dc:identifier>/gis,
(_match, attrs: string, value: string) => {
const trimmed = value.trim();
const suffix = `:${variant.identifierSuffix}`;
const nextValue = trimmed.includes(suffix) ? trimmed : `${trimmed}${suffix}`;
return `<dc:identifier${attrs}>${escapeXml(nextValue)}</dc:identifier>`;
},
);
} else {
updated = updated.replace(
/<\/metadata\s*>/i,
`<dc:identifier id="readest-filter-id">readest:${removeLanguage}:${Date.now()}</dc:identifier></metadata>`,
);
}
updated = updated.replace(
/<meta\b([^>]*\bproperty\s*=\s*["']dcterms:modified["'][^>]*)>.*?<\/meta>/is,
`<meta$1>${new Date().toISOString().replace(/\.\d{3}Z$/, 'Z')}</meta>`,
);
return updated;
};
const updateRunStats = (runStats: BilingualFilterStats, fileStats: BilingualFilterFileStats) => {
runStats.fileStats.push(fileStats);
runStats.paragraphs += fileStats.paragraphs;
runStats.removed += fileStats.removed;
runStats.kept += fileStats.kept;
runStats.unknown += fileStats.unknown;
runStats.anchorsMoved += fileStats.anchorsMoved;
};
export async function filterBilingualEpubFile(
sourceFile: File,
filterOptions: BilingualFilterOptions,
): Promise<BilingualFilterResult> {
const options: Required<BilingualFilterOptions> = {
mode: 'auto',
removeUnknown: false,
...filterOptions,
};
const {
BlobReader,
BlobWriter,
TextReader,
Uint8ArrayReader,
Uint8ArrayWriter,
ZipReader,
ZipWriter,
} = await import('@zip.js/zip.js');
const reader = new ZipReader(new BlobReader(sourceFile));
const writer = new ZipWriter(new BlobWriter('application/epub+zip'));
const runStats = makeEmptyRunStats();
try {
const entries = await reader.getEntries();
const readableEntries = entries.filter(isReadableZipEntry);
const opfEntry = readableEntries.find((entry) =>
entry.filename.toLowerCase().endsWith(OPF_EXT),
);
const opfTitle = opfEntry
? extractOpfTitle(decodeText(await opfEntry.getData(new Uint8ArrayWriter())))
: '';
const variant = getVariantInfo(sourceFile.name, options.removeLanguage, opfTitle);
const seen = new Set<string>();
const mimetype = entries.find((entry) => entry.filename.toLowerCase() === 'mimetype');
if (mimetype) {
await writer.add('mimetype', new TextReader('application/epub+zip'), { level: 0 });
seen.add(mimetype.filename);
runStats.filesSeen += 1;
}
for (const entry of readableEntries) {
if (seen.has(entry.filename)) continue;
seen.add(entry.filename);
runStats.filesSeen += 1;
const lowerName = entry.filename.toLowerCase();
const rawData = await entry.getData(new Uint8ArrayWriter());
let outData: Uint8Array | string = rawData;
if (isHtmlPath(lowerName)) {
runStats.htmlFiles += 1;
const originalText = decodeText(rawData);
const filtered = filterHtmlText(originalText, options, entry.filename);
const patchedText = setDocumentLanguageAndTitle(
filtered.text,
variant.title,
variant.language,
);
updateRunStats(runStats, filtered.stats);
if (patchedText !== originalText) runStats.changedFiles += 1;
outData = patchedText;
} else if (lowerName.endsWith(OPF_EXT)) {
const originalText = decodeText(rawData);
const patchedText = patchOpfMetadata(originalText, variant, options.removeLanguage);
if (patchedText !== originalText) runStats.changedFiles += 1;
outData = patchedText;
}
const writerReader =
typeof outData === 'string'
? new TextReader(outData)
: new Uint8ArrayReader(outData as Uint8Array);
await writer.add(entry.filename, writerReader, zipOptionsForEntry(entry));
}
const blob = await writer.close();
const file = new File([blob], variant.filename, { type: 'application/epub+zip' });
return { file, filename: variant.filename, title: variant.title, stats: runStats };
} finally {
await reader.close();
}
}
const isReadableZipEntry = (
entry: import('@zip.js/zip.js').Entry,
): entry is import('@zip.js/zip.js').FileEntry =>
!entry.directory && typeof entry.getData === 'function';
const zipOptionsForEntry = (entry: import('@zip.js/zip.js').Entry) =>
entry.filename.toLowerCase() === 'mimetype' ? { level: 0 } : {};
@@ -0,0 +1,374 @@
import { invoke } from '@tauri-apps/api/core';
import type { Book } from '@/types/book';
import type { AppService } from '@/types/system';
import { isTauriAppPlatform } from '@/services/environment';
export type ReviewEditorLaunchResponse = {
ok: boolean;
url?: string;
reused?: boolean;
reviewRoot?: string;
version?: string;
sessionId?: string | null;
error?: string;
};
export type ReviewSessionSummary = {
id: string;
source_name?: string;
title?: string;
source_epub?: string;
row_count?: number;
touched_count?: number;
marked_count?: number;
feedback_md?: string;
feedback_jsonl?: string;
latest_export?: string;
};
export type ReviewSessionOpenResponse = ReviewSessionSummary & {
has_session?: boolean;
};
export type ReviewSessionPayload = {
has_session: boolean;
id?: string;
title?: string;
source_name?: string;
source_epub?: string;
row_count?: number;
touched_count?: number;
marked_count?: number;
feedback_md?: string;
feedback_jsonl?: string;
review_root?: string;
error?: string;
};
export type ReviewRow = {
id: string;
file: string;
file_label?: string;
document_title?: string;
file_order?: number;
file_row_index?: number;
ja_p_index?: number;
cn_p_index?: number;
jp_html: string;
jp_text: string;
cn_html: string;
cn_text?: string;
current_html: string;
marked?: boolean;
issue_type?: string;
severity?: string;
comment?: string;
learn_note?: string;
tags?: string;
edited?: boolean;
updated_at?: string;
};
export type ReviewRowsPayload = {
rows: ReviewRow[];
};
export type ReviewGptConfig = {
configured?: boolean;
base_url?: string;
model?: string;
key_source?: string;
updated_at?: string;
translation_prompt?: string;
format_prompt?: string;
character_prompt?: string;
glossary_path?: string;
prompt_defaults?: {
translation_prompt?: string;
format_prompt?: string;
character_prompt?: string;
};
};
export type ReviewEditPayload = {
current_html: string;
marked: boolean;
issue_type: string;
severity: string;
tags: string;
comment: string;
learn_note: string;
};
export type ReviewSaveRowPayload = {
status?: string;
row_id?: string;
updated_at?: string;
current_html?: string;
};
export type ReviewGlossaryEntry = {
source: string;
target: string;
clean_target?: string;
};
export type ReviewGlossaryPayload = {
path?: string;
exists?: boolean;
count?: number;
entries?: ReviewGlossaryEntry[];
updated_at?: string;
};
export type ReviewBootstrapPayload = {
session: ReviewSessionPayload;
rows: ReviewRow[];
gptConfig: ReviewGptConfig;
};
export type ReviewSessionFromPathOptions = {
activate?: boolean;
reset?: boolean;
};
const ensureBaseUrl = (baseUrl: string) => {
if (!baseUrl) throw new Error('审校器后端尚未启动');
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
};
export async function sidecarApi<T>(
baseUrl: string,
path: string,
options: RequestInit = {},
sessionId?: string | null,
): Promise<T> {
const url = new URL(path, ensureBaseUrl(baseUrl));
if (sessionId) {
url.searchParams.set('session_id', sessionId);
}
const headers =
options.body === undefined
? options.headers
: {
'Content-Type': 'application/json',
...(options.headers || {}),
};
const response = await fetch(url.toString(), {
...options,
headers,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(String(data.error || `HTTP ${response.status}`));
}
return data as T;
}
export async function createReviewSessionFromPath(
baseUrl: string,
epubPath: string,
options: ReviewSessionFromPathOptions = {},
): 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,
}),
});
}
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,
): Promise<ReviewEditorLaunchResponse & { url: string; sessionId: string | null }> {
if (!appService) throw new Error('Readest 服务尚未初始化');
if (book.format !== 'EPUB') throw new Error('审校模式目前只支持 EPUB 书籍');
const epubPath = await appService.resolveNativeBookFilePath(book);
if (!epubPath) throw new Error('无法定位当前 EPUB 的本机文件路径');
const launch = isTauriAppPlatform()
? await invoke<ReviewEditorLaunchResponse>('launch_epub_review_editor', {
epubPath,
})
: await fetch('/api/review-editor/launch', {
method: 'POST',
headers: {
'x-readest-review-editor-launch': '1',
},
}).then((response) => response.json() as Promise<ReviewEditorLaunchResponse>);
if (!launch.ok || !launch.url) {
throw new Error(launch.error || '审校器启动失败');
}
let sessionId = launch.sessionId || null;
if (!sessionId) {
const createdSession = await createReviewSessionFromPath(launch.url, epubPath);
sessionId = createdSession.id || null;
}
return { ...launch, url: launch.url, sessionId };
}
export async function loadInlineReviewData(
baseUrl: string,
sessionId: string | null | undefined,
): Promise<ReviewBootstrapPayload> {
if (!sessionId) throw new Error('审校器没有返回当前书籍会话');
const [session, rowsPayload, gptConfig] = await Promise.all([
sidecarApi<ReviewSessionPayload>(baseUrl, '/api/session', {}, sessionId),
sidecarApi<ReviewRowsPayload>(baseUrl, '/api/rows', {}, sessionId),
sidecarApi<ReviewGptConfig>(baseUrl, '/api/gpt/config', {}, sessionId),
]);
return {
session,
rows: rowsPayload.rows || [],
gptConfig,
};
}
export async function saveReviewRow(
baseUrl: string,
sessionId: string | null | undefined,
rowId: string,
edit: ReviewEditPayload,
): Promise<ReviewSaveRowPayload> {
return sidecarApi(
baseUrl,
`/api/row/${encodeURIComponent(rowId)}`,
{
method: 'POST',
body: JSON.stringify(edit),
},
sessionId,
);
}
export async function retranslateReviewRow(
baseUrl: string,
sessionId: string | null | undefined,
rowId: string,
instruction: string,
): Promise<{
candidate_html?: string;
candidate_text?: string;
glossary_matches?: string[];
}> {
return sidecarApi(
baseUrl,
`/api/row/${encodeURIComponent(rowId)}/retranslate`,
{
method: 'POST',
body: JSON.stringify({ instruction }),
},
sessionId,
);
}
export async function saveReviewGptConfig(
baseUrl: string,
sessionId: string | null | undefined,
config: {
base_url?: string;
model?: string;
api_key?: string;
glossary_path?: string;
translation_prompt?: string;
format_prompt?: string;
character_prompt?: string;
},
): Promise<ReviewGptConfig> {
return sidecarApi(
baseUrl,
'/api/gpt/config',
{
method: 'POST',
body: JSON.stringify({
...config,
keep_existing: true,
}),
},
sessionId,
);
}
export async function loadReviewGlossary(
baseUrl: string,
sessionId?: string | null,
): Promise<ReviewGlossaryPayload> {
return sidecarApi(baseUrl, '/api/glossary', {}, sessionId);
}
export async function switchReviewGlossaryPath(
baseUrl: string,
sessionId: string | null | undefined,
path: string,
): Promise<ReviewGlossaryPayload> {
return sidecarApi(
baseUrl,
'/api/glossary',
{
method: 'POST',
body: JSON.stringify({ path }),
},
sessionId,
);
}
export async function saveReviewGlossary(
baseUrl: string,
sessionId: string | null | undefined,
path: string,
entries: ReviewGlossaryEntry[],
): Promise<ReviewGlossaryPayload> {
return sidecarApi(
baseUrl,
'/api/glossary',
{
method: 'POST',
body: JSON.stringify({ path, entries }),
},
sessionId,
);
}
export async function generateReviewFeedback(
baseUrl: string,
sessionId: string | null | undefined,
): Promise<{ feedback_md?: string; feedback_jsonl?: string }> {
return sidecarApi(baseUrl, '/api/feedback', {}, sessionId);
}
export async function exportReviewedEpub(
baseUrl: string,
sessionId: string | null | undefined,
): Promise<{ output?: string; download_url?: string }> {
return sidecarApi(
baseUrl,
'/api/export',
{
method: 'POST',
body: '{}',
},
sessionId,
);
}
@@ -0,0 +1,142 @@
import { create } from 'zustand';
import type {
ReviewGptConfig,
ReviewRow,
ReviewSessionPayload,
} from '@/services/reviewEditorService';
export type ReviewBookState = {
enabled: boolean;
loading: boolean;
error: string;
baseUrl: string;
sessionId: string | null;
reviewRoot?: string;
version?: string;
rows: ReviewRow[];
session: ReviewSessionPayload | null;
gptConfig: ReviewGptConfig | null;
selectedRowId: string;
};
type ReviewModeStore = {
activeBookKey: string | null;
isPanelVisible: boolean;
isPanelPinned: boolean;
panelWidth: string;
panelHeight: string;
books: Record<string, ReviewBookState>;
getBookState: (bookKey: string | null) => ReviewBookState | null;
setActiveBookKey: (bookKey: string | null) => void;
setPanelVisible: (visible: boolean) => void;
togglePanelPin: () => void;
setPanelWidth: (width: string) => void;
setPanelHeight: (height: string) => void;
setBookLoading: (bookKey: string, loading: boolean) => void;
setBookError: (bookKey: string, error: string) => void;
setBookEnabled: (bookKey: string, enabled: boolean) => void;
setBookData: (
bookKey: string,
data: Partial<Omit<ReviewBookState, 'enabled' | 'loading' | 'error'>>,
) => void;
selectRow: (bookKey: string, rowId: string) => void;
updateRow: (bookKey: string, row: ReviewRow) => void;
clearBook: (bookKey: string) => void;
};
const emptyBookState = (): ReviewBookState => ({
enabled: false,
loading: false,
error: '',
baseUrl: '',
sessionId: null,
rows: [],
session: null,
gptConfig: null,
selectedRowId: '',
});
const withBookState = (
books: Record<string, ReviewBookState>,
bookKey: string,
patch: Partial<ReviewBookState>,
) => ({
...books,
[bookKey]: {
...(books[bookKey] || emptyBookState()),
...patch,
},
});
export const useReviewModeStore = create<ReviewModeStore>((set, get) => ({
activeBookKey: null,
isPanelVisible: false,
isPanelPinned: false,
panelWidth: '32%',
panelHeight: '68vh',
books: {},
getBookState: (bookKey) => (bookKey ? get().books[bookKey] || null : null),
setActiveBookKey: (bookKey) => set({ activeBookKey: bookKey }),
setPanelVisible: (visible) => set({ isPanelVisible: visible }),
togglePanelPin: () => set((state) => ({ isPanelPinned: !state.isPanelPinned })),
setPanelWidth: (width) => set({ panelWidth: width }),
setPanelHeight: (height) => set({ panelHeight: height }),
setBookLoading: (bookKey, loading) =>
set((state) => ({
books: withBookState(state.books, bookKey, loading ? { loading, error: '' } : { loading }),
})),
setBookError: (bookKey, error) =>
set((state) => ({
books: withBookState(state.books, bookKey, { error, loading: false }),
})),
setBookEnabled: (bookKey, enabled) =>
set((state) => ({
activeBookKey: enabled
? bookKey
: state.activeBookKey === bookKey
? null
: state.activeBookKey,
isPanelVisible: enabled
? true
: state.activeBookKey === bookKey
? false
: state.isPanelVisible,
books: withBookState(state.books, bookKey, { enabled }),
})),
setBookData: (bookKey, data) =>
set((state) => ({
books: withBookState(state.books, bookKey, {
...data,
loading: false,
error: '',
}),
})),
selectRow: (bookKey, rowId) =>
set((state) => ({
activeBookKey: bookKey,
isPanelVisible: true,
books: withBookState(state.books, bookKey, {
selectedRowId: rowId,
}),
})),
updateRow: (bookKey, row) =>
set((state) => {
const current = state.books[bookKey] || emptyBookState();
return {
books: withBookState(state.books, bookKey, {
rows: current.rows.map((item) => (item.id === row.id ? row : item)),
}),
};
}),
clearBook: (bookKey) =>
set((state) => {
const books = { ...state.books };
delete books[bookKey];
return {
books,
activeBookKey: state.activeBookKey === bookKey ? null : state.activeBookKey,
isPanelVisible: state.activeBookKey === bookKey ? false : state.isPanelVisible,
};
}),
}));
+13
View File
@@ -1,4 +1,17 @@
const VIEW_TRANSITION_TIMEOUT = 'Transition was aborted because of timeout in DOM update';
export const isViewTransitionTimeoutError = (error: unknown) => {
if (process.env['NODE_ENV'] !== 'development') return false;
return (
error instanceof Error &&
error.name === 'TimeoutError' &&
error.message === VIEW_TRANSITION_TIMEOUT
);
};
export const handleGlobalError = (e: Error) => {
if (isViewTransitionTimeoutError(e)) return;
const isChunkError = e?.message?.includes('Loading chunk');
if (!isChunkError) {
+6 -2
View File
@@ -132,8 +132,12 @@ export const navigateToLibrary = (
router.replace(`/library${queryParams ? `?${queryParams}` : ''}`, navOptions);
};
export const navigateToReviewEditor = (router: ReturnType<typeof useRouter>) => {
router.push('/review-editor');
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
@@ -49,7 +49,9 @@ epub_review_sessions/
- `series_configs/<series-id>.json` 保存同系列共享的 `glossary_path``translation_prompt``format_prompt``character_prompt`,不得保存 API Key、Base URL 或模型密钥。
- `translation_jobs/<job-id>/job.json` 保存整书 AI 翻译任务状态、进度、输出路径和日志;不得保存 API Key。
- `translated_outputs/` 保存 AI 翻译生成的 EPUB;生成后应创建独立 session,使 `/api/sessions` 和书架自然可见。
- Readest 迁移阶段的本地入口委托 `tools/epub-review-editor/open_editor.ps1` `/api/review-editor/launch` 创建 `.venv`、安静检测 Flask 依赖、缺失时自动安装依赖、复用同版本已运行服务或启动 `server.py --daemon`;该入口不得硬编码用户私有路径。
- 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 配置与术语表应放在面板底部,避免挤占逐段编辑区。桌面端右侧面板必须保留固定按钮:固定时作为阅读布局的一列停靠并压缩正文,不固定时作为浮动面板覆盖在正文上方,可横向/纵向拖动位置,并可调整宽度和高度;固定/取消固定或调整固定面板宽度后,应尽量恢复用户原先正在阅读的位置。面板内容在窄宽度下应换行和纵向堆叠,避免文字被截断。移动窄屏仍可用抽屉/底部面板。不得迁入整书翻译、书架管理等无关功能。
## 目录与插图
@@ -93,6 +95,7 @@ epub_review_sessions/
- 鼠标停留在顶栏或阅读标题栏时,沉浸顶栏不得因自动计时过早收起;前端脚本和样式发布时应带版本参数或等效缓存失效机制,避免浏览器继续执行旧入口代码。
- 阅读区“上一节 / 下一节”必须按 spine/目录内的小章节顺序移动,覆盖文字 part 与插图条目,不得只跳顶层大章节。
- 点击正文段落打开右侧精修与重翻面板。
- 在 Readest 原生阅读页启用“校”审校模式后,点击或选中 Foliate 正文里的日文源段或中文译文段必须打开同一条审校记录;未开启审校模式时不得改变普通阅读、翻页、标注或笔记行为。
- 右侧面板打开或关闭导致正文宽度变化时,应恢复用户原来正在看的段落位置。
- 刷新页面、重启服务或从已有会话重新打开 EPUB 时,应恢复到上次阅读的章节/part/插图、段落和滚动位置。
- 移动窄屏下,侧栏和右侧面板应以抽屉/底部面板方式显示,不遮死主要操作。
@@ -104,6 +107,8 @@ epub_review_sessions/
- 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` 页面切换会话时串写。
生成反馈时必须:
@@ -239,7 +244,14 @@ Prompt 硬规则:
- `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 dev-web 中 `POST /api/review-editor/launch` 必须带 `x-readest-review-editor-launch: 1`,只能从本机入口调用;成功时返回 sidecar URL、版本和 review root
- 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`
@@ -2,18 +2,20 @@
## 当前定位
本目录是从长期翻译项目的 EPUB 审校器迁移进 Readest 的第一阶段基座。当前保留原 Python/Flask 后端与静态审校 UI,Readest 负责提供入口、启动本地 sidecar,并在 `/review-editor` 页面中承载审校器
本目录是从长期翻译项目的 EPUB 审校器迁移进 Readest 的桌面端基座。当前保留原 Python/Flask 后端作为本地 sidecar,Readest 负责提供入口、启动本地 sidecar,并在 `/review-editor` 页面中用原生 React 功能块承载“校对”和“翻译”核心流程
## 入口
- Readest 书架页菜单:`Settings Menu -> Advanced Settings -> EPUB 审校器`
- Next 路由:`/review-editor`
- 本地启动 API`POST /api/review-editor/launch`
- 桌面启动命令:`launch_epub_review_editor`
- dev-web 启动 API`POST /api/review-editor/launch`
- 开发脚本:`pnpm epub-reviewer:dev`
当前 Readest 页面内自动启动仅限本地 `dev-web` 开发环境。生产 Tauri 包会静态导出 Next 页面,不能依赖 Next API route 启动本地进程;正式迁移到桌面包前,需要改为 Tauri desktop command 或打包 sidecar
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`否则 Chromium 会把 iframe 请求拦截为 `ERR_BLOCKED_BY_RESPONSE`
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
## 运行数据
@@ -35,17 +37,22 @@ READEST_REVIEW_ROOT=<absolute path>
当前阶段不重写审校器业务逻辑,不把 Flask API 立即拆成 Next API 或 Tauri command。这样可以先保留已验证的功能闭环:书架、上传、双语审校、术语表、GPT 重翻、整书 AI 翻译和导出。
`static/index.html` + `static/app.js` 继续保留,用于独立启动、调试和应急 fallback;Readest 桌面迁移验收以 React 功能块为准,不再以 iframe 中显示旧 UI 为完成标准。
后续原生化顺序建议:
1. 把启动 sidecar 从 Next dev API 迁到 Tauri desktop command,生产桌面包不依赖 Next API route
2. 复用 Readest 的书库与文件选择能力,把单书“发送到审校器”入口接到 EPUB 文件路径
1. 继续收敛校对/翻译功能块,把术语表编辑、书架分类和阅读位置恢复等长尾操作逐步改成 Readest 原生 React 控件
2. 把 Python/Flask sidecar 打包成独立可执行 sidecar,减少对用户本机 Python 的依赖
3. 逐步把书架/审校/翻译 UI 拆成 React 组件,最后再替换 Flask API。
## 验收标准
- 本地 `dev-web` 环境下,`/review-editor` 能从 Readest 书架菜单进入。
- 桌面端与本地 `dev-web` 环境下,`/review-editor` 能从 Readest 书架菜单进入。
- `/review-editor` 提供“校对”“翻译”两个原生 React 功能块,主工作区不渲染旧静态审校器 iframe。
- Readest EPUB 书籍右键菜单能直接进入审校器校对或翻译。
- 首次进入会创建 `.venv`、安装 Flask 依赖并启动 `server.py --daemon --no-browser`
- 已运行同版本审校器时优先复用 `localhost:5177+` 端口,不重复启动。
- iframe 中能显示审校器书架/上传页
- 校对功能块能加载行、保存译文/标记、单段重翻、生成反馈并导出 EPUB
- 翻译功能块能读取可翻译正文块、保存 API/提示词配置、启动任务、轮询进度,并打开输出 session。
- 审校数据目录不会进入 git。
- 原审校器 `server.py` 能通过 Python 编译检查,迁移入口 TypeScript 能通过类型检查。
@@ -1,6 +1,6 @@
# 双语 EPUB 审校编辑器
当前版本:`0.13.2`
当前版本:`0.16.3`
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
@@ -52,6 +52,19 @@
`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 原生审校面板浮动模式交互:取消固定后面板不再占满整页高度,可在正文上方横向和纵向拖动,并支持分别调整宽度与高度。
## 独立安装
这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub-review-editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖:
@@ -94,7 +107,9 @@ Advanced Settings -> EPUB 审校器
apps/readest-app\epub_review_sessions\
```
这一阶段仍是 dev-web 基座:正式 Tauri 桌面包需要后续把启动逻辑迁到 Rust command 或打包 sidecar
桌面端会通过 Tauri command 启动本地 sidecardev-web 仍保留 `/api/review-editor/launch` 作为开发入口。Readest `/review-editor` 页面使用 React 原生“校对”“翻译”功能块直接调用 sidecar API;旧静态网页界面仅作为独立运行和排查问题时的 fallback
在 Readest 桌面端原生阅读界面中,打开 EPUB 后也可以点击顶栏“校”按钮进入内嵌审校模式。该模式不离开阅读页,只给当前书的双语段落加审校标记,并把旧审校器右侧栏能力收敛到阅读页右侧面板。
## 独立启动
@@ -156,6 +171,7 @@ python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessio
- 插图页会作为所属章节的次级条目出现在目录里,点击后可在阅读模式中直接查看原 EPUB 图片;点击目录项不会自动隐藏左侧侧栏。
- 检索支持“当前范围”和“全书全局”两种范围,适合在单章精查和整本书排查术语时切换。
- 在阅读模式中点击段落即可打开右侧精修与重翻面板,直接编辑中文译文、标记问题或调用 GPT 重翻;打开/关闭面板时会尽量保持原来的阅读位置。
- 在 Readest 原生阅读界面中可点击顶栏“校”启用内嵌审校模式;中文和日文段落会在正文中高亮可选,点击或选中任一侧都会打开同一段的审校面板。
- 精修模式保留原有逐段编辑界面,用于较细的逐段复审。
- 可从“审校工具”统一配置 OpenAI 兼容的 `base_url``model`、API Key、重翻提示词与术语表,并对当前段落生成重翻候选;候选不会自动覆盖正文,需要点击“应用重翻”并保存。
- 可在“审校工具”的“AI 设置与术语表”里配置并读取正式术语表,实时搜索、增删改术语并保存;单段重翻会按当前段落命中术语表条目,而不是只在提示词里泛泛要求遵守术语。
@@ -36,7 +36,7 @@ except ImportError:
"MINOR": "新增向后兼容能力、API 字段或可选配置",
"MAJOR": "破坏兼容的 API、配置或行为变更",
}
version = "0.13.2"
version = "0.15.0"
P_RE = re.compile(r"(<p\b[^>]*>)(.*?)(</p>)", re.S | re.I)
@@ -105,7 +105,7 @@ DEFAULT_TRANSLATION_LIMIT = 20
TRANSLATION_RANGE_LIMIT_MAX = 5000
TRANSLATION_CONTEXT_RADIUS = 1
LIBRARY_DB_NAME = "library.json"
ROWS_PARSER_VERSION = "0.13.1-translation-pair-cleanup"
ROWS_PARSER_VERSION = "0.16.0-inline-review-source-opacity"
def now_iso() -> str:
@@ -239,6 +239,14 @@ def is_gray_source(attrs: str, inner: str) -> bool:
return False
def is_inline_review_source_paragraph(open_tag: str, inner: str) -> bool:
attrs = open_tag[2:-1]
return is_gray_source(attrs, inner) or (
bool(re.search(r"\bopacity\s*:\s*0?\.[0-9]+", attrs, flags=re.I))
and is_japaneseish(inner)
)
def paragraph_visible_text(inner: str) -> str:
return compact_text(strip_tags(inner or ""))
@@ -541,6 +549,55 @@ def public_gpt_config(review_root: Path) -> dict[str, Any]:
}
def normalize_gpt_config(data: dict[str, Any]) -> dict[str, Any]:
api_key = str(data.get("api_key") or "").strip()
prompts = {
name: normalize_gpt_prompt(name, data.get(name, default))
for name, default in GPT_PROMPT_DEFAULTS.items()
}
return {
"base_url": normalize_gpt_base_url(str(data.get("base_url") or DEFAULT_GPT_BASE_URL)),
"model": str(data.get("model") or DEFAULT_GPT_MODEL).strip() or DEFAULT_GPT_MODEL,
"api_key": api_key,
"configured": bool(api_key),
"key_source": str(data.get("key_source") or ""),
"updated_at": data.get("updated_at", ""),
"glossary_path": normalize_glossary_path_value(str(data.get("glossary_path") or "")),
**prompts,
}
def session_gpt_config_path(session_root: Path) -> Path:
return session_root / "review_state" / "gpt_config.json"
def read_scoped_gpt_config(review_root: Path, session_root: Path | None = None) -> dict[str, Any]:
config = read_gpt_config(review_root)
if session_root is not None:
scoped = read_json(session_gpt_config_path(session_root), {})
if isinstance(scoped, dict):
config.update({key: value for key, value in scoped.items() if key != "api_key"})
return normalize_gpt_config(config)
def public_scoped_gpt_config(review_root: Path, session_root: Path | None = None) -> dict[str, Any]:
if session_root is None:
return public_gpt_config(review_root)
config = read_scoped_gpt_config(review_root, session_root)
return {
"configured": config["configured"],
"base_url": config["base_url"],
"model": config["model"],
"key_source": config["key_source"],
"updated_at": config["updated_at"],
"translation_prompt": config["translation_prompt"],
"format_prompt": config["format_prompt"],
"character_prompt": config["character_prompt"],
"glossary_path": config["glossary_path"],
"prompt_defaults": gpt_prompt_defaults(),
}
def save_gpt_config(review_root: Path, data: dict[str, Any]) -> dict[str, Any]:
base_url = normalize_gpt_base_url(str(data.get("base_url") or DEFAULT_GPT_BASE_URL))
model = str(data.get("model") or DEFAULT_GPT_MODEL).strip() or DEFAULT_GPT_MODEL
@@ -568,14 +625,47 @@ def save_gpt_config(review_root: Path, data: dict[str, Any]) -> dict[str, Any]:
return public_gpt_config(review_root)
def glossary_path(review_root: Path) -> Path:
def save_scoped_gpt_config(
review_root: Path, data: dict[str, Any], session_root: Path | None = None
) -> dict[str, Any]:
if session_root is None:
return save_gpt_config(review_root, data)
current = read_scoped_gpt_config(review_root, session_root)
base_url = normalize_gpt_base_url(str(data.get("base_url") or current["base_url"]))
model = str(data.get("model") or current["model"]).strip() or DEFAULT_GPT_MODEL
api_key = str(data.get("api_key") or "").strip()
if api_key:
update_gpt_config_fields(review_root, {"api_key": api_key})
prompts = {}
for key in ("translation_prompt", "format_prompt", "character_prompt"):
value = data.get(key) if key in data else current.get(key)
prompts[key] = normalize_gpt_prompt(key, value)
record = {
"base_url": base_url,
"model": model,
"glossary_path": normalize_glossary_path_value(
str(data.get("glossary_path") or current["glossary_path"] or "")
),
**prompts,
"updated_at": now_iso(),
}
write_json(session_gpt_config_path(session_root), record)
return public_scoped_gpt_config(review_root, session_root)
def glossary_path(review_root: Path, session_root: Path | None = None) -> Path:
if session_root is not None:
scoped = read_json(session_gpt_config_path(session_root), {})
configured = str(scoped.get("glossary_path") or "").strip()
if configured:
return Path(normalize_glossary_path_value(configured))
data = read_json(gpt_config_path(review_root), {})
configured = str(data.get("glossary_path") or "").strip()
return Path(normalize_glossary_path_value(configured))
def read_glossary(review_root: Path) -> dict[str, str]:
path = glossary_path(review_root)
def read_glossary(review_root: Path, session_root: Path | None = None) -> dict[str, str]:
path = glossary_path(review_root, session_root)
if not path.exists():
return {}
try:
@@ -591,9 +681,9 @@ def read_glossary(review_root: Path) -> dict[str, str]:
return glossary
def public_glossary(review_root: Path) -> dict[str, Any]:
path = glossary_path(review_root)
glossary = read_glossary(review_root)
def public_glossary(review_root: Path, session_root: Path | None = None) -> dict[str, Any]:
path = glossary_path(review_root, session_root)
glossary = read_glossary(review_root, session_root)
entries = [
{
"source": key,
@@ -611,8 +701,10 @@ def public_glossary(review_root: Path) -> dict[str, Any]:
}
def save_glossary(review_root: Path, entries: list[dict[str, Any]]) -> dict[str, Any]:
path = glossary_path(review_root)
def save_glossary(
review_root: Path, entries: list[dict[str, Any]], session_root: Path | None = None
) -> dict[str, Any]:
path = glossary_path(review_root, session_root)
if path.exists() and not path.is_file():
raise ValueError("术语表路径不是文件")
glossary: dict[str, str] = {}
@@ -634,7 +726,7 @@ def save_glossary(review_root: Path, entries: list[dict[str, Any]]) -> dict[str,
glossary[source] = target
path.parent.mkdir(parents=True, exist_ok=True)
write_json(path, glossary)
return public_glossary(review_root)
return public_glossary(review_root, session_root)
def search_glossary_entries(glossary: dict[str, str], term: str, limit: int = GLOSSARY_ENTRY_LIMIT) -> list[str]:
@@ -688,9 +780,14 @@ def glossary_prompt_line(key: str, value: str, source: str) -> str:
return f"{key} => {translation}"
def glossary_matches_for_prompt(review_root: Path, row: dict[str, Any], limit: int = GLOSSARY_PROMPT_LIMIT) -> list[str]:
def glossary_matches_for_prompt(
review_root: Path,
row: dict[str, Any],
limit: int = GLOSSARY_PROMPT_LIMIT,
session_root: Path | None = None,
) -> list[str]:
try:
glossary = read_glossary(review_root)
glossary = read_glossary(review_root, session_root)
except ValueError:
raise
except Exception as exc:
@@ -2401,7 +2498,9 @@ def build_rows(session_root: Path) -> list[dict[str, Any]]:
cn_inner = matches[i + 1].group(2)
next_open = matches[i + 1].group(1)
next_attrs = next_open[2:-1]
if is_gray_source(attrs, jp_inner) and not is_gray_source(next_attrs, cn_inner):
if is_inline_review_source_paragraph(open_tag, jp_inner) and not is_inline_review_source_paragraph(
next_open, cn_inner
):
serial += 1
file_serial += 1
rows.append(
@@ -3171,6 +3270,18 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
return app.config["SESSION_ROOT"], app.config["EPUB_PATH"]
return None, None
def session_from_request() -> tuple[Path | None, Path | None]:
session_id = str(request.args.get("session_id") or "").strip()
if not session_id:
return active_session()
try:
session_root = session_root_from_id(review_root, session_id)
except ValueError:
return None, None
if not (session_root / "review_state" / "state.json").exists() or session_is_hidden(session_root):
return None, None
return session_root, session_epub_path(session_root)
def no_active_response():
return jsonify(
{
@@ -3190,13 +3301,36 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
@app.after_request
def add_local_embedding_headers(response):
# Readest dev-web is cross-origin isolated; the local sidecar must opt in
# before Chromium will allow it inside the /review-editor iframe.
# 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 origin in allowed_origins:
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Credentials"] = "false"
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"
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:
@@ -3211,7 +3345,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
@app.route("/api/session")
def api_session():
session_root, epub_path = active_session()
session_root, epub_path = session_from_request()
if session_root is None or epub_path is None:
return jsonify(
{
@@ -3245,7 +3379,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
@app.route("/api/version")
def api_version():
return jsonify({"version": version, "version_rules": VERSION_RULES})
return jsonify({"version": version, "version_rules": VERSION_RULES, "review_root": str(review_root)})
@app.route("/api/sessions")
def api_sessions():
@@ -3312,6 +3446,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
def api_open_session():
data = request.get_json(silent=True) or {}
session_id = str(data.get("session_id", ""))
activate = bool(data.get("activate", True))
if not session_id:
return jsonify({"error": "缺少 session_id"}), 400
try:
@@ -3322,8 +3457,33 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
return jsonify({"error": "会话不存在或不完整"}), 404
if session_is_hidden(session_root):
return jsonify({"error": "会话已从书架移除"}), 404
if activate:
with app.config["LOCK"]:
set_active_session(session_root)
summary = summarize_session(session_root)
summary["has_session"] = True
return jsonify(summary)
@app.route("/api/session/from-path", methods=["POST"])
def api_session_from_path():
data = request.get_json(silent=True) or {}
raw_path = str(data.get("epub_path") or "").strip()
if not raw_path:
return jsonify({"error": "缺少 epub_path"}), 400
epub_path = Path(raw_path).expanduser().resolve()
if not epub_path.exists():
return jsonify({"error": f"EPUB not found: {epub_path}"}), 404
try:
validate_epub_file(epub_path)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
reset_session = bool(data.get("reset", False))
activate = bool(data.get("activate", True))
with app.config["LOCK"]:
set_active_session(session_root)
session_root = session_root_for(review_root, epub_path)
ensure_state(session_root, epub_path, reset=reset_session)
if activate:
set_active_session(session_root, epub_path)
summary = summarize_session(session_root)
summary["has_session"] = True
return jsonify(summary)
@@ -3349,21 +3509,21 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
@app.route("/api/rows")
def api_rows():
session_root, _epub_path = active_session()
session_root, _epub_path = session_from_request()
if session_root is None:
return no_active_response()
return jsonify({"rows": merge_rows(session_root)})
@app.route("/api/structure")
def api_structure():
session_root, _epub_path = active_session()
session_root, _epub_path = session_from_request()
if session_root is None:
return no_active_response()
return jsonify(build_structure(session_root))
@app.route("/api/reading-position", methods=["GET", "POST"])
def api_reading_position():
session_root, _epub_path = active_session()
session_root, _epub_path = session_from_request()
if session_root is None:
return no_active_response()
if request.method == "GET":
@@ -3385,7 +3545,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
@app.route("/api/asset/<path:entry_name>")
def api_asset(entry_name: str):
session_root, _epub_path = active_session()
session_root, _epub_path = session_from_request()
if session_root is None:
return no_active_response()
entry_name = urllib.parse.unquote(entry_name)
@@ -3434,12 +3594,13 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
@app.route("/api/gpt/config", methods=["GET", "POST"])
def api_gpt_config():
session_root, _epub_path = session_from_request()
if request.method == "GET":
return jsonify(public_gpt_config(review_root))
return jsonify(public_scoped_gpt_config(review_root, session_root))
data = request.get_json(silent=True) or {}
try:
if data.get("reset_prompts"):
current = read_gpt_config(review_root)
current = read_scoped_gpt_config(review_root, session_root)
data = {
**data,
"base_url": data.get("base_url") or current["base_url"],
@@ -3447,39 +3608,48 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
"keep_existing": data.get("keep_existing", True),
**gpt_prompt_defaults(),
}
return jsonify(save_gpt_config(review_root, data))
return jsonify(save_scoped_gpt_config(review_root, data, session_root))
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
@app.route("/api/glossary", methods=["GET", "POST"])
def api_glossary():
session_root, _epub_path = session_from_request()
if request.method == "GET":
try:
return jsonify(public_glossary(review_root))
return jsonify(public_glossary(review_root, session_root))
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
data = request.get_json(silent=True) or {}
try:
requested_path = str(data.get("path") or "").strip()
if requested_path:
update_gpt_config_fields(review_root, {"glossary_path": normalize_glossary_path_value(requested_path)})
path_value = normalize_glossary_path_value(requested_path)
if session_root is None:
update_gpt_config_fields(review_root, {"glossary_path": path_value})
else:
current = read_json(session_gpt_config_path(session_root), {})
current["glossary_path"] = path_value
current["updated_at"] = now_iso()
write_json(session_gpt_config_path(session_root), current)
if "entries" not in data:
return jsonify(public_glossary(review_root))
return jsonify(public_glossary(review_root, session_root))
entries = data.get("entries", [])
if not isinstance(entries, list):
return jsonify({"error": "entries 必须是数组"}), 400
return jsonify(save_glossary(review_root, entries))
return jsonify(save_glossary(review_root, entries, session_root))
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
@app.route("/api/glossary/entry", methods=["POST", "DELETE"])
def api_glossary_entry():
session_root, _epub_path = session_from_request()
data = request.get_json(silent=True) or {}
source = str(data.get("source") or "").strip()
if not source:
return jsonify({"error": "缺少术语原文"}), 400
try:
glossary = read_glossary(review_root)
glossary = read_glossary(review_root, session_root)
if request.method == "DELETE":
if source not in glossary:
return jsonify({"error": "术语不存在"}), 404
@@ -3492,15 +3662,22 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
if old_source and old_source != source:
glossary.pop(old_source, None)
glossary[source] = target
return jsonify(save_glossary(review_root, [{"source": key, "target": value} for key, value in glossary.items()]))
return jsonify(
save_glossary(
review_root,
[{"source": key, "target": value} for key, value in glossary.items()],
session_root,
)
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
@app.route("/api/glossary/search")
def api_glossary_search():
session_root, _epub_path = session_from_request()
term = str(request.args.get("q") or "")
try:
matches = search_glossary_entries(read_glossary(review_root), term)
matches = search_glossary_entries(read_glossary(review_root, session_root), term)
return jsonify({"query": term, "count": len(matches), "matches": matches})
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
@@ -3651,7 +3828,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
@app.route("/api/row/<row_id>/retranslate", methods=["POST"])
def api_retranslate_row(row_id: str):
session_root, _epub_path = active_session()
session_root, _epub_path = session_from_request()
if session_root is None:
return no_active_response()
rows = merge_rows(session_root)
@@ -3660,7 +3837,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
return jsonify({"error": "row not found"}), 404
data = request.get_json(silent=True) or {}
try:
config = read_gpt_config(review_root)
config = read_scoped_gpt_config(review_root, session_root)
requested_base_url = str(data.get("base_url") or "").strip()
requested_model = str(data.get("model") or "").strip()
if requested_base_url and normalize_gpt_base_url(requested_base_url) != config["base_url"]:
@@ -3668,12 +3845,13 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
if requested_model and requested_model != config["model"]:
return jsonify({"error": "重翻请求不能临时覆盖模型。请先保存 API 设置后再重翻。"}), 400
instruction = str(data.get("instruction") or "")
glossary_lines = glossary_matches_for_prompt(review_root, row)
glossary_lines = glossary_matches_for_prompt(review_root, row, session_root=session_root)
raw = call_openai_compatible_chat(config, build_retranslate_messages(row, rows, config, glossary_lines, instruction))
candidate_html = sanitize_cn_html(restore_glossary_ruby_candidates(normalize_candidate_punctuation(raw), glossary_lines))
if not candidate_html:
return jsonify({"error": "GPT 返回内容为空或无法作为译文保存"}), 502
candidate_text = strip_tags(candidate_html)
resolved_glossary_path = str(glossary_path(review_root, session_root))
record = {
"ts": now_iso(),
"event": "row_retranslated",
@@ -3685,7 +3863,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
"translation_prompt": config.get("translation_prompt", ""),
"format_prompt": config.get("format_prompt", ""),
"character_prompt": config.get("character_prompt", ""),
"glossary_path": str(glossary_path(review_root)),
"glossary_path": resolved_glossary_path,
"glossary_matches": glossary_lines,
"instruction": instruction[:1200],
"jp_text": row["jp_text"],
@@ -3700,7 +3878,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
"candidate_html": candidate_html,
"candidate_text": candidate_text,
"model": config["model"],
"glossary_path": str(glossary_path(review_root)),
"glossary_path": resolved_glossary_path,
"glossary_matches": glossary_lines,
"updated_at": record["ts"],
}
@@ -3712,11 +3890,11 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
@app.route("/api/row/<row_id>", methods=["POST"])
def api_save_row(row_id: str):
session_root, _epub_path = active_session()
session_root, _epub_path = session_from_request()
if session_root is None:
return no_active_response()
data = request.get_json(silent=True) or {}
rows = read_json(session_root / "review_state" / "rows.json", [])
rows = ensure_rows_current(session_root)
base = next((row for row in rows if row["id"] == row_id), None)
if base is None:
return jsonify({"error": "row not found"}), 404
@@ -3759,11 +3937,18 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
"after_cn_html": current_html,
},
)
return jsonify({"status": "ok", "row_id": row_id, "updated_at": edit["updated_at"]})
return jsonify(
{
"status": "ok",
"row_id": row_id,
"updated_at": edit["updated_at"],
"current_html": current_html,
}
)
@app.route("/api/apply", methods=["POST"])
def api_apply():
session_root, _epub_path = active_session()
session_root, _epub_path = session_from_request()
if session_root is None:
return no_active_response()
with app.config["LOCK"]:
@@ -3778,7 +3963,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
@app.route("/api/export", methods=["POST"])
def api_export():
session_root, _epub_path = active_session()
session_root, _epub_path = session_from_request()
if session_root is None:
return no_active_response()
with app.config["LOCK"]:
@@ -3787,36 +3972,40 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
{
"status": "ok",
"output": str(out_path),
"download_url": f"/downloads/export/{urllib.parse.quote(out_path.name)}",
"download_url": (
f"/downloads/export/{urllib.parse.quote(out_path.name)}"
f"?session_id={urllib.parse.quote(session_public_id(session_root))}"
),
}
)
@app.route("/api/feedback")
def api_feedback():
session_root, _epub_path = active_session()
session_root, _epub_path = session_from_request()
if session_root is None:
return no_active_response()
path = write_feedback_summary(session_root)
session_query = f"?session_id={urllib.parse.quote(session_public_id(session_root))}"
return jsonify(
{
"status": "ok",
"feedback_md": str(path),
"feedback_jsonl": str(session_root / "feedback" / "translation_feedback.jsonl"),
"feedback_md_url": "/downloads/feedback/feedback_for_codex.md",
"feedback_jsonl_url": "/downloads/feedback/translation_feedback.jsonl",
"feedback_md_url": f"/downloads/feedback/feedback_for_codex.md{session_query}",
"feedback_jsonl_url": f"/downloads/feedback/translation_feedback.jsonl{session_query}",
}
)
@app.route("/downloads/export/<path:filename>")
def download_export(filename: str):
session_root, _epub_path = active_session()
session_root, _epub_path = session_from_request()
if session_root is None:
return no_active_response()
return send_from_directory(session_root / "exports", filename, as_attachment=True)
@app.route("/downloads/feedback/<path:filename>")
def download_feedback(filename: str):
session_root, _epub_path = active_session()
session_root, _epub_path = session_from_request()
if session_root is None:
return no_active_response()
if filename not in {"feedback_for_codex.md", "translation_feedback.jsonl"}:
@@ -1718,6 +1718,40 @@ async function openSession(sessionId) {
}
}
async function openFromUrlParams() {
const params = new URLSearchParams(window.location.search);
const mode = params.get('mode') || 'review';
let sessionId = params.get('session_id') || '';
const epubPath = params.get('epub_path') || '';
if (!sessionId && epubPath) {
const payload = await api('/api/session/from-path', {
method: 'POST',
body: JSON.stringify({ epub_path: epubPath }),
});
sessionId = payload.id || '';
}
if (!sessionId && mode === 'translate') {
const session = await loadSession();
if (session && session.has_session && session.id) {
sessionId = session.id;
} else {
await showBookshelf();
return true;
}
}
if (!sessionId) return false;
if (mode === 'translate') {
await showTranslationScreen(sessionId);
return true;
}
await api('/api/session/open', {
method: 'POST',
body: JSON.stringify({ session_id: sessionId }),
});
await loadActiveSession({ message: '已打开审校会话。' });
return true;
}
function updateStructureCountsFromRows() {
for (const chapter of structure.chapters || []) {
chapter.row_count = 0;
@@ -3198,6 +3232,9 @@ function initEvents() {
async function main() {
initEvents();
try {
if (await openFromUrlParams()) {
return;
}
const session = await loadSession();
if (!session || !session.has_session) {
return;
@@ -4,13 +4,13 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>双语 EPUB 审校编辑器</title>
<link rel="stylesheet" href="/static/style.css?v=0.13.2">
<link rel="stylesheet" href="/static/style.css?v=0.16.3">
</head>
<body>
<header class="topbar">
<button id="bookshelfBtn" class="bookshelfNavButton" type="button">书架</button>
<div class="brandBlock">
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.13.2</span></h1>
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.16.3</span></h1>
<p id="sessionMeta">正在载入……</p>
</div>
<div class="modeTabs" role="tablist" aria-label="审校模式">
@@ -509,6 +509,6 @@
</aside>
<div class="toast" id="toast" hidden></div>
<script src="/static/app.js?v=0.13.2"></script>
<script src="/static/app.js?v=0.16.3"></script>
</body>
</html>
@@ -0,0 +1,171 @@
import importlib.util
import json
import tempfile
import unittest
from pathlib import Path
SERVER_PATH = Path(__file__).with_name("server.py")
SPEC = importlib.util.spec_from_file_location("epub_review_editor_server", SERVER_PATH)
server = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
SPEC.loader.exec_module(server)
def write_json(path: Path, data):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
class InlineReviewSessionScopeTest(unittest.TestCase):
def make_session(
self,
review_root: Path,
session_id: str,
epub_path: Path,
glossary_path: Path,
) -> Path:
session_root = review_root / session_id
write_json(
session_root / "review_state" / "session.json",
{
"id": session_id,
"source_name": epub_path.name,
"source_epub": str(epub_path),
},
)
write_json(
session_root / "review_state" / "state.json",
{
"source_epub": str(epub_path),
"edits": {},
},
)
write_json(
session_root / "review_state" / "gpt_config.json",
{
"base_url": "https://session.example.test/v1",
"model": "session-model",
"glossary_path": str(glossary_path),
"translation_prompt": "session translation prompt",
"format_prompt": "session format prompt",
"character_prompt": "session character prompt",
},
)
return session_root
def test_inline_retranslate_uses_request_session_config_and_glossary(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
review_root = root / "review-root"
global_glossary = root / "global-glossary.json"
session_glossary = root / "session-glossary.json"
write_json(global_glossary, {"別語": "全局译名"})
write_json(session_glossary, {"ファルシオン": "大砍刀"})
write_json(
review_root / "gpt_config.json",
{
"api_key": "test-key",
"base_url": "https://global.example.test/v1",
"model": "global-model",
"glossary_path": str(global_glossary),
},
)
epub_path = root / "book.epub"
epub_path.write_bytes(b"not a real epub")
session_root = self.make_session(review_root, "session-a", epub_path, session_glossary)
write_json(
session_root / "review_state" / "rows_meta.json",
{"parser_version": server.ROWS_PARSER_VERSION},
)
write_json(
session_root / "review_state" / "rows.json",
[
{
"id": "R00001",
"file": "chapter.xhtml",
"file_label": "chapter.xhtml",
"document_title": "chapter",
"ja_p_index": 0,
"cn_p_index": 1,
"jp_html": "<ruby>ファルシオン<rt>Falchion</rt></ruby>",
"jp_text": "ファルシオン",
"cn_html": "旧译文",
}
],
)
captured = {}
original_call = server.call_openai_compatible_chat
def fake_call(config, messages, temperature=0.2, timeout=90):
captured["config"] = config
captured["messages"] = messages
return "<span>候选译文</span>"
server.call_openai_compatible_chat = fake_call
try:
app = server.create_app(review_root)
response = app.test_client().post(
"/api/row/R00001/retranslate?session_id=session-a",
json={"instruction": ""},
)
finally:
server.call_openai_compatible_chat = original_call
self.assertEqual(response.status_code, 200, response.get_data(as_text=True))
payload = response.get_json()
self.assertEqual(payload["model"], "session-model")
self.assertEqual(payload["glossary_path"], str(session_glossary))
self.assertEqual(payload["glossary_matches"], ["ファルシオン => 大砍刀"])
self.assertEqual(captured["config"]["base_url"], "https://session.example.test/v1")
self.assertEqual(captured["config"]["translation_prompt"], "session translation prompt")
prompt_text = "\n".join(message["content"] for message in captured["messages"])
self.assertIn("ファルシオン => 大砍刀", prompt_text)
self.assertNotIn("全局译名", prompt_text)
def test_scoped_gpt_config_saves_secret_globally_and_public_response_hides_it(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
review_root = root / "review-root"
glossary = root / "glossary.json"
write_json(glossary, {})
write_json(review_root / "gpt_config.json", {"glossary_path": str(glossary)})
epub_path = root / "book.epub"
epub_path.write_bytes(b"not a real epub")
self.make_session(review_root, "session-a", epub_path, glossary)
app = server.create_app(review_root)
response = app.test_client().post(
"/api/gpt/config?session_id=session-a",
json={
"api_key": "session-secret",
"base_url": "https://session.example.test/v1",
"model": "session-model",
"glossary_path": str(glossary),
"translation_prompt": "session translation prompt",
"format_prompt": "session format prompt",
"character_prompt": "session character prompt",
},
)
self.assertEqual(response.status_code, 200, response.get_data(as_text=True))
payload = response.get_json()
self.assertTrue(payload["configured"])
self.assertNotIn("api_key", payload)
self.assertEqual(payload["base_url"], "https://session.example.test/v1")
self.assertEqual(
json.loads((review_root / "gpt_config.json").read_text(encoding="utf-8"))["api_key"],
"session-secret",
)
scoped = json.loads(
(review_root / "session-a" / "review_state" / "gpt_config.json").read_text(
encoding="utf-8"
)
)
self.assertNotIn("api_key", scoped)
if __name__ == "__main__":
unittest.main()
@@ -1,4 +1,4 @@
version = "0.13.2"
version = "0.16.3"
VERSION_RULES = {
"PATCH": "修复 bug 或兼容性小修",
+2
View File
@@ -0,0 +1,2 @@
@echo off
powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0open-readest-latest.ps1" %*
+162
View File
@@ -0,0 +1,162 @@
param(
[string]$Remote = "akai-tools",
[string]$Branch = "codex/desktop-review-editor-blocks",
[switch]$SkipPull,
[switch]$CheckOnly,
[switch]$KeepRunning
)
$ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
function Write-Step {
param([string]$Message)
Write-Host ""
Write-Host "==> $Message" -ForegroundColor Cyan
}
function Invoke-Checked {
param(
[string]$Command,
[string[]]$Arguments,
[string]$WorkingDirectory = $RepoRoot
)
Push-Location $WorkingDirectory
try {
& $Command @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$Command $($Arguments -join ' ') failed with exit code $LASTEXITCODE."
}
} finally {
Pop-Location
}
}
function Add-PathIfExists {
param([string]$PathToAdd)
if ($PathToAdd -and (Test-Path -LiteralPath $PathToAdd)) {
$script:PathParts += $PathToAdd
}
}
function Stop-OldReadestDev {
if ($KeepRunning) {
return
}
Write-Step "Stopping old Readest desktop/dev processes"
Get-Process -Name "Readest" -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
$repoLower = $RepoRoot.ToLowerInvariant()
$currentPid = $PID
Get-CimInstance Win32_Process |
Where-Object {
$_.ProcessId -ne $currentPid -and
$_.CommandLine -and
$_.CommandLine.ToLowerInvariant().Contains($repoLower) -and
($_.CommandLine -match "tauri\s+dev|next\s+dev|@readest/readest-app|epub-reviewer:dev")
} |
ForEach-Object {
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
}
}
function Assert-CleanWorktree {
$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
& git -C $RepoRoot status --short
throw "Commit, stash, or discard local changes before launching the latest organization version."
}
}
try {
$PathParts = @()
$runtimeRoot = Join-Path $env:USERPROFILE ".cache\codex-runtimes\codex-primary-runtime\dependencies"
Add-PathIfExists "C:\w64devkit\bin"
Add-PathIfExists (Join-Path $runtimeRoot "bin")
Add-PathIfExists (Join-Path $runtimeRoot "node\bin")
Add-PathIfExists (Join-Path $env:USERPROFILE ".cargo\bin")
$env:Path = (($PathParts + ($env:Path -split ";" | Where-Object { $_ })) -join ";")
$env:RUSTUP_TOOLCHAIN = "stable-x86_64-pc-windows-gnu"
foreach ($command in @("git", "pnpm", "cargo")) {
if (-not (Get-Command $command -ErrorAction SilentlyContinue)) {
throw "Required command not found: $command"
}
}
Write-Step "Readest latest launcher"
Write-Host "Repository: $RepoRoot"
Write-Host "Remote: $Remote"
Write-Host "Branch: $Branch"
$beforeHead = (& git -C $RepoRoot rev-parse HEAD).Trim()
if (-not $SkipPull) {
Assert-CleanWorktree
Write-Step "Fetching latest code from organization remote"
Invoke-Checked "git" @("-C", $RepoRoot, "fetch", "--prune", $Remote)
$currentBranch = (& git -C $RepoRoot branch --show-current).Trim()
if ($currentBranch -ne $Branch) {
Write-Step "Switching to $Branch"
& git -C $RepoRoot show-ref --verify --quiet "refs/heads/$Branch"
if ($LASTEXITCODE -eq 0) {
Invoke-Checked "git" @("-C", $RepoRoot, "switch", $Branch)
} else {
Invoke-Checked "git" @("-C", $RepoRoot, "switch", "--track", "-c", $Branch, "$Remote/$Branch")
}
}
Write-Step "Fast-forwarding local branch"
Invoke-Checked "git" @("-C", $RepoRoot, "pull", "--ff-only", $Remote, $Branch)
} else {
Write-Step "Skipping git pull by request"
}
$afterHead = (& git -C $RepoRoot rev-parse HEAD).Trim()
Write-Step "Updating submodules"
Invoke-Checked "git" @("-C", $RepoRoot, "submodule", "update", "--init", "--recursive")
$changedFiles = @()
if ($beforeHead -ne $afterHead) {
$changedFiles = (& git -C $RepoRoot diff --name-only $beforeHead $afterHead)
}
$needsInstall =
-not (Test-Path -LiteralPath (Join-Path $RepoRoot "node_modules")) -or
($changedFiles -contains "pnpm-lock.yaml") -or
($changedFiles -contains "package.json") -or
($changedFiles -contains "apps/readest-app/package.json")
if ($needsInstall) {
Write-Step "Installing/updating dependencies"
Invoke-Checked "pnpm" @("install", "--frozen-lockfile")
}
if ($CheckOnly) {
Write-Step "Launcher check complete"
exit 0
}
Stop-OldReadestDev
Write-Step "Starting Readest desktop with the latest code"
Write-Host "Close the Readest window or press Ctrl+C here to stop the dev server."
Invoke-Checked "pnpm" @("--filter", "@readest/readest-app", "tauri", "dev")
} catch {
Write-Host ""
Write-Host "Readest launcher failed:" -ForegroundColor Red
Write-Host $_.Exception.Message -ForegroundColor Red
Write-Host ""
Write-Host "Press Enter to close this window."
[void][Console]::ReadLine()
exit 1
}