From 37de63046c71d342f6aea79d937b766ce2233c89 Mon Sep 17 00:00:00 2001 From: xin61 Date: Wed, 8 Jul 2026 18:04:06 +0800 Subject: [PATCH] feat(editor): add metadata bookshelf and series settings --- README.md | 1 + tools/epub_review_editor/LONG_TERM_MEMORY.md | 31 ++ tools/epub_review_editor/MAINTENANCE.md | 20 +- tools/epub_review_editor/README.md | 16 +- tools/epub_review_editor/server.py | 497 +++++++++++++++++-- tools/epub_review_editor/static/app.js | 373 +++++++++++++- tools/epub_review_editor/static/index.html | 77 ++- tools/epub_review_editor/static/style.css | 428 ++++++++++++++-- tools/epub_review_editor/version.py | 2 +- 9 files changed, 1314 insertions(+), 131 deletions(-) create mode 100644 tools/epub_review_editor/LONG_TERM_MEMORY.md diff --git a/README.md b/README.md index 64a3fc5..39ea194 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ - `epub_delivery/build_epub_variants.py`:通用双语封装脚本 - `tools/epub_review_editor/`:可独立部署的 EPUB 网页审校器,支持阅读审校、书架、AI 翻译 EPUB、GPT 重翻、术语表编辑、反馈与导出 - `tools/epub_review_editor/MAINTENANCE.md`:网页审校器维护、验收、GPT 重翻、术语命中与版本规则 +- `tools/epub_review_editor/LONG_TERM_MEMORY.md`:审校器长期维护记忆,继续历史任务或上下文压缩后优先读取 - `打开EPUB审校器.cmd`:Windows 一键启动入口,会自动准备本地 Python 环境并打开网页工具 ## 使用建议 diff --git a/tools/epub_review_editor/LONG_TERM_MEMORY.md b/tools/epub_review_editor/LONG_TERM_MEMORY.md new file mode 100644 index 0000000..c3c01bc --- /dev/null +++ b/tools/epub_review_editor/LONG_TERM_MEMORY.md @@ -0,0 +1,31 @@ +# EPUB 审校器长期维护记忆 + +## 稳定规则 + +- 继续审校器开发、跨线程接力或上下文压缩后恢复任务时,先读 `AGENTS.md`、`rules/translation_rules.md`、`tools/epub_review_editor/MAINTENANCE.md` 和本文件。 +- 每次完成代码、文档或工作流更新后,按维护手册运行相关检查,提交并推送到 `https://git.diaomin.asia/akai/-----.git`;需要登录或凭据时先问用户。 +- 审校器版本遵守语义化:PATCH 修 bug,MINOR 新增向后兼容能力,MAJOR 才做破坏性行为变更。 +- 用户明确要求长期多 Agent 协作;审校器开发默认按后端/API、前端交互、文档/工作流、QA 验收拆分。 + +## 用户偏好 + +- UI 要遵循第一性原理:减少无意义入口,统计口径必须清楚,翻译相关设置应围绕“源文、命中术语、少量上下文、角色口吻、输出格式”组织。 +- 术语不可全局硬塞进 prompt;例如 Falchion/ファルシオン 只有命中时才提示,译名使用“大砍刀”,不要译成“偃月刀”。 +- 长篇 EPUB 优先阅读友好、章节可导航、编辑反馈能沉淀到后续翻译优化。 + +## 当前架构状态 + +- `0.13.0` 引入元数据书架:`/api/sessions` 与 `/api/library` 生成 `library.json`,书籍会带 `metadata`、`series_id`、`cover_url` 等字段。 +- 同系列配置存放在 `epub_review_sessions/series_configs/.json`,只保存术语表路径和三段提示词,不保存 API Key。 +- 整书翻译源抽取不使用双语审校的 `rows.json`;`rows.json` 是审校行数,日语 EPUB 的可翻译数量看 `/api/session//translation-source`,UI 文案应称为“正文块”以避免和双语审校“段”混淆。 +- 翻译源统计应区分可翻译正文块、HTML 文件、正文段、标题、图片、目录和空白块;默认试译只翻译前 20 段/块,不代表源书被删。 + +## 已知排查结论 + +- `TRPGプレイヤーが異世界で最強ビルドを目指す 3` 日版 EPUB 解包内容未被删除。此前用户看到数量偏少,原因是 UI 混用了双语审校 `row_count` 与日语源书 `source_count`,且试译 job 的 `total=25` 只是范围限制。 +- 当前修复后,该 session 的可翻译源正文块为 `2949`,统计包含 `3084` 个候选块、`3083` 个 `

`、`1` 个标题、`27` 个图片块、`7` 个目录块和 `129` 个空白块。 + +## 下次优先检查 + +- 若用户继续优化书架视觉,优先检查真实浏览器中的封面网格、悬停遮罩、移动端按钮可见性和长书名溢出。 +- 若用户继续优化翻译流程,优先检查同系列配置是否正确覆盖翻译页和后台 job,且不会把系列 prompt 保存进全局 `gpt_config.json`。 diff --git a/tools/epub_review_editor/MAINTENANCE.md b/tools/epub_review_editor/MAINTENANCE.md index 83c7fb9..34490bf 100644 --- a/tools/epub_review_editor/MAINTENANCE.md +++ b/tools/epub_review_editor/MAINTENANCE.md @@ -14,6 +14,9 @@ AI 翻译模块是可选辅助首译入口,不替代长期项目里的候选 epub_review_sessions/ app_state.json gpt_config.json + library.json + series_configs/ + .json translation_jobs/ / job.json @@ -42,6 +45,8 @@ epub_review_sessions/ - `session.json` 与 `app_state.json` 只保存会话定位信息。 - `gpt_config.json` 只属于本地运行环境,不写入 EPUB、不写入导出文件、不通过 API 回传密钥。 - `gpt_config.json` 可保存本地提示词设置与术语表路径,但仍不得写入 EPUB 或导出文件。 +- `library.json` 是由 EPUB 元数据和会话状态生成的书架索引,可重建;字段包括书籍、系列、作者、出版社、语言、封面和更新时间。 +- `series_configs/.json` 保存同系列共享的 `glossary_path`、`translation_prompt`、`format_prompt`、`character_prompt`,不得保存 API Key、Base URL 或模型密钥。 - `translation_jobs//job.json` 保存整书 AI 翻译任务状态、进度、输出路径和日志;不得保存 API Key。 - `translated_outputs/` 保存 AI 翻译生成的 EPUB;生成后应创建独立 session,使 `/api/sessions` 和书架自然可见。 - 项目根目录的 `打开EPUB审校器.cmd` 是用户一键入口,委托 `tools/epub_review_editor/open_editor.ps1` 创建 `.venv`、安静检测 Flask 依赖、缺失时自动安装依赖、复用同版本已运行服务或启动 `server.py --daemon` 并打开浏览器;该入口不得硬编码用户私有路径。 @@ -72,6 +77,10 @@ epub_review_sessions/ - 顶栏左上角必须保留“书架”入口;书架是独立页面,列出 `--review-root` 下历史打开过的审校书籍/会话。 - 点击书架条目必须通过会话打开流程进入对应审校页面,并恢复该书上次阅读位置;从审校页进入书架或切换书籍前必须保存当前阅读位置并沿用未保存编辑保护。 - 没有历史书籍时,书架显示空状态并提供“打开新的 EPUB”入口;开始页/书架页顶栏保持正常显示,不套用审校页沉浸隐藏。 +- 书架应按 EPUB 元数据建立分类:全部书籍、同系列、作者、出版社、语言;同系列分类使用稳定 `series_id`,显示书籍数量。 +- 书架主区域以封面网格为主,不用横向信息卡替代封面;无封面时使用稳定 fallback,不出现破图或布局跳动。 +- 鼠标悬停或键盘 focus 到封面时,封面变暗并显示书名、系列/作者/卷数、审校段数、最近更新时间,以及“校对”“翻译”两个按钮;点击“翻译”不得触发“校对”。 +- 选中同系列分类后,必须能打开“系列设置”,设置该系列共享的术语表路径、翻译内容提示词、格式提示词和角色口吻提示词。 - 初始进入审校时,左侧侧栏隐藏,正文占主要空间。 - 顶部“目录”只打开目录,“检索”只打开搜索/段落列表,不合并成一个常驻面板。 - 目录可展开/收起章节,插图作为所属章节的次级目录条目可点击阅读,文字 part 可点击定位。 @@ -165,16 +174,20 @@ Prompt 硬规则: 后端接口口径: - `GET /api/translation/defaults` 返回当前公开 GPT 配置、默认提示词、默认术语表路径、默认范围和输出模式,不返回 API Key。 -- `GET /api/session//translation-source` 只读取指定 session,统计可翻译日文段落和样例,不切换 active session。 +- `GET /api/session//translation-source` 只读取指定 session,统计可翻译日文正文块和样例,不切换 active session;必须同时返回诊断统计,说明审校行数、可翻译正文块、图片、目录、空白块不是同一口径。 +- `GET /api/library` 与 `GET /api/sessions` 返回按 EPUB 元数据生成的书架索引、系列列表和封面 URL。 +- `GET/POST /api/series//config` 读写同系列翻译配置;空字段表示未设置,不能自动写入全局默认 prompt。 - `POST /api/translation/start` 创建后台任务并快速返回 `job_id`;请求不得临时覆盖 `base_url` 或 `model` 后复用已保存密钥,要更换端点或模型必须先保存配置。 - `GET /api/translation/jobs/` 返回任务状态、进度、日志、输出 EPUB 和输出 session id,不返回 API Key。 翻译输入与提示词: - 日语源 EPUB 需要新的源段落抽取器,不复用只识别双语灰字的 `build_rows()`。 +- 翻译源抽取器至少覆盖 `

