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

1345 lines
48 KiB
Python

from __future__ import annotations
import argparse
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
UPLOAD_DIR_NAME = "uploads"
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, 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((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)
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 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()
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", {})
state["source_epub"] = str(epub_path)
write_json(state_path, state)
write_session_manifest(session_root, epub_path)
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", [])
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(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")
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(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",
)
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 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/<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)
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():
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(
{
"status": "ok",
"modified_files": modified,
"feedback_md": str(session_root / "feedback" / "feedback_for_codex.md"),
}
)
@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),
"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:
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:
bind_host = display_host(args.host)
port = find_free_port(args.port, bind_host)
review_root = Path(args.review_root).resolve()
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()),
"--review-root",
str(review_root),
"--host",
args.host,
"--port",
str(port),
]
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":
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://{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
if not args.no_browser:
try:
webbrowser.open(url)
except Exception:
pass
print(url)
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", 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.")
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 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()
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)
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"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
if __name__ == "__main__":
raise SystemExit(main())