Compare commits

..

1 Commits

Author SHA1 Message Date
akai fd41bcf420 feat: add native review editor desktop blocks 2026-07-09 00:50:58 +08:00
18 changed files with 2302 additions and 61 deletions
@@ -2,24 +2,29 @@
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.
+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,461 @@
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 }).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',
@@ -3,6 +3,7 @@ import { useCallback } 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 { revealItemInDir } from '@tauri-apps/plugin-opener';
@@ -15,6 +16,7 @@ import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
import { Book, BooksGroup, ReadingStatus } from '@/types/book';
import { navigateToReviewEditor } from '@/utils/nav';
import {
getBookContextMenuItemIds,
type BookContextMenuItemId,
@@ -125,9 +127,10 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
handleUpdateReadingStatus,
}) => {
const _ = useTranslation();
const router = useAppRouter();
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { openBook } = useOpenBook({ setLoading, handleBookDownload });
const { openBook, makeBookAvailable } = useOpenBook({ setLoading, handleBookDownload });
const showBookDetailsModal = useCallback(async (book: Book) => {
handleShowDetailsBook(book);
@@ -145,6 +148,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) {
@@ -214,6 +236,18 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
showBookDetailsModal(book);
},
},
reviewInEpubEditor: {
text: '用 EPUB 审校器校对',
action: async () => {
await openBookInReviewEditor(book, 'review');
},
},
translateInEpubEditor: {
text: '用 EPUB 审校器翻译',
action: async () => {
await openBookInReviewEditor(book, 'translate');
},
},
showInFinder: {
text: _(fileRevealLabel),
action: async () => {
@@ -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} />
@@ -651,6 +651,8 @@ export type BookContextMenuItemId =
| 'markAbandoned'
| 'clearStatus'
| 'showDetails'
| 'reviewInEpubEditor'
| 'translateInEpubEditor'
| 'showInFinder'
| 'searchGoodreads'
| 'download'
@@ -750,7 +752,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('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
File diff suppressed because it is too large Load Diff
+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,8 @@ 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。
## 目录与插图
@@ -239,7 +240,12 @@ 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
- 校对功能块至少能加载 `/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.15.0`
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
@@ -52,6 +52,11 @@
`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。
## 独立安装
这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub-review-editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖:
@@ -94,7 +99,7 @@ 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
## 独立启动
@@ -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)
@@ -3190,13 +3190,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:
@@ -3245,7 +3268,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():
@@ -3328,6 +3351,28 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
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))
with app.config["LOCK"]:
session_root = session_root_for(review_root, epub_path)
ensure_state(session_root, epub_path, reset=reset_session)
set_active_session(session_root, epub_path)
summary = summarize_session(session_root)
summary["has_session"] = True
return jsonify(summary)
@app.route("/api/session/<session_id>", methods=["DELETE"])
def api_delete_session(session_id: str):
try:
@@ -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.15.0">
</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.15.0</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.15.0"></script>
</body>
</html>
@@ -1,4 +1,4 @@
version = "0.13.2"
version = "0.15.0"
VERSION_RULES = {
"PATCH": "修复 bug 或兼容性小修",