diff --git a/tools/epub_review_editor/MAINTENANCE.md b/tools/epub_review_editor/MAINTENANCE.md index a65ce04..5231726 100644 --- a/tools/epub_review_editor/MAINTENANCE.md +++ b/tools/epub_review_editor/MAINTENANCE.md @@ -32,6 +32,7 @@ epub_review_sessions/ - `rows.json` 是初始解析结果,不应因用户编辑而重写。 - `state.json.edits` 保存用户修改与标记,字段缺省时必须能回退到原始译文。 +- `state.json.reading_position` 保存当前阅读位置,字段包括 `mode`、`scope`、`chapter_id`、`part_id`、`image_item_id`、`row_id`、`scroll_top`、`offset`、`updated_at`;缺省时必须回退到首页/首章。 - `session.json` 与 `app_state.json` 只保存会话定位信息。 - `gpt_config.json` 只属于本地运行环境,不写入 EPUB、不写入导出文件、不通过 API 回传密钥。 - `gpt_config.json` 可保存本地提示词设置与术语表路径,但仍不得写入 EPUB 或导出文件。 @@ -67,6 +68,7 @@ epub_review_sessions/ - 阅读模式不显示每段编号、快速编辑、快速标记按钮。 - 点击正文段落打开右侧精修与重翻面板。 - 右侧面板打开或关闭导致正文宽度变化时,应恢复用户原来正在看的段落位置。 +- 刷新页面、重启服务或从已有会话重新打开 EPUB 时,应恢复到上次阅读的章节/part/插图、段落和滚动位置。 - 移动窄屏下,侧栏和右侧面板应以抽屉/底部面板方式显示,不遮死主要操作。 ## 编辑、反馈与导出 @@ -156,3 +158,4 @@ Prompt 硬规则: - 配置保存后 `/api/gpt/config` 不包含密钥字段 - `/api/glossary` 能读取术语表;新增、修改、删除条目后保存,再读取内容一致 - 重翻候选事件包含 `glossary_path` 与 `glossary_matches` +- `/api/reading-position` 能保存/读取位置;刷新或重启后回到上次阅读处 diff --git a/tools/epub_review_editor/README.md b/tools/epub_review_editor/README.md index afebe20..0a1ea68 100644 --- a/tools/epub_review_editor/README.md +++ b/tools/epub_review_editor/README.md @@ -1,6 +1,6 @@ # 双语 EPUB 审校编辑器 -当前版本:`0.7.0` +当前版本:`0.8.0` 这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。 @@ -30,6 +30,8 @@ `0.7.0` 新增实时术语表能力:GPT 重翻会从配置的 `mingcibiao.json` 实时读取命中术语并写入提示词;网页中可查看当前术语表路径、搜索术语,新增、修改、删除条目并保存,保存后的术语会立即用于下一次重翻。 +`0.8.0` 新增阅读位置恢复:阅读器会自动记录当前章节/part/插图、当前可见段落和滚动位置;重新打开服务、刷新页面或切回已有会话时,会回到上次阅读的位置。 + ## 独立安装 这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub_review_editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖: @@ -86,6 +88,7 @@ python .\server.py --host 0.0.0.0 --port 5177 --review-root .\epub_review_sessio ## 能做什么 - 阅读模式下按章节连续阅读日文原文与中文译文,不需要逐句点击。 +- 每个审校会话会自动保存上次阅读位置,下次打开会回到上次所在章节、part、插图或段落附近。 - 左侧目录和检索默认隐藏,通过顶部“目录”“检索”两个按钮分别打开,不会把两种入口混在同一个常驻侧栏里。 - 目录优先按 EPUB 内部目录页或 nav/toc 组织章节;章节下显示内部 `part0000` 形式的 part 与次级插图条目,可展开/收起,点击 part 后只看该 part,点击章节则按原 spine 顺序连续阅读正文和插图。 - 插图页会作为所属章节的次级条目出现在目录里,点击后可在阅读模式中直接查看原 EPUB 图片;点击目录项不会自动隐藏左侧侧栏。 diff --git a/tools/epub_review_editor/server.py b/tools/epub_review_editor/server.py index 7bec4df..aa2e4e6 100644 --- a/tools/epub_review_editor/server.py +++ b/tools/epub_review_editor/server.py @@ -36,7 +36,7 @@ except ImportError: "MINOR": "新增向后兼容能力、API 字段或可选配置", "MAJOR": "破坏兼容的 API、配置或行为变更", } - version = "0.7.0" + version = "0.8.0" P_RE = re.compile(r"(]*>)(.*?)(

)", re.S | re.I) @@ -760,6 +760,45 @@ def session_epub_path(session_root: Path) -> Path: return Path(source_epub or fallback) +def normalize_reading_position(data: Any) -> dict[str, Any]: + if not isinstance(data, dict): + return {} + + def field(name: str, limit: int = 160) -> str: + return str(data.get(name) or "").strip()[:limit] + + def nonnegative_int(name: str, limit: int = 10_000_000) -> int: + try: + value = int(float(data.get(name) or 0)) + except (TypeError, ValueError): + value = 0 + return max(0, min(value, limit)) + + mode = field("mode", 20) + if mode not in {"reading", "polish"}: + mode = "reading" + scope = field("scope", 20) + if scope not in {"chapter", "part", "image", "none"}: + scope = "none" + position = { + "mode": mode, + "scope": scope, + "chapter_id": field("chapter_id"), + "part_id": field("part_id"), + "image_item_id": field("image_item_id"), + "row_id": field("row_id"), + "scroll_top": nonnegative_int("scroll_top"), + "offset": nonnegative_int("offset", 5000), + "updated_at": field("updated_at", 80) or now_iso(), + } + return position + + +def read_reading_position(session_root: Path) -> dict[str, Any]: + state = read_json(session_root / "review_state" / "state.json", {}) + return normalize_reading_position(state.get("reading_position", {})) + + 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", {}) @@ -787,6 +826,7 @@ def summarize_session(session_root: Path) -> dict[str, Any]: "feedback_md": str(session_root / "feedback" / "feedback_for_codex.md"), "feedback_jsonl": str(session_root / "feedback" / "translation_feedback.jsonl"), "latest_export": latest_export, + "reading_position": normalize_reading_position(state.get("reading_position", {})), } @@ -1902,6 +1942,7 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: "marked_count": sum(1 for row in rows if row.get("marked")), "feedback_md": str(session_root / "feedback" / "feedback_for_codex.md"), "feedback_jsonl": str(session_root / "feedback" / "translation_feedback.jsonl"), + "reading_position": read_reading_position(session_root), } ) @@ -1979,6 +2020,28 @@ def create_app(review_root: Path, initial_epub_path: Path | None = None, reset: return no_active_response() return jsonify(build_structure(session_root)) + @app.route("/api/reading-position", methods=["GET", "POST"]) + def api_reading_position(): + session_root, _epub_path = active_session() + if session_root is None: + return no_active_response() + if request.method == "GET": + return jsonify({"reading_position": read_reading_position(session_root)}) + data = request.get_json(silent=True) or {} + if not data and request.data: + try: + data = json.loads(request.data.decode("utf-8", errors="replace")) + except json.JSONDecodeError: + data = {} + position = normalize_reading_position(data) + with app.config["LOCK"]: + state_path = session_root / "review_state" / "state.json" + state = read_json(state_path, {"edits": {}}) + state["reading_position"] = position + state["updated_at"] = now_iso() + write_json(state_path, state) + return jsonify({"status": "ok", "reading_position": position}) + @app.route("/api/asset/") def api_asset(entry_name: str): session_root, _epub_path = active_session() diff --git a/tools/epub_review_editor/static/app.js b/tools/epub_review_editor/static/app.js index a53754b..698090d 100644 --- a/tools/epub_review_editor/static/app.js +++ b/tools/epub_review_editor/static/app.js @@ -19,6 +19,13 @@ let gptCandidateHtml = ""; let glossaryLoaded = false; let glossaryEntries = []; let glossaryDirty = false; +let savedReadingPosition = null; +let restoringReadingPosition = false; +let readingPositionSaveTimer = null; +let readingPositionInFlight = false; +let readingPositionSavePromise = null; +let pendingReadingPosition = null; +let readingPositionReady = false; const $ = (id) => document.getElementById(id); @@ -223,6 +230,81 @@ function withReaderAnchor(action) { return result; } +function imageItemById(chapter, imageItemId) { + if (!chapter || !imageItemId) return null; + return chapterItems(chapter).find((item) => isImageItem(item) && item.id === imageItemId) || null; +} + +function currentReadingPosition() { + const anchor = readerAnchor(); + const chapter = currentChapter || chapterForPart(currentPart); + const scope = currentImageItem ? "image" : currentPart ? "part" : chapter ? "chapter" : "none"; + return { + mode: currentMode, + scope, + chapter_id: chapter ? chapter.id : "", + part_id: currentPart ? currentPart.id : "", + image_item_id: currentImageItem ? currentImageItem.id : "", + row_id: (anchor && anchor.rowId) || (currentRow && currentRow.id) || "", + scroll_top: dom.readerPane ? Math.max(0, Math.round(dom.readerPane.scrollTop || 0)) : 0, + offset: anchor ? Math.max(0, Math.round(anchor.offset || 0)) : 0, + updated_at: new Date().toISOString(), + }; +} + +async function postReadingPosition(position) { + await api("/api/reading-position", { + method: "POST", + body: JSON.stringify(position), + }); +} + +async function saveReadingPositionNow(position) { + readingPositionInFlight = true; + const promise = postReadingPosition(position); + readingPositionSavePromise = promise; + try { + await promise; + savedReadingPosition = position; + } catch (_error) { + pendingReadingPosition = position; + } finally { + if (readingPositionSavePromise === promise) readingPositionSavePromise = null; + readingPositionInFlight = false; + if (pendingReadingPosition) saveReadingPositionSoon(); + } +} + +function saveReadingPositionSoon() { + if (!hasSession || !readingPositionReady || restoringReadingPosition) return; + const position = currentReadingPosition(); + pendingReadingPosition = position; + clearTimeout(readingPositionSaveTimer); + readingPositionSaveTimer = setTimeout(async () => { + if (readingPositionInFlight || !pendingReadingPosition) return; + const next = pendingReadingPosition; + pendingReadingPosition = null; + await saveReadingPositionNow(next); + }, 650); +} + +async function flushReadingPosition() { + if (!hasSession || !readingPositionReady || restoringReadingPosition) return; + clearTimeout(readingPositionSaveTimer); + if (readingPositionSavePromise) { + await readingPositionSavePromise.catch(() => {}); + } + const position = pendingReadingPosition || currentReadingPosition(); + pendingReadingPosition = null; + await saveReadingPositionNow(position); +} + +function sendReadingPositionBeacon() { + if (!hasSession || !readingPositionReady || restoringReadingPosition || !navigator.sendBeacon) return; + const blob = new Blob([JSON.stringify(currentReadingPosition())], { type: "application/json" }); + navigator.sendBeacon("/api/reading-position", blob); +} + function openSidePanel(mode) { if (!hasSession) return; sidePanelMode = mode; @@ -268,6 +350,14 @@ function resetReviewState() { glossaryLoaded = false; glossaryEntries = []; glossaryDirty = false; + savedReadingPosition = null; + restoringReadingPosition = false; + pendingReadingPosition = null; + readingPositionInFlight = false; + readingPositionSavePromise = null; + readingPositionReady = false; + clearTimeout(readingPositionSaveTimer); + readingPositionSaveTimer = null; dom.showTouchedBtn.classList.remove("primary"); dom.searchScopeCurrentBtn.classList.add("active"); dom.searchScopeBookBtn.classList.remove("active"); @@ -596,6 +686,7 @@ async function loadSession() { await showStartScreen(session); return session; } + savedReadingPosition = session.reading_position || null; setShellMode(true); const chapterCount = structure.chapter_count || (structure.chapters || []).length; const partCount = structure.part_count || allParts().length; @@ -645,13 +736,80 @@ async function loadRows(keepSelection = false) { renderAll(); } +function applySavedReadingPosition(position) { + if (!position || !(structure.chapters || []).length) return false; + restoringReadingPosition = true; + try { + const row = position.row_id ? rowById(position.row_id) : null; + const chapter = chapterById(position.chapter_id); + const part = partById(position.part_id); + const imageItem = imageItemById(chapter, position.image_item_id); + if (position.scope === "image" && chapter && imageItem) { + currentChapter = chapter; + currentPart = null; + currentImageItem = imageItem; + currentRow = null; + currentIndex = -1; + expandedChapters.add(chapter.id); + } else if (part) { + currentPart = part; + currentChapter = chapterForPart(part) || chapter; + currentImageItem = null; + if (currentChapter) expandedChapters.add(currentChapter.id); + currentRow = row && (part.row_ids || []).includes(row.id) ? row : partRows(part)[0] || null; + currentIndex = currentRow ? filteredRows.findIndex((item) => item.id === currentRow.id) : -1; + } else if (chapter) { + currentChapter = chapter; + currentPart = null; + currentImageItem = null; + expandedChapters.add(chapter.id); + const scopedRows = chapterRows(chapter); + currentRow = row && scopedRows.some((item) => item.id === row.id) ? row : scopedRows[0] || null; + currentIndex = currentRow ? filteredRows.findIndex((item) => item.id === currentRow.id) : -1; + } else if (row) { + currentRow = row; + syncScopeToRow(row); + currentIndex = filteredRows.findIndex((item) => item.id === row.id); + } else { + return false; + } + currentMode = position.mode === "polish" && currentRow ? "polish" : "reading"; + applyFilter(false); + currentIndex = currentRow ? filteredRows.findIndex((item) => item.id === currentRow.id) : -1; + renderAll(); + return true; + } finally { + restoringReadingPosition = false; + } +} + +function restoreReadingPositionScroll(position) { + if (!position || currentMode !== "reading") return; + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const rowId = position.row_id || ""; + const target = rowId ? dom.readingFlow.querySelector(`[data-row-id="${CSS.escape(rowId)}"]`) : null; + if (target) { + const paneRect = dom.readerPane.getBoundingClientRect(); + const nextOffset = target.getBoundingClientRect().top - paneRect.top; + dom.readerPane.scrollTop += nextOffset - (position.offset || 0); + } else { + dom.readerPane.scrollTop = Math.max(0, position.scroll_top || 0); + } + }); + }); +} + async function loadActiveSession(options = {}) { resetReviewState(); currentMode = "reading"; setShellMode(true); await loadStructure(); await loadRows(Boolean(options.keepSelection)); - await loadSession(); + const session = await loadSession(); + const restored = applySavedReadingPosition(session.reading_position); + if (restored) restoreReadingPositionScroll(session.reading_position); + readingPositionReady = true; await refreshSessions().catch(() => {}); if (options.message) toast(options.message, 4200); } @@ -1079,6 +1237,7 @@ function renderReading() { return; } dom.readingFlow.appendChild(frag); + if (!restoringReadingPosition) saveReadingPositionSoon(); } function renderEditor() { @@ -1210,6 +1369,7 @@ async function saveCurrent(silent = false) { } async function maybeSaveBeforeSwitch() { + await flushReadingPosition(); if (currentMode === "reading" && quickDirty && currentRow && !dom.quickEditor.hidden) { return saveCurrent(true); } @@ -1244,6 +1404,7 @@ function selectRow(row, options = {}) { } if (options.scroll) scrollRowIntoView(row.id); + saveReadingPositionSoon(); } async function selectChapter(chapter) { @@ -1270,6 +1431,7 @@ async function selectChapter(chapter) { if (currentMode === "reading") { dom.readerPane.scrollTo({ top: 0, behavior: "smooth" }); } + saveReadingPositionSoon(); } async function selectPart(part) { @@ -1289,6 +1451,7 @@ async function selectPart(part) { if (currentMode === "reading") { dom.readerPane.scrollTo({ top: 0, behavior: "smooth" }); } + saveReadingPositionSoon(); } async function selectImageItem(chapter, item) { @@ -1306,6 +1469,7 @@ async function selectImageItem(chapter, item) { currentMode = "reading"; renderAll(); dom.readerPane.scrollTo({ top: 0, behavior: "smooth" }); + saveReadingPositionSoon(); } function scrollRowIntoView(rowId) { @@ -1337,6 +1501,7 @@ async function setMode(mode, saveBefore = true) { } withReaderAnchor(() => renderAll()); if (mode === "reading" && currentRow) scrollRowIntoView(currentRow.id); + saveReadingPositionSoon(); } function openQuickEditor(row) { @@ -1353,6 +1518,7 @@ function openQuickEditor(row) { renderAll(); }); loadGptConfig().catch(() => {}); + saveReadingPositionSoon(); } function closeQuickEditor(save = false) { @@ -1366,6 +1532,7 @@ function closeQuickEditor(save = false) { quickDirty = false; renderAll(); }); + saveReadingPositionSoon(); } function markDirty() { @@ -1849,10 +2016,19 @@ function initEvents() { }); window.addEventListener("beforeunload", (event) => { + sendReadingPositionBeacon(); if (!dirty && !quickDirty) return; event.preventDefault(); event.returnValue = ""; }); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") { + sendReadingPositionBeacon(); + } else { + saveReadingPositionSoon(); + } + }); + dom.readerPane.addEventListener("scroll", saveReadingPositionSoon, { passive: true }); } async function main() { diff --git a/tools/epub_review_editor/static/index.html b/tools/epub_review_editor/static/index.html index 5c39212..e25649b 100644 --- a/tools/epub_review_editor/static/index.html +++ b/tools/epub_review_editor/static/index.html @@ -9,7 +9,7 @@
-

双语 EPUB 审校编辑器 v0.7.0

+

双语 EPUB 审校编辑器 v0.8.0

正在载入……

diff --git a/tools/epub_review_editor/version.py b/tools/epub_review_editor/version.py index 409c73c..c12ebcf 100644 --- a/tools/epub_review_editor/version.py +++ b/tools/epub_review_editor/version.py @@ -1,4 +1,4 @@ -version = "0.7.0" +version = "0.8.0" VERSION_RULES = { "PATCH": "修复 bug 或兼容性小修",