2 Commits

Author SHA1 Message Date
xin61 c14e3cb3d8 Add editor version metadata 2026-07-06 21:37:34 +08:00
xin61 7da060f827 Add standalone EPUB upload workflow 2026-07-06 21:28:45 +08:00
7 changed files with 870 additions and 47 deletions
+63 -1
View File
@@ -1,9 +1,48 @@
# 双语 EPUB 审校编辑器
当前版本:`0.2.0`
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
## 版本规则
维护版本号使用 `version = "0.1.0"` 这种语义化格式:
| 层级 | 触发条件 |
| --- | --- |
| PATCH | 修复 bug 或兼容性小修 |
| MINOR | 新增向后兼容能力、API 字段或可选配置 |
| MAJOR | 破坏兼容的 API、配置或行为变更 |
本次从 `0.1.0` 升到 `0.2.0`,因为新增了独立上传、会话管理、下载接口与可选 `--host` 配置,且保持原命令行 EPUB 路径用法兼容。
## 独立安装
这个审校器现在可以脱离 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 +54,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 +84,13 @@ http://localhost:5177
## 重要输出
每个 EPUB 会建立独立审校 session,默认位于:
每个 EPUB 会建立独立审校 session。通过浏览器上传时,默认位于:
```text
epub_review_sessions\<timestamp>_<epub-name>_<hash>\
```
通过命令行直接指定 EPUB 时,仍沿用固定路径:
```text
epub_review_sessions\<epub-name>_<hash>\
@@ -44,6 +104,8 @@ epub_review_sessions\<epub-name>_<hash>\
- `review_state/state.json`:网页编辑器当前状态。
- `extracted/`EPUB 解包后的工作副本。
在服务器上使用时,“生成反馈”和“导出审校 EPUB”会返回浏览器可访问的下载地址,不需要登录服务器找文件。
## 使用建议
- 正式长篇 EPUB 优先使用“阅读模式”:先在左侧点章节/part 连续读,看到问题再点段落快速编辑或标记。
@@ -0,0 +1 @@
Flask>=3.0,<4.0
+378 -36
View File
@@ -1,7 +1,6 @@
from __future__ import annotations
import argparse
import atexit
import copy
import datetime as dt
import hashlib
@@ -28,6 +27,16 @@ import xml.etree.ElementTree as ET
from flask import Flask, jsonify, request, send_from_directory
try:
from version import VERSION_RULES, version
except ImportError:
VERSION_RULES = {
"PATCH": "修复 bug 或兼容性小修",
"MINOR": "新增向后兼容能力、API 字段或可选配置",
"MAJOR": "破坏兼容的 API、配置或行为变更",
}
version = "0.2.0"
P_RE = re.compile(r"(<p\b[^>]*>)(.*?)(</p>)", re.S | re.I)
TAG_RE = re.compile(r"<[^>]+>")
@@ -40,6 +49,7 @@ GRAY_RE = re.compile(
DEFAULT_REVIEW_ROOT = "epub_review_sessions"
STATE_VERSION = 1
UPLOAD_DIR_NAME = "uploads"
def now_iso() -> str:
@@ -52,18 +62,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 +242,148 @@ 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),
"version": version,
"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 +796,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 +910,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 +944,164 @@ 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["APP_VERSION"] = version
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,
"version": version,
"version_rules": VERSION_RULES,
"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,
"version": version,
"version_rules": VERSION_RULES,
"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,
"version": version,
"version_rules": VERSION_RULES,
"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/version")
def api_version():
return jsonify({"version": version, "version_rules": VERSION_RULES})
@app.route("/api/sessions")
def api_sessions():
session_root, _epub_path = active_session()
return jsonify(
{
"review_root": str(review_root),
"version": version,
"version_rules": VERSION_RULES,
"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/<row_id>", 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 +1150,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 +1165,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/<path:filename>")
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/<path:filename>")
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 +1237,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 +1289,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 +1299,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 +1321,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 +1353,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
+208 -8
View File
@@ -9,13 +9,18 @@ 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"),
appVersion: $("appVersion"),
sessionMeta: $("sessionMeta"),
readingModeBtn: $("readingModeBtn"),
polishModeBtn: $("polishModeBtn"),
openLibraryBtn: $("openLibraryBtn"),
showTouchedBtn: $("showTouchedBtn"),
feedbackBtn: $("feedbackBtn"),
applyBtn: $("applyBtn"),
@@ -64,6 +69,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 +114,121 @@ 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 setVersionBadge(value) {
if (!value) return;
dom.appVersion.textContent = `v${value}`;
}
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 = '<div class="emptySession">还没有审校会话。上传 EPUB 后会自动创建。</div>';
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 = `
<div class="sessionInfo">
<h3>${escapeHtml(session.source_name || session.id)}</h3>
<p>${escapeHtml(session.session_root || "")}</p>
<div class="sessionFacts">
<span>${session.row_count || 0} 段</span>
<span>记录 ${session.touched_count || 0}</span>
<span>标记 ${session.marked_count || 0}</span>
${session.updated_at ? `<span>${escapeHtml(formatDateTime(session.updated_at))}</span>` : ""}
</div>
</div>
<button type="button" data-session-id="${escapeHtml(session.id)}">${session.id === activeId ? "继续审校" : "打开"}</button>
`;
card.querySelector("button").addEventListener("click", () => openSession(session.id));
frag.appendChild(card);
});
dom.sessionList.appendChild(frag);
}
async function refreshSessions() {
const data = await api("/api/sessions");
setVersionBadge(data.version);
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 = `<div class="emptySession">读取会话失败:${escapeHtml(error.message)}</div>`;
}
}
}
function rowCurrentText(row) {
return stripTags(row.current_html || row.cn_html || row.cn_text || "");
}
@@ -181,9 +304,17 @@ function scopeVisibleRows() {
async function loadSession() {
const session = await api("/api/session");
setVersionBadge(session.version);
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 +351,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 +447,7 @@ function applyFilter(shouldRender = true) {
}
function renderAll() {
if (!hasSession) return;
updateStructureCountsFromRows();
renderToc();
renderList();
@@ -544,6 +725,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 +941,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 +1000,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 +1015,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 +1073,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);
}
}
+32 -2
View File
@@ -9,7 +9,7 @@
<body>
<header class="topbar">
<div class="brandBlock">
<h1>双语 EPUB 审校编辑器</h1>
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.2.0</span></h1>
<p id="sessionMeta">正在载入……</p>
</div>
<div class="modeTabs" role="tablist" aria-label="审校模式">
@@ -17,6 +17,7 @@
<button id="polishModeBtn" class="modeButton" type="button">精修模式</button>
</div>
<div class="toolbar">
<button id="openLibraryBtn" type="button">打开 EPUB</button>
<button id="showTouchedBtn" type="button">仅看已记录</button>
<button id="feedbackBtn" type="button">生成反馈</button>
<button id="applyBtn" type="button">写回 EPUB 内容</button>
@@ -24,7 +25,36 @@
</div>
</header>
<main class="layout">
<main class="startScreen" id="startScreen" hidden>
<section class="startPanel">
<div class="startHeader">
<div>
<h2>打开中日双语 EPUB</h2>
<p>上传一个“日文灰字 + 中文译文紧随其后”的双语 EPUB,审校记录会保存在本机或服务器的审校目录中。</p>
</div>
<button id="refreshSessionsBtn" type="button">刷新会话</button>
</div>
<form class="uploadBox" id="uploadForm">
<label class="fileDrop" for="epubFileInput">
<span class="fileDropTitle">选择 EPUB 文件</span>
<span class="fileDropName" id="selectedFileName">尚未选择文件</span>
<input id="epubFileInput" type="file" accept=".epub,application/epub+zip">
</label>
<button id="uploadBtn" class="primary" type="submit">上传并开始审校</button>
</form>
<section class="sessionBrowser">
<div class="sectionTitle compactTitle">
<h2>已有审校会话</h2>
<span id="sessionListMeta"></span>
</div>
<div class="sessionList" id="sessionList"></div>
</section>
</section>
</main>
<main class="layout" id="reviewLayout" hidden>
<aside class="sidebar">
<div class="searchbox">
<input id="searchInput" type="search" placeholder="搜索原文、译文、备注">
+181
View File
@@ -58,6 +58,20 @@ h1 {
font-weight: 700;
}
.versionBadge {
display: inline-flex;
align-items: center;
min-height: 20px;
margin-left: 6px;
padding: 0 6px;
border-radius: 6px;
background: #e8edf2;
color: var(--muted);
font-size: 12px;
font-weight: 600;
vertical-align: middle;
}
#sessionMeta {
margin: 2px 0 0;
color: var(--muted);
@@ -109,6 +123,11 @@ button:hover {
border-color: var(--accent);
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
button.primary,
#saveBtn,
#quickSaveBtn,
@@ -125,6 +144,158 @@ button.primary:hover,
background: var(--accent-strong);
}
.startScreen {
min-height: calc(100vh - 78px);
padding: 28px;
background: var(--bg);
}
.startPanel {
max-width: 980px;
margin: 0 auto;
display: grid;
gap: 16px;
}
.startHeader,
.uploadBox,
.sessionBrowser {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 18px;
}
.startHeader {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.startHeader h2 {
margin: 0 0 4px;
font-size: 21px;
}
.startHeader p {
margin: 0;
color: var(--muted);
max-width: 680px;
}
.uploadBox {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: stretch;
}
.fileDrop {
min-width: 0;
display: grid;
gap: 4px;
border: 1px dashed #aeb8c4;
border-radius: 8px;
padding: 14px;
background: #f8fafc;
cursor: pointer;
}
.fileDrop:hover {
border-color: var(--accent);
}
.fileDrop input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.fileDrop:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(29, 111, 95, 0.12);
}
.fileDropTitle {
font-weight: 700;
}
.fileDropName {
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.compactTitle {
margin-bottom: 12px;
}
.compactTitle span {
color: var(--muted);
font-size: 12px;
}
.sessionList {
display: grid;
gap: 10px;
}
.sessionItem {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
border: 1px solid var(--line-soft);
border-radius: 8px;
padding: 12px;
background: var(--panel-soft);
}
.sessionItem.active {
border-color: var(--accent);
background: var(--accent-soft);
}
.sessionInfo {
min-width: 0;
}
.sessionInfo h3 {
margin: 0 0 2px;
font-size: 15px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sessionInfo p {
margin: 0 0 6px;
color: var(--muted);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sessionFacts {
display: flex;
flex-wrap: wrap;
gap: 8px;
color: var(--muted);
font-size: 12px;
}
.emptySession {
color: var(--muted);
border: 1px dashed var(--line);
border-radius: 8px;
padding: 18px;
text-align: center;
background: #f8fafc;
}
.layout {
height: calc(100vh - 78px);
display: grid;
@@ -721,4 +892,14 @@ textarea {
width: auto;
max-height: 72vh;
}
.startScreen {
padding: 14px;
}
.startHeader,
.uploadBox,
.sessionItem {
grid-template-columns: 1fr;
}
}
+7
View File
@@ -0,0 +1,7 @@
version = "0.2.0"
VERSION_RULES = {
"PATCH": "修复 bug 或兼容性小修",
"MINOR": "新增向后兼容能力、API 字段或可选配置",
"MAJOR": "破坏兼容的 API、配置或行为变更",
}