diff --git a/tools/epub_review_editor/LONG_TERM_MEMORY.md b/tools/epub_review_editor/LONG_TERM_MEMORY.md index c3c01bc..95606e9 100644 --- a/tools/epub_review_editor/LONG_TERM_MEMORY.md +++ b/tools/epub_review_editor/LONG_TERM_MEMORY.md @@ -15,15 +15,17 @@ ## 当前架构状态 -- `0.13.0` 引入元数据书架:`/api/sessions` 与 `/api/library` 生成 `library.json`,书籍会带 `metadata`、`series_id`、`cover_url` 等字段。 +- `0.13.1` 当前版本建立在元数据书架上:`/api/sessions` 与 `/api/library` 生成 `library.json`,书籍会带 `metadata`、`series_id`、`cover_url` 等字段;书架删除是软删除,只隐藏 session,不删除原始 EPUB。 - 同系列配置存放在 `epub_review_sessions/series_configs/.json`,只保存术语表路径和三段提示词,不保存 API Key。 - 整书翻译源抽取不使用双语审校的 `rows.json`;`rows.json` 是审校行数,日语 EPUB 的可翻译数量看 `/api/session//translation-source`,UI 文案应称为“正文块”以避免和双语审校“段”混淆。 - 翻译源统计应区分可翻译正文块、HTML 文件、正文段、标题、图片、目录和空白块;默认试译只翻译前 20 段/块,不代表源书被删。 +- 翻译页默认应保持克制,只显示源书名和可翻译正文块数量;冗余段落预览、诊断 note 和大批统计 chip 不应默认出现在 UI。 ## 已知排查结论 - `TRPGプレイヤーが異世界で最強ビルドを目指す 3` 日版 EPUB 解包内容未被删除。此前用户看到数量偏少,原因是 UI 混用了双语审校 `row_count` 与日语源书 `source_count`,且试译 job 的 `total=25` 只是范围限制。 - 当前修复后,该 session 的可翻译源正文块为 `2949`,统计包含 `3084` 个候选块、`3083` 个 `

`、`1` 个标题、`27` 个图片块、`7` 个目录块和 `129` 个空白块。 +- 2026-07-08 修复整书翻译重复层:如果输入已经是中日双语 EPUB,翻译源抽取只把显式灰色/source 源段作为 item,并记录下一段中文 pair;双语输出替换旧中文层,纯中文输出移除对应日文层。`build_rows()` 也收敛为只接受显式灰色/source 标记,不再因含假名就把普通段落当源文。 ## 下次优先检查 diff --git a/tools/epub_review_editor/MAINTENANCE.md b/tools/epub_review_editor/MAINTENANCE.md index 34490bf..f19cd45 100644 --- a/tools/epub_review_editor/MAINTENANCE.md +++ b/tools/epub_review_editor/MAINTENANCE.md @@ -79,7 +79,8 @@ epub_review_sessions/ - 没有历史书籍时,书架显示空状态并提供“打开新的 EPUB”入口;开始页/书架页顶栏保持正常显示,不套用审校页沉浸隐藏。 - 书架应按 EPUB 元数据建立分类:全部书籍、同系列、作者、出版社、语言;同系列分类使用稳定 `series_id`,显示书籍数量。 - 书架主区域以封面网格为主,不用横向信息卡替代封面;无封面时使用稳定 fallback,不出现破图或布局跳动。 -- 鼠标悬停或键盘 focus 到封面时,封面变暗并显示书名、系列/作者/卷数、审校段数、最近更新时间,以及“校对”“翻译”两个按钮;点击“翻译”不得触发“校对”。 +- 鼠标悬停或键盘 focus 到封面时,封面变暗并显示书名、系列/作者/卷数、审校段数、最近更新时间,以及“校对”“翻译”“删除”按钮;点击“翻译”或“删除”不得触发“校对”。 +- 书架删除默认是软删除:只在 session manifest/state 中写入 `hidden` / `deleted_at`,从 `/api/sessions` 与 `/api/library` 隐藏,不删除原始 EPUB、导出 EPUB 或反馈文件;删除 active session 时必须清空 `active_session_id`。 - 选中同系列分类后,必须能打开“系列设置”,设置该系列共享的术语表路径、翻译内容提示词、格式提示词和角色口吻提示词。 - 初始进入审校时,左侧侧栏隐藏,正文占主要空间。 - 顶部“目录”只打开目录,“检索”只打开搜索/段落列表,不合并成一个常驻面板。 @@ -192,7 +193,9 @@ Prompt 硬规则: 输出与书架: -- `bilingual` 模式在原日文 `

` 后插入中文 `

`,并把原日文段落标为灰色,方便进入现有双语审校器逐段审校。 +- `bilingual` 模式在原日文 `

` 后插入中文 `

