commit e29ec6be9efdbb34841f919e374485c8208239a5 Author: xin61 Date: Mon Jul 6 20:43:21 2026 +0800 Initial bilingual EPUB review editor diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a544198 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# This repository is scoped to the bilingual EPUB review editor. +# Keep translation corpora, EPUBs, review sessions, and generated outputs out of Git. +* + +!.gitignore +!tools/ +tools/* +!tools/epub_review_editor/ +!tools/epub_review_editor/** + +# Editor runtime/cache artifacts +tools/epub_review_editor/**/__pycache__/ +tools/epub_review_editor/**/*.py[cod] +tools/epub_review_editor/.pytest_cache/ +tools/epub_review_editor/.ruff_cache/ +tools/epub_review_editor/.mypy_cache/ +tools/epub_review_editor/.venv/ +tools/epub_review_editor/venv/ +tools/epub_review_editor/epub_review_sessions/ +tools/epub_review_editor/review_sessions/ +tools/epub_review_editor/**/*.log +tools/epub_review_editor/**/*.epub diff --git a/tools/epub_review_editor/.gitignore b/tools/epub_review_editor/.gitignore new file mode 100644 index 0000000..7848ba5 --- /dev/null +++ b/tools/epub_review_editor/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +.venv/ +venv/ +epub_review_sessions/ +review_sessions/ +*.log +*.epub diff --git a/tools/epub_review_editor/README.md b/tools/epub_review_editor/README.md new file mode 100644 index 0000000..cdd7b9d --- /dev/null +++ b/tools/epub_review_editor/README.md @@ -0,0 +1,60 @@ +# 双语 EPUB 审校编辑器 + +这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。 + +## 启动 + +```powershell +$env:PYTHONIOENCODING='utf-8' +python 'C:\Users\xin61\Documents\轻小说翻译持续改进\tools\epub_review_editor\server.py' '<中日双语版.epub>' --daemon +``` + +默认会自动打开浏览器。如果没有自动打开,命令会打印 URL,例如: + +```text +http://localhost:5177 +``` + +## 能做什么 + +- 阅读模式下按章节连续阅读日文原文与中文译文,不需要逐句点击。 +- 左侧目录按 EPUB nav/toc 或 spine 文件顺序组织章节;章节下显示内部 part,点击 part 后只看该 part,点击章节则看整章连续正文。 +- 在阅读模式中点击段落即可打开快速审校面板,直接编辑中文译文或标记问题。 +- 精修模式保留原有逐段编辑界面,用于较细的逐段复审。 +- 直接编辑中文译文。 +- 标记问题段落或选中文本。 +- 记录问题类型、严重程度、标签、备注和可沉淀的长期规则。 +- 生成反馈文件,供 Codex 后续持续优化翻译和润色质量。 +- 将编辑写回 EPUB 解包内容。 +- 导出一份新的“网页审校版 EPUB”。 + +## 重要输出 + +每个 EPUB 会建立独立审校 session,默认位于: + +```text +epub_review_sessions\_\ +``` + +关键文件: + +- `feedback/translation_feedback.jsonl`:逐次保存的结构化反馈记录。 +- `feedback/feedback_for_codex.md`:适合直接给 Codex 阅读的审校反馈摘要。 +- `exports/*_网页审校版_*.epub`:导出的修订版 EPUB。 +- `review_state/state.json`:网页编辑器当前状态。 +- `extracted/`:EPUB 解包后的工作副本。 + +## 使用建议 + +- 正式长篇 EPUB 优先使用“阅读模式”:先在左侧点章节/part 连续读,看到问题再点段落快速编辑或标记。 +- 需要集中逐段复审时切到“精修模式”,左侧段落列表会跟随当前章节筛选。 +- 只想记录问题时:勾选“标记为翻译问题”,写备注,保存本段。 +- 想直接修正译文时:编辑中文译文后保存本段。 +- 想让 Codex 学习你的偏好时:把“可沉淀为长期规则”写具体,例如“Falchion 固定译为大砍刀,不要译成偃月刀”。 +- 审校结束后点击“生成反馈”,再在聊天里告诉 Codex 读取 `feedback_for_codex.md` 和 `translation_feedback.jsonl`。 + +## 当前限制 + +- 当前解析规则针对本项目的“日文灰字 + 中文紧随其后”的双语 EPUB。 +- 目录用于审校导航,不负责可视化修改 EPUB 的图片、目录和 OPF 元数据。 +- 浏览器内保存的是审校工作副本;要生成 EPUB 文件,需要点击“导出审校 EPUB”。 diff --git a/tools/epub_review_editor/server.py b/tools/epub_review_editor/server.py new file mode 100644 index 0000000..c2f920b --- /dev/null +++ b/tools/epub_review_editor/server.py @@ -0,0 +1,1026 @@ +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"(]*>)(.*?)(

)", 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"]*>.*?", "", 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"") + + 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.*?", "", 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"]*>(.*?)", + r"]*>(.*?)", + ): + 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/", 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()) diff --git a/tools/epub_review_editor/static/app.js b/tools/epub_review_editor/static/app.js new file mode 100644 index 0000000..9cd6530 --- /dev/null +++ b/tools/epub_review_editor/static/app.js @@ -0,0 +1,887 @@ +let rows = []; +let filteredRows = []; +let structure = { chapters: [] }; +let currentIndex = -1; +let currentRow = null; +let currentChapter = null; +let currentPart = null; +let currentMode = "reading"; +let showTouchedOnly = false; +let dirty = false; +let quickDirty = false; + +const $ = (id) => document.getElementById(id); + +const dom = { + sessionMeta: $("sessionMeta"), + readingModeBtn: $("readingModeBtn"), + polishModeBtn: $("polishModeBtn"), + showTouchedBtn: $("showTouchedBtn"), + feedbackBtn: $("feedbackBtn"), + applyBtn: $("applyBtn"), + exportBtn: $("exportBtn"), + searchInput: $("searchInput"), + stats: $("stats"), + toc: $("toc"), + rowList: $("rowList"), + readerPane: $("readerPane"), + editorPane: $("editorPane"), + chapterKicker: $("chapterKicker"), + readerTitle: $("readerTitle"), + readingFlow: $("readingFlow"), + prevPartBtn: $("prevPartBtn"), + nextPartBtn: $("nextPartBtn"), + emptyState: $("emptyState"), + editorCard: $("editorCard"), + rowId: $("rowId"), + rowPath: $("rowPath"), + markedInput: $("markedInput"), + jpText: $("jpText"), + cnEditor: $("cnEditor"), + markSelectionBtn: $("markSelectionBtn"), + clearMarksBtn: $("clearMarksBtn"), + resetTextBtn: $("resetTextBtn"), + issueTypeInput: $("issueTypeInput"), + severityInput: $("severityInput"), + tagsInput: $("tagsInput"), + commentInput: $("commentInput"), + learnNoteInput: $("learnNoteInput"), + prevBtn: $("prevBtn"), + saveBtn: $("saveBtn"), + nextBtn: $("nextBtn"), + quickEditor: $("quickEditor"), + quickRowId: $("quickRowId"), + quickRowPath: $("quickRowPath"), + quickJpText: $("quickJpText"), + quickCnEditor: $("quickCnEditor"), + quickMarkedInput: $("quickMarkedInput"), + quickIssueTypeInput: $("quickIssueTypeInput"), + quickSeverityInput: $("quickSeverityInput"), + quickTagsInput: $("quickTagsInput"), + quickCommentInput: $("quickCommentInput"), + quickLearnNoteInput: $("quickLearnNoteInput"), + closeQuickEditorBtn: $("closeQuickEditorBtn"), + quickMarkSelectionBtn: $("quickMarkSelectionBtn"), + quickSaveBtn: $("quickSaveBtn"), + quickOpenFullBtn: $("quickOpenFullBtn"), + toast: $("toast"), +}; + +function escapeHtml(value) { + return String(value || "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function stripTags(value) { + const div = document.createElement("div"); + div.innerHTML = value || ""; + return div.textContent || div.innerText || ""; +} + +function toast(message, ms = 3600) { + dom.toast.textContent = message; + dom.toast.hidden = false; + clearTimeout(toast.timer); + toast.timer = setTimeout(() => { + dom.toast.hidden = true; + }, ms); +} + +async function api(path, options = {}) { + const response = await fetch(path, { + headers: { "Content-Type": "application/json", ...(options.headers || {}) }, + ...options, + }); + 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 rowCurrentText(row) { + return stripTags(row.current_html || row.cn_html || row.cn_text || ""); +} + +function rowById(rowId) { + return rows.find((item) => item.id === rowId) || null; +} + +function allParts() { + return (structure.chapters || []).flatMap((chapter) => chapter.parts || []); +} + +function allChapterScopes() { + return (structure.chapters || []).map((chapter) => ({ + type: "chapter", + id: chapter.id, + chapter, + })); +} + +function partById(partId) { + return allParts().find((part) => part.id === partId) || null; +} + +function chapterById(chapterId) { + return (structure.chapters || []).find((chapter) => chapter.id === chapterId) || null; +} + +function partRows(part) { + if (!part) return []; + const ids = new Set(part.row_ids || []); + return rows.filter((row) => ids.has(row.id)); +} + +function partForRow(row) { + if (!row) return null; + return allParts().find((part) => (part.row_ids || []).includes(row.id)) || null; +} + +function chapterForPart(part) { + if (!part) return null; + return (structure.chapters || []).find((chapter) => (chapter.parts || []).some((item) => item.id === part.id)) || null; +} + +function chapterRows(chapter) { + if (!chapter) return []; + const ids = new Set((chapter.parts || []).flatMap((part) => part.row_ids || [])); + return rows.filter((row) => ids.has(row.id)); +} + +function partVisibleRows(part) { + const ids = new Set((part && part.row_ids) || []); + return filteredRows.filter((row) => ids.has(row.id)); +} + +function chapterVisibleRows(chapter) { + if (!chapter) return []; + const ids = new Set((chapter.parts || []).flatMap((part) => part.row_ids || [])); + return filteredRows.filter((row) => ids.has(row.id)); +} + +function scopeRows() { + if (currentPart) return partRows(currentPart); + if (currentChapter) return chapterRows(currentChapter); + return rows; +} + +function scopeVisibleRows() { + if (currentPart) return partVisibleRows(currentPart); + if (currentChapter) return chapterVisibleRows(currentChapter); + return filteredRows; +} + +async function loadSession() { + const session = await api("/api/session"); + 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} 段`; +} + +async function loadStructure() { + structure = await api("/api/structure"); +} + +async function loadRows(keepSelection = false) { + const oldId = currentRow && currentRow.id; + const payload = await api("/api/rows"); + rows = payload.rows || []; + applyFilter(false); + + if (keepSelection && oldId) { + const nextRow = rowById(oldId); + if (nextRow) { + currentRow = nextRow; + currentIndex = filteredRows.findIndex((row) => row.id === nextRow.id); + if (currentPart && !(currentPart.row_ids || []).includes(nextRow.id)) { + currentPart = partForRow(nextRow); + } + renderAll(); + return; + } + } + + if (!currentPart) { + currentChapter = (structure.chapters || [])[0] || null; + currentPart = currentChapter && (currentChapter.parts || []).length === 1 ? currentChapter.parts[0] : null; + } + if (filteredRows.length > 0 && currentIndex < 0) { + currentRow = filteredRows[0]; + currentIndex = 0; + } + renderAll(); +} + +function updateStructureCountsFromRows() { + for (const chapter of structure.chapters || []) { + chapter.row_count = 0; + chapter.touched_count = 0; + chapter.marked_count = 0; + for (const part of chapter.parts || []) { + const partItems = partRows(part); + part.row_count = partItems.length; + part.touched_count = partItems.filter(rowTouched).length; + part.marked_count = partItems.filter((row) => row.marked).length; + chapter.row_count += part.row_count; + chapter.touched_count += part.touched_count; + chapter.marked_count += part.marked_count; + } + } +} + +function rowMatchesSearch(row, q) { + if (showTouchedOnly && !rowTouched(row)) return false; + if (!q) return true; + const haystack = [ + row.id, + row.file_label, + row.document_title, + row.jp_text, + row.cn_text, + rowCurrentText(row), + row.comment, + row.learn_note, + row.tags, + row.issue_type, + ] + .join("\n") + .toLowerCase(); + return haystack.includes(q); +} + +function applyFilter(shouldRender = true) { + const q = dom.searchInput.value.trim().toLowerCase(); + filteredRows = rows.filter((row) => rowMatchesSearch(row, q)); + if (currentRow) { + currentIndex = filteredRows.findIndex((row) => row.id === currentRow.id); + } + if (shouldRender) renderAll(); +} + +function renderAll() { + updateStructureCountsFromRows(); + renderToc(); + renderList(); + renderMode(); + if (currentMode === "reading") { + renderReading(); + } else { + renderEditor(); + } +} + +function renderMode() { + dom.readerPane.hidden = currentMode !== "reading"; + dom.editorPane.hidden = currentMode !== "polish"; + dom.readingModeBtn.classList.toggle("active", currentMode === "reading"); + dom.polishModeBtn.classList.toggle("active", currentMode === "polish"); + document.body.classList.toggle("quickOpen", currentMode === "reading" && !dom.quickEditor.hidden); +} + +function renderToc() { + dom.toc.innerHTML = ""; + const frag = document.createDocumentFragment(); + for (const chapter of structure.chapters || []) { + const group = document.createElement("section"); + group.className = "tocChapter"; + + const title = document.createElement("button"); + title.type = "button"; + title.className = "tocChapterButton"; + if ( + (currentChapter && chapter.id === currentChapter.id && !currentPart) + || (chapter.parts || []).some((part) => currentPart && part.id === currentPart.id) + ) { + title.classList.add("active"); + } + title.innerHTML = ` + ${escapeHtml(chapter.title || chapter.id)} + ${chapter.row_count || 0} 段 + `; + title.addEventListener("click", () => selectChapter(chapter)); + group.appendChild(title); + + const partList = document.createElement("div"); + partList.className = "tocParts"; + for (const part of chapter.parts || []) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "tocPartButton"; + if (currentPart && part.id === currentPart.id) button.classList.add("active"); + const badges = []; + if (part.touched_count) badges.push(`记 ${part.touched_count}`); + if (part.marked_count) badges.push(`标 ${part.marked_count}`); + button.innerHTML = ` + ${escapeHtml(part.title || part.file_label || part.id)} + ${part.row_count || 0}${badges.length ? " · " + escapeHtml(badges.join(" · ")) : ""} + `; + button.addEventListener("click", () => selectPart(part)); + partList.appendChild(button); + } + group.appendChild(partList); + frag.appendChild(group); + } + dom.toc.appendChild(frag); +} + +function renderList() { + const touched = rows.filter(rowTouched).length; + const marked = rows.filter((row) => row.marked).length; + const visibleInScope = scopeVisibleRows(); + dom.stats.textContent = `显示 ${filteredRows.length} / ${rows.length} 段 · 当前 ${visibleInScope.length} 段 · 已记录 ${touched} · 标记 ${marked}`; + dom.rowList.innerHTML = ""; + + const frag = document.createDocumentFragment(); + visibleInScope.forEach((row) => { + const button = document.createElement("button"); + button.type = "button"; + button.className = "rowItem"; + if (currentRow && row.id === currentRow.id) button.classList.add("active"); + if (rowTouched(row)) button.classList.add("touched"); + if (row.marked) button.classList.add("marked"); + + const badge = row.marked ? "已标记" : row.edited ? "已编辑" : row.comment ? "有备注" : "未记录"; + button.innerHTML = ` +
+ ${escapeHtml(row.id)} + ${escapeHtml(badge)} + ${escapeHtml(row.file_label)} · #${row.file_row_index || row.cn_p_index} +
+
${escapeHtml(rowCurrentText(row))}
+ `; + button.addEventListener("click", () => maybeSelectRow(row, { openEditor: currentMode === "polish", scroll: true })); + frag.appendChild(button); + }); + dom.rowList.appendChild(frag); +} + +function renderReading() { + const selectedChapter = currentChapter || chapterForPart(currentPart); + if (!currentPart && !selectedChapter) { + dom.chapterKicker.textContent = "目录"; + dom.readerTitle.textContent = "选择章节开始阅读"; + dom.readingFlow.innerHTML = '
没有可阅读段落。
'; + return; + } + + const selectedPart = currentPart; + dom.chapterKicker.textContent = selectedChapter ? selectedChapter.title : selectedPart.file_label || ""; + dom.readerTitle.textContent = selectedPart + ? selectedPart.title || selectedPart.file_label || "未命名 part" + : selectedChapter.title || "未命名章节"; + dom.readingFlow.innerHTML = ""; + + const visibleRows = scopeVisibleRows(); + const sourceRows = visibleRows.length || dom.searchInput.value || showTouchedOnly ? visibleRows : scopeRows(); + if (!sourceRows.length) { + dom.readingFlow.innerHTML = '
当前筛选条件下没有段落。
'; + return; + } + + const frag = document.createDocumentFragment(); + let lastFile = ""; + sourceRows.forEach((row) => { + if (!selectedPart && row.file !== lastFile) { + const part = partForRow(row); + if (part) { + const divider = document.createElement("h3"); + divider.className = "partDivider"; + divider.textContent = part.title || part.file_label || row.file; + frag.appendChild(divider); + } + lastFile = row.file; + } + const block = document.createElement("section"); + block.className = "readBlock"; + block.dataset.rowId = row.id; + if (currentRow && row.id === currentRow.id) block.classList.add("active"); + if (rowTouched(row)) block.classList.add("touched"); + if (row.marked) block.classList.add("marked"); + + const status = row.marked ? "已标记" : row.edited ? "已编辑" : row.comment ? "有备注" : ""; + block.innerHTML = ` +
+
${row.jp_html || escapeHtml(row.jp_text)}
+
${row.current_html || row.cn_html || escapeHtml(row.cn_text)}
+
+
+ ${escapeHtml(row.id)} · #${row.file_row_index || row.cn_p_index} + ${status ? `${escapeHtml(status)}` : ""} + + +
+ `; + block.addEventListener("click", (event) => { + const action = event.target && event.target.dataset && event.target.dataset.action; + if (action === "quick") { + event.stopPropagation(); + openQuickEditor(row); + return; + } + if (action === "mark") { + event.stopPropagation(); + markRowFromReading(row); + return; + } + maybeSelectRow(row, { openQuick: true, scroll: false }); + }); + frag.appendChild(block); + }); + dom.readingFlow.appendChild(frag); +} + +function renderEditor() { + if (!currentRow) { + dom.emptyState.hidden = false; + dom.editorCard.hidden = true; + return; + } + dom.emptyState.hidden = true; + dom.editorCard.hidden = false; + fillFullEditor(currentRow); +} + +function fillFullEditor(row) { + dom.rowId.textContent = row.id; + dom.rowPath.textContent = `${row.file} · 日文 P${row.ja_p_index} / 中文 P${row.cn_p_index}`; + dom.markedInput.checked = Boolean(row.marked); + dom.jpText.innerHTML = row.jp_html || escapeHtml(row.jp_text); + dom.cnEditor.innerHTML = row.current_html || row.cn_html || ""; + dom.issueTypeInput.value = row.issue_type || ""; + dom.severityInput.value = row.severity || ""; + dom.tagsInput.value = row.tags || ""; + dom.commentInput.value = row.comment || ""; + dom.learnNoteInput.value = row.learn_note || ""; +} + +function fillQuickEditor(row) { + dom.quickRowId.textContent = row.id; + dom.quickRowPath.textContent = `${row.file_label} · #${row.file_row_index || row.cn_p_index}`; + dom.quickJpText.innerHTML = row.jp_html || escapeHtml(row.jp_text); + dom.quickCnEditor.innerHTML = row.current_html || row.cn_html || ""; + dom.quickMarkedInput.checked = Boolean(row.marked); + dom.quickIssueTypeInput.value = row.issue_type || ""; + dom.quickSeverityInput.value = row.severity || ""; + dom.quickTagsInput.value = row.tags || ""; + dom.quickCommentInput.value = row.comment || ""; + dom.quickLearnNoteInput.value = row.learn_note || ""; + quickDirty = false; +} + +function collectCurrent() { + if (!currentRow) return null; + if (currentMode === "reading" && !dom.quickEditor.hidden) { + return collectQuick(); + } + return collectFull(); +} + +function collectFull() { + return { + current_html: dom.cnEditor.innerHTML, + marked: dom.markedInput.checked, + issue_type: dom.issueTypeInput.value, + severity: dom.severityInput.value, + tags: dom.tagsInput.value, + comment: dom.commentInput.value, + learn_note: dom.learnNoteInput.value, + }; +} + +function collectQuick() { + return { + current_html: dom.quickCnEditor.innerHTML, + marked: dom.quickMarkedInput.checked, + issue_type: dom.quickIssueTypeInput.value, + severity: dom.quickSeverityInput.value, + tags: dom.quickTagsInput.value, + comment: dom.quickCommentInput.value, + learn_note: dom.quickLearnNoteInput.value, + }; +} + +function applyRowData(row, data, updatedAt = "") { + row.current_html = data.current_html; + row.marked = data.marked; + row.issue_type = data.issue_type; + row.severity = data.severity; + row.tags = data.tags; + row.comment = data.comment; + row.learn_note = data.learn_note; + row.edited = data.current_html !== row.cn_html; + row.updated_at = updatedAt || row.updated_at || ""; +} + +async function saveRow(row, data, silent = false) { + if (!row) return true; + try { + const result = await api(`/api/row/${encodeURIComponent(row.id)}`, { + method: "POST", + body: JSON.stringify(data), + }); + const target = rowById(row.id); + if (target) applyRowData(target, data, result.updated_at); + currentRow = target || row; + dirty = false; + quickDirty = false; + applyFilter(false); + renderAll(); + await loadSession(); + if (!silent) toast("已保存本段记录。"); + return true; + } catch (error) { + toast(`保存失败:${error.message}`, 7000); + return false; + } +} + +async function saveCurrent(silent = false) { + if (!currentRow) return true; + return saveRow(currentRow, collectCurrent(), silent); +} + +async function maybeSaveBeforeSwitch() { + if (currentMode === "reading" && quickDirty && currentRow && !dom.quickEditor.hidden) { + return saveCurrent(true); + } + if (currentMode === "polish" && dirty && currentRow) { + return saveCurrent(true); + } + return true; +} + +async function maybeSelectRow(row, options = {}) { + const ok = await maybeSaveBeforeSwitch(); + if (!ok) return; + selectRow(row, options); +} + +function selectRow(row, options = {}) { + if (!row) return; + currentRow = row; + currentIndex = filteredRows.findIndex((item) => item.id === row.id); + const nextPart = partForRow(row); + if (nextPart) { + currentChapter = chapterForPart(nextPart); + currentPart = nextPart; + } + dirty = false; + + if (options.openEditor) { + setMode("polish", false); + } else if (options.openQuick) { + openQuickEditor(row); + } else { + renderAll(); + } + + if (options.scroll) scrollRowIntoView(row.id); +} + +async function selectChapter(chapter) { + if (!chapter) return; + const ok = await maybeSaveBeforeSwitch(); + if (!ok) return; + currentChapter = chapter; + currentPart = (chapter.parts || []).length === 1 ? chapter.parts[0] : null; + const visible = scopeVisibleRows(); + const fallback = scopeRows(); + currentRow = visible[0] || fallback[0] || currentRow; + currentIndex = currentRow ? filteredRows.findIndex((row) => row.id === currentRow.id) : -1; + renderAll(); + if (currentMode === "reading") { + dom.readerPane.scrollTo({ top: 0, behavior: "smooth" }); + } +} + +async function selectPart(part) { + if (!part) return; + const ok = await maybeSaveBeforeSwitch(); + if (!ok) return; + currentChapter = chapterForPart(part); + currentPart = part; + const visible = partVisibleRows(part); + const fallback = partRows(part); + currentRow = visible[0] || fallback[0] || currentRow; + currentIndex = currentRow ? filteredRows.findIndex((row) => row.id === currentRow.id) : -1; + renderAll(); + if (currentMode === "reading") { + dom.readerPane.scrollTo({ top: 0, behavior: "smooth" }); + } +} + +function scrollRowIntoView(rowId) { + if (currentMode === "reading") { + requestAnimationFrame(() => { + const target = dom.readingFlow.querySelector(`[data-row-id="${CSS.escape(rowId)}"]`); + if (target) target.scrollIntoView({ block: "center", behavior: "smooth" }); + }); + } +} + +async function setMode(mode, saveBefore = true) { + if (saveBefore) { + const ok = await maybeSaveBeforeSwitch(); + if (!ok) return; + } + currentMode = mode; + if (mode === "polish") { + closeQuickEditor(false); + if (!currentRow && filteredRows.length) currentRow = filteredRows[0]; + } + renderAll(); + if (mode === "reading" && currentRow) scrollRowIntoView(currentRow.id); +} + +function openQuickEditor(row) { + currentRow = row; + currentIndex = filteredRows.findIndex((item) => item.id === row.id); + const nextPart = partForRow(row); + if (nextPart) { + currentChapter = chapterForPart(nextPart); + currentPart = nextPart; + } + fillQuickEditor(row); + dom.quickEditor.hidden = false; + document.body.classList.add("quickOpen"); + renderAll(); +} + +function closeQuickEditor(save = false) { + if (save && quickDirty) { + saveCurrent(true); + return; + } + dom.quickEditor.hidden = true; + document.body.classList.remove("quickOpen"); + quickDirty = false; +} + +async function markRowFromReading(row) { + const ok = await maybeSaveBeforeSwitch(); + if (!ok) return; + const data = { + current_html: row.current_html || row.cn_html || "", + marked: true, + issue_type: row.issue_type || "翻译腔", + severity: row.severity || "", + tags: row.tags || "", + comment: row.comment || "", + learn_note: row.learn_note || "", + }; + await saveRow(row, data, false); + openQuickEditor(rowById(row.id) || row); +} + +function markDirty() { + dirty = true; +} + +function markQuickDirty() { + quickDirty = true; +} + +function resetCurrentText() { + if (!currentRow) return; + dom.cnEditor.innerHTML = currentRow.cn_html || ""; + markDirty(); +} + +function markSelectionInEditor(editor, markedInput, issueInput) { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + toast("请先在中文译文中选中要标记的文字。"); + return false; + } + const range = selection.getRangeAt(0); + if (!editor.contains(range.commonAncestorContainer)) { + toast("只能标记中文译文区域内的文字。"); + return false; + } + const mark = document.createElement("mark"); + mark.className = "review-mark"; + mark.setAttribute("data-review-mark", "bad-translation"); + try { + range.surroundContents(mark); + } catch (_error) { + const fragment = range.extractContents(); + mark.appendChild(fragment); + range.insertNode(mark); + } + selection.removeAllRanges(); + markedInput.checked = true; + if (!issueInput.value) issueInput.value = "翻译腔"; + return true; +} + +function markSelection() { + if (markSelectionInEditor(dom.cnEditor, dom.markedInput, dom.issueTypeInput)) markDirty(); +} + +function markQuickSelection() { + if (markSelectionInEditor(dom.quickCnEditor, dom.quickMarkedInput, dom.quickIssueTypeInput)) markQuickDirty(); +} + +function clearMarks() { + dom.cnEditor.querySelectorAll("mark, .review-mark, [data-review-mark]").forEach((node) => { + const parent = node.parentNode; + while (node.firstChild) parent.insertBefore(node.firstChild, node); + parent.removeChild(node); + parent.normalize(); + }); + markDirty(); +} + +async function go(delta) { + if (!filteredRows.length) return; + const index = currentIndex >= 0 ? currentIndex : filteredRows.findIndex((row) => currentRow && row.id === currentRow.id); + const next = Math.max(0, Math.min(filteredRows.length - 1, index + delta)); + if (next === index) return; + await maybeSelectRow(filteredRows[next], { openEditor: currentMode === "polish", openQuick: currentMode === "reading" && !dom.quickEditor.hidden, scroll: true }); +} + +async function goPart(delta) { + const scopes = allChapterScopes(); + if (!scopes.length) return; + const currentScopeId = currentChapter ? currentChapter.id : chapterForPart(currentPart)?.id; + const index = Math.max(0, scopes.findIndex((scope) => scope.id === currentScopeId)); + const next = Math.max(0, Math.min(scopes.length - 1, index + delta)); + if (next === index) return; + await selectChapter(scopes[next].chapter); +} + +async function generateFeedback() { + if (!(await maybeSaveBeforeSwitch())) return; + const data = await api("/api/feedback"); + toast(`已生成反馈文件:\n${data.feedback_md}\n${data.feedback_jsonl}`, 9000); +} + +async function applyBack() { + 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 (!(await maybeSaveBeforeSwitch())) return; + const data = await api("/api/export", { method: "POST", body: "{}" }); + toast(`已导出审校 EPUB:\n${data.output}`, 10000); +} + +function initEvents() { + [ + dom.markedInput, + dom.cnEditor, + dom.issueTypeInput, + dom.severityInput, + dom.tagsInput, + dom.commentInput, + dom.learnNoteInput, + ].forEach((el) => { + el.addEventListener("input", markDirty); + el.addEventListener("change", markDirty); + }); + + [ + dom.quickMarkedInput, + dom.quickCnEditor, + dom.quickIssueTypeInput, + dom.quickSeverityInput, + dom.quickTagsInput, + dom.quickCommentInput, + dom.quickLearnNoteInput, + ].forEach((el) => { + el.addEventListener("input", markQuickDirty); + el.addEventListener("change", markQuickDirty); + }); + + dom.searchInput.addEventListener("input", () => { + currentIndex = -1; + applyFilter(true); + }); + dom.showTouchedBtn.addEventListener("click", () => { + showTouchedOnly = !showTouchedOnly; + dom.showTouchedBtn.classList.toggle("primary", showTouchedOnly); + applyFilter(true); + }); + dom.readingModeBtn.addEventListener("click", () => setMode("reading")); + dom.polishModeBtn.addEventListener("click", () => setMode("polish")); + dom.prevPartBtn.addEventListener("click", () => goPart(-1)); + dom.nextPartBtn.addEventListener("click", () => goPart(1)); + dom.resetTextBtn.addEventListener("click", resetCurrentText); + dom.markSelectionBtn.addEventListener("click", markSelection); + dom.clearMarksBtn.addEventListener("click", clearMarks); + dom.saveBtn.addEventListener("click", () => saveCurrent(false)); + dom.prevBtn.addEventListener("click", () => go(-1)); + dom.nextBtn.addEventListener("click", () => go(1)); + dom.feedbackBtn.addEventListener("click", generateFeedback); + dom.applyBtn.addEventListener("click", applyBack); + dom.exportBtn.addEventListener("click", exportEpub); + dom.closeQuickEditorBtn.addEventListener("click", () => closeQuickEditor(false)); + dom.quickSaveBtn.addEventListener("click", () => saveCurrent(false)); + dom.quickMarkSelectionBtn.addEventListener("click", markQuickSelection); + dom.quickOpenFullBtn.addEventListener("click", async () => { + if (quickDirty) { + const ok = await saveCurrent(true); + if (!ok) return; + } + await setMode("polish", false); + }); + + document.addEventListener("keydown", async (event) => { + if (event.ctrlKey && event.key.toLowerCase() === "s") { + event.preventDefault(); + await saveCurrent(false); + return; + } + if (event.target && ["INPUT", "TEXTAREA"].includes(event.target.tagName)) return; + if (event.altKey && event.key === "ArrowLeft") { + event.preventDefault(); + go(-1); + } + if (event.altKey && event.key === "ArrowRight") { + event.preventDefault(); + go(1); + } + if (event.altKey && event.key === "ArrowUp") { + event.preventDefault(); + goPart(-1); + } + if (event.altKey && event.key === "ArrowDown") { + event.preventDefault(); + goPart(1); + } + if (event.key === "Escape" && !dom.quickEditor.hidden) { + event.preventDefault(); + closeQuickEditor(false); + } + }); + + window.addEventListener("beforeunload", (event) => { + if (!dirty && !quickDirty) return; + event.preventDefault(); + event.returnValue = ""; + }); +} + +async function main() { + initEvents(); + try { + await loadStructure(); + await loadRows(); + await loadSession(); + toast("阅读模式已就绪。可连续阅读,点击段落即可快速编辑或标记。", 4200); + } catch (error) { + toast(`启动失败:${error.message}`, 10000); + } +} + +main(); diff --git a/tools/epub_review_editor/static/index.html b/tools/epub_review_editor/static/index.html new file mode 100644 index 0000000..5a80ac2 --- /dev/null +++ b/tools/epub_review_editor/static/index.html @@ -0,0 +1,195 @@ + + + + + + 双语 EPUB 审校编辑器 + + + +
+
+

双语 EPUB 审校编辑器

+

正在载入……

+
+
+ + +
+
+ + + + +
+
+ +
+ + +
+
+
+
目录
+

选择章节开始阅读

+
+
+ + +
+
+
+
+ + +
+ + + + + + + diff --git a/tools/epub_review_editor/static/style.css b/tools/epub_review_editor/static/style.css new file mode 100644 index 0000000..2da0f39 --- /dev/null +++ b/tools/epub_review_editor/static/style.css @@ -0,0 +1,724 @@ +:root { + color-scheme: light; + --bg: #f4f6f8; + --panel: #ffffff; + --panel-soft: #f8fafc; + --ink: #222426; + --muted: #68707a; + --line: #d8dee6; + --line-soft: #e7edf3; + --accent: #1d6f5f; + --accent-strong: #164d43; + --accent-soft: #e3f0ec; + --warn: #a85f00; + --bad: #a43b31; + --source: #73777f; + --mark: #fff0a6; + --shadow: 0 18px 42px rgba(25, 34, 45, 0.18); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: "Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif; + font-size: 15px; + line-height: 1.65; +} + +button, +input, +select, +textarea { + font: inherit; +} + +.topbar { + height: 78px; + padding: 10px 18px; + display: grid; + grid-template-columns: minmax(260px, 1fr) auto minmax(320px, 1.15fr); + align-items: center; + gap: 14px; + background: #ffffff; + border-bottom: 1px solid var(--line); +} + +.brandBlock { + min-width: 0; +} + +h1 { + margin: 0; + font-size: 19px; + font-weight: 700; +} + +#sessionMeta { + margin: 2px 0 0; + color: var(--muted); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.modeTabs { + display: inline-grid; + grid-template-columns: 1fr 1fr; + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; + background: #f8fafc; +} + +.modeButton { + border: 0; + border-radius: 0; + background: transparent; + min-width: 86px; +} + +.modeButton.active { + background: var(--accent); + color: #ffffff; +} + +.toolbar { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +button { + border: 1px solid var(--line); + background: #ffffff; + color: var(--ink); + min-height: 34px; + padding: 5px 11px; + border-radius: 6px; + cursor: pointer; +} + +button:hover { + border-color: var(--accent); +} + +button.primary, +#saveBtn, +#quickSaveBtn, +#exportBtn { + background: var(--accent); + border-color: var(--accent); + color: #ffffff; +} + +button.primary:hover, +#saveBtn:hover, +#quickSaveBtn:hover, +#exportBtn:hover { + background: var(--accent-strong); +} + +.layout { + height: calc(100vh - 78px); + display: grid; + grid-template-columns: 340px minmax(0, 1fr); + min-width: 0; +} + +.sidebar { + border-right: 1px solid var(--line); + background: #f9fbfd; + min-height: 0; + display: flex; + flex-direction: column; +} + +.searchbox { + padding: 12px; + border-bottom: 1px solid var(--line); +} + +.searchbox input { + width: 100%; + min-height: 36px; + border: 1px solid var(--line); + border-radius: 6px; + padding: 6px 10px; + background: #ffffff; +} + +.stats { + padding: 8px 12px; + color: var(--muted); + font-size: 12px; + border-bottom: 1px solid var(--line); +} + +.toc { + max-height: 42%; + overflow: auto; + border-bottom: 1px solid var(--line); + padding: 8px 0; +} + +.tocChapter { + margin: 0 8px 6px; +} + +.tocChapterButton, +.tocPartButton { + width: 100%; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + text-align: left; + border: 0; + border-radius: 6px; + background: transparent; + padding: 7px 8px; +} + +.tocChapterButton { + font-weight: 700; +} + +.tocPartButton { + padding-left: 18px; + color: var(--muted); +} + +.tocChapterButton:hover, +.tocPartButton:hover { + background: #eef3f6; +} + +.tocChapterButton.active, +.tocPartButton.active { + background: var(--accent-soft); + color: var(--accent-strong); +} + +.tocTitle { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tocCount { + color: var(--muted); + font-weight: 400; + font-size: 12px; + white-space: nowrap; +} + +.rowList { + overflow: auto; + min-height: 0; + flex: 1; +} + +.rowItem { + width: 100%; + display: block; + text-align: left; + border: 0; + border-bottom: 1px solid #e6e0d6; + border-radius: 0; + background: transparent; + padding: 10px 12px; +} + +.rowItem:hover { + background: #eef3f6; +} + +.rowItem.active { + background: var(--accent-soft); + border-left: 4px solid var(--accent); + padding-left: 8px; +} + +.rowItem.touched .rowBadge { + background: #e9f5ee; + color: var(--accent-strong); +} + +.rowItem.marked .rowBadge { + background: #fff0d5; + color: var(--warn); +} + +.rowMeta { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 12px; + margin-bottom: 4px; +} + +.rowBadge { + display: inline-flex; + align-items: center; + min-height: 20px; + padding: 0 6px; + border-radius: 999px; + background: #e8edf2; +} + +.rowPreview { + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.readerPane, +.editorPane { + min-width: 0; + min-height: 0; + overflow: auto; +} + +.readerPane { + padding: 0; + background: #f4f6f8; +} + +.readerHeader { + position: sticky; + top: 0; + z-index: 5; + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + padding: 16px 28px; + background: rgba(255, 255, 255, 0.96); + border-bottom: 1px solid var(--line); +} + +.chapterKicker { + color: var(--muted); + font-size: 12px; + margin-bottom: 2px; +} + +#readerTitle { + margin: 0; + font-size: 20px; + line-height: 1.35; +} + +.readerTools { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +.readingFlow { + max-width: 920px; + margin: 0 auto; + padding: 24px 38px 90px; + background: #ffffff; + min-height: calc(100vh - 156px); + border-left: 1px solid #e7edf3; + border-right: 1px solid #e7edf3; +} + +.readBlock { + position: relative; + padding: 10px 0 14px; + border-bottom: 1px solid #e7edf3; +} + +.partDivider { + margin: 26px 0 14px; + padding: 12px 0 8px; + border-bottom: 2px solid var(--line); + color: var(--accent-strong); + font-size: 17px; +} + +.readBlock:hover { + background: linear-gradient(90deg, rgba(29, 111, 95, 0.06), transparent 38%); +} + +.readBlock.active { + box-shadow: inset 4px 0 0 var(--accent); + padding-left: 12px; +} + +.readBlock.marked { + box-shadow: inset 4px 0 0 var(--warn); + padding-left: 12px; +} + +.readSource { + color: var(--source); + font-size: 13px; + line-height: 1.75; + margin-bottom: 5px; +} + +.readCn { + color: var(--ink); + font-size: 17px; + line-height: 1.9; + letter-spacing: 0; +} + +.readCn mark, +.readCn .review-mark, +.cnEditor mark, +.cnEditor .review-mark { + background: var(--mark); + color: inherit; +} + +.readMeta { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-top: 6px; + color: var(--muted); + font-size: 12px; + opacity: 0; + transition: opacity 0.15s ease; +} + +.readBlock:hover .readMeta, +.readBlock.active .readMeta, +.readBlock.touched .readMeta, +.readBlock.marked .readMeta { + opacity: 1; +} + +.readStatus { + color: var(--warn); +} + +.miniButton { + min-height: 26px; + padding: 2px 8px; + font-size: 12px; + border-radius: 5px; +} + +.editorPane { + padding: 18px; +} + +.emptyState { + height: 100%; + min-height: 240px; + display: grid; + place-items: center; + color: var(--muted); +} + +.editorCard { + max-width: 1040px; + margin: 0 auto; +} + +.rowHeader { + display: flex; + justify-content: space-between; + gap: 18px; + align-items: flex-start; + margin-bottom: 14px; +} + +.rowId { + font-weight: 700; + font-size: 18px; +} + +.rowPath { + color: var(--muted); + font-size: 12px; + word-break: break-all; +} + +.markToggle { + white-space: nowrap; + display: flex; + align-items: center; + gap: 8px; + color: var(--warn); + font-weight: 600; +} + +.pair, +.reviewFields, +.notes, +.actions { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 14px; + margin-bottom: 12px; +} + +.pair h2, +.sectionTitle h2 { + margin: 0 0 8px; + font-size: 15px; +} + +.sourceText, +.quickSource { + color: var(--source); + white-space: pre-wrap; + background: #f8fafc; + border: 1px solid #e3e9f0; + border-radius: 6px; + padding: 12px; +} + +.sectionTitle { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-bottom: 8px; +} + +.inlineTools { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.cnEditor { + min-height: 160px; + background: #ffffff; + border: 1px solid var(--line); + border-radius: 6px; + padding: 12px; + outline: none; + white-space: pre-wrap; +} + +.cnEditor:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(29, 111, 95, 0.12); +} + +.reviewFields { + display: grid; + grid-template-columns: 1fr 1fr 2fr; + gap: 12px; +} + +label { + display: flex; + flex-direction: column; + gap: 5px; + font-weight: 600; +} + +label input, +label select, +label textarea { + font-weight: 400; +} + +input, +select, +textarea { + border: 1px solid var(--line); + border-radius: 6px; + background: #ffffff; + padding: 7px 9px; + color: var(--ink); +} + +textarea { + resize: vertical; +} + +.notes { + display: grid; + grid-template-columns: 1fr; + gap: 12px; +} + +.actions { + display: flex; + justify-content: center; + gap: 10px; +} + +.quickEditor { + position: fixed; + top: 92px; + right: 18px; + bottom: 18px; + width: min(520px, calc(100vw - 36px)); + z-index: 20; + display: flex; + flex-direction: column; + gap: 10px; + overflow: auto; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); + padding: 14px; +} + +.quickHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--line-soft); + padding-bottom: 10px; +} + +.quickSourceWrap { + border: 1px solid #e3e9f0; + border-radius: 6px; + background: #f8fafc; +} + +.quickSourceWrap summary { + cursor: pointer; + padding: 8px 12px; + color: var(--source); + font-weight: 700; + border-bottom: 1px solid #e3e9f0; +} + +.quickSource { + max-height: none; + overflow: visible; + font-size: 13px; + border: 0; + border-radius: 0; + background: transparent; +} + +.quickField { + display: flex; + flex-direction: column; + gap: 5px; +} + +.quickCnEditor { + min-height: 150px; + max-height: 260px; + overflow: auto; +} + +.quickControls { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 8px; + align-items: center; +} + +.quickActions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 2px; +} + +.toast { + position: fixed; + right: 16px; + bottom: 16px; + max-width: min(560px, calc(100vw - 32px)); + padding: 12px 14px; + background: #1f2933; + color: #ffffff; + border-radius: 8px; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22); + z-index: 30; + white-space: pre-wrap; +} + +[hidden] { + display: none !important; +} + +@media (min-width: 1180px) { + body.quickOpen .readingFlow { + margin-left: max(40px, calc((100% - 1320px) / 2)); + margin-right: 560px; + max-width: 840px; + } +} + +@media (max-width: 1040px) { + .topbar { + height: auto; + grid-template-columns: 1fr; + align-items: stretch; + } + + #sessionMeta { + white-space: normal; + } + + .toolbar { + justify-content: flex-start; + } + + .layout { + height: auto; + min-height: calc(100vh - 78px); + grid-template-columns: 1fr; + } + + .sidebar { + max-height: 48vh; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .toc { + max-height: 24vh; + } + + .readerPane, + .editorPane { + min-height: 70vh; + } + + .readingFlow { + border: 0; + padding: 18px 18px 80px; + } + + .readerHeader { + padding: 12px 16px; + } + + .reviewFields, + .quickControls { + grid-template-columns: 1fr; + } + + .quickEditor { + top: auto; + left: 10px; + right: 10px; + bottom: 10px; + width: auto; + max-height: 72vh; + } +}