feat(editor): restore reading position

This commit is contained in:
xin61
2026-07-07 18:13:08 +08:00
parent 8080a4ced9
commit 0ccd14cf67
6 changed files with 250 additions and 5 deletions
+3
View File
@@ -32,6 +32,7 @@ epub_review_sessions/
- `rows.json` 是初始解析结果,不应因用户编辑而重写。 - `rows.json` 是初始解析结果,不应因用户编辑而重写。
- `state.json.edits` 保存用户修改与标记,字段缺省时必须能回退到原始译文。 - `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` 只保存会话定位信息。 - `session.json``app_state.json` 只保存会话定位信息。
- `gpt_config.json` 只属于本地运行环境,不写入 EPUB、不写入导出文件、不通过 API 回传密钥。 - `gpt_config.json` 只属于本地运行环境,不写入 EPUB、不写入导出文件、不通过 API 回传密钥。
- `gpt_config.json` 可保存本地提示词设置与术语表路径,但仍不得写入 EPUB 或导出文件。 - `gpt_config.json` 可保存本地提示词设置与术语表路径,但仍不得写入 EPUB 或导出文件。
@@ -67,6 +68,7 @@ epub_review_sessions/
- 阅读模式不显示每段编号、快速编辑、快速标记按钮。 - 阅读模式不显示每段编号、快速编辑、快速标记按钮。
- 点击正文段落打开右侧精修与重翻面板。 - 点击正文段落打开右侧精修与重翻面板。
- 右侧面板打开或关闭导致正文宽度变化时,应恢复用户原来正在看的段落位置。 - 右侧面板打开或关闭导致正文宽度变化时,应恢复用户原来正在看的段落位置。
- 刷新页面、重启服务或从已有会话重新打开 EPUB 时,应恢复到上次阅读的章节/part/插图、段落和滚动位置。
- 移动窄屏下,侧栏和右侧面板应以抽屉/底部面板方式显示,不遮死主要操作。 - 移动窄屏下,侧栏和右侧面板应以抽屉/底部面板方式显示,不遮死主要操作。
## 编辑、反馈与导出 ## 编辑、反馈与导出
@@ -156,3 +158,4 @@ Prompt 硬规则:
- 配置保存后 `/api/gpt/config` 不包含密钥字段 - 配置保存后 `/api/gpt/config` 不包含密钥字段
- `/api/glossary` 能读取术语表;新增、修改、删除条目后保存,再读取内容一致 - `/api/glossary` 能读取术语表;新增、修改、删除条目后保存,再读取内容一致
- 重翻候选事件包含 `glossary_path``glossary_matches` - 重翻候选事件包含 `glossary_path``glossary_matches`
- `/api/reading-position` 能保存/读取位置;刷新或重启后回到上次阅读处
+4 -1
View File
@@ -1,6 +1,6 @@
# 双语 EPUB 审校编辑器 # 双语 EPUB 审校编辑器
当前版本:`0.7.0` 当前版本:`0.8.0`
这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。 这个工具用于把生成后的中日双语 EPUB 打开成浏览器审校界面。用户可以直接修改中文译文,也可以标记“哪里翻得不好”,并把所有记录沉淀为可交给 Codex 的反馈文件。
@@ -30,6 +30,8 @@
`0.7.0` 新增实时术语表能力:GPT 重翻会从配置的 `mingcibiao.json` 实时读取命中术语并写入提示词;网页中可查看当前术语表路径、搜索术语,新增、修改、删除条目并保存,保存后的术语会立即用于下一次重翻。 `0.7.0` 新增实时术语表能力:GPT 重翻会从配置的 `mingcibiao.json` 实时读取命中术语并写入提示词;网页中可查看当前术语表路径、搜索术语,新增、修改、删除条目并保存,保存后的术语会立即用于下一次重翻。
`0.8.0` 新增阅读位置恢复:阅读器会自动记录当前章节/part/插图、当前可见段落和滚动位置;重新打开服务、刷新页面或切回已有会话时,会回到上次阅读的位置。
## 独立安装 ## 独立安装
这个审校器现在可以脱离 Codex 使用,只需要 Python 和浏览器。把 `tools/epub_review_editor` 目录复制到其他电脑或服务器后,在该目录里安装依赖: 这个审校器现在可以脱离 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 内部目录页或 nav/toc 组织章节;章节下显示内部 `part0000` 形式的 part 与次级插图条目,可展开/收起,点击 part 后只看该 part,点击章节则按原 spine 顺序连续阅读正文和插图。
- 插图页会作为所属章节的次级条目出现在目录里,点击后可在阅读模式中直接查看原 EPUB 图片;点击目录项不会自动隐藏左侧侧栏。 - 插图页会作为所属章节的次级条目出现在目录里,点击后可在阅读模式中直接查看原 EPUB 图片;点击目录项不会自动隐藏左侧侧栏。
+64 -1
View File
@@ -36,7 +36,7 @@ except ImportError:
"MINOR": "新增向后兼容能力、API 字段或可选配置", "MINOR": "新增向后兼容能力、API 字段或可选配置",
"MAJOR": "破坏兼容的 API、配置或行为变更", "MAJOR": "破坏兼容的 API、配置或行为变更",
} }
version = "0.7.0" version = "0.8.0"
P_RE = re.compile(r"(<p\b[^>]*>)(.*?)(</p>)", re.S | re.I) P_RE = re.compile(r"(<p\b[^>]*>)(.*?)(</p>)", re.S | re.I)
@@ -760,6 +760,45 @@ def session_epub_path(session_root: Path) -> Path:
return Path(source_epub or fallback) 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]: 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", {})
@@ -787,6 +826,7 @@ def summarize_session(session_root: Path) -> dict[str, Any]:
"feedback_md": str(session_root / "feedback" / "feedback_for_codex.md"), "feedback_md": str(session_root / "feedback" / "feedback_for_codex.md"),
"feedback_jsonl": str(session_root / "feedback" / "translation_feedback.jsonl"), "feedback_jsonl": str(session_root / "feedback" / "translation_feedback.jsonl"),
"latest_export": latest_export, "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")), "marked_count": sum(1 for row in rows if row.get("marked")),
"feedback_md": str(session_root / "feedback" / "feedback_for_codex.md"), "feedback_md": str(session_root / "feedback" / "feedback_for_codex.md"),
"feedback_jsonl": str(session_root / "feedback" / "translation_feedback.jsonl"), "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 no_active_response()
return jsonify(build_structure(session_root)) 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/<path:entry_name>") @app.route("/api/asset/<path:entry_name>")
def api_asset(entry_name: str): def api_asset(entry_name: str):
session_root, _epub_path = active_session() session_root, _epub_path = active_session()
+177 -1
View File
@@ -19,6 +19,13 @@ let gptCandidateHtml = "";
let glossaryLoaded = false; let glossaryLoaded = false;
let glossaryEntries = []; let glossaryEntries = [];
let glossaryDirty = false; 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); const $ = (id) => document.getElementById(id);
@@ -223,6 +230,81 @@ function withReaderAnchor(action) {
return result; 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) { function openSidePanel(mode) {
if (!hasSession) return; if (!hasSession) return;
sidePanelMode = mode; sidePanelMode = mode;
@@ -268,6 +350,14 @@ function resetReviewState() {
glossaryLoaded = false; glossaryLoaded = false;
glossaryEntries = []; glossaryEntries = [];
glossaryDirty = false; glossaryDirty = false;
savedReadingPosition = null;
restoringReadingPosition = false;
pendingReadingPosition = null;
readingPositionInFlight = false;
readingPositionSavePromise = null;
readingPositionReady = false;
clearTimeout(readingPositionSaveTimer);
readingPositionSaveTimer = null;
dom.showTouchedBtn.classList.remove("primary"); dom.showTouchedBtn.classList.remove("primary");
dom.searchScopeCurrentBtn.classList.add("active"); dom.searchScopeCurrentBtn.classList.add("active");
dom.searchScopeBookBtn.classList.remove("active"); dom.searchScopeBookBtn.classList.remove("active");
@@ -596,6 +686,7 @@ async function loadSession() {
await showStartScreen(session); await showStartScreen(session);
return session; return session;
} }
savedReadingPosition = session.reading_position || null;
setShellMode(true); setShellMode(true);
const chapterCount = structure.chapter_count || (structure.chapters || []).length; const chapterCount = structure.chapter_count || (structure.chapters || []).length;
const partCount = structure.part_count || allParts().length; const partCount = structure.part_count || allParts().length;
@@ -645,13 +736,80 @@ async function loadRows(keepSelection = false) {
renderAll(); 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 = {}) { async function loadActiveSession(options = {}) {
resetReviewState(); resetReviewState();
currentMode = "reading"; currentMode = "reading";
setShellMode(true); setShellMode(true);
await loadStructure(); await loadStructure();
await loadRows(Boolean(options.keepSelection)); 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(() => {}); await refreshSessions().catch(() => {});
if (options.message) toast(options.message, 4200); if (options.message) toast(options.message, 4200);
} }
@@ -1079,6 +1237,7 @@ function renderReading() {
return; return;
} }
dom.readingFlow.appendChild(frag); dom.readingFlow.appendChild(frag);
if (!restoringReadingPosition) saveReadingPositionSoon();
} }
function renderEditor() { function renderEditor() {
@@ -1210,6 +1369,7 @@ async function saveCurrent(silent = false) {
} }
async function maybeSaveBeforeSwitch() { async function maybeSaveBeforeSwitch() {
await flushReadingPosition();
if (currentMode === "reading" && quickDirty && currentRow && !dom.quickEditor.hidden) { if (currentMode === "reading" && quickDirty && currentRow && !dom.quickEditor.hidden) {
return saveCurrent(true); return saveCurrent(true);
} }
@@ -1244,6 +1404,7 @@ function selectRow(row, options = {}) {
} }
if (options.scroll) scrollRowIntoView(row.id); if (options.scroll) scrollRowIntoView(row.id);
saveReadingPositionSoon();
} }
async function selectChapter(chapter) { async function selectChapter(chapter) {
@@ -1270,6 +1431,7 @@ async function selectChapter(chapter) {
if (currentMode === "reading") { if (currentMode === "reading") {
dom.readerPane.scrollTo({ top: 0, behavior: "smooth" }); dom.readerPane.scrollTo({ top: 0, behavior: "smooth" });
} }
saveReadingPositionSoon();
} }
async function selectPart(part) { async function selectPart(part) {
@@ -1289,6 +1451,7 @@ async function selectPart(part) {
if (currentMode === "reading") { if (currentMode === "reading") {
dom.readerPane.scrollTo({ top: 0, behavior: "smooth" }); dom.readerPane.scrollTo({ top: 0, behavior: "smooth" });
} }
saveReadingPositionSoon();
} }
async function selectImageItem(chapter, item) { async function selectImageItem(chapter, item) {
@@ -1306,6 +1469,7 @@ async function selectImageItem(chapter, item) {
currentMode = "reading"; currentMode = "reading";
renderAll(); renderAll();
dom.readerPane.scrollTo({ top: 0, behavior: "smooth" }); dom.readerPane.scrollTo({ top: 0, behavior: "smooth" });
saveReadingPositionSoon();
} }
function scrollRowIntoView(rowId) { function scrollRowIntoView(rowId) {
@@ -1337,6 +1501,7 @@ async function setMode(mode, saveBefore = true) {
} }
withReaderAnchor(() => renderAll()); withReaderAnchor(() => renderAll());
if (mode === "reading" && currentRow) scrollRowIntoView(currentRow.id); if (mode === "reading" && currentRow) scrollRowIntoView(currentRow.id);
saveReadingPositionSoon();
} }
function openQuickEditor(row) { function openQuickEditor(row) {
@@ -1353,6 +1518,7 @@ function openQuickEditor(row) {
renderAll(); renderAll();
}); });
loadGptConfig().catch(() => {}); loadGptConfig().catch(() => {});
saveReadingPositionSoon();
} }
function closeQuickEditor(save = false) { function closeQuickEditor(save = false) {
@@ -1366,6 +1532,7 @@ function closeQuickEditor(save = false) {
quickDirty = false; quickDirty = false;
renderAll(); renderAll();
}); });
saveReadingPositionSoon();
} }
function markDirty() { function markDirty() {
@@ -1849,10 +2016,19 @@ function initEvents() {
}); });
window.addEventListener("beforeunload", (event) => { window.addEventListener("beforeunload", (event) => {
sendReadingPositionBeacon();
if (!dirty && !quickDirty) return; if (!dirty && !quickDirty) return;
event.preventDefault(); event.preventDefault();
event.returnValue = ""; event.returnValue = "";
}); });
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
sendReadingPositionBeacon();
} else {
saveReadingPositionSoon();
}
});
dom.readerPane.addEventListener("scroll", saveReadingPositionSoon, { passive: true });
} }
async function main() { async function main() {
+1 -1
View File
@@ -9,7 +9,7 @@
<body> <body>
<header class="topbar"> <header class="topbar">
<div class="brandBlock"> <div class="brandBlock">
<h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.7.0</span></h1> <h1>双语 EPUB 审校编辑器 <span id="appVersion" class="versionBadge">v0.8.0</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="审校模式">
+1 -1
View File
@@ -1,4 +1,4 @@
version = "0.7.0" version = "0.8.0"
VERSION_RULES = { VERSION_RULES = {
"PATCH": "修复 bug 或兼容性小修", "PATCH": "修复 bug 或兼容性小修",