`,并把原日文段落标为灰色且标记为 source,方便进入现有双语审校器逐段审校。 +- 如果输入已经是中日双语 EPUB,翻译源抽取必须识别显式灰色/source 源段与下一段中文译文的 pair;`bilingual` 模式应替换已有中文层,不得追加第二个中文层;`translated` 模式应输出新的中文层并移除对应日文源层。 +- 如果历史 session 已经出现“日文源层 + 中文层 + 旧中文灰色源层 + 新中文层”的四层坏结构,进入翻译源统计、整书翻译或 rows 重建前应自动清理为“日文源层 + 第一个中文层”,避免旧中文继续被当作日文源文。 - `translated` 模式用中文替换原日文段落;该输出会加入书架,但由于没有日中成对段落,可能没有逐段双语审校行。 - 图片、样式、OPF、spine 和非正文文件应原样保留。 - 翻译失败的单段必须写入可见占位和任务日志,不能让整本输出静默缺段。 @@ -265,10 +268,13 @@ Prompt 硬规则: - 书架卡片“翻译”按钮能进入独立翻译页,且不会触发卡片的“进入审校” - `/api/translation/defaults` 不返回 API Key,提示词和术语表路径可读取 - `/api/session//translation-source` 能统计日语 EPUB 可翻译正文块和诊断字段;非日语或无正文时返回清晰空状态;审校行数、源正文块数、试译 limit 不得在 UI 上混为一个“段落数” +- 翻译页默认只显示源书名和“可翻译正文块 N 个”,不保留冗余段落预览、诊断 note 或大批统计 chip 的可见入口;诊断字段可保留在 API 中供排查 - 未配置 API Key 时,`/api/translation/start` 返回清晰错误,不影响阅读、编辑、导出和单段重翻 - 启动整书翻译任务后,`/api/translation/jobs/` 可看到 pending/running/completed/failed、总段数、已完成数、当前文件和日志 - 默认翻译范围是前 20 段试译;全书翻译必须由用户显式切换并确认 - 整书翻译每段只注入该段源文/ruby 命中的术语,不注入无关术语或全表 - 整书翻译完成后,输出 EPUB 写入 `translated_outputs/` 并自动出现在书架;中日双语输出打开后 `row_count > 0` +- 已双语 EPUB 再次执行整书翻译时,输出不得出现“旧中文译文 + 新中文译文”重复层;审校行 `jp_text` 不得变成中文旧译文 - 纯中文输出可以加入书架,且文档说明其不适合逐段双语审校 - 离开翻译页、返回书架或打开输出审校时,前端进度轮询会停止 +- 书架卡片“删除”按钮能软删除会话、刷新书架、清空 active session(如删除当前书),且不会删除原始 EPUB 文件 diff --git a/tools/epub_review_editor/README.md b/tools/epub_review_editor/README.md index 448dd0a..24ea65d 100644 --- a/tools/epub_review_editor/README.md +++ b/tools/epub_review_editor/README.md @@ -1,6 +1,6 @@ # 双语 EPUB 审校编辑器 -当前版本:`0.13.0` +当前版本:`0.13.1` 这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。 @@ -48,6 +48,8 @@ `0.13.0` 新增元数据书架与同系列配置:书架按 EPUB 元数据建立 `library.json`,左侧提供系列、作者、出版社、语言分类,封面悬停显示书籍信息以及“校对/翻译”入口;同系列可共享术语表路径、翻译内容提示词、格式提示词和角色口吻提示词。AI 翻译源统计改为正文块诊断,区分审校行数、可翻译日文正文块、图片、目录和空白块,并补翻日文标题与日语上下文里的短句,避免把默认试译范围误认为正文被删。 +`0.13.1` 修复整书 AI 翻译重复正文块:已是中日双语的 EPUB 再翻译时会识别旧的日文源文/中文译文 pair,并替换中文层,不再把旧译文当成源文追加新译文;审校行识别也收敛为只接受显式灰色/source 标记。翻译页删除冗余段落预览和诊断提示,统一使用“正文块”口径,书架封面悬停菜单新增“删除”,可从书架软删除会话且不删除原始 EPUB。 + ## 独立安装 这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub_review_editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖: @@ -108,7 +110,7 @@ python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessio - 点击“选择 EPUB 文件”,上传中日双语 EPUB 后会自动创建审校会话。 - 开始页会列出已有会话,换电脑或重启服务后可以继续打开之前的审校记录。 - 顶栏左上角“书架”会列出历史打开过的书籍,并按 EPUB 元数据生成分类;点击封面或“校对”会进入对应审校页面。 -- 书架中每本书封面悬停会变暗并显示书籍信息、“校对”和“翻译”按钮;“翻译”可对日语 EPUB 发起 AI 翻译任务,输出 EPUB 会自动加入书架。 +- 书架中每本书封面悬停会变暗并显示书籍信息、“校对”“翻译”和“删除”按钮;“翻译”可对日语 EPUB 发起 AI 翻译任务,输出 EPUB 会自动加入书架;“删除”只会从书架隐藏审校会话,不删除原始 EPUB。 - 上传的 EPUB、解包副本、反馈与导出文件都会保存在 `--review-root` 指定目录下。 ## 能做什么 diff --git a/tools/epub_review_editor/server.py b/tools/epub_review_editor/server.py index 4267985..e26487a 100644 --- a/tools/epub_review_editor/server.py +++ b/tools/epub_review_editor/server.py @@ -105,6 +105,7 @@ DEFAULT_TRANSLATION_LIMIT = 20 TRANSLATION_RANGE_LIMIT_MAX = 5000 TRANSLATION_CONTEXT_RADIUS = 1 LIBRARY_DB_NAME = "library.json" +ROWS_PARSER_VERSION = "0.13.1-translation-pair-cleanup" def now_iso() -> str: @@ -235,17 +236,63 @@ def is_japaneseish(fragment: str) -> bool: def is_gray_source(attrs: str, inner: str) -> bool: if GRAY_RE.search(attrs): return True - return is_japaneseish(inner) + return False def paragraph_visible_text(inner: str) -> str: return compact_text(strip_tags(inner or "")) +def comparable_visible_text(inner: str) -> str: + return re.sub(r"\s+", "", paragraph_visible_text(inner)) + + def paragraph_prompt_text(inner: str) -> str: return strip_tags(inner or "").strip() +def has_explicit_source_marker(open_tag: str) -> bool: + return bool(GRAY_RE.search(open_tag or "")) + + +def has_explicit_translation_marker(open_tag: str) -> bool: + match = re.search(r"\bclass\s*=\s*(['\"])(.*?)\1", open_tag or "", flags=re.I | re.S) + if not match: + return False + classes = {item.lower() for item in re.split(r"\s+", match.group(2).strip()) if item} + return bool(classes & {"cntext", "aitranslation", "translation", "zh", "cn"}) + + +def is_probable_translation_layer(match: re.Match[str]) -> bool: + if has_explicit_source_marker(match.group("open")): + return False + if has_explicit_translation_marker(match.group("open")): + return True + visible = paragraph_visible_text(match.group("inner")) + if not visible: + return False + return not KANA_RE.search(strip_html_keep_ruby_text(match.group("inner"))) + + +def is_existing_bilingual_translation_pair(source_match: re.Match[str], next_match: re.Match[str]) -> bool: + return has_explicit_source_marker(source_match.group("open")) and is_probable_translation_layer(next_match) + + +def merge_attr_value(open_tag: str, attr_name: str, value: str) -> str: + pattern = re.compile(rf"(\s{re.escape(attr_name)}\s*=\s*['\"])(.*?)(['\"])", flags=re.I | re.S) + + def replace(match: re.Match[str]) -> str: + current = match.group(2) + values = current.split() + if value not in values: + values.append(value) + return f"{match.group(1)}{' '.join(values)}{match.group(3)}" + + if pattern.search(open_tag): + return pattern.sub(replace, open_tag, count=1) + return re.sub(r">\s*$", f' {attr_name}="{html.escape(value, quote=True)}">', open_tag) + + def html_declares_japanese(text: str) -> bool: return bool(re.search(r"\b(?:xml:)?lang\s*=\s*['\"]ja(?:[-_'\"]|$)", text or "", flags=re.I)) @@ -995,6 +1042,10 @@ def session_manifest_path(session_root: Path) -> Path: return session_root / "review_state" / "session.json" +def rows_meta_path(session_root: Path) -> Path: + return session_root / "review_state" / "rows_meta.json" + + def library_db_path(review_root: Path) -> Path: return review_root / LIBRARY_DB_NAME @@ -1023,6 +1074,8 @@ def write_session_manifest(session_root: Path, epub_path: Path, source_name: str existing = read_json(session_manifest_path(session_root), {}) metadata = read_epub_metadata(session_root) metadata["source_name"] = source_name or epub_path.name + existing.pop("hidden", None) + existing.pop("deleted_at", None) record = { "id": session_public_id(session_root), "source_name": source_name or epub_path.name, @@ -1096,6 +1149,29 @@ def read_reading_position(session_root: Path) -> dict[str, Any]: return normalize_reading_position(state.get("reading_position", {})) +def session_is_hidden(session_root: Path) -> bool: + manifest = read_json(session_manifest_path(session_root), {}) + state = read_json(session_root / "review_state" / "state.json", {}) + return bool(manifest.get("hidden") or manifest.get("deleted_at") or state.get("hidden") or state.get("deleted_at")) + + +def mark_session_deleted(session_root: Path) -> dict[str, Any]: + deleted_at = now_iso() + manifest_path = session_manifest_path(session_root) + manifest = read_json(manifest_path, {}) + manifest["hidden"] = True + manifest["deleted_at"] = deleted_at + manifest["updated_at"] = deleted_at + write_json(manifest_path, manifest) + state_path = session_root / "review_state" / "state.json" + state = read_json(state_path, {}) + state["hidden"] = True + state["deleted_at"] = deleted_at + state["updated_at"] = deleted_at + write_json(state_path, state) + return {"hidden": True, "deleted_at": deleted_at} + + def summarize_session(session_root: Path) -> dict[str, Any]: manifest = read_json(session_manifest_path(session_root), {}) state = read_json(session_root / "review_state" / "state.json", {}) @@ -1166,6 +1242,8 @@ def list_review_sessions(review_root: Path) -> list[dict[str, Any]]: continue if not (child / "review_state" / "state.json").exists(): continue + if session_is_hidden(child): + continue try: sessions.append(summarize_session(child)) except Exception: @@ -1502,6 +1580,7 @@ def translation_source_stats(session_root: Path) -> dict[str, Any]: skipped_blank = 0 skipped_image = 0 skipped_no_japanese = 0 + skipped_existing_translation = 0 skipped_no_japanese_examples: list[dict[str, Any]] = [] translatable_by_file: dict[str, int] = {} for entry_name in html_files: @@ -1518,7 +1597,10 @@ def translation_source_stats(session_root: Path) -> dict[str, Any]: if nav: skipped_nav_files += 1 skipped_nav_blocks += len(matches) - for match in matches: + for block_index, match in enumerate(matches): + if block_index > 0 and is_existing_bilingual_translation_pair(matches[block_index - 1], match): + skipped_existing_translation += 1 + continue tag = match.group("tag").lower() inner = match.group("inner") visible = paragraph_visible_text(inner) @@ -1558,6 +1640,7 @@ def translation_source_stats(session_root: Path) -> dict[str, Any]: "skipped_blank": skipped_blank, "skipped_image": skipped_image, "skipped_no_japanese": skipped_no_japanese, + "skipped_existing_translation": skipped_existing_translation, "skipped_no_japanese_examples": skipped_no_japanese_examples, "translatable_by_file": translatable_by_file, } @@ -2224,6 +2307,80 @@ def build_structure(session_root: Path) -> dict[str, Any]: } +def is_source_match(match: re.Match[str]) -> bool: + open_tag = match.group(1) + attrs = open_tag[2:-1] + return is_gray_source(attrs, match.group(2)) + + +def normalize_duplicate_bilingual_layers(session_root: Path) -> int: + changed_files = 0 + try: + html_entries = html_entries_in_reading_order(session_root) + except Exception: + return 0 + for entry_name in html_entries: + path = extracted_path(session_root, entry_name) + if not path.exists(): + continue + text = path.read_text(encoding="utf-8", errors="replace") + matches = list(P_RE.finditer(text)) + if len(matches) < 4: + continue + parts: list[str] = [] + last = 0 + index = 0 + changed = False + while index < len(matches): + if index + 3 < len(matches): + source = matches[index] + old_cn = matches[index + 1] + duplicated_source = matches[index + 2] + new_cn = matches[index + 3] + duplicated_text = comparable_visible_text(duplicated_source.group(2)) + if ( + is_source_match(source) + and not is_source_match(old_cn) + and is_source_match(duplicated_source) + and not is_source_match(new_cn) + and duplicated_text + and not KANA_RE.search(strip_html_keep_ruby_text(duplicated_source.group(2))) + ): + parts.append(text[last:duplicated_source.start()]) + last = new_cn.end() + index += 4 + changed = True + continue + index += 1 + if changed: + parts.append(text[last:]) + path.write_text("".join(parts), encoding="utf-8") + changed_files += 1 + return changed_files + + +def rows_need_rebuild(session_root: Path) -> bool: + rows_path = session_root / "review_state" / "rows.json" + if not rows_path.exists(): + return True + meta = read_json(rows_meta_path(session_root), {}) + return meta.get("parser_version") != ROWS_PARSER_VERSION + + +def ensure_rows_current(session_root: Path) -> list[dict[str, Any]]: + if rows_need_rebuild(session_root): + normalize_duplicate_bilingual_layers(session_root) + return build_rows(session_root) + return read_json(session_root / "review_state" / "rows.json", []) + + +def normalize_duplicate_layers_and_rows(session_root: Path) -> int: + changed = normalize_duplicate_bilingual_layers(session_root) + if changed: + build_rows(session_root) + return changed + + def build_rows(session_root: Path) -> list[dict[str, Any]]: html_entries = html_entries_in_reading_order(session_root) rows: list[dict[str, Any]] = [] @@ -2267,6 +2424,14 @@ def build_rows(session_root: Path) -> list[dict[str, Any]]: else: i += 1 write_json(session_root / "review_state" / "rows.json", rows) + write_json( + rows_meta_path(session_root), + { + "parser_version": ROWS_PARSER_VERSION, + "updated_at": now_iso(), + "row_count": len(rows), + }, + ) return rows @@ -2274,8 +2439,8 @@ def ensure_state(session_root: Path, epub_path: Path, reset: bool = False) -> di 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) + if rows is None or rows_need_rebuild(session_root): + rows = ensure_rows_current(session_root) state_path = session_root / "review_state" / "state.json" state = read_json( state_path, @@ -2289,13 +2454,15 @@ def ensure_state(session_root: Path, epub_path: Path, reset: bool = False) -> di ) state.setdefault("edits", {}) state["source_epub"] = str(epub_path) + state.pop("hidden", None) + state.pop("deleted_at", None) write_json(state_path, state) write_session_manifest(session_root, epub_path) return state def merge_rows(session_root: Path) -> list[dict[str, Any]]: - rows = read_json(session_root / "review_state" / "rows.json", []) + rows = ensure_rows_current(session_root) state = read_json(session_root / "review_state" / "state.json", {"edits": {}}) edits = state.get("edits", {}) merged = [] @@ -2514,7 +2681,7 @@ def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, A try: limit = int(data.get("limit") or DEFAULT_TRANSLATION_LIMIT) except (TypeError, ValueError) as exc: - raise ValueError("试译段数必须是数字") from exc + raise ValueError("试译正文块数必须是数字") from exc limit = max(1, min(limit, TRANSLATION_RANGE_LIMIT_MAX)) try: temperature = float(data.get("temperature", 0.2)) @@ -2566,11 +2733,18 @@ def extract_translation_source_items(session_root: Path) -> list[dict[str, Any]] document_title = read_document_title(session_root, entry_name) or Path(entry_name).stem file_has_japanese_context = source_file_has_japanese_context(text) file_serial = 0 - for block_index, match in enumerate(SOURCE_BLOCK_RE.finditer(text)): + matches = list(SOURCE_BLOCK_RE.finditer(text)) + for block_index, match in enumerate(matches): + if block_index > 0 and is_existing_bilingual_translation_pair(matches[block_index - 1], match): + continue tag = match.group("tag").lower() inner = match.group("inner") if not is_translatable_source_block(inner, tag, file_has_japanese_context): continue + paired_cn_index = block_index + 1 if ( + block_index + 1 < len(matches) + and is_existing_bilingual_translation_pair(match, matches[block_index + 1]) + ) else None source_text = paragraph_prompt_text(inner) serial += 1 file_serial += 1 @@ -2583,6 +2757,7 @@ def extract_translation_source_items(session_root: Path) -> list[dict[str, Any]] "file_label": Path(entry_name).name, "document_title": document_title, "block_index": block_index, + "paired_cn_index": paired_cn_index, "p_index": block_index, "tag": tag, "open_tag": match.group("open"), @@ -2669,31 +2844,54 @@ def add_gray_style_to_p(open_tag: str) -> str: return re.sub(r">\s*$", ' style="color: #808080;">', open_tag) +def bilingual_source_open_tag(open_tag: str) -> str: + return merge_attr_value(add_gray_style_to_p(open_tag), "class", "sourceText") + + +def translated_open_tag(tag: str) -> str: + tag = tag if tag in {"p", "h1", "h2", "h3", "h4", "h5", "h6"} else "p" + return f'<{tag} class="cnText aiTranslation">' + + def replace_translated_paragraphs( text: str, - replacements: dict[int, dict[str, str]], + replacements: dict[int, dict[str, Any]], output_mode: str, ) -> str: parts: list[str] = [] last = 0 + skip_indexes = { + int(replacement["paired_cn_index"]) + for replacement in replacements.values() + if replacement.get("paired_cn_index") is not None + } for index, match in enumerate(SOURCE_BLOCK_RE.finditer(text)): parts.append(text[last:match.start()]) - if index not in replacements: + if index in skip_indexes: + pass + elif index not in replacements: parts.append(match.group(0)) elif output_mode == "bilingual": replacement = replacements[index] - source_open = add_gray_style_to_p(match.group("open")) + source_open = bilingual_source_open_tag(match.group("open")) + translated_tag = replacement.get("tag") or "p" parts.append(source_open) parts.append(match.group("inner")) parts.append(match.group("close")) - parts.append(f"\n<{replacement.get('tag') or 'p'}>") + parts.append(f"\n{translated_open_tag(translated_tag)}") parts.append(replacement.get("html", "")) - parts.append(f"") + parts.append(f"") else: replacement = replacements[index] - parts.append(match.group("open")) - parts.append(replacement.get("html", "")) - parts.append(match.group("close")) + if replacement.get("paired_cn_index") is not None: + translated_tag = replacement.get("tag") or "p" + parts.append(translated_open_tag(translated_tag)) + parts.append(replacement.get("html", "")) + parts.append(f"") + else: + parts.append(match.group("open")) + parts.append(replacement.get("html", "")) + parts.append(match.group("close")) last = match.end() parts.append(text[last:]) return "".join(parts) @@ -2709,7 +2907,7 @@ def build_translation_output_epub( out_path: Path, ) -> Path: entries = read_json(session_root / "review_state" / "epub_entries.json", []) - by_file: dict[str, dict[int, dict[str, str]]] = {} + by_file: dict[str, dict[int, dict[str, Any]]] = {} for item in items: translated = translations.get(item["id"]) if translated is None: @@ -2719,6 +2917,8 @@ def build_translation_output_epub( by_file.setdefault(item["file"], {})[int(item.get("block_index", item.get("p_index", 0)))] = { "html": translated, "tag": translated_tag, + "paired_cn_index": item.get("paired_cn_index"), + "paired_cn_tag": translated_tag, } out_path.parent.mkdir(parents=True, exist_ok=True) @@ -2812,6 +3012,7 @@ def run_translation_job(review_root: Path, job_id: str) -> None: glossary_file = Path(str(settings.get("glossary_path") or config.get("glossary_path") or "")) glossary = read_glossary_mapping_from_path(glossary_file) + normalize_duplicate_layers_and_rows(source_session) all_items = extract_translation_source_items(source_session) source_total = len(all_items) if settings.get("range_mode") == "all": @@ -2819,13 +3020,13 @@ def run_translation_job(review_root: Path, job_id: str) -> None: else: selected_items = all_items[: int(settings.get("limit") or DEFAULT_TRANSLATION_LIMIT)] if not selected_items: - raise ValueError("没有找到可翻译的日文正文段落") + raise ValueError("没有找到可翻译的日文正文块") job["progress"].update( { "total": len(selected_items), "source_total": source_total, - "message": f"已找到 {source_total} 个日文段落,开始翻译 {len(selected_items)} 段。", + "message": f"已找到 {source_total} 个可翻译正文块,开始翻译 {len(selected_items)} 个。", } ) add_translation_job_log(job, job["progress"]["message"]) @@ -2953,6 +3154,11 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: session_root = app.config.get("SESSION_ROOT") epub_path = app.config.get("EPUB_PATH") if isinstance(session_root, Path): + if session_is_hidden(session_root): + app.config["SESSION_ROOT"] = None + app.config["EPUB_PATH"] = None + update_app_manifest(review_root, None) + return None, None 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: @@ -2960,7 +3166,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: manifest_root = session_root_from_id(review_root, active_id) except ValueError: return None, None - if (manifest_root / "review_state" / "state.json").exists(): + if (manifest_root / "review_state" / "state.json").exists() and not session_is_hidden(manifest_root): set_active_session(manifest_root) return app.config["SESSION_ROOT"], app.config["EPUB_PATH"] return None, None @@ -3096,12 +3302,33 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: return jsonify({"error": str(exc)}), 400 if not (session_root / "review_state" / "state.json").exists(): return jsonify({"error": "会话不存在或不完整"}), 404 + if session_is_hidden(session_root): + return jsonify({"error": "会话已从书架移除"}), 404 with app.config["LOCK"]: set_active_session(session_root) summary = summarize_session(session_root) summary["has_session"] = True return jsonify(summary) + @app.route("/api/session/", methods=["DELETE"]) + def api_delete_session(session_id: str): + try: + session_root = session_root_from_id(review_root, session_id) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + if not (session_root / "review_state" / "state.json").exists(): + return jsonify({"error": "会话不存在或不完整"}), 404 + with app.config["LOCK"]: + active_root, _active_epub = active_session() + deleting_active = isinstance(active_root, Path) and active_root.resolve() == session_root.resolve() + mark_session_deleted(session_root) + if deleting_active: + app.config["SESSION_ROOT"] = None + app.config["EPUB_PATH"] = None + update_app_manifest(review_root, None) + library = build_library_index(review_root) + return jsonify({"status": "ok", "deleted_session_id": session_id, "library": library}) + @app.route("/api/rows") def api_rows(): session_root, _epub_path = active_session() @@ -3289,7 +3516,10 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: return jsonify({"error": str(exc)}), 400 if not (source_root / "review_state" / "state.json").exists(): return jsonify({"error": "会话不存在或不完整"}), 404 + if session_is_hidden(source_root): + return jsonify({"error": "会话已从书架移除"}), 404 try: + normalize_duplicate_layers_and_rows(source_root) items = extract_translation_source_items(source_root) stats = translation_source_stats(source_root) files: dict[str, int] = {} @@ -3335,6 +3565,8 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: return jsonify({"error": str(exc)}), 400 if not (source_root / "review_state" / "state.json").exists(): return jsonify({"error": "会话不存在或不完整"}), 404 + if session_is_hidden(source_root): + return jsonify({"error": "会话已从书架移除"}), 404 try: requested_base_url = str(data.get("base_url") or "").strip() requested_model = str(data.get("model") or "").strip() diff --git a/tools/epub_review_editor/static/app.js b/tools/epub_review_editor/static/app.js index 3ab3888..b94b7d9 100644 --- a/tools/epub_review_editor/static/app.js +++ b/tools/epub_review_editor/static/app.js @@ -183,8 +183,6 @@ const dom = { translationRangeModeInput: $("translationRangeModeInput"), translationLimitInput: $("translationLimitInput"), translationTemperatureInput: $("translationTemperatureInput"), - translationSample: $("translationSample"), - translationStats: $("translationStats"), translationGptStatus: $("translationGptStatus"), translationBaseUrlInput: $("translationBaseUrlInput"), translationModelInput: $("translationModelInput"), @@ -748,6 +746,23 @@ async function refreshSessions() { return data; } +async function deleteSession(sessionId, title = "") { + if (!sessionId) return false; + const label = title || sessionId; + if (!window.confirm(`从书架移除《${label}》?\n\n这只会隐藏本工具的审校会话,不会删除原始 EPUB 文件。`)) { + return false; + } + await api(`/api/session/${encodeURIComponent(sessionId)}`, { method: "DELETE" }); + if (activeBookshelfSessionId === sessionId) activeBookshelfSessionId = ""; + if (translationSessionId === sessionId) { + translationSessionId = ""; + clearTranslationPoll(); + } + toast("已从书架移除。"); + await refreshBookshelf(); + return true; +} + function readingPositionLabel(position = {}) { if (!position || !position.scope || position.scope === "none") return "尚无阅读记录"; if (position.scope === "image") return "上次阅读:插图"; @@ -868,17 +883,18 @@ function renderBookshelfFromState(activeId = "") {

