diff --git a/tools/epub_review_editor/MAINTENANCE.md b/tools/epub_review_editor/MAINTENANCE.md new file mode 100644 index 0000000..40dddf2 --- /dev/null +++ b/tools/epub_review_editor/MAINTENANCE.md @@ -0,0 +1,140 @@ +# EPUB 审校器长期维护手册 + +## 目标与边界 + +本工具服务于中日双语 EPUB 的网页审校:长篇连续阅读、逐段修改中文译文、标记翻译问题、导出反馈与修订 EPUB。翻译质量规则仍由项目根目录的 `AGENTS.md`、`rules/translation_rules.md`、当前系列的 `series-style.md` 与术语表共同约束。 + +审校器不负责整卷首译流程,也不直接改正式术语表。用户在网页中留下的编辑、问题备注、长期规则建议,需要由 Codex 后续读取反馈文件后再沉淀到系列风格或候选术语中。 + +## 数据模型与会话目录 + +`--review-root` 是唯一需要备份的运行数据目录。典型结构: + +```text +epub_review_sessions/ + app_state.json + gpt_config.json + / + source.epub + extracted/ + review_state/ + epub_entries.json + rows.json + session.json + state.json + feedback/ + translation_feedback.jsonl + feedback_for_codex.md + exports/ +``` + +兼容要求: + +- `rows.json` 是初始解析结果,不应因用户编辑而重写。 +- `state.json.edits` 保存用户修改与标记,字段缺省时必须能回退到原始译文。 +- `session.json` 与 `app_state.json` 只保存会话定位信息。 +- `gpt_config.json` 只属于本地运行环境,不写入 EPUB、不写入导出文件、不通过 API 回传密钥。 + +## 目录与插图 + +结构接口 `/api/structure` 应保持向后兼容: + +- text chapter: `kind: "text"`、`parts`、`row_count`、`touched_count`、`marked_count` +- image chapter: `kind: "image"`、`images`、`parts: []`、`row_count: 0` +- part 标题默认使用 `part0000` 形式,真实标题可保留在 `source_title` +- 插图通过 `/api/asset/` 读取,必须继续使用 `extracted_path()` 防止路径穿越 + +章节来源优先级: + +1. EPUB 内部目录页的中文条目 +2. `nav.xhtml` / `toc.xhtml` +3. 正文标题 +4. 文件名 fallback + +## 阅读与侧栏交互 + +验收口径: + +- 初始进入审校时,左侧侧栏隐藏,正文占主要空间。 +- 顶部“目录”只打开目录,“检索”只打开搜索/段落列表,不合并成一个常驻面板。 +- 目录可展开/收起章节,插图章节可点击阅读,文字 part 可点击定位。 +- 阅读模式不显示每段编号、快速编辑、快速标记按钮。 +- 点击正文段落打开右侧精修与重翻面板。 +- 右侧面板打开或关闭导致正文宽度变化时,应恢复用户原来正在看的段落位置。 +- 移动窄屏下,侧栏和右侧面板应以抽屉/底部面板方式显示,不遮死主要操作。 + +## 编辑、反馈与导出 + +保存本段时必须: + +- sanitize 中文 HTML,但允许 `ruby/rb/rt/rp/mark/span` 等必要内联标签。 +- 记录 `before_cn_html`、`after_cn_html`、问题类型、严重程度、标签、备注、长期规则建议。 +- 更新 `feedback/translation_feedback.jsonl`。 + +生成反馈时必须: + +- 写入 `feedback_for_codex.md`。 +- 只汇总已编辑、已标记或有备注的段落。 +- 保留日文原文、修改前、修改后,便于后续翻译规则复盘。 + +导出 EPUB 前必须先写回已保存的译文修改,插图文件保持原样。 + +## GPT 重翻 + +GPT 能力是可选增强,不应影响离线审校。 + +安全与配置: + +- 支持 OpenAI 兼容的 `base_url`、`model`、`api_key`。 +- API Key 优先读取 `gpt_config.json`,也支持环境变量 `OPENAI_API_KEY`。 +- `/api/gpt/config` 不得返回 API Key。 +- 未配置 Key 时,重翻接口应返回可读错误,不影响编辑保存。 + +单段重翻流程: + +1. 用户点击阅读段落,打开右侧面板。 +2. 用户可填写额外重翻要求。 +3. `/api/row//retranslate` 使用当前日文、当前中文、术语提示生成候选。 +4. 候选只显示在面板里,不自动覆盖。 +5. 用户点击“应用重翻”后写入编辑框,再点击保存才进入审校记录。 + +Prompt 硬规则: + +- 输出简体中文轻小说文风。 +- 意义优先,不贴日语语序硬译。 +- 不合并、不拆分段落。 +- 有意义 ruby 必须保留为真实 `......`,不要改成括号。 +- Falchion / ファルシオン 固定译为“大砍刀”,不要译成“偃月刀”。 + +## 版本规则 + +版本号使用 `version = "0.1.0"` 格式: + +| 层级 | 触发条件 | +| --- | --- | +| PATCH | 修复 bug 或兼容性小修 | +| MINOR | 新增向后兼容能力、API 字段或可选配置 | +| MAJOR | 破坏兼容的 API、配置或行为变更 | + +发版时必须同步: + +- `version.py` +- `static/index.html` 顶部版本徽标 fallback +- `README.md` 当前版本与版本说明 + +## 回归清单 + +每轮改动至少检查: + +- `python -m py_compile tools/epub_review_editor/server.py tools/epub_review_editor/version.py` +- `node --check tools/epub_review_editor/static/app.js` +- `git diff --check` +- `/api/version` +- `/api/session` +- `/api/structure` +- `/api/gpt/config` +- 首个插图 `/api/asset/...` 返回 200 且浏览器可显示 +- 目录按钮、检索按钮、隐藏侧栏、点击段落打开右侧面板 +- 阅读段落下方没有编号和“快速编辑/快速标记” +- 未配置 API Key 时重翻返回清晰错误 +- 配置保存后 `/api/gpt/config` 不包含密钥字段 diff --git a/tools/epub_review_editor/README.md b/tools/epub_review_editor/README.md index ec9e05d..3985389 100644 --- a/tools/epub_review_editor/README.md +++ b/tools/epub_review_editor/README.md @@ -1,6 +1,6 @@ # 双语 EPUB 审校编辑器 -当前版本:`0.2.0` +当前版本:`0.4.0` 这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。 @@ -16,6 +16,12 @@ 本次从 `0.1.0` 升到 `0.2.0`,因为新增了独立上传、会话管理、下载接口与可选 `--host` 配置,且保持原命令行 EPUB 路径用法兼容。 +`0.2.1` 修复目录章节识别:优先读取 EPUB 内部目录页的中文标题,并把目录锚点之间的多个正文 part 合并为同一章;左侧目录增加章节展开/收起按钮。 + +`0.3.0` 新增插图阅读能力:目录中会把 EPUB spine 里的插图页作为独立章节显示,阅读模式可直接查看图片;正文子章节标题统一使用 `part0000` 这类规整编号,方便长篇审校快速定位。 + +`0.4.0` 新增阅读型交互与 GPT 重翻:左侧目录/检索改为按需打开的独立侧栏;阅读模式去掉每段下方操作按钮,点击段落直接打开右侧精修与重翻面板,并在面板挤开正文后恢复原阅读位置;本地可配置 OpenAI 兼容 GPT API,对单段生成重翻候选,确认后再应用到编辑框。 + ## 独立安装 这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub_review_editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖: @@ -72,9 +78,12 @@ python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessio ## 能做什么 - 阅读模式下按章节连续阅读日文原文与中文译文,不需要逐句点击。 -- 左侧目录按 EPUB nav/toc 或 spine 文件顺序组织章节;章节下显示内部 part,点击 part 后只看该 part,点击章节则看整章连续正文。 -- 在阅读模式中点击段落即可打开快速审校面板,直接编辑中文译文或标记问题。 +- 左侧目录和检索默认隐藏,通过顶部“目录”“检索”两个按钮分别打开,不会把两种入口混在同一个常驻侧栏里。 +- 目录优先按 EPUB 内部目录页或 nav/toc 组织章节;章节下显示内部 `part0000` 形式的 part,可展开/收起,点击 part 后只看该 part,点击章节则看整章连续正文。 +- 插图页会像文字章节一样出现在目录里,点击后可在阅读模式中直接查看原 EPUB 图片。 +- 在阅读模式中点击段落即可打开右侧精修与重翻面板,直接编辑中文译文、标记问题或调用 GPT 重翻;打开/关闭面板时会尽量保持原来的阅读位置。 - 精修模式保留原有逐段编辑界面,用于较细的逐段复审。 +- 可在网页中配置 OpenAI 兼容的 `base_url`、`model` 与 API Key,对当前段落生成重翻候选;候选不会自动覆盖正文,需要点击“应用重翻”并保存。 - 直接编辑中文译文。 - 标记问题段落或选中文本。 - 记录问题类型、严重程度、标签、备注和可沉淀的长期规则。 @@ -113,10 +122,12 @@ epub_review_sessions\_\ - 只想记录问题时:勾选“标记为翻译问题”,写备注,保存本段。 - 想直接修正译文时:编辑中文译文后保存本段。 - 想让 Codex 学习你的偏好时:把“可沉淀为长期规则”写具体,例如“Falchion 固定译为大砍刀,不要译成偃月刀”。 +- 想单段重翻时:点击段落打开右侧面板,先在“API 设置”中保存 GPT 配置,再点“重翻本段”;满意后点“应用重翻”,最后保存本段记录。 - 审校结束后点击“生成反馈”,再在聊天里告诉 Codex 读取 `feedback_for_codex.md` 和 `translation_feedback.jsonl`。 ## 当前限制 - 当前解析规则针对本项目的“日文灰字 + 中文紧随其后”的双语 EPUB。 -- 目录用于审校导航,不负责可视化修改 EPUB 的图片、目录和 OPF 元数据。 +- 目录用于审校导航;插图可以阅读和跳转,但当前版本不提供图片内容编辑。 +- GPT API Key 仅保存在 `--review-root` 下的本地 `gpt_config.json`,接口不会回传密钥;服务器部署时请保护好审校目录,不要把没有鉴权的服务暴露到公网。 - 浏览器内保存的是审校工作副本;要生成 EPUB 文件,需要点击“导出审校 EPUB”。 diff --git a/tools/epub_review_editor/server.py b/tools/epub_review_editor/server.py index c827cfe..4669bf8 100644 --- a/tools/epub_review_editor/server.py +++ b/tools/epub_review_editor/server.py @@ -16,6 +16,7 @@ import subprocess import sys import threading import time +import urllib.error import urllib.parse import urllib.request import webbrowser @@ -35,21 +36,27 @@ except ImportError: "MINOR": "新增向后兼容能力、API 字段或可选配置", "MAJOR": "破坏兼容的 API、配置或行为变更", } - version = "0.2.0" + version = "0.4.0" P_RE = re.compile(r"(]*>)(.*?)(

)", re.S | re.I) TAG_RE = re.compile(r"<[^>]+>") +IMG_TAG_RE = re.compile(r"<(?:img|image)\b[^>]*>", re.S | re.I) +ATTR_RE = re.compile(r"([:\w-]+)\s*=\s*(['\"])(.*?)\2", re.S) 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, ) +EXTERNAL_URI_RE = re.compile(r"^(?:[a-z][a-z0-9+.-]*:|//)", re.I) +IMAGE_EXTENSIONS = {".apng", ".avif", ".bmp", ".gif", ".jpeg", ".jpg", ".png", ".svg", ".webp"} DEFAULT_REVIEW_ROOT = "epub_review_sessions" STATE_VERSION = 1 UPLOAD_DIR_NAME = "uploads" +DEFAULT_GPT_BASE_URL = "https://api.openai.com/v1" +DEFAULT_GPT_MODEL = "gpt-4o-mini" def now_iso() -> str: @@ -121,6 +128,13 @@ def normalize_href(base_dir: str, href: str) -> str: return posixpath.normpath(posixpath.join(base_dir, href)).lstrip("./") +def normalize_href_with_fragment(base_dir: str, href: str) -> tuple[str, str]: + href = urllib.parse.unquote(href or "") + path, fragment = (href.split("#", 1) + [""])[:2] if "#" in href else (href, "") + path = posixpath.normpath(posixpath.join(base_dir, path)).lstrip("./") if path else "" + return path, fragment + + def read_xml_root(path: Path) -> ET.Element | None: if not path.exists(): return None @@ -237,6 +251,161 @@ def sanitize_cn_html(value: str) -> str: return "".join(sanitizer.parts).strip() +def normalize_gpt_base_url(value: str) -> str: + value = (value or DEFAULT_GPT_BASE_URL).strip().rstrip("/") + if not value: + value = DEFAULT_GPT_BASE_URL + parsed = urllib.parse.urlparse(value) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("Base URL 必须是 http(s) 地址") + return value + + +def read_gpt_config(review_root: Path) -> dict[str, Any]: + data = read_json(gpt_config_path(review_root), {}) + api_key = str(data.get("api_key") or os.environ.get("OPENAI_API_KEY") or "").strip() + base_url = str(data.get("base_url") or os.environ.get("OPENAI_BASE_URL") or DEFAULT_GPT_BASE_URL).strip() + model = str(data.get("model") or os.environ.get("OPENAI_MODEL") or DEFAULT_GPT_MODEL).strip() + return { + "base_url": normalize_gpt_base_url(base_url), + "model": model or DEFAULT_GPT_MODEL, + "api_key": api_key, + "configured": bool(api_key), + "key_source": "config" if str(data.get("api_key") or "").strip() else ("env" if os.environ.get("OPENAI_API_KEY") else ""), + "updated_at": data.get("updated_at", ""), + } + + +def public_gpt_config(review_root: Path) -> dict[str, Any]: + config = read_gpt_config(review_root) + return { + "configured": config["configured"], + "base_url": config["base_url"], + "model": config["model"], + "key_source": config["key_source"], + "updated_at": config.get("updated_at", ""), + } + + +def save_gpt_config(review_root: Path, data: dict[str, Any]) -> dict[str, Any]: + base_url = normalize_gpt_base_url(str(data.get("base_url") or DEFAULT_GPT_BASE_URL)) + model = str(data.get("model") or DEFAULT_GPT_MODEL).strip() or DEFAULT_GPT_MODEL + api_key = str(data.get("api_key") or "").strip() + keep_existing = bool(data.get("keep_existing", False)) + existing = read_json(gpt_config_path(review_root), {}) + if keep_existing and not api_key: + api_key = str(existing.get("api_key") or "").strip() + record = { + "base_url": base_url, + "model": model, + "api_key": api_key, + "updated_at": now_iso(), + } + write_json(gpt_config_path(review_root), record) + return public_gpt_config(review_root) + + +def compact_glossary_for_prompt(row: dict[str, Any], limit: int = 36) -> list[str]: + project_root = Path(__file__).resolve().parents[2] + glossary_path = project_root / "TRPG玩家在异世界打造最强角色" / "mingcibiao.json" + if not glossary_path.exists(): + return ["Falchion/ファルシオン:大砍刀,不要译成偃月刀。"] + try: + glossary = read_json(glossary_path, {}) + except Exception: + glossary = {} + source = f"{row.get('jp_text', '')}\n{row.get('jp_html', '')}" + selected: list[str] = [] + for key, value in glossary.items(): + if not isinstance(key, str) or not isinstance(value, str): + continue + clean_value = value.split("#", 1)[0].strip() + if not clean_value: + continue + if key and key in source: + selected.append(f"{key} => {clean_value}") + if len(selected) >= limit: + break + falchion_rule = "Falchion/ファルシオン => 大砍刀,不要译成偃月刀。" + if not any("Falchion" in item or "ファルシオン" in item for item in selected): + selected.append(falchion_rule) + return selected[:limit] + + +def build_retranslate_messages(row: dict[str, Any], instruction: str = "") -> list[dict[str, str]]: + glossary_lines = "\n".join(f"- {item}" for item in compact_glossary_for_prompt(row)) + system_prompt = ( + "你是日语轻小说到简体中文的审校重翻助手。请只输出当前这一段的中文译文 HTML 片段," + "不要解释,不要输出 Markdown。译文要自然流畅,符合简中轻小说文风;意义优先,不贴日语语序硬译。" + "不得合并或拆分段落。保留必要的 HTML 内联标签,尤其是有意义的 ruby:" + "必须使用 中文读音/外语,不要改成括号。" + "纯读音 ruby 可自然译入中文。术语优先服从给定术语表。" + ) + user_parts = [ + "请重翻以下单段。", + "", + "日文原文 HTML:", + row.get("jp_html") or row.get("jp_text") or "", + "", + "当前中文译文 HTML:", + row.get("current_html") or row.get("cn_html") or row.get("cn_text") or "", + ] + if glossary_lines: + user_parts.extend(["", "相关术语:", glossary_lines]) + if instruction.strip(): + user_parts.extend(["", "本次额外要求:", instruction.strip()[:1200]]) + return [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": "\n".join(user_parts)}, + ] + + +def call_openai_compatible_chat( + config: dict[str, Any], + messages: list[dict[str, str]], + temperature: float = 0.2, + timeout: int = 90, +) -> str: + api_key = str(config.get("api_key") or "").strip() + if not api_key: + raise ValueError("尚未配置 GPT API Key") + base_url = normalize_gpt_base_url(str(config.get("base_url") or DEFAULT_GPT_BASE_URL)) + url = f"{base_url}/chat/completions" + payload = { + "model": str(config.get("model") or DEFAULT_GPT_MODEL), + "messages": messages, + "temperature": temperature, + } + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + req = urllib.request.Request( + url, + data=body, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as response: + result = json.loads(response.read().decode("utf-8", errors="replace")) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace")[:1200] + raise RuntimeError(f"GPT 请求失败:HTTP {exc.code} {detail}") from exc + except urllib.error.URLError as exc: + raise RuntimeError(f"GPT 请求失败:{exc.reason}") from exc + choices = result.get("choices") or [] + if not choices: + raise RuntimeError("GPT 返回为空") + content = ((choices[0].get("message") or {}).get("content") or "").strip() + if content.startswith("```"): + content = re.sub(r"^```(?:html|xml)?\s*", "", content, flags=re.I).strip() + content = re.sub(r"\s*```$", "", content).strip() + if not content: + raise RuntimeError("GPT 没有返回译文") + return content + + 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}" @@ -246,6 +415,10 @@ def app_manifest_path(review_root: Path) -> Path: return review_root / "app_state.json" +def gpt_config_path(review_root: Path) -> Path: + return review_root / "gpt_config.json" + + def session_manifest_path(session_root: Path) -> Path: return session_root / "review_state" / "session.json" @@ -255,7 +428,7 @@ def session_public_id(session_root: Path) -> str: def session_root_from_id(review_root: Path, session_id: str) -> Path: - safe_id = safe_slug(session_id, max_len=160) + safe_id = safe_slug(session_id, max_len=240) if safe_id != session_id: raise ValueError("invalid session id") root = (review_root / session_id).resolve() @@ -514,12 +687,19 @@ def extract_heading_from_html(text: str) -> str: match = re.search(pattern, text, flags=re.S | re.I) if not match: continue - title = strip_tags(match.group(1)) + title = compact_text(strip_tags(match.group(1))) if title: return title return "" +def extract_body_heading_from_html(text: str) -> str: + match = re.search(r"]*>(.*?)", text, flags=re.S | re.I) + if not match: + return "" + return compact_text(strip_tags(match.group(1))) + + def read_document_title(session_root: Path, entry_name: str) -> str: path = extracted_path(session_root, entry_name) if not path.exists(): @@ -528,6 +708,136 @@ def read_document_title(session_root: Path, entry_name: str) -> str: return extract_heading_from_html(text) +def read_body_heading(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_body_heading_from_html(text) + + +def first_content_text(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") + for match in P_RE.finditer(text): + inner = match.group(2) + if re.search(r" dict[str, str]: + attrs: dict[str, str] = {} + for key, _quote, value in ATTR_RE.findall(tag): + attrs[key.lower()] = html.unescape(value or "") + return attrs + + +def part_display_label(entry_name: str, fallback_index: int) -> str: + stem = Path(entry_name).stem + for pattern in (r"part[-_]?(\d+)$", r"p[-_]?(\d+)$", r"^(\d+)$"): + match = re.search(pattern, stem, flags=re.I) + if match: + return f"part{int(match.group(1)):04d}" + if stem and stem.lower() not in {"titlepage", "cover"}: + return stem + return f"part{fallback_index:04d}" + + +def is_image_asset(entry_name: str) -> bool: + suffix = posixpath.splitext(entry_name.split("?", 1)[0])[1].lower() + return suffix in IMAGE_EXTENSIONS + + +def asset_url(entry_name: str) -> str: + return f"/api/asset/{urllib.parse.quote(entry_name, safe='/')}" + + +def extract_images_from_html(session_root: Path, entry_name: str) -> list[dict[str, Any]]: + path = extracted_path(session_root, entry_name) + if not path.exists(): + return [] + text = path.read_text(encoding="utf-8", errors="replace") + images: list[dict[str, Any]] = [] + for index, match in enumerate(IMG_TAG_RE.finditer(text), start=1): + attrs = tag_attrs(match.group(0)) + href = attrs.get("src") or attrs.get("href") or attrs.get("xlink:href") + if not href or EXTERNAL_URI_RE.match(href): + continue + image_entry = normalize_href(entry_dir(entry_name), href) + if not image_entry or not is_image_asset(image_entry): + continue + images.append( + { + "index": index, + "file": entry_name, + "file_label": Path(entry_name).name, + "asset_path": image_entry, + "asset_name": Path(image_entry).name, + "asset_url": asset_url(image_entry), + "alt": compact_text(attrs.get("alt") or attrs.get("title") or ""), + } + ) + return images + + +def nav_target_key(base_dir: str, href: str) -> tuple[str, str]: + path, fragment = normalize_href_with_fragment(base_dir, href) + return path, fragment + + +def parse_internal_toc(session_root: Path) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + for entry_name in html_entries_in_reading_order(session_root): + path = extracted_path(session_root, entry_name) + if not path.exists(): + continue + text = path.read_text(encoding="utf-8", errors="replace") + if not re.search(r"]*href=", text, flags=re.I): + continue + matches = list(P_RE.finditer(text)) + base_dir = entry_dir(entry_name) + i = 0 + while i < len(matches): + source_inner = matches[i].group(2) + link_match = re.search(r"]*href=['\"]([^'\"]+)['\"][^>]*>(.*?)", source_inner, flags=re.S | re.I) + if not link_match: + i += 1 + continue + target_file, target_fragment = nav_target_key(base_dir, link_match.group(1)) + if not target_file: + i += 1 + continue + source_title = compact_text(strip_tags(link_match.group(2))) or compact_text(strip_tags(source_inner)) + title = source_title + if i + 1 < len(matches): + next_inner = matches[i + 1].group(2) + if not is_gray_source(matches[i + 1].group(1)[2:-1], next_inner): + next_title = compact_text(strip_tags(next_inner)) + if next_title: + title = next_title + i += 1 + if title: + items.append( + { + "title": title, + "href": target_file, + "fragment": target_fragment, + "children": [], + "source_title": source_title, + } + ) + i += 1 + if items: + return items + return [] + + def parse_nav_toc(session_root: Path) -> list[dict[str, Any]]: entries = read_json(session_root / "review_state" / "epub_entries.json", []) nav_entries = [ @@ -578,19 +888,20 @@ def parse_nav_ol(ol: ET.Element, base_dir: str) -> list[dict[str, Any]]: continue label = "" href = "" + fragment = "" 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", "")) + href, fragment = normalize_href_with_fragment(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}) + items.append({"title": label, "href": href, "fragment": fragment, "children": children}) return items @@ -614,10 +925,20 @@ def build_structure(session_root: Path) -> 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] + all_ordered_files = html_entries_in_reading_order(session_root) + image_by_file = { + entry_name: images + for entry_name in all_ordered_files + if (images := extract_images_from_html(session_root, entry_name)) + } + ordered_files = [file for file in all_ordered_files if file in rows_by_file] for file in rows_by_file: if file not in ordered_files: ordered_files.append(file) + content_files = [file for file in all_ordered_files if file in rows_by_file or file in image_by_file] + for file in sorted(set(rows_by_file) | set(image_by_file), key=file_sort_key): + if file not in content_files: + content_files.append(file) nav_tree = parse_nav_toc(session_root) nav_flat = flatten_nav_items(nav_tree) @@ -628,7 +949,57 @@ def build_structure(session_root: Path) -> dict[str, Any]: if href and title and href not in nav_titles: nav_titles[href] = title + start_titles: dict[str, str] = {} + chapter_specs: list[dict[str, Any]] = [] + toc_items = parse_internal_toc(session_root) + ordered_index = {entry_name: index for index, entry_name in enumerate(all_ordered_files)} + content_file_set = set(content_files) + + def first_content_file_at_or_after(entry_name: str) -> str: + start_pos = ordered_index.get(entry_name, -1) + if start_pos < 0: + return "" + for candidate in all_ordered_files[start_pos:]: + if candidate in content_file_set: + return candidate + return "" + + for item in toc_items: + href = item.get("href", "") + if href not in ordered_index: + continue + start = first_content_file_at_or_after(href) + if start and item.get("title") and start not in start_titles: + start_titles[start] = item["title"] + + for entry_name in ordered_files: + heading = read_body_heading(session_root, entry_name) + if heading and entry_name not in start_titles: + start_titles[entry_name] = heading + + if start_titles: + starts = sorted(start_titles, key=lambda name: ordered_index.get(name, 10**9)) + for index, start in enumerate(starts): + start_pos = ordered_index.get(start, -1) + next_pos = ordered_index.get(starts[index + 1], len(all_ordered_files)) if index + 1 < len(starts) else len(all_ordered_files) + if start_pos < 0: + continue + files = [ + entry_name + for entry_name in all_ordered_files[start_pos:next_pos] + if entry_name in rows_by_file or entry_name in image_by_file + ] + if files: + chapter_specs.append({"title": start_titles.get(start, ""), "files": files, "start": files[0]}) + + grouped_files = {entry_name for spec in chapter_specs for entry_name in spec["files"]} + for entry_name in content_files: + if entry_name not in grouped_files: + chapter_specs.append({"title": "", "files": [entry_name], "start": entry_name}) + chapter_specs.sort(key=lambda spec: ordered_index.get(spec["start"], 10**9)) + part_serial = 0 + image_serial = 0 def file_counts(file_rows: list[dict[str, Any]]) -> tuple[int, int]: touched_count = sum( @@ -649,16 +1020,18 @@ def build_structure(session_root: Path) -> 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 - ) + title = part_display_label(entry_name, part_serial - 1) touched_count, marked_count = file_counts(file_rows) return { "id": f"PT{part_serial:04d}", + "kind": "text", "title": title, + "source_title": title_override + or nav_titles.get(entry_name) + or read_body_heading(session_root, entry_name) + or first_content_text(session_root, entry_name) + or read_document_title(session_root, entry_name) + or Path(entry_name).stem, "file": entry_name, "file_label": Path(entry_name).name, "row_ids": [row["id"] for row in file_rows], @@ -669,20 +1042,58 @@ def build_structure(session_root: Path) -> dict[str, Any]: "last_row_id": file_rows[-1]["id"] if file_rows else "", } + def make_image_chapter( + entry_name: str, + image: dict[str, Any], + title_override: str = "", + image_index: int = 1, + image_total: int = 1, + ) -> dict[str, Any]: + nonlocal chapter_serial, image_serial + chapter_serial += 1 + image_serial += 1 + suffix = f"-{image_index:02d}" if image_total > 1 else "" + label = part_display_label(entry_name, image_serial - 1) + default_title = "封面" if Path(entry_name).stem.lower() in {"titlepage", "cover"} else f"插图 {label}{suffix}" + base_title = title_override or nav_titles.get(entry_name) or read_body_heading(session_root, entry_name) + if title_override and base_title: + title = f"{base_title} · 插图{suffix}" + else: + title = base_title or default_title + image_item = { + **image, + "id": f"IMG{image_serial:04d}-{image['index']:02d}", + } + return { + "id": f"C{chapter_serial:04d}", + "kind": "image", + "title": title, + "file": entry_name, + "file_label": Path(entry_name).name, + "images": [image_item], + "row_count": 0, + "touched_count": 0, + "marked_count": 0, + "parts": [], + "_sort": ordered_index.get(entry_name, 10**9) + image_index / 100, + } + def chapter_from_parts(chapter_id: str, title: str, parts: list[dict[str, Any]]) -> dict[str, Any]: return { "id": chapter_id, + "kind": "text", "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, + "_sort": min((ordered_index.get(part["file"], 10**9) for part in parts), default=10**9), } 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: + if href in rows_by_file or href in image_by_file: items.append({"href": href, "title": node.get("title") or ""}) for child in node.get("children", []): items.extend(flatten_node_files(child)) @@ -692,6 +1103,44 @@ def build_structure(session_root: Path) -> dict[str, Any]: assigned_files: set[str] = set() chapter_serial = 0 + def append_file_as_chapter(entry_name: str, title_override: str = "") -> None: + nonlocal chapter_serial + if entry_name in image_by_file: + images = image_by_file.get(entry_name, []) + for image_index, image in enumerate(images, start=1): + chapters.append(make_image_chapter(entry_name, image, title_override, image_index, len(images))) + return + if entry_name in rows_by_file: + chapter_serial += 1 + part = make_part(entry_name, title_override) + chapters.append(chapter_from_parts(f"C{chapter_serial:04d}", title_override or part["source_title"] or part["title"], [part])) + + def append_grouped_files(title: str, files: list[str]) -> None: + nonlocal chapter_serial + text_parts: list[dict[str, Any]] = [] + text_title = title + for file_index, entry_name in enumerate(files): + if entry_name in image_by_file: + images = image_by_file.get(entry_name, []) + for image_index, image in enumerate(images, start=1): + image_title = title if file_index == 0 and not text_parts else "" + chapters.append(make_image_chapter(entry_name, image, image_title, image_index, len(images))) + elif entry_name in rows_by_file: + text_parts.append(make_part(entry_name)) + if text_parts: + chapter_serial += 1 + chapters.append(chapter_from_parts(f"C{chapter_serial:04d}", text_title or text_parts[0]["source_title"] or text_parts[0]["title"], text_parts)) + + for spec in chapter_specs: + node_files = [entry_name for entry_name in spec.get("files", []) if entry_name not in assigned_files] + if not node_files: + continue + if len(node_files) == 1 and node_files[0] in image_by_file: + append_file_as_chapter(node_files[0], spec.get("title") or "") + else: + append_grouped_files(spec.get("title") or "", node_files) + assigned_files.update(node_files) + chapter_nodes = nav_tree while ( len(chapter_nodes) == 1 @@ -700,34 +1149,36 @@ def build_structure(session_root: Path) -> dict[str, Any]: ): 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: + if not chapters: + 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 - 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)) + append_grouped_files(node.get("title") or "", [item["href"] for item in node_files]) + assigned_files.update(item["href"] for item in node_files) - for entry_name in ordered_files: + for entry_name in content_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])) + append_file_as_chapter(entry_name) + + chapters.sort(key=lambda chapter: chapter.get("_sort", 10**9)) + for index, chapter in enumerate(chapters, start=1): + chapter["id"] = f"C{index:04d}" + chapter.pop("_sort", None) return { "chapters": chapters, "chapter_count": len(chapters), "part_count": sum(len(chapter["parts"]) for chapter in chapters), + "image_count": sum(len(chapter.get("images", [])) for chapter in chapters), "row_count": len(rows), } @@ -968,6 +1419,15 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: 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) + active_id = read_json(app_manifest_path(review_root), {}).get("active_session_id", "") + if active_id: + try: + manifest_root = session_root_from_id(review_root, active_id) + except ValueError: + return None, None + if (manifest_root / "review_state" / "state.json").exists(): + set_active_session(manifest_root) + return app.config["SESSION_ROOT"], app.config["EPUB_PATH"] return None, None def no_active_response(): @@ -1097,6 +1557,79 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: return no_active_response() return jsonify(build_structure(session_root)) + @app.route("/api/asset/") + def api_asset(entry_name: str): + session_root, _epub_path = active_session() + if session_root is None: + return no_active_response() + entry_name = urllib.parse.unquote(entry_name) + try: + path = extracted_path(session_root, entry_name) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + if not path.exists() or not path.is_file(): + return jsonify({"error": "asset not found"}), 404 + return send_from_directory(path.parent, path.name) + + @app.route("/api/gpt/config", methods=["GET", "POST"]) + def api_gpt_config(): + if request.method == "GET": + return jsonify(public_gpt_config(review_root)) + data = request.get_json(silent=True) or {} + try: + return jsonify(save_gpt_config(review_root, data)) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + + @app.route("/api/row//retranslate", methods=["POST"]) + def api_retranslate_row(row_id: str): + session_root, _epub_path = active_session() + if session_root is None: + return no_active_response() + rows = merge_rows(session_root) + row = next((item for item in rows if item["id"] == row_id), None) + if row is None: + return jsonify({"error": "row not found"}), 404 + data = request.get_json(silent=True) or {} + try: + config = read_gpt_config(review_root) + if data.get("base_url"): + config["base_url"] = normalize_gpt_base_url(str(data.get("base_url"))) + if data.get("model"): + config["model"] = str(data.get("model")).strip() + instruction = str(data.get("instruction") or "") + raw = call_openai_compatible_chat(config, build_retranslate_messages(row, instruction)) + candidate_html = sanitize_cn_html(raw) + if not candidate_html: + return jsonify({"error": "GPT 返回内容为空或无法作为译文保存"}), 502 + candidate_text = strip_tags(candidate_html) + record = { + "ts": now_iso(), + "event": "row_retranslated", + "row_id": row_id, + "file": row["file"], + "model": config["model"], + "base_url": config["base_url"], + "jp_text": row["jp_text"], + "before_cn_text": strip_tags(row.get("current_html") or row.get("cn_html") or ""), + "candidate_cn_text": candidate_text, + } + append_jsonl(session_root / "feedback" / "translation_feedback.jsonl", record) + return jsonify( + { + "status": "ok", + "row_id": row_id, + "candidate_html": candidate_html, + "candidate_text": candidate_text, + "model": config["model"], + "updated_at": record["ts"], + } + ) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + except RuntimeError as exc: + return jsonify({"error": str(exc)}), 502 + @app.route("/api/row/", methods=["POST"]) def api_save_row(row_id: str): session_root, _epub_path = active_session() diff --git a/tools/epub_review_editor/static/app.js b/tools/epub_review_editor/static/app.js index 0b8d9a5..7480341 100644 --- a/tools/epub_review_editor/static/app.js +++ b/tools/epub_review_editor/static/app.js @@ -10,6 +10,10 @@ let showTouchedOnly = false; let dirty = false; let quickDirty = false; let hasSession = false; +let expandedChapters = new Set(); +let sidePanelMode = null; +let gptConfigLoaded = false; +let gptCandidateHtml = ""; const $ = (id) => document.getElementById(id); @@ -25,8 +29,17 @@ const dom = { feedbackBtn: $("feedbackBtn"), applyBtn: $("applyBtn"), exportBtn: $("exportBtn"), + tocPanelBtn: $("tocPanelBtn"), + searchPanelBtn: $("searchPanelBtn"), + sidebar: $("sidebar"), + sideTitle: $("sideTitle"), + closeSidebarBtn: $("closeSidebarBtn"), + tocPanel: $("tocPanel"), + searchPanel: $("searchPanel"), searchInput: $("searchInput"), stats: $("stats"), + expandTocBtn: $("expandTocBtn"), + collapseTocBtn: $("collapseTocBtn"), toc: $("toc"), rowList: $("rowList"), readerPane: $("readerPane"), @@ -69,6 +82,17 @@ const dom = { quickMarkSelectionBtn: $("quickMarkSelectionBtn"), quickSaveBtn: $("quickSaveBtn"), quickOpenFullBtn: $("quickOpenFullBtn"), + toggleGptConfigBtn: $("toggleGptConfigBtn"), + gptStatus: $("gptStatus"), + gptConfig: $("gptConfig"), + gptBaseUrlInput: $("gptBaseUrlInput"), + gptModelInput: $("gptModelInput"), + gptApiKeyInput: $("gptApiKeyInput"), + saveGptConfigBtn: $("saveGptConfigBtn"), + gptInstructionInput: $("gptInstructionInput"), + retranslateBtn: $("retranslateBtn"), + applyRetranslationBtn: $("applyRetranslationBtn"), + gptCandidate: $("gptCandidate"), uploadForm: $("uploadForm"), epubFileInput: $("epubFileInput"), selectedFileName: $("selectedFileName"), @@ -130,6 +154,82 @@ function rowTouched(row) { return Boolean(row.edited || row.marked || row.comment || row.learn_note || row.issue_type || row.severity || row.tags); } +function readerAnchor() { + if (currentMode !== "reading" || !dom.readerPane || dom.readerPane.hidden) return null; + const paneRect = dom.readerPane.getBoundingClientRect(); + const headerRect = document.querySelector(".readerHeader")?.getBoundingClientRect(); + const top = headerRect ? headerRect.bottom + 8 : paneRect.top + 8; + const blocks = Array.from(dom.readingFlow.querySelectorAll("[data-row-id]")); + if (!blocks.length) { + return { rowId: "", offset: 0, scrollTop: dom.readerPane.scrollTop }; + } + let target = blocks[0]; + for (const block of blocks) { + const rect = block.getBoundingClientRect(); + if (rect.bottom >= top) { + target = block; + break; + } + } + return { + rowId: target.dataset.rowId || "", + offset: target.getBoundingClientRect().top - paneRect.top, + scrollTop: dom.readerPane.scrollTop, + }; +} + +function restoreReaderAnchor(anchor) { + if (!anchor || currentMode !== "reading") return; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (!anchor.rowId) { + dom.readerPane.scrollTop = anchor.scrollTop || 0; + return; + } + const target = dom.readingFlow.querySelector(`[data-row-id="${CSS.escape(anchor.rowId)}"]`); + if (!target) { + dom.readerPane.scrollTop = anchor.scrollTop || 0; + return; + } + const paneRect = dom.readerPane.getBoundingClientRect(); + const nextOffset = target.getBoundingClientRect().top - paneRect.top; + dom.readerPane.scrollTop += nextOffset - anchor.offset; + }); + }); +} + +function withReaderAnchor(action) { + const anchor = readerAnchor(); + const result = action(); + restoreReaderAnchor(anchor); + return result; +} + +function openSidePanel(mode) { + if (!hasSession) return; + sidePanelMode = mode; + dom.sidebar.hidden = false; + dom.tocPanel.hidden = mode !== "toc"; + dom.searchPanel.hidden = mode !== "search"; + dom.sideTitle.textContent = mode === "search" ? "检索" : "目录"; + dom.tocPanelBtn.classList.toggle("active", mode === "toc"); + dom.searchPanelBtn.classList.toggle("active", mode === "search"); + document.body.classList.add("sidebarOpen"); + if (mode === "search") { + requestAnimationFrame(() => dom.searchInput.focus()); + } +} + +function closeSidePanel() { + sidePanelMode = null; + if (dom.sidebar) dom.sidebar.hidden = true; + if (dom.tocPanel) dom.tocPanel.hidden = true; + if (dom.searchPanel) dom.searchPanel.hidden = true; + document.body.classList.remove("sidebarOpen"); + if (dom.tocPanelBtn) dom.tocPanelBtn.classList.remove("active"); + if (dom.searchPanelBtn) dom.searchPanelBtn.classList.remove("active"); +} + function resetReviewState() { rows = []; filteredRows = []; @@ -141,8 +241,17 @@ function resetReviewState() { showTouchedOnly = false; dirty = false; quickDirty = false; + expandedChapters = new Set(); + sidePanelMode = null; + gptCandidateHtml = ""; + gptConfigLoaded = false; dom.showTouchedBtn.classList.remove("primary"); dom.searchInput.value = ""; + dom.sidebar.hidden = true; + dom.tocPanel.hidden = true; + dom.searchPanel.hidden = true; + dom.tocPanelBtn.classList.remove("active"); + dom.searchPanelBtn.classList.remove("active"); if (dom.quickEditor) { dom.quickEditor.hidden = true; } @@ -160,7 +269,13 @@ function setShellMode(active) { dom.feedbackBtn.disabled = !active; dom.applyBtn.disabled = !active; dom.exportBtn.disabled = !active; + dom.tocPanelBtn.disabled = !active; + dom.searchPanelBtn.disabled = !active; + dom.closeSidebarBtn.disabled = !active; + dom.expandTocBtn.disabled = !active; + dom.collapseTocBtn.disabled = !active; document.body.classList.toggle("startOpen", !active); + if (!active) closeSidePanel(); if (!active) { dom.sessionMeta.textContent = "未打开 EPUB"; } @@ -249,6 +364,32 @@ function allChapterScopes() { })); } +function chapterIsExpanded(chapter) { + if (!chapter) return false; + return expandedChapters.has(chapter.id); +} + +function toggleChapterExpanded(chapter) { + if (!chapter) return; + if (expandedChapters.has(chapter.id)) { + expandedChapters.delete(chapter.id); + } else { + expandedChapters.add(chapter.id); + } + renderToc(); +} + +function expandAllChapters() { + expandedChapters = new Set((structure.chapters || []).map((chapter) => chapter.id)); + renderToc(); +} + +function collapseAllChapters() { + expandedChapters = new Set(); + if (currentChapter) expandedChapters.add(currentChapter.id); + renderToc(); +} + function partById(partId) { return allParts().find((part) => part.id === partId) || null; } @@ -257,6 +398,14 @@ function chapterById(chapterId) { return (structure.chapters || []).find((chapter) => chapter.id === chapterId) || null; } +function chapterKind(chapter) { + return chapter && chapter.kind === "image" ? "image" : "text"; +} + +function isImageChapter(chapter) { + return chapterKind(chapter) === "image"; +} + function partRows(part) { if (!part) return []; const ids = new Set(part.row_ids || []); @@ -279,6 +428,31 @@ function chapterRows(chapter) { return rows.filter((row) => ids.has(row.id)); } +function scopeCoversRow(row) { + if (!row) return false; + if (currentPart) return (currentPart.row_ids || []).includes(row.id); + if (currentChapter) { + return (currentChapter.parts || []).some((part) => (part.row_ids || []).includes(row.id)); + } + return true; +} + +function syncScopeToRow(row, forcePart = false) { + const nextPart = partForRow(row); + if (!nextPart) return; + const nextChapter = chapterForPart(nextPart); + if (!nextChapter) return; + if (forcePart || !currentChapter || currentChapter.id !== nextChapter.id || !scopeCoversRow(row)) { + currentChapter = nextChapter; + currentPart = nextPart; + expandedChapters.add(nextChapter.id); + return; + } + if (currentPart && currentPart.id !== nextPart.id) { + currentPart = nextPart; + } +} + function partVisibleRows(part) { const ids = new Set((part && part.row_ids) || []); return filteredRows.filter((row) => ids.has(row.id)); @@ -302,6 +476,22 @@ function scopeVisibleRows() { return filteredRows; } +function imageChapterMatchesSearch(chapter) { + if (!isImageChapter(chapter)) return false; + if (showTouchedOnly) return false; + const q = dom.searchInput.value.trim().toLowerCase(); + if (!q) return true; + const haystack = [ + chapter.id, + chapter.title, + chapter.file_label, + ...(chapter.images || []).flatMap((image) => [image.asset_name, image.alt, image.asset_path]), + ] + .join("\n") + .toLowerCase(); + return haystack.includes(q); +} + async function loadSession() { const session = await api("/api/session"); setVersionBadge(session.version); @@ -312,8 +502,9 @@ async function loadSession() { setShellMode(true); const chapterCount = structure.chapter_count || (structure.chapters || []).length; const partCount = structure.part_count || allParts().length; + const imageCount = structure.image_count || 0; const sourceName = session.source_name || session.source_epub; - dom.sessionMeta.textContent = `${sourceName} · ${session.row_count} 段 · ${chapterCount} 章 / ${partCount} part · 已记录 ${session.touched_count} 段`; + dom.sessionMeta.textContent = `${sourceName} · ${session.row_count} 段 · ${chapterCount} 章 / ${partCount} part / ${imageCount} 张图 · 已记录 ${session.touched_count} 段`; return session; } @@ -333,7 +524,7 @@ async function loadRows(keepSelection = false) { currentRow = nextRow; currentIndex = filteredRows.findIndex((row) => row.id === nextRow.id); if (currentPart && !(currentPart.row_ids || []).includes(nextRow.id)) { - currentPart = partForRow(nextRow); + syncScopeToRow(nextRow); } renderAll(); return; @@ -342,7 +533,7 @@ async function loadRows(keepSelection = false) { if (!currentPart) { currentChapter = (structure.chapters || [])[0] || null; - currentPart = currentChapter && (currentChapter.parts || []).length === 1 ? currentChapter.parts[0] : null; + currentPart = currentChapter && !isImageChapter(currentChapter) && (currentChapter.parts || []).length === 1 ? currentChapter.parts[0] : null; } if (filteredRows.length > 0 && currentIndex < 0) { currentRow = filteredRows[0]; @@ -405,6 +596,7 @@ function updateStructureCountsFromRows() { chapter.row_count = 0; chapter.touched_count = 0; chapter.marked_count = 0; + if (isImageChapter(chapter)) continue; for (const part of chapter.parts || []) { const partItems = partRows(part); part.row_count = partItems.length; @@ -464,35 +656,69 @@ function renderMode() { dom.editorPane.hidden = currentMode !== "polish"; dom.readingModeBtn.classList.toggle("active", currentMode === "reading"); dom.polishModeBtn.classList.toggle("active", currentMode === "polish"); + dom.polishModeBtn.disabled = !hasSession || (isImageChapter(currentChapter) && !currentPart); document.body.classList.toggle("quickOpen", currentMode === "reading" && !dom.quickEditor.hidden); + if (sidePanelMode) { + dom.tocPanel.hidden = sidePanelMode !== "toc"; + dom.searchPanel.hidden = sidePanelMode !== "search"; + } } function renderToc() { dom.toc.innerHTML = ""; const frag = document.createDocumentFragment(); for (const chapter of structure.chapters || []) { + const parts = chapter.parts || []; + const chapterIsImage = isImageChapter(chapter); + const isCurrentChapter = currentChapter && chapter.id === currentChapter.id; + const containsCurrentPart = parts.some((part) => currentPart && part.id === currentPart.id); + const expanded = !chapterIsImage && (chapterIsExpanded(chapter) || containsCurrentPart); const group = document.createElement("section"); group.className = "tocChapter"; + if (chapterIsImage) group.classList.add("imageChapter"); + if (expanded) group.classList.add("expanded"); + + const row = document.createElement("div"); + row.className = "tocChapterRow"; + if ((isCurrentChapter && !currentPart) || containsCurrentPart) { + row.classList.add("active"); + } + + const toggle = document.createElement("button"); + toggle.type = "button"; + toggle.className = "tocToggle"; + toggle.textContent = chapterIsImage ? "图" : expanded ? "▾" : "▸"; + toggle.disabled = chapterIsImage || parts.length === 0; + toggle.setAttribute("aria-label", chapterIsImage ? "插图章节" : expanded ? "收起章节" : "展开章节"); + toggle.setAttribute("aria-expanded", expanded ? "true" : "false"); + toggle.addEventListener("click", (event) => { + event.stopPropagation(); + if (chapterIsImage) return; + toggleChapterExpanded(chapter); + }); + row.appendChild(toggle); 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) + (isCurrentChapter && !currentPart) + || containsCurrentPart ) { title.classList.add("active"); } title.innerHTML = ` ${escapeHtml(chapter.title || chapter.id)} - ${chapter.row_count || 0} 段 + ${chapterIsImage ? `${(chapter.images || []).length || 1} 图` : `${chapter.row_count || 0} 段`} `; title.addEventListener("click", () => selectChapter(chapter)); - group.appendChild(title); + row.appendChild(title); + group.appendChild(row); const partList = document.createElement("div"); partList.className = "tocParts"; - for (const part of chapter.parts || []) { + partList.hidden = !expanded; + if (!chapterIsImage) for (const part of parts) { const button = document.createElement("button"); button.type = "button"; button.className = "tocPartButton"; @@ -517,10 +743,28 @@ 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}`; + const imageCount = structure.image_count || 0; + const isCurrentImage = isImageChapter(currentChapter) && !currentPart; + dom.stats.textContent = `显示 ${filteredRows.length} / ${rows.length} 段 · 当前 ${isCurrentImage ? `${(currentChapter.images || []).length} 图` : `${visibleInScope.length} 段`} · 插图 ${imageCount} · 已记录 ${touched} · 标记 ${marked}`; dom.rowList.innerHTML = ""; const frag = document.createDocumentFragment(); + if (isCurrentImage) { + const item = document.createElement("div"); + item.className = "rowItem imageRowItem active"; + const images = currentChapter.images || []; + item.innerHTML = ` +
+ ${escapeHtml(currentChapter.id)} + 插图 + ${escapeHtml(currentChapter.file_label || "")} +
+
${escapeHtml(images.map((image) => image.asset_name || image.asset_path).join(" / ") || "插图章节")}
+ `; + frag.appendChild(item); + dom.rowList.appendChild(frag); + return; + } visibleInScope.forEach((row) => { const button = document.createElement("button"); button.type = "button"; @@ -538,12 +782,37 @@ function renderList() {
${escapeHtml(rowCurrentText(row))}
`; - button.addEventListener("click", () => maybeSelectRow(row, { openEditor: currentMode === "polish", scroll: true })); + button.addEventListener("click", () => maybeSelectRow(row, { openEditor: currentMode === "polish", openQuick: currentMode === "reading", scroll: true })); frag.appendChild(button); }); dom.rowList.appendChild(frag); } +function renderImageChapter(chapter) { + const images = chapter.images || []; + dom.chapterKicker.textContent = chapter.file_label || "插图"; + dom.readerTitle.textContent = chapter.title || "插图"; + dom.readingFlow.innerHTML = ""; + if (!imageChapterMatchesSearch(chapter)) { + dom.readingFlow.innerHTML = '
当前筛选条件下没有插图。
'; + return; + } + const frag = document.createDocumentFragment(); + images.forEach((image, index) => { + const figure = document.createElement("figure"); + figure.className = "imageChapterFigure"; + figure.innerHTML = ` + ${escapeHtml(image.alt || chapter.title || +
+ ${escapeHtml(chapter.title || `插图 ${index + 1}`)} + ${escapeHtml(image.asset_name || image.asset_path || "")} +
+ `; + frag.appendChild(figure); + }); + dom.readingFlow.appendChild(frag); +} + function renderReading() { const selectedChapter = currentChapter || chapterForPart(currentPart); if (!currentPart && !selectedChapter) { @@ -554,6 +823,10 @@ function renderReading() { } const selectedPart = currentPart; + if (!selectedPart && isImageChapter(selectedChapter)) { + renderImageChapter(selectedChapter); + return; + } dom.chapterKicker.textContent = selectedChapter ? selectedChapter.title : selectedPart.file_label || ""; dom.readerTitle.textContent = selectedPart ? selectedPart.title || selectedPart.file_label || "未命名 part" @@ -587,31 +860,13 @@ function renderReading() { 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; - } + block.addEventListener("click", () => { maybeSelectRow(row, { openQuick: true, scroll: false }); }); frag.appendChild(block); @@ -620,8 +875,20 @@ function renderReading() { } function renderEditor() { + if (isImageChapter(currentChapter) && !currentPart) { + dom.emptyState.hidden = false; + dom.emptyState.textContent = "插图章节没有可编辑译文,请切回阅读模式查看图片。"; + dom.editorCard.hidden = true; + return; + } + if (currentRow && !scopeCoversRow(currentRow)) { + const visible = scopeVisibleRows(); + const fallback = scopeRows(); + currentRow = visible[0] || fallback[0] || null; + } if (!currentRow) { dom.emptyState.hidden = false; + dom.emptyState.textContent = "选择左侧段落开始审校。"; dom.editorCard.hidden = true; return; } @@ -654,6 +921,10 @@ function fillQuickEditor(row) { dom.quickTagsInput.value = row.tags || ""; dom.quickCommentInput.value = row.comment || ""; dom.quickLearnNoteInput.value = row.learn_note || ""; + gptCandidateHtml = ""; + dom.gptCandidate.hidden = true; + dom.gptCandidate.innerHTML = ""; + dom.applyRetranslationBtn.disabled = true; quickDirty = false; } @@ -714,7 +985,7 @@ async function saveRow(row, data, silent = false) { dirty = false; quickDirty = false; applyFilter(false); - renderAll(); + withReaderAnchor(() => renderAll()); await loadSession(); if (!silent) toast("已保存本段记录。"); return true; @@ -726,6 +997,7 @@ async function saveRow(row, data, silent = false) { async function saveCurrent(silent = false) { if (!hasSession) return true; + if (isImageChapter(currentChapter) && !currentPart) return true; if (!currentRow) return true; return saveRow(currentRow, collectCurrent(), silent); } @@ -750,11 +1022,7 @@ 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; - } + syncScopeToRow(row, Boolean(options.forcePart)); dirty = false; if (options.openEditor) { @@ -773,15 +1041,25 @@ async function selectChapter(chapter) { 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; + currentPart = null; + expandedChapters.add(chapter.id); + if (isImageChapter(chapter)) { + currentRow = null; + currentIndex = -1; + closeQuickEditor(false); + currentMode = "reading"; + } else { + const ids = new Set((chapter.parts || []).flatMap((part) => part.row_ids || [])); + const visible = filteredRows.filter((row) => ids.has(row.id)); + const fallback = rows.filter((row) => ids.has(row.id)); + 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" }); } + if (sidePanelMode === "toc") closeSidePanel(); } async function selectPart(part) { @@ -790,6 +1068,7 @@ async function selectPart(part) { if (!ok) return; currentChapter = chapterForPart(part); currentPart = part; + if (currentChapter) expandedChapters.add(currentChapter.id); const visible = partVisibleRows(part); const fallback = partRows(part); currentRow = visible[0] || fallback[0] || currentRow; @@ -798,6 +1077,7 @@ async function selectPart(part) { if (currentMode === "reading") { dom.readerPane.scrollTo({ top: 0, behavior: "smooth" }); } + if (sidePanelMode === "toc") closeSidePanel(); } function scrollRowIntoView(rowId) { @@ -814,27 +1094,34 @@ async function setMode(mode, saveBefore = true) { const ok = await maybeSaveBeforeSwitch(); if (!ok) return; } + if (mode === "polish" && isImageChapter(currentChapter) && !currentPart) { + toast("插图章节没有可编辑译文,已保持阅读模式。"); + mode = "reading"; + } currentMode = mode; + if (mode === "reading" && currentPart && isImageChapter(currentChapter)) { + currentPart = null; + } if (mode === "polish") { closeQuickEditor(false); - if (!currentRow && filteredRows.length) currentRow = filteredRows[0]; + if (!scopeCoversRow(currentRow)) currentRow = null; + if (!currentRow) currentRow = scopeVisibleRows()[0] || scopeRows()[0] || filteredRows[0] || null; } - renderAll(); + withReaderAnchor(() => 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(); + withReaderAnchor(() => { + currentRow = row; + currentIndex = filteredRows.findIndex((item) => item.id === row.id); + syncScopeToRow(row); + fillQuickEditor(row); + dom.quickEditor.hidden = false; + document.body.classList.add("quickOpen"); + renderAll(); + }); + loadGptConfig().catch(() => {}); } function closeQuickEditor(save = false) { @@ -842,25 +1129,12 @@ function closeQuickEditor(save = false) { 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); + withReaderAnchor(() => { + dom.quickEditor.hidden = true; + document.body.classList.remove("quickOpen"); + quickDirty = false; + renderAll(); + }); } function markDirty() { @@ -871,6 +1145,85 @@ function markQuickDirty() { quickDirty = true; } +function renderGptConfig(config) { + dom.gptBaseUrlInput.value = config.base_url || "https://api.openai.com/v1"; + dom.gptModelInput.value = config.model || "gpt-4o-mini"; + dom.gptApiKeyInput.value = ""; + const source = config.key_source === "env" ? "环境变量" : config.key_source === "config" ? "本地配置" : "未配置"; + dom.gptStatus.textContent = config.configured ? `已配置 · ${source} · ${config.model}` : "未配置 API Key,保存设置后可重翻。"; +} + +async function loadGptConfig(force = false) { + if (gptConfigLoaded && !force) return; + try { + const config = await api("/api/gpt/config"); + renderGptConfig(config); + gptConfigLoaded = true; + } catch (error) { + dom.gptStatus.textContent = `读取 GPT 配置失败:${error.message}`; + } +} + +async function saveGptConfig() { + try { + const config = await api("/api/gpt/config", { + method: "POST", + body: JSON.stringify({ + base_url: dom.gptBaseUrlInput.value, + model: dom.gptModelInput.value, + api_key: dom.gptApiKeyInput.value, + keep_existing: true, + }), + }); + renderGptConfig(config); + gptConfigLoaded = true; + toast("GPT API 设置已保存。"); + } catch (error) { + toast(`保存 GPT 设置失败:${error.message}`, 7000); + } +} + +async function retranslateCurrentRow() { + if (!currentRow) return; + if (quickDirty) { + const ok = await saveCurrent(true); + if (!ok) return; + } + dom.retranslateBtn.disabled = true; + dom.retranslateBtn.textContent = "重翻中……"; + dom.applyRetranslationBtn.disabled = true; + dom.gptCandidate.hidden = false; + dom.gptCandidate.textContent = "正在请求 GPT……"; + try { + const data = await api(`/api/row/${encodeURIComponent(currentRow.id)}/retranslate`, { + method: "POST", + body: JSON.stringify({ + instruction: dom.gptInstructionInput.value, + base_url: dom.gptBaseUrlInput.value, + model: dom.gptModelInput.value, + }), + }); + gptCandidateHtml = data.candidate_html || ""; + dom.gptCandidate.innerHTML = gptCandidateHtml || escapeHtml(data.candidate_text || ""); + dom.applyRetranslationBtn.disabled = !gptCandidateHtml; + toast("已生成重翻候选,可确认后应用。"); + } catch (error) { + dom.gptCandidate.textContent = `重翻失败:${error.message}`; + toast(`重翻失败:${error.message}`, 9000); + } finally { + dom.retranslateBtn.disabled = false; + dom.retranslateBtn.textContent = "重翻本段"; + } +} + +function applyRetranslation() { + if (!gptCandidateHtml) return; + dom.quickCnEditor.innerHTML = gptCandidateHtml; + quickDirty = true; + dom.applyRetranslationBtn.disabled = true; + toast("已应用到编辑框,保存后才会写入审校记录。"); +} + function resetCurrentText() { if (!currentRow) return; dom.cnEditor.innerHTML = currentRow.cn_html || ""; @@ -993,11 +1346,28 @@ function initEvents() { currentIndex = -1; applyFilter(true); }); + dom.tocPanelBtn.addEventListener("click", () => { + if (sidePanelMode === "toc") { + closeSidePanel(); + } else { + openSidePanel("toc"); + } + }); + dom.searchPanelBtn.addEventListener("click", () => { + if (sidePanelMode === "search") { + closeSidePanel(); + } else { + openSidePanel("search"); + } + }); + dom.closeSidebarBtn.addEventListener("click", closeSidePanel); dom.showTouchedBtn.addEventListener("click", () => { showTouchedOnly = !showTouchedOnly; dom.showTouchedBtn.classList.toggle("primary", showTouchedOnly); applyFilter(true); }); + dom.expandTocBtn.addEventListener("click", expandAllChapters); + dom.collapseTocBtn.addEventListener("click", collapseAllChapters); dom.readingModeBtn.addEventListener("click", () => setMode("reading")); dom.polishModeBtn.addEventListener("click", () => setMode("polish")); dom.openLibraryBtn.addEventListener("click", async () => { @@ -1026,6 +1396,13 @@ function initEvents() { dom.closeQuickEditorBtn.addEventListener("click", () => closeQuickEditor(false)); dom.quickSaveBtn.addEventListener("click", () => saveCurrent(false)); dom.quickMarkSelectionBtn.addEventListener("click", markQuickSelection); + dom.toggleGptConfigBtn.addEventListener("click", () => { + dom.gptConfig.hidden = !dom.gptConfig.hidden; + if (!dom.gptConfig.hidden) loadGptConfig(true).catch(() => {}); + }); + dom.saveGptConfigBtn.addEventListener("click", saveGptConfig); + dom.retranslateBtn.addEventListener("click", retranslateCurrentRow); + dom.applyRetranslationBtn.addEventListener("click", applyRetranslation); dom.quickOpenFullBtn.addEventListener("click", async () => { if (quickDirty) { const ok = await saveCurrent(true); @@ -1060,6 +1437,11 @@ function initEvents() { if (event.key === "Escape" && !dom.quickEditor.hidden) { event.preventDefault(); closeQuickEditor(false); + return; + } + if (event.key === "Escape" && sidePanelMode) { + event.preventDefault(); + closeSidePanel(); } }); @@ -1077,7 +1459,7 @@ async function main() { if (!session || !session.has_session) { return; } - await loadActiveSession({ message: "阅读模式已就绪。可连续阅读,点击段落即可快速编辑或标记。" }); + await loadActiveSession({ message: "阅读模式已就绪。可连续阅读,点击段落即可打开精修与重翻面板。" }); } catch (error) { await showStartScreen(); toast(`启动时未载入 EPUB:${error.message}`, 7000); diff --git a/tools/epub_review_editor/static/index.html b/tools/epub_review_editor/static/index.html index 9b98196..8779eb8 100644 --- a/tools/epub_review_editor/static/index.html +++ b/tools/epub_review_editor/static/index.html @@ -9,7 +9,7 @@
-

双语 EPUB 审校编辑器 v0.2.0

+

双语 EPUB 审校编辑器 v0.4.0

正在载入……

@@ -17,6 +17,8 @@
+ + @@ -55,13 +57,28 @@
-
-