feat(editor): restore reading position
This commit is contained in:
@@ -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` 能保存/读取位置;刷新或重启后回到上次阅读处
|
||||
|
||||
@@ -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 图片;点击目录项不会自动隐藏左侧侧栏。
|
||||
|
||||
@@ -36,7 +36,7 @@ except ImportError:
|
||||
"MINOR": "新增向后兼容能力、API 字段或可选配置",
|
||||
"MAJOR": "破坏兼容的 API、配置或行为变更",
|
||||
}
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
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/<path:entry_name>")
|
||||
def api_asset(entry_name: str):
|
||||
session_root, _epub_path = active_session()
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<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>
|
||||
</div>
|
||||
<div class="modeTabs" role="tablist" aria-label="审校模式">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
|
||||
VERSION_RULES = {
|
||||
"PATCH": "修复 bug 或兼容性小修",
|
||||
|
||||
Reference in New Issue
Block a user