diff --git a/tools/epub_review_editor/README.md b/tools/epub_review_editor/README.md index cdd7b9d..69a2032 100644 --- a/tools/epub_review_editor/README.md +++ b/tools/epub_review_editor/README.md @@ -2,8 +2,33 @@ 这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。 +## 独立安装 + +这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub_review_editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖: + +```powershell +python -m venv .venv +.\.venv\Scripts\python -m pip install -r requirements.txt +``` + +Linux/macOS: + +```bash +python3 -m venv .venv +./.venv/bin/python -m pip install -r requirements.txt +``` + ## 启动 +不预先指定 EPUB,直接打开上传界面: + +```powershell +$env:PYTHONIOENCODING='utf-8' +python 'C:\Users\xin61\Documents\轻小说翻译持续改进\tools\epub_review_editor\server.py' +``` + +也可以像原来一样直接打开某个 EPUB: + ```powershell $env:PYTHONIOENCODING='utf-8' python 'C:\Users\xin61\Documents\轻小说翻译持续改进\tools\epub_review_editor\server.py' '<中日双语版.epub>' --daemon @@ -15,6 +40,21 @@ python 'C:\Users\xin61\Documents\轻小说翻译持续改进\tools\epub_review_e http://localhost:5177 ``` +局域网或服务器部署时,把监听地址改为 `0.0.0.0`,并把审校数据目录固定到一个你会备份的位置: + +```powershell +python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessions +``` + +当前版本没有登录鉴权,不要直接暴露到公网。需要远程使用时,优先放在可信局域网、VPN、反向代理鉴权或 SSH 隧道后面。 + +## 浏览器上传 EPUB + +- 启动时不带 EPUB 路径,会进入开始页。 +- 点击“选择 EPUB 文件”,上传中日双语 EPUB 后会自动创建审校会话。 +- 开始页会列出已有会话,换电脑或重启服务后可以继续打开之前的审校记录。 +- 上传的 EPUB、解包副本、反馈与导出文件都会保存在 `--review-root` 指定目录下。 + ## 能做什么 - 阅读模式下按章节连续阅读日文原文与中文译文,不需要逐句点击。 @@ -30,7 +70,13 @@ http://localhost:5177 ## 重要输出 -每个 EPUB 会建立独立审校 session,默认位于: +每个 EPUB 会建立独立审校 session。通过浏览器上传时,默认位于: + +```text +epub_review_sessions\__\ +``` + +通过命令行直接指定 EPUB 时,仍沿用固定路径: ```text epub_review_sessions\_\ @@ -44,6 +90,8 @@ epub_review_sessions\_\ - `review_state/state.json`:网页编辑器当前状态。 - `extracted/`:EPUB 解包后的工作副本。 +在服务器上使用时,“生成反馈”和“导出审校 EPUB”会返回浏览器可访问的下载地址,不需要登录服务器找文件。 + ## 使用建议 - 正式长篇 EPUB 优先使用“阅读模式”:先在左侧点章节/part 连续读,看到问题再点段落快速编辑或标记。 diff --git a/tools/epub_review_editor/requirements.txt b/tools/epub_review_editor/requirements.txt new file mode 100644 index 0000000..840d434 --- /dev/null +++ b/tools/epub_review_editor/requirements.txt @@ -0,0 +1 @@ +Flask>=3.0,<4.0 diff --git a/tools/epub_review_editor/server.py b/tools/epub_review_editor/server.py index c2f920b..cf105fb 100644 --- a/tools/epub_review_editor/server.py +++ b/tools/epub_review_editor/server.py @@ -1,7 +1,6 @@ from __future__ import annotations import argparse -import atexit import copy import datetime as dt import hashlib @@ -40,6 +39,7 @@ GRAY_RE = re.compile( DEFAULT_REVIEW_ROOT = "epub_review_sessions" STATE_VERSION = 1 +UPLOAD_DIR_NAME = "uploads" def now_iso() -> str: @@ -52,18 +52,22 @@ def safe_slug(value: str, max_len: int = 80) -> str: return value[:max_len] or "epub" -def find_free_port(start: int) -> int: +def find_free_port(start: int, host: str = "127.0.0.1") -> int: for port in range(start, start + 100): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: - sock.bind(("127.0.0.1", port)) + sock.bind((host, port)) except OSError: continue return port raise RuntimeError(f"no free port found from {start}") +def display_host(host: str) -> str: + return "localhost" if host in {"127.0.0.1", "0.0.0.0", "::"} else host + + def read_json(path: Path, default: Any) -> Any: if not path.exists(): return copy.deepcopy(default) @@ -228,6 +232,147 @@ def session_root_for(review_root: Path, epub_path: Path) -> Path: return review_root / f"{safe_slug(epub_path.stem)}_{digest}" +def app_manifest_path(review_root: Path) -> Path: + return review_root / "app_state.json" + + +def session_manifest_path(session_root: Path) -> Path: + return session_root / "review_state" / "session.json" + + +def session_public_id(session_root: Path) -> str: + return session_root.name + + +def session_root_from_id(review_root: Path, session_id: str) -> Path: + safe_id = safe_slug(session_id, max_len=160) + if safe_id != session_id: + raise ValueError("invalid session id") + root = (review_root / session_id).resolve() + try: + root.relative_to(review_root.resolve()) + except ValueError as exc: + raise ValueError("invalid session id") from exc + return root + + +def write_session_manifest(session_root: Path, epub_path: Path, source_name: str = "") -> None: + existing = read_json(session_manifest_path(session_root), {}) + record = { + "id": session_public_id(session_root), + "source_name": source_name or epub_path.name, + "source_epub": str(epub_path), + "session_root": str(session_root), + "created_at": existing.get("created_at") or now_iso(), + "updated_at": now_iso(), + } + write_json(session_manifest_path(session_root), record) + + +def update_app_manifest(review_root: Path, session_root: Path | None) -> None: + data = read_json(app_manifest_path(review_root), {}) + data["active_session_id"] = session_public_id(session_root) if session_root else "" + data["updated_at"] = now_iso() + write_json(app_manifest_path(review_root), data) + + +def session_epub_path(session_root: Path) -> Path: + manifest = read_json(session_manifest_path(session_root), {}) + source_epub = manifest.get("source_epub") or read_json( + session_root / "review_state" / "state.json", {} + ).get("source_epub") + if source_epub: + path = Path(source_epub) + if path.exists(): + return path + fallback = session_root / "source.epub" + if fallback.exists(): + return fallback + return Path(source_epub or fallback) + + +def summarize_session(session_root: Path) -> dict[str, Any]: + manifest = read_json(session_manifest_path(session_root), {}) + state = read_json(session_root / "review_state" / "state.json", {}) + rows = merge_rows(session_root) if (session_root / "review_state" / "rows.json").exists() else [] + touched = [row for row in rows if rowTouched_py(row)] + source_epub = manifest.get("source_epub") or state.get("source_epub", "") + source_name = manifest.get("source_name") or (Path(source_epub).name if source_epub else session_root.name) + exports_dir = session_root / "exports" + latest_export = "" + if exports_dir.exists(): + exports = sorted(exports_dir.glob("*.epub"), key=lambda p: p.stat().st_mtime, reverse=True) + latest_export = str(exports[0]) if exports else "" + updated_at = state.get("updated_at") or manifest.get("updated_at") or "" + return { + "id": session_public_id(session_root), + "source_name": source_name, + "source_epub": source_epub, + "session_root": str(session_root), + "row_count": len(rows), + "touched_count": len(touched), + "marked_count": sum(1 for row in rows if row.get("marked")), + "updated_at": updated_at, + "created_at": state.get("created_at") or manifest.get("created_at") or "", + "feedback_md": str(session_root / "feedback" / "feedback_for_codex.md"), + "feedback_jsonl": str(session_root / "feedback" / "translation_feedback.jsonl"), + "latest_export": latest_export, + } + + +def rowTouched_py(row: dict[str, Any]) -> bool: + return bool( + row.get("edited") + or row.get("marked") + or row.get("comment") + or row.get("learn_note") + or row.get("issue_type") + or row.get("severity") + or row.get("tags") + ) + + +def list_review_sessions(review_root: Path) -> list[dict[str, Any]]: + if not review_root.exists(): + return [] + sessions = [] + for child in review_root.iterdir(): + if not child.is_dir() or child.name == UPLOAD_DIR_NAME: + continue + if not (child / "review_state" / "state.json").exists(): + continue + try: + sessions.append(summarize_session(child)) + except Exception: + continue + sessions.sort(key=lambda item: item.get("updated_at") or item.get("created_at") or "", reverse=True) + return sessions + + +def validate_epub_file(path: Path) -> None: + if path.suffix.lower() != ".epub": + raise ValueError("只支持 .epub 文件") + if not zipfile.is_zipfile(path): + raise ValueError("文件不是有效的 EPUB/ZIP 包") + + +def copy_upload_to_review_root(file_storage: Any, review_root: Path) -> Path: + filename = safe_slug(Path(file_storage.filename or "uploaded.epub").name, max_len=140) + if not filename.lower().endswith(".epub"): + filename = f"{filename}.epub" + upload_dir = review_root / UPLOAD_DIR_NAME + upload_dir.mkdir(parents=True, exist_ok=True) + stem = safe_slug(Path(filename).stem, max_len=100) + target = upload_dir / f"{dt.datetime.now().strftime('%Y%m%d_%H%M%S')}_{stem}.epub" + try: + file_storage.save(target) + validate_epub_file(target) + return target + except Exception: + target.unlink(missing_ok=True) + raise + + def extracted_path(session_root: Path, entry_name: str) -> Path: root = (session_root / "extracted").resolve() target = (root / entry_name).resolve() @@ -640,7 +785,9 @@ def ensure_state(session_root: Path, epub_path: Path, reset: bool = False) -> di }, ) state.setdefault("edits", {}) + state["source_epub"] = str(epub_path) write_json(state_path, state) + write_session_manifest(session_root, epub_path) return state @@ -752,11 +899,13 @@ def apply_edits_to_xhtml(session_root: Path) -> list[str]: def export_epub(session_root: Path) -> Path: apply_edits_to_xhtml(session_root) entries = read_json(session_root / "review_state" / "epub_entries.json", []) - source_epub = Path(read_json(session_root / "review_state" / "state.json", {}).get("source_epub", "reviewed.epub")) + manifest = read_json(session_manifest_path(session_root), {}) + state_source = read_json(session_root / "review_state" / "state.json", {}).get("source_epub", "reviewed.epub") + source_name = manifest.get("source_name") or Path(state_source).name ts = dt.datetime.now().strftime("%Y%m%d_%H%M%S") out_dir = session_root / "exports" out_dir.mkdir(parents=True, exist_ok=True) - out_path = out_dir / f"{safe_slug(source_epub.stem)}_网页审校版_{ts}.epub" + out_path = out_dir / f"{safe_slug(Path(source_name).stem)}_网页审校版_{ts}.epub" with zipfile.ZipFile(out_path, "w") as zf: names_written: set[str] = set() mimetype = extracted_path(session_root, "mimetype") @@ -784,46 +933,151 @@ def export_epub(session_root: Path) -> Path: return out_path -def create_app(session_root: Path, epub_path: Path) -> Flask: +def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: bool = False) -> Flask: app = Flask( __name__, root_path=str(Path(__file__).resolve().parent), static_folder="static", static_url_path="/static", ) - app.config["SESSION_ROOT"] = session_root - app.config["EPUB_PATH"] = epub_path + review_root.mkdir(parents=True, exist_ok=True) + app.config["REVIEW_ROOT"] = review_root + app.config["SESSION_ROOT"] = None + app.config["EPUB_PATH"] = None app.config["LOCK"] = threading.Lock() + def set_active_session(session_root: Path, epub_path: Path | None = None) -> None: + app.config["SESSION_ROOT"] = session_root + app.config["EPUB_PATH"] = epub_path or session_epub_path(session_root) + update_app_manifest(review_root, session_root) + + def active_session() -> tuple[Path | None, Path | None]: + session_root = app.config.get("SESSION_ROOT") + epub_path = app.config.get("EPUB_PATH") + if isinstance(session_root, Path): + return session_root, epub_path if isinstance(epub_path, Path) else session_epub_path(session_root) + return None, None + + def no_active_response(): + return jsonify( + { + "has_session": False, + "error": "尚未打开 EPUB。请先上传中日双语 EPUB,或从已有会话中选择一个。", + "review_root": str(review_root), + } + ), 409 + + if initial_epub_path is not None: + session_root = session_root_for(review_root, initial_epub_path) + ensure_state(session_root, initial_epub_path, reset=reset) + set_active_session(session_root, initial_epub_path) + @app.route("/") def index(): return send_from_directory(app.static_folder, "index.html") @app.route("/api/session") def api_session(): + session_root, epub_path = active_session() + if session_root is None or epub_path is None: + return jsonify( + { + "has_session": False, + "review_root": str(review_root), + "sessions": list_review_sessions(review_root), + } + ) rows = merge_rows(session_root) - touched = [row for row in rows if row["edited"] or row["marked"] or row["comment"] or row["learn_note"]] + touched = [row for row in rows if rowTouched_py(row)] + manifest = read_json(session_manifest_path(session_root), {}) return jsonify( { + "has_session": True, + "id": session_public_id(session_root), + "source_name": manifest.get("source_name") or epub_path.name, "source_epub": str(epub_path), "session_root": str(session_root), "row_count": len(rows), "touched_count": len(touched), + "marked_count": sum(1 for row in rows if row.get("marked")), "feedback_md": str(session_root / "feedback" / "feedback_for_codex.md"), "feedback_jsonl": str(session_root / "feedback" / "translation_feedback.jsonl"), } ) + @app.route("/api/sessions") + def api_sessions(): + session_root, _epub_path = active_session() + return jsonify( + { + "review_root": str(review_root), + "active_session_id": session_public_id(session_root) if session_root else "", + "sessions": list_review_sessions(review_root), + } + ) + + @app.route("/api/upload", methods=["POST"]) + def api_upload(): + file_storage = request.files.get("epub") + if file_storage is None or not file_storage.filename: + return jsonify({"error": "请选择一个 .epub 文件"}), 400 + reset_upload = str(request.form.get("reset", "")).lower() in {"1", "true", "yes", "on"} + try: + epub_path = copy_upload_to_review_root(file_storage, review_root) + session_root = session_root_for(review_root, epub_path) + with app.config["LOCK"]: + if reset_upload and session_root.exists(): + shutil.rmtree(session_root) + ensure_state(session_root, epub_path, reset=reset_upload) + write_session_manifest(session_root, epub_path, source_name=file_storage.filename) + set_active_session(session_root, epub_path) + summary = summarize_session(session_root) + summary["has_session"] = True + return jsonify(summary) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + except zipfile.BadZipFile: + return jsonify({"error": "文件不是有效的 EPUB/ZIP 包"}), 400 + except Exception as exc: + return jsonify({"error": f"EPUB 解析失败:{exc}"}), 500 + + @app.route("/api/session/open", methods=["POST"]) + def api_open_session(): + data = request.get_json(silent=True) or {} + session_id = str(data.get("session_id", "")) + if not session_id: + return jsonify({"error": "缺少 session_id"}), 400 + try: + session_root = session_root_from_id(review_root, session_id) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + if not (session_root / "review_state" / "state.json").exists(): + return jsonify({"error": "会话不存在或不完整"}), 404 + with app.config["LOCK"]: + set_active_session(session_root) + summary = summarize_session(session_root) + summary["has_session"] = True + return jsonify(summary) + @app.route("/api/rows") def api_rows(): + session_root, _epub_path = active_session() + if session_root is None: + return no_active_response() return jsonify({"rows": merge_rows(session_root)}) @app.route("/api/structure") def api_structure(): + session_root, _epub_path = active_session() + if session_root is None: + return no_active_response() return jsonify(build_structure(session_root)) @app.route("/api/row/", methods=["POST"]) def api_save_row(row_id: str): + session_root, _epub_path = active_session() + if session_root is None: + return no_active_response() data = request.get_json(silent=True) or {} rows = read_json(session_root / "review_state" / "rows.json", []) base = next((row for row in rows if row["id"] == row_id), None) @@ -872,6 +1126,9 @@ def create_app(session_root: Path, epub_path: Path) -> Flask: @app.route("/api/apply", methods=["POST"]) def api_apply(): + session_root, _epub_path = active_session() + if session_root is None: + return no_active_response() with app.config["LOCK"]: modified = apply_edits_to_xhtml(session_root) return jsonify( @@ -884,21 +1141,51 @@ def create_app(session_root: Path, epub_path: Path) -> Flask: @app.route("/api/export", methods=["POST"]) def api_export(): + session_root, _epub_path = active_session() + if session_root is None: + return no_active_response() with app.config["LOCK"]: out_path = export_epub(session_root) - return jsonify({"status": "ok", "output": str(out_path)}) + return jsonify( + { + "status": "ok", + "output": str(out_path), + "download_url": f"/downloads/export/{urllib.parse.quote(out_path.name)}", + } + ) @app.route("/api/feedback") def api_feedback(): + session_root, _epub_path = active_session() + if session_root is None: + return no_active_response() path = write_feedback_summary(session_root) return jsonify( { "status": "ok", "feedback_md": str(path), "feedback_jsonl": str(session_root / "feedback" / "translation_feedback.jsonl"), + "feedback_md_url": "/downloads/feedback/feedback_for_codex.md", + "feedback_jsonl_url": "/downloads/feedback/translation_feedback.jsonl", } ) + @app.route("/downloads/export/") + def download_export(filename: str): + session_root, _epub_path = active_session() + if session_root is None: + return no_active_response() + return send_from_directory(session_root / "exports", filename, as_attachment=True) + + @app.route("/downloads/feedback/") + def download_feedback(filename: str): + session_root, _epub_path = active_session() + if session_root is None: + return no_active_response() + if filename not in {"feedback_for_codex.md", "translation_feedback.jsonl"}: + return jsonify({"error": "unknown feedback file"}), 404 + return send_from_directory(session_root / "feedback", filename, as_attachment=True) + @app.route("/api/shutdown", methods=["POST"]) def api_shutdown(): def stop() -> None: @@ -926,25 +1213,43 @@ def wait_until_ready(url: str, proc: subprocess.Popen, timeout: int = 15) -> boo def run_daemon(args: argparse.Namespace) -> int: - port = find_free_port(args.port) + bind_host = display_host(args.host) + port = find_free_port(args.port, bind_host) review_root = Path(args.review_root).resolve() - epub_path = Path(args.epub).resolve() - session_root = session_root_for(review_root, epub_path) - if args.reset and session_root.exists(): - shutil.rmtree(session_root) - session_root.mkdir(parents=True, exist_ok=True) - log_path = session_root / "server.log" + review_root.mkdir(parents=True, exist_ok=True) + if args.epub: + epub_path = Path(args.epub).resolve() + if not epub_path.exists(): + print(f"EPUB not found: {epub_path}", file=sys.stderr) + return 1 + try: + validate_epub_file(epub_path) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 1 + session_root = session_root_for(review_root, epub_path) + if args.reset and session_root.exists(): + shutil.rmtree(session_root) + session_root.mkdir(parents=True, exist_ok=True) + log_path = session_root / "server.log" + else: + epub_path = None + log_path = review_root / "server.log" cmd = [ sys.executable, str(Path(__file__).resolve()), - str(epub_path), "--review-root", str(review_root), + "--host", + args.host, "--port", str(port), ] - if args.no_browser: - cmd.append("--no-browser") + if epub_path is not None: + cmd.insert(2, str(epub_path)) + cmd.append("--no-browser") + if args.reset: + cmd.append("--reset") creationflags = 0 popen_kwargs: dict[str, Any] = {} if os.name == "nt": @@ -960,7 +1265,7 @@ def run_daemon(args: argparse.Namespace) -> int: creationflags=creationflags, **popen_kwargs, ) - url = f"http://localhost:{port}" + url = f"http://{display_host(args.host)}:{port}" if not wait_until_ready(url, proc): print(f"review editor failed to start; see {log_path}", file=sys.stderr) return 1 @@ -970,15 +1275,19 @@ def run_daemon(args: argparse.Namespace) -> int: except Exception: pass print(url) - print(f"session: {session_root}") + if epub_path is not None: + print(f"session: {session_root}") + else: + print(f"review root: {review_root}") print(f"log: {log_path}") return 0 def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Open a bilingual EPUB in a browser review/editor.") - parser.add_argument("epub", help="Path to the bilingual EPUB.") + parser.add_argument("epub", nargs="?", help="Optional path to a bilingual EPUB. Omit it to upload EPUBs in the browser.") parser.add_argument("--review-root", default=DEFAULT_REVIEW_ROOT, help="Directory for review sessions.") + parser.add_argument("--host", default="127.0.0.1", help="Host to bind. Use 0.0.0.0 for trusted LAN/server access.") parser.add_argument("--port", type=int, default=5177, help="Port, auto-advances if busy.") parser.add_argument("--daemon", action="store_true", help="Start in background and print URL.") parser.add_argument("--no-browser", action="store_true", help="Do not auto-open the browser.") @@ -988,20 +1297,24 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: args = build_parser().parse_args(argv) - epub_path = Path(args.epub).resolve() - if not epub_path.exists(): - print(f"EPUB not found: {epub_path}", file=sys.stderr) - return 1 + epub_path = Path(args.epub).resolve() if args.epub else None + if epub_path is not None: + if not epub_path.exists(): + print(f"EPUB not found: {epub_path}", file=sys.stderr) + return 1 + try: + validate_epub_file(epub_path) + except ValueError as exc: + print(str(exc), file=sys.stderr) + return 1 if args.daemon: return run_daemon(args) review_root = Path(args.review_root).resolve() - session_root = session_root_for(review_root, epub_path) - ensure_state(session_root, epub_path, reset=args.reset) - - app = create_app(session_root, epub_path) - port = find_free_port(args.port) - url = f"http://localhost:{port}" + app = create_app(review_root, epub_path, reset=args.reset) + bind_host = display_host(args.host) + port = find_free_port(args.port, bind_host) + url = f"http://{display_host(args.host)}:{port}" if not args.no_browser: try: webbrowser.open(url) @@ -1016,9 +1329,14 @@ def main(argv: list[str] | None = None) -> int: except Exception: pass print(f"EPUB review editor: {url}") - print(f"Session: {session_root}") - print(f"Rows: {len(read_json(session_root / 'review_state' / 'rows.json', []))}") - app.run(host="127.0.0.1", port=port, debug=False, threaded=True) + print(f"Review root: {review_root}") + session_root = app.config.get("SESSION_ROOT") + if isinstance(session_root, Path): + print(f"Session: {session_root}") + print(f"Rows: {len(read_json(session_root / 'review_state' / 'rows.json', []))}") + else: + print("No EPUB loaded yet. Upload one in the browser.") + app.run(host=args.host, port=port, debug=False, threaded=True) return 0 diff --git a/tools/epub_review_editor/static/app.js b/tools/epub_review_editor/static/app.js index 9cd6530..3a0d80f 100644 --- a/tools/epub_review_editor/static/app.js +++ b/tools/epub_review_editor/static/app.js @@ -9,13 +9,17 @@ let currentMode = "reading"; let showTouchedOnly = false; let dirty = false; let quickDirty = false; +let hasSession = false; const $ = (id) => document.getElementById(id); const dom = { + startScreen: $("startScreen"), + reviewLayout: $("reviewLayout"), sessionMeta: $("sessionMeta"), readingModeBtn: $("readingModeBtn"), polishModeBtn: $("polishModeBtn"), + openLibraryBtn: $("openLibraryBtn"), showTouchedBtn: $("showTouchedBtn"), feedbackBtn: $("feedbackBtn"), applyBtn: $("applyBtn"), @@ -64,6 +68,13 @@ const dom = { quickMarkSelectionBtn: $("quickMarkSelectionBtn"), quickSaveBtn: $("quickSaveBtn"), quickOpenFullBtn: $("quickOpenFullBtn"), + uploadForm: $("uploadForm"), + epubFileInput: $("epubFileInput"), + selectedFileName: $("selectedFileName"), + uploadBtn: $("uploadBtn"), + refreshSessionsBtn: $("refreshSessionsBtn"), + sessionList: $("sessionList"), + sessionListMeta: $("sessionListMeta"), toast: $("toast"), }; @@ -102,10 +113,115 @@ async function api(path, options = {}) { return data; } +async function apiForm(path, formData) { + const response = await fetch(path, { + method: "POST", + body: formData, + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data.error || `HTTP ${response.status}`); + } + return data; +} + function rowTouched(row) { return Boolean(row.edited || row.marked || row.comment || row.learn_note || row.issue_type || row.severity || row.tags); } +function resetReviewState() { + rows = []; + filteredRows = []; + structure = { chapters: [] }; + currentIndex = -1; + currentRow = null; + currentChapter = null; + currentPart = null; + showTouchedOnly = false; + dirty = false; + quickDirty = false; + dom.showTouchedBtn.classList.remove("primary"); + dom.searchInput.value = ""; + if (dom.quickEditor) { + dom.quickEditor.hidden = true; + } + document.body.classList.remove("quickOpen"); +} + +function setShellMode(active) { + hasSession = active; + dom.startScreen.hidden = active; + dom.reviewLayout.hidden = !active; + dom.readingModeBtn.disabled = !active; + dom.polishModeBtn.disabled = !active; + dom.openLibraryBtn.disabled = false; + dom.showTouchedBtn.disabled = !active; + dom.feedbackBtn.disabled = !active; + dom.applyBtn.disabled = !active; + dom.exportBtn.disabled = !active; + document.body.classList.toggle("startOpen", !active); + if (!active) { + dom.sessionMeta.textContent = "未打开 EPUB"; + } +} + +function formatDateTime(value) { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString("zh-CN", { hour12: false }); +} + +function renderSessions(sessions = [], activeId = "") { + dom.sessionList.innerHTML = ""; + dom.sessionListMeta.textContent = sessions.length ? `${sessions.length} 个会话` : "暂无会话"; + if (!sessions.length) { + dom.sessionList.innerHTML = '
还没有审校会话。上传 EPUB 后会自动创建。
'; + return; + } + const frag = document.createDocumentFragment(); + sessions.forEach((session) => { + const card = document.createElement("article"); + card.className = "sessionItem"; + if (session.id === activeId) card.classList.add("active"); + card.innerHTML = ` +
+

