Files
-----/tools/epub_review_editor/server.py
T
2026-07-06 20:43:21 +08:00

1027 lines
36 KiB
Python

from __future__ import annotations
import argparse
import atexit
import copy
import datetime as dt
import hashlib
import html
import json
import os
import posixpath
import re
import shutil
import signal
import socket
import subprocess
import sys
import threading
import time
import urllib.parse
import urllib.request
import webbrowser
import zipfile
from html.parser import HTMLParser
from pathlib import Path
from typing import Any
import xml.etree.ElementTree as ET
from flask import Flask, jsonify, request, send_from_directory
P_RE = re.compile(r"(<p\b[^>]*>)(.*?)(</p>)", re.S | re.I)
TAG_RE = re.compile(r"<[^>]+>")
JP_RE = re.compile(r"[\u3040-\u30ff\u3400-\u9fff々〆〤]")
KANA_RE = re.compile(r"[\u3040-\u30ff]")
GRAY_RE = re.compile(
r"(?:color\s*:\s*(?:#?808080|gray|grey|rgb\(\s*128\s*,\s*128\s*,\s*128\s*\))|class\s*=\s*['\"][^'\"]*(?:gray|grey|jp|source)[^'\"]*['\"])",
re.I,
)
DEFAULT_REVIEW_ROOT = "epub_review_sessions"
STATE_VERSION = 1
def now_iso() -> str:
return dt.datetime.now(dt.timezone.utc).astimezone().isoformat(timespec="seconds")
def safe_slug(value: str, max_len: int = 80) -> str:
value = re.sub(r'[\\/:*?"<>|]+', "_", value).strip().strip(".")
value = re.sub(r"\s+", "_", value)
return value[:max_len] or "epub"
def find_free_port(start: int) -> 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))
except OSError:
continue
return port
raise RuntimeError(f"no free port found from {start}")
def read_json(path: Path, default: Any) -> Any:
if not path.exists():
return copy.deepcopy(default)
return json.loads(path.read_text(encoding="utf-8-sig"))
def write_json(path: Path, data: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def append_jsonl(path: Path, record: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8", newline="\n") as fh:
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
def strip_tags(fragment: str) -> str:
fragment = re.sub(r"<rt\b[^>]*>.*?</rt>", "", fragment, flags=re.S | re.I)
return html.unescape(TAG_RE.sub("", fragment)).strip()
def compact_text(value: str) -> str:
return re.sub(r"\s+", " ", html.unescape(value or "")).strip()
def file_sort_key(value: str) -> list[Any]:
parts = re.split(r"(\d+)", value.lower())
return [int(part) if part.isdigit() else part for part in parts]
def entry_dir(entry_name: str) -> str:
directory = posixpath.dirname(entry_name)
return f"{directory}/" if directory else ""
def normalize_href(base_dir: str, href: str) -> str:
href = urllib.parse.unquote((href or "").split("#", 1)[0])
if not href:
return ""
return posixpath.normpath(posixpath.join(base_dir, href)).lstrip("./")
def read_xml_root(path: Path) -> ET.Element | None:
if not path.exists():
return None
try:
return ET.fromstring(path.read_text(encoding="utf-8", errors="replace"))
except ET.ParseError:
try:
return ET.fromstring(path.read_bytes())
except ET.ParseError:
return None
def local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1].lower()
def element_text(elem: ET.Element) -> str:
return compact_text("".join(elem.itertext()))
def is_japaneseish_fragment(fragment: str) -> bool:
return bool(KANA_RE.search(fragment or ""))
def is_japaneseish(fragment: str) -> bool:
text = strip_tags(fragment)
return bool(KANA_RE.search(text))
def is_gray_source(attrs: str, inner: str) -> bool:
if GRAY_RE.search(attrs):
return True
return is_japaneseish(inner)
class HtmlSanitizer(HTMLParser):
allowed_tags = {
"a",
"b",
"br",
"code",
"em",
"i",
"mark",
"rb",
"rp",
"rt",
"ruby",
"small",
"span",
"strong",
"sub",
"sup",
}
allowed_attrs = {
"a": {"href", "title"},
"mark": {"class", "data-review-mark"},
"span": {"class", "style", "data-review-mark"},
"ruby": {"class"},
"rt": {"class"},
"rb": {"class"},
"rp": {"class"},
}
void_tags = {"br"}
style_safe = re.compile(r"^(?:\s*(?:color|background-color|font-weight|font-style)\s*:\s*[-#(),.%\w\s]+\s*;?)*$", re.I)
def __init__(self) -> None:
super().__init__(convert_charrefs=False)
self.parts: list[str] = []
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
tag = tag.lower()
if tag not in self.allowed_tags:
return
safe_attrs = []
for key, value in attrs:
key = key.lower()
value = value or ""
if key not in self.allowed_attrs.get(tag, set()):
continue
if key.startswith("on"):
continue
if key == "href" and re.match(r"\s*(?:javascript|data):", value, re.I):
continue
if key == "style" and not self.style_safe.fullmatch(value):
continue
safe_attrs.append(f'{key}="{html.escape(value, quote=True)}"')
attr_text = (" " + " ".join(safe_attrs)) if safe_attrs else ""
if tag in self.void_tags:
self.parts.append(f"<{tag}{attr_text}/>")
else:
self.parts.append(f"<{tag}{attr_text}>")
def handle_endtag(self, tag: str) -> None:
tag = tag.lower()
if tag in self.allowed_tags and tag not in self.void_tags:
self.parts.append(f"</{tag}>")
def handle_data(self, data: str) -> None:
self.parts.append(html.escape(data, quote=False))
def handle_entityref(self, name: str) -> None:
self.parts.append(f"&{name};")
def handle_charref(self, name: str) -> None:
self.parts.append(f"&#{name};")
def sanitize_cn_html(value: str) -> str:
value = re.sub(r"(?is)<\s*(script|style|iframe|object|embed)\b.*?</\s*\1\s*>", "", value)
sanitizer = HtmlSanitizer()
sanitizer.feed(value)
sanitizer.close()
return "".join(sanitizer.parts).strip()
def session_root_for(review_root: Path, epub_path: Path) -> Path:
digest = hashlib.sha1(str(epub_path.resolve()).encode("utf-8")).hexdigest()[:10]
return review_root / f"{safe_slug(epub_path.stem)}_{digest}"
def extracted_path(session_root: Path, entry_name: str) -> Path:
root = (session_root / "extracted").resolve()
target = (root / entry_name).resolve()
try:
target.relative_to(root)
except ValueError as exc:
raise ValueError(f"unsafe epub entry path: {entry_name}") from exc
return target
def extract_epub(epub_path: Path, session_root: Path, reset: bool = False) -> None:
extracted = session_root / "extracted"
if reset and session_root.exists():
shutil.rmtree(session_root)
if extracted.exists():
return
session_root.mkdir(parents=True, exist_ok=True)
shutil.copy2(epub_path, session_root / "source.epub")
extracted.mkdir(parents=True, exist_ok=True)
entries = []
with zipfile.ZipFile(epub_path, "r") as zf:
for info in zf.infolist():
entries.append(
{
"filename": info.filename,
"compress_type": int(info.compress_type),
"date_time": list(info.date_time),
"is_dir": info.is_dir(),
}
)
target = extracted_path(session_root, info.filename)
if info.is_dir():
target.mkdir(parents=True, exist_ok=True)
continue
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(zf.read(info.filename))
write_json(session_root / "review_state" / "epub_entries.json", entries)
def find_opf_entry(session_root: Path) -> str:
extracted = session_root / "extracted"
container = extracted / "META-INF" / "container.xml"
root = read_xml_root(container)
if root is not None:
for elem in root.iter():
if local_name(elem.tag) == "rootfile":
full_path = elem.attrib.get("full-path") or elem.attrib.get("full_path")
if full_path:
return posixpath.normpath(full_path).lstrip("./")
entries = read_json(session_root / "review_state" / "epub_entries.json", [])
for item in entries:
name = item.get("filename", "")
if not item.get("is_dir") and name.lower().endswith(".opf"):
return name
return ""
def parse_opf_metadata(session_root: Path) -> dict[str, Any]:
opf_entry = find_opf_entry(session_root)
if not opf_entry:
return {"opf_entry": "", "opf_dir": "", "spine": []}
opf_path = extracted_path(session_root, opf_entry)
root = read_xml_root(opf_path)
if root is None:
return {"opf_entry": opf_entry, "opf_dir": entry_dir(opf_entry), "spine": []}
opf_dir = entry_dir(opf_entry)
manifest: dict[str, dict[str, str]] = {}
spine_idrefs: list[str] = []
for elem in root.iter():
name = local_name(elem.tag)
if name == "item":
item_id = elem.attrib.get("id", "")
href = elem.attrib.get("href", "")
if item_id and href:
manifest[item_id] = {
"href": normalize_href(opf_dir, href),
"media_type": elem.attrib.get("media-type", ""),
"properties": elem.attrib.get("properties", ""),
}
elif name == "itemref":
idref = elem.attrib.get("idref", "")
if idref:
spine_idrefs.append(idref)
spine = []
for idref in spine_idrefs:
item = manifest.get(idref)
if not item:
continue
href = item["href"]
if href.lower().endswith((".xhtml", ".html", ".htm")):
spine.append(href)
return {
"opf_entry": opf_entry,
"opf_dir": opf_dir,
"manifest": manifest,
"spine": spine,
}
def html_entries_in_reading_order(session_root: Path) -> list[str]:
entries = read_json(session_root / "review_state" / "epub_entries.json", [])
all_html = [
item["filename"]
for item in entries
if not item.get("is_dir")
and item["filename"].lower().endswith((".xhtml", ".html", ".htm"))
]
opf = parse_opf_metadata(session_root)
ordered: list[str] = []
for item in opf.get("spine", []):
if item in all_html and item not in ordered:
ordered.append(item)
for item in sorted(all_html, key=file_sort_key):
basename = posixpath.basename(item).lower()
if item in ordered or basename in {"nav.xhtml", "toc.xhtml"}:
continue
ordered.append(item)
return ordered
def extract_heading_from_html(text: str) -> str:
for pattern in (
r"<h[1-6]\b[^>]*>(.*?)</h[1-6]>",
r"<title\b[^>]*>(.*?)</title>",
):
match = re.search(pattern, text, flags=re.S | re.I)
if not match:
continue
title = strip_tags(match.group(1))
if title:
return title
return ""
def read_document_title(session_root: Path, entry_name: str) -> str:
path = extracted_path(session_root, entry_name)
if not path.exists():
return ""
text = path.read_text(encoding="utf-8", errors="replace")
return extract_heading_from_html(text)
def parse_nav_toc(session_root: Path) -> list[dict[str, Any]]:
entries = read_json(session_root / "review_state" / "epub_entries.json", [])
nav_entries = [
item["filename"]
for item in entries
if not item.get("is_dir")
and posixpath.basename(item["filename"]).lower() in {"nav.xhtml", "toc.xhtml", "toc.html", "toc.htm"}
]
opf = parse_opf_metadata(session_root)
for manifest_item in opf.get("manifest", {}).values():
if "nav" in manifest_item.get("properties", "").split():
href = manifest_item.get("href", "")
if href and href not in nav_entries:
nav_entries.insert(0, href)
for entry_name in nav_entries:
path = extracted_path(session_root, entry_name)
root = read_xml_root(path)
if root is None:
continue
base_dir = entry_dir(entry_name)
toc_root: ET.Element | None = None
for elem in root.iter():
if local_name(elem.tag) != "nav":
continue
nav_type = elem.attrib.get("{http://www.idpf.org/2007/ops}type", "") or elem.attrib.get("epub:type", "")
if "toc" in nav_type.split() or toc_root is None:
toc_root = elem
if "toc" in nav_type.split():
break
if toc_root is None:
continue
top_ol = next((child for child in list(toc_root) if local_name(child.tag) == "ol"), None)
if top_ol is None:
top_ol = next((elem for elem in toc_root.iter() if local_name(elem.tag) == "ol"), None)
if top_ol is None:
continue
items = parse_nav_ol(top_ol, base_dir)
if items:
return items
return []
def parse_nav_ol(ol: ET.Element, base_dir: str) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
for li in list(ol):
if local_name(li.tag) != "li":
continue
label = ""
href = ""
children: list[dict[str, Any]] = []
for child in list(li):
name = local_name(child.tag)
if name == "a":
label = element_text(child)
href = normalize_href(base_dir, child.attrib.get("href", ""))
elif name == "span" and not label:
label = element_text(child)
elif name == "ol":
children = parse_nav_ol(child, base_dir)
if not label:
label = element_text(li)
items.append({"title": label, "href": href, "children": children})
return items
def flatten_nav_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
flattened: list[dict[str, Any]] = []
def walk(nodes: list[dict[str, Any]], depth: int) -> None:
for node in nodes:
href = node.get("href", "")
if href:
flattened.append({"title": node.get("title") or href, "href": href, "depth": depth})
walk(node.get("children", []), depth + 1)
walk(items, 0)
return flattened
def build_structure(session_root: Path) -> dict[str, Any]:
rows = merge_rows(session_root)
rows_by_file: dict[str, list[dict[str, Any]]] = {}
for row in rows:
rows_by_file.setdefault(row["file"], []).append(row)
ordered_files = [file for file in html_entries_in_reading_order(session_root) if file in rows_by_file]
for file in rows_by_file:
if file not in ordered_files:
ordered_files.append(file)
nav_tree = parse_nav_toc(session_root)
nav_flat = flatten_nav_items(nav_tree)
nav_titles: dict[str, str] = {}
for item in nav_flat:
href = item.get("href", "")
title = item.get("title", "")
if href and title and href not in nav_titles:
nav_titles[href] = title
part_serial = 0
def file_counts(file_rows: list[dict[str, Any]]) -> tuple[int, int]:
touched_count = sum(
1
for row in file_rows
if row["edited"]
or row["marked"]
or row["comment"]
or row["learn_note"]
or row["issue_type"]
or row["severity"]
or row["tags"]
)
marked_count = sum(1 for row in file_rows if row["marked"])
return touched_count, marked_count
def make_part(entry_name: str, title_override: str = "") -> dict[str, Any]:
nonlocal part_serial
part_serial += 1
file_rows = rows_by_file[entry_name]
title = (
title_override
or nav_titles.get(entry_name)
or read_document_title(session_root, entry_name)
or Path(entry_name).stem
)
touched_count, marked_count = file_counts(file_rows)
return {
"id": f"PT{part_serial:04d}",
"title": title,
"file": entry_name,
"file_label": Path(entry_name).name,
"row_ids": [row["id"] for row in file_rows],
"row_count": len(file_rows),
"touched_count": touched_count,
"marked_count": marked_count,
"first_row_id": file_rows[0]["id"] if file_rows else "",
"last_row_id": file_rows[-1]["id"] if file_rows else "",
}
def chapter_from_parts(chapter_id: str, title: str, parts: list[dict[str, Any]]) -> dict[str, Any]:
return {
"id": chapter_id,
"title": title,
"row_count": sum(part["row_count"] for part in parts),
"touched_count": sum(part["touched_count"] for part in parts),
"marked_count": sum(part["marked_count"] for part in parts),
"parts": parts,
}
def flatten_node_files(node: dict[str, Any]) -> list[dict[str, str]]:
items: list[dict[str, str]] = []
href = node.get("href", "")
if href in rows_by_file:
items.append({"href": href, "title": node.get("title") or ""})
for child in node.get("children", []):
items.extend(flatten_node_files(child))
return items
chapters: list[dict[str, Any]] = []
assigned_files: set[str] = set()
chapter_serial = 0
chapter_nodes = nav_tree
while (
len(chapter_nodes) == 1
and chapter_nodes[0].get("href") not in rows_by_file
and chapter_nodes[0].get("children")
):
chapter_nodes = chapter_nodes[0]["children"]
for node in chapter_nodes:
node_files = []
seen_in_node: set[str] = set()
for item in flatten_node_files(node):
href = item["href"]
if href in assigned_files or href in seen_in_node:
continue
seen_in_node.add(href)
node_files.append(item)
if not node_files:
continue
chapter_serial += 1
parts = [make_part(item["href"], item.get("title", "")) for item in node_files]
assigned_files.update(item["href"] for item in node_files)
chapter_title = node.get("title") or parts[0]["title"]
chapters.append(chapter_from_parts(f"C{chapter_serial:04d}", chapter_title, parts))
for entry_name in ordered_files:
if entry_name in assigned_files:
continue
chapter_serial += 1
part = make_part(entry_name)
chapters.append(chapter_from_parts(f"C{chapter_serial:04d}", part["title"], [part]))
return {
"chapters": chapters,
"chapter_count": len(chapters),
"part_count": sum(len(chapter["parts"]) for chapter in chapters),
"row_count": len(rows),
}
def build_rows(session_root: Path) -> list[dict[str, Any]]:
html_entries = html_entries_in_reading_order(session_root)
rows: list[dict[str, Any]] = []
serial = 0
for file_order, entry_name in enumerate(html_entries, start=1):
path = extracted_path(session_root, entry_name)
if not path.exists():
continue
document_title = read_document_title(session_root, entry_name) or Path(entry_name).stem
text = path.read_text(encoding="utf-8", errors="replace")
matches = list(P_RE.finditer(text))
i = 0
file_serial = 0
while i < len(matches) - 1:
open_tag = matches[i].group(1)
attrs = open_tag[2:-1]
jp_inner = matches[i].group(2)
cn_inner = matches[i + 1].group(2)
next_open = matches[i + 1].group(1)
next_attrs = next_open[2:-1]
if is_gray_source(attrs, jp_inner) and not is_gray_source(next_attrs, cn_inner):
serial += 1
file_serial += 1
rows.append(
{
"id": f"R{serial:05d}",
"file": entry_name,
"file_order": file_order,
"file_row_index": file_serial,
"file_label": Path(entry_name).name,
"document_title": document_title,
"ja_p_index": i,
"cn_p_index": i + 1,
"jp_html": jp_inner,
"jp_text": strip_tags(jp_inner),
"cn_html": cn_inner,
"cn_text": strip_tags(cn_inner),
}
)
i += 2
else:
i += 1
write_json(session_root / "review_state" / "rows.json", rows)
return rows
def ensure_state(session_root: Path, epub_path: Path, reset: bool = False) -> dict[str, Any]:
extract_epub(epub_path, session_root, reset=reset)
rows_path = session_root / "review_state" / "rows.json"
rows = read_json(rows_path, None)
if rows is None:
rows = build_rows(session_root)
state_path = session_root / "review_state" / "state.json"
state = read_json(
state_path,
{
"version": STATE_VERSION,
"created_at": now_iso(),
"updated_at": now_iso(),
"source_epub": str(epub_path),
"edits": {},
},
)
state.setdefault("edits", {})
write_json(state_path, state)
return state
def merge_rows(session_root: Path) -> list[dict[str, Any]]:
rows = read_json(session_root / "review_state" / "rows.json", [])
state = read_json(session_root / "review_state" / "state.json", {"edits": {}})
edits = state.get("edits", {})
merged = []
for row in rows:
item = dict(row)
edit = edits.get(row["id"], {})
item["current_html"] = edit.get("current_html", row["cn_html"])
item["marked"] = bool(edit.get("marked", False))
item["issue_type"] = edit.get("issue_type", "")
item["severity"] = edit.get("severity", "")
item["comment"] = edit.get("comment", "")
item["learn_note"] = edit.get("learn_note", "")
item["tags"] = edit.get("tags", "")
item["edited"] = item["current_html"] != row["cn_html"]
item["updated_at"] = edit.get("updated_at", "")
merged.append(item)
return merged
def write_feedback_summary(session_root: Path) -> Path:
rows = merge_rows(session_root)
marked = [
row
for row in rows
if row["edited"]
or row["marked"]
or row["comment"]
or row["learn_note"]
or row["issue_type"]
or row["severity"]
or row["tags"]
]
out = session_root / "feedback" / "feedback_for_codex.md"
lines = [
"# 双语 EPUB 审校反馈",
"",
f"- 更新时间:{now_iso()}",
f"- 有记录段落:{len(marked)}",
"",
"这些记录用于之后优化翻译、润色、术语和角色口吻。`修改前` 来自初始双语 EPUB,`修改后` 来自网页编辑器当前保存内容。",
"",
]
for row in marked:
lines.extend(
[
f"## {row['id']} · {row['file_label']} · P{row['cn_p_index']}",
"",
f"- 文件:`{row['file']}`",
f"- 问题类型:{row['issue_type'] or '未填写'}",
f"- 严重程度:{row['severity'] or '未填写'}",
f"- 标签:{row['tags'] or '无'}",
f"- 备注:{row['comment'] or '无'}",
f"- 可沉淀规则:{row['learn_note'] or '无'}",
"",
"日文原文:",
"",
row["jp_text"],
"",
"修改前:",
"",
strip_tags(row["cn_html"]),
"",
"修改后:",
"",
strip_tags(row["current_html"]),
"",
]
)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text("\n".join(lines), encoding="utf-8")
return out
def apply_edits_to_xhtml(session_root: Path) -> list[str]:
rows = merge_rows(session_root)
edited_rows = [row for row in rows if row["edited"]]
by_file: dict[str, list[dict[str, Any]]] = {}
for row in edited_rows:
by_file.setdefault(row["file"], []).append(row)
modified = []
for entry_name, items in by_file.items():
path = extracted_path(session_root, entry_name)
text = path.read_text(encoding="utf-8", errors="replace")
matches = list(P_RE.finditer(text))
replace_by_index = {int(row["cn_p_index"]): row["current_html"] for row in items}
parts: list[str] = []
last = 0
for index, match in enumerate(matches):
parts.append(text[last:match.start()])
if index in replace_by_index:
parts.append(match.group(1))
parts.append(replace_by_index[index])
parts.append(match.group(3))
else:
parts.append(match.group(0))
last = match.end()
parts.append(text[last:])
path.write_text("".join(parts), encoding="utf-8")
modified.append(entry_name)
write_feedback_summary(session_root)
return modified
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"))
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"
with zipfile.ZipFile(out_path, "w") as zf:
names_written: set[str] = set()
mimetype = extracted_path(session_root, "mimetype")
if mimetype.exists():
zf.write(mimetype, "mimetype", compress_type=zipfile.ZIP_STORED)
names_written.add("mimetype")
for entry in entries:
name = entry["filename"]
if name in names_written:
continue
path = extracted_path(session_root, name)
if not path.exists():
continue
compress = zipfile.ZIP_STORED if name == "mimetype" else zipfile.ZIP_DEFLATED
if entry.get("is_dir"):
info = zipfile.ZipInfo(name)
zf.writestr(info, b"")
else:
zf.write(path, name, compress_type=compress)
names_written.add(name)
append_jsonl(
session_root / "feedback" / "translation_feedback.jsonl",
{"ts": now_iso(), "event": "export_epub", "output": str(out_path)},
)
return out_path
def create_app(session_root: Path, epub_path: Path) -> 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
app.config["LOCK"] = threading.Lock()
@app.route("/")
def index():
return send_from_directory(app.static_folder, "index.html")
@app.route("/api/session")
def api_session():
rows = merge_rows(session_root)
touched = [row for row in rows if row["edited"] or row["marked"] or row["comment"] or row["learn_note"]]
return jsonify(
{
"source_epub": str(epub_path),
"session_root": str(session_root),
"row_count": len(rows),
"touched_count": len(touched),
"feedback_md": str(session_root / "feedback" / "feedback_for_codex.md"),
"feedback_jsonl": str(session_root / "feedback" / "translation_feedback.jsonl"),
}
)
@app.route("/api/rows")
def api_rows():
return jsonify({"rows": merge_rows(session_root)})
@app.route("/api/structure")
def api_structure():
return jsonify(build_structure(session_root))
@app.route("/api/row/<row_id>", methods=["POST"])
def api_save_row(row_id: str):
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)
if base is None:
return jsonify({"error": "row not found"}), 404
current_html = sanitize_cn_html(str(data.get("current_html", "")))
edit = {
"current_html": current_html,
"marked": bool(data.get("marked", False)),
"issue_type": str(data.get("issue_type", ""))[:80],
"severity": str(data.get("severity", ""))[:40],
"comment": str(data.get("comment", ""))[:4000],
"learn_note": str(data.get("learn_note", ""))[:4000],
"tags": str(data.get("tags", ""))[:500],
"updated_at": now_iso(),
}
with app.config["LOCK"]:
state_path = session_root / "review_state" / "state.json"
state = read_json(state_path, {"edits": {}})
state.setdefault("edits", {})[row_id] = edit
state["updated_at"] = now_iso()
write_json(state_path, state)
append_jsonl(
session_root / "feedback" / "translation_feedback.jsonl",
{
"ts": edit["updated_at"],
"event": "row_saved",
"row_id": row_id,
"file": base["file"],
"cn_p_index": base["cn_p_index"],
"edited": current_html != base["cn_html"],
"marked": edit["marked"],
"issue_type": edit["issue_type"],
"severity": edit["severity"],
"comment": edit["comment"],
"learn_note": edit["learn_note"],
"tags": edit["tags"],
"jp_text": base["jp_text"],
"before_cn_text": strip_tags(base["cn_html"]),
"after_cn_text": strip_tags(current_html),
"before_cn_html": base["cn_html"],
"after_cn_html": current_html,
},
)
return jsonify({"status": "ok", "row_id": row_id, "updated_at": edit["updated_at"]})
@app.route("/api/apply", methods=["POST"])
def api_apply():
with app.config["LOCK"]:
modified = apply_edits_to_xhtml(session_root)
return jsonify(
{
"status": "ok",
"modified_files": modified,
"feedback_md": str(session_root / "feedback" / "feedback_for_codex.md"),
}
)
@app.route("/api/export", methods=["POST"])
def api_export():
with app.config["LOCK"]:
out_path = export_epub(session_root)
return jsonify({"status": "ok", "output": str(out_path)})
@app.route("/api/feedback")
def api_feedback():
path = write_feedback_summary(session_root)
return jsonify(
{
"status": "ok",
"feedback_md": str(path),
"feedback_jsonl": str(session_root / "feedback" / "translation_feedback.jsonl"),
}
)
@app.route("/api/shutdown", methods=["POST"])
def api_shutdown():
def stop() -> None:
time.sleep(0.3)
os._exit(0)
threading.Thread(target=stop, daemon=True).start()
return jsonify({"status": "ok"})
return app
def wait_until_ready(url: str, proc: subprocess.Popen, timeout: int = 15) -> bool:
deadline = time.time() + timeout
while time.time() < deadline:
if proc.poll() is not None:
return False
try:
with urllib.request.urlopen(f"{url}/api/session", timeout=1) as response:
if response.status == 200:
return True
except OSError:
time.sleep(0.25)
return False
def run_daemon(args: argparse.Namespace) -> int:
port = find_free_port(args.port)
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"
cmd = [
sys.executable,
str(Path(__file__).resolve()),
str(epub_path),
"--review-root",
str(review_root),
"--port",
str(port),
]
if args.no_browser:
cmd.append("--no-browser")
creationflags = 0
popen_kwargs: dict[str, Any] = {}
if os.name == "nt":
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
else:
popen_kwargs["start_new_session"] = True
with log_path.open("a", encoding="utf-8") as log:
proc = subprocess.Popen(
cmd,
stdin=subprocess.DEVNULL,
stdout=log,
stderr=subprocess.STDOUT,
creationflags=creationflags,
**popen_kwargs,
)
url = f"http://localhost:{port}"
if not wait_until_ready(url, proc):
print(f"review editor failed to start; see {log_path}", file=sys.stderr)
return 1
if not args.no_browser:
try:
webbrowser.open(url)
except Exception:
pass
print(url)
print(f"session: {session_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("--review-root", default=DEFAULT_REVIEW_ROOT, help="Directory for review sessions.")
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.")
parser.add_argument("--reset", action="store_true", help="Re-extract the EPUB and discard prior review state.")
return parser
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
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}"
if not args.no_browser:
try:
webbrowser.open(url)
except Exception:
pass
def on_sigterm(_signum: int, _frame: Any) -> None:
sys.exit(0)
try:
signal.signal(signal.SIGTERM, on_sigterm)
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)
return 0
if __name__ == "__main__":
raise SystemExit(main())