fix(editor): prevent duplicate AI translation layers
This commit is contained in:
@@ -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/<series-id>.json`,只保存术语表路径和三段提示词,不保存 API Key。
|
- 同系列配置存放在 `epub_review_sessions/series_configs/<series-id>.json`,只保存术语表路径和三段提示词,不保存 API Key。
|
||||||
- 整书翻译源抽取不使用双语审校的 `rows.json`;`rows.json` 是审校行数,日语 EPUB 的可翻译数量看 `/api/session/<id>/translation-source`,UI 文案应称为“正文块”以避免和双语审校“段”混淆。
|
- 整书翻译源抽取不使用双语审校的 `rows.json`;`rows.json` 是审校行数,日语 EPUB 的可翻译数量看 `/api/session/<id>/translation-source`,UI 文案应称为“正文块”以避免和双语审校“段”混淆。
|
||||||
- 翻译源统计应区分可翻译正文块、HTML 文件、正文段、标题、图片、目录和空白块;默认试译只翻译前 20 段/块,不代表源书被删。
|
- 翻译源统计应区分可翻译正文块、HTML 文件、正文段、标题、图片、目录和空白块;默认试译只翻译前 20 段/块,不代表源书被删。
|
||||||
|
- 翻译页默认应保持克制,只显示源书名和可翻译正文块数量;冗余段落预览、诊断 note 和大批统计 chip 不应默认出现在 UI。
|
||||||
|
|
||||||
## 已知排查结论
|
## 已知排查结论
|
||||||
|
|
||||||
- `TRPGプレイヤーが異世界で最強ビルドを目指す 3` 日版 EPUB 解包内容未被删除。此前用户看到数量偏少,原因是 UI 混用了双语审校 `row_count` 与日语源书 `source_count`,且试译 job 的 `total=25` 只是范围限制。
|
- `TRPGプレイヤーが異世界で最強ビルドを目指す 3` 日版 EPUB 解包内容未被删除。此前用户看到数量偏少,原因是 UI 混用了双语审校 `row_count` 与日语源书 `source_count`,且试译 job 的 `total=25` 只是范围限制。
|
||||||
- 当前修复后,该 session 的可翻译源正文块为 `2949`,统计包含 `3084` 个候选块、`3083` 个 `<p>`、`1` 个标题、`27` 个图片块、`7` 个目录块和 `129` 个空白块。
|
- 当前修复后,该 session 的可翻译源正文块为 `2949`,统计包含 `3084` 个候选块、`3083` 个 `<p>`、`1` 个标题、`27` 个图片块、`7` 个目录块和 `129` 个空白块。
|
||||||
|
- 2026-07-08 修复整书翻译重复层:如果输入已经是中日双语 EPUB,翻译源抽取只把显式灰色/source 源段作为 item,并记录下一段中文 pair;双语输出替换旧中文层,纯中文输出移除对应日文层。`build_rows()` 也收敛为只接受显式灰色/source 标记,不再因含假名就把普通段落当源文。
|
||||||
|
|
||||||
## 下次优先检查
|
## 下次优先检查
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ epub_review_sessions/
|
|||||||
- 没有历史书籍时,书架显示空状态并提供“打开新的 EPUB”入口;开始页/书架页顶栏保持正常显示,不套用审校页沉浸隐藏。
|
- 没有历史书籍时,书架显示空状态并提供“打开新的 EPUB”入口;开始页/书架页顶栏保持正常显示,不套用审校页沉浸隐藏。
|
||||||
- 书架应按 EPUB 元数据建立分类:全部书籍、同系列、作者、出版社、语言;同系列分类使用稳定 `series_id`,显示书籍数量。
|
- 书架应按 EPUB 元数据建立分类:全部书籍、同系列、作者、出版社、语言;同系列分类使用稳定 `series_id`,显示书籍数量。
|
||||||
- 书架主区域以封面网格为主,不用横向信息卡替代封面;无封面时使用稳定 fallback,不出现破图或布局跳动。
|
- 书架主区域以封面网格为主,不用横向信息卡替代封面;无封面时使用稳定 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` 模式在原日文 `<p>` 后插入中文 `<p>`,并把原日文段落标为灰色,方便进入现有双语审校器逐段审校。
|
- `bilingual` 模式在原日文 `<p>` 后插入中文 `<p>`,并把原日文段落标为灰色且标记为 source,方便进入现有双语审校器逐段审校。
|
||||||
|
- 如果输入已经是中日双语 EPUB,翻译源抽取必须识别显式灰色/source 源段与下一段中文译文的 pair;`bilingual` 模式应替换已有中文层,不得追加第二个中文层;`translated` 模式应输出新的中文层并移除对应日文源层。
|
||||||
|
- 如果历史 session 已经出现“日文源层 + 中文层 + 旧中文灰色源层 + 新中文层”的四层坏结构,进入翻译源统计、整书翻译或 rows 重建前应自动清理为“日文源层 + 第一个中文层”,避免旧中文继续被当作日文源文。
|
||||||
- `translated` 模式用中文替换原日文段落;该输出会加入书架,但由于没有日中成对段落,可能没有逐段双语审校行。
|
- `translated` 模式用中文替换原日文段落;该输出会加入书架,但由于没有日中成对段落,可能没有逐段双语审校行。
|
||||||
- 图片、样式、OPF、spine 和非正文文件应原样保留。
|
- 图片、样式、OPF、spine 和非正文文件应原样保留。
|
||||||
- 翻译失败的单段必须写入可见占位和任务日志,不能让整本输出静默缺段。
|
- 翻译失败的单段必须写入可见占位和任务日志,不能让整本输出静默缺段。
|
||||||
@@ -265,10 +268,13 @@ Prompt 硬规则:
|
|||||||
- 书架卡片“翻译”按钮能进入独立翻译页,且不会触发卡片的“进入审校”
|
- 书架卡片“翻译”按钮能进入独立翻译页,且不会触发卡片的“进入审校”
|
||||||
- `/api/translation/defaults` 不返回 API Key,提示词和术语表路径可读取
|
- `/api/translation/defaults` 不返回 API Key,提示词和术语表路径可读取
|
||||||
- `/api/session/<session_id>/translation-source` 能统计日语 EPUB 可翻译正文块和诊断字段;非日语或无正文时返回清晰空状态;审校行数、源正文块数、试译 limit 不得在 UI 上混为一个“段落数”
|
- `/api/session/<session_id>/translation-source` 能统计日语 EPUB 可翻译正文块和诊断字段;非日语或无正文时返回清晰空状态;审校行数、源正文块数、试译 limit 不得在 UI 上混为一个“段落数”
|
||||||
|
- 翻译页默认只显示源书名和“可翻译正文块 N 个”,不保留冗余段落预览、诊断 note 或大批统计 chip 的可见入口;诊断字段可保留在 API 中供排查
|
||||||
- 未配置 API Key 时,`/api/translation/start` 返回清晰错误,不影响阅读、编辑、导出和单段重翻
|
- 未配置 API Key 时,`/api/translation/start` 返回清晰错误,不影响阅读、编辑、导出和单段重翻
|
||||||
- 启动整书翻译任务后,`/api/translation/jobs/<job_id>` 可看到 pending/running/completed/failed、总段数、已完成数、当前文件和日志
|
- 启动整书翻译任务后,`/api/translation/jobs/<job_id>` 可看到 pending/running/completed/failed、总段数、已完成数、当前文件和日志
|
||||||
- 默认翻译范围是前 20 段试译;全书翻译必须由用户显式切换并确认
|
- 默认翻译范围是前 20 段试译;全书翻译必须由用户显式切换并确认
|
||||||
- 整书翻译每段只注入该段源文/ruby 命中的术语,不注入无关术语或全表
|
- 整书翻译每段只注入该段源文/ruby 命中的术语,不注入无关术语或全表
|
||||||
- 整书翻译完成后,输出 EPUB 写入 `translated_outputs/` 并自动出现在书架;中日双语输出打开后 `row_count > 0`
|
- 整书翻译完成后,输出 EPUB 写入 `translated_outputs/` 并自动出现在书架;中日双语输出打开后 `row_count > 0`
|
||||||
|
- 已双语 EPUB 再次执行整书翻译时,输出不得出现“旧中文译文 + 新中文译文”重复层;审校行 `jp_text` 不得变成中文旧译文
|
||||||
- 纯中文输出可以加入书架,且文档说明其不适合逐段双语审校
|
- 纯中文输出可以加入书架,且文档说明其不适合逐段双语审校
|
||||||
- 离开翻译页、返回书架或打开输出审校时,前端进度轮询会停止
|
- 离开翻译页、返回书架或打开输出审校时,前端进度轮询会停止
|
||||||
|
- 书架卡片“删除”按钮能软删除会话、刷新书架、清空 active session(如删除当前书),且不会删除原始 EPUB 文件
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 双语 EPUB 审校编辑器
|
# 双语 EPUB 审校编辑器
|
||||||
|
|
||||||
当前版本:`0.13.0`
|
当前版本:`0.13.1`
|
||||||
|
|
||||||
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
|
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
|
||||||
|
|
||||||
@@ -48,6 +48,8 @@
|
|||||||
|
|
||||||
`0.13.0` 新增元数据书架与同系列配置:书架按 EPUB 元数据建立 `library.json`,左侧提供系列、作者、出版社、语言分类,封面悬停显示书籍信息以及“校对/翻译”入口;同系列可共享术语表路径、翻译内容提示词、格式提示词和角色口吻提示词。AI 翻译源统计改为正文块诊断,区分审校行数、可翻译日文正文块、图片、目录和空白块,并补翻日文标题与日语上下文里的短句,避免把默认试译范围误认为正文被删。
|
`0.13.0` 新增元数据书架与同系列配置:书架按 EPUB 元数据建立 `library.json`,左侧提供系列、作者、出版社、语言分类,封面悬停显示书籍信息以及“校对/翻译”入口;同系列可共享术语表路径、翻译内容提示词、格式提示词和角色口吻提示词。AI 翻译源统计改为正文块诊断,区分审校行数、可翻译日文正文块、图片、目录和空白块,并补翻日文标题与日语上下文里的短句,避免把默认试译范围误认为正文被删。
|
||||||
|
|
||||||
|
`0.13.1` 修复整书 AI 翻译重复正文块:已是中日双语的 EPUB 再翻译时会识别旧的日文源文/中文译文 pair,并替换中文层,不再把旧译文当成源文追加新译文;审校行识别也收敛为只接受显式灰色/source 标记。翻译页删除冗余段落预览和诊断提示,统一使用“正文块”口径,书架封面悬停菜单新增“删除”,可从书架软删除会话且不删除原始 EPUB。
|
||||||
|
|
||||||
## 独立安装
|
## 独立安装
|
||||||
|
|
||||||
这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub_review_editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖:
|
这个审校器现在可以脱离 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 后会自动创建审校会话。
|
||||||
- 开始页会列出已有会话,换电脑或重启服务后可以继续打开之前的审校记录。
|
- 开始页会列出已有会话,换电脑或重启服务后可以继续打开之前的审校记录。
|
||||||
- 顶栏左上角“书架”会列出历史打开过的书籍,并按 EPUB 元数据生成分类;点击封面或“校对”会进入对应审校页面。
|
- 顶栏左上角“书架”会列出历史打开过的书籍,并按 EPUB 元数据生成分类;点击封面或“校对”会进入对应审校页面。
|
||||||
- 书架中每本书封面悬停会变暗并显示书籍信息、“校对”和“翻译”按钮;“翻译”可对日语 EPUB 发起 AI 翻译任务,输出 EPUB 会自动加入书架。
|
- 书架中每本书封面悬停会变暗并显示书籍信息、“校对”“翻译”和“删除”按钮;“翻译”可对日语 EPUB 发起 AI 翻译任务,输出 EPUB 会自动加入书架;“删除”只会从书架隐藏审校会话,不删除原始 EPUB。
|
||||||
- 上传的 EPUB、解包副本、反馈与导出文件都会保存在 `--review-root` 指定目录下。
|
- 上传的 EPUB、解包副本、反馈与导出文件都会保存在 `--review-root` 指定目录下。
|
||||||
|
|
||||||
## 能做什么
|
## 能做什么
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ DEFAULT_TRANSLATION_LIMIT = 20
|
|||||||
TRANSLATION_RANGE_LIMIT_MAX = 5000
|
TRANSLATION_RANGE_LIMIT_MAX = 5000
|
||||||
TRANSLATION_CONTEXT_RADIUS = 1
|
TRANSLATION_CONTEXT_RADIUS = 1
|
||||||
LIBRARY_DB_NAME = "library.json"
|
LIBRARY_DB_NAME = "library.json"
|
||||||
|
ROWS_PARSER_VERSION = "0.13.1-translation-pair-cleanup"
|
||||||
|
|
||||||
|
|
||||||
def now_iso() -> str:
|
def now_iso() -> str:
|
||||||
@@ -235,17 +236,63 @@ def is_japaneseish(fragment: str) -> bool:
|
|||||||
def is_gray_source(attrs: str, inner: str) -> bool:
|
def is_gray_source(attrs: str, inner: str) -> bool:
|
||||||
if GRAY_RE.search(attrs):
|
if GRAY_RE.search(attrs):
|
||||||
return True
|
return True
|
||||||
return is_japaneseish(inner)
|
return False
|
||||||
|
|
||||||
|
|
||||||
def paragraph_visible_text(inner: str) -> str:
|
def paragraph_visible_text(inner: str) -> str:
|
||||||
return compact_text(strip_tags(inner or ""))
|
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:
|
def paragraph_prompt_text(inner: str) -> str:
|
||||||
return strip_tags(inner or "").strip()
|
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:
|
def html_declares_japanese(text: str) -> bool:
|
||||||
return bool(re.search(r"\b(?:xml:)?lang\s*=\s*['\"]ja(?:[-_'\"]|$)", text or "", flags=re.I))
|
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"
|
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:
|
def library_db_path(review_root: Path) -> Path:
|
||||||
return review_root / LIBRARY_DB_NAME
|
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), {})
|
existing = read_json(session_manifest_path(session_root), {})
|
||||||
metadata = read_epub_metadata(session_root)
|
metadata = read_epub_metadata(session_root)
|
||||||
metadata["source_name"] = source_name or epub_path.name
|
metadata["source_name"] = source_name or epub_path.name
|
||||||
|
existing.pop("hidden", None)
|
||||||
|
existing.pop("deleted_at", None)
|
||||||
record = {
|
record = {
|
||||||
"id": session_public_id(session_root),
|
"id": session_public_id(session_root),
|
||||||
"source_name": source_name or epub_path.name,
|
"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", {}))
|
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]:
|
def summarize_session(session_root: Path) -> dict[str, Any]:
|
||||||
manifest = read_json(session_manifest_path(session_root), {})
|
manifest = read_json(session_manifest_path(session_root), {})
|
||||||
state = read_json(session_root / "review_state" / "state.json", {})
|
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
|
continue
|
||||||
if not (child / "review_state" / "state.json").exists():
|
if not (child / "review_state" / "state.json").exists():
|
||||||
continue
|
continue
|
||||||
|
if session_is_hidden(child):
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
sessions.append(summarize_session(child))
|
sessions.append(summarize_session(child))
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -1502,6 +1580,7 @@ def translation_source_stats(session_root: Path) -> dict[str, Any]:
|
|||||||
skipped_blank = 0
|
skipped_blank = 0
|
||||||
skipped_image = 0
|
skipped_image = 0
|
||||||
skipped_no_japanese = 0
|
skipped_no_japanese = 0
|
||||||
|
skipped_existing_translation = 0
|
||||||
skipped_no_japanese_examples: list[dict[str, Any]] = []
|
skipped_no_japanese_examples: list[dict[str, Any]] = []
|
||||||
translatable_by_file: dict[str, int] = {}
|
translatable_by_file: dict[str, int] = {}
|
||||||
for entry_name in html_files:
|
for entry_name in html_files:
|
||||||
@@ -1518,7 +1597,10 @@ def translation_source_stats(session_root: Path) -> dict[str, Any]:
|
|||||||
if nav:
|
if nav:
|
||||||
skipped_nav_files += 1
|
skipped_nav_files += 1
|
||||||
skipped_nav_blocks += len(matches)
|
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()
|
tag = match.group("tag").lower()
|
||||||
inner = match.group("inner")
|
inner = match.group("inner")
|
||||||
visible = paragraph_visible_text(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_blank": skipped_blank,
|
||||||
"skipped_image": skipped_image,
|
"skipped_image": skipped_image,
|
||||||
"skipped_no_japanese": skipped_no_japanese,
|
"skipped_no_japanese": skipped_no_japanese,
|
||||||
|
"skipped_existing_translation": skipped_existing_translation,
|
||||||
"skipped_no_japanese_examples": skipped_no_japanese_examples,
|
"skipped_no_japanese_examples": skipped_no_japanese_examples,
|
||||||
"translatable_by_file": translatable_by_file,
|
"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]]:
|
def build_rows(session_root: Path) -> list[dict[str, Any]]:
|
||||||
html_entries = html_entries_in_reading_order(session_root)
|
html_entries = html_entries_in_reading_order(session_root)
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
@@ -2267,6 +2424,14 @@ def build_rows(session_root: Path) -> list[dict[str, Any]]:
|
|||||||
else:
|
else:
|
||||||
i += 1
|
i += 1
|
||||||
write_json(session_root / "review_state" / "rows.json", rows)
|
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
|
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)
|
extract_epub(epub_path, session_root, reset=reset)
|
||||||
rows_path = session_root / "review_state" / "rows.json"
|
rows_path = session_root / "review_state" / "rows.json"
|
||||||
rows = read_json(rows_path, None)
|
rows = read_json(rows_path, None)
|
||||||
if rows is None:
|
if rows is None or rows_need_rebuild(session_root):
|
||||||
rows = build_rows(session_root)
|
rows = ensure_rows_current(session_root)
|
||||||
state_path = session_root / "review_state" / "state.json"
|
state_path = session_root / "review_state" / "state.json"
|
||||||
state = read_json(
|
state = read_json(
|
||||||
state_path,
|
state_path,
|
||||||
@@ -2289,13 +2454,15 @@ def ensure_state(session_root: Path, epub_path: Path, reset: bool = False) -> di
|
|||||||
)
|
)
|
||||||
state.setdefault("edits", {})
|
state.setdefault("edits", {})
|
||||||
state["source_epub"] = str(epub_path)
|
state["source_epub"] = str(epub_path)
|
||||||
|
state.pop("hidden", None)
|
||||||
|
state.pop("deleted_at", None)
|
||||||
write_json(state_path, state)
|
write_json(state_path, state)
|
||||||
write_session_manifest(session_root, epub_path)
|
write_session_manifest(session_root, epub_path)
|
||||||
return state
|
return state
|
||||||
|
|
||||||
|
|
||||||
def merge_rows(session_root: Path) -> list[dict[str, Any]]:
|
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": {}})
|
state = read_json(session_root / "review_state" / "state.json", {"edits": {}})
|
||||||
edits = state.get("edits", {})
|
edits = state.get("edits", {})
|
||||||
merged = []
|
merged = []
|
||||||
@@ -2514,7 +2681,7 @@ def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, A
|
|||||||
try:
|
try:
|
||||||
limit = int(data.get("limit") or DEFAULT_TRANSLATION_LIMIT)
|
limit = int(data.get("limit") or DEFAULT_TRANSLATION_LIMIT)
|
||||||
except (TypeError, ValueError) as exc:
|
except (TypeError, ValueError) as exc:
|
||||||
raise ValueError("试译段数必须是数字") from exc
|
raise ValueError("试译正文块数必须是数字") from exc
|
||||||
limit = max(1, min(limit, TRANSLATION_RANGE_LIMIT_MAX))
|
limit = max(1, min(limit, TRANSLATION_RANGE_LIMIT_MAX))
|
||||||
try:
|
try:
|
||||||
temperature = float(data.get("temperature", 0.2))
|
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
|
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_has_japanese_context = source_file_has_japanese_context(text)
|
||||||
file_serial = 0
|
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()
|
tag = match.group("tag").lower()
|
||||||
inner = match.group("inner")
|
inner = match.group("inner")
|
||||||
if not is_translatable_source_block(inner, tag, file_has_japanese_context):
|
if not is_translatable_source_block(inner, tag, file_has_japanese_context):
|
||||||
continue
|
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)
|
source_text = paragraph_prompt_text(inner)
|
||||||
serial += 1
|
serial += 1
|
||||||
file_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,
|
"file_label": Path(entry_name).name,
|
||||||
"document_title": document_title,
|
"document_title": document_title,
|
||||||
"block_index": block_index,
|
"block_index": block_index,
|
||||||
|
"paired_cn_index": paired_cn_index,
|
||||||
"p_index": block_index,
|
"p_index": block_index,
|
||||||
"tag": tag,
|
"tag": tag,
|
||||||
"open_tag": match.group("open"),
|
"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)
|
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(
|
def replace_translated_paragraphs(
|
||||||
text: str,
|
text: str,
|
||||||
replacements: dict[int, dict[str, str]],
|
replacements: dict[int, dict[str, Any]],
|
||||||
output_mode: str,
|
output_mode: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
last = 0
|
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)):
|
for index, match in enumerate(SOURCE_BLOCK_RE.finditer(text)):
|
||||||
parts.append(text[last:match.start()])
|
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))
|
parts.append(match.group(0))
|
||||||
elif output_mode == "bilingual":
|
elif output_mode == "bilingual":
|
||||||
replacement = replacements[index]
|
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(source_open)
|
||||||
parts.append(match.group("inner"))
|
parts.append(match.group("inner"))
|
||||||
parts.append(match.group("close"))
|
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(replacement.get("html", ""))
|
||||||
parts.append(f"</{replacement.get('tag') or 'p'}>")
|
parts.append(f"</{translated_tag}>")
|
||||||
else:
|
else:
|
||||||
replacement = replacements[index]
|
replacement = replacements[index]
|
||||||
parts.append(match.group("open"))
|
if replacement.get("paired_cn_index") is not None:
|
||||||
parts.append(replacement.get("html", ""))
|
translated_tag = replacement.get("tag") or "p"
|
||||||
parts.append(match.group("close"))
|
parts.append(translated_open_tag(translated_tag))
|
||||||
|
parts.append(replacement.get("html", ""))
|
||||||
|
parts.append(f"</{translated_tag}>")
|
||||||
|
else:
|
||||||
|
parts.append(match.group("open"))
|
||||||
|
parts.append(replacement.get("html", ""))
|
||||||
|
parts.append(match.group("close"))
|
||||||
last = match.end()
|
last = match.end()
|
||||||
parts.append(text[last:])
|
parts.append(text[last:])
|
||||||
return "".join(parts)
|
return "".join(parts)
|
||||||
@@ -2709,7 +2907,7 @@ def build_translation_output_epub(
|
|||||||
out_path: Path,
|
out_path: Path,
|
||||||
) -> Path:
|
) -> Path:
|
||||||
entries = read_json(session_root / "review_state" / "epub_entries.json", [])
|
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:
|
for item in items:
|
||||||
translated = translations.get(item["id"])
|
translated = translations.get(item["id"])
|
||||||
if translated is None:
|
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)))] = {
|
by_file.setdefault(item["file"], {})[int(item.get("block_index", item.get("p_index", 0)))] = {
|
||||||
"html": translated,
|
"html": translated,
|
||||||
"tag": translated_tag,
|
"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)
|
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_file = Path(str(settings.get("glossary_path") or config.get("glossary_path") or ""))
|
||||||
glossary = read_glossary_mapping_from_path(glossary_file)
|
glossary = read_glossary_mapping_from_path(glossary_file)
|
||||||
|
|
||||||
|
normalize_duplicate_layers_and_rows(source_session)
|
||||||
all_items = extract_translation_source_items(source_session)
|
all_items = extract_translation_source_items(source_session)
|
||||||
source_total = len(all_items)
|
source_total = len(all_items)
|
||||||
if settings.get("range_mode") == "all":
|
if settings.get("range_mode") == "all":
|
||||||
@@ -2819,13 +3020,13 @@ def run_translation_job(review_root: Path, job_id: str) -> None:
|
|||||||
else:
|
else:
|
||||||
selected_items = all_items[: int(settings.get("limit") or DEFAULT_TRANSLATION_LIMIT)]
|
selected_items = all_items[: int(settings.get("limit") or DEFAULT_TRANSLATION_LIMIT)]
|
||||||
if not selected_items:
|
if not selected_items:
|
||||||
raise ValueError("没有找到可翻译的日文正文段落")
|
raise ValueError("没有找到可翻译的日文正文块")
|
||||||
|
|
||||||
job["progress"].update(
|
job["progress"].update(
|
||||||
{
|
{
|
||||||
"total": len(selected_items),
|
"total": len(selected_items),
|
||||||
"source_total": source_total,
|
"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"])
|
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")
|
session_root = app.config.get("SESSION_ROOT")
|
||||||
epub_path = app.config.get("EPUB_PATH")
|
epub_path = app.config.get("EPUB_PATH")
|
||||||
if isinstance(session_root, 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)
|
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", "")
|
active_id = read_json(app_manifest_path(review_root), {}).get("active_session_id", "")
|
||||||
if active_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)
|
manifest_root = session_root_from_id(review_root, active_id)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None, None
|
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)
|
set_active_session(manifest_root)
|
||||||
return app.config["SESSION_ROOT"], app.config["EPUB_PATH"]
|
return app.config["SESSION_ROOT"], app.config["EPUB_PATH"]
|
||||||
return None, None
|
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
|
return jsonify({"error": str(exc)}), 400
|
||||||
if not (session_root / "review_state" / "state.json").exists():
|
if not (session_root / "review_state" / "state.json").exists():
|
||||||
return jsonify({"error": "会话不存在或不完整"}), 404
|
return jsonify({"error": "会话不存在或不完整"}), 404
|
||||||
|
if session_is_hidden(session_root):
|
||||||
|
return jsonify({"error": "会话已从书架移除"}), 404
|
||||||
with app.config["LOCK"]:
|
with app.config["LOCK"]:
|
||||||
set_active_session(session_root)
|
set_active_session(session_root)
|
||||||
summary = summarize_session(session_root)
|
summary = summarize_session(session_root)
|
||||||
summary["has_session"] = True
|
summary["has_session"] = True
|
||||||
return jsonify(summary)
|
return jsonify(summary)
|
||||||
|
|
||||||
|
@app.route("/api/session/<session_id>", 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")
|
@app.route("/api/rows")
|
||||||
def api_rows():
|
def api_rows():
|
||||||
session_root, _epub_path = active_session()
|
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
|
return jsonify({"error": str(exc)}), 400
|
||||||
if not (source_root / "review_state" / "state.json").exists():
|
if not (source_root / "review_state" / "state.json").exists():
|
||||||
return jsonify({"error": "会话不存在或不完整"}), 404
|
return jsonify({"error": "会话不存在或不完整"}), 404
|
||||||
|
if session_is_hidden(source_root):
|
||||||
|
return jsonify({"error": "会话已从书架移除"}), 404
|
||||||
try:
|
try:
|
||||||
|
normalize_duplicate_layers_and_rows(source_root)
|
||||||
items = extract_translation_source_items(source_root)
|
items = extract_translation_source_items(source_root)
|
||||||
stats = translation_source_stats(source_root)
|
stats = translation_source_stats(source_root)
|
||||||
files: dict[str, int] = {}
|
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
|
return jsonify({"error": str(exc)}), 400
|
||||||
if not (source_root / "review_state" / "state.json").exists():
|
if not (source_root / "review_state" / "state.json").exists():
|
||||||
return jsonify({"error": "会话不存在或不完整"}), 404
|
return jsonify({"error": "会话不存在或不完整"}), 404
|
||||||
|
if session_is_hidden(source_root):
|
||||||
|
return jsonify({"error": "会话已从书架移除"}), 404
|
||||||
try:
|
try:
|
||||||
requested_base_url = str(data.get("base_url") or "").strip()
|
requested_base_url = str(data.get("base_url") or "").strip()
|
||||||
requested_model = str(data.get("model") or "").strip()
|
requested_model = str(data.get("model") or "").strip()
|
||||||
|
|||||||
@@ -183,8 +183,6 @@ const dom = {
|
|||||||
translationRangeModeInput: $("translationRangeModeInput"),
|
translationRangeModeInput: $("translationRangeModeInput"),
|
||||||
translationLimitInput: $("translationLimitInput"),
|
translationLimitInput: $("translationLimitInput"),
|
||||||
translationTemperatureInput: $("translationTemperatureInput"),
|
translationTemperatureInput: $("translationTemperatureInput"),
|
||||||
translationSample: $("translationSample"),
|
|
||||||
translationStats: $("translationStats"),
|
|
||||||
translationGptStatus: $("translationGptStatus"),
|
translationGptStatus: $("translationGptStatus"),
|
||||||
translationBaseUrlInput: $("translationBaseUrlInput"),
|
translationBaseUrlInput: $("translationBaseUrlInput"),
|
||||||
translationModelInput: $("translationModelInput"),
|
translationModelInput: $("translationModelInput"),
|
||||||
@@ -748,6 +746,23 @@ async function refreshSessions() {
|
|||||||
return data;
|
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 = {}) {
|
function readingPositionLabel(position = {}) {
|
||||||
if (!position || !position.scope || position.scope === "none") return "尚无阅读记录";
|
if (!position || !position.scope || position.scope === "none") return "尚无阅读记录";
|
||||||
if (position.scope === "image") return "上次阅读:插图";
|
if (position.scope === "image") return "上次阅读:插图";
|
||||||
@@ -868,17 +883,18 @@ function renderBookshelfFromState(activeId = "") {
|
|||||||
<div class="bookOverlay">
|
<div class="bookOverlay">
|
||||||
<h3>${escapeHtml(title)}</h3>
|
<h3>${escapeHtml(title)}</h3>
|
||||||
<p>${escapeHtml(subtitle)}</p>
|
<p>${escapeHtml(subtitle)}</p>
|
||||||
<div class="bookFacts overlayFacts">
|
<div class="bookFacts overlayFacts">
|
||||||
<span>${session.row_count || 0} 审校段</span>
|
<span>${session.row_count || 0} 审校段</span>
|
||||||
<span>${session.volume ? `第 ${escapeHtml(session.volume)} 卷` : escapeHtml(session.language || "未知语言")}</span>
|
<span>${session.volume ? `第 ${escapeHtml(session.volume)} 卷` : escapeHtml(session.language || "未知语言")}</span>
|
||||||
${updatedAt ? `<span>${escapeHtml(formatDateTime(updatedAt))}</span>` : ""}
|
${updatedAt ? `<span>${escapeHtml(formatDateTime(updatedAt))}</span>` : ""}
|
||||||
</div>
|
|
||||||
<div class="bookActions">
|
|
||||||
<button type="button" data-open-review>${session.id === activeId ? "继续校对" : "校对"}</button>
|
|
||||||
<button type="button" data-open-translation>翻译</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="bookActions">
|
||||||
|
<button type="button" data-open-review>${session.id === activeId ? "继续校对" : "校对"}</button>
|
||||||
|
<button type="button" data-open-translation>翻译</button>
|
||||||
|
<button type="button" class="dangerAction" data-delete-book>删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="bookCaption">
|
<div class="bookCaption">
|
||||||
<h3>${escapeHtml(title)}</h3>
|
<h3>${escapeHtml(title)}</h3>
|
||||||
<p>${escapeHtml(bookSeries(session))}</p>
|
<p>${escapeHtml(bookSeries(session))}</p>
|
||||||
@@ -887,6 +903,7 @@ function renderBookshelfFromState(activeId = "") {
|
|||||||
const open = () => openSession(session.id);
|
const open = () => openSession(session.id);
|
||||||
const reviewButton = card.querySelector("[data-open-review]");
|
const reviewButton = card.querySelector("[data-open-review]");
|
||||||
const translateButton = card.querySelector("[data-open-translation]");
|
const translateButton = card.querySelector("[data-open-translation]");
|
||||||
|
const deleteButton = card.querySelector("[data-delete-book]");
|
||||||
reviewButton.addEventListener("click", (event) => {
|
reviewButton.addEventListener("click", (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
open();
|
open();
|
||||||
@@ -895,6 +912,10 @@ function renderBookshelfFromState(activeId = "") {
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
showTranslationScreen(session.id).catch((error) => toast(`打开翻译页失败:${error.message}`, 7000));
|
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) => {
|
card.addEventListener("click", (event) => {
|
||||||
if (event.target.closest("button")) return;
|
if (event.target.closest("button")) return;
|
||||||
open();
|
open();
|
||||||
@@ -959,42 +980,9 @@ async function showStartScreen(sessionPayload = null) {
|
|||||||
function renderTranslationSource(payload) {
|
function renderTranslationSource(payload) {
|
||||||
const session = payload.session || {};
|
const session = payload.session || {};
|
||||||
const sourceCount = payload.source_count || 0;
|
const sourceCount = payload.source_count || 0;
|
||||||
dom.translationBookMeta.textContent = `${session.source_name || translationSessionId} · 可翻译日文正文块 ${sourceCount} 个`;
|
dom.translationBookMeta.textContent = session.source_name || translationSessionId;
|
||||||
dom.translationSourceMeta.textContent = sourceCount ? `检测到 ${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 = `
|
|
||||||
<div class="statChipRow">
|
|
||||||
${chips.map(([label, value]) => `<span class="statChip"><b>${escapeHtml(value)}</b>${escapeHtml(label)}</span>`).join("")}
|
|
||||||
</div>
|
|
||||||
${stats.note ? `<p>${escapeHtml(stats.note)}</p>` : ""}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
dom.translationLimitInput.max = String(Math.max(1, Math.min(5000, sourceCount || 5000)));
|
dom.translationLimitInput.max = String(Math.max(1, Math.min(5000, sourceCount || 5000)));
|
||||||
const sample = payload.sample || [];
|
|
||||||
if (!sample.length) {
|
|
||||||
dom.translationSample.innerHTML = '<div class="translationEmpty">暂无可预览段落。请确认这本书是日语 EPUB。</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
dom.translationSample.innerHTML = sample
|
|
||||||
.map((item) => `
|
|
||||||
<div class="translationSampleItem">
|
|
||||||
<strong>${escapeHtml(item.id)} · ${escapeHtml(item.file || "")}</strong>
|
|
||||||
<p>${escapeHtml(item.text || "")}</p>
|
|
||||||
</div>
|
|
||||||
`)
|
|
||||||
.join("");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function showTranslationScreen(sessionId) {
|
async function showTranslationScreen(sessionId) {
|
||||||
@@ -1036,7 +1024,7 @@ async function showTranslationScreen(sessionId) {
|
|||||||
dom.translationTemperatureInput.value = defaults.temperature ?? 0.2;
|
dom.translationTemperatureInput.value = defaults.temperature ?? 0.2;
|
||||||
applySeriesConfigToTranslation(sourcePayload.series_config || {});
|
applySeriesConfigToTranslation(sourcePayload.series_config || {});
|
||||||
renderTranslationSource(sourcePayload);
|
renderTranslationSource(sourcePayload);
|
||||||
dom.translationProgressText.textContent = "配置完成后可以开始翻译。默认先做前 20 段试译。";
|
dom.translationProgressText.textContent = "配置完成后可以开始翻译。默认先做前 20 个正文块试译。";
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectTranslationOptions() {
|
function collectTranslationOptions() {
|
||||||
@@ -1071,7 +1059,7 @@ function renderTranslationJob(job) {
|
|||||||
progress.message || "正在处理……",
|
progress.message || "正在处理……",
|
||||||
total ? `进度:${current}/${total}` : "",
|
total ? `进度:${current}/${total}` : "",
|
||||||
progress.current_file ? `当前文件:${progress.current_file}` : "",
|
progress.current_file ? `当前文件:${progress.current_file}` : "",
|
||||||
failed ? `失败段落:${failed}` : "",
|
failed ? `失败正文块:${failed}` : "",
|
||||||
job.error ? `错误:${job.error}` : "",
|
job.error ? `错误:${job.error}` : "",
|
||||||
].filter(Boolean).join("\n");
|
].filter(Boolean).join("\n");
|
||||||
const logs = (job.logs || []).slice(-12).reverse();
|
const logs = (job.logs || []).slice(-12).reverse();
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>双语 EPUB 审校编辑器</title>
|
<title>双语 EPUB 审校编辑器</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=0.13.0">
|
<link rel="stylesheet" href="/static/style.css?v=0.13.1">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<button id="bookshelfBtn" class="bookshelfNavButton" type="button">书架</button>
|
<button id="bookshelfBtn" class="bookshelfNavButton" type="button">书架</button>
|
||||||
<div class="brandBlock">
|
<div class="brandBlock">
|
||||||
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.13.0</span></h1>
|
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.13.1</span></h1>
|
||||||
<p id="sessionMeta">正在载入……</p>
|
<p id="sessionMeta">正在载入……</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modeTabs" role="tablist" aria-label="审校模式">
|
<div class="modeTabs" role="tablist" aria-label="审校模式">
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
<div class="translationHeader">
|
<div class="translationHeader">
|
||||||
<div>
|
<div>
|
||||||
<h2>AI 翻译 EPUB</h2>
|
<h2>AI 翻译 EPUB</h2>
|
||||||
<p id="translationBookMeta">选择书架中的日语 EPUB 后,可在这里设置提示词、术语表、范围和输出格式。</p>
|
<p id="translationBookMeta">选择书架中的 EPUB 后设置范围、输出格式和必要提示词。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="translationHeaderActions">
|
<div class="translationHeaderActions">
|
||||||
<button id="translationBackBookshelfBtn" type="button">返回书架</button>
|
<button id="translationBackBookshelfBtn" type="button">返回书架</button>
|
||||||
@@ -108,7 +108,6 @@
|
|||||||
<h2>输出与范围</h2>
|
<h2>输出与范围</h2>
|
||||||
<span id="translationSourceMeta">未读取源书信息</span>
|
<span id="translationSourceMeta">未读取源书信息</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="translationStats" id="translationStats"></div>
|
|
||||||
<div class="translationOptions">
|
<div class="translationOptions">
|
||||||
<label>
|
<label>
|
||||||
输出格式
|
输出格式
|
||||||
@@ -120,12 +119,12 @@
|
|||||||
<label>
|
<label>
|
||||||
翻译范围
|
翻译范围
|
||||||
<select id="translationRangeModeInput">
|
<select id="translationRangeModeInput">
|
||||||
<option value="limit">前 N 段试译</option>
|
<option value="limit">前 N 个正文块试译</option>
|
||||||
<option value="all">全书</option>
|
<option value="all">全书</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
段数
|
正文块数
|
||||||
<input id="translationLimitInput" type="number" min="1" max="5000" value="20">
|
<input id="translationLimitInput" type="number" min="1" max="5000" value="20">
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -133,7 +132,6 @@
|
|||||||
<input id="translationTemperatureInput" type="number" min="0" max="1" step="0.1" value="0.2">
|
<input id="translationTemperatureInput" type="number" min="0" max="1" step="0.1" value="0.2">
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="translationSample" id="translationSample"></div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="translationCard">
|
<section class="translationCard">
|
||||||
@@ -511,6 +509,6 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="toast" id="toast" hidden></div>
|
<div class="toast" id="toast" hidden></div>
|
||||||
<script src="/static/app.js?v=0.13.0"></script>
|
<script src="/static/app.js?v=0.13.1"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -568,6 +568,13 @@ button.primary:hover,
|
|||||||
color: #18202a;
|
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 {
|
.bookCaption {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -651,13 +658,6 @@ button.primary:hover,
|
|||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.translationSample {
|
|
||||||
display: grid;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.translationSampleItem,
|
|
||||||
.translationEmpty,
|
|
||||||
.translationOutput {
|
.translationOutput {
|
||||||
border: 1px solid var(--line-soft);
|
border: 1px solid var(--line-soft);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -665,47 +665,6 @@ button.primary:hover,
|
|||||||
background: var(--panel-soft);
|
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 {
|
.translationOutput p {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
version = "0.13.0"
|
version = "0.13.1"
|
||||||
|
|
||||||
VERSION_RULES = {
|
VERSION_RULES = {
|
||||||
"PATCH": "修复 bug 或兼容性小修",
|
"PATCH": "修复 bug 或兼容性小修",
|
||||||
|
|||||||
Reference in New Issue
Block a user