${escapeHtml(session.source_name || session.id)}

+

${escapeHtml(session.session_root || "")}

+
+ ${session.row_count || 0} 段 + 记录 ${session.touched_count || 0} + 标记 ${session.marked_count || 0} + ${session.updated_at ? `${escapeHtml(formatDateTime(session.updated_at))}` : ""} +
+
+ + `; + card.querySelector("button").addEventListener("click", () => openSession(session.id)); + frag.appendChild(card); + }); + dom.sessionList.appendChild(frag); +} + +async function refreshSessions() { + const data = await api("/api/sessions"); + renderSessions(data.sessions || [], data.active_session_id || ""); +} + +async function showStartScreen(sessionPayload = null) { + resetReviewState(); + setShellMode(false); + if (sessionPayload && Array.isArray(sessionPayload.sessions)) { + renderSessions(sessionPayload.sessions, ""); + } else { + try { + await refreshSessions(); + } catch (error) { + dom.sessionList.innerHTML = `
读取会话失败:${escapeHtml(error.message)}
`; + } + } +} + function rowCurrentText(row) { return stripTags(row.current_html || row.cn_html || row.cn_text || ""); } @@ -181,9 +297,16 @@ function scopeVisibleRows() { async function loadSession() { const session = await api("/api/session"); + if (!session.has_session) { + await showStartScreen(session); + return session; + } + setShellMode(true); const chapterCount = structure.chapter_count || (structure.chapters || []).length; const partCount = structure.part_count || allParts().length; - dom.sessionMeta.textContent = `${session.source_epub} · ${session.row_count} 段 · ${chapterCount} 章 / ${partCount} part · 已记录 ${session.touched_count} 段`; + const sourceName = session.source_name || session.source_epub; + dom.sessionMeta.textContent = `${sourceName} · ${session.row_count} 段 · ${chapterCount} 章 / ${partCount} part · 已记录 ${session.touched_count} 段`; + return session; } async function loadStructure() { @@ -220,6 +343,55 @@ async function loadRows(keepSelection = false) { renderAll(); } +async function loadActiveSession(options = {}) { + resetReviewState(); + currentMode = "reading"; + setShellMode(true); + await loadStructure(); + await loadRows(Boolean(options.keepSelection)); + await loadSession(); + await refreshSessions().catch(() => {}); + if (options.message) toast(options.message, 4200); +} + +async function uploadEpub(event) { + event.preventDefault(); + const file = dom.epubFileInput.files && dom.epubFileInput.files[0]; + if (!file) { + toast("请先选择一个 EPUB 文件。"); + return; + } + const formData = new FormData(); + formData.append("epub", file); + dom.uploadBtn.disabled = true; + dom.uploadBtn.textContent = "正在解析……"; + try { + await apiForm("/api/upload", formData); + dom.epubFileInput.value = ""; + dom.selectedFileName.textContent = "尚未选择文件"; + await loadActiveSession({ message: "EPUB 已载入,可以开始审校。" }); + } catch (error) { + toast(`上传失败:${error.message}`, 9000); + } finally { + dom.uploadBtn.disabled = false; + dom.uploadBtn.textContent = "上传并开始审校"; + } +} + +async function openSession(sessionId) { + if (!sessionId) return; + if (hasSession && !(await maybeSaveBeforeSwitch())) return; + try { + await api("/api/session/open", { + method: "POST", + body: JSON.stringify({ session_id: sessionId }), + }); + await loadActiveSession({ message: "已打开审校会话。" }); + } catch (error) { + toast(`打开会话失败:${error.message}`, 9000); + } +} + function updateStructureCountsFromRows() { for (const chapter of structure.chapters || []) { chapter.row_count = 0; @@ -267,6 +439,7 @@ function applyFilter(shouldRender = true) { } function renderAll() { + if (!hasSession) return; updateStructureCountsFromRows(); renderToc(); renderList(); @@ -544,6 +717,7 @@ async function saveRow(row, data, silent = false) { } async function saveCurrent(silent = false) { + if (!hasSession) return true; if (!currentRow) return true; return saveRow(currentRow, collectCurrent(), silent); } @@ -759,21 +933,25 @@ async function goPart(delta) { } async function generateFeedback() { + if (!hasSession) return; if (!(await maybeSaveBeforeSwitch())) return; const data = await api("/api/feedback"); - toast(`已生成反馈文件:\n${data.feedback_md}\n${data.feedback_jsonl}`, 9000); + const downloadText = data.feedback_md_url ? `\n可在当前浏览器下载:${data.feedback_md_url}` : ""; + toast(`已生成反馈文件:\n${data.feedback_md}\n${data.feedback_jsonl}${downloadText}`, 9000); } async function applyBack() { + if (!hasSession) return; if (!(await maybeSaveBeforeSwitch())) return; const data = await api("/api/apply", { method: "POST", body: "{}" }); toast(`已写回 EPUB 解包内容。\n修改文件数:${data.modified_files.length}\n反馈:${data.feedback_md}`, 9000); } async function exportEpub() { + if (!hasSession) return; if (!(await maybeSaveBeforeSwitch())) return; const data = await api("/api/export", { method: "POST", body: "{}" }); - toast(`已导出审校 EPUB:\n${data.output}`, 10000); + toast(`已导出审校 EPUB:\n${data.output}${data.download_url ? `\n可在当前浏览器下载:${data.download_url}` : ""}`, 10000); } function initEvents() { @@ -814,6 +992,10 @@ function initEvents() { }); dom.readingModeBtn.addEventListener("click", () => setMode("reading")); dom.polishModeBtn.addEventListener("click", () => setMode("polish")); + dom.openLibraryBtn.addEventListener("click", async () => { + if (hasSession && !(await maybeSaveBeforeSwitch())) return; + await showStartScreen(); + }); dom.prevPartBtn.addEventListener("click", () => goPart(-1)); dom.nextPartBtn.addEventListener("click", () => goPart(1)); dom.resetTextBtn.addEventListener("click", resetCurrentText); @@ -825,6 +1007,14 @@ function initEvents() { dom.feedbackBtn.addEventListener("click", generateFeedback); dom.applyBtn.addEventListener("click", applyBack); dom.exportBtn.addEventListener("click", exportEpub); + dom.uploadForm.addEventListener("submit", uploadEpub); + dom.epubFileInput.addEventListener("change", () => { + const file = dom.epubFileInput.files && dom.epubFileInput.files[0]; + dom.selectedFileName.textContent = file ? file.name : "尚未选择文件"; + }); + dom.refreshSessionsBtn.addEventListener("click", () => { + refreshSessions().catch((error) => toast(`刷新失败:${error.message}`, 7000)); + }); dom.closeQuickEditorBtn.addEventListener("click", () => closeQuickEditor(false)); dom.quickSaveBtn.addEventListener("click", () => saveCurrent(false)); dom.quickMarkSelectionBtn.addEventListener("click", markQuickSelection); @@ -875,12 +1065,14 @@ function initEvents() { async function main() { initEvents(); try { - await loadStructure(); - await loadRows(); - await loadSession(); - toast("阅读模式已就绪。可连续阅读,点击段落即可快速编辑或标记。", 4200); + const session = await loadSession(); + if (!session || !session.has_session) { + return; + } + await loadActiveSession({ message: "阅读模式已就绪。可连续阅读,点击段落即可快速编辑或标记。" }); } catch (error) { - toast(`启动失败:${error.message}`, 10000); + await showStartScreen(); + toast(`启动时未载入 EPUB:${error.message}`, 7000); } } diff --git a/tools/epub_review_editor/static/index.html b/tools/epub_review_editor/static/index.html index 5a80ac2..a218105 100644 --- a/tools/epub_review_editor/static/index.html +++ b/tools/epub_review_editor/static/index.html @@ -17,6 +17,7 @@
+ @@ -24,7 +25,36 @@
-
+
+
+
+
+

打开中日双语 EPUB

+

上传一个“日文灰字 + 中文译文紧随其后”的双语 EPUB,审校记录会保存在本机或服务器的审校目录中。

+
+ +
+ +
+ + +
+ +
+
+

已有审校会话

+ +
+
+
+
+
+ +