${escapeHtml(title)}

${escapeHtml(subtitle)}

-
- ${session.row_count || 0} 审校段 - ${session.volume ? `第 ${escapeHtml(session.volume)} 卷` : escapeHtml(session.language || "未知语言")} - ${updatedAt ? `${escapeHtml(formatDateTime(updatedAt))}` : ""} -
-
- - -
-
+
+ ${session.row_count || 0} 审校段 + ${session.volume ? `第 ${escapeHtml(session.volume)} 卷` : escapeHtml(session.language || "未知语言")} + ${updatedAt ? `${escapeHtml(formatDateTime(updatedAt))}` : ""}
+
+ + + +
+ +

${escapeHtml(title)}

${escapeHtml(bookSeries(session))}

@@ -887,6 +903,7 @@ function renderBookshelfFromState(activeId = "") { const open = () => openSession(session.id); const reviewButton = card.querySelector("[data-open-review]"); const translateButton = card.querySelector("[data-open-translation]"); + const deleteButton = card.querySelector("[data-delete-book]"); reviewButton.addEventListener("click", (event) => { event.stopPropagation(); open(); @@ -895,6 +912,10 @@ function renderBookshelfFromState(activeId = "") { event.stopPropagation(); showTranslationScreen(session.id).catch((error) => toast(`打开翻译页失败:${error.message}`, 7000)); }); + deleteButton.addEventListener("click", (event) => { + event.stopPropagation(); + deleteSession(session.id, title).catch((error) => toast(`删除失败:${error.message}`, 7000)); + }); card.addEventListener("click", (event) => { if (event.target.closest("button")) return; open(); @@ -959,42 +980,9 @@ async function showStartScreen(sessionPayload = null) { function renderTranslationSource(payload) { const session = payload.session || {}; const sourceCount = payload.source_count || 0; - dom.translationBookMeta.textContent = `${session.source_name || translationSessionId} · 可翻译日文正文块 ${sourceCount} 个`; - dom.translationSourceMeta.textContent = sourceCount ? `检测到 ${sourceCount} 个可翻译日文正文块` : "没有检测到可翻译日文正文块"; - const stats = payload.stats || {}; - if (dom.translationStats) { - const chips = [ - ["可翻译", sourceCount], - ["HTML", stats.html_file_count], - ["文本块", stats.total_blocks], - ["正文段", stats.total_paragraphs], - ["标题", stats.total_headings], - ["图片块", stats.image_blocks], - ["目录跳过", stats.skipped_nav_blocks], - ["空白跳过", stats.skipped_blank], - ["非日文跳过", stats.skipped_no_japanese], - ].filter((item) => item[1] !== undefined && item[1] !== null && item[1] !== ""); - dom.translationStats.innerHTML = ` -
- ${chips.map(([label, value]) => `${escapeHtml(value)}${escapeHtml(label)}`).join("")} -
- ${stats.note ? `

${escapeHtml(stats.note)}

` : ""} - `; - } + dom.translationBookMeta.textContent = session.source_name || translationSessionId; + dom.translationSourceMeta.textContent = sourceCount ? `可翻译正文块 ${sourceCount} 个` : "无可翻译正文块"; dom.translationLimitInput.max = String(Math.max(1, Math.min(5000, sourceCount || 5000))); - const sample = payload.sample || []; - if (!sample.length) { - dom.translationSample.innerHTML = '
暂无可预览段落。请确认这本书是日语 EPUB。
'; - return; - } - dom.translationSample.innerHTML = sample - .map((item) => ` -
- ${escapeHtml(item.id)} · ${escapeHtml(item.file || "")} -

${escapeHtml(item.text || "")}

-
- `) - .join(""); } async function showTranslationScreen(sessionId) { @@ -1036,7 +1024,7 @@ async function showTranslationScreen(sessionId) { dom.translationTemperatureInput.value = defaults.temperature ?? 0.2; applySeriesConfigToTranslation(sourcePayload.series_config || {}); renderTranslationSource(sourcePayload); - dom.translationProgressText.textContent = "配置完成后可以开始翻译。默认先做前 20 段试译。"; + dom.translationProgressText.textContent = "配置完成后可以开始翻译。默认先做前 20 个正文块试译。"; } function collectTranslationOptions() { @@ -1071,7 +1059,7 @@ function renderTranslationJob(job) { progress.message || "正在处理……", total ? `进度:${current}/${total}` : "", progress.current_file ? `当前文件:${progress.current_file}` : "", - failed ? `失败段落:${failed}` : "", + failed ? `失败正文块:${failed}` : "", job.error ? `错误:${job.error}` : "", ].filter(Boolean).join("\n"); const logs = (job.logs || []).slice(-12).reverse(); diff --git a/tools/epub_review_editor/static/index.html b/tools/epub_review_editor/static/index.html index 393c2bc..fd2b0f8 100644 --- a/tools/epub_review_editor/static/index.html +++ b/tools/epub_review_editor/static/index.html @@ -4,13 +4,13 @@ 双语 EPUB 审校编辑器 - +
-

双语 EPUB 审校编辑器 v0.13.0

+

双语 EPUB 审校编辑器 v0.13.1

正在载入……

@@ -93,7 +93,7 @@

AI 翻译 EPUB

-

选择书架中的日语 EPUB 后,可在这里设置提示词、术语表、范围和输出格式。

+

选择书架中的 EPUB 后设置范围、输出格式和必要提示词。

@@ -108,7 +108,6 @@

输出与范围

未读取源书信息
-
-
@@ -511,6 +509,6 @@ - + diff --git a/tools/epub_review_editor/static/style.css b/tools/epub_review_editor/static/style.css index 4862793..7d9265e 100644 --- a/tools/epub_review_editor/static/style.css +++ b/tools/epub_review_editor/static/style.css @@ -568,6 +568,13 @@ button.primary:hover, color: #18202a; } +.bookActions .dangerAction { + grid-column: 1 / -1; + border-color: rgba(255, 151, 151, 0.85); + background: rgba(168, 44, 44, 0.78); + color: #ffffff; +} + .bookCaption { display: grid; gap: 2px; @@ -651,13 +658,6 @@ button.primary:hover, line-height: 1.55; } -.translationSample { - display: grid; - gap: 8px; -} - -.translationSampleItem, -.translationEmpty, .translationOutput { border: 1px solid var(--line-soft); border-radius: 6px; @@ -665,47 +665,6 @@ button.primary:hover, background: var(--panel-soft); } -.translationStats { - display: grid; - gap: 6px; -} - -.statChipRow { - display: flex; - flex-wrap: wrap; - gap: 7px; -} - -.statChip { - display: inline-flex; - align-items: center; - gap: 5px; - border: 1px solid var(--line-soft); - border-radius: 999px; - padding: 4px 9px; - background: var(--panel-soft); - color: var(--muted); - font-size: 12px; -} - -.statChip b { - color: var(--ink); -} - -.translationStats p { - margin: 0; - color: var(--muted); - font-size: 12px; -} - -.translationSampleItem strong { - display: block; - margin-bottom: 4px; - font-size: 12px; - color: var(--muted); -} - -.translationSampleItem p, .translationOutput p { margin: 0; color: var(--ink); diff --git a/tools/epub_review_editor/version.py b/tools/epub_review_editor/version.py index 18281d2..c2d5d34 100644 --- a/tools/epub_review_editor/version.py +++ b/tools/epub_review_editor/version.py @@ -1,4 +1,4 @@ -version = "0.13.0" +version = "0.13.1" VERSION_RULES = { "PATCH": "修复 bug 或兼容性小修",