` 与正文 ``;日文上下文中的纯汉字/标点短句应进入翻译队列,纯图片、空白和目录页不进入。 - 默认范围必须是前 N 段试译,当前默认 20 段;全书翻译必须由用户显式选择。 - 每段请求只携带目标段落日文 HTML、前后一段少量上下文、该段实时命中的术语、翻译提示词、格式提示词与角色口吻提示词。 - 不得把完整术语表塞进 prompt;未命中的术语不得进入 prompt。 +- 若同系列配置存在,整书翻译优先使用同系列的术语表路径和提示词;API Key、Base URL、模型仍使用全局 API 配置。 - 输出只接受当前段中文 HTML 片段,随后进行中文标点规整、HTML sanitize 与术语 ruby 括号形式修复。 输出与书架: @@ -246,9 +259,12 @@ Prompt 硬规则: - 鼠标停留在顶栏按钮区域时,书架/打开 EPUB/审校工具按钮能连续点击,不会在点击前自动收起;浏览器重新加载后应使用当前版本 app.js/style.css - “上一节 / 下一节”在相邻 part 和章节内插图之间移动,不再按顶层大章节跳转 - 顶栏左上角“书架”能打开独立书架页;书架列出历史会话,空状态可进入上传页,点击书籍可进入对应审校页并恢复阅读位置 +- 书架左侧分类可按全部书籍、同系列、作者、出版社、语言筛选;右侧封面网格在桌面端悬停变暗显示信息和“校对/翻译”,移动端不依赖 hover 才能操作 +- `GET /api/sessions` 和 `GET /api/library` 返回 `library`、`series`、书籍元数据、`series_id`、`cover_url`,并写入可重建的 `library.json` +- `GET/POST /api/series//config` 能读写同系列术语表和提示词;保存后同系列其他书进入翻译页会自动套用 - 书架卡片“翻译”按钮能进入独立翻译页,且不会触发卡片的“进入审校” - `/api/translation/defaults` 不返回 API Key,提示词和术语表路径可读取 -- `/api/session//translation-source` 能统计日语 EPUB 段落;非日语或无正文时返回清晰空状态 +- `/api/session//translation-source` 能统计日语 EPUB 可翻译正文块和诊断字段;非日语或无正文时返回清晰空状态;审校行数、源正文块数、试译 limit 不得在 UI 上混为一个“段落数” - 未配置 API Key 时,`/api/translation/start` 返回清晰错误,不影响阅读、编辑、导出和单段重翻 - 启动整书翻译任务后,`/api/translation/jobs/` 可看到 pending/running/completed/failed、总段数、已完成数、当前文件和日志 - 默认翻译范围是前 20 段试译;全书翻译必须由用户显式切换并确认 diff --git a/tools/epub_review_editor/README.md b/tools/epub_review_editor/README.md index aad2f5a..448dd0a 100644 --- a/tools/epub_review_editor/README.md +++ b/tools/epub_review_editor/README.md @@ -1,6 +1,6 @@ # 双语 EPUB 审校编辑器 -当前版本:`0.12.2` +当前版本:`0.13.0` 这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。 @@ -46,6 +46,8 @@ `0.12.2` 修复一键启动入口首次运行时依赖检测会被 Flask 缺失的 traceback 中断的问题;现在会先安静检测依赖,缺失时自动安装,再启动网页工具。重复打开入口时会优先复用同版本已运行服务,并修正端口探测,避免多个服务同时监听同一端口。 +`0.13.0` 新增元数据书架与同系列配置:书架按 EPUB 元数据建立 `library.json`,左侧提供系列、作者、出版社、语言分类,封面悬停显示书籍信息以及“校对/翻译”入口;同系列可共享术语表路径、翻译内容提示词、格式提示词和角色口吻提示词。AI 翻译源统计改为正文块诊断,区分审校行数、可翻译日文正文块、图片、目录和空白块,并补翻日文标题与日语上下文里的短句,避免把默认试译范围误认为正文被删。 + ## 独立安装 这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub_review_editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖: @@ -105,14 +107,14 @@ python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessio - 启动时不带 EPUB 路径,会进入开始页。 - 点击“选择 EPUB 文件”,上传中日双语 EPUB 后会自动创建审校会话。 - 开始页会列出已有会话,换电脑或重启服务后可以继续打开之前的审校记录。 -- 顶栏左上角“书架”会列出历史打开过的书籍,适合在多本书之间快速切换;点击书籍后会进入对应审校页面。 -- 书架中每本书都有“翻译”按钮,可对日语 EPUB 发起 AI 翻译任务;输出 EPUB 会自动加入书架。 +- 顶栏左上角“书架”会列出历史打开过的书籍,并按 EPUB 元数据生成分类;点击封面或“校对”会进入对应审校页面。 +- 书架中每本书封面悬停会变暗并显示书籍信息、“校对”和“翻译”按钮;“翻译”可对日语 EPUB 发起 AI 翻译任务,输出 EPUB 会自动加入书架。 - 上传的 EPUB、解包副本、反馈与导出文件都会保存在 `--review-root` 指定目录下。 ## 能做什么 - 阅读模式下按章节连续阅读日文原文与中文译文,不需要逐句点击。 -- 顶栏左上角可进入“书架”,集中查看历史打开过的书籍、审校进度和最近阅读记录,并跳转回对应审校页面。 +- 顶栏左上角可进入“书架”,集中查看历史打开过的书籍、审校进度和最近阅读记录;书架左侧可按全部书籍、同系列、作者、出版社和语言分类,右侧以封面网格显示书籍。 - 阅读区可切换默认、护眼、夜间背景,并调整字号与行距;设置会保存在当前浏览器中,下次打开继续沿用。 - 审校页顶部标题栏与阅读标题栏默认隐藏,鼠标移到页面上方时显示,减少长文阅读时的遮挡。 - 阅读区“上一节 / 下一节”按 part 或插图小章节移动,不再只跳顶层大章节。 @@ -125,7 +127,7 @@ python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessio - 精修模式保留原有逐段编辑界面,用于较细的逐段复审。 - 可从“审校工具”统一配置 OpenAI 兼容的 `base_url`、`model`、API Key、重翻提示词与术语表,并对当前段落生成重翻候选;候选不会自动覆盖正文,需要点击“应用重翻”并保存。 - 可在“审校工具”的“AI 设置与术语表”里配置并读取正式术语表,实时搜索、增删改术语并保存;单段重翻会按当前段落命中术语表条目,而不是只在提示词里泛泛要求遵守术语。 -- 可从书架进入“AI 翻译 EPUB”页面,对日语 EPUB 设置翻译内容提示词、格式与标点提示词、角色口吻提示词、术语表路径、翻译范围、输出为中日双语或纯中文 EPUB;任务在后台运行,页面实时显示进度与日志。 +- 可从书架进入“AI 翻译 EPUB”页面,对日语 EPUB 设置翻译内容提示词、格式与标点提示词、角色口吻提示词、术语表路径、翻译范围、输出为中日双语或纯中文 EPUB;同系列书籍会优先套用系列提示词和术语表配置,任务在后台运行,页面实时显示进度与日志。 - 直接编辑中文译文。 - 标记问题段落或选中文本。 - 记录问题类型、严重程度、标签、备注和可沉淀的长期规则。 @@ -156,6 +158,8 @@ epub_review_sessions\_\ - `extracted/`:EPUB 解包后的工作副本。 - `translation_jobs//job.json`:AI 翻译任务状态、进度和日志。 - `translated_outputs/*_AI翻译_*.epub`:AI 翻译模块生成并自动加入书架的 EPUB。 +- `library.json`:按 EPUB 元数据生成的书架索引,包含书籍、系列和封面信息。 +- `series_configs/*.json`:同系列共享的提示词与术语表路径配置,不包含 API Key。 在服务器上使用时,“生成反馈”和“导出审校 EPUB”会返回浏览器可访问的下载地址,不需要登录服务器找文件。 @@ -168,7 +172,7 @@ epub_review_sessions\_\ - 想直接修正译文时:编辑中文译文后保存本段。 - 想让 Codex 学习你的偏好时:把“可沉淀为长期规则”写具体,例如“某个术语只在原文实际出现时才进入重翻提示”。 - 想单段重翻时:点击段落打开“审校工具”,先在“AI 设置与术语表”中保存 GPT 配置;提示词分为翻译内容、格式标点、角色口吻三块,可恢复预设。术语表路径会从当前项目中可唯一识别的系列 `mingcibiao.json` 推断,也可以改成其他 `mingcibiao.json`。再点“重翻本段”;满意后点“应用重翻”,最后保存本段记录。 -- 想整书 AI 翻译时:先上传或打开日语 EPUB,让它出现在书架里;在书架卡片点击“翻译”,保存 API 设置与术语表路径,默认先做前 20 段试译。确认效果后再把范围切换为全书。中日双语输出可直接进入审校;纯中文输出会入书架,但没有逐段双语审校行。 +- 想整书 AI 翻译时:先上传或打开日语 EPUB,让它出现在书架里;在书架封面悬停后点击“翻译”,保存 API 设置,并在系列设置里保存该系列术语表和提示词。默认先做前 20 段试译,确认效果后再把范围切换为全书。中日双语输出可直接进入审校;纯中文输出会入书架,但没有逐段双语审校行。 - 术语表路径默认会从当前项目中可唯一识别的系列 `mingcibiao.json` 推断;如果无法唯一推断,请在页面里填写目标术语表路径。 - 想调整术语时:打开“审校工具”的“AI 设置与术语表”,在术语表区域搜索、修改或新增条目,点击“保存术语表”;下一次重翻会重新读取已保存的最新术语。 - 审校结束后点击“生成反馈”,再在聊天里告诉 Codex 读取 `feedback_for_codex.md` 和 `translation_feedback.jsonl`。 diff --git a/tools/epub_review_editor/server.py b/tools/epub_review_editor/server.py index af1c17c..4267985 100644 --- a/tools/epub_review_editor/server.py +++ b/tools/epub_review_editor/server.py @@ -36,15 +36,20 @@ except ImportError: "MINOR": "新增向后兼容能力、API 字段或可选配置", "MAJOR": "破坏兼容的 API、配置或行为变更", } - version = "0.12.2" + version = "0.13.0" P_RE = re.compile(r"(]*>)(.*?)(

)", re.S | re.I) +SOURCE_BLOCK_RE = re.compile( + r"(?P<(?Pp|h[1-6])\b[^>]*>)(?P.*?)(?P)", + 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]") +JAPANESE_CONTEXT_RE = re.compile(r"[ぁ-ヿ々〆〤ー・「」『』【】()〈〉《》〔〕〜~…!?。]") 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, @@ -99,6 +104,7 @@ TRANSLATION_LOG_LIMIT = 80 DEFAULT_TRANSLATION_LIMIT = 20 TRANSLATION_RANGE_LIMIT_MAX = 5000 TRANSLATION_CONTEXT_RADIUS = 1 +LIBRARY_DB_NAME = "library.json" def now_iso() -> str: @@ -232,6 +238,48 @@ def is_gray_source(attrs: str, inner: str) -> bool: return is_japaneseish(inner) +def paragraph_visible_text(inner: str) -> str: + return compact_text(strip_tags(inner or "")) + + +def paragraph_prompt_text(inner: str) -> str: + return strip_tags(inner or "").strip() + + +def html_declares_japanese(text: str) -> bool: + return bool(re.search(r"\b(?:xml:)?lang\s*=\s*['\"]ja(?:[-_'\"]|$)", text or "", flags=re.I)) + + +def source_file_has_japanese_context(text: str) -> bool: + return html_declares_japanese(text) or bool(KANA_RE.search(strip_html_keep_ruby_text(text or ""))) + + +def cjk_core_text(text: str) -> str: + return re.sub(r"[^\u3400-\u9fff々〆〤]", "", text or "") + + +def is_translatable_source_block(inner: str, tag: str = "p", file_has_japanese_context: bool = False) -> bool: + visible = paragraph_visible_text(inner) + if not visible: + return False + if IMG_TAG_RE.search(inner) and not visible: + return False + prompt_text = strip_html_keep_ruby_text(inner) + if KANA_RE.search(prompt_text): + return True + if not JP_RE.search(prompt_text): + return False + if tag.lower().startswith("h") and file_has_japanese_context and JP_RE.search(prompt_text): + return True + if file_has_japanese_context and JAPANESE_CONTEXT_RE.search(prompt_text): + return True + return False + + +def is_translatable_source_paragraph(inner: str) -> bool: + return is_translatable_source_block(inner, "p", False) + + class HtmlSanitizer(HTMLParser): allowed_tags = { "a", @@ -675,6 +723,8 @@ def glossary_mapping_from_data(data: Any) -> dict[str, str]: def read_glossary_mapping_from_path(path: Path) -> dict[str, str]: + if not str(path): + return {} if not path.exists(): return {} try: @@ -945,6 +995,14 @@ def session_manifest_path(session_root: Path) -> Path: return session_root / "review_state" / "session.json" +def library_db_path(review_root: Path) -> Path: + return review_root / LIBRARY_DB_NAME + + +def series_config_path(review_root: Path, series_id: str) -> Path: + return review_root / "series_configs" / f"{safe_slug(series_id, max_len=120)}.json" + + def session_public_id(session_root: Path) -> str: return session_root.name @@ -963,6 +1021,8 @@ def session_root_from_id(review_root: Path, session_id: str) -> Path: def write_session_manifest(session_root: Path, epub_path: Path, source_name: str = "") -> None: existing = read_json(session_manifest_path(session_root), {}) + metadata = read_epub_metadata(session_root) + metadata["source_name"] = source_name or epub_path.name record = { "id": session_public_id(session_root), "source_name": source_name or epub_path.name, @@ -970,6 +1030,7 @@ def write_session_manifest(session_root: Path, epub_path: Path, source_name: str "session_root": str(session_root), "created_at": existing.get("created_at") or now_iso(), "updated_at": now_iso(), + "metadata": metadata, } write_json(session_manifest_path(session_root), record) @@ -1042,18 +1103,36 @@ def summarize_session(session_root: Path) -> dict[str, Any]: touched = [row for row in rows if rowTouched_py(row)] source_epub = manifest.get("source_epub") or state.get("source_epub", "") source_name = manifest.get("source_name") or (Path(source_epub).name if source_epub else session_root.name) + metadata = manifest.get("metadata") if isinstance(manifest.get("metadata"), dict) else {} + if not metadata: + metadata = read_epub_metadata(session_root) + manifest["metadata"] = metadata + write_json(session_manifest_path(session_root), manifest) exports_dir = session_root / "exports" latest_export = "" if exports_dir.exists(): exports = sorted(exports_dir.glob("*.epub"), key=lambda p: p.stat().st_mtime, reverse=True) latest_export = str(exports[0]) if exports else "" updated_at = state.get("updated_at") or manifest.get("updated_at") or "" + session_id = session_public_id(session_root) + cover_asset_path = str(metadata.get("cover_asset_path") or "") return { - "id": session_public_id(session_root), + "id": session_id, "version": version, "source_name": source_name, "source_epub": source_epub, "session_root": str(session_root), + "metadata": metadata, + "title": metadata.get("title") or source_name, + "series": metadata.get("series") or "未分类", + "series_id": metadata.get("series_id") or safe_slug(metadata.get("series") or "未分类", max_len=120), + "volume": metadata.get("volume", ""), + "authors": metadata.get("authors", []), + "publisher": metadata.get("publisher", ""), + "language": metadata.get("language", ""), + "date": metadata.get("date", ""), + "cover_asset_path": cover_asset_path, + "cover_url": session_asset_url(session_id, cover_asset_path) if cover_asset_path else "", "row_count": len(rows), "touched_count": len(touched), "marked_count": sum(1 for row in rows if row.get("marked")), @@ -1083,7 +1162,7 @@ def list_review_sessions(review_root: Path) -> list[dict[str, Any]]: return [] sessions = [] for child in review_root.iterdir(): - if not child.is_dir() or child.name == UPLOAD_DIR_NAME: + if not child.is_dir() or child.name in {UPLOAD_DIR_NAME, TRANSLATION_JOB_DIR_NAME, TRANSLATION_OUTPUT_DIR_NAME, "series_configs"}: continue if not (child / "review_state" / "state.json").exists(): continue @@ -1095,6 +1174,95 @@ def list_review_sessions(review_root: Path) -> list[dict[str, Any]]: return sessions +def build_library_index(review_root: Path) -> dict[str, Any]: + sessions = list_review_sessions(review_root) + series_map: dict[str, dict[str, Any]] = {} + for session in sessions: + series_id = str(session.get("series_id") or "uncategorized") + series = series_map.setdefault( + series_id, + { + "id": series_id, + "title": session.get("series") or "未分类", + "count": 0, + "books": [], + "authors": set(), + "publishers": set(), + "languages": set(), + }, + ) + series["count"] += 1 + series["books"].append(session["id"]) + for author in session.get("authors") or []: + if author: + series["authors"].add(author) + if session.get("publisher"): + series["publishers"].add(session["publisher"]) + if session.get("language"): + series["languages"].add(session["language"]) + series_list = [] + for item in series_map.values(): + series_list.append( + { + **item, + "authors": sorted(item["authors"]), + "publishers": sorted(item["publishers"]), + "languages": sorted(item["languages"]), + } + ) + series_list.sort(key=lambda item: item["title"]) + library = { + "version": version, + "updated_at": now_iso(), + "book_count": len(sessions), + "series_count": len(series_list), + "sessions": sessions, + "series": series_list, + } + write_json_atomic(library_db_path(review_root), library) + return library + + +def read_series_config(review_root: Path, series_id: str) -> dict[str, Any]: + path = series_config_path(review_root, series_id) + data = read_json(path, {}) + return { + "series_id": series_id, + "translation_prompt": str(data.get("translation_prompt") or ""), + "format_prompt": str(data.get("format_prompt") or ""), + "character_prompt": str(data.get("character_prompt") or ""), + "glossary_path": str(data.get("glossary_path") or ""), + "updated_at": str(data.get("updated_at") or ""), + } + + +def normalize_optional_prompt(name: str, value: Any) -> str: + text = str(value or "").strip() + if not text: + return "" + return normalize_gpt_prompt(name, text) + + +def normalize_optional_glossary_path(value: Any) -> str: + raw = str(value or "").strip() + if not raw: + return "" + return normalize_glossary_path_value(raw) + + +def save_series_config(review_root: Path, series_id: str, data: dict[str, Any]) -> dict[str, Any]: + record = { + "series_id": series_id, + "translation_prompt": normalize_optional_prompt("translation_prompt", data.get("translation_prompt")), + "format_prompt": normalize_optional_prompt("format_prompt", data.get("format_prompt")), + "character_prompt": normalize_optional_prompt("character_prompt", data.get("character_prompt")), + "glossary_path": normalize_optional_glossary_path(data.get("glossary_path")), + "updated_at": now_iso(), + } + write_json_atomic(series_config_path(review_root, series_id), record) + return record + + def validate_epub_file(path: Path) -> None: if path.suffix.lower() != ".epub": raise ValueError("只支持 .epub 文件") @@ -1179,17 +1347,36 @@ def find_opf_entry(session_root: Path) -> str: def parse_opf_metadata(session_root: Path) -> dict[str, Any]: opf_entry = find_opf_entry(session_root) if not opf_entry: - return {"opf_entry": "", "opf_dir": "", "spine": []} + return {"opf_entry": "", "opf_dir": "", "spine": [], "metadata": {}} opf_path = extracted_path(session_root, opf_entry) root = read_xml_root(opf_path) if root is None: - return {"opf_entry": opf_entry, "opf_dir": entry_dir(opf_entry), "spine": []} + return {"opf_entry": opf_entry, "opf_dir": entry_dir(opf_entry), "spine": [], "metadata": {}} opf_dir = entry_dir(opf_entry) manifest: dict[str, dict[str, str]] = {} spine_idrefs: list[str] = [] + metadata_values: dict[str, list[str]] = { + "title": [], + "creator": [], + "language": [], + "publisher": [], + "date": [], + "identifier": [], + "subject": [], + "description": [], + } + cover_id = "" for elem in root.iter(): name = local_name(elem.tag) + if name in metadata_values and elem.text: + value = compact_text(elem.text) + if value: + metadata_values[name].append(value) + elif name == "meta": + meta_name = (elem.attrib.get("name") or "").strip().lower() + if meta_name == "cover": + cover_id = elem.attrib.get("content", "").strip() if name == "item": item_id = elem.attrib.get("id", "") href = elem.attrib.get("href", "") @@ -1212,11 +1399,167 @@ def parse_opf_metadata(session_root: Path) -> dict[str, Any]: href = item["href"] if href.lower().endswith((".xhtml", ".html", ".htm")): spine.append(href) + + cover_entry = "" + if cover_id and cover_id in manifest: + cover_entry = manifest[cover_id].get("href", "") + if not cover_entry: + for item in manifest.values(): + if "cover-image" in item.get("properties", "").split() and is_raster_image_asset(item.get("href", "")): + cover_entry = item.get("href", "") + break + if not cover_entry: + for item in manifest.values(): + href = item.get("href", "") + media_type = item.get("media_type", "") + if media_type.startswith("image/") and is_raster_image_asset(href): + cover_entry = href + break + + metadata = { + key: values[0] if values else "" + for key, values in metadata_values.items() + if key not in {"creator", "identifier", "subject"} + } + metadata["creators"] = metadata_values["creator"] + metadata["identifiers"] = metadata_values["identifier"] + metadata["subjects"] = metadata_values["subject"] + metadata["cover_asset_path"] = cover_entry if cover_entry and is_raster_image_asset(cover_entry) else "" return { "opf_entry": opf_entry, "opf_dir": opf_dir, "manifest": manifest, "spine": spine, + "metadata": metadata, + } + + +def infer_volume_number(title: str) -> str: + text = compact_text(title) + patterns = [ + r"(?:第\s*)?([0-90-9]+)\s*[巻卷册集]", + r"\bvol(?:ume)?\.?\s*([0-90-9]+)\b", + r"\b([0-90-9]+)\s*(?:[~~].*)?$", + ] + for pattern in patterns: + match = re.search(pattern, text, flags=re.I) + if match: + return match.group(1).translate(str.maketrans("0123456789", "0123456789")) + return "" + + +def infer_series_title(title: str, source_name: str = "") -> str: + text = compact_text(title or Path(source_name).stem) + text = re.sub(r"_AI翻译_(?:中日双语|中文)_\d{8}_\d{6}$", "", text) + text = re.sub(r"\s*\([^)]*(?:文庫|ノベル|EPUB|nodrm)[^)]*\)\s*$", "", text, flags=re.I) + text = re.sub(r"\s*(?:第\s*)?[0-90-9]+\s*[巻卷册集]\s*(?:[~~].*)?$", "", text) + text = re.sub(r"\s+vol(?:ume)?\.?\s*[0-90-9]+\s*(?:[~~].*)?$", "", text, flags=re.I) + text = re.sub(r"\s+[0-90-9]+\s*(?:[~~].*)?$", "", text) + text = re.sub(r"\s+[0-90-9]+$", "", text) + return compact_text(text) or compact_text(title or source_name) or "未分类" + + +def read_epub_metadata(session_root: Path) -> dict[str, Any]: + opf = parse_opf_metadata(session_root) + metadata = dict(opf.get("metadata") or {}) + manifest = read_json(session_manifest_path(session_root), {}) + source_name = manifest.get("source_name") or Path(str(manifest.get("source_epub") or "")).name + title = compact_text(str(metadata.get("title") or "")) or Path(source_name).stem or session_root.name + creators = metadata.get("creators") if isinstance(metadata.get("creators"), list) else [] + series_title = infer_series_title(title, source_name) + series_id = safe_slug(series_title, max_len=120) + volume = infer_volume_number(title) or infer_volume_number(source_name) + metadata.update( + { + "title": title, + "series": series_title, + "series_id": series_id, + "volume": volume, + "authors": [compact_text(str(item)) for item in creators if compact_text(str(item))], + "publisher": compact_text(str(metadata.get("publisher") or "")), + "language": compact_text(str(metadata.get("language") or "")), + "date": compact_text(str(metadata.get("date") or "")), + "source_name": source_name, + } + ) + return metadata + + +def session_asset_url(session_id: str, entry_name: str) -> str: + return f"/api/session/{urllib.parse.quote(session_id)}/asset/{urllib.parse.quote(entry_name, safe='/')}" + + +def translation_source_stats(session_root: Path) -> dict[str, Any]: + html_files = html_entries_in_reading_order(session_root) + total_blocks = 0 + total_paragraphs = 0 + total_headings = 0 + nonempty_blocks = 0 + kana_blocks = 0 + image_blocks = 0 + skipped_nav_files = 0 + skipped_nav_blocks = 0 + skipped_blank = 0 + skipped_image = 0 + skipped_no_japanese = 0 + skipped_no_japanese_examples: list[dict[str, Any]] = [] + translatable_by_file: dict[str, int] = {} + for entry_name in html_files: + path = extracted_path(session_root, entry_name) + if not path.exists(): + continue + text = path.read_text(encoding="utf-8", errors="replace") + matches = list(SOURCE_BLOCK_RE.finditer(text)) + file_has_japanese_context = source_file_has_japanese_context(text) + total_blocks += len(matches) + total_paragraphs += sum(1 for match in matches if match.group("tag").lower() == "p") + total_headings += sum(1 for match in matches if match.group("tag").lower().startswith("h")) + nav = html_looks_like_navigation(entry_name, text) + if nav: + skipped_nav_files += 1 + skipped_nav_blocks += len(matches) + for match in matches: + tag = match.group("tag").lower() + inner = match.group("inner") + visible = paragraph_visible_text(inner) + if visible: + nonempty_blocks += 1 + else: + skipped_blank += 1 + if IMG_TAG_RE.search(inner): + image_blocks += 1 + if not visible: + skipped_image += 1 + if KANA_RE.search(strip_html_keep_ruby_text(inner)): + kana_blocks += 1 + included = (not nav) and is_translatable_source_block(inner, tag, file_has_japanese_context) + if included: + translatable_by_file[entry_name] = translatable_by_file.get(entry_name, 0) + 1 + elif not nav and visible and not IMG_TAG_RE.search(inner) and not KANA_RE.search(strip_html_keep_ruby_text(inner)): + skipped_no_japanese += 1 + if len(skipped_no_japanese_examples) < 5: + skipped_no_japanese_examples.append( + { + "file": entry_name, + "tag": tag, + "text": truncate_prompt_text(visible, 80), + } + ) + return { + "html_file_count": len(html_files), + "total_blocks": total_blocks, + "total_paragraphs": total_paragraphs, + "total_headings": total_headings, + "nonempty_blocks": nonempty_blocks, + "kana_blocks": kana_blocks, + "image_blocks": image_blocks, + "skipped_nav_files": skipped_nav_files, + "skipped_nav_blocks": skipped_nav_blocks, + "skipped_blank": skipped_blank, + "skipped_image": skipped_image, + "skipped_no_japanese": skipped_no_japanese, + "skipped_no_japanese_examples": skipped_no_japanese_examples, + "translatable_by_file": translatable_by_file, } @@ -2178,16 +2521,21 @@ def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, A except (TypeError, ValueError) as exc: raise ValueError("temperature 必须是数字") from exc temperature = max(0.0, min(1.0, temperature)) - glossary_value = str(data.get("glossary_path") or config.get("glossary_path") or "").strip() + def prompt_value(name: str) -> Any: + if data.get(f"use_series_{name}"): + return None + return data.get(name) or config.get(name) + + glossary_value = "" if data.get("use_series_glossary_path") else str(data.get("glossary_path") or config.get("glossary_path") or "").strip() return { "output_mode": output_mode, "range_mode": range_mode, "limit": limit, "temperature": temperature, - "glossary_path": normalize_glossary_path_value(glossary_value), - "translation_prompt": normalize_gpt_prompt("translation_prompt", data.get("translation_prompt") or config.get("translation_prompt")), - "format_prompt": normalize_gpt_prompt("format_prompt", data.get("format_prompt") or config.get("format_prompt")), - "character_prompt": normalize_gpt_prompt("character_prompt", data.get("character_prompt") or config.get("character_prompt")), + "glossary_path": normalize_glossary_path_value(glossary_value) if glossary_value else "", + "translation_prompt": "" if data.get("use_series_translation_prompt") else normalize_gpt_prompt("translation_prompt", prompt_value("translation_prompt")), + "format_prompt": "" if data.get("use_series_format_prompt") else normalize_gpt_prompt("format_prompt", prompt_value("format_prompt")), + "character_prompt": "" if data.get("use_series_character_prompt") else normalize_gpt_prompt("character_prompt", prompt_value("character_prompt")), } @@ -2216,16 +2564,14 @@ def extract_translation_source_items(session_root: Path) -> list[dict[str, Any]] if html_looks_like_navigation(entry_name, text): continue 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 p_index, match in enumerate(P_RE.finditer(text)): - inner = match.group(2) - source_text = strip_tags(inner) - if not source_text: - continue - if IMG_TAG_RE.search(inner) and not source_text: - continue - if not KANA_RE.search(strip_html_keep_ruby_text(inner)): + for block_index, match in enumerate(SOURCE_BLOCK_RE.finditer(text)): + tag = match.group("tag").lower() + inner = match.group("inner") + if not is_translatable_source_block(inner, tag, file_has_japanese_context): continue + source_text = paragraph_prompt_text(inner) serial += 1 file_serial += 1 items.append( @@ -2236,9 +2582,13 @@ def extract_translation_source_items(session_root: Path) -> list[dict[str, Any]] "file_row_index": file_serial, "file_label": Path(entry_name).name, "document_title": document_title, - "p_index": p_index, - "p_open": match.group(1), - "p_close": match.group(3), + "block_index": block_index, + "p_index": block_index, + "tag": tag, + "open_tag": match.group("open"), + "close_tag": match.group("close"), + "p_open": match.group("open"), + "p_close": match.group("close"), "source_html": inner, "source_text": source_text, } @@ -2321,27 +2671,29 @@ def add_gray_style_to_p(open_tag: str) -> str: def replace_translated_paragraphs( text: str, - replacements: dict[int, str], + replacements: dict[int, dict[str, str]], output_mode: str, ) -> str: parts: list[str] = [] last = 0 - for index, match in enumerate(P_RE.finditer(text)): + for index, match in enumerate(SOURCE_BLOCK_RE.finditer(text)): parts.append(text[last:match.start()]) if index not in replacements: parts.append(match.group(0)) elif output_mode == "bilingual": - source_open = add_gray_style_to_p(match.group(1)) + replacement = replacements[index] + source_open = add_gray_style_to_p(match.group("open")) parts.append(source_open) - parts.append(match.group(2)) - parts.append(match.group(3)) - parts.append("\n

") - parts.append(replacements[index]) - parts.append("

") + parts.append(match.group("inner")) + parts.append(match.group("close")) + parts.append(f"\n<{replacement.get('tag') or 'p'}>") + parts.append(replacement.get("html", "")) + parts.append(f"") else: - parts.append(match.group(1)) - parts.append(replacements[index]) - parts.append(match.group(3)) + replacement = replacements[index] + 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) @@ -2357,12 +2709,17 @@ 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, str]] = {} + by_file: dict[str, dict[int, dict[str, str]]] = {} for item in items: translated = translations.get(item["id"]) if translated is None: continue - by_file.setdefault(item["file"], {})[int(item["p_index"])] = translated + tag = str(item.get("tag") or "p").lower() + translated_tag = tag if tag.startswith("h") else "p" + by_file.setdefault(item["file"], {})[int(item.get("block_index", item.get("p_index", 0)))] = { + "html": translated, + "tag": translated_tag, + } out_path.parent.mkdir(parents=True, exist_ok=True) with zipfile.ZipFile(out_path, "w") as zf: @@ -2434,6 +2791,16 @@ def run_translation_job(review_root: Path, job_id: str) -> None: if not source_epub.exists(): raise ValueError("源 EPUB 文件不存在") settings = job.get("settings") if isinstance(job.get("settings"), dict) else {} + metadata = read_epub_metadata(source_session) + series_config = read_series_config(review_root, str(metadata.get("series_id") or "")) + series_overrides: list[str] = [] + for key in ("translation_prompt", "format_prompt", "character_prompt", "glossary_path"): + if series_config.get(key): + settings[key] = series_config[key] + series_overrides.append(key) + if series_overrides: + job["settings"] = settings + add_translation_job_log(job, f"已套用同系列配置:{', '.join(series_overrides)}。") config = read_gpt_config(review_root) if not config.get("configured"): raise ValueError("尚未配置 GPT API Key") @@ -2659,16 +3026,39 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: @app.route("/api/sessions") def api_sessions(): session_root, _epub_path = active_session() + library = build_library_index(review_root) return jsonify( { "review_root": str(review_root), "version": version, "version_rules": VERSION_RULES, "active_session_id": session_public_id(session_root) if session_root else "", - "sessions": list_review_sessions(review_root), + "sessions": library["sessions"], + "series": library["series"], + "library": { + "book_count": library["book_count"], + "series_count": library["series_count"], + "updated_at": library["updated_at"], + "db_path": str(library_db_path(review_root)), + }, } ) + @app.route("/api/library") + def api_library(): + return jsonify(build_library_index(review_root)) + + @app.route("/api/series//config", methods=["GET", "POST"]) + def api_series_config(series_id: str): + series_id = safe_slug(series_id, max_len=120) + if request.method == "GET": + return jsonify(read_series_config(review_root, series_id)) + data = request.get_json(silent=True) or {} + try: + return jsonify(save_series_config(review_root, series_id, data)) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + @app.route("/api/upload", methods=["POST"]) def api_upload(): file_storage = request.files.get("epub") @@ -2770,6 +3160,33 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: response.headers["Content-Security-Policy"] = "default-src 'none'; img-src 'self' data:; style-src 'none'; script-src 'none'; object-src 'none'" return response + @app.route("/api/session//asset/") + def api_session_asset(session_id: str, entry_name: str): + try: + target_root = session_root_from_id(review_root, session_id) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + if not (target_root / "review_state" / "state.json").exists(): + return jsonify({"error": "会话不存在或不完整"}), 404 + entry_name = urllib.parse.unquote(entry_name) + metadata = read_epub_metadata(target_root) + allowed = {metadata.get("cover_asset_path", "")} + allowed.update(collect_asset_paths_from_structure(build_structure(target_root))) + if entry_name not in allowed: + return jsonify({"error": "asset not allowed"}), 404 + if not is_raster_image_asset(entry_name): + return jsonify({"error": "asset type not allowed"}), 415 + try: + path = extracted_path(target_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 + response = make_response(send_from_directory(path.parent, path.name)) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Content-Security-Policy"] = "default-src 'none'; img-src 'self' data:; style-src 'none'; script-src 'none'; object-src 'none'" + return response + @app.route("/api/gpt/config", methods=["GET", "POST"]) def api_gpt_config(): if request.method == "GET": @@ -2874,13 +3291,23 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: return jsonify({"error": "会话不存在或不完整"}), 404 try: items = extract_translation_source_items(source_root) + stats = translation_source_stats(source_root) files: dict[str, int] = {} for item in items: files[item["file"]] = files.get(item["file"], 0) + 1 return jsonify( { "session": summarize_session(source_root), + "series_config": read_series_config( + review_root, + str(read_epub_metadata(source_root).get("series_id") or "uncategorized"), + ), "source_count": len(items), + "stats": { + **{key: value for key, value in stats.items() if key != "translatable_by_file"}, + "source_count": len(items), + "note": "段落统计只计算可翻译的日语正文块;空白、纯图片、目录页和不含日文线索的文本不会进入 API 翻译队列,源 EPUB 内容不会被删除。", + }, "files": [{"file": key, "count": value} for key, value in files.items()], "sample": [ { diff --git a/tools/epub_review_editor/static/app.js b/tools/epub_review_editor/static/app.js index 15637b1..3ab3888 100644 --- a/tools/epub_review_editor/static/app.js +++ b/tools/epub_review_editor/static/app.js @@ -35,6 +35,14 @@ let translationJobId = ""; let translationOutputSessionId = ""; let translationPollTimer = null; let translationPromptDefaults = {}; +let librarySessions = []; +let librarySeries = []; +let bookshelfFilter = { type: "all", value: "" }; +let bookshelfSearch = ""; +let activeSeriesId = ""; +let activeBookshelfSessionId = ""; +let activeSeriesConfig = null; +let translationUsesSeriesConfig = false; const READER_SETTINGS_KEY = "epubReviewReaderSettings"; const READER_THEMES = new Set(["default", "eye", "night"]); @@ -149,6 +157,21 @@ const dom = { sessionListMeta: $("sessionListMeta"), bookshelfRefreshBtn: $("bookshelfRefreshBtn"), bookshelfOpenUploadBtn: $("bookshelfOpenUploadBtn"), + bookshelfOpenUploadBtnInline: $("bookshelfOpenUploadBtnInline"), + bookshelfTitle: $("bookshelfTitle"), + bookshelfDescription: $("bookshelfDescription"), + bookshelfSearchInput: $("bookshelfSearchInput"), + bookshelfFacetList: $("bookshelfFacetList"), + seriesConfigBtn: $("seriesConfigBtn"), + seriesConfigDrawer: $("seriesConfigDrawer"), + seriesConfigTitle: $("seriesConfigTitle"), + seriesConfigMeta: $("seriesConfigMeta"), + closeSeriesConfigBtn: $("closeSeriesConfigBtn"), + seriesGlossaryPathInput: $("seriesGlossaryPathInput"), + seriesTranslationPromptInput: $("seriesTranslationPromptInput"), + seriesFormatPromptInput: $("seriesFormatPromptInput"), + seriesCharacterPromptInput: $("seriesCharacterPromptInput"), + saveSeriesConfigBtn: $("saveSeriesConfigBtn"), bookshelfMeta: $("bookshelfMeta"), bookshelfList: $("bookshelfList"), translationBookMeta: $("translationBookMeta"), @@ -161,6 +184,7 @@ const dom = { translationLimitInput: $("translationLimitInput"), translationTemperatureInput: $("translationTemperatureInput"), translationSample: $("translationSample"), + translationStats: $("translationStats"), translationGptStatus: $("translationGptStatus"), translationBaseUrlInput: $("translationBaseUrlInput"), translationModelInput: $("translationModelInput"), @@ -617,6 +641,75 @@ function formatDateTime(value) { return date.toLocaleString("zh-CN", { hour12: false }); } +function bookTitle(session) { + return session.title || (session.metadata && session.metadata.title) || session.source_name || session.id || "未命名书籍"; +} + +function bookSeries(session) { + return session.series || (session.metadata && session.metadata.series) || "未分类"; +} + +function bookSeriesId(session) { + return session.series_id || (session.metadata && session.metadata.series_id) || "uncategorized"; +} + +function seriesTitleById(seriesId) { + const item = (librarySeries || []).find((series) => series.id === seriesId); + return item ? item.title : seriesId || "未分类"; +} + +function bookAuthors(session) { + return Array.isArray(session.authors) ? session.authors : []; +} + +function formatBookSubtitle(session) { + const parts = []; + const authors = bookAuthors(session); + if (authors.length) parts.push(authors.join(" / ")); + if (session.publisher) parts.push(session.publisher); + if (session.volume) parts.push(`第 ${session.volume} 卷`); + return parts.join(" · ") || bookSeries(session); +} + +function matchesBookshelfSearch(session) { + const query = bookshelfSearch.trim().toLowerCase(); + if (!query) return true; + const haystack = [ + bookTitle(session), + bookSeries(session), + ...(bookAuthors(session)), + session.publisher || "", + session.source_name || "", + ].join(" ").toLowerCase(); + return haystack.includes(query); +} + +function sessionMatchesFilter(session) { + if (!matchesBookshelfSearch(session)) return false; + if (bookshelfFilter.type === "series") return bookSeriesId(session) === bookshelfFilter.value; + if (bookshelfFilter.type === "author") return bookAuthors(session).includes(bookshelfFilter.value); + if (bookshelfFilter.type === "publisher") return (session.publisher || "未填写") === bookshelfFilter.value; + if (bookshelfFilter.type === "language") return (session.language || "未知") === bookshelfFilter.value; + return true; +} + +function facetCountMap(sessions, getter) { + const map = new Map(); + sessions.forEach((session) => { + const values = getter(session); + (Array.isArray(values) ? values : [values]).forEach((value) => { + const key = value || "未填写"; + map.set(key, (map.get(key) || 0) + 1); + }); + }); + return [...map.entries()].sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0]), "zh-CN")); +} + +function setBookshelfFilter(type, value = "") { + bookshelfFilter = { type, value }; + renderBookshelfFromState(activeBookshelfSessionId); +} + function renderSessions(sessions = [], activeId = "") { dom.sessionList.innerHTML = ""; dom.sessionListMeta.textContent = sessions.length ? `${sessions.length} 个会话` : "暂无会话"; @@ -665,8 +758,78 @@ function readingPositionLabel(position = {}) { } function renderBookshelf(sessions = [], activeId = "") { + librarySessions = sessions || []; + activeBookshelfSessionId = activeId || activeBookshelfSessionId || ""; + renderBookshelfFromState(activeId); +} + +function renderBookshelfFacets(sessions) { + if (!dom.bookshelfFacetList) return; + const authorCount = facetCountMap(sessions, (session) => bookAuthors(session)); + const publisherCount = facetCountMap(sessions, (session) => session.publisher || "未填写"); + const languageCount = facetCountMap(sessions, (session) => session.language || "未知"); + const seriesItems = (librarySeries || []).map((item) => [item.title, item.count, item.id]); + const activeKey = `${bookshelfFilter.type}:${bookshelfFilter.value}`; + + function group(title, items, type, limit = 12) { + const rows = items.slice(0, limit).map((item) => { + const label = item[0]; + const count = item[1]; + const value = item[2] || label; + const key = `${type}:${value}`; + return ` + + `; + }).join(""); + return ` +
+
${escapeHtml(title)}
+ ${rows || '
暂无
'} +
+ `; + } + + dom.bookshelfFacetList.innerHTML = ` + + ${group("同系列", seriesItems, "series", 18)} + ${group("作者", authorCount, "author", 10)} + ${group("出版社", publisherCount, "publisher", 10)} + ${group("语言", languageCount, "language", 8)} + `; + dom.bookshelfFacetList.querySelectorAll("[data-facet-type]").forEach((button) => { + button.addEventListener("click", () => { + setBookshelfFilter(button.dataset.facetType || "all", button.dataset.facetValue || ""); + }); + }); +} + +function renderBookshelfFromState(activeId = "") { dom.bookshelfList.innerHTML = ""; - dom.bookshelfMeta.textContent = sessions.length ? `${sessions.length} 本历史书籍` : "暂无历史书籍"; + const sessions = librarySessions || []; + renderBookshelfFacets(sessions); + const filtered = sessions.filter(sessionMatchesFilter); + const filterText = bookshelfFilter.type === "all" + ? "全部书籍" + : bookshelfFilter.type === "series" + ? seriesTitleById(bookshelfFilter.value) + : bookshelfFilter.value || "筛选结果"; + activeSeriesId = bookshelfFilter.type === "series" ? bookshelfFilter.value : ""; + dom.bookshelfTitle.textContent = filterText; + dom.bookshelfDescription.textContent = activeSeriesId + ? "同系列书籍可以共用翻译提示词、角色口吻和术语表。" + : "按 EPUB 元数据建立书架,封面悬停可直接进入审校或翻译。"; + if (dom.seriesConfigBtn) { + dom.seriesConfigBtn.disabled = !activeSeriesId; + } + dom.bookshelfMeta.textContent = sessions.length + ? `显示 ${filtered.length} / ${sessions.length} 本 · ${librarySeries.length || 0} 个系列` + : "暂无历史书籍"; if (!sessions.length) { dom.bookshelfList.innerHTML = `
@@ -678,31 +841,47 @@ function renderBookshelf(sessions = [], activeId = "") { dom.bookshelfList.querySelector("[data-bookshelf-upload]").addEventListener("click", () => showStartScreen()); return; } + if (!filtered.length) { + dom.bookshelfList.innerHTML = ` +
+

没有匹配书籍

+

换个分类或搜索词试试。

+
+ `; + return; + } const frag = document.createDocumentFragment(); - sessions.forEach((session) => { + filtered.forEach((session) => { const card = document.createElement("article"); card.className = "bookshelfItem"; card.tabIndex = 0; if (session.id === activeId) card.classList.add("active"); - const title = session.source_name || session.id; + const title = bookTitle(session); const updatedAt = session.updated_at || session.created_at; const initial = String(title || "").trim().slice(0, 1) || "书"; + const coverUrl = session.cover_url || ""; + const subtitle = formatBookSubtitle(session); card.innerHTML = ` - -
-

${escapeHtml(title)}

-

${escapeHtml(readingPositionLabel(session.reading_position))}

-
- ${session.row_count || 0} 段 - 记录 ${session.touched_count || 0} - 标记 ${session.marked_count || 0} - ${updatedAt ? `${escapeHtml(formatDateTime(updatedAt))}` : ""} +
+ ${coverUrl ? `` : `
${escapeHtml(initial)}
`} +
+

${escapeHtml(title)}

+

${escapeHtml(subtitle)}

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

${escapeHtml(title)}

+

${escapeHtml(bookSeries(session))}

`; const open = () => openSession(session.id); @@ -735,6 +914,7 @@ async function refreshBookshelf() { dom.bookshelfMeta.textContent = "正在读取书架……"; const data = await api("/api/sessions"); setVersionBadge(data.version); + librarySeries = data.series || []; renderBookshelf(data.sessions || [], data.active_session_id || ""); return data; } @@ -779,8 +959,28 @@ 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} 个日文正文段落` : "没有检测到可翻译日文段落"; + 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.translationLimitInput.max = String(Math.max(1, Math.min(5000, sourceCount || 5000))); const sample = payload.sample || []; if (!sample.length) { @@ -803,6 +1003,9 @@ async function showTranslationScreen(sessionId) { resetReviewState(); setShellMode(false); translationSessionId = sessionId; + activeSeriesId = ""; + activeSeriesConfig = null; + translationUsesSeriesConfig = false; translationJobId = ""; translationOutputSessionId = ""; dom.startScreen.hidden = true; @@ -823,6 +1026,7 @@ async function showTranslationScreen(sessionId) { api(`/api/session/${encodeURIComponent(sessionId)}/translation-source`), api("/api/translation/defaults"), ]); + activeSeriesId = (sourcePayload.session && bookSeriesId(sourcePayload.session)) || ""; setVersionBadge(defaultsPayload.version); renderTranslationGptConfig(defaultsPayload.gpt || {}); const defaults = defaultsPayload.defaults || {}; @@ -830,6 +1034,7 @@ async function showTranslationScreen(sessionId) { dom.translationRangeModeInput.value = defaults.range_mode || "limit"; dom.translationLimitInput.value = defaults.limit || 20; dom.translationTemperatureInput.value = defaults.temperature ?? 0.2; + applySeriesConfigToTranslation(sourcePayload.series_config || {}); renderTranslationSource(sourcePayload); dom.translationProgressText.textContent = "配置完成后可以开始翻译。默认先做前 20 段试译。"; } @@ -847,6 +1052,10 @@ function collectTranslationOptions() { translation_prompt: dom.translationPromptInput.value, format_prompt: dom.translationFormatPromptInput.value, character_prompt: dom.translationCharacterPromptInput.value, + use_series_glossary_path: translationUsesSeriesConfig && activeSeriesConfig && activeSeriesConfig.glossary_path === dom.translationGlossaryPathInput.value, + use_series_translation_prompt: translationUsesSeriesConfig && activeSeriesConfig && activeSeriesConfig.translation_prompt === dom.translationPromptInput.value, + use_series_format_prompt: translationUsesSeriesConfig && activeSeriesConfig && activeSeriesConfig.format_prompt === dom.translationFormatPromptInput.value, + use_series_character_prompt: translationUsesSeriesConfig && activeSeriesConfig && activeSeriesConfig.character_prompt === dom.translationCharacterPromptInput.value, }; } @@ -884,6 +1093,50 @@ function renderTranslationJob(job) { } } +async function openSeriesConfigDrawer(seriesId = activeSeriesId) { + if (!seriesId) { + toast("请先在左侧选择一个系列。"); + return null; + } + activeSeriesId = seriesId; + const title = seriesTitleById(seriesId); + dom.seriesConfigTitle.textContent = `${title} · 系列设置`; + dom.seriesConfigMeta.textContent = "同系列书籍会优先共用这里的术语表和提示词。API Key 仍保存在全局 API 设置里。"; + dom.seriesConfigDrawer.hidden = false; + const config = await api(`/api/series/${encodeURIComponent(seriesId)}/config`); + activeSeriesConfig = config; + dom.seriesGlossaryPathInput.value = config.glossary_path || ""; + dom.seriesTranslationPromptInput.value = config.translation_prompt || ""; + dom.seriesFormatPromptInput.value = config.format_prompt || ""; + dom.seriesCharacterPromptInput.value = config.character_prompt || ""; + return config; +} + +function closeSeriesConfigDrawer() { + if (dom.seriesConfigDrawer) dom.seriesConfigDrawer.hidden = true; +} + +async function saveActiveSeriesConfig(showToast = true) { + const seriesId = activeSeriesId || (activeSeriesConfig && activeSeriesConfig.series_id) || ""; + if (!seriesId) return null; + if (showToast === false && activeSeriesConfig) { + updateSeriesDrawerFromTranslation(); + } + const payload = { + glossary_path: dom.seriesGlossaryPathInput.value, + translation_prompt: dom.seriesTranslationPromptInput.value, + format_prompt: dom.seriesFormatPromptInput.value, + character_prompt: dom.seriesCharacterPromptInput.value, + }; + const config = await api(`/api/series/${encodeURIComponent(seriesId)}/config`, { + method: "POST", + body: JSON.stringify(payload), + }); + activeSeriesConfig = config; + if (showToast) toast("系列设置已保存,同系列翻译会优先使用它。"); + return config; +} + async function pollTranslationProgress() { if (!translationJobId) return; try { @@ -2146,6 +2399,39 @@ function renderTranslationGptConfig(config) { dom.translationGptStatus.textContent = config.configured ? `已配置 · ${source} · ${config.model}` : "未配置 API Key。"; } +function updateSeriesDrawerFromTranslation() { + if (!activeSeriesConfig) return; + dom.seriesGlossaryPathInput.value = dom.translationGlossaryPathInput.value; + dom.seriesTranslationPromptInput.value = dom.translationPromptInput.value; + dom.seriesFormatPromptInput.value = dom.translationFormatPromptInput.value; + dom.seriesCharacterPromptInput.value = dom.translationCharacterPromptInput.value; +} + +function applySeriesConfigToTranslation(seriesConfig = {}) { + activeSeriesConfig = seriesConfig || {}; + const applied = []; + if (activeSeriesConfig.glossary_path) { + dom.translationGlossaryPathInput.value = activeSeriesConfig.glossary_path; + applied.push("术语表"); + } + if (activeSeriesConfig.translation_prompt) { + dom.translationPromptInput.value = activeSeriesConfig.translation_prompt; + applied.push("翻译提示词"); + } + if (activeSeriesConfig.format_prompt) { + dom.translationFormatPromptInput.value = activeSeriesConfig.format_prompt; + applied.push("格式提示词"); + } + if (activeSeriesConfig.character_prompt) { + dom.translationCharacterPromptInput.value = activeSeriesConfig.character_prompt; + applied.push("角色口吻"); + } + translationUsesSeriesConfig = applied.length > 0; + if (translationUsesSeriesConfig) { + dom.translationGptStatus.textContent = `${dom.translationGptStatus.textContent} · 已套用系列${applied.join("、")}`; + } +} + async function loadGptConfig(force = false) { if (gptConfigLoaded && !force) { if (!glossaryLoaded) loadGlossary(false).catch(() => {}); @@ -2190,20 +2476,29 @@ async function saveGptConfig() { async function saveTranslationGptConfig() { try { + const pageConfig = { + glossary_path: dom.translationGlossaryPathInput.value, + translation_prompt: dom.translationPromptInput.value, + format_prompt: dom.translationFormatPromptInput.value, + character_prompt: dom.translationCharacterPromptInput.value, + }; const config = await api("/api/gpt/config", { method: "POST", body: JSON.stringify({ base_url: dom.translationBaseUrlInput.value, model: dom.translationModelInput.value, api_key: dom.translationApiKeyInput.value, - glossary_path: dom.translationGlossaryPathInput.value, - translation_prompt: dom.translationPromptInput.value, - format_prompt: dom.translationFormatPromptInput.value, - character_prompt: dom.translationCharacterPromptInput.value, keep_existing: true, }), }); renderTranslationGptConfig(config); + dom.translationGlossaryPathInput.value = pageConfig.glossary_path; + dom.translationPromptInput.value = pageConfig.translation_prompt; + dom.translationFormatPromptInput.value = pageConfig.format_prompt; + dom.translationCharacterPromptInput.value = pageConfig.character_prompt; + if (translationUsesSeriesConfig) { + dom.translationGptStatus.textContent = `${dom.translationGptStatus.textContent} · 已套用系列配置`; + } renderGptConfig(config); gptConfigLoaded = true; glossaryLoaded = false; @@ -2216,9 +2511,20 @@ async function saveTranslationGptConfig() { } function resetTranslationPrompts() { + if (activeSeriesConfig && ( + activeSeriesConfig.translation_prompt + || activeSeriesConfig.format_prompt + || activeSeriesConfig.character_prompt + || activeSeriesConfig.glossary_path + )) { + applySeriesConfigToTranslation(activeSeriesConfig); + toast("已恢复同系列配置。"); + return; + } dom.translationPromptInput.value = translationPromptDefaults.translation_prompt || ""; dom.translationFormatPromptInput.value = translationPromptDefaults.format_prompt || ""; dom.translationCharacterPromptInput.value = translationPromptDefaults.character_prompt || ""; + translationUsesSeriesConfig = false; toast("已恢复翻译页预设提示词。"); } @@ -2599,6 +2905,31 @@ function initEvents() { if (!(await leaveReviewShell())) return; await showStartScreen(); }); + if (dom.bookshelfOpenUploadBtnInline) { + dom.bookshelfOpenUploadBtnInline.addEventListener("click", async () => { + if (!(await leaveReviewShell())) return; + await showStartScreen(); + }); + } + if (dom.bookshelfSearchInput) { + dom.bookshelfSearchInput.addEventListener("input", () => { + bookshelfSearch = dom.bookshelfSearchInput.value || ""; + renderBookshelfFromState(activeBookshelfSessionId); + }); + } + if (dom.seriesConfigBtn) { + dom.seriesConfigBtn.addEventListener("click", () => { + openSeriesConfigDrawer().catch((error) => toast(`读取系列设置失败:${error.message}`, 7000)); + }); + } + if (dom.closeSeriesConfigBtn) { + dom.closeSeriesConfigBtn.addEventListener("click", closeSeriesConfigDrawer); + } + if (dom.saveSeriesConfigBtn) { + dom.saveSeriesConfigBtn.addEventListener("click", () => { + saveActiveSeriesConfig(true).catch((error) => toast(`保存系列设置失败:${error.message}`, 9000)); + }); + } dom.translationBackBookshelfBtn.addEventListener("click", () => { showBookshelf().catch((error) => toast(`返回书架失败:${error.message}`, 7000)); }); diff --git a/tools/epub_review_editor/static/index.html b/tools/epub_review_editor/static/index.html index ff8ae36..393c2bc 100644 --- a/tools/epub_review_editor/static/index.html +++ b/tools/epub_review_editor/static/index.html @@ -4,13 +4,13 @@ 双语 EPUB 审校编辑器 - +
-

双语 EPUB 审校编辑器 v0.12.2

+

双语 EPUB 审校编辑器 v0.13.0

正在载入……

@@ -26,22 +26,68 @@
-
-
-
-

书架

-

这里列出这个审校目录里历史打开过的 EPUB。点击一本书即可回到对应审校页面。

+
+
+ +
+
+
+

全部书籍

+

按 EPUB 元数据建立书架,封面悬停可直接进入审校或翻译。

+
+
+ + +
+
+
正在读取书架……
+
+
+
+ +
@@ -62,6 +108,7 @@

输出与范围

未读取源书信息
+