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。
|
||||
- 整书翻译源抽取不使用双语审校的 `rows.json`;`rows.json` 是审校行数,日语 EPUB 的可翻译数量看 `/api/session/<id>/translation-source`,UI 文案应称为“正文块”以避免和双语审校“段”混淆。
|
||||
- 翻译源统计应区分可翻译正文块、HTML 文件、正文段、标题、图片、目录和空白块;默认试译只翻译前 20 段/块,不代表源书被删。
|
||||
- 翻译页默认应保持克制,只显示源书名和可翻译正文块数量;冗余段落预览、诊断 note 和大批统计 chip 不应默认出现在 UI。
|
||||
|
||||
## 已知排查结论
|
||||
|
||||
- `TRPGプレイヤーが異世界で最強ビルドを目指す 3` 日版 EPUB 解包内容未被删除。此前用户看到数量偏少,原因是 UI 混用了双语审校 `row_count` 与日语源书 `source_count`,且试译 job 的 `total=25` 只是范围限制。
|
||||
- 当前修复后,该 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 元数据建立分类:全部书籍、同系列、作者、出版社、语言;同系列分类使用稳定 `series_id`,显示书籍数量。
|
||||
- 书架主区域以封面网格为主,不用横向信息卡替代封面;无封面时使用稳定 fallback,不出现破图或布局跳动。
|
||||
- 鼠标悬停或键盘 focus 到封面时,封面变暗并显示书名、系列/作者/卷数、审校段数、最近更新时间,以及“校对”“翻译”两个按钮;点击“翻译”不得触发“校对”。
|
||||
- 鼠标悬停或键盘 focus 到封面时,封面变暗并显示书名、系列/作者/卷数、审校段数、最近更新时间,以及“校对”“翻译”“删除”按钮;点击“翻译”或“删除”不得触发“校对”。
|
||||
- 书架删除默认是软删除:只在 session manifest/state 中写入 `hidden` / `deleted_at`,从 `/api/sessions` 与 `/api/library` 隐藏,不删除原始 EPUB、导出 EPUB 或反馈文件;删除 active session 时必须清空 `active_session_id`。
|
||||
- 选中同系列分类后,必须能打开“系列设置”,设置该系列共享的术语表路径、翻译内容提示词、格式提示词和角色口吻提示词。
|
||||
- 初始进入审校时,左侧侧栏隐藏,正文占主要空间。
|
||||
- 顶部“目录”只打开目录,“检索”只打开搜索/段落列表,不合并成一个常驻面板。
|
||||
@@ -192,7 +193,9 @@ Prompt 硬规则:
|
||||
|
||||
输出与书架:
|
||||
|
||||
- `bilingual` 模式在原日文 `<p>` 后插入中文 `<p>`,并把原日文段落标为灰色,方便进入现有双语审校器逐段审校。
|
||||
- `bilingual` 模式在原日文 `<p>` 后插入中文 `<p>`,并把原日文段落标为灰色且标记为 source,方便进入现有双语审校器逐段审校。
|
||||
- 如果输入已经是中日双语 EPUB,翻译源抽取必须识别显式灰色/source 源段与下一段中文译文的 pair;`bilingual` 模式应替换已有中文层,不得追加第二个中文层;`translated` 模式应输出新的中文层并移除对应日文源层。
|
||||
- 如果历史 session 已经出现“日文源层 + 中文层 + 旧中文灰色源层 + 新中文层”的四层坏结构,进入翻译源统计、整书翻译或 rows 重建前应自动清理为“日文源层 + 第一个中文层”,避免旧中文继续被当作日文源文。
|
||||
- `translated` 模式用中文替换原日文段落;该输出会加入书架,但由于没有日中成对段落,可能没有逐段双语审校行。
|
||||
- 图片、样式、OPF、spine 和非正文文件应原样保留。
|
||||
- 翻译失败的单段必须写入可见占位和任务日志,不能让整本输出静默缺段。
|
||||
@@ -265,10 +268,13 @@ Prompt 硬规则:
|
||||
- 书架卡片“翻译”按钮能进入独立翻译页,且不会触发卡片的“进入审校”
|
||||
- `/api/translation/defaults` 不返回 API Key,提示词和术语表路径可读取
|
||||
- `/api/session/<session_id>/translation-source` 能统计日语 EPUB 可翻译正文块和诊断字段;非日语或无正文时返回清晰空状态;审校行数、源正文块数、试译 limit 不得在 UI 上混为一个“段落数”
|
||||
- 翻译页默认只显示源书名和“可翻译正文块 N 个”,不保留冗余段落预览、诊断 note 或大批统计 chip 的可见入口;诊断字段可保留在 API 中供排查
|
||||
- 未配置 API Key 时,`/api/translation/start` 返回清晰错误,不影响阅读、编辑、导出和单段重翻
|
||||
- 启动整书翻译任务后,`/api/translation/jobs/<job_id>` 可看到 pending/running/completed/failed、总段数、已完成数、当前文件和日志
|
||||
- 默认翻译范围是前 20 段试译;全书翻译必须由用户显式切换并确认
|
||||
- 整书翻译每段只注入该段源文/ruby 命中的术语,不注入无关术语或全表
|
||||
- 整书翻译完成后,输出 EPUB 写入 `translated_outputs/` 并自动出现在书架;中日双语输出打开后 `row_count > 0`
|
||||
- 已双语 EPUB 再次执行整书翻译时,输出不得出现“旧中文译文 + 新中文译文”重复层;审校行 `jp_text` 不得变成中文旧译文
|
||||
- 纯中文输出可以加入书架,且文档说明其不适合逐段双语审校
|
||||
- 离开翻译页、返回书架或打开输出审校时,前端进度轮询会停止
|
||||
- 书架卡片“删除”按钮能软删除会话、刷新书架、清空 active session(如删除当前书),且不会删除原始 EPUB 文件
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 双语 EPUB 审校编辑器
|
||||
|
||||
当前版本:`0.13.0`
|
||||
当前版本:`0.13.1`
|
||||
|
||||
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
|
||||
|
||||
@@ -48,6 +48,8 @@
|
||||
|
||||
`0.13.0` 新增元数据书架与同系列配置:书架按 EPUB 元数据建立 `library.json`,左侧提供系列、作者、出版社、语言分类,封面悬停显示书籍信息以及“校对/翻译”入口;同系列可共享术语表路径、翻译内容提示词、格式提示词和角色口吻提示词。AI 翻译源统计改为正文块诊断,区分审校行数、可翻译日文正文块、图片、目录和空白块,并补翻日文标题与日语上下文里的短句,避免把默认试译范围误认为正文被删。
|
||||
|
||||
`0.13.1` 修复整书 AI 翻译重复正文块:已是中日双语的 EPUB 再翻译时会识别旧的日文源文/中文译文 pair,并替换中文层,不再把旧译文当成源文追加新译文;审校行识别也收敛为只接受显式灰色/source 标记。翻译页删除冗余段落预览和诊断提示,统一使用“正文块”口径,书架封面悬停菜单新增“删除”,可从书架软删除会话且不删除原始 EPUB。
|
||||
|
||||
## 独立安装
|
||||
|
||||
这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub_review_editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖:
|
||||
@@ -108,7 +110,7 @@ python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessio
|
||||
- 点击“选择 EPUB 文件”,上传中日双语 EPUB 后会自动创建审校会话。
|
||||
- 开始页会列出已有会话,换电脑或重启服务后可以继续打开之前的审校记录。
|
||||
- 顶栏左上角“书架”会列出历史打开过的书籍,并按 EPUB 元数据生成分类;点击封面或“校对”会进入对应审校页面。
|
||||
- 书架中每本书封面悬停会变暗并显示书籍信息、“校对”和“翻译”按钮;“翻译”可对日语 EPUB 发起 AI 翻译任务,输出 EPUB 会自动加入书架。
|
||||
- 书架中每本书封面悬停会变暗并显示书籍信息、“校对”“翻译”和“删除”按钮;“翻译”可对日语 EPUB 发起 AI 翻译任务,输出 EPUB 会自动加入书架;“删除”只会从书架隐藏审校会话,不删除原始 EPUB。
|
||||
- 上传的 EPUB、解包副本、反馈与导出文件都会保存在 `--review-root` 指定目录下。
|
||||
|
||||
## 能做什么
|
||||
|
||||
@@ -105,6 +105,7 @@ DEFAULT_TRANSLATION_LIMIT = 20
|
||||
TRANSLATION_RANGE_LIMIT_MAX = 5000
|
||||
TRANSLATION_CONTEXT_RADIUS = 1
|
||||
LIBRARY_DB_NAME = "library.json"
|
||||
ROWS_PARSER_VERSION = "0.13.1-translation-pair-cleanup"
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
@@ -235,17 +236,63 @@ def is_japaneseish(fragment: str) -> bool:
|
||||
def is_gray_source(attrs: str, inner: str) -> bool:
|
||||
if GRAY_RE.search(attrs):
|
||||
return True
|
||||
return is_japaneseish(inner)
|
||||
return False
|
||||
|
||||
|
||||
def paragraph_visible_text(inner: str) -> str:
|
||||
return compact_text(strip_tags(inner or ""))
|
||||
|
||||
|
||||
def comparable_visible_text(inner: str) -> str:
|
||||
return re.sub(r"\s+", "", paragraph_visible_text(inner))
|
||||
|
||||
|
||||
def paragraph_prompt_text(inner: str) -> str:
|
||||
return strip_tags(inner or "").strip()
|
||||
|
||||
|
||||
def has_explicit_source_marker(open_tag: str) -> bool:
|
||||
return bool(GRAY_RE.search(open_tag or ""))
|
||||
|
||||
|
||||
def has_explicit_translation_marker(open_tag: str) -> bool:
|
||||
match = re.search(r"\bclass\s*=\s*(['\"])(.*?)\1", open_tag or "", flags=re.I | re.S)
|
||||
if not match:
|
||||
return False
|
||||
classes = {item.lower() for item in re.split(r"\s+", match.group(2).strip()) if item}
|
||||
return bool(classes & {"cntext", "aitranslation", "translation", "zh", "cn"})
|
||||
|
||||
|
||||
def is_probable_translation_layer(match: re.Match[str]) -> bool:
|
||||
if has_explicit_source_marker(match.group("open")):
|
||||
return False
|
||||
if has_explicit_translation_marker(match.group("open")):
|
||||
return True
|
||||
visible = paragraph_visible_text(match.group("inner"))
|
||||
if not visible:
|
||||
return False
|
||||
return not KANA_RE.search(strip_html_keep_ruby_text(match.group("inner")))
|
||||
|
||||
|
||||
def is_existing_bilingual_translation_pair(source_match: re.Match[str], next_match: re.Match[str]) -> bool:
|
||||
return has_explicit_source_marker(source_match.group("open")) and is_probable_translation_layer(next_match)
|
||||
|
||||
|
||||
def merge_attr_value(open_tag: str, attr_name: str, value: str) -> str:
|
||||
pattern = re.compile(rf"(\s{re.escape(attr_name)}\s*=\s*['\"])(.*?)(['\"])", flags=re.I | re.S)
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
current = match.group(2)
|
||||
values = current.split()
|
||||
if value not in values:
|
||||
values.append(value)
|
||||
return f"{match.group(1)}{' '.join(values)}{match.group(3)}"
|
||||
|
||||
if pattern.search(open_tag):
|
||||
return pattern.sub(replace, open_tag, count=1)
|
||||
return re.sub(r">\s*$", f' {attr_name}="{html.escape(value, quote=True)}">', open_tag)
|
||||
|
||||
|
||||
def html_declares_japanese(text: str) -> bool:
|
||||
return bool(re.search(r"\b(?:xml:)?lang\s*=\s*['\"]ja(?:[-_'\"]|$)", text or "", flags=re.I))
|
||||
|
||||
@@ -995,6 +1042,10 @@ def session_manifest_path(session_root: Path) -> Path:
|
||||
return session_root / "review_state" / "session.json"
|
||||
|
||||
|
||||
def rows_meta_path(session_root: Path) -> Path:
|
||||
return session_root / "review_state" / "rows_meta.json"
|
||||
|
||||
|
||||
def library_db_path(review_root: Path) -> Path:
|
||||
return review_root / LIBRARY_DB_NAME
|
||||
|
||||
@@ -1023,6 +1074,8 @@ def write_session_manifest(session_root: Path, epub_path: Path, source_name: str
|
||||
existing = read_json(session_manifest_path(session_root), {})
|
||||
metadata = read_epub_metadata(session_root)
|
||||
metadata["source_name"] = source_name or epub_path.name
|
||||
existing.pop("hidden", None)
|
||||
existing.pop("deleted_at", None)
|
||||
record = {
|
||||
"id": session_public_id(session_root),
|
||||
"source_name": source_name or epub_path.name,
|
||||
@@ -1096,6 +1149,29 @@ def read_reading_position(session_root: Path) -> dict[str, Any]:
|
||||
return normalize_reading_position(state.get("reading_position", {}))
|
||||
|
||||
|
||||
def session_is_hidden(session_root: Path) -> bool:
|
||||
manifest = read_json(session_manifest_path(session_root), {})
|
||||
state = read_json(session_root / "review_state" / "state.json", {})
|
||||
return bool(manifest.get("hidden") or manifest.get("deleted_at") or state.get("hidden") or state.get("deleted_at"))
|
||||
|
||||
|
||||
def mark_session_deleted(session_root: Path) -> dict[str, Any]:
|
||||
deleted_at = now_iso()
|
||||
manifest_path = session_manifest_path(session_root)
|
||||
manifest = read_json(manifest_path, {})
|
||||
manifest["hidden"] = True
|
||||
manifest["deleted_at"] = deleted_at
|
||||
manifest["updated_at"] = deleted_at
|
||||
write_json(manifest_path, manifest)
|
||||
state_path = session_root / "review_state" / "state.json"
|
||||
state = read_json(state_path, {})
|
||||
state["hidden"] = True
|
||||
state["deleted_at"] = deleted_at
|
||||
state["updated_at"] = deleted_at
|
||||
write_json(state_path, state)
|
||||
return {"hidden": True, "deleted_at": deleted_at}
|
||||
|
||||
|
||||
def summarize_session(session_root: Path) -> dict[str, Any]:
|
||||
manifest = read_json(session_manifest_path(session_root), {})
|
||||
state = read_json(session_root / "review_state" / "state.json", {})
|
||||
@@ -1166,6 +1242,8 @@ def list_review_sessions(review_root: Path) -> list[dict[str, Any]]:
|
||||
continue
|
||||
if not (child / "review_state" / "state.json").exists():
|
||||
continue
|
||||
if session_is_hidden(child):
|
||||
continue
|
||||
try:
|
||||
sessions.append(summarize_session(child))
|
||||
except Exception:
|
||||
@@ -1502,6 +1580,7 @@ def translation_source_stats(session_root: Path) -> dict[str, Any]:
|
||||
skipped_blank = 0
|
||||
skipped_image = 0
|
||||
skipped_no_japanese = 0
|
||||
skipped_existing_translation = 0
|
||||
skipped_no_japanese_examples: list[dict[str, Any]] = []
|
||||
translatable_by_file: dict[str, int] = {}
|
||||
for entry_name in html_files:
|
||||
@@ -1518,7 +1597,10 @@ def translation_source_stats(session_root: Path) -> dict[str, Any]:
|
||||
if nav:
|
||||
skipped_nav_files += 1
|
||||
skipped_nav_blocks += len(matches)
|
||||
for match in matches:
|
||||
for block_index, match in enumerate(matches):
|
||||
if block_index > 0 and is_existing_bilingual_translation_pair(matches[block_index - 1], match):
|
||||
skipped_existing_translation += 1
|
||||
continue
|
||||
tag = match.group("tag").lower()
|
||||
inner = match.group("inner")
|
||||
visible = paragraph_visible_text(inner)
|
||||
@@ -1558,6 +1640,7 @@ def translation_source_stats(session_root: Path) -> dict[str, Any]:
|
||||
"skipped_blank": skipped_blank,
|
||||
"skipped_image": skipped_image,
|
||||
"skipped_no_japanese": skipped_no_japanese,
|
||||
"skipped_existing_translation": skipped_existing_translation,
|
||||
"skipped_no_japanese_examples": skipped_no_japanese_examples,
|
||||
"translatable_by_file": translatable_by_file,
|
||||
}
|
||||
@@ -2224,6 +2307,80 @@ def build_structure(session_root: Path) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def is_source_match(match: re.Match[str]) -> bool:
|
||||
open_tag = match.group(1)
|
||||
attrs = open_tag[2:-1]
|
||||
return is_gray_source(attrs, match.group(2))
|
||||
|
||||
|
||||
def normalize_duplicate_bilingual_layers(session_root: Path) -> int:
|
||||
changed_files = 0
|
||||
try:
|
||||
html_entries = html_entries_in_reading_order(session_root)
|
||||
except Exception:
|
||||
return 0
|
||||
for entry_name in html_entries:
|
||||
path = extracted_path(session_root, entry_name)
|
||||
if not path.exists():
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
matches = list(P_RE.finditer(text))
|
||||
if len(matches) < 4:
|
||||
continue
|
||||
parts: list[str] = []
|
||||
last = 0
|
||||
index = 0
|
||||
changed = False
|
||||
while index < len(matches):
|
||||
if index + 3 < len(matches):
|
||||
source = matches[index]
|
||||
old_cn = matches[index + 1]
|
||||
duplicated_source = matches[index + 2]
|
||||
new_cn = matches[index + 3]
|
||||
duplicated_text = comparable_visible_text(duplicated_source.group(2))
|
||||
if (
|
||||
is_source_match(source)
|
||||
and not is_source_match(old_cn)
|
||||
and is_source_match(duplicated_source)
|
||||
and not is_source_match(new_cn)
|
||||
and duplicated_text
|
||||
and not KANA_RE.search(strip_html_keep_ruby_text(duplicated_source.group(2)))
|
||||
):
|
||||
parts.append(text[last:duplicated_source.start()])
|
||||
last = new_cn.end()
|
||||
index += 4
|
||||
changed = True
|
||||
continue
|
||||
index += 1
|
||||
if changed:
|
||||
parts.append(text[last:])
|
||||
path.write_text("".join(parts), encoding="utf-8")
|
||||
changed_files += 1
|
||||
return changed_files
|
||||
|
||||
|
||||
def rows_need_rebuild(session_root: Path) -> bool:
|
||||
rows_path = session_root / "review_state" / "rows.json"
|
||||
if not rows_path.exists():
|
||||
return True
|
||||
meta = read_json(rows_meta_path(session_root), {})
|
||||
return meta.get("parser_version") != ROWS_PARSER_VERSION
|
||||
|
||||
|
||||
def ensure_rows_current(session_root: Path) -> list[dict[str, Any]]:
|
||||
if rows_need_rebuild(session_root):
|
||||
normalize_duplicate_bilingual_layers(session_root)
|
||||
return build_rows(session_root)
|
||||
return read_json(session_root / "review_state" / "rows.json", [])
|
||||
|
||||
|
||||
def normalize_duplicate_layers_and_rows(session_root: Path) -> int:
|
||||
changed = normalize_duplicate_bilingual_layers(session_root)
|
||||
if changed:
|
||||
build_rows(session_root)
|
||||
return changed
|
||||
|
||||
|
||||
def build_rows(session_root: Path) -> list[dict[str, Any]]:
|
||||
html_entries = html_entries_in_reading_order(session_root)
|
||||
rows: list[dict[str, Any]] = []
|
||||
@@ -2267,6 +2424,14 @@ def build_rows(session_root: Path) -> list[dict[str, Any]]:
|
||||
else:
|
||||
i += 1
|
||||
write_json(session_root / "review_state" / "rows.json", rows)
|
||||
write_json(
|
||||
rows_meta_path(session_root),
|
||||
{
|
||||
"parser_version": ROWS_PARSER_VERSION,
|
||||
"updated_at": now_iso(),
|
||||
"row_count": len(rows),
|
||||
},
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
@@ -2274,8 +2439,8 @@ def ensure_state(session_root: Path, epub_path: Path, reset: bool = False) -> di
|
||||
extract_epub(epub_path, session_root, reset=reset)
|
||||
rows_path = session_root / "review_state" / "rows.json"
|
||||
rows = read_json(rows_path, None)
|
||||
if rows is None:
|
||||
rows = build_rows(session_root)
|
||||
if rows is None or rows_need_rebuild(session_root):
|
||||
rows = ensure_rows_current(session_root)
|
||||
state_path = session_root / "review_state" / "state.json"
|
||||
state = read_json(
|
||||
state_path,
|
||||
@@ -2289,13 +2454,15 @@ def ensure_state(session_root: Path, epub_path: Path, reset: bool = False) -> di
|
||||
)
|
||||
state.setdefault("edits", {})
|
||||
state["source_epub"] = str(epub_path)
|
||||
state.pop("hidden", None)
|
||||
state.pop("deleted_at", None)
|
||||
write_json(state_path, state)
|
||||
write_session_manifest(session_root, epub_path)
|
||||
return state
|
||||
|
||||
|
||||
def merge_rows(session_root: Path) -> list[dict[str, Any]]:
|
||||
rows = read_json(session_root / "review_state" / "rows.json", [])
|
||||
rows = ensure_rows_current(session_root)
|
||||
state = read_json(session_root / "review_state" / "state.json", {"edits": {}})
|
||||
edits = state.get("edits", {})
|
||||
merged = []
|
||||
@@ -2514,7 +2681,7 @@ def normalize_translation_job_settings(data: dict[str, Any], config: dict[str, A
|
||||
try:
|
||||
limit = int(data.get("limit") or DEFAULT_TRANSLATION_LIMIT)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("试译段数必须是数字") from exc
|
||||
raise ValueError("试译正文块数必须是数字") from exc
|
||||
limit = max(1, min(limit, TRANSLATION_RANGE_LIMIT_MAX))
|
||||
try:
|
||||
temperature = float(data.get("temperature", 0.2))
|
||||
@@ -2566,11 +2733,18 @@ def extract_translation_source_items(session_root: Path) -> list[dict[str, Any]]
|
||||
document_title = read_document_title(session_root, entry_name) or Path(entry_name).stem
|
||||
file_has_japanese_context = source_file_has_japanese_context(text)
|
||||
file_serial = 0
|
||||
for block_index, match in enumerate(SOURCE_BLOCK_RE.finditer(text)):
|
||||
matches = list(SOURCE_BLOCK_RE.finditer(text))
|
||||
for block_index, match in enumerate(matches):
|
||||
if block_index > 0 and is_existing_bilingual_translation_pair(matches[block_index - 1], match):
|
||||
continue
|
||||
tag = match.group("tag").lower()
|
||||
inner = match.group("inner")
|
||||
if not is_translatable_source_block(inner, tag, file_has_japanese_context):
|
||||
continue
|
||||
paired_cn_index = block_index + 1 if (
|
||||
block_index + 1 < len(matches)
|
||||
and is_existing_bilingual_translation_pair(match, matches[block_index + 1])
|
||||
) else None
|
||||
source_text = paragraph_prompt_text(inner)
|
||||
serial += 1
|
||||
file_serial += 1
|
||||
@@ -2583,6 +2757,7 @@ def extract_translation_source_items(session_root: Path) -> list[dict[str, Any]]
|
||||
"file_label": Path(entry_name).name,
|
||||
"document_title": document_title,
|
||||
"block_index": block_index,
|
||||
"paired_cn_index": paired_cn_index,
|
||||
"p_index": block_index,
|
||||
"tag": tag,
|
||||
"open_tag": match.group("open"),
|
||||
@@ -2669,31 +2844,54 @@ def add_gray_style_to_p(open_tag: str) -> str:
|
||||
return re.sub(r">\s*$", ' style="color: #808080;">', open_tag)
|
||||
|
||||
|
||||
def bilingual_source_open_tag(open_tag: str) -> str:
|
||||
return merge_attr_value(add_gray_style_to_p(open_tag), "class", "sourceText")
|
||||
|
||||
|
||||
def translated_open_tag(tag: str) -> str:
|
||||
tag = tag if tag in {"p", "h1", "h2", "h3", "h4", "h5", "h6"} else "p"
|
||||
return f'<{tag} class="cnText aiTranslation">'
|
||||
|
||||
|
||||
def replace_translated_paragraphs(
|
||||
text: str,
|
||||
replacements: dict[int, dict[str, str]],
|
||||
replacements: dict[int, dict[str, Any]],
|
||||
output_mode: str,
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
last = 0
|
||||
skip_indexes = {
|
||||
int(replacement["paired_cn_index"])
|
||||
for replacement in replacements.values()
|
||||
if replacement.get("paired_cn_index") is not None
|
||||
}
|
||||
for index, match in enumerate(SOURCE_BLOCK_RE.finditer(text)):
|
||||
parts.append(text[last:match.start()])
|
||||
if index not in replacements:
|
||||
if index in skip_indexes:
|
||||
pass
|
||||
elif index not in replacements:
|
||||
parts.append(match.group(0))
|
||||
elif output_mode == "bilingual":
|
||||
replacement = replacements[index]
|
||||
source_open = add_gray_style_to_p(match.group("open"))
|
||||
source_open = bilingual_source_open_tag(match.group("open"))
|
||||
translated_tag = replacement.get("tag") or "p"
|
||||
parts.append(source_open)
|
||||
parts.append(match.group("inner"))
|
||||
parts.append(match.group("close"))
|
||||
parts.append(f"\n<{replacement.get('tag') or 'p'}>")
|
||||
parts.append(f"\n{translated_open_tag(translated_tag)}")
|
||||
parts.append(replacement.get("html", ""))
|
||||
parts.append(f"</{replacement.get('tag') or 'p'}>")
|
||||
parts.append(f"</{translated_tag}>")
|
||||
else:
|
||||
replacement = replacements[index]
|
||||
parts.append(match.group("open"))
|
||||
parts.append(replacement.get("html", ""))
|
||||
parts.append(match.group("close"))
|
||||
if replacement.get("paired_cn_index") is not None:
|
||||
translated_tag = replacement.get("tag") or "p"
|
||||
parts.append(translated_open_tag(translated_tag))
|
||||
parts.append(replacement.get("html", ""))
|
||||
parts.append(f"</{translated_tag}>")
|
||||
else:
|
||||
parts.append(match.group("open"))
|
||||
parts.append(replacement.get("html", ""))
|
||||
parts.append(match.group("close"))
|
||||
last = match.end()
|
||||
parts.append(text[last:])
|
||||
return "".join(parts)
|
||||
@@ -2709,7 +2907,7 @@ def build_translation_output_epub(
|
||||
out_path: Path,
|
||||
) -> Path:
|
||||
entries = read_json(session_root / "review_state" / "epub_entries.json", [])
|
||||
by_file: dict[str, dict[int, dict[str, str]]] = {}
|
||||
by_file: dict[str, dict[int, dict[str, Any]]] = {}
|
||||
for item in items:
|
||||
translated = translations.get(item["id"])
|
||||
if translated is None:
|
||||
@@ -2719,6 +2917,8 @@ def build_translation_output_epub(
|
||||
by_file.setdefault(item["file"], {})[int(item.get("block_index", item.get("p_index", 0)))] = {
|
||||
"html": translated,
|
||||
"tag": translated_tag,
|
||||
"paired_cn_index": item.get("paired_cn_index"),
|
||||
"paired_cn_tag": translated_tag,
|
||||
}
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -2812,6 +3012,7 @@ def run_translation_job(review_root: Path, job_id: str) -> None:
|
||||
glossary_file = Path(str(settings.get("glossary_path") or config.get("glossary_path") or ""))
|
||||
glossary = read_glossary_mapping_from_path(glossary_file)
|
||||
|
||||
normalize_duplicate_layers_and_rows(source_session)
|
||||
all_items = extract_translation_source_items(source_session)
|
||||
source_total = len(all_items)
|
||||
if settings.get("range_mode") == "all":
|
||||
@@ -2819,13 +3020,13 @@ def run_translation_job(review_root: Path, job_id: str) -> None:
|
||||
else:
|
||||
selected_items = all_items[: int(settings.get("limit") or DEFAULT_TRANSLATION_LIMIT)]
|
||||
if not selected_items:
|
||||
raise ValueError("没有找到可翻译的日文正文段落")
|
||||
raise ValueError("没有找到可翻译的日文正文块")
|
||||
|
||||
job["progress"].update(
|
||||
{
|
||||
"total": len(selected_items),
|
||||
"source_total": source_total,
|
||||
"message": f"已找到 {source_total} 个日文段落,开始翻译 {len(selected_items)} 段。",
|
||||
"message": f"已找到 {source_total} 个可翻译正文块,开始翻译 {len(selected_items)} 个。",
|
||||
}
|
||||
)
|
||||
add_translation_job_log(job, job["progress"]["message"])
|
||||
@@ -2953,6 +3154,11 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
session_root = app.config.get("SESSION_ROOT")
|
||||
epub_path = app.config.get("EPUB_PATH")
|
||||
if isinstance(session_root, Path):
|
||||
if session_is_hidden(session_root):
|
||||
app.config["SESSION_ROOT"] = None
|
||||
app.config["EPUB_PATH"] = None
|
||||
update_app_manifest(review_root, None)
|
||||
return None, None
|
||||
return session_root, epub_path if isinstance(epub_path, Path) else session_epub_path(session_root)
|
||||
active_id = read_json(app_manifest_path(review_root), {}).get("active_session_id", "")
|
||||
if active_id:
|
||||
@@ -2960,7 +3166,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
manifest_root = session_root_from_id(review_root, active_id)
|
||||
except ValueError:
|
||||
return None, None
|
||||
if (manifest_root / "review_state" / "state.json").exists():
|
||||
if (manifest_root / "review_state" / "state.json").exists() and not session_is_hidden(manifest_root):
|
||||
set_active_session(manifest_root)
|
||||
return app.config["SESSION_ROOT"], app.config["EPUB_PATH"]
|
||||
return None, None
|
||||
@@ -3096,12 +3302,33 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if not (session_root / "review_state" / "state.json").exists():
|
||||
return jsonify({"error": "会话不存在或不完整"}), 404
|
||||
if session_is_hidden(session_root):
|
||||
return jsonify({"error": "会话已从书架移除"}), 404
|
||||
with app.config["LOCK"]:
|
||||
set_active_session(session_root)
|
||||
summary = summarize_session(session_root)
|
||||
summary["has_session"] = True
|
||||
return jsonify(summary)
|
||||
|
||||
@app.route("/api/session/<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")
|
||||
def api_rows():
|
||||
session_root, _epub_path = active_session()
|
||||
@@ -3289,7 +3516,10 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if not (source_root / "review_state" / "state.json").exists():
|
||||
return jsonify({"error": "会话不存在或不完整"}), 404
|
||||
if session_is_hidden(source_root):
|
||||
return jsonify({"error": "会话已从书架移除"}), 404
|
||||
try:
|
||||
normalize_duplicate_layers_and_rows(source_root)
|
||||
items = extract_translation_source_items(source_root)
|
||||
stats = translation_source_stats(source_root)
|
||||
files: dict[str, int] = {}
|
||||
@@ -3335,6 +3565,8 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
if not (source_root / "review_state" / "state.json").exists():
|
||||
return jsonify({"error": "会话不存在或不完整"}), 404
|
||||
if session_is_hidden(source_root):
|
||||
return jsonify({"error": "会话已从书架移除"}), 404
|
||||
try:
|
||||
requested_base_url = str(data.get("base_url") or "").strip()
|
||||
requested_model = str(data.get("model") or "").strip()
|
||||
|
||||
@@ -183,8 +183,6 @@ const dom = {
|
||||
translationRangeModeInput: $("translationRangeModeInput"),
|
||||
translationLimitInput: $("translationLimitInput"),
|
||||
translationTemperatureInput: $("translationTemperatureInput"),
|
||||
translationSample: $("translationSample"),
|
||||
translationStats: $("translationStats"),
|
||||
translationGptStatus: $("translationGptStatus"),
|
||||
translationBaseUrlInput: $("translationBaseUrlInput"),
|
||||
translationModelInput: $("translationModelInput"),
|
||||
@@ -748,6 +746,23 @@ async function refreshSessions() {
|
||||
return data;
|
||||
}
|
||||
|
||||
async function deleteSession(sessionId, title = "") {
|
||||
if (!sessionId) return false;
|
||||
const label = title || sessionId;
|
||||
if (!window.confirm(`从书架移除《${label}》?\n\n这只会隐藏本工具的审校会话,不会删除原始 EPUB 文件。`)) {
|
||||
return false;
|
||||
}
|
||||
await api(`/api/session/${encodeURIComponent(sessionId)}`, { method: "DELETE" });
|
||||
if (activeBookshelfSessionId === sessionId) activeBookshelfSessionId = "";
|
||||
if (translationSessionId === sessionId) {
|
||||
translationSessionId = "";
|
||||
clearTranslationPoll();
|
||||
}
|
||||
toast("已从书架移除。");
|
||||
await refreshBookshelf();
|
||||
return true;
|
||||
}
|
||||
|
||||
function readingPositionLabel(position = {}) {
|
||||
if (!position || !position.scope || position.scope === "none") return "尚无阅读记录";
|
||||
if (position.scope === "image") return "上次阅读:插图";
|
||||
@@ -868,17 +883,18 @@ function renderBookshelfFromState(activeId = "") {
|
||||
<div class="bookOverlay">
|
||||
<h3>${escapeHtml(title)}</h3>
|
||||
<p>${escapeHtml(subtitle)}</p>
|
||||
<div class="bookFacts overlayFacts">
|
||||
<span>${session.row_count || 0} 审校段</span>
|
||||
<span>${session.volume ? `第 ${escapeHtml(session.volume)} 卷` : escapeHtml(session.language || "未知语言")}</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 class="bookFacts overlayFacts">
|
||||
<span>${session.row_count || 0} 审校段</span>
|
||||
<span>${session.volume ? `第 ${escapeHtml(session.volume)} 卷` : escapeHtml(session.language || "未知语言")}</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>
|
||||
<button type="button" class="dangerAction" data-delete-book>删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bookCaption">
|
||||
<h3>${escapeHtml(title)}</h3>
|
||||
<p>${escapeHtml(bookSeries(session))}</p>
|
||||
@@ -887,6 +903,7 @@ function renderBookshelfFromState(activeId = "") {
|
||||
const open = () => openSession(session.id);
|
||||
const reviewButton = card.querySelector("[data-open-review]");
|
||||
const translateButton = card.querySelector("[data-open-translation]");
|
||||
const deleteButton = card.querySelector("[data-delete-book]");
|
||||
reviewButton.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
open();
|
||||
@@ -895,6 +912,10 @@ function renderBookshelfFromState(activeId = "") {
|
||||
event.stopPropagation();
|
||||
showTranslationScreen(session.id).catch((error) => toast(`打开翻译页失败:${error.message}`, 7000));
|
||||
});
|
||||
deleteButton.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
deleteSession(session.id, title).catch((error) => toast(`删除失败:${error.message}`, 7000));
|
||||
});
|
||||
card.addEventListener("click", (event) => {
|
||||
if (event.target.closest("button")) return;
|
||||
open();
|
||||
@@ -959,42 +980,9 @@ async function showStartScreen(sessionPayload = null) {
|
||||
function renderTranslationSource(payload) {
|
||||
const session = payload.session || {};
|
||||
const sourceCount = payload.source_count || 0;
|
||||
dom.translationBookMeta.textContent = `${session.source_name || translationSessionId} · 可翻译日文正文块 ${sourceCount} 个`;
|
||||
dom.translationSourceMeta.textContent = sourceCount ? `检测到 ${sourceCount} 个可翻译日文正文块` : "没有检测到可翻译日文正文块";
|
||||
const stats = payload.stats || {};
|
||||
if (dom.translationStats) {
|
||||
const chips = [
|
||||
["可翻译", sourceCount],
|
||||
["HTML", stats.html_file_count],
|
||||
["文本块", stats.total_blocks],
|
||||
["正文段", stats.total_paragraphs],
|
||||
["标题", stats.total_headings],
|
||||
["图片块", stats.image_blocks],
|
||||
["目录跳过", stats.skipped_nav_blocks],
|
||||
["空白跳过", stats.skipped_blank],
|
||||
["非日文跳过", stats.skipped_no_japanese],
|
||||
].filter((item) => item[1] !== undefined && item[1] !== null && item[1] !== "");
|
||||
dom.translationStats.innerHTML = `
|
||||
<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.translationBookMeta.textContent = session.source_name || translationSessionId;
|
||||
dom.translationSourceMeta.textContent = sourceCount ? `可翻译正文块 ${sourceCount} 个` : "无可翻译正文块";
|
||||
dom.translationLimitInput.max = String(Math.max(1, Math.min(5000, sourceCount || 5000)));
|
||||
const sample = payload.sample || [];
|
||||
if (!sample.length) {
|
||||
dom.translationSample.innerHTML = '<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) {
|
||||
@@ -1036,7 +1024,7 @@ async function showTranslationScreen(sessionId) {
|
||||
dom.translationTemperatureInput.value = defaults.temperature ?? 0.2;
|
||||
applySeriesConfigToTranslation(sourcePayload.series_config || {});
|
||||
renderTranslationSource(sourcePayload);
|
||||
dom.translationProgressText.textContent = "配置完成后可以开始翻译。默认先做前 20 段试译。";
|
||||
dom.translationProgressText.textContent = "配置完成后可以开始翻译。默认先做前 20 个正文块试译。";
|
||||
}
|
||||
|
||||
function collectTranslationOptions() {
|
||||
@@ -1071,7 +1059,7 @@ function renderTranslationJob(job) {
|
||||
progress.message || "正在处理……",
|
||||
total ? `进度:${current}/${total}` : "",
|
||||
progress.current_file ? `当前文件:${progress.current_file}` : "",
|
||||
failed ? `失败段落:${failed}` : "",
|
||||
failed ? `失败正文块:${failed}` : "",
|
||||
job.error ? `错误:${job.error}` : "",
|
||||
].filter(Boolean).join("\n");
|
||||
const logs = (job.logs || []).slice(-12).reverse();
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<button id="bookshelfBtn" class="bookshelfNavButton" type="button">书架</button>
|
||||
<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>
|
||||
</div>
|
||||
<div class="modeTabs" role="tablist" aria-label="审校模式">
|
||||
@@ -93,7 +93,7 @@
|
||||
<div class="translationHeader">
|
||||
<div>
|
||||
<h2>AI 翻译 EPUB</h2>
|
||||
<p id="translationBookMeta">选择书架中的日语 EPUB 后,可在这里设置提示词、术语表、范围和输出格式。</p>
|
||||
<p id="translationBookMeta">选择书架中的 EPUB 后设置范围、输出格式和必要提示词。</p>
|
||||
</div>
|
||||
<div class="translationHeaderActions">
|
||||
<button id="translationBackBookshelfBtn" type="button">返回书架</button>
|
||||
@@ -108,7 +108,6 @@
|
||||
<h2>输出与范围</h2>
|
||||
<span id="translationSourceMeta">未读取源书信息</span>
|
||||
</div>
|
||||
<div class="translationStats" id="translationStats"></div>
|
||||
<div class="translationOptions">
|
||||
<label>
|
||||
输出格式
|
||||
@@ -120,12 +119,12 @@
|
||||
<label>
|
||||
翻译范围
|
||||
<select id="translationRangeModeInput">
|
||||
<option value="limit">前 N 段试译</option>
|
||||
<option value="limit">前 N 个正文块试译</option>
|
||||
<option value="all">全书</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
段数
|
||||
正文块数
|
||||
<input id="translationLimitInput" type="number" min="1" max="5000" value="20">
|
||||
</label>
|
||||
<label>
|
||||
@@ -133,7 +132,6 @@
|
||||
<input id="translationTemperatureInput" type="number" min="0" max="1" step="0.1" value="0.2">
|
||||
</label>
|
||||
</div>
|
||||
<div class="translationSample" id="translationSample"></div>
|
||||
</section>
|
||||
|
||||
<section class="translationCard">
|
||||
@@ -511,6 +509,6 @@
|
||||
</aside>
|
||||
|
||||
<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>
|
||||
</html>
|
||||
|
||||
@@ -568,6 +568,13 @@ button.primary:hover,
|
||||
color: #18202a;
|
||||
}
|
||||
|
||||
.bookActions .dangerAction {
|
||||
grid-column: 1 / -1;
|
||||
border-color: rgba(255, 151, 151, 0.85);
|
||||
background: rgba(168, 44, 44, 0.78);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.bookCaption {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
@@ -651,13 +658,6 @@ button.primary:hover,
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.translationSample {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.translationSampleItem,
|
||||
.translationEmpty,
|
||||
.translationOutput {
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 6px;
|
||||
@@ -665,47 +665,6 @@ button.primary:hover,
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
.translationStats {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.statChipRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.statChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border: 1px solid var(--line-soft);
|
||||
border-radius: 999px;
|
||||
padding: 4px 9px;
|
||||
background: var(--panel-soft);
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.statChip b {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.translationStats p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.translationSampleItem strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.translationSampleItem p,
|
||||
.translationOutput p {
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version = "0.13.0"
|
||||
version = "0.13.1"
|
||||
|
||||
VERSION_RULES = {
|
||||
"PATCH": "修复 bug 或兼容性小修",
|
||||
|
||||
Reference in New Issue
Block a user