Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fd41bcf420 |
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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 注册 session,Readest 页面 URL 应改用 `session_id`,避免长期暴露本机 EPUB 路径。
|
||||
- Readest `/review-editor` 的主路径必须是原生 React 功能块,校对和翻译功能直接调用本地 sidecar API;旧 `static/index.html` 界面只保留为独立启动、调试或应急 fallback,不再作为桌面迁移验收的主界面。若 React 直接访问 loopback sidecar,sidecar 只能对本机 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 id;dev-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 API,sidecar 只对本机 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 启动本地 sidecar;dev-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 或兼容性小修",
|
||||
|
||||
Reference in New Issue
Block a user