Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ codep -a gal # 强制用 GAL

然后在 `src/config.js` 的 `DICT_REGISTRY` 数组里注册。

改过词库文件之后不用重启:今日复习和错题本会自动显示最新的释义(机制和已知边界见 [docs/review-cache.md](docs/review-cache.md))。

## 工作原理

```
Expand Down
90 changes: 90 additions & 0 deletions docs/review-cache.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# 复习释义为什么会「卡」在旧版本

这份笔记记录 `src/review.js` 里词库释义回查机制的来龙去脉 —— 主要是为了说明**为什么**要这么写,避免以后有人把它「优化」回去。

## 问题

编辑 `dicts/` 下的词库文件、改掉某个词的释义之后,「今日复习」和「错题本」里显示的还是旧释义。而且它不会自己恢复:重启 codep 也没用,只有那个词恰好被重新练到一次,显示才会更新。

## 根因

`recordResult()` 每次练习都会把当时看到的 `meaning` / `phonetic` 一起写进 `~/.codep/review.json`:

```json
"own:prowl": {
"word": "prowl",
"meaning": "v. to move about stealthily… (徘徊,游荡…)",
"interval": 3, "ease": 2.5, ...
}
```

`getDueWords()` / `getMistakeWords()` 之后直接把这份 `meaning` 拿去展示,从不回查词库文件。

这是一个**没有失效策略的缓存**。缓存本身是合理的优化,问题不在于「存了一份副本」,而在于没有任何机制让这份副本知道自己过期了 —— 所以陈旧不是「允许旧数据存在一小段时间」,而是**永久**。

## 为什么这个 bug 一直没被发现

内置词库(CET-4、GRE、程序员常见词……)基本不变。词库不变,缓存和源就永远一致,bug 从不触发。

只有**自己维护词库的用户**会踩到 —— 边学边改释义、调整格式的那种用法。词库会变,恰恰是自定义词库这个功能的设计前提。

## 缓存 vs 快照:两个东西,两条规则

修复之后,`review.json` 里的 `meaning` 不再是缓存,而是**兜底快照**。这两者的规则是相反的,代码里刻意不共用同一个运算符:

| | 规则 | 实现 |
|---|---|---|
| **读**(展示) | 说真话。词库里还能索引到这个词,就一律以词库为准 —— 哪怕释义被清空,也显示空 | `mergeCardWithDict`,条目级覆盖 |
| **写**(快照) | 维护最后兜底。只在词库给出**非空**值时才升级 | `recordResult` 里的 `\|\|` |

**读为什么要「词库为准,空也照显」**:词库是用户自己的文件,是唯一真相。拿旧翻译顶替,等于对用户自己的数据撒谎,而且完全无法调试 —— 用户会以为自己没保存。

**写为什么不能跟着改成 `??`**:卡片按 `dictId:word` 存,词从词库删掉之后卡片**不会被清理**,会一直来考你。这时快照是唯一还能告诉你「这个词什么意思」的东西。如果写入也用 `??`,那么词库里某个条目释义一旦为空,一次练习就会把历史快照冲成空字符串、永久丢失 —— 于是你会被一个空白的词永远考下去,比显示旧释义更糟。

`"" ?? "旧"` 是 `""`,`"" || "旧"` 是 `"旧"`。差别就在这里。

**为什么是条目级覆盖而不是字段级**:字段级(`dictEntry?.meaning ?? card.meaning`)会在字段粒度上重新制造同一个问题 —— 词库条目没有 `phonetic` 键时,旧音标就又变成一个不会失效的值了。条目级的规则是「绝不把同一个词的两个版本混着显示」。

## 失效策略:为什么是 dev + ino + size + mtimeNs

```js
`${st.dev}:${st.ino}:${st.size}:${st.mtimeNs}`
```

- **`ino` + `dev`** —— 编辑器保存常用「写临时文件再 rename」,inode 会变而 mtime 未必动多少。
- **`mtimeNs`**(纳秒)—— 应对同一秒内的两次原地写入。
- **不做内容 hash** —— 大词库两万条,每次调用 hash 一遍完全抵消了缓存的意义。

已知盲区:同字节长度 + 同 mtime + 同 inode 的改写检测不到。在 mtime 有纳秒精度的文件系统上(APFS、ext4)这需要手工把 mtime 改回去,实践中不会发生;但在 mtime 只有秒级精度的文件系统上(部分网络挂载、某些容器 bind mount),同一秒内的等长原地改写确实可能漏掉。测试 `词库文件时间戳未变时复用缓存` 如实钉住了这个行为。

## symlink 陷阱:必须用 statSync,不能用 lstatSync

词库文件可以是软链 —— 自己维护生词本的人常常把 `dicts/` 下的文件软链到笔记库里的真实文件。实测一个这样的软链词库:

| | size | mtime | inode |
|---|---|---|---|
| `lstatSync`(软链自身) | 57 | Aug 2 22:09 | 123605618 |
| `statSync`(跟随到目标) | 38212 | Aug 8 21:27 | 124082378 |

软链自身的 mtime 是**创建那天**,之后永远不变。用 `lstatSync` 会做出一个看起来在失效、实际永不失效的缓存 —— 悄悄地把这个 bug 原样复现一遍。

另外 `throwIfNoEntry: false` **只压制 ENOENT**:文件不存在和软链断裂会安全地返回 `undefined`,但软链循环仍会抛 `ELOOP`、路径穿过普通文件会抛 `ENOTDIR`。所以 stat 必须自己包一层 try/catch,否则进「今日复习」会直接崩。

stat 失败时返回 `null` 且**不写缓存**:拿不到有效指纹,用任何合成哨兵缓存都会粘住到进程重启为止 —— 又是同一类 bug。解析失败则按指纹缓存,因为文件修好后指纹会变,能自愈。

## 已知未覆盖

以下都是同一 bug 类的其他缓存,但都只存在于内存里、重启即恢复,远没有 `review.json` 那份跨重启永久陈旧严重:

1. **章节练习** —— `src/ui/input.js` 在选词库/启动时 `words = loadDict(dict)` 加载一次。停在同一个词库里不退回菜单时,编辑不生效;退回菜单重进或重启即刷新。
2. **「再来一轮」** —— `lastReviewWords` 冻结整个进程生命周期。
3. **改词/改大小写会孤立 SM-2 历史** —— 卡片按 `dictId:word` 存且从不清理。既有问题,但本次修复会让孤立卡片变成「唯一不更新的那一个」,更隐蔽。
4. **`loadDict` 的格式分叉** —— `storage.js` 里 `if (item.word && item.meaning) return item;` 这一行,对 `{word:"x", meaning:""}` 这种写法(`meaning` 为空 → falsy → 走规范化分支 → `word: item.name` 而 `name` 不存在)会让 **`word` 变成 `undefined`**,该条目索引不到,于是退回快照。所以「释义清空必定覆盖」这句话对这种写法不成立。README 记载的 `{name, trans}` 格式不受影响。

## 新增的失败模式

改动前 `review.js` 从不为词库碰文件系统。改动后进入「今日复习」或「错题本」时,每个涉及的词库会做一次 `existsSync` + 一次 `statSync`;指纹变了还会同步 `readFileSync` + `JSON.parse` 整个词库文件(大词库两万条就是几 MB 的同步读)。词库若是软链,这些操作还会跨到软链目标所在的位置。网络挂载卡住时,真正会冻结 TUI 的是那次**读**,不是 stat。

每次取词只做一次、且 `getDueCount` / `getMistakeCount` 已经拆出来只数个数,既不排序也不碰词库(菜单每按一次方向键都会调用它们),所以这个代价可以接受 —— 但它确实是一个新增的风险点。

另外,解析过的词库会以 `Map` 的形式在内存里保留到进程结束(实测约 0.7 MB / 3700 词条)。缓存条目数上限就是 `DICT_REGISTRY` 的长度,不会无限增长。
218 changes: 169 additions & 49 deletions src/review.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
*/

const fs = require("fs");
const { REVIEW_FILE } = require("./config");
const { REVIEW_FILE, DICT_REGISTRY } = require("./config");
const { loadDict, resolveDictPath } = require("./storage");

// SM-2 参数
const INITIAL_EASE = 2.5;
Expand All @@ -34,6 +35,106 @@ function normalizeResult(result) {
return mapping[result] || "wrong";
}

// ─── 词库释义实时回查 ──────────────────────────────────────
// review.json 里存的 meaning/phonetic 只是「最后一次练习时看到的释义」快照,
// 不是真相。用户随时可能编辑词库文件(尤其是自己维护的生词本),所以展示前
// 一律回查词库。快照只在词库里索引不到这个词时才兜底。

// codep 常常在 tmux 里一挂就是好几天,所以缓存必须能在进程运行中失效,
// 否则只是把「永不更新」的问题从 review.json 挪进内存而已。

/** dictId -> { path, stamp, words: Map<word, entry> | null } */
const dictCache = new Map();

/**
* 词库文件指纹。
*
* 用 statSync 而不是 lstatSync —— 词库可能是软链(作者的生词本就软链到
* 笔记库),lstat 拿到的是软链自身的 mtime,创建之后永远不变,会做出一个
* 看起来在失效、实际永不失效的缓存。
*
* 指纹里带 dev+ino:编辑器常用「写临时文件再 rename」保存,inode 会变而
* mtime 未必动;带 mtimeNs:应对同一秒内的两次原地写入。
*
* @returns {string} 有效指纹,或文件不存在时的 "missing"(真实指纹形如 n:n:n:n,不会撞)
* @returns {null} stat 本身失败(ELOOP / EACCES / ENOTDIR 等)——拿不到有效指纹
*/
function dictFileStamp(filePath) {
try {
const st = fs.statSync(filePath, { bigint: true, throwIfNoEntry: false });
return st ? `${st.dev}:${st.ino}:${st.size}:${st.mtimeNs}` : "missing";
} catch {
// throwIfNoEntry 只压制 ENOENT,软链循环等仍会抛。
return null;
}
}

/** 取词库的 word -> entry 映射;未注册、加载失败或 stat 失败时返回 null。 */
function getDictWordMap(dictId) {
const dictInfo = DICT_REGISTRY.find((d) => d.id === dictId);
if (!dictInfo) return null; // 未注册的词库:不碰磁盘

const filePath = resolveDictPath(dictInfo);
const stamp = dictFileStamp(filePath);
// stat 失败拿不到有效指纹,用任何合成哨兵缓存都会粘住到进程重启为止,
// 正是本次要消灭的那类 bug。所以不写缓存,下次重试。
if (stamp === null) return null;

// path 也要比:覆盖词库从仓库根目录挪进 dicts/ 的情况。
const cached = dictCache.get(dictId);
if (cached && cached.path === filePath && cached.stamp === stamp) return cached.words;

let words = null;
try {
words = new Map(loadDict(dictInfo).map((w) => [w.word, w]));
} catch {
words = null; // 文件缺失/损坏:退回快照
}
// 解析失败也按指纹缓存:文件修好后指纹会变,能自愈。
dictCache.set(dictId, { path: filePath, stamp, words });
return words;
}

/**
* 清空词库缓存。
* 目前只有测试用;将来若要加「重载词库」快捷键,也是走这里。
*/
function resetDictCache() {
dictCache.clear();
}

/**
* 合并复习卡片与词库条目,决定展示用的释义。
*
* 规则:词库里**还能索引到**这个词 → 展示一律以词库条目为准(哪怕释义为空,
* 词库是唯一真相,拿旧翻译顶替等于对用户自己的数据撒谎);
* 索引不到(词被删除/改名,或条目缺 word 字段)→ 整体退回卡片里的历史快照。
*
* 采用「条目级」而非「字段级」覆盖:绝不把同一个词的两个版本混着显示,
* 否则字段粒度上又会出现一个不会失效的旧值。
*
* @param {{word, meaning, phonetic, dictId}} card
* @param {{word, meaning, phonetic}|null|undefined} dictEntry
*/
function mergeCardWithDict(card, dictEntry) {
const source = dictEntry || card;
return {
word: card.word, // 身份永远来自卡片,不取词库条目的 word
meaning: source.meaning ?? "",
phonetic: source.phonetic ?? "",
dictId: card.dictId,
};
}

/** 一次调用内每个词库只解析一次,保证同一批词看到的是同一份词库版本。 */
function withFreshMeanings(cards) {
const maps = new Map();
for (const card of cards) {
if (!maps.has(card.dictId)) maps.set(card.dictId, getDictWordMap(card.dictId));
}
return cards.map((card) => mergeCardWithDict(card, maps.get(card.dictId)?.get(card.word)));
}

// ─── 数据读写 ────────────────────────────────────────────

function loadReviewData() {
Expand Down Expand Up @@ -61,10 +162,6 @@ function addDays(dateStr, days) {
return d.toISOString().slice(0, 10);
}

function isDue(dateStr) {
return dateStr <= today();
}

// ─── SM-2 核心算法 ───────────────────────────────────────

/**
Expand Down Expand Up @@ -117,71 +214,77 @@ function calcNext(card, result) {

// ─── 对外接口 ────────────────────────────────────────────

// 排序权重:错误多的排前面,interval 短的排前面。
// totalMistakes 加 || 0 是一致性加固(与下面 initialWeakness / 错题排序的守卫一致):
// codep 自己产生的卡片一定带这个字段,防的是手工编辑过的 review.json。
function duePriority(card) {
return (card.totalMistakes || 0) * 10 + 1 / (card.interval || 1);
}

function isDueCard(card, dictId, todayStr) {
return (!dictId || card.dictId === dictId) && card.nextReview <= todayStr;
}

function isMistakeCard(card, dictId) {
return (!dictId || card.dictId === dictId) && initialWeakness(card) > 0;
}

/**
* 获取今天到期需要复习的词列表
* 纯函数:挑出到期的卡片并排序。不碰文件系统,便于测试。
* @param {object} data - review.json 的内容
* @param {string} [dictId] - 可选,限定某词库
* @returns {Array<{word, meaning, phonetic, dictId}>}
* @param {string} todayStr - "YYYY-MM-DD"
*/
function getDueWords(dictId) {
const data = loadReviewData();
const due = [];

for (const [key, card] of Object.entries(data)) {
if (dictId && card.dictId !== dictId) continue;
if (isDue(card.nextReview)) {
due.push({
word: card.word,
meaning: card.meaning || "",
phonetic: card.phonetic || "",
dictId: card.dictId,
_key: key,
// 排序用:错误多的排前面,interval 短的排前面
_priority: card.totalMistakes * 10 + (1 / (card.interval || 1)),
});
}
}
function selectDueCards(data, dictId, todayStr) {
return Object.values(data)
.filter((card) => isDueCard(card, dictId, todayStr))
.sort((a, b) => duePriority(b) - duePriority(a));
}

// 按优先级排序:错误多的 + 间隔短的优先
due.sort((a, b) => b._priority - a._priority);
/** 纯函数:挑出薄弱词并排序。不碰文件系统,便于测试。 */
function selectMistakeCards(data, dictId) {
return Object.values(data)
.filter((card) => isMistakeCard(card, dictId))
.sort(
(a, b) =>
initialWeakness(b) - initialWeakness(a) || (b.totalMistakes || 0) - (a.totalMistakes || 0)
);
}

return due.map(({ _key, _priority, ...rest }) => rest);
/**
* 获取今天到期需要复习的词列表(释义实时取自词库)
* @param {string} [dictId] - 可选,限定某词库
* @returns {Array<{word, meaning, phonetic, dictId}>}
*/
function getDueWords(dictId) {
return withFreshMeanings(selectDueCards(loadReviewData(), dictId, today()));
}

/**
* 获取今日到期词数量
* 获取今日到期词数量。只做筛选,不回查词库
* (菜单每次按方向键都会调用它,不该为了数个数去解析整个词库)。
* @param {string} [dictId] - 可选,限定某词库
* @returns {number}
*/
function getDueCount(dictId) {
const data = loadReviewData();
let count = 0;
for (const card of Object.values(data)) {
if (dictId && card.dictId !== dictId) continue;
if (isDue(card.nextReview)) count++;
}
return count;
const todayStr = today();
// 只数个数,不排序也不回查词库。
return Object.values(loadReviewData()).filter((card) => isDueCard(card, dictId, todayStr)).length;
}

function initialWeakness(card) {
if (typeof card.weaknessScore === "number") return card.weaknessScore;
return (card.totalMistakes || 0) > 0 ? Math.min(3, card.totalMistakes) : 0;
}

/** 获取错题本词列表(释义实时取自词库) */
function getMistakeWords(dictId) {
const data = loadReviewData();
return Object.values(data)
.filter((card) => (!dictId || card.dictId === dictId) && initialWeakness(card) > 0)
.sort((a, b) => initialWeakness(b) - initialWeakness(a) || (b.totalMistakes || 0) - (a.totalMistakes || 0))
.map((card) => ({
word: card.word,
meaning: card.meaning || "",
phonetic: card.phonetic || "",
dictId: card.dictId,
}));
return withFreshMeanings(selectMistakeCards(loadReviewData(), dictId));
}

/** 获取错题数量。同 getDueCount,只数个数,不排序也不回查词库。 */
function getMistakeCount(dictId) {
return getMistakeWords(dictId).length;
return Object.values(loadReviewData()).filter((card) => isMistakeCard(card, dictId)).length;
}

/**
Expand Down Expand Up @@ -229,7 +332,13 @@ function recordResult(wordObj, dictId, result) {
card.repetitions = next.repetitions;
card.nextReview = next.nextReview;

// 更新 meaning/phonetic(词库可能更新过)
// 兜底快照:万一以后这个词从词库里删了/改名了,复习界面还能显示它的含义
// (卡片按 dictId:word 存且从不清理,没有快照就会拿着空释义永远来考你)。
//
// 这里必须用 || 而不是 ??,读和写是两条不同的规则:
// 读(展示,见 mergeCardWithDict)= 说真话 → 词库为准,词库里是空的就显示空;
// 写(这里)= 维护最后兜底 → 只在词库给出非空值时才升级快照。
// 若这里改成 ??,词库某条目释义为空时会把历史快照冲成空字符串、永久丢失。
card.meaning = wordObj.meaning || card.meaning;
card.phonetic = wordObj.phonetic || card.phonetic;

Expand All @@ -238,7 +347,11 @@ function recordResult(wordObj, dictId, result) {
}

/**
* 获取单词的复习统计
* 获取单词的复习统计(返回原始卡片)
*
* ⚠️ 卡片里的 meaning/phonetic 是历史快照,可能早已过时,不要直接拿去展示。
* 要展示释义请走 getDueWords / getMistakeWords,它们会回查词库。
*
* @param {string} word
* @param {string} dictId
* @returns {object|null}
Expand Down Expand Up @@ -286,4 +399,11 @@ module.exports = {
today,
calcNext,
normalizeResult,

// —— 测试接缝 ——
// 纯函数,把「合并规则」和「筛选规则」从文件 I/O 里剥出来单独测。
mergeCardWithDict,
selectDueCards,
selectMistakeCards,
resetDictCache,
};
Loading