diff --git a/apps/readest-app/.claude/memory/epub-review-editor-migration.md b/apps/readest-app/.claude/memory/epub-review-editor-migration.md index d2199321..0ad0c1c9 100644 --- a/apps/readest-app/.claude/memory/epub-review-editor-migration.md +++ b/apps/readest-app/.claude/memory/epub-review-editor-migration.md @@ -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. diff --git a/apps/readest-app/src-tauri/src/lib.rs b/apps/readest-app/src-tauri/src/lib.rs index 111fc265..9e033b54 100644 --- a/apps/readest-app/src-tauri/src/lib.rs +++ b/apps/readest-app/src-tauri/src/lib.rs @@ -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, diff --git a/apps/readest-app/src-tauri/src/review_editor.rs b/apps/readest-app/src-tauri/src/review_editor.rs new file mode 100644 index 00000000..62d52bfb --- /dev/null +++ b/apps/readest-app/src-tauri/src/review_editor.rs @@ -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, +} + +struct CommandOutput { + stdout: String, + stderr: String, +} + +struct PythonCommand { + program: String, + args: Vec, +} + +#[tauri::command] +pub async fn launch_epub_review_editor( + app: AppHandle, + epub_path: Option, +) -> Result { + 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, +) -> Result { + 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 { + 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 { + let candidates: Vec = 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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::(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 { + 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::() + .map_err(|err| format!("无法解析审校器端口:{port_text} ({err})")) +} + +fn ensure_session_from_path(url: &str, epub_path: Option<&str>) -> Result, 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 { + 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 { + text.split_whitespace() + .find(|part| part.starts_with("http://") || part.starts_with("https://")) + .map(ToOwned::to_owned) +} diff --git a/apps/readest-app/src-tauri/tauri.conf.json b/apps/readest-app/src-tauri/tauri.conf.json index 452c7899..fbbc8709 100644 --- a/apps/readest-app/src-tauri/tauri.conf.json +++ b/apps/readest-app/src-tauri/tauri.conf.json @@ -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" diff --git a/apps/readest-app/src-tauri/tauri.windows.conf.json b/apps/readest-app/src-tauri/tauri.windows.conf.json index 35d39335..325aaeb6 100644 --- a/apps/readest-app/src-tauri/tauri.windows.conf.json +++ b/apps/readest-app/src-tauri/tauri.windows.conf.json @@ -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/" } } } diff --git a/apps/readest-app/src/__tests__/app/library/book-context-menu.test.ts b/apps/readest-app/src/__tests__/app/library/book-context-menu.test.ts index ead3a39b..cf10aae3 100644 --- a/apps/readest-app/src/__tests__/app/library/book-context-menu.test.ts +++ b/apps/readest-app/src/__tests__/app/library/book-context-menu.test.ts @@ -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', diff --git a/apps/readest-app/src/app/library/components/BookshelfItem.tsx b/apps/readest-app/src/app/library/components/BookshelfItem.tsx index 1c7ea0a6..8b6ecaf6 100644 --- a/apps/readest-app/src/app/library/components/BookshelfItem.tsx +++ b/apps/readest-app/src/app/library/components/BookshelfItem.tsx @@ -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 = ({ 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 = ({ [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 = ({ 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 () => { diff --git a/apps/readest-app/src/app/library/components/SettingsMenu.tsx b/apps/readest-app/src/app/library/components/SettingsMenu.tsx index 3d64685b..17d5b087 100644 --- a/apps/readest-app/src/app/library/components/SettingsMenu.tsx +++ b/apps/readest-app/src/app/library/components/SettingsMenu.tsx @@ -436,7 +436,8 @@ const SettingsMenu: React.FC = ({ onPullLibrary, setIsDropdow
    - {process.env['NODE_ENV'] === 'development' && isWebAppPlatform() && ( + {(isTauriAppPlatform() || + (process.env['NODE_ENV'] === 'development' && isWebAppPlatform())) && ( )} diff --git a/apps/readest-app/src/app/library/utils/libraryUtils.ts b/apps/readest-app/src/app/library/utils/libraryUtils.ts index 03446f56..e0ef1244 100644 --- a/apps/readest-app/src/app/library/utils/libraryUtils.ts +++ b/apps/readest-app/src/app/library/utils/libraryUtils.ts @@ -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 diff --git a/apps/readest-app/src/app/review-editor/ReviewEditorLauncher.tsx b/apps/readest-app/src/app/review-editor/ReviewEditorLauncher.tsx index ae63e76c..faaa26c3 100644 --- a/apps/readest-app/src/app/review-editor/ReviewEditorLauncher.tsx +++ b/apps/readest-app/src/app/review-editor/ReviewEditorLauncher.tsx @@ -1,13 +1,30 @@ 'use client'; +import { invoke } from '@tauri-apps/api/core'; import clsx from 'clsx'; -import { ArrowLeft, ExternalLink, RefreshCw, Rocket, Wrench } from 'lucide-react'; +import DOMPurify from 'dompurify'; +import { + ArrowLeft, + BookOpenCheck, + Check, + Download, + ExternalLink, + FileText, + Languages, + RefreshCw, + Rocket, + Save, + Sparkles, + Wrench, +} from 'lucide-react'; import { useRouter } from 'next/navigation'; -import type { ReactNode } from 'react'; +import type { Dispatch, ReactNode, SetStateAction } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { isTauriAppPlatform } from '@/services/environment'; import { openExternalUrl } from '@/utils/open'; type LaunchState = 'idle' | 'launching' | 'ready' | 'failed'; +type LaunchMode = 'review' | 'translate'; type LaunchResponse = { ok: boolean; @@ -15,9 +32,252 @@ type LaunchResponse = { reused?: boolean; reviewRoot?: string; version?: string; + sessionId?: string | null; error?: string; }; +type LaunchContext = { + mode?: LaunchMode; + epubPath?: string; + sessionId?: string; +}; + +type SessionSummary = { + 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; + series_id?: string; + series?: string; + metadata?: Record; +}; + +type SessionPayload = { + 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; +}; + +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; +}; + +type RowsPayload = { + rows: ReviewRow[]; +}; + +type StructurePayload = { + chapters?: Array<{ + id: string; + title?: string; + kind?: string; + row_count?: number; + parts?: Array<{ id: string; title?: string; source_title?: string; file?: string }>; + items?: Array<{ id: string; title?: string; kind?: string; file?: string }>; + }>; +}; + +type GptConfig = { + 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; + }; +}; + +type TranslationDefaultsPayload = { + version?: string; + gpt?: GptConfig; + defaults?: { + output_mode?: string; + range_mode?: string; + limit?: number; + temperature?: number; + translation_prompt?: string; + format_prompt?: string; + character_prompt?: string; + glossary_path?: string; + }; + prompt_defaults?: GptConfig['prompt_defaults']; +}; + +type TranslationSourcePayload = { + session: SessionSummary; + series_config?: { + series_id?: string; + translation_prompt?: string; + format_prompt?: string; + character_prompt?: string; + glossary_path?: string; + }; + source_count: number; + stats?: Record; + files?: Array<{ file: string; count: number }>; + sample?: Array<{ id: string; file: string; p_index: number; text: string }>; +}; + +type TranslationJob = { + id?: string; + status?: string; + output_epub?: string; + output_session_id?: string; + error?: string; + logs?: Array<{ ts?: string; level?: string; message?: string }>; + progress?: { + current?: number; + total?: number; + percent?: number; + failed?: number; + message?: string; + current_file?: string; + current_item_id?: string; + }; + settings_summary?: Record; +}; + +type TranslationJobPayload = { + status?: string; + job_id?: string; + job?: TranslationJob; + error?: string; +}; + +type EditState = { + current_html: string; + marked: boolean; + issue_type: string; + severity: string; + tags: string; + comment: string; + learn_note: string; +}; + +type TranslationFormState = { + base_url: string; + model: string; + api_key: string; + glossary_path: string; + translation_prompt: string; + format_prompt: string; + character_prompt: string; + output_mode: 'bilingual' | 'translated'; + range_mode: 'limit' | 'all'; + limit: number; + temperature: number; + use_series_config: boolean; +}; + +const featureBlocks: Array<{ + mode: LaunchMode; + title: string; + description: string; + icon: typeof BookOpenCheck; +}> = [ + { + mode: 'review', + title: '校对', + description: '逐段读取、修改译文、记录问题、单段重翻与导出 EPUB。', + icon: BookOpenCheck, + }, + { + mode: 'translate', + title: '翻译', + description: '设置 API、提示词、术语表、范围与输出格式,启动整书翻译。', + icon: Languages, + }, +]; + +const emptyEditState: EditState = { + current_html: '', + marked: false, + issue_type: '', + severity: '', + tags: '', + comment: '', + learn_note: '', +}; + +const emptyTranslationForm: TranslationFormState = { + base_url: '', + model: '', + api_key: '', + glossary_path: '', + translation_prompt: '', + format_prompt: '', + character_prompt: '', + output_mode: 'bilingual', + range_mode: 'limit', + limit: 20, + temperature: 0.2, + use_series_config: false, +}; + +const cnHtmlPurifyOptions = { + 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 LaunchButton = ({ onClick, disabled, @@ -43,25 +303,1328 @@ const LaunchButton = ({ ); +const ToolbarButton = ({ + onClick, + disabled, + children, + icon, + variant = 'ghost', +}: { + onClick: () => void; + disabled?: boolean; + children: ReactNode; + icon?: ReactNode; + variant?: 'primary' | 'ghost'; +}) => ( + +); + +const sanitizeInlineHtml = (html: string) => DOMPurify.sanitize(html || '', cnHtmlPurifyOptions); + +const stripHtml = (html: string) => { + if (typeof window === 'undefined') return html.replace(/<[^>]*>/g, ''); + const div = document.createElement('div'); + div.innerHTML = sanitizeInlineHtml(html); + return div.textContent || div.innerText || ''; +}; + +const formatPercent = (value: number) => + `${Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0)).toFixed(value % 1 ? 1 : 0)}%`; + +const getSessionTitle = (session: SessionSummary | SessionPayload | null) => + session?.title || session?.source_name || session?.id || '未选择 EPUB'; + +async function sidecarApi(baseUrl: string, path: string, options: RequestInit = {}): Promise { + const url = new URL(path, baseUrl); + 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; +} + +function readLaunchContextFromBrowser(): LaunchContext { + if (typeof window === 'undefined') return {}; + const params = new URLSearchParams(window.location.search); + let context: LaunchContext = {}; + try { + context = JSON.parse(sessionStorage.getItem('reviewEditorLaunchContext') || '{}'); + } catch (_error) { + context = {}; + } + if (params.has('epub_path')) { + context.epubPath = params.get('epub_path') || undefined; + } + if (params.has('session_id')) { + context.sessionId = params.get('session_id') || undefined; + } + const requestedMode = params.get('mode') || context.mode; + if (requestedMode === 'translate' || requestedMode === 'review') { + context.mode = requestedMode; + } + return context; +} + +function rowGroupTitle(row: ReviewRow) { + return row.document_title || row.file_label || row.file; +} + +function rowMatchesScope(row: ReviewRow, scope: string) { + return scope === 'all' || row.file === scope || rowGroupTitle(row) === scope; +} + +function updateUrlSession( + router: ReturnType, + mode: LaunchMode, + sessionId?: string | null, +) { + if (typeof window === 'undefined') return; + const params = new URLSearchParams(window.location.search); + params.delete('epub_path'); + params.set('mode', mode); + if (sessionId) { + params.set('session_id', sessionId); + } else { + params.delete('session_id'); + } + router.replace(`/review-editor?${params.toString()}`); +} + +function GptConfigPanel({ + form, + setForm, + onSave, + disabled, + compact = false, +}: { + form: TranslationFormState; + setForm: Dispatch>; + onSave: () => Promise; + disabled?: boolean; + compact?: boolean; +}) { + const [open, setOpen] = useState(!compact); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(''); + + const save = async () => { + setSaving(true); + setMessage(''); + try { + await onSave(); + setMessage('已保存 API 与提示词设置。'); + } catch (error) { + setMessage(error instanceof Error ? error.message : String(error)); + } finally { + setSaving(false); + } + }; + + return ( +
    + + {open ? ( +
    +
    + + +
    + + +